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, StorageMode,
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_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 }
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 }
217fn default_peer_dscp() -> u8 {
218 0x08 }
220fn default_max_peers_per_torrent() -> usize {
221 128
222}
223fn default_pass0_grace_secs() -> u64 {
225 60 }
227fn default_proactive_evictions_per_minute_limit() -> u32 {
228 30 }
230fn default_eviction_ban_duration_secs() -> u64 {
231 600 }
234fn default_eviction_ban_set_cap() -> usize {
235 1024 }
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 }
273fn default_max_message_size() -> usize {
274 16 * 1024 * 1024 }
276fn default_max_piece_length() -> u64 {
277 32 * 1024 * 1024 }
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 }
321fn default_choke_rotation_max_evictions() -> u32 {
322 0 }
324fn default_max_concurrent_connects() -> u16 {
325 128 }
327fn default_connect_soft_timeout() -> u64 {
328 3 }
330fn default_dispatch_backlog_cap() -> usize {
331 8 }
333fn default_event_backlog_cap() -> usize {
334 32 }
336fn default_peer_writer_channel_cap() -> usize {
337 1024 }
339fn default_web_seed_progress_throttle_ms() -> u64 {
340 250 }
342fn default_save_resume_interval() -> u64 {
343 300 }
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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366pub struct Settings {
367 #[serde(default = "default_listen_port")]
370 pub listen_port: u16,
371 #[serde(default)]
373 pub randomize_port_on_startup: bool,
374 #[serde(default = "default_download_dir")]
376 pub download_dir: PathBuf,
377 #[serde(default = "default_max_torrents")]
379 pub max_torrents: usize,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub resume_data_dir: Option<PathBuf>,
383 #[serde(default = "default_save_resume_interval")]
386 pub save_resume_interval_secs: u64,
387
388 #[serde(default = "default_true")]
391 pub enable_dht: bool,
392 #[serde(default = "default_true")]
394 pub enable_pex: bool,
395 #[serde(default = "default_true")]
397 pub enable_lsd: bool,
398 #[serde(default = "default_true")]
401 pub enable_fast_extension: bool,
402 #[serde(default = "default_true")]
406 pub enable_utp: bool,
407 #[serde(default = "default_true")]
410 pub enable_upnp: bool,
411 #[serde(default = "default_true")]
414 pub enable_natpmp: bool,
415 #[serde(default = "default_true")]
419 pub enable_ipv6: bool,
420 #[serde(default = "default_true")]
424 pub enable_web_seed: bool,
425 #[serde(default = "default_true")]
429 pub enable_holepunch: bool,
430 #[serde(default = "default_true")]
434 pub enable_bep40_eviction: bool,
435 #[serde(default)]
439 pub enable_diagnostic_counters: bool,
440 #[serde(default = "default_encryption")]
442 pub encryption_mode: EncryptionMode,
443 #[serde(default)]
446 pub anonymous_mode: bool,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub external_ip: Option<IpAddr>,
451
452 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub seed_ratio_limit: Option<f64>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub seed_time_limit_secs: Option<u64>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub inactive_seed_time_limit_secs: Option<u64>,
467 #[serde(default)]
470 pub max_ratio_action: MaxRatioAction,
471 #[serde(default = "default_true")]
475 pub create_subfolder: bool,
476 #[serde(default)]
480 pub auto_manage_torrents: bool,
481 #[serde(default)]
485 pub queueing_enabled: bool,
486 #[serde(default)]
489 pub default_super_seeding: bool,
490 #[serde(default)]
493 pub default_share_mode: bool,
494 #[serde(default = "default_true")]
497 pub upload_only_announce: bool,
498 #[serde(default)]
501 pub upload_rate_limit: u64,
502 #[serde(default)]
504 pub download_rate_limit: u64,
505 #[serde(default)]
507 pub tcp_upload_rate_limit: u64,
508 #[serde(default)]
510 pub tcp_download_rate_limit: u64,
511 #[serde(default)]
513 pub utp_upload_rate_limit: u64,
514 #[serde(default)]
516 pub utp_download_rate_limit: u64,
517 #[serde(default = "default_true")]
519 pub auto_upload_slots: bool,
520 #[serde(default = "default_auto_upload_slots_min")]
522 pub auto_upload_slots_min: usize,
523 #[serde(default = "default_auto_upload_slots_max")]
525 pub auto_upload_slots_max: usize,
526 #[serde(default = "default_max_upload_slots_global")]
528 pub max_upload_slots_global: i32,
529 #[serde(default = "default_max_upload_slots_per_torrent")]
531 pub max_upload_slots_per_torrent: i32,
532 #[serde(default = "default_max_connections_global")]
534 pub max_connections_global: i32,
535 #[serde(default = "default_max_uploads_per_torrent")]
541 pub max_uploads_per_torrent: i32,
542 #[serde(default)]
544 pub alt_download_rate_limit: u64,
545 #[serde(default)]
547 pub alt_upload_rate_limit: u64,
548 #[serde(default)]
550 pub alt_speed_enabled: bool,
551 #[serde(default)]
553 pub alt_speed_schedule_enabled: bool,
554 #[serde(default)]
556 pub alt_speed_schedule_from: u16,
557 #[serde(default)]
559 pub alt_speed_schedule_to: u16,
560 #[serde(default)]
562 pub alt_speed_schedule_days: u8,
563 #[serde(default = "default_true")]
565 pub rate_limit_includes_overhead: bool,
566 #[serde(default = "default_true")]
568 pub rate_limit_utp: bool,
569 #[serde(default)]
571 pub rate_limit_lan: bool,
572 #[serde(default = "default_mixed_mode")]
574 pub mixed_mode_algorithm: MixedModeAlgorithm,
575
576 #[serde(default = "default_active_downloads")]
579 pub active_downloads: i32,
580 #[serde(default = "default_active_seeds")]
582 pub active_seeds: i32,
583 #[serde(default = "default_active_limit")]
585 pub active_limit: i32,
586 #[serde(default = "default_active_checking")]
588 pub active_checking: i32,
589 #[serde(default = "default_true")]
592 pub dont_count_slow_torrents: bool,
593 #[serde(default = "default_inactive_rate")]
596 pub inactive_down_rate: u64,
597 #[serde(default = "default_inactive_rate")]
600 pub inactive_up_rate: u64,
601 #[serde(default = "default_auto_manage_interval")]
603 pub auto_manage_interval: u64,
604 #[serde(default = "default_auto_manage_startup")]
607 pub auto_manage_startup: u64,
608 #[serde(default)]
610 pub auto_manage_prefer_seeds: bool,
611 #[serde(default = "default_queue_rate_ewma_alpha")]
615 pub queue_rate_ewma_alpha: f64,
616 #[serde(default = "default_seed_queue_min_active_secs")]
620 pub seed_queue_min_active_secs: u64,
621
622 #[serde(default = "default_alert_mask")]
625 pub alert_mask: AlertCategory,
626 #[serde(default = "default_alert_channel_size")]
628 pub alert_channel_size: usize,
629
630 #[serde(default = "default_smart_ban_max_failures")]
634 pub smart_ban_max_failures: u32,
635 #[serde(default = "default_true")]
638 pub smart_ban_parole: bool,
639
640 #[serde(default = "default_disk_io_threads")]
643 pub disk_io_threads: usize,
644 #[serde(default = "default_max_blocking_threads")]
647 pub max_blocking_threads: usize,
648 #[serde(default = "default_storage_mode")]
650 pub storage_mode: StorageMode,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub preallocate_mode: Option<PreallocateMode>,
655 #[serde(default = "default_disk_cache_size")]
657 pub disk_cache_size: usize,
658 #[serde(default = "default_disk_write_cache_ratio")]
660 pub disk_write_cache_ratio: f32,
661 #[serde(default = "default_disk_channel_capacity")]
663 pub disk_channel_capacity: usize,
664 #[serde(default = "default_buffer_pool_capacity")]
667 pub buffer_pool_capacity: usize,
668 #[serde(default = "default_enable_mlock")]
672 pub enable_mlock: bool,
673 #[serde(default = "default_io_uring_sq_depth")]
676 pub io_uring_sq_depth: u32,
677 #[serde(default)]
680 pub io_uring_direct_io: bool,
681 #[serde(default)]
685 pub filesystem_direct_io: bool,
686 #[serde(default = "default_io_uring_batch_threshold")]
689 pub io_uring_batch_threshold: usize,
690 #[serde(default)]
693 pub iocp_concurrent_threads: u32,
694 #[serde(default)]
697 pub iocp_direct_io: bool,
698 #[serde(default = "default_hashing_threads")]
701 pub hashing_threads: usize,
702 #[serde(default = "default_max_request_queue_depth")]
704 pub max_request_queue_depth: usize,
705 #[serde(default = "default_initial_queue_depth")]
708 pub initial_queue_depth: usize,
709 #[serde(default = "default_request_queue_time")]
716 pub request_queue_time: f64,
717 #[serde(default = "default_block_request_timeout")]
720 pub block_request_timeout_secs: u32,
721 #[serde(default = "default_max_concurrent_streams")]
724 pub max_concurrent_stream_reads: usize,
725 #[serde(default = "default_true")]
728 pub auto_sequential: bool,
729 #[serde(default = "default_strict_end_game")]
733 pub strict_end_game: bool,
734 #[serde(default = "default_max_web_seeds")]
736 pub max_web_seeds: usize,
737 #[serde(default = "default_web_seed_retry_base_secs")]
739 pub web_seed_retry_base_secs: u64,
740 #[serde(default = "default_web_seed_retry_factor")]
742 pub web_seed_retry_factor: u64,
743 #[serde(default = "default_web_seed_retry_cap_secs")]
745 pub web_seed_retry_cap_secs: u64,
746 #[serde(default = "default_web_seed_max_failures")]
748 pub web_seed_max_failures: u32,
749 #[serde(default = "default_initial_picker_threshold")]
752 pub initial_picker_threshold: u32,
753 #[serde(default = "default_whole_pieces_threshold")]
756 pub whole_pieces_threshold: u32,
757 #[serde(default = "default_snub_timeout_secs")]
760 pub snub_timeout_secs: u32,
761 #[serde(default = "default_readahead_pieces")]
763 pub readahead_pieces: u32,
764 #[serde(default = "default_true")]
766 pub streaming_timeout_escalation: bool,
767 #[serde(default = "default_steal_threshold_ratio")]
770 pub steal_threshold_ratio: f64,
771 #[serde(default = "default_use_block_stealing")]
774 pub use_block_stealing: bool,
775 #[serde(default = "default_steal_stale_piece_secs")]
780 pub steal_stale_piece_secs: u64,
781 #[serde(default = "default_steal_threshold_endgame")]
784 pub steal_threshold_endgame: f64,
785 #[serde(default = "default_fixed_pipeline_depth")]
790 pub fixed_pipeline_depth: usize,
791
792 #[serde(default = "default_true")]
796 pub piece_extent_affinity: bool,
797 #[serde(default = "default_suggest_mode")]
801 pub suggest_mode: bool,
802 #[serde(default = "default_max_suggest_pieces")]
804 pub max_suggest_pieces: usize,
805 #[serde(default = "default_predictive_piece_announce_ms")]
809 pub predictive_piece_announce_ms: u64,
810
811 #[serde(default)]
814 pub proxy: ProxyConfig,
815 #[serde(default)]
818 pub force_proxy: bool,
819
820 #[serde(default)]
823 pub ip_filter_enabled: bool,
824 #[serde(default)]
826 pub ip_filter_path: String,
827 #[serde(default)]
829 pub ip_filter_auto_refresh: bool,
830
831 #[serde(default = "default_true")]
834 pub apply_ip_filter_to_trackers: bool,
835
836 #[serde(default = "default_dht_qps")]
839 pub dht_queries_per_second: usize,
840 #[serde(default = "default_dht_timeout")]
842 pub dht_query_timeout_secs: u64,
843 #[serde(default)]
846 pub dht_enforce_node_id: bool,
847 #[serde(default = "default_true")]
849 pub dht_restrict_routing_ips: bool,
850 #[serde(default = "default_dht_max_items")]
852 pub dht_max_items: usize,
853 #[serde(default = "default_dht_item_lifetime")]
855 pub dht_item_lifetime_secs: u64,
856 #[serde(default = "default_dht_sample_interval")]
859 pub dht_sample_infohashes_interval: u64,
860 #[serde(default)]
864 pub dht_read_only: bool,
865
866 #[serde(default = "default_upnp_lease")]
869 pub upnp_lease_duration: u32,
870 #[serde(default = "default_natpmp_lifetime")]
872 pub natpmp_lifetime: u32,
873
874 #[serde(default = "default_utp_max_conns")]
877 pub utp_max_connections: usize,
878
879 #[serde(default)]
882 pub enable_i2p: bool,
883 #[serde(default = "default_i2p_hostname")]
885 pub i2p_hostname: String,
886 #[serde(default = "default_i2p_port")]
888 pub i2p_port: u16,
889 #[serde(default = "default_i2p_tunnel_quantity")]
891 pub i2p_inbound_quantity: u8,
892 #[serde(default = "default_i2p_tunnel_quantity")]
894 pub i2p_outbound_quantity: u8,
895 #[serde(default = "default_i2p_tunnel_length")]
897 pub i2p_inbound_length: u8,
898 #[serde(default = "default_i2p_tunnel_length")]
900 pub i2p_outbound_length: u8,
901 #[serde(default)]
904 pub allow_i2p_mixed: bool,
905
906 #[serde(default = "default_ssl_listen_port")]
911 pub ssl_listen_port: u16,
912 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub ssl_cert_path: Option<PathBuf>,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub ssl_key_path: Option<PathBuf>,
920
921 #[serde(default = "default_seed_choking_algorithm")]
924 pub seed_choking_algorithm: SeedChokingAlgorithm,
925 #[serde(default = "default_choking_algorithm")]
927 pub choking_algorithm: ChokingAlgorithm,
928
929 #[serde(default = "default_max_peers_per_torrent")]
934 pub max_peers_per_torrent: usize,
935
936 #[serde(default = "default_pass0_grace_secs")]
941 pub pass0_grace_secs: u64,
942
943 #[serde(default = "default_proactive_evictions_per_minute_limit")]
948 pub proactive_evictions_per_minute_limit: u32,
949
950 #[serde(default = "default_eviction_ban_duration_secs")]
955 pub eviction_ban_duration_secs: u64,
956
957 #[serde(default = "default_eviction_ban_set_cap")]
961 pub eviction_ban_set_cap: usize,
962
963 #[serde(default = "default_peer_read_timeout_secs")]
966 pub peer_read_timeout_secs: u64,
967 #[serde(default = "default_peer_write_timeout_secs")]
970 pub peer_write_timeout_secs: u64,
971
972 #[serde(default = "default_data_contribution_timeout")]
975 pub data_contribution_timeout_secs: u64,
976
977 #[serde(default = "default_choke_rotation_max_evictions")]
979 pub choke_rotation_max_evictions: u32,
980
981 #[serde(default = "default_max_concurrent_connects")]
983 pub max_concurrent_connects: u16,
984
985 #[serde(default = "default_connect_soft_timeout")]
988 pub connect_soft_timeout: u64,
989
990 #[serde(default = "default_dispatch_backlog_cap")]
996 pub dispatch_backlog_cap: usize,
997
998 #[serde(default = "default_event_backlog_cap")]
1002 pub event_backlog_cap: usize,
1003
1004 #[serde(default = "default_peer_writer_channel_cap")]
1013 pub peer_writer_channel_cap: usize,
1014
1015 #[serde(default = "default_true")]
1017 pub use_actor_dispatch: bool,
1018
1019 #[serde(default = "default_web_seed_progress_throttle_ms")]
1024 pub web_seed_progress_throttle_ms: u64,
1025
1026 #[serde(default = "default_true")]
1030 pub ssrf_mitigation: bool,
1031 #[serde(default)]
1033 pub allow_idna: bool,
1034 #[serde(default = "default_true")]
1036 pub validate_https_trackers: bool,
1037 #[serde(default = "default_max_metadata_size")]
1040 pub max_metadata_size: u64,
1041 #[serde(default = "default_max_message_size")]
1044 pub max_message_size: usize,
1045 #[serde(default = "default_max_piece_length")]
1048 pub max_piece_length: u64,
1049 #[serde(default = "default_max_outstanding_requests")]
1053 pub max_outstanding_requests: usize,
1054 #[serde(default = "default_max_in_flight_pieces")]
1059 pub max_in_flight_pieces: usize,
1060 #[serde(default = "default_peer_connect_timeout")]
1063 pub peer_connect_timeout: u64,
1064 #[serde(default = "default_peer_dscp")]
1068 pub peer_dscp: u8,
1069
1070 #[serde(default = "default_stats_report_interval")]
1074 pub stats_report_interval: u64,
1075
1076 #[serde(default = "default_runtime_worker_threads")]
1080 pub runtime_worker_threads: usize,
1081 #[serde(default = "default_true")]
1083 pub pin_cores: bool,
1084
1085 #[serde(default = "default_lock_warn_threshold_ms")]
1091 pub lock_warn_threshold_ms: u64,
1092
1093 #[serde(skip)]
1099 pub dht_saved_nodes: Vec<String>,
1100 #[serde(skip)]
1104 pub dht_node_id: Option<irontide_core::Id20>,
1105
1106 #[serde(default)]
1110 pub qbt_compat: QbtCompatSettings,
1111
1112 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub category_registry_path: Option<PathBuf>,
1117
1118 #[serde(default, skip_serializing_if = "Option::is_none")]
1122 pub tag_registry_path: Option<PathBuf>,
1123
1124 #[serde(default)]
1129 pub notify_on_complete: bool,
1130 #[serde(default)]
1133 pub notify_on_error: bool,
1134 #[serde(default, skip_serializing_if = "Option::is_none")]
1139 pub on_complete_program: Option<PathBuf>,
1140 #[serde(default)]
1144 pub use_incomplete_dir: bool,
1145 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 pub incomplete_dir: Option<PathBuf>,
1150 #[serde(default)]
1154 pub default_skip_hash_check: bool,
1155 #[serde(default = "default_true")]
1159 pub incomplete_extension_enabled: bool,
1160 #[serde(default, skip_serializing_if = "Option::is_none")]
1164 pub watched_folder: Option<PathBuf>,
1165 #[serde(default)]
1170 pub delete_torrent_after_add: bool,
1171 #[serde(default)]
1175 pub move_completed_enabled: bool,
1176 #[serde(default, skip_serializing_if = "Option::is_none")]
1180 pub move_completed_to: Option<PathBuf>,
1181 #[serde(default)]
1185 pub web_ui_https_enabled: bool,
1186 #[serde(default, skip_serializing_if = "Option::is_none")]
1190 pub network_interface: Option<String>,
1191 #[serde(default)]
1196 pub default_add_paused: bool,
1197}
1198
1199#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
1217#[serde(default)]
1218pub struct QbtCompatSettings {
1219 pub enabled: bool,
1227 pub username: String,
1230 pub password_hash: String,
1238 #[serde(default)]
1244 pub password: String,
1245 pub spoof_app_version: String,
1248 pub spoof_webapi_version: String,
1251 pub session_ttl_secs: u64,
1254 pub max_sessions: usize,
1257 #[serde(default, skip_serializing_if = "Option::is_none")]
1262 pub max_concurrent_argon2_ops: Option<u32>,
1263
1264 #[serde(default = "default_qbt_port")]
1269 pub port: u16,
1270
1271 #[serde(default = "default_qbt_bind_address")]
1276 pub bind_address: String,
1277
1278 #[serde(default = "default_csrf_protection_enabled")]
1285 pub csrf_protection_enabled: bool,
1286 #[serde(default = "default_host_header_validation_enabled")]
1292 pub host_header_validation_enabled: bool,
1293 #[serde(default)]
1301 pub web_ui_reverse_proxy_enabled: bool,
1302 #[serde(default)]
1313 pub web_ui_reverse_proxies_list: Vec<String>,
1314
1315 #[serde(default = "default_max_failed_auth_count")]
1322 pub max_failed_auth_count: u32,
1323 #[serde(default = "default_ban_duration_secs")]
1326 pub ban_duration_secs: u64,
1327 #[serde(default)]
1335 pub bypass_local_auth: bool,
1336 #[serde(default)]
1344 pub bypass_auth_subnet_whitelist: Vec<String>,
1345 #[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
1364fn default_qbt_port() -> u16 {
1367 9080
1368}
1369
1370fn default_qbt_bind_address() -> String {
1371 "127.0.0.1".to_owned()
1372}
1373
1374#[must_use]
1376pub const fn default_max_failed_auth_count() -> u32 {
1377 5
1378}
1379
1380#[must_use]
1382pub const fn default_ban_duration_secs() -> u64 {
1383 3_600
1384}
1385
1386pub 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 port: default_qbt_port(),
1412 bind_address: default_qbt_bind_address(),
1413 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1430pub enum QbtCredentialMigration {
1431 NoOp,
1433 Upgraded,
1437}
1438
1439pub 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 let salt = SaltString::generate(&mut rand_core::OsRng);
1458 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#[derive(Debug, thiserror::Error)]
1471pub enum QbtMigrationError {
1472 #[error("argon2 hash: {0}")]
1474 Hash(String),
1475}
1476
1477pub 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 let _drain = zeroize::Zeroizing::new(std::mem::take(&mut qbt.password));
1519 Ok(QbtCredentialMigration::Upgraded)
1520}
1521
1522fn is_valid_app_version(s: &str) -> bool {
1524 let Some(rest) = s.strip_prefix('v') else {
1525 return false;
1526 };
1527 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
1541fn is_valid_webapi_version(s: &str) -> bool {
1543 is_valid_dotted_numeric(s)
1544}
1545
1546fn 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 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 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 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 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 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 alert_mask: AlertCategory::ALL,
1634 alert_channel_size: 1024,
1635 smart_ban_max_failures: 3,
1637 smart_ban_parole: true,
1638 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_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_extent_affinity: true,
1680 suggest_mode: true,
1681 max_suggest_pieces: 16,
1682 predictive_piece_announce_ms: 0,
1683 proxy: ProxyConfig::default(),
1685 force_proxy: false,
1686 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_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 upnp_lease_duration: 3600,
1702 natpmp_lifetime: 7200,
1703 utp_max_connections: 256,
1705 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_listen_port: 0,
1716 ssl_cert_path: None,
1717 ssl_key_path: None,
1718 seed_choking_algorithm: SeedChokingAlgorithm::FastestUpload,
1720 choking_algorithm: ChokingAlgorithm::FixedSlots,
1721 max_peers_per_torrent: 128,
1723 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 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 stats_report_interval: 1000,
1752 runtime_worker_threads: default_runtime_worker_threads(),
1754 pin_cores: true,
1755 lock_warn_threshold_ms: 50,
1757 dht_saved_nodes: Vec::new(),
1759 dht_node_id: None,
1760 qbt_compat: QbtCompatSettings::default(),
1762 category_registry_path: None,
1765 tag_registry_path: None,
1768 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
1787const MAX_TORRENTS_CEILING: usize = 100_000;
1792
1793impl Settings {
1794 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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#[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 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 let decoded: Settings = serde_json::from_str("{}").unwrap();
2289 assert_eq!(decoded, Settings::default());
2290 }
2291
2292 #[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 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 #[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 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 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 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 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 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 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 let mut s = Settings {
2608 enable_i2p: false,
2609 ..Settings::default()
2610 };
2611 s.i2p_inbound_quantity = 0; s.validate().unwrap(); }
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 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 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 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 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, ..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 let s = Settings::default();
2833 assert_eq!(s.runtime_worker_threads, default_runtime_worker_threads());
2834 assert!(s.pin_cores);
2835
2836 let mut s = Settings {
2838 runtime_worker_threads: 0,
2839 ..Settings::default()
2840 };
2841 assert!(s.validate().is_ok());
2842
2843 s.runtime_worker_threads = 256;
2845 assert!(s.validate().is_ok());
2846
2847 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 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); }
2867
2868 #[test]
2869 fn recalc_max_in_flight_formula() {
2870 let base = 512_usize;
2873
2874 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); 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); 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); let connected = 129; 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); }
2901
2902 #[test]
2905 fn settings_default_enables_qbt_compat_v0_172_1() {
2906 let s = Settings::default();
2911 assert!(s.qbt_compat.enabled);
2912 assert_eq!(s.qbt_compat.username, "admin");
2913 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 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(); 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; 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; let err = s.validate().expect_err("ttl too large must fail");
2986 assert!(format!("{err}").contains("session_ttl_secs"));
2987 }
2988
2989 #[test]
2992 fn default_hash_roundtrips_admin_admin() {
2993 use argon2::Argon2;
2994 use argon2::password_hash::{PasswordHash, PasswordVerifier};
2995
2996 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 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 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 #[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 #[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 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 #[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 #[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 #[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 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}