1use std::net::IpAddr;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use irontide_core::{
13 AlertCategory, ChokingAlgorithm, MixedModeAlgorithm, PreallocateMode, ProxyConfig, ProxyType,
14 SeedChokingAlgorithm,
15};
16use irontide_wire::mse::EncryptionMode;
17
18mod schema;
21
22#[derive(Debug, thiserror::Error)]
29pub enum SettingsError {
30 #[error("{0}")]
32 Invalid(String),
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum MaxRatioAction {
45 #[default]
47 Pause,
48 Remove,
50 EnableSuperSeeding,
52}
53
54fn default_true() -> bool {
57 true
58}
59fn default_listen_port() -> u16 {
60 42020
61}
62fn default_download_dir() -> PathBuf {
63 PathBuf::from(".")
64}
65fn default_max_torrents() -> usize {
66 100
67}
68fn default_encryption() -> EncryptionMode {
69 EncryptionMode::Disabled
70}
71fn default_auto_upload_slots_min() -> usize {
72 2
73}
74fn default_auto_upload_slots_max() -> usize {
75 20
76}
77fn default_active_downloads() -> i32 {
78 3
79}
80fn default_active_seeds() -> i32 {
81 5
82}
83fn default_active_limit() -> i32 {
84 500
85}
86fn default_active_checking() -> i32 {
87 3
88}
89fn default_inactive_rate() -> u64 {
90 2048
91}
92fn default_auto_manage_interval() -> u64 {
93 30
94}
95fn default_auto_manage_startup() -> u64 {
96 60
97}
98fn default_queue_rate_ewma_alpha() -> f64 {
99 0.3
100}
101fn default_seed_queue_min_active_secs() -> u64 {
102 1800
103}
104fn default_alert_mask() -> AlertCategory {
105 AlertCategory::ALL
106}
107fn default_alert_channel_size() -> usize {
108 1024
109}
110fn default_smart_ban_max_failures() -> u32 {
111 3
112}
113fn default_disk_io_threads() -> usize {
114 let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
115 (cores / 2).clamp(4, 16)
116}
117fn default_max_blocking_threads() -> usize {
118 std::thread::available_parallelism().map_or(4, std::num::NonZero::get)
119}
120fn default_disk_cache_size() -> usize {
121 16 * 1024 * 1024
122}
123fn default_disk_write_cache_ratio() -> f32 {
124 0.5
125}
126fn default_buffer_pool_capacity() -> usize {
127 64 * 1024 * 1024
128}
129fn default_enable_mlock() -> bool {
130 cfg!(unix)
131}
132fn default_disk_channel_capacity() -> usize {
133 512
134}
135fn default_hashing_threads() -> usize {
136 let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
137 (cores / 4).clamp(2, 8)
138}
139fn default_max_request_queue_depth() -> usize {
140 250
141}
142fn default_initial_queue_depth() -> usize {
143 128
144}
145fn default_request_budget_per_torrent() -> u32 {
146 512
147}
148fn default_request_budget_floor() -> u32 {
149 8
150}
151fn default_request_queue_time() -> f64 {
152 3.0
153}
154fn default_block_request_timeout() -> u32 {
155 60
156}
157fn default_max_concurrent_streams() -> usize {
158 8
159}
160fn default_dht_qps() -> usize {
161 50
162}
163fn default_dht_timeout() -> u64 {
164 5
165}
166fn default_upnp_lease() -> u32 {
167 3600
168}
169fn default_natpmp_lifetime() -> u32 {
170 7200
171}
172fn default_utp_max_conns() -> usize {
173 256
174}
175fn default_dht_max_items() -> usize {
176 700
177}
178fn default_dht_item_lifetime() -> u64 {
179 7200
180}
181fn default_dht_sample_interval() -> u64 {
182 0
183}
184fn default_suggest_mode() -> bool {
185 true
186}
187fn default_max_suggest_pieces() -> usize {
188 16
189}
190fn default_predictive_piece_announce_ms() -> u64 {
191 0
192}
193fn default_ssl_listen_port() -> u16 {
194 0 }
196fn default_seed_choking_algorithm() -> SeedChokingAlgorithm {
197 SeedChokingAlgorithm::FastestUpload
198}
199fn default_choking_algorithm() -> ChokingAlgorithm {
200 ChokingAlgorithm::FixedSlots
201}
202fn default_mixed_mode() -> MixedModeAlgorithm {
203 MixedModeAlgorithm::PeerProportional
204}
205fn default_steal_threshold_ratio() -> f64 {
206 10.0
207}
208fn default_use_block_stealing() -> bool {
209 true
210}
211fn default_peer_connect_timeout() -> u64 {
212 10 }
214fn default_peer_dscp() -> u8 {
215 0x08 }
217fn default_max_peers_per_torrent() -> usize {
218 128
219}
220fn default_pass0_grace_secs() -> u64 {
222 60 }
224fn default_proactive_evictions_per_minute_limit() -> u32 {
225 30 }
227fn default_eviction_ban_duration_secs() -> u64 {
228 600 }
231fn default_eviction_ban_set_cap() -> usize {
232 1024 }
234fn default_stats_report_interval() -> u64 {
235 1000
236}
237fn default_strict_end_game() -> bool {
238 true
239}
240fn default_max_web_seeds() -> usize {
241 4
242}
243fn default_web_seed_retry_base_secs() -> u64 {
244 10
245}
246fn default_web_seed_retry_factor() -> u64 {
247 6
248}
249fn default_web_seed_retry_cap_secs() -> u64 {
250 3600
251}
252fn default_web_seed_max_failures() -> u32 {
253 10
254}
255fn default_initial_picker_threshold() -> u32 {
256 4
257}
258fn default_whole_pieces_threshold() -> u32 {
259 20
260}
261fn default_snub_timeout_secs() -> u32 {
262 15
263}
264fn default_readahead_pieces() -> u32 {
265 8
266}
267fn default_max_metadata_size() -> u64 {
268 4 * 1024 * 1024 }
270fn default_max_message_size() -> usize {
271 16 * 1024 * 1024 }
273fn default_max_piece_length() -> u64 {
274 32 * 1024 * 1024 }
276fn default_max_outstanding_requests() -> usize {
277 500
278}
279fn default_max_in_flight_pieces() -> usize {
280 512
281}
282fn default_fixed_pipeline_depth() -> usize {
283 128
284}
285fn default_i2p_hostname() -> String {
286 "127.0.0.1".into()
287}
288fn default_i2p_port() -> u16 {
289 7656
290}
291fn default_i2p_tunnel_quantity() -> u8 {
292 3
293}
294fn default_i2p_tunnel_length() -> u8 {
295 3
296}
297fn default_runtime_worker_threads() -> usize {
298 std::thread::available_parallelism().map_or(4, |n| n.get().min(8))
299}
300fn default_lock_warn_threshold_ms() -> u64 {
301 50
302}
303fn default_steal_stale_piece_secs() -> u64 {
304 2
305}
306fn default_steal_threshold_endgame() -> f64 {
307 3.0
308}
309fn default_peer_read_timeout_secs() -> u64 {
310 10
311}
312fn default_peer_write_timeout_secs() -> u64 {
313 10
314}
315fn default_data_contribution_timeout() -> u64 {
316 0 }
318fn default_choke_rotation_max_evictions() -> u32 {
319 0 }
321fn default_max_concurrent_connects() -> u16 {
322 128 }
324fn default_connect_soft_timeout() -> u64 {
325 3 }
327fn default_dispatch_backlog_cap() -> usize {
328 8 }
330fn default_event_backlog_cap() -> usize {
331 32 }
333fn default_peer_writer_channel_cap() -> usize {
334 1024 }
336fn default_web_seed_progress_throttle_ms() -> u64 {
337 250 }
339fn default_save_resume_interval() -> u64 {
340 300 }
342fn default_max_upload_slots_global() -> i32 {
343 -1
344}
345fn default_max_upload_slots_per_torrent() -> i32 {
346 4
347}
348fn default_max_connections_global() -> i32 {
349 -1
350}
351fn default_max_uploads_per_torrent() -> i32 {
352 -1
353}
354
355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363pub struct Settings {
364 #[serde(default = "default_listen_port")]
367 pub listen_port: u16,
368 #[serde(default)]
370 pub randomize_port_on_startup: bool,
371 #[serde(default = "default_download_dir")]
373 pub download_dir: PathBuf,
374 #[serde(default = "default_max_torrents")]
376 pub max_torrents: usize,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub resume_data_dir: Option<PathBuf>,
380 #[serde(default = "default_save_resume_interval")]
383 pub save_resume_interval_secs: u64,
384
385 #[serde(default = "default_true")]
388 pub enable_dht: bool,
389 #[serde(default = "default_true")]
391 pub enable_pex: bool,
392 #[serde(default = "default_true")]
394 pub enable_lsd: bool,
395 #[serde(default = "default_true")]
398 pub enable_fast_extension: bool,
399 #[serde(default = "default_true")]
403 pub enable_utp: bool,
404 #[serde(default = "default_true")]
407 pub enable_upnp: bool,
408 #[serde(default = "default_true")]
411 pub enable_natpmp: bool,
412 #[serde(default = "default_true")]
416 pub enable_ipv6: bool,
417 #[serde(default = "default_true")]
421 pub enable_web_seed: bool,
422 #[serde(default = "default_true")]
426 pub enable_holepunch: bool,
427 #[serde(default = "default_true")]
431 pub enable_bep40_eviction: bool,
432 #[serde(default)]
436 pub enable_diagnostic_counters: bool,
437 #[serde(default = "default_encryption")]
439 pub encryption_mode: EncryptionMode,
440 #[serde(default)]
443 pub anonymous_mode: bool,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub external_ip: Option<IpAddr>,
448
449 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub seed_ratio_limit: Option<f64>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub seed_time_limit_secs: Option<u64>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub inactive_seed_time_limit_secs: Option<u64>,
464 #[serde(default)]
467 pub max_ratio_action: MaxRatioAction,
468 #[serde(default = "default_true")]
472 pub create_subfolder: bool,
473 #[serde(default)]
477 pub auto_manage_torrents: bool,
478 #[serde(default)]
482 pub queueing_enabled: bool,
483 #[serde(default)]
486 pub default_super_seeding: bool,
487 #[serde(default)]
490 pub default_share_mode: bool,
491 #[serde(default = "default_true")]
494 pub upload_only_announce: bool,
495 #[serde(default)]
498 pub upload_rate_limit: u64,
499 #[serde(default)]
501 pub download_rate_limit: u64,
502 #[serde(default)]
504 pub tcp_upload_rate_limit: u64,
505 #[serde(default)]
507 pub tcp_download_rate_limit: u64,
508 #[serde(default)]
510 pub utp_upload_rate_limit: u64,
511 #[serde(default)]
513 pub utp_download_rate_limit: u64,
514 #[serde(default = "default_true")]
516 pub auto_upload_slots: bool,
517 #[serde(default = "default_auto_upload_slots_min")]
519 pub auto_upload_slots_min: usize,
520 #[serde(default = "default_auto_upload_slots_max")]
522 pub auto_upload_slots_max: usize,
523 #[serde(default = "default_max_upload_slots_global")]
525 pub max_upload_slots_global: i32,
526 #[serde(default = "default_max_upload_slots_per_torrent")]
528 pub max_upload_slots_per_torrent: i32,
529 #[serde(default = "default_max_connections_global")]
531 pub max_connections_global: i32,
532 #[serde(default = "default_max_uploads_per_torrent")]
538 pub max_uploads_per_torrent: i32,
539 #[serde(default)]
541 pub alt_download_rate_limit: u64,
542 #[serde(default)]
544 pub alt_upload_rate_limit: u64,
545 #[serde(default)]
547 pub alt_speed_enabled: bool,
548 #[serde(default)]
550 pub alt_speed_schedule_enabled: bool,
551 #[serde(default)]
553 pub alt_speed_schedule_from: u16,
554 #[serde(default)]
556 pub alt_speed_schedule_to: u16,
557 #[serde(default)]
559 pub alt_speed_schedule_days: u8,
560 #[serde(default = "default_true")]
562 pub rate_limit_includes_overhead: bool,
563 #[serde(default = "default_true")]
565 pub rate_limit_utp: bool,
566 #[serde(default)]
568 pub rate_limit_lan: bool,
569 #[serde(default = "default_mixed_mode")]
571 pub mixed_mode_algorithm: MixedModeAlgorithm,
572
573 #[serde(default = "default_active_downloads")]
576 pub active_downloads: i32,
577 #[serde(default = "default_active_seeds")]
579 pub active_seeds: i32,
580 #[serde(default = "default_active_limit")]
582 pub active_limit: i32,
583 #[serde(default = "default_active_checking")]
585 pub active_checking: i32,
586 #[serde(default = "default_true")]
589 pub dont_count_slow_torrents: bool,
590 #[serde(default = "default_inactive_rate")]
593 pub inactive_down_rate: u64,
594 #[serde(default = "default_inactive_rate")]
597 pub inactive_up_rate: u64,
598 #[serde(default = "default_auto_manage_interval")]
600 pub auto_manage_interval: u64,
601 #[serde(default = "default_auto_manage_startup")]
604 pub auto_manage_startup: u64,
605 #[serde(default)]
607 pub auto_manage_prefer_seeds: bool,
608 #[serde(default = "default_queue_rate_ewma_alpha")]
612 pub queue_rate_ewma_alpha: f64,
613 #[serde(default = "default_seed_queue_min_active_secs")]
617 pub seed_queue_min_active_secs: u64,
618
619 #[serde(default = "default_alert_mask")]
622 pub alert_mask: AlertCategory,
623 #[serde(default = "default_alert_channel_size")]
625 pub alert_channel_size: usize,
626
627 #[serde(default = "default_smart_ban_max_failures")]
631 pub smart_ban_max_failures: u32,
632 #[serde(default = "default_true")]
635 pub smart_ban_parole: bool,
636
637 #[serde(default = "default_disk_io_threads")]
640 pub disk_io_threads: usize,
641 #[serde(default = "default_max_blocking_threads")]
644 pub max_blocking_threads: usize,
645 #[serde(default)]
649 pub preallocate_mode: PreallocateMode,
650 #[serde(default = "default_disk_cache_size")]
652 pub disk_cache_size: usize,
653 #[serde(default = "default_disk_write_cache_ratio")]
655 pub disk_write_cache_ratio: f32,
656 #[serde(default = "default_disk_channel_capacity")]
658 pub disk_channel_capacity: usize,
659 #[serde(default = "default_buffer_pool_capacity")]
662 pub buffer_pool_capacity: usize,
663 #[serde(default = "default_enable_mlock")]
667 pub enable_mlock: bool,
668 #[serde(default)]
672 pub filesystem_direct_io: bool,
673 #[serde(default = "default_hashing_threads")]
676 pub hashing_threads: usize,
677 #[serde(default = "default_max_request_queue_depth")]
679 pub max_request_queue_depth: usize,
680 #[serde(default = "default_initial_queue_depth")]
683 pub initial_queue_depth: usize,
684 #[serde(default = "default_request_budget_per_torrent")]
689 pub request_budget_per_torrent: u32,
690 #[serde(default = "default_request_budget_floor")]
694 pub request_budget_floor: u32,
695 #[serde(default = "default_request_queue_time")]
702 pub request_queue_time: f64,
703 #[serde(default = "default_block_request_timeout")]
706 pub block_request_timeout_secs: u32,
707 #[serde(default = "default_max_concurrent_streams")]
710 pub max_concurrent_stream_reads: usize,
711 #[serde(default = "default_true")]
714 pub auto_sequential: bool,
715 #[serde(default = "default_strict_end_game")]
719 pub strict_end_game: bool,
720 #[serde(default = "default_max_web_seeds")]
722 pub max_web_seeds: usize,
723 #[serde(default = "default_web_seed_retry_base_secs")]
725 pub web_seed_retry_base_secs: u64,
726 #[serde(default = "default_web_seed_retry_factor")]
728 pub web_seed_retry_factor: u64,
729 #[serde(default = "default_web_seed_retry_cap_secs")]
731 pub web_seed_retry_cap_secs: u64,
732 #[serde(default = "default_web_seed_max_failures")]
734 pub web_seed_max_failures: u32,
735 #[serde(default = "default_initial_picker_threshold")]
738 pub initial_picker_threshold: u32,
739 #[serde(default = "default_whole_pieces_threshold")]
742 pub whole_pieces_threshold: u32,
743 #[serde(default = "default_snub_timeout_secs")]
746 pub snub_timeout_secs: u32,
747 #[serde(default = "default_readahead_pieces")]
749 pub readahead_pieces: u32,
750 #[serde(default = "default_true")]
752 pub streaming_timeout_escalation: bool,
753 #[serde(default = "default_steal_threshold_ratio")]
756 pub steal_threshold_ratio: f64,
757 #[serde(default = "default_use_block_stealing")]
760 pub use_block_stealing: bool,
761 #[serde(default = "default_steal_stale_piece_secs")]
766 pub steal_stale_piece_secs: u64,
767 #[serde(default = "default_steal_threshold_endgame")]
770 pub steal_threshold_endgame: f64,
771 #[serde(default = "default_fixed_pipeline_depth")]
776 pub fixed_pipeline_depth: usize,
777
778 #[serde(default = "default_true")]
782 pub piece_extent_affinity: bool,
783 #[serde(default = "default_suggest_mode")]
787 pub suggest_mode: bool,
788 #[serde(default = "default_max_suggest_pieces")]
790 pub max_suggest_pieces: usize,
791 #[serde(default = "default_predictive_piece_announce_ms")]
795 pub predictive_piece_announce_ms: u64,
796
797 #[serde(default)]
800 pub proxy: ProxyConfig,
801 #[serde(default)]
804 pub force_proxy: bool,
805
806 #[serde(default)]
809 pub ip_filter_enabled: bool,
810 #[serde(default)]
812 pub ip_filter_path: String,
813 #[serde(default)]
815 pub ip_filter_auto_refresh: bool,
816
817 #[serde(default = "default_true")]
820 pub apply_ip_filter_to_trackers: bool,
821
822 #[serde(default = "default_dht_qps")]
825 pub dht_queries_per_second: usize,
826 #[serde(default = "default_dht_timeout")]
828 pub dht_query_timeout_secs: u64,
829 #[serde(default)]
832 pub dht_enforce_node_id: bool,
833 #[serde(default = "default_true")]
835 pub dht_restrict_routing_ips: bool,
836 #[serde(default = "default_dht_max_items")]
838 pub dht_max_items: usize,
839 #[serde(default = "default_dht_item_lifetime")]
841 pub dht_item_lifetime_secs: u64,
842 #[serde(default = "default_dht_sample_interval")]
845 pub dht_sample_infohashes_interval: u64,
846 #[serde(default)]
850 pub dht_read_only: bool,
851
852 #[serde(default = "default_upnp_lease")]
855 pub upnp_lease_duration: u32,
856 #[serde(default = "default_natpmp_lifetime")]
858 pub natpmp_lifetime: u32,
859
860 #[serde(default = "default_utp_max_conns")]
863 pub utp_max_connections: usize,
864
865 #[serde(default)]
868 pub enable_i2p: bool,
869 #[serde(default = "default_i2p_hostname")]
871 pub i2p_hostname: String,
872 #[serde(default = "default_i2p_port")]
874 pub i2p_port: u16,
875 #[serde(default = "default_i2p_tunnel_quantity")]
877 pub i2p_inbound_quantity: u8,
878 #[serde(default = "default_i2p_tunnel_quantity")]
880 pub i2p_outbound_quantity: u8,
881 #[serde(default = "default_i2p_tunnel_length")]
883 pub i2p_inbound_length: u8,
884 #[serde(default = "default_i2p_tunnel_length")]
886 pub i2p_outbound_length: u8,
887 #[serde(default)]
890 pub allow_i2p_mixed: bool,
891
892 #[serde(default = "default_ssl_listen_port")]
897 pub ssl_listen_port: u16,
898 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub ssl_cert_path: Option<PathBuf>,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub ssl_key_path: Option<PathBuf>,
906
907 #[serde(default = "default_seed_choking_algorithm")]
910 pub seed_choking_algorithm: SeedChokingAlgorithm,
911 #[serde(default = "default_choking_algorithm")]
913 pub choking_algorithm: ChokingAlgorithm,
914
915 #[serde(default = "default_max_peers_per_torrent")]
920 pub max_peers_per_torrent: usize,
921
922 #[serde(default = "default_pass0_grace_secs")]
927 pub pass0_grace_secs: u64,
928
929 #[serde(default = "default_proactive_evictions_per_minute_limit")]
934 pub proactive_evictions_per_minute_limit: u32,
935
936 #[serde(default = "default_eviction_ban_duration_secs")]
941 pub eviction_ban_duration_secs: u64,
942
943 #[serde(default = "default_eviction_ban_set_cap")]
947 pub eviction_ban_set_cap: usize,
948
949 #[serde(default = "default_peer_read_timeout_secs")]
952 pub peer_read_timeout_secs: u64,
953 #[serde(default = "default_peer_write_timeout_secs")]
956 pub peer_write_timeout_secs: u64,
957
958 #[serde(default = "default_data_contribution_timeout")]
961 pub data_contribution_timeout_secs: u64,
962
963 #[serde(default = "default_choke_rotation_max_evictions")]
965 pub choke_rotation_max_evictions: u32,
966
967 #[serde(default = "default_max_concurrent_connects")]
969 pub max_concurrent_connects: u16,
970
971 #[serde(default = "default_connect_soft_timeout")]
974 pub connect_soft_timeout: u64,
975
976 #[serde(default = "default_dispatch_backlog_cap")]
982 pub dispatch_backlog_cap: usize,
983
984 #[serde(default = "default_event_backlog_cap")]
988 pub event_backlog_cap: usize,
989
990 #[serde(default = "default_peer_writer_channel_cap")]
999 pub peer_writer_channel_cap: usize,
1000
1001 #[serde(default = "default_true")]
1003 pub use_actor_dispatch: bool,
1004
1005 #[serde(default = "default_web_seed_progress_throttle_ms")]
1010 pub web_seed_progress_throttle_ms: u64,
1011
1012 #[serde(default = "default_true")]
1016 pub ssrf_mitigation: bool,
1017 #[serde(default)]
1019 pub allow_idna: bool,
1020 #[serde(default = "default_true")]
1022 pub validate_https_trackers: bool,
1023 #[serde(default = "default_max_metadata_size")]
1026 pub max_metadata_size: u64,
1027 #[serde(default = "default_max_message_size")]
1030 pub max_message_size: usize,
1031 #[serde(default = "default_max_piece_length")]
1034 pub max_piece_length: u64,
1035 #[serde(default = "default_max_outstanding_requests")]
1039 pub max_outstanding_requests: usize,
1040 #[serde(default = "default_max_in_flight_pieces")]
1045 pub max_in_flight_pieces: usize,
1046 #[serde(default = "default_peer_connect_timeout")]
1049 pub peer_connect_timeout: u64,
1050 #[serde(default = "default_peer_dscp")]
1054 pub peer_dscp: u8,
1055
1056 #[serde(default = "default_stats_report_interval")]
1060 pub stats_report_interval: u64,
1061
1062 #[serde(default = "default_runtime_worker_threads")]
1066 pub runtime_worker_threads: usize,
1067 #[serde(default = "default_true")]
1069 pub pin_cores: bool,
1070
1071 #[serde(default = "default_lock_warn_threshold_ms")]
1077 pub lock_warn_threshold_ms: u64,
1078
1079 #[serde(skip)]
1085 pub dht_saved_nodes: Vec<String>,
1086 #[serde(skip)]
1090 pub dht_node_id: Option<irontide_core::Id20>,
1091
1092 #[serde(default)]
1096 pub qbt_compat: QbtCompatSettings,
1097
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1102 pub category_registry_path: Option<PathBuf>,
1103
1104 #[serde(default, skip_serializing_if = "Option::is_none")]
1108 pub tag_registry_path: Option<PathBuf>,
1109
1110 #[serde(default)]
1115 pub notify_on_complete: bool,
1116 #[serde(default)]
1119 pub notify_on_error: bool,
1120 #[serde(default, skip_serializing_if = "Option::is_none")]
1125 pub on_complete_program: Option<PathBuf>,
1126 #[serde(default)]
1130 pub use_incomplete_dir: bool,
1131 #[serde(default, skip_serializing_if = "Option::is_none")]
1135 pub incomplete_dir: Option<PathBuf>,
1136 #[serde(default)]
1140 pub default_skip_hash_check: bool,
1141 #[serde(default = "default_true")]
1145 pub incomplete_extension_enabled: bool,
1146 #[serde(default, skip_serializing_if = "Option::is_none")]
1150 pub watched_folder: Option<PathBuf>,
1151 #[serde(default)]
1156 pub delete_torrent_after_add: bool,
1157 #[serde(default)]
1161 pub move_completed_enabled: bool,
1162 #[serde(default, skip_serializing_if = "Option::is_none")]
1166 pub move_completed_to: Option<PathBuf>,
1167 #[serde(default)]
1171 pub web_ui_https_enabled: bool,
1172 #[serde(default, skip_serializing_if = "Option::is_none")]
1176 pub network_interface: Option<String>,
1177 #[serde(default)]
1182 pub default_add_paused: bool,
1183 #[serde(default)]
1188 pub resolve_peer_countries: bool,
1189 #[serde(default, skip_serializing_if = "Option::is_none")]
1193 pub peer_country_db_path: Option<PathBuf>,
1194}
1195
1196#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
1214#[serde(default)]
1215pub struct QbtCompatSettings {
1216 pub enabled: bool,
1224 pub username: String,
1227 pub password_hash: String,
1235 #[serde(default)]
1241 pub password: String,
1242 pub spoof_app_version: String,
1245 pub spoof_webapi_version: String,
1248 pub session_ttl_secs: u64,
1251 pub max_sessions: usize,
1254 #[serde(default, skip_serializing_if = "Option::is_none")]
1259 pub max_concurrent_argon2_ops: Option<u32>,
1260
1261 #[serde(default = "default_qbt_port")]
1266 pub port: u16,
1267
1268 #[serde(default = "default_qbt_bind_address")]
1273 pub bind_address: String,
1274
1275 #[serde(default = "default_csrf_protection_enabled")]
1282 pub csrf_protection_enabled: bool,
1283 #[serde(default = "default_host_header_validation_enabled")]
1289 pub host_header_validation_enabled: bool,
1290 #[serde(default)]
1298 pub web_ui_reverse_proxy_enabled: bool,
1299 #[serde(default)]
1310 pub web_ui_reverse_proxies_list: Vec<String>,
1311
1312 #[serde(default = "default_max_failed_auth_count")]
1319 pub max_failed_auth_count: u32,
1320 #[serde(default = "default_ban_duration_secs")]
1323 pub ban_duration_secs: u64,
1324 #[serde(default)]
1332 pub bypass_local_auth: bool,
1333 #[serde(default)]
1341 pub bypass_auth_subnet_whitelist: Vec<String>,
1342 #[serde(default, skip_serializing_if = "Option::is_none")]
1350 pub brute_force_registry_capacity: Option<usize>,
1351}
1352
1353fn default_csrf_protection_enabled() -> bool {
1354 true
1355}
1356
1357fn default_host_header_validation_enabled() -> bool {
1358 true
1359}
1360
1361fn default_qbt_port() -> u16 {
1364 9080
1365}
1366
1367fn default_qbt_bind_address() -> String {
1368 "127.0.0.1".to_owned()
1369}
1370
1371#[must_use]
1373pub const fn default_max_failed_auth_count() -> u32 {
1374 5
1375}
1376
1377#[must_use]
1379pub const fn default_ban_duration_secs() -> u64 {
1380 3_600
1381}
1382
1383pub const DEFAULT_ADMINADMIN_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$u3doPIM7ab7NlbMfhMFm6A$ctIAjFfl70eUfUsThdGcXICr0lcD6bEUilRujvnXLPg";
1394
1395impl Default for QbtCompatSettings {
1396 fn default() -> Self {
1397 Self {
1398 enabled: true,
1399 username: "admin".into(),
1400 password_hash: DEFAULT_ADMINADMIN_HASH.into(),
1401 password: String::new(),
1402 spoof_app_version: "v5.1.4".into(),
1403 spoof_webapi_version: "2.11.4".into(),
1404 session_ttl_secs: 86_400,
1405 max_sessions: 1024,
1406 max_concurrent_argon2_ops: None,
1407 port: default_qbt_port(),
1409 bind_address: default_qbt_bind_address(),
1410 csrf_protection_enabled: true,
1412 host_header_validation_enabled: true,
1413 web_ui_reverse_proxy_enabled: false,
1414 web_ui_reverse_proxies_list: Vec::new(),
1415 max_failed_auth_count: default_max_failed_auth_count(),
1417 ban_duration_secs: default_ban_duration_secs(),
1418 bypass_local_auth: false,
1419 bypass_auth_subnet_whitelist: Vec::new(),
1420 brute_force_registry_capacity: None,
1421 }
1422 }
1423}
1424
1425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1427pub enum QbtCredentialMigration {
1428 NoOp,
1430 Upgraded,
1434}
1435
1436pub fn hash_qbt_password(plaintext: &str) -> Result<String, QbtMigrationError> {
1448 use argon2::password_hash::{PasswordHasher, SaltString};
1449 use argon2::{Algorithm, Argon2, Params, Version};
1450
1451 let salt = SaltString::generate(&mut rand_core::OsRng);
1455 let params = Params::new(19_456, 2, 1, Some(32))
1458 .map_err(|e| QbtMigrationError::Hash(format!("argon2 params: {e}")))?;
1459 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1460 let hash = argon2
1461 .hash_password(plaintext.as_bytes(), &salt)
1462 .map_err(|e| QbtMigrationError::Hash(format!("argon2 hash: {e}")))?;
1463 Ok(hash.to_string())
1464}
1465
1466#[derive(Debug, thiserror::Error)]
1468pub enum QbtMigrationError {
1469 #[error("argon2 hash: {0}")]
1471 Hash(String),
1472}
1473
1474pub fn migrate_qbt_credentials(
1501 qbt: &mut QbtCompatSettings,
1502) -> Result<QbtCredentialMigration, QbtMigrationError> {
1503 if !qbt.password_hash.is_empty() {
1504 return Ok(QbtCredentialMigration::NoOp);
1505 }
1506 if qbt.password.is_empty() {
1507 return Ok(QbtCredentialMigration::NoOp);
1508 }
1509
1510 let hash = hash_qbt_password(&qbt.password)?;
1511 qbt.password_hash = hash;
1512 let _drain = zeroize::Zeroizing::new(std::mem::take(&mut qbt.password));
1516 Ok(QbtCredentialMigration::Upgraded)
1517}
1518
1519fn is_valid_app_version(s: &str) -> bool {
1521 let Some(rest) = s.strip_prefix('v') else {
1522 return false;
1523 };
1524 let (core, suffix_ok) = match rest.split_once('-') {
1526 Some((core, suffix)) => (
1527 core,
1528 !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_alphanumeric()),
1529 ),
1530 None => (rest, true),
1531 };
1532 if !suffix_ok {
1533 return false;
1534 }
1535 is_valid_dotted_numeric(core)
1536}
1537
1538fn is_valid_webapi_version(s: &str) -> bool {
1540 is_valid_dotted_numeric(s)
1541}
1542
1543fn is_valid_dotted_numeric(s: &str) -> bool {
1545 let parts: Vec<&str> = s.split('.').collect();
1546 if !(2..=3).contains(&parts.len()) {
1547 return false;
1548 }
1549 parts
1550 .iter()
1551 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
1552}
1553
1554impl Default for Settings {
1555 fn default() -> Self {
1556 Self {
1557 listen_port: 42020,
1559 randomize_port_on_startup: false,
1560 download_dir: PathBuf::from("."),
1561 max_torrents: 100,
1562 resume_data_dir: None,
1563 save_resume_interval_secs: 300,
1564 enable_dht: true,
1566 enable_pex: true,
1567 enable_lsd: true,
1568 enable_fast_extension: true,
1569 enable_utp: true,
1570 enable_upnp: true,
1571 enable_natpmp: true,
1572 enable_ipv6: true,
1573 enable_web_seed: true,
1574 enable_holepunch: true,
1575 enable_bep40_eviction: true,
1576 enable_diagnostic_counters: false,
1577 encryption_mode: EncryptionMode::Disabled,
1578 anonymous_mode: false,
1579 external_ip: None,
1580 seed_ratio_limit: None,
1582 seed_time_limit_secs: None,
1583 inactive_seed_time_limit_secs: None,
1584 max_ratio_action: MaxRatioAction::Pause,
1585 create_subfolder: true,
1586 auto_manage_torrents: false,
1587 queueing_enabled: false,
1588 default_super_seeding: false,
1589 default_share_mode: false,
1590 upload_only_announce: true,
1591 upload_rate_limit: 0,
1593 download_rate_limit: 0,
1594 tcp_upload_rate_limit: 0,
1595 tcp_download_rate_limit: 0,
1596 utp_upload_rate_limit: 0,
1597 utp_download_rate_limit: 0,
1598 auto_upload_slots: true,
1599 auto_upload_slots_min: 2,
1600 auto_upload_slots_max: 20,
1601 max_upload_slots_global: -1,
1602 max_upload_slots_per_torrent: 4,
1603 max_connections_global: -1,
1604 max_uploads_per_torrent: -1,
1605 alt_download_rate_limit: 0,
1606 alt_upload_rate_limit: 0,
1607 alt_speed_enabled: false,
1608 alt_speed_schedule_enabled: false,
1609 alt_speed_schedule_from: 0,
1610 alt_speed_schedule_to: 0,
1611 alt_speed_schedule_days: 0,
1612 rate_limit_includes_overhead: true,
1613 rate_limit_utp: true,
1614 rate_limit_lan: false,
1615 mixed_mode_algorithm: MixedModeAlgorithm::PeerProportional,
1616 active_downloads: 3,
1618 active_seeds: 5,
1619 active_limit: 500,
1620 active_checking: 3,
1621 dont_count_slow_torrents: true,
1622 inactive_down_rate: 2048,
1623 inactive_up_rate: 2048,
1624 auto_manage_interval: 30,
1625 auto_manage_startup: 60,
1626 auto_manage_prefer_seeds: false,
1627 queue_rate_ewma_alpha: 0.3,
1628 seed_queue_min_active_secs: 1800,
1629 alert_mask: AlertCategory::ALL,
1631 alert_channel_size: 1024,
1632 smart_ban_max_failures: 3,
1634 smart_ban_parole: true,
1635 disk_io_threads: default_disk_io_threads(),
1637 max_blocking_threads: default_max_blocking_threads(),
1638 preallocate_mode: PreallocateMode::None,
1639 disk_cache_size: 16 * 1024 * 1024,
1640 disk_write_cache_ratio: 0.5,
1641 disk_channel_capacity: 512,
1642 buffer_pool_capacity: 64 * 1024 * 1024,
1643 enable_mlock: cfg!(unix),
1644 filesystem_direct_io: false,
1645 hashing_threads: default_hashing_threads(),
1647 max_request_queue_depth: 250,
1648 initial_queue_depth: 128,
1649 request_budget_per_torrent: 512,
1650 request_budget_floor: 8,
1651 request_queue_time: 3.0,
1652 block_request_timeout_secs: 60,
1653 max_concurrent_stream_reads: 8,
1654 auto_sequential: true,
1655 steal_threshold_ratio: 10.0,
1656 use_block_stealing: true,
1657 steal_stale_piece_secs: 2,
1658 steal_threshold_endgame: 3.0,
1659 fixed_pipeline_depth: 128,
1660 strict_end_game: true,
1661 max_web_seeds: 4,
1662 web_seed_retry_base_secs: 10,
1663 web_seed_retry_factor: 6,
1664 web_seed_retry_cap_secs: 3600,
1665 web_seed_max_failures: 10,
1666 initial_picker_threshold: 4,
1667 whole_pieces_threshold: 20,
1668 snub_timeout_secs: 15,
1669 readahead_pieces: 8,
1670 streaming_timeout_escalation: true,
1671 piece_extent_affinity: true,
1673 suggest_mode: true,
1674 max_suggest_pieces: 16,
1675 predictive_piece_announce_ms: 0,
1676 proxy: ProxyConfig::default(),
1678 force_proxy: false,
1679 ip_filter_enabled: false,
1681 ip_filter_path: String::new(),
1682 ip_filter_auto_refresh: false,
1683 apply_ip_filter_to_trackers: true,
1684 dht_queries_per_second: 50,
1686 dht_query_timeout_secs: 5,
1687 dht_enforce_node_id: false,
1688 dht_restrict_routing_ips: true,
1689 dht_max_items: 700,
1690 dht_item_lifetime_secs: 7200,
1691 dht_sample_infohashes_interval: 0,
1692 dht_read_only: false,
1693 upnp_lease_duration: 3600,
1695 natpmp_lifetime: 7200,
1696 utp_max_connections: 256,
1698 enable_i2p: false,
1700 i2p_hostname: "127.0.0.1".into(),
1701 i2p_port: 7656,
1702 i2p_inbound_quantity: 3,
1703 i2p_outbound_quantity: 3,
1704 i2p_inbound_length: 3,
1705 i2p_outbound_length: 3,
1706 allow_i2p_mixed: false,
1707 ssl_listen_port: 0,
1709 ssl_cert_path: None,
1710 ssl_key_path: None,
1711 seed_choking_algorithm: SeedChokingAlgorithm::FastestUpload,
1713 choking_algorithm: ChokingAlgorithm::FixedSlots,
1714 max_peers_per_torrent: 128,
1716 pass0_grace_secs: 60,
1718 proactive_evictions_per_minute_limit: 30,
1719 eviction_ban_duration_secs: 600,
1720 eviction_ban_set_cap: 1024,
1721 peer_read_timeout_secs: 10,
1722 peer_write_timeout_secs: 10,
1723 data_contribution_timeout_secs: 0,
1724 choke_rotation_max_evictions: 0,
1725 max_concurrent_connects: 128,
1726 connect_soft_timeout: 3,
1727 dispatch_backlog_cap: 8,
1728 event_backlog_cap: 32,
1729 peer_writer_channel_cap: 1024,
1730 use_actor_dispatch: true,
1731 web_seed_progress_throttle_ms: 250,
1732 ssrf_mitigation: true,
1734 allow_idna: false,
1735 validate_https_trackers: true,
1736 max_metadata_size: 4 * 1024 * 1024,
1737 max_message_size: 16 * 1024 * 1024,
1738 max_piece_length: 32 * 1024 * 1024,
1739 max_outstanding_requests: 500,
1740 max_in_flight_pieces: 512,
1741 peer_connect_timeout: 10,
1742 peer_dscp: 0x08,
1743 stats_report_interval: 1000,
1745 runtime_worker_threads: default_runtime_worker_threads(),
1747 pin_cores: true,
1748 lock_warn_threshold_ms: 50,
1750 dht_saved_nodes: Vec::new(),
1752 dht_node_id: None,
1753 qbt_compat: QbtCompatSettings::default(),
1755 category_registry_path: None,
1758 tag_registry_path: None,
1761 notify_on_complete: false,
1763 notify_on_error: false,
1764 on_complete_program: None,
1765 use_incomplete_dir: false,
1766 incomplete_dir: None,
1767 default_skip_hash_check: false,
1768 incomplete_extension_enabled: true,
1769 watched_folder: None,
1770 delete_torrent_after_add: false,
1771 move_completed_enabled: false,
1772 move_completed_to: None,
1773 web_ui_https_enabled: false,
1774 network_interface: None,
1775 default_add_paused: false,
1776 resolve_peer_countries: false,
1777 peer_country_db_path: None,
1778 }
1779 }
1780}
1781
1782const MAX_TORRENTS_CEILING: usize = 100_000;
1787
1788impl Settings {
1789 #[must_use]
1791 pub fn min_memory() -> Self {
1792 Self {
1793 disk_cache_size: 8 * 1024 * 1024,
1794 buffer_pool_capacity: 16 * 1024 * 1024,
1795 max_torrents: 20,
1796 max_peers_per_torrent: 30,
1797 active_downloads: 1,
1798 active_seeds: 2,
1799 active_limit: 10,
1800 alert_channel_size: 256,
1801 utp_max_connections: 64,
1802 max_request_queue_depth: 50,
1803 peer_writer_channel_cap: 256,
1804 initial_queue_depth: 16,
1805 max_concurrent_stream_reads: 2,
1806 hashing_threads: 1,
1807 disk_io_threads: 1,
1808 dht_max_items: 100,
1809 max_in_flight_pieces: 32,
1810 fixed_pipeline_depth: 32,
1811 ..Self::default()
1812 }
1813 }
1814
1815 #[must_use]
1817 pub fn high_performance() -> Self {
1818 Self {
1819 disk_cache_size: 256 * 1024 * 1024,
1820 buffer_pool_capacity: 256 * 1024 * 1024,
1821 max_torrents: 2000,
1822 max_peers_per_torrent: 200,
1823 active_downloads: 30,
1824 active_seeds: 100,
1825 active_limit: 2000,
1826 alert_channel_size: 4096,
1827 utp_max_connections: 1024,
1828 max_request_queue_depth: 1000,
1829 peer_writer_channel_cap: 2048,
1830 initial_queue_depth: 256,
1831 max_concurrent_stream_reads: 32,
1832 hashing_threads: 4,
1833 disk_io_threads: 8,
1834 auto_upload_slots_max: 100,
1835 suggest_mode: true,
1836 steal_threshold_ratio: 5.0,
1837 steal_threshold_endgame: 2.0,
1838 use_block_stealing: true,
1839 max_in_flight_pieces: 512,
1840 ..Self::default()
1841 }
1842 }
1843
1844 pub fn validate(&self) -> Result<(), SettingsError> {
1850 if self.force_proxy && self.proxy.proxy_type == ProxyType::None {
1851 return Err(SettingsError::Invalid(
1852 "force_proxy is enabled but no proxy type is configured".into(),
1853 ));
1854 }
1855
1856 if self.active_downloads > 0
1857 && self.active_limit > 0
1858 && self.active_downloads > self.active_limit
1859 {
1860 return Err(SettingsError::Invalid(
1861 "active_downloads exceeds active_limit".into(),
1862 ));
1863 }
1864
1865 if self.active_seeds > 0 && self.active_limit > 0 && self.active_seeds > self.active_limit {
1866 return Err(SettingsError::Invalid(
1867 "active_seeds exceeds active_limit".into(),
1868 ));
1869 }
1870
1871 if !(0.0..=1.0).contains(&self.disk_write_cache_ratio) {
1872 return Err(SettingsError::Invalid(
1873 "disk_write_cache_ratio must be between 0.0 and 1.0".into(),
1874 ));
1875 }
1876
1877 if self.disk_cache_size < 1024 * 1024 {
1878 return Err(SettingsError::Invalid(
1879 "disk_cache_size must be at least 1 MiB".into(),
1880 ));
1881 }
1882
1883 if self.hashing_threads == 0 {
1884 return Err(SettingsError::Invalid(
1885 "hashing_threads must be at least 1".into(),
1886 ));
1887 }
1888
1889 if self.request_budget_floor == 0 || self.request_budget_floor > 128 {
1892 return Err(SettingsError::Invalid(
1893 "request_budget_floor must be in 1..=128".into(),
1894 ));
1895 }
1896 if self.request_budget_per_torrent != 0
1897 && self.request_budget_per_torrent < self.request_budget_floor
1898 {
1899 return Err(SettingsError::Invalid(
1900 "request_budget_per_torrent must be 0 (disabled) or >= request_budget_floor".into(),
1901 ));
1902 }
1903
1904 if self.peer_writer_channel_cap == 0 {
1909 return Err(SettingsError::Invalid(
1910 "peer_writer_channel_cap must be > 0 (the writer channel must be bounded)".into(),
1911 ));
1912 }
1913 if self.peer_writer_channel_cap <= self.max_request_queue_depth {
1914 return Err(SettingsError::Invalid(
1915 "peer_writer_channel_cap must be > max_request_queue_depth (avoids false-positive writer-backpressure disconnects)".into(),
1916 ));
1917 }
1918
1919 if self.use_incomplete_dir && self.incomplete_dir.is_none() {
1921 return Err(SettingsError::Invalid(
1922 "incomplete_dir must be set when use_incomplete_dir=true".into(),
1923 ));
1924 }
1925 if self.move_completed_enabled && self.move_completed_to.is_none() {
1926 return Err(SettingsError::Invalid(
1927 "move_completed_to must be set when move_completed_enabled=true".into(),
1928 ));
1929 }
1930 if self.resolve_peer_countries && self.peer_country_db_path.is_none() {
1933 return Err(SettingsError::Invalid(
1934 "peer_country_db_path must be set when resolve_peer_countries=true".into(),
1935 ));
1936 }
1937 for (name, opt) in [
1940 ("watched_folder", self.watched_folder.as_ref()),
1941 ("incomplete_dir", self.incomplete_dir.as_ref()),
1942 ("move_completed_to", self.move_completed_to.as_ref()),
1943 ("peer_country_db_path", self.peer_country_db_path.as_ref()),
1944 ] {
1945 if let Some(p) = opt
1946 && !p.is_absolute()
1947 {
1948 return Err(SettingsError::Invalid(format!(
1949 "{name} must be an absolute path, got {}",
1950 p.display()
1951 )));
1952 }
1953 }
1954 if let Some(p) = self.watched_folder.as_ref() {
1957 const DENY: &[&str] = &[
1958 "/", "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/sys", "/proc",
1959 "/dev", "/run", "/var/lib", "/var/log",
1960 ];
1961 let s = p.to_string_lossy();
1962 if DENY.iter().any(|d| s == *d) {
1963 return Err(SettingsError::Invalid(format!(
1964 "watched_folder rejected: {} is a system path (would risk shredding system files if delete_torrent_after_add=true)",
1965 p.display()
1966 )));
1967 }
1968 if let Some(home) = std::env::var_os("HOME") {
1969 let home_path = PathBuf::from(home);
1970 if p == &home_path {
1971 return Err(SettingsError::Invalid(format!(
1972 "watched_folder cannot be $HOME ({}) — too broad to be a torrent dropbox; pick a dedicated subdirectory",
1973 p.display()
1974 )));
1975 }
1976 }
1977 }
1978
1979 if self.max_uploads_per_torrent == 0 || self.max_uploads_per_torrent < -1 {
1985 return Err(SettingsError::Invalid(
1986 "max_uploads_per_torrent must be -1 (unlimited) or >= 1".into(),
1987 ));
1988 }
1989
1990 if self.disk_io_threads == 0 {
1991 return Err(SettingsError::Invalid(
1992 "disk_io_threads must be at least 1".into(),
1993 ));
1994 }
1995
1996 if self.max_blocking_threads == 0 {
1997 return Err(SettingsError::Invalid(
1998 "max_blocking_threads must be at least 1".into(),
1999 ));
2000 }
2001
2002 if self.max_torrents == 0 {
2007 return Err(SettingsError::Invalid(
2008 "max_torrents must be at least 1".into(),
2009 ));
2010 }
2011 if self.max_torrents > MAX_TORRENTS_CEILING {
2012 return Err(SettingsError::Invalid(format!(
2013 "max_torrents {} exceeds the {MAX_TORRENTS_CEILING} ceiling",
2014 self.max_torrents
2015 )));
2016 }
2017
2018 if self.default_share_mode && !self.enable_fast_extension {
2019 return Err(SettingsError::Invalid(
2020 "share_mode requires enable_fast_extension for RejectRequest messages".into(),
2021 ));
2022 }
2023
2024 if self.ssl_cert_path.is_some() != self.ssl_key_path.is_some() {
2026 return Err(SettingsError::Invalid(
2027 "ssl_cert_path and ssl_key_path must both be set or both absent".into(),
2028 ));
2029 }
2030
2031 if self.enable_i2p {
2032 if self.i2p_inbound_quantity == 0 || self.i2p_inbound_quantity > 16 {
2033 return Err(SettingsError::Invalid(
2034 "i2p_inbound_quantity must be 1-16".into(),
2035 ));
2036 }
2037 if self.i2p_outbound_quantity == 0 || self.i2p_outbound_quantity > 16 {
2038 return Err(SettingsError::Invalid(
2039 "i2p_outbound_quantity must be 1-16".into(),
2040 ));
2041 }
2042 if self.i2p_inbound_length > 7 {
2043 return Err(SettingsError::Invalid(
2044 "i2p_inbound_length must be 0-7".into(),
2045 ));
2046 }
2047 if self.i2p_outbound_length > 7 {
2048 return Err(SettingsError::Invalid(
2049 "i2p_outbound_length must be 0-7".into(),
2050 ));
2051 }
2052 }
2053
2054 if self.runtime_worker_threads > 256 {
2055 return Err(SettingsError::Invalid(
2056 "runtime_worker_threads must be at most 256".into(),
2057 ));
2058 }
2059
2060 if self.qbt_compat.enabled {
2064 if self.qbt_compat.username.is_empty() {
2065 return Err(SettingsError::Invalid(
2066 "qbt_compat.username must not be empty when enabled".into(),
2067 ));
2068 }
2069 if self.qbt_compat.password_hash.is_empty() {
2073 if self.qbt_compat.password.len() < 8 {
2074 return Err(SettingsError::Invalid(
2075 "qbt_compat: either password_hash must be set OR \
2076 password must be at least 8 characters (legacy upgrade path)"
2077 .into(),
2078 ));
2079 }
2080 } else if !self.qbt_compat.password_hash.starts_with("$argon2id$") {
2081 return Err(SettingsError::Invalid(
2085 "qbt_compat.password_hash must be an argon2id PHC string \
2086 starting with `$argon2id$`"
2087 .into(),
2088 ));
2089 }
2090 if let Some(0) = self.qbt_compat.max_concurrent_argon2_ops {
2091 return Err(SettingsError::Invalid(
2092 "qbt_compat.max_concurrent_argon2_ops must be > 0 when set".into(),
2093 ));
2094 }
2095 if !is_valid_app_version(&self.qbt_compat.spoof_app_version) {
2096 return Err(SettingsError::Invalid(
2097 "qbt_compat.spoof_app_version must match vN.N[.N][-suffix] (e.g. v5.1.4)"
2098 .into(),
2099 ));
2100 }
2101 if !is_valid_webapi_version(&self.qbt_compat.spoof_webapi_version) {
2102 return Err(SettingsError::Invalid(
2103 "qbt_compat.spoof_webapi_version must match N.N[.N] (e.g. 2.11.4)".into(),
2104 ));
2105 }
2106 if !(60..=604_800).contains(&self.qbt_compat.session_ttl_secs) {
2107 return Err(SettingsError::Invalid(
2108 "qbt_compat.session_ttl_secs must be in [60, 604800]".into(),
2109 ));
2110 }
2111 if self.qbt_compat.max_sessions == 0 {
2112 return Err(SettingsError::Invalid(
2113 "qbt_compat.max_sessions must be at least 1".into(),
2114 ));
2115 }
2116 for entry in &self.qbt_compat.web_ui_reverse_proxies_list {
2121 if entry.parse::<ipnet::IpNet>().is_err() {
2122 return Err(SettingsError::Invalid(format!(
2123 "qbt_compat.web_ui_reverse_proxies_list: invalid CIDR '{entry}'"
2124 )));
2125 }
2126 }
2127
2128 if self.qbt_compat.max_failed_auth_count == 0 && !self.qbt_compat.bypass_local_auth {
2134 return Err(SettingsError::Invalid(
2135 "qbt_compat.max_failed_auth_count must be > 0 when bypass_local_auth is false"
2136 .into(),
2137 ));
2138 }
2139 if !(60..=86_400).contains(&self.qbt_compat.ban_duration_secs) {
2140 return Err(SettingsError::Invalid(
2141 "qbt_compat.ban_duration_secs must be in [60, 86400]".into(),
2142 ));
2143 }
2144 for cidr in &self.qbt_compat.bypass_auth_subnet_whitelist {
2145 if cidr.parse::<ipnet::IpNet>().is_err() {
2146 return Err(SettingsError::Invalid(format!(
2147 "qbt_compat.bypass_auth_subnet_whitelist: invalid CIDR `{cidr}`"
2148 )));
2149 }
2150 }
2151 if let Some(cap) = self.qbt_compat.brute_force_registry_capacity
2152 && cap < 100
2153 {
2154 return Err(SettingsError::Invalid(
2155 "qbt_compat.brute_force_registry_capacity must be at least 100".into(),
2156 ));
2157 }
2158 }
2159
2160 Ok(())
2161 }
2162}
2163
2164#[cfg(test)]
2175mod tests {
2176 use super::*;
2177
2178 #[test]
2179 fn default_settings_values() {
2180 let s = Settings::default();
2181 assert_eq!(s.listen_port, 42020);
2182 assert_eq!(s.download_dir, PathBuf::from("."));
2183 assert_eq!(s.max_torrents, 100);
2184 assert!(s.resume_data_dir.is_none());
2185 assert_eq!(s.save_resume_interval_secs, 300);
2186 assert!(s.enable_dht);
2187 assert!(s.enable_pex);
2188 assert!(s.enable_lsd);
2189 assert!(s.enable_fast_extension);
2190 assert!(s.enable_utp);
2191 assert!(s.enable_upnp);
2192 assert!(s.enable_natpmp);
2193 assert!(s.enable_ipv6);
2194 assert!(s.enable_web_seed);
2195 assert_eq!(s.encryption_mode, EncryptionMode::Disabled);
2196 assert!(!s.anonymous_mode);
2197 assert!(s.seed_ratio_limit.is_none());
2198 assert!(!s.default_super_seeding);
2199 assert!(!s.default_share_mode);
2200 assert!(s.upload_only_announce);
2201 assert_eq!(s.upload_rate_limit, 0);
2202 assert_eq!(s.download_rate_limit, 0);
2203 assert!(s.auto_upload_slots);
2204 assert_eq!(s.active_downloads, 3);
2205 assert_eq!(s.active_seeds, 5);
2206 assert_eq!(s.active_limit, 500);
2207 assert_eq!(s.active_checking, 3);
2208 assert!(s.dont_count_slow_torrents);
2209 assert_eq!(s.alert_mask, AlertCategory::ALL);
2210 assert_eq!(s.alert_channel_size, 1024);
2211 assert_eq!(s.smart_ban_max_failures, 3);
2212 assert!(s.smart_ban_parole);
2213 assert_eq!(s.disk_io_threads, default_disk_io_threads());
2214 assert_eq!(s.max_blocking_threads, default_max_blocking_threads());
2215 assert_eq!(s.preallocate_mode, PreallocateMode::None);
2216 assert_eq!(s.disk_cache_size, 16 * 1024 * 1024);
2217 assert!((s.disk_write_cache_ratio - 0.5).abs() < f32::EPSILON);
2218 assert_eq!(s.disk_channel_capacity, 512);
2219 assert_eq!(s.hashing_threads, default_hashing_threads());
2220 assert_eq!(s.max_request_queue_depth, 250);
2221 assert_eq!(s.initial_queue_depth, 128);
2222 assert!((s.request_queue_time - 3.0).abs() < f64::EPSILON);
2223 assert_eq!(s.block_request_timeout_secs, 60);
2224 assert_eq!(s.max_concurrent_stream_reads, 8);
2225 assert!(!s.force_proxy);
2226 assert!(s.apply_ip_filter_to_trackers);
2227 assert_eq!(s.dht_queries_per_second, 50);
2228 assert_eq!(s.dht_query_timeout_secs, 5);
2229 assert!(!s.dht_enforce_node_id);
2230 assert!(s.dht_restrict_routing_ips);
2231 assert_eq!(s.upnp_lease_duration, 3600);
2232 assert_eq!(s.natpmp_lifetime, 7200);
2233 assert_eq!(s.utp_max_connections, 256);
2234 assert_eq!(s.mixed_mode_algorithm, MixedModeAlgorithm::PeerProportional);
2235 assert!(s.auto_sequential);
2236 assert!(s.strict_end_game);
2237 assert_eq!(s.max_web_seeds, 4);
2238 assert_eq!(s.initial_picker_threshold, 4);
2239 assert_eq!(s.whole_pieces_threshold, 20);
2240 assert_eq!(s.snub_timeout_secs, 15);
2241 assert_eq!(s.readahead_pieces, 8);
2242 assert!(s.streaming_timeout_escalation);
2243 assert_eq!(s.max_peers_per_torrent, 128);
2244 assert_eq!(s.runtime_worker_threads, default_runtime_worker_threads());
2245 assert!(s.pin_cores);
2246 }
2247
2248 #[test]
2249 fn min_memory_preset() {
2250 let s = Settings::min_memory();
2251 assert_eq!(s.disk_cache_size, 8 * 1024 * 1024);
2252 assert_eq!(s.max_torrents, 20);
2253 assert_eq!(s.max_peers_per_torrent, 30);
2254 assert_eq!(s.active_downloads, 1);
2255 assert_eq!(s.active_seeds, 2);
2256 assert_eq!(s.active_limit, 10);
2257 assert_eq!(s.alert_channel_size, 256);
2258 assert_eq!(s.utp_max_connections, 64);
2259 assert_eq!(s.max_request_queue_depth, 50);
2260 assert_eq!(s.initial_queue_depth, 16);
2261 assert_eq!(s.max_concurrent_stream_reads, 2);
2262 assert_eq!(s.hashing_threads, 1);
2263 assert_eq!(s.disk_io_threads, 1);
2264 }
2265
2266 #[test]
2267 fn high_performance_preset() {
2268 let s = Settings::high_performance();
2269 assert_eq!(s.disk_cache_size, 256 * 1024 * 1024);
2270 assert_eq!(s.max_torrents, 2000);
2271 assert_eq!(s.max_peers_per_torrent, 200);
2272 assert_eq!(s.active_downloads, 30);
2273 assert_eq!(s.active_seeds, 100);
2274 assert_eq!(s.active_limit, 2000);
2275 assert_eq!(s.alert_channel_size, 4096);
2276 assert_eq!(s.utp_max_connections, 1024);
2277 assert_eq!(s.max_request_queue_depth, 1000);
2278 assert_eq!(s.initial_queue_depth, 256);
2279 assert_eq!(s.max_concurrent_stream_reads, 32);
2280 assert_eq!(s.hashing_threads, 4);
2281 assert_eq!(s.disk_io_threads, 8);
2282 assert_eq!(s.auto_upload_slots_max, 100);
2283 }
2284
2285 #[test]
2286 fn json_round_trip() {
2287 let original = Settings::default();
2288 let json = serde_json::to_string(&original).unwrap();
2289 let decoded: Settings = serde_json::from_str(&json).unwrap();
2290 assert_eq!(original, decoded);
2291 }
2292
2293 #[test]
2294 fn json_round_trip_presets() {
2295 for original in [Settings::min_memory(), Settings::high_performance()] {
2297 let json = serde_json::to_string(&original).unwrap();
2298 let decoded: Settings = serde_json::from_str(&json).unwrap();
2299 assert_eq!(original, decoded);
2300 }
2301 }
2302
2303 #[test]
2304 fn json_missing_fields_use_defaults() {
2305 let decoded: Settings = serde_json::from_str("{}").unwrap();
2307 assert_eq!(decoded, Settings::default());
2308 }
2309
2310 #[test]
2317 fn settings_tolerates_removed_storage_mode_key() {
2318 let decoded: Settings =
2319 serde_json::from_str(r#"{"storage_mode": "Full", "io_uring_sq_depth": 512}"#)
2320 .expect("unknown keys ignored");
2321 assert_eq!(decoded.preallocate_mode, PreallocateMode::None);
2322 }
2323
2324 #[test]
2326 fn seed_time_limits_default_none() {
2327 let s = Settings::default();
2328 assert!(s.seed_time_limit_secs.is_none());
2329 assert!(s.inactive_seed_time_limit_secs.is_none());
2330 }
2331
2332 #[test]
2333 fn seed_time_limits_round_trip_json() {
2334 let s = Settings {
2335 seed_time_limit_secs: Some(3600),
2336 inactive_seed_time_limit_secs: Some(1800),
2337 ..Settings::default()
2338 };
2339 let json = serde_json::to_string(&s).unwrap();
2340 let decoded: Settings = serde_json::from_str(&json).unwrap();
2341 assert_eq!(decoded.seed_time_limit_secs, Some(3600));
2342 assert_eq!(decoded.inactive_seed_time_limit_secs, Some(1800));
2343 }
2344
2345 #[test]
2346 fn seed_time_limits_skipped_when_none() {
2347 let s = Settings::default();
2349 let json = serde_json::to_string(&s).unwrap();
2350 assert!(
2351 !json.contains("seed_time_limit_secs"),
2352 "None should not be serialised: {json}"
2353 );
2354 assert!(
2355 !json.contains("inactive_seed_time_limit_secs"),
2356 "None should not be serialised: {json}"
2357 );
2358 }
2359
2360 #[test]
2362 fn m171_settings_defaults_pause_true_false_false() {
2363 let s = Settings::default();
2364 assert_eq!(s.max_ratio_action, MaxRatioAction::Pause);
2365 assert!(
2366 s.create_subfolder,
2367 "create_subfolder defaults true (qBt factory default)"
2368 );
2369 assert!(!s.auto_manage_torrents);
2370 assert!(!s.queueing_enabled);
2371 }
2372
2373 #[test]
2374 fn m171_settings_round_trip_preserves_all_four() {
2375 let s = Settings {
2376 max_ratio_action: MaxRatioAction::EnableSuperSeeding,
2377 create_subfolder: false,
2378 auto_manage_torrents: true,
2379 queueing_enabled: true,
2380 ..Settings::default()
2381 };
2382 let json = serde_json::to_string(&s).unwrap();
2383 let decoded: Settings = serde_json::from_str(&json).unwrap();
2384 assert_eq!(decoded, s);
2385 }
2386
2387 #[test]
2388 fn max_ratio_action_wire_snake_case() {
2389 let pause = serde_json::to_string(&MaxRatioAction::Pause).unwrap();
2391 let remove = serde_json::to_string(&MaxRatioAction::Remove).unwrap();
2392 let super_seed = serde_json::to_string(&MaxRatioAction::EnableSuperSeeding).unwrap();
2393 assert_eq!(pause, "\"pause\"");
2394 assert_eq!(remove, "\"remove\"");
2395 assert_eq!(super_seed, "\"enable_super_seeding\"");
2396 }
2397
2398 #[test]
2399 fn max_ratio_action_wire_snake_case_round_trip() {
2400 let pause: MaxRatioAction = serde_json::from_str("\"pause\"").unwrap();
2402 let remove: MaxRatioAction = serde_json::from_str("\"remove\"").unwrap();
2403 let super_seed: MaxRatioAction = serde_json::from_str("\"enable_super_seeding\"").unwrap();
2404 assert_eq!(pause, MaxRatioAction::Pause);
2405 assert_eq!(remove, MaxRatioAction::Remove);
2406 assert_eq!(super_seed, MaxRatioAction::EnableSuperSeeding);
2407 }
2408
2409 #[test]
2410 fn validation_force_proxy_no_proxy() {
2411 let s = Settings {
2412 force_proxy: true,
2413 ..Settings::default()
2414 };
2415 let err = s.validate().unwrap_err();
2417 assert!(err.to_string().contains("force_proxy"));
2418 }
2419
2420 #[test]
2421 fn validation_valid_defaults() {
2422 Settings::default().validate().unwrap();
2423 Settings::min_memory().validate().unwrap();
2424 Settings::high_performance().validate().unwrap();
2425 }
2426
2427 #[test]
2428 fn external_ip_default_and_json() {
2429 let s = Settings::default();
2430 assert!(s.external_ip.is_none());
2431
2432 let json = r#"{"external_ip": "203.0.113.5"}"#;
2434 let decoded: Settings = serde_json::from_str(json).unwrap();
2435 assert_eq!(
2436 decoded.external_ip,
2437 Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(
2438 203, 0, 113, 5
2439 )))
2440 );
2441
2442 let encoded = serde_json::to_string(&decoded).unwrap();
2444 let roundtrip: Settings = serde_json::from_str(&encoded).unwrap();
2445 assert_eq!(roundtrip.external_ip, decoded.external_ip);
2446 }
2447
2448 #[test]
2449 fn validation_zero_threads() {
2450 let s = Settings {
2451 hashing_threads: 0,
2452 ..Settings::default()
2453 };
2454 let err = s.validate().unwrap_err();
2455 assert!(err.to_string().contains("hashing_threads"));
2456
2457 let s = Settings {
2458 disk_io_threads: 0,
2459 ..Settings::default()
2460 };
2461 let err = s.validate().unwrap_err();
2462 assert!(err.to_string().contains("disk_io_threads"));
2463
2464 let s = Settings {
2465 max_blocking_threads: 0,
2466 ..Settings::default()
2467 };
2468 let err = s.validate().unwrap_err();
2469 assert!(err.to_string().contains("max_blocking_threads"));
2470 }
2471
2472 #[test]
2473 fn m241_validate_rejects_zero_max_torrents() {
2474 let s = Settings {
2475 max_torrents: 0,
2476 ..Settings::default()
2477 };
2478 let err = s.validate().unwrap_err();
2479 assert!(err.to_string().contains("max_torrents"));
2480 }
2481
2482 #[test]
2483 fn m241_validate_rejects_max_torrents_over_ceiling() {
2484 let s = Settings {
2485 max_torrents: 100_001,
2486 ..Settings::default()
2487 };
2488 let err = s.validate().unwrap_err();
2489 assert!(err.to_string().contains("max_torrents"));
2490 }
2491
2492 #[test]
2493 fn m241_validate_accepts_max_torrents_at_ceiling() {
2494 let s = Settings {
2495 max_torrents: 100_000,
2496 ..Settings::default()
2497 };
2498 s.validate().unwrap();
2499 }
2500
2501 #[test]
2502 fn m241_validate_accepts_reasonable_max_torrents() {
2503 let s = Settings {
2504 max_torrents: 500,
2505 ..Settings::default()
2506 };
2507 s.validate().unwrap();
2508 }
2509
2510 #[test]
2511 fn share_mode_requires_fast_extension() {
2512 let mut s = Settings {
2513 default_share_mode: true,
2514 enable_fast_extension: false,
2515 ..Settings::default()
2516 };
2517 let err = s.validate().unwrap_err();
2518 assert!(err.to_string().contains("share_mode"));
2519
2520 s.enable_fast_extension = true;
2522 s.validate().unwrap();
2523 }
2524
2525 #[test]
2526 fn dht_storage_settings_defaults() {
2527 let s = Settings::default();
2528 assert_eq!(s.dht_max_items, 700);
2529 assert_eq!(s.dht_item_lifetime_secs, 7200);
2530 }
2531
2532 #[test]
2533 fn dht_sample_interval_default_disabled() {
2534 let s = Settings::default();
2535 assert_eq!(s.dht_sample_infohashes_interval, 0);
2536 }
2537
2538 #[test]
2539 fn dht_sample_interval_json_round_trip() {
2540 let json = r#"{"dht_sample_infohashes_interval": 300}"#;
2541 let decoded: Settings = serde_json::from_str(json).unwrap();
2542 assert_eq!(decoded.dht_sample_infohashes_interval, 300);
2543
2544 let encoded = serde_json::to_string(&decoded).unwrap();
2545 let roundtrip: Settings = serde_json::from_str(&encoded).unwrap();
2546 assert_eq!(roundtrip.dht_sample_infohashes_interval, 300);
2547 }
2548
2549 #[test]
2550 fn min_memory_restricts_dht_items() {
2551 let s = Settings::min_memory();
2552 assert_eq!(s.dht_max_items, 100);
2553 }
2554
2555 #[test]
2556 fn enable_holepunch_default_true() {
2557 let s = Settings::default();
2558 assert!(s.enable_holepunch);
2559 }
2560
2561 #[test]
2562 fn enable_holepunch_json_round_trip() {
2563 let json = r#"{"enable_holepunch": false}"#;
2564 let decoded: Settings = serde_json::from_str(json).unwrap();
2565 assert!(!decoded.enable_holepunch);
2566
2567 let encoded = serde_json::to_string(&decoded).unwrap();
2568 let roundtrip: Settings = serde_json::from_str(&encoded).unwrap();
2569 assert!(!roundtrip.enable_holepunch);
2570 }
2571
2572 #[test]
2573 fn i2p_settings_defaults() {
2574 let s = Settings::default();
2575 assert!(!s.enable_i2p);
2576 assert_eq!(s.i2p_hostname, "127.0.0.1");
2577 assert_eq!(s.i2p_port, 7656);
2578 assert_eq!(s.i2p_inbound_quantity, 3);
2579 assert_eq!(s.i2p_outbound_quantity, 3);
2580 assert_eq!(s.i2p_inbound_length, 3);
2581 assert_eq!(s.i2p_outbound_length, 3);
2582 assert!(!s.allow_i2p_mixed);
2583 }
2584
2585 #[test]
2586 fn i2p_settings_json_roundtrip() {
2587 let s = Settings {
2588 enable_i2p: true,
2589 i2p_hostname: "10.0.0.1".into(),
2590 i2p_port: 7700,
2591 i2p_inbound_quantity: 5,
2592 i2p_outbound_quantity: 4,
2593 i2p_inbound_length: 2,
2594 i2p_outbound_length: 1,
2595 allow_i2p_mixed: true,
2596 ..Settings::default()
2597 };
2598 let json = serde_json::to_string(&s).unwrap();
2599 let decoded: Settings = serde_json::from_str(&json).unwrap();
2600 assert_eq!(s, decoded);
2601 }
2602
2603 #[test]
2604 fn i2p_validation_quantity_zero() {
2605 let s = Settings {
2606 enable_i2p: true,
2607 i2p_inbound_quantity: 0,
2608 ..Settings::default()
2609 };
2610 let err = s.validate().unwrap_err();
2611 assert!(err.to_string().contains("i2p_inbound_quantity"));
2612 }
2613
2614 #[test]
2615 fn i2p_validation_quantity_too_high() {
2616 let s = Settings {
2617 enable_i2p: true,
2618 i2p_outbound_quantity: 17,
2619 ..Settings::default()
2620 };
2621 let err = s.validate().unwrap_err();
2622 assert!(err.to_string().contains("i2p_outbound_quantity"));
2623 }
2624
2625 #[test]
2626 fn i2p_validation_length_too_high() {
2627 let s = Settings {
2628 enable_i2p: true,
2629 i2p_inbound_length: 8,
2630 ..Settings::default()
2631 };
2632 let err = s.validate().unwrap_err();
2633 assert!(err.to_string().contains("i2p_inbound_length"));
2634 }
2635
2636 #[test]
2637 fn i2p_validation_passes_when_disabled() {
2638 let mut s = Settings {
2640 enable_i2p: false,
2641 ..Settings::default()
2642 };
2643 s.i2p_inbound_quantity = 0; s.validate().unwrap(); }
2646
2647 #[test]
2648 fn i2p_validation_valid_config() {
2649 let s = Settings {
2650 enable_i2p: true,
2651 i2p_inbound_quantity: 1,
2652 i2p_outbound_quantity: 16,
2653 i2p_inbound_length: 0,
2654 i2p_outbound_length: 7,
2655 ..Settings::default()
2656 };
2657 s.validate().unwrap();
2658 }
2659
2660 #[test]
2661 fn ssl_settings_defaults() {
2662 let s = Settings::default();
2663 assert_eq!(s.ssl_listen_port, 0);
2664 assert!(s.ssl_cert_path.is_none());
2665 assert!(s.ssl_key_path.is_none());
2666 }
2667
2668 #[test]
2669 fn ssl_settings_json_round_trip() {
2670 let s = Settings {
2671 ssl_listen_port: 4433,
2672 ssl_cert_path: Some(PathBuf::from("/etc/ssl/cert.pem")),
2673 ssl_key_path: Some(PathBuf::from("/etc/ssl/key.pem")),
2674 ..Settings::default()
2675 };
2676 let json = serde_json::to_string(&s).unwrap();
2677 let decoded: Settings = serde_json::from_str(&json).unwrap();
2678 assert_eq!(s, decoded);
2679 }
2680
2681 #[test]
2682 fn ssl_validation_cert_without_key() {
2683 let s = Settings {
2684 ssl_cert_path: Some(PathBuf::from("/tmp/cert.pem")),
2685 ..Settings::default()
2686 };
2687 let err = s.validate().unwrap_err();
2689 assert!(err.to_string().contains("ssl_cert_path"));
2690 }
2691
2692 #[test]
2693 fn ssl_validation_key_without_cert() {
2694 let s = Settings {
2695 ssl_key_path: Some(PathBuf::from("/tmp/key.pem")),
2696 ..Settings::default()
2697 };
2698 let err = s.validate().unwrap_err();
2700 assert!(err.to_string().contains("ssl_cert_path"));
2701 }
2702
2703 #[test]
2704 fn ssl_validation_both_set_passes() {
2705 let s = Settings {
2706 ssl_cert_path: Some(PathBuf::from("/tmp/cert.pem")),
2707 ssl_key_path: Some(PathBuf::from("/tmp/key.pem")),
2708 ..Settings::default()
2709 };
2710 s.validate().unwrap();
2711 }
2712
2713 #[test]
2714 fn ssl_validation_both_absent_passes() {
2715 let s = Settings::default();
2716 s.validate().unwrap();
2718 }
2719
2720 #[test]
2721 fn default_choking_algorithms() {
2722 let s = Settings::default();
2723 assert_eq!(
2724 s.seed_choking_algorithm,
2725 SeedChokingAlgorithm::FastestUpload
2726 );
2727 assert_eq!(s.choking_algorithm, ChokingAlgorithm::FixedSlots);
2728 }
2729
2730 #[test]
2731 fn choking_algorithm_json_round_trip() {
2732 let s = Settings {
2733 seed_choking_algorithm: SeedChokingAlgorithm::AntiLeech,
2734 choking_algorithm: ChokingAlgorithm::RateBased,
2735 ..Settings::default()
2736 };
2737 let json = serde_json::to_string(&s).unwrap();
2738 let decoded: Settings = serde_json::from_str(&json).unwrap();
2739 assert_eq!(
2740 decoded.seed_choking_algorithm,
2741 SeedChokingAlgorithm::AntiLeech
2742 );
2743 assert_eq!(decoded.choking_algorithm, ChokingAlgorithm::RateBased);
2744 }
2745
2746 #[test]
2747 fn m44_settings_defaults() {
2748 let s = Settings::default();
2749 assert!(s.piece_extent_affinity);
2750 assert!(s.suggest_mode);
2751 assert_eq!(s.max_suggest_pieces, 16);
2752 assert_eq!(s.predictive_piece_announce_ms, 0);
2753 }
2754
2755 #[test]
2756 fn m44_high_performance_enables_suggest() {
2757 let s = Settings::high_performance();
2758 assert!(s.suggest_mode);
2759 }
2760
2761 #[test]
2762 fn m44_json_round_trip() {
2763 let s = Settings {
2764 piece_extent_affinity: false,
2765 suggest_mode: true,
2766 max_suggest_pieces: 5,
2767 predictive_piece_announce_ms: 50,
2768 ..Settings::default()
2769 };
2770 let json = serde_json::to_string(&s).unwrap();
2771 let decoded: Settings = serde_json::from_str(&json).unwrap();
2772 assert_eq!(s, decoded);
2773 }
2774
2775 #[test]
2776 fn security_settings_defaults() {
2777 let s = Settings::default();
2778 assert!(s.ssrf_mitigation);
2779 assert!(!s.allow_idna);
2780 assert!(s.validate_https_trackers);
2781 }
2782
2783 #[test]
2784 fn security_settings_json_round_trip() {
2785 let s = Settings {
2786 ssrf_mitigation: false,
2787 allow_idna: true,
2788 validate_https_trackers: false,
2789 ..Settings::default()
2790 };
2791 let json = serde_json::to_string(&s).unwrap();
2792 let decoded: Settings = serde_json::from_str(&json).unwrap();
2793 assert_eq!(s, decoded);
2794 }
2795
2796 #[test]
2797 fn security_settings_missing_use_defaults() {
2798 let decoded: Settings = serde_json::from_str("{}").unwrap();
2800 assert!(decoded.ssrf_mitigation);
2801 assert!(!decoded.allow_idna);
2802 assert!(decoded.validate_https_trackers);
2803 }
2804
2805 #[test]
2806 fn default_peer_dscp_value() {
2807 let s = Settings::default();
2808 assert_eq!(s.peer_dscp, 0x08);
2809 }
2810
2811 #[test]
2812 fn peer_dscp_json_round_trip() {
2813 let s = Settings {
2814 peer_dscp: 0x2E, ..Settings::default()
2816 };
2817 let json = serde_json::to_string(&s).unwrap();
2818 let decoded: Settings = serde_json::from_str(&json).unwrap();
2819 assert_eq!(decoded.peer_dscp, 0x2E);
2820 }
2821
2822 #[test]
2823 fn peer_dscp_zero_disables() {
2824 let s = Settings {
2825 peer_dscp: 0,
2826 ..Settings::default()
2827 };
2828 let json = serde_json::to_string(&s).unwrap();
2829 let decoded: Settings = serde_json::from_str(&json).unwrap();
2830 assert_eq!(decoded.peer_dscp, 0);
2831 }
2832
2833 #[test]
2834 fn default_stats_report_interval() {
2835 let s = Settings::default();
2836 assert_eq!(s.stats_report_interval, 1000);
2837 }
2838
2839 #[test]
2840 fn stats_report_interval_json_round_trip() {
2841 let s = Settings {
2842 stats_report_interval: 5000,
2843 ..Settings::default()
2844 };
2845 let json = serde_json::to_string(&s).unwrap();
2846 let decoded: Settings = serde_json::from_str(&json).unwrap();
2847 assert_eq!(decoded.stats_report_interval, 5000);
2848 }
2849
2850 #[test]
2851 fn stats_report_interval_zero_disables() {
2852 let s = Settings {
2853 stats_report_interval: 0,
2854 ..Settings::default()
2855 };
2856 let json = serde_json::to_string(&s).unwrap();
2857 let decoded: Settings = serde_json::from_str(&json).unwrap();
2858 assert_eq!(decoded.stats_report_interval, 0);
2859 }
2860
2861 #[test]
2862 fn settings_runtime_worker_threads_and_pin_cores() {
2863 let s = Settings::default();
2865 assert_eq!(s.runtime_worker_threads, default_runtime_worker_threads());
2866 assert!(s.pin_cores);
2867
2868 let mut s = Settings {
2870 runtime_worker_threads: 0,
2871 ..Settings::default()
2872 };
2873 assert!(s.validate().is_ok());
2874
2875 s.runtime_worker_threads = 256;
2877 assert!(s.validate().is_ok());
2878
2879 s.runtime_worker_threads = 257;
2881 assert!(s.validate().is_err());
2882 }
2883
2884 #[test]
2885 fn max_in_flight_512_default() {
2886 let s = Settings::default();
2887 assert_eq!(s.max_in_flight_pieces, 512);
2888 assert_eq!(s.fixed_pipeline_depth, 128);
2889
2890 let mm = Settings::min_memory();
2892 assert_eq!(mm.max_in_flight_pieces, 32);
2893 assert_eq!(mm.fixed_pipeline_depth, 32);
2894
2895 let hp = Settings::high_performance();
2896 assert_eq!(hp.max_in_flight_pieces, 512);
2897 assert_eq!(hp.fixed_pipeline_depth, 128); }
2899
2900 #[test]
2901 fn recalc_max_in_flight_formula() {
2902 let base = 512_usize;
2905
2906 let connected = 10;
2908 let num_pieces = 2000_u32;
2909 let calculated = base.max(connected * 4);
2910 let result = calculated.min(num_pieces as usize / 2).max(base);
2911 assert_eq!(result, 512); let connected = 200;
2915 let calculated = base.max(connected * 4);
2916 let result = calculated.min(num_pieces as usize / 2).max(base);
2917 assert_eq!(result, 800); let connected = 200;
2921 let num_pieces = 100_u32;
2922 let calculated = base.max(connected * 4);
2923 let result = calculated.min(num_pieces as usize / 2).max(base);
2924 assert_eq!(result, 512); let connected = 129; let num_pieces = 10000_u32;
2929 let calculated = base.max(connected * 4);
2930 let result = calculated.min(num_pieces as usize / 2).max(base);
2931 assert_eq!(result, 516); }
2933
2934 #[test]
2937 fn settings_default_enables_qbt_compat_v0_172_1() {
2938 let s = Settings::default();
2943 assert!(s.qbt_compat.enabled);
2944 assert_eq!(s.qbt_compat.username, "admin");
2945 assert_eq!(s.qbt_compat.password, "");
2949 assert!(
2950 s.qbt_compat
2951 .password_hash
2952 .starts_with("$argon2id$v=19$m=19456,t=2,p=1$")
2953 );
2954 assert_eq!(s.qbt_compat.spoof_app_version, "v5.1.4");
2955 assert_eq!(s.qbt_compat.spoof_webapi_version, "2.11.4");
2956 assert_eq!(s.qbt_compat.session_ttl_secs, 86_400);
2957 assert_eq!(s.qbt_compat.max_sessions, 1024);
2958 assert!(s.qbt_compat.max_concurrent_argon2_ops.is_none());
2959 }
2960
2961 #[test]
2962 fn validate_rejects_empty_username() {
2963 let mut s = Settings::default();
2964 s.qbt_compat.enabled = true;
2965 s.qbt_compat.username = String::new();
2966 let err = s.validate().expect_err("empty username must fail");
2967 let msg = format!("{err}");
2968 assert!(msg.contains("username"), "error was: {msg}");
2969 }
2970
2971 #[test]
2972 fn validate_rejects_short_legacy_password_lt_8_when_hash_empty() {
2973 let mut s = Settings::default();
2974 s.qbt_compat.enabled = true;
2975 s.qbt_compat.password_hash.clear();
2977 s.qbt_compat.password = "short".into();
2978 let err = s.validate().expect_err("short password must fail");
2979 let msg = format!("{err}");
2980 assert!(
2981 msg.contains("password") && msg.contains("hash"),
2982 "error was: {msg}"
2983 );
2984 }
2985
2986 #[test]
2987 fn validate_rejects_bad_app_version_format() {
2988 let mut s = Settings::default();
2989 s.qbt_compat.enabled = true;
2990 s.qbt_compat.spoof_app_version = "garbage".into();
2991 let err = s.validate().expect_err("bad app version must fail");
2992 let msg = format!("{err}");
2993 assert!(msg.contains("spoof_app_version"), "error was: {msg}");
2994 }
2995
2996 #[test]
2997 fn validate_rejects_bad_webapi_version_format() {
2998 let mut s = Settings::default();
2999 s.qbt_compat.enabled = true;
3000 s.qbt_compat.spoof_webapi_version = "v2.11".into(); let err = s.validate().expect_err("bad webapi version must fail");
3002 let msg = format!("{err}");
3003 assert!(msg.contains("spoof_webapi_version"), "error was: {msg}");
3004 }
3005
3006 #[test]
3007 fn validate_rejects_ttl_out_of_bounds() {
3008 let mut s = Settings::default();
3009 s.qbt_compat.enabled = true;
3010 s.qbt_compat.session_ttl_secs = 10; let err = s.validate().expect_err("ttl too small must fail");
3012 assert!(format!("{err}").contains("session_ttl_secs"));
3013
3014 let mut s = Settings::default();
3015 s.qbt_compat.enabled = true;
3016 s.qbt_compat.session_ttl_secs = 604_801; let err = s.validate().expect_err("ttl too large must fail");
3018 assert!(format!("{err}").contains("session_ttl_secs"));
3019 }
3020
3021 #[test]
3024 fn default_hash_roundtrips_admin_admin() {
3025 use argon2::Argon2;
3026 use argon2::password_hash::{PasswordHash, PasswordVerifier};
3027
3028 let hash = PasswordHash::new(DEFAULT_ADMINADMIN_HASH)
3038 .expect("DEFAULT_ADMINADMIN_HASH must be a valid PHC string");
3039 Argon2::default()
3040 .verify_password(b"adminadmin", &hash)
3041 .expect("default hash must verify the 'adminadmin' plaintext");
3042 }
3043
3044 #[test]
3045 fn validate_rejects_password_hash_not_starting_with_argon2id() {
3046 let mut s = Settings::default();
3047 s.qbt_compat.enabled = true;
3048 s.qbt_compat.password_hash =
3050 "$2b$12$KIXQ5.pHJN3iLz9H6CfQEe2/6rFv1h4jdXWv.0eoGzJ6w7L4Yj7vi".into();
3051 let err = s.validate().expect_err("non-argon2id hash must fail");
3052 let msg = format!("{err}");
3053 assert!(msg.contains("argon2id"), "error was: {msg}");
3054 }
3055
3056 #[test]
3057 fn validate_rejects_zero_max_concurrent_argon2_ops() {
3058 let mut s = Settings::default();
3059 s.qbt_compat.enabled = true;
3060 s.qbt_compat.max_concurrent_argon2_ops = Some(0);
3061 let err = s.validate().expect_err("zero argon2 semaphore must fail");
3062 assert!(format!("{err}").contains("max_concurrent_argon2_ops"));
3063 }
3064
3065 #[test]
3066 fn default_settings_ship_pre_hashed_no_migration_needed() {
3067 let s = Settings::default();
3068 assert!(s.qbt_compat.password_hash.starts_with("$argon2id$"));
3069 assert!(s.qbt_compat.password.is_empty());
3070 }
3071
3072 #[test]
3073 fn hash_qbt_password_roundtrips() {
3074 let h = hash_qbt_password("correct horse battery staple")
3075 .expect("hash must succeed for a simple plaintext");
3076 assert!(h.starts_with("$argon2id$v=19$m=19456,t=2,p=1$"));
3077 let h2 =
3079 hash_qbt_password("correct horse battery staple").expect("second hash must succeed");
3080 assert_ne!(h, h2, "argon2 must use a fresh salt per call");
3081 }
3082
3083 #[test]
3084 fn migrate_qbt_credentials_noop_when_hash_present() {
3085 let mut qbt = QbtCompatSettings {
3086 password_hash: DEFAULT_ADMINADMIN_HASH.into(),
3087 password: String::new(),
3088 ..Default::default()
3089 };
3090 let outcome = migrate_qbt_credentials(&mut qbt).expect("noop");
3091 assert_eq!(outcome, QbtCredentialMigration::NoOp);
3092 assert_eq!(qbt.password_hash, DEFAULT_ADMINADMIN_HASH);
3093 assert!(qbt.password.is_empty());
3094 }
3095
3096 #[test]
3097 fn migrate_qbt_credentials_upgrades_legacy_plaintext() {
3098 use argon2::Argon2;
3099 use argon2::password_hash::{PasswordHash, PasswordVerifier};
3100
3101 let mut qbt = QbtCompatSettings {
3102 password_hash: String::new(),
3103 password: "legacy-plaintext-pw".into(),
3104 ..Default::default()
3105 };
3106 let outcome = migrate_qbt_credentials(&mut qbt).expect("upgrade");
3107 assert_eq!(outcome, QbtCredentialMigration::Upgraded);
3108 assert!(qbt.password_hash.starts_with("$argon2id$"));
3109 assert!(
3110 qbt.password.is_empty(),
3111 "plaintext must be zeroed after migration"
3112 );
3113
3114 let parsed =
3115 PasswordHash::new(&qbt.password_hash).expect("migration wrote a valid PHC string");
3116 Argon2::default()
3117 .verify_password(b"legacy-plaintext-pw", &parsed)
3118 .expect("migrated hash must verify the original plaintext");
3119 }
3120
3121 #[test]
3122 fn migrate_qbt_credentials_noop_when_both_empty() {
3123 let mut qbt = QbtCompatSettings {
3124 password_hash: String::new(),
3125 password: String::new(),
3126 ..Default::default()
3127 };
3128 let outcome = migrate_qbt_credentials(&mut qbt).expect("noop on empty");
3129 assert_eq!(outcome, QbtCredentialMigration::NoOp);
3130 }
3131
3132 #[test]
3135 fn brute_force_defaults_are_5_attempts_and_one_hour_ban() {
3136 let s = Settings::default();
3137 assert_eq!(s.qbt_compat.max_failed_auth_count, 5);
3138 assert_eq!(s.qbt_compat.ban_duration_secs, 3_600);
3139 assert!(!s.qbt_compat.bypass_local_auth);
3140 assert!(s.qbt_compat.bypass_auth_subnet_whitelist.is_empty());
3141 assert!(s.qbt_compat.brute_force_registry_capacity.is_none());
3142 }
3143
3144 #[test]
3145 fn validate_rejects_zero_max_failed_auth_count_without_bypass() {
3146 let mut s = Settings::default();
3147 s.qbt_compat.enabled = true;
3148 s.qbt_compat.max_failed_auth_count = 0;
3149 s.qbt_compat.bypass_local_auth = false;
3150 let err = s
3151 .validate()
3152 .expect_err("zero attempts without bypass must fail");
3153 assert!(format!("{err}").contains("max_failed_auth_count"));
3154 }
3155
3156 #[test]
3157 fn validate_accepts_zero_max_failed_auth_count_when_bypass_local() {
3158 let mut s = Settings::default();
3159 s.qbt_compat.enabled = true;
3160 s.qbt_compat.max_failed_auth_count = 0;
3161 s.qbt_compat.bypass_local_auth = true;
3162 s.validate().expect("bypass_local_auth disarms the check");
3163 }
3164
3165 #[test]
3166 fn validate_rejects_ban_duration_out_of_bounds() {
3167 let mut s = Settings::default();
3168 s.qbt_compat.enabled = true;
3169 s.qbt_compat.ban_duration_secs = 59;
3170 let err = s.validate().expect_err("too short ban must fail");
3171 assert!(format!("{err}").contains("ban_duration_secs"));
3172
3173 let mut s = Settings::default();
3174 s.qbt_compat.enabled = true;
3175 s.qbt_compat.ban_duration_secs = 86_401;
3176 let err = s.validate().expect_err("too long ban must fail");
3177 assert!(format!("{err}").contains("ban_duration_secs"));
3178 }
3179
3180 #[test]
3181 fn validate_rejects_malformed_bypass_whitelist_cidr() {
3182 let mut s = Settings::default();
3183 s.qbt_compat.enabled = true;
3184 s.qbt_compat.bypass_auth_subnet_whitelist = vec!["not-a-cidr".into()];
3185 let err = s.validate().expect_err("bad cidr must fail");
3186 let msg = format!("{err}");
3187 assert!(msg.contains("bypass_auth_subnet_whitelist"));
3188 assert!(msg.contains("not-a-cidr"));
3189 }
3190
3191 #[test]
3192 fn validate_accepts_valid_bypass_whitelist_cidrs() {
3193 let mut s = Settings::default();
3194 s.qbt_compat.enabled = true;
3195 s.qbt_compat.bypass_auth_subnet_whitelist = vec![
3196 "10.0.0.0/8".into(),
3197 "192.168.1.0/24".into(),
3198 "::1/128".into(),
3199 ];
3200 s.validate().expect("valid cidrs pass");
3201 }
3202
3203 #[test]
3204 fn validate_rejects_registry_capacity_below_floor() {
3205 let mut s = Settings::default();
3206 s.qbt_compat.enabled = true;
3207 s.qbt_compat.brute_force_registry_capacity = Some(99);
3208 let err = s
3209 .validate()
3210 .expect_err("capacity < 100 must fail sanity floor");
3211 assert!(format!("{err}").contains("brute_force_registry_capacity"));
3212 }
3213
3214 #[test]
3217 fn validate_rejects_zero_max_uploads_per_torrent() {
3218 let s = Settings {
3219 max_uploads_per_torrent: 0,
3220 ..Settings::default()
3221 };
3222 let err = s
3223 .validate()
3224 .expect_err("max_uploads_per_torrent = 0 must fail");
3225 let msg = format!("{err}");
3226 assert!(msg.contains("max_uploads_per_torrent"), "error was: {msg}");
3227 }
3228
3229 #[test]
3230 fn validate_rejects_negative_below_minus_one_max_uploads_per_torrent() {
3231 let s = Settings {
3232 max_uploads_per_torrent: -2,
3233 ..Settings::default()
3234 };
3235 let err = s
3236 .validate()
3237 .expect_err("max_uploads_per_torrent < -1 must fail");
3238 let msg = format!("{err}");
3239 assert!(msg.contains("max_uploads_per_torrent"), "error was: {msg}");
3240 }
3241
3242 #[test]
3243 fn validate_accepts_minus_one_max_uploads_per_torrent() {
3244 let s = Settings::default();
3245 assert_eq!(s.max_uploads_per_torrent, -1);
3246 s.validate().expect("default -1 must validate");
3247 }
3248
3249 #[test]
3250 fn validate_accepts_positive_max_uploads_per_torrent() {
3251 let s = Settings {
3252 max_uploads_per_torrent: 4,
3253 ..Settings::default()
3254 };
3255 s.validate().expect("n >= 1 must validate");
3256 }
3257
3258 #[test]
3259 fn m257c_validate_budget_floor_relations() {
3260 let s = Settings::default();
3262 assert_eq!(s.request_budget_per_torrent, 512);
3263 assert_eq!(s.request_budget_floor, 8);
3264 s.validate().expect("defaults must validate");
3265 let s = Settings {
3267 request_budget_floor: 0,
3268 ..Settings::default()
3269 };
3270 let msg = format!("{}", s.validate().expect_err("floor 0 must fail"));
3271 assert!(msg.contains("request_budget_floor"), "error was: {msg}");
3272 let s = Settings {
3274 request_budget_floor: 129,
3275 ..Settings::default()
3276 };
3277 let msg = format!("{}", s.validate().expect_err("floor 129 must fail"));
3278 assert!(msg.contains("request_budget_floor"), "error was: {msg}");
3279 let s = Settings {
3281 request_budget_per_torrent: 0,
3282 ..Settings::default()
3283 };
3284 s.validate().expect("budget 0 (disabled) must validate");
3285 let s = Settings {
3287 request_budget_per_torrent: 4,
3288 request_budget_floor: 8,
3289 ..Settings::default()
3290 };
3291 let msg = format!("{}", s.validate().expect_err("budget < floor must fail"));
3292 assert!(
3293 msg.contains("request_budget_per_torrent"),
3294 "error was: {msg}"
3295 );
3296 }
3297
3298 #[test]
3299 fn max_uploads_per_torrent_default_deserialize_without_field() {
3300 let s = Settings::default();
3304 let mut value = serde_json::to_value(&s).expect("serialise");
3305 let obj = value.as_object_mut().expect("Settings is a JSON object");
3306 assert!(
3307 obj.remove("max_uploads_per_torrent").is_some(),
3308 "field should have been present in serialised default"
3309 );
3310 let decoded: Settings = serde_json::from_value(value).expect("deserialise without field");
3311 assert_eq!(decoded.max_uploads_per_torrent, -1);
3312 decoded.validate().expect("default-via-serde must validate");
3313 }
3314
3315 #[test]
3316 fn brute_force_settings_json_round_trip() {
3317 let mut s = Settings::default();
3318 s.qbt_compat.max_failed_auth_count = 7;
3319 s.qbt_compat.ban_duration_secs = 1_800;
3320 s.qbt_compat.bypass_local_auth = true;
3321 s.qbt_compat.bypass_auth_subnet_whitelist = vec!["10.0.0.0/8".into()];
3322 s.qbt_compat.brute_force_registry_capacity = Some(5_000);
3323
3324 let json = serde_json::to_string(&s).expect("serialise");
3325 let decoded: Settings = serde_json::from_str(&json).expect("deserialise");
3326 assert_eq!(decoded.qbt_compat.max_failed_auth_count, 7);
3327 assert_eq!(decoded.qbt_compat.ban_duration_secs, 1_800);
3328 assert!(decoded.qbt_compat.bypass_local_auth);
3329 assert_eq!(
3330 decoded.qbt_compat.bypass_auth_subnet_whitelist,
3331 vec!["10.0.0.0/8".to_string()]
3332 );
3333 assert_eq!(
3334 decoded.qbt_compat.brute_force_registry_capacity,
3335 Some(5_000)
3336 );
3337 }
3338
3339 #[test]
3342 fn settings_default_notify_on_complete_is_false() {
3343 assert!(!Settings::default().notify_on_complete);
3344 }
3345
3346 #[test]
3347 fn settings_default_notify_on_error_is_false() {
3348 assert!(!Settings::default().notify_on_error);
3349 }
3350
3351 #[test]
3352 fn settings_default_on_complete_program_is_none() {
3353 assert!(Settings::default().on_complete_program.is_none());
3354 }
3355
3356 #[test]
3357 fn settings_default_use_incomplete_dir_is_false() {
3358 assert!(!Settings::default().use_incomplete_dir);
3359 }
3360
3361 #[test]
3362 fn settings_default_incomplete_dir_is_none() {
3363 assert!(Settings::default().incomplete_dir.is_none());
3364 }
3365
3366 #[test]
3367 fn settings_default_default_skip_hash_check_is_false() {
3368 assert!(!Settings::default().default_skip_hash_check);
3369 }
3370
3371 #[test]
3372 fn settings_default_incomplete_extension_enabled_is_true() {
3373 assert!(Settings::default().incomplete_extension_enabled);
3374 }
3375
3376 #[test]
3377 fn settings_default_watched_folder_is_none() {
3378 assert!(Settings::default().watched_folder.is_none());
3379 }
3380
3381 #[test]
3382 fn settings_default_delete_torrent_after_add_is_false() {
3383 assert!(!Settings::default().delete_torrent_after_add);
3384 }
3385
3386 #[test]
3387 fn settings_default_move_completed_enabled_is_false() {
3388 assert!(!Settings::default().move_completed_enabled);
3389 }
3390
3391 #[test]
3392 fn settings_default_move_completed_to_is_none() {
3393 assert!(Settings::default().move_completed_to.is_none());
3394 }
3395
3396 #[test]
3397 fn settings_default_web_ui_https_enabled_is_false() {
3398 assert!(!Settings::default().web_ui_https_enabled);
3399 }
3400
3401 #[test]
3402 fn settings_default_network_interface_is_none() {
3403 assert!(Settings::default().network_interface.is_none());
3404 }
3405
3406 #[test]
3407 fn settings_default_default_add_paused_is_false() {
3408 assert!(!Settings::default().default_add_paused);
3409 }
3410
3411 #[test]
3412 fn m255_settings_default_resolve_peer_countries_is_false() {
3413 assert!(!Settings::default().resolve_peer_countries);
3414 assert!(Settings::default().peer_country_db_path.is_none());
3415 }
3416
3417 #[test]
3418 fn m255_validate_rejects_resolve_countries_without_db_path() {
3419 let s = Settings {
3420 resolve_peer_countries: true,
3421 ..Settings::default()
3422 };
3423 assert!(s.validate().is_err());
3424 }
3425
3426 #[test]
3427 fn m255_validate_rejects_relative_peer_country_db_path() {
3428 let s = Settings {
3429 resolve_peer_countries: true,
3430 peer_country_db_path: Some(PathBuf::from("relative/geo.mmdb")),
3431 ..Settings::default()
3432 };
3433 assert!(s.validate().is_err());
3434 }
3435
3436 #[test]
3437 fn m255_validate_accepts_resolve_countries_with_absolute_path() {
3438 let s = Settings {
3439 resolve_peer_countries: true,
3440 peer_country_db_path: Some(PathBuf::from("/var/lib/irontide/geo.mmdb")),
3441 ..Settings::default()
3442 };
3443 assert!(s.validate().is_ok());
3444 }
3445
3446 #[test]
3449 fn validate_rejects_use_incomplete_dir_without_incomplete_dir() {
3450 let s = Settings {
3451 use_incomplete_dir: true,
3452 incomplete_dir: None,
3453 ..Settings::default()
3454 };
3455 let err = s.validate().expect_err("must require incomplete_dir");
3456 assert!(format!("{err}").contains("incomplete_dir"));
3457 }
3458
3459 #[test]
3460 fn validate_accepts_use_incomplete_dir_with_incomplete_dir() {
3461 let s = Settings {
3462 use_incomplete_dir: true,
3463 incomplete_dir: Some(PathBuf::from("/tmp/irontide-incomplete")),
3464 ..Settings::default()
3465 };
3466 s.validate().expect("paired fields valid");
3467 }
3468
3469 #[test]
3470 fn validate_rejects_move_completed_without_move_completed_to() {
3471 let s = Settings {
3472 move_completed_enabled: true,
3473 move_completed_to: None,
3474 ..Settings::default()
3475 };
3476 let err = s.validate().expect_err("must require move_completed_to");
3477 assert!(format!("{err}").contains("move_completed_to"));
3478 }
3479
3480 #[test]
3481 fn validate_rejects_relative_watched_folder() {
3482 let s = Settings {
3483 watched_folder: Some(PathBuf::from("relative/path")),
3484 ..Settings::default()
3485 };
3486 let err = s.validate().expect_err("relative path must fail");
3487 assert!(format!("{err}").contains("absolute"));
3488 }
3489
3490 #[test]
3491 fn validate_rejects_relative_incomplete_dir() {
3492 let s = Settings {
3493 incomplete_dir: Some(PathBuf::from("inc")),
3494 ..Settings::default()
3495 };
3496 let err = s.validate().expect_err("relative path must fail");
3497 assert!(format!("{err}").contains("absolute"));
3498 }
3499
3500 #[test]
3501 fn validate_rejects_relative_move_completed_to() {
3502 let s = Settings {
3503 move_completed_to: Some(PathBuf::from("done")),
3504 ..Settings::default()
3505 };
3506 let err = s.validate().expect_err("relative path must fail");
3507 assert!(format!("{err}").contains("absolute"));
3508 }
3509
3510 #[test]
3511 fn validate_rejects_system_path_as_watched_folder() {
3512 for sys in ["/", "/etc", "/usr", "/bin", "/sys", "/proc"] {
3513 let s = Settings {
3514 watched_folder: Some(PathBuf::from(sys)),
3515 ..Settings::default()
3516 };
3517 let err = s.validate().expect_err("system path must be rejected");
3518 assert!(
3519 format!("{err}").contains("system path"),
3520 "{sys}: error must mention 'system path', got: {err}"
3521 );
3522 }
3523 }
3524
3525 #[test]
3526 fn validate_accepts_safe_watched_folder() {
3527 let s = Settings {
3528 watched_folder: Some(PathBuf::from("/tmp/irontide-watched")),
3529 ..Settings::default()
3530 };
3531 s.validate().expect("safe path must validate");
3532 }
3533
3534 #[test]
3536 fn peer_writer_channel_cap_defaults_to_1024() {
3537 assert_eq!(Settings::default().peer_writer_channel_cap, 1024);
3538 }
3539
3540 #[test]
3541 fn peer_writer_channel_cap_zero_is_rejected() {
3542 let s = Settings {
3543 peer_writer_channel_cap: 0,
3544 ..Settings::default()
3545 };
3546 let err = s
3547 .validate()
3548 .expect_err("zero writer-channel cap must be rejected");
3549 assert!(format!("{err}").contains("must be > 0"));
3550 }
3551
3552 #[test]
3553 fn peer_writer_channel_cap_at_or_below_request_queue_depth_is_rejected() {
3554 let depth = Settings::default().max_request_queue_depth;
3555 let s = Settings {
3558 peer_writer_channel_cap: depth,
3559 ..Settings::default()
3560 };
3561 let err = s
3562 .validate()
3563 .expect_err("cap <= max_request_queue_depth must be rejected");
3564 assert!(format!("{err}").contains("max_request_queue_depth"));
3565 }
3566
3567 #[test]
3568 fn default_cap_exceeds_max_request_queue_depth() {
3569 let s = Settings::default();
3570 assert!(
3571 s.peer_writer_channel_cap > s.max_request_queue_depth,
3572 "default writer cap {} must exceed default request queue depth {}",
3573 s.peer_writer_channel_cap,
3574 s.max_request_queue_depth
3575 );
3576 }
3577}