diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index 8e58db3..ce230c5 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -37,6 +37,10 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar - Optional `bt-prioritize-piece` preview policy for the head, tail, or both ends of every selected file. The constrained policy is validated, persisted, normalized, and reapplied when a Torrent starts or retries. +- One validated Torrent encryption policy mapped to Aria2's + `bt-force-encryption`, `bt-require-crypto`, and `bt-min-crypto-level`: + disabled, required obfuscated handshake, or forced ARC4 payload encryption. + The policy is persisted and reapplied when a Torrent starts or retries. - Optional `bt-remove-unselected-file` cleanup after completion when a selected-file subset is configured. Firelink requires explicit confirmation, reserves the unselected paths against competing downloads, keeps those @@ -44,9 +48,9 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar after observing Aria2's completion cleanup (or on terminal failure, cancellation, or reconfiguration). - Deterministic local Aria2 smoke coverage for metadata resolution, selected - output, piece priority, pause/resume, ownership, cancellation/removal, - unavailable trackers, daemon failure, and `bt-stop-timeout` terminal behavior; - RPC-boundary coverage is separate. + output, piece priority, encryption policy, pause/resume, ownership, + cancellation/removal, unavailable trackers, daemon failure, and + `bt-stop-timeout` terminal behavior; RPC-boundary coverage is separate. ## Priority tiers for remaining work @@ -61,10 +65,7 @@ No remaining Tier 0 items. per-file priority option. Firelink therefore does not pretend that `select-file` is file priority; this remains pending an engine capability or a safe product-level model. -2. **Encryption policy** — expose `bt-force-encryption`, - `bt-require-crypto`, and `bt-min-crypto-level` as one validated policy so - users cannot accidentally select contradictory combinations. -3. **Tracker timing controls** — expose tracker connect timeout, request +2. **Tracker timing controls** — expose tracker connect timeout, request timeout, and interval only when their effect on battery/network behavior is explained and persisted. @@ -79,5 +80,5 @@ No remaining Tier 0 items. The first implementation in this task was remote `.torrent` metadata intake; follow-up implementations add stall-timeout control, bounded peer diagnostics, -persisted tracker exclusion, piece-preview priority, and safe unselected-file -removal. +persisted tracker exclusion, piece-preview priority, safe unselected-file +removal, and the validated encryption policy. diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index 71403e5..dc3f689 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -628,10 +628,11 @@ async function main() { const probeDir = path.join(tempRoot, 'probe'); const finalDir = path.join(tempRoot, 'final'); const integrityDir = path.join(tempRoot, 'integrity'); + const encryptionDir = path.join(tempRoot, 'encryption'); const cancelDir = path.join(tempRoot, 'cancel'); const removeUnselectedDir = path.join(tempRoot, 'remove-unselected'); const stallDir = path.join(tempRoot, 'stall'); - for (const directory of [seedRoot, probeDir, finalDir, integrityDir, cancelDir, removeUnselectedDir, stallDir]) fs.mkdirSync(directory, { recursive: true }); + for (const directory of [seedRoot, probeDir, finalDir, integrityDir, encryptionDir, cancelDir, removeUnselectedDir, stallDir]) fs.mkdirSync(directory, { recursive: true }); const seederListenPort = await findAvailablePort(); const clientListenPort = await findAvailablePort(); @@ -765,6 +766,29 @@ async function main() { assert(fs.readFileSync(integrityPath).equals(torrent.files[0].data), 'integrity check did not replace corrupted torrent data'); console.log('[OK] check-integrity detected and repaired corrupted Torrent data'); + const encryptionGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ + savedTorrentBytes.toString('base64'), + [], + { + dir: encryptionDir, + 'select-file': '1', + 'index-out': indexOut, + 'bt-force-encryption': 'true', + 'bt-require-crypto': 'true', + 'bt-min-crypto-level': 'arc4', + 'seed-time': '0', + 'auto-file-renaming': 'false', + }, + ]); + const encryptionOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [encryptionGid]); + assert(encryptionOptions['bt-force-encryption'] === 'true', 'Aria2 did not retain force encryption'); + assert(encryptionOptions['bt-require-crypto'] === 'true', 'Aria2 did not retain the crypto requirement'); + assert(encryptionOptions['bt-min-crypto-level'] === 'arc4', 'Aria2 did not retain the ARC4 minimum crypto level'); + await waitForTerminal(client, encryptionGid, 30000); + const encryptedPath = path.join(encryptionDir, torrent.name, 'selected.bin'); + assert(fs.readFileSync(encryptedPath).equals(torrent.files[0].data), 'encrypted Torrent output content differs'); + console.log('[OK] Torrent encryption policy retained all three Aria2 options and completed'); + const removalSkippedPath = path.join(removeUnselectedDir, torrent.name, 'skipped.bin'); fs.mkdirSync(path.dirname(removalSkippedPath), { recursive: true }); fs.writeFileSync(removalSkippedPath, Buffer.from('pre-existing file owned outside the Torrent\n')); diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 6d17d39..e849bbd 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -209,6 +209,9 @@ pub struct DownloadItem { #[serde(default)] #[ts(optional)] pub torrent_remove_unselected_file: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_encryption_policy: Option, } #[derive(Clone, Debug, Serialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 14986bd..fbb5bf6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5806,6 +5806,9 @@ async fn validate_torrent_enqueue( item.torrent_prioritize_piece = queue::normalize_torrent_prioritize_piece( item.torrent_prioritize_piece.as_deref(), )?; + item.torrent_encryption_policy = queue::normalize_torrent_encryption_policy( + item.torrent_encryption_policy.as_deref(), + )?; validate_enqueue_uris("", item.mirrors.as_deref()).await?; if let Some(path) = item.torrent_path.as_deref() { let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?; diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 672e8a8..584d02b 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -221,6 +221,7 @@ pub struct SpawnPayload { pub torrent_stop_timeout: Option, pub torrent_prioritize_piece: Option, pub torrent_remove_unselected_file: bool, + pub torrent_encryption_policy: Option, } /// A sidecar spawner. In production this calls the real aria2/yt-dlp @@ -3506,6 +3507,23 @@ pub(crate) fn normalize_torrent_exclude_trackers( normalize_torrent_tracker_list(value, true) } +pub(crate) fn normalize_torrent_encryption_policy( + value: Option<&str>, +) -> Result, String> { + let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + match raw { + "disabled" => Ok(None), + "require-crypto" => Ok(Some("require-crypto".to_string())), + "force-encryption" => Ok(Some("force-encryption".to_string())), + _ => Err( + "torrent encryption policy must be disabled, require-crypto, or force-encryption" + .to_string(), + ), + } +} + fn apply_aria2_torrent_options( options: &mut serde_json::Map, payload: &SpawnPayload, @@ -3514,6 +3532,30 @@ fn apply_aria2_torrent_options( return Ok(()); } + let encryption_policy = + normalize_torrent_encryption_policy(payload.torrent_encryption_policy.as_deref())?; + let (force_encryption, require_crypto, min_crypto_level) = + match encryption_policy.as_deref() { + Some("require-crypto") => (false, true, "plain"), + Some("force-encryption") => (true, true, "arc4"), + None => (false, false, "plain"), + Some(policy) => { + return Err(format!("unsupported normalized Torrent encryption policy: {policy}")); + } + }; + options.insert( + "bt-force-encryption".to_string(), + serde_json::json!(force_encryption.to_string()), + ); + options.insert( + "bt-require-crypto".to_string(), + serde_json::json!(require_crypto.to_string()), + ); + options.insert( + "bt-min-crypto-level".to_string(), + serde_json::json!(min_crypto_level), + ); + let seed_time = payload .torrent_seed_time .map(|value| format_aria2_torrent_number(value, "seed time")) @@ -4224,6 +4266,9 @@ pub struct EnqueueItem { pub torrent_remove_unselected_file: Option, #[serde(default)] #[ts(optional)] + pub torrent_encryption_policy: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -4279,6 +4324,7 @@ impl EnqueueItem { torrent_remove_unselected_file: self .torrent_remove_unselected_file .unwrap_or(false), + torrent_encryption_policy: self.torrent_encryption_policy, }, } } @@ -4343,6 +4389,87 @@ mod tests { assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0"))); assert!(!options.contains_key("max-upload-limit")); + assert_eq!( + options.get("bt-force-encryption"), + Some(&serde_json::json!("false")) + ); + assert_eq!( + options.get("bt-require-crypto"), + Some(&serde_json::json!("false")) + ); + assert_eq!( + options.get("bt-min-crypto-level"), + Some(&serde_json::json!("plain")) + ); + } + + #[test] + fn torrent_encryption_policy_maps_to_one_consistent_aria2_policy() { + let cases = [ + ( + Some("require-crypto"), + ("false", "true", "plain"), + ), + ( + Some("force-encryption"), + ("true", "true", "arc4"), + ), + (None, ("false", "false", "plain")), + ]; + + for (policy, expected) in cases { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_encryption_policy: policy.map(str::to_string), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert_eq!( + options.get("bt-force-encryption"), + Some(&serde_json::json!(expected.0)) + ); + assert_eq!( + options.get("bt-require-crypto"), + Some(&serde_json::json!(expected.1)) + ); + assert_eq!( + options.get("bt-min-crypto-level"), + Some(&serde_json::json!(expected.2)) + ); + } + } + + #[test] + fn torrent_encryption_policy_rejects_unknown_values() { + assert_eq!(normalize_torrent_encryption_policy(None).unwrap(), None); + assert_eq!( + normalize_torrent_encryption_policy(Some(" disabled ")).unwrap(), + None + ); + assert_eq!( + normalize_torrent_encryption_policy(Some("require-crypto")).unwrap(), + Some("require-crypto".to_string()) + ); + assert!(normalize_torrent_encryption_policy(Some("arc4")).is_err()); + assert!(normalize_torrent_encryption_policy(Some("true")).is_err()); + } + + #[test] + fn torrent_encryption_policy_is_not_applied_to_non_torrent_payloads() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + torrent_encryption_policy: Some("force-encryption".to_string()), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert!(!options.contains_key("bt-force-encryption")); + assert!(!options.contains_key("bt-require-crypto")); + assert!(!options.contains_key("bt-min-crypto-level")); } #[test] @@ -4472,6 +4599,26 @@ mod tests { assert!(item.into_task().payload.torrent_check_integrity); } + #[test] + fn enqueue_item_preserves_torrent_encryption_policy() { + let item: EnqueueItem = serde_json::from_value(serde_json::json!({ + "id": "torrent-encryption", + "queue_id": "main", + "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", + "destination": "/tmp/downloads", + "filename": "payload", + "is_media": false, + "is_torrent": true, + "torrent_encryption_policy": "force-encryption" + })) + .expect("frontend enqueue payload should deserialize"); + + assert_eq!( + item.into_task().payload.torrent_encryption_policy.as_deref(), + Some("force-encryption") + ); + } + #[test] fn torrent_trackers_are_normalized_and_deduplicated() { assert_eq!( diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 166bc02..7db14a7 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index ef7a88d..4932dff 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, lifecycle_generation?: string, }; +export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, lifecycle_generation?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index fdc52f4..7f361c2 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { expandTilde, @@ -234,6 +234,7 @@ export const AddDownloadsModal = () => { const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState(''); const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false); + const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState(TORRENT_ENCRYPTION_POLICY_DISABLED); const [torrentTrackers, setTorrentTrackers] = useState(''); const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); @@ -1495,6 +1496,9 @@ export const AddDownloadsModal = () => { torrentRemoveUnselectedFile: item.isTorrent && torrentRemoveUnselectedFile && hasPartialTorrentSelection(item) ? true : undefined, + torrentEncryptionPolicy: item.isTorrent && torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED + ? torrentEncryptionPolicy + : undefined, torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined, torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined, torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined, @@ -2164,6 +2168,31 @@ export const AddDownloadsModal = () => { +
+ + +

+ {t($ => $.addDownloads.torrentEncryptionPolicyHint)} +

+