use std::net::IpAddr;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use irontide_core::{
AlertCategory, ChokingAlgorithm, MixedModeAlgorithm, PreallocateMode, ProxyConfig, ProxyType,
SeedChokingAlgorithm, StorageMode,
};
use irontide_wire::mse::EncryptionMode;
mod schema;
#[derive(Debug, thiserror::Error)]
pub enum SettingsError {
#[error("{0}")]
Invalid(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MaxRatioAction {
#[default]
Pause,
Remove,
EnableSuperSeeding,
}
fn default_true() -> bool {
true
}
fn default_listen_port() -> u16 {
42020
}
fn default_download_dir() -> PathBuf {
PathBuf::from(".")
}
fn default_max_torrents() -> usize {
100
}
fn default_encryption() -> EncryptionMode {
EncryptionMode::Disabled
}
fn default_auto_upload_slots_min() -> usize {
2
}
fn default_auto_upload_slots_max() -> usize {
20
}
fn default_active_downloads() -> i32 {
3
}
fn default_active_seeds() -> i32 {
5
}
fn default_active_limit() -> i32 {
500
}
fn default_active_checking() -> i32 {
3
}
fn default_inactive_rate() -> u64 {
2048
}
fn default_auto_manage_interval() -> u64 {
30
}
fn default_auto_manage_startup() -> u64 {
60
}
fn default_queue_rate_ewma_alpha() -> f64 {
0.3
}
fn default_seed_queue_min_active_secs() -> u64 {
1800
}
fn default_alert_mask() -> AlertCategory {
AlertCategory::ALL
}
fn default_alert_channel_size() -> usize {
1024
}
fn default_smart_ban_max_failures() -> u32 {
3
}
fn default_disk_io_threads() -> usize {
let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
(cores / 2).clamp(4, 16)
}
fn default_max_blocking_threads() -> usize {
std::thread::available_parallelism().map_or(4, std::num::NonZero::get)
}
fn default_storage_mode() -> StorageMode {
StorageMode::Auto
}
fn default_disk_cache_size() -> usize {
16 * 1024 * 1024
}
fn default_disk_write_cache_ratio() -> f32 {
0.5
}
fn default_buffer_pool_capacity() -> usize {
64 * 1024 * 1024
}
fn default_enable_mlock() -> bool {
cfg!(unix)
}
fn default_io_uring_sq_depth() -> u32 {
256
}
fn default_io_uring_batch_threshold() -> usize {
4
}
fn default_disk_channel_capacity() -> usize {
512
}
fn default_hashing_threads() -> usize {
let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
(cores / 4).clamp(2, 8)
}
fn default_max_request_queue_depth() -> usize {
250
}
fn default_initial_queue_depth() -> usize {
128
}
fn default_request_queue_time() -> f64 {
3.0
}
fn default_block_request_timeout() -> u32 {
60
}
fn default_max_concurrent_streams() -> usize {
8
}
fn default_dht_qps() -> usize {
50
}
fn default_dht_timeout() -> u64 {
5
}
fn default_upnp_lease() -> u32 {
3600
}
fn default_natpmp_lifetime() -> u32 {
7200
}
fn default_utp_max_conns() -> usize {
256
}
fn default_dht_max_items() -> usize {
700
}
fn default_dht_item_lifetime() -> u64 {
7200
}
fn default_dht_sample_interval() -> u64 {
0
}
fn default_suggest_mode() -> bool {
true
}
fn default_max_suggest_pieces() -> usize {
16
}
fn default_predictive_piece_announce_ms() -> u64 {
0
}
fn default_ssl_listen_port() -> u16 {
0 }
fn default_seed_choking_algorithm() -> SeedChokingAlgorithm {
SeedChokingAlgorithm::FastestUpload
}
fn default_choking_algorithm() -> ChokingAlgorithm {
ChokingAlgorithm::FixedSlots
}
fn default_mixed_mode() -> MixedModeAlgorithm {
MixedModeAlgorithm::PeerProportional
}
fn default_steal_threshold_ratio() -> f64 {
10.0
}
fn default_use_block_stealing() -> bool {
true
}
fn default_peer_connect_timeout() -> u64 {
10 }
fn default_peer_dscp() -> u8 {
0x08 }
fn default_max_peers_per_torrent() -> usize {
128
}
fn default_pass0_grace_secs() -> u64 {
60 }
fn default_proactive_evictions_per_minute_limit() -> u32 {
30 }
fn default_eviction_ban_duration_secs() -> u64 {
600 }
fn default_eviction_ban_set_cap() -> usize {
1024 }
fn default_stats_report_interval() -> u64 {
1000
}
fn default_strict_end_game() -> bool {
true
}
fn default_max_web_seeds() -> usize {
4
}
fn default_web_seed_retry_base_secs() -> u64 {
10
}
fn default_web_seed_retry_factor() -> u64 {
6
}
fn default_web_seed_retry_cap_secs() -> u64 {
3600
}
fn default_web_seed_max_failures() -> u32 {
10
}
fn default_initial_picker_threshold() -> u32 {
4
}
fn default_whole_pieces_threshold() -> u32 {
20
}
fn default_snub_timeout_secs() -> u32 {
15
}
fn default_readahead_pieces() -> u32 {
8
}
fn default_max_metadata_size() -> u64 {
4 * 1024 * 1024 }
fn default_max_message_size() -> usize {
16 * 1024 * 1024 }
fn default_max_piece_length() -> u64 {
32 * 1024 * 1024 }
fn default_max_outstanding_requests() -> usize {
500
}
fn default_max_in_flight_pieces() -> usize {
512
}
fn default_fixed_pipeline_depth() -> usize {
128
}
fn default_i2p_hostname() -> String {
"127.0.0.1".into()
}
fn default_i2p_port() -> u16 {
7656
}
fn default_i2p_tunnel_quantity() -> u8 {
3
}
fn default_i2p_tunnel_length() -> u8 {
3
}
fn default_runtime_worker_threads() -> usize {
std::thread::available_parallelism().map_or(4, |n| n.get().min(8))
}
fn default_lock_warn_threshold_ms() -> u64 {
50
}
fn default_steal_stale_piece_secs() -> u64 {
2
}
fn default_steal_threshold_endgame() -> f64 {
3.0
}
fn default_peer_read_timeout_secs() -> u64 {
10
}
fn default_peer_write_timeout_secs() -> u64 {
10
}
fn default_data_contribution_timeout() -> u64 {
0 }
fn default_choke_rotation_max_evictions() -> u32 {
0 }
fn default_max_concurrent_connects() -> u16 {
128 }
fn default_connect_soft_timeout() -> u64 {
3 }
fn default_dispatch_backlog_cap() -> usize {
8 }
fn default_event_backlog_cap() -> usize {
32 }
fn default_peer_writer_channel_cap() -> usize {
1024 }
fn default_web_seed_progress_throttle_ms() -> u64 {
250 }
fn default_save_resume_interval() -> u64 {
300 }
fn default_max_upload_slots_global() -> i32 {
-1
}
fn default_max_upload_slots_per_torrent() -> i32 {
4
}
fn default_max_connections_global() -> i32 {
-1
}
fn default_max_uploads_per_torrent() -> i32 {
-1
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_listen_port")]
pub listen_port: u16,
#[serde(default)]
pub randomize_port_on_startup: bool,
#[serde(default = "default_download_dir")]
pub download_dir: PathBuf,
#[serde(default = "default_max_torrents")]
pub max_torrents: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_data_dir: Option<PathBuf>,
#[serde(default = "default_save_resume_interval")]
pub save_resume_interval_secs: u64,
#[serde(default = "default_true")]
pub enable_dht: bool,
#[serde(default = "default_true")]
pub enable_pex: bool,
#[serde(default = "default_true")]
pub enable_lsd: bool,
#[serde(default = "default_true")]
pub enable_fast_extension: bool,
#[serde(default = "default_true")]
pub enable_utp: bool,
#[serde(default = "default_true")]
pub enable_upnp: bool,
#[serde(default = "default_true")]
pub enable_natpmp: bool,
#[serde(default = "default_true")]
pub enable_ipv6: bool,
#[serde(default = "default_true")]
pub enable_web_seed: bool,
#[serde(default = "default_true")]
pub enable_holepunch: bool,
#[serde(default = "default_true")]
pub enable_bep40_eviction: bool,
#[serde(default)]
pub enable_diagnostic_counters: bool,
#[serde(default = "default_encryption")]
pub encryption_mode: EncryptionMode,
#[serde(default)]
pub anonymous_mode: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_ip: Option<IpAddr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed_ratio_limit: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed_time_limit_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inactive_seed_time_limit_secs: Option<u64>,
#[serde(default)]
pub max_ratio_action: MaxRatioAction,
#[serde(default = "default_true")]
pub create_subfolder: bool,
#[serde(default)]
pub auto_manage_torrents: bool,
#[serde(default)]
pub queueing_enabled: bool,
#[serde(default)]
pub default_super_seeding: bool,
#[serde(default)]
pub default_share_mode: bool,
#[serde(default = "default_true")]
pub upload_only_announce: bool,
#[serde(default)]
pub upload_rate_limit: u64,
#[serde(default)]
pub download_rate_limit: u64,
#[serde(default)]
pub tcp_upload_rate_limit: u64,
#[serde(default)]
pub tcp_download_rate_limit: u64,
#[serde(default)]
pub utp_upload_rate_limit: u64,
#[serde(default)]
pub utp_download_rate_limit: u64,
#[serde(default = "default_true")]
pub auto_upload_slots: bool,
#[serde(default = "default_auto_upload_slots_min")]
pub auto_upload_slots_min: usize,
#[serde(default = "default_auto_upload_slots_max")]
pub auto_upload_slots_max: usize,
#[serde(default = "default_max_upload_slots_global")]
pub max_upload_slots_global: i32,
#[serde(default = "default_max_upload_slots_per_torrent")]
pub max_upload_slots_per_torrent: i32,
#[serde(default = "default_max_connections_global")]
pub max_connections_global: i32,
#[serde(default = "default_max_uploads_per_torrent")]
pub max_uploads_per_torrent: i32,
#[serde(default)]
pub alt_download_rate_limit: u64,
#[serde(default)]
pub alt_upload_rate_limit: u64,
#[serde(default)]
pub alt_speed_enabled: bool,
#[serde(default)]
pub alt_speed_schedule_enabled: bool,
#[serde(default)]
pub alt_speed_schedule_from: u16,
#[serde(default)]
pub alt_speed_schedule_to: u16,
#[serde(default)]
pub alt_speed_schedule_days: u8,
#[serde(default = "default_true")]
pub rate_limit_includes_overhead: bool,
#[serde(default = "default_true")]
pub rate_limit_utp: bool,
#[serde(default)]
pub rate_limit_lan: bool,
#[serde(default = "default_mixed_mode")]
pub mixed_mode_algorithm: MixedModeAlgorithm,
#[serde(default = "default_active_downloads")]
pub active_downloads: i32,
#[serde(default = "default_active_seeds")]
pub active_seeds: i32,
#[serde(default = "default_active_limit")]
pub active_limit: i32,
#[serde(default = "default_active_checking")]
pub active_checking: i32,
#[serde(default = "default_true")]
pub dont_count_slow_torrents: bool,
#[serde(default = "default_inactive_rate")]
pub inactive_down_rate: u64,
#[serde(default = "default_inactive_rate")]
pub inactive_up_rate: u64,
#[serde(default = "default_auto_manage_interval")]
pub auto_manage_interval: u64,
#[serde(default = "default_auto_manage_startup")]
pub auto_manage_startup: u64,
#[serde(default)]
pub auto_manage_prefer_seeds: bool,
#[serde(default = "default_queue_rate_ewma_alpha")]
pub queue_rate_ewma_alpha: f64,
#[serde(default = "default_seed_queue_min_active_secs")]
pub seed_queue_min_active_secs: u64,
#[serde(default = "default_alert_mask")]
pub alert_mask: AlertCategory,
#[serde(default = "default_alert_channel_size")]
pub alert_channel_size: usize,
#[serde(default = "default_smart_ban_max_failures")]
pub smart_ban_max_failures: u32,
#[serde(default = "default_true")]
pub smart_ban_parole: bool,
#[serde(default = "default_disk_io_threads")]
pub disk_io_threads: usize,
#[serde(default = "default_max_blocking_threads")]
pub max_blocking_threads: usize,
#[serde(default = "default_storage_mode")]
pub storage_mode: StorageMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preallocate_mode: Option<PreallocateMode>,
#[serde(default = "default_disk_cache_size")]
pub disk_cache_size: usize,
#[serde(default = "default_disk_write_cache_ratio")]
pub disk_write_cache_ratio: f32,
#[serde(default = "default_disk_channel_capacity")]
pub disk_channel_capacity: usize,
#[serde(default = "default_buffer_pool_capacity")]
pub buffer_pool_capacity: usize,
#[serde(default = "default_enable_mlock")]
pub enable_mlock: bool,
#[serde(default = "default_io_uring_sq_depth")]
pub io_uring_sq_depth: u32,
#[serde(default)]
pub io_uring_direct_io: bool,
#[serde(default)]
pub filesystem_direct_io: bool,
#[serde(default = "default_io_uring_batch_threshold")]
pub io_uring_batch_threshold: usize,
#[serde(default)]
pub iocp_concurrent_threads: u32,
#[serde(default)]
pub iocp_direct_io: bool,
#[serde(default = "default_hashing_threads")]
pub hashing_threads: usize,
#[serde(default = "default_max_request_queue_depth")]
pub max_request_queue_depth: usize,
#[serde(default = "default_initial_queue_depth")]
pub initial_queue_depth: usize,
#[serde(default = "default_request_queue_time")]
pub request_queue_time: f64,
#[serde(default = "default_block_request_timeout")]
pub block_request_timeout_secs: u32,
#[serde(default = "default_max_concurrent_streams")]
pub max_concurrent_stream_reads: usize,
#[serde(default = "default_true")]
pub auto_sequential: bool,
#[serde(default = "default_strict_end_game")]
pub strict_end_game: bool,
#[serde(default = "default_max_web_seeds")]
pub max_web_seeds: usize,
#[serde(default = "default_web_seed_retry_base_secs")]
pub web_seed_retry_base_secs: u64,
#[serde(default = "default_web_seed_retry_factor")]
pub web_seed_retry_factor: u64,
#[serde(default = "default_web_seed_retry_cap_secs")]
pub web_seed_retry_cap_secs: u64,
#[serde(default = "default_web_seed_max_failures")]
pub web_seed_max_failures: u32,
#[serde(default = "default_initial_picker_threshold")]
pub initial_picker_threshold: u32,
#[serde(default = "default_whole_pieces_threshold")]
pub whole_pieces_threshold: u32,
#[serde(default = "default_snub_timeout_secs")]
pub snub_timeout_secs: u32,
#[serde(default = "default_readahead_pieces")]
pub readahead_pieces: u32,
#[serde(default = "default_true")]
pub streaming_timeout_escalation: bool,
#[serde(default = "default_steal_threshold_ratio")]
pub steal_threshold_ratio: f64,
#[serde(default = "default_use_block_stealing")]
pub use_block_stealing: bool,
#[serde(default = "default_steal_stale_piece_secs")]
pub steal_stale_piece_secs: u64,
#[serde(default = "default_steal_threshold_endgame")]
pub steal_threshold_endgame: f64,
#[serde(default = "default_fixed_pipeline_depth")]
pub fixed_pipeline_depth: usize,
#[serde(default = "default_true")]
pub piece_extent_affinity: bool,
#[serde(default = "default_suggest_mode")]
pub suggest_mode: bool,
#[serde(default = "default_max_suggest_pieces")]
pub max_suggest_pieces: usize,
#[serde(default = "default_predictive_piece_announce_ms")]
pub predictive_piece_announce_ms: u64,
#[serde(default)]
pub proxy: ProxyConfig,
#[serde(default)]
pub force_proxy: bool,
#[serde(default)]
pub ip_filter_enabled: bool,
#[serde(default)]
pub ip_filter_path: String,
#[serde(default)]
pub ip_filter_auto_refresh: bool,
#[serde(default = "default_true")]
pub apply_ip_filter_to_trackers: bool,
#[serde(default = "default_dht_qps")]
pub dht_queries_per_second: usize,
#[serde(default = "default_dht_timeout")]
pub dht_query_timeout_secs: u64,
#[serde(default)]
pub dht_enforce_node_id: bool,
#[serde(default = "default_true")]
pub dht_restrict_routing_ips: bool,
#[serde(default = "default_dht_max_items")]
pub dht_max_items: usize,
#[serde(default = "default_dht_item_lifetime")]
pub dht_item_lifetime_secs: u64,
#[serde(default = "default_dht_sample_interval")]
pub dht_sample_infohashes_interval: u64,
#[serde(default)]
pub dht_read_only: bool,
#[serde(default = "default_upnp_lease")]
pub upnp_lease_duration: u32,
#[serde(default = "default_natpmp_lifetime")]
pub natpmp_lifetime: u32,
#[serde(default = "default_utp_max_conns")]
pub utp_max_connections: usize,
#[serde(default)]
pub enable_i2p: bool,
#[serde(default = "default_i2p_hostname")]
pub i2p_hostname: String,
#[serde(default = "default_i2p_port")]
pub i2p_port: u16,
#[serde(default = "default_i2p_tunnel_quantity")]
pub i2p_inbound_quantity: u8,
#[serde(default = "default_i2p_tunnel_quantity")]
pub i2p_outbound_quantity: u8,
#[serde(default = "default_i2p_tunnel_length")]
pub i2p_inbound_length: u8,
#[serde(default = "default_i2p_tunnel_length")]
pub i2p_outbound_length: u8,
#[serde(default)]
pub allow_i2p_mixed: bool,
#[serde(default = "default_ssl_listen_port")]
pub ssl_listen_port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssl_cert_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssl_key_path: Option<PathBuf>,
#[serde(default = "default_seed_choking_algorithm")]
pub seed_choking_algorithm: SeedChokingAlgorithm,
#[serde(default = "default_choking_algorithm")]
pub choking_algorithm: ChokingAlgorithm,
#[serde(default = "default_max_peers_per_torrent")]
pub max_peers_per_torrent: usize,
#[serde(default = "default_pass0_grace_secs")]
pub pass0_grace_secs: u64,
#[serde(default = "default_proactive_evictions_per_minute_limit")]
pub proactive_evictions_per_minute_limit: u32,
#[serde(default = "default_eviction_ban_duration_secs")]
pub eviction_ban_duration_secs: u64,
#[serde(default = "default_eviction_ban_set_cap")]
pub eviction_ban_set_cap: usize,
#[serde(default = "default_peer_read_timeout_secs")]
pub peer_read_timeout_secs: u64,
#[serde(default = "default_peer_write_timeout_secs")]
pub peer_write_timeout_secs: u64,
#[serde(default = "default_data_contribution_timeout")]
pub data_contribution_timeout_secs: u64,
#[serde(default = "default_choke_rotation_max_evictions")]
pub choke_rotation_max_evictions: u32,
#[serde(default = "default_max_concurrent_connects")]
pub max_concurrent_connects: u16,
#[serde(default = "default_connect_soft_timeout")]
pub connect_soft_timeout: u64,
#[serde(default = "default_dispatch_backlog_cap")]
pub dispatch_backlog_cap: usize,
#[serde(default = "default_event_backlog_cap")]
pub event_backlog_cap: usize,
#[serde(default = "default_peer_writer_channel_cap")]
pub peer_writer_channel_cap: usize,
#[serde(default = "default_true")]
pub use_actor_dispatch: bool,
#[serde(default = "default_web_seed_progress_throttle_ms")]
pub web_seed_progress_throttle_ms: u64,
#[serde(default = "default_true")]
pub ssrf_mitigation: bool,
#[serde(default)]
pub allow_idna: bool,
#[serde(default = "default_true")]
pub validate_https_trackers: bool,
#[serde(default = "default_max_metadata_size")]
pub max_metadata_size: u64,
#[serde(default = "default_max_message_size")]
pub max_message_size: usize,
#[serde(default = "default_max_piece_length")]
pub max_piece_length: u64,
#[serde(default = "default_max_outstanding_requests")]
pub max_outstanding_requests: usize,
#[serde(default = "default_max_in_flight_pieces")]
pub max_in_flight_pieces: usize,
#[serde(default = "default_peer_connect_timeout")]
pub peer_connect_timeout: u64,
#[serde(default = "default_peer_dscp")]
pub peer_dscp: u8,
#[serde(default = "default_stats_report_interval")]
pub stats_report_interval: u64,
#[serde(default = "default_runtime_worker_threads")]
pub runtime_worker_threads: usize,
#[serde(default = "default_true")]
pub pin_cores: bool,
#[serde(default = "default_lock_warn_threshold_ms")]
pub lock_warn_threshold_ms: u64,
#[serde(skip)]
pub dht_saved_nodes: Vec<String>,
#[serde(skip)]
pub dht_node_id: Option<irontide_core::Id20>,
#[serde(default)]
pub qbt_compat: QbtCompatSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category_registry_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag_registry_path: Option<PathBuf>,
#[serde(default)]
pub notify_on_complete: bool,
#[serde(default)]
pub notify_on_error: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_complete_program: Option<PathBuf>,
#[serde(default)]
pub use_incomplete_dir: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub incomplete_dir: Option<PathBuf>,
#[serde(default)]
pub default_skip_hash_check: bool,
#[serde(default = "default_true")]
pub incomplete_extension_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub watched_folder: Option<PathBuf>,
#[serde(default)]
pub delete_torrent_after_add: bool,
#[serde(default)]
pub move_completed_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub move_completed_to: Option<PathBuf>,
#[serde(default)]
pub web_ui_https_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_interface: Option<String>,
#[serde(default)]
pub default_add_paused: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct QbtCompatSettings {
pub enabled: bool,
pub username: String,
pub password_hash: String,
#[serde(default)]
pub password: String,
pub spoof_app_version: String,
pub spoof_webapi_version: String,
pub session_ttl_secs: u64,
pub max_sessions: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_argon2_ops: Option<u32>,
#[serde(default = "default_qbt_port")]
pub port: u16,
#[serde(default = "default_qbt_bind_address")]
pub bind_address: String,
#[serde(default = "default_csrf_protection_enabled")]
pub csrf_protection_enabled: bool,
#[serde(default = "default_host_header_validation_enabled")]
pub host_header_validation_enabled: bool,
#[serde(default)]
pub web_ui_reverse_proxy_enabled: bool,
#[serde(default)]
pub web_ui_reverse_proxies_list: Vec<String>,
#[serde(default = "default_max_failed_auth_count")]
pub max_failed_auth_count: u32,
#[serde(default = "default_ban_duration_secs")]
pub ban_duration_secs: u64,
#[serde(default)]
pub bypass_local_auth: bool,
#[serde(default)]
pub bypass_auth_subnet_whitelist: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brute_force_registry_capacity: Option<usize>,
}
fn default_csrf_protection_enabled() -> bool {
true
}
fn default_host_header_validation_enabled() -> bool {
true
}
fn default_qbt_port() -> u16 {
9080
}
fn default_qbt_bind_address() -> String {
"127.0.0.1".to_owned()
}
#[must_use]
pub const fn default_max_failed_auth_count() -> u32 {
5
}
#[must_use]
pub const fn default_ban_duration_secs() -> u64 {
3_600
}
pub const DEFAULT_ADMINADMIN_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$u3doPIM7ab7NlbMfhMFm6A$ctIAjFfl70eUfUsThdGcXICr0lcD6bEUilRujvnXLPg";
impl Default for QbtCompatSettings {
fn default() -> Self {
Self {
enabled: true,
username: "admin".into(),
password_hash: DEFAULT_ADMINADMIN_HASH.into(),
password: String::new(),
spoof_app_version: "v5.1.4".into(),
spoof_webapi_version: "2.11.4".into(),
session_ttl_secs: 86_400,
max_sessions: 1024,
max_concurrent_argon2_ops: None,
port: default_qbt_port(),
bind_address: default_qbt_bind_address(),
csrf_protection_enabled: true,
host_header_validation_enabled: true,
web_ui_reverse_proxy_enabled: false,
web_ui_reverse_proxies_list: Vec::new(),
max_failed_auth_count: default_max_failed_auth_count(),
ban_duration_secs: default_ban_duration_secs(),
bypass_local_auth: false,
bypass_auth_subnet_whitelist: Vec::new(),
brute_force_registry_capacity: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QbtCredentialMigration {
NoOp,
Upgraded,
}
pub fn hash_qbt_password(plaintext: &str) -> Result<String, QbtMigrationError> {
use argon2::password_hash::{PasswordHasher, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
let salt = SaltString::generate(&mut rand_core::OsRng);
let params = Params::new(19_456, 2, 1, Some(32))
.map_err(|e| QbtMigrationError::Hash(format!("argon2 params: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let hash = argon2
.hash_password(plaintext.as_bytes(), &salt)
.map_err(|e| QbtMigrationError::Hash(format!("argon2 hash: {e}")))?;
Ok(hash.to_string())
}
#[derive(Debug, thiserror::Error)]
pub enum QbtMigrationError {
#[error("argon2 hash: {0}")]
Hash(String),
}
pub fn migrate_qbt_credentials(
qbt: &mut QbtCompatSettings,
) -> Result<QbtCredentialMigration, QbtMigrationError> {
if !qbt.password_hash.is_empty() {
return Ok(QbtCredentialMigration::NoOp);
}
if qbt.password.is_empty() {
return Ok(QbtCredentialMigration::NoOp);
}
let hash = hash_qbt_password(&qbt.password)?;
qbt.password_hash = hash;
let _drain = zeroize::Zeroizing::new(std::mem::take(&mut qbt.password));
Ok(QbtCredentialMigration::Upgraded)
}
fn is_valid_app_version(s: &str) -> bool {
let Some(rest) = s.strip_prefix('v') else {
return false;
};
let (core, suffix_ok) = match rest.split_once('-') {
Some((core, suffix)) => (
core,
!suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_alphanumeric()),
),
None => (rest, true),
};
if !suffix_ok {
return false;
}
is_valid_dotted_numeric(core)
}
fn is_valid_webapi_version(s: &str) -> bool {
is_valid_dotted_numeric(s)
}
fn is_valid_dotted_numeric(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
if !(2..=3).contains(&parts.len()) {
return false;
}
parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}
impl Default for Settings {
fn default() -> Self {
Self {
listen_port: 42020,
randomize_port_on_startup: false,
download_dir: PathBuf::from("."),
max_torrents: 100,
resume_data_dir: None,
save_resume_interval_secs: 300,
enable_dht: true,
enable_pex: true,
enable_lsd: true,
enable_fast_extension: true,
enable_utp: true,
enable_upnp: true,
enable_natpmp: true,
enable_ipv6: true,
enable_web_seed: true,
enable_holepunch: true,
enable_bep40_eviction: true,
enable_diagnostic_counters: false,
encryption_mode: EncryptionMode::Disabled,
anonymous_mode: false,
external_ip: None,
seed_ratio_limit: None,
seed_time_limit_secs: None,
inactive_seed_time_limit_secs: None,
max_ratio_action: MaxRatioAction::Pause,
create_subfolder: true,
auto_manage_torrents: false,
queueing_enabled: false,
default_super_seeding: false,
default_share_mode: false,
upload_only_announce: true,
upload_rate_limit: 0,
download_rate_limit: 0,
tcp_upload_rate_limit: 0,
tcp_download_rate_limit: 0,
utp_upload_rate_limit: 0,
utp_download_rate_limit: 0,
auto_upload_slots: true,
auto_upload_slots_min: 2,
auto_upload_slots_max: 20,
max_upload_slots_global: -1,
max_upload_slots_per_torrent: 4,
max_connections_global: -1,
max_uploads_per_torrent: -1,
alt_download_rate_limit: 0,
alt_upload_rate_limit: 0,
alt_speed_enabled: false,
alt_speed_schedule_enabled: false,
alt_speed_schedule_from: 0,
alt_speed_schedule_to: 0,
alt_speed_schedule_days: 0,
rate_limit_includes_overhead: true,
rate_limit_utp: true,
rate_limit_lan: false,
mixed_mode_algorithm: MixedModeAlgorithm::PeerProportional,
active_downloads: 3,
active_seeds: 5,
active_limit: 500,
active_checking: 3,
dont_count_slow_torrents: true,
inactive_down_rate: 2048,
inactive_up_rate: 2048,
auto_manage_interval: 30,
auto_manage_startup: 60,
auto_manage_prefer_seeds: false,
queue_rate_ewma_alpha: 0.3,
seed_queue_min_active_secs: 1800,
alert_mask: AlertCategory::ALL,
alert_channel_size: 1024,
smart_ban_max_failures: 3,
smart_ban_parole: true,
disk_io_threads: default_disk_io_threads(),
max_blocking_threads: default_max_blocking_threads(),
storage_mode: StorageMode::Auto,
preallocate_mode: None,
disk_cache_size: 16 * 1024 * 1024,
disk_write_cache_ratio: 0.5,
disk_channel_capacity: 512,
buffer_pool_capacity: 64 * 1024 * 1024,
enable_mlock: cfg!(unix),
io_uring_sq_depth: 256,
io_uring_direct_io: false,
filesystem_direct_io: false,
io_uring_batch_threshold: 4,
iocp_concurrent_threads: 0,
iocp_direct_io: false,
hashing_threads: default_hashing_threads(),
max_request_queue_depth: 250,
initial_queue_depth: 128,
request_queue_time: 3.0,
block_request_timeout_secs: 60,
max_concurrent_stream_reads: 8,
auto_sequential: true,
steal_threshold_ratio: 10.0,
use_block_stealing: true,
steal_stale_piece_secs: 2,
steal_threshold_endgame: 3.0,
fixed_pipeline_depth: 128,
strict_end_game: true,
max_web_seeds: 4,
web_seed_retry_base_secs: 10,
web_seed_retry_factor: 6,
web_seed_retry_cap_secs: 3600,
web_seed_max_failures: 10,
initial_picker_threshold: 4,
whole_pieces_threshold: 20,
snub_timeout_secs: 15,
readahead_pieces: 8,
streaming_timeout_escalation: true,
piece_extent_affinity: true,
suggest_mode: true,
max_suggest_pieces: 16,
predictive_piece_announce_ms: 0,
proxy: ProxyConfig::default(),
force_proxy: false,
ip_filter_enabled: false,
ip_filter_path: String::new(),
ip_filter_auto_refresh: false,
apply_ip_filter_to_trackers: true,
dht_queries_per_second: 50,
dht_query_timeout_secs: 5,
dht_enforce_node_id: false,
dht_restrict_routing_ips: true,
dht_max_items: 700,
dht_item_lifetime_secs: 7200,
dht_sample_infohashes_interval: 0,
dht_read_only: false,
upnp_lease_duration: 3600,
natpmp_lifetime: 7200,
utp_max_connections: 256,
enable_i2p: false,
i2p_hostname: "127.0.0.1".into(),
i2p_port: 7656,
i2p_inbound_quantity: 3,
i2p_outbound_quantity: 3,
i2p_inbound_length: 3,
i2p_outbound_length: 3,
allow_i2p_mixed: false,
ssl_listen_port: 0,
ssl_cert_path: None,
ssl_key_path: None,
seed_choking_algorithm: SeedChokingAlgorithm::FastestUpload,
choking_algorithm: ChokingAlgorithm::FixedSlots,
max_peers_per_torrent: 128,
pass0_grace_secs: 60,
proactive_evictions_per_minute_limit: 30,
eviction_ban_duration_secs: 600,
eviction_ban_set_cap: 1024,
peer_read_timeout_secs: 10,
peer_write_timeout_secs: 10,
data_contribution_timeout_secs: 0,
choke_rotation_max_evictions: 0,
max_concurrent_connects: 128,
connect_soft_timeout: 3,
dispatch_backlog_cap: 8,
event_backlog_cap: 32,
peer_writer_channel_cap: 1024,
use_actor_dispatch: true,
web_seed_progress_throttle_ms: 250,
ssrf_mitigation: true,
allow_idna: false,
validate_https_trackers: true,
max_metadata_size: 4 * 1024 * 1024,
max_message_size: 16 * 1024 * 1024,
max_piece_length: 32 * 1024 * 1024,
max_outstanding_requests: 500,
max_in_flight_pieces: 512,
peer_connect_timeout: 10,
peer_dscp: 0x08,
stats_report_interval: 1000,
runtime_worker_threads: default_runtime_worker_threads(),
pin_cores: true,
lock_warn_threshold_ms: 50,
dht_saved_nodes: Vec::new(),
dht_node_id: None,
qbt_compat: QbtCompatSettings::default(),
category_registry_path: None,
tag_registry_path: None,
notify_on_complete: false,
notify_on_error: false,
on_complete_program: None,
use_incomplete_dir: false,
incomplete_dir: None,
default_skip_hash_check: false,
incomplete_extension_enabled: true,
watched_folder: None,
delete_torrent_after_add: false,
move_completed_enabled: false,
move_completed_to: None,
web_ui_https_enabled: false,
network_interface: None,
default_add_paused: false,
}
}
}
const MAX_TORRENTS_CEILING: usize = 100_000;
impl Settings {
#[must_use]
pub fn min_memory() -> Self {
Self {
disk_cache_size: 8 * 1024 * 1024,
buffer_pool_capacity: 16 * 1024 * 1024,
max_torrents: 20,
max_peers_per_torrent: 30,
active_downloads: 1,
active_seeds: 2,
active_limit: 10,
alert_channel_size: 256,
utp_max_connections: 64,
max_request_queue_depth: 50,
peer_writer_channel_cap: 256,
initial_queue_depth: 16,
max_concurrent_stream_reads: 2,
hashing_threads: 1,
disk_io_threads: 1,
dht_max_items: 100,
max_in_flight_pieces: 32,
fixed_pipeline_depth: 32,
..Self::default()
}
}
#[must_use]
pub fn high_performance() -> Self {
Self {
disk_cache_size: 256 * 1024 * 1024,
buffer_pool_capacity: 256 * 1024 * 1024,
max_torrents: 2000,
max_peers_per_torrent: 200,
active_downloads: 30,
active_seeds: 100,
active_limit: 2000,
alert_channel_size: 4096,
utp_max_connections: 1024,
max_request_queue_depth: 1000,
peer_writer_channel_cap: 2048,
initial_queue_depth: 256,
max_concurrent_stream_reads: 32,
hashing_threads: 4,
disk_io_threads: 8,
auto_upload_slots_max: 100,
suggest_mode: true,
steal_threshold_ratio: 5.0,
steal_threshold_endgame: 2.0,
use_block_stealing: true,
max_in_flight_pieces: 512,
..Self::default()
}
}
pub fn validate(&self) -> Result<(), SettingsError> {
if self.force_proxy && self.proxy.proxy_type == ProxyType::None {
return Err(SettingsError::Invalid(
"force_proxy is enabled but no proxy type is configured".into(),
));
}
if self.active_downloads > 0
&& self.active_limit > 0
&& self.active_downloads > self.active_limit
{
return Err(SettingsError::Invalid(
"active_downloads exceeds active_limit".into(),
));
}
if self.active_seeds > 0 && self.active_limit > 0 && self.active_seeds > self.active_limit {
return Err(SettingsError::Invalid(
"active_seeds exceeds active_limit".into(),
));
}
if !(0.0..=1.0).contains(&self.disk_write_cache_ratio) {
return Err(SettingsError::Invalid(
"disk_write_cache_ratio must be between 0.0 and 1.0".into(),
));
}
if self.disk_cache_size < 1024 * 1024 {
return Err(SettingsError::Invalid(
"disk_cache_size must be at least 1 MiB".into(),
));
}
if self.hashing_threads == 0 {
return Err(SettingsError::Invalid(
"hashing_threads must be at least 1".into(),
));
}
if self.peer_writer_channel_cap == 0 {
return Err(SettingsError::Invalid(
"peer_writer_channel_cap must be > 0 (the writer channel must be bounded)".into(),
));
}
if self.peer_writer_channel_cap <= self.max_request_queue_depth {
return Err(SettingsError::Invalid(
"peer_writer_channel_cap must be > max_request_queue_depth (avoids false-positive writer-backpressure disconnects)".into(),
));
}
if self.use_incomplete_dir && self.incomplete_dir.is_none() {
return Err(SettingsError::Invalid(
"incomplete_dir must be set when use_incomplete_dir=true".into(),
));
}
if self.move_completed_enabled && self.move_completed_to.is_none() {
return Err(SettingsError::Invalid(
"move_completed_to must be set when move_completed_enabled=true".into(),
));
}
for (name, opt) in [
("watched_folder", self.watched_folder.as_ref()),
("incomplete_dir", self.incomplete_dir.as_ref()),
("move_completed_to", self.move_completed_to.as_ref()),
] {
if let Some(p) = opt
&& !p.is_absolute()
{
return Err(SettingsError::Invalid(format!(
"{name} must be an absolute path, got {}",
p.display()
)));
}
}
if let Some(p) = self.watched_folder.as_ref() {
const DENY: &[&str] = &[
"/", "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/sys", "/proc",
"/dev", "/run", "/var/lib", "/var/log",
];
let s = p.to_string_lossy();
if DENY.iter().any(|d| s == *d) {
return Err(SettingsError::Invalid(format!(
"watched_folder rejected: {} is a system path (would risk shredding system files if delete_torrent_after_add=true)",
p.display()
)));
}
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(home);
if p == &home_path {
return Err(SettingsError::Invalid(format!(
"watched_folder cannot be $HOME ({}) — too broad to be a torrent dropbox; pick a dedicated subdirectory",
p.display()
)));
}
}
}
if self.max_uploads_per_torrent == 0 || self.max_uploads_per_torrent < -1 {
return Err(SettingsError::Invalid(
"max_uploads_per_torrent must be -1 (unlimited) or >= 1".into(),
));
}
if self.disk_io_threads == 0 {
return Err(SettingsError::Invalid(
"disk_io_threads must be at least 1".into(),
));
}
if self.max_blocking_threads == 0 {
return Err(SettingsError::Invalid(
"max_blocking_threads must be at least 1".into(),
));
}
if self.max_torrents == 0 {
return Err(SettingsError::Invalid(
"max_torrents must be at least 1".into(),
));
}
if self.max_torrents > MAX_TORRENTS_CEILING {
return Err(SettingsError::Invalid(format!(
"max_torrents {} exceeds the {MAX_TORRENTS_CEILING} ceiling",
self.max_torrents
)));
}
if self.default_share_mode && !self.enable_fast_extension {
return Err(SettingsError::Invalid(
"share_mode requires enable_fast_extension for RejectRequest messages".into(),
));
}
if self.ssl_cert_path.is_some() != self.ssl_key_path.is_some() {
return Err(SettingsError::Invalid(
"ssl_cert_path and ssl_key_path must both be set or both absent".into(),
));
}
if self.enable_i2p {
if self.i2p_inbound_quantity == 0 || self.i2p_inbound_quantity > 16 {
return Err(SettingsError::Invalid(
"i2p_inbound_quantity must be 1-16".into(),
));
}
if self.i2p_outbound_quantity == 0 || self.i2p_outbound_quantity > 16 {
return Err(SettingsError::Invalid(
"i2p_outbound_quantity must be 1-16".into(),
));
}
if self.i2p_inbound_length > 7 {
return Err(SettingsError::Invalid(
"i2p_inbound_length must be 0-7".into(),
));
}
if self.i2p_outbound_length > 7 {
return Err(SettingsError::Invalid(
"i2p_outbound_length must be 0-7".into(),
));
}
}
if self.runtime_worker_threads > 256 {
return Err(SettingsError::Invalid(
"runtime_worker_threads must be at most 256".into(),
));
}
if self.qbt_compat.enabled {
if self.qbt_compat.username.is_empty() {
return Err(SettingsError::Invalid(
"qbt_compat.username must not be empty when enabled".into(),
));
}
if self.qbt_compat.password_hash.is_empty() {
if self.qbt_compat.password.len() < 8 {
return Err(SettingsError::Invalid(
"qbt_compat: either password_hash must be set OR \
password must be at least 8 characters (legacy upgrade path)"
.into(),
));
}
} else if !self.qbt_compat.password_hash.starts_with("$argon2id$") {
return Err(SettingsError::Invalid(
"qbt_compat.password_hash must be an argon2id PHC string \
starting with `$argon2id$`"
.into(),
));
}
if let Some(0) = self.qbt_compat.max_concurrent_argon2_ops {
return Err(SettingsError::Invalid(
"qbt_compat.max_concurrent_argon2_ops must be > 0 when set".into(),
));
}
if !is_valid_app_version(&self.qbt_compat.spoof_app_version) {
return Err(SettingsError::Invalid(
"qbt_compat.spoof_app_version must match vN.N[.N][-suffix] (e.g. v5.1.4)"
.into(),
));
}
if !is_valid_webapi_version(&self.qbt_compat.spoof_webapi_version) {
return Err(SettingsError::Invalid(
"qbt_compat.spoof_webapi_version must match N.N[.N] (e.g. 2.11.4)".into(),
));
}
if !(60..=604_800).contains(&self.qbt_compat.session_ttl_secs) {
return Err(SettingsError::Invalid(
"qbt_compat.session_ttl_secs must be in [60, 604800]".into(),
));
}
if self.qbt_compat.max_sessions == 0 {
return Err(SettingsError::Invalid(
"qbt_compat.max_sessions must be at least 1".into(),
));
}
for entry in &self.qbt_compat.web_ui_reverse_proxies_list {
if entry.parse::<ipnet::IpNet>().is_err() {
return Err(SettingsError::Invalid(format!(
"qbt_compat.web_ui_reverse_proxies_list: invalid CIDR '{entry}'"
)));
}
}
if self.qbt_compat.max_failed_auth_count == 0 && !self.qbt_compat.bypass_local_auth {
return Err(SettingsError::Invalid(
"qbt_compat.max_failed_auth_count must be > 0 when bypass_local_auth is false"
.into(),
));
}
if !(60..=86_400).contains(&self.qbt_compat.ban_duration_secs) {
return Err(SettingsError::Invalid(
"qbt_compat.ban_duration_secs must be in [60, 86400]".into(),
));
}
for cidr in &self.qbt_compat.bypass_auth_subnet_whitelist {
if cidr.parse::<ipnet::IpNet>().is_err() {
return Err(SettingsError::Invalid(format!(
"qbt_compat.bypass_auth_subnet_whitelist: invalid CIDR `{cidr}`"
)));
}
}
if let Some(cap) = self.qbt_compat.brute_force_registry_capacity
&& cap < 100
{
return Err(SettingsError::Invalid(
"qbt_compat.brute_force_registry_capacity must be at least 100".into(),
));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_settings_values() {
let s = Settings::default();
assert_eq!(s.listen_port, 42020);
assert_eq!(s.download_dir, PathBuf::from("."));
assert_eq!(s.max_torrents, 100);
assert!(s.resume_data_dir.is_none());
assert_eq!(s.save_resume_interval_secs, 300);
assert!(s.enable_dht);
assert!(s.enable_pex);
assert!(s.enable_lsd);
assert!(s.enable_fast_extension);
assert!(s.enable_utp);
assert!(s.enable_upnp);
assert!(s.enable_natpmp);
assert!(s.enable_ipv6);
assert!(s.enable_web_seed);
assert_eq!(s.encryption_mode, EncryptionMode::Disabled);
assert!(!s.anonymous_mode);
assert!(s.seed_ratio_limit.is_none());
assert!(!s.default_super_seeding);
assert!(!s.default_share_mode);
assert!(s.upload_only_announce);
assert_eq!(s.upload_rate_limit, 0);
assert_eq!(s.download_rate_limit, 0);
assert!(s.auto_upload_slots);
assert_eq!(s.active_downloads, 3);
assert_eq!(s.active_seeds, 5);
assert_eq!(s.active_limit, 500);
assert_eq!(s.active_checking, 3);
assert!(s.dont_count_slow_torrents);
assert_eq!(s.alert_mask, AlertCategory::ALL);
assert_eq!(s.alert_channel_size, 1024);
assert_eq!(s.smart_ban_max_failures, 3);
assert!(s.smart_ban_parole);
assert_eq!(s.disk_io_threads, default_disk_io_threads());
assert_eq!(s.max_blocking_threads, default_max_blocking_threads());
assert_eq!(s.storage_mode, StorageMode::Auto);
assert_eq!(s.disk_cache_size, 16 * 1024 * 1024);
assert!((s.disk_write_cache_ratio - 0.5).abs() < f32::EPSILON);
assert_eq!(s.disk_channel_capacity, 512);
assert_eq!(s.hashing_threads, default_hashing_threads());
assert_eq!(s.max_request_queue_depth, 250);
assert_eq!(s.initial_queue_depth, 128);
assert!((s.request_queue_time - 3.0).abs() < f64::EPSILON);
assert_eq!(s.block_request_timeout_secs, 60);
assert_eq!(s.max_concurrent_stream_reads, 8);
assert!(!s.force_proxy);
assert!(s.apply_ip_filter_to_trackers);
assert_eq!(s.dht_queries_per_second, 50);
assert_eq!(s.dht_query_timeout_secs, 5);
assert!(!s.dht_enforce_node_id);
assert!(s.dht_restrict_routing_ips);
assert_eq!(s.upnp_lease_duration, 3600);
assert_eq!(s.natpmp_lifetime, 7200);
assert_eq!(s.utp_max_connections, 256);
assert_eq!(s.mixed_mode_algorithm, MixedModeAlgorithm::PeerProportional);
assert!(s.auto_sequential);
assert!(s.strict_end_game);
assert_eq!(s.max_web_seeds, 4);
assert_eq!(s.initial_picker_threshold, 4);
assert_eq!(s.whole_pieces_threshold, 20);
assert_eq!(s.snub_timeout_secs, 15);
assert_eq!(s.readahead_pieces, 8);
assert!(s.streaming_timeout_escalation);
assert_eq!(s.max_peers_per_torrent, 128);
assert_eq!(s.runtime_worker_threads, default_runtime_worker_threads());
assert!(s.pin_cores);
}
#[test]
fn min_memory_preset() {
let s = Settings::min_memory();
assert_eq!(s.disk_cache_size, 8 * 1024 * 1024);
assert_eq!(s.max_torrents, 20);
assert_eq!(s.max_peers_per_torrent, 30);
assert_eq!(s.active_downloads, 1);
assert_eq!(s.active_seeds, 2);
assert_eq!(s.active_limit, 10);
assert_eq!(s.alert_channel_size, 256);
assert_eq!(s.utp_max_connections, 64);
assert_eq!(s.max_request_queue_depth, 50);
assert_eq!(s.initial_queue_depth, 16);
assert_eq!(s.max_concurrent_stream_reads, 2);
assert_eq!(s.hashing_threads, 1);
assert_eq!(s.disk_io_threads, 1);
}
#[test]
fn high_performance_preset() {
let s = Settings::high_performance();
assert_eq!(s.disk_cache_size, 256 * 1024 * 1024);
assert_eq!(s.max_torrents, 2000);
assert_eq!(s.max_peers_per_torrent, 200);
assert_eq!(s.active_downloads, 30);
assert_eq!(s.active_seeds, 100);
assert_eq!(s.active_limit, 2000);
assert_eq!(s.alert_channel_size, 4096);
assert_eq!(s.utp_max_connections, 1024);
assert_eq!(s.max_request_queue_depth, 1000);
assert_eq!(s.initial_queue_depth, 256);
assert_eq!(s.max_concurrent_stream_reads, 32);
assert_eq!(s.hashing_threads, 4);
assert_eq!(s.disk_io_threads, 8);
assert_eq!(s.auto_upload_slots_max, 100);
}
#[test]
fn json_round_trip() {
let original = Settings::default();
let json = serde_json::to_string(&original).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(original, decoded);
}
#[test]
fn json_round_trip_presets() {
for original in [Settings::min_memory(), Settings::high_performance()] {
let json = serde_json::to_string(&original).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(original, decoded);
}
}
#[test]
fn json_missing_fields_use_defaults() {
let decoded: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(decoded, Settings::default());
}
#[test]
fn seed_time_limits_default_none() {
let s = Settings::default();
assert!(s.seed_time_limit_secs.is_none());
assert!(s.inactive_seed_time_limit_secs.is_none());
}
#[test]
fn seed_time_limits_round_trip_json() {
let s = Settings {
seed_time_limit_secs: Some(3600),
inactive_seed_time_limit_secs: Some(1800),
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.seed_time_limit_secs, Some(3600));
assert_eq!(decoded.inactive_seed_time_limit_secs, Some(1800));
}
#[test]
fn seed_time_limits_skipped_when_none() {
let s = Settings::default();
let json = serde_json::to_string(&s).unwrap();
assert!(
!json.contains("seed_time_limit_secs"),
"None should not be serialised: {json}"
);
assert!(
!json.contains("inactive_seed_time_limit_secs"),
"None should not be serialised: {json}"
);
}
#[test]
fn m171_settings_defaults_pause_true_false_false() {
let s = Settings::default();
assert_eq!(s.max_ratio_action, MaxRatioAction::Pause);
assert!(
s.create_subfolder,
"create_subfolder defaults true (qBt factory default)"
);
assert!(!s.auto_manage_torrents);
assert!(!s.queueing_enabled);
}
#[test]
fn m171_settings_round_trip_preserves_all_four() {
let s = Settings {
max_ratio_action: MaxRatioAction::EnableSuperSeeding,
create_subfolder: false,
auto_manage_torrents: true,
queueing_enabled: true,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, s);
}
#[test]
fn max_ratio_action_wire_snake_case() {
let pause = serde_json::to_string(&MaxRatioAction::Pause).unwrap();
let remove = serde_json::to_string(&MaxRatioAction::Remove).unwrap();
let super_seed = serde_json::to_string(&MaxRatioAction::EnableSuperSeeding).unwrap();
assert_eq!(pause, "\"pause\"");
assert_eq!(remove, "\"remove\"");
assert_eq!(super_seed, "\"enable_super_seeding\"");
}
#[test]
fn max_ratio_action_wire_snake_case_round_trip() {
let pause: MaxRatioAction = serde_json::from_str("\"pause\"").unwrap();
let remove: MaxRatioAction = serde_json::from_str("\"remove\"").unwrap();
let super_seed: MaxRatioAction = serde_json::from_str("\"enable_super_seeding\"").unwrap();
assert_eq!(pause, MaxRatioAction::Pause);
assert_eq!(remove, MaxRatioAction::Remove);
assert_eq!(super_seed, MaxRatioAction::EnableSuperSeeding);
}
#[test]
fn validation_force_proxy_no_proxy() {
let s = Settings {
force_proxy: true,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("force_proxy"));
}
#[test]
fn validation_valid_defaults() {
Settings::default().validate().unwrap();
Settings::min_memory().validate().unwrap();
Settings::high_performance().validate().unwrap();
}
#[test]
fn external_ip_default_and_json() {
let s = Settings::default();
assert!(s.external_ip.is_none());
let json = r#"{"external_ip": "203.0.113.5"}"#;
let decoded: Settings = serde_json::from_str(json).unwrap();
assert_eq!(
decoded.external_ip,
Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(
203, 0, 113, 5
)))
);
let encoded = serde_json::to_string(&decoded).unwrap();
let roundtrip: Settings = serde_json::from_str(&encoded).unwrap();
assert_eq!(roundtrip.external_ip, decoded.external_ip);
}
#[test]
fn validation_zero_threads() {
let s = Settings {
hashing_threads: 0,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("hashing_threads"));
let s = Settings {
disk_io_threads: 0,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("disk_io_threads"));
let s = Settings {
max_blocking_threads: 0,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("max_blocking_threads"));
}
#[test]
fn m241_validate_rejects_zero_max_torrents() {
let s = Settings {
max_torrents: 0,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("max_torrents"));
}
#[test]
fn m241_validate_rejects_max_torrents_over_ceiling() {
let s = Settings {
max_torrents: 100_001,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("max_torrents"));
}
#[test]
fn m241_validate_accepts_max_torrents_at_ceiling() {
let s = Settings {
max_torrents: 100_000,
..Settings::default()
};
s.validate().unwrap();
}
#[test]
fn m241_validate_accepts_reasonable_max_torrents() {
let s = Settings {
max_torrents: 500,
..Settings::default()
};
s.validate().unwrap();
}
#[test]
fn share_mode_requires_fast_extension() {
let mut s = Settings {
default_share_mode: true,
enable_fast_extension: false,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("share_mode"));
s.enable_fast_extension = true;
s.validate().unwrap();
}
#[test]
fn dht_storage_settings_defaults() {
let s = Settings::default();
assert_eq!(s.dht_max_items, 700);
assert_eq!(s.dht_item_lifetime_secs, 7200);
}
#[test]
fn dht_sample_interval_default_disabled() {
let s = Settings::default();
assert_eq!(s.dht_sample_infohashes_interval, 0);
}
#[test]
fn dht_sample_interval_json_round_trip() {
let json = r#"{"dht_sample_infohashes_interval": 300}"#;
let decoded: Settings = serde_json::from_str(json).unwrap();
assert_eq!(decoded.dht_sample_infohashes_interval, 300);
let encoded = serde_json::to_string(&decoded).unwrap();
let roundtrip: Settings = serde_json::from_str(&encoded).unwrap();
assert_eq!(roundtrip.dht_sample_infohashes_interval, 300);
}
#[test]
fn min_memory_restricts_dht_items() {
let s = Settings::min_memory();
assert_eq!(s.dht_max_items, 100);
}
#[test]
fn enable_holepunch_default_true() {
let s = Settings::default();
assert!(s.enable_holepunch);
}
#[test]
fn enable_holepunch_json_round_trip() {
let json = r#"{"enable_holepunch": false}"#;
let decoded: Settings = serde_json::from_str(json).unwrap();
assert!(!decoded.enable_holepunch);
let encoded = serde_json::to_string(&decoded).unwrap();
let roundtrip: Settings = serde_json::from_str(&encoded).unwrap();
assert!(!roundtrip.enable_holepunch);
}
#[test]
fn i2p_settings_defaults() {
let s = Settings::default();
assert!(!s.enable_i2p);
assert_eq!(s.i2p_hostname, "127.0.0.1");
assert_eq!(s.i2p_port, 7656);
assert_eq!(s.i2p_inbound_quantity, 3);
assert_eq!(s.i2p_outbound_quantity, 3);
assert_eq!(s.i2p_inbound_length, 3);
assert_eq!(s.i2p_outbound_length, 3);
assert!(!s.allow_i2p_mixed);
}
#[test]
fn i2p_settings_json_roundtrip() {
let s = Settings {
enable_i2p: true,
i2p_hostname: "10.0.0.1".into(),
i2p_port: 7700,
i2p_inbound_quantity: 5,
i2p_outbound_quantity: 4,
i2p_inbound_length: 2,
i2p_outbound_length: 1,
allow_i2p_mixed: true,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(s, decoded);
}
#[test]
fn i2p_validation_quantity_zero() {
let s = Settings {
enable_i2p: true,
i2p_inbound_quantity: 0,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("i2p_inbound_quantity"));
}
#[test]
fn i2p_validation_quantity_too_high() {
let s = Settings {
enable_i2p: true,
i2p_outbound_quantity: 17,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("i2p_outbound_quantity"));
}
#[test]
fn i2p_validation_length_too_high() {
let s = Settings {
enable_i2p: true,
i2p_inbound_length: 8,
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("i2p_inbound_length"));
}
#[test]
fn i2p_validation_passes_when_disabled() {
let mut s = Settings {
enable_i2p: false,
..Settings::default()
};
s.i2p_inbound_quantity = 0; s.validate().unwrap(); }
#[test]
fn i2p_validation_valid_config() {
let s = Settings {
enable_i2p: true,
i2p_inbound_quantity: 1,
i2p_outbound_quantity: 16,
i2p_inbound_length: 0,
i2p_outbound_length: 7,
..Settings::default()
};
s.validate().unwrap();
}
#[test]
fn ssl_settings_defaults() {
let s = Settings::default();
assert_eq!(s.ssl_listen_port, 0);
assert!(s.ssl_cert_path.is_none());
assert!(s.ssl_key_path.is_none());
}
#[test]
fn ssl_settings_json_round_trip() {
let s = Settings {
ssl_listen_port: 4433,
ssl_cert_path: Some(PathBuf::from("/etc/ssl/cert.pem")),
ssl_key_path: Some(PathBuf::from("/etc/ssl/key.pem")),
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(s, decoded);
}
#[test]
fn ssl_validation_cert_without_key() {
let s = Settings {
ssl_cert_path: Some(PathBuf::from("/tmp/cert.pem")),
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("ssl_cert_path"));
}
#[test]
fn ssl_validation_key_without_cert() {
let s = Settings {
ssl_key_path: Some(PathBuf::from("/tmp/key.pem")),
..Settings::default()
};
let err = s.validate().unwrap_err();
assert!(err.to_string().contains("ssl_cert_path"));
}
#[test]
fn ssl_validation_both_set_passes() {
let s = Settings {
ssl_cert_path: Some(PathBuf::from("/tmp/cert.pem")),
ssl_key_path: Some(PathBuf::from("/tmp/key.pem")),
..Settings::default()
};
s.validate().unwrap();
}
#[test]
fn ssl_validation_both_absent_passes() {
let s = Settings::default();
s.validate().unwrap();
}
#[test]
fn default_choking_algorithms() {
let s = Settings::default();
assert_eq!(
s.seed_choking_algorithm,
SeedChokingAlgorithm::FastestUpload
);
assert_eq!(s.choking_algorithm, ChokingAlgorithm::FixedSlots);
}
#[test]
fn choking_algorithm_json_round_trip() {
let s = Settings {
seed_choking_algorithm: SeedChokingAlgorithm::AntiLeech,
choking_algorithm: ChokingAlgorithm::RateBased,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(
decoded.seed_choking_algorithm,
SeedChokingAlgorithm::AntiLeech
);
assert_eq!(decoded.choking_algorithm, ChokingAlgorithm::RateBased);
}
#[test]
fn m44_settings_defaults() {
let s = Settings::default();
assert!(s.piece_extent_affinity);
assert!(s.suggest_mode);
assert_eq!(s.max_suggest_pieces, 16);
assert_eq!(s.predictive_piece_announce_ms, 0);
}
#[test]
fn m44_high_performance_enables_suggest() {
let s = Settings::high_performance();
assert!(s.suggest_mode);
}
#[test]
fn m44_json_round_trip() {
let s = Settings {
piece_extent_affinity: false,
suggest_mode: true,
max_suggest_pieces: 5,
predictive_piece_announce_ms: 50,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(s, decoded);
}
#[test]
fn security_settings_defaults() {
let s = Settings::default();
assert!(s.ssrf_mitigation);
assert!(!s.allow_idna);
assert!(s.validate_https_trackers);
}
#[test]
fn security_settings_json_round_trip() {
let s = Settings {
ssrf_mitigation: false,
allow_idna: true,
validate_https_trackers: false,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(s, decoded);
}
#[test]
fn security_settings_missing_use_defaults() {
let decoded: Settings = serde_json::from_str("{}").unwrap();
assert!(decoded.ssrf_mitigation);
assert!(!decoded.allow_idna);
assert!(decoded.validate_https_trackers);
}
#[test]
fn default_peer_dscp_value() {
let s = Settings::default();
assert_eq!(s.peer_dscp, 0x08);
}
#[test]
fn peer_dscp_json_round_trip() {
let s = Settings {
peer_dscp: 0x2E, ..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.peer_dscp, 0x2E);
}
#[test]
fn peer_dscp_zero_disables() {
let s = Settings {
peer_dscp: 0,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.peer_dscp, 0);
}
#[test]
fn default_stats_report_interval() {
let s = Settings::default();
assert_eq!(s.stats_report_interval, 1000);
}
#[test]
fn stats_report_interval_json_round_trip() {
let s = Settings {
stats_report_interval: 5000,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.stats_report_interval, 5000);
}
#[test]
fn stats_report_interval_zero_disables() {
let s = Settings {
stats_report_interval: 0,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
let decoded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.stats_report_interval, 0);
}
#[test]
fn settings_runtime_worker_threads_and_pin_cores() {
let s = Settings::default();
assert_eq!(s.runtime_worker_threads, default_runtime_worker_threads());
assert!(s.pin_cores);
let mut s = Settings {
runtime_worker_threads: 0,
..Settings::default()
};
assert!(s.validate().is_ok());
s.runtime_worker_threads = 256;
assert!(s.validate().is_ok());
s.runtime_worker_threads = 257;
assert!(s.validate().is_err());
}
#[test]
fn max_in_flight_512_default() {
let s = Settings::default();
assert_eq!(s.max_in_flight_pieces, 512);
assert_eq!(s.fixed_pipeline_depth, 128);
let mm = Settings::min_memory();
assert_eq!(mm.max_in_flight_pieces, 32);
assert_eq!(mm.fixed_pipeline_depth, 32);
let hp = Settings::high_performance();
assert_eq!(hp.max_in_flight_pieces, 512);
assert_eq!(hp.fixed_pipeline_depth, 128); }
#[test]
fn recalc_max_in_flight_formula() {
let base = 512_usize;
let connected = 10;
let num_pieces = 2000_u32;
let calculated = base.max(connected * 4);
let result = calculated.min(num_pieces as usize / 2).max(base);
assert_eq!(result, 512);
let connected = 200;
let calculated = base.max(connected * 4);
let result = calculated.min(num_pieces as usize / 2).max(base);
assert_eq!(result, 800);
let connected = 200;
let num_pieces = 100_u32;
let calculated = base.max(connected * 4);
let result = calculated.min(num_pieces as usize / 2).max(base);
assert_eq!(result, 512);
let connected = 129; let num_pieces = 10000_u32;
let calculated = base.max(connected * 4);
let result = calculated.min(num_pieces as usize / 2).max(base);
assert_eq!(result, 516); }
#[test]
fn settings_default_enables_qbt_compat_v0_172_1() {
let s = Settings::default();
assert!(s.qbt_compat.enabled);
assert_eq!(s.qbt_compat.username, "admin");
assert_eq!(s.qbt_compat.password, "");
assert!(
s.qbt_compat
.password_hash
.starts_with("$argon2id$v=19$m=19456,t=2,p=1$")
);
assert_eq!(s.qbt_compat.spoof_app_version, "v5.1.4");
assert_eq!(s.qbt_compat.spoof_webapi_version, "2.11.4");
assert_eq!(s.qbt_compat.session_ttl_secs, 86_400);
assert_eq!(s.qbt_compat.max_sessions, 1024);
assert!(s.qbt_compat.max_concurrent_argon2_ops.is_none());
}
#[test]
fn validate_rejects_empty_username() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.username = String::new();
let err = s.validate().expect_err("empty username must fail");
let msg = format!("{err}");
assert!(msg.contains("username"), "error was: {msg}");
}
#[test]
fn validate_rejects_short_legacy_password_lt_8_when_hash_empty() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.password_hash.clear();
s.qbt_compat.password = "short".into();
let err = s.validate().expect_err("short password must fail");
let msg = format!("{err}");
assert!(
msg.contains("password") && msg.contains("hash"),
"error was: {msg}"
);
}
#[test]
fn validate_rejects_bad_app_version_format() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.spoof_app_version = "garbage".into();
let err = s.validate().expect_err("bad app version must fail");
let msg = format!("{err}");
assert!(msg.contains("spoof_app_version"), "error was: {msg}");
}
#[test]
fn validate_rejects_bad_webapi_version_format() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.spoof_webapi_version = "v2.11".into(); let err = s.validate().expect_err("bad webapi version must fail");
let msg = format!("{err}");
assert!(msg.contains("spoof_webapi_version"), "error was: {msg}");
}
#[test]
fn validate_rejects_ttl_out_of_bounds() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.session_ttl_secs = 10; let err = s.validate().expect_err("ttl too small must fail");
assert!(format!("{err}").contains("session_ttl_secs"));
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.session_ttl_secs = 604_801; let err = s.validate().expect_err("ttl too large must fail");
assert!(format!("{err}").contains("session_ttl_secs"));
}
#[test]
fn default_hash_roundtrips_admin_admin() {
use argon2::Argon2;
use argon2::password_hash::{PasswordHash, PasswordVerifier};
let hash = PasswordHash::new(DEFAULT_ADMINADMIN_HASH)
.expect("DEFAULT_ADMINADMIN_HASH must be a valid PHC string");
Argon2::default()
.verify_password(b"adminadmin", &hash)
.expect("default hash must verify the 'adminadmin' plaintext");
}
#[test]
fn validate_rejects_password_hash_not_starting_with_argon2id() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.password_hash =
"$2b$12$KIXQ5.pHJN3iLz9H6CfQEe2/6rFv1h4jdXWv.0eoGzJ6w7L4Yj7vi".into();
let err = s.validate().expect_err("non-argon2id hash must fail");
let msg = format!("{err}");
assert!(msg.contains("argon2id"), "error was: {msg}");
}
#[test]
fn validate_rejects_zero_max_concurrent_argon2_ops() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.max_concurrent_argon2_ops = Some(0);
let err = s.validate().expect_err("zero argon2 semaphore must fail");
assert!(format!("{err}").contains("max_concurrent_argon2_ops"));
}
#[test]
fn default_settings_ship_pre_hashed_no_migration_needed() {
let s = Settings::default();
assert!(s.qbt_compat.password_hash.starts_with("$argon2id$"));
assert!(s.qbt_compat.password.is_empty());
}
#[test]
fn hash_qbt_password_roundtrips() {
let h = hash_qbt_password("correct horse battery staple")
.expect("hash must succeed for a simple plaintext");
assert!(h.starts_with("$argon2id$v=19$m=19456,t=2,p=1$"));
let h2 =
hash_qbt_password("correct horse battery staple").expect("second hash must succeed");
assert_ne!(h, h2, "argon2 must use a fresh salt per call");
}
#[test]
fn migrate_qbt_credentials_noop_when_hash_present() {
let mut qbt = QbtCompatSettings {
password_hash: DEFAULT_ADMINADMIN_HASH.into(),
password: String::new(),
..Default::default()
};
let outcome = migrate_qbt_credentials(&mut qbt).expect("noop");
assert_eq!(outcome, QbtCredentialMigration::NoOp);
assert_eq!(qbt.password_hash, DEFAULT_ADMINADMIN_HASH);
assert!(qbt.password.is_empty());
}
#[test]
fn migrate_qbt_credentials_upgrades_legacy_plaintext() {
use argon2::Argon2;
use argon2::password_hash::{PasswordHash, PasswordVerifier};
let mut qbt = QbtCompatSettings {
password_hash: String::new(),
password: "legacy-plaintext-pw".into(),
..Default::default()
};
let outcome = migrate_qbt_credentials(&mut qbt).expect("upgrade");
assert_eq!(outcome, QbtCredentialMigration::Upgraded);
assert!(qbt.password_hash.starts_with("$argon2id$"));
assert!(
qbt.password.is_empty(),
"plaintext must be zeroed after migration"
);
let parsed =
PasswordHash::new(&qbt.password_hash).expect("migration wrote a valid PHC string");
Argon2::default()
.verify_password(b"legacy-plaintext-pw", &parsed)
.expect("migrated hash must verify the original plaintext");
}
#[test]
fn migrate_qbt_credentials_noop_when_both_empty() {
let mut qbt = QbtCompatSettings {
password_hash: String::new(),
password: String::new(),
..Default::default()
};
let outcome = migrate_qbt_credentials(&mut qbt).expect("noop on empty");
assert_eq!(outcome, QbtCredentialMigration::NoOp);
}
#[test]
fn brute_force_defaults_are_5_attempts_and_one_hour_ban() {
let s = Settings::default();
assert_eq!(s.qbt_compat.max_failed_auth_count, 5);
assert_eq!(s.qbt_compat.ban_duration_secs, 3_600);
assert!(!s.qbt_compat.bypass_local_auth);
assert!(s.qbt_compat.bypass_auth_subnet_whitelist.is_empty());
assert!(s.qbt_compat.brute_force_registry_capacity.is_none());
}
#[test]
fn validate_rejects_zero_max_failed_auth_count_without_bypass() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.max_failed_auth_count = 0;
s.qbt_compat.bypass_local_auth = false;
let err = s
.validate()
.expect_err("zero attempts without bypass must fail");
assert!(format!("{err}").contains("max_failed_auth_count"));
}
#[test]
fn validate_accepts_zero_max_failed_auth_count_when_bypass_local() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.max_failed_auth_count = 0;
s.qbt_compat.bypass_local_auth = true;
s.validate().expect("bypass_local_auth disarms the check");
}
#[test]
fn validate_rejects_ban_duration_out_of_bounds() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.ban_duration_secs = 59;
let err = s.validate().expect_err("too short ban must fail");
assert!(format!("{err}").contains("ban_duration_secs"));
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.ban_duration_secs = 86_401;
let err = s.validate().expect_err("too long ban must fail");
assert!(format!("{err}").contains("ban_duration_secs"));
}
#[test]
fn validate_rejects_malformed_bypass_whitelist_cidr() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.bypass_auth_subnet_whitelist = vec!["not-a-cidr".into()];
let err = s.validate().expect_err("bad cidr must fail");
let msg = format!("{err}");
assert!(msg.contains("bypass_auth_subnet_whitelist"));
assert!(msg.contains("not-a-cidr"));
}
#[test]
fn validate_accepts_valid_bypass_whitelist_cidrs() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.bypass_auth_subnet_whitelist = vec![
"10.0.0.0/8".into(),
"192.168.1.0/24".into(),
"::1/128".into(),
];
s.validate().expect("valid cidrs pass");
}
#[test]
fn validate_rejects_registry_capacity_below_floor() {
let mut s = Settings::default();
s.qbt_compat.enabled = true;
s.qbt_compat.brute_force_registry_capacity = Some(99);
let err = s
.validate()
.expect_err("capacity < 100 must fail sanity floor");
assert!(format!("{err}").contains("brute_force_registry_capacity"));
}
#[test]
fn validate_rejects_zero_max_uploads_per_torrent() {
let s = Settings {
max_uploads_per_torrent: 0,
..Settings::default()
};
let err = s
.validate()
.expect_err("max_uploads_per_torrent = 0 must fail");
let msg = format!("{err}");
assert!(msg.contains("max_uploads_per_torrent"), "error was: {msg}");
}
#[test]
fn validate_rejects_negative_below_minus_one_max_uploads_per_torrent() {
let s = Settings {
max_uploads_per_torrent: -2,
..Settings::default()
};
let err = s
.validate()
.expect_err("max_uploads_per_torrent < -1 must fail");
let msg = format!("{err}");
assert!(msg.contains("max_uploads_per_torrent"), "error was: {msg}");
}
#[test]
fn validate_accepts_minus_one_max_uploads_per_torrent() {
let s = Settings::default();
assert_eq!(s.max_uploads_per_torrent, -1);
s.validate().expect("default -1 must validate");
}
#[test]
fn validate_accepts_positive_max_uploads_per_torrent() {
let s = Settings {
max_uploads_per_torrent: 4,
..Settings::default()
};
s.validate().expect("n >= 1 must validate");
}
#[test]
fn max_uploads_per_torrent_default_deserialize_without_field() {
let s = Settings::default();
let mut value = serde_json::to_value(&s).expect("serialise");
let obj = value.as_object_mut().expect("Settings is a JSON object");
assert!(
obj.remove("max_uploads_per_torrent").is_some(),
"field should have been present in serialised default"
);
let decoded: Settings = serde_json::from_value(value).expect("deserialise without field");
assert_eq!(decoded.max_uploads_per_torrent, -1);
decoded.validate().expect("default-via-serde must validate");
}
#[test]
fn brute_force_settings_json_round_trip() {
let mut s = Settings::default();
s.qbt_compat.max_failed_auth_count = 7;
s.qbt_compat.ban_duration_secs = 1_800;
s.qbt_compat.bypass_local_auth = true;
s.qbt_compat.bypass_auth_subnet_whitelist = vec!["10.0.0.0/8".into()];
s.qbt_compat.brute_force_registry_capacity = Some(5_000);
let json = serde_json::to_string(&s).expect("serialise");
let decoded: Settings = serde_json::from_str(&json).expect("deserialise");
assert_eq!(decoded.qbt_compat.max_failed_auth_count, 7);
assert_eq!(decoded.qbt_compat.ban_duration_secs, 1_800);
assert!(decoded.qbt_compat.bypass_local_auth);
assert_eq!(
decoded.qbt_compat.bypass_auth_subnet_whitelist,
vec!["10.0.0.0/8".to_string()]
);
assert_eq!(
decoded.qbt_compat.brute_force_registry_capacity,
Some(5_000)
);
}
#[test]
fn settings_default_notify_on_complete_is_false() {
assert!(!Settings::default().notify_on_complete);
}
#[test]
fn settings_default_notify_on_error_is_false() {
assert!(!Settings::default().notify_on_error);
}
#[test]
fn settings_default_on_complete_program_is_none() {
assert!(Settings::default().on_complete_program.is_none());
}
#[test]
fn settings_default_use_incomplete_dir_is_false() {
assert!(!Settings::default().use_incomplete_dir);
}
#[test]
fn settings_default_incomplete_dir_is_none() {
assert!(Settings::default().incomplete_dir.is_none());
}
#[test]
fn settings_default_default_skip_hash_check_is_false() {
assert!(!Settings::default().default_skip_hash_check);
}
#[test]
fn settings_default_incomplete_extension_enabled_is_true() {
assert!(Settings::default().incomplete_extension_enabled);
}
#[test]
fn settings_default_watched_folder_is_none() {
assert!(Settings::default().watched_folder.is_none());
}
#[test]
fn settings_default_delete_torrent_after_add_is_false() {
assert!(!Settings::default().delete_torrent_after_add);
}
#[test]
fn settings_default_move_completed_enabled_is_false() {
assert!(!Settings::default().move_completed_enabled);
}
#[test]
fn settings_default_move_completed_to_is_none() {
assert!(Settings::default().move_completed_to.is_none());
}
#[test]
fn settings_default_web_ui_https_enabled_is_false() {
assert!(!Settings::default().web_ui_https_enabled);
}
#[test]
fn settings_default_network_interface_is_none() {
assert!(Settings::default().network_interface.is_none());
}
#[test]
fn settings_default_default_add_paused_is_false() {
assert!(!Settings::default().default_add_paused);
}
#[test]
fn validate_rejects_use_incomplete_dir_without_incomplete_dir() {
let s = Settings {
use_incomplete_dir: true,
incomplete_dir: None,
..Settings::default()
};
let err = s.validate().expect_err("must require incomplete_dir");
assert!(format!("{err}").contains("incomplete_dir"));
}
#[test]
fn validate_accepts_use_incomplete_dir_with_incomplete_dir() {
let s = Settings {
use_incomplete_dir: true,
incomplete_dir: Some(PathBuf::from("/tmp/irontide-incomplete")),
..Settings::default()
};
s.validate().expect("paired fields valid");
}
#[test]
fn validate_rejects_move_completed_without_move_completed_to() {
let s = Settings {
move_completed_enabled: true,
move_completed_to: None,
..Settings::default()
};
let err = s.validate().expect_err("must require move_completed_to");
assert!(format!("{err}").contains("move_completed_to"));
}
#[test]
fn validate_rejects_relative_watched_folder() {
let s = Settings {
watched_folder: Some(PathBuf::from("relative/path")),
..Settings::default()
};
let err = s.validate().expect_err("relative path must fail");
assert!(format!("{err}").contains("absolute"));
}
#[test]
fn validate_rejects_relative_incomplete_dir() {
let s = Settings {
incomplete_dir: Some(PathBuf::from("inc")),
..Settings::default()
};
let err = s.validate().expect_err("relative path must fail");
assert!(format!("{err}").contains("absolute"));
}
#[test]
fn validate_rejects_relative_move_completed_to() {
let s = Settings {
move_completed_to: Some(PathBuf::from("done")),
..Settings::default()
};
let err = s.validate().expect_err("relative path must fail");
assert!(format!("{err}").contains("absolute"));
}
#[test]
fn validate_rejects_system_path_as_watched_folder() {
for sys in ["/", "/etc", "/usr", "/bin", "/sys", "/proc"] {
let s = Settings {
watched_folder: Some(PathBuf::from(sys)),
..Settings::default()
};
let err = s.validate().expect_err("system path must be rejected");
assert!(
format!("{err}").contains("system path"),
"{sys}: error must mention 'system path', got: {err}"
);
}
}
#[test]
fn validate_accepts_safe_watched_folder() {
let s = Settings {
watched_folder: Some(PathBuf::from("/tmp/irontide-watched")),
..Settings::default()
};
s.validate().expect("safe path must validate");
}
#[test]
fn peer_writer_channel_cap_defaults_to_1024() {
assert_eq!(Settings::default().peer_writer_channel_cap, 1024);
}
#[test]
fn peer_writer_channel_cap_zero_is_rejected() {
let s = Settings {
peer_writer_channel_cap: 0,
..Settings::default()
};
let err = s
.validate()
.expect_err("zero writer-channel cap must be rejected");
assert!(format!("{err}").contains("must be > 0"));
}
#[test]
fn peer_writer_channel_cap_at_or_below_request_queue_depth_is_rejected() {
let depth = Settings::default().max_request_queue_depth;
let s = Settings {
peer_writer_channel_cap: depth,
..Settings::default()
};
let err = s
.validate()
.expect_err("cap <= max_request_queue_depth must be rejected");
assert!(format!("{err}").contains("max_request_queue_depth"));
}
#[test]
fn default_cap_exceeds_max_request_queue_depth() {
let s = Settings::default();
assert!(
s.peer_writer_channel_cap > s.max_request_queue_depth,
"default writer cap {} must exceed default request queue depth {}",
s.peer_writer_channel_cap,
s.max_request_queue_depth
);
}
}