ant_core/data/client/mod.rs
1//! Client operations for the Autonomi network.
2//!
3//! Provides high-level APIs for storing and retrieving data
4//! on the Autonomi decentralized network.
5
6pub mod adaptive;
7pub mod batch;
8pub mod cache;
9pub(crate) mod cached_merkle;
10pub(crate) mod cached_single;
11pub mod chunk;
12pub mod data;
13pub mod file;
14pub mod merkle;
15pub mod payment;
16pub(crate) mod peer_cache;
17pub mod quote;
18
19use crate::data::client::adaptive::{AdaptiveConfig, AdaptiveController, ChannelStart, Outcome};
20use crate::data::client::cache::ChunkCache;
21use crate::data::error::{Error, Result};
22use crate::data::network::Network;
23use ant_protocol::evm::Wallet;
24use ant_protocol::transport::{MultiAddr, P2PNode, PeerId};
25use ant_protocol::{XorName, CLOSE_GROUP_SIZE};
26use std::path::PathBuf;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::Arc;
29use tracing::debug;
30
31/// Classify a `data::error::Error` into a controller `Outcome`.
32///
33/// Capacity signals (Timeout / NetworkError) drive the controller
34/// down; application errors do not. The mapping is conservative:
35/// anything that COULD be transport-related is treated as a network
36/// signal, because under-classifying a real network failure as
37/// "application error" makes the controller blind to genuine stress.
38///
39/// Mapping policy:
40/// - `Timeout` -> `Timeout` (per-op deadline elapsed)
41/// - `Network`, `InsufficientPeers`, `Io` -> `NetworkError` (transport
42/// layer reported failure)
43/// - `Protocol`, `Storage` -> `NetworkError` (these wrap remote errors
44/// that frequently include peer disconnects mid-stream — under
45/// network stress these are how transport failures surface)
46/// - `PartialUpload` -> `NetworkError` (literal capacity signal: some
47/// chunks could not be stored)
48/// - `AlreadyStored`, `Encryption`, `Crypto`, `Payment`,
49/// `Serialization`, `InvalidData`, `SignatureVerification`,
50/// `Config`, `InsufficientDiskSpace`, `CostEstimationInconclusive`
51/// -> `ApplicationError` (would happen on a perfectly healthy link)
52pub(crate) fn classify_error(err: &Error) -> Outcome {
53 match err {
54 Error::Timeout(_) => Outcome::Timeout,
55 Error::Network(_)
56 | Error::InsufficientPeers(_)
57 | Error::Io(_)
58 | Error::Protocol(_)
59 | Error::Storage(_)
60 | Error::PartialUpload { .. } => Outcome::NetworkError,
61 Error::AlreadyStored
62 | Error::Encryption(_)
63 | Error::Crypto(_)
64 | Error::Payment(_)
65 | Error::Serialization(_)
66 | Error::InvalidData(_)
67 | Error::SignatureVerification(_)
68 | Error::Config(_)
69 | Error::InsufficientDiskSpace(_)
70 | Error::CostEstimationInconclusive(_)
71 | Error::BadQuoteBinding { .. } => Outcome::ApplicationError,
72 }
73}
74
75/// Compute XOR distance between a peer's ID bytes and a target address.
76///
77/// Uses the first 32 bytes of the peer ID (or fewer if shorter) XORed
78/// with the target address. The returned byte array sorts
79/// lexicographically from closest to furthest.
80pub(crate) fn peer_xor_distance(peer_id: &PeerId, target: &[u8; 32]) -> [u8; 32] {
81 let peer_bytes = peer_id.as_bytes();
82 let mut distance = [0u8; 32];
83 for (i, d) in distance.iter_mut().enumerate() {
84 let peer_byte = peer_bytes.get(i).copied().unwrap_or(0);
85 *d = peer_byte ^ target[i];
86 }
87 distance
88}
89
90/// Default timeout for lightweight network operations (quotes, DHT lookups) in seconds.
91const DEFAULT_QUOTE_TIMEOUT_SECS: u64 = 10;
92
93/// Default timeout for the per-peer chunk GET response and any other
94/// caller that explicitly reads `store_timeout_secs`, in seconds.
95///
96/// Note despite the name: this knob does **not** govern the non-merkle
97/// chunk PUT response timeout — that path uses the
98/// `STORE_RESPONSE_TIMEOUT` constant in `chunk.rs` directly. Nor does
99/// it govern the merkle batch PUT timeout — see
100/// `DEFAULT_MERKLE_STORE_TIMEOUT_SECS`.
101///
102/// 10 s matches the pre-existing `main` default and intentionally
103/// excludes residential-upload tuning, which is Mick's PR #78
104/// territory (splitting GET into its own field).
105const DEFAULT_STORE_TIMEOUT_SECS: u64 = 10;
106
107/// Default timeout for **merkle batch** chunk store operations in seconds.
108///
109/// Separate from `DEFAULT_STORE_TIMEOUT_SECS` because merkle PUTs carry
110/// an extra storer-side cost: the payment verifier runs an iterative
111/// DHT lookup (`CLOSENESS_LOOKUP_TIMEOUT` in `ant-node`, **240 s**
112/// post-PR #89) before accepting the proof.
113///
114/// This timeout MUST be >= the storer-side `CLOSENESS_LOOKUP_TIMEOUT`
115/// plus padding for the store-response round-trip and storer-local
116/// I/O. Otherwise the client gives up while the storer is still
117/// happily verifying, the storer wastes CPU/bandwidth on a chunk the
118/// client has already discarded, and the client re-targets a
119/// different close-K member — potentially double-storing the same
120/// chunk and polluting routing.
121///
122/// 270 s = 240 s (storer lookup) + 30 s padding (network RTT + LMDB
123/// put + fsync + clock skew tolerance).
124///
125/// This invariant must be re-validated if either side's timeout
126/// changes. Empirically surfaced as "every cross-region merkle chunk
127/// times out at 10 s" on a 210-node 7-region testnet run on
128/// 2026-05-12; bumping to 270 s flipped that 0/22 -> 9/9 pass rate.
129const DEFAULT_MERKLE_STORE_TIMEOUT_SECS: u64 = 270;
130
131/// Default timeout for chunk GET response operations in seconds.
132const DEFAULT_CHUNK_GET_TIMEOUT_SECS: u64 = 10;
133
134/// Default quote concurrency: high because quoting is pure network I/O
135/// (DHT lookups + small request/response messages) with no CPU-bound work.
136const DEFAULT_QUOTE_CONCURRENCY: usize = 32;
137
138/// Default store concurrency: moderate because each chunk PUT sends ~4MB
139/// to 7 close-group peers. At 8 concurrent stores, ~225MB of outbound
140/// traffic can be in flight. Users on fast connections can increase this
141/// with --store-concurrency; users on slow connections can decrease it.
142const DEFAULT_STORE_CONCURRENCY: usize = 8;
143
144/// Configuration for the Autonomi client.
145#[derive(Debug, Clone)]
146pub struct ClientConfig {
147 /// Per-op timeout for lightweight network operations (quotes,
148 /// DHT lookups), in seconds. The adaptive controller does NOT
149 /// currently size timeouts; this remains a static knob.
150 pub quote_timeout_secs: u64,
151 /// Per-op timeout, in seconds, for the chunk GET response path
152 /// (`chunk_get_from_peer`) and any other caller that reads this
153 /// field directly.
154 ///
155 /// Note despite the historical name `store_timeout_secs`: this
156 /// knob does **not** govern the non-merkle chunk PUT response
157 /// timeout (that path uses the `STORE_RESPONSE_TIMEOUT` constant
158 /// in `chunk.rs`) and does **not** govern the merkle batch PUT
159 /// timeout (see `merkle_store_timeout_secs`). Rename pending in
160 /// Mick's PR #78 which adds a dedicated `chunk_get_timeout_secs`.
161 ///
162 /// The adaptive controller does NOT currently size timeouts;
163 /// this remains a static knob.
164 pub store_timeout_secs: u64,
165 /// Per-op timeout for **merkle batch** chunk store (PUT)
166 /// operations, in seconds. Separate from `store_timeout_secs`
167 /// because merkle PUTs incur the storer-side
168 /// `CLOSENESS_LOOKUP_TIMEOUT` (240 s post-PR #89) on top of the
169 /// usual store path; the client must wait at least that long
170 /// plus padding, or the storer wastes work on a chunk the client
171 /// has already given up on. Default 270 s.
172 pub merkle_store_timeout_secs: u64,
173 /// Per-peer response timeout for chunk GET operations, in seconds.
174 /// This is intentionally independent from `store_timeout_secs`: PUTs
175 /// and GETs have different payload direction and performance profiles.
176 pub chunk_get_timeout_secs: u64,
177 /// Number of closest peers to consider for routing.
178 pub close_group_size: usize,
179 /// **Deprecated.** Pre-adaptive ceiling for quote concurrency.
180 ///
181 /// The adaptive controller now sizes quote fan-out from observed
182 /// signals. This field, when non-zero and smaller than the
183 /// controller's per-channel default, clamps the **quote channel
184 /// only** (it does NOT bleed into store or fetch). Removed in a
185 /// future release.
186 pub quote_concurrency: usize,
187 /// **Deprecated.** Pre-adaptive ceiling for store concurrency.
188 ///
189 /// The adaptive controller now sizes store fan-out from observed
190 /// signals. This field, when non-zero and smaller than the
191 /// controller's per-channel default, clamps the **store channel
192 /// only** (it does NOT bleed into quote or fetch). Removed in a
193 /// future release.
194 pub store_concurrency: usize,
195 /// Adaptive controller configuration. Defaults are tuned to match
196 /// or exceed the prior static behavior — disabling adaptation
197 /// (`adaptive.enabled = false`) reverts to the controller's
198 /// `initial` values without re-evaluation.
199 pub adaptive: AdaptiveConfig,
200 /// Allow loopback (`127.0.0.1`) connections in the saorsa-transport
201 /// layer. Set to `true` only for devnet / local testing. Production
202 /// peers on the public Autonomi network reject the QUIC handshake
203 /// variant produced when this is `true`, so the default is `false`.
204 ///
205 /// This mirrors the `--allow-loopback` flag in `ant-cli`, which already
206 /// defaults to `false` and threads through to the same
207 /// `CoreNodeConfig::builder().local(...)` call.
208 pub allow_loopback: bool,
209 /// Bind a dual-stack IPv6 socket (`true`) or an IPv4-only socket
210 /// (`false`). Defaults to `true`, matching the CLI default.
211 ///
212 /// Set to `false` only when running on hosts without a working IPv6
213 /// stack, to avoid advertising unreachable v6 addresses to the DHT
214 /// (which causes slow connects and junk DHT address records). This
215 /// mirrors the `--ipv4-only` flag in `ant-cli`.
216 pub ipv6: bool,
217}
218
219impl Default for ClientConfig {
220 fn default() -> Self {
221 Self {
222 quote_timeout_secs: DEFAULT_QUOTE_TIMEOUT_SECS,
223 store_timeout_secs: DEFAULT_STORE_TIMEOUT_SECS,
224 merkle_store_timeout_secs: DEFAULT_MERKLE_STORE_TIMEOUT_SECS,
225 chunk_get_timeout_secs: DEFAULT_CHUNK_GET_TIMEOUT_SECS,
226 close_group_size: CLOSE_GROUP_SIZE,
227 quote_concurrency: DEFAULT_QUOTE_CONCURRENCY,
228 store_concurrency: DEFAULT_STORE_CONCURRENCY,
229 adaptive: AdaptiveConfig::default(),
230 allow_loopback: false,
231 ipv6: true,
232 }
233 }
234}
235
236/// Build the adaptive controller for a `Client`. Loads any persisted
237/// snapshot, clamps cold-start values into the deprecated-flag bounds
238/// **per channel** (so a pin on `--store-concurrency` does NOT bleed
239/// into the fetch / quote channels), and returns the persistence path
240/// so callers can save back at shutdown.
241fn build_controller(config: &ClientConfig) -> (AdaptiveController, Option<PathBuf>) {
242 let mut adaptive_cfg = config.adaptive.clone();
243
244 // Per-channel ceilings: each legacy field is interpreted as a cap
245 // for ONLY its matching channel. The fetch channel has no
246 // pre-existing legacy field; it always uses the controller's
247 // default ceiling.
248 //
249 // The legacy fields are non-zero by ClientConfig::default(), but
250 // we honor them as bounds only when they would actually CONSTRAIN
251 // the controller — i.e. when smaller than the per-channel default
252 // max. A default ClientConfig must not silently lower the
253 // controller's ceilings.
254 // A value equal to the historic legacy default is treated as
255 // "not pinned by the user" — without this, every default
256 // ClientConfig would silently lower the controller's per-channel
257 // ceilings to the prior static values (32/8) and the controller
258 // could never grow above them.
259 let user_quote_max = config.quote_concurrency;
260 let user_store_max = config.store_concurrency;
261 let quote_pinned = user_quote_max > 0 && user_quote_max != DEFAULT_QUOTE_CONCURRENCY;
262 let store_pinned = user_store_max > 0 && user_store_max != DEFAULT_STORE_CONCURRENCY;
263 if quote_pinned && user_quote_max < adaptive_cfg.max.quote {
264 adaptive_cfg.max.quote = user_quote_max;
265 }
266 if store_pinned && user_store_max < adaptive_cfg.max.store {
267 adaptive_cfg.max.store = user_store_max;
268 }
269
270 // Cold-start values: matched to the prior static defaults. If the
271 // legacy field caps the channel below the cold-start, lower the
272 // start to match — never start above the channel's max.
273 let mut start = ChannelStart::default();
274 start.quote = start.quote.min(adaptive_cfg.max.quote);
275 start.store = start.store.min(adaptive_cfg.max.store);
276 start.fetch = start.fetch.min(adaptive_cfg.max.fetch);
277
278 let adaptive_enabled = adaptive_cfg.enabled;
279 let controller = AdaptiveController::new(start, adaptive_cfg);
280 // Skip disk warm-start entirely when adaptation is disabled —
281 // fixed-concurrency mode means the user wants exactly the cold
282 // start, no surprises from prior runs. (warm_start is also a
283 // no-op when disabled, but skipping the load avoids file I/O
284 // and the path-resolution side effects.)
285 let persist_path = if adaptive_enabled {
286 let p = adaptive::default_persist_path();
287 if let Some(ref path) = p {
288 if let Some(snap) = adaptive::load_snapshot(path) {
289 debug!(path = %path.display(), "adaptive: warm-start from disk");
290 controller.warm_start(snap);
291 }
292 }
293 p
294 } else {
295 // Even with adaptation off, persist_path is computed so
296 // explicit save_adaptive_snapshot() calls still work — but
297 // the controller currently never moves, so saving the cold
298 // start is harmless.
299 adaptive::default_persist_path()
300 };
301
302 // File downloads choose a stream-decrypt batch size per download
303 // from the current fetch cap and usable RAM, then pass it into
304 // self_encryption's runtime batch-size API. The adaptive controller
305 // still drives fan-out inside each batch by re-reading
306 // `controller.fetch.current()` in the decrypt callback.
307
308 (controller, persist_path)
309}
310
311/// Client for the Autonomi decentralized network.
312///
313/// Provides high-level APIs for storing and retrieving chunks
314/// and files on the network.
315pub struct Client {
316 config: ClientConfig,
317 network: Network,
318 wallet: Option<Arc<Wallet>>,
319 evm_network: Option<ant_protocol::evm::Network>,
320 chunk_cache: ChunkCache,
321 next_request_id: AtomicU64,
322 /// Adaptive concurrency controller: replaces the static
323 /// quote/store concurrency knobs. See `adaptive` module.
324 controller: AdaptiveController,
325 /// Path the controller persists its snapshot to. `None` disables
326 /// persistence (useful for tests / non-disk environments).
327 persist_path: Option<PathBuf>,
328}
329
330impl Client {
331 /// Create a client connected to the given P2P node.
332 #[must_use]
333 pub fn from_node(node: Arc<P2PNode>, config: ClientConfig) -> Self {
334 let network = Network::from_node(node);
335 let (controller, persist_path) = build_controller(&config);
336 Self {
337 config,
338 network,
339 wallet: None,
340 evm_network: None,
341 chunk_cache: ChunkCache::default(),
342 next_request_id: AtomicU64::new(1),
343 controller,
344 persist_path,
345 }
346 }
347
348 /// Create a client connected to bootstrap peers.
349 ///
350 /// Threads `config.allow_loopback` and `config.ipv6` through to
351 /// `Network::new`, which controls the saorsa-transport `local` and
352 /// `ipv6` flags on the underlying `CoreNodeConfig`. See
353 /// `ClientConfig::allow_loopback` and `ClientConfig::ipv6` for details.
354 ///
355 /// # Errors
356 ///
357 /// Returns an error if the P2P node cannot be created or bootstrapping fails.
358 pub async fn connect(
359 bootstrap_peers: &[std::net::SocketAddr],
360 config: ClientConfig,
361 ) -> Result<Self> {
362 debug!(
363 "Connecting to Autonomi network with {} bootstrap peers (allow_loopback={}, ipv6={})",
364 bootstrap_peers.len(),
365 config.allow_loopback,
366 config.ipv6,
367 );
368 let network = Network::new(bootstrap_peers, config.allow_loopback, config.ipv6).await?;
369 let (controller, persist_path) = build_controller(&config);
370 Ok(Self {
371 config,
372 network,
373 wallet: None,
374 evm_network: None,
375 chunk_cache: ChunkCache::default(),
376 next_request_id: AtomicU64::new(1),
377 controller,
378 persist_path,
379 })
380 }
381
382 /// Set the wallet for payment operations.
383 ///
384 /// Also populates the EVM network from the wallet so that
385 /// token approvals work without a separate `with_evm_network` call.
386 #[must_use]
387 pub fn with_wallet(mut self, wallet: Wallet) -> Self {
388 self.evm_network = Some(wallet.network().clone());
389 self.wallet = Some(Arc::new(wallet));
390 self
391 }
392
393 /// Set the EVM network without requiring a wallet.
394 ///
395 /// This enables token approval and contract interactions
396 /// for external-signer flows where the private key lives outside Rust.
397 #[must_use]
398 pub fn with_evm_network(mut self, network: ant_protocol::evm::Network) -> Self {
399 self.evm_network = Some(network);
400 self
401 }
402
403 /// Get the EVM network, falling back to the wallet's network if available.
404 ///
405 /// # Errors
406 ///
407 /// Returns an error if neither `with_evm_network` nor `with_wallet` was called.
408 pub(crate) fn require_evm_network(&self) -> Result<&ant_protocol::evm::Network> {
409 if let Some(ref net) = self.evm_network {
410 return Ok(net);
411 }
412 if let Some(ref wallet) = self.wallet {
413 return Ok(wallet.network());
414 }
415 Err(Error::Payment(
416 "EVM network not configured — call with_evm_network() or with_wallet() first"
417 .to_string(),
418 ))
419 }
420
421 /// Get the client configuration.
422 #[must_use]
423 pub fn config(&self) -> &ClientConfig {
424 &self.config
425 }
426
427 /// Get a mutable reference to the client configuration.
428 pub fn config_mut(&mut self) -> &mut ClientConfig {
429 &mut self.config
430 }
431
432 /// Get a reference to the network layer.
433 #[must_use]
434 pub fn network(&self) -> &Network {
435 &self.network
436 }
437
438 /// Get the wallet, if configured.
439 #[must_use]
440 pub fn wallet(&self) -> Option<&Arc<Wallet>> {
441 self.wallet.as_ref()
442 }
443
444 /// Get a reference to the chunk cache.
445 #[must_use]
446 pub fn chunk_cache(&self) -> &ChunkCache {
447 &self.chunk_cache
448 }
449
450 /// Adaptive concurrency controller. Hot loops read
451 /// `controller().<channel>.current()` to size their fan-out and
452 /// call `.observe(...)` on each completion.
453 #[must_use]
454 pub fn controller(&self) -> &AdaptiveController {
455 &self.controller
456 }
457
458 /// Persist the current adaptive snapshot to disk so the next
459 /// `Client::connect` warm-starts at the learned values instead of
460 /// cold defaults. Best effort — failures log and are discarded.
461 /// Idempotent. Safe to call from a Drop impl or an explicit
462 /// shutdown hook.
463 pub fn save_adaptive_snapshot(&self) {
464 if let Some(ref path) = self.persist_path {
465 adaptive::save_snapshot(path, self.controller.snapshot());
466 }
467 }
468
469 /// Get the next request ID for protocol messages.
470 pub(crate) fn next_request_id(&self) -> u64 {
471 self.next_request_id.fetch_add(1, Ordering::Relaxed)
472 }
473
474 /// Return all peers in the close group for a target address.
475 ///
476 /// Queries the DHT for the closest peers by XOR distance.
477 /// Returns each peer paired with its known network addresses.
478 pub(crate) async fn close_group_peers(
479 &self,
480 target: &XorName,
481 ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
482 self.closest_peers(target, self.config().close_group_size)
483 .await
484 }
485
486 /// Return the requested number of closest peers for a target address.
487 ///
488 /// Queries the DHT for peers by XOR distance. Returns each peer
489 /// paired with its known network addresses.
490 pub(crate) async fn closest_peers(
491 &self,
492 target: &XorName,
493 count: usize,
494 ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
495 let peers = self.network().find_closest_peers(target, count).await?;
496
497 if peers.is_empty() {
498 return Err(Error::InsufficientPeers(
499 "DHT returned no peers for target address".to_string(),
500 ));
501 }
502 Ok(peers)
503 }
504}
505
506/// Persist the adaptive snapshot when the `Client` is dropped, so any
507/// caller — CLI, daemon, library user, integration test — gets
508/// warm-start carry-over for free without remembering to call
509/// `save_adaptive_snapshot()` explicitly. Best effort, sync `std::fs`,
510/// no panic risk on a poisoned mutex (the inner helper handles it).
511///
512/// We deliberately write SYNCHRONOUSLY (not via `spawn_blocking`)
513/// because Drop runs during process shutdown / runtime teardown,
514/// when fire-and-forget background tasks can be dropped before they
515/// complete and the snapshot is silently lost. A small synchronous
516/// stall on a tokio worker (typically <1ms for a local-disk JSON
517/// write of ~50 bytes) is the right tradeoff for guaranteed
518/// persistence — BOUNDED by `DROP_SAVE_TIMEOUT` so a stalled
519/// network-mounted data dir cannot block process shutdown.
520const DROP_SAVE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
521
522impl Drop for Client {
523 fn drop(&mut self) {
524 let Some(path) = self.persist_path.clone() else {
525 return;
526 };
527 let snap = self.controller.snapshot();
528 adaptive::save_snapshot_with_timeout(path, snap, DROP_SAVE_TIMEOUT);
529 }
530}
531
532#[cfg(test)]
533#[allow(clippy::unwrap_used)]
534mod tests {
535 use super::*;
536
537 /// Cover EVERY variant of `data::error::Error`. Build an instance of
538 /// each, classify it, and assert the resulting `Outcome` matches the
539 /// only sensible mapping. If a future commit adds a new error variant
540 /// without updating `classify_error`, this test fails to ensure the
541 /// adaptive controller always sees correct capacity signals.
542 ///
543 /// Mapping policy (mirrors `classify_error` doc):
544 /// - `Timeout` -> `Outcome::Timeout`
545 /// - `Network`, `InsufficientPeers`, `Io`, `Protocol`, `Storage`,
546 /// `PartialUpload` -> `Outcome::NetworkError` (transport-related
547 /// or literal capacity failure)
548 /// - everything else -> `Outcome::ApplicationError` (would happen
549 /// on a perfectly healthy network)
550 #[test]
551 fn classify_error_covers_all_variants() {
552 let cases: Vec<(Error, Outcome)> = vec![
553 (Error::Timeout("t".to_string()), Outcome::Timeout),
554 (Error::Network("n".to_string()), Outcome::NetworkError),
555 (
556 Error::InsufficientPeers("p".to_string()),
557 Outcome::NetworkError,
558 ),
559 (Error::Storage("s".to_string()), Outcome::NetworkError),
560 (Error::Payment("p".to_string()), Outcome::ApplicationError),
561 (Error::Protocol("p".to_string()), Outcome::NetworkError),
562 (
563 Error::InvalidData("d".to_string()),
564 Outcome::ApplicationError,
565 ),
566 (
567 Error::Serialization("s".to_string()),
568 Outcome::ApplicationError,
569 ),
570 (Error::Crypto("c".to_string()), Outcome::ApplicationError),
571 (
572 Error::Io(std::io::Error::other("io")),
573 Outcome::NetworkError,
574 ),
575 (Error::Config("c".to_string()), Outcome::ApplicationError),
576 (
577 Error::SignatureVerification("s".to_string()),
578 Outcome::ApplicationError,
579 ),
580 (
581 Error::Encryption("e".to_string()),
582 Outcome::ApplicationError,
583 ),
584 (Error::AlreadyStored, Outcome::ApplicationError),
585 (
586 Error::InsufficientDiskSpace("d".to_string()),
587 Outcome::ApplicationError,
588 ),
589 (
590 Error::CostEstimationInconclusive("c".to_string()),
591 Outcome::ApplicationError,
592 ),
593 (
594 Error::PartialUpload {
595 stored: vec![],
596 stored_count: 0,
597 failed: vec![],
598 failed_count: 0,
599 total_chunks: 0,
600 reason: "r".to_string(),
601 },
602 Outcome::NetworkError,
603 ),
604 ];
605 for (err, expected) in &cases {
606 let got = classify_error(err);
607 assert_eq!(
608 got, *expected,
609 "classify_error({err:?}) = {got:?}, expected {expected:?}",
610 );
611 }
612 }
613
614 /// C4 fix guard: pinning the legacy `quote_concurrency` /
615 /// `store_concurrency` ClientConfig fields must clamp ONLY the
616 /// matching channel's max in the resulting controller. The fetch
617 /// (download) channel must keep its full default ceiling.
618 #[test]
619 fn legacy_concurrency_pin_does_not_bleed_across_channels() {
620 let cfg = ClientConfig {
621 quote_concurrency: 4,
622 store_concurrency: 2,
623 ..ClientConfig::default()
624 };
625 let (controller, _) = build_controller(&cfg);
626 // The store/quote caps must be clamped to the user's pin.
627 assert_eq!(controller.config.max.quote, 4, "quote pin not respected");
628 assert_eq!(controller.config.max.store, 2, "store pin not respected");
629 // The fetch cap must NOT have been lowered — that's the
630 // regression C4 was about.
631 let default_fetch_max = adaptive::ChannelMax::default().fetch;
632 assert_eq!(
633 controller.config.max.fetch, default_fetch_max,
634 "fetch cap was lowered by store/quote pin (C4 regression)"
635 );
636 // Cold-start values must respect the lowered ceilings.
637 assert!(
638 controller.quote.current() <= 4,
639 "quote start exceeds its cap"
640 );
641 assert!(
642 controller.store.current() <= 2,
643 "store start exceeds its cap"
644 );
645 }
646
647 /// Default ClientConfig must NOT silently lower the controller's
648 /// per-channel ceilings — the adaptive defaults give every channel
649 /// real headroom to grow. This guards against future commits
650 /// re-introducing a global clamp.
651 #[test]
652 fn default_client_config_does_not_clamp_controller_max() {
653 let cfg = ClientConfig::default();
654 let (controller, _) = build_controller(&cfg);
655 let defaults = adaptive::ChannelMax::default();
656 // The legacy fields default to 32/8 (the prior static knobs),
657 // both of which are <= the per-channel adaptive defaults
658 // (128/64). build_controller must keep the larger, not clobber
659 // with the legacy values.
660 assert_eq!(controller.config.max.quote, defaults.quote);
661 assert_eq!(controller.config.max.store, defaults.store);
662 assert_eq!(controller.config.max.fetch, defaults.fetch);
663 // Compile-time-ish guard: if a new variant is added to Error,
664 // this match forces an update here.
665 let _ = |e: &Error| match e {
666 Error::Timeout(_)
667 | Error::Network(_)
668 | Error::InsufficientPeers(_)
669 | Error::Storage(_)
670 | Error::Payment(_)
671 | Error::Protocol(_)
672 | Error::InvalidData(_)
673 | Error::Serialization(_)
674 | Error::Crypto(_)
675 | Error::Io(_)
676 | Error::Config(_)
677 | Error::SignatureVerification(_)
678 | Error::Encryption(_)
679 | Error::AlreadyStored
680 | Error::InsufficientDiskSpace(_)
681 | Error::CostEstimationInconclusive(_)
682 | Error::PartialUpload { .. }
683 | Error::BadQuoteBinding { .. } => (),
684 };
685 }
686}