mod errors;
pub mod resolved;
pub mod secret;
pub mod types;
use std::path::{Path, PathBuf};
use alloy::primitives::Address;
use anyhow::Context;
use errors::{IDENTITY_DATA_DIR, IDENTITY_REGION, one_section};
use crate::cli::common::{self, expand_tilde};
use crate::cli::run::RunArgs;
use crate::redact::redact_userinfo;
pub use errors::{ConfigDiagnostics, ConfigNotice, ConfigNoticeLevel};
pub use resolved::{
LoadShedPolicyKind, ResolvedBlockchain, ResolvedCache, ResolvedConfig, ResolvedContent,
ResolvedDht, ResolvedDiscovery, ResolvedDiscoveryPeer, ResolvedIdentity, ResolvedLoadShed,
ResolvedNetwork, ResolvedObservability, ResolvedOrigin, ResolvedPayment, ResolvedProbe,
ResolvedReceipts, ResolvedS3Config, ResolvedS3Credentials, ResolvedSecurity,
ResolvedServeEconomics, ResolvedTinyLfu,
};
pub use types::FileConfig;
const DEFAULT_BIND_PORT: u16 = 4433;
#[must_use]
pub fn configured_bind_port(file_bind_port: Option<u16>) -> u16 {
std::env::var("DECDN_BIND_PORT")
.ok()
.and_then(|v| v.trim().parse().ok())
.or(file_bind_port)
.unwrap_or(DEFAULT_BIND_PORT)
}
const DEFAULT_CACHE_SIZE_MB: u64 = 102_400;
pub const DEFAULT_DISK_HEADROOM_MB: u64 = 8192;
const DEFAULT_MAX_BLOB_SIZE_MB: u64 = 51_200;
const DEFAULT_RATE_PER_MB: u64 = 10;
pub const DEFAULT_METRICS_PORT: u16 = 9090;
const DEFAULT_METRICS_BIND: std::net::IpAddr = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
pub const DEFAULT_ADMIN_PORT: u16 = 9191;
const DEFAULT_RPC_WATCHDOG_INTERVAL_SEC: u64 = 30;
const MIN_RPC_WATCHDOG_INTERVAL_SEC: u64 = 10;
const DEFAULT_EVENT_POLL_INTERVAL_MS: u64 = 7000;
const MIN_EVENT_POLL_INTERVAL_MS: u64 = 250;
pub const DEFAULT_GET_LOGS_MAX_BLOCK_SPAN: u64 = 10_000;
const DEFAULT_REDEEM_THRESHOLD_MICRO_USDC: u64 = 1_000_000;
const DEFAULT_REDEEM_MAX_VOUCHERS_PER_TX: u64 = 300;
pub const DEFAULT_REDEEM_INTERVAL_SECS: u64 = 300;
pub const REDEEM_LANDING_SLACK_SECS: u64 = 120;
#[must_use]
pub const fn capability_expiry_margin_secs(redeem_interval_secs: u64) -> u64 {
redeem_interval_secs.saturating_add(REDEEM_LANDING_SLACK_SECS)
}
#[must_use]
pub const fn inside_capability_expiry_margin(expiry: u64, margin_secs: u64, now: u64) -> bool {
expiry != 0 && now.saturating_add(margin_secs) >= expiry
}
const MAX_REDEEM_INTERVAL_SECS: u64 = 6 * 60 * 60;
pub const DEFAULT_BUYER_WORKING_DEPOSIT_MICRO_USDC: u64 = 10_000_000;
pub const DEFAULT_POOL_MIN_REMAINING_DEPOSIT_MICRO_USDC: u64 = 1_000_000;
pub const DEFAULT_POOL_FLOOR_SIGNER_LIVE_WINDOWS: u64 = 8;
const DEFAULT_MAX_CONCURRENT_HANDLERS: u32 = 256;
const DEFAULT_PER_SOURCE_RATE_PER_SEC: f64 = 100.0;
const DEFAULT_PER_SOURCE_BURST: u32 = 200;
const DEFAULT_MAX_TRACKED_SOURCES: usize = 4096;
const DEFAULT_LOAD_SHED_EGRESS_BUDGET_MBPS: u64 = 0;
const DEFAULT_LOAD_SHED_SERVES_HIGH: u32 = 256;
const DEFAULT_LOAD_SHED_SERVES_LOW: u32 = 192;
const DEFAULT_LOAD_SHED_PER_CLIENT_CAP: u32 = 32;
const DEFAULT_DHT_PER_PEER_RATE_PER_SEC: f64 = 20.0;
const DEFAULT_DHT_PER_PEER_BURST: u32 = 40;
const DEFAULT_DHT_PER_IP_RATE_PER_SEC: f64 = 100.0;
const DEFAULT_DHT_PER_IP_BURST: u32 = 200;
const DEFAULT_DHT_GLOBAL_RATE_PER_SEC: f64 = 1000.0;
const DEFAULT_DHT_GLOBAL_BURST: u32 = 2000;
const DEFAULT_DHT_MAX_TRACKED_PER_IP: usize = 4096;
const DEFAULT_DHT_MAX_TRACKED_PER_PEER: usize = 4096;
const DEFAULT_PROBE_PER_PEER_RATE_PER_SEC: f64 = 5.0;
const DEFAULT_PROBE_PER_PEER_BURST: u32 = 5;
const DEFAULT_PROBE_PER_IP_RATE_PER_SEC: f64 = 50.0;
const DEFAULT_PROBE_PER_IP_BURST: u32 = 200;
const DEFAULT_PROBE_GLOBAL_RATE_PER_SEC: f64 = 1000.0;
const DEFAULT_PROBE_GLOBAL_BURST: u32 = 2000;
const DEFAULT_PROBE_MAX_TRACKED_PER_IP: usize = 4096;
const DEFAULT_PROBE_MAX_TRACKED_PER_PEER: usize = 4096;
pub const DEFAULT_GC_INTERVAL_SEC: u64 = 300;
pub const DEFAULT_FS_RESCAN_INTERVAL_SEC: u64 = 60;
pub use decdn_config_types::{
DEFAULT_ORIGIN_PROBE_FAULT_TTL_SEC, DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY,
DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL_SEC, DEFAULT_ORIGIN_PROBE_TIMEOUT_MS,
DEFAULT_ORIGIN_PROBE_TTL_SEC,
};
pub const DEFAULT_RELAY_FOREIGN_NAMESPACES: bool = true;
pub const DEFAULT_ORIGIN_DIRECTORY_POSITIVE_TTL_SEC: u64 = 300;
pub const DEFAULT_ORIGIN_DIRECTORY_NEGATIVE_TTL_SEC: u64 = 30;
pub const DEFAULT_ORIGIN_DIRECTORY_CACHE_CAPACITY: usize = 4096;
pub const DEFAULT_EVICTION_HIGH_WATER_PCT: u64 = 90;
pub const EVICTION_HIGH_WATER_PCT_BOUNDS: (u64, u64) = (60, 95);
pub const DEFAULT_EVICTION_TARGET_PCT: u64 = 80;
pub const EVICTION_TARGET_PCT_BOUNDS: (u64, u64) = (40, 90);
pub const EVICTION_HYSTERESIS_GAP_PCT: u64 = 5;
pub const DEFAULT_EVICTION_PER_SWEEP_BUDGET: u64 = 16;
pub const EVICTION_PER_SWEEP_BUDGET_BOUNDS: (u64, u64) = (1, 256);
pub const DEFAULT_EVICTION_TICK_SECS: u64 = 1;
pub const EVICTION_TICK_SECS_BOUNDS: (u64, u64) = (1, 60);
pub const DEFAULT_EVICTION_POLICY: &str = "lru";
pub const DEFAULT_ADMISSION_POLICY: &str = "always";
pub const DEFAULT_TINYLFU_SKETCH_BYTES: usize = 262_144;
pub const MIN_TINYLFU_SKETCH_BYTES: usize = 16_384;
const _: () = assert!(DEFAULT_TINYLFU_SKETCH_BYTES >= MIN_TINYLFU_SKETCH_BYTES);
pub const DEFAULT_TINYLFU_PROMOTION_THRESHOLD: u32 = 2;
pub const DEFAULT_TINYLFU_PROBATION_TARGET_PCT: u64 = 10;
pub const TINYLFU_PROBATION_TARGET_PCT_BOUNDS: (u64, u64) = (1, 50);
pub const DEFAULT_TINYLFU_AGING_HALFLIFE_SEC: u64 = 600;
pub const DEFAULT_SERVE_ECONOMICS_POLICY: &str = "margin";
pub const DEFAULT_SERVE_ECONOMICS_DISCOUNT_BPS: u32 = 5000;
pub const DEFAULT_SERVE_ECONOMICS_N_MAX: u32 = 64;
pub const DEFAULT_SERVE_ECONOMICS_WARMING_BUDGET: u64 = 5_000_000;
pub const DEFAULT_SERVE_ECONOMICS_WARMING_REFILL: u64 = 58;
pub const DEFAULT_CHAIN_ID: u64 = 421_614;
pub const DEFAULT_CONTENT_BLACKLIST_POLL_INTERVAL_SEC: u64 = 600;
pub const DEFAULT_CHAIN_STALENESS_GRACE_SEC: u64 = 1800;
pub const DEFAULT_FEE_SHARES_POLL_INTERVAL_SEC: u64 = 3600;
pub const DEFAULT_MAX_PROBE_HOLDS: usize = decdn_config_types::DEFAULT_MAX_PROBE_HOLDS;
pub const DEFAULT_STAKE_LANE_RESERVED_HOLDS: usize = 0;
pub const DEFAULT_NODE_PULL_PROBE_FANOUT: usize = 5;
pub const DEFAULT_NODE_PULL_TIMEOUT_SEC: u64 = 20;
pub const DEFAULT_NODE_PULL_STALL_WINDOW_SEC: u64 = 20;
pub const DEFAULT_NODE_PULL_MIN_THROUGHPUT_BPS: u64 = 4096;
pub const DEFAULT_CREDIT_MAX: u64 = 64 * 1024 * 1024;
pub const DEFAULT_CREDIT_RAMP_DIVISOR: u64 = 2;
pub const DEFAULT_FRAME_TARGET_BYTES: u64 = decdn_protocol::CHUNK_BYTES;
pub const DEFAULT_VOUCHER_COMMIT_INTERVAL_MS: u64 = 5_000;
pub const DEFAULT_RECEIPT_MAX_FILE_BYTES: u64 = 128 << 20;
pub const MIN_RECEIPT_MAX_FILE_BYTES: u64 = 1 << 20;
pub const MAX_RECEIPT_MAX_FILE_BYTES: u64 = 1 << 30;
pub const DEFAULT_RECEIPT_RETAINED_FILES: u32 = 4;
pub const MAX_RECEIPT_RETAINED_FILES: u32 = 100;
pub const RECEIPT_LOG_FILE: &str = "download_receipts.jsonl";
pub fn resolve_config(
config_path: Option<&Path>,
cli: &RunArgs,
) -> anyhow::Result<(ResolvedConfig, Vec<ConfigNotice>)> {
let file = load_file_config(config_path)?;
let mut bag = ConfigDiagnostics::new();
let identity = resolve_identity_into(&cli.identity, file.identity.as_ref(), &mut bag);
let data_dir_valid = !bag.has_field(IDENTITY_DATA_DIR);
let network = resolve_network_into(&cli.network, file.network.as_ref(), &mut bag);
let blockchain = resolve_blockchain_into(
&cli.blockchain,
file.blockchain.as_ref(),
&identity.data_dir,
data_dir_valid,
&mut bag,
);
let cache = resolve_cache_into(
&cli.cache,
file.cache.as_ref(),
&identity.data_dir,
&mut bag,
);
let payment = resolve_payment_into(&cli.payment, file.payment.as_ref(), &mut bag);
let observability =
resolve_observability_into(&cli.observability, file.observability.as_ref(), &mut bag);
let security = resolve_security_into(file.security.as_ref(), &mut bag);
let load_shed = resolve_load_shed_into(file.load_shed.as_ref(), &mut bag);
let dht = resolve_dht_into(file.dht.as_ref(), &mut bag);
let probe = resolve_probe_into(file.probe.as_ref(), &mut bag);
let receipts = resolve_receipts_into(file.receipts.as_ref(), &mut bag);
let content = resolve_content_into(file.content.as_ref(), &mut bag);
validate_port_layout_into(&network, &observability, &mut bag);
ensure_no_hash_pinned_and_denied_into(&cache, &content, &mut bag);
let notices = bag.take_notices();
bag.into_result()?;
Ok((
ResolvedConfig {
identity,
network,
blockchain,
cache,
payment,
observability,
security,
load_shed,
dht,
probe,
receipts,
content,
},
notices,
))
}
#[cfg(test)]
fn ensure_no_hash_pinned_and_denied(
cache: &ResolvedCache,
content: &ResolvedContent,
) -> anyhow::Result<()> {
one_section(|bag| ensure_no_hash_pinned_and_denied_into(cache, content, bag))
}
fn ensure_no_hash_pinned_and_denied_into(
cache: &ResolvedCache,
content: &ResolvedContent,
bag: &mut ConfigDiagnostics,
) {
let mut both: Vec<String> = cache
.pinned_hashes
.iter()
.filter(|&h| content.denied_hashes.contains(h))
.map(ToString::to_string)
.collect();
both.sort();
bag.check_with(both.is_empty(), "content.denied_hashes", || {
format!(
"hash(es) appear in both cache.pinned_hashes and \
content.denied_hashes: {}. Pinning a denied hash is contradictory — \
a denied hash is refused by the serve gate, so pinning it only pays \
storage for content the node will never serve. Remove each from one \
of the two lists",
both.join(", ")
)
});
}
#[cfg(test)]
fn validate_port_layout(
network: &ResolvedNetwork,
observability: &ResolvedObservability,
) -> anyhow::Result<()> {
one_section(|bag| validate_port_layout_into(network, observability, bag))
}
fn validate_port_layout_into(
network: &ResolvedNetwork,
observability: &ResolvedObservability,
bag: &mut ConfigDiagnostics,
) {
let bind = network.bind_port;
let metrics = observability.metrics_port;
let admin = observability.admin_port;
if bind != 0 && metrics != 0 {
bag.check_with(
bind != metrics,
"network.bind_port vs observability.metrics_port",
|| format!(
"network.bind_port ({bind}) must differ from observability.metrics_port ({metrics}); \
QUIC (UDP) and metrics (TCP) would not collide at bind time, but sharing \
the same port number is almost certainly an operator typo"
),
);
}
if let Some(admin) = admin
&& bind != 0
&& admin != 0
{
bag.check_with(
bind != admin,
"network.bind_port vs observability.admin_port",
|| format!(
"network.bind_port ({bind}) must differ from observability.admin_port ({admin}); \
QUIC (UDP) and admin (TCP) would not collide at bind time, but sharing \
the same port number is almost certainly an operator typo"
),
);
}
if let Some(admin) = admin
&& metrics != 0
&& admin != 0
{
bag.check_with(
admin != metrics,
"observability.admin_port vs observability.metrics_port",
|| format!(
"observability.admin_port ({admin}) must differ from observability.metrics_port ({metrics}); \
the two servers cannot share a TCP port"
),
);
}
for (name, port) in [
("network.bind_port", Some(bind)),
("observability.metrics_port", Some(metrics)),
("observability.admin_port", admin),
] {
if let Some(p) = port
&& (1..1024).contains(&p)
{
bag.warn(
name,
format!(
"{p} is in the well-known range (<1024); requires elevated privilege to \
bind on Unix and may collide with a standardized service"
),
);
}
}
}
#[cfg(test)]
fn resolve_identity(
cli: &crate::cli::run::IdentityArgs,
file: Option<&types::IdentityConfig>,
) -> anyhow::Result<ResolvedIdentity> {
one_section(|bag| resolve_identity_into(cli, file, bag))
}
fn resolve_identity_into(
cli: &crate::cli::run::IdentityArgs,
file: Option<&types::IdentityConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedIdentity {
let data_dir = cli
.data_dir
.clone()
.map(|p| expand_tilde(&p))
.or_else(|| {
file.and_then(|i| i.data_dir.clone())
.map(|p| expand_tilde(&p))
})
.or_else(common::default_data_dir)
.unwrap_or_else(|| {
bag.push(
IDENTITY_DATA_DIR,
"cannot determine data directory: home dir not found",
);
PathBuf::from("/nonexistent")
});
let region = match cli
.region
.clone()
.or_else(|| file.and_then(|i| i.region.clone()))
{
Some(raw) => bag.try_with(IDENTITY_REGION, normalize_region(&raw)),
None => None,
};
ResolvedIdentity { data_dir, region }
}
fn normalize_region(raw: &str) -> anyhow::Result<String> {
let upper = raw.to_ascii_uppercase();
anyhow::ensure!(
decdn_protocol::is_valid_region(&upper),
"identity.region must be an ISO 3166-1 alpha-2 code \
(assigned or user-reserved AA/QM-QZ/XA-XZ/ZZ), got {raw:?}"
);
Ok(upper)
}
fn resolve_network_into(
cli: &crate::cli::run::NetworkArgs,
file: Option<&types::NetworkConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedNetwork {
let bind_port = cli
.bind_port
.or_else(|| file.and_then(|n| n.bind_port))
.unwrap_or(DEFAULT_BIND_PORT);
let relay_urls = if let Some(url) = cli.relay_url.clone() {
vec![url]
} else {
file.and_then(|n| n.relay_urls.clone()).unwrap_or_default()
};
let file_relay_list_len = file.and_then(|n| n.relay_urls.as_ref()).map_or(0, Vec::len);
if cli.relay_url.is_some() && file_relay_list_len > 0 {
bag.warn(
"network.relay_url",
format!(
"--relay-url (or DECDN_RELAY_URL) overrides the {file_relay_list_len}-entry \
network.relay_urls list; multi-relay failover is disabled"
),
);
}
let from_array = cli.relay_url.is_none()
&& file
.and_then(|n| n.relay_urls.as_ref())
.is_some_and(|l| !l.is_empty());
for (i, entry) in relay_urls.iter().enumerate() {
if let Err(e) = url::Url::parse(entry) {
let label = if from_array {
format!("network.relay_urls[{i}]")
} else {
"network.relay_url".to_string()
};
bag.push(
label,
format!("invalid relay URL {:?}: {e}", redact_userinfo(entry)),
);
}
}
let discovery = resolve_discovery_into(file, bag);
ResolvedNetwork {
bind_port,
relay_urls,
discovery,
}
}
pub fn resolve_discovery(file: &FileConfig) -> anyhow::Result<ResolvedDiscovery> {
one_section(|bag| resolve_discovery_into(file.network.as_ref(), bag))
}
fn resolve_discovery_into(
file: Option<&types::NetworkConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedDiscovery {
let Some(disc) = file.and_then(|n| n.discovery.as_ref()) else {
return ResolvedDiscovery::default();
};
let pkarr_url = disc.pkarr_url.as_ref().and_then(|raw| {
bag.try_with(
"network.discovery.pkarr_url",
url::Url::parse(raw)
.map(|_| raw.clone())
.map_err(|e| anyhow::anyhow!("invalid URL {:?}: {e}", redact_userinfo(raw))),
)
});
let dns_origin = disc.dns_origin.as_ref().and_then(|raw| {
bag.check(
!raw.trim().is_empty(),
"network.discovery.dns_origin",
"must not be empty when set; omit the key instead",
)
.then(|| raw.clone())
});
bag.check(
!(disc.pkarr_url.is_some() && disc.dns_origin.is_none()),
"network.discovery.dns_origin",
"network.discovery.pkarr_url publishes this node's address record to a pkarr \
relay, but no network.discovery.dns_origin is configured to resolve peers from \
it; set dns_origin or remove pkarr_url",
);
let mut peers: Vec<ResolvedDiscoveryPeer> = disc
.peers
.iter()
.flatten()
.filter_map(|(node_id, peer)| resolve_discovery_peer(node_id, peer, bag))
.collect();
peers.sort_unstable_by(|a, b| a.node_id.cmp(&b.node_id));
ResolvedDiscovery {
pkarr_url,
dns_origin,
peers,
}
}
fn resolve_discovery_peer(
node_id: &str,
peer: &types::DiscoveryPeer,
bag: &mut ConfigDiagnostics,
) -> Option<ResolvedDiscoveryPeer> {
let mut ok = bag
.try_with(
format!("network.discovery.peers[{node_id}]"),
node_id.parse::<iroh::PublicKey>().map(|_| ()).map_err(|e| {
anyhow::anyhow!(
"invalid NodeId (expected a 64-char lowercase-hex iroh NodeId): {e}"
)
}),
)
.is_some();
if let Some(relay) = peer.relay_url.as_ref() {
ok &= bag
.try_with(
format!("network.discovery.peers[{node_id}].relay_url"),
url::Url::parse(relay)
.map(|_| ())
.map_err(|e| anyhow::anyhow!("invalid URL {:?}: {e}", redact_userinfo(relay))),
)
.is_some();
}
for (i, addr) in peer.addrs.iter().enumerate() {
ok &= bag
.try_with(
format!("network.discovery.peers[{node_id}].addrs[{i}]"),
addr.parse::<std::net::SocketAddr>()
.map(|_| ())
.map_err(|e| anyhow::anyhow!("invalid socket address {addr:?}: {e}")),
)
.is_some();
}
ok.then(|| ResolvedDiscoveryPeer {
node_id: node_id.to_string(),
relay_url: peer.relay_url.clone(),
addrs: peer.addrs.clone(),
})
}
#[cfg(test)]
fn resolve_network(
cli: &crate::cli::run::NetworkArgs,
file: Option<&types::NetworkConfig>,
) -> ResolvedNetwork {
let mut bag = ConfigDiagnostics::new();
let resolved = resolve_network_into(cli, file, &mut bag);
debug_assert_eq!(
bag.problem_count(),
0,
"resolve_network shim discards validation problems; use a bag-aware path"
);
resolved
}
pub fn parse_contract_address(flag_name: &str, raw: &str) -> anyhow::Result<String> {
let trimmed = raw.trim();
let addr = Address::parse_checksummed(trimmed, None).with_context(|| {
format!(
"invalid {flag_name}: value {raw:?} (trimmed: {trimmed:?}); expected an EIP-55 \
checksummed 0x-prefixed 40-hex-character address"
)
})?;
Ok(addr.to_checksum(None))
}
fn check_keystore_readable(path: &std::path::Path) -> anyhow::Result<()> {
let meta = std::fs::metadata(path)
.with_context(|| format!("invalid eth_keystore: cannot access {}", path.display()))?;
anyhow::ensure!(
meta.is_file(),
"invalid eth_keystore: {} is not a regular file",
path.display()
);
std::fs::File::open(path).with_context(|| {
format!(
"invalid eth_keystore: cannot open {} for reading",
path.display()
)
})?;
Ok(())
}
fn resolve_contract_address(
field: &str,
flag_name: &str,
missing_msg: &str,
raw: Option<String>,
bag: &mut ConfigDiagnostics,
) -> String {
match raw.filter(|s| !s.is_empty()) {
None => {
bag.push(field, missing_msg);
String::new()
}
Some(v) => bag
.try_with(field, parse_contract_address(flag_name, &v))
.unwrap_or_default(),
}
}
#[cfg(test)]
fn resolve_blockchain(
cli: &crate::cli::run::BlockchainArgs,
file: Option<&types::BlockchainConfig>,
data_dir: &std::path::Path,
) -> anyhow::Result<ResolvedBlockchain> {
one_section(|bag| resolve_blockchain_into(cli, file, data_dir, true, bag))
}
#[allow(clippy::too_many_lines)]
fn resolve_blockchain_into(
cli: &crate::cli::run::BlockchainArgs,
file: Option<&types::BlockchainConfig>,
data_dir: &std::path::Path,
data_dir_valid: bool,
bag: &mut ConfigDiagnostics,
) -> ResolvedBlockchain {
let rpc_url = match cli
.rpc_url
.clone()
.or_else(|| file.and_then(|b| b.rpc_url.clone()))
.filter(|s| !s.is_empty())
{
None => {
bag.push(
"blockchain.rpc_url",
"missing required option: --rpc-url (or blockchain.rpc_url in config file)",
);
String::new()
}
Some(raw) => {
match bag.try_with(
"blockchain.rpc_url",
url::Url::parse(&raw).context("blockchain.rpc_url is not a valid URL"),
) {
None => String::new(),
Some(parsed) => {
if bag.check_with(
parsed.scheme() == "http" || parsed.scheme() == "https",
"blockchain.rpc_url",
|| {
format!(
"blockchain.rpc_url must use http or https scheme (got {:?})",
parsed.scheme()
)
},
) {
parsed.to_string()
} else {
String::new()
}
}
}
}
};
let keystore_from_cli_or_file =
cli.eth_keystore.is_some() || file.and_then(|b| b.eth_keystore.as_ref()).is_some();
let eth_keystore = cli
.eth_keystore
.clone()
.map(|p| expand_tilde(&p))
.or_else(|| {
file.and_then(|b| b.eth_keystore.clone())
.map(|p| expand_tilde(&p))
})
.unwrap_or_else(|| data_dir.join("keystore.json"));
if keystore_from_cli_or_file || data_dir_valid {
bag.try_with(
"blockchain.eth_keystore",
check_keystore_readable(ð_keystore),
);
}
let payment_pool_address = resolve_contract_address(
"blockchain.payment_pool_address",
"payment_pool_address",
"missing required option: --payment-pool-address \
(or blockchain.payment_pool_address in config file)",
cli.payment_pool_address
.clone()
.or_else(|| file.and_then(|b| b.payment_pool_address.clone())),
bag,
);
let capacity_bond_address = resolve_contract_address(
"blockchain.capacity_bond_address",
"capacity_bond_address",
"missing required option: --capacity-bond-address \
(or blockchain.capacity_bond_address in config file)",
cli.capacity_bond_address
.clone()
.or_else(|| file.and_then(|b| b.capacity_bond_address.clone())),
bag,
);
if !payment_pool_address.is_empty() {
bag.check(
payment_pool_address
.trim_start_matches("0x")
.bytes()
.any(|b| b != b'0'),
"blockchain.payment_pool_address",
"blockchain.payment_pool_address must not be the zero address — \
set it to the deployed PaymentPool contract",
);
}
if !capacity_bond_address.is_empty() {
bag.check(
capacity_bond_address
.trim_start_matches("0x")
.bytes()
.any(|b| b != b'0'),
"blockchain.capacity_bond_address",
"blockchain.capacity_bond_address must not be the zero address — \
set it to the deployed CapacityBond contract",
);
}
let origin_assignment_raw = cli
.origin_assignment_address
.clone()
.or_else(|| file.and_then(|b| b.origin_assignment_address.clone()))
.filter(|s| !s.is_empty());
let publisher_registry_raw = cli
.publisher_registry_address
.clone()
.or_else(|| file.and_then(|b| b.publisher_registry_address.clone()))
.filter(|s| !s.is_empty());
let origin_assignment_address = origin_assignment_raw.and_then(|v| {
bag.try_with(
"blockchain.origin_assignment_address",
parse_contract_address("origin_assignment_address", &v),
)
});
let publisher_registry_address = publisher_registry_raw.and_then(|v| {
bag.try_with(
"blockchain.publisher_registry_address",
parse_contract_address("publisher_registry_address", &v),
)
});
if let Some(addr) = origin_assignment_address.as_deref() {
bag.check(
addr.trim_start_matches("0x").bytes().any(|b| b != b'0'),
"blockchain.origin_assignment_address",
"blockchain.origin_assignment_address must not be the zero address — \
set it to the deployed OriginAssignment contract",
);
}
if let Some(addr) = publisher_registry_address.as_deref() {
bag.check(
addr.trim_start_matches("0x").bytes().any(|b| b != b'0'),
"blockchain.publisher_registry_address",
"blockchain.publisher_registry_address must not be the zero address — \
set it to the deployed PublisherRegistry contract",
);
}
let slash_judge_address = resolve_contract_address(
"blockchain.slash_judge_address",
"slash_judge_address",
"missing required option: --slash-judge-address \
(or blockchain.slash_judge_address in config file)",
cli.slash_judge_address
.clone()
.or_else(|| file.and_then(|b| b.slash_judge_address.clone())),
bag,
);
if !slash_judge_address.is_empty() {
bag.check(
slash_judge_address
.trim_start_matches("0x")
.bytes()
.any(|b| b != b'0'),
"blockchain.slash_judge_address",
"blockchain.slash_judge_address must not be the zero address — \
set it to the deployed SlashJudge contract (ADR 014 §1)",
);
}
let content_blacklist_address = resolve_contract_address(
"blockchain.content_blacklist_address",
"content_blacklist_address",
"missing required option: --content-blacklist-address \
(or blockchain.content_blacklist_address in config file)",
cli.content_blacklist_address
.clone()
.or_else(|| file.and_then(|b| b.content_blacklist_address.clone())),
bag,
);
if !content_blacklist_address.is_empty() {
bag.check(
content_blacklist_address
.trim_start_matches("0x")
.bytes()
.any(|b| b != b'0'),
"blockchain.content_blacklist_address",
"blockchain.content_blacklist_address must not be the zero address — \
set it to the deployed ContentBlacklist contract (ADR 011/031)",
);
}
let content_blacklist_address =
(!content_blacklist_address.is_empty()).then_some(content_blacklist_address);
let content_blacklist_poll_interval_sec = file
.and_then(|b| b.content_blacklist_poll_interval_sec)
.unwrap_or(DEFAULT_CONTENT_BLACKLIST_POLL_INTERVAL_SEC);
bag.check(
content_blacklist_poll_interval_sec != 0,
"blockchain.content_blacklist_poll_interval_sec",
"blockchain.content_blacklist_poll_interval_sec must not be 0 — the \
reconcile interval must be non-zero; omit it for the default (600s)",
);
let chain_staleness_grace_sec = file
.and_then(|b| b.chain_staleness_grace_sec)
.unwrap_or(DEFAULT_CHAIN_STALENESS_GRACE_SEC);
bag.check(
chain_staleness_grace_sec != 0,
"blockchain.chain_staleness_grace_sec",
"blockchain.chain_staleness_grace_sec must not be 0 — a zero grace \
refuses every serve; set a large value to opt out, or omit it for the \
default (1800s)",
);
let fee_shares_poll_interval_sec = file
.and_then(|b| b.fee_shares_poll_interval_sec)
.unwrap_or(DEFAULT_FEE_SHARES_POLL_INTERVAL_SEC);
bag.check(
fee_shares_poll_interval_sec != 0,
"blockchain.fee_shares_poll_interval_sec",
"blockchain.fee_shares_poll_interval_sec must not be 0 — the \
authoritative getShares() re-read is a slow safety net; omit it \
for the default (3600s)",
);
let origin_directory_positive_ttl_sec = file
.and_then(|b| b.origin_directory_positive_ttl_sec)
.unwrap_or(DEFAULT_ORIGIN_DIRECTORY_POSITIVE_TTL_SEC);
let origin_directory_negative_ttl_sec = file
.and_then(|b| b.origin_directory_negative_ttl_sec)
.unwrap_or(DEFAULT_ORIGIN_DIRECTORY_NEGATIVE_TTL_SEC);
let origin_directory_cache_capacity = file
.and_then(|b| b.origin_directory_cache_capacity)
.unwrap_or(DEFAULT_ORIGIN_DIRECTORY_CACHE_CAPACITY);
let chain_id = cli
.chain_id
.or_else(|| file.and_then(|b| b.chain_id))
.unwrap_or(DEFAULT_CHAIN_ID);
bag.check_with(chain_id != 0, "blockchain.chain_id", || {
format!(
"blockchain.chain_id must not be 0 — set it to the deployed L2 \
chain id (default {DEFAULT_CHAIN_ID}, Arbitrum Sepolia)"
)
});
let rpc_watchdog_interval_sec = file
.and_then(|b| b.rpc_watchdog_interval_sec)
.unwrap_or(DEFAULT_RPC_WATCHDOG_INTERVAL_SEC);
bag.check_with(
rpc_watchdog_interval_sec == 0
|| rpc_watchdog_interval_sec >= MIN_RPC_WATCHDOG_INTERVAL_SEC,
"blockchain.rpc_watchdog_interval_sec",
|| {
format!(
"blockchain.rpc_watchdog_interval_sec={rpc_watchdog_interval_sec} \
would flood the RPC endpoint (minimum {MIN_RPC_WATCHDOG_INTERVAL_SEC}s, \
or 0 to disable)"
)
},
);
let event_poll_interval_ms = file
.and_then(|b| b.event_poll_interval_ms)
.unwrap_or(DEFAULT_EVENT_POLL_INTERVAL_MS);
bag.check_with(
event_poll_interval_ms >= MIN_EVENT_POLL_INTERVAL_MS,
"blockchain.event_poll_interval_ms",
|| {
format!(
"blockchain.event_poll_interval_ms={event_poll_interval_ms} would poll the \
RPC endpoint too frequently — it drives both the eth_getLogs watcher \
tick and pending-tx receipt polling \
(minimum {MIN_EVENT_POLL_INTERVAL_MS}ms)"
)
},
);
let get_logs_max_block_span = file
.and_then(|b| b.get_logs_max_block_span)
.unwrap_or(DEFAULT_GET_LOGS_MAX_BLOCK_SPAN);
bag.check_with(
get_logs_max_block_span > 0,
"blockchain.get_logs_max_block_span",
|| {
"blockchain.get_logs_max_block_span=0 would scan no blocks; use at least 1 \
(the provider's eth_getLogs range limit)"
.to_owned()
},
);
let redeem_threshold_micro_usdc = file
.and_then(|b| b.redeem_threshold_micro_usdc)
.unwrap_or(DEFAULT_REDEEM_THRESHOLD_MICRO_USDC);
bag.check_with(
redeem_threshold_micro_usdc > 0,
"blockchain.redeem_threshold_micro_usdc",
|| {
"blockchain.redeem_threshold_micro_usdc must be > 0 (a 0 threshold \
withdraws on every voucher, burning gas and reverting on zero-delta)"
.to_string()
},
);
let redeem_interval_secs = file
.and_then(|b| b.redeem_interval_secs)
.unwrap_or(DEFAULT_REDEEM_INTERVAL_SECS);
bag.check_with(
redeem_interval_secs > 0,
"blockchain.redeem_interval_secs",
|| {
"blockchain.redeem_interval_secs must be > 0 (a 0 interval is not a valid \
sweep period)"
.to_string()
},
);
bag.check_with(
redeem_interval_secs <= MAX_REDEEM_INTERVAL_SECS,
"blockchain.redeem_interval_secs",
|| {
format!(
"blockchain.redeem_interval_secs must be <= {MAX_REDEEM_INTERVAL_SECS} (6h): the \
redeem sweep must run well inside the 48h grace window to secure vouchers \
before an owner can reclaim a closing pool"
)
},
);
let redeem_max_vouchers_per_tx = file
.and_then(|b| b.redeem_max_vouchers_per_tx)
.unwrap_or(DEFAULT_REDEEM_MAX_VOUCHERS_PER_TX);
bag.check_with(
redeem_max_vouchers_per_tx > 0,
"blockchain.redeem_max_vouchers_per_tx",
|| {
"blockchain.redeem_max_vouchers_per_tx must be > 0 (a 0 chunk size \
carries no vouchers and strands every redemption)"
.to_string()
},
);
let buyer_working_deposit_micro_usdc = file
.and_then(|b| b.buyer_working_deposit_micro_usdc)
.unwrap_or(DEFAULT_BUYER_WORKING_DEPOSIT_MICRO_USDC);
bag.check_with(
buyer_working_deposit_micro_usdc > 0,
"blockchain.buyer_working_deposit_micro_usdc",
|| {
"blockchain.buyer_working_deposit_micro_usdc must be > 0 (openPool reverts \
ZeroAmount on a zero deposit)"
.to_string()
},
);
let buyer_max_approve = file.and_then(|b| b.buyer_max_approve).unwrap_or(true);
let pool_min_remaining_deposit_micro_usdc = file
.and_then(|b| b.pool_min_remaining_deposit_micro_usdc)
.unwrap_or(DEFAULT_POOL_MIN_REMAINING_DEPOSIT_MICRO_USDC);
let pool_floor_signer_live_windows = file
.and_then(|b| b.pool_floor_signer_live_windows)
.unwrap_or(DEFAULT_POOL_FLOOR_SIGNER_LIVE_WINDOWS);
let keystore_password_file = cli.keystore_password_file.clone().map(|p| expand_tilde(&p));
ResolvedBlockchain {
rpc_url,
eth_keystore,
keystore_password_file,
payment_pool_address,
capacity_bond_address,
origin_assignment_address,
origin_directory_positive_ttl_sec,
origin_directory_negative_ttl_sec,
origin_directory_cache_capacity,
publisher_registry_address,
slash_judge_address,
content_blacklist_address,
content_blacklist_poll_interval_sec,
chain_staleness_grace_sec,
chain_id,
rpc_watchdog_interval_sec,
event_poll_interval_ms,
get_logs_max_block_span,
fee_shares_poll_interval_sec,
redeem_threshold_micro_usdc,
redeem_max_vouchers_per_tx,
redeem_interval_secs,
buyer_working_deposit_micro_usdc,
buyer_max_approve,
pool_min_remaining_deposit_micro_usdc,
pool_floor_signer_live_windows,
}
}
#[cfg(test)]
fn resolve_cache(
cli: &crate::cli::run::CacheArgs,
file: Option<&types::CacheConfig>,
data_dir: &std::path::Path,
) -> anyhow::Result<ResolvedCache> {
one_section(|bag| resolve_cache_into(cli, file, data_dir, bag))
}
#[allow(clippy::too_many_lines)] fn resolve_cache_into(
cli: &crate::cli::run::CacheArgs,
file: Option<&types::CacheConfig>,
data_dir: &std::path::Path,
bag: &mut ConfigDiagnostics,
) -> ResolvedCache {
let cache_dir = cli
.cache_dir
.clone()
.map(|p| expand_tilde(&p))
.or_else(|| {
file.and_then(|c| c.cache_dir.clone())
.map(|p| expand_tilde(&p))
})
.unwrap_or_else(|| data_dir.join("cache"));
let cache_size_mb = cli
.cache_size_mb
.or_else(|| file.and_then(|c| c.cache_size_mb))
.unwrap_or(DEFAULT_CACHE_SIZE_MB);
let disk_headroom_mb = file
.and_then(|c| c.disk_headroom_mb)
.unwrap_or(DEFAULT_DISK_HEADROOM_MB);
let max_blob_size_mb = cli
.max_blob_size_mb
.or_else(|| file.and_then(|c| c.max_blob_size_mb))
.unwrap_or_else(|| DEFAULT_MAX_BLOB_SIZE_MB.min(cache_size_mb));
let max_rate_per_mb = cli
.max_rate_per_mb
.or_else(|| file.and_then(|c| c.max_rate_per_mb))
.unwrap_or(0);
bag.check_with(
max_blob_size_mb <= cache_size_mb,
"cache.max_blob_size_mb",
|| {
format!(
"cache.max_blob_size_mb ({max_blob_size_mb}) must not exceed \
cache.cache_size_mb ({cache_size_mb}); a blob larger than the whole \
cache could never be admitted"
)
},
);
let origins = resolve_origins_into(file, bag);
let pinned_hashes = bag
.try_with(
"cache.pinned_hashes",
parse_pinned_hashes(file.and_then(|c| c.pinned_hashes.as_deref()))
.context("invalid cache.pinned_hashes"),
)
.unwrap_or_else(decdn_config_types::PinnedHashes::empty);
let origin_retry = bag
.try_with(
"cache.origin_retry",
resolve_origin_retry(file.and_then(|c| c.origin_retry.as_ref()))
.context("invalid cache.origin_retry"),
)
.unwrap_or_default();
let circuit_breaker = bag
.try_with(
"cache.circuit_breaker",
resolve_circuit_breaker(file.and_then(|c| c.circuit_breaker.as_ref()))
.context("invalid cache.circuit_breaker"),
)
.unwrap_or_default();
let user_agent = match file.and_then(|c| c.user_agent.as_ref()) {
Some(s)
if bag
.try_with("cache.user_agent", validate_user_agent(s))
.is_some() =>
{
s.clone()
}
_ => decdn_config_types::DEFAULT_USER_AGENT.to_string(),
};
let gc_interval_sec = file
.and_then(|c| c.gc_interval_sec)
.unwrap_or(DEFAULT_GC_INTERVAL_SEC);
let fs_rescan_interval_sec = file
.and_then(|c| c.fs_rescan_interval_sec)
.unwrap_or(DEFAULT_FS_RESCAN_INTERVAL_SEC);
let origin_probe_ttl_sec = file
.and_then(|c| c.origin_probe_ttl_sec)
.unwrap_or(DEFAULT_ORIGIN_PROBE_TTL_SEC);
let origin_probe_negative_ttl_sec = file
.and_then(|c| c.origin_probe_negative_ttl_sec)
.unwrap_or(DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL_SEC);
let origin_probe_fault_ttl_sec = file
.and_then(|c| c.origin_probe_fault_ttl_sec)
.unwrap_or(DEFAULT_ORIGIN_PROBE_FAULT_TTL_SEC);
bag.check_with(
origin_probe_negative_ttl_sec <= origin_probe_fault_ttl_sec
&& origin_probe_fault_ttl_sec <= origin_probe_ttl_sec,
"cache.origin_probe_fault_ttl_sec",
|| {
format!(
"cache.origin_probe_fault_ttl_sec ({origin_probe_fault_ttl_sec}) must sit \
between cache.origin_probe_negative_ttl_sec \
({origin_probe_negative_ttl_sec}) and cache.origin_probe_ttl_sec \
({origin_probe_ttl_sec}) inclusive: a fault is re-probed no less eagerly \
than a positive answer and no more eagerly than an absence"
)
},
);
let origin_probe_timeout_ms = file
.and_then(|c| c.origin_probe_timeout_ms)
.unwrap_or(DEFAULT_ORIGIN_PROBE_TIMEOUT_MS);
let origin_probe_memo_capacity = file
.and_then(|c| c.origin_probe_memo_capacity)
.unwrap_or(DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY);
let eviction_high_water_pct = file
.and_then(|c| c.eviction_high_water_pct)
.unwrap_or(DEFAULT_EVICTION_HIGH_WATER_PCT);
bag.check_with(
(EVICTION_HIGH_WATER_PCT_BOUNDS.0..=EVICTION_HIGH_WATER_PCT_BOUNDS.1)
.contains(&eviction_high_water_pct),
"cache.eviction_high_water_pct",
|| {
format!(
"cache.eviction_high_water_pct ({eviction_high_water_pct}) must be within \
[{}, {}]",
EVICTION_HIGH_WATER_PCT_BOUNDS.0, EVICTION_HIGH_WATER_PCT_BOUNDS.1
)
},
);
let eviction_target_pct = file
.and_then(|c| c.eviction_target_pct)
.unwrap_or(DEFAULT_EVICTION_TARGET_PCT);
bag.check_with(
(EVICTION_TARGET_PCT_BOUNDS.0..=EVICTION_TARGET_PCT_BOUNDS.1)
.contains(&eviction_target_pct),
"cache.eviction_target_pct",
|| {
format!(
"cache.eviction_target_pct ({eviction_target_pct}) must be within [{}, {}]",
EVICTION_TARGET_PCT_BOUNDS.0, EVICTION_TARGET_PCT_BOUNDS.1
)
},
);
bag.check_with(
eviction_target_pct <= eviction_high_water_pct.saturating_sub(EVICTION_HYSTERESIS_GAP_PCT),
"cache.eviction_target_pct",
|| {
format!(
"cache.eviction_target_pct ({eviction_target_pct}) must be at least \
{EVICTION_HYSTERESIS_GAP_PCT} points below cache.eviction_high_water_pct \
({eviction_high_water_pct}): the hysteresis gap is structural"
)
},
);
let eviction_per_sweep_budget = file
.and_then(|c| c.eviction_per_sweep_budget)
.unwrap_or(DEFAULT_EVICTION_PER_SWEEP_BUDGET);
bag.check_with(
(EVICTION_PER_SWEEP_BUDGET_BOUNDS.0..=EVICTION_PER_SWEEP_BUDGET_BOUNDS.1)
.contains(&eviction_per_sweep_budget),
"cache.eviction_per_sweep_budget",
|| {
format!(
"cache.eviction_per_sweep_budget ({eviction_per_sweep_budget}) must be within \
[{}, {}]",
EVICTION_PER_SWEEP_BUDGET_BOUNDS.0, EVICTION_PER_SWEEP_BUDGET_BOUNDS.1
)
},
);
let eviction_tick_secs = file
.and_then(|c| c.eviction_tick_secs)
.unwrap_or(DEFAULT_EVICTION_TICK_SECS);
bag.check_with(
(EVICTION_TICK_SECS_BOUNDS.0..=EVICTION_TICK_SECS_BOUNDS.1).contains(&eviction_tick_secs),
"cache.eviction_tick_secs",
|| {
format!(
"cache.eviction_tick_secs ({eviction_tick_secs}) must be within [{}, {}]",
EVICTION_TICK_SECS_BOUNDS.0, EVICTION_TICK_SECS_BOUNDS.1
)
},
);
let max_probe_holds = cli
.max_probe_holds
.or_else(|| file.and_then(|c| c.max_probe_holds))
.map_or(DEFAULT_MAX_PROBE_HOLDS, |v| {
usize::try_from(v).unwrap_or(usize::MAX)
});
let stake_lane_reserved_holds = cli
.stake_lane_reserved_holds
.or_else(|| file.and_then(|c| c.stake_lane_reserved_holds))
.map_or(DEFAULT_STAKE_LANE_RESERVED_HOLDS, |v| {
usize::try_from(v).unwrap_or(usize::MAX)
});
let node_to_node_pull_through_enabled = file
.and_then(|c| c.node_to_node_pull_through_enabled)
.unwrap_or(false);
let relay_foreign_namespaces = file
.and_then(|c| c.relay_foreign_namespaces)
.unwrap_or(origins.is_empty());
let node_pull_probe_fanout = file
.and_then(|c| c.node_pull_probe_fanout)
.unwrap_or(DEFAULT_NODE_PULL_PROBE_FANOUT);
let node_pull_timeout_sec = file
.and_then(|c| c.node_pull_timeout_sec)
.unwrap_or(DEFAULT_NODE_PULL_TIMEOUT_SEC);
bag.check(
node_pull_timeout_sec > 0,
"cache.node_pull_timeout_sec",
"cache.node_pull_timeout_sec must be > 0 (a 0 budget abandons every upstream \
before its handshake can complete, so no pull can ever succeed)",
);
let node_pull_stall_window_sec = file
.and_then(|c| c.node_pull_stall_window_sec)
.unwrap_or(DEFAULT_NODE_PULL_STALL_WINDOW_SEC);
bag.check(
node_pull_stall_window_sec > 0,
"cache.node_pull_stall_window_sec",
"cache.node_pull_stall_window_sec must be > 0 (a 0 window makes the throughput \
floor unsatisfiable, abandoning every upstream on the first read)",
);
let node_pull_min_throughput_bps = file
.and_then(|c| c.node_pull_min_throughput_bps)
.unwrap_or(DEFAULT_NODE_PULL_MIN_THROUGHPUT_BPS);
let eviction_policy = file
.and_then(|c| c.eviction_policy.clone())
.unwrap_or_else(|| DEFAULT_EVICTION_POLICY.to_string());
bag.check_with(
matches!(eviction_policy.as_str(), "lru" | "tinylfu"),
"cache.eviction_policy",
|| format!("cache.eviction_policy ({eviction_policy}) must be \"lru\" or \"tinylfu\""),
);
let admission_policy = file
.and_then(|c| c.admission_policy.clone())
.unwrap_or_else(|| DEFAULT_ADMISSION_POLICY.to_string());
bag.check_with(
matches!(admission_policy.as_str(), "always" | "tinylfu"),
"cache.admission_policy",
|| format!("cache.admission_policy ({admission_policy}) must be \"always\" or \"tinylfu\""),
);
let tinylfu_file = file.and_then(|c| c.tinylfu.as_ref());
let tinylfu_sketch_bytes = tinylfu_file
.and_then(|t| t.sketch_bytes)
.unwrap_or(DEFAULT_TINYLFU_SKETCH_BYTES);
let tinylfu_promotion_threshold = tinylfu_file
.and_then(|t| t.promotion_threshold)
.unwrap_or(DEFAULT_TINYLFU_PROMOTION_THRESHOLD);
bag.check_with(
tinylfu_sketch_bytes >= MIN_TINYLFU_SKETCH_BYTES,
"cache.tinylfu.sketch_bytes",
|| {
format!(
"cache.tinylfu.sketch_bytes ({tinylfu_sketch_bytes}) must be >= \
{MIN_TINYLFU_SKETCH_BYTES}: a narrower sketch reports cold blobs \
as hot often enough to move admission, eviction and \
serve-economics decisions. It applies whether or not a policy \
selector names \"tinylfu\" — the default \
cache.serve_economics.policy = \"margin\" builds the same \
sketch."
)
},
);
bag.check_with(
tinylfu_promotion_threshold >= 1,
"cache.tinylfu.promotion_threshold",
|| {
format!(
"cache.tinylfu.promotion_threshold ({tinylfu_promotion_threshold}) must be >= 1"
)
},
);
let tinylfu_probation_target_pct = tinylfu_file
.and_then(|t| t.probation_target_pct)
.unwrap_or(DEFAULT_TINYLFU_PROBATION_TARGET_PCT);
bag.check_with(
(TINYLFU_PROBATION_TARGET_PCT_BOUNDS.0..=TINYLFU_PROBATION_TARGET_PCT_BOUNDS.1)
.contains(&tinylfu_probation_target_pct),
"cache.tinylfu.probation_target_pct",
|| {
format!(
"cache.tinylfu.probation_target_pct ({tinylfu_probation_target_pct}) must be \
within [{}, {}]",
TINYLFU_PROBATION_TARGET_PCT_BOUNDS.0, TINYLFU_PROBATION_TARGET_PCT_BOUNDS.1
)
},
);
let tinylfu_aging_halflife_sec = tinylfu_file
.and_then(|t| t.aging_halflife_sec)
.unwrap_or(DEFAULT_TINYLFU_AGING_HALFLIFE_SEC);
let se_file = file.and_then(|c| c.serve_economics.as_ref());
let se_policy = se_file
.and_then(|s| s.policy.clone())
.unwrap_or_else(|| DEFAULT_SERVE_ECONOMICS_POLICY.to_string());
bag.check_with(
matches!(se_policy.as_str(), "off" | "margin"),
"cache.serve_economics.policy",
|| format!("cache.serve_economics.policy ({se_policy}) must be \"off\" or \"margin\""),
);
let se_discount = se_file.and_then(|s| s.discount);
bag.check_with(
se_discount.is_none_or(|d| d > 0.0 && d <= 1.0),
"cache.serve_economics.discount",
|| format!("cache.serve_economics.discount ({se_discount:?}) must be within (0.0, 1.0]"),
);
let se_discount_bps = se_discount.map_or(DEFAULT_SERVE_ECONOMICS_DISCOUNT_BPS, |d| {
let bps = (d * 10_000.0).round().clamp(1.0, 10_000.0);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let bps = bps as u32;
bps
});
let se_n_max = se_file
.and_then(|s| s.n_max)
.unwrap_or(DEFAULT_SERVE_ECONOMICS_N_MAX);
bag.check_with(se_n_max >= 1, "cache.serve_economics.n_max", || {
format!("cache.serve_economics.n_max ({se_n_max}) must be >= 1")
});
let se_warming_budget = se_file
.and_then(|s| s.warming_budget)
.unwrap_or(DEFAULT_SERVE_ECONOMICS_WARMING_BUDGET);
bag.check_with(
se_warming_budget > 0,
"cache.serve_economics.warming_budget",
|| format!("cache.serve_economics.warming_budget ({se_warming_budget}) must be > 0"),
);
let se_warming_refill = se_file
.and_then(|s| s.warming_refill)
.unwrap_or(DEFAULT_SERVE_ECONOMICS_WARMING_REFILL);
ResolvedCache {
cache_dir,
cache_size_mb,
disk_headroom_mb,
max_blob_size_mb,
max_rate_per_mb,
origins,
pinned_hashes,
origin_retry,
circuit_breaker,
user_agent,
gc_interval_sec,
fs_rescan_interval_sec,
origin_probe_ttl_sec,
origin_probe_negative_ttl_sec,
origin_probe_fault_ttl_sec,
origin_probe_timeout_ms,
origin_probe_memo_capacity,
eviction_high_water_pct,
eviction_target_pct,
eviction_per_sweep_budget,
eviction_tick_secs,
max_probe_holds,
stake_lane_reserved_holds,
node_to_node_pull_through_enabled,
relay_foreign_namespaces,
node_pull_probe_fanout,
node_pull_timeout_sec,
node_pull_stall_window_sec,
node_pull_min_throughput_bps,
eviction_policy,
admission_policy,
tinylfu: ResolvedTinyLfu {
sketch_bytes: tinylfu_sketch_bytes,
promotion_threshold: tinylfu_promotion_threshold,
probation_target_pct: tinylfu_probation_target_pct,
aging_halflife_sec: tinylfu_aging_halflife_sec,
},
serve_economics: ResolvedServeEconomics {
policy: se_policy,
discount_bps: se_discount_bps,
n_max: se_n_max,
warming_budget: se_warming_budget,
warming_refill: se_warming_refill,
},
}
}
fn resolve_origins_into(
file: Option<&types::CacheConfig>,
bag: &mut ConfigDiagnostics,
) -> Vec<crate::config::ResolvedOrigin> {
let Some(cache) = file else {
return Vec::new();
};
match (&cache.origin, &cache.origins) {
(Some(_), Some(_)) => {
bag.push(
"cache.origins",
"cache.origin and cache.origins are mutually exclusive — \
use [cache.origin] for a single backend or [[cache.origins]] \
for an ordered fallback list, not both",
);
Vec::new()
}
(Some(single), None) => bag
.try_with(
"cache.origin",
resolve_origin(single).context("invalid cache.origin"),
)
.map(|o| vec![o])
.unwrap_or_default(),
(None, Some(list)) => {
if !bag.check(
!list.is_empty(),
"cache.origins",
"cache.origins must contain at least one entry; \
omit the key entirely for no pull-through",
) {
return Vec::new();
}
let mut resolved = Vec::with_capacity(list.len());
for (idx, entry) in list.iter().enumerate() {
if let Some(o) = bag.try_with(
format!("cache.origins[{idx}]"),
resolve_origin(entry).with_context(|| format!("invalid cache.origins[{idx}]")),
) {
resolved.push(o);
}
}
warn_on_duplicate_origins(&resolved, bag);
resolved
}
(None, None) => Vec::new(),
}
}
fn origin_identity_key(origin: &crate::config::ResolvedOrigin) -> String {
use crate::config::ResolvedOrigin;
match origin {
ResolvedOrigin::Http { url, .. } => format!("http|{url}"),
ResolvedOrigin::Fs { path } => format!("fs|{}", path.display()),
ResolvedOrigin::S3(s3) => format!(
"s3|{}|{}|{}|{}",
s3.bucket,
s3.region,
s3.endpoint_url
.as_ref()
.map(ToString::to_string)
.unwrap_or_default(),
s3.prefix,
),
}
}
fn warn_on_duplicate_origins(
origins: &[crate::config::ResolvedOrigin],
bag: &mut ConfigDiagnostics,
) {
let mut seen: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(origins.len());
for (idx, origin) in origins.iter().enumerate() {
let key = origin_identity_key(origin);
if !seen.insert(key.clone()) {
bag.warn(
format!("cache.origins[{idx}]"),
format!(
"{key} duplicates an earlier entry — the cache engine will dispatch the \
same backend twice in the fallback chain (intentional for connection-pool \
sharding, otherwise a likely copy-paste mistake)"
),
);
}
}
}
fn resolve_origin(cfg: &types::OriginConfig) -> anyhow::Result<crate::config::ResolvedOrigin> {
use crate::config::ResolvedOrigin;
match cfg {
types::OriginConfig::Http { url, decompress } => {
anyhow::ensure!(!url.is_empty(), "cache.origin.url must not be empty");
let parsed =
decdn_config_types::parse_origin_url(url).context("invalid cache.origin.url")?;
Ok(ResolvedOrigin::Http {
url: parsed,
decompress: decompress.unwrap_or_default(),
})
}
types::OriginConfig::Fs { path } => {
let expanded = expand_tilde(path);
anyhow::ensure!(
!expanded.as_os_str().is_empty(),
"cache.origin.path must not be empty"
);
Ok(ResolvedOrigin::Fs { path: expanded })
}
types::OriginConfig::S3(s3) => {
let resolved = resolve_s3_origin(s3.clone())?;
Ok(ResolvedOrigin::S3(resolved))
}
}
}
fn resolve_s3_origin(
cfg: types::S3OriginConfig,
) -> anyhow::Result<crate::config::ResolvedS3Config> {
use crate::config::resolved::{ResolvedS3Config, ResolvedS3Credentials};
let types::S3OriginConfig {
bucket,
region,
endpoint_url,
path_style,
prefix,
credentials,
decompress,
} = cfg;
validate_s3_bucket_name(&bucket).context("invalid cache.origin.bucket")?;
anyhow::ensure!(
!region.trim().is_empty(),
"cache.origin.region must not be empty (the AWS SDK uses it for SigV4 signing \
even when a custom endpoint_url is set)"
);
let endpoint_url = if let Some(endpoint) = endpoint_url {
anyhow::ensure!(
!endpoint.is_empty(),
"cache.origin.endpoint_url must not be empty when set; omit the key instead"
);
Some(
decdn_config_types::parse_origin_url(&endpoint)
.context("invalid cache.origin.endpoint_url")?,
)
} else {
None
};
let prefix = match prefix {
None => String::new(),
Some(mut prefix) => {
anyhow::ensure!(
!prefix.starts_with('/'),
"cache.origin.prefix is a key prefix, not a filesystem path: \
it must not start with `/` (use `prefix = \"\"` or omit the key for no prefix)"
);
anyhow::ensure!(
!prefix.contains(".."),
"cache.origin.prefix must not contain `..` (key-prefix shape)"
);
anyhow::ensure!(
!prefix.contains('\\'),
"cache.origin.prefix must not contain `\\` (key-prefix shape)"
);
anyhow::ensure!(
!prefix.bytes().any(|b| b.is_ascii_control()),
"cache.origin.prefix must not contain ASCII control characters"
);
anyhow::ensure!(
!prefix.bytes().any(|b| b == b' ' || b == b'\t'),
"cache.origin.prefix must not contain whitespace"
);
if !prefix.is_empty() && !prefix.ends_with('/') {
prefix.push('/');
}
prefix
}
};
let credentials = credentials.map(|c| match c {
types::S3Credentials::Static {
access_key_id,
secret_access_key,
session_token,
} => ResolvedS3Credentials::Static {
access_key_id,
secret_access_key,
session_token,
},
types::S3Credentials::DefaultChain { profile } => {
let profile = profile.filter(|p| !p.is_empty());
ResolvedS3Credentials::DefaultChain { profile }
}
});
Ok(ResolvedS3Config {
bucket,
region,
endpoint_url,
path_style: path_style.unwrap_or(false),
prefix,
credentials,
decompress: decompress.unwrap_or_default(),
})
}
pub fn validate_s3_bucket_name(name: &str) -> anyhow::Result<()> {
anyhow::ensure!(!name.is_empty(), "bucket name must not be empty");
let len = name.len();
anyhow::ensure!(
(3..=63).contains(&len),
"bucket name must be 3..=63 chars (got {len})"
);
let first = name.bytes().next().unwrap_or(0);
let last = name.bytes().next_back().unwrap_or(0);
anyhow::ensure!(
first.is_ascii_lowercase() || first.is_ascii_digit(),
"bucket name must begin with a lowercase letter or digit"
);
anyhow::ensure!(
last.is_ascii_lowercase() || last.is_ascii_digit(),
"bucket name must end with a lowercase letter or digit"
);
anyhow::ensure!(
!name.contains(".."),
"bucket name must not contain consecutive dots"
);
anyhow::ensure!(
name.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'.'),
"bucket name must contain only lowercase letters, digits, `-`, or `.`"
);
if name.parse::<std::net::Ipv4Addr>().is_ok() {
anyhow::bail!("bucket name must not be formatted as an IPv4 address");
}
Ok(())
}
fn validate_user_agent(s: &str) -> anyhow::Result<()> {
anyhow::ensure!(
!s.is_empty(),
"cache.user_agent must be a non-empty string when set"
);
if let Some((idx, byte)) = s
.bytes()
.enumerate()
.find(|(_, b)| !(*b == b'\t' || (0x20..=0x7e).contains(b)))
{
anyhow::bail!(
"cache.user_agent contains an invalid byte 0x{byte:02x} at position {idx}; \
only visible ASCII (0x20..=0x7e) and tab (0x09) are allowed in HTTP header values"
);
}
Ok(())
}
pub const MAX_BUFFERED_MAX_BYTES: u64 = 64 << 20;
pub fn resolve_origin_retry(
file: Option<&decdn_config_types::RetryPolicy>,
) -> anyhow::Result<decdn_config_types::RetryPolicy> {
let p = file.copied().unwrap_or_default();
anyhow::ensure!(
p.initial_backoff_ms <= p.max_backoff_ms,
"cache.origin_retry: initial_backoff_ms ({}) must be <= max_backoff_ms ({}); \
otherwise the schedule never grows",
p.initial_backoff_ms,
p.max_backoff_ms,
);
anyhow::ensure!(
p.jitter_ratio.is_finite() && (0.0..=1.0).contains(&p.jitter_ratio),
"cache.origin_retry: jitter_ratio={} must be a finite number in 0.0..=1.0",
p.jitter_ratio,
);
anyhow::ensure!(
p.buffered_max_bytes <= MAX_BUFFERED_MAX_BYTES,
"cache.origin_retry: buffered_max_bytes ({}) exceeds the hard ceiling of {} bytes \
(#519). The streaming abort+restart path covers blobs of any size without raising \
this knob; if mid-stream retries on large blobs is the goal, leave \
buffered_max_bytes at the default and rely on the streaming path",
p.buffered_max_bytes,
MAX_BUFFERED_MAX_BYTES,
);
Ok(p)
}
pub fn resolve_circuit_breaker(
file: Option<&decdn_config_types::CircuitBreakerPolicy>,
) -> anyhow::Result<decdn_config_types::CircuitBreakerPolicy> {
let p = file.copied().unwrap_or_default();
if p.is_active() {
anyhow::ensure!(
p.half_open_max_calls >= 1,
"cache.circuit_breaker: half_open_max_calls ({}) must be >= 1 when the breaker is \
active; otherwise it could never admit a trial pull to probe recovery and would \
stay open forever after the first trip",
p.half_open_max_calls,
);
}
Ok(p)
}
pub fn parse_pinned_hashes(
raw: Option<&[String]>,
) -> anyhow::Result<decdn_config_types::PinnedHashes> {
Ok(decdn_config_types::PinnedHashes::new(parse_hash_list(
raw,
"cache.pinned_hashes",
)?))
}
pub fn parse_denied_hashes(
raw: Option<&[String]>,
) -> anyhow::Result<decdn_config_types::DeniedHashes> {
Ok(decdn_config_types::DeniedHashes::new(parse_hash_list(
raw,
"content.denied_hashes",
)?))
}
fn parse_hash_list(
raw: Option<&[String]>,
label: &str,
) -> anyhow::Result<std::collections::HashSet<decdn_config_types::Hash>> {
use std::str::FromStr;
let mut out = std::collections::HashSet::new();
let Some(entries) = raw else {
return Ok(out);
};
for (idx, entry) in entries.iter().enumerate() {
let trimmed = entry.trim();
anyhow::ensure!(
trimmed.len() == 64,
"{label}[{idx}] must be 64 hex chars (BLAKE3); got {} chars",
trimmed.len()
);
anyhow::ensure!(
trimmed.chars().all(|c| c.is_ascii_hexdigit())
&& !trimmed.chars().any(|c| c.is_ascii_uppercase()),
"{label}[{idx}] must be lowercase hex (0-9, a-f)"
);
let parsed = decdn_config_types::Hash::from_str(trimmed)
.with_context(|| format!("{label}[{idx}] failed to parse as a BLAKE3 hash"))?;
out.insert(parsed);
}
Ok(out)
}
pub fn parse_denied_origins(
raw: Option<&[String]>,
) -> anyhow::Result<std::collections::HashSet<alloy::primitives::Address>> {
let mut out = std::collections::HashSet::new();
let Some(entries) = raw else {
return Ok(out);
};
for (idx, entry) in entries.iter().enumerate() {
let label = format!("content.denied_origins[{idx}]");
let addr = crate::address::parse_address(entry.trim(), &label)?;
anyhow::ensure!(
addr != alloy::primitives::Address::ZERO,
"{label} must not be the zero address — list a real operator address, \
or remove the entry"
);
out.insert(addr);
}
Ok(out)
}
fn resolve_content_into(
file: Option<&types::ContentConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedContent {
let denied_hashes = bag
.try_with(
"content.denied_hashes",
parse_denied_hashes(file.and_then(|c| c.denied_hashes.as_deref()))
.context("invalid content.denied_hashes"),
)
.unwrap_or_else(decdn_config_types::DeniedHashes::empty);
let denied_origins = bag
.try_with(
"content.denied_origins",
parse_denied_origins(file.and_then(|c| c.denied_origins.as_deref()))
.context("invalid content.denied_origins"),
)
.unwrap_or_default();
ResolvedContent {
denied_hashes,
denied_origins,
}
}
#[cfg(test)]
pub fn resolve_payment(
cli: &crate::cli::run::PaymentArgs,
file: Option<&types::PaymentConfig>,
) -> anyhow::Result<ResolvedPayment> {
one_section(|bag| resolve_payment_into(cli, file, bag))
}
pub fn resolve_payment_into(
cli: &crate::cli::run::PaymentArgs,
file: Option<&types::PaymentConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedPayment {
let rate_per_mb = cli
.rate_per_mb
.or_else(|| file.and_then(|p| p.rate_per_mb))
.unwrap_or(DEFAULT_RATE_PER_MB);
bag.check(
rate_per_mb > 0,
"payment.rate_per_mb",
"payment.rate_per_mb must be > 0 (used in the node selection score, \
ADR 001); got 0",
);
bag.check_with(
rate_per_mb <= decdn_protocol::MAX_RATE_PER_MB,
"payment.rate_per_mb",
|| {
format!(
"payment.rate_per_mb {rate_per_mb} exceeds protocol MAX_RATE_PER_MB ({}); \
honest clients reject `ProbeResponse`s above this ceiling (issue #378)",
decdn_protocol::MAX_RATE_PER_MB,
)
},
);
let credit_max = file
.and_then(|p| p.credit_max.as_ref())
.map_or(DEFAULT_CREDIT_MAX, |b| b.get());
let credit_ramp_divisor = file
.and_then(|p| p.credit_ramp_divisor)
.unwrap_or(DEFAULT_CREDIT_RAMP_DIVISOR);
let frame_target_bytes = file
.and_then(|p| p.frame_target_bytes.as_ref())
.map_or(DEFAULT_FRAME_TARGET_BYTES, |b| b.get());
bag.check_with(
frame_target_bytes > 0 && frame_target_bytes <= decdn_protocol::CHUNK_BYTES,
"payment.frame_target_bytes",
|| {
format!(
"payment.frame_target_bytes must be in 1..={} (one payment chunk); a \
frame never crosses a payment-chunk boundary, so a larger value \
could never reach the wire",
decdn_protocol::CHUNK_BYTES
)
},
);
let voucher_commit_interval_ms = file
.and_then(|p| p.voucher_commit_interval_ms)
.unwrap_or(DEFAULT_VOUCHER_COMMIT_INTERVAL_MS);
bag.check_with(
voucher_commit_interval_ms > 0,
"payment.voucher_commit_interval_ms",
|| {
"payment.voucher_commit_interval_ms must be > 0 (a 0 interval is not a \
valid flush period)"
.to_string()
},
);
ResolvedPayment {
rate_per_mb,
credit_max,
credit_ramp_divisor,
frame_target_bytes,
voucher_commit_interval_ms,
}
}
#[cfg(test)]
pub fn resolve_observability(
cli: &crate::cli::run::ObservabilityArgs,
file: Option<&types::ObservabilityConfig>,
) -> anyhow::Result<ResolvedObservability> {
one_section(|bag| resolve_observability_into(cli, file, bag))
}
pub fn resolve_observability_into(
cli: &crate::cli::run::ObservabilityArgs,
file: Option<&types::ObservabilityConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedObservability {
let log_level = cli
.log_level
.or_else(|| file.and_then(|o| o.log_level))
.unwrap_or_default();
let log_format = cli
.log_format
.or_else(|| file.and_then(|o| o.log_format))
.unwrap_or_default();
let metrics_port = cli
.metrics_port
.or_else(|| file.and_then(|o| o.metrics_port))
.unwrap_or(DEFAULT_METRICS_PORT);
let metrics_bind = cli
.metrics_bind
.or_else(|| file.and_then(|o| o.metrics_bind))
.unwrap_or(DEFAULT_METRICS_BIND);
let admin_port_raw = cli
.admin_port
.or_else(|| file.and_then(|o| o.admin_port))
.unwrap_or(DEFAULT_ADMIN_PORT);
let admin_port = if admin_port_raw == 0 {
None
} else {
Some(admin_port_raw)
};
let otlp_endpoint = cli
.otlp_endpoint
.clone()
.or_else(|| file.and_then(|o| o.otlp_endpoint.clone()))
.filter(|s| !s.is_empty());
if let Some(ref ep) = otlp_endpoint {
bag.try_with("observability.otlp_endpoint", validate_otlp_endpoint(ep));
}
ResolvedObservability {
log_level,
log_format,
metrics_port,
metrics_bind,
admin_port,
otlp_endpoint,
}
}
fn validate_otlp_endpoint(endpoint: &str) -> anyhow::Result<()> {
anyhow::ensure!(
endpoint.trim() == endpoint && !endpoint.contains(['\t', '\n', '\r']),
"observability.otlp_endpoint must not contain whitespace, tabs, or newlines"
);
anyhow::ensure!(
endpoint
.get(..7)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")),
"observability.otlp_endpoint must start with http://; the OTLP gRPC exporter has \
no TLS, so point it at a local collector and terminate TLS there"
);
let parsed = url::Url::parse(endpoint)
.map_err(|e| anyhow::anyhow!("observability.otlp_endpoint is not a valid URL: {e}"))?;
anyhow::ensure!(
parsed.host().is_some(),
"observability.otlp_endpoint has no host"
);
let authority = endpoint
.split_once("://")
.map_or("", |(_, rest)| rest)
.split(['/', '?', '#'])
.next()
.unwrap_or_default();
anyhow::ensure!(
!authority.contains('@') && parsed.username().is_empty() && parsed.password().is_none(),
"observability.otlp_endpoint must not carry userinfo; pass collector \
credentials through the collector, not the URL"
);
anyhow::ensure!(
parsed.path() == "/" && parsed.query().is_none() && parsed.fragment().is_none(),
"observability.otlp_endpoint must be http://host:port with no path, query, or \
fragment; an OTLP/HTTP URL (port 4318, /v1/traces) is the wrong protocol"
);
let has_port = authority.rsplit_once(':').is_some_and(|(host, port)| {
!port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) && !host.is_empty()
});
anyhow::ensure!(
has_port,
"observability.otlp_endpoint must name the collector port (e.g. http://localhost:4317)"
);
Ok(())
}
#[cfg(test)]
fn resolve_receipts(file: Option<&types::ReceiptsConfig>) -> anyhow::Result<ResolvedReceipts> {
one_section(|bag| resolve_receipts_into(file, bag))
}
fn resolve_receipts_into(
file: Option<&types::ReceiptsConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedReceipts {
let max_file_bytes = file
.and_then(|r| r.max_file_bytes)
.unwrap_or(DEFAULT_RECEIPT_MAX_FILE_BYTES);
bag.check_with(
(MIN_RECEIPT_MAX_FILE_BYTES..=MAX_RECEIPT_MAX_FILE_BYTES).contains(&max_file_bytes),
"receipts.max_file_bytes",
|| {
format!(
"receipts.max_file_bytes ({max_file_bytes}) must be within \
[{MIN_RECEIPT_MAX_FILE_BYTES}, {MAX_RECEIPT_MAX_FILE_BYTES}] bytes"
)
},
);
let retained_files = file
.and_then(|r| r.retained_files)
.unwrap_or(DEFAULT_RECEIPT_RETAINED_FILES);
bag.check_with(
retained_files <= MAX_RECEIPT_RETAINED_FILES,
"receipts.retained_files",
|| {
format!(
"receipts.retained_files ({retained_files}) must be <= \
{MAX_RECEIPT_RETAINED_FILES}"
)
},
);
ResolvedReceipts {
max_file_bytes,
retained_files,
}
}
#[cfg(test)]
pub fn resolve_security(file: Option<&types::SecurityConfig>) -> anyhow::Result<ResolvedSecurity> {
one_section(|bag| resolve_security_into(file, bag))
}
#[allow(clippy::cognitive_complexity)]
pub fn resolve_security_into(
file: Option<&types::SecurityConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedSecurity {
let max_concurrent_handlers = file
.and_then(|s| s.max_concurrent_handlers)
.unwrap_or(DEFAULT_MAX_CONCURRENT_HANDLERS);
let max_permits = tokio::sync::Semaphore::MAX_PERMITS;
bag.check_with(
usize::try_from(max_concurrent_handlers).is_ok_and(|v| v <= max_permits),
"security.max_concurrent_handlers",
|| format!(
"security.max_concurrent_handlers={max_concurrent_handlers} exceeds tokio Semaphore::MAX_PERMITS={max_permits} on this target"
),
);
let per_source_rate_per_sec = file
.and_then(|s| s.per_source_rate_per_sec)
.unwrap_or(DEFAULT_PER_SOURCE_RATE_PER_SEC);
bag.check(
per_source_rate_per_sec.is_finite() && per_source_rate_per_sec >= 0.0,
"security.per_source_rate_per_sec",
"security.per_source_rate_per_sec must be a finite non-negative number \
(0 disables the per-source layer)",
);
let per_source_burst = file
.and_then(|s| s.per_source_burst)
.unwrap_or(DEFAULT_PER_SOURCE_BURST);
bag.check(
per_source_rate_per_sec == 0.0 || per_source_burst > 0,
"security.per_source_burst",
"security.per_source_burst must be > 0 when per_source_rate_per_sec > 0 \
(set both to 0 to disable the per-source layer)",
);
let max_tracked_sources = file
.and_then(|s| s.max_tracked_sources)
.unwrap_or(DEFAULT_MAX_TRACKED_SOURCES);
if max_concurrent_handlers == 0 {
bag.note(
"security.max_concurrent_handlers",
"0: global concurrency cap disabled",
);
}
if per_source_rate_per_sec == 0.0 {
bag.note(
"security.per_source_rate_per_sec",
"0: per-source rate-limit disabled",
);
}
if max_tracked_sources == 0 {
bag.warn(
"security.max_tracked_sources",
"0: rate-limit bookkeeping map is unbounded; an attacker churning sources can \
grow it without limit",
);
}
ResolvedSecurity {
max_concurrent_handlers,
per_source_rate_per_sec,
per_source_burst,
max_tracked_sources,
}
}
#[cfg(test)]
pub fn resolve_load_shed(file: Option<&types::LoadShedConfig>) -> anyhow::Result<ResolvedLoadShed> {
one_section(|bag| resolve_load_shed_into(file, bag))
}
pub fn resolve_load_shed_into(
file: Option<&types::LoadShedConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedLoadShed {
let policy = match file.and_then(|c| c.policy.as_deref()) {
None | Some("resource-pressure") => LoadShedPolicyKind::ResourcePressure,
Some("always-admit") => LoadShedPolicyKind::AlwaysAdmit,
Some(other) => {
bag.push(
"load_shed.policy",
format!(
"load_shed.policy must be \"resource-pressure\" or \"always-admit\", got {other:?}"
),
);
LoadShedPolicyKind::ResourcePressure
}
};
let high = file
.and_then(|c| c.max_concurrent_serves_high)
.unwrap_or(DEFAULT_LOAD_SHED_SERVES_HIGH);
let low = file
.and_then(|c| c.max_concurrent_serves_low)
.unwrap_or(DEFAULT_LOAD_SHED_SERVES_LOW);
bag.check_with(high >= low, "load_shed.max_concurrent_serves_high", || {
format!("load_shed.max_concurrent_serves_high ({high}) must be >= _low ({low})")
});
ResolvedLoadShed {
policy,
egress_budget_mbps: file
.and_then(|c| c.egress_budget_mbps)
.unwrap_or(DEFAULT_LOAD_SHED_EGRESS_BUDGET_MBPS),
max_concurrent_serves_high: high,
max_concurrent_serves_low: low,
per_client_serve_cap: file
.and_then(|c| c.per_client_serve_cap)
.unwrap_or(DEFAULT_LOAD_SHED_PER_CLIENT_CAP),
}
}
#[allow(clippy::cognitive_complexity)] pub fn resolve_dht_into(
file: Option<&types::DhtConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedDht {
let rate_limit = file.and_then(|d| d.rate_limit.as_ref());
let per_peer_rate_per_sec = rate_limit
.and_then(|r| r.per_peer_rate_per_sec)
.unwrap_or(DEFAULT_DHT_PER_PEER_RATE_PER_SEC);
bag.check(
per_peer_rate_per_sec.is_finite() && per_peer_rate_per_sec >= 0.0,
"dht.rate_limit.per_peer_rate_per_sec",
"dht.rate_limit.per_peer_rate_per_sec must be a finite non-negative number (0 disables the layer)",
);
let per_peer_burst = rate_limit
.and_then(|r| r.per_peer_burst)
.unwrap_or(DEFAULT_DHT_PER_PEER_BURST);
bag.check(
per_peer_rate_per_sec == 0.0 || per_peer_burst > 0,
"dht.rate_limit.per_peer_burst",
"dht.rate_limit.per_peer_burst must be > 0 when per_peer_rate_per_sec > 0 (set both to 0 to disable)",
);
let per_ip_rate_per_sec = rate_limit
.and_then(|r| r.per_ip_rate_per_sec)
.unwrap_or(DEFAULT_DHT_PER_IP_RATE_PER_SEC);
bag.check(
per_ip_rate_per_sec.is_finite() && per_ip_rate_per_sec >= 0.0,
"dht.rate_limit.per_ip_rate_per_sec",
"dht.rate_limit.per_ip_rate_per_sec must be a finite non-negative number (0 disables the layer)",
);
let per_ip_burst = rate_limit
.and_then(|r| r.per_ip_burst)
.unwrap_or(DEFAULT_DHT_PER_IP_BURST);
bag.check(
per_ip_rate_per_sec == 0.0 || per_ip_burst > 0,
"dht.rate_limit.per_ip_burst",
"dht.rate_limit.per_ip_burst must be > 0 when per_ip_rate_per_sec > 0 (set both to 0 to disable)",
);
let global_rate_per_sec = rate_limit
.and_then(|r| r.global_rate_per_sec)
.unwrap_or(DEFAULT_DHT_GLOBAL_RATE_PER_SEC);
bag.check(
global_rate_per_sec.is_finite() && global_rate_per_sec >= 0.0,
"dht.rate_limit.global_rate_per_sec",
"dht.rate_limit.global_rate_per_sec must be a finite non-negative number (0 disables the layer)",
);
let global_burst = rate_limit
.and_then(|r| r.global_burst)
.unwrap_or(DEFAULT_DHT_GLOBAL_BURST);
bag.check(
global_rate_per_sec == 0.0 || global_burst > 0,
"dht.rate_limit.global_burst",
"dht.rate_limit.global_burst must be > 0 when global_rate_per_sec > 0 (set both to 0 to disable)",
);
let max_tracked_per_ip = rate_limit
.and_then(|r| r.max_tracked_per_ip)
.unwrap_or(DEFAULT_DHT_MAX_TRACKED_PER_IP);
let max_tracked_per_peer = rate_limit
.and_then(|r| r.max_tracked_per_peer)
.unwrap_or(DEFAULT_DHT_MAX_TRACKED_PER_PEER);
if max_tracked_per_ip == 0 {
bag.warn(
"dht.rate_limit.max_tracked_per_ip",
"0: per-IP bookkeeping map is unbounded; an attacker churning source IPs can \
grow it without limit",
);
}
if max_tracked_per_peer == 0 {
bag.warn(
"dht.rate_limit.max_tracked_per_peer",
"0: per-peer bookkeeping map is unbounded; an attacker churning NodeIds can \
grow it without limit",
);
}
ResolvedDht {
per_peer_rate_per_sec,
per_peer_burst,
per_ip_rate_per_sec,
per_ip_burst,
global_rate_per_sec,
global_burst,
max_tracked_per_ip,
max_tracked_per_peer,
}
}
#[cfg(test)]
pub fn resolve_dht(file: Option<&types::DhtConfig>) -> anyhow::Result<ResolvedDht> {
one_section(|bag| resolve_dht_into(file, bag))
}
#[allow(clippy::cognitive_complexity)] pub fn resolve_probe_into(
file: Option<&types::ProbeConfig>,
bag: &mut ConfigDiagnostics,
) -> ResolvedProbe {
let rate_limit = file.and_then(|p| p.rate_limit.as_ref());
let per_peer_rate_per_sec = rate_limit
.and_then(|r| r.per_peer_rate_per_sec)
.unwrap_or(DEFAULT_PROBE_PER_PEER_RATE_PER_SEC);
bag.check(
per_peer_rate_per_sec.is_finite() && per_peer_rate_per_sec >= 0.0,
"probe.rate_limit.per_peer_rate_per_sec",
"probe.rate_limit.per_peer_rate_per_sec must be a finite non-negative number (0 disables the layer)",
);
let per_peer_burst = rate_limit
.and_then(|r| r.per_peer_burst)
.unwrap_or(DEFAULT_PROBE_PER_PEER_BURST);
bag.check(
per_peer_rate_per_sec == 0.0 || per_peer_burst > 0,
"probe.rate_limit.per_peer_burst",
"probe.rate_limit.per_peer_burst must be > 0 when per_peer_rate_per_sec > 0 (set both to 0 to disable)",
);
let per_ip_rate_per_sec = rate_limit
.and_then(|r| r.per_ip_rate_per_sec)
.unwrap_or(DEFAULT_PROBE_PER_IP_RATE_PER_SEC);
bag.check(
per_ip_rate_per_sec.is_finite() && per_ip_rate_per_sec >= 0.0,
"probe.rate_limit.per_ip_rate_per_sec",
"probe.rate_limit.per_ip_rate_per_sec must be a finite non-negative number (0 disables the layer)",
);
let per_ip_burst = rate_limit
.and_then(|r| r.per_ip_burst)
.unwrap_or(DEFAULT_PROBE_PER_IP_BURST);
bag.check(
per_ip_rate_per_sec == 0.0 || per_ip_burst > 0,
"probe.rate_limit.per_ip_burst",
"probe.rate_limit.per_ip_burst must be > 0 when per_ip_rate_per_sec > 0 (set both to 0 to disable)",
);
let global_rate_per_sec = rate_limit
.and_then(|r| r.global_rate_per_sec)
.unwrap_or(DEFAULT_PROBE_GLOBAL_RATE_PER_SEC);
bag.check(
global_rate_per_sec.is_finite() && global_rate_per_sec >= 0.0,
"probe.rate_limit.global_rate_per_sec",
"probe.rate_limit.global_rate_per_sec must be a finite non-negative number (0 disables the layer)",
);
let global_burst = rate_limit
.and_then(|r| r.global_burst)
.unwrap_or(DEFAULT_PROBE_GLOBAL_BURST);
bag.check(
global_rate_per_sec == 0.0 || global_burst > 0,
"probe.rate_limit.global_burst",
"probe.rate_limit.global_burst must be > 0 when global_rate_per_sec > 0 (set both to 0 to disable)",
);
let max_tracked_per_ip = rate_limit
.and_then(|r| r.max_tracked_per_ip)
.unwrap_or(DEFAULT_PROBE_MAX_TRACKED_PER_IP);
let max_tracked_per_peer = rate_limit
.and_then(|r| r.max_tracked_per_peer)
.unwrap_or(DEFAULT_PROBE_MAX_TRACKED_PER_PEER);
if max_tracked_per_ip == 0 {
bag.warn(
"probe.rate_limit.max_tracked_per_ip",
"0: per-IP bookkeeping map is unbounded; an attacker churning source IPs can \
grow it without limit",
);
}
if max_tracked_per_peer == 0 {
bag.warn(
"probe.rate_limit.max_tracked_per_peer",
"0: per-peer bookkeeping map is unbounded; an attacker churning NodeIds can \
grow it without limit",
);
}
ResolvedProbe {
per_peer_rate_per_sec,
per_peer_burst,
per_ip_rate_per_sec,
per_ip_burst,
global_rate_per_sec,
global_burst,
max_tracked_per_ip,
max_tracked_per_peer,
}
}
#[cfg(test)]
pub fn resolve_probe(file: Option<&types::ProbeConfig>) -> anyhow::Result<ResolvedProbe> {
one_section(|bag| resolve_probe_into(file, bag))
}
pub fn load_file_config(explicit_path: Option<&Path>) -> anyhow::Result<FileConfig> {
let path = match explicit_path {
Some(p) => p.to_path_buf(),
None => match common::default_config_path() {
Some(p) if p.exists() => p,
_ => return Ok(FileConfig::default()),
},
};
let contents = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("failed to read config file {}: {e}", path.display()))?;
let mut config: FileConfig = toml::from_str(&contents)
.map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", path.display()))?;
expand_env(&mut config)?;
Ok(config)
}
fn expand_env(cfg: &mut FileConfig) -> anyhow::Result<()> {
if let Some(i) = cfg.identity.as_mut() {
expand_path(&mut i.data_dir, "identity.data_dir")?;
expand_str(&mut i.region, "identity.region")?;
}
if let Some(n) = cfg.network.as_mut() {
if let Some(urls) = n.relay_urls.as_mut() {
for (idx, url) in urls.iter_mut().enumerate() {
*url = expand_value(url, &format!("network.relay_urls[{idx}]"))?;
}
}
if let Some(d) = n.discovery.as_mut() {
expand_str(&mut d.pkarr_url, "network.discovery.pkarr_url")?;
expand_str(&mut d.dns_origin, "network.discovery.dns_origin")?;
if let Some(peers) = d.peers.as_mut() {
let mut entries: Vec<_> = peers.iter_mut().collect();
entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
for (id, peer) in entries {
expand_str(
&mut peer.relay_url,
&format!("network.discovery.peers[{id}].relay_url"),
)?;
for (idx, addr) in peer.addrs.iter_mut().enumerate() {
*addr = expand_value(
addr,
&format!("network.discovery.peers[{id}].addrs[{idx}]"),
)?;
}
}
}
}
}
if let Some(b) = cfg.blockchain.as_mut() {
expand_str(&mut b.rpc_url, "blockchain.rpc_url")?;
expand_path(&mut b.eth_keystore, "blockchain.eth_keystore")?;
expand_str(
&mut b.payment_pool_address,
"blockchain.payment_pool_address",
)?;
expand_str(
&mut b.capacity_bond_address,
"blockchain.capacity_bond_address",
)?;
expand_str(
&mut b.origin_assignment_address,
"blockchain.origin_assignment_address",
)?;
expand_str(
&mut b.publisher_registry_address,
"blockchain.publisher_registry_address",
)?;
expand_str(&mut b.slash_judge_address, "blockchain.slash_judge_address")?;
expand_str(
&mut b.slash_appeal_address,
"blockchain.slash_appeal_address",
)?;
expand_str(
&mut b.content_blacklist_address,
"blockchain.content_blacklist_address",
)?;
}
if let Some(c) = cfg.cache.as_mut() {
expand_path(&mut c.cache_dir, "cache.cache_dir")?;
if let Some(origin) = c.origin.as_mut() {
expand_origin(origin, "cache.origin")?;
}
if let Some(list) = c.origins.as_mut() {
for (idx, entry) in list.iter_mut().enumerate() {
let path_ctx = format!("cache.origins[{idx}]");
expand_origin(entry, &path_ctx)?;
}
}
expand_str(&mut c.user_agent, "cache.user_agent")?;
}
if let Some(o) = cfg.observability.as_mut() {
expand_str(&mut o.otlp_endpoint, "observability.otlp_endpoint")?;
}
Ok(())
}
fn expand_str(field: &mut Option<String>, ctx: &str) -> anyhow::Result<()> {
if let Some(s) = field.as_mut() {
*s = expand_value(s, ctx)?;
}
Ok(())
}
fn expand_origin(origin: &mut types::OriginConfig, prefix: &str) -> anyhow::Result<()> {
match origin {
types::OriginConfig::Http { url, .. } => {
*url = expand_value(url, &format!("{prefix}.url"))?;
}
types::OriginConfig::Fs { path } => {
let as_str = path.to_string_lossy();
let expanded = expand_value(&as_str, &format!("{prefix}.path"))?;
*path = PathBuf::from(expanded);
}
types::OriginConfig::S3(s3) => {
s3.bucket = expand_value(&s3.bucket, &format!("{prefix}.bucket"))?;
s3.region = expand_value(&s3.region, &format!("{prefix}.region"))?;
expand_str(&mut s3.endpoint_url, &format!("{prefix}.endpoint_url"))?;
expand_str(&mut s3.prefix, &format!("{prefix}.prefix"))?;
if let Some(creds) = s3.credentials.as_mut() {
match creds {
types::S3Credentials::Static {
access_key_id,
secret_access_key,
session_token,
} => {
expand_secret(
access_key_id,
&format!("{prefix}.credentials.access_key_id"),
)?;
expand_secret(
secret_access_key,
&format!("{prefix}.credentials.secret_access_key"),
)?;
if let Some(token) = session_token.as_mut() {
expand_secret(token, &format!("{prefix}.credentials.session_token"))?;
}
}
types::S3Credentials::DefaultChain { profile } => {
expand_str(profile, &format!("{prefix}.credentials.profile"))?;
}
}
}
}
}
Ok(())
}
fn expand_path(field: &mut Option<PathBuf>, ctx: &str) -> anyhow::Result<()> {
if let Some(p) = field.as_mut() {
let as_str = p.to_string_lossy();
let expanded = expand_value(&as_str, ctx)?;
*p = PathBuf::from(expanded);
}
Ok(())
}
fn expand_secret(field: &mut secret::SecretString, ctx: &str) -> anyhow::Result<()> {
let expanded = expand_value(field.expose(), ctx)?;
*field = secret::SecretString::new(expanded);
Ok(())
}
fn expand_value(raw: &str, ctx: &str) -> anyhow::Result<String> {
if !needs_expansion(raw) {
return Ok(raw.to_string());
}
let tilde_expanded = expand_tilde_prefix(raw, ctx)?;
expand_braces(&tilde_expanded, ctx)
}
fn needs_expansion(raw: &str) -> bool {
raw.contains("${") || raw.starts_with('~')
}
fn expand_tilde_prefix(raw: &str, ctx: &str) -> anyhow::Result<String> {
if raw == "~" {
let home = dirs::home_dir().ok_or_else(|| missing_home_err(ctx))?;
return Ok(home.to_string_lossy().into_owned());
}
let rest = raw.strip_prefix("~/").or_else(|| {
if cfg!(windows) {
raw.strip_prefix(r"~\")
} else {
None
}
});
if let Some(rest) = rest {
let home = dirs::home_dir().ok_or_else(|| missing_home_err(ctx))?;
return Ok(home.join(rest).to_string_lossy().into_owned());
}
Ok(raw.to_string())
}
fn missing_home_err(ctx: &str) -> anyhow::Error {
anyhow::anyhow!("config field `{ctx}` uses `~` but home directory is not available")
}
fn expand_braces(raw: &str, ctx: &str) -> anyhow::Result<String> {
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some((before, after)) = rest.split_once("${") {
out.push_str(before);
let (name, tail) = after.split_once('}').ok_or_else(|| {
anyhow::anyhow!("config field `{ctx}` has unterminated `${{` sequence")
})?;
let value = match std::env::var(name) {
Ok(v) => v,
Err(std::env::VarError::NotPresent) => {
anyhow::bail!("config field `{ctx}` references undefined env var `{name}`")
}
Err(std::env::VarError::NotUnicode(_)) => anyhow::bail!(
"config field `{ctx}` references env var `{name}` whose value is not valid UTF-8"
),
};
out.push_str(&value);
rest = tail;
}
out.push_str(rest);
Ok(out)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests;