diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index f5fa262..e85d0ca 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -627,8 +627,9 @@ async function main() { const seedRoot = path.join(seedParent, 'firelink-torrent-runtime'); const probeDir = path.join(tempRoot, 'probe'); const finalDir = path.join(tempRoot, 'final'); + const integrityDir = path.join(tempRoot, 'integrity'); const cancelDir = path.join(tempRoot, 'cancel'); - for (const directory of [seedRoot, probeDir, finalDir, cancelDir]) fs.mkdirSync(directory, { recursive: true }); + for (const directory of [seedRoot, probeDir, finalDir, integrityDir, cancelDir]) fs.mkdirSync(directory, { recursive: true }); const seederListenPort = await findAvailablePort(); const clientListenPort = await findAvailablePort(); @@ -730,6 +731,27 @@ async function main() { assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output'); console.log('[OK] selected addTorrent output, pause/resume, and Aria2 file ownership passed'); + const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin'); + fs.mkdirSync(path.dirname(integrityPath), { recursive: true }); + fs.writeFileSync(integrityPath, Buffer.alloc(torrent.files[0].data.length, 0x00)); + const integrityGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ + savedTorrentBytes.toString('base64'), + [], + { + dir: integrityDir, + 'select-file': '1', + 'index-out': indexOut, + 'check-integrity': 'true', + 'bt-hash-check-seed': 'false', + 'seed-time': '0', + 'auto-file-renaming': 'false', + }, + ]); + const integrityStatus = await waitForTerminal(client, integrityGid, 30000); + assert(integrityStatus.status === 'complete', 'integrity-check torrent did not complete'); + 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 cancelGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ savedTorrentBytes.toString('base64'), [], diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 4f50627..f7a37b8 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -191,6 +191,9 @@ pub struct DownloadItem { #[serde(default)] #[ts(optional)] pub torrent_peer_speed_limit: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_check_integrity: Option, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index b870801..966516b 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -214,6 +214,7 @@ pub struct SpawnPayload { pub torrent_upload_limit: Option, pub torrent_max_peers: Option, pub torrent_peer_speed_limit: Option, + pub torrent_check_integrity: bool, } /// A sidecar spawner. In production this calls the real aria2/yt-dlp @@ -3163,6 +3164,22 @@ fn apply_aria2_torrent_options( serde_json::json!(normalized), ); } + if payload.torrent_check_integrity { + options.insert( + "check-integrity".to_string(), + serde_json::json!("true"), + ); + options.insert( + "bt-hash-check-seed".to_string(), + serde_json::json!(torrent_seeding_requested(payload).to_string()), + ); + // Do not let a daemon-wide bt-seed-unverified setting bypass the + // explicit per-download integrity request. + options.insert( + "bt-seed-unverified".to_string(), + serde_json::json!("false"), + ); + } Ok(()) } @@ -3752,6 +3769,9 @@ pub struct EnqueueItem { pub torrent_peer_speed_limit: Option, #[serde(default)] #[ts(optional)] + pub torrent_check_integrity: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -3799,6 +3819,7 @@ impl EnqueueItem { torrent_upload_limit: self.torrent_upload_limit, torrent_max_peers: self.torrent_max_peers, torrent_peer_speed_limit: self.torrent_peer_speed_limit, + torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false), }, } } @@ -3913,6 +3934,85 @@ mod tests { assert!(torrent_seeding_requested(&payload)); } + #[test] + fn torrent_integrity_check_does_not_enter_seeding_without_a_seed_policy() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_check_integrity: true, + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true"))); + assert_eq!( + options.get("bt-hash-check-seed"), + Some(&serde_json::json!("false")) + ); + assert_eq!( + options.get("bt-seed-unverified"), + Some(&serde_json::json!("false")) + ); + } + + #[test] + fn torrent_integrity_check_preserves_an_explicit_seeding_policy() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_check_integrity: true, + torrent_seed_ratio: Some(1.0), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true"))); + assert_eq!( + options.get("bt-hash-check-seed"), + Some(&serde_json::json!("true")) + ); + assert_eq!( + options.get("bt-seed-unverified"), + Some(&serde_json::json!("false")) + ); + } + + #[test] + fn torrent_integrity_options_are_not_emitted_when_disabled_or_non_torrent() { + for payload in [ + SpawnPayload::default(), + SpawnPayload { + is_torrent: true, + ..Default::default() + }, + ] { + let mut options = serde_json::Map::new(); + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + assert!(!options.contains_key("check-integrity")); + assert!(!options.contains_key("bt-hash-check-seed")); + assert!(!options.contains_key("bt-seed-unverified")); + } + } + + #[test] + fn enqueue_item_deserializes_integrity_policy_from_frontend_payload() { + let item: EnqueueItem = serde_json::from_value(serde_json::json!({ + "id": "torrent-integrity", + "queue_id": "main", + "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", + "destination": "/tmp/downloads", + "filename": "payload", + "is_media": false, + "is_torrent": true, + "torrent_check_integrity": true + })) + .expect("frontend enqueue payload should deserialize"); + + assert!(item.into_task().payload.torrent_check_integrity); + } + #[test] fn torrent_options_reject_invalid_seed_values() { let mut options = serde_json::Map::new(); diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index b010af6..f8addff 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, }; +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, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 075f7e9..1f82dd1 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, 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, lifecycle_generation?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index e7b097d..2853330 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -232,6 +232,7 @@ export const AddDownloadsModal = () => { const [torrentUploadLimit, setTorrentUploadLimit] = useState('1024'); const [torrentMaxPeers, setTorrentMaxPeers] = useState(''); const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState(''); + const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); const [freeSpace, setFreeSpace] = useState('Unknown'); const freeSpaceRequestRef = useRef(0); @@ -372,6 +373,7 @@ export const AddDownloadsModal = () => { setTorrentUploadLimit('1024'); setTorrentMaxPeers(''); setTorrentPeerSpeedLimit(''); + setTorrentCheckIntegrity(false); setUseAuth(false); setUsername(''); setPassword(''); @@ -1448,6 +1450,7 @@ export const AddDownloadsModal = () => { torrentPeerSpeedLimit: item.isTorrent ? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined : undefined, + torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), sizeBytes: item.sizeBytes }, action); @@ -2094,6 +2097,20 @@ export const AddDownloadsModal = () => { KiB/s ) : null} +