#![allow(deprecated)]
use crate::error::JacsError;
use crate::schema::utils::{CONFIG_SCHEMA_STRING, EmbeddedSchemaResolver};
use crate::storage::jenv::{EnvError, get_env_var, get_required_env_var};
use getset::Getters;
use jsonschema::{Draft, Validator};
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::str::FromStr;
use tracing::{error, info, warn};
use crate::validation::split_agent_id;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyResolutionSource {
Local,
Dns,
Registry,
}
impl fmt::Display for KeyResolutionSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KeyResolutionSource::Local => write!(f, "local"),
KeyResolutionSource::Dns => write!(f, "dns"),
KeyResolutionSource::Registry => write!(f, "registry"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NetworkCapability {
DnsLookup,
RemoteKeyFetch,
RegistryLookup,
RemoteSchemaFetch,
JwksFetch,
AgentCardFetch,
}
impl NetworkCapability {
pub fn env_var(self) -> &'static str {
match self {
NetworkCapability::DnsLookup => "JACS_ALLOW_DNS",
NetworkCapability::RemoteKeyFetch => "JACS_ALLOW_REMOTE_KEY_FETCH",
NetworkCapability::RegistryLookup => "JACS_ALLOW_REGISTRY",
NetworkCapability::RemoteSchemaFetch => "JACS_ALLOW_REMOTE_SCHEMA_FETCH",
NetworkCapability::JwksFetch => "JACS_ALLOW_JWKS_FETCH",
NetworkCapability::AgentCardFetch => "JACS_ALLOW_AGENT_CARD_FETCH",
}
}
pub fn description(self) -> &'static str {
match self {
NetworkCapability::DnsLookup => "DNS lookup",
NetworkCapability::RemoteKeyFetch => "remote public-key fetch",
NetworkCapability::RegistryLookup => "registry lookup",
NetworkCapability::RemoteSchemaFetch => "remote schema fetch",
NetworkCapability::JwksFetch => "JWKS fetch",
NetworkCapability::AgentCardFetch => "A2A Agent Card fetch",
}
}
}
impl fmt::Display for NetworkCapability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.description())
}
}
impl FromStr for NetworkCapability {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"dns" | "dns_lookup" => Ok(NetworkCapability::DnsLookup),
"remote_key_fetch" | "key_fetch" | "public_key_fetch" | "registry_key_fetch" => {
Ok(NetworkCapability::RemoteKeyFetch)
}
"registry" | "registry_lookup" => Ok(NetworkCapability::RegistryLookup),
"schema" | "schema_fetch" | "remote_schema_fetch" => {
Ok(NetworkCapability::RemoteSchemaFetch)
}
"jwks" | "jwks_fetch" => Ok(NetworkCapability::JwksFetch),
"agent_card" | "agent_card_fetch" | "a2a_discovery" | "agent_discovery" => {
Ok(NetworkCapability::AgentCardFetch)
}
other => Err(format!(
"Unknown network capability '{}'. Valid values are: dns, remote_key_fetch, registry, remote_schema_fetch, jwks, agent_card_fetch",
other
)),
}
}
}
fn env_var_truthy(key: &str) -> bool {
match get_env_var(key, false) {
Ok(Some(value)) => matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
_ => false,
}
}
pub fn is_network_access_allowed(capability: NetworkCapability) -> bool {
env_var_truthy("JACS_ALLOW_NETWORK") || env_var_truthy(capability.env_var())
}
pub fn ensure_network_access(capability: NetworkCapability) -> Result<(), JacsError> {
if is_network_access_allowed(capability) {
return Ok(());
}
Err(JacsError::ConfigError(format!(
"{} is disabled by default. Set {}=true to allow it, or JACS_ALLOW_NETWORK=true to allow all JACS network access.",
capability.description(),
capability.env_var(),
)))
}
impl FromStr for KeyResolutionSource {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"local" => Ok(KeyResolutionSource::Local),
"dns" => Ok(KeyResolutionSource::Dns),
"registry" => Ok(KeyResolutionSource::Registry),
other => Err(format!(
"Unknown key resolution source '{}'. Valid options are: local, dns, registry",
other
)),
}
}
}
pub fn get_key_resolution_order() -> Vec<KeyResolutionSource> {
let default_order = vec![KeyResolutionSource::Local, KeyResolutionSource::Registry];
let order_str = match get_env_var("JACS_KEY_RESOLUTION", false) {
Ok(Some(val)) if !val.is_empty() => val,
_ => return default_order,
};
let mut sources = Vec::new();
for part in order_str.split(',') {
match KeyResolutionSource::from_str(part) {
Ok(source) => sources.push(source),
Err(e) => {
warn!("JACS_KEY_RESOLUTION: {}", e);
}
}
}
if sources.is_empty() {
warn!(
"JACS_KEY_RESOLUTION resulted in empty list after parsing '{}', using default (local,registry)",
order_str
);
return default_order;
}
info!("Key resolution order: {:?}", sources);
sources
}
pub mod constants;
#[derive(Serialize, Deserialize, Debug, Clone, Getters)]
pub struct Config {
#[serde(rename = "$schema")]
#[serde(default = "default_schema")]
#[getset(get)]
schema: String,
#[getset(get = "pub")]
#[serde(default = "default_security")]
jacs_use_security: Option<String>,
#[getset(get = "pub")]
#[serde(default = "default_data_directory")]
jacs_data_directory: Option<String>,
#[getset(get = "pub")]
#[serde(default = "default_key_directory")]
jacs_key_directory: Option<String>,
#[getset(get = "pub")]
jacs_agent_private_key_filename: Option<String>,
#[getset(get = "pub")]
jacs_agent_public_key_filename: Option<String>,
#[getset(get = "pub")]
#[serde(default = "default_algorithm")]
jacs_agent_key_algorithm: Option<String>,
#[serde(default, skip_serializing)]
jacs_private_key_password: Option<String>,
#[getset(get = "pub")]
jacs_agent_id_and_version: Option<String>,
#[getset(get = "pub")]
#[serde(default = "default_storage")]
jacs_default_storage: Option<String>,
#[getset(get = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
jacs_agent_domain: Option<String>,
#[getset(get = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
jacs_dns_validate: Option<bool>,
#[getset(get = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
jacs_dns_strict: Option<bool>,
#[getset(get = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
jacs_dns_required: Option<bool>,
#[getset(get = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
jacs_keychain_backend: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observability: Option<ObservabilityConfig>,
#[getset(get = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
jacs_database_url: Option<String>,
#[getset(get = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
jacs_database_max_connections: Option<u32>,
#[getset(get = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
jacs_database_min_connections: Option<u32>,
#[getset(get = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
jacs_database_connect_timeout_secs: Option<u64>,
#[serde(skip)]
config_dir: Option<std::path::PathBuf>,
}
fn default_schema() -> String {
"https://hai.ai/schemas/jacs.config.schema.json".to_string()
}
macro_rules! env_default {
($fn_name:ident, $env_var:literal, $default:expr) => {
fn $fn_name() -> Option<String> {
match get_env_var($env_var, false) {
Ok(Some(val)) if !val.is_empty() => Some(val),
_ => Some($default.to_string()),
}
}
};
}
env_default!(default_storage, "JACS_DEFAULT_STORAGE", "fs");
env_default!(default_algorithm, "JACS_AGENT_KEY_ALGORITHM", "pq2025");
fn default_security() -> Option<String> {
if let Ok(Some(val)) = get_env_var("JACS_ENABLE_FILESYSTEM_QUARANTINE", false) {
if !val.is_empty() {
return Some(val);
}
}
if let Ok(Some(val)) = get_env_var("JACS_USE_SECURITY", false) {
if !val.is_empty() {
eprintln!(
"DEPRECATION WARNING: JACS_USE_SECURITY is deprecated. \
Use JACS_ENABLE_FILESYSTEM_QUARANTINE instead. \
This env var only controls filesystem quarantine of executable files, \
not cryptographic verification."
);
return Some(val);
}
}
Some("false".to_string())
}
fn default_directory_with_cwd(env_var: &str, dir_name: &str) -> Option<String> {
match get_env_var(env_var, false) {
Ok(Some(val)) if !val.is_empty() => Some(val),
_ => {
let fallback = format!("./{}", dir_name);
if default_storage() == Some("fs".to_string()) {
match std::env::current_dir() {
Ok(cur_dir) => Some(cur_dir.join(dir_name).to_string_lossy().to_string()),
Err(_) => Some(fallback),
}
} else {
Some(fallback)
}
}
}
}
fn default_data_directory() -> Option<String> {
default_directory_with_cwd("JACS_DATA_DIRECTORY", "jacs_data")
}
fn default_key_directory() -> Option<String> {
default_directory_with_cwd("JACS_KEY_DIRECTORY", "jacs_keys")
}
impl Default for Config {
fn default() -> Self {
Config {
schema: default_schema(),
jacs_use_security: default_security(),
jacs_data_directory: default_data_directory(),
jacs_key_directory: default_key_directory(),
jacs_agent_private_key_filename: None,
jacs_agent_public_key_filename: None,
jacs_agent_key_algorithm: default_algorithm(),
jacs_private_key_password: None,
jacs_agent_id_and_version: None,
jacs_default_storage: default_storage(),
jacs_agent_domain: None,
jacs_dns_validate: None,
jacs_dns_strict: None,
jacs_dns_required: None,
jacs_keychain_backend: None,
observability: None,
jacs_database_url: None,
jacs_database_max_connections: None,
jacs_database_min_connections: None,
jacs_database_connect_timeout_secs: None,
config_dir: None,
}
}
}
#[derive(Debug, Default)]
pub struct ConfigBuilder {
agent_id_and_version: Option<String>,
key_algorithm: Option<String>,
private_key_filename: Option<String>,
public_key_filename: Option<String>,
key_directory: Option<String>,
data_directory: Option<String>,
default_storage: Option<String>,
use_security: Option<bool>,
agent_domain: Option<String>,
dns_validate: Option<bool>,
dns_strict: Option<bool>,
dns_required: Option<bool>,
observability: Option<ObservabilityConfig>,
}
impl ConfigBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn agent_id_and_version(mut self, id_version: &str) -> Self {
self.agent_id_and_version = Some(id_version.to_string());
self
}
pub fn key_algorithm(mut self, algo: &str) -> Self {
self.key_algorithm = Some(algo.to_string());
self
}
pub fn private_key_filename(mut self, filename: &str) -> Self {
self.private_key_filename = Some(filename.to_string());
self
}
pub fn public_key_filename(mut self, filename: &str) -> Self {
self.public_key_filename = Some(filename.to_string());
self
}
pub fn key_directory(mut self, dir: &str) -> Self {
self.key_directory = Some(dir.to_string());
self
}
pub fn data_directory(mut self, dir: &str) -> Self {
self.data_directory = Some(dir.to_string());
self
}
pub fn default_storage(mut self, storage: &str) -> Self {
self.default_storage = Some(storage.to_string());
self
}
pub fn use_security(mut self, enabled: bool) -> Self {
self.use_security = Some(enabled);
self
}
pub fn agent_domain(mut self, domain: &str) -> Self {
self.agent_domain = Some(domain.to_string());
self
}
pub fn dns_validate(mut self, enabled: bool) -> Self {
self.dns_validate = Some(enabled);
self
}
pub fn dns_strict(mut self, enabled: bool) -> Self {
self.dns_strict = Some(enabled);
self
}
pub fn dns_required(mut self, required: bool) -> Self {
self.dns_required = Some(required);
self
}
pub fn observability(mut self, config: ObservabilityConfig) -> Self {
self.observability = Some(config);
self
}
pub fn build(self) -> Config {
Config {
schema: default_schema(),
jacs_use_security: Some(
self.use_security
.map(|b| b.to_string())
.unwrap_or_else(|| "false".to_string()),
),
jacs_data_directory: Some(
self.data_directory
.unwrap_or_else(|| "./jacs_data".to_string()),
),
jacs_key_directory: Some(
self.key_directory
.unwrap_or_else(|| "./jacs_keys".to_string()),
),
jacs_agent_private_key_filename: self.private_key_filename,
jacs_agent_public_key_filename: self.public_key_filename,
jacs_agent_key_algorithm: Some(
self.key_algorithm.unwrap_or_else(|| "pq2025".to_string()),
),
jacs_private_key_password: None, jacs_agent_id_and_version: self.agent_id_and_version,
jacs_default_storage: Some(self.default_storage.unwrap_or_else(|| "fs".to_string())),
jacs_agent_domain: self.agent_domain,
jacs_dns_validate: self.dns_validate,
jacs_dns_strict: self.dns_strict,
jacs_dns_required: self.dns_required,
jacs_keychain_backend: None,
observability: self.observability,
jacs_database_url: None,
jacs_database_max_connections: None,
jacs_database_min_connections: None,
jacs_database_connect_timeout_secs: None,
config_dir: None,
}
}
}
impl Config {
pub fn builder() -> ConfigBuilder {
ConfigBuilder::new()
}
#[allow(clippy::too_many_arguments)]
pub fn new(
jacs_use_security: Option<String>,
jacs_data_directory: Option<String>,
jacs_key_directory: Option<String>,
jacs_agent_private_key_filename: Option<String>,
jacs_agent_public_key_filename: Option<String>,
jacs_agent_key_algorithm: Option<String>,
jacs_private_key_password: Option<String>,
jacs_agent_id_and_version: Option<String>,
jacs_default_storage: Option<String>,
) -> Config {
if jacs_private_key_password.is_some() {
warn!(
"SECURITY WARNING: Password passed to Config::new() is deprecated and will be ignored. \
Use the JACS_PRIVATE_KEY_PASSWORD environment variable instead."
);
}
Config {
schema: default_schema(),
jacs_use_security,
jacs_data_directory,
jacs_key_directory,
jacs_agent_private_key_filename,
jacs_agent_public_key_filename,
jacs_agent_key_algorithm,
jacs_private_key_password: None, jacs_agent_id_and_version,
jacs_default_storage,
jacs_agent_domain: None,
jacs_dns_validate: None,
jacs_dns_strict: None,
jacs_dns_required: None,
jacs_keychain_backend: None,
observability: None,
jacs_database_url: None,
jacs_database_max_connections: None,
jacs_database_min_connections: None,
jacs_database_connect_timeout_secs: None,
config_dir: None,
}
}
pub fn get_key_algorithm(&self) -> Result<String, JacsError> {
if let Some(algo_str) = self.jacs_agent_key_algorithm().as_deref() {
return Ok(algo_str.to_string());
}
get_required_env_var("JACS_AGENT_KEY_ALGORITHM", true)
.map_err(|e| JacsError::ConfigError(e.to_string()))
}
pub fn config_dir(&self) -> Option<&std::path::Path> {
self.config_dir.as_deref()
}
pub fn set_config_dir(&mut self, dir: Option<std::path::PathBuf>) {
self.config_dir = dir;
}
fn replace_if_some<T>(target: &mut Option<T>, incoming: Option<T>) {
if incoming.is_some() {
*target = incoming;
}
}
fn env_opt(key: &str) -> Option<String> {
match get_env_var(key, false) {
Ok(Some(val)) if !val.is_empty() => Some(val),
_ => None,
}
}
fn env_opt_bool(key: &str) -> Option<bool> {
match Self::env_opt(key) {
Some(val) => Some(val.to_lowercase() == "true" || val == "1"),
None => None,
}
}
fn apply_string_override(target: &mut Option<String>, key: &str) {
if let Some(val) = Self::env_opt(key) {
*target = Some(val);
}
}
fn apply_bool_override(target: &mut Option<bool>, key: &str) {
if let Some(val) = Self::env_opt_bool(key) {
*target = Some(val);
}
}
fn apply_parsed_override<T>(target: &mut Option<T>, key: &str)
where
T: std::str::FromStr,
{
if let Some(val) = Self::env_opt(key)
&& let Ok(parsed) = val.parse::<T>()
{
*target = Some(parsed);
}
}
pub fn merge(&mut self, other: Config) {
let Config {
schema: _,
jacs_use_security,
jacs_data_directory,
jacs_key_directory,
jacs_agent_private_key_filename,
jacs_agent_public_key_filename,
jacs_agent_key_algorithm,
jacs_private_key_password: _,
jacs_agent_id_and_version,
jacs_default_storage,
jacs_agent_domain,
jacs_dns_validate,
jacs_dns_strict,
jacs_dns_required,
jacs_keychain_backend,
observability,
jacs_database_url,
jacs_database_max_connections,
jacs_database_min_connections,
jacs_database_connect_timeout_secs,
config_dir,
} = other;
Self::replace_if_some(&mut self.jacs_use_security, jacs_use_security);
Self::replace_if_some(&mut self.jacs_data_directory, jacs_data_directory);
Self::replace_if_some(&mut self.jacs_key_directory, jacs_key_directory);
Self::replace_if_some(
&mut self.jacs_agent_private_key_filename,
jacs_agent_private_key_filename,
);
Self::replace_if_some(
&mut self.jacs_agent_public_key_filename,
jacs_agent_public_key_filename,
);
Self::replace_if_some(&mut self.jacs_agent_key_algorithm, jacs_agent_key_algorithm);
Self::replace_if_some(
&mut self.jacs_agent_id_and_version,
jacs_agent_id_and_version,
);
Self::replace_if_some(&mut self.jacs_default_storage, jacs_default_storage);
Self::replace_if_some(&mut self.jacs_agent_domain, jacs_agent_domain);
Self::replace_if_some(&mut self.jacs_dns_validate, jacs_dns_validate);
Self::replace_if_some(&mut self.jacs_dns_strict, jacs_dns_strict);
Self::replace_if_some(&mut self.jacs_dns_required, jacs_dns_required);
Self::replace_if_some(&mut self.jacs_keychain_backend, jacs_keychain_backend);
Self::replace_if_some(&mut self.observability, observability);
Self::replace_if_some(&mut self.jacs_database_url, jacs_database_url);
Self::replace_if_some(
&mut self.jacs_database_max_connections,
jacs_database_max_connections,
);
Self::replace_if_some(
&mut self.jacs_database_min_connections,
jacs_database_min_connections,
);
Self::replace_if_some(
&mut self.jacs_database_connect_timeout_secs,
jacs_database_connect_timeout_secs,
);
Self::replace_if_some(&mut self.config_dir, config_dir);
}
pub fn apply_env_overrides(&mut self) {
Self::apply_string_override(&mut self.jacs_use_security, "JACS_USE_SECURITY");
Self::apply_string_override(&mut self.jacs_data_directory, "JACS_DATA_DIRECTORY");
Self::apply_string_override(&mut self.jacs_key_directory, "JACS_KEY_DIRECTORY");
Self::apply_string_override(
&mut self.jacs_agent_private_key_filename,
"JACS_AGENT_PRIVATE_KEY_FILENAME",
);
Self::apply_string_override(
&mut self.jacs_agent_public_key_filename,
"JACS_AGENT_PUBLIC_KEY_FILENAME",
);
Self::apply_string_override(
&mut self.jacs_agent_key_algorithm,
"JACS_AGENT_KEY_ALGORITHM",
);
Self::apply_string_override(
&mut self.jacs_agent_id_and_version,
"JACS_AGENT_ID_AND_VERSION",
);
Self::apply_string_override(&mut self.jacs_default_storage, "JACS_DEFAULT_STORAGE");
Self::apply_string_override(&mut self.jacs_agent_domain, "JACS_AGENT_DOMAIN");
Self::apply_bool_override(&mut self.jacs_dns_validate, "JACS_DNS_VALIDATE");
Self::apply_bool_override(&mut self.jacs_dns_strict, "JACS_DNS_STRICT");
Self::apply_bool_override(&mut self.jacs_dns_required, "JACS_DNS_REQUIRED");
Self::apply_string_override(&mut self.jacs_database_url, "JACS_DATABASE_URL");
Self::apply_parsed_override(
&mut self.jacs_database_max_connections,
"JACS_DATABASE_MAX_CONNECTIONS",
);
Self::apply_parsed_override(
&mut self.jacs_database_min_connections,
"JACS_DATABASE_MIN_CONNECTIONS",
);
Self::apply_parsed_override(
&mut self.jacs_database_connect_timeout_secs,
"JACS_DATABASE_CONNECT_TIMEOUT_SECS",
);
}
pub fn with_defaults() -> Self {
Config {
schema: default_schema(),
jacs_use_security: Some("false".to_string()),
jacs_data_directory: Some("./jacs_data".to_string()),
jacs_key_directory: Some("./jacs_keys".to_string()),
jacs_agent_private_key_filename: None,
jacs_agent_public_key_filename: None,
jacs_agent_key_algorithm: Some("pq2025".to_string()),
jacs_private_key_password: None,
jacs_agent_id_and_version: None,
jacs_default_storage: Some("fs".to_string()),
jacs_agent_domain: None,
jacs_dns_validate: None,
jacs_dns_strict: None,
jacs_dns_required: None,
jacs_keychain_backend: None,
observability: None,
jacs_database_url: None,
jacs_database_max_connections: None,
jacs_database_min_connections: None,
jacs_database_connect_timeout_secs: None,
config_dir: None,
}
}
pub fn from_file(path: &str) -> Result<Config, JacsError> {
let json_str = fs::read_to_string(path).map_err(|e| {
let help = match e.kind() {
std::io::ErrorKind::NotFound => {
format!(
"Config file not found at '{}'. Create a jacs.config.json file or use \
environment variables (JACS_DATA_DIRECTORY, JACS_KEY_DIRECTORY, etc.) \
to configure JACS without a file.",
path
)
}
std::io::ErrorKind::PermissionDenied => {
format!(
"Permission denied reading config file '{}'. Check file permissions.",
path
)
}
_ => {
format!("Failed to read config file '{}': {}", path, e)
}
};
JacsError::ConfigError(help)
})?;
let validated_value: Value = validate_config(&json_str)
.map_err(|e| JacsError::ConfigError(format!("Invalid config at '{}': {}", path, e)))?;
let mut config: Config = serde_json::from_value(validated_value.clone()).map_err(|e| {
JacsError::ConfigError(format!(
"Config structure error at '{}': {}. The JSON may have valid syntax but incorrect field types.",
path, e
))
})?;
if config.jacs_private_key_password.is_some() {
warn!(
"SECURITY WARNING: Password found in config file '{}'. \
This is insecure - passwords should only be set via JACS_PRIVATE_KEY_PASSWORD \
environment variable. The password in the config file will be ignored.",
path
);
}
config.config_dir = std::path::Path::new(path)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(std::path::PathBuf::from);
Ok(config)
}
}
impl fmt::Display for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
r#"
Loading JACS config variables of:
JACS_USE_SECURITY: {},
JACS_DATA_DIRECTORY: {},
JACS_KEY_DIRECTORY: {},
JACS_AGENT_PRIVATE_KEY_FILENAME: {},
JACS_AGENT_PUBLIC_KEY_FILENAME: {},
JACS_AGENT_KEY_ALGORITHM: {},
JACS_PRIVATE_KEY_PASSWORD: REDACTED,
JACS_AGENT_ID_AND_VERSION: {},
JACS_DEFAULT_STORAGE: {},
JACS_DATABASE_URL: {},
"#,
self.jacs_use_security.as_deref().unwrap_or(""),
self.jacs_data_directory.as_deref().unwrap_or(""),
self.jacs_key_directory.as_deref().unwrap_or(""),
self.jacs_agent_private_key_filename
.as_deref()
.unwrap_or(""),
self.jacs_agent_public_key_filename.as_deref().unwrap_or(""),
self.jacs_agent_key_algorithm.as_deref().unwrap_or(""),
self.jacs_agent_id_and_version.as_deref().unwrap_or(""),
self.jacs_default_storage.as_deref().unwrap_or(""),
if self.jacs_database_url.is_some() {
"REDACTED"
} else {
""
}
)
}
}
#[deprecated(
since = "0.9.8",
note = "Use Config::from_file(path) + config.apply_env_overrides() + Agent::from_config(config, password) instead"
)]
pub fn load_config_12factor(config_path: Option<&str>) -> Result<Config, JacsError> {
let mut config = Config::with_defaults();
if let Some(path) = config_path {
match Config::from_file(path) {
Ok(file_config) => {
info!("Loaded config file: {}", path);
config.merge(file_config);
}
Err(e) => {
return Err(e);
}
}
}
config.apply_env_overrides();
info!("Final config (12-Factor):{}", config);
Ok(config)
}
#[deprecated(
since = "0.9.8",
note = "Use Config::from_file(path) directly. Skip apply_env_overrides() for file-only loading."
)]
pub fn load_config_file_only(config_path: &str) -> Result<Config, JacsError> {
let mut config = Config::with_defaults();
let file_config = Config::from_file(config_path)?;
config.merge(file_config);
info!("Loaded config (file-only, no env overrides): {}", config);
Ok(config)
}
#[deprecated(
since = "0.9.8",
note = "Use Config::from_file(path) + config.apply_env_overrides() + Agent::from_config(config, password) instead"
)]
pub fn load_config_12factor_optional(config_path: Option<&str>) -> Result<Config, JacsError> {
let mut config = Config::with_defaults();
if let Some(path) = config_path {
if std::path::Path::new(path).exists() {
match Config::from_file(path) {
Ok(file_config) => {
info!("Loaded config file: {}", path);
config.merge(file_config);
}
Err(e) => {
warn!(
"Failed to parse config file '{}': {}. Using defaults.",
path, e
);
}
}
} else {
info!(
"Config file '{}' not found. Using defaults and environment variables.",
path
);
}
}
config.apply_env_overrides();
info!("Final config (12-Factor):{}", config);
Ok(config)
}
#[deprecated(
since = "0.2.0",
note = "Use load_config_12factor() for 12-Factor compliant config loading"
)]
pub fn load_config(config_path: &str) -> Result<Config, JacsError> {
Config::from_file(config_path)
}
#[deprecated(
since = "0.3.0",
note = "Use crate::validation::split_agent_id instead"
)]
pub fn split_id(input: &str) -> Option<(&str, &str)> {
split_agent_id(input)
}
const CONFIG_FIELD_HELP: &[(&str, &str)] = &[
(
"jacs_agent_key_algorithm",
"Expected one of: RSA-PSS, ring-Ed25519, pq2025",
),
("jacs_default_storage", "Expected one of: fs, aws"),
(
"jacs_use_security",
"Expected 'true' or 'false' as a string",
),
("jacs_data_directory", "Expected a valid directory path"),
("jacs_key_directory", "Expected a valid directory path"),
(
"jacs_agent_private_key_filename",
"Expected a filename (e.g., 'rsa_pss_private.pem')",
),
(
"jacs_agent_public_key_filename",
"Expected a filename (e.g., 'rsa_pss_public.pem')",
),
(
"jacs_agent_id_and_version",
"Expected format: UUID:UUID (e.g., '550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001')",
),
(
"jacs_agent_domain",
"Expected a domain name (e.g., 'example.com')",
),
("jacs_dns_validate", "Expected a boolean (true/false)"),
("jacs_dns_strict", "Expected a boolean (true/false)"),
("jacs_dns_required", "Expected a boolean (true/false)"),
];
fn get_field_help(field_name: &str) -> Option<&'static str> {
CONFIG_FIELD_HELP
.iter()
.find(|(name, _)| field_name.contains(name))
.map(|(_, help)| *help)
}
fn format_validation_error(error: &jsonschema::ValidationError, instance: &Value) -> String {
let path = error.instance_path.to_string();
let field_name = if path.is_empty() || path == "/" {
"root".to_string()
} else {
path.trim_start_matches('/').to_string()
};
let invalid_value: Option<String> = if !path.is_empty() && path != "/" {
let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
let mut current = instance;
for part in &path_parts {
if let Some(obj) = current.as_object() {
if let Some(val) = obj.get(*part) {
current = val;
} else {
break;
}
} else {
break;
}
}
if current != instance {
let s = current.to_string();
if s.len() > 50 {
Some(format!("{}...", &s[..47]))
} else {
Some(s)
}
} else {
None
}
} else {
None
};
let mut msg = format!("Config validation error at '{}': {}", field_name, error);
if let Some(val) = invalid_value {
msg.push_str(&format!(" (got: {})", val));
}
if let Some(help) = get_field_help(&field_name) {
msg.push_str(&format!(". {}", help));
}
let error_str = error.to_string();
if error_str.contains("required") {
msg.push_str(". Required fields: jacs_data_directory, jacs_key_directory, jacs_agent_private_key_filename, jacs_agent_public_key_filename, jacs_agent_key_algorithm, jacs_default_storage");
}
if error_str.contains("is not one of") {
if field_name.contains("jacs_agent_key_algorithm") {
msg.push_str(". Valid algorithms: RSA-PSS, ring-Ed25519, pq2025");
} else if field_name.contains("jacs_default_storage") {
msg.push_str(". Valid storage options: fs, aws");
}
}
msg
}
pub fn validate_config(config_json: &str) -> Result<Value, JacsError> {
let jacsconfigschema_result: Value = serde_json::from_str(CONFIG_SCHEMA_STRING)
.map_err(|e| JacsError::ConfigError(format!("Failed to parse config schema: {}", e)))?;
let jacsconfigschema = Validator::options()
.with_draft(Draft::Draft7)
.with_retriever(EmbeddedSchemaResolver::new())
.build(&jacsconfigschema_result)
.map_err(|e| JacsError::ConfigError(format!("Failed to compile config schema: {}", e)))?;
let instance: Value = serde_json::from_str(config_json).map_err(|e| {
let category = match e.classify() {
serde_json::error::Category::Io => "IO error",
serde_json::error::Category::Syntax => "syntax error",
serde_json::error::Category::Data => "data type error",
serde_json::error::Category::Eof => "unexpected end of file",
};
let err_msg = format!(
"Config JSON parse error at line {}, column {}: {} - {}. \
Ensure the config file contains valid JSON syntax (check for missing commas, quotes, or brackets).",
e.line(),
e.column(),
category,
e
);
error!("{}", err_msg);
JacsError::ConfigError(err_msg)
})?;
if let Err(e) = jacsconfigschema.validate(&instance) {
let err_msg = format_validation_error(&e, &instance);
error!("{}", err_msg);
return Err(JacsError::ConfigError(err_msg));
}
Ok(instance)
}
pub fn check_env_vars(ignore_agent_id: bool) -> Result<String, EnvError> {
let vars = [
("JACS_USE_SECURITY", true),
("JACS_DATA_DIRECTORY", true),
("JACS_KEY_DIRECTORY", true),
("JACS_AGENT_PRIVATE_KEY_FILENAME", true),
("JACS_AGENT_PUBLIC_KEY_FILENAME", true),
("JACS_AGENT_KEY_ALGORITHM", true),
("JACS_PRIVATE_KEY_PASSWORD", true),
("JACS_AGENT_ID_AND_VERSION", true),
];
let mut message = String::from("\nChecking JACS environment variables:\n");
let mut missing_vars = Vec::new();
for (var_name, required) in vars.iter() {
if var_name == &"JACS_AGENT_ID_AND_VERSION" && ignore_agent_id {
message.push_str(&format!(
" {:<35} {}\n",
var_name.to_string() + ":",
"SKIPPED (ignore_agent_id=true)"
));
continue;
}
let value = get_env_var(var_name, *required)?;
let status = match value {
Some(val) => {
if *var_name == "JACS_PRIVATE_KEY_PASSWORD" {
"REDACTED".to_string()
} else {
val
}
}
None => {
if *required {
missing_vars.push(var_name);
}
"MISSING".to_string()
}
};
message.push_str(&format!(
" {:<35} {}\n",
var_name.to_string() + ":",
status
));
}
if !missing_vars.is_empty() {
message.push_str("\nMissing required environment variables:\n");
for var in missing_vars {
message.push_str(&format!(" {}\n", var));
}
}
Ok(message)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ObservabilityConfig {
#[serde(default)]
pub logs: LogConfig,
#[serde(default)]
pub metrics: MetricsConfig,
#[serde(default)]
pub tracing: Option<TracingConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_log_destination")]
pub destination: LogDestination,
#[serde(default)]
pub headers: Option<HashMap<String, String>>,
}
impl Default for LogConfig {
fn default() -> Self {
Self {
enabled: true,
level: "info".to_string(),
destination: LogDestination::Stderr,
headers: None,
}
}
}
fn default_true() -> bool {
true
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_log_destination() -> LogDestination {
LogDestination::Stderr
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub destination: MetricsDestination,
pub export_interval_seconds: Option<u64>,
#[serde(default)]
pub headers: Option<HashMap<String, String>>,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: false,
destination: MetricsDestination::Stdout,
export_interval_seconds: None,
headers: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TracingConfig {
pub enabled: bool,
#[serde(default)]
pub sampling: SamplingConfig,
#[serde(default)]
pub resource: Option<ResourceConfig>,
#[serde(default)]
pub destination: Option<TracingDestination>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamplingConfig {
#[serde(default = "default_sampling_ratio")]
pub ratio: f64,
#[serde(default)]
pub parent_based: bool,
#[serde(default)]
pub rate_limit: Option<u32>, }
impl Default for SamplingConfig {
fn default() -> Self {
Self {
ratio: 1.0, parent_based: true,
rate_limit: None,
}
}
}
fn default_sampling_ratio() -> f64 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceConfig {
pub service_name: String,
pub service_version: Option<String>,
pub environment: Option<String>,
#[serde(default)]
pub attributes: HashMap<String, String>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LogDestination {
#[serde(rename = "stderr")]
Stderr,
#[serde(rename = "file")]
File { path: String },
#[serde(rename = "otlp")]
Otlp {
endpoint: String,
#[serde(default)]
headers: Option<HashMap<String, String>>,
},
#[serde(rename = "null")]
Null,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub enum MetricsDestination {
#[serde(rename = "otlp")]
Otlp {
endpoint: String,
#[serde(default)]
headers: Option<HashMap<String, String>>,
},
#[serde(rename = "prometheus")]
Prometheus {
endpoint: String,
#[serde(default)]
headers: Option<HashMap<String, String>>,
},
#[serde(rename = "file")]
File { path: String },
#[serde(rename = "stdout")]
#[default]
Stdout,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TracingDestination {
#[serde(rename = "otlp")]
Otlp {
endpoint: String,
#[serde(default)]
headers: Option<HashMap<String, String>>,
},
#[serde(rename = "jaeger")]
Jaeger {
endpoint: String,
#[serde(default)]
headers: Option<HashMap<String, String>>,
},
}
impl Default for TracingDestination {
fn default() -> Self {
TracingDestination::Otlp {
endpoint: "http://localhost:4318".to_string(),
headers: None,
}
}
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use super::*;
use crate::storage::jenv::{clear_env_var, set_env_var};
use serial_test::serial;
fn clear_jacs_env_vars() {
let vars = [
"JACS_USE_SECURITY",
"JACS_DATA_DIRECTORY",
"JACS_KEY_DIRECTORY",
"JACS_AGENT_PRIVATE_KEY_FILENAME",
"JACS_AGENT_PUBLIC_KEY_FILENAME",
"JACS_AGENT_KEY_ALGORITHM",
"JACS_PRIVATE_KEY_PASSWORD",
"JACS_AGENT_ID_AND_VERSION",
"JACS_DEFAULT_STORAGE",
"JACS_AGENT_DOMAIN",
"JACS_DNS_VALIDATE",
"JACS_DNS_STRICT",
"JACS_DNS_REQUIRED",
"JACS_ALLOW_NETWORK",
"JACS_ALLOW_DNS",
"JACS_ALLOW_REMOTE_KEY_FETCH",
"JACS_ALLOW_REGISTRY",
"JACS_ALLOW_REMOTE_SCHEMA_FETCH",
"JACS_ALLOW_JWKS_FETCH",
"JACS_ALLOW_AGENT_CARD_FETCH",
];
for var in vars {
let _ = clear_env_var(var);
unsafe {
std::env::remove_var(var);
}
}
}
#[test]
fn test_config_with_defaults() {
let config = Config::with_defaults();
assert_eq!(config.jacs_use_security, Some("false".to_string()));
assert_eq!(config.jacs_data_directory, Some("./jacs_data".to_string()));
assert_eq!(config.jacs_key_directory, Some("./jacs_keys".to_string()));
assert_eq!(config.jacs_agent_key_algorithm, Some("pq2025".to_string()));
assert_eq!(config.jacs_default_storage, Some("fs".to_string()));
assert!(config.jacs_private_key_password.is_none());
}
#[test]
fn test_config_merge() {
let mut base = Config::with_defaults();
let override_config = Config {
schema: default_schema(),
jacs_use_security: Some("true".to_string()),
jacs_data_directory: Some("/custom/data".to_string()),
jacs_key_directory: None, jacs_agent_private_key_filename: Some("custom.pem".to_string()),
jacs_agent_public_key_filename: None,
jacs_agent_key_algorithm: Some("pq2025".to_string()),
jacs_private_key_password: None,
jacs_agent_id_and_version: None,
jacs_default_storage: None, jacs_agent_domain: Some("example.com".to_string()),
jacs_dns_validate: Some(true),
jacs_dns_strict: None,
jacs_dns_required: None,
jacs_keychain_backend: None,
observability: None,
jacs_database_url: None,
jacs_database_max_connections: None,
jacs_database_min_connections: None,
jacs_database_connect_timeout_secs: None,
config_dir: None,
};
base.merge(override_config);
assert_eq!(base.jacs_use_security, Some("true".to_string()));
assert_eq!(base.jacs_data_directory, Some("/custom/data".to_string()));
assert_eq!(
base.jacs_agent_private_key_filename,
Some("custom.pem".to_string())
);
assert_eq!(base.jacs_agent_key_algorithm, Some("pq2025".to_string()));
assert_eq!(base.jacs_agent_domain, Some("example.com".to_string()));
assert_eq!(base.jacs_dns_validate, Some(true));
assert_eq!(base.jacs_key_directory, Some("./jacs_keys".to_string()));
assert_eq!(base.jacs_default_storage, Some("fs".to_string()));
}
#[test]
#[serial(jacs_env)]
fn test_apply_env_overrides() {
clear_jacs_env_vars();
set_env_var("JACS_DATA_DIRECTORY", "/env/data").unwrap();
set_env_var("JACS_AGENT_KEY_ALGORITHM", "Ed25519").unwrap();
set_env_var("JACS_DNS_VALIDATE", "true").unwrap();
set_env_var("JACS_DNS_STRICT", "1").unwrap();
let mut config = Config::with_defaults();
config.apply_env_overrides();
assert_eq!(config.jacs_data_directory, Some("/env/data".to_string()));
assert_eq!(config.jacs_agent_key_algorithm, Some("Ed25519".to_string()));
assert_eq!(config.jacs_dns_validate, Some(true));
assert_eq!(config.jacs_dns_strict, Some(true));
assert_eq!(config.jacs_key_directory, Some("./jacs_keys".to_string()));
assert_eq!(config.jacs_default_storage, Some("fs".to_string()));
clear_jacs_env_vars();
}
#[test]
#[serial(jacs_env)]
fn test_env_overrides_config_file() {
clear_jacs_env_vars();
let mut config = Config::with_defaults();
let file_config = Config {
schema: default_schema(),
jacs_use_security: None,
jacs_data_directory: Some("/config/data".to_string()),
jacs_key_directory: Some("/config/keys".to_string()),
jacs_agent_private_key_filename: None,
jacs_agent_public_key_filename: None,
jacs_agent_key_algorithm: Some("pq2025".to_string()),
jacs_private_key_password: None,
jacs_agent_id_and_version: None,
jacs_default_storage: None,
jacs_agent_domain: None,
jacs_dns_validate: None,
jacs_dns_strict: None,
jacs_dns_required: None,
jacs_keychain_backend: None,
observability: None,
jacs_database_url: None,
jacs_database_max_connections: None,
jacs_database_min_connections: None,
jacs_database_connect_timeout_secs: None,
config_dir: None,
};
config.merge(file_config);
assert_eq!(config.jacs_data_directory, Some("/config/data".to_string()));
assert_eq!(config.jacs_agent_key_algorithm, Some("pq2025".to_string()));
set_env_var("JACS_AGENT_KEY_ALGORITHM", "ring-Ed25519").unwrap();
set_env_var("JACS_DATA_DIRECTORY", "/env/override/data").unwrap();
config.apply_env_overrides();
assert_eq!(
config.jacs_agent_key_algorithm,
Some("ring-Ed25519".to_string())
);
assert_eq!(
config.jacs_data_directory,
Some("/env/override/data".to_string())
);
assert_eq!(config.jacs_key_directory, Some("/config/keys".to_string()));
clear_jacs_env_vars();
}
#[test]
#[serial(jacs_env)]
fn test_load_config_12factor_no_file() {
clear_jacs_env_vars();
set_env_var("JACS_USE_SECURITY", "true").unwrap();
set_env_var("JACS_DATA_DIRECTORY", "/production/data").unwrap();
let config = load_config_12factor(None).expect("Should load successfully");
assert_eq!(config.jacs_use_security, Some("true".to_string()));
assert_eq!(
config.jacs_data_directory,
Some("/production/data".to_string())
);
assert_eq!(config.jacs_key_directory, Some("./jacs_keys".to_string()));
clear_jacs_env_vars();
}
#[test]
#[serial(jacs_env)]
fn test_load_config_12factor_optional_missing_file() {
clear_jacs_env_vars();
set_env_var("JACS_AGENT_KEY_ALGORITHM", "pq2025").unwrap();
let config = load_config_12factor_optional(Some("/nonexistent/config.json"))
.expect("Should load successfully even with missing file");
assert_eq!(config.jacs_agent_key_algorithm, Some("pq2025".to_string()));
assert_eq!(config.jacs_use_security, Some("false".to_string()));
clear_jacs_env_vars();
}
#[test]
#[serial(jacs_env)]
fn test_boolean_env_var_parsing() {
clear_jacs_env_vars();
let mut config = Config::with_defaults();
set_env_var("JACS_DNS_VALIDATE", "true").unwrap();
config.apply_env_overrides();
assert_eq!(config.jacs_dns_validate, Some(true));
set_env_var("JACS_DNS_VALIDATE", "TRUE").unwrap();
config.apply_env_overrides();
assert_eq!(config.jacs_dns_validate, Some(true));
set_env_var("JACS_DNS_VALIDATE", "1").unwrap();
config.apply_env_overrides();
assert_eq!(config.jacs_dns_validate, Some(true));
set_env_var("JACS_DNS_VALIDATE", "false").unwrap();
config.apply_env_overrides();
assert_eq!(config.jacs_dns_validate, Some(false));
set_env_var("JACS_DNS_VALIDATE", "0").unwrap();
config.apply_env_overrides();
assert_eq!(config.jacs_dns_validate, Some(false));
clear_jacs_env_vars();
}
#[test]
#[serial(jacs_env)]
fn test_apply_env_overrides_ignores_empty_string_values() {
clear_jacs_env_vars();
let mut config = Config::with_defaults();
let original_data_dir = config.jacs_data_directory.clone();
set_env_var("JACS_DATA_DIRECTORY", "").unwrap();
config.apply_env_overrides();
assert_eq!(config.jacs_data_directory, original_data_dir);
clear_jacs_env_vars();
}
#[test]
#[serial(jacs_env)]
fn test_apply_env_overrides_ignores_invalid_database_numbers() {
clear_jacs_env_vars();
let mut config = Config::with_defaults();
config.jacs_database_max_connections = Some(10);
config.jacs_database_min_connections = Some(2);
config.jacs_database_connect_timeout_secs = Some(30);
set_env_var("JACS_DATABASE_MAX_CONNECTIONS", "not-a-number").unwrap();
set_env_var("JACS_DATABASE_MIN_CONNECTIONS", "bad").unwrap();
set_env_var("JACS_DATABASE_CONNECT_TIMEOUT_SECS", "oops").unwrap();
config.apply_env_overrides();
assert_eq!(config.jacs_database_max_connections, Some(10));
assert_eq!(config.jacs_database_min_connections, Some(2));
assert_eq!(config.jacs_database_connect_timeout_secs, Some(30));
clear_jacs_env_vars();
}
#[test]
#[serial(jacs_env)]
fn test_apply_env_overrides_preserves_config_dir() {
clear_jacs_env_vars();
let mut config = Config::with_defaults();
let test_dir = std::path::PathBuf::from("/some/config/dir");
config.set_config_dir(Some(test_dir.clone()));
set_env_var("JACS_DATA_DIRECTORY", "/env/data").unwrap();
config.apply_env_overrides();
assert_eq!(
config.config_dir(),
Some(test_dir.as_path()),
"config_dir must be preserved through apply_env_overrides"
);
clear_jacs_env_vars();
}
#[test]
fn test_config_builder_defaults() {
let config = Config::builder().build();
assert_eq!(config.jacs_use_security, Some("false".to_string()));
assert_eq!(config.jacs_data_directory, Some("./jacs_data".to_string()));
assert_eq!(config.jacs_key_directory, Some("./jacs_keys".to_string()));
assert_eq!(config.jacs_agent_key_algorithm, Some("pq2025".to_string()));
assert_eq!(config.jacs_default_storage, Some("fs".to_string()));
assert!(config.jacs_private_key_password.is_none());
assert!(config.jacs_agent_private_key_filename.is_none());
assert!(config.jacs_agent_public_key_filename.is_none());
assert!(config.jacs_agent_id_and_version.is_none());
assert!(config.jacs_agent_domain.is_none());
}
#[test]
fn test_config_builder_custom_values() {
let config = Config::builder()
.key_algorithm("Ed25519")
.key_directory("/custom/keys")
.data_directory("/custom/data")
.default_storage("memory")
.use_security(true)
.private_key_filename("my_private.pem")
.public_key_filename("my_public.pem")
.agent_id_and_version(
"550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001",
)
.agent_domain("example.com")
.dns_validate(true)
.dns_strict(false)
.dns_required(true)
.build();
assert_eq!(config.jacs_agent_key_algorithm, Some("Ed25519".to_string()));
assert_eq!(config.jacs_key_directory, Some("/custom/keys".to_string()));
assert_eq!(config.jacs_data_directory, Some("/custom/data".to_string()));
assert_eq!(config.jacs_default_storage, Some("memory".to_string()));
assert_eq!(config.jacs_use_security, Some("true".to_string()));
assert_eq!(
config.jacs_agent_private_key_filename,
Some("my_private.pem".to_string())
);
assert_eq!(
config.jacs_agent_public_key_filename,
Some("my_public.pem".to_string())
);
assert_eq!(
config.jacs_agent_id_and_version,
Some(
"550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001"
.to_string()
)
);
assert_eq!(config.jacs_agent_domain, Some("example.com".to_string()));
assert_eq!(config.jacs_dns_validate, Some(true));
assert_eq!(config.jacs_dns_strict, Some(false));
assert_eq!(config.jacs_dns_required, Some(true));
}
#[test]
fn test_config_builder_partial() {
let config = Config::builder()
.key_algorithm("pq2025")
.use_security(true)
.build();
assert_eq!(config.jacs_agent_key_algorithm, Some("pq2025".to_string()));
assert_eq!(config.jacs_use_security, Some("true".to_string()));
assert_eq!(config.jacs_data_directory, Some("./jacs_data".to_string()));
assert_eq!(config.jacs_key_directory, Some("./jacs_keys".to_string()));
assert_eq!(config.jacs_default_storage, Some("fs".to_string()));
}
#[test]
fn test_config_builder_method_chaining() {
let builder = ConfigBuilder::new()
.key_algorithm("Ed25519")
.key_directory("/keys")
.data_directory("/data");
let config = builder.build();
assert_eq!(config.jacs_agent_key_algorithm, Some("Ed25519".to_string()));
assert_eq!(config.jacs_key_directory, Some("/keys".to_string()));
assert_eq!(config.jacs_data_directory, Some("/data".to_string()));
}
#[test]
fn test_config_builder_vs_with_defaults() {
let builder_config = Config::builder().build();
let defaults_config = Config::with_defaults();
assert_eq!(
builder_config.jacs_use_security,
defaults_config.jacs_use_security
);
assert_eq!(
builder_config.jacs_agent_key_algorithm,
defaults_config.jacs_agent_key_algorithm
);
assert_eq!(
builder_config.jacs_default_storage,
defaults_config.jacs_default_storage
);
}
#[test]
fn test_validate_config_invalid_json_error_message() {
let invalid_json = r#"{
"jacs_data_directory": "/data",
"jacs_key_directory": "/keys"
"jacs_agent_key_algorithm": "RSA-PSS"
}"#;
let result = validate_config(invalid_json);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("line"),
"Error should include line number: {}",
err
);
assert!(
err.contains("column"),
"Error should include column: {}",
err
);
assert!(
err.contains("syntax"),
"Error should mention syntax issue: {}",
err
);
assert!(err.contains("JSON"), "Error should mention JSON: {}", err);
}
#[test]
fn test_validate_config_invalid_algorithm_error_message() {
let invalid_algo = r#"{
"jacs_data_directory": "/data",
"jacs_key_directory": "/keys",
"jacs_agent_private_key_filename": "private.pem",
"jacs_agent_public_key_filename": "public.pem",
"jacs_agent_key_algorithm": "INVALID_ALGO",
"jacs_default_storage": "fs"
}"#;
let result = validate_config(invalid_algo);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("jacs_agent_key_algorithm"),
"Error should mention field name: {}",
err
);
assert!(
err.contains("RSA-PSS") || err.contains("Valid algorithms"),
"Error should mention valid algorithms: {}",
err
);
}
#[test]
fn test_validate_config_missing_required_field_error_message() {
let missing_field = r#"{
"jacs_data_directory": "/data"
}"#;
let result = validate_config(missing_field);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("required") || err.contains("Required"),
"Error should mention required fields: {}",
err
);
}
#[test]
fn test_validate_config_invalid_storage_error_message() {
let invalid_storage = r#"{
"jacs_data_directory": "/data",
"jacs_key_directory": "/keys",
"jacs_agent_private_key_filename": "private.pem",
"jacs_agent_public_key_filename": "public.pem",
"jacs_agent_key_algorithm": "RSA-PSS",
"jacs_default_storage": "invalid_storage"
}"#;
let result = validate_config(invalid_storage);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("jacs_default_storage"),
"Error should mention field name: {}",
err
);
assert!(
err.contains("fs") || err.contains("Valid storage"),
"Error should mention valid storage options: {}",
err
);
}
#[test]
fn test_config_from_file_not_found_error_message() {
let result = Config::from_file("/nonexistent/path/jacs.config.json");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("nonexistent"),
"Error should include path: {}",
err
);
assert!(
err.contains("environment") || err.contains("not found"),
"Error should provide guidance: {}",
err
);
}
#[test]
fn test_get_field_help() {
assert!(
get_field_help("jacs_agent_key_algorithm")
.unwrap()
.contains("RSA-PSS")
);
assert!(
get_field_help("jacs_default_storage")
.unwrap()
.contains("fs")
);
assert!(
get_field_help("jacs_data_directory")
.unwrap()
.contains("path")
);
assert!(
get_field_help("jacs_agent_id_and_version")
.unwrap()
.contains("UUID")
);
assert!(get_field_help("unknown_field").is_none());
}
#[test]
fn test_key_resolution_source_from_str() {
assert_eq!(
KeyResolutionSource::from_str("local").unwrap(),
KeyResolutionSource::Local
);
assert_eq!(
KeyResolutionSource::from_str("LOCAL").unwrap(),
KeyResolutionSource::Local
);
assert_eq!(
KeyResolutionSource::from_str("Local").unwrap(),
KeyResolutionSource::Local
);
assert_eq!(
KeyResolutionSource::from_str("dns").unwrap(),
KeyResolutionSource::Dns
);
assert_eq!(
KeyResolutionSource::from_str("DNS").unwrap(),
KeyResolutionSource::Dns
);
assert_eq!(
KeyResolutionSource::from_str("registry").unwrap(),
KeyResolutionSource::Registry
);
assert_eq!(
KeyResolutionSource::from_str("REGISTRY").unwrap(),
KeyResolutionSource::Registry
);
assert_eq!(
KeyResolutionSource::from_str(" registry ").unwrap(),
KeyResolutionSource::Registry
);
assert!(
KeyResolutionSource::from_str("hai").is_err(),
"\"hai\" should be rejected as a key resolution source"
);
assert!(
KeyResolutionSource::from_str("HAI").is_err(),
"\"HAI\" should be rejected as a key resolution source"
);
assert!(KeyResolutionSource::from_str("invalid").is_err());
assert!(KeyResolutionSource::from_str("").is_err());
}
#[test]
fn test_key_resolution_source_display() {
assert_eq!(format!("{}", KeyResolutionSource::Local), "local");
assert_eq!(format!("{}", KeyResolutionSource::Dns), "dns");
assert_eq!(format!("{}", KeyResolutionSource::Registry), "registry");
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_default() {
clear_jacs_env_vars();
let _ = clear_env_var("JACS_KEY_RESOLUTION");
let order = get_key_resolution_order();
assert_eq!(
order,
vec![KeyResolutionSource::Local, KeyResolutionSource::Registry]
);
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_local_only() {
clear_jacs_env_vars();
set_env_var("JACS_KEY_RESOLUTION", "local").unwrap();
let order = get_key_resolution_order();
assert_eq!(order, vec![KeyResolutionSource::Local]);
let _ = clear_env_var("JACS_KEY_RESOLUTION");
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_registry_only() {
clear_jacs_env_vars();
set_env_var("JACS_KEY_RESOLUTION", "registry").unwrap();
let order = get_key_resolution_order();
assert_eq!(order, vec![KeyResolutionSource::Registry]);
let _ = clear_env_var("JACS_KEY_RESOLUTION");
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_hai_is_rejected() {
clear_jacs_env_vars();
set_env_var("JACS_KEY_RESOLUTION", "hai").unwrap();
let order = get_key_resolution_order();
assert!(
!order.iter().any(|s| format!("{}", s) == "hai"),
"\"hai\" should not appear in key resolution order"
);
let _ = clear_env_var("JACS_KEY_RESOLUTION");
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_with_dns() {
clear_jacs_env_vars();
set_env_var("JACS_KEY_RESOLUTION", "local,dns,registry").unwrap();
let order = get_key_resolution_order();
assert_eq!(
order,
vec![
KeyResolutionSource::Local,
KeyResolutionSource::Dns,
KeyResolutionSource::Registry,
]
);
let _ = clear_env_var("JACS_KEY_RESOLUTION");
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_case_insensitive() {
clear_jacs_env_vars();
set_env_var("JACS_KEY_RESOLUTION", "LOCAL,DNS,REGISTRY").unwrap();
let order = get_key_resolution_order();
assert_eq!(
order,
vec![
KeyResolutionSource::Local,
KeyResolutionSource::Dns,
KeyResolutionSource::Registry,
]
);
let _ = clear_env_var("JACS_KEY_RESOLUTION");
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_skips_invalid() {
clear_jacs_env_vars();
set_env_var("JACS_KEY_RESOLUTION", "local,invalid,registry").unwrap();
let order = get_key_resolution_order();
assert_eq!(
order,
vec![KeyResolutionSource::Local, KeyResolutionSource::Registry]
);
let _ = clear_env_var("JACS_KEY_RESOLUTION");
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_all_invalid_falls_back() {
clear_jacs_env_vars();
set_env_var("JACS_KEY_RESOLUTION", "invalid,also_invalid").unwrap();
let order = get_key_resolution_order();
assert_eq!(
order,
vec![KeyResolutionSource::Local, KeyResolutionSource::Registry]
);
let _ = clear_env_var("JACS_KEY_RESOLUTION");
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_empty_string_falls_back() {
clear_jacs_env_vars();
set_env_var("JACS_KEY_RESOLUTION", "").unwrap();
let order = get_key_resolution_order();
assert_eq!(
order,
vec![KeyResolutionSource::Local, KeyResolutionSource::Registry]
);
let _ = clear_env_var("JACS_KEY_RESOLUTION");
}
#[test]
#[serial(jacs_env)]
fn test_get_key_resolution_order_whitespace_handling() {
clear_jacs_env_vars();
set_env_var("JACS_KEY_RESOLUTION", " local , registry ").unwrap();
let order = get_key_resolution_order();
assert_eq!(
order,
vec![KeyResolutionSource::Local, KeyResolutionSource::Registry]
);
let _ = clear_env_var("JACS_KEY_RESOLUTION");
}
#[test]
fn test_network_capability_from_str() {
assert_eq!(
NetworkCapability::from_str("dns").unwrap(),
NetworkCapability::DnsLookup
);
assert_eq!(
NetworkCapability::from_str("public_key_fetch").unwrap(),
NetworkCapability::RemoteKeyFetch
);
assert_eq!(
NetworkCapability::from_str("registry").unwrap(),
NetworkCapability::RegistryLookup
);
assert_eq!(
NetworkCapability::from_str("schema_fetch").unwrap(),
NetworkCapability::RemoteSchemaFetch
);
assert_eq!(
NetworkCapability::from_str("jwks").unwrap(),
NetworkCapability::JwksFetch
);
assert_eq!(
NetworkCapability::from_str("agent_card_fetch").unwrap(),
NetworkCapability::AgentCardFetch
);
assert!(NetworkCapability::from_str("unknown").is_err());
}
#[test]
#[serial(jacs_env)]
fn test_network_access_defaults_to_disabled() {
clear_jacs_env_vars();
assert!(!is_network_access_allowed(NetworkCapability::DnsLookup));
let err = ensure_network_access(NetworkCapability::DnsLookup).unwrap_err();
assert!(err.to_string().contains("JACS_ALLOW_DNS"));
}
#[test]
#[serial(jacs_env)]
fn test_network_access_capability_override() {
clear_jacs_env_vars();
set_env_var("JACS_ALLOW_JWKS_FETCH", "true").unwrap();
assert!(is_network_access_allowed(NetworkCapability::JwksFetch));
assert!(ensure_network_access(NetworkCapability::JwksFetch).is_ok());
assert!(!is_network_access_allowed(
NetworkCapability::RemoteKeyFetch
));
let _ = clear_env_var("JACS_ALLOW_JWKS_FETCH");
}
#[test]
#[serial(jacs_env)]
fn test_network_access_global_override() {
clear_jacs_env_vars();
set_env_var("JACS_ALLOW_NETWORK", "true").unwrap();
assert!(is_network_access_allowed(NetworkCapability::DnsLookup));
assert!(is_network_access_allowed(
NetworkCapability::RemoteSchemaFetch
));
assert!(ensure_network_access(NetworkCapability::AgentCardFetch).is_ok());
let _ = clear_env_var("JACS_ALLOW_NETWORK");
}
}