Skip to main content

irontide_settings/
lib.rs

1//! Unified settings pack for session configuration.
2//!
3//! Replaces the old `SessionConfig` with a single strongly-typed struct that
4//! consolidates all configurable knobs. Supports presets, validation, and
5//! serde serialization (bencode + JSON).
6
7use std::net::IpAddr;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use irontide_core::{
13    AlertCategory, ChokingAlgorithm, MixedModeAlgorithm, PreallocateMode, ProxyConfig, ProxyType,
14    SeedChokingAlgorithm,
15};
16use irontide_wire::mse::EncryptionMode;
17
18/// Settings SSOT registry (M247a): `for_each_setting!` / `for_each_qbt_compat_setting!`
19/// / `for_each_proxy_setting!` X-macros + the mechanism tracer.
20mod schema;
21
22/// Validation failure produced by [`Settings::validate`].
23///
24/// Crate-local so `irontide-settings` does not depend on `irontide-session`'s
25/// error type. `irontide-session` maps this into its own
26/// `Error::InvalidSettings` via `From` (see `error.rs`), preserving the
27/// public error surface and message text.
28#[derive(Debug, thiserror::Error)]
29pub enum SettingsError {
30    /// A setting (or combination of settings) is invalid.
31    #[error("{0}")]
32    Invalid(String),
33}
34
35/// M171: Action taken when a torrent's seed ratio reaches its configured limit.
36///
37/// Wire format is qBt's `snake_case` string (`"pause"` / `"remove"` /
38/// `"enable_super_seeding"`). Pause keeps the torrent in the session in a
39/// user-stopped state, Remove deletes the torrent record (files remain),
40/// and `EnableSuperSeeding` flips the torrent into BEP 16 super-seed mode
41/// without stopping.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum MaxRatioAction {
45    /// Pause the torrent — keep it in the session but stop all activity.
46    #[default]
47    Pause,
48    /// Remove the torrent record. Files on disk are left untouched.
49    Remove,
50    /// Enable BEP 16 super seeding on the torrent rather than stopping it.
51    EnableSuperSeeding,
52}
53
54// ── Serde default helpers ────────────────────────────────────────────
55
56fn default_true() -> bool {
57    true
58}
59fn default_listen_port() -> u16 {
60    42020
61}
62fn default_download_dir() -> PathBuf {
63    PathBuf::from(".")
64}
65fn default_max_torrents() -> usize {
66    100
67}
68fn default_encryption() -> EncryptionMode {
69    EncryptionMode::Disabled
70}
71fn default_auto_upload_slots_min() -> usize {
72    2
73}
74fn default_auto_upload_slots_max() -> usize {
75    20
76}
77fn default_active_downloads() -> i32 {
78    3
79}
80fn default_active_seeds() -> i32 {
81    5
82}
83fn default_active_limit() -> i32 {
84    500
85}
86fn default_active_checking() -> i32 {
87    3
88}
89fn default_inactive_rate() -> u64 {
90    2048
91}
92fn default_auto_manage_interval() -> u64 {
93    30
94}
95fn default_auto_manage_startup() -> u64 {
96    60
97}
98fn default_queue_rate_ewma_alpha() -> f64 {
99    0.3
100}
101fn default_seed_queue_min_active_secs() -> u64 {
102    1800
103}
104fn default_alert_mask() -> AlertCategory {
105    AlertCategory::ALL
106}
107fn default_alert_channel_size() -> usize {
108    1024
109}
110fn default_smart_ban_max_failures() -> u32 {
111    3
112}
113fn default_disk_io_threads() -> usize {
114    let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
115    (cores / 2).clamp(4, 16)
116}
117fn default_max_blocking_threads() -> usize {
118    std::thread::available_parallelism().map_or(4, std::num::NonZero::get)
119}
120fn default_disk_cache_size() -> usize {
121    16 * 1024 * 1024
122}
123fn default_disk_write_cache_ratio() -> f32 {
124    0.5
125}
126fn default_buffer_pool_capacity() -> usize {
127    64 * 1024 * 1024
128}
129fn default_enable_mlock() -> bool {
130    cfg!(unix)
131}
132fn default_disk_channel_capacity() -> usize {
133    512
134}
135fn default_hashing_threads() -> usize {
136    let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
137    (cores / 4).clamp(2, 8)
138}
139fn default_max_request_queue_depth() -> usize {
140    250
141}
142fn default_initial_queue_depth() -> usize {
143    128
144}
145fn default_request_budget_per_torrent() -> u32 {
146    512
147}
148fn default_request_budget_floor() -> u32 {
149    8
150}
151fn default_request_queue_time() -> f64 {
152    3.0
153}
154fn default_block_request_timeout() -> u32 {
155    60
156}
157fn default_max_concurrent_streams() -> usize {
158    8
159}
160fn default_dht_qps() -> usize {
161    50
162}
163fn default_dht_timeout() -> u64 {
164    5
165}
166fn default_upnp_lease() -> u32 {
167    3600
168}
169fn default_natpmp_lifetime() -> u32 {
170    7200
171}
172fn default_utp_max_conns() -> usize {
173    256
174}
175fn default_dht_max_items() -> usize {
176    700
177}
178fn default_dht_item_lifetime() -> u64 {
179    7200
180}
181fn default_dht_sample_interval() -> u64 {
182    0
183}
184fn default_suggest_mode() -> bool {
185    true
186}
187fn default_max_suggest_pieces() -> usize {
188    16
189}
190fn default_predictive_piece_announce_ms() -> u64 {
191    0
192}
193fn default_ssl_listen_port() -> u16 {
194    0 // 0 = disabled
195}
196fn default_seed_choking_algorithm() -> SeedChokingAlgorithm {
197    SeedChokingAlgorithm::FastestUpload
198}
199fn default_choking_algorithm() -> ChokingAlgorithm {
200    ChokingAlgorithm::FixedSlots
201}
202fn default_mixed_mode() -> MixedModeAlgorithm {
203    MixedModeAlgorithm::PeerProportional
204}
205fn default_steal_threshold_ratio() -> f64 {
206    10.0
207}
208fn default_use_block_stealing() -> bool {
209    true
210}
211fn default_peer_connect_timeout() -> u64 {
212    10 // M139: match rqbit — longer timeout produces more natural connect failures for cycling
213}
214fn default_peer_dscp() -> u8 {
215    0x08 // CS1 (scavenger/low-priority)
216}
217fn default_max_peers_per_torrent() -> usize {
218    128
219}
220// v0.187.3: eviction-policy tunables.
221fn default_pass0_grace_secs() -> u64 {
222    60 // Per OV2/12A: full minute of post-handshake slow-start before Pass 0 fires.
223}
224fn default_proactive_evictions_per_minute_limit() -> u32 {
225    30 // Sliding-window cap that prevents the 130→20-50 churn observed in dogfood.
226}
227fn default_eviction_ban_duration_secs() -> u64 {
228    600 // 10 min (was 1800/30 min). Long enough to break churn loops, short enough
229    // that a legitimately slow peer can rejoin after warming up.
230}
231fn default_eviction_ban_set_cap() -> usize {
232    1024 // FIFO cap on the banned-peer set (raised from the legacy 256).
233}
234fn default_stats_report_interval() -> u64 {
235    1000
236}
237fn default_strict_end_game() -> bool {
238    true
239}
240fn default_max_web_seeds() -> usize {
241    4
242}
243fn default_web_seed_retry_base_secs() -> u64 {
244    10
245}
246fn default_web_seed_retry_factor() -> u64 {
247    6
248}
249fn default_web_seed_retry_cap_secs() -> u64 {
250    3600
251}
252fn default_web_seed_max_failures() -> u32 {
253    10
254}
255fn default_initial_picker_threshold() -> u32 {
256    4
257}
258fn default_whole_pieces_threshold() -> u32 {
259    20
260}
261fn default_snub_timeout_secs() -> u32 {
262    15
263}
264fn default_readahead_pieces() -> u32 {
265    8
266}
267fn default_max_metadata_size() -> u64 {
268    4 * 1024 * 1024 // 4 MiB — libtorrent default
269}
270fn default_max_message_size() -> usize {
271    16 * 1024 * 1024 // 16 MiB — matches wire codec constant
272}
273fn default_max_piece_length() -> u64 {
274    32 * 1024 * 1024 // 32 MiB — largest reasonable piece size
275}
276fn default_max_outstanding_requests() -> usize {
277    500
278}
279fn default_max_in_flight_pieces() -> usize {
280    512
281}
282fn default_fixed_pipeline_depth() -> usize {
283    128
284}
285fn default_i2p_hostname() -> String {
286    "127.0.0.1".into()
287}
288fn default_i2p_port() -> u16 {
289    7656
290}
291fn default_i2p_tunnel_quantity() -> u8 {
292    3
293}
294fn default_i2p_tunnel_length() -> u8 {
295    3
296}
297fn default_runtime_worker_threads() -> usize {
298    std::thread::available_parallelism().map_or(4, |n| n.get().min(8))
299}
300fn default_lock_warn_threshold_ms() -> u64 {
301    50
302}
303fn default_steal_stale_piece_secs() -> u64 {
304    2
305}
306fn default_steal_threshold_endgame() -> f64 {
307    3.0
308}
309fn default_peer_read_timeout_secs() -> u64 {
310    10
311}
312fn default_peer_write_timeout_secs() -> u64 {
313    10
314}
315fn default_data_contribution_timeout() -> u64 {
316    0 // M139: disabled by default — rqbit doesn't evict for no data
317}
318fn default_choke_rotation_max_evictions() -> u32 {
319    0 // M139: disabled by default — rqbit doesn't proactively rotate choked peers
320}
321fn default_max_concurrent_connects() -> u16 {
322    128 // M147: ConnectPool — gates connection attempts, released on handshake
323}
324fn default_connect_soft_timeout() -> u64 {
325    3 // M147: seconds without TCP SYN-ACK before soft reap disconnects
326}
327fn default_dispatch_backlog_cap() -> usize {
328    8 // M182: dispatch_tx reader-side spill cap (constant pre-perf-harness)
329}
330fn default_event_backlog_cap() -> usize {
331    32 // M182: event_tx reader-side spill cap (constant pre-perf-harness)
332}
333fn default_peer_writer_channel_cap() -> usize {
334    1024 // M243 (L6): per-peer outbound writer channel bound (see field doc)
335}
336fn default_web_seed_progress_throttle_ms() -> u64 {
337    250 // M178: per-URL minimum interval for PeerEvent::WebSeedProgress (0 = disabled)
338}
339fn default_save_resume_interval() -> u64 {
340    300 // M161: 5 minutes between periodic resume file saves
341}
342fn default_max_upload_slots_global() -> i32 {
343    -1
344}
345fn default_max_upload_slots_per_torrent() -> i32 {
346    4
347}
348fn default_max_connections_global() -> i32 {
349    -1
350}
351fn default_max_uploads_per_torrent() -> i32 {
352    -1
353}
354
355// ── Settings ─────────────────────────────────────────────────────────
356
357/// Unified session settings (replaces `SessionConfig`).
358///
359/// All 56 configurable fields in a single strongly-typed struct.
360/// Supports presets via factory functions and runtime mutation via
361/// `SessionHandle::apply_settings()`.
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363pub struct Settings {
364    // ── General ──
365    /// TCP listen port for incoming peer connections (default: 42020).
366    #[serde(default = "default_listen_port")]
367    pub listen_port: u16,
368    /// Randomize the listen port each time the session starts. Default: false.
369    #[serde(default)]
370    pub randomize_port_on_startup: bool,
371    /// Default download directory for new torrents (default: ".").
372    #[serde(default = "default_download_dir")]
373    pub download_dir: PathBuf,
374    /// Maximum number of concurrent torrents (default: 100).
375    #[serde(default = "default_max_torrents")]
376    pub max_torrents: usize,
377    /// Directory for fast-resume data files. If `None`, resume data is not persisted.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub resume_data_dir: Option<PathBuf>,
380    /// Interval in seconds between periodic resume file saves (0 = disabled).
381    /// Default: 300 (5 minutes).
382    #[serde(default = "default_save_resume_interval")]
383    pub save_resume_interval_secs: u64,
384
385    // ── Protocol features ──
386    /// Enable Kademlia DHT peer discovery (BEP 5). Default: true.
387    #[serde(default = "default_true")]
388    pub enable_dht: bool,
389    /// Enable Peer Exchange (BEP 11). Default: true.
390    #[serde(default = "default_true")]
391    pub enable_pex: bool,
392    /// Enable Local Service Discovery via multicast (BEP 14). Default: true.
393    #[serde(default = "default_true")]
394    pub enable_lsd: bool,
395    /// Enable BEP 6 Fast Extension (`AllowedFast`, `HaveAll`, `HaveNone`, Reject,
396    /// `SuggestPiece`). Default: true.
397    #[serde(default = "default_true")]
398    pub enable_fast_extension: bool,
399    /// Enable uTP (BEP 29) micro transport protocol. When enabled, outbound
400    /// connections try uTP first with a 5-second timeout before falling back
401    /// to TCP. Default: true.
402    #[serde(default = "default_true")]
403    pub enable_utp: bool,
404    /// Enable `UPnP` IGD port mapping (last resort after PCP and NAT-PMP).
405    /// Default: true.
406    #[serde(default = "default_true")]
407    pub enable_upnp: bool,
408    /// Enable NAT-PMP (RFC 6886) and PCP (RFC 6887) port mapping.
409    /// PCP is tried first, then NAT-PMP as fallback. Default: true.
410    #[serde(default = "default_true")]
411    pub enable_natpmp: bool,
412    /// Enable IPv6 dual-stack support (BEP 7, 24). Binds listeners on both
413    /// IPv4 and IPv6, starts a second DHT instance, and processes IPv6 peers
414    /// in PEX and tracker responses. Default: true.
415    #[serde(default = "default_true")]
416    pub enable_ipv6: bool,
417    /// Enable HTTP/web seeding (BEP 19 `GetRight`, BEP 17 Hoffman). Torrents
418    /// with `url-list` or `httpseeds` download pieces from HTTP servers
419    /// alongside peer-to-peer transfers. Default: true.
420    #[serde(default = "default_true")]
421    pub enable_web_seed: bool,
422    /// Enable BEP 55 holepunch extension for NAT traversal. Advertises
423    /// `ut_holepunch` in the extension handshake and can act as initiator,
424    /// relay, or target for holepunch connections. Default: true.
425    #[serde(default = "default_true")]
426    pub enable_holepunch: bool,
427    /// Enable BEP 40 canonical peer priority for connection eviction.
428    /// When at capacity, incoming peers with higher deterministic priority
429    /// can displace lower-priority ones. Default: true.
430    #[serde(default = "default_true")]
431    pub enable_bep40_eviction: bool,
432    /// Enable diagnostic counters (dispatch timing, backpressure high-water,
433    /// peer unchoke/choke/lifetime telemetry). Default: false — enable for
434    /// benchmarking or troubleshooting via `--diagnostics` or config.
435    #[serde(default)]
436    pub enable_diagnostic_counters: bool,
437    /// Connection encryption mode (MSE/PE). Default: Disabled.
438    #[serde(default = "default_encryption")]
439    pub encryption_mode: EncryptionMode,
440    /// Suppress identifying information (client version in BEP 10 handshake)
441    /// and disable DHT, LSD, `UPnP`, and NAT-PMP. Default: false.
442    #[serde(default)]
443    pub anonymous_mode: bool,
444    /// Manually configured external IP for BEP 40 peer priority.
445    /// If not set, discovered automatically via NAT traversal.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub external_ip: Option<IpAddr>,
448
449    // ── Seeding ──
450    /// Stop seeding when this upload/download ratio is reached. `None` = unlimited.
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub seed_ratio_limit: Option<f64>,
453    /// M171: Stop seeding after this many cumulative seeding seconds.
454    /// `None` = no limit. Mirrors qBt's "Maximum seeding time" preference,
455    /// which is exposed in minutes on the wire but stored here in seconds
456    /// to match the other duration-typed fields.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub seed_time_limit_secs: Option<u64>,
459    /// M171: Stop seeding after this many seconds of inactivity while in the
460    /// Seeding state (no outgoing Piece data). `None` = no limit. Mirrors
461    /// qBt's "Maximum inactive seeding time" preference.
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub inactive_seed_time_limit_secs: Option<u64>,
464    /// M171: What to do when `seed_ratio_limit` is reached.
465    /// Mirrors qBt's `max_ratio_act` wire enum. Default: `Pause`.
466    #[serde(default)]
467    pub max_ratio_action: MaxRatioAction,
468    /// M171: Create a subfolder named after the torrent when adding a
469    /// multi-file torrent. Mirrors qBt's `create_subfolder_enabled`.
470    /// Default: `true` (qBt factory default).
471    #[serde(default = "default_true")]
472    pub create_subfolder: bool,
473    /// M171: Automatically manage torrent resources via the queueing
474    /// subsystem (start/stop/recheck order). Mirrors qBt's
475    /// `auto_tmm_enabled`. Default: `false`.
476    #[serde(default)]
477    pub auto_manage_torrents: bool,
478    /// M171: Enable the download/upload queueing subsystem. When `false`,
479    /// no queueing is applied and torrents run concurrently up to per-torrent
480    /// limits. Mirrors qBt's `queueing_enabled`. Default: `false`.
481    #[serde(default)]
482    pub queueing_enabled: bool,
483    /// Enable BEP 16 super seeding for new torrents. Reveals pieces one-per-peer
484    /// to maximize piece diversity across the swarm. Default: false.
485    #[serde(default)]
486    pub default_super_seeding: bool,
487    /// Default share mode for new torrents. When true, torrents relay pieces
488    /// in memory without writing to disk. Requires fast extension (BEP 6).
489    #[serde(default)]
490    pub default_share_mode: bool,
491    /// Advertise upload-only status via extension handshake when a torrent
492    /// transitions to seeding (BEP 21). Default: true.
493    #[serde(default = "default_true")]
494    pub upload_only_announce: bool,
495    // ── Rate limiting ──
496    /// Global upload rate limit in bytes/sec (0 = unlimited).
497    #[serde(default)]
498    pub upload_rate_limit: u64,
499    /// Global download rate limit in bytes/sec (0 = unlimited).
500    #[serde(default)]
501    pub download_rate_limit: u64,
502    /// TCP upload rate limit in bytes/sec (0 = unlimited).
503    #[serde(default)]
504    pub tcp_upload_rate_limit: u64,
505    /// TCP download rate limit in bytes/sec (0 = unlimited).
506    #[serde(default)]
507    pub tcp_download_rate_limit: u64,
508    /// uTP upload rate limit in bytes/sec (0 = unlimited).
509    #[serde(default)]
510    pub utp_upload_rate_limit: u64,
511    /// uTP download rate limit in bytes/sec (0 = unlimited).
512    #[serde(default)]
513    pub utp_download_rate_limit: u64,
514    /// Automatically adjust the number of upload slots based on bandwidth. Default: true.
515    #[serde(default = "default_true")]
516    pub auto_upload_slots: bool,
517    /// Minimum number of automatic upload slots (default: 2).
518    #[serde(default = "default_auto_upload_slots_min")]
519    pub auto_upload_slots_min: usize,
520    /// Maximum number of automatic upload slots (default: 20).
521    #[serde(default = "default_auto_upload_slots_max")]
522    pub auto_upload_slots_max: usize,
523    /// Maximum upload slots across all torrents (-1 = unlimited). Default: -1.
524    #[serde(default = "default_max_upload_slots_global")]
525    pub max_upload_slots_global: i32,
526    /// Maximum upload slots per torrent. Default: 4.
527    #[serde(default = "default_max_upload_slots_per_torrent")]
528    pub max_upload_slots_per_torrent: i32,
529    /// Maximum peer connections across all torrents (-1 = unlimited). Default: -1.
530    #[serde(default = "default_max_connections_global")]
531    pub max_connections_global: i32,
532    /// Maximum unchoked upload slots per torrent (-1 = unlimited). Default: -1.
533    /// M224: qBt wire `max_uploads_per_torrent`. `-1` is unlimited; `n >= 1`
534    /// caps the choker's unchoke set; `0` is explicitly rejected by
535    /// [`Settings::validate`] (choking every peer is almost certainly a
536    /// wire-format mistake, not user intent).
537    #[serde(default = "default_max_uploads_per_torrent")]
538    pub max_uploads_per_torrent: i32,
539    /// Alternative download rate limit in bytes/sec (0 = unlimited).
540    #[serde(default)]
541    pub alt_download_rate_limit: u64,
542    /// Alternative upload rate limit in bytes/sec (0 = unlimited).
543    #[serde(default)]
544    pub alt_upload_rate_limit: u64,
545    /// Whether alternative speed limits are currently active. Default: false.
546    #[serde(default)]
547    pub alt_speed_enabled: bool,
548    /// Whether the alternative speed schedule is enabled. Default: false.
549    #[serde(default)]
550    pub alt_speed_schedule_enabled: bool,
551    /// Schedule start time in minutes-of-day (0-1439). Default: 0.
552    #[serde(default)]
553    pub alt_speed_schedule_from: u16,
554    /// Schedule end time in minutes-of-day (0-1439). Default: 0.
555    #[serde(default)]
556    pub alt_speed_schedule_to: u16,
557    /// Schedule active days as a bitmask (bit 0 = Mon .. bit 6 = Sun). Default: 0.
558    #[serde(default)]
559    pub alt_speed_schedule_days: u8,
560    /// Include protocol overhead in rate limit calculations. Default: true.
561    #[serde(default = "default_true")]
562    pub rate_limit_includes_overhead: bool,
563    /// Apply rate limits to uTP connections. Default: true.
564    #[serde(default = "default_true")]
565    pub rate_limit_utp: bool,
566    /// Apply rate limits to LAN connections. Default: false.
567    #[serde(default)]
568    pub rate_limit_lan: bool,
569    /// Mixed-mode TCP/uTP bandwidth allocation algorithm.
570    #[serde(default = "default_mixed_mode")]
571    pub mixed_mode_algorithm: MixedModeAlgorithm,
572
573    // ── Queue management ──
574    /// Maximum concurrent auto-managed downloading torrents (-1 = unlimited, default: 3).
575    #[serde(default = "default_active_downloads")]
576    pub active_downloads: i32,
577    /// Maximum concurrent auto-managed seeding torrents (-1 = unlimited, default: 5).
578    #[serde(default = "default_active_seeds")]
579    pub active_seeds: i32,
580    /// Hard cap on all active auto-managed torrents (-1 = unlimited, default: 500).
581    #[serde(default = "default_active_limit")]
582    pub active_limit: i32,
583    /// Maximum concurrent hash-check operations (default: 1).
584    #[serde(default = "default_active_checking")]
585    pub active_checking: i32,
586    /// Exempt inactive torrents from download/seed limits. A torrent is inactive
587    /// if its rate is below `inactive_down_rate` / `inactive_up_rate`. Default: true.
588    #[serde(default = "default_true")]
589    pub dont_count_slow_torrents: bool,
590    /// Download rate threshold (bytes/sec) below which a torrent is considered
591    /// inactive for queue management purposes (default: 2048).
592    #[serde(default = "default_inactive_rate")]
593    pub inactive_down_rate: u64,
594    /// Upload rate threshold (bytes/sec) below which a torrent is considered
595    /// inactive for queue management purposes (default: 2048).
596    #[serde(default = "default_inactive_rate")]
597    pub inactive_up_rate: u64,
598    /// Interval in seconds between queue evaluations (default: 30).
599    #[serde(default = "default_auto_manage_interval")]
600    pub auto_manage_interval: u64,
601    /// Grace period in seconds where a torrent is considered active regardless
602    /// of speed after being started (default: 60).
603    #[serde(default = "default_auto_manage_startup")]
604    pub auto_manage_startup: u64,
605    /// Allocate seeding slots before download slots. Default: false.
606    #[serde(default)]
607    pub auto_manage_prefer_seeds: bool,
608    /// EWMA smoothing factor for queue rate classification (0.0–1.0).
609    /// 0.0 = pure history (never adapts), 1.0 = no smoothing (raw rate).
610    /// Default: 0.3.
611    #[serde(default = "default_queue_rate_ewma_alpha")]
612    pub queue_rate_ewma_alpha: f64,
613    /// Anti-flap grace period for seeding torrents, in seconds.
614    /// Seeding torrents are exempt from queue-pause for this duration after
615    /// starting (default: 1800 = 30 minutes, matching libtorrent).
616    #[serde(default = "default_seed_queue_min_active_secs")]
617    pub seed_queue_min_active_secs: u64,
618
619    // ── Alerts ──
620    /// Bitmask of alert categories to receive (default: ALL).
621    #[serde(default = "default_alert_mask")]
622    pub alert_mask: AlertCategory,
623    /// Capacity of the alert broadcast channel (default: 1024).
624    #[serde(default = "default_alert_channel_size")]
625    pub alert_channel_size: usize,
626
627    // ── Smart banning ──
628    /// Number of hash-failure involvements before a peer is auto-banned.
629    /// Lower values ban faster but risk false positives (default: 3).
630    #[serde(default = "default_smart_ban_max_failures")]
631    pub smart_ban_max_failures: u32,
632    /// Enable parole mode: re-download a failed piece from a single uninvolved
633    /// peer to definitively attribute fault before striking. Default: true.
634    #[serde(default = "default_true")]
635    pub smart_ban_parole: bool,
636
637    // ── Disk I/O ──
638    /// Number of concurrent disk I/O threads (default: 4).
639    #[serde(default = "default_disk_io_threads")]
640    pub disk_io_threads: usize,
641    /// Maximum number of concurrent blocking I/O operations dispatched via
642    /// `block_in_place`. Defaults to the number of available CPU cores.
643    #[serde(default = "default_max_blocking_threads")]
644    pub max_blocking_threads: usize,
645    /// Pre-allocation strategy for torrent files (None/Sparse/Full, default: None —
646    /// files are created sparse via `set_len`). M257a: replaces the deleted
647    /// `storage_mode` (its `Full` variant ≙ `PreallocateMode::Full`).
648    #[serde(default)]
649    pub preallocate_mode: PreallocateMode,
650    /// Total ARC disk cache size in bytes (default: 16 MiB, minimum: 1 MiB).
651    #[serde(default = "default_disk_cache_size")]
652    pub disk_cache_size: usize,
653    /// Fraction of disk cache reserved for write buffering (0.0–1.0, default: 0.5).
654    #[serde(default = "default_disk_write_cache_ratio")]
655    pub disk_write_cache_ratio: f32,
656    /// Capacity of the async disk I/O command channel (default: 512).
657    #[serde(default = "default_disk_channel_capacity")]
658    pub disk_channel_capacity: usize,
659    /// Unified buffer pool capacity in bytes (default: 64 MiB).
660    /// Replaces `disk_cache_size` when set. Covers both write buffering and read cache.
661    #[serde(default = "default_buffer_pool_capacity")]
662    pub buffer_pool_capacity: usize,
663    /// Lock cached piece data in physical memory (default: true on Unix).
664    /// Prevents the OS from swapping out hot cache entries. Silently ignored
665    /// if `RLIMIT_MEMLOCK` is exceeded.
666    #[serde(default = "default_enable_mlock")]
667    pub enable_mlock: bool,
668    /// Enable direct I/O for filesystem storage (bypasses kernel page cache).
669    /// Linux/FreeBSD: `O_DIRECT`, macOS: `F_NOCACHE`; best-effort no-op where
670    /// unsupported. Default: false.
671    #[serde(default)]
672    pub filesystem_direct_io: bool,
673    // ── Hashing & piece picking ──
674    /// Number of concurrent piece hash verification threads (default: 2).
675    #[serde(default = "default_hashing_threads")]
676    pub hashing_threads: usize,
677    /// Maximum per-peer request queue depth (default: 250).
678    #[serde(default = "default_max_request_queue_depth")]
679    pub max_request_queue_depth: usize,
680    /// Initial per-peer request queue depth (default: 128). Higher values let
681    /// peers reach full throughput faster by skipping slow-start ramp-up.
682    #[serde(default = "default_initial_queue_depth")]
683    pub initial_queue_depth: usize,
684    /// M257c: torrent-wide aggregate in-flight request budget (blocks),
685    /// allocated across unchoked peers proportionally to EWMA download
686    /// rate each pipeline tick. `0` disables the allocator (legacy
687    /// fixed-128 depth per peer). Default: 512.
688    #[serde(default = "default_request_budget_per_torrent")]
689    pub request_budget_per_torrent: u32,
690    /// M257c: minimum per-peer request quota (probe depth) so new and
691    /// slow peers stay measurable under the budget. Must be `>= 1` and
692    /// `<= 128`. Default: 8.
693    #[serde(default = "default_request_budget_floor")]
694    pub request_budget_floor: u32,
695    /// Request queue time multiplier in seconds (default: 3.0).
696    ///
697    /// **Deprecated**: This field is retained for backward compatibility with
698    /// existing config files. The pipeline now uses a fixed-depth model where
699    /// queue depth equals `initial_queue_depth` for the lifetime of the
700    /// connection; this value is no longer used in depth computation.
701    #[serde(default = "default_request_queue_time")]
702    pub request_queue_time: f64,
703    /// Block request timeout in seconds before the request is considered
704    /// lost and re-issued (default: 60).
705    #[serde(default = "default_block_request_timeout")]
706    pub block_request_timeout_secs: u32,
707    /// Maximum concurrent `FileStream` readers. Controls how many simultaneous
708    /// file-streaming reads can proceed (default: 8).
709    #[serde(default = "default_max_concurrent_streams")]
710    pub max_concurrent_stream_reads: usize,
711    /// Automatically switch to sequential piece picking when too many partial
712    /// pieces accumulate. Uses hysteresis (1.6x activate / 1.3x deactivate).
713    #[serde(default = "default_true")]
714    pub auto_sequential: bool,
715    /// In end-game mode, cancel duplicate requests when a piece completes.
716    /// When false, both copies download — wastes bandwidth but finishes faster
717    /// on unreliable peers. Default: true.
718    #[serde(default = "default_strict_end_game")]
719    pub strict_end_game: bool,
720    /// Maximum concurrent web seed connections per torrent (default: 4).
721    #[serde(default = "default_max_web_seeds")]
722    pub max_web_seeds: usize,
723    /// M186: Base delay (seconds) for web seed exponential backoff. Default: 10.
724    #[serde(default = "default_web_seed_retry_base_secs")]
725    pub web_seed_retry_base_secs: u64,
726    /// M186: Multiplier for web seed exponential backoff. Default: 6.
727    #[serde(default = "default_web_seed_retry_factor")]
728    pub web_seed_retry_factor: u64,
729    /// M186: Maximum backoff (seconds) for web seed retry. Default: 3600.
730    #[serde(default = "default_web_seed_retry_cap_secs")]
731    pub web_seed_retry_cap_secs: u64,
732    /// M186: Consecutive failures before permanently banning a web seed. Default: 10.
733    #[serde(default = "default_web_seed_max_failures")]
734    pub web_seed_max_failures: u32,
735    /// Completed piece count below which the picker uses random selection
736    /// to promote piece diversity in the swarm. Default: 4.
737    #[serde(default = "default_initial_picker_threshold")]
738    pub initial_picker_threshold: u32,
739    /// Seconds to download a piece — if a peer is faster, it gets exclusive
740    /// assignment (no block splitting). Default: 20.
741    #[serde(default = "default_whole_pieces_threshold")]
742    pub whole_pieces_threshold: u32,
743    /// Seconds without data from a peer before marking it as snubbed.
744    /// Snubbed peers get queue depth clamped to 1. Default: 60.
745    #[serde(default = "default_snub_timeout_secs")]
746    pub snub_timeout_secs: u32,
747    /// Number of pieces ahead of the streaming cursor to prioritize (default: 8).
748    #[serde(default = "default_readahead_pieces")]
749    pub readahead_pieces: u32,
750    /// Escalate streaming piece requests that exceed the mean RTT. Default: true.
751    #[serde(default = "default_true")]
752    pub streaming_timeout_escalation: bool,
753    /// Steal blocks from peers this many times slower than the requesting peer (default: 10.0).
754    /// Set to 0.0 to disable stealing.
755    #[serde(default = "default_steal_threshold_ratio")]
756    pub steal_threshold_ratio: f64,
757    /// Enable per-block stealing: fast peers can steal individual unrequested
758    /// blocks from pieces reserved by slower peers (default: true).
759    #[serde(default = "default_use_block_stealing")]
760    pub use_block_stealing: bool,
761    /// Seconds between steal-queue population scans. Every N seconds, all
762    /// in-flight pieces are pushed into the steal queue so fast peers can
763    /// steal blocks mid-download (not just at endgame). 0 = disabled.
764    /// Default: 2.
765    #[serde(default = "default_steal_stale_piece_secs")]
766    pub steal_stale_piece_secs: u64,
767    /// M149: Steal threshold multiplier when >90% complete (endgame).
768    /// Pieces taking longer than `swarm_avg` * this value are stolen. Default: 3.0.
769    #[serde(default = "default_steal_threshold_endgame")]
770    pub steal_threshold_endgame: f64,
771    /// Fixed per-peer pipeline depth (number of concurrent requests per peer).
772    /// Replaces the old AIMD dynamic depth system. rqbit uses a fixed
773    /// `Semaphore(128)` per peer — simpler and faster. This setting allows
774    /// benchmarking different fixed depths. Default: 128.
775    #[serde(default = "default_fixed_pipeline_depth")]
776    pub fixed_pipeline_depth: usize,
777
778    // ── Piece picker enhancements (M44) ──
779    /// Prefer pieces adjacent to those already downloaded for improved sequential
780    /// disk access patterns (4 MiB extent groups). Default: true.
781    #[serde(default = "default_true")]
782    pub piece_extent_affinity: bool,
783    /// Enable BEP 6 `SuggestPiece`: suggest newly verified pieces to peers that
784    /// Send `SuggestPiece` messages for cached pieces so peers can request what they
785    /// don't have them, improving piece diversity in the swarm. Default: true.
786    #[serde(default = "default_suggest_mode")]
787    pub suggest_mode: bool,
788    /// Maximum `SuggestPiece` messages per peer to avoid flooding (default: 10).
789    #[serde(default = "default_max_suggest_pieces")]
790    pub max_suggest_pieces: usize,
791    /// Predictive piece announce delay in milliseconds. When > 0, a Have message
792    /// is sent before hash verification completes, reducing piece availability
793    /// latency at the cost of a possible false announce. Default: 0 (disabled).
794    #[serde(default = "default_predictive_piece_announce_ms")]
795    pub predictive_piece_announce_ms: u64,
796
797    // ── Proxy ──
798    /// Proxy configuration for peer and tracker connections. Default: no proxy.
799    #[serde(default)]
800    pub proxy: ProxyConfig,
801    /// Force all connections through the configured proxy. Disables listen
802    /// sockets, `UPnP`, NAT-PMP, DHT, and LSD. Default: false.
803    #[serde(default)]
804    pub force_proxy: bool,
805
806    // ── IP Filtering ──
807    /// Enable the IP filter (blocklist). Default: false.
808    #[serde(default)]
809    pub ip_filter_enabled: bool,
810    /// Path to the IP filter file (e.g. `ipfilter.dat`). Default: empty.
811    #[serde(default)]
812    pub ip_filter_path: String,
813    /// Automatically refresh the IP filter when the file changes. Default: false.
814    #[serde(default)]
815    pub ip_filter_auto_refresh: bool,
816
817    /// Check tracker IP addresses against the IP filter. When false, trackers
818    /// are exempt from IP filtering. Default: true.
819    #[serde(default = "default_true")]
820    pub apply_ip_filter_to_trackers: bool,
821
822    // ── DHT tuning ──
823    /// Maximum DHT queries per second to control network traffic (default: 50).
824    #[serde(default = "default_dht_qps")]
825    pub dht_queries_per_second: usize,
826    /// Timeout in seconds for a single DHT query before it is abandoned (default: 5).
827    #[serde(default = "default_dht_timeout")]
828    pub dht_query_timeout_secs: u64,
829    /// BEP 42: Enforce node ID verification in DHT routing table.
830    /// Disabled by default: too many real DHT nodes lack BEP 42-compliant IDs.
831    #[serde(default)]
832    pub dht_enforce_node_id: bool,
833    /// BEP 42: Restrict DHT routing table to one node per IP.
834    #[serde(default = "default_true")]
835    pub dht_restrict_routing_ips: bool,
836    /// Maximum number of BEP 44 items stored in the DHT (immutable + mutable).
837    #[serde(default = "default_dht_max_items")]
838    pub dht_max_items: usize,
839    /// Lifetime of BEP 44 DHT items in seconds before expiry (default: 7200 = 2 hours).
840    #[serde(default = "default_dht_item_lifetime")]
841    pub dht_item_lifetime_secs: u64,
842    /// Interval in seconds for periodic `sample_infohashes` queries (BEP 51).
843    /// 0 = disabled (default). Non-zero enables background DHT indexing.
844    #[serde(default = "default_dht_sample_interval")]
845    pub dht_sample_infohashes_interval: u64,
846    /// BEP 43: Run DHT in read-only mode. Read-only nodes can query the DHT
847    /// but do not store data or announce. Other nodes should not add us to
848    /// their routing tables. Useful for resource-constrained clients.
849    #[serde(default)]
850    pub dht_read_only: bool,
851
852    // ── NAT tuning ──
853    /// `UPnP` lease duration in seconds (default: 3600).
854    #[serde(default = "default_upnp_lease")]
855    pub upnp_lease_duration: u32,
856    /// NAT-PMP mapping lifetime in seconds (default: 7200).
857    #[serde(default = "default_natpmp_lifetime")]
858    pub natpmp_lifetime: u32,
859
860    // ── uTP tuning ──
861    /// Maximum concurrent uTP connections (default: 256).
862    #[serde(default = "default_utp_max_conns")]
863    pub utp_max_connections: usize,
864
865    // ── I2P ──
866    /// Enable I2P anonymous network support (requires SAM bridge).
867    #[serde(default)]
868    pub enable_i2p: bool,
869    /// SAM bridge hostname (default: "127.0.0.1").
870    #[serde(default = "default_i2p_hostname")]
871    pub i2p_hostname: String,
872    /// SAM bridge port (default: 7656).
873    #[serde(default = "default_i2p_port")]
874    pub i2p_port: u16,
875    /// Number of inbound I2P tunnels (1-16, default: 3).
876    #[serde(default = "default_i2p_tunnel_quantity")]
877    pub i2p_inbound_quantity: u8,
878    /// Number of outbound I2P tunnels (1-16, default: 3).
879    #[serde(default = "default_i2p_tunnel_quantity")]
880    pub i2p_outbound_quantity: u8,
881    /// Number of hops in inbound I2P tunnels (0-7, default: 3).
882    #[serde(default = "default_i2p_tunnel_length")]
883    pub i2p_inbound_length: u8,
884    /// Number of hops in outbound I2P tunnels (0-7, default: 3).
885    #[serde(default = "default_i2p_tunnel_length")]
886    pub i2p_outbound_length: u8,
887    /// Allow mixing I2P and clearnet peers in the same torrent.
888    /// When false (default), I2P-enabled torrents only connect to I2P peers.
889    #[serde(default)]
890    pub allow_i2p_mixed: bool,
891
892    // ── SSL torrents (M42) ──
893    /// SSL listen port for SSL torrent incoming connections.
894    /// 0 = disabled (no SSL listener). When set, a TLS listener is bound
895    /// on this port for torrents with `ssl-cert` in their info dict.
896    #[serde(default = "default_ssl_listen_port")]
897    pub ssl_listen_port: u16,
898    /// Path to the PEM-encoded certificate file for SSL torrent connections.
899    /// If not set, a self-signed certificate is auto-generated on first use
900    /// and stored in `resume_data_dir` (or a temp directory).
901    #[serde(default, skip_serializing_if = "Option::is_none")]
902    pub ssl_cert_path: Option<PathBuf>,
903    /// Path to the PEM-encoded private key file for SSL torrent connections.
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub ssl_key_path: Option<PathBuf>,
906
907    // ── Choking algorithms (M43) ──
908    /// Algorithm for ranking peers during seed-mode choking.
909    #[serde(default = "default_seed_choking_algorithm")]
910    pub seed_choking_algorithm: SeedChokingAlgorithm,
911    /// Algorithm for determining the number of unchoke slots.
912    #[serde(default = "default_choking_algorithm")]
913    pub choking_algorithm: ChokingAlgorithm,
914
915    // ── Peer connections ──
916    /// Maximum peer connections per torrent (default: 128). When `0`, falls back
917    /// to `HARD_PEER_CEILING` (4096) — there is no "unlimited" mode, see Bug 7
918    /// fix in v0.187.3.
919    #[serde(default = "default_max_peers_per_torrent")]
920    pub max_peers_per_torrent: usize,
921
922    /// v0.187.3 / OV2 / 12A: seconds after a peer goes Live before Pass 0
923    /// (zero-throughput) eviction can fire against it. Default 60. Prevents
924    /// the proactive-eviction loop from culling peers still in `BitTorrent`
925    /// slow-start. 0 = disable grace (legacy v0.187.2 behaviour).
926    #[serde(default = "default_pass0_grace_secs")]
927    pub pass0_grace_secs: u64,
928
929    /// v0.187.3 / 3A: sliding-window cap on proactive evictions in any
930    /// rolling 60s window. Default 30. The churn from the dogfood report
931    /// ("130 → 20-50 every few seconds") shows what 0 looks like; this is
932    /// the upper bound on how aggressive the eviction loop can be.
933    #[serde(default = "default_proactive_evictions_per_minute_limit")]
934    pub proactive_evictions_per_minute_limit: u32,
935
936    /// v0.187.3: how long a Pass 0 eviction victim is blocked from
937    /// reconnection. Default 600 (10 min). Was effectively 1800 (30 min)
938    /// pre-v0.187.3 — shorter ban duration lets slow peers warm up and
939    /// rejoin without forcing the user to restart.
940    #[serde(default = "default_eviction_ban_duration_secs")]
941    pub eviction_ban_duration_secs: u64,
942
943    /// v0.187.3 / OV4: FIFO cap on the banned-peer set. Default 1024 (was
944    /// 256). With the previous cap, busy swarms thrashed the ban set —
945    /// peers fell off the back faster than ban duration could elapse.
946    #[serde(default = "default_eviction_ban_set_cap")]
947    pub eviction_ban_set_cap: usize,
948
949    /// M133: Seconds without any wire message before disconnecting a peer.
950    /// Matches rqbit's 10s read timeout. 0 = disabled. Default: 10.
951    #[serde(default = "default_peer_read_timeout_secs")]
952    pub peer_read_timeout_secs: u64,
953    /// M133: Seconds before a stalled outgoing write disconnects a peer.
954    /// 0 = disabled. Default: 10.
955    #[serde(default = "default_peer_write_timeout_secs")]
956    pub peer_write_timeout_secs: u64,
957
958    /// M137: Data contribution timeout — seconds without receiving a Piece
959    /// message before disconnecting. Set to 0 to disable. Default: 60.
960    #[serde(default = "default_data_contribution_timeout")]
961    pub data_contribution_timeout_secs: u64,
962
963    /// M138: Maximum peers to evict per choke rotation tick (0 = disabled).
964    #[serde(default = "default_choke_rotation_max_evictions")]
965    pub choke_rotation_max_evictions: u32,
966
967    /// M138: Maximum concurrent outbound peer connections (throttles connect ramp).
968    #[serde(default = "default_max_concurrent_connects")]
969    pub max_concurrent_connects: u16,
970
971    /// M147: Seconds without TCP SYN-ACK before soft reap disconnects a connecting
972    /// peer. Peers that have received SYN-ACK get the full `peer_connect_timeout`.
973    #[serde(default = "default_connect_soft_timeout")]
974    pub connect_soft_timeout: u64,
975
976    /// M182: dispatch-channel backlog cap. The reader's `BackpressureQueue`
977    /// spills up to this many items if `dispatch_tx` is full; on overflow
978    /// the peer is disconnected. Lowering this value forces overflow under
979    /// less load — the would-have-caught harness uses `cap = 2` to
980    /// reproduce the M182 backlog-too-small regression class.
981    #[serde(default = "default_dispatch_backlog_cap")]
982    pub dispatch_backlog_cap: usize,
983
984    /// M182: event-channel backlog cap. Same role as
985    /// [`Self::dispatch_backlog_cap`] for the `event_tx` queue carrying
986    /// `PeerEvent::*` from reader to `TorrentActor`.
987    #[serde(default = "default_event_backlog_cap")]
988    pub event_backlog_cap: usize,
989
990    /// M243 (L6): per-peer outbound writer channel capacity. The writer
991    /// task drains this bounded channel to the wire; producers `try_send`
992    /// and apply a per-variant overflow policy (hot-path `Request` →
993    /// disconnect, idempotent `Have`/control → drop). Bounding it caps the
994    /// memory a stalled/slow peer socket can pin. Must be `> 0` and
995    /// `> max_request_queue_depth` (see [`Self::validate`]) so a full
996    /// request pipeline cannot by itself trip writer-backpressure
997    /// disconnects.
998    #[serde(default = "default_peer_writer_channel_cap")]
999    pub peer_writer_channel_cap: usize,
1000
1001    /// M187 A/B: use actor-centralised dispatch (true) or per-peer CAS dispatch (false).
1002    #[serde(default = "default_true")]
1003    pub use_actor_dispatch: bool,
1004
1005    /// M178: Minimum milliseconds between `PeerEvent::WebSeedProgress` emissions
1006    /// per URL. Coalesces stat updates from `WebSeedTask` so the actor channel
1007    /// stays bounded under fast piece-fetch loops. Cold-start (first event for
1008    /// a URL) and error events bypass the throttle. `0` disables coalescing.
1009    #[serde(default = "default_web_seed_progress_throttle_ms")]
1010    pub web_seed_progress_throttle_ms: u64,
1011
1012    // ── Security ──
1013    /// Enable SSRF mitigation: restrict localhost tracker paths, block
1014    /// public-to-private redirects, and reject query strings on local web seeds.
1015    #[serde(default = "default_true")]
1016    pub ssrf_mitigation: bool,
1017    /// Allow internationalised (non-ASCII) domain names in tracker/web seed URLs.
1018    #[serde(default)]
1019    pub allow_idna: bool,
1020    /// Require HTTPS for HTTP tracker announces (UDP trackers are unaffected).
1021    #[serde(default = "default_true")]
1022    pub validate_https_trackers: bool,
1023    /// Maximum BEP 9 metadata size in bytes that will be accepted from peers.
1024    /// Protects against OOM from peers claiming enormous metadata. Default: 4 MiB.
1025    #[serde(default = "default_max_metadata_size")]
1026    pub max_metadata_size: u64,
1027    /// Maximum wire protocol message size in bytes. Messages exceeding this are
1028    /// rejected by the codec. Default: 16 MiB.
1029    #[serde(default = "default_max_message_size")]
1030    pub max_message_size: usize,
1031    /// Maximum accepted piece length when adding a torrent. Rejects torrents
1032    /// with piece sizes above this limit. Default: 32 MiB.
1033    #[serde(default = "default_max_piece_length")]
1034    pub max_piece_length: u64,
1035    /// Maximum outstanding incoming requests per peer. When a peer sends more
1036    /// Request messages than this without them being served, excess requests
1037    /// are dropped. Default: 500.
1038    #[serde(default = "default_max_outstanding_requests")]
1039    pub max_outstanding_requests: usize,
1040    /// Maximum number of pieces simultaneously in-flight (downloaded but not
1041    /// yet verified). Caps memory usage for in-progress pieces. When the cap
1042    /// is reached, the piece selector only returns blocks from already-in-flight
1043    /// pieces. Default: 512.
1044    #[serde(default = "default_max_in_flight_pieces")]
1045    pub max_in_flight_pieces: usize,
1046    /// Timeout in seconds for outbound TCP peer connections.
1047    /// Default 10. Set to 0 to use the OS default (~2 minutes on Linux).
1048    #[serde(default = "default_peer_connect_timeout")]
1049    pub peer_connect_timeout: u64,
1050    /// DSCP (Differentiated Services Code Point) value for peer traffic sockets.
1051    /// Applied to TCP listeners, outbound TCP connections, uTP sockets, and UDP tracker sockets.
1052    /// Default 0x08 (CS1/scavenger — low-priority background). Set to 0 to disable DSCP marking.
1053    #[serde(default = "default_peer_dscp")]
1054    pub peer_dscp: u8,
1055
1056    // ── Session Stats (M50) ──
1057    /// Interval in milliseconds between `SessionStatsAlert` emissions.
1058    /// Default 1000 (1 second). Set to 0 to disable periodic stats alerts.
1059    #[serde(default = "default_stats_report_interval")]
1060    pub stats_report_interval: u64,
1061
1062    // ── Runtime tuning (M95) ──
1063    /// Number of tokio worker threads. Default: min(available cores, 8).
1064    /// Set to 0 to use tokio's default (= `available_parallelism()`).
1065    #[serde(default = "default_runtime_worker_threads")]
1066    pub runtime_worker_threads: usize,
1067    /// Pin tokio worker threads to CPU cores for cache locality. Default: true.
1068    #[serde(default = "default_true")]
1069    pub pin_cores: bool,
1070
1071    // ── Lock diagnostics (M120) ──
1072    /// Warning threshold in milliseconds for lock hold duration.
1073    /// When a hot-path lock is held longer than this, a tracing warning is
1074    /// emitted. Set to 0 to disable timing entirely (zero overhead).
1075    /// Default: 50.
1076    #[serde(default = "default_lock_warn_threshold_ms")]
1077    pub lock_warn_threshold_ms: u64,
1078
1079    // ── DHT bootstrap (M56) ──
1080    /// Previously saved DHT routing table nodes for fast bootstrap.
1081    /// These are prepended to the bootstrap node list on startup so that
1082    /// peer discovery starts instantly instead of bootstrapping from scratch.
1083    /// Runtime-injected, not serialized.
1084    #[serde(skip)]
1085    pub dht_saved_nodes: Vec<String>,
1086    /// BEP 42-compliant DHT node ID from previous session.
1087    /// Reusing the same ID avoids routing table regeneration on every startup.
1088    /// Runtime-injected, not serialized.
1089    #[serde(skip)]
1090    pub dht_node_id: Option<irontide_core::Id20>,
1091
1092    /// qBittorrent `WebUI` v2 compatibility layer (M168).
1093    /// Opt-in; disabled by default. Enables *arr integration via qBt's de-facto
1094    /// API protocol. See `QbtCompatSettings` for full field documentation.
1095    #[serde(default)]
1096    pub qbt_compat: QbtCompatSettings,
1097
1098    /// M170: Path to the qBt-compat category registry TOML file. When
1099    /// `None`, the default `$XDG_CONFIG_HOME/irontide/categories.toml`
1100    /// resolution is used (matching the `config.toml` convention).
1101    #[serde(default, skip_serializing_if = "Option::is_none")]
1102    pub category_registry_path: Option<PathBuf>,
1103
1104    /// M171: Path to the qBt-compat tag registry TOML file. When `None`,
1105    /// the default `$XDG_CONFIG_HOME/irontide/tags.toml` resolution is
1106    /// used (matching the `category_registry_path` convention).
1107    #[serde(default, skip_serializing_if = "Option::is_none")]
1108    pub tag_registry_path: Option<PathBuf>,
1109
1110    // ── M226: Notifications / paths / watched folder / network ──────────
1111    /// M226: Fire an OS desktop notification when a torrent finishes
1112    /// downloading. Wired through `NotificationSink` in `notification.rs`.
1113    /// Default: false.
1114    #[serde(default)]
1115    pub notify_on_complete: bool,
1116    /// M226: Fire an OS desktop notification when a torrent enters an error
1117    /// state. Default: false.
1118    #[serde(default)]
1119    pub notify_on_error: bool,
1120    /// M226: Path to a program to run on torrent completion (qBt parity
1121    /// field). STORED ONLY — subprocess spawning is deferred to a future
1122    /// engine milestone (child-reaper, env scrubbing, exec safety audit
1123    /// pending). Default: None.
1124    #[serde(default, skip_serializing_if = "Option::is_none")]
1125    pub on_complete_program: Option<PathBuf>,
1126    /// M226: Whether in-progress downloads use a separate directory before
1127    /// being moved to `download_dir` on completion. STORED ONLY — storage
1128    /// layer wiring deferred. Default: false.
1129    #[serde(default)]
1130    pub use_incomplete_dir: bool,
1131    /// M226: Directory for in-progress downloads (paired with
1132    /// `use_incomplete_dir`). STORED ONLY — storage layer wiring deferred.
1133    /// Must be absolute when `Some`. Default: None.
1134    #[serde(default, skip_serializing_if = "Option::is_none")]
1135    pub incomplete_dir: Option<PathBuf>,
1136    /// M226: Default value for `AddTorrentParams.skip_checking` when the
1137    /// caller does not specify. STORED ONLY — add-torrent flow wiring is
1138    /// deferred to M227's GUI "Skip hash check" toggle. Default: false.
1139    #[serde(default)]
1140    pub default_skip_hash_check: bool,
1141    /// M226: Append `.!ut` to filenames during download (qBt convention to
1142    /// signal partial files to file managers). STORED ONLY — storage layer
1143    /// wiring deferred. Default: true (qBt parity).
1144    #[serde(default = "default_true")]
1145    pub incomplete_extension_enabled: bool,
1146    /// M226: Path to a folder to watch for new `.torrent` files; on detection
1147    /// the file is auto-added to the session. Wired through `watched_folder.rs`.
1148    /// Must be absolute when `Some`. Default: None.
1149    #[serde(default, skip_serializing_if = "Option::is_none")]
1150    pub watched_folder: Option<PathBuf>,
1151    /// M226: After successfully adding a `.torrent` file from `watched_folder`,
1152    /// delete the source file. When false, the file is renamed to
1153    /// `<name>.duplicate` to prevent infinite-rescan (see plan §G2). Default:
1154    /// false (dry-run safe).
1155    #[serde(default)]
1156    pub delete_torrent_after_add: bool,
1157    /// M226: Whether to move completed torrents to `move_completed_to`.
1158    /// STORED ONLY — on-completion move logic deferred to a future engine
1159    /// milestone. Default: false.
1160    #[serde(default)]
1161    pub move_completed_enabled: bool,
1162    /// M226: Destination for completed torrents (paired with
1163    /// `move_completed_enabled`). STORED ONLY — move logic deferred. Must be
1164    /// absolute when `Some`. Default: None.
1165    #[serde(default, skip_serializing_if = "Option::is_none")]
1166    pub move_completed_to: Option<PathBuf>,
1167    /// M226: Enable `HTTPS` for the qBt v2 `WebUI` listener. STORED ONLY —
1168    /// rustls integration deferred to a Phase O follow-on or Phase P
1169    /// milestone. Default: false.
1170    #[serde(default)]
1171    pub web_ui_https_enabled: bool,
1172    /// M226: Bind peer listeners to a specific network interface (qBt
1173    /// `current_network_interface`). STORED ONLY — `SO_BINDTODEVICE` wiring
1174    /// deferred. Default: None (use 0.0.0.0 / [::]).
1175    #[serde(default, skip_serializing_if = "Option::is_none")]
1176    pub network_interface: Option<String>,
1177    /// M226: When `AddTorrentParams.paused` is `None`, this default decides
1178    /// whether new torrents start paused. Surfaced through the qBt v2
1179    /// `preferences.rs` GET projection as `start_paused_enabled`. Default:
1180    /// false.
1181    #[serde(default)]
1182    pub default_add_paused: bool,
1183    /// M255/ER1: resolve per-peer country codes via a user-supplied
1184    /// MaxMind/DB-IP `.mmdb` database (qBt preferences key
1185    /// `resolve_peer_countries`). Default: false — no DB is loaded and
1186    /// `PeerInfo.country_code` stays `None` (zero overhead).
1187    #[serde(default)]
1188    pub resolve_peer_countries: bool,
1189    /// M255/ER1: absolute path to the `GeoIP` country database
1190    /// (`IronTide` extension — qBt bundles its DB and exposes no path).
1191    /// Required (and must be absolute) when `resolve_peer_countries=true`.
1192    #[serde(default, skip_serializing_if = "Option::is_none")]
1193    pub peer_country_db_path: Option<PathBuf>,
1194}
1195
1196/// qBittorrent `WebUI` v2 compatibility layer configuration.
1197///
1198/// # Security note (M172a)
1199/// As of M172a passwords are stored in PHC-format argon2id hashes in
1200/// [`Self::password_hash`]. The legacy [`Self::password`] field is retained
1201/// as a one-shot upgrade path — on daemon startup, if `password_hash` is
1202/// empty and `password` is non-empty, the daemon hashes the plaintext,
1203/// writes it back to [`Self::password_hash`] (zeroing the plaintext), and
1204/// atomically rewrites the config file via
1205/// [`crate::migrate_qbt_credentials`]. Fresh installs ship with
1206/// `password = ""` and a pre-hashed `password_hash` so no migration WARN
1207/// ever fires on a clean daemon.
1208///
1209/// File permissions (`0o600`) are still enforced by
1210/// [`irontide-config`'s `save_config_atomic`] as defence-in-depth — the PHC
1211/// hash is not directly reversible, but password-cracking dictionaries
1212/// remain feasible for weak passwords.
1213#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
1214#[serde(default)]
1215pub struct QbtCompatSettings {
1216    /// Master enable flag. When `false`, all `/api/v2/*` routes return 404
1217    /// (not 403) — the route must appear non-existent. Default: `true`
1218    /// (v0.172.1 flip, inverted from v0.168.0's security-through-invisibility
1219    /// default). Set `enabled = false` in `config.toml` under `[qbt_compat]`
1220    /// to opt out — the argon2id hash + brute-force ban + CSRF middleware
1221    /// (M172a) remain the primary defences; 404-on-disabled is defence-in-
1222    /// depth, not primary security.
1223    pub enabled: bool,
1224    /// Username required for qBt v2 login. Default: `"admin"` (qBt factory
1225    /// default). Must be non-empty when `enabled`.
1226    pub username: String,
1227    /// Argon2id PHC-format password hash (M172a). Example:
1228    /// `"$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>"`. OWASP-recommended
1229    /// parameters (m=19456 KiB, t=2, p=1).
1230    ///
1231    /// Fresh installs ship a non-empty default pre-hashing the factory
1232    /// "adminadmin" password so no migration WARN ever fires. The daemon
1233    /// rejects a malformed hash at validate-time.
1234    pub password_hash: String,
1235    /// Legacy plaintext password — **deprecated, migration-only**. Populated
1236    /// on config files written before M172a; the daemon rehashes and
1237    /// zeroizes this on next startup, leaving it permanently empty
1238    /// afterwards. New configs must ship with `password = ""` and a
1239    /// non-empty `password_hash`. Default: `""`.
1240    #[serde(default)]
1241    pub password: String,
1242    /// Version string returned by `GET /api/v2/app/version`. Must match the
1243    /// regex `^v\d+\.\d+(\.\d+)?(-\w+)?$`. Default: `"v5.1.4"`.
1244    pub spoof_app_version: String,
1245    /// Version string returned by `GET /api/v2/app/webapiVersion`. Must match
1246    /// the regex `^\d+\.\d+(\.\d+)?$` (no leading `v`). Default: `"2.11.4"`.
1247    pub spoof_webapi_version: String,
1248    /// Session cookie TTL in seconds. Bounds: `[60, 604_800]` (1 minute to
1249    /// 1 week). Default: `86_400` (24 hours).
1250    pub session_ttl_secs: u64,
1251    /// Maximum concurrent sessions. Prevents unbounded growth on login
1252    /// storms. Must be > 0. Default: `1024`.
1253    pub max_sessions: usize,
1254    /// Optional override for the global argon2 verification semaphore size
1255    /// (M172a G2). `None` means use the computed default
1256    /// `num_cpus::get() * 2`, clamped to `[2, 16]`. Rejects `Some(0)`. Peak
1257    /// memory under flood is bounded by `permits * 19 MiB`.
1258    #[serde(default, skip_serializing_if = "Option::is_none")]
1259    pub max_concurrent_argon2_ops: Option<u32>,
1260
1261    /// v0.187.3 / 2A: TCP port the Web UI listens on. Single source of truth
1262    /// for the listen port; the legacy `[api].port` field is deprecated and
1263    /// auto-migrates to this value on config load with a one-time warning.
1264    /// Default: `9080`. Validated `> 0` when `enabled` is true.
1265    #[serde(default = "default_qbt_port")]
1266    pub port: u16,
1267
1268    /// v0.187.3 / 2A: bind address the Web UI listens on. Single source of
1269    /// truth; the legacy `[api].bind` field is deprecated and auto-migrates.
1270    /// Default: `"127.0.0.1"`. Use `"0.0.0.0"` to expose on all interfaces
1271    /// (behind a reverse proxy strongly recommended).
1272    #[serde(default = "default_qbt_bind_address")]
1273    pub bind_address: String,
1274
1275    // M172a Lane B (CSRF + reverse-proxy). Fields appended at the end of the
1276    // struct to keep merge conflicts minimal with parallel Lane C.
1277    /// M172a Lane B: enable Origin/Referer CSRF checks on mutating requests
1278    /// (POST/PATCH/PUT/DELETE) against `/webui/*` and `/api/v2/*`. When both
1279    /// headers are absent the request is allowed (server-to-server case;
1280    /// matches qBt and is what `*arr` clients need). Default: `true`.
1281    #[serde(default = "default_csrf_protection_enabled")]
1282    pub csrf_protection_enabled: bool,
1283    /// M172a Lane B: enable Host-header validation against Origin/Referer.
1284    /// When `csrf_protection_enabled` is `true` but this flag is `false`, the
1285    /// middleware short-circuits to allow. Useful for reverse-proxy setups
1286    /// whose proxy strips/rewrites the Host header in a non-trivial way.
1287    /// Default: `true`.
1288    #[serde(default = "default_host_header_validation_enabled")]
1289    pub host_header_validation_enabled: bool,
1290    /// M172a Lane B: when true, the CSRF middleware resolves the real client
1291    /// IP via the XFF trust-hop algorithm and validates Host against
1292    /// `X-Forwarded-Host` + `X-Forwarded-Proto` *only when* the peer matches
1293    /// one of the CIDRs in [`Self::web_ui_reverse_proxies_list`]. Untrusted
1294    /// peers fall back to direct Host validation — defence-in-depth against
1295    /// an attacker spoofing XFH from outside the proxy layer. Default:
1296    /// `false`.
1297    #[serde(default)]
1298    pub web_ui_reverse_proxy_enabled: bool,
1299    /// M172a Lane B: list of CIDRs trusted to supply `X-Forwarded-For` and
1300    /// `X-Forwarded-Host` headers. Each entry must parse as
1301    /// [`ipnet::IpNet`] (validated in [`Settings::validate`]). Empty list
1302    /// is valid but degrades reverse-proxy mode to "trust nobody" — the
1303    /// middleware falls back to direct Host validation in that case.
1304    ///
1305    /// **Narrow is safer.** Prefer single-host CIDRs like `172.20.0.5/32`
1306    /// over block-wide `172.16.0.0/12`. A too-wide mask means any client
1307    /// inside a trusted subnet can spoof `X-Forwarded-Host` and defeat
1308    /// CSRF protection; a `/32` binds trust to the exact proxy IP.
1309    #[serde(default)]
1310    pub web_ui_reverse_proxies_list: Vec<String>,
1311
1312    // ── M172a Lane C: brute-force ban ──────────────────────────────────
1313    /// Maximum number of failed `auth/login` attempts from a single source
1314    /// IP before the IP is banned. Must be `> 0` unless
1315    /// [`Self::bypass_local_auth`] is `true`. Default: `5`.
1316    ///
1317    /// The counter resets on a successful login and on ban expiry.
1318    #[serde(default = "default_max_failed_auth_count")]
1319    pub max_failed_auth_count: u32,
1320    /// Ban duration (seconds) after hitting [`Self::max_failed_auth_count`].
1321    /// Bounds: `[60, 86_400]` (1 minute to 1 day). Default: `3_600` (1 hour).
1322    #[serde(default = "default_ban_duration_secs")]
1323    pub ban_duration_secs: u64,
1324    /// When `true`, any request whose resolved client IP is loopback
1325    /// (`127.0.0.0/8`, `::1`) bypasses authentication entirely and
1326    /// receives a valid SID cookie. Default: `false`.
1327    ///
1328    /// Combined with [`Self::bypass_auth_subnet_whitelist`] these provide
1329    /// the qBt-parity "local auth off" and "whitelisted subnets" escape
1330    /// hatches that `*arr` clients rely on.
1331    #[serde(default)]
1332    pub bypass_local_auth: bool,
1333    /// CIDR strings whose resolved client IP bypasses authentication
1334    /// entirely. Each string must parse as an [`ipnet::IpNet`]. Default:
1335    /// `vec![]`.
1336    ///
1337    /// Interpreted at router construction time and re-parsed on
1338    /// `setPreferences` apply so runtime reconfiguration flows through the
1339    /// shared `QbtState::bypass_auth_subnet_whitelist` `RwLock`.
1340    #[serde(default)]
1341    pub bypass_auth_subnet_whitelist: Vec<String>,
1342    /// Optional override for the brute-force-ban registry's LRU capacity.
1343    /// `None` means use the internal default of `10_000`. Rejects values
1344    /// `< 100`. Default: `None`.
1345    ///
1346    /// The registry retains its initial capacity until daemon restart —
1347    /// runtime changes only affect NEW entries admitted afterwards (see
1348    /// `FIXME` in the `classify_immediate` handler).
1349    #[serde(default, skip_serializing_if = "Option::is_none")]
1350    pub brute_force_registry_capacity: Option<usize>,
1351}
1352
1353fn default_csrf_protection_enabled() -> bool {
1354    true
1355}
1356
1357fn default_host_header_validation_enabled() -> bool {
1358    true
1359}
1360
1361// v0.187.3 / 2A: Web UI bind + port defaults. Match what previously lived
1362// on `[api]`.
1363fn default_qbt_port() -> u16 {
1364    9080
1365}
1366
1367fn default_qbt_bind_address() -> String {
1368    "127.0.0.1".to_owned()
1369}
1370
1371/// Default for [`QbtCompatSettings::max_failed_auth_count`].
1372#[must_use]
1373pub const fn default_max_failed_auth_count() -> u32 {
1374    5
1375}
1376
1377/// Default for [`QbtCompatSettings::ban_duration_secs`].
1378#[must_use]
1379pub const fn default_ban_duration_secs() -> u64 {
1380    3_600
1381}
1382
1383/// Argon2id PHC hash of the default "adminadmin" password (M172a A3).
1384///
1385/// Pre-computed once using the OWASP-recommended parameters with a
1386/// deterministic salt so round-tripping the default config across installs
1387/// is stable. The salt literal below is not a secret — it's supposed to be
1388/// recognisably the shipped default so operators know to rotate it.
1389///
1390/// The hash is regenerated by the [`tests::default_hash_roundtrips_admin_admin`]
1391/// test, which will fail with a suggested replacement value if parameters
1392/// or salt change.
1393pub const DEFAULT_ADMINADMIN_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$u3doPIM7ab7NlbMfhMFm6A$ctIAjFfl70eUfUsThdGcXICr0lcD6bEUilRujvnXLPg";
1394
1395impl Default for QbtCompatSettings {
1396    fn default() -> Self {
1397        Self {
1398            enabled: true,
1399            username: "admin".into(),
1400            password_hash: DEFAULT_ADMINADMIN_HASH.into(),
1401            password: String::new(),
1402            spoof_app_version: "v5.1.4".into(),
1403            spoof_webapi_version: "2.11.4".into(),
1404            session_ttl_secs: 86_400,
1405            max_sessions: 1024,
1406            max_concurrent_argon2_ops: None,
1407            // v0.187.3 / 2A: Web UI listen socket — single source of truth.
1408            port: default_qbt_port(),
1409            bind_address: default_qbt_bind_address(),
1410            // M172a Lane B defaults — CSRF on, host validation on, no proxy.
1411            csrf_protection_enabled: true,
1412            host_header_validation_enabled: true,
1413            web_ui_reverse_proxy_enabled: false,
1414            web_ui_reverse_proxies_list: Vec::new(),
1415            // M172a Lane C: brute-force ban defaults.
1416            max_failed_auth_count: default_max_failed_auth_count(),
1417            ban_duration_secs: default_ban_duration_secs(),
1418            bypass_local_auth: false,
1419            bypass_auth_subnet_whitelist: Vec::new(),
1420            brute_force_registry_capacity: None,
1421        }
1422    }
1423}
1424
1425/// Outcome of a legacy-plaintext migration pass (M172a A3 / C2).
1426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1427pub enum QbtCredentialMigration {
1428    /// `password_hash` already present or nothing to migrate — no change.
1429    NoOp,
1430    /// Plaintext was hashed and written back into `password_hash`; the
1431    /// in-memory `password` was zeroed. The in-memory settings are mutated
1432    /// in place; callers should persist the mutation.
1433    Upgraded,
1434}
1435
1436/// Hash `plaintext` with OWASP-recommended argon2id parameters and return the
1437/// PHC-format encoded string (M172a).
1438///
1439/// Pure CPU work — callers on async stacks should wrap in
1440/// `tokio::task::spawn_blocking` for anything other than a one-shot startup
1441/// migration. Login-time verification has its own concurrency limiter.
1442///
1443/// # Errors
1444///
1445/// Returns an error when the `argon2` crate's own hashing fails (empty
1446/// plaintext, OS entropy failure, internal parameter error).
1447pub fn hash_qbt_password(plaintext: &str) -> Result<String, QbtMigrationError> {
1448    use argon2::password_hash::{PasswordHasher, SaltString};
1449    use argon2::{Algorithm, Argon2, Params, Version};
1450
1451    // `rand_core::OsRng` + its `getrandom` feature is our entropy source —
1452    // pulled in directly rather than via argon2's `rand` feature flag so the
1453    // hash path is decoupled from argon2 feature churn.
1454    let salt = SaltString::generate(&mut rand_core::OsRng);
1455    // OWASP cheat-sheet (argon2id): m=19_456 KiB, t=2, p=1. Output length
1456    // 32 bytes = 256 bits of key material.
1457    let params = Params::new(19_456, 2, 1, Some(32))
1458        .map_err(|e| QbtMigrationError::Hash(format!("argon2 params: {e}")))?;
1459    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1460    let hash = argon2
1461        .hash_password(plaintext.as_bytes(), &salt)
1462        .map_err(|e| QbtMigrationError::Hash(format!("argon2 hash: {e}")))?;
1463    Ok(hash.to_string())
1464}
1465
1466/// Errors raised by [`migrate_qbt_credentials`].
1467#[derive(Debug, thiserror::Error)]
1468pub enum QbtMigrationError {
1469    /// argon2 hashing failure — returned by [`hash_qbt_password`].
1470    #[error("argon2 hash: {0}")]
1471    Hash(String),
1472}
1473
1474/// One-shot legacy-plaintext → argon2id migration for
1475/// [`QbtCompatSettings`].
1476///
1477/// Semantics:
1478///
1479/// * `password_hash` non-empty → [`QbtCredentialMigration::NoOp`] (most
1480///   common path: fresh install, already-migrated install).
1481/// * `password_hash` empty **and** `password` non-empty → compute a fresh
1482///   PHC-format hash, assign it to `password_hash`, zero the plaintext via
1483///   [`zeroize::Zeroizing`] + `std::mem::take`, return
1484///   [`QbtCredentialMigration::Upgraded`].
1485/// * Both empty → [`QbtCredentialMigration::NoOp`]. Validation elsewhere
1486///   rejects an "enabled and both-empty" combo so we never authenticate an
1487///   unconfigured daemon.
1488///
1489/// This helper does *not* touch disk — callers pair it with
1490/// [`irontide_config::save_config_atomic`] (or a hand-rolled rewrite) to
1491/// persist the rewritten `Settings`. On migration failure the plaintext is
1492/// left untouched in memory so the daemon can still authenticate during
1493/// this boot; the migration will retry on the next startup.
1494///
1495/// # Errors
1496///
1497/// Propagates [`QbtMigrationError::Hash`] if argon2 hashing itself fails.
1498/// On `Err` the input is left unmodified so the caller's session-startup
1499/// path can continue with the plaintext still in memory.
1500pub fn migrate_qbt_credentials(
1501    qbt: &mut QbtCompatSettings,
1502) -> Result<QbtCredentialMigration, QbtMigrationError> {
1503    if !qbt.password_hash.is_empty() {
1504        return Ok(QbtCredentialMigration::NoOp);
1505    }
1506    if qbt.password.is_empty() {
1507        return Ok(QbtCredentialMigration::NoOp);
1508    }
1509
1510    let hash = hash_qbt_password(&qbt.password)?;
1511    qbt.password_hash = hash;
1512    // C7: substantive zeroize — the Settings struct has a longer in-memory
1513    // lifetime than the request path, so scrubbing the plaintext here
1514    // actually removes a residual copy rather than theatre.
1515    let _drain = zeroize::Zeroizing::new(std::mem::take(&mut qbt.password));
1516    Ok(QbtCredentialMigration::Upgraded)
1517}
1518
1519/// Validate an app-version string (e.g. `v5.1.4` or `v4.6.4-rc1`).
1520fn is_valid_app_version(s: &str) -> bool {
1521    let Some(rest) = s.strip_prefix('v') else {
1522        return false;
1523    };
1524    // Split optional "-suffix" (pre-release tag like rc1, beta2) from the numeric core.
1525    let (core, suffix_ok) = match rest.split_once('-') {
1526        Some((core, suffix)) => (
1527            core,
1528            !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_alphanumeric()),
1529        ),
1530        None => (rest, true),
1531    };
1532    if !suffix_ok {
1533        return false;
1534    }
1535    is_valid_dotted_numeric(core)
1536}
1537
1538/// Validate a webapi-version string (e.g. `2.11.4`).
1539fn is_valid_webapi_version(s: &str) -> bool {
1540    is_valid_dotted_numeric(s)
1541}
1542
1543/// Accepts two or three non-empty dot-separated numeric segments.
1544fn is_valid_dotted_numeric(s: &str) -> bool {
1545    let parts: Vec<&str> = s.split('.').collect();
1546    if !(2..=3).contains(&parts.len()) {
1547        return false;
1548    }
1549    parts
1550        .iter()
1551        .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
1552}
1553
1554impl Default for Settings {
1555    fn default() -> Self {
1556        Self {
1557            // General
1558            listen_port: 42020,
1559            randomize_port_on_startup: false,
1560            download_dir: PathBuf::from("."),
1561            max_torrents: 100,
1562            resume_data_dir: None,
1563            save_resume_interval_secs: 300,
1564            // Protocol features
1565            enable_dht: true,
1566            enable_pex: true,
1567            enable_lsd: true,
1568            enable_fast_extension: true,
1569            enable_utp: true,
1570            enable_upnp: true,
1571            enable_natpmp: true,
1572            enable_ipv6: true,
1573            enable_web_seed: true,
1574            enable_holepunch: true,
1575            enable_bep40_eviction: true,
1576            enable_diagnostic_counters: false,
1577            encryption_mode: EncryptionMode::Disabled,
1578            anonymous_mode: false,
1579            external_ip: None,
1580            // Seeding
1581            seed_ratio_limit: None,
1582            seed_time_limit_secs: None,
1583            inactive_seed_time_limit_secs: None,
1584            max_ratio_action: MaxRatioAction::Pause,
1585            create_subfolder: true,
1586            auto_manage_torrents: false,
1587            queueing_enabled: false,
1588            default_super_seeding: false,
1589            default_share_mode: false,
1590            upload_only_announce: true,
1591            // Rate limiting
1592            upload_rate_limit: 0,
1593            download_rate_limit: 0,
1594            tcp_upload_rate_limit: 0,
1595            tcp_download_rate_limit: 0,
1596            utp_upload_rate_limit: 0,
1597            utp_download_rate_limit: 0,
1598            auto_upload_slots: true,
1599            auto_upload_slots_min: 2,
1600            auto_upload_slots_max: 20,
1601            max_upload_slots_global: -1,
1602            max_upload_slots_per_torrent: 4,
1603            max_connections_global: -1,
1604            max_uploads_per_torrent: -1,
1605            alt_download_rate_limit: 0,
1606            alt_upload_rate_limit: 0,
1607            alt_speed_enabled: false,
1608            alt_speed_schedule_enabled: false,
1609            alt_speed_schedule_from: 0,
1610            alt_speed_schedule_to: 0,
1611            alt_speed_schedule_days: 0,
1612            rate_limit_includes_overhead: true,
1613            rate_limit_utp: true,
1614            rate_limit_lan: false,
1615            mixed_mode_algorithm: MixedModeAlgorithm::PeerProportional,
1616            // Queue management
1617            active_downloads: 3,
1618            active_seeds: 5,
1619            active_limit: 500,
1620            active_checking: 3,
1621            dont_count_slow_torrents: true,
1622            inactive_down_rate: 2048,
1623            inactive_up_rate: 2048,
1624            auto_manage_interval: 30,
1625            auto_manage_startup: 60,
1626            auto_manage_prefer_seeds: false,
1627            queue_rate_ewma_alpha: 0.3,
1628            seed_queue_min_active_secs: 1800,
1629            // Alerts
1630            alert_mask: AlertCategory::ALL,
1631            alert_channel_size: 1024,
1632            // Smart banning
1633            smart_ban_max_failures: 3,
1634            smart_ban_parole: true,
1635            // Disk I/O
1636            disk_io_threads: default_disk_io_threads(),
1637            max_blocking_threads: default_max_blocking_threads(),
1638            preallocate_mode: PreallocateMode::None,
1639            disk_cache_size: 16 * 1024 * 1024,
1640            disk_write_cache_ratio: 0.5,
1641            disk_channel_capacity: 512,
1642            buffer_pool_capacity: 64 * 1024 * 1024,
1643            enable_mlock: cfg!(unix),
1644            filesystem_direct_io: false,
1645            // Hashing & piece picking
1646            hashing_threads: default_hashing_threads(),
1647            max_request_queue_depth: 250,
1648            initial_queue_depth: 128,
1649            request_budget_per_torrent: 512,
1650            request_budget_floor: 8,
1651            request_queue_time: 3.0,
1652            block_request_timeout_secs: 60,
1653            max_concurrent_stream_reads: 8,
1654            auto_sequential: true,
1655            steal_threshold_ratio: 10.0,
1656            use_block_stealing: true,
1657            steal_stale_piece_secs: 2,
1658            steal_threshold_endgame: 3.0,
1659            fixed_pipeline_depth: 128,
1660            strict_end_game: true,
1661            max_web_seeds: 4,
1662            web_seed_retry_base_secs: 10,
1663            web_seed_retry_factor: 6,
1664            web_seed_retry_cap_secs: 3600,
1665            web_seed_max_failures: 10,
1666            initial_picker_threshold: 4,
1667            whole_pieces_threshold: 20,
1668            snub_timeout_secs: 15,
1669            readahead_pieces: 8,
1670            streaming_timeout_escalation: true,
1671            // Piece picker enhancements (M44)
1672            piece_extent_affinity: true,
1673            suggest_mode: true,
1674            max_suggest_pieces: 16,
1675            predictive_piece_announce_ms: 0,
1676            // Proxy
1677            proxy: ProxyConfig::default(),
1678            force_proxy: false,
1679            // IP Filtering
1680            ip_filter_enabled: false,
1681            ip_filter_path: String::new(),
1682            ip_filter_auto_refresh: false,
1683            apply_ip_filter_to_trackers: true,
1684            // DHT tuning
1685            dht_queries_per_second: 50,
1686            dht_query_timeout_secs: 5,
1687            dht_enforce_node_id: false,
1688            dht_restrict_routing_ips: true,
1689            dht_max_items: 700,
1690            dht_item_lifetime_secs: 7200,
1691            dht_sample_infohashes_interval: 0,
1692            dht_read_only: false,
1693            // NAT tuning
1694            upnp_lease_duration: 3600,
1695            natpmp_lifetime: 7200,
1696            // uTP tuning
1697            utp_max_connections: 256,
1698            // I2P
1699            enable_i2p: false,
1700            i2p_hostname: "127.0.0.1".into(),
1701            i2p_port: 7656,
1702            i2p_inbound_quantity: 3,
1703            i2p_outbound_quantity: 3,
1704            i2p_inbound_length: 3,
1705            i2p_outbound_length: 3,
1706            allow_i2p_mixed: false,
1707            // SSL torrents
1708            ssl_listen_port: 0,
1709            ssl_cert_path: None,
1710            ssl_key_path: None,
1711            // Choking algorithms
1712            seed_choking_algorithm: SeedChokingAlgorithm::FastestUpload,
1713            choking_algorithm: ChokingAlgorithm::FixedSlots,
1714            // Peer connections
1715            max_peers_per_torrent: 128,
1716            // v0.187.3 eviction policy (see field doc-comments)
1717            pass0_grace_secs: 60,
1718            proactive_evictions_per_minute_limit: 30,
1719            eviction_ban_duration_secs: 600,
1720            eviction_ban_set_cap: 1024,
1721            peer_read_timeout_secs: 10,
1722            peer_write_timeout_secs: 10,
1723            data_contribution_timeout_secs: 0,
1724            choke_rotation_max_evictions: 0,
1725            max_concurrent_connects: 128,
1726            connect_soft_timeout: 3,
1727            dispatch_backlog_cap: 8,
1728            event_backlog_cap: 32,
1729            peer_writer_channel_cap: 1024,
1730            use_actor_dispatch: true,
1731            web_seed_progress_throttle_ms: 250,
1732            // Security
1733            ssrf_mitigation: true,
1734            allow_idna: false,
1735            validate_https_trackers: true,
1736            max_metadata_size: 4 * 1024 * 1024,
1737            max_message_size: 16 * 1024 * 1024,
1738            max_piece_length: 32 * 1024 * 1024,
1739            max_outstanding_requests: 500,
1740            max_in_flight_pieces: 512,
1741            peer_connect_timeout: 10,
1742            peer_dscp: 0x08,
1743            // Session Stats (M50)
1744            stats_report_interval: 1000,
1745            // Runtime tuning (M95)
1746            runtime_worker_threads: default_runtime_worker_threads(),
1747            pin_cores: true,
1748            // Lock diagnostics (M120)
1749            lock_warn_threshold_ms: 50,
1750            // DHT bootstrap (M56)
1751            dht_saved_nodes: Vec::new(),
1752            dht_node_id: None,
1753            // qBt v2 compatibility (M168)
1754            qbt_compat: QbtCompatSettings::default(),
1755            // M170: default to None → resolved via XDG to
1756            // `$XDG_CONFIG_HOME/irontide/categories.toml` at registry load time.
1757            category_registry_path: None,
1758            // M171: default to None → resolved via XDG to
1759            // `$XDG_CONFIG_HOME/irontide/tags.toml` at registry load time.
1760            tag_registry_path: None,
1761            // M226: notifications + paths + watched folder + network
1762            notify_on_complete: false,
1763            notify_on_error: false,
1764            on_complete_program: None,
1765            use_incomplete_dir: false,
1766            incomplete_dir: None,
1767            default_skip_hash_check: false,
1768            incomplete_extension_enabled: true,
1769            watched_folder: None,
1770            delete_torrent_after_add: false,
1771            move_completed_enabled: false,
1772            move_completed_to: None,
1773            web_ui_https_enabled: false,
1774            network_interface: None,
1775            default_add_paused: false,
1776            resolve_peer_countries: false,
1777            peer_country_db_path: None,
1778        }
1779    }
1780}
1781
1782/// T3 (M241): sanity ceiling for `max_torrents` in [`Settings::validate`]. Far
1783/// above any realistic seedbox (deployments run hundreds–low-thousands of
1784/// torrents); it exists to reject fat-finger configs that would OOM the daemon,
1785/// not to tune resource limits.
1786const MAX_TORRENTS_CEILING: usize = 100_000;
1787
1788impl Settings {
1789    /// Preset for constrained/embedded environments.
1790    #[must_use]
1791    pub fn min_memory() -> Self {
1792        Self {
1793            disk_cache_size: 8 * 1024 * 1024,
1794            buffer_pool_capacity: 16 * 1024 * 1024,
1795            max_torrents: 20,
1796            max_peers_per_torrent: 30,
1797            active_downloads: 1,
1798            active_seeds: 2,
1799            active_limit: 10,
1800            alert_channel_size: 256,
1801            utp_max_connections: 64,
1802            max_request_queue_depth: 50,
1803            peer_writer_channel_cap: 256,
1804            initial_queue_depth: 16,
1805            max_concurrent_stream_reads: 2,
1806            hashing_threads: 1,
1807            disk_io_threads: 1,
1808            dht_max_items: 100,
1809            max_in_flight_pieces: 32,
1810            fixed_pipeline_depth: 32,
1811            ..Self::default()
1812        }
1813    }
1814
1815    /// Preset for desktop/server environments with ample resources.
1816    #[must_use]
1817    pub fn high_performance() -> Self {
1818        Self {
1819            disk_cache_size: 256 * 1024 * 1024,
1820            buffer_pool_capacity: 256 * 1024 * 1024,
1821            max_torrents: 2000,
1822            max_peers_per_torrent: 200,
1823            active_downloads: 30,
1824            active_seeds: 100,
1825            active_limit: 2000,
1826            alert_channel_size: 4096,
1827            utp_max_connections: 1024,
1828            max_request_queue_depth: 1000,
1829            peer_writer_channel_cap: 2048,
1830            initial_queue_depth: 256,
1831            max_concurrent_stream_reads: 32,
1832            hashing_threads: 4,
1833            disk_io_threads: 8,
1834            auto_upload_slots_max: 100,
1835            suggest_mode: true,
1836            steal_threshold_ratio: 5.0,
1837            steal_threshold_endgame: 2.0,
1838            use_block_stealing: true,
1839            max_in_flight_pieces: 512,
1840            ..Self::default()
1841        }
1842    }
1843
1844    /// Validate settings. Returns error on the first invalid combination found.
1845    ///
1846    /// # Errors
1847    ///
1848    /// Returns an error if validation fails.
1849    pub fn validate(&self) -> Result<(), SettingsError> {
1850        if self.force_proxy && self.proxy.proxy_type == ProxyType::None {
1851            return Err(SettingsError::Invalid(
1852                "force_proxy is enabled but no proxy type is configured".into(),
1853            ));
1854        }
1855
1856        if self.active_downloads > 0
1857            && self.active_limit > 0
1858            && self.active_downloads > self.active_limit
1859        {
1860            return Err(SettingsError::Invalid(
1861                "active_downloads exceeds active_limit".into(),
1862            ));
1863        }
1864
1865        if self.active_seeds > 0 && self.active_limit > 0 && self.active_seeds > self.active_limit {
1866            return Err(SettingsError::Invalid(
1867                "active_seeds exceeds active_limit".into(),
1868            ));
1869        }
1870
1871        if !(0.0..=1.0).contains(&self.disk_write_cache_ratio) {
1872            return Err(SettingsError::Invalid(
1873                "disk_write_cache_ratio must be between 0.0 and 1.0".into(),
1874            ));
1875        }
1876
1877        if self.disk_cache_size < 1024 * 1024 {
1878            return Err(SettingsError::Invalid(
1879                "disk_cache_size must be at least 1 MiB".into(),
1880            ));
1881        }
1882
1883        if self.hashing_threads == 0 {
1884            return Err(SettingsError::Invalid(
1885                "hashing_threads must be at least 1".into(),
1886            ));
1887        }
1888
1889        // M257c: request-budget relational bounds. The 128 ceiling is the
1890        // engine's per-peer pipeline cap (`INITIAL_QUEUE_DEPTH`).
1891        if self.request_budget_floor == 0 || self.request_budget_floor > 128 {
1892            return Err(SettingsError::Invalid(
1893                "request_budget_floor must be in 1..=128".into(),
1894            ));
1895        }
1896        if self.request_budget_per_torrent != 0
1897            && self.request_budget_per_torrent < self.request_budget_floor
1898        {
1899            return Err(SettingsError::Invalid(
1900                "request_budget_per_torrent must be 0 (disabled) or >= request_budget_floor".into(),
1901            ));
1902        }
1903
1904        // M243 (L6): the per-peer writer channel must be bounded, and its
1905        // bound must exceed the request pipeline depth — otherwise a peer
1906        // running a full request queue could fill the writer channel on its
1907        // own and trip a false-positive backpressure disconnect.
1908        if self.peer_writer_channel_cap == 0 {
1909            return Err(SettingsError::Invalid(
1910                "peer_writer_channel_cap must be > 0 (the writer channel must be bounded)".into(),
1911            ));
1912        }
1913        if self.peer_writer_channel_cap <= self.max_request_queue_depth {
1914            return Err(SettingsError::Invalid(
1915                "peer_writer_channel_cap must be > max_request_queue_depth (avoids false-positive writer-backpressure disconnects)".into(),
1916            ));
1917        }
1918
1919        // M226: paired-field validation + path-shape validation.
1920        if self.use_incomplete_dir && self.incomplete_dir.is_none() {
1921            return Err(SettingsError::Invalid(
1922                "incomplete_dir must be set when use_incomplete_dir=true".into(),
1923            ));
1924        }
1925        if self.move_completed_enabled && self.move_completed_to.is_none() {
1926            return Err(SettingsError::Invalid(
1927                "move_completed_to must be set when move_completed_enabled=true".into(),
1928            ));
1929        }
1930        // M255/ER1: same paired-Some shape — enabling resolution without a
1931        // DB is an actionable config error, not silent no-flags.
1932        if self.resolve_peer_countries && self.peer_country_db_path.is_none() {
1933            return Err(SettingsError::Invalid(
1934                "peer_country_db_path must be set when resolve_peer_countries=true".into(),
1935            ));
1936        }
1937        // M226 F11: every Option<PathBuf> path-field must be absolute when Some
1938        // so silent breakage at runtime is replaced with a config-load error.
1939        for (name, opt) in [
1940            ("watched_folder", self.watched_folder.as_ref()),
1941            ("incomplete_dir", self.incomplete_dir.as_ref()),
1942            ("move_completed_to", self.move_completed_to.as_ref()),
1943            ("peer_country_db_path", self.peer_country_db_path.as_ref()),
1944        ] {
1945            if let Some(p) = opt
1946                && !p.is_absolute()
1947            {
1948                return Err(SettingsError::Invalid(format!(
1949                    "{name} must be an absolute path, got {}",
1950                    p.display()
1951                )));
1952            }
1953        }
1954        // M226 H6: reject obviously-dangerous watched_folder paths. When
1955        // delete_torrent_after_add=true a typo here could shred system files.
1956        if let Some(p) = self.watched_folder.as_ref() {
1957            const DENY: &[&str] = &[
1958                "/", "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/sys", "/proc",
1959                "/dev", "/run", "/var/lib", "/var/log",
1960            ];
1961            let s = p.to_string_lossy();
1962            if DENY.iter().any(|d| s == *d) {
1963                return Err(SettingsError::Invalid(format!(
1964                    "watched_folder rejected: {} is a system path (would risk shredding system files if delete_torrent_after_add=true)",
1965                    p.display()
1966                )));
1967            }
1968            if let Some(home) = std::env::var_os("HOME") {
1969                let home_path = PathBuf::from(home);
1970                if p == &home_path {
1971                    return Err(SettingsError::Invalid(format!(
1972                        "watched_folder cannot be $HOME ({}) — too broad to be a torrent dropbox; pick a dedicated subdirectory",
1973                        p.display()
1974                    )));
1975                }
1976            }
1977        }
1978
1979        // M224: max_uploads_per_torrent uses `-1` sentinel for unlimited
1980        // (matches max_connections_global precedent). `0` is rejected — qBt's
1981        // wire format accepts `0` on some GET paths to mean unlimited, but
1982        // qBt's setPreferences accepts `-1`; we mirror the `-1` convention
1983        // for input and reject `0` as a likely wire-format mistake.
1984        if self.max_uploads_per_torrent == 0 || self.max_uploads_per_torrent < -1 {
1985            return Err(SettingsError::Invalid(
1986                "max_uploads_per_torrent must be -1 (unlimited) or >= 1".into(),
1987            ));
1988        }
1989
1990        if self.disk_io_threads == 0 {
1991            return Err(SettingsError::Invalid(
1992                "disk_io_threads must be at least 1".into(),
1993            ));
1994        }
1995
1996        if self.max_blocking_threads == 0 {
1997            return Err(SettingsError::Invalid(
1998                "max_blocking_threads must be at least 1".into(),
1999            ));
2000        }
2001
2002        // T3 (M241): `max_torrents` had no bounds. A config/API value of 0
2003        // disables adds entirely (footgun), and an absurd value (e.g. 1_000_000)
2004        // OOMs the daemon on the Nth add as the torrents map + per-torrent actors
2005        // are allocated. Reject both against MAX_TORRENTS_CEILING (module scope).
2006        if self.max_torrents == 0 {
2007            return Err(SettingsError::Invalid(
2008                "max_torrents must be at least 1".into(),
2009            ));
2010        }
2011        if self.max_torrents > MAX_TORRENTS_CEILING {
2012            return Err(SettingsError::Invalid(format!(
2013                "max_torrents {} exceeds the {MAX_TORRENTS_CEILING} ceiling",
2014                self.max_torrents
2015            )));
2016        }
2017
2018        if self.default_share_mode && !self.enable_fast_extension {
2019            return Err(SettingsError::Invalid(
2020                "share_mode requires enable_fast_extension for RejectRequest messages".into(),
2021            ));
2022        }
2023
2024        // SSL cert/key must both be set or both absent
2025        if self.ssl_cert_path.is_some() != self.ssl_key_path.is_some() {
2026            return Err(SettingsError::Invalid(
2027                "ssl_cert_path and ssl_key_path must both be set or both absent".into(),
2028            ));
2029        }
2030
2031        if self.enable_i2p {
2032            if self.i2p_inbound_quantity == 0 || self.i2p_inbound_quantity > 16 {
2033                return Err(SettingsError::Invalid(
2034                    "i2p_inbound_quantity must be 1-16".into(),
2035                ));
2036            }
2037            if self.i2p_outbound_quantity == 0 || self.i2p_outbound_quantity > 16 {
2038                return Err(SettingsError::Invalid(
2039                    "i2p_outbound_quantity must be 1-16".into(),
2040                ));
2041            }
2042            if self.i2p_inbound_length > 7 {
2043                return Err(SettingsError::Invalid(
2044                    "i2p_inbound_length must be 0-7".into(),
2045                ));
2046            }
2047            if self.i2p_outbound_length > 7 {
2048                return Err(SettingsError::Invalid(
2049                    "i2p_outbound_length must be 0-7".into(),
2050                ));
2051            }
2052        }
2053
2054        if self.runtime_worker_threads > 256 {
2055            return Err(SettingsError::Invalid(
2056                "runtime_worker_threads must be at most 256".into(),
2057            ));
2058        }
2059
2060        // qBt v2 compatibility settings (M168, extended M172a) — only validated
2061        // when enabled, so projects with qbt_compat disabled can leave bogus
2062        // defaults in place.
2063        if self.qbt_compat.enabled {
2064            if self.qbt_compat.username.is_empty() {
2065                return Err(SettingsError::Invalid(
2066                    "qbt_compat.username must not be empty when enabled".into(),
2067                ));
2068            }
2069            // M172a: either the PHC hash is present, or the legacy plaintext
2070            // is set to a non-trivial value (for the upcoming migration).
2071            // Forbidding both-empty prevents an "anyone can log in" misconfig.
2072            if self.qbt_compat.password_hash.is_empty() {
2073                if self.qbt_compat.password.len() < 8 {
2074                    return Err(SettingsError::Invalid(
2075                        "qbt_compat: either password_hash must be set OR \
2076                         password must be at least 8 characters (legacy upgrade path)"
2077                            .into(),
2078                    ));
2079                }
2080            } else if !self.qbt_compat.password_hash.starts_with("$argon2id$") {
2081                // M172a: reject unknown-scheme hashes early so an operator
2082                // copy-pasting a bcrypt or plaintext into `password_hash`
2083                // doesn't silently authenticate every request.
2084                return Err(SettingsError::Invalid(
2085                    "qbt_compat.password_hash must be an argon2id PHC string \
2086                     starting with `$argon2id$`"
2087                        .into(),
2088                ));
2089            }
2090            if let Some(0) = self.qbt_compat.max_concurrent_argon2_ops {
2091                return Err(SettingsError::Invalid(
2092                    "qbt_compat.max_concurrent_argon2_ops must be > 0 when set".into(),
2093                ));
2094            }
2095            if !is_valid_app_version(&self.qbt_compat.spoof_app_version) {
2096                return Err(SettingsError::Invalid(
2097                    "qbt_compat.spoof_app_version must match vN.N[.N][-suffix] (e.g. v5.1.4)"
2098                        .into(),
2099                ));
2100            }
2101            if !is_valid_webapi_version(&self.qbt_compat.spoof_webapi_version) {
2102                return Err(SettingsError::Invalid(
2103                    "qbt_compat.spoof_webapi_version must match N.N[.N] (e.g. 2.11.4)".into(),
2104                ));
2105            }
2106            if !(60..=604_800).contains(&self.qbt_compat.session_ttl_secs) {
2107                return Err(SettingsError::Invalid(
2108                    "qbt_compat.session_ttl_secs must be in [60, 604800]".into(),
2109                ));
2110            }
2111            if self.qbt_compat.max_sessions == 0 {
2112                return Err(SettingsError::Invalid(
2113                    "qbt_compat.max_sessions must be at least 1".into(),
2114                ));
2115            }
2116            // M172a Lane B: every CIDR in the reverse-proxy list must parse as
2117            // an `ipnet::IpNet`. An empty list is valid (middleware falls back
2118            // to direct-Host validation). The parse failure mode names the
2119            // offending entry so operators can fix the config without a diff.
2120            for entry in &self.qbt_compat.web_ui_reverse_proxies_list {
2121                if entry.parse::<ipnet::IpNet>().is_err() {
2122                    return Err(SettingsError::Invalid(format!(
2123                        "qbt_compat.web_ui_reverse_proxies_list: invalid CIDR '{entry}'"
2124                    )));
2125                }
2126            }
2127
2128            // M172a Lane C: brute-force ban validation.
2129            // max_failed_auth_count must be > 0 unless the operator has
2130            // explicitly enabled bypass_local_auth (in which case loopback
2131            // requests skip the check entirely and the counter is inert for
2132            // the only caller class that could trip it).
2133            if self.qbt_compat.max_failed_auth_count == 0 && !self.qbt_compat.bypass_local_auth {
2134                return Err(SettingsError::Invalid(
2135                    "qbt_compat.max_failed_auth_count must be > 0 when bypass_local_auth is false"
2136                        .into(),
2137                ));
2138            }
2139            if !(60..=86_400).contains(&self.qbt_compat.ban_duration_secs) {
2140                return Err(SettingsError::Invalid(
2141                    "qbt_compat.ban_duration_secs must be in [60, 86400]".into(),
2142                ));
2143            }
2144            for cidr in &self.qbt_compat.bypass_auth_subnet_whitelist {
2145                if cidr.parse::<ipnet::IpNet>().is_err() {
2146                    return Err(SettingsError::Invalid(format!(
2147                        "qbt_compat.bypass_auth_subnet_whitelist: invalid CIDR `{cidr}`"
2148                    )));
2149                }
2150            }
2151            if let Some(cap) = self.qbt_compat.brute_force_registry_capacity
2152                && cap < 100
2153            {
2154                return Err(SettingsError::Invalid(
2155                    "qbt_compat.brute_force_registry_capacity must be at least 100".into(),
2156                ));
2157            }
2158        }
2159
2160        Ok(())
2161    }
2162}
2163
2164// ── Sub-config conversions ───────────────────────────────────────────
2165// Relocated (M242): `From<&Settings>` for DiskConfig → disk.rs and for
2166// BanConfig → ban.rs; the six `to_*_config` methods → settings_convert.rs
2167// (the session-local `SettingsConvertExt` trait). Kept out of settings.rs so
2168// the file is free of session-only types before the irontide-settings extract.
2169
2170// ── PartialEq (manual — f32/f64 fields need special handling) ────────
2171
2172// ── Tests ────────────────────────────────────────────────────────────
2173
2174#[cfg(test)]
2175mod tests {
2176    use super::*;
2177
2178    #[test]
2179    fn default_settings_values() {
2180        let s = Settings::default();
2181        assert_eq!(s.listen_port, 42020);
2182        assert_eq!(s.download_dir, PathBuf::from("."));
2183        assert_eq!(s.max_torrents, 100);
2184        assert!(s.resume_data_dir.is_none());
2185        assert_eq!(s.save_resume_interval_secs, 300);
2186        assert!(s.enable_dht);
2187        assert!(s.enable_pex);
2188        assert!(s.enable_lsd);
2189        assert!(s.enable_fast_extension);
2190        assert!(s.enable_utp);
2191        assert!(s.enable_upnp);
2192        assert!(s.enable_natpmp);
2193        assert!(s.enable_ipv6);
2194        assert!(s.enable_web_seed);
2195        assert_eq!(s.encryption_mode, EncryptionMode::Disabled);
2196        assert!(!s.anonymous_mode);
2197        assert!(s.seed_ratio_limit.is_none());
2198        assert!(!s.default_super_seeding);
2199        assert!(!s.default_share_mode);
2200        assert!(s.upload_only_announce);
2201        assert_eq!(s.upload_rate_limit, 0);
2202        assert_eq!(s.download_rate_limit, 0);
2203        assert!(s.auto_upload_slots);
2204        assert_eq!(s.active_downloads, 3);
2205        assert_eq!(s.active_seeds, 5);
2206        assert_eq!(s.active_limit, 500);
2207        assert_eq!(s.active_checking, 3);
2208        assert!(s.dont_count_slow_torrents);
2209        assert_eq!(s.alert_mask, AlertCategory::ALL);
2210        assert_eq!(s.alert_channel_size, 1024);
2211        assert_eq!(s.smart_ban_max_failures, 3);
2212        assert!(s.smart_ban_parole);
2213        assert_eq!(s.disk_io_threads, default_disk_io_threads());
2214        assert_eq!(s.max_blocking_threads, default_max_blocking_threads());
2215        assert_eq!(s.preallocate_mode, PreallocateMode::None);
2216        assert_eq!(s.disk_cache_size, 16 * 1024 * 1024);
2217        assert!((s.disk_write_cache_ratio - 0.5).abs() < f32::EPSILON);
2218        assert_eq!(s.disk_channel_capacity, 512);
2219        assert_eq!(s.hashing_threads, default_hashing_threads());
2220        assert_eq!(s.max_request_queue_depth, 250);
2221        assert_eq!(s.initial_queue_depth, 128);
2222        assert!((s.request_queue_time - 3.0).abs() < f64::EPSILON);
2223        assert_eq!(s.block_request_timeout_secs, 60);
2224        assert_eq!(s.max_concurrent_stream_reads, 8);
2225        assert!(!s.force_proxy);
2226        assert!(s.apply_ip_filter_to_trackers);
2227        assert_eq!(s.dht_queries_per_second, 50);
2228        assert_eq!(s.dht_query_timeout_secs, 5);
2229        assert!(!s.dht_enforce_node_id);
2230        assert!(s.dht_restrict_routing_ips);
2231        assert_eq!(s.upnp_lease_duration, 3600);
2232        assert_eq!(s.natpmp_lifetime, 7200);
2233        assert_eq!(s.utp_max_connections, 256);
2234        assert_eq!(s.mixed_mode_algorithm, MixedModeAlgorithm::PeerProportional);
2235        assert!(s.auto_sequential);
2236        assert!(s.strict_end_game);
2237        assert_eq!(s.max_web_seeds, 4);
2238        assert_eq!(s.initial_picker_threshold, 4);
2239        assert_eq!(s.whole_pieces_threshold, 20);
2240        assert_eq!(s.snub_timeout_secs, 15);
2241        assert_eq!(s.readahead_pieces, 8);
2242        assert!(s.streaming_timeout_escalation);
2243        assert_eq!(s.max_peers_per_torrent, 128);
2244        assert_eq!(s.runtime_worker_threads, default_runtime_worker_threads());
2245        assert!(s.pin_cores);
2246    }
2247
2248    #[test]
2249    fn min_memory_preset() {
2250        let s = Settings::min_memory();
2251        assert_eq!(s.disk_cache_size, 8 * 1024 * 1024);
2252        assert_eq!(s.max_torrents, 20);
2253        assert_eq!(s.max_peers_per_torrent, 30);
2254        assert_eq!(s.active_downloads, 1);
2255        assert_eq!(s.active_seeds, 2);
2256        assert_eq!(s.active_limit, 10);
2257        assert_eq!(s.alert_channel_size, 256);
2258        assert_eq!(s.utp_max_connections, 64);
2259        assert_eq!(s.max_request_queue_depth, 50);
2260        assert_eq!(s.initial_queue_depth, 16);
2261        assert_eq!(s.max_concurrent_stream_reads, 2);
2262        assert_eq!(s.hashing_threads, 1);
2263        assert_eq!(s.disk_io_threads, 1);
2264    }
2265
2266    #[test]
2267    fn high_performance_preset() {
2268        let s = Settings::high_performance();
2269        assert_eq!(s.disk_cache_size, 256 * 1024 * 1024);
2270        assert_eq!(s.max_torrents, 2000);
2271        assert_eq!(s.max_peers_per_torrent, 200);
2272        assert_eq!(s.active_downloads, 30);
2273        assert_eq!(s.active_seeds, 100);
2274        assert_eq!(s.active_limit, 2000);
2275        assert_eq!(s.alert_channel_size, 4096);
2276        assert_eq!(s.utp_max_connections, 1024);
2277        assert_eq!(s.max_request_queue_depth, 1000);
2278        assert_eq!(s.initial_queue_depth, 256);
2279        assert_eq!(s.max_concurrent_stream_reads, 32);
2280        assert_eq!(s.hashing_threads, 4);
2281        assert_eq!(s.disk_io_threads, 8);
2282        assert_eq!(s.auto_upload_slots_max, 100);
2283    }
2284
2285    #[test]
2286    fn json_round_trip() {
2287        let original = Settings::default();
2288        let json = serde_json::to_string(&original).unwrap();
2289        let decoded: Settings = serde_json::from_str(&json).unwrap();
2290        assert_eq!(original, decoded);
2291    }
2292
2293    #[test]
2294    fn json_round_trip_presets() {
2295        // Verify all presets survive JSON serialization
2296        for original in [Settings::min_memory(), Settings::high_performance()] {
2297            let json = serde_json::to_string(&original).unwrap();
2298            let decoded: Settings = serde_json::from_str(&json).unwrap();
2299            assert_eq!(original, decoded);
2300        }
2301    }
2302
2303    #[test]
2304    fn json_missing_fields_use_defaults() {
2305        // An empty JSON object should deserialize to defaults (via serde(default))
2306        let decoded: Settings = serde_json::from_str("{}").unwrap();
2307        assert_eq!(decoded, Settings::default());
2308    }
2309
2310    /// M257a: configs written before the `StorageMode` purge carry a `storage_mode`
2311    /// key — deserialization must tolerate (ignore) it rather than erroring.
2312    /// NOTE (intentional + documented): a 1.x `storage_mode = "Full"` is IGNORED,
2313    /// not translated — full-preallocation users downgrade to sparse unless they
2314    /// set `preallocate_mode = "Full"`. The migration rule ships in the 2.0.0
2315    /// release notes; the client maps it in its config-load path at its M23.
2316    #[test]
2317    fn settings_tolerates_removed_storage_mode_key() {
2318        let decoded: Settings =
2319            serde_json::from_str(r#"{"storage_mode": "Full", "io_uring_sq_depth": 512}"#)
2320                .expect("unknown keys ignored");
2321        assert_eq!(decoded.preallocate_mode, PreallocateMode::None);
2322    }
2323
2324    // M171 D1a — seed-time limit Settings fields
2325    #[test]
2326    fn seed_time_limits_default_none() {
2327        let s = Settings::default();
2328        assert!(s.seed_time_limit_secs.is_none());
2329        assert!(s.inactive_seed_time_limit_secs.is_none());
2330    }
2331
2332    #[test]
2333    fn seed_time_limits_round_trip_json() {
2334        let s = Settings {
2335            seed_time_limit_secs: Some(3600),
2336            inactive_seed_time_limit_secs: Some(1800),
2337            ..Settings::default()
2338        };
2339        let json = serde_json::to_string(&s).unwrap();
2340        let decoded: Settings = serde_json::from_str(&json).unwrap();
2341        assert_eq!(decoded.seed_time_limit_secs, Some(3600));
2342        assert_eq!(decoded.inactive_seed_time_limit_secs, Some(1800));
2343    }
2344
2345    #[test]
2346    fn seed_time_limits_skipped_when_none() {
2347        // `skip_serializing_if` keeps the wire format small when no limit is set.
2348        let s = Settings::default();
2349        let json = serde_json::to_string(&s).unwrap();
2350        assert!(
2351            !json.contains("seed_time_limit_secs"),
2352            "None should not be serialised: {json}"
2353        );
2354        assert!(
2355            !json.contains("inactive_seed_time_limit_secs"),
2356            "None should not be serialised: {json}"
2357        );
2358    }
2359
2360    // M171 D1 — max_ratio_action + create_subfolder + auto_manage_torrents + queueing_enabled
2361    #[test]
2362    fn m171_settings_defaults_pause_true_false_false() {
2363        let s = Settings::default();
2364        assert_eq!(s.max_ratio_action, MaxRatioAction::Pause);
2365        assert!(
2366            s.create_subfolder,
2367            "create_subfolder defaults true (qBt factory default)"
2368        );
2369        assert!(!s.auto_manage_torrents);
2370        assert!(!s.queueing_enabled);
2371    }
2372
2373    #[test]
2374    fn m171_settings_round_trip_preserves_all_four() {
2375        let s = Settings {
2376            max_ratio_action: MaxRatioAction::EnableSuperSeeding,
2377            create_subfolder: false,
2378            auto_manage_torrents: true,
2379            queueing_enabled: true,
2380            ..Settings::default()
2381        };
2382        let json = serde_json::to_string(&s).unwrap();
2383        let decoded: Settings = serde_json::from_str(&json).unwrap();
2384        assert_eq!(decoded, s);
2385    }
2386
2387    #[test]
2388    fn max_ratio_action_wire_snake_case() {
2389        // Critical for qBt compat: the wire format must be snake_case.
2390        let pause = serde_json::to_string(&MaxRatioAction::Pause).unwrap();
2391        let remove = serde_json::to_string(&MaxRatioAction::Remove).unwrap();
2392        let super_seed = serde_json::to_string(&MaxRatioAction::EnableSuperSeeding).unwrap();
2393        assert_eq!(pause, "\"pause\"");
2394        assert_eq!(remove, "\"remove\"");
2395        assert_eq!(super_seed, "\"enable_super_seeding\"");
2396    }
2397
2398    #[test]
2399    fn max_ratio_action_wire_snake_case_round_trip() {
2400        // Deserialisation from snake_case works too.
2401        let pause: MaxRatioAction = serde_json::from_str("\"pause\"").unwrap();
2402        let remove: MaxRatioAction = serde_json::from_str("\"remove\"").unwrap();
2403        let super_seed: MaxRatioAction = serde_json::from_str("\"enable_super_seeding\"").unwrap();
2404        assert_eq!(pause, MaxRatioAction::Pause);
2405        assert_eq!(remove, MaxRatioAction::Remove);
2406        assert_eq!(super_seed, MaxRatioAction::EnableSuperSeeding);
2407    }
2408
2409    #[test]
2410    fn validation_force_proxy_no_proxy() {
2411        let s = Settings {
2412            force_proxy: true,
2413            ..Settings::default()
2414        };
2415        // proxy_type defaults to None
2416        let err = s.validate().unwrap_err();
2417        assert!(err.to_string().contains("force_proxy"));
2418    }
2419
2420    #[test]
2421    fn validation_valid_defaults() {
2422        Settings::default().validate().unwrap();
2423        Settings::min_memory().validate().unwrap();
2424        Settings::high_performance().validate().unwrap();
2425    }
2426
2427    #[test]
2428    fn external_ip_default_and_json() {
2429        let s = Settings::default();
2430        assert!(s.external_ip.is_none());
2431
2432        // JSON with external_ip set
2433        let json = r#"{"external_ip": "203.0.113.5"}"#;
2434        let decoded: Settings = serde_json::from_str(json).unwrap();
2435        assert_eq!(
2436            decoded.external_ip,
2437            Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(
2438                203, 0, 113, 5
2439            )))
2440        );
2441
2442        // Round-trip preserves external_ip
2443        let encoded = serde_json::to_string(&decoded).unwrap();
2444        let roundtrip: Settings = serde_json::from_str(&encoded).unwrap();
2445        assert_eq!(roundtrip.external_ip, decoded.external_ip);
2446    }
2447
2448    #[test]
2449    fn validation_zero_threads() {
2450        let s = Settings {
2451            hashing_threads: 0,
2452            ..Settings::default()
2453        };
2454        let err = s.validate().unwrap_err();
2455        assert!(err.to_string().contains("hashing_threads"));
2456
2457        let s = Settings {
2458            disk_io_threads: 0,
2459            ..Settings::default()
2460        };
2461        let err = s.validate().unwrap_err();
2462        assert!(err.to_string().contains("disk_io_threads"));
2463
2464        let s = Settings {
2465            max_blocking_threads: 0,
2466            ..Settings::default()
2467        };
2468        let err = s.validate().unwrap_err();
2469        assert!(err.to_string().contains("max_blocking_threads"));
2470    }
2471
2472    #[test]
2473    fn m241_validate_rejects_zero_max_torrents() {
2474        let s = Settings {
2475            max_torrents: 0,
2476            ..Settings::default()
2477        };
2478        let err = s.validate().unwrap_err();
2479        assert!(err.to_string().contains("max_torrents"));
2480    }
2481
2482    #[test]
2483    fn m241_validate_rejects_max_torrents_over_ceiling() {
2484        let s = Settings {
2485            max_torrents: 100_001,
2486            ..Settings::default()
2487        };
2488        let err = s.validate().unwrap_err();
2489        assert!(err.to_string().contains("max_torrents"));
2490    }
2491
2492    #[test]
2493    fn m241_validate_accepts_max_torrents_at_ceiling() {
2494        let s = Settings {
2495            max_torrents: 100_000,
2496            ..Settings::default()
2497        };
2498        s.validate().unwrap();
2499    }
2500
2501    #[test]
2502    fn m241_validate_accepts_reasonable_max_torrents() {
2503        let s = Settings {
2504            max_torrents: 500,
2505            ..Settings::default()
2506        };
2507        s.validate().unwrap();
2508    }
2509
2510    #[test]
2511    fn share_mode_requires_fast_extension() {
2512        let mut s = Settings {
2513            default_share_mode: true,
2514            enable_fast_extension: false,
2515            ..Settings::default()
2516        };
2517        let err = s.validate().unwrap_err();
2518        assert!(err.to_string().contains("share_mode"));
2519
2520        // With fast extension enabled, share mode is valid
2521        s.enable_fast_extension = true;
2522        s.validate().unwrap();
2523    }
2524
2525    #[test]
2526    fn dht_storage_settings_defaults() {
2527        let s = Settings::default();
2528        assert_eq!(s.dht_max_items, 700);
2529        assert_eq!(s.dht_item_lifetime_secs, 7200);
2530    }
2531
2532    #[test]
2533    fn dht_sample_interval_default_disabled() {
2534        let s = Settings::default();
2535        assert_eq!(s.dht_sample_infohashes_interval, 0);
2536    }
2537
2538    #[test]
2539    fn dht_sample_interval_json_round_trip() {
2540        let json = r#"{"dht_sample_infohashes_interval": 300}"#;
2541        let decoded: Settings = serde_json::from_str(json).unwrap();
2542        assert_eq!(decoded.dht_sample_infohashes_interval, 300);
2543
2544        let encoded = serde_json::to_string(&decoded).unwrap();
2545        let roundtrip: Settings = serde_json::from_str(&encoded).unwrap();
2546        assert_eq!(roundtrip.dht_sample_infohashes_interval, 300);
2547    }
2548
2549    #[test]
2550    fn min_memory_restricts_dht_items() {
2551        let s = Settings::min_memory();
2552        assert_eq!(s.dht_max_items, 100);
2553    }
2554
2555    #[test]
2556    fn enable_holepunch_default_true() {
2557        let s = Settings::default();
2558        assert!(s.enable_holepunch);
2559    }
2560
2561    #[test]
2562    fn enable_holepunch_json_round_trip() {
2563        let json = r#"{"enable_holepunch": false}"#;
2564        let decoded: Settings = serde_json::from_str(json).unwrap();
2565        assert!(!decoded.enable_holepunch);
2566
2567        let encoded = serde_json::to_string(&decoded).unwrap();
2568        let roundtrip: Settings = serde_json::from_str(&encoded).unwrap();
2569        assert!(!roundtrip.enable_holepunch);
2570    }
2571
2572    #[test]
2573    fn i2p_settings_defaults() {
2574        let s = Settings::default();
2575        assert!(!s.enable_i2p);
2576        assert_eq!(s.i2p_hostname, "127.0.0.1");
2577        assert_eq!(s.i2p_port, 7656);
2578        assert_eq!(s.i2p_inbound_quantity, 3);
2579        assert_eq!(s.i2p_outbound_quantity, 3);
2580        assert_eq!(s.i2p_inbound_length, 3);
2581        assert_eq!(s.i2p_outbound_length, 3);
2582        assert!(!s.allow_i2p_mixed);
2583    }
2584
2585    #[test]
2586    fn i2p_settings_json_roundtrip() {
2587        let s = Settings {
2588            enable_i2p: true,
2589            i2p_hostname: "10.0.0.1".into(),
2590            i2p_port: 7700,
2591            i2p_inbound_quantity: 5,
2592            i2p_outbound_quantity: 4,
2593            i2p_inbound_length: 2,
2594            i2p_outbound_length: 1,
2595            allow_i2p_mixed: true,
2596            ..Settings::default()
2597        };
2598        let json = serde_json::to_string(&s).unwrap();
2599        let decoded: Settings = serde_json::from_str(&json).unwrap();
2600        assert_eq!(s, decoded);
2601    }
2602
2603    #[test]
2604    fn i2p_validation_quantity_zero() {
2605        let s = Settings {
2606            enable_i2p: true,
2607            i2p_inbound_quantity: 0,
2608            ..Settings::default()
2609        };
2610        let err = s.validate().unwrap_err();
2611        assert!(err.to_string().contains("i2p_inbound_quantity"));
2612    }
2613
2614    #[test]
2615    fn i2p_validation_quantity_too_high() {
2616        let s = Settings {
2617            enable_i2p: true,
2618            i2p_outbound_quantity: 17,
2619            ..Settings::default()
2620        };
2621        let err = s.validate().unwrap_err();
2622        assert!(err.to_string().contains("i2p_outbound_quantity"));
2623    }
2624
2625    #[test]
2626    fn i2p_validation_length_too_high() {
2627        let s = Settings {
2628            enable_i2p: true,
2629            i2p_inbound_length: 8,
2630            ..Settings::default()
2631        };
2632        let err = s.validate().unwrap_err();
2633        assert!(err.to_string().contains("i2p_inbound_length"));
2634    }
2635
2636    #[test]
2637    fn i2p_validation_passes_when_disabled() {
2638        // Invalid values should not trigger errors when I2P is disabled
2639        let mut s = Settings {
2640            enable_i2p: false,
2641            ..Settings::default()
2642        };
2643        s.i2p_inbound_quantity = 0; // would be invalid if enabled
2644        s.validate().unwrap(); // should pass
2645    }
2646
2647    #[test]
2648    fn i2p_validation_valid_config() {
2649        let s = Settings {
2650            enable_i2p: true,
2651            i2p_inbound_quantity: 1,
2652            i2p_outbound_quantity: 16,
2653            i2p_inbound_length: 0,
2654            i2p_outbound_length: 7,
2655            ..Settings::default()
2656        };
2657        s.validate().unwrap();
2658    }
2659
2660    #[test]
2661    fn ssl_settings_defaults() {
2662        let s = Settings::default();
2663        assert_eq!(s.ssl_listen_port, 0);
2664        assert!(s.ssl_cert_path.is_none());
2665        assert!(s.ssl_key_path.is_none());
2666    }
2667
2668    #[test]
2669    fn ssl_settings_json_round_trip() {
2670        let s = Settings {
2671            ssl_listen_port: 4433,
2672            ssl_cert_path: Some(PathBuf::from("/etc/ssl/cert.pem")),
2673            ssl_key_path: Some(PathBuf::from("/etc/ssl/key.pem")),
2674            ..Settings::default()
2675        };
2676        let json = serde_json::to_string(&s).unwrap();
2677        let decoded: Settings = serde_json::from_str(&json).unwrap();
2678        assert_eq!(s, decoded);
2679    }
2680
2681    #[test]
2682    fn ssl_validation_cert_without_key() {
2683        let s = Settings {
2684            ssl_cert_path: Some(PathBuf::from("/tmp/cert.pem")),
2685            ..Settings::default()
2686        };
2687        // ssl_key_path is None
2688        let err = s.validate().unwrap_err();
2689        assert!(err.to_string().contains("ssl_cert_path"));
2690    }
2691
2692    #[test]
2693    fn ssl_validation_key_without_cert() {
2694        let s = Settings {
2695            ssl_key_path: Some(PathBuf::from("/tmp/key.pem")),
2696            ..Settings::default()
2697        };
2698        // ssl_cert_path is None
2699        let err = s.validate().unwrap_err();
2700        assert!(err.to_string().contains("ssl_cert_path"));
2701    }
2702
2703    #[test]
2704    fn ssl_validation_both_set_passes() {
2705        let s = Settings {
2706            ssl_cert_path: Some(PathBuf::from("/tmp/cert.pem")),
2707            ssl_key_path: Some(PathBuf::from("/tmp/key.pem")),
2708            ..Settings::default()
2709        };
2710        s.validate().unwrap();
2711    }
2712
2713    #[test]
2714    fn ssl_validation_both_absent_passes() {
2715        let s = Settings::default();
2716        // Both are None by default
2717        s.validate().unwrap();
2718    }
2719
2720    #[test]
2721    fn default_choking_algorithms() {
2722        let s = Settings::default();
2723        assert_eq!(
2724            s.seed_choking_algorithm,
2725            SeedChokingAlgorithm::FastestUpload
2726        );
2727        assert_eq!(s.choking_algorithm, ChokingAlgorithm::FixedSlots);
2728    }
2729
2730    #[test]
2731    fn choking_algorithm_json_round_trip() {
2732        let s = Settings {
2733            seed_choking_algorithm: SeedChokingAlgorithm::AntiLeech,
2734            choking_algorithm: ChokingAlgorithm::RateBased,
2735            ..Settings::default()
2736        };
2737        let json = serde_json::to_string(&s).unwrap();
2738        let decoded: Settings = serde_json::from_str(&json).unwrap();
2739        assert_eq!(
2740            decoded.seed_choking_algorithm,
2741            SeedChokingAlgorithm::AntiLeech
2742        );
2743        assert_eq!(decoded.choking_algorithm, ChokingAlgorithm::RateBased);
2744    }
2745
2746    #[test]
2747    fn m44_settings_defaults() {
2748        let s = Settings::default();
2749        assert!(s.piece_extent_affinity);
2750        assert!(s.suggest_mode);
2751        assert_eq!(s.max_suggest_pieces, 16);
2752        assert_eq!(s.predictive_piece_announce_ms, 0);
2753    }
2754
2755    #[test]
2756    fn m44_high_performance_enables_suggest() {
2757        let s = Settings::high_performance();
2758        assert!(s.suggest_mode);
2759    }
2760
2761    #[test]
2762    fn m44_json_round_trip() {
2763        let s = Settings {
2764            piece_extent_affinity: false,
2765            suggest_mode: true,
2766            max_suggest_pieces: 5,
2767            predictive_piece_announce_ms: 50,
2768            ..Settings::default()
2769        };
2770        let json = serde_json::to_string(&s).unwrap();
2771        let decoded: Settings = serde_json::from_str(&json).unwrap();
2772        assert_eq!(s, decoded);
2773    }
2774
2775    #[test]
2776    fn security_settings_defaults() {
2777        let s = Settings::default();
2778        assert!(s.ssrf_mitigation);
2779        assert!(!s.allow_idna);
2780        assert!(s.validate_https_trackers);
2781    }
2782
2783    #[test]
2784    fn security_settings_json_round_trip() {
2785        let s = Settings {
2786            ssrf_mitigation: false,
2787            allow_idna: true,
2788            validate_https_trackers: false,
2789            ..Settings::default()
2790        };
2791        let json = serde_json::to_string(&s).unwrap();
2792        let decoded: Settings = serde_json::from_str(&json).unwrap();
2793        assert_eq!(s, decoded);
2794    }
2795
2796    #[test]
2797    fn security_settings_missing_use_defaults() {
2798        // An empty JSON object should deserialize security fields to defaults.
2799        let decoded: Settings = serde_json::from_str("{}").unwrap();
2800        assert!(decoded.ssrf_mitigation);
2801        assert!(!decoded.allow_idna);
2802        assert!(decoded.validate_https_trackers);
2803    }
2804
2805    #[test]
2806    fn default_peer_dscp_value() {
2807        let s = Settings::default();
2808        assert_eq!(s.peer_dscp, 0x08);
2809    }
2810
2811    #[test]
2812    fn peer_dscp_json_round_trip() {
2813        let s = Settings {
2814            peer_dscp: 0x2E, // EF
2815            ..Settings::default()
2816        };
2817        let json = serde_json::to_string(&s).unwrap();
2818        let decoded: Settings = serde_json::from_str(&json).unwrap();
2819        assert_eq!(decoded.peer_dscp, 0x2E);
2820    }
2821
2822    #[test]
2823    fn peer_dscp_zero_disables() {
2824        let s = Settings {
2825            peer_dscp: 0,
2826            ..Settings::default()
2827        };
2828        let json = serde_json::to_string(&s).unwrap();
2829        let decoded: Settings = serde_json::from_str(&json).unwrap();
2830        assert_eq!(decoded.peer_dscp, 0);
2831    }
2832
2833    #[test]
2834    fn default_stats_report_interval() {
2835        let s = Settings::default();
2836        assert_eq!(s.stats_report_interval, 1000);
2837    }
2838
2839    #[test]
2840    fn stats_report_interval_json_round_trip() {
2841        let s = Settings {
2842            stats_report_interval: 5000,
2843            ..Settings::default()
2844        };
2845        let json = serde_json::to_string(&s).unwrap();
2846        let decoded: Settings = serde_json::from_str(&json).unwrap();
2847        assert_eq!(decoded.stats_report_interval, 5000);
2848    }
2849
2850    #[test]
2851    fn stats_report_interval_zero_disables() {
2852        let s = Settings {
2853            stats_report_interval: 0,
2854            ..Settings::default()
2855        };
2856        let json = serde_json::to_string(&s).unwrap();
2857        let decoded: Settings = serde_json::from_str(&json).unwrap();
2858        assert_eq!(decoded.stats_report_interval, 0);
2859    }
2860
2861    #[test]
2862    fn settings_runtime_worker_threads_and_pin_cores() {
2863        // Defaults
2864        let s = Settings::default();
2865        assert_eq!(s.runtime_worker_threads, default_runtime_worker_threads());
2866        assert!(s.pin_cores);
2867
2868        // 0 is valid (means auto-detect)
2869        let mut s = Settings {
2870            runtime_worker_threads: 0,
2871            ..Settings::default()
2872        };
2873        assert!(s.validate().is_ok());
2874
2875        // 256 is valid (boundary)
2876        s.runtime_worker_threads = 256;
2877        assert!(s.validate().is_ok());
2878
2879        // 257 is invalid
2880        s.runtime_worker_threads = 257;
2881        assert!(s.validate().is_err());
2882    }
2883
2884    #[test]
2885    fn max_in_flight_512_default() {
2886        let s = Settings::default();
2887        assert_eq!(s.max_in_flight_pieces, 512);
2888        assert_eq!(s.fixed_pipeline_depth, 128);
2889
2890        // Presets
2891        let mm = Settings::min_memory();
2892        assert_eq!(mm.max_in_flight_pieces, 32);
2893        assert_eq!(mm.fixed_pipeline_depth, 32);
2894
2895        let hp = Settings::high_performance();
2896        assert_eq!(hp.max_in_flight_pieces, 512);
2897        assert_eq!(hp.fixed_pipeline_depth, 128); // inherits default
2898    }
2899
2900    #[test]
2901    fn recalc_max_in_flight_formula() {
2902        // M104: The formula in torrent.rs: max(512, connected * 4), clamped to
2903        // num_pieces / 2, floored at 512. Validate the logic here.
2904        let base = 512_usize;
2905
2906        // Few peers: floor dominates
2907        let connected = 10;
2908        let num_pieces = 2000_u32;
2909        let calculated = base.max(connected * 4);
2910        let result = calculated.min(num_pieces as usize / 2).max(base);
2911        assert_eq!(result, 512); // max(512, 40) = 512, min(512, 1000) = 512
2912
2913        // Many peers: peer count drives it up
2914        let connected = 200;
2915        let calculated = base.max(connected * 4);
2916        let result = calculated.min(num_pieces as usize / 2).max(base);
2917        assert_eq!(result, 800); // max(512, 800) = 800, min(800, 1000) = 800
2918
2919        // Small torrent: piece clamp wins
2920        let connected = 200;
2921        let num_pieces = 100_u32;
2922        let calculated = base.max(connected * 4);
2923        let result = calculated.min(num_pieces as usize / 2).max(base);
2924        assert_eq!(result, 512); // max(512, 800) = 800, min(800, 50) = 50, max(50, 512) = 512
2925
2926        // Exact boundary: connected * 4 == base
2927        let connected = 129; // 129 * 4 = 516, just above 512
2928        let num_pieces = 10000_u32;
2929        let calculated = base.max(connected * 4);
2930        let result = calculated.min(num_pieces as usize / 2).max(base);
2931        assert_eq!(result, 516); // max(512, 516) = 516, min(516, 5000) = 516
2932    }
2933
2934    // ── M168: qBt v2 compatibility settings tests ────────────────────
2935
2936    #[test]
2937    fn settings_default_enables_qbt_compat_v0_172_1() {
2938        // v0.172.1: default flipped from false (M168 security-through-
2939        // invisibility) to true so *arr clients work out of the box. The
2940        // real defences (argon2id hash, brute-force ban, CSRF middleware)
2941        // all ship in M172a. Operators opt out via [qbt_compat] enabled = false.
2942        let s = Settings::default();
2943        assert!(s.qbt_compat.enabled);
2944        assert_eq!(s.qbt_compat.username, "admin");
2945        // M172a: plaintext `password` ships empty by default; `password_hash`
2946        // ships a pre-hashed "adminadmin" so fresh installs never run the
2947        // legacy-migration path.
2948        assert_eq!(s.qbt_compat.password, "");
2949        assert!(
2950            s.qbt_compat
2951                .password_hash
2952                .starts_with("$argon2id$v=19$m=19456,t=2,p=1$")
2953        );
2954        assert_eq!(s.qbt_compat.spoof_app_version, "v5.1.4");
2955        assert_eq!(s.qbt_compat.spoof_webapi_version, "2.11.4");
2956        assert_eq!(s.qbt_compat.session_ttl_secs, 86_400);
2957        assert_eq!(s.qbt_compat.max_sessions, 1024);
2958        assert!(s.qbt_compat.max_concurrent_argon2_ops.is_none());
2959    }
2960
2961    #[test]
2962    fn validate_rejects_empty_username() {
2963        let mut s = Settings::default();
2964        s.qbt_compat.enabled = true;
2965        s.qbt_compat.username = String::new();
2966        let err = s.validate().expect_err("empty username must fail");
2967        let msg = format!("{err}");
2968        assert!(msg.contains("username"), "error was: {msg}");
2969    }
2970
2971    #[test]
2972    fn validate_rejects_short_legacy_password_lt_8_when_hash_empty() {
2973        let mut s = Settings::default();
2974        s.qbt_compat.enabled = true;
2975        // Simulate a pre-M172a config: hash is empty, plaintext is too short.
2976        s.qbt_compat.password_hash.clear();
2977        s.qbt_compat.password = "short".into();
2978        let err = s.validate().expect_err("short password must fail");
2979        let msg = format!("{err}");
2980        assert!(
2981            msg.contains("password") && msg.contains("hash"),
2982            "error was: {msg}"
2983        );
2984    }
2985
2986    #[test]
2987    fn validate_rejects_bad_app_version_format() {
2988        let mut s = Settings::default();
2989        s.qbt_compat.enabled = true;
2990        s.qbt_compat.spoof_app_version = "garbage".into();
2991        let err = s.validate().expect_err("bad app version must fail");
2992        let msg = format!("{err}");
2993        assert!(msg.contains("spoof_app_version"), "error was: {msg}");
2994    }
2995
2996    #[test]
2997    fn validate_rejects_bad_webapi_version_format() {
2998        let mut s = Settings::default();
2999        s.qbt_compat.enabled = true;
3000        s.qbt_compat.spoof_webapi_version = "v2.11".into(); // leading v is wrong for webapi
3001        let err = s.validate().expect_err("bad webapi version must fail");
3002        let msg = format!("{err}");
3003        assert!(msg.contains("spoof_webapi_version"), "error was: {msg}");
3004    }
3005
3006    #[test]
3007    fn validate_rejects_ttl_out_of_bounds() {
3008        let mut s = Settings::default();
3009        s.qbt_compat.enabled = true;
3010        s.qbt_compat.session_ttl_secs = 10; // below 60
3011        let err = s.validate().expect_err("ttl too small must fail");
3012        assert!(format!("{err}").contains("session_ttl_secs"));
3013
3014        let mut s = Settings::default();
3015        s.qbt_compat.enabled = true;
3016        s.qbt_compat.session_ttl_secs = 604_801; // above 604800
3017        let err = s.validate().expect_err("ttl too large must fail");
3018        assert!(format!("{err}").contains("session_ttl_secs"));
3019    }
3020
3021    // ── M172a Lane A: argon2 PHC + migration ──────────────────────────
3022
3023    #[test]
3024    fn default_hash_roundtrips_admin_admin() {
3025        use argon2::Argon2;
3026        use argon2::password_hash::{PasswordHash, PasswordVerifier};
3027
3028        // If this test fails because someone changed the default salt or
3029        // Argon2 parameters, regenerate `DEFAULT_ADMINADMIN_HASH` with:
3030        //
3031        //   cargo run --example regen_qbt_default_hash
3032        //
3033        // and paste the output back into the constant. The asymmetry matters:
3034        // production verification uses the same crate + params, so this test
3035        // is the canary for a bad paste. We do not regenerate the hash here
3036        // (non-deterministic salt would break cross-install round-tripping).
3037        let hash = PasswordHash::new(DEFAULT_ADMINADMIN_HASH)
3038            .expect("DEFAULT_ADMINADMIN_HASH must be a valid PHC string");
3039        Argon2::default()
3040            .verify_password(b"adminadmin", &hash)
3041            .expect("default hash must verify the 'adminadmin' plaintext");
3042    }
3043
3044    #[test]
3045    fn validate_rejects_password_hash_not_starting_with_argon2id() {
3046        let mut s = Settings::default();
3047        s.qbt_compat.enabled = true;
3048        // bcrypt-style hash — wrong scheme.
3049        s.qbt_compat.password_hash =
3050            "$2b$12$KIXQ5.pHJN3iLz9H6CfQEe2/6rFv1h4jdXWv.0eoGzJ6w7L4Yj7vi".into();
3051        let err = s.validate().expect_err("non-argon2id hash must fail");
3052        let msg = format!("{err}");
3053        assert!(msg.contains("argon2id"), "error was: {msg}");
3054    }
3055
3056    #[test]
3057    fn validate_rejects_zero_max_concurrent_argon2_ops() {
3058        let mut s = Settings::default();
3059        s.qbt_compat.enabled = true;
3060        s.qbt_compat.max_concurrent_argon2_ops = Some(0);
3061        let err = s.validate().expect_err("zero argon2 semaphore must fail");
3062        assert!(format!("{err}").contains("max_concurrent_argon2_ops"));
3063    }
3064
3065    #[test]
3066    fn default_settings_ship_pre_hashed_no_migration_needed() {
3067        let s = Settings::default();
3068        assert!(s.qbt_compat.password_hash.starts_with("$argon2id$"));
3069        assert!(s.qbt_compat.password.is_empty());
3070    }
3071
3072    #[test]
3073    fn hash_qbt_password_roundtrips() {
3074        let h = hash_qbt_password("correct horse battery staple")
3075            .expect("hash must succeed for a simple plaintext");
3076        assert!(h.starts_with("$argon2id$v=19$m=19456,t=2,p=1$"));
3077        // Every call produces a fresh salt → different PHC output.
3078        let h2 =
3079            hash_qbt_password("correct horse battery staple").expect("second hash must succeed");
3080        assert_ne!(h, h2, "argon2 must use a fresh salt per call");
3081    }
3082
3083    #[test]
3084    fn migrate_qbt_credentials_noop_when_hash_present() {
3085        let mut qbt = QbtCompatSettings {
3086            password_hash: DEFAULT_ADMINADMIN_HASH.into(),
3087            password: String::new(),
3088            ..Default::default()
3089        };
3090        let outcome = migrate_qbt_credentials(&mut qbt).expect("noop");
3091        assert_eq!(outcome, QbtCredentialMigration::NoOp);
3092        assert_eq!(qbt.password_hash, DEFAULT_ADMINADMIN_HASH);
3093        assert!(qbt.password.is_empty());
3094    }
3095
3096    #[test]
3097    fn migrate_qbt_credentials_upgrades_legacy_plaintext() {
3098        use argon2::Argon2;
3099        use argon2::password_hash::{PasswordHash, PasswordVerifier};
3100
3101        let mut qbt = QbtCompatSettings {
3102            password_hash: String::new(),
3103            password: "legacy-plaintext-pw".into(),
3104            ..Default::default()
3105        };
3106        let outcome = migrate_qbt_credentials(&mut qbt).expect("upgrade");
3107        assert_eq!(outcome, QbtCredentialMigration::Upgraded);
3108        assert!(qbt.password_hash.starts_with("$argon2id$"));
3109        assert!(
3110            qbt.password.is_empty(),
3111            "plaintext must be zeroed after migration"
3112        );
3113
3114        let parsed =
3115            PasswordHash::new(&qbt.password_hash).expect("migration wrote a valid PHC string");
3116        Argon2::default()
3117            .verify_password(b"legacy-plaintext-pw", &parsed)
3118            .expect("migrated hash must verify the original plaintext");
3119    }
3120
3121    #[test]
3122    fn migrate_qbt_credentials_noop_when_both_empty() {
3123        let mut qbt = QbtCompatSettings {
3124            password_hash: String::new(),
3125            password: String::new(),
3126            ..Default::default()
3127        };
3128        let outcome = migrate_qbt_credentials(&mut qbt).expect("noop on empty");
3129        assert_eq!(outcome, QbtCredentialMigration::NoOp);
3130    }
3131
3132    // ── M172a Lane C: brute-force ban settings ────────────────────────
3133
3134    #[test]
3135    fn brute_force_defaults_are_5_attempts_and_one_hour_ban() {
3136        let s = Settings::default();
3137        assert_eq!(s.qbt_compat.max_failed_auth_count, 5);
3138        assert_eq!(s.qbt_compat.ban_duration_secs, 3_600);
3139        assert!(!s.qbt_compat.bypass_local_auth);
3140        assert!(s.qbt_compat.bypass_auth_subnet_whitelist.is_empty());
3141        assert!(s.qbt_compat.brute_force_registry_capacity.is_none());
3142    }
3143
3144    #[test]
3145    fn validate_rejects_zero_max_failed_auth_count_without_bypass() {
3146        let mut s = Settings::default();
3147        s.qbt_compat.enabled = true;
3148        s.qbt_compat.max_failed_auth_count = 0;
3149        s.qbt_compat.bypass_local_auth = false;
3150        let err = s
3151            .validate()
3152            .expect_err("zero attempts without bypass must fail");
3153        assert!(format!("{err}").contains("max_failed_auth_count"));
3154    }
3155
3156    #[test]
3157    fn validate_accepts_zero_max_failed_auth_count_when_bypass_local() {
3158        let mut s = Settings::default();
3159        s.qbt_compat.enabled = true;
3160        s.qbt_compat.max_failed_auth_count = 0;
3161        s.qbt_compat.bypass_local_auth = true;
3162        s.validate().expect("bypass_local_auth disarms the check");
3163    }
3164
3165    #[test]
3166    fn validate_rejects_ban_duration_out_of_bounds() {
3167        let mut s = Settings::default();
3168        s.qbt_compat.enabled = true;
3169        s.qbt_compat.ban_duration_secs = 59;
3170        let err = s.validate().expect_err("too short ban must fail");
3171        assert!(format!("{err}").contains("ban_duration_secs"));
3172
3173        let mut s = Settings::default();
3174        s.qbt_compat.enabled = true;
3175        s.qbt_compat.ban_duration_secs = 86_401;
3176        let err = s.validate().expect_err("too long ban must fail");
3177        assert!(format!("{err}").contains("ban_duration_secs"));
3178    }
3179
3180    #[test]
3181    fn validate_rejects_malformed_bypass_whitelist_cidr() {
3182        let mut s = Settings::default();
3183        s.qbt_compat.enabled = true;
3184        s.qbt_compat.bypass_auth_subnet_whitelist = vec!["not-a-cidr".into()];
3185        let err = s.validate().expect_err("bad cidr must fail");
3186        let msg = format!("{err}");
3187        assert!(msg.contains("bypass_auth_subnet_whitelist"));
3188        assert!(msg.contains("not-a-cidr"));
3189    }
3190
3191    #[test]
3192    fn validate_accepts_valid_bypass_whitelist_cidrs() {
3193        let mut s = Settings::default();
3194        s.qbt_compat.enabled = true;
3195        s.qbt_compat.bypass_auth_subnet_whitelist = vec![
3196            "10.0.0.0/8".into(),
3197            "192.168.1.0/24".into(),
3198            "::1/128".into(),
3199        ];
3200        s.validate().expect("valid cidrs pass");
3201    }
3202
3203    #[test]
3204    fn validate_rejects_registry_capacity_below_floor() {
3205        let mut s = Settings::default();
3206        s.qbt_compat.enabled = true;
3207        s.qbt_compat.brute_force_registry_capacity = Some(99);
3208        let err = s
3209            .validate()
3210            .expect_err("capacity < 100 must fail sanity floor");
3211        assert!(format!("{err}").contains("brute_force_registry_capacity"));
3212    }
3213
3214    // ── M224: max_uploads_per_torrent validate + serde-default ──────────
3215
3216    #[test]
3217    fn validate_rejects_zero_max_uploads_per_torrent() {
3218        let s = Settings {
3219            max_uploads_per_torrent: 0,
3220            ..Settings::default()
3221        };
3222        let err = s
3223            .validate()
3224            .expect_err("max_uploads_per_torrent = 0 must fail");
3225        let msg = format!("{err}");
3226        assert!(msg.contains("max_uploads_per_torrent"), "error was: {msg}");
3227    }
3228
3229    #[test]
3230    fn validate_rejects_negative_below_minus_one_max_uploads_per_torrent() {
3231        let s = Settings {
3232            max_uploads_per_torrent: -2,
3233            ..Settings::default()
3234        };
3235        let err = s
3236            .validate()
3237            .expect_err("max_uploads_per_torrent < -1 must fail");
3238        let msg = format!("{err}");
3239        assert!(msg.contains("max_uploads_per_torrent"), "error was: {msg}");
3240    }
3241
3242    #[test]
3243    fn validate_accepts_minus_one_max_uploads_per_torrent() {
3244        let s = Settings::default();
3245        assert_eq!(s.max_uploads_per_torrent, -1);
3246        s.validate().expect("default -1 must validate");
3247    }
3248
3249    #[test]
3250    fn validate_accepts_positive_max_uploads_per_torrent() {
3251        let s = Settings {
3252            max_uploads_per_torrent: 4,
3253            ..Settings::default()
3254        };
3255        s.validate().expect("n >= 1 must validate");
3256    }
3257
3258    #[test]
3259    fn m257c_validate_budget_floor_relations() {
3260        // defaults are valid (budget 512, floor 8)
3261        let s = Settings::default();
3262        assert_eq!(s.request_budget_per_torrent, 512);
3263        assert_eq!(s.request_budget_floor, 8);
3264        s.validate().expect("defaults must validate");
3265        // floor must be >= 1
3266        let s = Settings {
3267            request_budget_floor: 0,
3268            ..Settings::default()
3269        };
3270        let msg = format!("{}", s.validate().expect_err("floor 0 must fail"));
3271        assert!(msg.contains("request_budget_floor"), "error was: {msg}");
3272        // floor must not exceed the per-peer ceiling (128)
3273        let s = Settings {
3274            request_budget_floor: 129,
3275            ..Settings::default()
3276        };
3277        let msg = format!("{}", s.validate().expect_err("floor 129 must fail"));
3278        assert!(msg.contains("request_budget_floor"), "error was: {msg}");
3279        // budget 0 = disabled, always valid
3280        let s = Settings {
3281            request_budget_per_torrent: 0,
3282            ..Settings::default()
3283        };
3284        s.validate().expect("budget 0 (disabled) must validate");
3285        // nonzero budget must cover at least one floor
3286        let s = Settings {
3287            request_budget_per_torrent: 4,
3288            request_budget_floor: 8,
3289            ..Settings::default()
3290        };
3291        let msg = format!("{}", s.validate().expect_err("budget < floor must fail"));
3292        assert!(
3293            msg.contains("request_budget_per_torrent"),
3294            "error was: {msg}"
3295        );
3296    }
3297
3298    #[test]
3299    fn max_uploads_per_torrent_default_deserialize_without_field() {
3300        // R6 guard: existing Settings JSON files without the new field must
3301        // deserialize cleanly to -1 (the default-fn sentinel), not 0 (which
3302        // would then fail validate() and break every existing config).
3303        let s = Settings::default();
3304        let mut value = serde_json::to_value(&s).expect("serialise");
3305        let obj = value.as_object_mut().expect("Settings is a JSON object");
3306        assert!(
3307            obj.remove("max_uploads_per_torrent").is_some(),
3308            "field should have been present in serialised default"
3309        );
3310        let decoded: Settings = serde_json::from_value(value).expect("deserialise without field");
3311        assert_eq!(decoded.max_uploads_per_torrent, -1);
3312        decoded.validate().expect("default-via-serde must validate");
3313    }
3314
3315    #[test]
3316    fn brute_force_settings_json_round_trip() {
3317        let mut s = Settings::default();
3318        s.qbt_compat.max_failed_auth_count = 7;
3319        s.qbt_compat.ban_duration_secs = 1_800;
3320        s.qbt_compat.bypass_local_auth = true;
3321        s.qbt_compat.bypass_auth_subnet_whitelist = vec!["10.0.0.0/8".into()];
3322        s.qbt_compat.brute_force_registry_capacity = Some(5_000);
3323
3324        let json = serde_json::to_string(&s).expect("serialise");
3325        let decoded: Settings = serde_json::from_str(&json).expect("deserialise");
3326        assert_eq!(decoded.qbt_compat.max_failed_auth_count, 7);
3327        assert_eq!(decoded.qbt_compat.ban_duration_secs, 1_800);
3328        assert!(decoded.qbt_compat.bypass_local_auth);
3329        assert_eq!(
3330            decoded.qbt_compat.bypass_auth_subnet_whitelist,
3331            vec!["10.0.0.0/8".to_string()]
3332        );
3333        assert_eq!(
3334            decoded.qbt_compat.brute_force_registry_capacity,
3335            Some(5_000)
3336        );
3337    }
3338
3339    // ── M226: Notifications / paths / watched folder / network — defaults ────
3340
3341    #[test]
3342    fn settings_default_notify_on_complete_is_false() {
3343        assert!(!Settings::default().notify_on_complete);
3344    }
3345
3346    #[test]
3347    fn settings_default_notify_on_error_is_false() {
3348        assert!(!Settings::default().notify_on_error);
3349    }
3350
3351    #[test]
3352    fn settings_default_on_complete_program_is_none() {
3353        assert!(Settings::default().on_complete_program.is_none());
3354    }
3355
3356    #[test]
3357    fn settings_default_use_incomplete_dir_is_false() {
3358        assert!(!Settings::default().use_incomplete_dir);
3359    }
3360
3361    #[test]
3362    fn settings_default_incomplete_dir_is_none() {
3363        assert!(Settings::default().incomplete_dir.is_none());
3364    }
3365
3366    #[test]
3367    fn settings_default_default_skip_hash_check_is_false() {
3368        assert!(!Settings::default().default_skip_hash_check);
3369    }
3370
3371    #[test]
3372    fn settings_default_incomplete_extension_enabled_is_true() {
3373        assert!(Settings::default().incomplete_extension_enabled);
3374    }
3375
3376    #[test]
3377    fn settings_default_watched_folder_is_none() {
3378        assert!(Settings::default().watched_folder.is_none());
3379    }
3380
3381    #[test]
3382    fn settings_default_delete_torrent_after_add_is_false() {
3383        assert!(!Settings::default().delete_torrent_after_add);
3384    }
3385
3386    #[test]
3387    fn settings_default_move_completed_enabled_is_false() {
3388        assert!(!Settings::default().move_completed_enabled);
3389    }
3390
3391    #[test]
3392    fn settings_default_move_completed_to_is_none() {
3393        assert!(Settings::default().move_completed_to.is_none());
3394    }
3395
3396    #[test]
3397    fn settings_default_web_ui_https_enabled_is_false() {
3398        assert!(!Settings::default().web_ui_https_enabled);
3399    }
3400
3401    #[test]
3402    fn settings_default_network_interface_is_none() {
3403        assert!(Settings::default().network_interface.is_none());
3404    }
3405
3406    #[test]
3407    fn settings_default_default_add_paused_is_false() {
3408        assert!(!Settings::default().default_add_paused);
3409    }
3410
3411    #[test]
3412    fn m255_settings_default_resolve_peer_countries_is_false() {
3413        assert!(!Settings::default().resolve_peer_countries);
3414        assert!(Settings::default().peer_country_db_path.is_none());
3415    }
3416
3417    #[test]
3418    fn m255_validate_rejects_resolve_countries_without_db_path() {
3419        let s = Settings {
3420            resolve_peer_countries: true,
3421            ..Settings::default()
3422        };
3423        assert!(s.validate().is_err());
3424    }
3425
3426    #[test]
3427    fn m255_validate_rejects_relative_peer_country_db_path() {
3428        let s = Settings {
3429            resolve_peer_countries: true,
3430            peer_country_db_path: Some(PathBuf::from("relative/geo.mmdb")),
3431            ..Settings::default()
3432        };
3433        assert!(s.validate().is_err());
3434    }
3435
3436    #[test]
3437    fn m255_validate_accepts_resolve_countries_with_absolute_path() {
3438        let s = Settings {
3439            resolve_peer_countries: true,
3440            peer_country_db_path: Some(PathBuf::from("/var/lib/irontide/geo.mmdb")),
3441            ..Settings::default()
3442        };
3443        assert!(s.validate().is_ok());
3444    }
3445
3446    // ── M226: validation rules ────
3447
3448    #[test]
3449    fn validate_rejects_use_incomplete_dir_without_incomplete_dir() {
3450        let s = Settings {
3451            use_incomplete_dir: true,
3452            incomplete_dir: None,
3453            ..Settings::default()
3454        };
3455        let err = s.validate().expect_err("must require incomplete_dir");
3456        assert!(format!("{err}").contains("incomplete_dir"));
3457    }
3458
3459    #[test]
3460    fn validate_accepts_use_incomplete_dir_with_incomplete_dir() {
3461        let s = Settings {
3462            use_incomplete_dir: true,
3463            incomplete_dir: Some(PathBuf::from("/tmp/irontide-incomplete")),
3464            ..Settings::default()
3465        };
3466        s.validate().expect("paired fields valid");
3467    }
3468
3469    #[test]
3470    fn validate_rejects_move_completed_without_move_completed_to() {
3471        let s = Settings {
3472            move_completed_enabled: true,
3473            move_completed_to: None,
3474            ..Settings::default()
3475        };
3476        let err = s.validate().expect_err("must require move_completed_to");
3477        assert!(format!("{err}").contains("move_completed_to"));
3478    }
3479
3480    #[test]
3481    fn validate_rejects_relative_watched_folder() {
3482        let s = Settings {
3483            watched_folder: Some(PathBuf::from("relative/path")),
3484            ..Settings::default()
3485        };
3486        let err = s.validate().expect_err("relative path must fail");
3487        assert!(format!("{err}").contains("absolute"));
3488    }
3489
3490    #[test]
3491    fn validate_rejects_relative_incomplete_dir() {
3492        let s = Settings {
3493            incomplete_dir: Some(PathBuf::from("inc")),
3494            ..Settings::default()
3495        };
3496        let err = s.validate().expect_err("relative path must fail");
3497        assert!(format!("{err}").contains("absolute"));
3498    }
3499
3500    #[test]
3501    fn validate_rejects_relative_move_completed_to() {
3502        let s = Settings {
3503            move_completed_to: Some(PathBuf::from("done")),
3504            ..Settings::default()
3505        };
3506        let err = s.validate().expect_err("relative path must fail");
3507        assert!(format!("{err}").contains("absolute"));
3508    }
3509
3510    #[test]
3511    fn validate_rejects_system_path_as_watched_folder() {
3512        for sys in ["/", "/etc", "/usr", "/bin", "/sys", "/proc"] {
3513            let s = Settings {
3514                watched_folder: Some(PathBuf::from(sys)),
3515                ..Settings::default()
3516            };
3517            let err = s.validate().expect_err("system path must be rejected");
3518            assert!(
3519                format!("{err}").contains("system path"),
3520                "{sys}: error must mention 'system path', got: {err}"
3521            );
3522        }
3523    }
3524
3525    #[test]
3526    fn validate_accepts_safe_watched_folder() {
3527        let s = Settings {
3528            watched_folder: Some(PathBuf::from("/tmp/irontide-watched")),
3529            ..Settings::default()
3530        };
3531        s.validate().expect("safe path must validate");
3532    }
3533
3534    // M243 (L6): per-peer writer channel bound + its validation invariants.
3535    #[test]
3536    fn peer_writer_channel_cap_defaults_to_1024() {
3537        assert_eq!(Settings::default().peer_writer_channel_cap, 1024);
3538    }
3539
3540    #[test]
3541    fn peer_writer_channel_cap_zero_is_rejected() {
3542        let s = Settings {
3543            peer_writer_channel_cap: 0,
3544            ..Settings::default()
3545        };
3546        let err = s
3547            .validate()
3548            .expect_err("zero writer-channel cap must be rejected");
3549        assert!(format!("{err}").contains("must be > 0"));
3550    }
3551
3552    #[test]
3553    fn peer_writer_channel_cap_at_or_below_request_queue_depth_is_rejected() {
3554        let depth = Settings::default().max_request_queue_depth;
3555        // Equal is also invalid: a full request pipeline must not be able to
3556        // fill the writer channel on its own and trip a false disconnect.
3557        let s = Settings {
3558            peer_writer_channel_cap: depth,
3559            ..Settings::default()
3560        };
3561        let err = s
3562            .validate()
3563            .expect_err("cap <= max_request_queue_depth must be rejected");
3564        assert!(format!("{err}").contains("max_request_queue_depth"));
3565    }
3566
3567    #[test]
3568    fn default_cap_exceeds_max_request_queue_depth() {
3569        let s = Settings::default();
3570        assert!(
3571            s.peer_writer_channel_cap > s.max_request_queue_depth,
3572            "default writer cap {} must exceed default request queue depth {}",
3573            s.peer_writer_channel_cap,
3574            s.max_request_queue_depth
3575        );
3576    }
3577}