decdn_common/config/mod.rs
1//! Configuration loading and resolution.
2//!
3//! Three-layer merge: CLI flags > TOML config file > built-in defaults.
4
5mod errors;
6pub mod resolved;
7pub mod secret;
8pub mod types;
9
10use std::path::{Path, PathBuf};
11
12use alloy::primitives::Address;
13use anyhow::Context;
14
15use errors::{IDENTITY_DATA_DIR, IDENTITY_REGION, one_section};
16
17use crate::cli::common::{self, expand_tilde};
18use crate::cli::run::RunArgs;
19use crate::redact::redact_userinfo;
20
21pub use errors::{ConfigDiagnostics, ConfigNotice, ConfigNoticeLevel};
22pub use resolved::{
23 LoadShedPolicyKind, ResolvedBlockchain, ResolvedCache, ResolvedConfig, ResolvedContent,
24 ResolvedDht, ResolvedDiscovery, ResolvedDiscoveryPeer, ResolvedIdentity, ResolvedLoadShed,
25 ResolvedNetwork, ResolvedObservability, ResolvedOrigin, ResolvedPayment, ResolvedProbe,
26 ResolvedReceipts, ResolvedS3Config, ResolvedS3Credentials, ResolvedSecurity,
27 ResolvedServeEconomics, ResolvedTinyLfu,
28};
29pub use types::FileConfig;
30
31/// Default QUIC bind port.
32const DEFAULT_BIND_PORT: u16 = 4433;
33
34/// The daemon's QUIC bind port as a command without `RunArgs` resolves it:
35/// `DECDN_BIND_PORT`, then `file_bind_port` (the config file's
36/// `network.bind_port`), then the default. The same precedence
37/// [`resolve_config`] applies, minus the `--bind-port` flag that only
38/// `decdn-node run` takes. An unparseable env value falls through to the file,
39/// as the daemon would reject it at bring-up anyway.
40#[must_use]
41pub fn configured_bind_port(file_bind_port: Option<u16>) -> u16 {
42 std::env::var("DECDN_BIND_PORT")
43 .ok()
44 .and_then(|v| v.trim().parse().ok())
45 .or(file_bind_port)
46 .unwrap_or(DEFAULT_BIND_PORT)
47}
48/// Default maximum cache size in megabytes (100 GB) — sized for the large-file
49/// (AI model) wedge, where a node holds many multi-GB shards.
50const DEFAULT_CACHE_SIZE_MB: u64 = 102_400;
51/// Default free disk kept unused on the `cache_dir` volume (8 GiB), defended by
52/// the eviction driver against any process (#1930). Sized so a node dropped onto
53/// an arbitrary machine leaves comfortable room for the OS, logs, and other
54/// services below the cache's reactive (soft-evict + GC-lagged) ceiling.
55pub const DEFAULT_DISK_HEADROOM_MB: u64 = 8192;
56/// Default largest single blob admitted (50 GB), when `max_blob_size_mb` is unset.
57/// Comfortably holds the largest model shard the chunked-manifest wedge delivers
58/// while capping the RAM the buffered miss tier spends on one pull. Clamped down to
59/// `cache_size_mb` at resolve time, so the `max_blob_size_mb <= cache_size_mb`
60/// invariant holds even when an operator shrinks the cache below this.
61const DEFAULT_MAX_BLOB_SIZE_MB: u64 = 51_200;
62/// Default rate per MB in USDC base units ($0.00001/MB).
63const DEFAULT_RATE_PER_MB: u64 = 10;
64/// Default Prometheus metrics port. Exposed publicly so `decdn node top`
65/// (issue #275) can fall back to the same number the daemon binds on
66/// without duplicating the constant.
67pub const DEFAULT_METRICS_PORT: u16 = 9090;
68/// Default metrics bind address (loopback). Operators in containerised
69/// deployments override to `0.0.0.0` via CLI/env/config.
70const DEFAULT_METRICS_BIND: std::net::IpAddr = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
71/// Default loopback admin HTTP port (`appendix-local-admin-http.md`). Exposed to the rest of
72/// the `node` crate so `decdn node <sub>` clients can fall back to the
73/// same default the server binds on, without duplicating the number.
74pub const DEFAULT_ADMIN_PORT: u16 = 9191;
75/// Default interval between RPC connectivity watchdog probes. `0`
76/// disables the watchdog; absent in config => this value.
77const DEFAULT_RPC_WATCHDOG_INTERVAL_SEC: u64 = 30;
78/// Minimum non-zero watchdog interval. Values below this would have
79/// the watchdog probing the RPC endpoint frequently enough to risk
80/// tripping provider rate limits or exhausting paid quotas.
81const MIN_RPC_WATCHDOG_INTERVAL_SEC: u64 = 10;
82/// Default chain-event poll interval (milliseconds). One knob, two unrelated
83/// consumers (see `ResolvedBlockchain::event_poll_interval_ms`): the
84/// `eth_getLogs` watcher tick cadence, and alloy's pending-transaction receipt
85/// heartbeat. 7000 ms matches alloy's non-local default, so live-RPC load is
86/// unchanged from before #1011; it also overrides alloy's 250 ms localhost
87/// default, which the receipt heartbeat would otherwise use against a dev anvil.
88const DEFAULT_EVENT_POLL_INTERVAL_MS: u64 = 7000;
89/// Minimum chain-event poll interval. The node polls chain events with
90/// `eth_getLogs` and issues no `eth_getFilterChanges` anywhere. At 250 ms the node's
91/// ~6 watcher loops would each scan `[cursor, head]` four times a second against
92/// one endpoint, tripping provider rate limits and burning paid quota just as the
93/// original flood did. The same floor bounds the receipt-heartbeat consumer.
94/// 250 ms is kept as the floor because it is alloy's own localhost cadence: a dev
95/// anvil can still be driven at the fastest interval alloy itself considers sane.
96const MIN_EVENT_POLL_INTERVAL_MS: u64 = 250;
97/// Default ceiling on the block span of one chain-watcher `eth_getLogs`
98/// request. A node that resumes far behind head scans the gap in windows of at
99/// most this many blocks, because one unbounded request would exceed the range
100/// and result caps that RPC providers enforce. 10 000 clears the common
101/// paid-tier range caps. It bounds the block span, not the result count, so a
102/// dense window can still trip a result cap. A provider with a lower cap (free
103/// tiers go down to 10 blocks) or a result cap rejects the request, and the
104/// poller then halves its window until the provider accepts it.
105pub const DEFAULT_GET_LOGS_MAX_BLOCK_SPAN: u64 = 10_000;
106
107/// Default accrued-claim redemption threshold: 1 USDC (`1_000_000` `µUSDC`).
108/// At this size the ~$0.10 redeem gas is a few percent of the redeemed
109/// amount while bounding unsettled exposure to ~1 USDC per pool (#327).
110const DEFAULT_REDEEM_THRESHOLD_MICRO_USDC: u64 = 1_000_000;
111/// Default redemption chunk size: 300 vouchers per `redeemMany` transaction.
112/// The benchmarks in `contracts/test/PaymentPool.t.sol` pin two marginals for
113/// one added cold-lane voucher: ~34.5k gas when the signer is already
114/// registered (`test_redeemMany_gas_N…`), and ~63.4k gas when the signer is
115/// first-time and its capability registers in the same call
116/// (`test_redeemMany_gas_firstTime_N…`). A high-fan-out node serving one-time
117/// payers hits the first-time case on every lane, so ~63.4k is the sizing
118/// figure. Registration cost is bounded: the node only redeems capabilities
119/// whose owner signature it verified off-chain against an EOA pool owner
120/// (`ClientHandler::intake_capability` rejects contract/ERC-1271 owners), so no
121/// unbounded owner-signature verification enters a `redeemMany`. Against
122/// Arbitrum One's block gas limit (~32M gas, an external reference — confirm
123/// live via `eth_getBlockByNumber` before a deploy decision), 300 first-time
124/// lanes cost ~19M gas: they fit a full block (the first-time ceiling is ~504
125/// lanes) but exceed a conservative half-block budget (~252 lanes). The
126/// reactive halve-retry in `submit_chunk` splits any chunk that a live block
127/// still rejects, so 300 stays safe with that backstop.
128const DEFAULT_REDEEM_MAX_VOUCHERS_PER_TX: u64 = 300;
129/// Default redeemer self-tick interval: 300s (5 min). Kept well below the
130/// hourly expiry sweep so accrued earnings are withdrawn promptly without
131/// leaning on the advisory per-voucher hints (#327, #751).
132///
133/// `pub` so a `decdn-node` client handler built without config, and `decdn pool
134/// assign`, take the same interval for the capability-expiry margin.
135pub const DEFAULT_REDEEM_INTERVAL_SECS: u64 = 300;
136/// Time the node's redeemer allows a `redeemMany` to land on chain: 120 s. The
137/// redeemer skips a lane whose capability expires, or whose pool's close deadline
138/// falls, within this slack of now: a transaction that lands past the expiry pays
139/// 0, and one that lands past the deadline reverts `PoolClosed` for its whole
140/// batch.
141pub const REDEEM_LANDING_SLACK_SECS: u64 = 120;
142/// The node's capability-expiry margin in seconds for a redeem interval: one
143/// interval plus [`REDEEM_LANDING_SLACK_SECS`] (ADR 003 §Revocation). Inside the
144/// margin the node accepts no voucher and no preimage for the capability
145/// ([`inside_capability_expiry_margin`]), so the lane's claim is final. The
146/// redeemer sweeps the lane one second after the margin starts, and that sweep
147/// has one interval to start the redemption and the landing slack to land it.
148#[must_use]
149pub const fn capability_expiry_margin_secs(redeem_interval_secs: u64) -> u64 {
150 redeem_interval_secs.saturating_add(REDEEM_LANDING_SLACK_SECS)
151}
152/// Whether the Unix second `now` falls inside the expiry margin of a capability
153/// that expires at `expiry`: `now + margin_secs >= expiry`. An `expiry` of `0`
154/// means "not tracked" and is never inside the margin.
155#[must_use]
156pub const fn inside_capability_expiry_margin(expiry: u64, margin_secs: u64, now: u64) -> bool {
157 expiry != 0 && now.saturating_add(margin_secs) >= expiry
158}
159/// Upper bound on the redeemer self-tick interval: 6h (`21_600s`). The sweep is the
160/// node's only defense against an owner's grace-window close — it must run several
161/// times inside the 48h grace floor so accrued vouchers redeem before the owner
162/// can `reclaim`. 6h leaves 8× headroom for tx landing and retries. An operator
163/// who wants a laxer cadence to shave gas builds from source or opens an issue.
164const MAX_REDEEM_INTERVAL_SECS: u64 = 6 * 60 * 60;
165/// Default pool-open and refill-target deposit every top-up restores the pool
166/// balance toward: 10 USDC (`10_000_000` `µUSDC`). ADR 003 § Deposit Economics
167/// recommends a 10 USDC practical minimum (gas overhead ~2.3%); it is a
168/// client-side recommendation, not an on-chain floor, so the resolved value is
169/// escrowed as configured (#744).
170///
171/// `pub` so `decdn-cli`'s `--working-deposit-micro-usdc` resolution shares this
172/// single source of truth with the config resolver rather than duplicating it.
173pub const DEFAULT_BUYER_WORKING_DEPOSIT_MICRO_USDC: u64 = 10_000_000;
174/// Default refundable floor `M`: 1 USDC (`1_000_000` `µUSDC`). ADR 003 §
175/// Sizing defines `M = k·ρ·B·Δ` (redeem cadence × rate × credit window ×
176/// round-trip slack); this is a conservative static value at the
177/// redeem-threshold scale, picked so the node keeps enough headroom to cover
178/// one in-flight credit window even against a buyer that stops topping up.
179/// Precise sizing per ADR 003 is governance/ops policy, not a build-time
180/// constant.
181pub const DEFAULT_POOL_MIN_REMAINING_DEPOSIT_MICRO_USDC: u64 = 1_000_000;
182/// Default per-signer LIVE concurrency cap `k`, in ramp-start credit windows: `8`.
183/// The most live un-vouchered floor reservation any one capability signer may hold
184/// against a pool is `k · one credit window`, underneath the pool-wide `remaining − M`
185/// ceiling (ADR 003 § Pool solvency, per-signer floor isolation). The window is the
186/// unit rationed — honest need is `concurrent un-vouchered streams × one window`
187/// regardless of pool size — so an absolute count fits it directly. It carries no
188/// permanent memory: it recycles as each stream pays, and never penalizes a signer for
189/// quitting.
190pub const DEFAULT_POOL_FLOOR_SIGNER_LIVE_WINDOWS: u64 = 8;
191/// Default global cap on concurrent in-flight QUIC handler tasks.
192const DEFAULT_MAX_CONCURRENT_HANDLERS: u32 = 256;
193/// Default per-source rate-limit refill (cells/second). A single source
194/// may legitimately host a fleet of clients; the value is generous
195/// enough to absorb that without rejecting well-behaved peers.
196const DEFAULT_PER_SOURCE_RATE_PER_SEC: f64 = 100.0;
197/// Default per-source burst — 2× the steady-state rate gives well-behaved
198/// peers headroom for jitter/clumping that would otherwise produce
199/// spurious rejections at burst == rate.
200const DEFAULT_PER_SOURCE_BURST: u32 = 200;
201/// Default hard cap on tracked source entries in the keyed limiter.
202const DEFAULT_MAX_TRACKED_SOURCES: usize = 4096;
203/// Egress ceiling off unless the operator sets one.
204const DEFAULT_LOAD_SHED_EGRESS_BUDGET_MBPS: u64 = 0;
205/// Load-shed concurrency high-water mark (start shedding misses at/above).
206const DEFAULT_LOAD_SHED_SERVES_HIGH: u32 = 256;
207/// Load-shed concurrency low-water mark (resume at/below).
208const DEFAULT_LOAD_SHED_SERVES_LOW: u32 = 192;
209/// Default per-client concurrent-serve cap under pressure.
210const DEFAULT_LOAD_SHED_PER_CLIENT_CAP: u32 = 32;
211/// Default sustained per-peer (`NodeId`) rate for `cdn/dht/v1` inbound
212/// (ADR 022 §DHT Rate Limiting). Conservative ceiling on adversarial load,
213/// not a steady-state target.
214const DEFAULT_DHT_PER_PEER_RATE_PER_SEC: f64 = 20.0;
215/// Default per-peer burst capacity for `cdn/dht/v1`. ADR 022 default: 40.
216const DEFAULT_DHT_PER_PEER_BURST: u32 = 40;
217/// Default sustained per-IP rate for `cdn/dht/v1`. ADR 022 default: 100.
218const DEFAULT_DHT_PER_IP_RATE_PER_SEC: f64 = 100.0;
219/// Default per-IP burst capacity for `cdn/dht/v1`. ADR 022 default: 200.
220const DEFAULT_DHT_PER_IP_BURST: u32 = 200;
221/// Default sustained global rate for `cdn/dht/v1`. ADR 022 default: 1000.
222const DEFAULT_DHT_GLOBAL_RATE_PER_SEC: f64 = 1000.0;
223/// Default global burst capacity for `cdn/dht/v1`. ADR 022 default: 2000.
224const DEFAULT_DHT_GLOBAL_BURST: u32 = 2000;
225/// Default hard cap on tracked per-IP entries in the DHT keyed limiter
226/// (#645). Mirrors `DEFAULT_MAX_TRACKED_SOURCES` for the dispatch layer.
227const DEFAULT_DHT_MAX_TRACKED_PER_IP: usize = 4096;
228/// Default hard cap on tracked per-peer (`NodeId`) entries in the DHT
229/// keyed limiter (#645).
230const DEFAULT_DHT_MAX_TRACKED_PER_PEER: usize = 4096;
231/// Default sustained per-peer (`NodeId`) rate for `cdn/probe/v1` inbound
232/// (ADR 005 §Probe rate limiting). Tighter than the DHT layer because a
233/// probe is unauthenticated and cheaper to flood.
234const DEFAULT_PROBE_PER_PEER_RATE_PER_SEC: f64 = 5.0;
235/// Default per-peer burst capacity for `cdn/probe/v1`. ADR 005 default: 5.
236const DEFAULT_PROBE_PER_PEER_BURST: u32 = 5;
237/// Default sustained per-IP rate for `cdn/probe/v1`. ADR 005 default: 50.
238const DEFAULT_PROBE_PER_IP_RATE_PER_SEC: f64 = 50.0;
239/// Default per-IP burst capacity for `cdn/probe/v1`. ADR 005 default: 200.
240const DEFAULT_PROBE_PER_IP_BURST: u32 = 200;
241/// Default sustained global rate for `cdn/probe/v1`. ADR 005 default: 1000.
242const DEFAULT_PROBE_GLOBAL_RATE_PER_SEC: f64 = 1000.0;
243/// Default global burst capacity for `cdn/probe/v1`. ADR 005 default: 2000.
244const DEFAULT_PROBE_GLOBAL_BURST: u32 = 2000;
245/// Default hard cap on tracked per-IP entries in the probe keyed limiter
246/// (#645). Mirrors `DEFAULT_DHT_MAX_TRACKED_PER_IP`.
247const DEFAULT_PROBE_MAX_TRACKED_PER_IP: usize = 4096;
248/// Default hard cap on tracked per-peer (`NodeId`) entries in the probe
249/// keyed limiter (#645).
250const DEFAULT_PROBE_MAX_TRACKED_PER_PEER: usize = 4096;
251/// Default interval between iroh-blobs GC sweeps in seconds (#518). Five
252/// minutes balances the hostile-origin amplification window against the
253/// per-sweep cost of walking the blob list. The window matters because
254/// a single failed pull-through orphans up to `max_blob_size_mb`
255/// (one upload-per-request bound, not multiplied by the interval), but
256/// a stream of failed requests inside one interval compounds: total
257/// leak before reclaim is bounded by `requests_in_window * max_blob_size_mb`.
258/// Tuning the interval down shrinks that window. Operators on lean disks
259/// can tune lower; setting to `0` disables the periodic sweep entirely.
260pub const DEFAULT_GC_INTERVAL_SEC: u64 = 300;
261
262/// Default interval between origin-held-index rescans in seconds (#1130). A
263/// minute balances "drop a file, it's fetchable soon" against directory-walk
264/// cost; operators indexing a large fs origin can raise it, and `0` disables
265/// the periodic rescan (startup + reload still run one).
266pub const DEFAULT_FS_RESCAN_INTERVAL_SEC: u64 = 60;
267
268// Live-origin probe memo defaults (#1130 pt3). Canonical u64 values live in
269// `decdn-config-types` so `decdn-cache` (which wraps them as `Duration`s) and
270// this crate agree from a single source; re-exported here so the existing
271// `crate::config::DEFAULT_ORIGIN_PROBE_*` paths keep resolving.
272pub use decdn_config_types::{
273 DEFAULT_ORIGIN_PROBE_FAULT_TTL_SEC, DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY,
274 DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL_SEC, DEFAULT_ORIGIN_PROBE_TIMEOUT_MS,
275 DEFAULT_ORIGIN_PROBE_TTL_SEC,
276};
277
278/// Fallback for `cache.relay_foreign_namespaces` (#1759) on a node with no
279/// origin configured. The resolver's effective default is role-derived, not
280/// this flat constant: an origin node (a backend is configured) defaults to
281/// origin-only (`false`) — an origin is not a general proxy — while a
282/// no-origin node is a pure relay edge and falls back to this `true`. An
283/// operator sets the field explicitly to override either default.
284pub const DEFAULT_RELAY_FOREIGN_NAMESPACES: bool = true;
285
286/// Positive-hit TTL for the lazy origin directory cache: how long a resolved,
287/// non-empty `getOrigins(namespaceId)` set is served before a re-read. Bounds
288/// origin-set staleness (there is no event tail); 5 min matches the negative
289/// probe cache order of magnitude.
290pub const DEFAULT_ORIGIN_DIRECTORY_POSITIVE_TTL_SEC: u64 = 300;
291/// Negative-hit TTL: how long "this namespace has no origins / does not
292/// exist" is cached. Shorter than the positive TTL so a namespace that later
293/// gains an origin becomes reachable within one short window — and long
294/// enough that a flood of bogus/attacker-chosen request namespaces cannot
295/// force a `getOrigins` RPC per request. Namespace creation is permissionless
296/// and free (`PublisherRegistry.createNamespace`), so this is the `DoS` bound.
297pub const DEFAULT_ORIGIN_DIRECTORY_NEGATIVE_TTL_SEC: u64 = 30;
298/// Max distinct namespaces held in the lazy origin cache (LRU eviction).
299/// Bounds memory against the permissionless global namespace count — the
300/// cache only ever holds namespaces this node was actually asked to resolve.
301pub const DEFAULT_ORIGIN_DIRECTORY_CACHE_CAPACITY: usize = 4096;
302
303/// Default LRU eviction driver high-water percent of `cache.cache_size_mb`
304/// (#1173, ADR 040). Above this
305/// fraction the driver actively evicts.
306pub const DEFAULT_EVICTION_HIGH_WATER_PCT: u64 = 90;
307/// Hard bounds `[60, 95]` for [`DEFAULT_EVICTION_HIGH_WATER_PCT`].
308pub const EVICTION_HIGH_WATER_PCT_BOUNDS: (u64, u64) = (60, 95);
309/// Default LRU eviction driver target percent of `cache.cache_size_mb` — the
310/// driver evicts down to this fraction before idling (#1173).
311pub const DEFAULT_EVICTION_TARGET_PCT: u64 = 80;
312/// Hard bounds `[40, 90]` for [`DEFAULT_EVICTION_TARGET_PCT`].
313pub const EVICTION_TARGET_PCT_BOUNDS: (u64, u64) = (40, 90);
314/// Structural hysteresis gap: `eviction_target_pct` must be at least this many
315/// points below `eviction_high_water_pct` (#1173).
316pub const EVICTION_HYSTERESIS_GAP_PCT: u64 = 5;
317/// Default LRU eviction driver max candidates removed per tick (#1173).
318pub const DEFAULT_EVICTION_PER_SWEEP_BUDGET: u64 = 16;
319/// Hard bounds `[1, 256]` for [`DEFAULT_EVICTION_PER_SWEEP_BUDGET`].
320pub const EVICTION_PER_SWEEP_BUDGET_BOUNDS: (u64, u64) = (1, 256);
321/// Default LRU eviction driver wakeup cadence in seconds (#1173).
322pub const DEFAULT_EVICTION_TICK_SECS: u64 = 1;
323/// Hard bounds `[1, 60]` for [`DEFAULT_EVICTION_TICK_SECS`].
324pub const EVICTION_TICK_SECS_BOUNDS: (u64, u64) = (1, 60);
325
326/// Default `cache.eviction_policy` (ADR 040). Reproduces pre-ADR-040 behavior.
327pub const DEFAULT_EVICTION_POLICY: &str = "lru";
328/// Default `cache.admission_policy` (ADR 040). Reproduces pre-ADR-040 behavior.
329pub const DEFAULT_ADMISSION_POLICY: &str = "always";
330/// Default `cache.tinylfu.sketch_bytes` (ADR 040).
331pub const DEFAULT_TINYLFU_SKETCH_BYTES: usize = 262_144;
332/// Floor for `cache.tinylfu.sketch_bytes` (ADR 040 §Configuration surface).
333///
334/// One `u8` per counter over the sketch's four rows means `cols = bytes / 4`,
335/// so this floor buys 4096 columns.
336///
337/// It is set from an over-report target. A count-min sketch reads a key hotter
338/// than it is when every one of its four row counters also holds some other
339/// key's count — the polluting keys need not be the same one across rows, so
340/// the far rarer "one twin collides in all four rows" event does not bound the
341/// error. For `N` live hashes over `cols` columns the rate is
342/// `(1 - e^(-N / cols))^4`. Sharding cancels out of that expression: a shard
343/// divides the columns and the hashes in the same proportion, so the width
344/// alone sets the accuracy.
345///
346/// Because the rate turns only on `N / cols`, a target rate fixes a hash count
347/// proportional to the width — about `0.38 * cols` live hashes hold it under
348/// one percent. This floor is therefore good for roughly `1_500` hashes and
349/// [`DEFAULT_TINYLFU_SKETCH_BYTES`] for roughly `25_000`; a node holding more
350/// needs a proportionally wider sketch, not a fixed step up. Counts are an
351/// upper bound, since the sketch counts every hash it observes between
352/// halvings rather than only the resident ones.
353pub const MIN_TINYLFU_SKETCH_BYTES: usize = 16_384;
354// A default below its own floor would make every node that ships without a
355// `[cache.tinylfu]` block fail to start.
356const _: () = assert!(DEFAULT_TINYLFU_SKETCH_BYTES >= MIN_TINYLFU_SKETCH_BYTES);
357/// Default `cache.tinylfu.promotion_threshold` (ADR 040).
358pub const DEFAULT_TINYLFU_PROMOTION_THRESHOLD: u32 = 2;
359/// Default `cache.tinylfu.probation_target_pct` (ADR 040).
360pub const DEFAULT_TINYLFU_PROBATION_TARGET_PCT: u64 = 10;
361/// Hard bounds `[1, 50]` for [`DEFAULT_TINYLFU_PROBATION_TARGET_PCT`].
362pub const TINYLFU_PROBATION_TARGET_PCT_BOUNDS: (u64, u64) = (1, 50);
363/// Default `cache.tinylfu.aging_halflife_sec` (ADR 040). Reserved: resolved
364/// and stored but not currently consulted by the shipped sketch.
365pub const DEFAULT_TINYLFU_AGING_HALFLIFE_SEC: u64 = 600;
366
367/// Default `cache.serve_economics.policy` (ADR 041).
368pub const DEFAULT_SERVE_ECONOMICS_POLICY: &str = "margin";
369/// Default `cache.serve_economics.discount` (0.5), expressed in basis points.
370pub const DEFAULT_SERVE_ECONOMICS_DISCOUNT_BPS: u32 = 5000;
371/// Default `cache.serve_economics.n_max` (ADR 041).
372pub const DEFAULT_SERVE_ECONOMICS_N_MAX: u32 = 64;
373/// Default `cache.serve_economics.warming_budget` (ADR 041). $5 in USDC
374/// 6-decimal base units. The per-source net-P&L ledger is debited the full
375/// upstream buy cost on a speculative pull and credited the realized operator
376/// margin on each serve, so a one-hit blob nets the fee skim (~`0.4·P_sell`) as
377/// its lasting loss; on a $0.01/GB flat mesh this `$5` bounds roughly 1,250 GB of
378/// unrecovered one-hit warming per source before it drops to the profit floor.
379pub const DEFAULT_SERVE_ECONOMICS_WARMING_BUDGET: u64 = 5_000_000;
380/// Default `cache.serve_economics.warming_refill` (ADR 041). ~$5/day: honest
381/// sources recover their allowance daily; sustained grief is bounded to
382/// ≤ $5/day/source. Base units/sec ≈ `5_000_000 / 86_400`.
383pub const DEFAULT_SERVE_ECONOMICS_WARMING_REFILL: u64 = 58;
384
385/// Default EIP-712 `chainId` for the `slash_sig` domain separator (ADR 014).
386/// Arbitrum Sepolia — the initial network target; matches the chain id bound
387/// on the runtime `PrivateKeySigner` (`decdn_incentive::eth_identity`). To
388/// target a different chain, override via `blockchain.chain_id` (see
389/// `appendix-poc-production-seams.md` §Seam 8).
390pub const DEFAULT_CHAIN_ID: u64 = 421_614;
391
392/// Default seconds between the blacklist watcher's periodic re-enumeration +
393/// re-scope pass (ADR 011 § Node Behavior's 10-minute cadence).
394pub const DEFAULT_CONTENT_BLACKLIST_POLL_INTERVAL_SEC: u64 = 600;
395
396/// Default seconds the node may go without a successful chain read before the
397/// serve and probe paths refuse (ADR 011 § Serving while chain-stale). Thirty
398/// minutes: comfortably inside the one-hour compliance-window floor, so a node
399/// stops serving well before an outage-hidden takedown could cross into
400/// slashable territory, while short RPC blips never take the node dark.
401pub const DEFAULT_CHAIN_STALENESS_GRACE_SEC: u64 = 1800;
402
403/// Default seconds between authoritative `FeeRouter.getShares()` re-reads by
404/// the fee-shares watcher (ADR 041 / ADR 016 § Tunable Economics) — the
405/// safety-net cadence alongside the `SharesUpdated` event subscription. One
406/// hour.
407pub const DEFAULT_FEE_SHARES_POLL_INTERVAL_SEC: u64 = 3600;
408
409/// Default maximum concurrently held (eviction-exempt) blobs for the
410/// probe-triggered hold (ADR 005 §Hold budget, #318). Per-blob holds: many
411/// peers probing one hash share a single slot. Re-exported from the
412/// `decdn_config_types` leaf crate (its canonical home, #578) so
413/// the config default and the cache engine's own default (used by
414/// direct `CacheEngine::open` callers) cannot drift apart.
415pub const DEFAULT_MAX_PROBE_HOLDS: usize = decdn_config_types::DEFAULT_MAX_PROBE_HOLDS;
416
417/// Default probe-hold slots reserved for the stake lane (#757). `0` keeps
418/// the stake-lane reservation off by default, so a node that does not opt in
419/// shares every probe-hold slot across all peers — the reservation is strictly
420/// operator opt-in.
421pub const DEFAULT_STAKE_LANE_RESERVED_HOLDS: usize = 0;
422
423/// Default providers probed before ranking on a node-to-node cache-miss pull
424/// (#831). Five is enough to find a healthy upstream in a tens-of-nodes
425/// network without spending the miss-latency budget on a wide probe fan-out.
426pub const DEFAULT_NODE_PULL_PROBE_FANOUT: usize = 5;
427/// Default wall-clock bound (seconds) on the STREAM-OPEN stage of a single upstream
428/// pull during a node-to-node cache-miss fill (#831) — connect, handshake, signed
429/// `StreamResponse`. Matches the integration-test budget; a slow upstream is abandoned
430/// for the next ranked candidate at this deadline.
431///
432/// It does NOT cover the buyer pool open, which precedes it on its own 5 s budget
433/// (`POOL_OPEN_CALLER_BUDGET`), nor the streaming that follows it, which is bounded by
434/// the throughput floor ([`DEFAULT_NODE_PULL_STALL_WINDOW_SEC`]). All three are sequential
435/// stages of ONE candidate attempt, and the node derives the overall pull-through deadline
436/// as `MAX_PROVIDER_ATTEMPTS × (pool open + this + window) + a fixed discovery allowance`,
437/// so the fallback loop can reach every ranked candidate before the serving path gives up
438/// (#859).
439///
440/// Raising this to give a slow L2 more room does nothing: that is the pool open, on the
441/// budget named above.
442pub const DEFAULT_NODE_PULL_TIMEOUT_SEC: u64 = 20;
443/// Default THROUGHPUT-FLOOR window (seconds) on the streaming stage of an upstream pull
444/// (#1797). Bytes are counted off the QUIC stream sub-frame, so it trips only when
445/// throughput falls below the floor — never because a blob is large or a frame is big.
446///
447/// Set equal to [`DEFAULT_NODE_PULL_TIMEOUT_SEC`] because both answer the same
448/// question ("how long do we wait on an unresponsive upstream?"), just at
449/// different stages; they are separate knobs because only one of them can be
450/// safely raised for large content.
451///
452/// Raising it is not free, even though it does not scale with blob size. A candidate that
453/// goes SILENT costs one full window of this before the pull abandons it, and
454/// `outer_pull_deadline` must budget that window for each of `MAX_PROVIDER_ATTEMPTS`
455/// candidates — otherwise a single silent peer eats the whole deadline and the fallback
456/// loop never reaches the others (#859, and the reason this knob is an argument to that
457/// function). So each second added here adds ~3 to the worst-case wait a client can see on
458/// a total miss: 167.5 s at defaults.
459pub const DEFAULT_NODE_PULL_STALL_WINDOW_SEC: u64 = 20;
460/// Default minimum sustained upstream throughput (bytes/sec) over
461/// [`DEFAULT_NODE_PULL_STALL_WINDOW_SEC`] (#1797). At ~40× a 100 B/s drip and far below any
462/// honest link, it catches a slow-drip wedge without tripping on a genuinely slow client.
463/// `0` disables the throughput test and leaves pure idle detection (one byte per window).
464pub const DEFAULT_NODE_PULL_MIN_THROUGHPUT_BPS: u64 = 4096;
465
466/// Default downstream credit-window ceiling (ADR 003 §Credit window): 64 MiB. A
467/// stream's window ramps from one chunk toward this cap in proportion to what
468/// the stream has already paid; a fully-ramped high-bandwidth lane runs
469/// link-bound within this bound, while a non-paying lane stays pinned at the
470/// chunk floor. Node-local policy, floored to one chunk by the serve loop.
471pub const DEFAULT_CREDIT_MAX: u64 = 64 * 1024 * 1024;
472/// Default ramp divisor (ADR 003 §Credit window): 2. The credit window is at most
473/// `paid / credit_ramp_divisor`, so the node's unbilled egress on a stream never
474/// exceeds half the revenue the stream has already confirmed. Lower ramps faster;
475/// `0` opens the full [`DEFAULT_CREDIT_MAX`] from the first byte.
476pub const DEFAULT_CREDIT_RAMP_DIVISOR: u64 = 2;
477/// Default serve-path wire-frame target (ADR 005 §`cdn/client/v1`): 1 MiB, matching
478/// the payment quantum so a fully-ramped stream sends about one frame per priced
479/// chunk. Node-local policy that travels on no message: the payer accepts any
480/// non-empty frame, and neither the bao codec's chunk groups nor the payment meter
481/// is defined over frame boundaries. Larger frames cost proportionally less
482/// per-frame CPU per byte served, and one payment interval is both the default and
483/// the maximum — a frame never crosses a payment-chunk boundary. The serve loop also
484/// clamps each frame to the credit window's remaining room, so this is a ceiling
485/// rather than an exact size.
486pub const DEFAULT_FRAME_TARGET_BYTES: u64 = decdn_protocol::CHUNK_BYTES;
487/// Default background flush period in milliseconds when
488/// `payment.voucher_commit_interval_ms` is unset (ADR 003 §Off-chain voucher
489/// state persistence): 5 s.
490///
491/// The node advances each lane's voucher watermark in memory on verify and
492/// mirrors the working set to the redb lane store on this interval — one fsynced
493/// transaction covering every dirty lane. A crash loses at most one interval of
494/// *frontier*, which is safe to lose: an honest client resumes forward and an
495/// un-redeemed replay is still on-chain-payable. The redeemed watermark is
496/// floored separately by the pre-redeem flush, so this interval trades only
497/// throughput-nines against seconds of harmless frontier replay. Must be `> 0`.
498/// Node-local policy, not a wire or governance parameter.
499pub const DEFAULT_VOUCHER_COMMIT_INTERVAL_MS: u64 = 5_000;
500
501/// Default size at which the download-receipt log rotates (#802): 128 MiB.
502/// With the default `retained_files` this bounds the audit log to ~640 MiB
503/// of `data_dir` while still keeping a multi-hundred-MiB delivery history.
504pub const DEFAULT_RECEIPT_MAX_FILE_BYTES: u64 = 128 << 20;
505/// Floor for `receipts.max_file_bytes` (#802): 1 MiB. Below this, rotation
506/// would churn near-constantly at the ~1-line-per-MiB-delivered write rate.
507pub const MIN_RECEIPT_MAX_FILE_BYTES: u64 = 1 << 20;
508/// Ceiling for `receipts.max_file_bytes` (#802): 1 GiB. A single live file
509/// larger than this defeats the point of bounding `data_dir` growth.
510pub const MAX_RECEIPT_MAX_FILE_BYTES: u64 = 1 << 30;
511/// Default number of rotated receipt-log backups retained (#802).
512pub const DEFAULT_RECEIPT_RETAINED_FILES: u32 = 4;
513/// Ceiling for `receipts.retained_files` (#802). Generous; the total disk
514/// bound is `(retained_files + 1) * max_file_bytes`.
515pub const MAX_RECEIPT_RETAINED_FILES: u32 = 100;
516/// Canonical filename of the download-receipt audit log within `data_dir`
517/// (#802). The path is derived (`data_dir`/this), not a configurable field;
518/// the daemon writer and the `config validate` summary both reference this so
519/// the surfaced path can't drift from where the log actually lands.
520pub const RECEIPT_LOG_FILE: &str = "download_receipts.jsonl";
521
522/// Load config from file (if present) and merge with CLI args.
523///
524/// CLI args take precedence over file values; defaults fill gaps.
525///
526/// Returns the resolved config together with every [`ConfigNotice`] the
527/// resolvers recorded. Notices are non-fatal by construction, and the caller
528/// renders them: the daemon replays them through `tracing` once `init_tracing`
529/// has installed a subscriber, `decdn config validate` prints them in its
530/// summary, and `decdn node doctor` turns them into findings. Nothing here
531/// writes to a sink: no subscriber exists yet in the daemon, the CLI links
532/// none at all, and stderr is not the stream an operator is reading.
533///
534/// # Errors
535///
536/// Returns an error if:
537/// - The config file exists but cannot be read or parsed.
538/// - A required field (`rpc_url`, `payment_pool_address`,
539/// `capacity_bond_address`) is not provided by any source.
540/// - The home directory cannot be determined for default paths.
541pub fn resolve_config(
542 config_path: Option<&Path>,
543 cli: &RunArgs,
544) -> anyhow::Result<(ResolvedConfig, Vec<ConfigNotice>)> {
545 // `load_file_config` stays fail-fast: a file we could not read, parse,
546 // or env-expand never produced a `FileConfig`, so there is nothing to
547 // validate. Everything *after* this accumulates into one `bag` so an
548 // operator sees every problem in a single pass.
549 let file = load_file_config(config_path)?;
550
551 let mut bag = ConfigDiagnostics::new();
552
553 let identity = resolve_identity_into(&cli.identity, file.identity.as_ref(), &mut bag);
554 // A region that was supplied but failed `normalize_region` is already
555 // reported under `identity.region`; the cross-section publish check
556 // must not also fire (see `ensure_region_when_publishing_global_into`).
557 // A data_dir we could not determine becomes a `/nonexistent` placeholder
558 // (see `resolve_identity_into`); the keystore/cache-dir derived from it
559 // would surface as a "cannot access" cascade on top of the real
560 // `identity.data_dir` problem, so downstream resolvers skip path-existence
561 // checks driven off it when `data_dir_valid` is false. The placeholder
562 // never escapes `resolve_config`: `bag.into_result()?` below fails before
563 // the materialized `ResolvedConfig` is returned to the caller.
564 let data_dir_valid = !bag.has_field(IDENTITY_DATA_DIR);
565 let network = resolve_network_into(&cli.network, file.network.as_ref(), &mut bag);
566 let blockchain = resolve_blockchain_into(
567 &cli.blockchain,
568 file.blockchain.as_ref(),
569 &identity.data_dir,
570 data_dir_valid,
571 &mut bag,
572 );
573 let cache = resolve_cache_into(
574 &cli.cache,
575 file.cache.as_ref(),
576 &identity.data_dir,
577 &mut bag,
578 );
579 let payment = resolve_payment_into(&cli.payment, file.payment.as_ref(), &mut bag);
580 let observability =
581 resolve_observability_into(&cli.observability, file.observability.as_ref(), &mut bag);
582 let security = resolve_security_into(file.security.as_ref(), &mut bag);
583 let load_shed = resolve_load_shed_into(file.load_shed.as_ref(), &mut bag);
584 let dht = resolve_dht_into(file.dht.as_ref(), &mut bag);
585 let probe = resolve_probe_into(file.probe.as_ref(), &mut bag);
586 let receipts = resolve_receipts_into(file.receipts.as_ref(), &mut bag);
587 let content = resolve_content_into(file.content.as_ref(), &mut bag);
588
589 validate_port_layout_into(&network, &observability, &mut bag);
590 ensure_no_hash_pinned_and_denied_into(&cache, &content, &mut bag);
591
592 // Drain before `into_result` consumes the bag. Notices are dropped on the
593 // error path deliberately: a config that does not resolve is not the one
594 // the operator is running, so its notices would describe nothing live.
595 let notices = bag.take_notices();
596 bag.into_result()?;
597
598 Ok((
599 ResolvedConfig {
600 identity,
601 network,
602 blockchain,
603 cache,
604 payment,
605 observability,
606 security,
607 load_shed,
608 dht,
609 probe,
610 receipts,
611 content,
612 },
613 notices,
614 ))
615}
616
617/// Reject a hash that is simultaneously **pinned** (`cache.pinned_hashes`) and
618/// **denied** (`content.denied_hashes`, which the serve gate refuses) —
619/// contradictory operator intent. A plain pinned hash is held forever and
620/// excluded from LRU eviction; a denied one is unservable, so pinning it means
621/// paying storage for content the node will never serve. (At runtime the cache's
622/// "deny wins over pin" carve-out — see [`decdn_config_types::DeniedHashes`] and
623/// `CacheEngine::eviction_candidates` — makes a denied + pinned hash reclaimable
624/// under space pressure, so the bytes are not *strictly* held forever; that is a
625/// safety net, not a reason to configure the combination.) An operator almost
626/// certainly meant one or the other.
627///
628/// Cross-section because the two lists live in different config sections, so it
629/// belongs at the resolver boundary alongside the port-layout check rather than
630/// inside either single-section resolver.
631///
632/// Scope: this is a *static local-config* check only. The on-chain governance
633/// deny-set (fed at runtime through `CacheEngine::set_chain_denied`) can still
634/// collide with a pinned hash after startup; no static check can catch that.
635// Single-section shim preserving the `anyhow::Result` API for the unit tests;
636// `resolve_config` uses the `*_into` worker with the shared bag instead.
637#[cfg(test)]
638fn ensure_no_hash_pinned_and_denied(
639 cache: &ResolvedCache,
640 content: &ResolvedContent,
641) -> anyhow::Result<()> {
642 one_section(|bag| ensure_no_hash_pinned_and_denied_into(cache, content, bag))
643}
644
645fn ensure_no_hash_pinned_and_denied_into(
646 cache: &ResolvedCache,
647 content: &ResolvedContent,
648 bag: &mut ConfigDiagnostics,
649) {
650 let mut both: Vec<String> = cache
651 .pinned_hashes
652 .iter()
653 .filter(|&h| content.denied_hashes.contains(h))
654 .map(ToString::to_string)
655 .collect();
656 both.sort();
657 bag.check_with(both.is_empty(), "content.denied_hashes", || {
658 format!(
659 "hash(es) appear in both cache.pinned_hashes and \
660 content.denied_hashes: {}. Pinning a denied hash is contradictory — \
661 a denied hash is refused by the serve gate, so pinning it only pays \
662 storage for content the node will never serve. Remove each from one \
663 of the two lists",
664 both.join(", ")
665 )
666 });
667}
668
669/// Cross-section check on the running node's port assignments. Lives at the
670/// resolver boundary because `bind_port`, `metrics_port`, and `admin_port`
671/// come from different sections — the collision rules are inherently
672/// cross-section, and operators who set two of them equal almost certainly
673/// made a typo. Also emits a stderr warning for well-known ports (<1024),
674/// which bind on Unix only with elevated privilege and may collide with
675/// standardized services.
676///
677/// Collision rules:
678/// - `bind_port` vs. `metrics_port` — QUIC (UDP) and metrics (TCP) would
679/// not collide at bind time, but sharing a number is an operator typo.
680/// - `bind_port` vs. `admin_port` — same UDP/TCP story as above.
681/// - `metrics_port` vs. `admin_port` — both TCP; sharing the port would
682/// silently make the second bind fail at startup.
683///
684/// Port `0` is the sentinel for OS-assigned ephemeral ports: it collides
685/// with nothing (the OS picks distinct values) and needs no elevated
686/// privilege, so every check skips it. Admin disabled (`None`) means we
687/// skip the pair checks that involve it.
688#[cfg(test)]
689fn validate_port_layout(
690 network: &ResolvedNetwork,
691 observability: &ResolvedObservability,
692) -> anyhow::Result<()> {
693 one_section(|bag| validate_port_layout_into(network, observability, bag))
694}
695
696fn validate_port_layout_into(
697 network: &ResolvedNetwork,
698 observability: &ResolvedObservability,
699 bag: &mut ConfigDiagnostics,
700) {
701 let bind = network.bind_port;
702 let metrics = observability.metrics_port;
703 let admin = observability.admin_port;
704
705 // bind vs metrics — UDP/TCP, same-number operator typo. The three pair
706 // checks are independent: accumulate every collision so an operator who
707 // set all three equal sees all of them, not just the first. Each pair
708 // gets a distinct label encoding both fields, so the bag can carry all
709 // three problems without two collisions colliding under one key (and so
710 // a future `has_field` guard could pick out a specific pair).
711 if bind != 0 && metrics != 0 {
712 bag.check_with(
713 bind != metrics,
714 "network.bind_port vs observability.metrics_port",
715 || format!(
716 "network.bind_port ({bind}) must differ from observability.metrics_port ({metrics}); \
717 QUIC (UDP) and metrics (TCP) would not collide at bind time, but sharing \
718 the same port number is almost certainly an operator typo"
719 ),
720 );
721 }
722
723 // bind vs admin — UDP/TCP, same-number operator typo.
724 if let Some(admin) = admin
725 && bind != 0
726 && admin != 0
727 {
728 bag.check_with(
729 bind != admin,
730 "network.bind_port vs observability.admin_port",
731 || format!(
732 "network.bind_port ({bind}) must differ from observability.admin_port ({admin}); \
733 QUIC (UDP) and admin (TCP) would not collide at bind time, but sharing \
734 the same port number is almost certainly an operator typo"
735 ),
736 );
737 }
738
739 // metrics vs admin — both TCP, second bind would fail silently.
740 if let Some(admin) = admin
741 && metrics != 0
742 && admin != 0
743 {
744 bag.check_with(
745 admin != metrics,
746 "observability.admin_port vs observability.metrics_port",
747 || format!(
748 "observability.admin_port ({admin}) must differ from observability.metrics_port ({metrics}); \
749 the two servers cannot share a TCP port"
750 ),
751 );
752 }
753
754 for (name, port) in [
755 ("network.bind_port", Some(bind)),
756 ("observability.metrics_port", Some(metrics)),
757 ("observability.admin_port", admin),
758 ] {
759 if let Some(p) = port
760 && (1..1024).contains(&p)
761 {
762 bag.warn(
763 name,
764 format!(
765 "{p} is in the well-known range (<1024); requires elevated privilege to \
766 bind on Unix and may collide with a standardized service"
767 ),
768 );
769 }
770 }
771}
772
773/// Resolve identity fields.
774#[cfg(test)]
775fn resolve_identity(
776 cli: &crate::cli::run::IdentityArgs,
777 file: Option<&types::IdentityConfig>,
778) -> anyhow::Result<ResolvedIdentity> {
779 one_section(|bag| resolve_identity_into(cli, file, bag))
780}
781
782fn resolve_identity_into(
783 cli: &crate::cli::run::IdentityArgs,
784 file: Option<&types::IdentityConfig>,
785 bag: &mut ConfigDiagnostics,
786) -> ResolvedIdentity {
787 let data_dir = cli
788 .data_dir
789 .clone()
790 .map(|p| expand_tilde(&p))
791 .or_else(|| {
792 file.and_then(|i| i.data_dir.clone())
793 .map(|p| expand_tilde(&p))
794 })
795 .or_else(common::default_data_dir)
796 .unwrap_or_else(|| {
797 // Placeholder so blockchain/cache resolution keeps running and
798 // accumulating their own problems. The placeholder never escapes:
799 // `resolve_config` derives `data_dir_valid` from
800 // `bag.has_field(IDENTITY_DATA_DIR)` and stops path-existence
801 // cascades; the materialized `ResolvedConfig` carrying the
802 // placeholder is dropped when `bag.into_result()?` short-circuits.
803 bag.push(
804 IDENTITY_DATA_DIR,
805 "cannot determine data directory: home dir not found",
806 );
807 PathBuf::from("/nonexistent")
808 });
809
810 let region = match cli
811 .region
812 .clone()
813 .or_else(|| file.and_then(|i| i.region.clone()))
814 {
815 Some(raw) => bag.try_with(IDENTITY_REGION, normalize_region(&raw)),
816 None => None,
817 };
818
819 ResolvedIdentity { data_dir, region }
820}
821
822/// Normalize an operator-supplied region code: uppercase it and check it
823/// against the ISO 3166-1 alpha-2 allowlist in
824/// [`decdn_protocol::is_valid_region`] (assigned codes + the user-reserved
825/// ranges `AA`, `QM`–`QZ`, `XA`–`XZ`, `ZZ`). A bad value here would
826/// otherwise reach the ADR 030 region-latency penalty and region-accounting
827/// paths as an unrecognized code peers cannot compare against their own.
828/// Fail loudly at startup.
829fn normalize_region(raw: &str) -> anyhow::Result<String> {
830 let upper = raw.to_ascii_uppercase();
831 anyhow::ensure!(
832 decdn_protocol::is_valid_region(&upper),
833 "identity.region must be an ISO 3166-1 alpha-2 code \
834 (assigned or user-reserved AA/QM-QZ/XA-XZ/ZZ), got {raw:?}"
835 );
836 Ok(upper)
837}
838
839/// Resolve network fields, recording any malformed `relay_urls` entry into
840/// `bag` (#818).
841///
842/// Each resolved relay URL is parse-checked with [`url::Url`] so
843/// `decdn config validate` fails fast and names a bad entry, instead of the
844/// failure only surfacing later at node bring-up (`parse_relay_urls` in the
845/// `node` runtime). The check is deliberately a *subset* of bring-up's
846/// `iroh::RelayUrl` parse: `RelayUrl` wraps a `url::Url`, so anything
847/// `url::Url::parse` rejects `RelayUrl` rejects too — the validate-time gate is
848/// never stricter than bring-up (no false positives, e.g. a `relay://` scheme
849/// passes both), and bring-up's `RelayUrl` parse stays the authoritative gate
850/// for relay-specific shape. The validation here uses only `url`, so the config
851/// module needs no `iroh` import (the `common` crate depends on `iroh`
852/// elsewhere, e.g. identity loading — this check does not).
853///
854/// A rejected entry is run through [`redact_userinfo`](crate::redact) before it
855/// is echoed, upholding the same "relay userinfo never reaches a log/error"
856/// invariant bring-up enforces — a credential-bearing typo
857/// (`relay://user:pass@bad host`) is exactly the malformed shape that lands on
858/// the error path.
859fn resolve_network_into(
860 cli: &crate::cli::run::NetworkArgs,
861 file: Option<&types::NetworkConfig>,
862 bag: &mut ConfigDiagnostics,
863) -> ResolvedNetwork {
864 let bind_port = cli
865 .bind_port
866 .or_else(|| file.and_then(|n| n.bind_port))
867 .unwrap_or(DEFAULT_BIND_PORT);
868
869 // Relay precedence (highest first): CLI `--relay-url` (singular) >
870 // file `network.relay_urls` (list). An empty result means "use the n0
871 // default relays" — the node only swaps in a custom relay map when this is
872 // non-empty. The CLI/env surface stays singular; the multi-relay surface
873 // is the TOML `relay_urls` array (issue #795).
874 let relay_urls = if let Some(url) = cli.relay_url.clone() {
875 vec![url]
876 } else {
877 file.and_then(|n| n.relay_urls.clone()).unwrap_or_default()
878 };
879
880 // #843: `--relay-url` (and `DECDN_RELAY_URL`, which clap folds into it)
881 // takes precedence over the file list, so a stale exported env var silently
882 // collapses a multi-entry `network.relay_urls` failover list (#795/#817) to
883 // the single env value. Warn rather than defeat relay redundancy quietly.
884 let file_relay_list_len = file.and_then(|n| n.relay_urls.as_ref()).map_or(0, Vec::len);
885 if cli.relay_url.is_some() && file_relay_list_len > 0 {
886 bag.warn(
887 "network.relay_url",
888 format!(
889 "--relay-url (or DECDN_RELAY_URL) overrides the {file_relay_list_len}-entry \
890 network.relay_urls list; multi-relay failover is disabled"
891 ),
892 );
893 }
894
895 // Validate each resolved entry. The label names the source the operator
896 // actually wrote: the indexed array field (`network.relay_urls[i]`, matching
897 // the `cache.origins[i]` convention) only when the list branch above was
898 // taken, otherwise the singular `network.relay_url` (the `--relay-url` /
899 // `DECDN_RELAY_URL` override, which always resolves to a one-element vec) —
900 // reporting `relay_urls[0]` there would point at an array the operator never
901 // defined.
902 // The offending entry is echoed with userinfo redacted (a malformed entry
903 // can still carry `user:pass@`), mirroring bring-up's `parse_relay_urls`.
904 let from_array = cli.relay_url.is_none()
905 && file
906 .and_then(|n| n.relay_urls.as_ref())
907 .is_some_and(|l| !l.is_empty());
908 for (i, entry) in relay_urls.iter().enumerate() {
909 if let Err(e) = url::Url::parse(entry) {
910 let label = if from_array {
911 format!("network.relay_urls[{i}]")
912 } else {
913 "network.relay_url".to_string()
914 };
915 bag.push(
916 label,
917 format!("invalid relay URL {:?}: {e}", redact_userinfo(entry)),
918 );
919 }
920 }
921
922 let discovery = resolve_discovery_into(file, bag);
923
924 ResolvedNetwork {
925 bind_port,
926 relay_urls,
927 discovery,
928 }
929}
930
931/// Resolve just `[network.discovery]` from a loaded [`FileConfig`], for the
932/// one-shot client commands (`decdn get`/`probe`) that dial nodes by their
933/// iroh `NodeId` via discovery but do not run the full node `resolve_config`
934/// pass. Applies the exact same shape validation as `resolve_config` does for
935/// this section; an absent `[network]`/`[network.discovery]` yields the empty
936/// default (the caller falls back to `presets::N0`).
937///
938/// # Errors
939///
940/// Fails if any configured discovery field is malformed (same shape checks the
941/// full `resolve_config` pass applies to this section).
942pub fn resolve_discovery(file: &FileConfig) -> anyhow::Result<ResolvedDiscovery> {
943 one_section(|bag| resolve_discovery_into(file.network.as_ref(), bag))
944}
945
946/// Resolve and shape-validate `[network.discovery]` (#818 scope 1), recording
947/// every malformed entry into `bag`.
948///
949/// Validation is a parse check (a parseable URL, a non-empty origin, a valid
950/// iroh `NodeId` — the canonical 64-char lowercase-hex form — and a parseable
951/// `SocketAddr`)
952/// using the same parsers the node uses; the authoritative build into iroh
953/// types happens in the `node` wiring layer (`build_endpoint`), per the
954/// discovery-provider seam in `adr/appendix-poc-production-seams.md`. Echoed
955/// URLs are run through [`redact_userinfo`](crate::redact) so a credential-
956/// bearing typo never reaches an error string, matching the relay-URL path.
957///
958/// `pkarr_url` without `dns_origin` is rejected: publishing this node's record
959/// to a pkarr relay that no configured resolver reads from is a misconfiguration
960/// (the node would advertise into a namespace the fleet never resolves). The
961/// reverse — `dns_origin` alone — is valid (a resolve-only node).
962fn resolve_discovery_into(
963 file: Option<&types::NetworkConfig>,
964 bag: &mut ConfigDiagnostics,
965) -> ResolvedDiscovery {
966 let Some(disc) = file.and_then(|n| n.discovery.as_ref()) else {
967 return ResolvedDiscovery::default();
968 };
969
970 let pkarr_url = disc.pkarr_url.as_ref().and_then(|raw| {
971 bag.try_with(
972 "network.discovery.pkarr_url",
973 url::Url::parse(raw)
974 .map(|_| raw.clone())
975 .map_err(|e| anyhow::anyhow!("invalid URL {:?}: {e}", redact_userinfo(raw))),
976 )
977 });
978
979 let dns_origin = disc.dns_origin.as_ref().and_then(|raw| {
980 bag.check(
981 !raw.trim().is_empty(),
982 "network.discovery.dns_origin",
983 "must not be empty when set; omit the key instead",
984 )
985 .then(|| raw.clone())
986 });
987
988 bag.check(
989 !(disc.pkarr_url.is_some() && disc.dns_origin.is_none()),
990 "network.discovery.dns_origin",
991 "network.discovery.pkarr_url publishes this node's address record to a pkarr \
992 relay, but no network.discovery.dns_origin is configured to resolve peers from \
993 it; set dns_origin or remove pkarr_url",
994 );
995
996 let mut peers: Vec<ResolvedDiscoveryPeer> = disc
997 .peers
998 .iter()
999 .flatten()
1000 .filter_map(|(node_id, peer)| resolve_discovery_peer(node_id, peer, bag))
1001 .collect();
1002 // `HashMap` iteration order is nondeterministic; sort so the node build and
1003 // any test assertions are stable. Unstable sort: peer node_ids are unique
1004 // (HashMap keys), so stable ordering buys nothing and `sort_unstable_by`
1005 // avoids the aux allocation.
1006 peers.sort_unstable_by(|a, b| a.node_id.cmp(&b.node_id));
1007
1008 ResolvedDiscovery {
1009 pkarr_url,
1010 dns_origin,
1011 peers,
1012 }
1013}
1014
1015/// Shape-validate one `[network.discovery.peers.<id>]` entry, recording any
1016/// problem into `bag`. Returns the resolved peer only when the `NodeId`, relay
1017/// URL, and every socket address are well-formed; a bad entry is dropped from
1018/// the address book but all of its problems are still recorded so an operator
1019/// sees every fix needed at once.
1020///
1021/// The `NodeId` is validated with the exact parser the node uses at bring-up
1022/// (`iroh::PublicKey`, via `add_discovery_lookups`), not just a 64-hex shape
1023/// check: `PublicKey::from_str` requires lowercase hex *and* a valid Ed25519
1024/// curve point, so an uppercase or non-curve-point id that a bare hex check
1025/// would accept must be rejected here too — otherwise it would pass
1026/// `config validate` and then fail node startup. This path carries the id as a
1027/// String the node re-parses, so the config-time and bring-up checks must
1028/// agree.
1029fn resolve_discovery_peer(
1030 node_id: &str,
1031 peer: &types::DiscoveryPeer,
1032 bag: &mut ConfigDiagnostics,
1033) -> Option<ResolvedDiscoveryPeer> {
1034 let mut ok = bag
1035 .try_with(
1036 format!("network.discovery.peers[{node_id}]"),
1037 node_id.parse::<iroh::PublicKey>().map(|_| ()).map_err(|e| {
1038 anyhow::anyhow!(
1039 "invalid NodeId (expected a 64-char lowercase-hex iroh NodeId): {e}"
1040 )
1041 }),
1042 )
1043 .is_some();
1044
1045 if let Some(relay) = peer.relay_url.as_ref() {
1046 ok &= bag
1047 .try_with(
1048 format!("network.discovery.peers[{node_id}].relay_url"),
1049 url::Url::parse(relay)
1050 .map(|_| ())
1051 .map_err(|e| anyhow::anyhow!("invalid URL {:?}: {e}", redact_userinfo(relay))),
1052 )
1053 .is_some();
1054 }
1055
1056 for (i, addr) in peer.addrs.iter().enumerate() {
1057 ok &= bag
1058 .try_with(
1059 format!("network.discovery.peers[{node_id}].addrs[{i}]"),
1060 addr.parse::<std::net::SocketAddr>()
1061 .map(|_| ())
1062 .map_err(|e| anyhow::anyhow!("invalid socket address {addr:?}: {e}")),
1063 )
1064 .is_some();
1065 }
1066
1067 ok.then(|| ResolvedDiscoveryPeer {
1068 node_id: node_id.to_string(),
1069 relay_url: peer.relay_url.clone(),
1070 addrs: peer.addrs.clone(),
1071 })
1072}
1073
1074/// Infallible field-resolution shim for the unit tests that assert on resolved
1075/// values without exercising the relay-URL validation (those tests supply
1076/// well-formed URLs, so the discarded bag is always empty). The validation path
1077/// is covered by the dedicated `resolve_network_*` tests that inspect the bag.
1078#[cfg(test)]
1079fn resolve_network(
1080 cli: &crate::cli::run::NetworkArgs,
1081 file: Option<&types::NetworkConfig>,
1082) -> ResolvedNetwork {
1083 let mut bag = ConfigDiagnostics::new();
1084 let resolved = resolve_network_into(cli, file, &mut bag);
1085 // This shim discards the bag, so it must only be fed well-formed relay
1086 // URLs. Trip loudly if a future caller passes a malformed entry whose
1087 // problem would otherwise be silently dropped (the validation path proper
1088 // is covered by the `resolve_network_*` tests that inspect the bag).
1089 debug_assert_eq!(
1090 bag.problem_count(),
1091 0,
1092 "resolve_network shim discards validation problems; use a bag-aware path"
1093 );
1094 resolved
1095}
1096
1097/// Validate a user-supplied EVM contract address string.
1098///
1099/// Requires `0x` prefix, 40 hex characters, and a correct EIP-55 checksum.
1100/// Returns the canonical checksummed form. Public so callers that generate
1101/// config (e.g. `decdn config init --chain`) can apply the same check to
1102/// baked-in addresses.
1103pub fn parse_contract_address(flag_name: &str, raw: &str) -> anyhow::Result<String> {
1104 let trimmed = raw.trim();
1105 let addr = Address::parse_checksummed(trimmed, None).with_context(|| {
1106 format!(
1107 "invalid {flag_name}: value {raw:?} (trimmed: {trimmed:?}); expected an EIP-55 \
1108 checksummed 0x-prefixed 40-hex-character address"
1109 )
1110 })?;
1111 Ok(addr.to_checksum(None))
1112}
1113
1114/// `metadata`/`is_file`/`File::open` triad proving the keystore path
1115/// exists, is a regular file, and is readable. Kept as one fail-fast
1116/// `Result` (the three steps are sequentially dependent — one keystore
1117/// problem, not three) and routed through the bag by the caller so a
1118/// bad keystore doesn't abort the rest of blockchain resolution.
1119fn check_keystore_readable(path: &std::path::Path) -> anyhow::Result<()> {
1120 let meta = std::fs::metadata(path)
1121 .with_context(|| format!("invalid eth_keystore: cannot access {}", path.display()))?;
1122 anyhow::ensure!(
1123 meta.is_file(),
1124 "invalid eth_keystore: {} is not a regular file",
1125 path.display()
1126 );
1127 std::fs::File::open(path).with_context(|| {
1128 format!(
1129 "invalid eth_keystore: cannot open {} for reading",
1130 path.display()
1131 )
1132 })?;
1133 Ok(())
1134}
1135
1136/// Resolve a required contract address: record the "missing required
1137/// option" problem when absent, or the parse/checksum problem when
1138/// malformed, and return the canonical checksummed form (empty-string
1139/// placeholder on failure so resolution keeps accumulating).
1140fn resolve_contract_address(
1141 field: &str,
1142 flag_name: &str,
1143 missing_msg: &str,
1144 raw: Option<String>,
1145 bag: &mut ConfigDiagnostics,
1146) -> String {
1147 match raw.filter(|s| !s.is_empty()) {
1148 None => {
1149 bag.push(field, missing_msg);
1150 String::new()
1151 }
1152 Some(v) => bag
1153 .try_with(field, parse_contract_address(flag_name, &v))
1154 .unwrap_or_default(),
1155 }
1156}
1157
1158/// Resolve blockchain fields.
1159#[cfg(test)]
1160fn resolve_blockchain(
1161 cli: &crate::cli::run::BlockchainArgs,
1162 file: Option<&types::BlockchainConfig>,
1163 data_dir: &std::path::Path,
1164) -> anyhow::Result<ResolvedBlockchain> {
1165 one_section(|bag| resolve_blockchain_into(cli, file, data_dir, true, bag))
1166}
1167
1168// Linear field-by-field resolution (rpc_url, keystore, three contract
1169// addresses, chain_id, watchdog) — splitting it would scatter the
1170// "missing required option" error wording that tests assert on.
1171#[allow(clippy::too_many_lines)]
1172fn resolve_blockchain_into(
1173 cli: &crate::cli::run::BlockchainArgs,
1174 file: Option<&types::BlockchainConfig>,
1175 data_dir: &std::path::Path,
1176 data_dir_valid: bool,
1177 bag: &mut ConfigDiagnostics,
1178) -> ResolvedBlockchain {
1179 let rpc_url = match cli
1180 .rpc_url
1181 .clone()
1182 .or_else(|| file.and_then(|b| b.rpc_url.clone()))
1183 .filter(|s| !s.is_empty())
1184 {
1185 None => {
1186 bag.push(
1187 "blockchain.rpc_url",
1188 "missing required option: --rpc-url (or blockchain.rpc_url in config file)",
1189 );
1190 // Placeholder; skip the URL parse + scheme checks below — a
1191 // synthesized URL would only emit a misleading second error.
1192 String::new()
1193 }
1194 Some(raw) => {
1195 match bag.try_with(
1196 "blockchain.rpc_url",
1197 url::Url::parse(&raw).context("blockchain.rpc_url is not a valid URL"),
1198 ) {
1199 None => String::new(),
1200 Some(parsed) => {
1201 if bag.check_with(
1202 parsed.scheme() == "http" || parsed.scheme() == "https",
1203 "blockchain.rpc_url",
1204 || {
1205 format!(
1206 "blockchain.rpc_url must use http or https scheme (got {:?})",
1207 parsed.scheme()
1208 )
1209 },
1210 ) {
1211 // Store the normalized form (lowercase scheme,
1212 // trailing slash, etc.). Userinfo (basic auth) is
1213 // preserved by `url::Url::to_string` and we depend
1214 // on that for RPC providers that require it.
1215 parsed.to_string()
1216 } else {
1217 String::new()
1218 }
1219 }
1220 }
1221 }
1222 };
1223
1224 let keystore_from_cli_or_file =
1225 cli.eth_keystore.is_some() || file.and_then(|b| b.eth_keystore.as_ref()).is_some();
1226 let eth_keystore = cli
1227 .eth_keystore
1228 .clone()
1229 .map(|p| expand_tilde(&p))
1230 .or_else(|| {
1231 file.and_then(|b| b.eth_keystore.clone())
1232 .map(|p| expand_tilde(&p))
1233 })
1234 .unwrap_or_else(|| data_dir.join("keystore.json"));
1235
1236 // An explicitly-set keystore path (CLI/file) is always validated. A
1237 // *defaulted* keystore is validated only when data_dir resolved: when
1238 // data_dir itself failed, the keystore path is `/nonexistent/keystore.json`
1239 // and a "cannot access" error here is pure cascade noise on top of the
1240 // real `identity.data_dir` problem (which already fails resolution), so
1241 // the skip is deliberate cascade-suppression, not an unchecked hole.
1242 if keystore_from_cli_or_file || data_dir_valid {
1243 bag.try_with(
1244 "blockchain.eth_keystore",
1245 check_keystore_readable(ð_keystore),
1246 );
1247 }
1248
1249 let payment_pool_address = resolve_contract_address(
1250 "blockchain.payment_pool_address",
1251 "payment_pool_address",
1252 "missing required option: --payment-pool-address \
1253 (or blockchain.payment_pool_address in config file)",
1254 cli.payment_pool_address
1255 .clone()
1256 .or_else(|| file.and_then(|b| b.payment_pool_address.clone())),
1257 bag,
1258 );
1259
1260 let capacity_bond_address = resolve_contract_address(
1261 "blockchain.capacity_bond_address",
1262 "capacity_bond_address",
1263 "missing required option: --capacity-bond-address \
1264 (or blockchain.capacity_bond_address in config file)",
1265 cli.capacity_bond_address
1266 .clone()
1267 .or_else(|| file.and_then(|b| b.capacity_bond_address.clone())),
1268 bag,
1269 );
1270
1271 // The all-zero address checksum-validates cleanly but is never a real
1272 // deployment; settling/staking against a codeless address surfaces only as
1273 // an opaque on-chain revert. Reject it here — mirroring `slash_judge_address`
1274 // and `content_blacklist_address` below — so `decdn config check` catches it
1275 // and the daemon's runtime guard (#1219) is uniform defense-in-depth rather
1276 // than the sole check. Skipped when the value is an empty placeholder: the
1277 // missing/parse problem is already recorded.
1278 if !payment_pool_address.is_empty() {
1279 bag.check(
1280 payment_pool_address
1281 .trim_start_matches("0x")
1282 .bytes()
1283 .any(|b| b != b'0'),
1284 "blockchain.payment_pool_address",
1285 "blockchain.payment_pool_address must not be the zero address — \
1286 set it to the deployed PaymentPool contract",
1287 );
1288 }
1289 if !capacity_bond_address.is_empty() {
1290 bag.check(
1291 capacity_bond_address
1292 .trim_start_matches("0x")
1293 .bytes()
1294 .any(|b| b != b'0'),
1295 "blockchain.capacity_bond_address",
1296 "blockchain.capacity_bond_address must not be the zero address — \
1297 set it to the deployed CapacityBond contract",
1298 );
1299 }
1300
1301 // Optional chain-backed origin directory (ADR 022 §FIND_VALUE Flow): keyed
1302 // solely on `OriginAssignment` — a request's namespace resolves directly to
1303 // `getOrigins(namespaceId)`, with no hash→namespace lookup, so the directory
1304 // needs only this one address. Unset => the runtime uses an empty (deny-all)
1305 // directory: the pull-through authorized-origin gate finds no on-chain
1306 // origins. `publisher_registry_address` is independent — it is the publish
1307 // CLI's `namespace create` target and is not consumed by the node runtime.
1308 let origin_assignment_raw = cli
1309 .origin_assignment_address
1310 .clone()
1311 .or_else(|| file.and_then(|b| b.origin_assignment_address.clone()))
1312 .filter(|s| !s.is_empty());
1313 let publisher_registry_raw = cli
1314 .publisher_registry_address
1315 .clone()
1316 .or_else(|| file.and_then(|b| b.publisher_registry_address.clone()))
1317 .filter(|s| !s.is_empty());
1318 let origin_assignment_address = origin_assignment_raw.and_then(|v| {
1319 bag.try_with(
1320 "blockchain.origin_assignment_address",
1321 parse_contract_address("origin_assignment_address", &v),
1322 )
1323 });
1324 let publisher_registry_address = publisher_registry_raw.and_then(|v| {
1325 bag.try_with(
1326 "blockchain.publisher_registry_address",
1327 parse_contract_address("publisher_registry_address", &v),
1328 )
1329 });
1330 // Present-but-zero is a fail-open trap like the other contract addresses:
1331 // the directory would resolve every hash against a codeless address. Reject
1332 // it (mirroring `content_blacklist_address`); a missing value stays `None`.
1333 if let Some(addr) = origin_assignment_address.as_deref() {
1334 bag.check(
1335 addr.trim_start_matches("0x").bytes().any(|b| b != b'0'),
1336 "blockchain.origin_assignment_address",
1337 "blockchain.origin_assignment_address must not be the zero address — \
1338 set it to the deployed OriginAssignment contract",
1339 );
1340 }
1341 if let Some(addr) = publisher_registry_address.as_deref() {
1342 bag.check(
1343 addr.trim_start_matches("0x").bytes().any(|b| b != b'0'),
1344 "blockchain.publisher_registry_address",
1345 "blockchain.publisher_registry_address must not be the zero address — \
1346 set it to the deployed PublisherRegistry contract",
1347 );
1348 }
1349 // Required like the other contract addresses: a wrong/zero
1350 // `verifyingContract` silently produces `slash_sig`s no verifier accepts
1351 // (ADR 014 §1).
1352 let slash_judge_address = resolve_contract_address(
1353 "blockchain.slash_judge_address",
1354 "slash_judge_address",
1355 "missing required option: --slash-judge-address \
1356 (or blockchain.slash_judge_address in config file)",
1357 cli.slash_judge_address
1358 .clone()
1359 .or_else(|| file.and_then(|b| b.slash_judge_address.clone())),
1360 bag,
1361 );
1362 // The all-zero address is syntactically valid but is never a real
1363 // `SlashJudge` deployment; signing against it produces `slash_sig`s no
1364 // verifier can attribute (ADR 014 §1). Only meaningful once the address
1365 // itself parsed — a placeholder empty string here just means the
1366 // missing/parse problem is already recorded, so skip the cascade.
1367 if !slash_judge_address.is_empty() {
1368 bag.check(
1369 slash_judge_address
1370 .trim_start_matches("0x")
1371 .bytes()
1372 .any(|b| b != b'0'),
1373 "blockchain.slash_judge_address",
1374 "blockchain.slash_judge_address must not be the zero address — \
1375 set it to the deployed SlashJudge contract (ADR 014 §1)",
1376 );
1377 }
1378
1379 // `blockchain.slash_appeal_address` is accepted in the file (see
1380 // `BlockchainConfig`) but consumed only by the `decdn appeal slash` CLI
1381 // (via `chain_ctx::resolve_appeal`, which validates it) — the daemon does
1382 // not resolve or use it, so there is nothing to resolve here.
1383
1384 // Required for every paid-delivery node: without this contract the daemon
1385 // has no local protection against serving slashable blacklisted content.
1386 let content_blacklist_address = resolve_contract_address(
1387 "blockchain.content_blacklist_address",
1388 "content_blacklist_address",
1389 "missing required option: --content-blacklist-address \
1390 (or blockchain.content_blacklist_address in config file)",
1391 cli.content_blacklist_address
1392 .clone()
1393 .or_else(|| file.and_then(|b| b.content_blacklist_address.clone())),
1394 bag,
1395 );
1396 // The zero address is a fail-open trap for compliance (unlike the opt-in
1397 // origin-directory addresses): every `isHashBlacklistedForOperator` call
1398 // against a codeless address reverts on empty return data, the watcher maps
1399 // that to "leave the blob in place", and nothing is ever evicted while the
1400 // operator believes compliance is active — maximal slash exposure with a
1401 // "configured" watcher. Reject it explicitly, mirroring `slash_judge_address`.
1402 if !content_blacklist_address.is_empty() {
1403 bag.check(
1404 content_blacklist_address
1405 .trim_start_matches("0x")
1406 .bytes()
1407 .any(|b| b != b'0'),
1408 "blockchain.content_blacklist_address",
1409 "blockchain.content_blacklist_address must not be the zero address — \
1410 set it to the deployed ContentBlacklist contract (ADR 011/031)",
1411 );
1412 }
1413 let content_blacklist_address =
1414 (!content_blacklist_address.is_empty()).then_some(content_blacklist_address);
1415 let content_blacklist_poll_interval_sec = file
1416 .and_then(|b| b.content_blacklist_poll_interval_sec)
1417 .unwrap_or(DEFAULT_CONTENT_BLACKLIST_POLL_INTERVAL_SEC);
1418 // `0` is not a "disable" sentinel — disabling the periodic re-scope would
1419 // break the retain/re-scope compliance guarantee — and it panics
1420 // `tokio::time::interval_at` ("period must be non-zero"), which would kill
1421 // the watcher task and silently stop enforcement. Reject it up front.
1422 bag.check(
1423 content_blacklist_poll_interval_sec != 0,
1424 "blockchain.content_blacklist_poll_interval_sec",
1425 "blockchain.content_blacklist_poll_interval_sec must not be 0 — the \
1426 reconcile interval must be non-zero; omit it for the default (600s)",
1427 );
1428
1429 let chain_staleness_grace_sec = file
1430 .and_then(|b| b.chain_staleness_grace_sec)
1431 .unwrap_or(DEFAULT_CHAIN_STALENESS_GRACE_SEC);
1432 // `0` is not a "disable" sentinel — a zero grace makes every chain read
1433 // instantly stale, so the node would refuse every serve. Opting out is done
1434 // by setting a large window, not `0`. Reject it up front.
1435 bag.check(
1436 chain_staleness_grace_sec != 0,
1437 "blockchain.chain_staleness_grace_sec",
1438 "blockchain.chain_staleness_grace_sec must not be 0 — a zero grace \
1439 refuses every serve; set a large value to opt out, or omit it for the \
1440 default (1800s)",
1441 );
1442
1443 let fee_shares_poll_interval_sec = file
1444 .and_then(|b| b.fee_shares_poll_interval_sec)
1445 .unwrap_or(DEFAULT_FEE_SHARES_POLL_INTERVAL_SEC);
1446 // `0` would make the authoritative re-read run every tick (no throttle),
1447 // hammering the RPC — the `SharesUpdated` event subscription is already the
1448 // prompt path, so the re-read is a slow safety net. Reject rather than
1449 // silently over-poll.
1450 bag.check(
1451 fee_shares_poll_interval_sec != 0,
1452 "blockchain.fee_shares_poll_interval_sec",
1453 "blockchain.fee_shares_poll_interval_sec must not be 0 — the \
1454 authoritative getShares() re-read is a slow safety net; omit it \
1455 for the default (3600s)",
1456 );
1457
1458 let origin_directory_positive_ttl_sec = file
1459 .and_then(|b| b.origin_directory_positive_ttl_sec)
1460 .unwrap_or(DEFAULT_ORIGIN_DIRECTORY_POSITIVE_TTL_SEC);
1461 let origin_directory_negative_ttl_sec = file
1462 .and_then(|b| b.origin_directory_negative_ttl_sec)
1463 .unwrap_or(DEFAULT_ORIGIN_DIRECTORY_NEGATIVE_TTL_SEC);
1464 let origin_directory_cache_capacity = file
1465 .and_then(|b| b.origin_directory_cache_capacity)
1466 .unwrap_or(DEFAULT_ORIGIN_DIRECTORY_CACHE_CAPACITY);
1467 // No `!= 0` rejection: a `0` TTL is a valid "disable caching" choice
1468 // (every entry reads as already-expired, matching `probe_cache`'s
1469 // documented zero-TTL behavior), and the cache clamps capacity to `>= 1`
1470 // itself.
1471
1472 let chain_id = cli
1473 .chain_id
1474 .or_else(|| file.and_then(|b| b.chain_id))
1475 .unwrap_or(DEFAULT_CHAIN_ID);
1476 // `chain_id` is bound into every `slash_sig` EIP-712 domain separator
1477 // (and the runtime signer). Chain id 0 is not a real network; signing
1478 // against it produces `slash_sig`s no `SlashJudge` can verify — the same
1479 // silently-broken-but-running failure mode the zero-`slash_judge_address`
1480 // check above prevents.
1481 bag.check_with(chain_id != 0, "blockchain.chain_id", || {
1482 format!(
1483 "blockchain.chain_id must not be 0 — set it to the deployed L2 \
1484 chain id (default {DEFAULT_CHAIN_ID}, Arbitrum Sepolia)"
1485 )
1486 });
1487
1488 let rpc_watchdog_interval_sec = file
1489 .and_then(|b| b.rpc_watchdog_interval_sec)
1490 .unwrap_or(DEFAULT_RPC_WATCHDOG_INTERVAL_SEC);
1491 bag.check_with(
1492 rpc_watchdog_interval_sec == 0
1493 || rpc_watchdog_interval_sec >= MIN_RPC_WATCHDOG_INTERVAL_SEC,
1494 "blockchain.rpc_watchdog_interval_sec",
1495 || {
1496 format!(
1497 "blockchain.rpc_watchdog_interval_sec={rpc_watchdog_interval_sec} \
1498 would flood the RPC endpoint (minimum {MIN_RPC_WATCHDOG_INTERVAL_SEC}s, \
1499 or 0 to disable)"
1500 )
1501 },
1502 );
1503
1504 let event_poll_interval_ms = file
1505 .and_then(|b| b.event_poll_interval_ms)
1506 .unwrap_or(DEFAULT_EVENT_POLL_INTERVAL_MS);
1507 bag.check_with(
1508 event_poll_interval_ms >= MIN_EVENT_POLL_INTERVAL_MS,
1509 "blockchain.event_poll_interval_ms",
1510 || {
1511 format!(
1512 "blockchain.event_poll_interval_ms={event_poll_interval_ms} would poll the \
1513 RPC endpoint too frequently — it drives both the eth_getLogs watcher \
1514 tick and pending-tx receipt polling \
1515 (minimum {MIN_EVENT_POLL_INTERVAL_MS}ms)"
1516 )
1517 },
1518 );
1519
1520 let get_logs_max_block_span = file
1521 .and_then(|b| b.get_logs_max_block_span)
1522 .unwrap_or(DEFAULT_GET_LOGS_MAX_BLOCK_SPAN);
1523 bag.check_with(
1524 get_logs_max_block_span > 0,
1525 "blockchain.get_logs_max_block_span",
1526 || {
1527 "blockchain.get_logs_max_block_span=0 would scan no blocks; use at least 1 \
1528 (the provider's eth_getLogs range limit)"
1529 .to_owned()
1530 },
1531 );
1532
1533 let redeem_threshold_micro_usdc = file
1534 .and_then(|b| b.redeem_threshold_micro_usdc)
1535 .unwrap_or(DEFAULT_REDEEM_THRESHOLD_MICRO_USDC);
1536 // A `0` threshold would withdraw on every accepted voucher — burning gas
1537 // per MB and reverting on-chain (`NothingToWithdraw`) for any zero-delta
1538 // re-hint. Reject it; operators wanting aggressive redemption set a small
1539 // positive value (base units, µUSDC).
1540 bag.check_with(
1541 redeem_threshold_micro_usdc > 0,
1542 "blockchain.redeem_threshold_micro_usdc",
1543 || {
1544 "blockchain.redeem_threshold_micro_usdc must be > 0 (a 0 threshold \
1545 withdraws on every voucher, burning gas and reverting on zero-delta)"
1546 .to_string()
1547 },
1548 );
1549
1550 let redeem_interval_secs = file
1551 .and_then(|b| b.redeem_interval_secs)
1552 .unwrap_or(DEFAULT_REDEEM_INTERVAL_SECS);
1553 // A `0` interval would build a zero-period `tokio::time::interval`, which
1554 // panics. Reject it; operators wanting a tighter backstop set a small
1555 // positive value (the advisory hints already redeem between sweeps).
1556 bag.check_with(
1557 redeem_interval_secs > 0,
1558 "blockchain.redeem_interval_secs",
1559 || {
1560 "blockchain.redeem_interval_secs must be > 0 (a 0 interval is not a valid \
1561 sweep period)"
1562 .to_string()
1563 },
1564 );
1565 // The sweep is the only thing that redeems this node's vouchers before an
1566 // owner's grace-window close lets them `reclaim`. It must run several times
1567 // inside the 48h grace floor, so cap the interval at 6h — a laxer cadence
1568 // risks forfeiting real earnings on a pool that closes between sweeps.
1569 bag.check_with(
1570 redeem_interval_secs <= MAX_REDEEM_INTERVAL_SECS,
1571 "blockchain.redeem_interval_secs",
1572 || {
1573 format!(
1574 "blockchain.redeem_interval_secs must be <= {MAX_REDEEM_INTERVAL_SECS} (6h): the \
1575 redeem sweep must run well inside the 48h grace window to secure vouchers \
1576 before an owner can reclaim a closing pool"
1577 )
1578 },
1579 );
1580
1581 let redeem_max_vouchers_per_tx = file
1582 .and_then(|b| b.redeem_max_vouchers_per_tx)
1583 .unwrap_or(DEFAULT_REDEEM_MAX_VOUCHERS_PER_TX);
1584 // A `0` chunk size could never carry a voucher, stranding every redemption.
1585 // Reject it; operators tuning gas set a small positive value.
1586 bag.check_with(
1587 redeem_max_vouchers_per_tx > 0,
1588 "blockchain.redeem_max_vouchers_per_tx",
1589 || {
1590 "blockchain.redeem_max_vouchers_per_tx must be > 0 (a 0 chunk size \
1591 carries no vouchers and strands every redemption)"
1592 .to_string()
1593 },
1594 );
1595
1596 let buyer_working_deposit_micro_usdc = file
1597 .and_then(|b| b.buyer_working_deposit_micro_usdc)
1598 .unwrap_or(DEFAULT_BUYER_WORKING_DEPOSIT_MICRO_USDC);
1599 // The buyer opens the pool at this deposit, and `openPool` reverts
1600 // `ZeroAmount` on a zero deposit, so a configured 0 can never open a pool.
1601 // Reject it here too: the contract is the authority, but catching it at
1602 // load time beats surfacing it as a failed transaction on the first
1603 // cache-miss pull.
1604 bag.check_with(
1605 buyer_working_deposit_micro_usdc > 0,
1606 "blockchain.buyer_working_deposit_micro_usdc",
1607 || {
1608 "blockchain.buyer_working_deposit_micro_usdc must be > 0 (openPool reverts \
1609 ZeroAmount on a zero deposit)"
1610 .to_string()
1611 },
1612 );
1613 // Default-on: the one-time max approval is what lets the buyer path join
1614 // pools without a manual approve step (ADR 003 § Deposit Economics).
1615 let buyer_max_approve = file.and_then(|b| b.buyer_max_approve).unwrap_or(true);
1616
1617 // The node's refundable floor `M` (ADR 003 § Sizing). No `> 0` check — `0`
1618 // is a legal (if aggressive) operator choice that keeps no reserve at all.
1619 let pool_min_remaining_deposit_micro_usdc = file
1620 .and_then(|b| b.pool_min_remaining_deposit_micro_usdc)
1621 .unwrap_or(DEFAULT_POOL_MIN_REMAINING_DEPOSIT_MICRO_USDC);
1622
1623 // Per-signer LIVE concurrency cap `k`, in credit windows (ADR 003 § Pool
1624 // solvency, per-signer floor isolation). Any value is legal: the node
1625 // lower-clamps the cap to one window at use, so `0` behaves as "admit a lone
1626 // signer's first stream" rather than wedging it — no bound to enforce.
1627 let pool_floor_signer_live_windows = file
1628 .and_then(|b| b.pool_floor_signer_live_windows)
1629 .unwrap_or(DEFAULT_POOL_FLOOR_SIGNER_LIVE_WINDOWS);
1630
1631 // CLI/env only — no TOML field. `expand_tilde` for parity with the
1632 // keystore path itself. Existence check is intentionally deferred to
1633 // the runtime loader: a stale path falls through there, so a headless run
1634 // fails with "no keystore password source available" naming the path,
1635 // which is clearer than a config-resolution-time stat() error. On a TTY it
1636 // falls through to the prompt instead, and the stale path goes unreported.
1637 let keystore_password_file = cli.keystore_password_file.clone().map(|p| expand_tilde(&p));
1638
1639 ResolvedBlockchain {
1640 rpc_url,
1641 eth_keystore,
1642 keystore_password_file,
1643 payment_pool_address,
1644 capacity_bond_address,
1645 origin_assignment_address,
1646 origin_directory_positive_ttl_sec,
1647 origin_directory_negative_ttl_sec,
1648 origin_directory_cache_capacity,
1649 publisher_registry_address,
1650 slash_judge_address,
1651 content_blacklist_address,
1652 content_blacklist_poll_interval_sec,
1653 chain_staleness_grace_sec,
1654 chain_id,
1655 rpc_watchdog_interval_sec,
1656 event_poll_interval_ms,
1657 get_logs_max_block_span,
1658 fee_shares_poll_interval_sec,
1659 redeem_threshold_micro_usdc,
1660 redeem_max_vouchers_per_tx,
1661 redeem_interval_secs,
1662 buyer_working_deposit_micro_usdc,
1663 buyer_max_approve,
1664 pool_min_remaining_deposit_micro_usdc,
1665 pool_floor_signer_live_windows,
1666 }
1667}
1668
1669/// Resolve cache fields.
1670///
1671/// Enforces `max_blob_size_mb <= cache_size_mb`: a blob larger than the whole
1672/// cache could never be admitted, so a value above the disk budget is a
1673/// permanent-reject misconfiguration. Equality is allowed and is the default —
1674/// the disk budget is the ceiling, and a node serves any blob it can hold.
1675#[cfg(test)]
1676fn resolve_cache(
1677 cli: &crate::cli::run::CacheArgs,
1678 file: Option<&types::CacheConfig>,
1679 data_dir: &std::path::Path,
1680) -> anyhow::Result<ResolvedCache> {
1681 one_section(|bag| resolve_cache_into(cli, file, data_dir, bag))
1682}
1683
1684#[allow(clippy::too_many_lines)] // one flat field-by-field resolution; splitting obscures it.
1685fn resolve_cache_into(
1686 cli: &crate::cli::run::CacheArgs,
1687 file: Option<&types::CacheConfig>,
1688 data_dir: &std::path::Path,
1689 bag: &mut ConfigDiagnostics,
1690) -> ResolvedCache {
1691 let cache_dir = cli
1692 .cache_dir
1693 .clone()
1694 .map(|p| expand_tilde(&p))
1695 .or_else(|| {
1696 file.and_then(|c| c.cache_dir.clone())
1697 .map(|p| expand_tilde(&p))
1698 })
1699 .unwrap_or_else(|| data_dir.join("cache"));
1700
1701 let cache_size_mb = cli
1702 .cache_size_mb
1703 .or_else(|| file.and_then(|c| c.cache_size_mb))
1704 .unwrap_or(DEFAULT_CACHE_SIZE_MB);
1705
1706 // Free-disk headroom the eviction driver defends on the cache volume (#1930).
1707 // No range check: `0` is the valid opt-out (only `cache_size_mb` binds), and
1708 // a headroom larger than the volume simply keeps the effective ceiling pinned
1709 // to the footprint — degenerate but safe, and `decdn node doctor` flags a
1710 // headroom that leaves the cache no room to grow.
1711 let disk_headroom_mb = file
1712 .and_then(|c| c.disk_headroom_mb)
1713 .unwrap_or(DEFAULT_DISK_HEADROOM_MB);
1714
1715 // Unset => `DEFAULT_MAX_BLOB_SIZE_MB` (50 GB), clamped to `cache_size_mb` so a
1716 // node with a smaller-than-default cache still resolves a valid ceiling rather
1717 // than tripping the `max_blob <= cache_size` invariant below. Operators who want
1718 // a different per-blob bound (for example to hold a larger monolithic blob, or to
1719 // cap the RAM the buffered miss tier spends on one pull) set it explicitly.
1720 let max_blob_size_mb = cli
1721 .max_blob_size_mb
1722 .or_else(|| file.and_then(|c| c.max_blob_size_mb))
1723 .unwrap_or_else(|| DEFAULT_MAX_BLOB_SIZE_MB.min(cache_size_mb));
1724
1725 // Buyer-side absolute per-MB rate ceiling (#1375); `0` = unlimited (the
1726 // default). CLI/env override wins over the file, matching every other knob.
1727 let max_rate_per_mb = cli
1728 .max_rate_per_mb
1729 .or_else(|| file.and_then(|c| c.max_rate_per_mb))
1730 .unwrap_or(0);
1731
1732 bag.check_with(
1733 max_blob_size_mb <= cache_size_mb,
1734 "cache.max_blob_size_mb",
1735 || {
1736 format!(
1737 "cache.max_blob_size_mb ({max_blob_size_mb}) must not exceed \
1738 cache.cache_size_mb ({cache_size_mb}); a blob larger than the whole \
1739 cache could never be admitted"
1740 )
1741 },
1742 );
1743
1744 let origins = resolve_origins_into(file, bag);
1745
1746 let pinned_hashes = bag
1747 .try_with(
1748 "cache.pinned_hashes",
1749 parse_pinned_hashes(file.and_then(|c| c.pinned_hashes.as_deref()))
1750 .context("invalid cache.pinned_hashes"),
1751 )
1752 .unwrap_or_else(decdn_config_types::PinnedHashes::empty);
1753
1754 let origin_retry = bag
1755 .try_with(
1756 "cache.origin_retry",
1757 resolve_origin_retry(file.and_then(|c| c.origin_retry.as_ref()))
1758 .context("invalid cache.origin_retry"),
1759 )
1760 .unwrap_or_default();
1761
1762 let circuit_breaker = bag
1763 .try_with(
1764 "cache.circuit_breaker",
1765 resolve_circuit_breaker(file.and_then(|c| c.circuit_breaker.as_ref()))
1766 .context("invalid cache.circuit_breaker"),
1767 )
1768 .unwrap_or_default();
1769
1770 // `cache.user_agent` (#435): operator override of the default
1771 // `decdn-node/<version>` UA we send on every origin pull. We
1772 // validate the value at config load — both for a clear error
1773 // message and to slam the door on header-injection (CRLF, NUL,
1774 // other control bytes) before it reaches `reqwest::Client::builder`,
1775 // which would otherwise surface a generic "failed to build reqwest
1776 // client" at runtime. Whitespace inside the value is preserved
1777 // verbatim so an operator who genuinely wants `MyCdn / 1.0` gets
1778 // exactly that.
1779 let user_agent = match file.and_then(|c| c.user_agent.as_ref()) {
1780 Some(s)
1781 if bag
1782 .try_with("cache.user_agent", validate_user_agent(s))
1783 .is_some() =>
1784 {
1785 s.clone()
1786 }
1787 // Absent, or present-but-invalid (the problem is already in the
1788 // bag): fall back to the default UA so resolution can continue.
1789 _ => decdn_config_types::DEFAULT_USER_AGENT.to_string(),
1790 };
1791
1792 let gc_interval_sec = file
1793 .and_then(|c| c.gc_interval_sec)
1794 .unwrap_or(DEFAULT_GC_INTERVAL_SEC);
1795
1796 let fs_rescan_interval_sec = file
1797 .and_then(|c| c.fs_rescan_interval_sec)
1798 .unwrap_or(DEFAULT_FS_RESCAN_INTERVAL_SEC);
1799
1800 // Live-origin probe memo knobs (#1130 pt3). No range checks: a `0` TTL simply
1801 // memoises nothing (every probe re-HEADs), a `0` timeout is clamped to a live
1802 // future by `tokio::time::timeout`, and a `0` capacity is floored to 1 by
1803 // `OriginProbeMemo::new` — all degenerate-but-safe, so there is no invalid
1804 // value to reject.
1805 let origin_probe_ttl_sec = file
1806 .and_then(|c| c.origin_probe_ttl_sec)
1807 .unwrap_or(DEFAULT_ORIGIN_PROBE_TTL_SEC);
1808 let origin_probe_negative_ttl_sec = file
1809 .and_then(|c| c.origin_probe_negative_ttl_sec)
1810 .unwrap_or(DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL_SEC);
1811 let origin_probe_fault_ttl_sec = file
1812 .and_then(|c| c.origin_probe_fault_ttl_sec)
1813 .unwrap_or(DEFAULT_ORIGIN_PROBE_FAULT_TTL_SEC);
1814 // The three probe TTLs are one graded policy, not three independent knobs.
1815 // A fault must be re-probed at least as eagerly as a positive answer, or a
1816 // recovered origin stays hidden behind a stale fault — and the origin-only
1817 // serve gate refuses paying clients for as long as it does. It must be at
1818 // least as patient as an absence, or memoising it buys nothing over the
1819 // negative TTL it would otherwise share. Enforced here so the ordering the
1820 // knob docs state is a property of every accepted config, not of the
1821 // defaults alone.
1822 bag.check_with(
1823 origin_probe_negative_ttl_sec <= origin_probe_fault_ttl_sec
1824 && origin_probe_fault_ttl_sec <= origin_probe_ttl_sec,
1825 "cache.origin_probe_fault_ttl_sec",
1826 || {
1827 format!(
1828 "cache.origin_probe_fault_ttl_sec ({origin_probe_fault_ttl_sec}) must sit \
1829 between cache.origin_probe_negative_ttl_sec \
1830 ({origin_probe_negative_ttl_sec}) and cache.origin_probe_ttl_sec \
1831 ({origin_probe_ttl_sec}) inclusive: a fault is re-probed no less eagerly \
1832 than a positive answer and no more eagerly than an absence"
1833 )
1834 },
1835 );
1836 let origin_probe_timeout_ms = file
1837 .and_then(|c| c.origin_probe_timeout_ms)
1838 .unwrap_or(DEFAULT_ORIGIN_PROBE_TIMEOUT_MS);
1839 let origin_probe_memo_capacity = file
1840 .and_then(|c| c.origin_probe_memo_capacity)
1841 .unwrap_or(DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY);
1842
1843 // LRU eviction driver knobs (#1173, ADR 040). Each
1844 // is range-checked against its structural bounds; the target/high-water
1845 // hysteresis gap is a cross-field invariant enforced after both resolve.
1846 let eviction_high_water_pct = file
1847 .and_then(|c| c.eviction_high_water_pct)
1848 .unwrap_or(DEFAULT_EVICTION_HIGH_WATER_PCT);
1849 bag.check_with(
1850 (EVICTION_HIGH_WATER_PCT_BOUNDS.0..=EVICTION_HIGH_WATER_PCT_BOUNDS.1)
1851 .contains(&eviction_high_water_pct),
1852 "cache.eviction_high_water_pct",
1853 || {
1854 format!(
1855 "cache.eviction_high_water_pct ({eviction_high_water_pct}) must be within \
1856 [{}, {}]",
1857 EVICTION_HIGH_WATER_PCT_BOUNDS.0, EVICTION_HIGH_WATER_PCT_BOUNDS.1
1858 )
1859 },
1860 );
1861 let eviction_target_pct = file
1862 .and_then(|c| c.eviction_target_pct)
1863 .unwrap_or(DEFAULT_EVICTION_TARGET_PCT);
1864 bag.check_with(
1865 (EVICTION_TARGET_PCT_BOUNDS.0..=EVICTION_TARGET_PCT_BOUNDS.1)
1866 .contains(&eviction_target_pct),
1867 "cache.eviction_target_pct",
1868 || {
1869 format!(
1870 "cache.eviction_target_pct ({eviction_target_pct}) must be within [{}, {}]",
1871 EVICTION_TARGET_PCT_BOUNDS.0, EVICTION_TARGET_PCT_BOUNDS.1
1872 )
1873 },
1874 );
1875 // Structural hysteresis gap: target must sit at least
1876 // EVICTION_HYSTERESIS_GAP_PCT points below high-water, else the driver
1877 // would thrash on writes hovering near the trigger. `saturating_sub` keeps
1878 // the comparison well-defined when high-water is below the gap.
1879 bag.check_with(
1880 eviction_target_pct <= eviction_high_water_pct.saturating_sub(EVICTION_HYSTERESIS_GAP_PCT),
1881 "cache.eviction_target_pct",
1882 || {
1883 format!(
1884 "cache.eviction_target_pct ({eviction_target_pct}) must be at least \
1885 {EVICTION_HYSTERESIS_GAP_PCT} points below cache.eviction_high_water_pct \
1886 ({eviction_high_water_pct}): the hysteresis gap is structural"
1887 )
1888 },
1889 );
1890 let eviction_per_sweep_budget = file
1891 .and_then(|c| c.eviction_per_sweep_budget)
1892 .unwrap_or(DEFAULT_EVICTION_PER_SWEEP_BUDGET);
1893 bag.check_with(
1894 (EVICTION_PER_SWEEP_BUDGET_BOUNDS.0..=EVICTION_PER_SWEEP_BUDGET_BOUNDS.1)
1895 .contains(&eviction_per_sweep_budget),
1896 "cache.eviction_per_sweep_budget",
1897 || {
1898 format!(
1899 "cache.eviction_per_sweep_budget ({eviction_per_sweep_budget}) must be within \
1900 [{}, {}]",
1901 EVICTION_PER_SWEEP_BUDGET_BOUNDS.0, EVICTION_PER_SWEEP_BUDGET_BOUNDS.1
1902 )
1903 },
1904 );
1905 let eviction_tick_secs = file
1906 .and_then(|c| c.eviction_tick_secs)
1907 .unwrap_or(DEFAULT_EVICTION_TICK_SECS);
1908 bag.check_with(
1909 (EVICTION_TICK_SECS_BOUNDS.0..=EVICTION_TICK_SECS_BOUNDS.1).contains(&eviction_tick_secs),
1910 "cache.eviction_tick_secs",
1911 || {
1912 format!(
1913 "cache.eviction_tick_secs ({eviction_tick_secs}) must be within [{}, {}]",
1914 EVICTION_TICK_SECS_BOUNDS.0, EVICTION_TICK_SECS_BOUNDS.1
1915 )
1916 },
1917 );
1918
1919 let max_probe_holds = cli
1920 .max_probe_holds
1921 .or_else(|| file.and_then(|c| c.max_probe_holds))
1922 .map_or(DEFAULT_MAX_PROBE_HOLDS, |v| {
1923 usize::try_from(v).unwrap_or(usize::MAX)
1924 });
1925
1926 let stake_lane_reserved_holds = cli
1927 .stake_lane_reserved_holds
1928 .or_else(|| file.and_then(|c| c.stake_lane_reserved_holds))
1929 .map_or(DEFAULT_STAKE_LANE_RESERVED_HOLDS, |v| {
1930 usize::try_from(v).unwrap_or(usize::MAX)
1931 });
1932
1933 let node_to_node_pull_through_enabled = file
1934 .and_then(|c| c.node_to_node_pull_through_enabled)
1935 .unwrap_or(false);
1936 let relay_foreign_namespaces = file
1937 .and_then(|c| c.relay_foreign_namespaces)
1938 // Role-derived default: an origin node (a backend is configured) serves only
1939 // its own namespace; a node with no origin is a pure relay edge and relays.
1940 // Explicit config overrides either way.
1941 .unwrap_or(origins.is_empty());
1942 let node_pull_probe_fanout = file
1943 .and_then(|c| c.node_pull_probe_fanout)
1944 .unwrap_or(DEFAULT_NODE_PULL_PROBE_FANOUT);
1945 let node_pull_timeout_sec = file
1946 .and_then(|c| c.node_pull_timeout_sec)
1947 .unwrap_or(DEFAULT_NODE_PULL_TIMEOUT_SEC);
1948 bag.check(
1949 node_pull_timeout_sec > 0,
1950 "cache.node_pull_timeout_sec",
1951 "cache.node_pull_timeout_sec must be > 0 (a 0 budget abandons every upstream \
1952 before its handshake can complete, so no pull can ever succeed)",
1953 );
1954 let node_pull_stall_window_sec = file
1955 .and_then(|c| c.node_pull_stall_window_sec)
1956 .unwrap_or(DEFAULT_NODE_PULL_STALL_WINDOW_SEC);
1957 // A 0 window makes the throughput floor demand progress over no time at all, so it
1958 // trips on the first poll of every streaming read and abandons every upstream before a
1959 // byte can arrive. The abort is non-attributable (#1797), so a fat-fingered value here
1960 // does not defame peers — but it still wedges this node's pull path, so it is rejected
1961 // at load.
1962 bag.check(
1963 node_pull_stall_window_sec > 0,
1964 "cache.node_pull_stall_window_sec",
1965 "cache.node_pull_stall_window_sec must be > 0 (a 0 window makes the throughput \
1966 floor unsatisfiable, abandoning every upstream on the first read)",
1967 );
1968 // The floor RATE may legitimately be 0 — that is idle-detection mode (one byte per
1969 // window) — so only the window duration is bounded below.
1970 let node_pull_min_throughput_bps = file
1971 .and_then(|c| c.node_pull_min_throughput_bps)
1972 .unwrap_or(DEFAULT_NODE_PULL_MIN_THROUGHPUT_BPS);
1973 // Cache admission/eviction policy selectors (ADR 040). Unknown names are
1974 // rejected here, at config load — never a silent fallback to the default
1975 // policy, which would mask an operator typo behind quietly-unchanged
1976 // behavior.
1977 let eviction_policy = file
1978 .and_then(|c| c.eviction_policy.clone())
1979 .unwrap_or_else(|| DEFAULT_EVICTION_POLICY.to_string());
1980 bag.check_with(
1981 matches!(eviction_policy.as_str(), "lru" | "tinylfu"),
1982 "cache.eviction_policy",
1983 || format!("cache.eviction_policy ({eviction_policy}) must be \"lru\" or \"tinylfu\""),
1984 );
1985 let admission_policy = file
1986 .and_then(|c| c.admission_policy.clone())
1987 .unwrap_or_else(|| DEFAULT_ADMISSION_POLICY.to_string());
1988 bag.check_with(
1989 matches!(admission_policy.as_str(), "always" | "tinylfu"),
1990 "cache.admission_policy",
1991 || format!("cache.admission_policy ({admission_policy}) must be \"always\" or \"tinylfu\""),
1992 );
1993
1994 let tinylfu_file = file.and_then(|c| c.tinylfu.as_ref());
1995 let tinylfu_sketch_bytes = tinylfu_file
1996 .and_then(|t| t.sketch_bytes)
1997 .unwrap_or(DEFAULT_TINYLFU_SKETCH_BYTES);
1998 let tinylfu_promotion_threshold = tinylfu_file
1999 .and_then(|t| t.promotion_threshold)
2000 .unwrap_or(DEFAULT_TINYLFU_PROMOTION_THRESHOLD);
2001 // `sketch_bytes / 4` is the sketch's column count, and the over-report rate
2002 // `(1 - e^(-N / cols))^4` climbs steeply as that count shrinks. Reject an
2003 // undersized one at load, never clamp (ADR 040 §Configuration surface).
2004 // The check does not consult the policy selectors, and for `sketch_bytes`
2005 // that is not a precaution: the estimator is also built when
2006 // `serve_economics.policy` is `margin`, which is the default, so this knob
2007 // sizes a live sketch on a node whose selectors are `lru`/`always`. The two
2008 // checks below gate genuinely selector-only knobs, and are unconditional so
2009 // that a value which is wrong stays rejected when an operator switches.
2010 bag.check_with(
2011 tinylfu_sketch_bytes >= MIN_TINYLFU_SKETCH_BYTES,
2012 "cache.tinylfu.sketch_bytes",
2013 || {
2014 format!(
2015 "cache.tinylfu.sketch_bytes ({tinylfu_sketch_bytes}) must be >= \
2016 {MIN_TINYLFU_SKETCH_BYTES}: a narrower sketch reports cold blobs \
2017 as hot often enough to move admission, eviction and \
2018 serve-economics decisions. It applies whether or not a policy \
2019 selector names \"tinylfu\" — the default \
2020 cache.serve_economics.policy = \"margin\" builds the same \
2021 sketch."
2022 )
2023 },
2024 );
2025 // `promotion_threshold` counts prior sightings before a probation member
2026 // admits to `Main`; zero is nonsensical — it would make admission
2027 // always-`Main` and promote everything, defeating probationary admission.
2028 // Reject it at load, never clamp (ADR 040 §Probationary admission).
2029 bag.check_with(
2030 tinylfu_promotion_threshold >= 1,
2031 "cache.tinylfu.promotion_threshold",
2032 || {
2033 format!(
2034 "cache.tinylfu.promotion_threshold ({tinylfu_promotion_threshold}) must be >= 1"
2035 )
2036 },
2037 );
2038 let tinylfu_probation_target_pct = tinylfu_file
2039 .and_then(|t| t.probation_target_pct)
2040 .unwrap_or(DEFAULT_TINYLFU_PROBATION_TARGET_PCT);
2041 bag.check_with(
2042 (TINYLFU_PROBATION_TARGET_PCT_BOUNDS.0..=TINYLFU_PROBATION_TARGET_PCT_BOUNDS.1)
2043 .contains(&tinylfu_probation_target_pct),
2044 "cache.tinylfu.probation_target_pct",
2045 || {
2046 format!(
2047 "cache.tinylfu.probation_target_pct ({tinylfu_probation_target_pct}) must be \
2048 within [{}, {}]",
2049 TINYLFU_PROBATION_TARGET_PCT_BOUNDS.0, TINYLFU_PROBATION_TARGET_PCT_BOUNDS.1
2050 )
2051 },
2052 );
2053 let tinylfu_aging_halflife_sec = tinylfu_file
2054 .and_then(|t| t.aging_halflife_sec)
2055 .unwrap_or(DEFAULT_TINYLFU_AGING_HALFLIFE_SEC);
2056
2057 // Refuse-to-serve economics (ADR 041). Unknown policy names and
2058 // out-of-range knobs are rejected here, at config load — never a silent
2059 // fallback to the default.
2060 let se_file = file.and_then(|c| c.serve_economics.as_ref());
2061 let se_policy = se_file
2062 .and_then(|s| s.policy.clone())
2063 .unwrap_or_else(|| DEFAULT_SERVE_ECONOMICS_POLICY.to_string());
2064 bag.check_with(
2065 matches!(se_policy.as_str(), "off" | "margin"),
2066 "cache.serve_economics.policy",
2067 || format!("cache.serve_economics.policy ({se_policy}) must be \"off\" or \"margin\""),
2068 );
2069 let se_discount = se_file.and_then(|s| s.discount);
2070 bag.check_with(
2071 se_discount.is_none_or(|d| d > 0.0 && d <= 1.0),
2072 "cache.serve_economics.discount",
2073 || format!("cache.serve_economics.discount ({se_discount:?}) must be within (0.0, 1.0]"),
2074 );
2075 // Round to bps without float panics; the clamp (on the float, before the
2076 // cast) defends the conversion even if validation above is bypassed, so
2077 // the truncation/sign-loss casts below are always in `[1, 10_000]`.
2078 let se_discount_bps = se_discount.map_or(DEFAULT_SERVE_ECONOMICS_DISCOUNT_BPS, |d| {
2079 let bps = (d * 10_000.0).round().clamp(1.0, 10_000.0);
2080 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2081 let bps = bps as u32;
2082 bps
2083 });
2084 let se_n_max = se_file
2085 .and_then(|s| s.n_max)
2086 .unwrap_or(DEFAULT_SERVE_ECONOMICS_N_MAX);
2087 bag.check_with(se_n_max >= 1, "cache.serve_economics.n_max", || {
2088 format!("cache.serve_economics.n_max ({se_n_max}) must be >= 1")
2089 });
2090 let se_warming_budget = se_file
2091 .and_then(|s| s.warming_budget)
2092 .unwrap_or(DEFAULT_SERVE_ECONOMICS_WARMING_BUDGET);
2093 bag.check_with(
2094 se_warming_budget > 0,
2095 "cache.serve_economics.warming_budget",
2096 || format!("cache.serve_economics.warming_budget ({se_warming_budget}) must be > 0"),
2097 );
2098 // `warming_refill` may be 0: that disables time-based refill (allowance
2099 // only resets on restart or via the serve-vindicated upgrade), which is
2100 // a valid operator choice, not an error.
2101 let se_warming_refill = se_file
2102 .and_then(|s| s.warming_refill)
2103 .unwrap_or(DEFAULT_SERVE_ECONOMICS_WARMING_REFILL);
2104
2105 ResolvedCache {
2106 cache_dir,
2107 cache_size_mb,
2108 disk_headroom_mb,
2109 max_blob_size_mb,
2110 max_rate_per_mb,
2111 origins,
2112 pinned_hashes,
2113 origin_retry,
2114 circuit_breaker,
2115 user_agent,
2116 gc_interval_sec,
2117 fs_rescan_interval_sec,
2118 origin_probe_ttl_sec,
2119 origin_probe_negative_ttl_sec,
2120 origin_probe_fault_ttl_sec,
2121 origin_probe_timeout_ms,
2122 origin_probe_memo_capacity,
2123 eviction_high_water_pct,
2124 eviction_target_pct,
2125 eviction_per_sweep_budget,
2126 eviction_tick_secs,
2127 max_probe_holds,
2128 stake_lane_reserved_holds,
2129 node_to_node_pull_through_enabled,
2130 relay_foreign_namespaces,
2131 node_pull_probe_fanout,
2132 node_pull_timeout_sec,
2133 node_pull_stall_window_sec,
2134 node_pull_min_throughput_bps,
2135 eviction_policy,
2136 admission_policy,
2137 tinylfu: ResolvedTinyLfu {
2138 sketch_bytes: tinylfu_sketch_bytes,
2139 promotion_threshold: tinylfu_promotion_threshold,
2140 probation_target_pct: tinylfu_probation_target_pct,
2141 aging_halflife_sec: tinylfu_aging_halflife_sec,
2142 },
2143 serve_economics: ResolvedServeEconomics {
2144 policy: se_policy,
2145 discount_bps: se_discount_bps,
2146 n_max: se_n_max,
2147 warming_budget: se_warming_budget,
2148 warming_refill: se_warming_refill,
2149 },
2150 }
2151}
2152
2153/// Resolve and validate the cache origin section (#437, #284). Collapses
2154/// the singular `[cache.origin]` table and the plural `[[cache.origins]]`
2155/// array-of-tables into a single canonical [`Vec<ResolvedOrigin>`]:
2156/// absent => `vec![]`, singular => one-element vec, plural => the
2157/// resolved vec in operator-supplied order. The two wire forms are
2158/// mutually exclusive — setting both at once is an operator mistake
2159/// (each form names a distinct fallback policy) and the error message
2160/// names both keys so the fix is unambiguous.
2161///
2162/// Empty `origins = []` is rejected rather than treated as "no
2163/// pull-through" — an operator who wrote `origins = []` almost
2164/// certainly meant to populate it later and forgot. Failing at config
2165/// load surfaces the mistake before the first cache miss instead of
2166/// silently degrading to `NoOrigin`.
2167///
2168/// Duplicate entries (same kind + identity key) are permitted, with a
2169/// [`ConfigNotice`] recorded per duplicate. Two HTTP origins pointing at the
2170/// same URL is legitimate for connection-pool sharding, but is more often a
2171/// copy-paste mistake worth putting in front of the operator.
2172fn resolve_origins_into(
2173 file: Option<&types::CacheConfig>,
2174 bag: &mut ConfigDiagnostics,
2175) -> Vec<crate::config::ResolvedOrigin> {
2176 let Some(cache) = file else {
2177 return Vec::new();
2178 };
2179
2180 match (&cache.origin, &cache.origins) {
2181 (Some(_), Some(_)) => {
2182 // One unambiguous problem: the operator picked two mutually
2183 // exclusive fallback policies. Don't also emit "must contain
2184 // at least one entry" — resolve to no pull-through.
2185 bag.push(
2186 "cache.origins",
2187 "cache.origin and cache.origins are mutually exclusive — \
2188 use [cache.origin] for a single backend or [[cache.origins]] \
2189 for an ordered fallback list, not both",
2190 );
2191 Vec::new()
2192 }
2193 (Some(single), None) => bag
2194 .try_with(
2195 "cache.origin",
2196 resolve_origin(single).context("invalid cache.origin"),
2197 )
2198 .map(|o| vec![o])
2199 .unwrap_or_default(),
2200 (None, Some(list)) => {
2201 if !bag.check(
2202 !list.is_empty(),
2203 "cache.origins",
2204 "cache.origins must contain at least one entry; \
2205 omit the key entirely for no pull-through",
2206 ) {
2207 return Vec::new();
2208 }
2209 // Independent entries: validate every one so an operator with
2210 // several bad backends sees all of them, not just the first.
2211 let mut resolved = Vec::with_capacity(list.len());
2212 for (idx, entry) in list.iter().enumerate() {
2213 if let Some(o) = bag.try_with(
2214 format!("cache.origins[{idx}]"),
2215 resolve_origin(entry).with_context(|| format!("invalid cache.origins[{idx}]")),
2216 ) {
2217 resolved.push(o);
2218 }
2219 }
2220 warn_on_duplicate_origins(&resolved, bag);
2221 resolved
2222 }
2223 (None, None) => Vec::new(),
2224 }
2225}
2226
2227/// Operator-visible identity key for a resolved origin — used solely to
2228/// detect duplicates within a `[[cache.origins]]` array. Carries the
2229/// fields that distinguish two backends at the operator level (URL for
2230/// HTTP, filesystem path for fs, bucket+region+endpoint+prefix for S3).
2231/// Credentials are deliberately excluded: rotating an access key on an
2232/// otherwise-identical S3 backend should still count as a duplicate.
2233fn origin_identity_key(origin: &crate::config::ResolvedOrigin) -> String {
2234 use crate::config::ResolvedOrigin;
2235 match origin {
2236 ResolvedOrigin::Http { url, .. } => format!("http|{url}"),
2237 ResolvedOrigin::Fs { path } => format!("fs|{}", path.display()),
2238 ResolvedOrigin::S3(s3) => format!(
2239 "s3|{}|{}|{}|{}",
2240 s3.bucket,
2241 s3.region,
2242 s3.endpoint_url
2243 .as_ref()
2244 .map(ToString::to_string)
2245 .unwrap_or_default(),
2246 s3.prefix,
2247 ),
2248 }
2249}
2250
2251/// Emit a `tracing::warn!` for any `[[cache.origins]]` entry whose
2252/// identity key matches an earlier entry. Two origins pointing at the
2253/// same backend can be intentional (connection-pool sharding) but is
2254/// usually a copy-paste mistake; warning at startup gives the operator
2255/// a chance to notice before debugging a "why is one origin being hit
2256/// twice as often" puzzle. Not an error: ordering still determines
2257/// fallback behaviour, and the engine handles duplicate backends
2258/// without misbehaviour.
2259fn warn_on_duplicate_origins(
2260 origins: &[crate::config::ResolvedOrigin],
2261 bag: &mut ConfigDiagnostics,
2262) {
2263 let mut seen: std::collections::HashSet<String> =
2264 std::collections::HashSet::with_capacity(origins.len());
2265 for (idx, origin) in origins.iter().enumerate() {
2266 let key = origin_identity_key(origin);
2267 if !seen.insert(key.clone()) {
2268 bag.warn(
2269 format!("cache.origins[{idx}]"),
2270 format!(
2271 "{key} duplicates an earlier entry — the cache engine will dispatch the \
2272 same backend twice in the fallback chain (intentional for connection-pool \
2273 sharding, otherwise a likely copy-paste mistake)"
2274 ),
2275 );
2276 }
2277 }
2278}
2279
2280/// Resolve and validate a `[cache.origin]` table into the typed
2281/// runtime form. The match arms run per-variant validation (URL parse
2282/// for HTTP, S3 bucket/region/prefix shape) and reject empty paths or
2283/// URLs early so a misconfigured backend surfaces at startup rather
2284/// than on the first cache miss.
2285fn resolve_origin(cfg: &types::OriginConfig) -> anyhow::Result<crate::config::ResolvedOrigin> {
2286 use crate::config::ResolvedOrigin;
2287 match cfg {
2288 types::OriginConfig::Http { url, decompress } => {
2289 anyhow::ensure!(!url.is_empty(), "cache.origin.url must not be empty");
2290 let parsed =
2291 decdn_config_types::parse_origin_url(url).context("invalid cache.origin.url")?;
2292 Ok(ResolvedOrigin::Http {
2293 url: parsed,
2294 decompress: decompress.unwrap_or_default(),
2295 })
2296 }
2297 types::OriginConfig::Fs { path } => {
2298 let expanded = expand_tilde(path);
2299 anyhow::ensure!(
2300 !expanded.as_os_str().is_empty(),
2301 "cache.origin.path must not be empty"
2302 );
2303 Ok(ResolvedOrigin::Fs { path: expanded })
2304 }
2305 types::OriginConfig::S3(s3) => {
2306 let resolved = resolve_s3_origin(s3.clone())?;
2307 Ok(ResolvedOrigin::S3(resolved))
2308 }
2309 }
2310}
2311
2312/// Validate, normalize, and lift an `S3OriginConfig` into the runtime
2313/// form `ResolvedS3Config` (#437). This is the **intended** path
2314/// from the TOML wire form to a resolved-and-validated runtime form,
2315/// and the only producer the rest of the resolution layer
2316/// (`resolve_origin`, `resolve_cache`, `resolve_config`) feeds into
2317/// the runtime. `ResolvedS3Config` itself has `pub` fields (matching
2318/// the `Resolved*` shape used throughout this crate) — Rust
2319/// visibility doesn't *enforce* the validator-only contract, but the
2320/// runtime never bypasses it. See the type doc on
2321/// [`ResolvedS3Config`].
2322///
2323/// Normalization performed here:
2324/// - `prefix` gets a trailing `/` auto-appended if non-empty and
2325/// missing one (mirrors `parse_origin_url`'s normalization).
2326/// Absent prefix becomes `String::new()` on the resolved side.
2327/// - `path_style: Option<bool>` is collapsed to `bool` with `None`
2328/// mapped to `false` (the SDK default = virtual-hosted-style).
2329/// - `endpoint_url` is parsed into `OriginUrl` so the S3 backend
2330/// can hand it straight to the SDK without re-parsing.
2331///
2332/// Validation covers the cases the type can't express:
2333/// - DNS-safe bucket name (see [`validate_s3_bucket_name`]);
2334/// - non-empty region (the AWS SDK uses it for `SigV4` signing even
2335/// when a custom `endpoint_url` is set);
2336/// - `endpoint_url`, when present, parses as `http`/`https`;
2337/// - `prefix` is a key prefix, not a path: must not start with `/`,
2338/// must not contain `..`, must not contain backslashes, and must
2339/// not contain ASCII control or whitespace characters.
2340fn resolve_s3_origin(
2341 cfg: types::S3OriginConfig,
2342) -> anyhow::Result<crate::config::ResolvedS3Config> {
2343 use crate::config::resolved::{ResolvedS3Config, ResolvedS3Credentials};
2344
2345 let types::S3OriginConfig {
2346 bucket,
2347 region,
2348 endpoint_url,
2349 path_style,
2350 prefix,
2351 credentials,
2352 decompress,
2353 } = cfg;
2354
2355 validate_s3_bucket_name(&bucket).context("invalid cache.origin.bucket")?;
2356 anyhow::ensure!(
2357 !region.trim().is_empty(),
2358 "cache.origin.region must not be empty (the AWS SDK uses it for SigV4 signing \
2359 even when a custom endpoint_url is set)"
2360 );
2361
2362 let endpoint_url = if let Some(endpoint) = endpoint_url {
2363 anyhow::ensure!(
2364 !endpoint.is_empty(),
2365 "cache.origin.endpoint_url must not be empty when set; omit the key instead"
2366 );
2367 // `parse_origin_url` rejects query/fragment and forces
2368 // trailing-slash normalization. That's fine for an S3
2369 // endpoint base — the SDK appends `/{bucket}/{key}` itself,
2370 // and storing the parsed/normalized form here means the
2371 // runtime hands a single canonical string to the SDK
2372 // regardless of whether the operator wrote
2373 // `http://minio:9000` or `http://minio:9000/`.
2374 Some(
2375 decdn_config_types::parse_origin_url(&endpoint)
2376 .context("invalid cache.origin.endpoint_url")?,
2377 )
2378 } else {
2379 None
2380 };
2381
2382 let prefix = match prefix {
2383 None => String::new(),
2384 Some(mut prefix) => {
2385 anyhow::ensure!(
2386 !prefix.starts_with('/'),
2387 "cache.origin.prefix is a key prefix, not a filesystem path: \
2388 it must not start with `/` (use `prefix = \"\"` or omit the key for no prefix)"
2389 );
2390 anyhow::ensure!(
2391 !prefix.contains(".."),
2392 "cache.origin.prefix must not contain `..` (key-prefix shape)"
2393 );
2394 anyhow::ensure!(
2395 !prefix.contains('\\'),
2396 "cache.origin.prefix must not contain `\\` (key-prefix shape)"
2397 );
2398 anyhow::ensure!(
2399 !prefix.bytes().any(|b| b.is_ascii_control()),
2400 "cache.origin.prefix must not contain ASCII control characters"
2401 );
2402 anyhow::ensure!(
2403 !prefix.bytes().any(|b| b == b' ' || b == b'\t'),
2404 "cache.origin.prefix must not contain whitespace"
2405 );
2406 if !prefix.is_empty() && !prefix.ends_with('/') {
2407 prefix.push('/');
2408 }
2409 prefix
2410 }
2411 };
2412
2413 let credentials = credentials.map(|c| match c {
2414 types::S3Credentials::Static {
2415 access_key_id,
2416 secret_access_key,
2417 session_token,
2418 } => ResolvedS3Credentials::Static {
2419 access_key_id,
2420 secret_access_key,
2421 session_token,
2422 },
2423 types::S3Credentials::DefaultChain { profile } => {
2424 // Collapse `profile = ""` (post env-var expansion) to None
2425 // so the runtime's `loader.profile_name(...)` arm only fires
2426 // for a real name. The SDK treats `profile_name("")` as
2427 // distinct from "no override" and would surface a confusing
2428 // "profile '' not found" error at first credential need.
2429 let profile = profile.filter(|p| !p.is_empty());
2430 ResolvedS3Credentials::DefaultChain { profile }
2431 }
2432 });
2433
2434 Ok(ResolvedS3Config {
2435 bucket,
2436 region,
2437 endpoint_url,
2438 path_style: path_style.unwrap_or(false),
2439 prefix,
2440 credentials,
2441 decompress: decompress.unwrap_or_default(),
2442 })
2443}
2444
2445/// AWS S3 bucket-name validation (#437). Mirrors the documented
2446/// constraints: 3–63 chars; lowercase `[a-z0-9.-]`; no leading or
2447/// trailing `.` or `-`; no consecutive dots; must not be formatted
2448/// as an IPv4 address. Catches the common operator typo at config
2449/// load instead of on the first request.
2450///
2451/// Public because `decdn config init --origin s3://<bucket>` (the CLI)
2452/// validates the bucket through this same guard before writing it, so a
2453/// bucket `config init` accepts cannot fail config resolution.
2454///
2455/// AWS-permissive choices we deliberately accept (but stricter
2456/// frontends like virtual-hosted-style URLs may reject): names
2457/// shorter than 3 chars are rejected (per AWS rule), but `xn--`
2458/// prefix and `--ol-s3` suffix are not rejected here — they're
2459/// reserved by AWS to never be assigned and the operator-typo case
2460/// is rare enough not to warrant the extra code.
2461pub fn validate_s3_bucket_name(name: &str) -> anyhow::Result<()> {
2462 anyhow::ensure!(!name.is_empty(), "bucket name must not be empty");
2463 let len = name.len();
2464 anyhow::ensure!(
2465 (3..=63).contains(&len),
2466 "bucket name must be 3..=63 chars (got {len})"
2467 );
2468 // AWS: "Bucket names must begin and end with a letter or number"
2469 // — i.e. no leading/trailing `.` or `-`. Both the leading/trailing
2470 // dot and hyphen are rejected here. Names like `-foo` or `foo-`
2471 // would parse but fail virtual-hosted-style URLs at request time.
2472 let first = name.bytes().next().unwrap_or(0);
2473 let last = name.bytes().next_back().unwrap_or(0);
2474 anyhow::ensure!(
2475 first.is_ascii_lowercase() || first.is_ascii_digit(),
2476 "bucket name must begin with a lowercase letter or digit"
2477 );
2478 anyhow::ensure!(
2479 last.is_ascii_lowercase() || last.is_ascii_digit(),
2480 "bucket name must end with a lowercase letter or digit"
2481 );
2482 anyhow::ensure!(
2483 !name.contains(".."),
2484 "bucket name must not contain consecutive dots"
2485 );
2486 anyhow::ensure!(
2487 name.bytes()
2488 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'.'),
2489 "bucket name must contain only lowercase letters, digits, `-`, or `.`"
2490 );
2491 // Reject IPv4-literal-shaped names (e.g. `192.168.1.1`). AWS
2492 // rejects these on the wire; pre-flighting here gives a
2493 // friendlier error. The check uses `Ipv4Addr::from_str` which
2494 // only accepts the canonical 4-octet decimal form — a
2495 // 5-segment all-numeric name like `1.2.3.4.5` is technically
2496 // accepted by this validator and rejected by AWS at request
2497 // time. That gap is acknowledged; the typo it would catch is
2498 // exotic enough that the extra parsing complexity isn't earned
2499 // here.
2500 if name.parse::<std::net::Ipv4Addr>().is_ok() {
2501 anyhow::bail!("bucket name must not be formatted as an IPv4 address");
2502 }
2503 Ok(())
2504}
2505
2506/// Validate `cache.user_agent` as an HTTP header value at config load
2507/// time. Rejects empty strings and any byte that would break header
2508/// framing (CR, LF, NUL, other C0 controls, DEL). Tab is permitted —
2509/// HTTP allows it in field values and operators occasionally use it as
2510/// a separator inside the UA. Non-ASCII bytes (≥ 0x80) are rejected
2511/// because real-world `User-Agent` strings are pure visible ASCII and
2512/// silently passing through obs-text would hide a typo or a botched
2513/// env-var expansion. Catching this here, rather than letting reqwest
2514/// surface a generic `"failed to build reqwest client"` at startup,
2515/// gives the operator a message that actually names the offending
2516/// field and position.
2517fn validate_user_agent(s: &str) -> anyhow::Result<()> {
2518 anyhow::ensure!(
2519 !s.is_empty(),
2520 "cache.user_agent must be a non-empty string when set"
2521 );
2522 if let Some((idx, byte)) = s
2523 .bytes()
2524 .enumerate()
2525 .find(|(_, b)| !(*b == b'\t' || (0x20..=0x7e).contains(b)))
2526 {
2527 anyhow::bail!(
2528 "cache.user_agent contains an invalid byte 0x{byte:02x} at position {idx}; \
2529 only visible ASCII (0x20..=0x7e) and tab (0x09) are allowed in HTTP header values"
2530 );
2531 }
2532 Ok(())
2533}
2534
2535/// Hard ceiling on `cache.origin_retry.buffered_max_bytes` (#519).
2536/// 64 MiB is well above the 4 MiB default but small enough that an
2537/// accidental "set to 4 GiB" gets caught at config-load time. The
2538/// streaming abort+restart path covers blobs of any size without
2539/// raising this knob; lifting the ceiling would let an operator
2540/// silently amplify per-fetch RSS into territory that doesn't
2541/// actually buy them anything.
2542pub const MAX_BUFFERED_MAX_BYTES: u64 = 64 << 20;
2543
2544/// Resolve the origin retry policy (#285, extended in #519). Absent =>
2545/// defaults via `RetryPolicy::default()`. Present partial sections
2546/// fill missing fields from the same defaults (handled by
2547/// `#[serde(default)]` on `RetryPolicy` itself). This function
2548/// enforces the cross-field invariants the type can't express:
2549/// monotone schedule, finite jitter in `[0, 1]`, and a sane ceiling
2550/// on the body-phase buffer budget.
2551pub fn resolve_origin_retry(
2552 file: Option<&decdn_config_types::RetryPolicy>,
2553) -> anyhow::Result<decdn_config_types::RetryPolicy> {
2554 let p = file.copied().unwrap_or_default();
2555 anyhow::ensure!(
2556 p.initial_backoff_ms <= p.max_backoff_ms,
2557 "cache.origin_retry: initial_backoff_ms ({}) must be <= max_backoff_ms ({}); \
2558 otherwise the schedule never grows",
2559 p.initial_backoff_ms,
2560 p.max_backoff_ms,
2561 );
2562 anyhow::ensure!(
2563 p.jitter_ratio.is_finite() && (0.0..=1.0).contains(&p.jitter_ratio),
2564 "cache.origin_retry: jitter_ratio={} must be a finite number in 0.0..=1.0",
2565 p.jitter_ratio,
2566 );
2567 anyhow::ensure!(
2568 p.buffered_max_bytes <= MAX_BUFFERED_MAX_BYTES,
2569 "cache.origin_retry: buffered_max_bytes ({}) exceeds the hard ceiling of {} bytes \
2570 (#519). The streaming abort+restart path covers blobs of any size without raising \
2571 this knob; if mid-stream retries on large blobs is the goal, leave \
2572 buffered_max_bytes at the default and rely on the streaming path",
2573 p.buffered_max_bytes,
2574 MAX_BUFFERED_MAX_BYTES,
2575 );
2576 Ok(p)
2577}
2578
2579/// Resolve the per-origin circuit-breaker policy (#963). Absent =>
2580/// defaults via `CircuitBreakerPolicy::default()`. Present partial
2581/// sections fill missing fields from the same defaults (handled by
2582/// `#[serde(default)]` on `CircuitBreakerPolicy` itself). This function
2583/// enforces the one cross-field invariant the type can't express: an
2584/// *active* breaker must admit at least one half-open trial, otherwise
2585/// it could never probe for recovery and would stay OPEN forever after
2586/// the first trip.
2587pub fn resolve_circuit_breaker(
2588 file: Option<&decdn_config_types::CircuitBreakerPolicy>,
2589) -> anyhow::Result<decdn_config_types::CircuitBreakerPolicy> {
2590 let p = file.copied().unwrap_or_default();
2591 // Only bind the invariant when the breaker is actually active —
2592 // a disabled breaker (`enabled = false` or `failure_threshold = 0`)
2593 // never reaches HALF-OPEN, so `half_open_max_calls = 0` is harmless
2594 // there and an operator opting out shouldn't have to also set a
2595 // half-open value.
2596 if p.is_active() {
2597 anyhow::ensure!(
2598 p.half_open_max_calls >= 1,
2599 "cache.circuit_breaker: half_open_max_calls ({}) must be >= 1 when the breaker is \
2600 active; otherwise it could never admit a trial pull to probe recovery and would \
2601 stay open forever after the first trip",
2602 p.half_open_max_calls,
2603 );
2604 }
2605 Ok(p)
2606}
2607
2608/// Parse the operator-supplied `cache.pinned_hashes` list (#276) into a
2609/// [`decdn_config_types::PinnedHashes`]. Each entry must be 64 lowercase hex
2610/// chars (BLAKE3 digest size); anything else fails resolution. Duplicates
2611/// are silently de-duplicated — they're harmless.
2612///
2613/// `None` and the empty list both resolve to the empty set, so an absent
2614/// or empty `pinned_hashes` key just means "no pinning".
2615pub fn parse_pinned_hashes(
2616 raw: Option<&[String]>,
2617) -> anyhow::Result<decdn_config_types::PinnedHashes> {
2618 Ok(decdn_config_types::PinnedHashes::new(parse_hash_list(
2619 raw,
2620 "cache.pinned_hashes",
2621 )?))
2622}
2623
2624/// Parse the operator-supplied `content.denied_hashes` list (ADR 011 §Local
2625/// Denylist) into a [`decdn_config_types::DeniedHashes`].
2626///
2627/// Same spelling as `cache.pinned_hashes` — bare 64-char lowercase hex, no
2628/// `blake3:` prefix — so an operator has one hash format across the whole
2629/// config file. The distinct return type is what keeps a denylist from ever
2630/// being handed to the pinning slot.
2631pub fn parse_denied_hashes(
2632 raw: Option<&[String]>,
2633) -> anyhow::Result<decdn_config_types::DeniedHashes> {
2634 Ok(decdn_config_types::DeniedHashes::new(parse_hash_list(
2635 raw,
2636 "content.denied_hashes",
2637 )?))
2638}
2639
2640/// Shared validation behind `cache.pinned_hashes` and `content.denied_hashes`.
2641/// `label` is the config key, so each list's errors name their own field.
2642fn parse_hash_list(
2643 raw: Option<&[String]>,
2644 label: &str,
2645) -> anyhow::Result<std::collections::HashSet<decdn_config_types::Hash>> {
2646 use std::str::FromStr;
2647
2648 let mut out = std::collections::HashSet::new();
2649 let Some(entries) = raw else {
2650 return Ok(out);
2651 };
2652 for (idx, entry) in entries.iter().enumerate() {
2653 let trimmed = entry.trim();
2654 // BLAKE3 lowercase hex is 64 chars. We require lowercase rather
2655 // than letting `Hash::from_str` accept either case because
2656 // mixed-case entries are almost always a copy-paste mistake from
2657 // somewhere they got upper-cased; surfacing it as a config error
2658 // now beats a silent "did the operator pin this or not?" later.
2659 anyhow::ensure!(
2660 trimmed.len() == 64,
2661 "{label}[{idx}] must be 64 hex chars (BLAKE3); got {} chars",
2662 trimmed.len()
2663 );
2664 anyhow::ensure!(
2665 trimmed.chars().all(|c| c.is_ascii_hexdigit())
2666 && !trimmed.chars().any(|c| c.is_ascii_uppercase()),
2667 "{label}[{idx}] must be lowercase hex (0-9, a-f)"
2668 );
2669 let parsed = decdn_config_types::Hash::from_str(trimmed)
2670 .with_context(|| format!("{label}[{idx}] failed to parse as a BLAKE3 hash"))?;
2671 out.insert(parsed);
2672 }
2673 Ok(out)
2674}
2675
2676/// Parse the operator-supplied `content.denied_origins` list (ADR 011 §Local
2677/// Denylist) into a set of operator addresses.
2678///
2679/// These are operator **EOAs**, not contract addresses, so this does NOT use
2680/// [`crate::address::parse_nonzero_address`] — that helper's zero-address hint
2681/// ("set it to the deployed contract address") is wrong advice for a denylist of
2682/// operator accounts (see the note on that fn). The zero address is still
2683/// rejected — it can never own a payment pool, and accepting it would let a
2684/// stray empty string sit in the denylist reading as a real entry — but with an
2685/// operator-appropriate message.
2686pub fn parse_denied_origins(
2687 raw: Option<&[String]>,
2688) -> anyhow::Result<std::collections::HashSet<alloy::primitives::Address>> {
2689 let mut out = std::collections::HashSet::new();
2690 let Some(entries) = raw else {
2691 return Ok(out);
2692 };
2693 for (idx, entry) in entries.iter().enumerate() {
2694 let label = format!("content.denied_origins[{idx}]");
2695 let addr = crate::address::parse_address(entry.trim(), &label)?;
2696 anyhow::ensure!(
2697 addr != alloy::primitives::Address::ZERO,
2698 "{label} must not be the zero address — list a real operator address, \
2699 or remove the entry"
2700 );
2701 out.insert(addr);
2702 }
2703 Ok(out)
2704}
2705
2706/// Resolve the local content denylist (ADR 011 §Local Denylist).
2707///
2708/// Both lists fail resolution on a malformed entry rather than skipping it. A
2709/// denylist is discharging a legal order; "one line was ignored" is the one
2710/// outcome an operator must never get silently.
2711fn resolve_content_into(
2712 file: Option<&types::ContentConfig>,
2713 bag: &mut ConfigDiagnostics,
2714) -> ResolvedContent {
2715 let denied_hashes = bag
2716 .try_with(
2717 "content.denied_hashes",
2718 parse_denied_hashes(file.and_then(|c| c.denied_hashes.as_deref()))
2719 .context("invalid content.denied_hashes"),
2720 )
2721 .unwrap_or_else(decdn_config_types::DeniedHashes::empty);
2722 let denied_origins = bag
2723 .try_with(
2724 "content.denied_origins",
2725 parse_denied_origins(file.and_then(|c| c.denied_origins.as_deref()))
2726 .context("invalid content.denied_origins"),
2727 )
2728 .unwrap_or_default();
2729 ResolvedContent {
2730 denied_hashes,
2731 denied_origins,
2732 }
2733}
2734
2735/// Resolve payment fields.
2736///
2737/// Rejects `rate_per_mb == 0`: the value participates in the node selection
2738/// score (`rate_per_mb × rtt_ms × …`, ADR 001 § Node Selection Algorithm) and
2739/// feeds the on-chain rate-mismatch evidence path (ADR 014). A zero rate would
2740/// make this node trivially win every client selection while earning no
2741/// payable revenue — an obvious misconfiguration that should fail startup, not
2742/// silently degrade the network.
2743///
2744/// Rejects `rate_per_mb > MAX_RATE_PER_MB` (issue #378): the wire boundary
2745/// enforces the same ceiling on inbound `ProbeResponse`s, so an operator
2746/// configuring a rate above this would publish probe responses that every
2747/// honest client decoder rejects — fail at startup rather than silently
2748/// emit unparseable wire traffic. The bound is also a defense-in-depth
2749/// against the selection-score overflow path (issue #322).
2750#[cfg(test)]
2751pub fn resolve_payment(
2752 cli: &crate::cli::run::PaymentArgs,
2753 file: Option<&types::PaymentConfig>,
2754) -> anyhow::Result<ResolvedPayment> {
2755 one_section(|bag| resolve_payment_into(cli, file, bag))
2756}
2757
2758/// Bag-threading variant of the test-only `resolve_payment` shim. Used by both
2759/// [`resolve_config`] (single bag across every section at startup) and the
2760/// SIGHUP hot-reload path in `runtime::reload` (single bag across every
2761/// reloadable section), so an operator sees every problem in one error
2762/// instead of fixing them one SIGHUP at a time. Always returns a
2763/// [`ResolvedPayment`] (with placeholder values for fields that failed
2764/// validation) so later checks can still run against it.
2765pub fn resolve_payment_into(
2766 cli: &crate::cli::run::PaymentArgs,
2767 file: Option<&types::PaymentConfig>,
2768 bag: &mut ConfigDiagnostics,
2769) -> ResolvedPayment {
2770 let rate_per_mb = cli
2771 .rate_per_mb
2772 .or_else(|| file.and_then(|p| p.rate_per_mb))
2773 .unwrap_or(DEFAULT_RATE_PER_MB);
2774 bag.check(
2775 rate_per_mb > 0,
2776 "payment.rate_per_mb",
2777 "payment.rate_per_mb must be > 0 (used in the node selection score, \
2778 ADR 001); got 0",
2779 );
2780 bag.check_with(
2781 rate_per_mb <= decdn_protocol::MAX_RATE_PER_MB,
2782 "payment.rate_per_mb",
2783 || {
2784 format!(
2785 "payment.rate_per_mb {rate_per_mb} exceeds protocol MAX_RATE_PER_MB ({}); \
2786 honest clients reject `ProbeResponse`s above this ceiling (issue #378)",
2787 decdn_protocol::MAX_RATE_PER_MB,
2788 )
2789 },
2790 );
2791 // Downstream credit-window ceiling (ADR 003 §Credit window). Default 64 MiB;
2792 // no upper bound beyond the runtime deposit guard — a larger ceiling is more
2793 // unbilled egress the node fronts once a stream has ramped up, which the
2794 // operator owns. Floored to one chunk by the serve loop, so no lower
2795 // bound is enforced here.
2796 let credit_max = file
2797 .and_then(|p| p.credit_max.as_ref())
2798 .map_or(DEFAULT_CREDIT_MAX, |b| b.get());
2799 // Ramp divisor (ADR 003 §Credit window). Default 2; `0` (open the full
2800 // ceiling immediately) is a valid setting, so it merges as a first-class
2801 // value rather than falling back to the default.
2802 let credit_ramp_divisor = file
2803 .and_then(|p| p.credit_ramp_divisor)
2804 .unwrap_or(DEFAULT_CREDIT_RAMP_DIVISOR);
2805 // Serve-path wire-frame target (ADR 005 §`cdn/client/v1`). Default and ceiling
2806 // are both one `CHUNK_BYTES` payment interval: a frame never crosses a payment
2807 // boundary, so the serve loop clamps every request to the interval remainder and
2808 // a larger value could not reach the wire. Rejected rather than silently clamped
2809 // — an operator who sets 8 MiB expecting bigger frames deserves to be told the
2810 // value is unreachable, not to watch for a change that never comes. `0` is
2811 // rejected for the opposite reason: it would ask the framers for zero-length
2812 // frames, and an empty `ChunkData` is a protocol error. The framers refuse a
2813 // zero target themselves, so this is the first of two doors, not the only one.
2814 let frame_target_bytes = file
2815 .and_then(|p| p.frame_target_bytes.as_ref())
2816 .map_or(DEFAULT_FRAME_TARGET_BYTES, |b| b.get());
2817 bag.check_with(
2818 frame_target_bytes > 0 && frame_target_bytes <= decdn_protocol::CHUNK_BYTES,
2819 "payment.frame_target_bytes",
2820 || {
2821 format!(
2822 "payment.frame_target_bytes must be in 1..={} (one payment chunk); a \
2823 frame never crosses a payment-chunk boundary, so a larger value \
2824 could never reach the wire",
2825 decdn_protocol::CHUNK_BYTES
2826 )
2827 },
2828 );
2829 // Background flush period (ADR 003 §Off-chain voucher state persistence).
2830 // `0` would build a zero-period `tokio::time::interval`, which panics; reject
2831 // it so an operator wanting tight durability sets a small positive value.
2832 let voucher_commit_interval_ms = file
2833 .and_then(|p| p.voucher_commit_interval_ms)
2834 .unwrap_or(DEFAULT_VOUCHER_COMMIT_INTERVAL_MS);
2835 bag.check_with(
2836 voucher_commit_interval_ms > 0,
2837 "payment.voucher_commit_interval_ms",
2838 || {
2839 "payment.voucher_commit_interval_ms must be > 0 (a 0 interval is not a \
2840 valid flush period)"
2841 .to_string()
2842 },
2843 );
2844 ResolvedPayment {
2845 rate_per_mb,
2846 credit_max,
2847 credit_ramp_divisor,
2848 frame_target_bytes,
2849 voucher_commit_interval_ms,
2850 }
2851}
2852
2853/// Resolve observability fields.
2854///
2855/// The admin port is merged with `0` as a first-class "disable" value so
2856/// operators can turn the surface off without removing the line from their
2857/// config. Cross-port collision checks (bind/metrics/admin) live in
2858/// `validate_port_layout`, which sees all three sections at once — see
2859/// there for the full ruleset.
2860#[cfg(test)]
2861pub fn resolve_observability(
2862 cli: &crate::cli::run::ObservabilityArgs,
2863 file: Option<&types::ObservabilityConfig>,
2864) -> anyhow::Result<ResolvedObservability> {
2865 one_section(|bag| resolve_observability_into(cli, file, bag))
2866}
2867
2868/// Bag-threading variant of the test-only `resolve_observability` shim. Shares a bag with
2869/// other sections during startup ([`resolve_config`]) and SIGHUP reload
2870/// (`runtime::reload`); see [`resolve_payment_into`] for the rationale.
2871pub fn resolve_observability_into(
2872 cli: &crate::cli::run::ObservabilityArgs,
2873 file: Option<&types::ObservabilityConfig>,
2874 bag: &mut ConfigDiagnostics,
2875) -> ResolvedObservability {
2876 let log_level = cli
2877 .log_level
2878 .or_else(|| file.and_then(|o| o.log_level))
2879 .unwrap_or_default();
2880
2881 let log_format = cli
2882 .log_format
2883 .or_else(|| file.and_then(|o| o.log_format))
2884 .unwrap_or_default();
2885
2886 let metrics_port = cli
2887 .metrics_port
2888 .or_else(|| file.and_then(|o| o.metrics_port))
2889 .unwrap_or(DEFAULT_METRICS_PORT);
2890
2891 let metrics_bind = cli
2892 .metrics_bind
2893 .or_else(|| file.and_then(|o| o.metrics_bind))
2894 .unwrap_or(DEFAULT_METRICS_BIND);
2895
2896 let admin_port_raw = cli
2897 .admin_port
2898 .or_else(|| file.and_then(|o| o.admin_port))
2899 .unwrap_or(DEFAULT_ADMIN_PORT);
2900 let admin_port = if admin_port_raw == 0 {
2901 None
2902 } else {
2903 Some(admin_port_raw)
2904 };
2905
2906 let otlp_endpoint = cli
2907 .otlp_endpoint
2908 .clone()
2909 .or_else(|| file.and_then(|o| o.otlp_endpoint.clone()))
2910 .filter(|s| !s.is_empty());
2911
2912 if let Some(ref ep) = otlp_endpoint {
2913 bag.try_with("observability.otlp_endpoint", validate_otlp_endpoint(ep));
2914 }
2915
2916 ResolvedObservability {
2917 log_level,
2918 log_format,
2919 metrics_port,
2920 metrics_bind,
2921 admin_port,
2922 otlp_endpoint,
2923 }
2924}
2925
2926/// Check that `endpoint` is a URL the node's OTLP gRPC exporter can use:
2927/// `http://host:port` with nothing after the authority.
2928///
2929/// - `http` only: the exporter is built without TLS, so an `https://`
2930/// endpoint would pass resolution and then abort daemon start-up.
2931/// - An explicit port: without one the exporter dials port 80, which is
2932/// almost never an OTLP/gRPC collector. The port is read from the raw
2933/// authority because `Url::port` hides an explicit default port (`:80`).
2934/// - No path, query, fragment, or userinfo: gRPC uses none of them, and an
2935/// OTLP/HTTP URL (`…:4318/v1/traces`) there means the wrong protocol.
2936///
2937/// - No surrounding whitespace, tab, or newline: the URL parser strips them
2938/// but the exporter does not, so such a value would pass here and fail at
2939/// start-up.
2940///
2941/// Errors never echo any part of the endpoint: it can carry credentials, so
2942/// it is redacted like `rpc_url`.
2943fn validate_otlp_endpoint(endpoint: &str) -> anyhow::Result<()> {
2944 anyhow::ensure!(
2945 endpoint.trim() == endpoint && !endpoint.contains(['\t', '\n', '\r']),
2946 "observability.otlp_endpoint must not contain whitespace, tabs, or newlines"
2947 );
2948 // Checked on the raw text, before parsing: `http:host:4317` parses as a URL
2949 // but is not `http://…`, and a scheme-less value would parse its first
2950 // segment (possibly a username) as the scheme.
2951 anyhow::ensure!(
2952 endpoint
2953 .get(..7)
2954 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")),
2955 "observability.otlp_endpoint must start with http://; the OTLP gRPC exporter has \
2956 no TLS, so point it at a local collector and terminate TLS there"
2957 );
2958 let parsed = url::Url::parse(endpoint)
2959 .map_err(|e| anyhow::anyhow!("observability.otlp_endpoint is not a valid URL: {e}"))?;
2960 anyhow::ensure!(
2961 parsed.host().is_some(),
2962 "observability.otlp_endpoint has no host"
2963 );
2964 // Authority = text after `://` up to the first `/`, `?`, or `#`. Read raw:
2965 // the parsed URL reports an empty userinfo (`http://@host`) as absent and
2966 // hides an explicit default port.
2967 let authority = endpoint
2968 .split_once("://")
2969 .map_or("", |(_, rest)| rest)
2970 .split(['/', '?', '#'])
2971 .next()
2972 .unwrap_or_default();
2973 anyhow::ensure!(
2974 !authority.contains('@') && parsed.username().is_empty() && parsed.password().is_none(),
2975 "observability.otlp_endpoint must not carry userinfo; pass collector \
2976 credentials through the collector, not the URL"
2977 );
2978 anyhow::ensure!(
2979 parsed.path() == "/" && parsed.query().is_none() && parsed.fragment().is_none(),
2980 "observability.otlp_endpoint must be http://host:port with no path, query, or \
2981 fragment; an OTLP/HTTP URL (port 4318, /v1/traces) is the wrong protocol"
2982 );
2983 // IPv6 hosts are bracketed, so a trailing `:<digits>` is always a port.
2984 let has_port = authority.rsplit_once(':').is_some_and(|(host, port)| {
2985 !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) && !host.is_empty()
2986 });
2987 anyhow::ensure!(
2988 has_port,
2989 "observability.otlp_endpoint must name the collector port (e.g. http://localhost:4317)"
2990 );
2991 Ok(())
2992}
2993
2994/// Resolve download-receipt audit-log retention fields (#802).
2995///
2996/// `max_file_bytes` is clamped to `[MIN_RECEIPT_MAX_FILE_BYTES,
2997/// MAX_RECEIPT_MAX_FILE_BYTES]` (rejected, not silently clamped) so a typo
2998/// can neither rotate on every line nor defeat rotation. `retained_files`
2999/// accepts `0` (truncate-in-place, no backups) up to
3000/// `MAX_RECEIPT_RETAINED_FILES`.
3001#[cfg(test)]
3002fn resolve_receipts(file: Option<&types::ReceiptsConfig>) -> anyhow::Result<ResolvedReceipts> {
3003 one_section(|bag| resolve_receipts_into(file, bag))
3004}
3005
3006/// Bag-threading worker for the `[receipts]` section. Shares a bag with the
3007/// other sections during startup ([`resolve_config`]); see
3008/// [`resolve_payment_into`] for the rationale. The `#[cfg(test)]`
3009/// `resolve_receipts` shim wraps this for direct unit tests.
3010fn resolve_receipts_into(
3011 file: Option<&types::ReceiptsConfig>,
3012 bag: &mut ConfigDiagnostics,
3013) -> ResolvedReceipts {
3014 let max_file_bytes = file
3015 .and_then(|r| r.max_file_bytes)
3016 .unwrap_or(DEFAULT_RECEIPT_MAX_FILE_BYTES);
3017 bag.check_with(
3018 (MIN_RECEIPT_MAX_FILE_BYTES..=MAX_RECEIPT_MAX_FILE_BYTES).contains(&max_file_bytes),
3019 "receipts.max_file_bytes",
3020 || {
3021 format!(
3022 "receipts.max_file_bytes ({max_file_bytes}) must be within \
3023 [{MIN_RECEIPT_MAX_FILE_BYTES}, {MAX_RECEIPT_MAX_FILE_BYTES}] bytes"
3024 )
3025 },
3026 );
3027
3028 let retained_files = file
3029 .and_then(|r| r.retained_files)
3030 .unwrap_or(DEFAULT_RECEIPT_RETAINED_FILES);
3031 bag.check_with(
3032 retained_files <= MAX_RECEIPT_RETAINED_FILES,
3033 "receipts.retained_files",
3034 || {
3035 format!(
3036 "receipts.retained_files ({retained_files}) must be <= \
3037 {MAX_RECEIPT_RETAINED_FILES}"
3038 )
3039 },
3040 );
3041
3042 ResolvedReceipts {
3043 max_file_bytes,
3044 retained_files,
3045 }
3046}
3047
3048/// Resolve security / rate-limiting fields.
3049///
3050/// Each numeric field accepts `0` as the "disable this layer" sentinel:
3051/// `max_concurrent_handlers = 0` skips the global semaphore acquire,
3052/// `per_*_rate_per_sec = 0.0` skips the corresponding token bucket, and
3053/// `max_tracked_sources = 0` makes the bookkeeping map unbounded (operator
3054/// opt-in — an attacker churning identities can grow the map without
3055/// bound). The `per_*_burst > 0` rule is paired with the matching rate:
3056/// `burst` is irrelevant when `rate == 0` (the layer's `try_consume` short-
3057/// circuits before the bucket is touched), but a `rate > 0` with `burst == 0`
3058/// is a deny-all corner case operators don't actually want — reject it
3059/// outright so a typo turns into a config error rather than a black-hole node.
3060// Each field is a linear "default-or-file → validate → log-if-disabled"
3061// triple; splitting them out would scatter the field-pair invariants
3062// (rate/burst coupling) across helpers that have to take both arguments
3063// anyway. Keep it linear.
3064#[cfg(test)]
3065pub fn resolve_security(file: Option<&types::SecurityConfig>) -> anyhow::Result<ResolvedSecurity> {
3066 one_section(|bag| resolve_security_into(file, bag))
3067}
3068
3069/// Bag-threading variant of the test-only `resolve_security` shim. Shares a bag with other
3070/// sections during startup ([`resolve_config`]) and SIGHUP reload
3071/// (`runtime::reload`); see [`resolve_payment_into`] for the rationale.
3072#[allow(clippy::cognitive_complexity)]
3073pub fn resolve_security_into(
3074 file: Option<&types::SecurityConfig>,
3075 bag: &mut ConfigDiagnostics,
3076) -> ResolvedSecurity {
3077 let max_concurrent_handlers = file
3078 .and_then(|s| s.max_concurrent_handlers)
3079 .unwrap_or(DEFAULT_MAX_CONCURRENT_HANDLERS);
3080 // `tokio::sync::Semaphore` panics if asked to hold more than
3081 // `MAX_PERMITS` (= `usize::MAX >> 3`). On 64-bit this is ~2.3×10^18
3082 // so any `u32` is safe; on 32-bit it's ~5.4×10^8 and any
3083 // `max_concurrent_handlers > MAX_PERMITS` would crash startup *or*
3084 // a SIGHUP-driven reload via `add_permits`. Reject at config time
3085 // so the failure mode is "node refuses to boot with a clear
3086 // error" rather than "node panics on next reload."
3087 let max_permits = tokio::sync::Semaphore::MAX_PERMITS;
3088 bag.check_with(
3089 usize::try_from(max_concurrent_handlers).is_ok_and(|v| v <= max_permits),
3090 "security.max_concurrent_handlers",
3091 || format!(
3092 "security.max_concurrent_handlers={max_concurrent_handlers} exceeds tokio Semaphore::MAX_PERMITS={max_permits} on this target"
3093 ),
3094 );
3095
3096 let per_source_rate_per_sec = file
3097 .and_then(|s| s.per_source_rate_per_sec)
3098 .unwrap_or(DEFAULT_PER_SOURCE_RATE_PER_SEC);
3099 bag.check(
3100 per_source_rate_per_sec.is_finite() && per_source_rate_per_sec >= 0.0,
3101 "security.per_source_rate_per_sec",
3102 "security.per_source_rate_per_sec must be a finite non-negative number \
3103 (0 disables the per-source layer)",
3104 );
3105
3106 let per_source_burst = file
3107 .and_then(|s| s.per_source_burst)
3108 .unwrap_or(DEFAULT_PER_SOURCE_BURST);
3109 bag.check(
3110 per_source_rate_per_sec == 0.0 || per_source_burst > 0,
3111 "security.per_source_burst",
3112 "security.per_source_burst must be > 0 when per_source_rate_per_sec > 0 \
3113 (set both to 0 to disable the per-source layer)",
3114 );
3115
3116 let max_tracked_sources = file
3117 .and_then(|s| s.max_tracked_sources)
3118 .unwrap_or(DEFAULT_MAX_TRACKED_SOURCES);
3119
3120 if max_concurrent_handlers == 0 {
3121 bag.note(
3122 "security.max_concurrent_handlers",
3123 "0: global concurrency cap disabled",
3124 );
3125 }
3126 if per_source_rate_per_sec == 0.0 {
3127 bag.note(
3128 "security.per_source_rate_per_sec",
3129 "0: per-source rate-limit disabled",
3130 );
3131 }
3132 if max_tracked_sources == 0 {
3133 bag.warn(
3134 "security.max_tracked_sources",
3135 "0: rate-limit bookkeeping map is unbounded; an attacker churning sources can \
3136 grow it without limit",
3137 );
3138 }
3139
3140 ResolvedSecurity {
3141 max_concurrent_handlers,
3142 per_source_rate_per_sec,
3143 per_source_burst,
3144 max_tracked_sources,
3145 }
3146}
3147
3148/// Resolve node-local load-shedding thresholds.
3149#[cfg(test)]
3150pub fn resolve_load_shed(file: Option<&types::LoadShedConfig>) -> anyhow::Result<ResolvedLoadShed> {
3151 one_section(|bag| resolve_load_shed_into(file, bag))
3152}
3153
3154/// Bag-threading variant of the test-only `resolve_load_shed` shim. Shares a bag with other
3155/// sections during startup ([`resolve_config`]) and SIGHUP reload
3156/// (`runtime::reload`); see [`resolve_payment_into`] for the rationale.
3157pub fn resolve_load_shed_into(
3158 file: Option<&types::LoadShedConfig>,
3159 bag: &mut ConfigDiagnostics,
3160) -> ResolvedLoadShed {
3161 let policy = match file.and_then(|c| c.policy.as_deref()) {
3162 None | Some("resource-pressure") => LoadShedPolicyKind::ResourcePressure,
3163 Some("always-admit") => LoadShedPolicyKind::AlwaysAdmit,
3164 Some(other) => {
3165 bag.push(
3166 "load_shed.policy",
3167 format!(
3168 "load_shed.policy must be \"resource-pressure\" or \"always-admit\", got {other:?}"
3169 ),
3170 );
3171 LoadShedPolicyKind::ResourcePressure
3172 }
3173 };
3174 let high = file
3175 .and_then(|c| c.max_concurrent_serves_high)
3176 .unwrap_or(DEFAULT_LOAD_SHED_SERVES_HIGH);
3177 let low = file
3178 .and_then(|c| c.max_concurrent_serves_low)
3179 .unwrap_or(DEFAULT_LOAD_SHED_SERVES_LOW);
3180 bag.check_with(high >= low, "load_shed.max_concurrent_serves_high", || {
3181 format!("load_shed.max_concurrent_serves_high ({high}) must be >= _low ({low})")
3182 });
3183 ResolvedLoadShed {
3184 policy,
3185 egress_budget_mbps: file
3186 .and_then(|c| c.egress_budget_mbps)
3187 .unwrap_or(DEFAULT_LOAD_SHED_EGRESS_BUDGET_MBPS),
3188 max_concurrent_serves_high: high,
3189 max_concurrent_serves_low: low,
3190 per_client_serve_cap: file
3191 .and_then(|c| c.per_client_serve_cap)
3192 .unwrap_or(DEFAULT_LOAD_SHED_PER_CLIENT_CAP),
3193 }
3194}
3195
3196/// Resolve `cdn/dht/v1` rate-limit settings (ADR 022 §DHT Rate Limiting).
3197///
3198/// Each `*_rate_per_sec == 0.0` paired with `*_burst == 0` disables that
3199/// layer (operator opt-out). Mixing `rate > 0` with `burst == 0` is
3200/// rejected as a deny-all corner case — the resolver treats it the same
3201/// way [`resolve_security_into`] handles the `per_source` pairing.
3202#[allow(clippy::cognitive_complexity)] // linear "default-or-file → validate" rows.
3203pub fn resolve_dht_into(
3204 file: Option<&types::DhtConfig>,
3205 bag: &mut ConfigDiagnostics,
3206) -> ResolvedDht {
3207 // ADR 022 nests the rate-limit knobs under `dht.rate_limit.*`.
3208 // The file shape mirrors that; an absent `[dht.rate_limit]` collapses
3209 // to "all defaults" through the same `.and_then` chain the other
3210 // resolvers use.
3211 let rate_limit = file.and_then(|d| d.rate_limit.as_ref());
3212 let per_peer_rate_per_sec = rate_limit
3213 .and_then(|r| r.per_peer_rate_per_sec)
3214 .unwrap_or(DEFAULT_DHT_PER_PEER_RATE_PER_SEC);
3215 bag.check(
3216 per_peer_rate_per_sec.is_finite() && per_peer_rate_per_sec >= 0.0,
3217 "dht.rate_limit.per_peer_rate_per_sec",
3218 "dht.rate_limit.per_peer_rate_per_sec must be a finite non-negative number (0 disables the layer)",
3219 );
3220 let per_peer_burst = rate_limit
3221 .and_then(|r| r.per_peer_burst)
3222 .unwrap_or(DEFAULT_DHT_PER_PEER_BURST);
3223 bag.check(
3224 per_peer_rate_per_sec == 0.0 || per_peer_burst > 0,
3225 "dht.rate_limit.per_peer_burst",
3226 "dht.rate_limit.per_peer_burst must be > 0 when per_peer_rate_per_sec > 0 (set both to 0 to disable)",
3227 );
3228
3229 let per_ip_rate_per_sec = rate_limit
3230 .and_then(|r| r.per_ip_rate_per_sec)
3231 .unwrap_or(DEFAULT_DHT_PER_IP_RATE_PER_SEC);
3232 bag.check(
3233 per_ip_rate_per_sec.is_finite() && per_ip_rate_per_sec >= 0.0,
3234 "dht.rate_limit.per_ip_rate_per_sec",
3235 "dht.rate_limit.per_ip_rate_per_sec must be a finite non-negative number (0 disables the layer)",
3236 );
3237 let per_ip_burst = rate_limit
3238 .and_then(|r| r.per_ip_burst)
3239 .unwrap_or(DEFAULT_DHT_PER_IP_BURST);
3240 bag.check(
3241 per_ip_rate_per_sec == 0.0 || per_ip_burst > 0,
3242 "dht.rate_limit.per_ip_burst",
3243 "dht.rate_limit.per_ip_burst must be > 0 when per_ip_rate_per_sec > 0 (set both to 0 to disable)",
3244 );
3245
3246 let global_rate_per_sec = rate_limit
3247 .and_then(|r| r.global_rate_per_sec)
3248 .unwrap_or(DEFAULT_DHT_GLOBAL_RATE_PER_SEC);
3249 bag.check(
3250 global_rate_per_sec.is_finite() && global_rate_per_sec >= 0.0,
3251 "dht.rate_limit.global_rate_per_sec",
3252 "dht.rate_limit.global_rate_per_sec must be a finite non-negative number (0 disables the layer)",
3253 );
3254 let global_burst = rate_limit
3255 .and_then(|r| r.global_burst)
3256 .unwrap_or(DEFAULT_DHT_GLOBAL_BURST);
3257 bag.check(
3258 global_rate_per_sec == 0.0 || global_burst > 0,
3259 "dht.rate_limit.global_burst",
3260 "dht.rate_limit.global_burst must be > 0 when global_rate_per_sec > 0 (set both to 0 to disable)",
3261 );
3262
3263 let max_tracked_per_ip = rate_limit
3264 .and_then(|r| r.max_tracked_per_ip)
3265 .unwrap_or(DEFAULT_DHT_MAX_TRACKED_PER_IP);
3266 let max_tracked_per_peer = rate_limit
3267 .and_then(|r| r.max_tracked_per_peer)
3268 .unwrap_or(DEFAULT_DHT_MAX_TRACKED_PER_PEER);
3269 if max_tracked_per_ip == 0 {
3270 bag.warn(
3271 "dht.rate_limit.max_tracked_per_ip",
3272 "0: per-IP bookkeeping map is unbounded; an attacker churning source IPs can \
3273 grow it without limit",
3274 );
3275 }
3276 if max_tracked_per_peer == 0 {
3277 bag.warn(
3278 "dht.rate_limit.max_tracked_per_peer",
3279 "0: per-peer bookkeeping map is unbounded; an attacker churning NodeIds can \
3280 grow it without limit",
3281 );
3282 }
3283
3284 ResolvedDht {
3285 per_peer_rate_per_sec,
3286 per_peer_burst,
3287 per_ip_rate_per_sec,
3288 per_ip_burst,
3289 global_rate_per_sec,
3290 global_burst,
3291 max_tracked_per_ip,
3292 max_tracked_per_peer,
3293 }
3294}
3295
3296/// Convenience wrapper for [`resolve_dht_into`] that takes a fresh
3297/// `ConfigDiagnostics`. Test-only.
3298#[cfg(test)]
3299pub fn resolve_dht(file: Option<&types::DhtConfig>) -> anyhow::Result<ResolvedDht> {
3300 one_section(|bag| resolve_dht_into(file, bag))
3301}
3302
3303/// Resolve the `[probe.rate_limit]` section into [`ResolvedProbe`], applying
3304/// the ADR 005 §Probe rate limiting defaults and the same validation the DHT
3305/// layer uses: each `*_rate_per_sec` finite and `>= 0` (0 disables the layer),
3306/// and the matching `*_burst > 0` whenever its rate is `> 0` (no deny-all).
3307/// Mirrors [`resolve_dht_into`]; only the
3308/// defaults and the `probe.rate_limit.*` field keys differ.
3309#[allow(clippy::cognitive_complexity)] // linear "default-or-file → validate" rows.
3310pub fn resolve_probe_into(
3311 file: Option<&types::ProbeConfig>,
3312 bag: &mut ConfigDiagnostics,
3313) -> ResolvedProbe {
3314 // ADR 005 nests the rate-limit knobs under `probe.rate_limit.*`.
3315 let rate_limit = file.and_then(|p| p.rate_limit.as_ref());
3316 let per_peer_rate_per_sec = rate_limit
3317 .and_then(|r| r.per_peer_rate_per_sec)
3318 .unwrap_or(DEFAULT_PROBE_PER_PEER_RATE_PER_SEC);
3319 bag.check(
3320 per_peer_rate_per_sec.is_finite() && per_peer_rate_per_sec >= 0.0,
3321 "probe.rate_limit.per_peer_rate_per_sec",
3322 "probe.rate_limit.per_peer_rate_per_sec must be a finite non-negative number (0 disables the layer)",
3323 );
3324 let per_peer_burst = rate_limit
3325 .and_then(|r| r.per_peer_burst)
3326 .unwrap_or(DEFAULT_PROBE_PER_PEER_BURST);
3327 bag.check(
3328 per_peer_rate_per_sec == 0.0 || per_peer_burst > 0,
3329 "probe.rate_limit.per_peer_burst",
3330 "probe.rate_limit.per_peer_burst must be > 0 when per_peer_rate_per_sec > 0 (set both to 0 to disable)",
3331 );
3332
3333 let per_ip_rate_per_sec = rate_limit
3334 .and_then(|r| r.per_ip_rate_per_sec)
3335 .unwrap_or(DEFAULT_PROBE_PER_IP_RATE_PER_SEC);
3336 bag.check(
3337 per_ip_rate_per_sec.is_finite() && per_ip_rate_per_sec >= 0.0,
3338 "probe.rate_limit.per_ip_rate_per_sec",
3339 "probe.rate_limit.per_ip_rate_per_sec must be a finite non-negative number (0 disables the layer)",
3340 );
3341 let per_ip_burst = rate_limit
3342 .and_then(|r| r.per_ip_burst)
3343 .unwrap_or(DEFAULT_PROBE_PER_IP_BURST);
3344 bag.check(
3345 per_ip_rate_per_sec == 0.0 || per_ip_burst > 0,
3346 "probe.rate_limit.per_ip_burst",
3347 "probe.rate_limit.per_ip_burst must be > 0 when per_ip_rate_per_sec > 0 (set both to 0 to disable)",
3348 );
3349
3350 let global_rate_per_sec = rate_limit
3351 .and_then(|r| r.global_rate_per_sec)
3352 .unwrap_or(DEFAULT_PROBE_GLOBAL_RATE_PER_SEC);
3353 bag.check(
3354 global_rate_per_sec.is_finite() && global_rate_per_sec >= 0.0,
3355 "probe.rate_limit.global_rate_per_sec",
3356 "probe.rate_limit.global_rate_per_sec must be a finite non-negative number (0 disables the layer)",
3357 );
3358 let global_burst = rate_limit
3359 .and_then(|r| r.global_burst)
3360 .unwrap_or(DEFAULT_PROBE_GLOBAL_BURST);
3361 bag.check(
3362 global_rate_per_sec == 0.0 || global_burst > 0,
3363 "probe.rate_limit.global_burst",
3364 "probe.rate_limit.global_burst must be > 0 when global_rate_per_sec > 0 (set both to 0 to disable)",
3365 );
3366
3367 let max_tracked_per_ip = rate_limit
3368 .and_then(|r| r.max_tracked_per_ip)
3369 .unwrap_or(DEFAULT_PROBE_MAX_TRACKED_PER_IP);
3370 let max_tracked_per_peer = rate_limit
3371 .and_then(|r| r.max_tracked_per_peer)
3372 .unwrap_or(DEFAULT_PROBE_MAX_TRACKED_PER_PEER);
3373 if max_tracked_per_ip == 0 {
3374 bag.warn(
3375 "probe.rate_limit.max_tracked_per_ip",
3376 "0: per-IP bookkeeping map is unbounded; an attacker churning source IPs can \
3377 grow it without limit",
3378 );
3379 }
3380 if max_tracked_per_peer == 0 {
3381 bag.warn(
3382 "probe.rate_limit.max_tracked_per_peer",
3383 "0: per-peer bookkeeping map is unbounded; an attacker churning NodeIds can \
3384 grow it without limit",
3385 );
3386 }
3387
3388 ResolvedProbe {
3389 per_peer_rate_per_sec,
3390 per_peer_burst,
3391 per_ip_rate_per_sec,
3392 per_ip_burst,
3393 global_rate_per_sec,
3394 global_burst,
3395 max_tracked_per_ip,
3396 max_tracked_per_peer,
3397 }
3398}
3399
3400/// Convenience wrapper for [`resolve_probe_into`] that takes a fresh
3401/// `ConfigDiagnostics`. Test-only.
3402#[cfg(test)]
3403pub fn resolve_probe(file: Option<&types::ProbeConfig>) -> anyhow::Result<ResolvedProbe> {
3404 one_section(|bag| resolve_probe_into(file, bag))
3405}
3406
3407/// Load a [`FileConfig`] from disk.
3408///
3409/// - If `explicit_path` is `Some`, reads that file (errors if missing).
3410/// - If `explicit_path` is `None`, tries the default path; returns
3411/// `FileConfig::default()` if the file does not exist.
3412pub fn load_file_config(explicit_path: Option<&Path>) -> anyhow::Result<FileConfig> {
3413 let path = match explicit_path {
3414 Some(p) => p.to_path_buf(),
3415 None => match common::default_config_path() {
3416 Some(p) if p.exists() => p,
3417 _ => return Ok(FileConfig::default()),
3418 },
3419 };
3420
3421 let contents = std::fs::read_to_string(&path)
3422 .map_err(|e| anyhow::anyhow!("failed to read config file {}: {e}", path.display()))?;
3423
3424 let mut config: FileConfig = toml::from_str(&contents)
3425 .map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", path.display()))?;
3426
3427 expand_env(&mut config)?;
3428
3429 Ok(config)
3430}
3431
3432/// Expand `${VAR}` and a leading `~` in the TOML config fields listed
3433/// below. Missing env vars produce a contextual error naming the offending
3434/// field. Enables a single TOML template to be reused across container and
3435/// Kubernetes deployments without file mutation.
3436///
3437/// Bare `$VAR` (without braces) is intentionally **not** expanded: many
3438/// legitimate config values contain a literal `$` (URLs with basic-auth
3439/// passwords, query parameters, contract addresses), and eager expansion
3440/// would turn those into spurious "undefined env var" errors. Operators
3441/// wanting substitution must use the explicit `${VAR}` form.
3442///
3443/// The substitution has **no escape semantics** — backslashes pass through
3444/// verbatim. This matters on Windows, where values like
3445/// `C:\Users\${USER}\data` must still have `${USER}` expanded; a shell-style
3446/// escape interpreter would treat `\$` as a literal `$` and skip the
3447/// expansion.
3448fn expand_env(cfg: &mut FileConfig) -> anyhow::Result<()> {
3449 if let Some(i) = cfg.identity.as_mut() {
3450 expand_path(&mut i.data_dir, "identity.data_dir")?;
3451 expand_str(&mut i.region, "identity.region")?;
3452 }
3453 if let Some(n) = cfg.network.as_mut() {
3454 if let Some(urls) = n.relay_urls.as_mut() {
3455 for (idx, url) in urls.iter_mut().enumerate() {
3456 *url = expand_value(url, &format!("network.relay_urls[{idx}]"))?;
3457 }
3458 }
3459 // #863: `[network.discovery]` fields were skipped by `${VAR}` expansion
3460 // unlike their `relay_urls` sibling — `dns_origin = "${DNS_ORIGIN}"`
3461 // passed the non-empty check and reached bring-up as a literal string,
3462 // silently breaking peer resolution. Mirror the relay loop here.
3463 if let Some(d) = n.discovery.as_mut() {
3464 expand_str(&mut d.pkarr_url, "network.discovery.pkarr_url")?;
3465 expand_str(&mut d.dns_origin, "network.discovery.dns_origin")?;
3466 if let Some(peers) = d.peers.as_mut() {
3467 // `HashMap` iteration order is nondeterministic; sort by peer id
3468 // so that when two peers both carry a bad `${VAR}`, the `?`
3469 // surfaces a stable error across runs. Mirrors the sort in
3470 // `resolve_discovery_into`. Unstable sort: keys are unique.
3471 let mut entries: Vec<_> = peers.iter_mut().collect();
3472 entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
3473 for (id, peer) in entries {
3474 expand_str(
3475 &mut peer.relay_url,
3476 &format!("network.discovery.peers[{id}].relay_url"),
3477 )?;
3478 for (idx, addr) in peer.addrs.iter_mut().enumerate() {
3479 *addr = expand_value(
3480 addr,
3481 &format!("network.discovery.peers[{id}].addrs[{idx}]"),
3482 )?;
3483 }
3484 }
3485 }
3486 }
3487 }
3488 if let Some(b) = cfg.blockchain.as_mut() {
3489 expand_str(&mut b.rpc_url, "blockchain.rpc_url")?;
3490 expand_path(&mut b.eth_keystore, "blockchain.eth_keystore")?;
3491 expand_str(
3492 &mut b.payment_pool_address,
3493 "blockchain.payment_pool_address",
3494 )?;
3495 expand_str(
3496 &mut b.capacity_bond_address,
3497 "blockchain.capacity_bond_address",
3498 )?;
3499 expand_str(
3500 &mut b.origin_assignment_address,
3501 "blockchain.origin_assignment_address",
3502 )?;
3503 expand_str(
3504 &mut b.publisher_registry_address,
3505 "blockchain.publisher_registry_address",
3506 )?;
3507 expand_str(&mut b.slash_judge_address, "blockchain.slash_judge_address")?;
3508 expand_str(
3509 &mut b.slash_appeal_address,
3510 "blockchain.slash_appeal_address",
3511 )?;
3512 expand_str(
3513 &mut b.content_blacklist_address,
3514 "blockchain.content_blacklist_address",
3515 )?;
3516 }
3517 if let Some(c) = cfg.cache.as_mut() {
3518 expand_path(&mut c.cache_dir, "cache.cache_dir")?;
3519 if let Some(origin) = c.origin.as_mut() {
3520 expand_origin(origin, "cache.origin")?;
3521 }
3522 if let Some(list) = c.origins.as_mut() {
3523 for (idx, entry) in list.iter_mut().enumerate() {
3524 let path_ctx = format!("cache.origins[{idx}]");
3525 expand_origin(entry, &path_ctx)?;
3526 }
3527 }
3528 expand_str(&mut c.user_agent, "cache.user_agent")?;
3529 }
3530 if let Some(o) = cfg.observability.as_mut() {
3531 expand_str(&mut o.otlp_endpoint, "observability.otlp_endpoint")?;
3532 }
3533 Ok(())
3534}
3535
3536fn expand_str(field: &mut Option<String>, ctx: &str) -> anyhow::Result<()> {
3537 if let Some(s) = field.as_mut() {
3538 *s = expand_value(s, ctx)?;
3539 }
3540 Ok(())
3541}
3542
3543/// Walk an [`types::OriginConfig`] and run `${VAR}` / leading-`~`
3544/// expansion on every URL and path field. Sibling of the per-section
3545/// expansion blocks in [`expand_env`]; lifted out because the cache
3546/// origin is a tagged enum with backend-specific shape. `prefix` names
3547/// the TOML path of the containing table (`"cache.origin"` for the
3548/// singular form, `"cache.origins[i]"` for the array form) so error
3549/// messages carry the operator-visible field path.
3550fn expand_origin(origin: &mut types::OriginConfig, prefix: &str) -> anyhow::Result<()> {
3551 match origin {
3552 types::OriginConfig::Http { url, .. } => {
3553 *url = expand_value(url, &format!("{prefix}.url"))?;
3554 }
3555 types::OriginConfig::Fs { path } => {
3556 let as_str = path.to_string_lossy();
3557 let expanded = expand_value(&as_str, &format!("{prefix}.path"))?;
3558 *path = PathBuf::from(expanded);
3559 }
3560 types::OriginConfig::S3(s3) => {
3561 s3.bucket = expand_value(&s3.bucket, &format!("{prefix}.bucket"))?;
3562 s3.region = expand_value(&s3.region, &format!("{prefix}.region"))?;
3563 expand_str(&mut s3.endpoint_url, &format!("{prefix}.endpoint_url"))?;
3564 expand_str(&mut s3.prefix, &format!("{prefix}.prefix"))?;
3565 if let Some(creds) = s3.credentials.as_mut() {
3566 match creds {
3567 types::S3Credentials::Static {
3568 access_key_id,
3569 secret_access_key,
3570 session_token,
3571 } => {
3572 expand_secret(
3573 access_key_id,
3574 &format!("{prefix}.credentials.access_key_id"),
3575 )?;
3576 expand_secret(
3577 secret_access_key,
3578 &format!("{prefix}.credentials.secret_access_key"),
3579 )?;
3580 if let Some(token) = session_token.as_mut() {
3581 expand_secret(token, &format!("{prefix}.credentials.session_token"))?;
3582 }
3583 }
3584 types::S3Credentials::DefaultChain { profile } => {
3585 expand_str(profile, &format!("{prefix}.credentials.profile"))?;
3586 }
3587 }
3588 }
3589 }
3590 }
3591 Ok(())
3592}
3593
3594fn expand_path(field: &mut Option<PathBuf>, ctx: &str) -> anyhow::Result<()> {
3595 if let Some(p) = field.as_mut() {
3596 let as_str = p.to_string_lossy();
3597 let expanded = expand_value(&as_str, ctx)?;
3598 *p = PathBuf::from(expanded);
3599 }
3600 Ok(())
3601}
3602
3603/// `${VAR}` expansion for a [`secret::SecretString`] field. The
3604/// expansion runs against the raw secret value via
3605/// [`secret::SecretString::expose`], then re-wraps the result so the
3606/// redacted-Debug invariant is preserved at every other call site.
3607///
3608/// `expand_value`'s error chain only mentions the field's dotted path
3609/// and the missing env-var name — never the partially-expanded value
3610/// — so a missing env-var error here cannot leak any cleartext that
3611/// happens to precede the `${VAR}` marker (verified via the existing
3612/// `expand_value` contract; see `expand_braces` in
3613/// `crates/common/src/cli/common.rs`).
3614fn expand_secret(field: &mut secret::SecretString, ctx: &str) -> anyhow::Result<()> {
3615 let expanded = expand_value(field.expose(), ctx)?;
3616 *field = secret::SecretString::new(expanded);
3617 Ok(())
3618}
3619
3620fn expand_value(raw: &str, ctx: &str) -> anyhow::Result<String> {
3621 if !needs_expansion(raw) {
3622 return Ok(raw.to_string());
3623 }
3624 let tilde_expanded = expand_tilde_prefix(raw, ctx)?;
3625 expand_braces(&tilde_expanded, ctx)
3626}
3627
3628/// Returns true if `raw` contains an expansion marker (`${` or leading `~`).
3629/// Values without a marker are passed through verbatim so literal `$` stays
3630/// literal (see [`expand_env`] for rationale).
3631fn needs_expansion(raw: &str) -> bool {
3632 raw.contains("${") || raw.starts_with('~')
3633}
3634
3635/// Replace a leading `~`, `~/`, or (on Windows) `~\` with the user's home
3636/// directory. Other occurrences of `~` (e.g. in the middle of a string) are
3637/// left alone. Errors contextually if the home directory is not resolvable,
3638/// rather than silently returning a literal `~` that downstream file I/O
3639/// would later fail on with an unrelated error.
3640fn expand_tilde_prefix(raw: &str, ctx: &str) -> anyhow::Result<String> {
3641 if raw == "~" {
3642 let home = dirs::home_dir().ok_or_else(|| missing_home_err(ctx))?;
3643 return Ok(home.to_string_lossy().into_owned());
3644 }
3645 let rest = raw.strip_prefix("~/").or_else(|| {
3646 if cfg!(windows) {
3647 raw.strip_prefix(r"~\")
3648 } else {
3649 None
3650 }
3651 });
3652 if let Some(rest) = rest {
3653 let home = dirs::home_dir().ok_or_else(|| missing_home_err(ctx))?;
3654 return Ok(home.join(rest).to_string_lossy().into_owned());
3655 }
3656 Ok(raw.to_string())
3657}
3658
3659fn missing_home_err(ctx: &str) -> anyhow::Error {
3660 anyhow::anyhow!("config field `{ctx}` uses `~` but home directory is not available")
3661}
3662
3663/// Substitute `${VAR}` sequences with the corresponding env var value. No
3664/// escape semantics — backslashes, single `$`, and any other character pass
3665/// through verbatim. This is important for Windows paths like
3666/// `C:\Users\${USER}\data`, where a shell-style escape interpreter would
3667/// swallow the `\` before `$` and disable the substitution.
3668fn expand_braces(raw: &str, ctx: &str) -> anyhow::Result<String> {
3669 let mut out = String::with_capacity(raw.len());
3670 let mut rest = raw;
3671 while let Some((before, after)) = rest.split_once("${") {
3672 out.push_str(before);
3673 let (name, tail) = after.split_once('}').ok_or_else(|| {
3674 anyhow::anyhow!("config field `{ctx}` has unterminated `${{` sequence")
3675 })?;
3676 let value = match std::env::var(name) {
3677 Ok(v) => v,
3678 Err(std::env::VarError::NotPresent) => {
3679 anyhow::bail!("config field `{ctx}` references undefined env var `{name}`")
3680 }
3681 Err(std::env::VarError::NotUnicode(_)) => anyhow::bail!(
3682 "config field `{ctx}` references env var `{name}` whose value is not valid UTF-8"
3683 ),
3684 };
3685 out.push_str(&value);
3686 rest = tail;
3687 }
3688 out.push_str(rest);
3689 Ok(out)
3690}
3691
3692#[cfg(test)]
3693#[allow(
3694 clippy::unwrap_used,
3695 clippy::expect_used,
3696 clippy::panic,
3697 clippy::indexing_slicing
3698)]
3699mod tests;