Skip to main content

irontide_settings/
lib.rs

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