Skip to main content

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