diff --git a/CHANGELOG.md b/CHANGELOG.md index c109aa44..ed6dc4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MAT-133 cleanup observability**: engine tests now record artifact deletion + requests so pre-download failures cannot silently exercise a cleanup path. +- **MAT-133 review portability**: resume metadata locks preserve read-only + sidecars, huge-file segment limits cannot wrap, and interruption coverage is + synchronized with persisted progress. +- **MAT-133 final review hardening**: protected ranged responses are inspected + independently, interrupted segments resume from persisted offsets, and + metadata cleanup cannot race tombstone recovery. +- **MAT-133 final PR feedback**: cleanup tombstones survive restarts, credentialed + capabilities always use the restricted HTTP policy, XML-declared HTML is + rejected, and unknown-size retries cannot retain stale trailing bytes. +- **MAT-133 XML response validation**: non-HTML doctypes are consumed before + inspecting the document root, so XML-declared HTML error pages are rejected. +- **MAT-133 hoster download planning**: MediaFire, PixelDrain, and Gofile page + URLs are resolved through their hoster plugins before transfer; stable source + URLs and plugin metadata reach the queue while ephemeral direct URLs and + request headers stay backend-only and are refreshed on retry. Unexpected HTML + responses and typed hoster failures no longer appear as successful files. +- **MAT-133 review hardening**: built-in HTTP downloads keep their normal online + probe, while current hoster plugin failures map to safe typed errors without + exposing upstream diagnostics. +- **MAT-133 review follow-up**: protected capabilities now refresh once during + an active failed transfer, preserve plugin headers and file metadata, reject + ambiguous HTML and size mismatches, and clean up only Vortex-owned artifacts. +- **MAT-133 adversarial coverage**: added regressions for hostile stable URLs, + blank capabilities, response fan-out, disguised HTML, destination collisions, + exact error classification, and recoverable online probes. +- **MAT-133 protected-source hardening**: plugin payloads and per-file metadata + are bounded, mono-file hosters retain the user-supplied stable URL, Gofile + child identifiers stay on validated official origins, and protected transfers + now reject disguised HTML without deleting or overwriting unowned destinations. - **MAT-132 PR review hardening**: premium selection now excludes free accounts, serializes persisted cooldowns with rotation, revalidates download associations on every JIT resolution, and lets cancellation win without late diff --git a/src-tauri/src/adapters/driven/filesystem/file_storage.rs b/src-tauri/src/adapters/driven/filesystem/file_storage.rs index 77d7eda8..45bf9ffe 100644 --- a/src-tauri/src/adapters/driven/filesystem/file_storage.rs +++ b/src-tauri/src/adapters/driven/filesystem/file_storage.rs @@ -7,9 +7,9 @@ use std::fs::{self, File, OpenOptions}; use std::io; use std::io::{Read as _, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; use tracing::{debug, warn}; +use uuid::Uuid; use crate::domain::error::DomainError; use crate::domain::model::meta::DownloadMeta; @@ -17,9 +17,6 @@ use crate::domain::ports::driven::FileStorage; use super::meta_storage; -/// Counter for unique temporary file names during atomic writes. -static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); - /// Filesystem-backed implementation of [`FileStorage`]. /// /// Stateless — every method receives the target path explicitly. @@ -43,6 +40,104 @@ fn meta_path(download_path: &Path) -> PathBuf { download_path.with_file_name(meta_name) } +fn unique_sibling_path(path: &Path, marker: &str, suffix: &str) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".{marker}.{}.{suffix}", Uuid::new_v4())); + path.with_file_name(name) +} + +fn staged_meta_paths(download_path: &Path) -> Result, DomainError> { + let mp = meta_path(download_path); + let parent = mp.parent().unwrap_or_else(|| Path::new(".")); + let canonical_name = mp.file_name().unwrap_or_default().to_string_lossy(); + let prefix = format!("{canonical_name}."); + let entries = match fs::read_dir(parent) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(DomainError::StorageError(format!( + "failed to inspect staged metadata in {}: {error}", + parent.display() + ))); + } + }; + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + DomainError::StorageError(format!( + "failed to inspect staged metadata in {}: {error}", + parent.display() + )) + })?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + let Some(staging_id) = name + .strip_prefix(&prefix) + .and_then(|suffix| suffix.strip_suffix(".delete")) + else { + continue; + }; + let staging_id = staging_id + .strip_prefix("vortex-meta.") + .unwrap_or(staging_id); + if Uuid::parse_str(staging_id).is_ok() + || (!staging_id.is_empty() && staging_id.bytes().all(|byte| byte.is_ascii_digit())) + { + paths.push(entry.path()); + } + } + paths.sort(); + Ok(paths) +} + +fn recover_staged_meta(download_path: &Path) -> Result<(), DomainError> { + let mp = meta_path(download_path); + if mp.try_exists().map_err(|error| { + DomainError::StorageError(format!("failed to inspect {}: {error}", mp.display())) + })? { + return Ok(()); + } + for staged in staged_meta_paths(download_path)? { + let staged_guard = match OpenOptions::new().read(true).open(&staged) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(DomainError::StorageError(format!( + "failed to open staged metadata {}: {error}", + staged.display() + ))); + } + }; + staged_guard.lock().map_err(|error| { + DomainError::StorageError(format!( + "failed to lock staged metadata {}: {error}", + staged.display() + )) + })?; + match fs::hard_link(&staged, &mp) { + Ok(()) => { + if let Err(error) = fs::remove_file(&staged) { + warn!( + path = %staged.display(), + error = %error, + "recovered staged metadata but could not remove its tombstone" + ); + } + return Ok(()); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(DomainError::StorageError(format!( + "failed to recover staged metadata {}: {error}", + staged.display() + ))); + } + } + } + Ok(()) +} + impl FileStorage for FsFileStorage { /// Pre-allocate a sparse file at `path` with logical size `size`. /// @@ -67,12 +162,33 @@ impl FileStorage for FsFileStorage { })?; // set_len creates a sparse file — the OS only allocates blocks // as data is actually written, so a 1 GB file uses ~0 bytes on disk. - file.set_len(size).map_err(|e| { - DomainError::StorageError(format!( - "failed to pre-allocate {} ({size} bytes): {e}", + if let Err(error) = file.set_len(size) { + let staged_path = unique_sibling_path(path, "vortex-preallocation", "delete"); + match fs::rename(path, &staged_path) { + Ok(()) => { + drop(file); + if let Err(cleanup_error) = fs::remove_file(&staged_path) { + warn!( + path = %staged_path.display(), + error = %cleanup_error, + "failed to delete an isolated unsuccessful file reservation" + ); + } + } + Err(stage_error) => { + drop(file); + warn!( + path = %path.display(), + error = %stage_error, + "failed to isolate an unsuccessful file reservation; leaving it in place" + ); + } + } + return Err(DomainError::StorageError(format!( + "failed to pre-allocate {} ({size} bytes): {error}", path.display() - )) - })?; + ))); + } debug!(path = %path.display(), size, "pre-allocated download file"); Ok(()) } @@ -121,7 +237,59 @@ impl FileStorage for FsFileStorage { Ok(()) } + fn write_growing_segment( + &self, + path: &Path, + offset: u64, + data: &[u8], + ) -> Result<(), DomainError> { + let mut file = OpenOptions::new().write(true).open(path).map_err(|error| { + DomainError::StorageError(format!( + "failed to open {} for writing: {error}", + path.display() + )) + })?; + file.seek(SeekFrom::Start(offset)).map_err(|error| { + DomainError::StorageError(format!( + "failed to seek to offset {offset} in {}: {error}", + path.display() + )) + })?; + file.write_all(data).map_err(|error| { + DomainError::StorageError(format!( + "failed to write {} bytes at offset {offset} in {}: {error}", + data.len(), + path.display() + )) + }) + } + + fn grow_file(&self, path: &Path, minimum_size: u64) -> Result<(), DomainError> { + let file = OpenOptions::new().write(true).open(path).map_err(|e| { + DomainError::StorageError(format!("failed to open {} for growth: {e}", path.display())) + })?; + let current_size = file + .metadata() + .map(|metadata| metadata.len()) + .map_err(|e| { + DomainError::StorageError(format!( + "failed to read metadata for {}: {e}", + path.display() + )) + })?; + if current_size < minimum_size { + file.set_len(minimum_size).map_err(|e| { + DomainError::StorageError(format!( + "failed to grow {} to {minimum_size} bytes: {e}", + path.display() + )) + })?; + } + Ok(()) + } + fn read_meta(&self, path: &Path) -> Result, DomainError> { + recover_staged_meta(path)?; let mp = meta_path(path); // Open directly and handle NotFound — avoids TOCTOU race with delete_meta. let mut file = match File::open(&mp) { @@ -170,14 +338,14 @@ impl FileStorage for FsFileStorage { } fn write_meta(&self, path: &Path, meta: &DownloadMeta) -> Result<(), DomainError> { + recover_staged_meta(path)?; let mp = meta_path(path); let data = meta_storage::serialize_meta(meta)?; // Atomic write: write to a uniquely-named temporary file then rename, // so a crash during write never leaves a half-written .vortex-meta, // and concurrent writers don't clobber each other's temp files. - let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let tmp = mp.with_extension(format!("vortex-meta.{n}.tmp")); + let tmp = unique_sibling_path(&mp, "vortex-meta", "tmp"); fs::write(&tmp, &data).map_err(|e| { let _ = fs::remove_file(&tmp); DomainError::StorageError(format!("failed to write {}: {e}", tmp.display())) @@ -194,6 +362,7 @@ impl FileStorage for FsFileStorage { } fn delete_meta(&self, path: &Path) -> Result<(), DomainError> { + recover_staged_meta(path)?; let mp = meta_path(path); match fs::remove_file(&mp) { Ok(()) => { @@ -208,6 +377,70 @@ impl FileStorage for FsFileStorage { } } + fn delete_download_artifacts(&self, path: &Path) -> Result<(), DomainError> { + recover_staged_meta(path)?; + let mp = meta_path(path); + let staged_meta = unique_sibling_path(&mp, "vortex-meta", "delete"); + let metadata_guard = match OpenOptions::new().read(true).open(&mp) { + Ok(file) => { + file.lock().map_err(|error| { + DomainError::StorageError(format!( + "failed to lock {} for deletion: {error}", + mp.display() + )) + })?; + Some(file) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => { + return Err(DomainError::StorageError(format!( + "failed to open {} for deletion: {error}", + mp.display() + ))); + } + }; + let metadata_staged = if metadata_guard.is_some() { + match fs::rename(&mp, &staged_meta) { + Ok(()) => true, + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + Err(error) => { + return Err(DomainError::StorageError(format!( + "failed to stage {} for deletion: {error}", + mp.display() + ))); + } + } + } else { + false + }; + match fs::remove_file(path) { + Ok(()) => debug!(path = %path.display(), "deleted download body"), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + if metadata_staged && let Err(restore_error) = fs::rename(&staged_meta, &mp) { + return Err(DomainError::StorageError(format!( + "failed to delete {}: {error}; failed to restore {}: {restore_error}", + path.display(), + mp.display() + ))); + } + return Err(DomainError::StorageError(format!( + "failed to delete {}: {error}", + path.display() + ))); + } + } + if metadata_staged { + fs::remove_file(&staged_meta).map_err(|error| { + DomainError::StorageError(format!( + "failed to delete staged metadata {}: {error}", + staged_meta.display() + )) + })?; + } + Ok(()) + } + fn move_file(&self, from: &Path, to: &Path) -> Result<(), DomainError> { if from == to { return Ok(()); @@ -476,6 +709,19 @@ mod tests { ); } + #[test] + fn test_create_file_rolls_back_when_preallocation_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("too-large.bin"); + let storage = FsFileStorage::new(); + + let result = storage.create_file(&file_path, u64::MAX); + + assert!(result.is_err()); + assert!(!file_path.exists()); + assert_eq!(dir.path().read_dir().unwrap().count(), 0); + } + #[test] fn test_write_segment_at_offset() { let dir = tempfile::tempdir().expect("tempdir"); @@ -533,6 +779,69 @@ mod tests { assert_eq!(original, restored); } + #[test] + fn test_read_meta_recovers_metadata_staged_by_interrupted_cleanup() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("download.bin"); + let storage = FsFileStorage::new(); + let original = make_meta(); + storage.create_file(&file_path, 4).expect("create body"); + storage + .write_meta(&file_path, &original) + .expect("write metadata"); + let mp = meta_path(&file_path); + let staged = unique_sibling_path(&mp, "vortex-meta", "delete"); + fs::rename(&mp, &staged).expect("simulate crash after staging metadata"); + + let restored = storage + .read_meta(&file_path) + .expect("read staged metadata") + .expect("metadata must remain recoverable"); + + assert_eq!(restored, original); + assert!(mp.exists(), "ownership metadata must be canonical again"); + assert!(!staged.exists(), "recovered tombstone must be consumed"); + + storage + .delete_download_artifacts(&file_path) + .expect("cleanup retry must remove recovered artifacts"); + assert!(!file_path.exists()); + assert!(!mp.exists()); + } + + #[cfg(unix)] + #[test] + fn read_only_metadata_can_be_recovered_and_deleted() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("download.bin"); + let storage = FsFileStorage::new(); + let original = make_meta(); + storage.create_file(&file_path, 4).expect("create body"); + storage + .write_meta(&file_path, &original) + .expect("write metadata"); + + let mp = meta_path(&file_path); + let staged = unique_sibling_path(&mp, "vortex-meta", "delete"); + fs::rename(&mp, &staged).expect("simulate crash after staging metadata"); + fs::set_permissions(&staged, fs::Permissions::from_mode(0o444)) + .expect("make staged metadata read-only"); + + let restored = storage + .read_meta(&file_path) + .expect("recover read-only staged metadata") + .expect("metadata must remain recoverable"); + assert_eq!(restored, original); + + storage + .delete_download_artifacts(&file_path) + .expect("delete artifacts with read-only metadata"); + assert!(!file_path.exists()); + assert!(!mp.exists()); + } + #[test] fn test_delete_meta_removes_file() { let dir = tempfile::tempdir().expect("tempdir"); @@ -577,6 +886,94 @@ mod tests { .expect("delete_meta on missing file should succeed"); } + #[test] + fn test_delete_download_artifacts_removes_body_and_metadata_idempotently() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("download.bin"); + let storage = FsFileStorage::new(); + storage.create_file(&file_path, 4).expect("create body"); + storage + .write_meta(&file_path, &make_meta()) + .expect("create metadata"); + let stale_tombstone = meta_path(&file_path).with_extension("vortex-meta.42.delete"); + fs::copy(meta_path(&file_path), &stale_tombstone).expect("seed stale tombstone"); + + storage + .delete_download_artifacts(&file_path) + .expect("delete artifacts"); + storage + .delete_download_artifacts(&file_path) + .expect("repeated deletion remains safe"); + + assert!(!file_path.exists()); + assert!(!meta_path(&file_path).exists()); + assert!(!stale_tombstone.exists()); + } + + #[test] + fn test_delete_download_artifacts_preserves_metadata_when_body_removal_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let body_path = dir.path().join("body-directory"); + fs::create_dir(&body_path).expect("create body directory"); + let storage = FsFileStorage::new(); + storage + .write_meta(&body_path, &make_meta()) + .expect("create ownership metadata"); + + assert!(storage.delete_download_artifacts(&body_path).is_err()); + assert!(meta_path(&body_path).exists()); + } + + #[test] + fn staged_metadata_is_not_recovered_while_cleanup_holds_its_lock() { + use std::sync::mpsc::{self, RecvTimeoutError}; + use std::time::Duration; + + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("download.bin"); + let storage = FsFileStorage::new(); + storage.create_file(&file_path, 4).expect("create body"); + storage + .write_meta(&file_path, &make_meta()) + .expect("create metadata"); + + let mp = meta_path(&file_path); + let staged = unique_sibling_path(&mp, "vortex-meta", "delete"); + let guard = OpenOptions::new() + .read(true) + .open(&mp) + .expect("open metadata guard"); + guard.lock().expect("lock metadata guard"); + fs::rename(&mp, &staged).expect("stage metadata"); + + let read_path = file_path.clone(); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + sender + .send(FsFileStorage::new().read_meta(&read_path)) + .expect("send recovery result"); + }); + + assert_eq!( + receiver.recv_timeout(Duration::from_millis(100)), + Err(RecvTimeoutError::Timeout), + "recovery must wait for the active cleanup" + ); + + fs::remove_file(&file_path).expect("delete body"); + fs::remove_file(&staged).expect("delete staged metadata"); + drop(guard); + + assert!( + receiver + .recv_timeout(Duration::from_secs(1)) + .expect("recovery finishes") + .expect("recovery succeeds") + .is_none() + ); + assert!(!mp.exists(), "active cleanup metadata must not be revived"); + } + #[test] fn test_write_segment_multiple_offsets() { let dir = tempfile::tempdir().expect("tempdir"); @@ -601,6 +998,20 @@ mod tests { assert_eq!(&data[200..300], &[0xCC; 100]); } + #[test] + fn test_write_growing_segment_extends_an_unknown_length_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let file_path = dir.path().join("unknown.bin"); + let storage = FsFileStorage::new(); + storage.create_file(&file_path, 0).expect("reserve file"); + + storage + .write_growing_segment(&file_path, 0, b"data") + .expect("write unknown-length chunk"); + + assert_eq!(fs::read(file_path).unwrap(), b"data"); + } + #[test] fn test_read_meta_corrupted_returns_none() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/adapters/driven/network/download_artifact_lifecycle.rs b/src-tauri/src/adapters/driven/network/download_artifact_lifecycle.rs new file mode 100644 index 00000000..af8a51e9 --- /dev/null +++ b/src-tauri/src/adapters/driven/network/download_artifact_lifecycle.rs @@ -0,0 +1,126 @@ +use std::path::Path; +use std::sync::Arc; + +use crate::domain::error::DomainError; +use crate::domain::model::download::DownloadId; +use crate::domain::model::meta::{DownloadMeta, SegmentMeta}; +use crate::domain::ports::driven::FileStorage; + +pub(super) struct AttemptFailure { + pub(super) message: String, + pub(super) owns_artifacts: bool, + pub(super) retryable_with_mirror: bool, +} + +pub(super) enum AttemptOutcome { + Completed, + Cancelled, + Failed(AttemptFailure), +} + +impl AttemptFailure { + pub(super) fn retryable(message: String, owns_artifacts: bool) -> Self { + Self { + message, + owns_artifacts, + retryable_with_mirror: true, + } + } + + pub(super) fn terminal(message: String, owns_artifacts: bool) -> Self { + Self { + message, + owns_artifacts, + retryable_with_mirror: false, + } + } +} + +pub(super) fn resume_metadata_matches( + metadata: &DownloadMeta, + download_id: DownloadId, + stable_url: &str, + destination: &Path, + total_size: u64, +) -> bool { + let filename_matches = destination + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|filename| metadata.file_name == filename); + let size_matches = if total_size > 0 { + metadata.total_bytes == Some(total_size) + } else { + matches!(metadata.total_bytes, None | Some(0)) + }; + metadata.download_id == download_id + && metadata.url == stable_url + && filename_matches + && size_matches +} + +pub(super) fn ownership_metadata( + download_id: DownloadId, + stable_url: String, + destination: &Path, + total_size: u64, + segments: Vec, +) -> DownloadMeta { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + DownloadMeta { + download_id, + url: stable_url, + file_name: destination + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_string(), + total_bytes: (total_size > 0).then_some(total_size), + segments, + checksum_expected: None, + created_at: now, + updated_at: now, + } +} + +pub(super) async fn cleanup_download_artifacts( + file_storage: &Arc, + dest_path: &Path, +) -> Result<(), DomainError> { + let storage = file_storage.clone(); + let path = dest_path.to_path_buf(); + tokio::task::spawn_blocking(move || storage.delete_download_artifacts(&path)) + .await + .map_err(|_| DomainError::StorageError("download artifact cleanup stopped".into()))? +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metadata(total_bytes: Option) -> DownloadMeta { + DownloadMeta { + download_id: DownloadId(7), + url: "https://example.com/file.bin".into(), + file_name: "file.bin".into(), + total_bytes, + segments: Vec::new(), + checksum_expected: None, + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn unknown_remote_size_rejects_a_known_size_artifact() { + assert!(!resume_metadata_matches( + &metadata(Some(42)), + DownloadId(7), + "https://example.com/file.bin", + Path::new("/tmp/file.bin"), + 0, + )); + } +} diff --git a/src-tauri/src/adapters/driven/network/download_engine.rs b/src-tauri/src/adapters/driven/network/download_engine.rs index 47e25d46..13225fd9 100644 --- a/src-tauri/src/adapters/driven/network/download_engine.rs +++ b/src-tauri/src/adapters/driven/network/download_engine.rs @@ -15,8 +15,13 @@ use crate::domain::ports::driven::{ DownloadEngine, DownloadSourceResolver, EventBus, FileStorage, ResolutionCancellation, }; +use super::download_artifact_lifecycle::{ + AttemptFailure, AttemptOutcome, cleanup_download_artifacts, ownership_metadata, + resume_metadata_matches, +}; +use super::download_source_preparation::{ResolvedClientFactory, prepare_sources}; use super::segment_worker::{SegmentError, SegmentParams, download_segment}; -use super::{format_error_chain, restricted_download_client}; +use super::{SourcePolicy, format_error_chain, safe_source_failure}; struct ActiveDownload { cancel_token: CancellationToken, @@ -24,6 +29,13 @@ struct ActiveDownload { pause_sender: watch::Sender, } +struct RemoteMetadata { + content_length: u64, + accepts_ranges: bool, + content_type: Option, + status: reqwest::StatusCode, +} + /// Minimum age and downloaded bytes a segment must have before it is /// eligible for split. Without this gate a fresh split child (downloaded == 0, /// elapsed ≈ 0) would compute as 0 B/s, become the guaranteed "slowest" @@ -32,6 +44,22 @@ struct ActiveDownload { /// signal. const MIN_SPLIT_SAMPLE_DURATION: std::time::Duration = std::time::Duration::from_millis(500); +fn segment_count_for_attempt( + requested_segments: u32, + total_size: u64, + min_segment_bytes: u64, + supports_range: bool, +) -> u32 { + if supports_range && total_size > 0 { + let segment_limit = (total_size / min_segment_bytes) + .max(1) + .min(u64::from(u32::MAX)) as u32; + requested_segments.min(segment_limit).max(1) + } else { + 1 + } +} + /// Runtime state of one in-flight segment, tracked by the engine so it can /// shrink the segment's range and observe its throughput for dynamic split. struct SegmentRuntimeState { @@ -40,9 +68,10 @@ struct SegmentRuntimeState { started_at: std::time::Instant, start_byte: u64, initial_end: u64, + already_downloaded: u64, /// Set by the coordinator when the worker for this slot returns `Ok(_)`. /// Completed slots stay in `active_segments` (instead of being cleared) - /// so `persist_split_meta` records their byte range with `completed: true` + /// so `persist_resume_meta` records their byte range with `completed: true` /// — otherwise a crash right after a split would leave the resume meta /// without any record that those bytes are already on disk. completed: bool, @@ -71,7 +100,10 @@ fn pick_split_target( if elapsed < MIN_SPLIT_SAMPLE_DURATION { continue; // worker hasn't run long enough to produce a meaningful bps } - let current_offset = state.start_byte.saturating_add(downloaded); + let current_offset = state + .start_byte + .saturating_add(state.already_downloaded) + .saturating_add(downloaded); if current_offset >= state.initial_end { continue; // already at end — completion event will fire shortly } @@ -95,10 +127,9 @@ fn pick_split_target( slowest.map(|(idx, _, split_at)| (idx, split_at)) } -/// Atomically rewrite `.vortex-meta` after a dynamic split so resume after a -/// crash sees the updated segment topology. A failure here only logs — the -/// in-memory split is still valid for the live download. -async fn persist_split_meta( +/// Atomically rewrite `.vortex-meta` with the latest segment topology and +/// progress. A failure here only logs — the in-memory download still proceeds. +async fn persist_resume_meta( file_storage: &Arc, dest_path: &Path, download_id: DownloadId, @@ -117,7 +148,9 @@ async fn persist_split_meta( let downloaded = if st.completed { st.initial_end.saturating_sub(st.start_byte) } else { - st.progress.load(Ordering::Relaxed) + st.already_downloaded + .saturating_add(st.progress.load(Ordering::Relaxed)) + .min(st.initial_end.saturating_sub(st.start_byte)) }; SegmentMeta { id: i as u32, @@ -165,6 +198,36 @@ async fn persist_split_meta( } } +fn validated_resume_segments( + metadata: Option<&DownloadMeta>, + total_size: u64, + supports_range: bool, +) -> Option> { + if !supports_range || total_size == 0 { + return None; + } + let mut segments = metadata?.segments.clone(); + if segments.is_empty() { + return None; + } + segments.sort_by_key(|segment| segment.start_byte); + let mut expected_start = 0; + for (index, segment) in segments.iter_mut().enumerate() { + let segment_size = segment.end_byte.checked_sub(segment.start_byte)?; + if segment.start_byte != expected_start + || segment_size == 0 + || segment.end_byte > total_size + || segment.downloaded_bytes > segment_size + { + return None; + } + segment.id = index as u32; + segment.completed = segment.downloaded_bytes == segment_size; + expected_start = segment.end_byte; + } + (expected_start == total_size).then_some(segments) +} + pub struct SegmentedDownloadEngine { client: reqwest::Client, file_storage: Arc, @@ -175,6 +238,7 @@ pub struct SegmentedDownloadEngine { dynamic_split_min_remaining_bytes: Arc, active_downloads: Arc>>, source_resolver: Option>, + resolved_client_factory: ResolvedClientFactory, } impl SegmentedDownloadEngine { @@ -194,6 +258,7 @@ impl SegmentedDownloadEngine { dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(4 * 1024 * 1024)), active_downloads: Arc::new(Mutex::new(HashMap::new())), source_resolver: None, + resolved_client_factory: super::restricted_download_client, } } @@ -207,6 +272,12 @@ impl SegmentedDownloadEngine { self } + #[cfg(test)] + fn with_resolved_client_factory_for_testing(mut self, factory: ResolvedClientFactory) -> Self { + self.resolved_client_factory = factory; + self + } + /// Configure runtime re-splitting of slow segments. PRD §7.1. /// `min_remaining_mb == 0` disables the size gate entirely; the engine /// then only refuses to split if the candidate has 0 bytes left. @@ -240,7 +311,7 @@ impl SegmentedDownloadEngine { async fn probe_remote_metadata( client: &reqwest::Client, url: &str, - ) -> Result<(u64, bool), reqwest::Error> { + ) -> Result { let response = match client.head(url).send().await { Ok(response) if response.status().is_success() => response, Ok(response) => { @@ -268,116 +339,20 @@ impl SegmentedDownloadEngine { .and_then(|v| v.to_str().ok()) .map(|v| v.eq_ignore_ascii_case("bytes")) .unwrap_or(false); - - Ok((content_length, accepts_ranges)) - } -} - -/// Outcome of a single mirror attempt inside the failover loop. -/// -/// The outer engine task converts this into a `DomainEvent`: -/// - `Completed` → `DownloadCompleted` -/// - `Cancelled` → `DownloadCancelled` -/// - `Failed(_)` → either `MirrorSwitched` + retry the next mirror, or -/// `DownloadFailed` if no mirror remains. -enum AttemptOutcome { - Completed, - Cancelled, - Failed(String), -} - -struct PreparedSources { - urls: Vec, - initial_index: usize, - client: reqwest::Client, - resume_url: String, - sensitive: bool, -} - -async fn prepare_sources( - download: Download, - resolver: Option>, - client: reqwest::Client, - cancel_token: CancellationToken, - resolution_cancellation: ResolutionCancellation, -) -> Result { - if cancel_token.is_cancelled() { - return Err(DomainError::PluginError( - "premium source resolution cancelled".into(), - )); - } - let resume_url = download.url().as_str().to_string(); - if download.account_id().is_some() { - let resolver = resolver.ok_or_else(|| { - DomainError::PluginError("premium source resolver is not configured".into()) - })?; - let mut resolve_task = tokio::task::spawn_blocking(move || { - resolver.resolve_cancellable(&download, &resolution_cancellation) - }); - let source = tokio::select! { - biased; - _ = cancel_token.cancelled() => { - return Err(DomainError::PluginError("premium source resolution cancelled".into())); - } - result = &mut resolve_task => { - result - .map_err(|_| DomainError::PluginError("premium source resolver stopped".into()))?? - } - }; - let request_url = source.request_url().to_string(); - let mut safety_task = tokio::task::spawn_blocking(move || { - let parsed = reqwest::Url::parse(&request_url) - .map_err(|_| DomainError::NetworkError("plugin returned an invalid URL".into()))?; - let client = restricted_download_client(&parsed)?; - Ok::<_, DomainError>((request_url, client)) - }); - let (request_url, client) = tokio::select! { - biased; - _ = cancel_token.cancelled() => { - return Err(DomainError::PluginError("premium source resolution cancelled".into())); - } - result = &mut safety_task => { - result - .map_err(|_| DomainError::NetworkError("URL safety check stopped".into()))?? - } - }; - return Ok(PreparedSources { - urls: vec![request_url], - initial_index: 0, - client, - resume_url, - sensitive: true, - }); - } - let urls = if download.mirrors().is_empty() { - vec![resume_url.clone()] - } else { - download - .mirrors() - .iter() - .map(|mirror| mirror.url().as_str().to_string()) - .collect::>() - }; - let initial_index = - (download.current_mirror_index() as usize).min(urls.len().saturating_sub(1)); - Ok(PreparedSources { - urls, - initial_index, - client, - resume_url, - sensitive: false, - }) -} - -fn source_failure_message(error: &DomainError) -> String { - match error { - DomainError::AccountInvalidCredentials => "Account credentials were rejected", - DomainError::AccountExpired => "Account is expired", - DomainError::AccountCooldown => "Account is temporarily rate-limited", - DomainError::AccountQuotaExceeded => "Account quota is exhausted", - _ => "Premium download source could not be resolved", + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let status = response.status(); + + Ok(RemoteMetadata { + content_length, + accepts_ranges, + content_type, + status, + }) } - .to_string() } impl DownloadEngine for SegmentedDownloadEngine { @@ -425,15 +400,17 @@ impl DownloadEngine for SegmentedDownloadEngine { let dynamic_split_enabled = self.dynamic_split_enabled.clone(); let dynamic_split_min_remaining_bytes = self.dynamic_split_min_remaining_bytes.clone(); let source_resolver = self.source_resolver.clone(); + let resolved_client_factory = self.resolved_client_factory; let download = download.clone(); tokio::spawn(async move { let prepared = match prepare_sources( - download, - source_resolver, - client, + download.clone(), + source_resolver.clone(), + client.clone(), cancel_token.clone(), - resolution_cancellation, + resolution_cancellation.clone(), + resolved_client_factory, ) .await { @@ -444,7 +421,7 @@ impl DownloadEngine for SegmentedDownloadEngine { } else { DomainEvent::DownloadFailed { id: download_id, - error: source_failure_message(&error), + error: safe_source_failure(&error), } }; event_bus.publish(event); @@ -455,10 +432,13 @@ impl DownloadEngine for SegmentedDownloadEngine { return; } }; - let client = prepared.client; - let mirror_urls = prepared.urls; - let resume_url = prepared.resume_url; - let sensitive_url = prepared.sensitive; + let mut download_client = prepared.client; + let mut mirror_urls = prepared.urls; + let mut resume_url = prepared.resume_url; + let mut source_policy = prepared.policy; + let mut size_hint = prepared.size_hint; + let mut resume_supported = prepared.resume_supported; + let mut resolved_source = prepared.resolved; if cancel_token.is_cancelled() { event_bus.publish(DomainEvent::DownloadCancelled { id: download_id }); active_downloads @@ -468,6 +448,7 @@ impl DownloadEngine for SegmentedDownloadEngine { return; } let mut mirror_idx = prepared.initial_index; + let mut source_refreshes = 0; loop { let url = mirror_urls[mirror_idx].clone(); // Each attempt gets a fresh attempt-scoped child token so @@ -480,7 +461,7 @@ impl DownloadEngine for SegmentedDownloadEngine { url, download_id, segments_count, - client: client.clone(), + client: download_client.clone(), file_storage: file_storage.clone(), event_bus: event_bus.clone(), dest_path: dest_path.clone(), @@ -491,7 +472,9 @@ impl DownloadEngine for SegmentedDownloadEngine { dynamic_split_enabled: dynamic_split_enabled.clone(), dynamic_split_min_remaining_bytes: dynamic_split_min_remaining_bytes.clone(), resume_url: resume_url.clone(), - sensitive_url, + source_policy, + size_hint, + resume_supported, }) .await; @@ -504,9 +487,68 @@ impl DownloadEngine for SegmentedDownloadEngine { event_bus.publish(DomainEvent::DownloadCancelled { id: download_id }); break; } - AttemptOutcome::Failed(err) => { + AttemptOutcome::Failed(failure) => { + if failure.retryable_with_mirror && resolved_source && source_refreshes == 0 + { + if cancel_token.is_cancelled() { + event_bus + .publish(DomainEvent::DownloadCancelled { id: download_id }); + break; + } + if failure.owns_artifacts + && let Err(error) = + cleanup_download_artifacts(&file_storage, &dest_path).await + { + tracing::warn!( + download_id = download_id.0, + error = %error, + "failed to reset download artifacts before source refresh" + ); + event_bus.publish(DomainEvent::DownloadFailed { + id: download_id, + error: + "failed to reset the partial download before source refresh" + .into(), + }); + break; + } + let refreshed = match prepare_sources( + download.clone(), + source_resolver.clone(), + client.clone(), + cancel_token.clone(), + resolution_cancellation.clone(), + resolved_client_factory, + ) + .await + { + Ok(refreshed) => refreshed, + Err(error) => { + let event = if cancel_token.is_cancelled() { + DomainEvent::DownloadCancelled { id: download_id } + } else { + DomainEvent::DownloadFailed { + id: download_id, + error: safe_source_failure(&error), + } + }; + event_bus.publish(event); + break; + } + }; + download_client = refreshed.client; + mirror_urls = refreshed.urls; + mirror_idx = refreshed.initial_index; + resume_url = refreshed.resume_url; + source_policy = refreshed.policy; + size_hint = refreshed.size_hint; + resume_supported = refreshed.resume_supported; + resolved_source = refreshed.resolved; + source_refreshes += 1; + continue; + } let next = mirror_idx + 1; - if next < mirror_urls.len() { + if failure.retryable_with_mirror && next < mirror_urls.len() { // A user-cancel that landed after the attempt // returned `Failed` (or while the cleanup runs // below) must not be silently upgraded into a @@ -520,18 +562,18 @@ impl DownloadEngine for SegmentedDownloadEngine { break; } mirror_idx = next; - if sensitive_url { + if source_policy.is_protected() { tracing::info!( download_id = download_id.0, new_mirror_index = mirror_idx, - "switching sensitive source after failure" + "switching protected source after failure" ); } else { tracing::info!( download_id = download_id.0, new_mirror_index = mirror_idx, new_url = %mirror_urls[mirror_idx], - previous_error = %err, + previous_error = %failure.message, "switching to next mirror after failure" ); } @@ -543,27 +585,23 @@ impl DownloadEngine for SegmentedDownloadEngine { // a connection. Bytes from mirror N are not safe to // splice with mirror N+1 anyway (the servers may // serve subtly different payloads). - let storage = file_storage.clone(); - let path = dest_path.clone(); - let _ = tokio::task::spawn_blocking(move || { - if let Err(e) = storage.delete_meta(&path) { - tracing::debug!( - path = %path.display(), - error = %e, - "failed to delete stale meta before mirror retry" - ); - } - if path.exists() - && let Err(e) = std::fs::remove_file(&path) - { - tracing::warn!( - path = %path.display(), - error = %e, - "failed to remove stale file before mirror retry" - ); - } - }) - .await; + if failure.owns_artifacts + && let Err(error) = + cleanup_download_artifacts(&file_storage, &dest_path).await + { + tracing::warn!( + download_id = download_id.0, + error = %error, + "failed to reset download artifacts before mirror retry" + ); + event_bus.publish(DomainEvent::DownloadFailed { + id: download_id, + error: + "failed to reset the partial download before mirror retry" + .into(), + }); + break; + } // Re-check after cleanup since the user may have // hit cancel while it was running. if cancel_token.is_cancelled() { @@ -574,7 +612,7 @@ impl DownloadEngine for SegmentedDownloadEngine { event_bus.publish(DomainEvent::MirrorSwitched { id: download_id, new_mirror_index: mirror_idx as u32, - new_url: if sensitive_url { + new_url: if source_policy.is_protected() { resume_url.clone() } else { mirror_urls[mirror_idx].clone() @@ -587,10 +625,12 @@ impl DownloadEngine for SegmentedDownloadEngine { // mirror-driven failure from post-download failures // (extract / verify / domain `fail()`) and reset the // cursor only on this signal. - event_bus.publish(DomainEvent::AllMirrorsExhausted { id: download_id }); + if failure.retryable_with_mirror { + event_bus.publish(DomainEvent::AllMirrorsExhausted { id: download_id }); + } event_bus.publish(DomainEvent::DownloadFailed { id: download_id, - error: err, + error: failure.message, }); break; } @@ -673,7 +713,9 @@ struct MirrorAttemptParams { dynamic_split_enabled: Arc, dynamic_split_min_remaining_bytes: Arc, resume_url: String, - sensitive_url: bool, + source_policy: SourcePolicy, + size_hint: Option, + resume_supported: Option, } async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { @@ -692,59 +734,153 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { dynamic_split_enabled, dynamic_split_min_remaining_bytes, resume_url, - sensitive_url, + source_policy, + size_hint, + resume_supported, } = params; - let (total_size, supports_range) = - match SegmentedDownloadEngine::probe_remote_metadata(&client, &url).await { - Ok(metadata) => metadata, - Err(e) => { - if sensitive_url { - tracing::warn!(download_id = download_id.0, "metadata probe failed"); - } else { - tracing::warn!( - download_id = download_id.0, - url = %url, - error = %format_error_chain(&e), - "metadata probe failed (mirror attempt)" - ); - } - if user_cancel_token.is_cancelled() { - return AttemptOutcome::Cancelled; - } - return AttemptOutcome::Failed(if sensitive_url { + let metadata = match SegmentedDownloadEngine::probe_remote_metadata(&client, &url).await { + Ok(metadata) => metadata, + Err(e) => { + if source_policy.is_protected() { + tracing::warn!(download_id = download_id.0, "metadata probe failed"); + } else { + tracing::warn!( + download_id = download_id.0, + url = %url, + error = %format_error_chain(&e), + "metadata probe failed (mirror attempt)" + ); + } + if user_cancel_token.is_cancelled() { + return AttemptOutcome::Cancelled; + } + return AttemptOutcome::Failed(AttemptFailure::retryable( + if source_policy.is_protected() { "metadata probe failed".into() } else { format!("metadata probe failed: {}", format_error_chain(&e)) - }); - } - }; + }, + false, + )); + } + }; + + if let Some(error) = + source_policy.response_error(metadata.status, metadata.content_type.as_deref()) + { + return AttemptOutcome::Failed(AttemptFailure::retryable( + safe_source_failure(&error), + false, + )); + } + if metadata.content_length > 0 + && size_hint.is_some_and(|hint| hint > 0 && hint != metadata.content_length) + { + return AttemptOutcome::Failed(AttemptFailure::retryable( + "remote size conflicts with resolved download metadata".into(), + false, + )); + } + let total_size = if metadata.content_length > 0 { + metadata.content_length + } else { + size_hint.unwrap_or(0) + }; + let supports_range = metadata.accepts_ranges && resume_supported != Some(false); if user_cancel_token.is_cancelled() { return AttemptOutcome::Cancelled; } - if total_size > 0 { + let num_segments = segment_count_for_attempt( + segments_count, + total_size, + min_segment_bytes, + supports_range, + ); + let fresh_segments: Vec = { + let ranges: Vec<(u64, u64)> = if supports_range && total_size > 0 && num_segments > 1 { + let segment_size = total_size / num_segments as u64; + (0..num_segments) + .map(|i| { + let start = i as u64 * segment_size; + let end = if i == num_segments - 1 { + total_size + } else { + (i as u64 + 1) * segment_size + }; + (start, end) + }) + .collect() + } else if supports_range && total_size > 0 { + vec![(0, total_size)] + } else { + vec![(0, u64::MAX)] + }; + ranges + .into_iter() + .enumerate() + .map(|(index, (start_byte, end_byte))| SegmentMeta { + id: index as u32, + start_byte, + end_byte, + downloaded_bytes: 0, + completed: false, + }) + .collect() + }; + + let (created_by_attempt, resume_metadata) = { let storage = file_storage.clone(); let path = dest_path.clone(); + let stable_url = resume_url.clone(); + let ownership_segments = fresh_segments.clone(); match tokio::task::spawn_blocking(move || { - if path.exists() { - let has_meta = storage.read_meta(&path).ok().flatten().is_some(); - if !has_meta { - if let Err(e) = std::fs::remove_file(&path) { - tracing::warn!( - path = %path.display(), - error = %e, - "failed to remove stale download file; create_file may fail" - ); - } else { - tracing::debug!( - path = %path.display(), - "removed orphaned download file before re-creating" - ); + if storage.file_exists(&path)? { + match storage.read_meta(&path)? { + Some(metadata) + if resume_metadata_matches( + &metadata, + download_id, + &stable_url, + &path, + total_size, + ) => + { + return Ok((false, Some(metadata))); + } + Some(metadata) if metadata.download_id != download_id => { + return Err(DomainError::AlreadyExists( + "destination belongs to another Vortex download".into(), + )); + } + Some(_) => storage.delete_download_artifacts(&path)?, + None => { + return Err(DomainError::AlreadyExists( + "destination file has no Vortex resume metadata".into(), + )); } } } - storage.create_file(&path, total_size) + storage.create_file(&path, total_size)?; + let metadata = ownership_metadata( + download_id, + stable_url, + &path, + total_size, + ownership_segments, + ); + if let Err(error) = storage.write_meta(&path, &metadata) { + if let Err(cleanup_error) = storage.delete_download_artifacts(&path) { + tracing::warn!( + download_id = download_id.0, + error = %cleanup_error, + "failed to roll back a download whose ownership metadata could not be written" + ); + } + return Err(error); + } + Ok((true, None)) }) .await { @@ -754,94 +890,141 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { error = %e, "spawn_blocking for create_file panicked" ); - return AttemptOutcome::Failed(format!("file pre-allocation failed: {e}")); + return AttemptOutcome::Failed(AttemptFailure::terminal( + format!("file pre-allocation failed: {e}"), + false, + )); } Ok(Err(e)) => { - return AttemptOutcome::Failed(format!("file pre-allocation failed: {e}")); + return AttemptOutcome::Failed(AttemptFailure::terminal( + format!("file pre-allocation failed: {e}"), + false, + )); } - Ok(Ok(())) => {} + Ok(Ok(result)) => result, } - } - - let num_segments = if supports_range && total_size > 0 { - segments_count - .min((total_size / min_segment_bytes).max(1) as u32) - .max(1) - } else { - 1 - }; - - let segments: Vec<(u64, u64)> = if supports_range && total_size > 0 && num_segments > 1 { - let segment_size = total_size / num_segments as u64; - (0..num_segments) - .map(|i| { - let start = i as u64 * segment_size; - let end = if i == num_segments - 1 { - total_size - } else { - (i as u64 + 1) * segment_size - }; - (start, end) - }) - .collect() - } else if supports_range && total_size > 0 { - vec![(0, total_size)] - } else { - vec![(0, u64::MAX)] }; - if user_cancel_token.is_cancelled() { + let segments: Vec = + validated_resume_segments(resume_metadata.as_ref(), total_size, supports_range) + .unwrap_or(fresh_segments); + + let cancelled = user_cancel_token.is_cancelled(); + if source_policy.is_protected() + && created_by_attempt + && cancelled + && let Err(error) = cleanup_download_artifacts(&file_storage, &dest_path).await + { + tracing::warn!( + download_id = download_id.0, + error = %error, + "failed to clean up a cancelled protected download attempt" + ); + } + if cancelled { return AttemptOutcome::Cancelled; } event_bus.publish(DomainEvent::DownloadStarted { id: download_id }); - let shared_downloaded = Arc::new(AtomicU64::new(0)); + let shared_downloaded = Arc::new(AtomicU64::new( + segments + .iter() + .map(|segment| segment.downloaded_bytes) + .sum(), + )); let mut join_set: JoinSet<(usize, Result)> = JoinSet::new(); let mut active_segments: Vec = Vec::with_capacity(segments.len()); - for (index, (start, end)) in segments.iter().enumerate() { - let (end_tx, end_rx) = watch::channel(*end); + for (index, segment) in segments.iter().enumerate() { + let (end_tx, end_rx) = watch::channel(segment.end_byte); let progress = Arc::new(AtomicU64::new(0)); active_segments.push(SegmentRuntimeState { end_tx, progress: progress.clone(), started_at: std::time::Instant::now(), - start_byte: *start, - initial_end: *end, - completed: false, + start_byte: segment.start_byte, + initial_end: segment.end_byte, + already_downloaded: segment.downloaded_bytes, + completed: segment.completed, }); + if segment.completed { + continue; + } let params = SegmentParams { client: client.clone(), file_storage: file_storage.clone(), event_bus: event_bus.clone(), download_id, - segment_index: index as u32, + segment_index: segment.id, url: url.clone(), - start_byte: *start, + start_byte: segment.start_byte, end_byte_rx: end_rx, - already_downloaded: 0, + already_downloaded: segment.downloaded_bytes, total_file_size: total_size, dest_path: dest_path.clone(), pause_rx: pause_rx.clone(), cancel_token: attempt_token.clone(), shared_downloaded: shared_downloaded.clone(), segment_progress: progress, - sensitive_url, + source_policy, }; let slot_idx = index; join_set.spawn(async move { (slot_idx, download_segment(params).await) }); } + if supports_range && total_size > 0 { + persist_resume_meta( + &file_storage, + &dest_path, + download_id, + &resume_url, + total_size, + &active_segments, + ) + .await; + } + let mut failed = false; let mut error_msg = String::new(); let mut next_segment_id: u32 = segments.len() as u32; - - while let Some(result) = join_set.join_next().await { + let mut resume_interval = tokio::time::interval(std::time::Duration::from_millis(500)); + resume_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + resume_interval.tick().await; + + while !join_set.is_empty() { + let Some(result) = (tokio::select! { + result = join_set.join_next() => result, + _ = resume_interval.tick(), if supports_range && total_size > 0 => { + persist_resume_meta( + &file_storage, + &dest_path, + download_id, + &resume_url, + total_size, + &active_segments, + ) + .await; + continue; + } + }) else { + break; + }; match result { Ok((slot_idx, Ok(_bytes))) => { if slot_idx < active_segments.len() { active_segments[slot_idx].completed = true; } + if supports_range && total_size > 0 { + persist_resume_meta( + &file_storage, + &dest_path, + download_id, + &resume_url, + total_size, + &active_segments, + ) + .await; + } if dynamic_split_enabled.load(Ordering::Relaxed) && !attempt_token.is_cancelled() @@ -890,7 +1073,7 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { cancel_token: attempt_token.clone(), shared_downloaded: shared_downloaded.clone(), segment_progress: new_progress.clone(), - sensitive_url, + source_policy, }; join_set.spawn(async move { (new_slot_idx, download_segment(params).await) }); active_segments.push(SegmentRuntimeState { @@ -899,10 +1082,11 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { started_at: std::time::Instant::now(), start_byte: split_at, initial_end, + already_downloaded: 0, completed: false, }); - persist_split_meta( + persist_resume_meta( &file_storage, &dest_path, download_id, @@ -944,1024 +1128,82 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { } } - if user_cancel_token.is_cancelled() { - return AttemptOutcome::Cancelled; - } - if failed { - return AttemptOutcome::Failed(error_msg); - } - AttemptOutcome::Completed -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::path::Path; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering}; - use std::sync::{Condvar, Mutex}; - use std::time::Duration; - - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - use crate::domain::model::account::AccountId; - use crate::domain::model::download::{Download, DownloadId, Url}; - use crate::domain::model::meta::DownloadMeta; - use crate::domain::ports::driven::{ - DownloadSourceResolver, EventBus, FileStorage, ResolvedDownloadSource, - }; - - // --- Mock types --- - - type WriteRecord = (PathBuf, u64, Vec); - - struct MockFileStorage { - writes: Arc>>, - } - - impl MockFileStorage { - fn new() -> Self { - Self { - writes: Arc::new(Mutex::new(Vec::new())), - } - } - } - - impl FileStorage for MockFileStorage { - fn create_file(&self, _path: &Path, _size: u64) -> Result<(), DomainError> { - Ok(()) - } - - fn write_segment(&self, path: &Path, offset: u64, data: &[u8]) -> Result<(), DomainError> { - self.writes - .lock() - .unwrap() - .push((path.to_path_buf(), offset, data.to_vec())); - Ok(()) - } - - fn read_meta(&self, _path: &Path) -> Result, DomainError> { - Ok(None) - } - - fn write_meta(&self, _path: &Path, _meta: &DownloadMeta) -> Result<(), DomainError> { - Ok(()) - } - - fn delete_meta(&self, _path: &Path) -> Result<(), DomainError> { - Ok(()) - } - } - - struct CollectingEventBus { - events: Arc>>, - } - - impl CollectingEventBus { - fn new() -> Self { - Self { - events: Arc::new(Mutex::new(Vec::new())), - } - } - - fn collected(&self) -> Vec { - self.events.lock().unwrap().clone() - } - - async fn wait_for_event_async(&self, predicate: F, timeout: Duration) -> bool - where - F: Fn(&DomainEvent) -> bool, - { - let deadline = tokio::time::Instant::now() + timeout; - loop { - if self.collected().iter().any(&predicate) { - return true; - } - if tokio::time::Instant::now() >= deadline { - return false; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - } - } - - impl EventBus for CollectingEventBus { - fn publish(&self, event: DomainEvent) { - self.events.lock().unwrap().push(event); - } - - fn subscribe(&self, _handler: Box) {} - } - - fn make_download(id: u64, url: &str) -> Download { - let download_id = DownloadId(id); - let parsed_url = Url::new(url).unwrap(); - // destination_path must be the full file path (dir + filename), - // matching how StartDownloadCommand builds it in production. - Download::new( + let cancelled = user_cancel_token.is_cancelled(); + if supports_range && total_size > 0 && (cancelled || failed) { + persist_resume_meta( + &file_storage, + &dest_path, download_id, - parsed_url, - "test_file.bin".to_string(), - "/tmp/test_file.bin".to_string(), + &resume_url, + total_size, + &active_segments, ) + .await; } - - fn make_engine( - storage: Arc, - bus: Arc, - ) -> SegmentedDownloadEngine { - SegmentedDownloadEngine::new(reqwest::Client::new(), storage, bus, 4) - } - - struct LoopbackSourceResolver { - calls: AtomicUsize, - } - - struct BlockingSourceResolver { - entered: Mutex>>, - release: Arc<(Mutex, Condvar)>, - committed: AtomicBool, - finished: AtomicBool, + let cleanup_failed = + if source_policy.is_protected() && created_by_attempt && (cancelled || failed) { + cleanup_download_artifacts(&file_storage, &dest_path) + .await + .inspect_err(|error| { + tracing::warn!( + download_id = download_id.0, + error = %error, + "failed to clean up a protected download attempt" + ); + }) + .is_err() + } else { + false + }; + if cancelled { + return AttemptOutcome::Cancelled; } - - impl DownloadSourceResolver for BlockingSourceResolver { - fn resolve(&self, _: &Download) -> Result { - Err(DomainError::PluginError( - "cancellable path was not used".into(), - )) - } - - fn resolve_cancellable( - &self, - _: &Download, - cancellation: &ResolutionCancellation, - ) -> Result { - if let Some(entered) = self.entered.lock().unwrap().take() { - entered.send(()).unwrap(); - } - let (released, condition) = &*self.release; - let mut released = released.lock().unwrap(); - while !*released { - released = condition.wait(released).unwrap(); - } - let result = cancellation.run_if_active(|| { - self.committed.store(true, AtomicOrdering::SeqCst); - Ok(ResolvedDownloadSource::sensitive( - "https://1.1.1.1/late-token".into(), - )) - }); - self.finished.store(true, AtomicOrdering::SeqCst); - result + if failed { + if cleanup_failed { + return AttemptOutcome::Failed(AttemptFailure::terminal( + "failed to clean up the protected download attempt".into(), + true, + )); } + return AttemptOutcome::Failed(AttemptFailure::retryable(error_msg, true)); } - - impl DownloadSourceResolver for LoopbackSourceResolver { - fn resolve(&self, _: &Download) -> Result { - self.calls.fetch_add(1, AtomicOrdering::SeqCst); - Ok(ResolvedDownloadSource::sensitive( - "https://127.0.0.1/secret-token".into(), + let storage = file_storage.clone(); + let path = dest_path.clone(); + match tokio::task::spawn_blocking(move || storage.delete_meta(&path)).await { + Ok(Ok(())) => AttemptOutcome::Completed, + Ok(Err(error)) => { + tracing::warn!( + download_id = download_id.0, + error = %error, + "failed to finalize download ownership metadata" + ); + AttemptOutcome::Failed(AttemptFailure::terminal( + "failed to finalize the completed download".into(), + true, )) } - } - - #[tokio::test] - async fn premium_source_is_resolved_jit_and_blocked_before_private_network_access() { - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let resolver = Arc::new(LoopbackSourceResolver { - calls: AtomicUsize::new(0), - }); - let engine = make_engine(storage, bus.clone()).with_source_resolver(resolver.clone()); - let download = make_download(99, "https://1fichier.com/?abc123") - .with_module_name("vortex-mod-1fichier".into()) - .with_account_id(AccountId::new("account-1")); - - engine.start(&download).expect("spawn download"); - - assert!( - bus.wait_for_event_async( - |event| matches!(event, DomainEvent::DownloadFailed { id, .. } if id.0 == 99), - Duration::from_secs(2), - ) - .await - ); - assert_eq!(resolver.calls.load(AtomicOrdering::SeqCst), 1); - let serialized = format!("{:?}", bus.collected()); - assert!(!serialized.contains("secret-token")); - assert_eq!(download.url().as_str(), "https://1fichier.com/?abc123"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn cancellation_wins_against_blocked_jit_resolution_without_late_commit() { - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let (entered_tx, entered_rx) = std::sync::mpsc::channel(); - let release = Arc::new((Mutex::new(false), Condvar::new())); - let resolver = Arc::new(BlockingSourceResolver { - entered: Mutex::new(Some(entered_tx)), - release: release.clone(), - committed: AtomicBool::new(false), - finished: AtomicBool::new(false), - }); - let engine = make_engine(storage, bus.clone()).with_source_resolver(resolver.clone()); - let download = make_download(98, "https://1fichier.com/?abc123") - .with_module_name("vortex-mod-1fichier".into()) - .with_account_id(AccountId::new("account-1")); - engine.start(&download).expect("spawn download"); - tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(1))) - .await - .unwrap() - .expect("resolver entered"); - - engine - .cancel(download.id()) - .expect("cancel active download"); - assert!( - bus.wait_for_event_async( - |event| matches!(event, DomainEvent::DownloadCancelled { id } if id.0 == 98), - Duration::from_millis(300), - ) - .await, - "cancellation must not wait for the blocking resolver" - ); - - let (released, condition) = &*release; - *released.lock().unwrap() = true; - condition.notify_all(); - let deadline = tokio::time::Instant::now() + Duration::from_secs(1); - while !resolver.finished.load(AtomicOrdering::SeqCst) - && tokio::time::Instant::now() < deadline - { - tokio::time::sleep(Duration::from_millis(10)).await; - } - assert!(resolver.finished.load(AtomicOrdering::SeqCst)); - assert!(!resolver.committed.load(AtomicOrdering::SeqCst)); - } - - // --- Tests --- - - #[tokio::test] - async fn test_start_spawns_download_and_completes() { - let server = MockServer::start().await; - let body = vec![b'a'; 1024]; - - Mock::given(method("HEAD")) - .and(path("/file")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-length", "1024") - .insert_header("accept-ranges", "bytes"), - ) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/file")) - .respond_with(ResponseTemplate::new(206).set_body_bytes(body)) - .mount(&server) - .await; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus.clone()); - - let url = format!("{}/file", server.uri()); - let download = make_download(1, &url); - - engine.start(&download).unwrap(); - - let found = bus - .wait_for_event_async( - |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 1), - Duration::from_secs(5), - ) - .await; - - assert!(found, "DownloadCompleted not received"); - - let events = bus.collected(); - assert!( - events - .iter() - .any(|e| matches!(e, DomainEvent::DownloadStarted { id } if id.0 == 1)), - "DownloadStarted not published" - ); - } - - #[tokio::test] - async fn test_start_fallback_single_segment_no_range() { - let server = MockServer::start().await; - let body = vec![b'b'; 512]; - - Mock::given(method("HEAD")) - .and(path("/norange")) - .respond_with( - ResponseTemplate::new(200).insert_header("content-length", "512"), - // No accept-ranges header → single segment fallback - ) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/norange")) - .respond_with(ResponseTemplate::new(206).set_body_bytes(body)) - .mount(&server) - .await; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus.clone()); - - let url = format!("{}/norange", server.uri()); - let download = make_download(2, &url); - - engine.start(&download).unwrap(); - - let found = bus - .wait_for_event_async( - |e| { - matches!( - e, - DomainEvent::DownloadCompleted { id } | DomainEvent::DownloadFailed { id, .. } - if id.0 == 2 - ) - }, - Duration::from_secs(5), - ) - .await; - - assert!(found, "download did not finish"); - - let events = bus.collected(); - assert!( - events - .iter() - .any(|e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 2)), - "expected DownloadCompleted, events: {events:?}" - ); - } - - #[tokio::test] - async fn test_start_falls_back_to_get_when_head_returns_non_success() { - let server = MockServer::start().await; - let body = vec![b'g'; 256]; - - Mock::given(method("HEAD")) - .and(path("/head-blocked")) - .respond_with(ResponseTemplate::new(405)) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/head-blocked")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-length", "256") - .set_body_bytes(body), - ) - .mount(&server) - .await; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus.clone()); - - let url = format!("{}/head-blocked", server.uri()); - let download = make_download(20, &url); - - engine.start(&download).unwrap(); - - let found = bus - .wait_for_event_async( - |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 20), - Duration::from_secs(5), - ) - .await; - - assert!(found, "download should complete via GET fallback"); - } - - #[tokio::test] - async fn test_pause_sends_signal() { - let server = MockServer::start().await; - // Slow server to keep download active - let body = vec![b'p'; 64 * 1024]; - - Mock::given(method("HEAD")) - .and(path("/slow")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-length", &(64 * 1024u64).to_string()) - .insert_header("accept-ranges", "bytes"), - ) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/slow")) - .respond_with( - ResponseTemplate::new(206) - .set_body_bytes(body) - .set_delay(Duration::from_secs(10)), - ) - .mount(&server) - .await; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus.clone()); - - let url = format!("{}/slow", server.uri()); - let download = make_download(3, &url); - - engine.start(&download).unwrap(); - - // Wait for DownloadStarted before pausing - bus.wait_for_event_async( - |e| matches!(e, DomainEvent::DownloadStarted { id } if id.0 == 3), - Duration::from_secs(3), - ) - .await; - - let pause_result = engine.pause(DownloadId(3)); - assert!(pause_result.is_ok(), "pause should succeed"); - - let events = bus.collected(); - assert!( - events - .iter() - .any(|e| matches!(e, DomainEvent::DownloadPaused { id } if id.0 == 3)), - "DownloadPaused not published" - ); - - // Clean up - let _ = engine.cancel(DownloadId(3)); - } - - #[tokio::test] - async fn test_cancel_stops_download() { - let server = MockServer::start().await; - let body = vec![b'c'; 64 * 1024]; - - Mock::given(method("HEAD")) - .and(path("/cancel")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-length", &(64 * 1024u64).to_string()) - .insert_header("accept-ranges", "bytes"), - ) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/cancel")) - .respond_with( - ResponseTemplate::new(206) - .set_body_bytes(body) - .set_delay(Duration::from_secs(10)), - ) - .mount(&server) - .await; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus.clone()); - - let url = format!("{}/cancel", server.uri()); - let download = make_download(4, &url); - - engine.start(&download).unwrap(); - - // Wait for DownloadStarted - bus.wait_for_event_async( - |e| matches!(e, DomainEvent::DownloadStarted { id } if id.0 == 4), - Duration::from_secs(3), - ) - .await; - - let cancel_result = engine.cancel(DownloadId(4)); - assert!(cancel_result.is_ok(), "cancel should succeed"); - - // Cancel is idempotent — second call succeeds (task removes itself on exit) - let cancel_again = engine.cancel(DownloadId(4)); - assert!( - cancel_again.is_ok(), - "second cancel should succeed (idempotent)" - ); - } - - #[tokio::test] - async fn test_dynamic_split_skipped_when_remaining_too_small() { - // 2 KiB total, 4 segments, min_remaining 4 MiB → split must NOT trigger. - let server = MockServer::start().await; - let body = vec![b'a'; 2048]; - - Mock::given(method("HEAD")) - .and(path("/small")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-length", "2048") - .insert_header("accept-ranges", "bytes"), - ) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/small")) - .respond_with(ResponseTemplate::new(206).set_body_bytes(body)) - .mount(&server) - .await; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = SegmentedDownloadEngine::new(reqwest::Client::new(), storage, bus.clone(), 4) - .with_min_segment_bytes(256) - .with_dynamic_split(true, 4); // 4 MiB threshold blocks 2 KiB file - - let url = format!("{}/small", server.uri()); - let download = make_download(70, &url); - engine.start(&download).unwrap(); - - let found = bus - .wait_for_event_async( - |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 70), - Duration::from_secs(5), - ) - .await; - assert!(found, "download did not complete"); - - let events = bus.collected(); - assert!( - !events - .iter() - .any(|e| matches!(e, DomainEvent::SegmentSplit { .. })), - "no split should fire when remaining < threshold; got {events:?}" - ); - } - - #[tokio::test] - async fn test_dynamic_split_disabled_via_config_does_not_split() { - let server = MockServer::start().await; - let body = vec![b'x'; 64 * 1024]; - - Mock::given(method("HEAD")) - .and(path("/disabled")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-length", "65536") - .insert_header("accept-ranges", "bytes"), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/disabled")) - .respond_with(ResponseTemplate::new(206).set_body_bytes(body)) - .mount(&server) - .await; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = SegmentedDownloadEngine::new(reqwest::Client::new(), storage, bus.clone(), 4) - .with_min_segment_bytes(1024) - .with_dynamic_split(false, 0); - - let url = format!("{}/disabled", server.uri()); - let download = make_download(71, &url); - engine.start(&download).unwrap(); - - let found = bus - .wait_for_event_async( - |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 71), - Duration::from_secs(5), - ) - .await; - assert!(found); - let events = bus.collected(); - assert!( - !events - .iter() - .any(|e| matches!(e, DomainEvent::SegmentSplit { .. })), - "split must not fire when disabled" - ); - } - - #[test] - fn test_pick_split_target_prefers_slowest_above_threshold() { - let make = |start: u64, end: u64, downloaded: u64, age_ms: u64| SegmentRuntimeState { - end_tx: watch::channel(end).0, - progress: Arc::new(AtomicU64::new(downloaded)), - started_at: std::time::Instant::now() - std::time::Duration::from_millis(age_ms), - start_byte: start, - initial_end: end, - completed: false, - }; - let segs = [ - // fast: 1 MiB downloaded in 1500 ms → ~700 KiB/s - make(0, 16 * 1024 * 1024, 1024 * 1024, 1500), - // slow: 100 KiB in 1000 ms → ~100 KiB/s, plenty of remaining - make(16 * 1024 * 1024, 32 * 1024 * 1024, 100 * 1024, 1000), - // tiny remaining → must be filtered - make(32 * 1024 * 1024, 32 * 1024 * 1024 + 1024, 512, 600), - ]; - let pick = pick_split_target(&segs, 4 * 1024 * 1024); - assert_eq!( - pick.map(|(i, _)| i), - Some(1), - "expected slot 1 (slowest with enough remaining), got {pick:?}" - ); - let (_, split_at) = pick.unwrap(); - assert!( - split_at > 16 * 1024 * 1024 + 100 * 1024, - "split must be above current offset" - ); - assert!( - split_at < 32 * 1024 * 1024, - "split must be below initial_end" - ); - } - - #[test] - fn test_pick_split_target_returns_none_when_all_below_threshold() { - let make = |start: u64, end: u64, downloaded: u64| SegmentRuntimeState { - end_tx: watch::channel(end).0, - progress: Arc::new(AtomicU64::new(downloaded)), - started_at: std::time::Instant::now() - std::time::Duration::from_millis(800), - start_byte: start, - initial_end: end, - completed: false, - }; - let segs = [make(0, 1024, 100), make(1024, 2048, 1), make(2048, 3072, 1)]; - let pick = pick_split_target(&segs, 4 * 1024 * 1024); - assert!(pick.is_none(), "got {pick:?}"); - } - - #[test] - fn test_pick_split_target_skips_fresh_segments() { - // Brand-new split children should not be candidates: no throughput - // sample yet (downloaded == 0) and elapsed below MIN_SPLIT_SAMPLE_DURATION. - // A genuinely slow neighbor (1000 ms / 100 KiB) sits next to them. - let mk = |start: u64, end: u64, downloaded: u64, age_ms: u64| SegmentRuntimeState { - end_tx: watch::channel(end).0, - progress: Arc::new(AtomicU64::new(downloaded)), - started_at: std::time::Instant::now() - std::time::Duration::from_millis(age_ms), - start_byte: start, - initial_end: end, - completed: false, - }; - let segs = [ - // fresh child: 0 bytes, 50 ms — must be skipped despite being "slowest" - mk(0, 16 * 1024 * 1024, 0, 50), - // slightly older but still no sample — must be skipped - mk(16 * 1024 * 1024, 32 * 1024 * 1024, 0, 200), - // genuinely slow but mature - mk(32 * 1024 * 1024, 48 * 1024 * 1024, 100 * 1024, 1000), - ]; - let pick = pick_split_target(&segs, 4 * 1024 * 1024); - assert_eq!(pick.map(|(i, _)| i), Some(2), "got {pick:?}"); - } - - #[test] - fn test_pick_split_target_skips_completed_segments() { - // A completed slot must never be picked even if its throughput was the - // slowest before completion. - let mk = |start: u64, end: u64, downloaded: u64, completed: bool| SegmentRuntimeState { - end_tx: watch::channel(end).0, - progress: Arc::new(AtomicU64::new(downloaded)), - started_at: std::time::Instant::now() - std::time::Duration::from_millis(1000), - start_byte: start, - initial_end: end, - completed, - }; - let segs = [ - // completed slow segment — must be ignored - mk(0, 16 * 1024 * 1024, 16 * 1024 * 1024, true), - // live, slower in absolute terms but only it is eligible - mk(16 * 1024 * 1024, 32 * 1024 * 1024, 100 * 1024, false), - ]; - let pick = pick_split_target(&segs, 4 * 1024 * 1024); - assert_eq!(pick.map(|(i, _)| i), Some(1)); - } - - #[tokio::test] - async fn test_pause_unknown_id_returns_not_found() { - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus); - - let result = engine.pause(DownloadId(999)); - assert!( - matches!(result, Err(DomainError::NotFound(_))), - "expected NotFound, got {result:?}" - ); - } - - #[tokio::test] - async fn test_cancel_unknown_id_returns_not_found() { - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus); - - let result = engine.cancel(DownloadId(888)); - assert!( - matches!(result, Err(DomainError::NotFound(_))), - "expected NotFound, got {result:?}" - ); - } - - fn make_download_with_mirrors(id: u64, mirrors: Vec) -> Download { - let download_id = DownloadId(id); - // The canonical URL is intentionally invalid for the mock server so - // failing this fallback still surfaces a clear test signal — we want - // every fetch to flow through `mirrors`. - let parsed_url = Url::new("https://invalid-canonical.example.invalid/file.bin").unwrap(); - Download::new( - download_id, - parsed_url, - "test_file.bin".to_string(), - "/tmp/test_file.bin".to_string(), - ) - .with_mirrors(mirrors) - } - - #[tokio::test] - async fn test_three_mirrors_first_404_triggers_failover_to_second() { - // 3 mirrors. First returns 404 for the GET range request so the - // attempt fails; second succeeds. Engine must publish - // MirrorSwitched then DownloadCompleted. - let server = MockServer::start().await; - let body = vec![b'm'; 256]; - - // Mirror 1 — HEAD 404 (probe fail) so attempt fails fast. - Mock::given(method("HEAD")) - .and(path("/m1")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/m1")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - - // Mirror 2 — works. - Mock::given(method("HEAD")) - .and(path("/m2")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-length", "256") - .insert_header("accept-ranges", "bytes"), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/m2")) - .respond_with(ResponseTemplate::new(206).set_body_bytes(body.clone())) - .mount(&server) - .await; - - // Mirror 3 — also works but should not be hit (m2 succeeds first). - Mock::given(method("HEAD")) - .and(path("/m3")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-length", "256") - .insert_header("accept-ranges", "bytes"), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/m3")) - .respond_with(ResponseTemplate::new(206).set_body_bytes(body)) - .mount(&server) - .await; - - let mirrors = vec![ - crate::domain::model::Mirror::new( - Url::new(&format!("{}/m1", server.uri())).unwrap(), - 90, // highest priority — tried first - None, - ) - .unwrap(), - crate::domain::model::Mirror::new( - Url::new(&format!("{}/m2", server.uri())).unwrap(), - 70, - None, - ) - .unwrap(), - crate::domain::model::Mirror::new( - Url::new(&format!("{}/m3", server.uri())).unwrap(), - 50, - None, - ) - .unwrap(), - ]; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus.clone()); - - let download = make_download_with_mirrors(100, mirrors); - engine.start(&download).unwrap(); - - let completed = bus - .wait_for_event_async( - |e| { - matches!( - e, - DomainEvent::DownloadCompleted { id } | DomainEvent::DownloadFailed { id, .. } - if id.0 == 100 - ) - }, - Duration::from_secs(10), - ) - .await; - assert!(completed, "engine did not finish"); - - let events = bus.collected(); - assert!( - events - .iter() - .any(|e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 100)), - "expected DownloadCompleted, events: {events:?}" - ); - - let switched: Vec<_> = events - .iter() - .filter_map(|e| match e { - DomainEvent::MirrorSwitched { - id, - new_mirror_index, - new_url, - } if id.0 == 100 => Some((*new_mirror_index, new_url.clone())), - _ => None, - }) - .collect(); - assert_eq!( - switched.len(), - 1, - "exactly one mirror switch expected, got {switched:?}" - ); - let (idx, url) = &switched[0]; - assert_eq!(*idx, 1, "switched to slot 1 (priority 70)"); - assert!(url.ends_with("/m2"), "expected /m2 mirror url, got {url}"); - } - - #[tokio::test] - async fn test_all_mirrors_fail_publishes_download_failed() { - let server = MockServer::start().await; - - for p in ["/all1", "/all2"] { - Mock::given(method("HEAD")) - .and(path(p)) - .respond_with(ResponseTemplate::new(500)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(p)) - .respond_with(ResponseTemplate::new(500)) - .mount(&server) - .await; + Err(error) => { + tracing::warn!( + download_id = download_id.0, + error = %error, + "download metadata finalization task stopped" + ); + AttemptOutcome::Failed(AttemptFailure::terminal( + "failed to finalize the completed download".into(), + true, + )) } - - let mirrors = vec![ - crate::domain::model::Mirror::new( - Url::new(&format!("{}/all1", server.uri())).unwrap(), - 80, - None, - ) - .unwrap(), - crate::domain::model::Mirror::new( - Url::new(&format!("{}/all2", server.uri())).unwrap(), - 40, - None, - ) - .unwrap(), - ]; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus.clone()); - - let download = make_download_with_mirrors(101, mirrors); - engine.start(&download).unwrap(); - - let done = bus - .wait_for_event_async( - |e| matches!(e, DomainEvent::DownloadFailed { id, .. } if id.0 == 101), - Duration::from_secs(10), - ) - .await; - assert!(done, "expected DownloadFailed after all mirrors exhausted"); - - let events = bus.collected(); - // Exactly one MirrorSwitched (between mirror 1 and mirror 2). - let switches: Vec<_> = events - .iter() - .filter(|e| matches!(e, DomainEvent::MirrorSwitched { id, .. } if id.0 == 101)) - .collect(); - assert_eq!(switches.len(), 1, "switched once before final failure"); - assert!( - !events - .iter() - .any(|e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 101)), - "must not emit DownloadCompleted on full mirror exhaustion" - ); } +} - #[tokio::test] - async fn test_priority_respected_highest_first() { - // Priority order: low (10) < mid (50) < high (90). The engine - // must pick `high` first; we confirm by failing only `high` and - // observing exactly one MirrorSwitched ending on `mid`. - let server = MockServer::start().await; - let body = vec![b'p'; 128]; - - // high — fails - Mock::given(method("HEAD")) - .and(path("/high")) - .respond_with(ResponseTemplate::new(503)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/high")) - .respond_with(ResponseTemplate::new(503)) - .mount(&server) - .await; - // mid — succeeds - Mock::given(method("HEAD")) - .and(path("/mid")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-length", "128") - .insert_header("accept-ranges", "bytes"), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/mid")) - .respond_with(ResponseTemplate::new(206).set_body_bytes(body)) - .mount(&server) - .await; - // low — must not be reached - Mock::given(method("HEAD")) - .and(path("/low")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - - // Insert in non-priority-order to assert the engine sorts. - let mirrors = vec![ - crate::domain::model::Mirror::new( - Url::new(&format!("{}/low", server.uri())).unwrap(), - 10, - None, - ) - .unwrap(), - crate::domain::model::Mirror::new( - Url::new(&format!("{}/high", server.uri())).unwrap(), - 90, - None, - ) - .unwrap(), - crate::domain::model::Mirror::new( - Url::new(&format!("{}/mid", server.uri())).unwrap(), - 50, - None, - ) - .unwrap(), - ]; - - let storage = Arc::new(MockFileStorage::new()); - let bus = Arc::new(CollectingEventBus::new()); - let engine = make_engine(storage, bus.clone()); - - let download = make_download_with_mirrors(102, mirrors); - engine.start(&download).unwrap(); +#[cfg(test)] +#[path = "download_engine_test_support.rs"] +mod test_support; - let done = bus - .wait_for_event_async( - |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 102), - Duration::from_secs(10), - ) - .await; - assert!(done, "expected DownloadCompleted via mid mirror"); +#[cfg(test)] +#[path = "download_engine_hoster_tests.rs"] +mod hoster_tests; - let events = bus.collected(); - let switches: Vec<_> = events - .iter() - .filter_map(|e| match e { - DomainEvent::MirrorSwitched { id, new_url, .. } if id.0 == 102 => { - Some(new_url.clone()) - } - _ => None, - }) - .collect(); - assert_eq!(switches.len(), 1, "exactly one switch (high → mid)"); - assert!( - switches[0].ends_with("/mid"), - "expected switch to /mid, got {}", - switches[0] - ); - } -} +#[cfg(test)] +#[path = "download_engine_tests.rs"] +mod tests; diff --git a/src-tauri/src/adapters/driven/network/download_engine_hoster_tests.rs b/src-tauri/src/adapters/driven/network/download_engine_hoster_tests.rs new file mode 100644 index 00000000..b49b602b --- /dev/null +++ b/src-tauri/src/adapters/driven/network/download_engine_hoster_tests.rs @@ -0,0 +1,844 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::adapters::driven::filesystem::FsFileStorage; +use crate::adapters::driven::network::restricted_download_client; +use crate::domain::model::account::AccountId; +use crate::domain::model::download::{Download, DownloadId}; +use crate::domain::model::meta::DownloadMeta; +use crate::domain::ports::driven::{ + DownloadSourceResolver, EventBus, FileStorage, ResolvedDownloadSource, +}; + +use super::test_support::*; +use super::*; + +struct LoopbackSourceResolver { + calls: AtomicUsize, + url: String, +} + +struct RotatingHosterSourceResolver { + calls: AtomicUsize, + base_url: String, +} + +struct DirectSourceResolver; + +struct HeaderedDirectSourceResolver { + url: String, +} + +fn loopback_source_client( + _: &reqwest::Url, + request_headers: &[(String, String)], +) -> Result { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .default_headers( + crate::adapters::driven::network::safe_url::validated_plugin_headers(request_headers)?, + ) + .build() + .map_err(|_| DomainError::NetworkError("test HTTP client creation failed".into())) +} + +struct BlockingSourceResolver { + entered: Mutex>>, + release: Arc<(Mutex, Condvar)>, + committed: AtomicBool, + finished: AtomicBool, +} + +impl DownloadSourceResolver for BlockingSourceResolver { + fn resolve(&self, _: &Download) -> Result { + Err(DomainError::PluginError( + "cancellable path was not used".into(), + )) + } + + fn resolve_cancellable( + &self, + _: &Download, + cancellation: &ResolutionCancellation, + ) -> Result { + if let Some(entered) = self.entered.lock().unwrap().take() { + entered.send(()).unwrap(); + } + let (released, condition) = &*self.release; + let mut released = released.lock().unwrap(); + while !*released { + released = condition.wait(released).unwrap(); + } + let result = cancellation.run_if_active(|| { + self.committed.store(true, AtomicOrdering::SeqCst); + Ok(ResolvedDownloadSource::protected( + "https://1.1.1.1/late-token".into(), + )) + }); + self.finished.store(true, AtomicOrdering::SeqCst); + result + } +} + +impl DownloadSourceResolver for LoopbackSourceResolver { + fn resolve(&self, _: &Download) -> Result { + self.calls.fetch_add(1, AtomicOrdering::SeqCst); + Ok(ResolvedDownloadSource::protected(self.url.clone())) + } +} + +impl DownloadSourceResolver for RotatingHosterSourceResolver { + fn requires_resolution(&self, _: &Download) -> Result { + Ok(true) + } + + fn resolve(&self, _: &Download) -> Result { + let call = self.calls.fetch_add(1, AtomicOrdering::SeqCst) + 1; + Ok(ResolvedDownloadSource::protected(format!( + "{}/{}", + self.base_url, + if call == 1 { "expired" } else { "fresh" } + )) + .with_request_headers(vec![( + "Authorization".into(), + format!("Bearer token-{call}"), + )])) + } +} + +impl DownloadSourceResolver for DirectSourceResolver { + fn requires_resolution(&self, _: &Download) -> Result { + Ok(true) + } + + fn resolve(&self, _: &Download) -> Result { + Ok(ResolvedDownloadSource::direct( + "https://example.com/file.bin".into(), + )) + } +} + +impl DownloadSourceResolver for HeaderedDirectSourceResolver { + fn requires_resolution(&self, _: &Download) -> Result { + Ok(true) + } + + fn resolve(&self, _: &Download) -> Result { + Ok(ResolvedDownloadSource::direct(self.url.clone()) + .with_request_headers(vec![("Authorization".into(), "Bearer secret".into())])) + } +} + +#[tokio::test] +async fn resolved_direct_source_keeps_direct_policy() { + let prepared = prepare_sources( + make_download(197, "https://example.com/file.bin"), + Some(Arc::new(DirectSourceResolver)), + reqwest::Client::new(), + CancellationToken::new(), + ResolutionCancellation::default(), + restricted_download_client, + ) + .await + .expect("direct source prepares"); + + assert_eq!(prepared.policy, SourcePolicy::Direct); +} + +#[tokio::test] +async fn credential_bearing_direct_source_uses_restricted_network_policy() { + let error = match prepare_sources( + make_download(198, "https://example.com/file.bin"), + Some(Arc::new(HeaderedDirectSourceResolver { + url: "http://127.0.0.1/private".into(), + })), + reqwest::Client::new(), + CancellationToken::new(), + ResolutionCancellation::default(), + restricted_download_client, + ) + .await + { + Ok(_) => panic!("credential-bearing direct source must reject private cleartext URLs"), + Err(error) => error, + }; + + assert!(matches!(error, DomainError::NetworkError(_))); +} + +#[tokio::test] +async fn premium_source_is_resolved_jit_and_blocked_before_private_network_access() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let private_url = format!("https://{}/secret-token", listener.local_addr().unwrap()); + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let resolver = Arc::new(LoopbackSourceResolver { + calls: AtomicUsize::new(0), + url: private_url, + }); + let engine = make_engine(storage.clone(), bus.clone()).with_source_resolver(resolver.clone()); + let download = make_download(99, "https://1fichier.com/?abc123") + .with_module_name("vortex-mod-1fichier".into()) + .with_account_id(AccountId::new("account-1")); + + engine.start(&download).expect("spawn download"); + + assert!( + bus.wait_for_event_async( + |event| matches!(event, DomainEvent::DownloadFailed { id, .. } if id.0 == 99), + Duration::from_secs(2), + ) + .await + ); + assert_eq!(resolver.calls.load(AtomicOrdering::SeqCst), 1); + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err(), + "private listener must not receive a connection" + ); + let serialized = format!("{:?}", bus.collected()); + assert!(!serialized.contains("secret-token")); + assert_eq!(download.url().as_str(), "https://1fichier.com/?abc123"); + assert!( + storage.artifact_deletions().is_empty(), + "source preparation must not clean up an artifact it never created" + ); +} + +#[tokio::test] +async fn every_hoster_engine_start_resolves_a_fresh_download_capability() { + for (index, service) in [ + "vortex-mod-mediafire", + "vortex-mod-pixeldrain", + "vortex-mod-gofile", + ] + .into_iter() + .enumerate() + { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/expired")) + .respond_with(ResponseTemplate::new(410)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/expired")) + .respond_with(ResponseTemplate::new(410)) + .mount(&server) + .await; + Mock::given(method("HEAD")) + .and(path("/fresh")) + .and(header("authorization", "Bearer token-2")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/octet-stream") + .insert_header("content-length", "4"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/fresh")) + .and(header("authorization", "Bearer token-2")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"data")) + .mount(&server) + .await; + let resolver = Arc::new(RotatingHosterSourceResolver { + calls: AtomicUsize::new(0), + base_url: server.uri(), + }); + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join(format!("hoster-{index}.bin")); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(Arc::new(FsFileStorage::new()), bus.clone()) + .with_source_resolver(resolver.clone()) + .with_resolved_client_factory_for_testing(loopback_source_client); + let download = Download::new( + DownloadId(200 + index as u64), + crate::domain::model::download::Url::new("https://hoster.example/page").unwrap(), + format!("hoster-{index}.bin"), + destination.to_string_lossy().into_owned(), + ) + .with_module_name(service.into()); + + engine.start(&download).expect("engine start"); + assert!( + bus.wait_for_event_async( + |event| matches!(event, DomainEvent::DownloadCompleted { id } if id == &download.id()), + Duration::from_secs(3), + ) + .await, + "{service} did not complete" + ); + assert_eq!(resolver.calls.load(AtomicOrdering::SeqCst), 2, "{service}"); + assert_eq!(std::fs::read(destination).unwrap(), b"data", "{service}"); + } +} + +#[tokio::test] +async fn protected_hoster_attempt_rejects_an_unexpected_html_page() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/html; charset=utf-8") + .insert_header("content-length", "18"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/html; charset=utf-8") + .set_body_string("expired"), + ) + .mount(&server) + .await; + let storage: Arc = Arc::new(MockFileStorage::new()); + let events: Arc = Arc::new(CollectingEventBus::new()); + + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(299), + segments_count: 1, + client: reqwest::Client::new(), + file_storage: storage, + event_bus: events, + dest_path: PathBuf::from("/tmp/vortex-hoster-html-test.bin"), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://hoster.example/page".into(), + source_policy: SourcePolicy::Protected { allow_html: false }, + size_hint: None, + resume_supported: None, + }) + .await; + + match outcome { + AttemptOutcome::Failed(failure) => { + assert!(failure.message.contains("HTML"), "{}", failure.message) + } + _ => panic!("hoster HTML must be rejected"), + } +} + +#[tokio::test] +async fn protected_hoster_attempt_rejects_html_disguised_as_binary() { + let server = MockServer::start().await; + let body = "expired"; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/octet-stream") + .insert_header("content-length", body.len().to_string()), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/octet-stream") + .set_body_string(body), + ) + .mount(&server) + .await; + + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("disguised-html.bin"); + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(300), + segments_count: 1, + client: reqwest::Client::new(), + file_storage: Arc::new(FsFileStorage::new()), + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://hoster.example/page".into(), + source_policy: SourcePolicy::Protected { allow_html: false }, + size_hint: None, + resume_supported: None, + }) + .await; + + match outcome { + AttemptOutcome::Failed(failure) => { + assert!(failure.message.contains("HTML"), "{}", failure.message) + } + _ => panic!("disguised hoster HTML must be rejected"), + } + assert!(!destination.exists()); +} + +#[tokio::test] +async fn protected_hoster_attempt_rejects_html_in_a_later_range() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/octet-stream") + .insert_header("content-length", "32") + .insert_header("accept-ranges", "bytes"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .and(header("range", "bytes=0-15")) + .respond_with( + ResponseTemplate::new(206) + .insert_header("content-type", "application/octet-stream") + .insert_header("content-length", "16") + .insert_header("content-range", "bytes 0-15/32") + .set_body_bytes(vec![0; 16]), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .and(header("range", "bytes=16-31")) + .respond_with( + ResponseTemplate::new(206) + .insert_header("content-type", "application/octet-stream") + .insert_header("content-length", "16") + .insert_header("content-range", "bytes 16-31/32") + .set_body_string("bad"), + ) + .mount(&server) + .await; + + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("later-range-html.bin"); + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(311), + segments_count: 2, + client: reqwest::Client::new(), + file_storage: Arc::new(FsFileStorage::new()), + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination, + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://hoster.example/page".into(), + source_policy: SourcePolicy::Protected { allow_html: false }, + size_hint: Some(32), + resume_supported: Some(true), + }) + .await; + + match outcome { + AttemptOutcome::Failed(failure) => { + assert!(failure.message.contains("HTML"), "{}", failure.message) + } + _ => panic!("HTML returned for a later range must be rejected"), + } +} + +#[tokio::test] +async fn protected_hoster_attempt_grows_an_atomically_reserved_unknown_length_file() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200).insert_header("content-type", "application/octet-stream"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"data")) + .mount(&server) + .await; + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("unknown-length.bin"); + + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(302), + segments_count: 1, + client: reqwest::Client::new(), + file_storage: Arc::new(FsFileStorage::new()), + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://hoster.example/page".into(), + source_policy: SourcePolicy::Protected { allow_html: false }, + size_hint: None, + resume_supported: None, + }) + .await; + + assert!(matches!(outcome, AttemptOutcome::Completed)); + assert_eq!(std::fs::read(destination).unwrap(), b"data"); +} + +#[tokio::test] +async fn protected_hoster_attempt_never_deletes_an_unowned_destination() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200).insert_header("content-type", "application/octet-stream"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"data")) + .mount(&server) + .await; + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("existing.bin"); + std::fs::write(&destination, b"user-owned").unwrap(); + + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(301), + segments_count: 1, + client: reqwest::Client::new(), + file_storage: Arc::new(FsFileStorage::new()), + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://hoster.example/page".into(), + source_policy: SourcePolicy::Protected { allow_html: false }, + size_hint: None, + resume_supported: None, + }) + .await; + + assert!(matches!(outcome, AttemptOutcome::Failed(_))); + assert_eq!(std::fs::read(destination).unwrap(), b"user-owned"); +} + +#[tokio::test] +async fn direct_attempt_never_deletes_an_unowned_destination() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/octet-stream") + .insert_header("content-length", "4"), + ) + .mount(&server) + .await; + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("existing-direct.bin"); + std::fs::write(&destination, b"user-owned").unwrap(); + + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(306), + segments_count: 1, + client: reqwest::Client::new(), + file_storage: Arc::new(FsFileStorage::new()), + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://example.com/file.bin".into(), + source_policy: SourcePolicy::Direct, + size_hint: None, + resume_supported: None, + }) + .await; + + assert!(matches!(outcome, AttemptOutcome::Failed(_))); + assert_eq!(std::fs::read(destination).unwrap(), b"user-owned"); +} + +#[tokio::test] +async fn protected_hoster_attempt_rejects_resume_metadata_owned_by_another_download() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/octet-stream") + .insert_header("content-length", "4"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"data")) + .mount(&server) + .await; + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("collision.bin"); + std::fs::write(&destination, b"mine").unwrap(); + let storage = Arc::new(FsFileStorage::new()); + storage + .write_meta( + &destination, + &DownloadMeta { + download_id: DownloadId(999), + url: "https://hoster.example/page".into(), + file_name: "collision.bin".into(), + total_bytes: Some(4), + segments: Vec::new(), + checksum_expected: None, + created_at: 0, + updated_at: 0, + }, + ) + .unwrap(); + + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(303), + segments_count: 1, + client: reqwest::Client::new(), + file_storage: storage, + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://hoster.example/page".into(), + source_policy: SourcePolicy::Protected { allow_html: false }, + size_hint: None, + resume_supported: None, + }) + .await; + + assert!(matches!(outcome, AttemptOutcome::Failed(_))); + assert_eq!(std::fs::read(destination).unwrap(), b"mine"); +} + +#[tokio::test] +async fn protected_hoster_attempt_rejects_a_body_shorter_than_the_plugin_size_hint() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200).insert_header("content-type", "application/octet-stream"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"data")) + .mount(&server) + .await; + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("truncated.bin"); + + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(304), + segments_count: 1, + client: reqwest::Client::new(), + file_storage: Arc::new(FsFileStorage::new()), + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://hoster.example/page".into(), + source_policy: SourcePolicy::Protected { allow_html: false }, + size_hint: Some(8), + resume_supported: None, + }) + .await; + + assert!(matches!(outcome, AttemptOutcome::Failed(_))); + assert!(!destination.exists()); +} + +#[tokio::test] +async fn protected_hoster_attempt_rejects_content_length_conflicting_with_size_hint() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/octet-stream") + .insert_header("content-length", "4"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"data")) + .mount(&server) + .await; + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("size-conflict.bin"); + + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(307), + segments_count: 1, + client: reqwest::Client::new(), + file_storage: Arc::new(FsFileStorage::new()), + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://hoster.example/page".into(), + source_policy: SourcePolicy::Protected { allow_html: false }, + size_hint: Some(8), + resume_supported: None, + }) + .await; + + assert!(matches!(outcome, AttemptOutcome::Failed(_))); + assert!(!destination.exists()); +} + +#[tokio::test] +async fn unknown_remote_size_replaces_a_known_size_owned_artifact() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(200).insert_header("content-type", "application/octet-stream"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"data")) + .mount(&server) + .await; + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("owned.bin"); + std::fs::write(&destination, b"stale-data").unwrap(); + let storage = Arc::new(FsFileStorage::new()); + storage + .write_meta( + &destination, + &DownloadMeta { + download_id: DownloadId(308), + url: "https://hoster.example/page".into(), + file_name: "owned.bin".into(), + total_bytes: Some(10), + segments: Vec::new(), + checksum_expected: None, + created_at: 0, + updated_at: 0, + }, + ) + .unwrap(); + + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/download", server.uri()), + download_id: DownloadId(308), + segments_count: 1, + client: reqwest::Client::new(), + file_storage: storage, + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: "https://hoster.example/page".into(), + source_policy: SourcePolicy::Protected { allow_html: false }, + size_hint: None, + resume_supported: None, + }) + .await; + + assert!(matches!(outcome, AttemptOutcome::Completed)); + assert_eq!(std::fs::read(destination).unwrap(), b"data"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancellation_wins_against_blocked_jit_resolution_without_late_commit() { + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let resolver = Arc::new(BlockingSourceResolver { + entered: Mutex::new(Some(entered_tx)), + release: release.clone(), + committed: AtomicBool::new(false), + finished: AtomicBool::new(false), + }); + let engine = make_engine(storage, bus.clone()).with_source_resolver(resolver.clone()); + let download = make_download(98, "https://1fichier.com/?abc123") + .with_module_name("vortex-mod-1fichier".into()) + .with_account_id(AccountId::new("account-1")); + engine.start(&download).expect("spawn download"); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(1))) + .await + .unwrap() + .expect("resolver entered"); + + engine + .cancel(download.id()) + .expect("cancel active download"); + assert!( + bus.wait_for_event_async( + |event| matches!(event, DomainEvent::DownloadCancelled { id } if id.0 == 98), + Duration::from_millis(300), + ) + .await, + "cancellation must not wait for the blocking resolver" + ); + + let (released, condition) = &*release; + *released.lock().unwrap() = true; + condition.notify_all(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(1); + while !resolver.finished.load(AtomicOrdering::SeqCst) && tokio::time::Instant::now() < deadline + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(resolver.finished.load(AtomicOrdering::SeqCst)); + assert!(!resolver.committed.load(AtomicOrdering::SeqCst)); +} + +// --- Tests --- diff --git a/src-tauri/src/adapters/driven/network/download_engine_test_support.rs b/src-tauri/src/adapters/driven/network/download_engine_test_support.rs new file mode 100644 index 00000000..77e67654 --- /dev/null +++ b/src-tauri/src/adapters/driven/network/download_engine_test_support.rs @@ -0,0 +1,131 @@ +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::domain::error::DomainError; +use crate::domain::event::DomainEvent; +use crate::domain::model::download::{Download, DownloadId, Url}; +use crate::domain::model::meta::DownloadMeta; +use crate::domain::ports::driven::{EventBus, FileStorage}; + +use super::SegmentedDownloadEngine; + +pub(super) type WriteRecord = (PathBuf, u64, Vec); + +pub(super) struct MockFileStorage { + pub(super) writes: Arc>>, + artifact_deletions: Arc>>, +} + +impl MockFileStorage { + pub(super) fn new() -> Self { + Self { + writes: Arc::new(Mutex::new(Vec::new())), + artifact_deletions: Arc::new(Mutex::new(Vec::new())), + } + } + + pub(super) fn artifact_deletions(&self) -> Vec { + self.artifact_deletions.lock().unwrap().clone() + } +} + +impl FileStorage for MockFileStorage { + fn create_file(&self, _path: &Path, _size: u64) -> Result<(), DomainError> { + Ok(()) + } + + fn write_segment(&self, path: &Path, offset: u64, data: &[u8]) -> Result<(), DomainError> { + self.writes + .lock() + .unwrap() + .push((path.to_path_buf(), offset, data.to_vec())); + Ok(()) + } + + fn grow_file(&self, _path: &Path, _minimum_size: u64) -> Result<(), DomainError> { + Ok(()) + } + + fn read_meta(&self, _path: &Path) -> Result, DomainError> { + Ok(None) + } + + fn write_meta(&self, _path: &Path, _meta: &DownloadMeta) -> Result<(), DomainError> { + Ok(()) + } + + fn delete_meta(&self, _path: &Path) -> Result<(), DomainError> { + Ok(()) + } + + fn delete_download_artifacts(&self, path: &Path) -> Result<(), DomainError> { + self.artifact_deletions + .lock() + .unwrap() + .push(path.to_path_buf()); + Ok(()) + } + + fn file_exists(&self, _path: &Path) -> Result { + Ok(false) + } +} + +pub(super) struct CollectingEventBus { + events: Arc>>, +} + +impl CollectingEventBus { + pub(super) fn new() -> Self { + Self { + events: Arc::new(Mutex::new(Vec::new())), + } + } + + pub(super) fn collected(&self) -> Vec { + self.events.lock().unwrap().clone() + } + + pub(super) async fn wait_for_event_async(&self, predicate: F, timeout: Duration) -> bool + where + F: Fn(&DomainEvent) -> bool, + { + let deadline = tokio::time::Instant::now() + timeout; + loop { + if self.collected().iter().any(&predicate) { + return true; + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } +} + +impl EventBus for CollectingEventBus { + fn publish(&self, event: DomainEvent) { + self.events.lock().unwrap().push(event); + } + + fn subscribe(&self, _handler: Box) {} +} + +pub(super) fn make_download(id: u64, url: &str) -> Download { + let download_id = DownloadId(id); + let parsed_url = Url::new(url).unwrap(); + Download::new( + download_id, + parsed_url, + "test_file.bin".to_string(), + "/tmp/test_file.bin".to_string(), + ) +} + +pub(super) fn make_engine( + storage: Arc, + bus: Arc, +) -> SegmentedDownloadEngine { + SegmentedDownloadEngine::new(reqwest::Client::new(), storage, bus, 4) +} diff --git a/src-tauri/src/adapters/driven/network/download_engine_tests.rs b/src-tauri/src/adapters/driven/network/download_engine_tests.rs new file mode 100644 index 00000000..ea77f44e --- /dev/null +++ b/src-tauri/src/adapters/driven/network/download_engine_tests.rs @@ -0,0 +1,1142 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::time::Duration; + +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::adapters::driven::filesystem::FsFileStorage; +use crate::domain::model::download::{Download, DownloadId, Url}; +use crate::domain::model::meta::DownloadMeta; +use crate::domain::ports::driven::FileStorage; + +use super::test_support::*; +use super::*; + +#[test] +fn mock_file_storage_does_not_probe_the_host_filesystem() { + let temp = tempfile::tempdir().unwrap(); + let existing = temp.path().join("existing.bin"); + std::fs::write(&existing, b"user-owned").unwrap(); + + assert!(!MockFileStorage::new().file_exists(&existing).unwrap()); +} + +async fn start_staggered_range_server(total_size: u64) -> (String, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + let mut request = vec![0; 4096]; + let mut read = 0; + while read < request.len() + && !request[..read] + .windows(4) + .any(|window| window == b"\r\n\r\n") + { + let Ok(bytes_read) = stream.read(&mut request[read..]).await else { + return; + }; + if bytes_read == 0 { + return; + } + read += bytes_read; + } + let request = String::from_utf8_lossy(&request[..read]); + if request.starts_with("HEAD ") { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {total_size}\r\nAccept-Ranges: bytes\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(response.as_bytes()).await; + return; + } + let Some(range) = request + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("range:")) + .and_then(|line| line.split_once(':')) + .map(|(_, value)| value.trim()) + .and_then(|value| value.strip_prefix("bytes=")) + .and_then(|value| value.split_once('-')) + else { + return; + }; + let (Ok(start), Ok(end)) = (range.0.parse::(), range.1.parse::()) else { + return; + }; + let body = vec![b'x'; (end - start + 1) as usize]; + let response = format!( + "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{end}/{total_size}\r\nConnection: close\r\n\r\n", + body.len() + ); + if stream.write_all(response.as_bytes()).await.is_err() { + return; + } + if start == 0 { + tokio::time::sleep(Duration::from_millis(750)).await; + let _ = stream.write_all(&body).await; + return; + } + let chunk_size = body.len().div_ceil(4).max(1); + for chunk in body.chunks(chunk_size) { + if stream.write_all(chunk).await.is_err() || stream.flush().await.is_err() { + return; + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + }); + } + }); + (format!("http://{address}/file"), task) +} + +#[tokio::test] +async fn matching_resume_metadata_continues_from_the_persisted_offset() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/file")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "8") + .insert_header("accept-ranges", "bytes"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/file")) + .and(header("range", "bytes=6-7")) + .respond_with( + ResponseTemplate::new(206) + .insert_header("content-length", "2") + .insert_header("content-range", "bytes 6-7/8") + .set_body_bytes(b"gh"), + ) + .mount(&server) + .await; + + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("resume.bin"); + let storage = Arc::new(FsFileStorage::new()); + storage.create_file(&destination, 8).unwrap(); + storage.write_segment(&destination, 0, b"abcdef").unwrap(); + let stable_url = "https://example.com/stable"; + storage + .write_meta( + &destination, + &DownloadMeta { + download_id: DownloadId(312), + url: stable_url.into(), + file_name: "resume.bin".into(), + total_bytes: Some(8), + segments: vec![ + crate::domain::model::meta::SegmentMeta { + id: 0, + start_byte: 0, + end_byte: 4, + downloaded_bytes: 4, + completed: true, + }, + crate::domain::model::meta::SegmentMeta { + id: 1, + start_byte: 4, + end_byte: 8, + downloaded_bytes: 2, + completed: false, + }, + ], + checksum_expected: None, + created_at: 1, + updated_at: 1, + }, + ) + .unwrap(); + + let outcome = run_mirror_attempt(MirrorAttemptParams { + url: format!("{}/file", server.uri()), + download_id: DownloadId(312), + segments_count: 2, + client: reqwest::Client::new(), + file_storage: storage.clone(), + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: CancellationToken::new(), + attempt_token: CancellationToken::new(), + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: stable_url.into(), + source_policy: SourcePolicy::Direct, + size_hint: Some(8), + resume_supported: Some(true), + }) + .await; + + assert!(matches!(outcome, AttemptOutcome::Completed)); + assert_eq!(std::fs::read(&destination).unwrap(), b"abcdefgh"); + assert_eq!( + server + .received_requests() + .await + .unwrap() + .iter() + .filter(|request| request.method.as_str() == "GET") + .count(), + 1 + ); +} + +#[tokio::test] +async fn interrupted_attempt_persists_partial_segment_progress() { + let total_size = 8_192; + let (url, server_task) = start_staggered_range_server(total_size).await; + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("interrupted.bin"); + let storage = Arc::new(FsFileStorage::new()); + let user_cancel_token = CancellationToken::new(); + let attempt_token = user_cancel_token.child_token(); + let attempt = tokio::spawn(run_mirror_attempt(MirrorAttemptParams { + url: url.clone(), + download_id: DownloadId(313), + segments_count: 2, + client: reqwest::Client::new(), + file_storage: storage.clone(), + event_bus: Arc::new(CollectingEventBus::new()), + dest_path: destination.clone(), + pause_rx: watch::channel(false).1, + user_cancel_token: user_cancel_token.clone(), + attempt_token, + min_segment_bytes: 1, + dynamic_split_enabled: Arc::new(AtomicBool::new(false)), + dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(1)), + resume_url: url, + source_policy: SourcePolicy::Direct, + size_hint: Some(total_size), + resume_supported: Some(true), + })); + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let has_partial_segment = storage + .read_meta(&destination) + .expect("read in-progress metadata") + .is_some_and(|metadata| { + metadata + .segments + .iter() + .any(|segment| segment.downloaded_bytes > 0 && !segment.completed) + }); + if has_partial_segment { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("partial segment progress is persisted"); + user_cancel_token.cancel(); + let outcome = tokio::time::timeout(Duration::from_secs(3), attempt) + .await + .expect("attempt stops after cancellation") + .expect("attempt task joins"); + server_task.abort(); + + assert!(matches!(outcome, AttemptOutcome::Cancelled)); + let metadata = storage + .read_meta(&destination) + .unwrap() + .expect("interrupted download retains resume metadata"); + assert_eq!(metadata.total_bytes, Some(total_size)); + assert!( + metadata + .segments + .iter() + .any(|segment| segment.downloaded_bytes > 0 && !segment.completed), + "partial segment progress must be persisted" + ); +} + +#[test] +fn segment_count_does_not_wrap_for_huge_downloads() { + assert_eq!( + segment_count_for_attempt(8, u64::from(u32::MAX) + 2, 1, true), + 8 + ); +} + +#[tokio::test] +async fn test_start_spawns_download_and_completes() { + let server = MockServer::start().await; + let body = vec![b'a'; 1024]; + + Mock::given(method("HEAD")) + .and(path("/file")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "1024") + .insert_header("accept-ranges", "bytes"), + ) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/file")) + .respond_with(ResponseTemplate::new(206).set_body_bytes(body)) + .mount(&server) + .await; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage.clone(), bus.clone()); + + let url = format!("{}/file", server.uri()); + let download = make_download(1, &url); + + engine.start(&download).unwrap(); + + let found = bus + .wait_for_event_async( + |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 1), + Duration::from_secs(5), + ) + .await; + + assert!(found, "DownloadCompleted not received"); + + let events = bus.collected(); + assert!( + events + .iter() + .any(|e| matches!(e, DomainEvent::DownloadStarted { id } if id.0 == 1)), + "DownloadStarted not published" + ); +} + +#[tokio::test] +async fn test_start_fallback_single_segment_no_range() { + let server = MockServer::start().await; + let body = vec![b'b'; 512]; + + Mock::given(method("HEAD")) + .and(path("/norange")) + .respond_with( + ResponseTemplate::new(200).insert_header("content-length", "512"), + // No accept-ranges header → single segment fallback + ) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/norange")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone())) + .mount(&server) + .await; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage.clone(), bus.clone()); + + let url = format!("{}/norange", server.uri()); + let download = make_download(2, &url); + + engine.start(&download).unwrap(); + + let found = bus + .wait_for_event_async( + |e| { + matches!( + e, + DomainEvent::DownloadCompleted { id } | DomainEvent::DownloadFailed { id, .. } + if id.0 == 2 + ) + }, + Duration::from_secs(5), + ) + .await; + + assert!(found, "download did not finish"); + + let events = bus.collected(); + assert!( + events + .iter() + .any(|e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 2)), + "expected DownloadCompleted, events: {events:?}" + ); + let requests = server.received_requests().await.unwrap(); + assert!( + requests + .iter() + .filter(|request| request.method.as_str() == "GET") + .all(|request| !request.headers.contains_key("range")), + "no-range fallback must issue a full GET" + ); + let writes = storage.writes.lock().unwrap(); + assert_eq!( + writes + .iter() + .flat_map(|(_, _, bytes)| bytes.iter().copied()) + .collect::>(), + body + ); +} + +#[tokio::test] +async fn test_start_falls_back_to_get_when_head_returns_non_success() { + let server = MockServer::start().await; + let body = vec![b'g'; 256]; + + Mock::given(method("HEAD")) + .and(path("/head-blocked")) + .respond_with(ResponseTemplate::new(405)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/head-blocked")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "256") + .set_body_bytes(body), + ) + .mount(&server) + .await; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage, bus.clone()); + + let url = format!("{}/head-blocked", server.uri()); + let download = make_download(20, &url); + + engine.start(&download).unwrap(); + + let found = bus + .wait_for_event_async( + |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 20), + Duration::from_secs(5), + ) + .await; + + assert!(found, "download should complete via GET fallback"); +} + +#[tokio::test] +async fn test_pause_sends_signal() { + let server = MockServer::start().await; + // Slow server to keep download active + let body = vec![b'p'; 64 * 1024]; + + Mock::given(method("HEAD")) + .and(path("/slow")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", &(64 * 1024u64).to_string()) + .insert_header("accept-ranges", "bytes"), + ) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/slow")) + .respond_with( + ResponseTemplate::new(206) + .set_body_bytes(body) + .set_delay(Duration::from_secs(10)), + ) + .mount(&server) + .await; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage, bus.clone()); + + let url = format!("{}/slow", server.uri()); + let download = make_download(3, &url); + + engine.start(&download).unwrap(); + + // Wait for DownloadStarted before pausing + bus.wait_for_event_async( + |e| matches!(e, DomainEvent::DownloadStarted { id } if id.0 == 3), + Duration::from_secs(3), + ) + .await; + + let pause_result = engine.pause(DownloadId(3)); + assert!(pause_result.is_ok(), "pause should succeed"); + + let events = bus.collected(); + assert!( + events + .iter() + .any(|e| matches!(e, DomainEvent::DownloadPaused { id } if id.0 == 3)), + "DownloadPaused not published" + ); + + // Clean up + let _ = engine.cancel(DownloadId(3)); +} + +#[tokio::test] +async fn test_cancel_stops_download() { + let server = MockServer::start().await; + let body = vec![b'c'; 64 * 1024]; + + Mock::given(method("HEAD")) + .and(path("/cancel")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", &(64 * 1024u64).to_string()) + .insert_header("accept-ranges", "bytes"), + ) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/cancel")) + .respond_with( + ResponseTemplate::new(206) + .set_body_bytes(body) + .set_delay(Duration::from_millis(500)), + ) + .mount(&server) + .await; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage, bus.clone()); + + let url = format!("{}/cancel", server.uri()); + let download = make_download(4, &url); + + engine.start(&download).unwrap(); + + // Wait for DownloadStarted + bus.wait_for_event_async( + |e| matches!(e, DomainEvent::DownloadStarted { id } if id.0 == 4), + Duration::from_secs(3), + ) + .await; + + let cancel_result = engine.cancel(DownloadId(4)); + assert!(cancel_result.is_ok(), "cancel should succeed"); + assert!( + bus.wait_for_event_async( + |event| matches!(event, DomainEvent::DownloadCancelled { id } if id.0 == 4), + Duration::from_secs(3), + ) + .await, + "DownloadCancelled not received" + ); +} + +#[tokio::test] +async fn terminal_destination_failure_does_not_emit_mirror_exhaustion() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/file")) + .respond_with(ResponseTemplate::new(200).insert_header("content-length", "4")) + .mount(&server) + .await; + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("occupied"); + std::fs::create_dir(&destination).unwrap(); + let storage = Arc::new(FsFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage, bus.clone()); + let download = Download::new( + DownloadId(309), + Url::new(&format!("{}/file", server.uri())).unwrap(), + "occupied".into(), + destination.to_string_lossy().into_owned(), + ); + + engine.start(&download).unwrap(); + assert!( + bus.wait_for_event_async( + |event| matches!(event, DomainEvent::DownloadFailed { id, .. } if id.0 == 309), + Duration::from_secs(3), + ) + .await + ); + assert!( + !bus.collected() + .iter() + .any(|event| matches!(event, DomainEvent::AllMirrorsExhausted { id } if id.0 == 309)) + ); +} + +#[tokio::test] +async fn test_dynamic_split_skipped_when_remaining_too_small() { + // 2 KiB total, 4 segments, min_remaining 4 MiB → split must NOT trigger. + let (url, server) = start_staggered_range_server(2048).await; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = SegmentedDownloadEngine::new(reqwest::Client::new(), storage, bus.clone(), 4) + .with_min_segment_bytes(256) + .with_dynamic_split(true, 4); // 4 MiB threshold blocks 2 KiB file + + let download = make_download(70, &url).with_segments_count(4); + engine.start(&download).unwrap(); + + let found = bus + .wait_for_event_async( + |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 70), + Duration::from_secs(5), + ) + .await; + assert!(found, "download did not complete"); + + let events = bus.collected(); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, DomainEvent::SegmentStarted { .. })) + .count(), + 4, + "threshold coverage must exercise a multi-segment attempt" + ); + assert!( + !events + .iter() + .any(|e| matches!(e, DomainEvent::SegmentSplit { .. })), + "no split should fire when remaining < threshold; got {events:?}" + ); + server.abort(); +} + +#[tokio::test] +async fn test_dynamic_split_disabled_via_config_does_not_split() { + let (url, server) = start_staggered_range_server(64 * 1024).await; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = SegmentedDownloadEngine::new(reqwest::Client::new(), storage, bus.clone(), 4) + .with_min_segment_bytes(1024) + .with_dynamic_split(false, 0); + + let download = make_download(71, &url).with_segments_count(4); + engine.start(&download).unwrap(); + + let found = bus + .wait_for_event_async( + |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 71), + Duration::from_secs(5), + ) + .await; + assert!(found); + let events = bus.collected(); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, DomainEvent::SegmentStarted { .. })) + .count(), + 4, + "disabled-split coverage must exercise a multi-segment attempt" + ); + assert!( + !events + .iter() + .any(|e| matches!(e, DomainEvent::SegmentSplit { .. })), + "split must not fire when disabled" + ); + server.abort(); +} + +#[test] +fn test_pick_split_target_prefers_slowest_above_threshold() { + let make = |start: u64, end: u64, downloaded: u64, age_ms: u64| SegmentRuntimeState { + end_tx: watch::channel(end).0, + progress: Arc::new(AtomicU64::new(downloaded)), + started_at: std::time::Instant::now() - std::time::Duration::from_millis(age_ms), + start_byte: start, + initial_end: end, + already_downloaded: 0, + completed: false, + }; + let segs = [ + // fast: 1 MiB downloaded in 1500 ms → ~700 KiB/s + make(0, 16 * 1024 * 1024, 1024 * 1024, 1500), + // slow: 100 KiB in 1000 ms → ~100 KiB/s, plenty of remaining + make(16 * 1024 * 1024, 32 * 1024 * 1024, 100 * 1024, 1000), + // tiny remaining → must be filtered + make(32 * 1024 * 1024, 32 * 1024 * 1024 + 1024, 512, 600), + ]; + let pick = pick_split_target(&segs, 4 * 1024 * 1024); + assert_eq!( + pick.map(|(i, _)| i), + Some(1), + "expected slot 1 (slowest with enough remaining), got {pick:?}" + ); + let (_, split_at) = pick.unwrap(); + assert!( + split_at > 16 * 1024 * 1024 + 100 * 1024, + "split must be above current offset" + ); + assert!( + split_at < 32 * 1024 * 1024, + "split must be below initial_end" + ); +} + +#[test] +fn test_pick_split_target_returns_none_when_all_below_threshold() { + let make = |start: u64, end: u64, downloaded: u64| SegmentRuntimeState { + end_tx: watch::channel(end).0, + progress: Arc::new(AtomicU64::new(downloaded)), + started_at: std::time::Instant::now() - std::time::Duration::from_millis(800), + start_byte: start, + initial_end: end, + already_downloaded: 0, + completed: false, + }; + let segs = [make(0, 1024, 100), make(1024, 2048, 1), make(2048, 3072, 1)]; + let pick = pick_split_target(&segs, 4 * 1024 * 1024); + assert!(pick.is_none(), "got {pick:?}"); +} + +#[test] +fn test_pick_split_target_skips_fresh_segments() { + // Brand-new split children should not be candidates: no throughput + // sample yet (downloaded == 0) and elapsed below MIN_SPLIT_SAMPLE_DURATION. + // A genuinely slow neighbor (1000 ms / 100 KiB) sits next to them. + let mk = |start: u64, end: u64, downloaded: u64, age_ms: u64| SegmentRuntimeState { + end_tx: watch::channel(end).0, + progress: Arc::new(AtomicU64::new(downloaded)), + started_at: std::time::Instant::now() - std::time::Duration::from_millis(age_ms), + start_byte: start, + initial_end: end, + already_downloaded: 0, + completed: false, + }; + let segs = [ + // fresh child: 0 bytes, 50 ms — must be skipped despite being "slowest" + mk(0, 16 * 1024 * 1024, 0, 50), + // slightly older but still no sample — must be skipped + mk(16 * 1024 * 1024, 32 * 1024 * 1024, 0, 200), + // genuinely slow but mature + mk(32 * 1024 * 1024, 48 * 1024 * 1024, 100 * 1024, 1000), + ]; + let pick = pick_split_target(&segs, 4 * 1024 * 1024); + assert_eq!(pick.map(|(i, _)| i), Some(2), "got {pick:?}"); +} + +#[test] +fn test_pick_split_target_skips_completed_segments() { + // A completed slot must never be picked even if its throughput was the + // slowest before completion. + let mk = |start: u64, end: u64, downloaded: u64, completed: bool| SegmentRuntimeState { + end_tx: watch::channel(end).0, + progress: Arc::new(AtomicU64::new(downloaded)), + started_at: std::time::Instant::now() - std::time::Duration::from_millis(1000), + start_byte: start, + initial_end: end, + already_downloaded: 0, + completed, + }; + let segs = [ + // completed slow segment — must be ignored + mk(0, 16 * 1024 * 1024, 16 * 1024 * 1024, true), + // live, slower in absolute terms but only it is eligible + mk(16 * 1024 * 1024, 32 * 1024 * 1024, 100 * 1024, false), + ]; + let pick = pick_split_target(&segs, 4 * 1024 * 1024); + assert_eq!(pick.map(|(i, _)| i), Some(1)); +} + +#[tokio::test] +async fn test_pause_unknown_id_returns_not_found() { + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage, bus); + + let result = engine.pause(DownloadId(999)); + assert!( + matches!(result, Err(DomainError::NotFound(_))), + "expected NotFound, got {result:?}" + ); +} + +#[tokio::test] +async fn test_cancel_unknown_id_returns_not_found() { + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage, bus); + + let result = engine.cancel(DownloadId(888)); + assert!( + matches!(result, Err(DomainError::NotFound(_))), + "expected NotFound, got {result:?}" + ); +} + +fn make_download_with_mirrors(id: u64, mirrors: Vec) -> Download { + let download_id = DownloadId(id); + // The canonical URL is intentionally invalid for the mock server so + // failing this fallback still surfaces a clear test signal — we want + // every fetch to flow through `mirrors`. + let parsed_url = Url::new("https://invalid-canonical.example.invalid/file.bin").unwrap(); + Download::new( + download_id, + parsed_url, + "test_file.bin".to_string(), + "/tmp/test_file.bin".to_string(), + ) + .with_mirrors(mirrors) +} + +#[tokio::test] +async fn mirror_retry_never_cleans_artifacts_owned_by_another_download() { + let server = MockServer::start().await; + for route in ["/m1", "/m2"] { + Mock::given(method("HEAD")) + .and(path(route)) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "4") + .insert_header("accept-ranges", "bytes"), + ) + .mount(&server) + .await; + } + let mirrors = [90, 70] + .into_iter() + .enumerate() + .map(|(index, priority)| { + crate::domain::model::Mirror::new( + Url::new(&format!("{}/m{}", server.uri(), index + 1)).unwrap(), + priority, + None, + ) + .unwrap() + }) + .collect(); + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("collision.bin"); + std::fs::write(&destination, b"mine").unwrap(); + let storage = Arc::new(FsFileStorage::new()); + storage + .write_meta( + &destination, + &DownloadMeta { + download_id: DownloadId(999), + url: "https://invalid-canonical.example.invalid/file.bin".into(), + file_name: "collision.bin".into(), + total_bytes: Some(4), + segments: Vec::new(), + checksum_expected: None, + created_at: 0, + updated_at: 0, + }, + ) + .unwrap(); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage.clone(), bus.clone()); + let download = Download::new( + DownloadId(305), + Url::new("https://invalid-canonical.example.invalid/file.bin").unwrap(), + "collision.bin".into(), + destination.to_string_lossy().into_owned(), + ) + .with_mirrors(mirrors); + + engine.start(&download).unwrap(); + assert!( + bus.wait_for_event_async( + |event| matches!(event, DomainEvent::DownloadFailed { id, .. } if id.0 == 305), + Duration::from_secs(5), + ) + .await + ); + + assert_eq!(std::fs::read(&destination).unwrap(), b"mine"); + assert!(storage.read_meta(&destination).unwrap().is_some()); + assert!( + !bus.collected() + .iter() + .any(|event| matches!(event, DomainEvent::MirrorSwitched { .. })) + ); +} + +#[tokio::test] +async fn test_three_mirrors_first_404_triggers_failover_to_second() { + // 3 mirrors. First returns 404 for the GET range request so the + // attempt fails; second succeeds. Engine must publish + // MirrorSwitched then DownloadCompleted. + let server = MockServer::start().await; + let body = vec![b'm'; 256]; + + // Mirror 1 — HEAD 404 (probe fail) so attempt fails fast. + Mock::given(method("HEAD")) + .and(path("/m1")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/m1")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + // Mirror 2 — works. + Mock::given(method("HEAD")) + .and(path("/m2")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "256") + .insert_header("accept-ranges", "bytes"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/m2")) + .respond_with(ResponseTemplate::new(206).set_body_bytes(body.clone())) + .mount(&server) + .await; + + // Mirror 3 — also works but should not be hit (m2 succeeds first). + Mock::given(method("HEAD")) + .and(path("/m3")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "256") + .insert_header("accept-ranges", "bytes"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/m3")) + .respond_with(ResponseTemplate::new(206).set_body_bytes(body)) + .mount(&server) + .await; + + let mirrors = vec![ + crate::domain::model::Mirror::new( + Url::new(&format!("{}/m1", server.uri())).unwrap(), + 90, // highest priority — tried first + None, + ) + .unwrap(), + crate::domain::model::Mirror::new( + Url::new(&format!("{}/m2", server.uri())).unwrap(), + 70, + None, + ) + .unwrap(), + crate::domain::model::Mirror::new( + Url::new(&format!("{}/m3", server.uri())).unwrap(), + 50, + None, + ) + .unwrap(), + ]; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage, bus.clone()); + + let download = make_download_with_mirrors(100, mirrors); + engine.start(&download).unwrap(); + + let completed = bus + .wait_for_event_async( + |e| { + matches!( + e, + DomainEvent::DownloadCompleted { id } | DomainEvent::DownloadFailed { id, .. } + if id.0 == 100 + ) + }, + Duration::from_secs(10), + ) + .await; + assert!(completed, "engine did not finish"); + + let events = bus.collected(); + assert!( + events + .iter() + .any(|e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 100)), + "expected DownloadCompleted, events: {events:?}" + ); + + let switched: Vec<_> = events + .iter() + .filter_map(|e| match e { + DomainEvent::MirrorSwitched { + id, + new_mirror_index, + new_url, + } if id.0 == 100 => Some((*new_mirror_index, new_url.clone())), + _ => None, + }) + .collect(); + assert_eq!( + switched.len(), + 1, + "exactly one mirror switch expected, got {switched:?}" + ); + let (idx, url) = &switched[0]; + assert_eq!(*idx, 1, "switched to slot 1 (priority 70)"); + assert!(url.ends_with("/m2"), "expected /m2 mirror url, got {url}"); +} + +#[tokio::test] +async fn test_all_mirrors_fail_publishes_download_failed() { + let server = MockServer::start().await; + + for p in ["/all1", "/all2"] { + Mock::given(method("HEAD")) + .and(path(p)) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(p)) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + } + + let mirrors = vec![ + crate::domain::model::Mirror::new( + Url::new(&format!("{}/all1", server.uri())).unwrap(), + 80, + None, + ) + .unwrap(), + crate::domain::model::Mirror::new( + Url::new(&format!("{}/all2", server.uri())).unwrap(), + 40, + None, + ) + .unwrap(), + ]; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage, bus.clone()); + + let download = make_download_with_mirrors(101, mirrors); + engine.start(&download).unwrap(); + + let done = bus + .wait_for_event_async( + |e| matches!(e, DomainEvent::DownloadFailed { id, .. } if id.0 == 101), + Duration::from_secs(10), + ) + .await; + assert!(done, "expected DownloadFailed after all mirrors exhausted"); + + let events = bus.collected(); + // Exactly one MirrorSwitched (between mirror 1 and mirror 2). + let switches: Vec<_> = events + .iter() + .filter(|e| matches!(e, DomainEvent::MirrorSwitched { id, .. } if id.0 == 101)) + .collect(); + assert_eq!(switches.len(), 1, "switched once before final failure"); + assert!( + !events + .iter() + .any(|e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 101)), + "must not emit DownloadCompleted on full mirror exhaustion" + ); +} + +#[tokio::test] +async fn test_priority_respected_highest_first() { + // Priority order: low (10) < mid (50) < high (90). The engine + // must pick `high` first; we confirm by failing only `high` and + // observing exactly one MirrorSwitched ending on `mid`. + let server = MockServer::start().await; + let body = vec![b'p'; 128]; + + // high — fails + Mock::given(method("HEAD")) + .and(path("/high")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/high")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + // mid — succeeds + Mock::given(method("HEAD")) + .and(path("/mid")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "128") + .insert_header("accept-ranges", "bytes"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/mid")) + .respond_with(ResponseTemplate::new(206).set_body_bytes(body)) + .mount(&server) + .await; + // low — must not be reached + Mock::given(method("HEAD")) + .and(path("/low")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + // Insert in non-priority-order to assert the engine sorts. + let mirrors = vec![ + crate::domain::model::Mirror::new( + Url::new(&format!("{}/low", server.uri())).unwrap(), + 10, + None, + ) + .unwrap(), + crate::domain::model::Mirror::new( + Url::new(&format!("{}/high", server.uri())).unwrap(), + 90, + None, + ) + .unwrap(), + crate::domain::model::Mirror::new( + Url::new(&format!("{}/mid", server.uri())).unwrap(), + 50, + None, + ) + .unwrap(), + ]; + + let storage = Arc::new(MockFileStorage::new()); + let bus = Arc::new(CollectingEventBus::new()); + let engine = make_engine(storage, bus.clone()); + + let download = make_download_with_mirrors(102, mirrors); + engine.start(&download).unwrap(); + + let done = bus + .wait_for_event_async( + |e| matches!(e, DomainEvent::DownloadCompleted { id } if id.0 == 102), + Duration::from_secs(10), + ) + .await; + assert!(done, "expected DownloadCompleted via mid mirror"); + + let events = bus.collected(); + let switches: Vec<_> = events + .iter() + .filter_map(|e| match e { + DomainEvent::MirrorSwitched { id, new_url, .. } if id.0 == 102 => Some(new_url.clone()), + _ => None, + }) + .collect(); + assert_eq!(switches.len(), 1, "exactly one switch (high → mid)"); + assert!( + switches[0].ends_with("/mid"), + "expected switch to /mid, got {}", + switches[0] + ); +} diff --git a/src-tauri/src/adapters/driven/network/download_source_preparation.rs b/src-tauri/src/adapters/driven/network/download_source_preparation.rs new file mode 100644 index 00000000..368fa02c --- /dev/null +++ b/src-tauri/src/adapters/driven/network/download_source_preparation.rs @@ -0,0 +1,127 @@ +use std::sync::Arc; + +use tokio_util::sync::CancellationToken; + +use crate::domain::error::DomainError; +use crate::domain::model::download::Download; +use crate::domain::ports::driven::{DownloadSourceResolver, ResolutionCancellation}; + +use super::{SourcePolicy, allows_html_filename}; + +pub(super) type ResolvedClientFactory = + fn(&reqwest::Url, &[(String, String)]) -> Result; + +pub(super) struct PreparedSources { + pub(super) urls: Vec, + pub(super) initial_index: usize, + pub(super) client: reqwest::Client, + pub(super) resume_url: String, + pub(super) policy: SourcePolicy, + pub(super) size_hint: Option, + pub(super) resume_supported: Option, + pub(super) resolved: bool, +} + +pub(super) async fn prepare_sources( + download: Download, + resolver: Option>, + client: reqwest::Client, + cancel_token: CancellationToken, + resolution_cancellation: ResolutionCancellation, + resolved_client_factory: ResolvedClientFactory, +) -> Result { + if cancel_token.is_cancelled() { + return Err(DomainError::PluginError( + "download source resolution cancelled".into(), + )); + } + let resume_url = download.url().as_str().to_string(); + let allow_html = allows_html_filename(download.file_name()); + let size_hint = download.file_size().map(|size| size.0); + let requires_resolution = match resolver.as_ref() { + Some(resolver) => resolver.requires_resolution(&download)?, + None => download.account_id().is_some(), + }; + if requires_resolution { + let resolver = resolver.ok_or_else(|| { + DomainError::PluginError("download source resolver is not configured".into()) + })?; + let mut resolve_task = tokio::task::spawn_blocking(move || { + resolver.resolve_cancellable(&download, &resolution_cancellation) + }); + let source = tokio::select! { + biased; + _ = cancel_token.cancelled() => { + return Err(DomainError::PluginError("download source resolution cancelled".into())); + } + result = &mut resolve_task => { + result + .map_err(|_| DomainError::PluginError("download source resolver stopped".into()))?? + } + }; + let policy = if source.is_protected() { + SourcePolicy::Protected { + allow_html: source.filename().map_or(allow_html, allows_html_filename), + } + } else { + SourcePolicy::Direct + }; + let size_hint = source.size_bytes().or(size_hint); + let resume_supported = source.resumable(); + let protected = source.is_protected(); + let request_url = source.request_url().to_string(); + let request_headers = source.request_headers().to_vec(); + let direct_client = client.clone(); + let mut safety_task = tokio::task::spawn_blocking(move || { + let parsed = reqwest::Url::parse(&request_url) + .map_err(|_| DomainError::NetworkError("plugin returned an invalid URL".into()))?; + let client = if protected || !request_headers.is_empty() { + resolved_client_factory(&parsed, &request_headers)? + } else { + direct_client + }; + Ok::<_, DomainError>((request_url, client)) + }); + let (request_url, client) = tokio::select! { + biased; + _ = cancel_token.cancelled() => { + return Err(DomainError::PluginError("download source resolution cancelled".into())); + } + result = &mut safety_task => { + result + .map_err(|_| DomainError::NetworkError("URL safety check stopped".into()))?? + } + }; + return Ok(PreparedSources { + urls: vec![request_url], + initial_index: 0, + client, + resume_url, + policy, + size_hint, + resume_supported, + resolved: true, + }); + } + let urls = if download.mirrors().is_empty() { + vec![resume_url.clone()] + } else { + download + .mirrors() + .iter() + .map(|mirror| mirror.url().as_str().to_string()) + .collect::>() + }; + let initial_index = + (download.current_mirror_index() as usize).min(urls.len().saturating_sub(1)); + Ok(PreparedSources { + urls, + initial_index, + client, + resume_url, + policy: SourcePolicy::Direct, + size_hint, + resume_supported: None, + resolved: false, + }) +} diff --git a/src-tauri/src/adapters/driven/network/mod.rs b/src-tauri/src/adapters/driven/network/mod.rs index ef99f002..b87104bc 100644 --- a/src-tauri/src/adapters/driven/network/mod.rs +++ b/src-tauri/src/adapters/driven/network/mod.rs @@ -1,6 +1,9 @@ mod checksum; +mod download_artifact_lifecycle; mod download_engine; +mod download_source_preparation; mod nat64; +mod protected_source; mod reqwest_client; mod safe_url; mod segment_worker; @@ -8,6 +11,9 @@ mod wait_manager; pub use checksum::StreamingChecksumComputer; pub use download_engine::SegmentedDownloadEngine; +pub(crate) use protected_source::{ + BodyPrefixDecision, SourcePolicy, allows_html_filename, safe_source_failure, +}; pub use reqwest_client::ReqwestHttpClient; pub(crate) use safe_url::{restricted_download_client, validate_public_url}; pub use wait_manager::WaitManager; diff --git a/src-tauri/src/adapters/driven/network/protected_source.rs b/src-tauri/src/adapters/driven/network/protected_source.rs new file mode 100644 index 00000000..69b2d8ae --- /dev/null +++ b/src-tauri/src/adapters/driven/network/protected_source.rs @@ -0,0 +1,368 @@ +//! Response policy for short-lived download capabilities returned by plugins. + +use crate::domain::error::DomainError; + +const MAX_PROTECTED_PREFIX_BYTES: usize = 8 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SourcePolicy { + Direct, + Protected { allow_html: bool }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum BodyPrefixDecision { + Accept, + NeedMore, + Reject, +} + +impl SourcePolicy { + pub(crate) fn is_protected(self) -> bool { + matches!(self, Self::Protected { .. }) + } + + pub(crate) fn response_error( + self, + status: reqwest::StatusCode, + content_type: Option<&str>, + ) -> Option { + let Self::Protected { allow_html } = self else { + return None; + }; + match status { + reqwest::StatusCode::UNAUTHORIZED => Some(DomainError::HosterAuthenticationRequired), + reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::GONE => { + Some(DomainError::HosterDirectUrlExpired) + } + status if !status.is_success() => Some(DomainError::NetworkError( + "hoster direct URL returned an unsuccessful status".into(), + )), + _ if !allow_html && content_type.is_some_and(is_html_content_type) => { + Some(DomainError::HosterUnexpectedHtml) + } + _ => None, + } + } + + pub(crate) fn body_prefix_decision( + self, + bytes: &[u8], + end_of_stream: bool, + ) -> BodyPrefixDecision { + if !matches!(self, Self::Protected { allow_html: false }) { + return BodyPrefixDecision::Accept; + } + let can_read_more = !end_of_stream && bytes.len() < MAX_PROTECTED_PREFIX_BYTES; + let Some(prefix) = normalized_prefix(bytes) else { + return if can_read_more { + BodyPrefixDecision::NeedMore + } else { + BodyPrefixDecision::Accept + }; + }; + if prefix.is_empty() { + return if can_read_more { + BodyPrefixDecision::NeedMore + } else { + BodyPrefixDecision::Reject + }; + } + html_prefix_decision(&prefix, can_read_more) + } +} + +pub(crate) fn allows_html_filename(filename: &str) -> bool { + let filename = filename.to_ascii_lowercase(); + filename.ends_with(".html") || filename.ends_with(".htm") +} + +pub(crate) fn safe_source_failure(error: &DomainError) -> String { + match error { + DomainError::AccountInvalidCredentials + | DomainError::AccountExpired + | DomainError::AccountCooldown + | DomainError::AccountQuotaExceeded + | DomainError::HosterNoFile + | DomainError::HosterAuthenticationRequired + | DomainError::HosterDirectUrlExpired + | DomainError::HosterUnexpectedHtml => error.to_string(), + _ => "Download source could not be resolved".to_string(), + } +} + +fn is_html_content_type(content_type: &str) -> bool { + let media_type = content_type.split(';').next().unwrap_or_default().trim(); + media_type.eq_ignore_ascii_case("text/html") + || media_type.eq_ignore_ascii_case("application/xhtml+xml") +} + +fn normalized_prefix(bytes: &[u8]) -> Option> { + const UTF8_BOM: &[u8] = &[0xef, 0xbb, 0xbf]; + if bytes.len() < UTF8_BOM.len() && UTF8_BOM.starts_with(bytes) { + return None; + } + let bytes = bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes); + Some( + bytes + .iter() + .copied() + .skip_while(u8::is_ascii_whitespace) + .map(|byte| byte.to_ascii_lowercase()) + .collect(), + ) +} + +fn html_prefix_decision(prefix: &[u8], can_read_more: bool) -> BodyPrefixDecision { + const COMMENT: &[u8] = b""; + + let prefix = trim_ascii_start(prefix); + if prefix.starts_with(COMMENT_START) { + let Some(end) = prefix + .windows(COMMENT_END.len()) + .position(|window| window == COMMENT_END) + else { + return incomplete_html_decision(can_read_more); + }; + return xml_root_decision(&prefix[end + COMMENT_END.len()..], can_read_more); + } + if prefix.is_empty() { + return incomplete_html_decision(can_read_more); + } + let Some(tag) = prefix.strip_prefix(b"<") else { + return BodyPrefixDecision::Accept; + }; + if tag.starts_with(b"?") { + return xml_document_decision(prefix, can_read_more); + } + if tag.starts_with(b"!doctype") { + let decision = html_prefix_decision(prefix, can_read_more); + if decision != BodyPrefixDecision::Accept { + return decision; + } + let Some(end) = prefix.iter().position(|byte| *byte == b'>') else { + return incomplete_html_decision(can_read_more); + }; + return xml_root_decision(&prefix[end + 1..], can_read_more); + } + if tag.is_empty() { + return incomplete_html_decision(can_read_more); + } + let name_len = tag + .iter() + .position(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b':'))) + .unwrap_or(tag.len()); + if name_len == tag.len() { + return incomplete_html_decision(can_read_more); + } + let name = &tag[..name_len]; + if name == b"html" || name.ends_with(b":html") { + BodyPrefixDecision::Reject + } else { + BodyPrefixDecision::Accept + } +} + +fn trim_ascii_start(bytes: &[u8]) -> &[u8] { + let start = bytes + .iter() + .position(|byte| !byte.is_ascii_whitespace()) + .unwrap_or(bytes.len()); + &bytes[start..] +} + +fn incomplete_html_decision(can_read_more: bool) -> BodyPrefixDecision { + if can_read_more { + BodyPrefixDecision::NeedMore + } else { + BodyPrefixDecision::Reject + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protected_binary_rejects_html_even_with_whitespace_and_bom() { + let policy = SourcePolicy::Protected { allow_html: false }; + assert_eq!( + policy.body_prefix_decision(b"\xef\xbb\xbf \nexpired", false), + BodyPrefixDecision::Reject + ); + for body in [ + b"PK\x03\x04archive".as_slice(), + b"".as_slice(), + b"".as_slice(), + b"".as_slice(), + ] { + assert_eq!( + policy.body_prefix_decision(body, false), + BodyPrefixDecision::Accept + ); + } + assert_eq!( + policy.body_prefix_decision( + b"expired", + false, + ), + BodyPrefixDecision::Reject + ); + assert_eq!( + policy.body_prefix_decision( + b"expired", + false, + ), + BodyPrefixDecision::Reject + ); + assert_eq!( + policy.body_prefix_decision( + b"expired", + false, + ), + BodyPrefixDecision::Reject + ); + assert_eq!( + policy.body_prefix_decision(b"<", false), + BodyPrefixDecision::NeedMore + ); + assert_eq!( + policy.body_prefix_decision(b"", false), + BodyPrefixDecision::Reject + ); + for body in [ + b"expired".as_slice(), + b"expired".as_slice(), + b"

login required

".as_slice(), + b"

expired

".as_slice(), + b"

expired

".as_slice(), + ] { + assert_eq!( + policy.body_prefix_decision(body, false), + BodyPrefixDecision::Reject, + "{body:?}" + ); + } + } + + #[test] + fn protected_binary_fails_closed_on_whitespace_only_prefix_at_limit() { + let policy = SourcePolicy::Protected { allow_html: false }; + let prefix = vec![b' '; MAX_PROTECTED_PREFIX_BYTES]; + + assert_eq!( + policy.body_prefix_decision(&prefix, false), + BodyPrefixDecision::Reject + ); + } + + #[test] + fn protected_binary_fails_closed_on_incomplete_html_at_end_of_stream() { + let policy = SourcePolicy::Protected { allow_html: false }; + + for body in [ + b"<".as_slice(), + b"document", false), + BodyPrefixDecision::Accept + )); + } +} diff --git a/src-tauri/src/adapters/driven/network/safe_url.rs b/src-tauri/src/adapters/driven/network/safe_url.rs index e1f74136..21a6b9b2 100644 --- a/src-tauri/src/adapters/driven/network/safe_url.rs +++ b/src-tauri/src/adapters/driven/network/safe_url.rs @@ -3,6 +3,8 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::time::Duration; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + use crate::domain::error::DomainError; use super::nat64::{Nat64Prefix, discovered_prefixes}; @@ -43,6 +45,7 @@ pub(crate) fn validate_public_url( pub(crate) fn restricted_download_client( url: &reqwest::Url, + request_headers: &[(String, String)], ) -> Result { if !url.username().is_empty() || url.password().is_some() { return Err(DomainError::NetworkError( @@ -50,12 +53,14 @@ pub(crate) fn restricted_download_client( )); } let addresses = validate_public_url(url)?; + let headers = validated_plugin_headers(request_headers)?; let mut builder = reqwest::Client::builder() .no_proxy() .user_agent("Vortex/0.1") .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_secs(30)) - .timeout(Duration::from_secs(3600)); + .timeout(Duration::from_secs(3600)) + .default_headers(headers); if let (Some(host), Some(addresses)) = (url.host_str(), addresses.as_deref()) { builder = builder.resolve_to_addrs(host, addresses); } @@ -64,6 +69,34 @@ pub(crate) fn restricted_download_client( .map_err(|_| DomainError::NetworkError("restricted HTTP client creation failed".into())) } +pub(crate) fn validated_plugin_headers( + request_headers: &[(String, String)], +) -> Result { + let mut headers = HeaderMap::new(); + for (name, value) in request_headers { + let name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| DomainError::NetworkError("plugin returned an invalid header".into()))?; + if !matches!( + name.as_str(), + "accept" + | "accept-language" + | "authorization" + | "cookie" + | "origin" + | "referer" + | "user-agent" + ) { + return Err(DomainError::NetworkError( + "plugin returned a disallowed download header".into(), + )); + } + let value = HeaderValue::from_str(value) + .map_err(|_| DomainError::NetworkError("plugin returned an invalid header".into()))?; + headers.insert(name, value); + } + Ok(headers) +} + fn blocked() -> DomainError { DomainError::NetworkError("plugin URL targets a non-public network".into()) } diff --git a/src-tauri/src/adapters/driven/network/safe_url_tests.rs b/src-tauri/src/adapters/driven/network/safe_url_tests.rs index cafe37af..0c602949 100644 --- a/src-tauri/src/adapters/driven/network/safe_url_tests.rs +++ b/src-tauri/src/adapters/driven/network/safe_url_tests.rs @@ -34,8 +34,8 @@ fn accepts_globally_routable_ipv6_address() { fn restricted_client_requires_https_and_public_destination() { let http = reqwest::Url::parse("http://1.1.1.1/file").unwrap(); let local = reqwest::Url::parse("https://127.0.0.1/file").unwrap(); - assert!(restricted_download_client(&http).is_err()); - assert!(restricted_download_client(&local).is_err()); + assert!(restricted_download_client(&http, &[]).is_err()); + assert!(restricted_download_client(&local, &[]).is_err()); } #[test] @@ -44,3 +44,32 @@ fn plugin_http_validator_rejects_cleartext_urls() { assert!(validate_public_url(&url).is_err()); } + +#[test] +fn plugin_download_headers_allow_only_explicit_end_to_end_fields() { + let headers = validated_plugin_headers(&[ + ("Authorization".into(), "Bearer secret".into()), + ("Referer".into(), "https://hoster.example/page".into()), + ]) + .expect("approved hoster headers"); + + assert_eq!(headers.get("authorization").unwrap(), "Bearer secret"); + assert_eq!( + headers.get("referer").unwrap(), + "https://hoster.example/page" + ); + + for forbidden in ["Host", "Range", "Connection", "Proxy-Authorization"] { + assert!( + validated_plugin_headers(&[(forbidden.into(), "value".into())]).is_err(), + "{forbidden} must remain host-controlled" + ); + } +} + +#[test] +fn plugin_download_headers_reject_invalid_values() { + assert!( + validated_plugin_headers(&[("Referer".into(), "safe\r\ninjected: true".into())]).is_err() + ); +} diff --git a/src-tauri/src/adapters/driven/network/segment_worker.rs b/src-tauri/src/adapters/driven/network/segment_worker.rs index d58f9879..e10cf71c 100644 --- a/src-tauri/src/adapters/driven/network/segment_worker.rs +++ b/src-tauri/src/adapters/driven/network/segment_worker.rs @@ -10,7 +10,7 @@ use crate::domain::event::DomainEvent; use crate::domain::model::download::DownloadId; use crate::domain::ports::driven::{EventBus, FileStorage}; -use super::format_error_chain; +use super::{BodyPrefixDecision, SourcePolicy, format_error_chain, safe_source_failure}; /// Typed error for segment download failures. #[derive(Debug, PartialEq)] @@ -53,8 +53,8 @@ pub(crate) struct SegmentParams { /// Per-segment downloaded counter, observable by the engine to estimate /// throughput when picking a split target. pub segment_progress: Arc, - /// Suppress request diagnostics that may contain a short-lived capability. - pub sensitive_url: bool, + /// Controls capability redaction and response validation. + pub source_policy: SourcePolicy, } /// Downloads a single byte range and writes it to disk. @@ -78,7 +78,7 @@ pub(crate) async fn download_segment(params: SegmentParams) -> Result Result Result Result 0 && status == reqwest::StatusCode::OK { let msg = "server returned 200 instead of 206 for ranged request".to_string(); @@ -157,6 +169,7 @@ pub(crate) async fn download_segment(params: SegmentParams) -> Result Result Result { + let msg = safe_source_failure( + &crate::domain::error::DomainError::HosterUnexpectedHtml, + ); + event_bus.publish(DomainEvent::SegmentFailed { + download_id, + segment_id: segment_index, + error: msg.clone(), + }); + return Err(SegmentError::Http(msg)); + } + BodyPrefixDecision::NeedMore => continue, + BodyPrefixDecision::Accept => {} + } + data = std::mem::take(&mut protected_prefix); + } let mut chunk_len = data.len() as u64; // Re-read end_byte AFTER chunk fetch so an engine-driven mid-flight @@ -242,18 +273,24 @@ pub(crate) async fn download_segment(params: SegmentParams) -> Result Result 0).then(|| total_file_size.saturating_sub(effective_start)) + } else { + Some( + final_end + .saturating_sub(start_byte) + .saturating_sub(already_downloaded), + ) + }; + if expected_bytes.is_some_and(|expected| bytes_downloaded != expected) { + let expected_bytes = expected_bytes.unwrap_or_default(); let msg = format!("truncated response: got {bytes_downloaded} bytes, expected {expected_bytes}"); event_bus.publish(DomainEvent::SegmentFailed { @@ -334,12 +381,21 @@ mod tests { struct MockFileStorage { writes: Arc>>, + fail_growth: bool, } impl MockFileStorage { fn new() -> Self { Self { writes: Arc::new(Mutex::new(Vec::new())), + fail_growth: false, + } + } + + fn failing_growth() -> Self { + Self { + writes: Arc::new(Mutex::new(Vec::new())), + fail_growth: true, } } } @@ -357,6 +413,14 @@ mod tests { Ok(()) } + fn grow_file(&self, _path: &Path, _minimum_size: u64) -> Result<(), DomainError> { + if self.fail_growth { + Err(DomainError::StorageError("disk full".into())) + } else { + Ok(()) + } + } + fn read_meta(&self, _path: &Path) -> Result, DomainError> { Ok(None) } @@ -402,6 +466,48 @@ mod tests { // --- Tests --- + #[tokio::test] + async fn unknown_length_growth_failure_publishes_segment_failed() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/file")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"data")) + .mount(&server) + .await; + let storage = Arc::new(MockFileStorage::failing_growth()); + let bus = Arc::new(CollectingEventBus::new()); + + let result = download_segment(SegmentParams { + client: make_client(), + file_storage: storage, + event_bus: bus.clone(), + download_id: DownloadId(100), + segment_index: 0, + url: format!("{}/file", server.uri()), + start_byte: 0, + end_byte_rx: watch::channel(u64::MAX).1, + already_downloaded: 0, + total_file_size: 0, + dest_path: PathBuf::from("/tmp/growth_failure.bin"), + pause_rx: watch::channel(false).1, + cancel_token: CancellationToken::new(), + shared_downloaded: Arc::new(AtomicU64::new(0)), + segment_progress: Arc::new(AtomicU64::new(0)), + source_policy: SourcePolicy::Direct, + }) + .await; + + assert!(matches!(result, Err(SegmentError::Storage(_)))); + assert!(bus.collected().iter().any(|event| matches!( + event, + DomainEvent::SegmentFailed { + download_id: DownloadId(100), + segment_id: 0, + .. + } + ))); + } + #[tokio::test] async fn test_segment_downloads_and_writes_to_file() { let server = MockServer::start().await; @@ -437,7 +543,7 @@ mod tests { cancel_token: cancel, shared_downloaded: Arc::new(AtomicU64::new(0)), segment_progress: Arc::new(AtomicU64::new(0)), - sensitive_url: false, + source_policy: SourcePolicy::Direct, }) .await; @@ -491,7 +597,7 @@ mod tests { cancel_token: cancel, shared_downloaded: Arc::new(AtomicU64::new(0)), segment_progress: Arc::new(AtomicU64::new(0)), - sensitive_url: false, + source_policy: SourcePolicy::Direct, }) .await; @@ -545,7 +651,7 @@ mod tests { cancel_token: cancel, shared_downloaded: Arc::new(AtomicU64::new(0)), segment_progress: Arc::new(AtomicU64::new(0)), - sensitive_url: false, + source_policy: SourcePolicy::Direct, }) .await; @@ -591,7 +697,7 @@ mod tests { cancel_token: cancel, shared_downloaded: Arc::new(AtomicU64::new(0)), segment_progress: Arc::new(AtomicU64::new(0)), - sensitive_url: false, + source_policy: SourcePolicy::Direct, }) .await; @@ -654,7 +760,7 @@ mod tests { cancel_token: cancel, shared_downloaded: Arc::new(AtomicU64::new(0)), segment_progress: Arc::new(AtomicU64::new(0)), - sensitive_url: false, + source_policy: SourcePolicy::Direct, }) .await; @@ -710,7 +816,7 @@ mod tests { cancel_token: cancel, shared_downloaded: Arc::new(AtomicU64::new(0)), segment_progress: Arc::new(AtomicU64::new(0)), - sensitive_url: false, + source_policy: SourcePolicy::Direct, }) .await; @@ -794,7 +900,7 @@ mod tests { cancel_token: cancel, shared_downloaded: Arc::new(AtomicU64::new(0)), segment_progress: segment_progress.clone(), - sensitive_url: false, + source_policy: SourcePolicy::Direct, }) .await; @@ -849,7 +955,7 @@ mod tests { cancel_token: cancel, shared_downloaded: shared_downloaded.clone(), segment_progress: Arc::new(AtomicU64::new(0)), - sensitive_url: false, + source_policy: SourcePolicy::Direct, }) .await; diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs index b913d7f2..c99da8f1 100644 --- a/src-tauri/src/adapters/driven/plugin/extism_loader.rs +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -15,7 +15,7 @@ use crate::domain::ports::driven::{ExtractedHosterLink, PluginLoader, Validation use super::builtin::HttpModule; use super::capabilities::{SharedHostResources, build_host_functions_for_instance}; -use super::hoster_contract::parse_hoster_link; +use super::hoster_contract::parse_hoster_links; use super::manifest::{ find_wasm_file, parse_manifest, parse_manifest_metadata, parse_manifest_metadata_bytes, }; @@ -497,6 +497,18 @@ impl PluginLoader for ExtismPluginLoader { url: &str, credential: Option<&Credential>, ) -> Result { + self.extract_hoster_links(service_name, url, credential)? + .into_iter() + .next() + .ok_or(DomainError::HosterNoFile) + } + + fn extract_hoster_links( + &self, + service_name: &str, + url: &str, + credential: Option<&Credential>, + ) -> Result, DomainError> { let info = self .registry .list_info() @@ -520,12 +532,15 @@ impl PluginLoader for ExtismPluginLoader { Some(credential) => self .registry .call_plugin_with_credential(service_name, "extract_links", url, credential.clone()) - .map_err(|error| classify_account_plugin_error(&error.to_string()))?, + .map_err(|error| { + classify_plugin_call_error(error, classify_credentialed_hoster_plugin_error) + })?, None => self .registry - .call_plugin(service_name, "extract_links", url)?, + .call_plugin(service_name, "extract_links", url) + .map_err(|error| classify_plugin_call_error(error, classify_hoster_plugin_error))?, }; - parse_hoster_link(&output) + parse_hoster_links(&output) } fn validate_account( @@ -760,24 +775,96 @@ fn is_adaptive_stream_error(msg: &str) -> bool { } fn classify_account_plugin_error(message: &str) -> DomainError { - let has_code = |expected: &str| { - message - .split(|character: char| !(character.is_ascii_uppercase() || character == '_')) - .any(|token| token == expected) - }; - if has_code("ACCOUNT_INVALID_CREDENTIALS") { + if has_error_code(message, "ACCOUNT_INVALID_CREDENTIALS") { DomainError::AccountInvalidCredentials - } else if has_code("ACCOUNT_EXPIRED") { + } else if has_error_code(message, "ACCOUNT_EXPIRED") { DomainError::AccountExpired - } else if has_code("ACCOUNT_COOLDOWN") { + } else if has_error_code(message, "ACCOUNT_COOLDOWN") { DomainError::AccountCooldown - } else if has_code("ACCOUNT_QUOTA_EXCEEDED") { + } else if has_error_code(message, "ACCOUNT_QUOTA_EXCEEDED") { DomainError::AccountQuotaExceeded } else { DomainError::PluginError("plugin account operation failed".into()) } } +fn classify_plugin_call_error( + error: DomainError, + classify: fn(&str) -> DomainError, +) -> DomainError { + match error { + DomainError::NetworkError(_) => error, + error => classify(&error.to_string()), + } +} + +fn classify_credentialed_hoster_plugin_error(message: &str) -> DomainError { + let account_error = classify_account_plugin_error(message); + if matches!(account_error, DomainError::PluginError(_)) { + classify_hoster_plugin_error(message) + } else { + account_error + } +} + +fn classify_hoster_plugin_error(message: &str) -> DomainError { + let normalized = message.to_ascii_lowercase(); + if normalized.starts_with("network error:") + || normalized.contains("http_request: network error:") + || normalized.contains("http_request: request failed:") + { + DomainError::NetworkError("hoster network request failed".into()) + } else if has_error_code(message, "HOSTER_DIRECT_URL_EXPIRED") { + DomainError::HosterDirectUrlExpired + } else if has_error_code(message, "HOSTER_AUTHENTICATION_REQUIRED") { + DomainError::HosterAuthenticationRequired + } else if has_error_code(message, "HOSTER_NO_FILE") { + DomainError::HosterNoFile + } else { + classify_legacy_hoster_error(message) + } +} + +fn has_error_code(message: &str, expected: &str) -> bool { + message + .split(|character: char| !(character.is_ascii_uppercase() || character == '_')) + .any(|token| token == expected) +} + +/// Compatibility for the three Lot 1 plugins until their WASM ABI exposes +/// structured error codes. Keep these matches tied to their exact diagnostics; +/// generic prose must never become a trusted typed error. +fn classify_legacy_hoster_error(message: &str) -> DomainError { + let message = message.to_ascii_lowercase(); + if message.contains("error-passwordrequired") + || message.contains( + "no direct download link found in mediafire page (file may be private or password-protected)", + ) + || has_official_hoster_http_status(&message, 401) + || has_official_hoster_http_status(&message, 403) + { + DomainError::HosterAuthenticationRequired + } else if has_official_hoster_http_status(&message, 410) { + DomainError::HosterDirectUrlExpired + } else if message.contains("mediafire file is offline or removed:") + || message.contains("pixeldrain file is offline or removed:") + || message.contains("gofile content is offline or removed:") + || message.contains("gofile folder is empty (no children)") + || (message.contains("gofile file id ") && message.ends_with(" not found in folder")) + || has_official_hoster_http_status(&message, 404) + { + DomainError::HosterNoFile + } else { + DomainError::PluginError("hoster plugin operation failed".into()) + } +} + +fn has_official_hoster_http_status(message: &str, status: u16) -> bool { + ["mediafire", "pixeldrain", "gofile", "hoster"] + .into_iter() + .any(|service| message.contains(&format!("{service} http returned status {status}:"))) +} + #[derive(serde::Deserialize)] struct PluginValidationOutcome { valid: bool, @@ -889,6 +976,88 @@ mod tests { assert!(!error.to_string().contains("super-secret-key")); } + #[test] + fn hoster_plugin_errors_map_to_safe_typed_errors() { + assert_eq!( + classify_hoster_plugin_error("MediaFire file is offline or removed: missing"), + DomainError::HosterNoFile + ); + assert_eq!( + classify_hoster_plugin_error( + "no direct download link found in MediaFire page (file may be private or password-protected)" + ), + DomainError::HosterAuthenticationRequired + ); + assert_eq!( + classify_hoster_plugin_error("hoster HTTP returned status 410: gone"), + DomainError::HosterDirectUrlExpired + ); + for service in ["MediaFire", "Pixeldrain", "Gofile"] { + assert_eq!( + classify_hoster_plugin_error(&format!( + "{service} HTTP returned status 403: forbidden" + )), + DomainError::HosterAuthenticationRequired + ); + } + assert_eq!( + classify_hoster_plugin_error( + "Gofile content is offline or removed: error-passwordRequired" + ), + DomainError::HosterAuthenticationRequired + ); + assert_eq!( + classify_hoster_plugin_error( + "Plugin error: plugin call failed: http_request: Network error: connection refused" + ), + DomainError::NetworkError("hoster network request failed".into()) + ); + assert_eq!( + classify_plugin_call_error( + DomainError::NetworkError("connection refused".into()), + classify_hoster_plugin_error, + ), + DomainError::NetworkError("connection refused".into()) + ); + } + + #[test] + fn credentialed_hoster_errors_preserve_account_and_hoster_codes() { + assert_eq!( + classify_credentialed_hoster_plugin_error("ACCOUNT_EXPIRED: subscription ended"), + DomainError::AccountExpired + ); + assert_eq!( + classify_credentialed_hoster_plugin_error("HOSTER_NO_FILE: removed"), + DomainError::HosterNoFile + ); + assert_eq!( + classify_credentialed_hoster_plugin_error("hoster HTTP returned status 410: gone"), + DomainError::HosterDirectUrlExpired + ); + } + + #[test] + fn unknown_hoster_plugin_error_does_not_expose_plugin_diagnostics() { + let error = + classify_hoster_plugin_error("upstream echoed Authorization: Bearer super-secret-key"); + assert_eq!( + error, + DomainError::PluginError("hoster plugin operation failed".into()) + ); + assert!(!error.to_string().contains("super-secret-key")); + } + + #[test] + fn unrelated_hoster_prose_does_not_become_a_typed_error() { + assert_eq!( + classify_hoster_plugin_error( + "private network policy expired while authenticating diagnostics" + ), + DomainError::PluginError("hoster plugin operation failed".into()) + ); + } + #[test] fn validation_response_defaults_success_to_valid_status() { let outcome = parse_validation_outcome(r#"{"valid":true}"#).expect("valid outcome"); diff --git a/src-tauri/src/adapters/driven/plugin/hoster_contract.rs b/src-tauri/src/adapters/driven/plugin/hoster_contract.rs index a8d73929..0543f220 100644 --- a/src-tauri/src/adapters/driven/plugin/hoster_contract.rs +++ b/src-tauri/src/adapters/driven/plugin/hoster_contract.rs @@ -1,10 +1,20 @@ //! Deserialisation boundary for the generic hoster plugin wire format. +use std::collections::BTreeMap; + use serde::Deserialize; use crate::domain::error::DomainError; use crate::domain::ports::driven::ExtractedHosterLink; +const MAX_HOSTER_PAYLOAD_BYTES: usize = 8 * 1024 * 1024; +const MAX_HOSTER_FILES: usize = 500; +const MAX_URL_BYTES: usize = 8 * 1024; +const MAX_FILENAME_BYTES: usize = 4 * 1024; +const MAX_HEADERS_PER_FILE: usize = 32; +const MAX_HEADER_NAME_BYTES: usize = 256; +const MAX_HEADER_VALUE_BYTES: usize = 16 * 1024; + #[derive(Deserialize)] struct HosterResponse { files: Vec, @@ -16,24 +26,76 @@ struct HosterFile { filename: Option, size_bytes: Option, direct_url: Option, + resumable: Option, + #[serde(default)] + headers: BTreeMap, traffic_used_bytes: Option, traffic_total_bytes: Option, } +#[cfg(test)] pub(super) fn parse_hoster_link(payload: &str) -> Result { + parse_hoster_links(payload)? + .into_iter() + .next() + .ok_or(DomainError::HosterNoFile) +} + +pub(super) fn parse_hoster_links(payload: &str) -> Result, DomainError> { + if payload.len() > MAX_HOSTER_PAYLOAD_BYTES { + return Err(limit_error()); + } let response: HosterResponse = serde_json::from_str(payload) .map_err(|_| DomainError::PluginError("hoster returned an invalid response".into()))?; - let file = response + if response.files.is_empty() { + return Err(DomainError::HosterNoFile); + } + if response.files.len() > MAX_HOSTER_FILES { + return Err(limit_error()); + } + response .files .into_iter() - .next() - .ok_or_else(|| DomainError::PluginError("hoster returned no file".into()))?; - Ok(ExtractedHosterLink { - source_url: file.url, - filename: file.filename, - size_bytes: file.size_bytes, - direct_url: file.direct_url, - traffic_used_bytes: file.traffic_used_bytes, - traffic_total_bytes: file.traffic_total_bytes, - }) + .map(|file| { + let source_url = bounded_required_url(file.url)?; + let direct_url = + bounded_required_url(file.direct_url.ok_or(DomainError::HosterNoFile)?)?; + if file + .filename + .as_ref() + .is_some_and(|name| name.len() > MAX_FILENAME_BYTES) + || file.headers.len() > MAX_HEADERS_PER_FILE + || file.headers.iter().any(|(name, value)| { + name.len() > MAX_HEADER_NAME_BYTES || value.len() > MAX_HEADER_VALUE_BYTES + }) + { + return Err(limit_error()); + } + Ok(ExtractedHosterLink { + source_url, + filename: file.filename.filter(|name| !name.trim().is_empty()), + size_bytes: file.size_bytes, + direct_url: Some(direct_url), + resumable: file.resumable, + request_headers: file.headers.into_iter().collect(), + traffic_used_bytes: file.traffic_used_bytes, + traffic_total_bytes: file.traffic_total_bytes, + }) + }) + .collect() +} + +fn bounded_required_url(value: String) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(DomainError::HosterNoFile); + } + if value.len() > MAX_URL_BYTES { + return Err(limit_error()); + } + Ok(value.to_string()) +} + +fn limit_error() -> DomainError { + DomainError::PluginError("hoster response exceeds safety limits".into()) } diff --git a/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs index 2be412d8..350cdc05 100644 --- a/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs +++ b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs @@ -2,7 +2,8 @@ use std::sync::Arc; use super::ExtismPluginLoader; use super::capabilities::SharedHostResources; -use super::hoster_contract::parse_hoster_link; +use super::hoster_contract::{parse_hoster_link, parse_hoster_links}; +use crate::domain::error::DomainError; use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; use crate::domain::ports::driven::PluginLoader; @@ -10,19 +11,68 @@ use crate::domain::ports::driven::PluginLoader; #[test] fn test_parse_hoster_link_maps_wire_fields() { let parsed = parse_hoster_link( - r#"{"files":[{"url":"https://1fichier.com/?a","filename":"a.zip","size_bytes":42,"direct_url":"https://cdn.example/a","traffic_used_bytes":1,"traffic_total_bytes":100}]}"#, + r#"{"files":[{"url":"https://1fichier.com/?a","filename":"a.zip","size_bytes":42,"direct_url":"https://cdn.example/a","resumable":true,"headers":{"Referer":"https://1fichier.com/"},"traffic_used_bytes":1,"traffic_total_bytes":100}]}"#, ) .expect("valid hoster response"); assert_eq!(parsed.source_url, "https://1fichier.com/?a"); assert_eq!(parsed.filename.as_deref(), Some("a.zip")); assert_eq!(parsed.direct_url.as_deref(), Some("https://cdn.example/a")); + assert_eq!(parsed.resumable, Some(true)); + assert_eq!( + parsed.request_headers, + vec![("Referer".to_string(), "https://1fichier.com/".to_string())] + ); assert_eq!(parsed.traffic_total_bytes, Some(100)); } #[test] fn test_parse_hoster_link_rejects_empty_file_list() { - assert!(parse_hoster_link(r#"{"files":[]}"#).is_err()); + assert_eq!( + parse_hoster_link(r#"{"files":[]}"#), + Err(DomainError::HosterNoFile) + ); +} + +#[test] +fn test_parse_hoster_links_preserves_every_gofile_entry() { + let parsed = parse_hoster_links( + r#"{"files":[{"url":"https://gofile.io/d/folder/file-a","filename":"a.zip","size_bytes":10,"direct_url":"https://store.example/a","resumable":true},{"url":"https://gofile.io/d/folder/file-b","filename":"b.zip","size_bytes":20,"direct_url":"https://store.example/b","resumable":false}]}"#, + ) + .expect("valid multi-file hoster response"); + + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].filename.as_deref(), Some("a.zip")); + assert_eq!(parsed[1].source_url, "https://gofile.io/d/folder/file-b"); + assert_eq!(parsed[1].resumable, Some(false)); +} + +#[test] +fn test_parse_hoster_links_rejects_blank_source_or_direct_urls() { + for payload in [ + r#"{"files":[{"url":" ","direct_url":"https://cdn.example/file"}]}"#, + r#"{"files":[{"url":"https://hoster.example/file","direct_url":" "}]}"#, + ] { + assert_eq!(parse_hoster_links(payload), Err(DomainError::HosterNoFile)); + } +} + +#[test] +fn test_parse_hoster_links_caps_plugin_fan_out() { + let files = (0..501) + .map(|index| { + serde_json::json!({ + "url": format!("https://gofile.io/d/folder/file-{index}"), + "direct_url": format!("https://store.example/file-{index}") + }) + }) + .collect::>(); + let payload = serde_json::json!({ "files": files }).to_string(); + + assert!(matches!( + parse_hoster_links(&payload), + Err(DomainError::PluginError(_)) + )); } #[test] diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index c319f12b..e4c6cdf8 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -89,18 +89,30 @@ pub struct AppState { pub wait_manager: Arc, } +#[derive(Debug, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadStartMetadata { + filename: Option, + size_bytes: Option, + resume_supported: Option, +} + #[tauri::command] pub async fn download_start( state: State<'_, AppState>, url: String, destination: Option, + metadata: Option, module_name: Option, account_id: Option, ) -> Result { + let metadata = metadata.unwrap_or_default(); let cmd = StartDownloadCommand { url, destination: destination.map(PathBuf::from), - filename: None, + filename: metadata.filename, + size_bytes: metadata.size_bytes, + resume_supported: metadata.resume_supported, source_hostname_override: None, module_name, account_id: account_id.map(AccountId::new), @@ -1753,6 +1765,8 @@ async fn start_media_download_for_url( url: stream_url, destination: None, filename, + size_bytes: None, + resume_supported: None, source_hostname_override, module_name: None, account_id: None, diff --git a/src-tauri/src/application/commands/hoster_download_source.rs b/src-tauri/src/application/commands/hoster_download_source.rs new file mode 100644 index 00000000..965cf70c --- /dev/null +++ b/src-tauri/src/application/commands/hoster_download_source.rs @@ -0,0 +1,63 @@ +//! Classifies persisted downloads and resolves protected hoster capabilities. + +use super::ResolveHosterSourceHandler; +use crate::application::services::download_source_policy::classify_download_module; +use crate::domain::error::DomainError; +use crate::domain::model::download::Download; +use crate::domain::ports::driven::{ + DownloadSourceResolver, ExtractedHosterLink, ResolutionCancellation, ResolvedDownloadSource, +}; + +pub(super) fn resolved_protected_source( + link: ExtractedHosterLink, +) -> Result { + let direct_url = link + .direct_url + .filter(|url| !url.trim().is_empty()) + .ok_or(DomainError::HosterNoFile)?; + Ok(ResolvedDownloadSource::protected(direct_url) + .with_request_headers(link.request_headers) + .with_metadata(link.filename, link.size_bytes, link.resumable)) +} + +impl DownloadSourceResolver for ResolveHosterSourceHandler { + fn requires_resolution(&self, download: &Download) -> Result { + if download.account_id().is_some() { + return Ok(true); + } + Ok(classify_download_module(self.plugins.as_ref(), download.module_name())?.is_protected()) + } + + fn resolve(&self, download: &Download) -> Result { + self.resolve_source(download, &ResolutionCancellation::default()) + } + + fn resolve_cancellable( + &self, + download: &Download, + cancellation: &ResolutionCancellation, + ) -> Result { + self.resolve_source(download, cancellation) + } +} + +impl ResolveHosterSourceHandler { + fn resolve_source( + &self, + download: &Download, + cancellation: &ResolutionCancellation, + ) -> Result { + if download.account_id().is_some() { + return self.resolve_download(download, cancellation); + } + cancellation.ensure_active()?; + let service_name = download.module_name().ok_or_else(|| { + DomainError::ValidationError("hoster download has no plugin association".into()) + })?; + let link = self + .plugins + .extract_hoster_link(service_name, download.url().as_str(), None)?; + cancellation.ensure_active()?; + resolved_protected_source(link) + } +} diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index dd799bdb..edb2392a 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -73,6 +73,10 @@ pub struct StartDownloadCommand { /// Pre-computed filename (e.g. "Rick Astley - Never Gonna Give You Up.mp4"). /// When set, skips the HEAD probe and URL-fallback derivation. pub filename: Option, + /// File size reported by the source plugin, if known. + pub size_bytes: Option, + /// Whether the source plugin declares byte-range resume support. + pub resume_supported: Option, /// Hostname to store in `source_hostname` instead of the one derived from /// `url`. Used when `url` is a CDN URL but we want to display the origin /// host (e.g. "youtube.com" instead of "rr1---sn-n4g-cvq6.googlevideo.com"). diff --git a/src-tauri/src/application/commands/premium_account_resolution.rs b/src-tauri/src/application/commands/premium_account_resolution.rs index 935d2001..d6a5f696 100644 --- a/src-tauri/src/application/commands/premium_account_resolution.rs +++ b/src-tauri/src/application/commands/premium_account_resolution.rs @@ -5,9 +5,9 @@ use crate::domain::model::account::{Account, AccountStatus}; use crate::domain::model::credential::Credential; use crate::domain::ports::driven::{ExtractedHosterLink, ResolutionCancellation}; -use super::{ResolvePremiumSourceCommand, ResolvePremiumSourceHandler}; +use super::{ResolveHosterSourceHandler, ResolvePremiumSourceCommand}; -impl ResolvePremiumSourceHandler { +impl ResolveHosterSourceHandler { pub(super) fn resolve_locked( &self, command: ResolvePremiumSourceCommand, @@ -94,10 +94,12 @@ impl ResolvePremiumSourceHandler { link: &ExtractedHosterLink, cancellation: &ResolutionCancellation, ) -> Result<(), DomainError> { - if link.direct_url.is_none() { - return Err(DomainError::PluginError( - "premium plugin returned no direct URL".into(), - )); + if link + .direct_url + .as_deref() + .is_none_or(|url| url.trim().is_empty()) + { + return Err(DomainError::HosterNoFile); } if let Some(total) = link.traffic_total_bytes { account.set_traffic_total(total); diff --git a/src-tauri/src/application/commands/premium_account_rotation.rs b/src-tauri/src/application/commands/premium_account_rotation.rs index 9986d83e..fd9664b1 100644 --- a/src-tauri/src/application/commands/premium_account_rotation.rs +++ b/src-tauri/src/application/commands/premium_account_rotation.rs @@ -7,9 +7,10 @@ use crate::domain::ports::driven::{ ExtractedHosterLink, ResolutionCancellation, ResolvedDownloadSource, }; -use super::{ResolvePremiumSourceCommand, ResolvePremiumSourceHandler}; +use super::source::resolved_protected_source; +use super::{ResolveHosterSourceHandler, ResolvePremiumSourceCommand}; -impl ResolvePremiumSourceHandler { +impl ResolveHosterSourceHandler { pub(super) fn resolve_download( &self, download: &Download, @@ -30,7 +31,7 @@ impl ResolvePremiumSourceHandler { match self.resolve_once(download, service, &account_id, cancellation) { Ok(link) => { cancellation.ensure_active()?; - return sensitive_source(link); + return resolved_protected_source(link); } Err(error) if is_rotatable(&error) => last_error = Some(error), Err(error) => return Err(error), @@ -108,13 +109,6 @@ impl ResolvePremiumSourceHandler { } } -fn sensitive_source(link: ExtractedHosterLink) -> Result { - let direct_url = link - .direct_url - .ok_or_else(|| DomainError::PluginError("premium plugin returned no direct URL".into()))?; - Ok(ResolvedDownloadSource::sensitive(direct_url)) -} - fn is_rotatable(error: &DomainError) -> bool { match error { DomainError::AccountInvalidCredentials diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 6fbcd1bb..bbe78149 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -9,14 +9,26 @@ use uuid::Uuid; use crate::application::command_bus::CommandBus; use crate::application::error::AppError; use crate::application::services::account_rotator::NextAccountOutcome; +use crate::application::services::download_source_policy::is_protected_plugin_category; use crate::domain::error::DomainError; use crate::domain::model::http::HttpResponse; -use crate::domain::model::plugin::PluginCategory; use crate::domain::ports::driven::ExtractedHosterLink; use super::ResolveLinksCommand; /// Resolution metadata for a single URL. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LinkResolutionErrorKind { + InvalidUrl, + NoFile, + AuthenticationRequired, + Expired, + AccountUnavailable, + Plugin, + Network, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ResolvedLinkDto { @@ -25,19 +37,24 @@ pub struct ResolvedLinkDto { pub resolved_url: Option, pub filename: Option, pub size_bytes: Option, + pub resumable: Option, /// "checking" | "online" | "offline" | "error" pub status: String, pub error_message: Option, + pub error_kind: Option, pub module_name: String, pub account_id: Option, pub is_media: bool, pub media_type: Option, + /// Whether the frontend should ask the backend for a live HTTP probe. + pub requires_online_probe: bool, } struct HosterResolution { - resolved_url: String, - filename: Option, + stable_url: String, + filename: String, size_bytes: Option, + resumable: Option, account_id: Option, } @@ -47,6 +64,7 @@ impl CommandBus { cmd: ResolveLinksCommand, ) -> Result, AppError> { const MAX_URLS: usize = 500; + const MAX_URL_BYTES: usize = 8 * 1024; if cmd.urls.len() > MAX_URLS { return Err(AppError::Validation(format!( "Too many URLs: {} (max {})", @@ -54,6 +72,11 @@ impl CommandBus { MAX_URLS ))); } + if cmd.urls.iter().any(|url| url.len() > MAX_URL_BYTES) { + return Err(AppError::Validation(format!( + "URL exceeds the {MAX_URL_BYTES}-byte limit" + ))); + } let mut results = Vec::with_capacity(cmd.urls.len()); @@ -64,36 +87,50 @@ impl CommandBus { let id = Uuid::new_v4().to_string(); if !is_allowed_scheme(url) { - results.push(ResolvedLinkDto { - id, - original_url: url.clone(), - resolved_url: None, - filename: None, - size_bytes: None, - status: "error".to_string(), - error_message: Some("URL scheme not allowed".to_string()), - module_name: "core-http".to_string(), - account_id: None, - is_media: false, - media_type: None, - }); + push_bounded_result( + &mut results, + ResolvedLinkDto { + id, + original_url: url.clone(), + resolved_url: None, + filename: None, + size_bytes: None, + resumable: None, + status: "error".to_string(), + error_message: Some("URL scheme not allowed".to_string()), + error_kind: Some(LinkResolutionErrorKind::InvalidUrl), + module_name: "core-http".to_string(), + account_id: None, + is_media: false, + media_type: None, + requires_online_probe: false, + }, + MAX_URLS, + )?; continue; } if url.to_lowercase().starts_with("magnet:") { - results.push(ResolvedLinkDto { - id, - original_url: url.clone(), - resolved_url: Some(url.clone()), - filename: None, - size_bytes: None, - status: "online".to_string(), - error_message: None, - module_name: "magnet".to_string(), - account_id: None, - is_media: false, - media_type: None, - }); + push_bounded_result( + &mut results, + ResolvedLinkDto { + id, + original_url: url.clone(), + resolved_url: Some(url.clone()), + filename: None, + size_bytes: None, + resumable: None, + status: "online".to_string(), + error_message: None, + error_kind: None, + module_name: "magnet".to_string(), + account_id: None, + is_media: false, + media_type: None, + requires_online_probe: false, + }, + MAX_URLS, + )?; continue; } @@ -105,39 +142,57 @@ impl CommandBus { let is_hoster = matches!( plugin_info.as_ref().ok().and_then(Option::as_ref), - Some(info) - if matches!(info.category(), PluginCategory::Hoster | PluginCategory::Debrid) + Some(info) if is_protected_plugin_category(info.category()) ); if is_hoster { - match self.resolve_hoster_link(url, &module_name).await { - Ok(resolved) => results.push(ResolvedLinkDto { - id, - original_url: url.clone(), - resolved_url: Some(resolved.resolved_url), - filename: resolved.filename, - size_bytes: resolved.size_bytes, - status: "online".to_string(), - error_message: None, - module_name, - account_id: resolved.account_id, - is_media: false, - media_type: None, - }), + match self.resolve_hoster_links(url, &module_name).await { + Ok(resolutions) => { + for resolved in resolutions { + push_bounded_result( + &mut results, + ResolvedLinkDto { + id: Uuid::new_v4().to_string(), + original_url: resolved.stable_url.clone(), + resolved_url: Some(resolved.stable_url), + filename: Some(resolved.filename), + size_bytes: resolved.size_bytes, + resumable: resolved.resumable, + status: "online".to_string(), + error_message: None, + error_kind: None, + module_name: module_name.clone(), + account_id: resolved.account_id, + is_media: false, + media_type: None, + requires_online_probe: false, + }, + MAX_URLS, + )?; + } + } Err(error) => { tracing::debug!(module_name, "hoster link resolution failed"); - results.push(ResolvedLinkDto { - id, - original_url: url.clone(), - resolved_url: None, - filename: None, - size_bytes: None, - status: "error".to_string(), - error_message: Some(sanitize_hoster_error(&error)), - module_name, - account_id: None, - is_media: false, - media_type: None, - }); + let (error_kind, error_message) = hoster_error_details(&error); + push_bounded_result( + &mut results, + ResolvedLinkDto { + id, + original_url: url.clone(), + resolved_url: None, + filename: None, + size_bytes: None, + resumable: None, + status: "error".to_string(), + error_message: Some(error_message), + error_kind: Some(error_kind), + module_name, + account_id: None, + is_media: false, + media_type: None, + requires_online_probe: false, + }, + MAX_URLS, + )?; } } continue; @@ -154,50 +209,71 @@ impl CommandBus { Ok(response) if response.is_success() => { let filename = extract_filename_from_url(url); let size = extract_content_length(&response); - results.push(ResolvedLinkDto { - id, - original_url: url.clone(), - resolved_url: Some(url.clone()), - filename, - size_bytes: size, - status: "online".to_string(), - error_message: None, - module_name, - account_id: None, - is_media, - media_type, - }); + push_bounded_result( + &mut results, + ResolvedLinkDto { + id, + original_url: url.clone(), + resolved_url: Some(url.clone()), + filename, + size_bytes: size, + resumable: None, + status: "online".to_string(), + error_message: None, + error_kind: None, + module_name, + account_id: None, + is_media, + media_type, + requires_online_probe: true, + }, + MAX_URLS, + )?; } Ok(_) => { - results.push(ResolvedLinkDto { - id, - original_url: url.clone(), - resolved_url: None, - filename: None, - size_bytes: None, - status: "offline".to_string(), - error_message: None, - module_name, - account_id: None, - is_media, - media_type, - }); + push_bounded_result( + &mut results, + ResolvedLinkDto { + id, + original_url: url.clone(), + resolved_url: None, + filename: None, + size_bytes: None, + resumable: None, + status: "offline".to_string(), + error_message: None, + error_kind: None, + module_name, + account_id: None, + is_media, + media_type, + requires_online_probe: true, + }, + MAX_URLS, + )?; } Err(e) => { tracing::debug!(error = %e, "link resolution failed"); - results.push(ResolvedLinkDto { - id, - original_url: url.clone(), - resolved_url: None, - filename: None, - size_bytes: None, - status: "error".to_string(), - error_message: Some(sanitize_resolve_error(&e)), - module_name, - account_id: None, - is_media, - media_type, - }); + push_bounded_result( + &mut results, + ResolvedLinkDto { + id, + original_url: url.clone(), + resolved_url: None, + filename: None, + size_bytes: None, + resumable: None, + status: "error".to_string(), + error_message: Some(sanitize_resolve_error(&e)), + error_kind: Some(LinkResolutionErrorKind::Network), + module_name, + account_id: None, + is_media, + media_type, + requires_online_probe: true, + }, + MAX_URLS, + )?; } } } @@ -205,20 +281,25 @@ impl CommandBus { Ok(results) } - async fn resolve_hoster_link( + async fn resolve_hoster_links( &self, url: &str, service_name: &str, - ) -> Result { + ) -> Result, AppError> { + if service_name == "vortex-mod-gofile" { + validate_gofile_requested_origin(url)?; + } if self.account_repo().is_some() { match self.next_hoster_account(service_name)? { NextAccountOutcome::Picked(account) => { - return Ok(HosterResolution { - resolved_url: url.to_string(), - filename: extract_filename_from_url(url), + return Ok(vec![HosterResolution { + stable_url: url.to_string(), + filename: extract_filename_from_url(url) + .unwrap_or_else(|| "download".into()), size_bytes: None, + resumable: None, account_id: Some(account.id().as_str().to_string()), - }); + }]); } NextAccountOutcome::AllExhausted { reason, .. } => { return Err(reason.into_domain_error().into()); @@ -227,10 +308,17 @@ impl CommandBus { } } - let link = self + let links = self .plugin_loader() - .extract_hoster_link(service_name, url, None)?; - Ok(into_hoster_resolution(link, url)) + .extract_hoster_links(service_name, url, None)?; + if links.is_empty() { + return Err(DomainError::HosterNoFile.into()); + } + links + .into_iter() + .map(|link| into_hoster_resolution(link, url, service_name)) + .collect::, _>>() + .map_err(Into::into) } fn next_hoster_account(&self, service_name: &str) -> Result { @@ -245,28 +333,167 @@ impl CommandBus { } } -fn into_hoster_resolution(link: ExtractedHosterLink, stable_url: &str) -> HosterResolution { - HosterResolution { - resolved_url: stable_url.to_string(), - filename: link.filename, +fn push_bounded_result( + results: &mut Vec, + result: ResolvedLinkDto, + max_results: usize, +) -> Result<(), AppError> { + if results.len() >= max_results { + return Err(AppError::Validation(format!( + "Resolved link output exceeds the {max_results}-item limit" + ))); + } + results.push(result); + Ok(()) +} + +fn into_hoster_resolution( + link: ExtractedHosterLink, + requested_url: &str, + service_name: &str, +) -> Result { + if link.source_url.trim().is_empty() + || link + .direct_url + .as_deref() + .is_none_or(|url| url.trim().is_empty()) + { + return Err(DomainError::HosterNoFile); + } + if service_name == "vortex-mod-gofile" { + validate_gofile_requested_origin(requested_url)?; + } + let stable_url = if link.source_url.trim() == requested_url { + requested_url.to_string() + } else if service_name == "vortex-mod-gofile" { + validated_gofile_child_source(requested_url, &link.source_url)? + } else { + requested_url.to_string() + }; + let filename = link + .filename + .filter(|name| !name.trim().is_empty()) + .or_else(|| extract_filename_from_url(&stable_url)) + .unwrap_or_else(|| "download".into()); + Ok(HosterResolution { + stable_url, + filename, size_bytes: link.size_bytes, + resumable: link.resumable, account_id: None, + }) +} + +fn validate_gofile_requested_origin(requested_url: &str) -> Result<(), DomainError> { + let requested = reqwest::Url::parse(requested_url) + .map_err(|_| DomainError::PluginError("hoster received an invalid source URL".into()))?; + if !is_supported_gofile_origin(&requested, false) + || !requested.username().is_empty() + || requested.password().is_some() + { + return Err(DomainError::PluginError( + "hoster received an unsafe source URL".into(), + )); + } + Ok(()) +} + +fn validated_gofile_child_source( + requested_url: &str, + candidate_url: &str, +) -> Result { + let requested = reqwest::Url::parse(requested_url) + .map_err(|_| DomainError::PluginError("hoster returned an invalid source URL".into()))?; + let candidate = reqwest::Url::parse(candidate_url.trim()) + .map_err(|_| DomainError::PluginError("hoster returned an invalid source URL".into()))?; + let requested_segments = path_segments(&requested); + let candidate_segments = path_segments(&candidate); + let canonical_child = matches!( + (requested_segments.as_slice(), candidate_segments.as_slice()), + (["d", folder], ["d", candidate_folder, child]) + if folder == candidate_folder && is_gofile_id(folder, false) && is_gofile_id(child, true) + ); + if !is_supported_gofile_origin(&requested, false) + || !is_supported_gofile_origin(&candidate, true) + || !candidate.username().is_empty() + || candidate.password().is_some() + || candidate.query().is_some() + || candidate.fragment().is_some() + || !canonical_child + { + return Err(DomainError::PluginError( + "hoster returned an unsafe source URL".into(), + )); } + Ok(candidate.to_string()) } -fn sanitize_hoster_error(error: &AppError) -> String { +fn is_supported_gofile_origin(url: &reqwest::Url, require_https: bool) -> bool { + let scheme_allowed = if require_https { + url.scheme() == "https" + } else { + matches!(url.scheme(), "http" | "https") + }; + let default_port = match url.scheme() { + "http" => url.port_or_known_default() == Some(80), + "https" => url.port_or_known_default() == Some(443), + _ => false, + }; + scheme_allowed && default_port && matches!(url.host_str(), Some("gofile.io" | "www.gofile.io")) +} + +fn path_segments(url: &reqwest::Url) -> Vec<&str> { + url.path() + .split('/') + .filter(|segment| !segment.is_empty()) + .collect() +} + +fn is_gofile_id(value: &str, allow_separator: bool) -> bool { + value.len() >= 6 + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || (allow_separator && matches!(byte, b'_' | b'-')) + }) +} + +fn hoster_error_details(error: &AppError) -> (LinkResolutionErrorKind, String) { match error { - AppError::Domain(DomainError::AccountInvalidCredentials) => { - "Account credentials were rejected".to_string() - } - AppError::Domain(DomainError::AccountExpired) => "Account is expired".to_string(), - AppError::Domain(DomainError::AccountCooldown) => { - "Account is temporarily rate-limited".to_string() - } - AppError::Domain(DomainError::AccountQuotaExceeded) => { - "Account quota is exhausted".to_string() - } - _ => "Could not resolve hoster link".to_string(), + AppError::Domain(DomainError::AccountInvalidCredentials) => ( + LinkResolutionErrorKind::AuthenticationRequired, + "Account credentials were rejected".to_string(), + ), + AppError::Domain(DomainError::HosterAuthenticationRequired) => ( + LinkResolutionErrorKind::AuthenticationRequired, + "Hoster authentication is required".to_string(), + ), + AppError::Domain(DomainError::AccountExpired) => ( + LinkResolutionErrorKind::Expired, + "Account is expired".to_string(), + ), + AppError::Domain(DomainError::HosterDirectUrlExpired) => ( + LinkResolutionErrorKind::Expired, + "The direct download URL has expired".to_string(), + ), + AppError::Domain(DomainError::HosterNoFile) => ( + LinkResolutionErrorKind::NoFile, + "No downloadable file was found".to_string(), + ), + AppError::Domain(DomainError::AccountCooldown) => ( + LinkResolutionErrorKind::AccountUnavailable, + "Account is temporarily rate-limited".to_string(), + ), + AppError::Domain(DomainError::AccountQuotaExceeded) => ( + LinkResolutionErrorKind::AccountUnavailable, + "Account quota is exhausted".to_string(), + ), + AppError::Domain(DomainError::NetworkError(_)) => ( + LinkResolutionErrorKind::Network, + "Could not reach the hoster".to_string(), + ), + _ => ( + LinkResolutionErrorKind::Plugin, + "Could not resolve hoster link".to_string(), + ), } } @@ -355,397 +582,9 @@ fn detect_media_type(url: &str) -> Option { } #[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - - use super::*; - use crate::application::commands::tests_support::{ - CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, - build_account_bus_with_plugin_loader, - }; - use crate::application::services::{AccountRotator, AccountSelector}; - use crate::domain::error::DomainError; - use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; - use crate::domain::model::credential::Credential; - use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; - use crate::domain::ports::driven::{ - AccountCredentialStore, AccountRepository, Clock, PluginLoader, - }; - - struct FixedClock; - - impl Clock for FixedClock { - fn now_unix_secs(&self) -> u64 { - 1_700_000_000 - } - } - - struct PremiumPluginLoader { - credentials: Mutex>, - services: Mutex>, - } - - impl PremiumPluginLoader { - fn new() -> Self { - Self { - credentials: Mutex::new(Vec::new()), - services: Mutex::new(Vec::new()), - } - } - - fn plugin_info() -> PluginInfo { - PluginInfo::new( - "vortex-mod-1fichier".into(), - "1.1.0".into(), - "1fichier".into(), - "vortex".into(), - PluginCategory::Hoster, - ) - } - } - - impl PluginLoader for PremiumPluginLoader { - fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { - Ok(()) - } - - fn unload(&self, _: &str) -> Result<(), DomainError> { - Ok(()) - } - - fn resolve_url(&self, _: &str) -> Result, DomainError> { - Ok(Some(Self::plugin_info())) - } - - fn list_loaded(&self) -> Result, DomainError> { - Ok(vec![Self::plugin_info()]) - } - - fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { - Ok(()) - } - - fn extract_hoster_link( - &self, - service_name: &str, - _: &str, - credential: Option<&Credential>, - ) -> Result { - self.services.lock().unwrap().push(service_name.to_string()); - let credential = credential.ok_or_else(|| { - DomainError::NotFound("free hoster extraction is not configured".into()) - })?; - self.credentials - .lock() - .unwrap() - .push(credential.password().to_string()); - match credential.password() { - "expired-key" => return Err(DomainError::AccountExpired), - "invalid-key" => return Err(DomainError::AccountInvalidCredentials), - "quota-key" => return Err(DomainError::AccountQuotaExceeded), - "cooldown-key" => return Err(DomainError::AccountCooldown), - _ => {} - } - Ok(ExtractedHosterLink { - source_url: "https://1fichier.com/?abc123".into(), - filename: Some("file.zip".into()), - size_bytes: Some(42), - direct_url: Some("https://download.1fichier.com/token/file.zip".into()), - traffic_used_bytes: Some(1), - traffic_total_bytes: Some(100), - }) - } - } - - fn premium_account(id: &str, traffic_left: u64) -> Account { - Account::reconstruct_with_status( - AccountId::new(id), - "vortex-mod-1fichier".into(), - format!("user-{id}"), - AccountType::Premium, - true, - Some(traffic_left), - Some(100), - Some(u64::MAX), - Some(1), - 0, - AccountStatus::Valid, - None, - ) - } - - async fn resolve_with_primary_credential( - primary_password: Option<&str>, - include_backup: bool, - temporary_status: Option, - ) -> ( - Vec, - Arc, - Arc, - Account, - ) { - let repo = Arc::new(InMemoryAccountRepo::new()); - let credentials = Arc::new(FakeAccountCredentialStore::new()); - let events = Arc::new(CapturingEventBus::new()); - let plugin = Arc::new(PremiumPluginLoader::new()); - let mut primary = premium_account("primary", 100); - let backup = premium_account("backup", 50); - match temporary_status { - Some(AccountStatus::QuotaExhausted) => primary.mark_exhausted(1_700_000_060_000), - Some(AccountStatus::Cooldown) => primary.mark_cooldown(1_700_000_060_000), - _ => {} - } - repo.save(&primary).unwrap(); - if include_backup { - repo.save(&backup).unwrap(); - } - if let Some(password) = primary_password { - credentials.store_password(primary.id(), password).unwrap(); - } - if include_backup { - credentials - .store_password(backup.id(), "working-key") - .unwrap(); - } - let clock: Arc = Arc::new(FixedClock); - let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); - let rotator = AccountRotator::new(selector.clone(), repo.clone(), events.clone(), clock); - let bus = build_account_bus_with_plugin_loader( - repo.clone(), - credentials, - events, - None, - None, - plugin.clone(), - ) - .with_account_selector(selector) - .with_account_rotator(rotator); - - let result = bus - .handle_resolve_links(ResolveLinksCommand { - urls: vec!["https://1fichier.com/?abc123".into()], - }) - .await - .expect("resolve succeeds"); - (result, repo, plugin, primary) - } - - #[tokio::test] - async fn resolve_hoster_selects_account_without_reading_secret_or_issuing_token() { - let (result, repo, plugin, primary) = - resolve_with_primary_credential(Some("working-key"), true, None).await; - - assert_eq!( - result[0].resolved_url.as_deref(), - Some("https://1fichier.com/?abc123") - ); - assert!( - !serde_json::to_string(&result) - .expect("serialize resolved links") - .contains("download.1fichier.com/token"), - "short-lived direct capabilities must not cross IPC" - ); - assert_eq!(result[0].account_id.as_deref(), Some("primary")); - assert_eq!(result[0].module_name, "vortex-mod-1fichier"); - assert_eq!( - repo.find_by_id(primary.id()).unwrap().unwrap().status(), - AccountStatus::Valid - ); - assert!(plugin.credentials.lock().unwrap().is_empty()); - assert!(plugin.services.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn resolve_hoster_surfaces_persisted_exhaustion_without_free_fallback() { - let (result, _, plugin, _) = resolve_with_primary_credential( - Some("quota-key"), - false, - Some(AccountStatus::QuotaExhausted), - ) - .await; - - assert_eq!(result[0].status, "error"); - assert_eq!( - result[0].error_message.as_deref(), - Some("Account quota is exhausted") - ); - assert!(plugin.credentials.lock().unwrap().is_empty()); - assert!(plugin.services.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn resolve_hoster_preserves_persisted_cooldown_without_free_fallback() { - let (result, _, plugin, _) = resolve_with_primary_credential( - Some("cooldown-key"), - false, - Some(AccountStatus::Cooldown), - ) - .await; - - assert_eq!(result[0].status, "error"); - assert_eq!( - result[0].error_message.as_deref(), - Some("Account is temporarily rate-limited") - ); - assert!(plugin.credentials.lock().unwrap().is_empty()); - assert!(plugin.services.lock().unwrap().is_empty()); - } - - #[test] - fn test_extract_filename_from_url_returns_last_path_segment() { - assert_eq!( - extract_filename_from_url("https://example.com/files/archive.zip"), - Some("archive.zip".to_string()) - ); - } - - #[test] - fn test_extract_filename_from_url_strips_query_string() { - assert_eq!( - extract_filename_from_url("https://example.com/file.pdf?token=abc"), - Some("file.pdf".to_string()) - ); - } - - #[test] - fn test_extract_filename_from_url_returns_none_for_bare_host() { - assert_eq!(extract_filename_from_url("https://example.com/"), None); - } - - #[test] - fn test_is_media_url_detects_youtube() { - assert!(is_media_url("https://www.youtube.com/watch?v=abc")); - } - - #[test] - fn test_is_media_url_detects_vimeo() { - assert!(is_media_url("https://vimeo.com/12345678")); - } - - #[test] - fn test_is_media_url_detects_soundcloud() { - assert!(is_media_url("https://soundcloud.com/artist/track")); - } - - #[test] - fn test_is_media_url_detects_soundcloud_artist_profile() { - assert!(is_media_url("https://soundcloud.com/forss")); - } - - #[test] - fn test_is_media_url_detects_soundcloud_playlist() { - assert!(is_media_url("https://soundcloud.com/forss/sets/soulhack")); - } +#[path = "resolve_links_hoster_tests.rs"] +mod hoster_tests; - #[test] - fn test_is_media_url_returns_false_for_regular_url() { - assert!(!is_media_url("https://example.com/file.zip")); - } - - #[test] - fn test_detect_media_type_returns_video_for_youtube() { - assert_eq!( - detect_media_type("https://www.youtube.com/watch?v=abc"), - Some("video".to_string()) - ); - } - - #[test] - fn test_detect_media_type_returns_audio_for_soundcloud() { - assert_eq!( - detect_media_type("https://soundcloud.com/artist/track"), - Some("audio".to_string()) - ); - } - - #[test] - fn test_detect_media_type_returns_audio_for_soundcloud_artist_profile() { - assert_eq!( - detect_media_type("https://soundcloud.com/forss"), - Some("audio".to_string()) - ); - } - - #[test] - fn test_detect_media_type_returns_audio_for_soundcloud_playlist() { - assert_eq!( - detect_media_type("https://soundcloud.com/forss/sets/soulhack"), - Some("audio".to_string()) - ); - } - - #[test] - fn test_detect_media_type_returns_none_for_non_media() { - assert_eq!(detect_media_type("https://example.com/file.zip"), None); - } - - #[test] - fn test_extract_content_length_reads_header() { - let mut headers = HashMap::new(); - headers.insert("content-length".to_string(), vec!["1024".to_string()]); - let response = HttpResponse { - status_code: 200, - headers, - body: vec![], - }; - assert_eq!(extract_content_length(&response), Some(1024)); - } - - #[test] - fn test_extract_content_length_returns_none_when_absent() { - let response = HttpResponse { - status_code: 200, - headers: HashMap::new(), - body: vec![], - }; - assert_eq!(extract_content_length(&response), None); - } - - #[test] - fn test_is_allowed_scheme_accepts_http() { - assert!(is_allowed_scheme("http://example.com/file.zip")); - } - - #[test] - fn test_is_allowed_scheme_accepts_https() { - assert!(is_allowed_scheme("https://example.com/file.zip")); - } - - #[test] - fn test_is_allowed_scheme_accepts_ftp() { - assert!(is_allowed_scheme("ftp://example.com/file.zip")); - } - - #[test] - fn test_is_allowed_scheme_accepts_magnet() { - assert!(is_allowed_scheme( - "magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a" - )); - } - - #[test] - fn test_is_allowed_scheme_rejects_file() { - assert!(!is_allowed_scheme("file:///etc/passwd")); - } - - #[test] - fn test_is_allowed_scheme_rejects_javascript() { - assert!(!is_allowed_scheme("javascript:alert(1)")); - } - - #[test] - fn test_is_allowed_scheme_rejects_container() { - assert!(!is_allowed_scheme("container://some-image")); - } - - #[test] - fn test_is_media_url_detects_subdomain_youtube() { - assert!(is_media_url("https://www.youtube.com/watch?v=abc")); - } - - #[test] - fn test_is_media_url_rejects_fake_youtube_domain() { - assert!(!is_media_url("https://not-youtube.com/watch?v=abc")); - } -} +#[cfg(test)] +#[path = "resolve_links_tests.rs"] +mod tests; diff --git a/src-tauri/src/application/commands/resolve_links_hoster_tests.rs b/src-tauri/src/application/commands/resolve_links_hoster_tests.rs new file mode 100644 index 00000000..a10e5cda --- /dev/null +++ b/src-tauri/src/application/commands/resolve_links_hoster_tests.rs @@ -0,0 +1,446 @@ +use std::sync::{Arc, Mutex}; + +use super::*; +use crate::application::commands::StartDownloadCommand; +use crate::application::commands::resolve_premium_source::ResolveHosterSourceHandler; +use crate::application::commands::tests_support::{ + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, + build_account_bus_with_plugin_loader, build_download_bus_with_plugin_loader, +}; +use crate::application::services::account_operation_locks::AccountOperationLocks; +use crate::application::services::{AccountRotator, AccountSelector}; +use crate::domain::error::DomainError; +use crate::domain::model::config::{AppConfig, ConfigPatch}; +use crate::domain::model::credential::Credential; +use crate::domain::model::http::HttpResponse; +use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; +use crate::domain::ports::driven::{ + Clock, ConfigStore, DownloadRepository, DownloadSourceResolver, HttpClient, PluginLoader, +}; + +struct FixedClock; + +impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } +} + +struct DefaultConfigStore; + +impl ConfigStore for DefaultConfigStore { + fn get_config(&self) -> Result { + Ok(AppConfig::default()) + } + + fn update_config(&self, _: ConfigPatch) -> Result { + Ok(AppConfig::default()) + } +} + +struct RejectingHttpClient; + +impl HttpClient for RejectingHttpClient { + fn head(&self, _: &str) -> Result { + Err(DomainError::NetworkError("unexpected HTTP probe".into())) + } + + fn get_range(&self, _: &str, _: u64, _: u64) -> Result, DomainError> { + Err(DomainError::NetworkError("unexpected HTTP request".into())) + } + + fn supports_range(&self, _: &str) -> Result { + Err(DomainError::NetworkError("unexpected HTTP probe".into())) + } +} + +struct FreeHosterPluginLoader { + services: Mutex>, +} + +impl FreeHosterPluginLoader { + fn new() -> Self { + Self { + services: Mutex::new(Vec::new()), + } + } + + fn plugin_info(name: &str) -> PluginInfo { + PluginInfo::new( + name.to_string(), + "1.0.0".into(), + name.to_string(), + "vortex".into(), + PluginCategory::Hoster, + ) + } + + fn service_for_url(url: &str) -> &'static str { + if url.contains("mediafire.com") { + "vortex-mod-mediafire" + } else if url.contains("pixeldrain.com") { + "vortex-mod-pixeldrain" + } else { + "vortex-mod-gofile" + } + } +} + +impl PluginLoader for FreeHosterPluginLoader { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn resolve_url(&self, url: &str) -> Result, DomainError> { + Ok(Some(Self::plugin_info(Self::service_for_url(url)))) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok([ + "vortex-mod-mediafire", + "vortex-mod-pixeldrain", + "vortex-mod-gofile", + ] + .into_iter() + .map(Self::plugin_info) + .collect()) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + + fn extract_hoster_link( + &self, + service_name: &str, + url: &str, + credential: Option<&Credential>, + ) -> Result { + self.extract_hoster_links(service_name, url, credential)? + .into_iter() + .next() + .ok_or(DomainError::HosterNoFile) + } + + fn extract_hoster_links( + &self, + service_name: &str, + url: &str, + credential: Option<&Credential>, + ) -> Result, DomainError> { + assert!(credential.is_none()); + self.services.lock().unwrap().push(service_name.to_string()); + if url.contains("missing") { + return Err(DomainError::HosterNoFile); + } + if url.contains("empty") { + return Ok(Vec::new()); + } + if service_name == "vortex-mod-gofile" && url.contains("fanout500") { + return Ok((0..500) + .map(|index| ExtractedHosterLink { + source_url: format!("https://gofile.io/d/fanout500/file-{index}"), + filename: Some(format!("file-{index}.bin")), + size_bytes: Some(1), + direct_url: Some(format!("https://cdn.example/file-{index}?token=secret")), + resumable: Some(true), + request_headers: Vec::new(), + traffic_used_bytes: None, + traffic_total_bytes: None, + }) + .collect()); + } + let files = if service_name == "vortex-mod-gofile" && url.contains("single1") { + vec![("file-a", "a.zip", 10_u64)] + } else if service_name == "vortex-mod-gofile" { + vec![("file-a", "a.zip", 10_u64), ("file-b", "b.zip", 20_u64)] + } else { + vec![("file", "archive.zip", 42_u64)] + }; + Ok(files + .into_iter() + .map(|(id, filename, size_bytes)| ExtractedHosterLink { + source_url: if url.contains("unsafe-source") || url.contains("unsafesource") { + "https://cdn.example/file?stable-token=leaked".into() + } else if service_name == "vortex-mod-gofile" { + let folder = url + .split("/d/") + .nth(1) + .and_then(|path| path.split(['?', '#']).next()) + .expect("test Gofile URL contains a folder"); + format!("https://gofile.io/d/{folder}/{id}") + } else { + url.to_string() + }, + filename: if url.contains("unsafe-source") || url.contains("unsafesource") { + None + } else { + Some(filename.into()) + }, + size_bytes: Some(size_bytes), + direct_url: Some(format!("https://cdn.example/{id}?token=secret")), + resumable: Some(true), + request_headers: vec![("Referer".into(), url.into())], + traffic_used_bytes: None, + traffic_total_bytes: None, + }) + .collect()) + } +} + +async fn resolve_free_hoster(url: &str) -> (Vec, Arc) { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let plugin = Arc::new(FreeHosterPluginLoader::new()); + let bus = + build_account_bus_with_plugin_loader(repo, credentials, events, None, None, plugin.clone()); + let result = bus + .handle_resolve_links(ResolveLinksCommand { + urls: vec![url.into()], + }) + .await + .expect("free hoster resolution succeeds"); + (result, plugin) +} + +#[tokio::test] +async fn free_mediafire_and_pixeldrain_resolution_preserves_download_metadata() { + for (url, service) in [ + ( + "https://www.mediafire.com/file/abc/archive.zip/file", + "vortex-mod-mediafire", + ), + ("https://pixeldrain.com/u/abc", "vortex-mod-pixeldrain"), + ] { + let plugin = Arc::new(FreeHosterPluginLoader::new()); + let (bus, downloads, events) = + build_download_bus_with_plugin_loader(Arc::new(RejectingHttpClient), plugin.clone()); + let result = bus + .handle_resolve_links(ResolveLinksCommand { + urls: vec![url.into()], + }) + .await + .expect("free hoster resolution succeeds"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].module_name, service); + assert_eq!(result[0].filename.as_deref(), Some("archive.zip")); + assert_eq!(result[0].size_bytes, Some(42)); + assert_eq!(result[0].resumable, Some(true)); + assert_eq!(result[0].resolved_url.as_deref(), Some(url)); + assert!(!result[0].requires_online_probe); + assert_eq!(*plugin.services.lock().unwrap(), vec![service.to_string()]); + assert!( + !serde_json::to_string(&result) + .unwrap() + .contains("token=secret") + ); + + let temp = tempfile::tempdir().unwrap(); + let resolved = &result[0]; + let id = bus + .handle_start_download(StartDownloadCommand { + url: resolved.resolved_url.clone().unwrap(), + destination: Some(temp.path().to_path_buf()), + filename: resolved.filename.clone(), + size_bytes: resolved.size_bytes, + resume_supported: resolved.resumable, + source_hostname_override: None, + module_name: Some(resolved.module_name.clone()), + account_id: None, + }) + .await + .expect("resolved hoster download is created"); + let download = downloads + .find_by_id(id) + .unwrap() + .expect("created download is persisted"); + assert_eq!(download.url().as_str(), url); + + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let clock: Arc = Arc::new(FixedClock); + let selector = AccountSelector::new(account_repo.clone(), events.clone(), clock.clone()); + let rotator = AccountRotator::new( + selector, + account_repo.clone(), + events.clone(), + clock.clone(), + ); + let resolver = ResolveHosterSourceHandler::new( + account_repo, + Arc::new(FakeAccountCredentialStore::new()), + plugin.clone(), + events, + clock, + Arc::new(AccountOperationLocks::default()), + downloads, + Arc::new(DefaultConfigStore), + rotator, + ); + let source = resolver.resolve(&download).expect("JIT source resolves"); + + assert!(source.is_protected()); + assert_eq!(source.request_headers(), &[("Referer".into(), url.into())]); + assert_eq!( + plugin.services.lock().unwrap().as_slice(), + [service, service] + ); + } +} + +#[tokio::test] +async fn free_gofile_folder_expands_to_one_stable_row_per_file() { + let folder = "https://gofile.io/d/folder"; + let (result, plugin) = resolve_free_hoster(folder).await; + + assert_eq!(result.len(), 2); + assert_eq!(result[0].original_url, format!("{folder}/file-a")); + assert_eq!(result[1].original_url, format!("{folder}/file-b")); + assert_eq!(result[0].filename.as_deref(), Some("a.zip")); + assert_eq!(result[1].size_bytes, Some(20)); + assert_eq!( + plugin.services.lock().unwrap().as_slice(), + ["vortex-mod-gofile"] + ); + assert!( + !serde_json::to_string(&result) + .unwrap() + .contains("token=secret") + ); +} + +#[tokio::test] +async fn one_file_gofile_folder_keeps_its_stable_child_identity() { + let folder = "https://gofile.io/d/single1"; + let (result, _) = resolve_free_hoster(folder).await; + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].resolved_url.as_deref(), + Some("https://gofile.io/d/single1/file-a") + ); +} + +#[tokio::test] +async fn gofile_child_identity_accepts_official_alias_and_https_canonicalization() { + for folder in [ + "https://www.gofile.io/d/folder", + "http://gofile.io/d/folder", + "https://gofile.io/d/folder?foo=bar#section", + ] { + let (result, _) = resolve_free_hoster(folder).await; + + assert_eq!(result.len(), 2, "{folder}"); + assert_eq!( + result[0].resolved_url.as_deref(), + Some("https://gofile.io/d/folder/file-a"), + "{folder}" + ); + } +} + +#[tokio::test] +async fn gofile_child_identity_rejects_unsafe_requested_origins() { + for folder in [ + "https://user:pass@gofile.io/d/folder", + "https://gofile.io:444/d/folder", + ] { + let (result, plugin) = resolve_free_hoster(folder).await; + + assert_eq!(result.len(), 1, "{folder}"); + assert_eq!(result[0].status, "error", "{folder}"); + assert_eq!( + result[0].error_kind, + Some(LinkResolutionErrorKind::Plugin), + "{folder}" + ); + assert!(result[0].resolved_url.is_none(), "{folder}"); + assert!( + plugin.services.lock().unwrap().is_empty(), + "unsafe Gofile origins must be rejected before plugin invocation" + ); + } +} + +#[tokio::test] +async fn empty_hoster_result_is_reported_as_no_file() { + let (result, _) = resolve_free_hoster("https://gofile.io/d/empty-folder").await; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].status, "error"); + assert_eq!(result[0].error_kind, Some(LinkResolutionErrorKind::NoFile)); +} + +#[tokio::test] +async fn typed_no_file_error_is_returned_to_link_grabber() { + let (result, _) = resolve_free_hoster("https://gofile.io/d/missing").await; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].status, "error"); + assert_eq!(result[0].error_kind, Some(LinkResolutionErrorKind::NoFile)); + assert_eq!( + result[0].error_message.as_deref(), + Some("No downloadable file was found") + ); + assert!(!result[0].requires_online_probe); +} + +#[tokio::test] +async fn single_hoster_keeps_the_requested_url_and_synthesizes_a_safe_filename() { + let requested = "https://www.mediafire.com/file/unsafe-source"; + let (result, _) = resolve_free_hoster(requested).await; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].original_url, requested); + assert_eq!(result[0].resolved_url.as_deref(), Some(requested)); + assert!( + result[0] + .filename + .as_deref() + .is_some_and(|name| !name.is_empty()) + ); + assert!( + !serde_json::to_string(&result) + .unwrap() + .contains("stable-token=leaked") + ); +} + +#[tokio::test] +async fn multi_file_hoster_rejects_plugin_sources_outside_the_requested_origin() { + let (result, _) = resolve_free_hoster("https://gofile.io/d/unsafesource").await; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].status, "error"); + assert_eq!(result[0].error_kind, Some(LinkResolutionErrorKind::Plugin)); + assert!( + !serde_json::to_string(&result) + .unwrap() + .contains("stable-token=leaked") + ); +} + +#[tokio::test] +async fn resolved_output_limit_applies_to_rows_after_a_hoster_fanout() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let plugin = Arc::new(FreeHosterPluginLoader::new()); + let bus = build_account_bus_with_plugin_loader(repo, credentials, events, None, None, plugin); + + let result = bus + .handle_resolve_links(ResolveLinksCommand { + urls: vec![ + "https://gofile.io/d/fanout500".into(), + "invalid-scheme://extra".into(), + ], + }) + .await; + + assert!(matches!(result, Err(AppError::Validation(_)))); +} diff --git a/src-tauri/src/application/commands/resolve_links_tests.rs b/src-tauri/src/application/commands/resolve_links_tests.rs new file mode 100644 index 00000000..76e0d22c --- /dev/null +++ b/src-tauri/src/application/commands/resolve_links_tests.rs @@ -0,0 +1,409 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use super::*; +use crate::application::commands::tests_support::{ + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, + build_account_bus_with_plugin_loader, +}; +use crate::application::services::{AccountRotator, AccountSelector}; +use crate::domain::error::DomainError; +use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; +use crate::domain::model::credential::Credential; +use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; +use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, Clock, PluginLoader, +}; + +struct FixedClock; + +impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } +} + +struct PremiumPluginLoader { + credentials: Mutex>, + services: Mutex>, +} + +impl PremiumPluginLoader { + fn new() -> Self { + Self { + credentials: Mutex::new(Vec::new()), + services: Mutex::new(Vec::new()), + } + } + + fn plugin_info() -> PluginInfo { + PluginInfo::new( + "vortex-mod-1fichier".into(), + "1.1.0".into(), + "1fichier".into(), + "vortex".into(), + PluginCategory::Hoster, + ) + } +} + +impl PluginLoader for PremiumPluginLoader { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn resolve_url(&self, _: &str) -> Result, DomainError> { + Ok(Some(Self::plugin_info())) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok(vec![Self::plugin_info()]) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + + fn extract_hoster_link( + &self, + service_name: &str, + _: &str, + credential: Option<&Credential>, + ) -> Result { + self.services.lock().unwrap().push(service_name.to_string()); + let credential = credential.ok_or_else(|| { + DomainError::NotFound("free hoster extraction is not configured".into()) + })?; + self.credentials + .lock() + .unwrap() + .push(credential.password().to_string()); + match credential.password() { + "expired-key" => return Err(DomainError::AccountExpired), + "invalid-key" => return Err(DomainError::AccountInvalidCredentials), + "quota-key" => return Err(DomainError::AccountQuotaExceeded), + "cooldown-key" => return Err(DomainError::AccountCooldown), + _ => {} + } + Ok(ExtractedHosterLink { + source_url: "https://1fichier.com/?abc123".into(), + filename: Some("file.zip".into()), + size_bytes: Some(42), + direct_url: Some("https://download.1fichier.com/token/file.zip".into()), + resumable: Some(true), + request_headers: Vec::new(), + traffic_used_bytes: Some(1), + traffic_total_bytes: Some(100), + }) + } +} + +fn premium_account(id: &str, traffic_left: u64) -> Account { + Account::reconstruct_with_status( + AccountId::new(id), + "vortex-mod-1fichier".into(), + format!("user-{id}"), + AccountType::Premium, + true, + Some(traffic_left), + Some(100), + Some(u64::MAX), + Some(1), + 0, + AccountStatus::Valid, + None, + ) +} + +async fn resolve_with_primary_credential( + primary_password: Option<&str>, + include_backup: bool, + temporary_status: Option, +) -> ( + Vec, + Arc, + Arc, + Account, +) { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let plugin = Arc::new(PremiumPluginLoader::new()); + let mut primary = premium_account("primary", 100); + let backup = premium_account("backup", 50); + match temporary_status { + Some(AccountStatus::QuotaExhausted) => primary.mark_exhausted(1_700_000_060_000), + Some(AccountStatus::Cooldown) => primary.mark_cooldown(1_700_000_060_000), + _ => {} + } + repo.save(&primary).unwrap(); + if include_backup { + repo.save(&backup).unwrap(); + } + if let Some(password) = primary_password { + credentials.store_password(primary.id(), password).unwrap(); + } + if include_backup { + credentials + .store_password(backup.id(), "working-key") + .unwrap(); + } + let clock: Arc = Arc::new(FixedClock); + let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); + let rotator = AccountRotator::new(selector.clone(), repo.clone(), events.clone(), clock); + let bus = build_account_bus_with_plugin_loader( + repo.clone(), + credentials, + events, + None, + None, + plugin.clone(), + ) + .with_account_selector(selector) + .with_account_rotator(rotator); + + let result = bus + .handle_resolve_links(ResolveLinksCommand { + urls: vec!["https://1fichier.com/?abc123".into()], + }) + .await + .expect("resolve succeeds"); + (result, repo, plugin, primary) +} + +#[tokio::test] +async fn resolve_hoster_selects_account_without_reading_secret_or_issuing_token() { + let (result, repo, plugin, primary) = + resolve_with_primary_credential(Some("working-key"), true, None).await; + + assert_eq!( + result[0].resolved_url.as_deref(), + Some("https://1fichier.com/?abc123") + ); + assert!( + !serde_json::to_string(&result) + .expect("serialize resolved links") + .contains("download.1fichier.com/token"), + "short-lived direct capabilities must not cross IPC" + ); + assert_eq!(result[0].account_id.as_deref(), Some("primary")); + assert_eq!(result[0].module_name, "vortex-mod-1fichier"); + assert_eq!( + repo.find_by_id(primary.id()).unwrap().unwrap().status(), + AccountStatus::Valid + ); + assert!(plugin.credentials.lock().unwrap().is_empty()); + assert!(plugin.services.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn resolve_hoster_surfaces_persisted_exhaustion_without_free_fallback() { + let (result, _, plugin, _) = resolve_with_primary_credential( + Some("quota-key"), + false, + Some(AccountStatus::QuotaExhausted), + ) + .await; + + assert_eq!(result[0].status, "error"); + assert_eq!( + result[0].error_kind, + Some(LinkResolutionErrorKind::AccountUnavailable) + ); + assert_eq!( + result[0].error_message.as_deref(), + Some("Account quota is exhausted") + ); + assert!(plugin.credentials.lock().unwrap().is_empty()); + assert!(plugin.services.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn resolve_hoster_preserves_persisted_cooldown_without_free_fallback() { + let (result, _, plugin, _) = + resolve_with_primary_credential(Some("cooldown-key"), false, Some(AccountStatus::Cooldown)) + .await; + + assert_eq!(result[0].status, "error"); + assert_eq!( + result[0].error_kind, + Some(LinkResolutionErrorKind::AccountUnavailable) + ); + assert_eq!( + result[0].error_message.as_deref(), + Some("Account is temporarily rate-limited") + ); + assert!(plugin.credentials.lock().unwrap().is_empty()); + assert!(plugin.services.lock().unwrap().is_empty()); +} + +#[test] +fn test_extract_filename_from_url_returns_last_path_segment() { + assert_eq!( + extract_filename_from_url("https://example.com/files/archive.zip"), + Some("archive.zip".to_string()) + ); +} + +#[test] +fn test_extract_filename_from_url_strips_query_string() { + assert_eq!( + extract_filename_from_url("https://example.com/file.pdf?token=abc"), + Some("file.pdf".to_string()) + ); +} + +#[test] +fn test_extract_filename_from_url_returns_none_for_bare_host() { + assert_eq!(extract_filename_from_url("https://example.com/"), None); +} + +#[test] +fn test_is_media_url_detects_youtube() { + assert!(is_media_url("https://www.youtube.com/watch?v=abc")); +} + +#[test] +fn test_is_media_url_detects_vimeo() { + assert!(is_media_url("https://vimeo.com/12345678")); +} + +#[test] +fn test_is_media_url_detects_soundcloud() { + assert!(is_media_url("https://soundcloud.com/artist/track")); +} + +#[test] +fn test_is_media_url_detects_soundcloud_artist_profile() { + assert!(is_media_url("https://soundcloud.com/forss")); +} + +#[test] +fn test_is_media_url_detects_soundcloud_playlist() { + assert!(is_media_url("https://soundcloud.com/forss/sets/soulhack")); +} + +#[test] +fn test_is_media_url_returns_false_for_regular_url() { + assert!(!is_media_url("https://example.com/file.zip")); +} + +#[test] +fn test_detect_media_type_returns_video_for_youtube() { + assert_eq!( + detect_media_type("https://www.youtube.com/watch?v=abc"), + Some("video".to_string()) + ); +} + +#[test] +fn test_detect_media_type_returns_audio_for_soundcloud() { + assert_eq!( + detect_media_type("https://soundcloud.com/artist/track"), + Some("audio".to_string()) + ); +} + +#[test] +fn test_detect_media_type_returns_audio_for_soundcloud_artist_profile() { + assert_eq!( + detect_media_type("https://soundcloud.com/forss"), + Some("audio".to_string()) + ); +} + +#[test] +fn test_detect_media_type_returns_audio_for_soundcloud_playlist() { + assert_eq!( + detect_media_type("https://soundcloud.com/forss/sets/soulhack"), + Some("audio".to_string()) + ); +} + +#[test] +fn test_detect_media_type_returns_none_for_non_media() { + assert_eq!(detect_media_type("https://example.com/file.zip"), None); +} + +#[test] +fn test_extract_content_length_reads_header() { + let mut headers = HashMap::new(); + headers.insert("content-length".to_string(), vec!["1024".to_string()]); + let response = HttpResponse { + status_code: 200, + headers, + body: vec![], + }; + assert_eq!(extract_content_length(&response), Some(1024)); +} + +#[test] +fn test_extract_content_length_returns_none_when_absent() { + let response = HttpResponse { + status_code: 200, + headers: HashMap::new(), + body: vec![], + }; + assert_eq!(extract_content_length(&response), None); +} + +#[test] +fn test_is_allowed_scheme_accepts_http() { + assert!(is_allowed_scheme("http://example.com/file.zip")); +} + +#[test] +fn test_is_allowed_scheme_accepts_https() { + assert!(is_allowed_scheme("https://example.com/file.zip")); +} + +#[test] +fn test_is_allowed_scheme_accepts_ftp() { + assert!(is_allowed_scheme("ftp://example.com/file.zip")); +} + +#[test] +fn test_is_allowed_scheme_accepts_magnet() { + assert!(is_allowed_scheme( + "magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a" + )); +} + +#[test] +fn test_is_allowed_scheme_rejects_file() { + assert!(!is_allowed_scheme("file:///etc/passwd")); +} + +#[test] +fn test_is_allowed_scheme_rejects_javascript() { + assert!(!is_allowed_scheme("javascript:alert(1)")); +} + +#[test] +fn test_is_allowed_scheme_rejects_container() { + assert!(!is_allowed_scheme("container://some-image")); +} + +#[test] +fn test_is_media_url_detects_subdomain_youtube() { + assert!(is_media_url("https://music.youtube.com/watch?v=abc")); +} + +#[test] +fn hoster_network_failures_keep_the_network_error_kind() { + let (kind, message) = hoster_error_details(&AppError::Domain(DomainError::NetworkError( + "connection refused".into(), + ))); + + assert_eq!(kind, LinkResolutionErrorKind::Network); + assert_eq!(message, "Could not reach the hoster"); +} + +#[test] +fn test_is_media_url_rejects_fake_youtube_domain() { + assert!(!is_media_url("https://not-youtube.com/watch?v=abc")); +} diff --git a/src-tauri/src/application/commands/resolve_premium_source.rs b/src-tauri/src/application/commands/resolve_premium_source.rs index 6eb79a3e..928002b7 100644 --- a/src-tauri/src/application/commands/resolve_premium_source.rs +++ b/src-tauri/src/application/commands/resolve_premium_source.rs @@ -1,4 +1,4 @@ -//! Command handler for one credential-scoped premium hoster resolution. +//! Command handler and just-in-time resolver for protected hoster sources. use std::sync::Arc; @@ -6,11 +6,9 @@ use crate::application::services::AccountRotator; use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::domain::error::DomainError; use crate::domain::model::account::AccountId; -use crate::domain::model::download::Download; use crate::domain::ports::driven::{ - AccountCredentialStore, AccountRepository, Clock, ConfigStore, DownloadRepository, - DownloadSourceResolver, EventBus, ExtractedHosterLink, PluginLoader, ResolutionCancellation, - ResolvedDownloadSource, + AccountCredentialStore, AccountRepository, Clock, ConfigStore, DownloadRepository, EventBus, + ExtractedHosterLink, PluginLoader, ResolutionCancellation, }; use crate::domain::ports::driving::Command; @@ -18,6 +16,8 @@ use crate::domain::ports::driving::Command; mod resolution; #[path = "premium_account_rotation.rs"] mod rotation; +#[path = "hoster_download_source.rs"] +mod source; #[derive(Debug)] pub struct ResolvePremiumSourceCommand { @@ -39,7 +39,7 @@ impl ResolvePremiumSourceCommand { impl Command for ResolvePremiumSourceCommand {} #[derive(Clone)] -pub struct ResolvePremiumSourceHandler { +pub struct ResolveHosterSourceHandler { repo: Arc, credentials: Arc, plugins: Arc, @@ -51,7 +51,7 @@ pub struct ResolvePremiumSourceHandler { rotator: Arc, } -impl ResolvePremiumSourceHandler { +impl ResolveHosterSourceHandler { #[allow(clippy::too_many_arguments)] pub fn new( repo: Arc, @@ -102,20 +102,6 @@ impl ResolvePremiumSourceHandler { } } -impl DownloadSourceResolver for ResolvePremiumSourceHandler { - fn resolve(&self, download: &Download) -> Result { - self.resolve_download(download, &ResolutionCancellation::default()) - } - - fn resolve_cancellable( - &self, - download: &Download, - cancellation: &ResolutionCancellation, - ) -> Result { - self.resolve_download(download, cancellation) - } -} - #[cfg(test)] #[path = "resolve_premium_source_tests.rs"] mod tests; diff --git a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs index 5037df8f..cf2b4fc1 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_test_support.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs @@ -11,12 +11,12 @@ use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountTy use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::credential::Credential; use crate::domain::model::download::{Download, DownloadId, Url}; -use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; use crate::domain::ports::driven::{ AccountRepository, Clock, ConfigStore, DownloadRepository, ExtractedHosterLink, PluginLoader, }; -use super::ResolvePremiumSourceHandler; +use super::ResolveHosterSourceHandler; pub(super) struct FixedClock; @@ -49,7 +49,13 @@ impl PluginLoader for DirectUrlPlugin { Ok(None) } fn list_loaded(&self) -> Result, DomainError> { - Ok(Vec::new()) + Ok(vec![PluginInfo::new( + "vortex-mod-1fichier".into(), + "1.1.0".into(), + "1fichier".into(), + "vortex".into(), + PluginCategory::Hoster, + )]) } fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { Ok(()) @@ -60,22 +66,22 @@ impl PluginLoader for DirectUrlPlugin { url: &str, credential: Option<&Credential>, ) -> Result { - let credential = credential.expect("premium credential"); + let password = credential.map(Credential::password).unwrap_or_default(); self.calls.lock().unwrap().push(( service.to_string(), url.to_string(), - credential.password().to_string(), + password.to_string(), )); - if credential.password() == "quota-key" { + if password == "quota-key" { return Err(DomainError::AccountQuotaExceeded); } - if credential.password() == "expired-key" { + if password == "expired-key" { return Err(DomainError::AccountExpired); } - if credential.password() == "cooldown-key" { + if password == "cooldown-key" { return Err(DomainError::AccountCooldown); } - let traffic_used_bytes = if credential.password() == "zero-traffic-key" { + let traffic_used_bytes = if password == "zero-traffic-key" { Some(100) } else { Some(10) @@ -84,7 +90,10 @@ impl PluginLoader for DirectUrlPlugin { source_url: url.to_string(), filename: Some("file.zip".into()), size_bytes: Some(42), - direct_url: Some("https://1.1.1.1/short-lived-token".into()), + direct_url: (password != "missing-url") + .then(|| "https://1.1.1.1/short-lived-token".into()), + resumable: Some(true), + request_headers: vec![("Referer".into(), "https://1fichier.com/".into())], traffic_used_bytes, traffic_total_bytes: Some(100), }) @@ -166,7 +175,7 @@ pub(super) fn handler( credentials: Arc, plugin: Arc, events: Arc, -) -> Arc { +) -> Arc { handler_with_downloads( repo, credentials, @@ -182,11 +191,11 @@ pub(super) fn handler_with_downloads( plugin: Arc, events: Arc, downloads: Arc, -) -> Arc { +) -> Arc { let clock: Arc = Arc::new(FixedClock); let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); let rotator = AccountRotator::new(selector, repo.clone(), events.clone(), clock.clone()); - Arc::new(ResolvePremiumSourceHandler::new( + Arc::new(ResolveHosterSourceHandler::new( repo, credentials, plugin, diff --git a/src-tauri/src/application/commands/resolve_premium_source_tests.rs b/src-tauri/src/application/commands/resolve_premium_source_tests.rs index cf5ead4c..7324c24b 100644 --- a/src-tauri/src/application/commands/resolve_premium_source_tests.rs +++ b/src-tauri/src/application/commands/resolve_premium_source_tests.rs @@ -8,6 +8,7 @@ use crate::application::commands::tests_support::{ }; use crate::domain::event::DomainEvent; use crate::domain::model::account::AccountStatus; +use crate::domain::model::download::{Download, DownloadId, Url}; use crate::domain::ports::driven::{ AccountCredentialStore, AccountRepository, DownloadRepository, DownloadSourceResolver, }; @@ -42,6 +43,10 @@ async fn resolves_the_direct_url_only_when_the_engine_requests_it() { .unwrap(); assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + assert!(source.is_protected()); + assert_eq!(source.filename(), Some("file.zip")); + assert_eq!(source.size_bytes(), Some(42)); + assert_eq!(source.resumable(), Some(true)); assert_eq!(plugin.calls.lock().unwrap()[0].2, "api-key"); assert_eq!( repo.find_by_id(account.id()) @@ -58,6 +63,57 @@ async fn resolves_the_direct_url_only_when_the_engine_requests_it() { ); } +#[test] +fn free_hoster_download_is_resolved_jit_with_backend_only_headers() { + let plugin = Arc::new(DirectUrlPlugin::new()); + let resolver = handler( + Arc::new(InMemoryAccountRepo::new()), + Arc::new(FakeAccountCredentialStore::new()), + plugin.clone(), + Arc::new(CapturingEventBus::new()), + ); + let download = Download::new( + DownloadId(2), + Url::new("https://1fichier.com/?free").unwrap(), + "file.zip".into(), + "/tmp/file.zip".into(), + ) + .with_module_name("vortex-mod-1fichier".into()); + + assert!(resolver.requires_resolution(&download).unwrap()); + let source = resolver.resolve(&download).expect("free hoster resolves"); + + assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + assert!(source.is_protected()); + assert_eq!( + source.request_headers(), + &[("Referer".into(), "https://1fichier.com/".into())] + ); + assert_eq!(source.filename(), Some("file.zip")); + assert_eq!(source.size_bytes(), Some(42)); + assert_eq!(source.resumable(), Some(true)); + assert_eq!(plugin.calls.lock().unwrap()[0].2, ""); +} + +#[test] +fn builtin_http_download_does_not_require_plugin_resolution() { + let resolver = handler( + Arc::new(InMemoryAccountRepo::new()), + Arc::new(FakeAccountCredentialStore::new()), + Arc::new(DirectUrlPlugin::new()), + Arc::new(CapturingEventBus::new()), + ); + let download = Download::new( + DownloadId(3), + Url::new("https://example.com/file.zip").unwrap(), + "file.zip".into(), + "/tmp/file.zip".into(), + ) + .with_module_name("builtin-http".into()); + + assert!(!resolver.requires_resolution(&download).unwrap()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn rotates_jit_failures_and_persists_the_selected_backup() { for (password, expected_status, expected_calls) in [ @@ -421,6 +477,31 @@ async fn test_jit_resolution_preserves_cooldown_when_no_backup_exists() { assert!(matches!(error, DomainError::AccountCooldown)); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_premium_direct_url_is_a_typed_hoster_no_file_error() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account = valid_account("primary"); + repo.save(&account).unwrap(); + credentials + .store_password(account.id(), "missing-url") + .unwrap(); + let resolver = handler( + repo, + credentials, + Arc::new(DirectUrlPlugin::new()), + Arc::new(CapturingEventBus::new()), + ); + let download = download(account.id().clone()); + + let error = tokio::task::spawn_blocking(move || resolver.resolve(&download)) + .await + .unwrap() + .expect_err("missing direct URL must remain a typed hoster failure"); + + assert_eq!(error, DomainError::HosterNoFile); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn missing_credential_persistence_failure_is_not_suppressed() { let repo = Arc::new(SaveFailingRepo::new()); diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index b723d322..e0505dd7 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use crate::application::command_bus::CommandBus; use crate::application::error::AppError; +use crate::application::services::download_source_policy::classify_download_module; use crate::domain::event::DomainEvent; use crate::domain::model::account::{AccountId, AccountStatus}; use crate::domain::model::download::{Download, DownloadId, Url}; @@ -43,6 +44,13 @@ impl CommandBus { )); } name.to_string() + } else if classify_download_module(self.plugin_loader(), cmd.module_name.as_deref())? + .is_protected() + { + // Hoster URLs may be plugin-generated stable identifiers. Never send + // them through the unrestricted generic HTTP adapter before JIT + // resolution applies its restricted-network policy. + filename_from_url(&url) } else { // file_size and resume_supported are discovered by the engine at download time. // The HEAD probe here is used only for filename resolution. @@ -84,7 +92,8 @@ impl CommandBus { let queue_position = super::move_queue::next_queue_position(self.download_repo())?; let mut download = Download::new(id, url, file_name, dest.to_string_lossy().to_string()) - .with_queue_position(queue_position); + .with_queue_position(queue_position) + .with_remote_metadata(cmd.size_bytes, cmd.resume_supported); if let Some(hostname) = cmd.source_hostname_override { download = download.with_source_hostname(hostname); @@ -205,693 +214,5 @@ fn parse_content_disposition(value: &str) -> Option { } #[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::path::Path; - use std::sync::Mutex; - - use crate::application::command_bus::CommandBus; - use crate::application::commands::{DeleteAccountCommand, StartDownloadCommand}; - use crate::application::error::AppError; - use crate::domain::error::DomainError; - use crate::domain::event::DomainEvent; - use crate::domain::model::config::{AppConfig, ConfigPatch}; - use crate::domain::model::credential::Credential; - use crate::domain::model::download::{Download, DownloadId, DownloadState}; - use crate::domain::model::http::HttpResponse; - use crate::domain::model::meta::DownloadMeta; - use crate::domain::model::plugin::{PluginInfo, PluginManifest}; - use crate::domain::ports::driven::{ - ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, - EventBus, FileStorage, HttpClient, PluginLoader, - }; - use std::sync::Arc; - - use crate::application::commands::tests_support::{ - FakeAccountCredentialStore, InMemoryAccountRepo, - }; - use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; - use crate::domain::ports::driven::{AccountCredentialStore, AccountRepository, Clock}; - - struct FixedAccountClock; - - impl Clock for FixedAccountClock { - fn now_unix_secs(&self) -> u64 { - 1 - } - } - - struct SignallingCredentialStore { - password_read: Mutex>>, - } - - impl AccountCredentialStore for SignallingCredentialStore { - fn store_password(&self, _: &AccountId, _: &str) -> Result<(), DomainError> { - Ok(()) - } - - fn get_password(&self, _: &AccountId) -> Result, DomainError> { - if let Some(sender) = self.password_read.lock().unwrap().take() { - let _ = sender.send(()); - } - Ok(Some("api-key".into())) - } - - fn delete_password(&self, _: &AccountId) -> Result<(), DomainError> { - Ok(()) - } - } - - struct MockDownloadRepo { - store: Mutex>, - } - - impl MockDownloadRepo { - fn new() -> Self { - Self { - store: Mutex::new(HashMap::new()), - } - } - } - - impl DownloadRepository for MockDownloadRepo { - fn find_by_id(&self, id: DownloadId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&id.0).cloned()) - } - - fn save(&self, d: &Download) -> Result<(), DomainError> { - self.store.lock().unwrap().insert(d.id().0, d.clone()); - Ok(()) - } - - fn delete(&self, id: DownloadId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&id.0); - Ok(()) - } - - fn find_by_state(&self, s: DownloadState) -> Result, DomainError> { - Ok(self - .store - .lock() - .unwrap() - .values() - .filter(|d| d.state() == s) - .cloned() - .collect()) - } - } - - struct MockDownloadEngine; - - impl DownloadEngine for MockDownloadEngine { - fn start(&self, _download: &Download) -> Result<(), DomainError> { - Ok(()) - } - fn pause(&self, _id: DownloadId) -> Result<(), DomainError> { - Ok(()) - } - fn resume(&self, _id: DownloadId) -> Result<(), DomainError> { - Ok(()) - } - fn cancel(&self, _id: DownloadId) -> Result<(), DomainError> { - Ok(()) - } - } - - struct MockEventBus { - events: Mutex>, - } - - impl MockEventBus { - fn new() -> Self { - Self { - events: Mutex::new(Vec::new()), - } - } - } - - impl EventBus for MockEventBus { - fn publish(&self, event: DomainEvent) { - self.events.lock().unwrap().push(event); - } - fn subscribe(&self, _handler: Box) {} - } - - struct MockHttpClient { - response: Mutex>, - } - - impl MockHttpClient { - fn with_response(resp: HttpResponse) -> Self { - Self { - response: Mutex::new(Some(resp)), - } - } - - fn failing() -> Self { - Self { - response: Mutex::new(None), - } - } - } - - impl HttpClient for MockHttpClient { - fn head(&self, _url: &str) -> Result { - self.response - .lock() - .unwrap() - .clone() - .ok_or_else(|| DomainError::NetworkError("connection refused".to_string())) - } - fn get_range(&self, _url: &str, start: u64, end: u64) -> Result, DomainError> { - Ok(vec![ - 0u8; - end.saturating_sub(start).saturating_add(1) as usize - ]) - } - fn supports_range(&self, _url: &str) -> Result { - Ok(true) - } - } - - struct MockFileStorage; - impl FileStorage for MockFileStorage { - fn create_file(&self, _path: &Path, _size: u64) -> Result<(), DomainError> { - Ok(()) - } - fn write_segment( - &self, - _path: &Path, - _offset: u64, - _data: &[u8], - ) -> Result<(), DomainError> { - Ok(()) - } - fn read_meta(&self, _path: &Path) -> Result, DomainError> { - Ok(None) - } - fn write_meta(&self, _path: &Path, _meta: &DownloadMeta) -> Result<(), DomainError> { - Ok(()) - } - fn delete_meta(&self, _path: &Path) -> Result<(), DomainError> { - Ok(()) - } - } - - struct MockPluginLoader; - impl PluginLoader for MockPluginLoader { - fn load(&self, _manifest: &PluginManifest) -> Result<(), DomainError> { - Ok(()) - } - fn unload(&self, _name: &str) -> Result<(), DomainError> { - Ok(()) - } - fn resolve_url(&self, _url: &str) -> Result, DomainError> { - Ok(None) - } - fn list_loaded(&self) -> Result, DomainError> { - Ok(vec![]) - } - fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { - Ok(()) - } - } - - struct MockConfigStore; - impl ConfigStore for MockConfigStore { - fn get_config(&self) -> Result { - Ok(AppConfig::default()) - } - fn update_config(&self, _patch: ConfigPatch) -> Result { - Ok(AppConfig::default()) - } - } - - struct MockCredentialStore; - impl CredentialStore for MockCredentialStore { - fn get(&self, _service: &str) -> Result, DomainError> { - Ok(None) - } - fn store(&self, _service: &str, _credential: &Credential) -> Result<(), DomainError> { - Ok(()) - } - fn delete(&self, _service: &str) -> Result<(), DomainError> { - Ok(()) - } - } - - struct MockClipboardObserver; - impl ClipboardObserver for MockClipboardObserver { - fn start(&self) -> Result<(), DomainError> { - Ok(()) - } - fn stop(&self) -> Result<(), DomainError> { - Ok(()) - } - fn get_urls(&self) -> Result, DomainError> { - Ok(vec![]) - } - } - - struct FakeArchiveExtractor; - impl crate::domain::ports::driven::ArchiveExtractor for FakeArchiveExtractor { - fn detect_format( - &self, - _file_path: &std::path::Path, - ) -> Result, DomainError> { - Ok(None) - } - fn can_extract(&self, _file_path: &std::path::Path) -> Result { - Ok(false) - } - fn extract( - &self, - _file_path: &std::path::Path, - _dest_dir: &std::path::Path, - _password: Option<&str>, - ) -> Result { - Ok(crate::domain::model::archive::ExtractSummary { - extracted_files: 0, - extracted_bytes: 0, - duration_ms: 0, - warnings: vec![], - }) - } - fn list_contents( - &self, - _file_path: &std::path::Path, - _password: Option<&str>, - ) -> Result, DomainError> { - Ok(vec![]) - } - fn detect_segments( - &self, - _file_path: &std::path::Path, - ) -> Result>, DomainError> { - Ok(None) - } - } - - fn make_command_bus( - http_client: Arc, - ) -> (CommandBus, Arc, Arc) { - let repo = Arc::new(MockDownloadRepo::new()); - let event_bus = Arc::new(MockEventBus::new()); - let bus = CommandBus::new( - repo.clone(), - Arc::new(MockDownloadEngine), - event_bus.clone(), - Arc::new(MockFileStorage), - http_client, - Arc::new(MockPluginLoader), - Arc::new(MockConfigStore), - Arc::new(MockCredentialStore), - Arc::new(MockClipboardObserver), - Arc::new(FakeArchiveExtractor), - Arc::new(crate::application::test_support::NoopHistoryRepo), - None, - ); - (bus, repo, event_bus) - } - - #[tokio::test] - async fn test_start_download_persists_and_emits_event() { - let mut headers = HashMap::new(); - headers.insert("content-length".to_string(), vec!["1024".to_string()]); - headers.insert( - "content-disposition".to_string(), - vec!["attachment; filename=\"report.pdf\"".to_string()], - ); - let resp = HttpResponse { - status_code: 200, - headers, - body: vec![], - }; - - let (bus, repo, event_bus) = - make_command_bus(Arc::new(MockHttpClient::with_response(resp))); - - let cmd = StartDownloadCommand { - url: "https://example.com/files/report.pdf".to_string(), - destination: Some(PathBuf::from("/tmp/downloads")), - filename: None, - source_hostname_override: None, - module_name: None, - account_id: None, - }; - - let id = bus.handle_start_download(cmd).await.unwrap(); - - let saved = repo.store.lock().unwrap().get(&id.0).cloned(); - assert!(saved.is_some()); - let dl = saved.unwrap(); - assert_eq!(dl.state(), DownloadState::Queued); - assert_eq!(dl.file_name(), "report.pdf"); - - let events = event_bus.events.lock().unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0], DomainEvent::DownloadCreated { id }); - } - - #[tokio::test] - async fn test_start_download_persists_validated_account_association() { - let (bus, repo, _) = make_command_bus(Arc::new(MockHttpClient::failing())); - let account_repo = Arc::new(InMemoryAccountRepo::new()); - let credentials = Arc::new(FakeAccountCredentialStore::new()); - let account_id = AccountId::new("account-1"); - let account = Account::reconstruct_with_status( - account_id.clone(), - "vortex-mod-1fichier".into(), - "alice".into(), - AccountType::Premium, - true, - None, - None, - Some(u64::MAX), - Some(1), - 0, - AccountStatus::Valid, - None, - ); - account_repo.save(&account).unwrap(); - credentials.store_password(&account_id, "api-key").unwrap(); - let bus = bus - .with_account_repo(account_repo) - .with_account_credential_store(credentials) - .with_account_clock(Arc::new(FixedAccountClock)); - - let id = bus - .handle_start_download(StartDownloadCommand { - url: "https://download.1fichier.com/token/file.zip".into(), - destination: Some(PathBuf::from("/tmp")), - filename: Some("file.zip".into()), - source_hostname_override: Some("1fichier.com".into()), - module_name: Some("vortex-mod-1fichier".into()), - account_id: Some(account_id.clone()), - }) - .await - .expect("valid account association"); - - let stored = repo.store.lock().unwrap().get(&id.0).cloned().unwrap(); - assert_eq!(stored.module_name(), Some("vortex-mod-1fichier")); - assert_eq!(stored.account_id(), Some(&account_id)); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn account_delete_waits_until_validated_download_is_persisted() { - let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); - let account_repo = Arc::new(InMemoryAccountRepo::new()); - let account_id = AccountId::new("account-1"); - let mut account = Account::new( - account_id.clone(), - "vortex-mod-1fichier".into(), - "alice".into(), - AccountType::Premium, - 0, - ); - account.set_status(AccountStatus::Valid); - account_repo.save(&account).unwrap(); - let (password_read_tx, password_read_rx) = tokio::sync::oneshot::channel(); - let credentials = Arc::new(SignallingCredentialStore { - password_read: Mutex::new(Some(password_read_tx)), - }); - let bus = Arc::new( - bus.with_account_repo(account_repo) - .with_account_credential_store(credentials) - .with_account_clock(Arc::new(FixedAccountClock)), - ); - let queue_guard = bus.lock_queue_positions().await; - let start_bus = Arc::clone(&bus); - let start_account = account_id.clone(); - let start = tokio::spawn(async move { - start_bus - .handle_start_download(StartDownloadCommand { - url: "https://1fichier.com/?abc123".into(), - destination: Some(PathBuf::from("/tmp")), - filename: Some("file.zip".into()), - source_hostname_override: None, - module_name: Some("vortex-mod-1fichier".into()), - account_id: Some(start_account), - }) - .await - }); - password_read_rx.await.expect("account validation reached"); - - let delete_bus = Arc::clone(&bus); - let mut deletion = tokio::spawn(async move { - delete_bus - .handle_delete_account(DeleteAccountCommand { id: account_id }) - .await - }); - assert!( - tokio::time::timeout(std::time::Duration::from_millis(100), &mut deletion) - .await - .is_err(), - "deletion must serialize with validation and persistence" - ); - - drop(queue_guard); - start.await.unwrap().expect("download persisted first"); - let error = deletion - .await - .unwrap() - .expect_err("a referenced account cannot be deleted"); - assert!(matches!(error, AppError::Validation(_))); - } - - #[tokio::test] - async fn test_start_download_rejects_account_with_missing_credential() { - let (bus, _, events) = make_command_bus(Arc::new(MockHttpClient::failing())); - let account_repo = Arc::new(InMemoryAccountRepo::new()); - let credentials = Arc::new(FakeAccountCredentialStore::new()); - let account_id = AccountId::new("account-1"); - let account = Account::reconstruct_with_status( - account_id.clone(), - "vortex-mod-1fichier".into(), - "alice".into(), - AccountType::Premium, - true, - None, - None, - Some(u64::MAX), - Some(1), - 0, - AccountStatus::Valid, - None, - ); - account_repo.save(&account).unwrap(); - let bus = bus - .with_account_repo(account_repo) - .with_account_credential_store(credentials); - - let error = bus - .handle_start_download(StartDownloadCommand { - url: "https://download.1fichier.com/token/file.zip".into(), - destination: Some(PathBuf::from("/tmp")), - filename: Some("file.zip".into()), - source_hostname_override: Some("1fichier.com".into()), - module_name: Some("vortex-mod-1fichier".into()), - account_id: Some(account_id.clone()), - }) - .await - .expect_err("missing credential must reject association"); - - assert!(matches!(error, AppError::NotFound(_))); - assert!(events.events.lock().unwrap().iter().any(|event| matches!( - event, - DomainEvent::AccountValidationFailed { id, .. } if id == &account_id - ))); - } - - #[tokio::test] - async fn test_start_download_uses_injected_clock_for_account_cooldown() { - let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); - let account_repo = Arc::new(InMemoryAccountRepo::new()); - let credentials = Arc::new(FakeAccountCredentialStore::new()); - let account_id = AccountId::new("account-1"); - let account = Account::reconstruct_with_status( - account_id.clone(), - "vortex-mod-1fichier".into(), - "alice".into(), - AccountType::Premium, - true, - None, - None, - Some(u64::MAX), - Some(1), - 0, - AccountStatus::Cooldown, - Some(2_000), - ); - account_repo.save(&account).unwrap(); - credentials.store_password(&account_id, "api-key").unwrap(); - let bus = bus - .with_account_repo(account_repo) - .with_account_credential_store(credentials) - .with_account_clock(Arc::new(FixedAccountClock)); - - let error = bus - .handle_start_download(StartDownloadCommand { - url: "https://download.1fichier.com/token/file.zip".into(), - destination: Some(PathBuf::from("/tmp")), - filename: Some("file.zip".into()), - source_hostname_override: Some("1fichier.com".into()), - module_name: Some("vortex-mod-1fichier".into()), - account_id: Some(account_id), - }) - .await - .expect_err("injected clock keeps cooldown active"); - - assert!(matches!(error, AppError::Validation(_))); - } - - #[tokio::test] - async fn test_start_download_invalid_url_returns_error() { - let (bus, _, _) = make_command_bus(Arc::new(MockHttpClient::failing())); - - let cmd = StartDownloadCommand { - url: "not-a-valid-url".to_string(), - destination: None, - filename: None, - source_hostname_override: None, - module_name: None, - account_id: None, - }; - - let result = bus.handle_start_download(cmd).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_start_download_head_failure_uses_url_fallback() { - let (bus, repo, _) = make_command_bus(Arc::new(MockHttpClient::failing())); - - let cmd = StartDownloadCommand { - url: "https://example.com/path/archive.tar.gz".to_string(), - destination: Some(PathBuf::from("/tmp")), - filename: None, - source_hostname_override: None, - module_name: None, - account_id: None, - }; - - let id = bus.handle_start_download(cmd).await.unwrap(); - - let saved = repo.store.lock().unwrap().get(&id.0).cloned().unwrap(); - assert_eq!(saved.file_name(), "archive.tar.gz"); - } - - use std::path::PathBuf; - - #[test] - fn test_parse_content_disposition_extracts_filename() { - let name = super::parse_content_disposition("attachment; filename=\"hello.zip\""); - assert_eq!(name, Some("hello.zip".to_string())); - } - - #[test] - fn test_parse_content_disposition_returns_none_without_filename() { - let name = super::parse_content_disposition("inline"); - assert_eq!(name, None); - } - - #[test] - fn test_filename_from_url_extracts_last_segment() { - let url = - crate::domain::model::download::Url::new("https://example.com/path/file.bin").unwrap(); - assert_eq!(super::filename_from_url(&url), "file.bin"); - } - - #[test] - fn test_filename_from_url_strips_query_string() { - let url = - crate::domain::model::download::Url::new("https://example.com/file.bin?token=abc") - .unwrap(); - assert_eq!(super::filename_from_url(&url), "file.bin"); - } - - #[tokio::test] - async fn test_filename_override_skips_head_probe() { - // Regression for YouTube downloads: when a filename override is provided - // (e.g. "Rick Astley - Never Gonna Give You Up.mp4") the HEAD probe must - // be skipped and the override used directly. - let (bus, repo, _) = make_command_bus(Arc::new(MockHttpClient::failing())); - - let cmd = StartDownloadCommand { - url: "https://rr1---sn-n4g-cvq6.googlevideo.com/videoplayback?expire=123".to_string(), - destination: Some(PathBuf::from("/tmp")), - filename: Some("Rick Astley - Never Gonna Give You Up.mp4".to_string()), - source_hostname_override: None, - module_name: None, - account_id: None, - }; - - let id = bus.handle_start_download(cmd).await.unwrap(); - - let saved = repo.store.lock().unwrap().get(&id.0).cloned().unwrap(); - assert_eq!( - saved.file_name(), - "Rick Astley - Never Gonna Give You Up.mp4", - "filename override must be used, not the CDN URL path segment" - ); - } - - #[tokio::test] - async fn test_start_download_appends_to_back_of_existing_queue() { - // Regression: a freshly created download must not jump in front of - // items the user has already reordered. With a max queue_position of 5 - // among reorderable items, the new download must land at 5 + stride. - let (bus, repo, _) = make_command_bus(Arc::new(MockHttpClient::failing())); - - let pre_existing = Download::new( - DownloadId(1), - crate::domain::model::download::Url::new("https://example.com/a.zip").unwrap(), - "a.zip".to_string(), - "/tmp/a.zip".to_string(), - ) - .with_queue_position(5); - repo.save(&pre_existing).unwrap(); - - let cmd = StartDownloadCommand { - url: "https://example.com/b.zip".to_string(), - destination: Some(PathBuf::from("/tmp")), - filename: Some("b.zip".to_string()), - source_hostname_override: None, - module_name: None, - account_id: None, - }; - - let id = bus.handle_start_download(cmd).await.unwrap(); - let saved = repo.store.lock().unwrap().get(&id.0).cloned().unwrap(); - assert_eq!( - saved.queue_position(), - 5 + 1024, - "new download must append after the highest existing reorderable position" - ); - } - - #[tokio::test] - async fn test_source_hostname_override_replaces_cdn_hostname() { - // Regression for YouTube downloads: the download must store "youtube.com" - // (the origin) rather than "rr1---sn-n4g-cvq6.googlevideo.com" (the CDN). - let (bus, repo, _) = make_command_bus(Arc::new(MockHttpClient::failing())); - - let cmd = StartDownloadCommand { - url: "https://rr1---sn-n4g-cvq6.googlevideo.com/videoplayback?expire=123".to_string(), - destination: Some(PathBuf::from("/tmp")), - filename: Some("video.mp4".to_string()), - source_hostname_override: Some("www.youtube.com".to_string()), - module_name: None, - account_id: None, - }; - - let id = bus.handle_start_download(cmd).await.unwrap(); - - let saved = repo.store.lock().unwrap().get(&id.0).cloned().unwrap(); - assert_eq!( - saved.source_hostname(), - "www.youtube.com", - "source_hostname must reflect the origin, not the CDN" - ); - } -} +#[path = "start_download_tests.rs"] +mod tests; diff --git a/src-tauri/src/application/commands/start_download_tests.rs b/src-tauri/src/application/commands/start_download_tests.rs new file mode 100644 index 00000000..22131688 --- /dev/null +++ b/src-tauri/src/application/commands/start_download_tests.rs @@ -0,0 +1,591 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::application::commands::{DeleteAccountCommand, StartDownloadCommand}; +use crate::application::error::AppError; +use crate::domain::error::DomainError; +use crate::domain::event::DomainEvent; +use crate::domain::model::download::{Download, DownloadId, DownloadState}; +use crate::domain::model::http::HttpResponse; +use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; +use crate::domain::ports::driven::{DownloadRepository, HttpClient, PluginLoader}; + +use crate::application::commands::tests_support::{ + FakeAccountCredentialStore, InMemoryAccountRepo, build_download_bus, + build_download_bus_with_plugin_loader, +}; +use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; +use crate::domain::ports::driven::{AccountCredentialStore, AccountRepository, Clock}; + +struct FixedAccountClock; + +impl Clock for FixedAccountClock { + fn now_unix_secs(&self) -> u64 { + 1 + } +} + +struct SignallingCredentialStore { + password_read: Mutex>>, +} + +impl AccountCredentialStore for SignallingCredentialStore { + fn store_password(&self, _: &AccountId, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn get_password(&self, _: &AccountId) -> Result, DomainError> { + if let Some(sender) = self.password_read.lock().unwrap().take() { + let _ = sender.send(()); + } + Ok(Some("api-key".into())) + } + + fn delete_password(&self, _: &AccountId) -> Result<(), DomainError> { + Ok(()) + } +} + +struct MockHttpClient { + response: Mutex>, +} + +impl MockHttpClient { + fn with_response(resp: HttpResponse) -> Self { + Self { + response: Mutex::new(Some(resp)), + } + } + + fn failing() -> Self { + Self { + response: Mutex::new(None), + } + } +} + +impl HttpClient for MockHttpClient { + fn head(&self, _url: &str) -> Result { + self.response + .lock() + .unwrap() + .clone() + .ok_or_else(|| DomainError::NetworkError("connection refused".to_string())) + } + fn get_range(&self, _url: &str, start: u64, end: u64) -> Result, DomainError> { + Ok(vec![ + 0u8; + end.saturating_sub(start).saturating_add(1) as usize + ]) + } + fn supports_range(&self, _url: &str) -> Result { + Ok(true) + } +} + +struct HosterPluginLoader; +impl PluginLoader for HosterPluginLoader { + fn load(&self, _manifest: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + fn unload(&self, _name: &str) -> Result<(), DomainError> { + Ok(()) + } + fn resolve_url(&self, _url: &str) -> Result, DomainError> { + Ok(None) + } + fn list_loaded(&self) -> Result, DomainError> { + Ok(vec![PluginInfo::new( + "vortex-mod-hoster".into(), + "1.0.0".into(), + "test hoster".into(), + "vortex".into(), + PluginCategory::Hoster, + )]) + } + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } +} + +#[tokio::test] +async fn test_start_download_persists_and_emits_event() { + let mut headers = HashMap::new(); + headers.insert("content-length".to_string(), vec!["1024".to_string()]); + headers.insert( + "content-disposition".to_string(), + vec!["attachment; filename=\"report.pdf\"".to_string()], + ); + let resp = HttpResponse { + status_code: 200, + headers, + body: vec![], + }; + + let (bus, repo, event_bus) = build_download_bus(Arc::new(MockHttpClient::with_response(resp))); + + let cmd = StartDownloadCommand { + url: "https://example.com/files/report.pdf".to_string(), + destination: Some(PathBuf::from("/tmp/downloads")), + filename: None, + size_bytes: None, + resume_supported: None, + source_hostname_override: None, + module_name: None, + account_id: None, + }; + + let id = bus.handle_start_download(cmd).await.unwrap(); + + let saved = repo.find_by_id(id).unwrap(); + assert!(saved.is_some()); + let dl = saved.unwrap(); + assert_eq!(dl.state(), DownloadState::Queued); + assert_eq!(dl.file_name(), "report.pdf"); + + let events = event_bus.snapshot(); + assert_eq!(events.len(), 1); + assert_eq!(events[0], DomainEvent::DownloadCreated { id }); +} + +#[tokio::test] +async fn test_start_download_persists_validated_account_association() { + let (bus, repo, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account_id = AccountId::new("account-1"); + let account = Account::reconstruct_with_status( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + Some(u64::MAX), + Some(1), + 0, + AccountStatus::Valid, + None, + ); + account_repo.save(&account).unwrap(); + credentials.store_password(&account_id, "api-key").unwrap(); + let bus = bus + .with_account_repo(account_repo) + .with_account_credential_store(credentials) + .with_account_clock(Arc::new(FixedAccountClock)); + + let id = bus + .handle_start_download(StartDownloadCommand { + url: "https://download.1fichier.com/token/file.zip".into(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("file.zip".into()), + size_bytes: None, + resume_supported: None, + source_hostname_override: Some("1fichier.com".into()), + module_name: Some("vortex-mod-1fichier".into()), + account_id: Some(account_id.clone()), + }) + .await + .expect("valid account association"); + + let stored = repo.find_by_id(id).unwrap().unwrap(); + assert_eq!(stored.module_name(), Some("vortex-mod-1fichier")); + assert_eq!(stored.account_id(), Some(&account_id)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn account_delete_waits_until_validated_download_is_persisted() { + let (bus, _, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let account_id = AccountId::new("account-1"); + let mut account = Account::new( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 0, + ); + account.set_status(AccountStatus::Valid); + account_repo.save(&account).unwrap(); + let (password_read_tx, password_read_rx) = tokio::sync::oneshot::channel(); + let credentials = Arc::new(SignallingCredentialStore { + password_read: Mutex::new(Some(password_read_tx)), + }); + let bus = Arc::new( + bus.with_account_repo(account_repo) + .with_account_credential_store(credentials) + .with_account_clock(Arc::new(FixedAccountClock)), + ); + let queue_guard = bus.lock_queue_positions().await; + let start_bus = Arc::clone(&bus); + let start_account = account_id.clone(); + let start = tokio::spawn(async move { + start_bus + .handle_start_download(StartDownloadCommand { + url: "https://1fichier.com/?abc123".into(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("file.zip".into()), + size_bytes: None, + resume_supported: None, + source_hostname_override: None, + module_name: Some("vortex-mod-1fichier".into()), + account_id: Some(start_account), + }) + .await + }); + password_read_rx.await.expect("account validation reached"); + + let delete_bus = Arc::clone(&bus); + let mut deletion = tokio::spawn(async move { + delete_bus + .handle_delete_account(DeleteAccountCommand { id: account_id }) + .await + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), &mut deletion) + .await + .is_err(), + "deletion must serialize with validation and persistence" + ); + + drop(queue_guard); + start.await.unwrap().expect("download persisted first"); + let error = deletion + .await + .unwrap() + .expect_err("a referenced account cannot be deleted"); + assert!(matches!(error, AppError::Validation(_))); +} + +#[tokio::test] +async fn test_start_download_rejects_account_with_missing_credential() { + let (bus, _, events) = build_download_bus(Arc::new(MockHttpClient::failing())); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account_id = AccountId::new("account-1"); + let account = Account::reconstruct_with_status( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + Some(u64::MAX), + Some(1), + 0, + AccountStatus::Valid, + None, + ); + account_repo.save(&account).unwrap(); + let bus = bus + .with_account_repo(account_repo) + .with_account_credential_store(credentials); + + let error = bus + .handle_start_download(StartDownloadCommand { + url: "https://download.1fichier.com/token/file.zip".into(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("file.zip".into()), + size_bytes: None, + resume_supported: None, + source_hostname_override: Some("1fichier.com".into()), + module_name: Some("vortex-mod-1fichier".into()), + account_id: Some(account_id.clone()), + }) + .await + .expect_err("missing credential must reject association"); + + assert!(matches!(error, AppError::NotFound(_))); + assert!(events.snapshot().iter().any(|event| matches!( + event, + DomainEvent::AccountValidationFailed { id, .. } if id == &account_id + ))); +} + +#[tokio::test] +async fn test_start_download_uses_injected_clock_for_account_cooldown() { + let (bus, _, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account_id = AccountId::new("account-1"); + let account = Account::reconstruct_with_status( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + Some(u64::MAX), + Some(1), + 0, + AccountStatus::Cooldown, + Some(2_000), + ); + account_repo.save(&account).unwrap(); + credentials.store_password(&account_id, "api-key").unwrap(); + let bus = bus + .with_account_repo(account_repo) + .with_account_credential_store(credentials) + .with_account_clock(Arc::new(FixedAccountClock)); + + let error = bus + .handle_start_download(StartDownloadCommand { + url: "https://download.1fichier.com/token/file.zip".into(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("file.zip".into()), + size_bytes: None, + resume_supported: None, + source_hostname_override: Some("1fichier.com".into()), + module_name: Some("vortex-mod-1fichier".into()), + account_id: Some(account_id), + }) + .await + .expect_err("injected clock keeps cooldown active"); + + assert!(matches!(error, AppError::Validation(_))); +} + +#[tokio::test] +async fn test_start_download_invalid_url_returns_error() { + let (bus, _, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + + let cmd = StartDownloadCommand { + url: "not-a-valid-url".to_string(), + destination: None, + filename: None, + size_bytes: None, + resume_supported: None, + source_hostname_override: None, + module_name: None, + account_id: None, + }; + + let result = bus.handle_start_download(cmd).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_start_download_head_failure_uses_url_fallback() { + let (bus, repo, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + + let cmd = StartDownloadCommand { + url: "https://example.com/path/archive.tar.gz".to_string(), + destination: Some(PathBuf::from("/tmp")), + filename: None, + size_bytes: None, + resume_supported: None, + source_hostname_override: None, + module_name: None, + account_id: None, + }; + + let id = bus.handle_start_download(cmd).await.unwrap(); + + let saved = repo.find_by_id(id).unwrap().unwrap(); + assert_eq!(saved.file_name(), "archive.tar.gz"); +} + +use std::path::PathBuf; + +#[test] +fn test_parse_content_disposition_extracts_filename() { + let name = super::parse_content_disposition("attachment; filename=\"hello.zip\""); + assert_eq!(name, Some("hello.zip".to_string())); +} + +#[test] +fn test_parse_content_disposition_returns_none_without_filename() { + let name = super::parse_content_disposition("inline"); + assert_eq!(name, None); +} + +#[test] +fn test_filename_from_url_extracts_last_segment() { + let url = + crate::domain::model::download::Url::new("https://example.com/path/file.bin").unwrap(); + assert_eq!(super::filename_from_url(&url), "file.bin"); +} + +#[test] +fn test_filename_from_url_strips_query_string() { + let url = + crate::domain::model::download::Url::new("https://example.com/file.bin?token=abc").unwrap(); + assert_eq!(super::filename_from_url(&url), "file.bin"); +} + +#[tokio::test] +async fn test_filename_override_skips_head_probe() { + // Regression for YouTube downloads: when a filename override is provided + // (e.g. "Rick Astley - Never Gonna Give You Up.mp4") the HEAD probe must + // be skipped and the override used directly. + let (bus, repo, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + + let cmd = StartDownloadCommand { + url: "https://rr1---sn-n4g-cvq6.googlevideo.com/videoplayback?expire=123".to_string(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("Rick Astley - Never Gonna Give You Up.mp4".to_string()), + size_bytes: None, + resume_supported: None, + source_hostname_override: None, + module_name: None, + account_id: None, + }; + + let id = bus.handle_start_download(cmd).await.unwrap(); + + let saved = repo.find_by_id(id).unwrap().unwrap(); + assert_eq!( + saved.file_name(), + "Rick Astley - Never Gonna Give You Up.mp4", + "filename override must be used, not the CDN URL path segment" + ); +} + +#[tokio::test] +async fn hoster_without_filename_never_uses_the_generic_head_probe() { + let mut headers = HashMap::new(); + headers.insert( + "content-disposition".to_string(), + vec!["attachment; filename=from-unsafe-probe.bin".to_string()], + ); + let response = HttpResponse { + status_code: 200, + headers, + body: vec![], + }; + let (bus, repo, _) = build_download_bus_with_plugin_loader( + Arc::new(MockHttpClient::with_response(response)), + Arc::new(HosterPluginLoader), + ); + + let id = bus + .handle_start_download(StartDownloadCommand { + url: "https://hoster.example/stable-id".into(), + destination: Some(PathBuf::from("/tmp")), + filename: None, + size_bytes: None, + resume_supported: None, + source_hostname_override: None, + module_name: Some("vortex-mod-hoster".into()), + account_id: None, + }) + .await + .expect("protected hoster source is queued"); + + let saved = repo.find_by_id(id).unwrap().unwrap(); + assert_eq!(saved.file_name(), "stable-id"); +} + +#[tokio::test] +async fn builtin_http_without_filename_does_not_require_a_plugin_manifest() { + let (bus, repo, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + + let id = bus + .handle_start_download(StartDownloadCommand { + url: "https://example.com/".into(), + destination: Some(PathBuf::from("/tmp")), + filename: None, + size_bytes: None, + resume_supported: None, + source_hostname_override: None, + module_name: Some("builtin-http".into()), + account_id: None, + }) + .await + .expect("built-in HTTP remains a direct source"); + + let saved = repo.find_by_id(id).unwrap().unwrap(); + assert_eq!(saved.file_name(), "download"); +} + +#[tokio::test] +async fn test_start_download_appends_to_back_of_existing_queue() { + // Regression: a freshly created download must not jump in front of + // items the user has already reordered. With a max queue_position of 5 + // among reorderable items, the new download must land at 5 + stride. + let (bus, repo, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + + let pre_existing = Download::new( + DownloadId(1), + crate::domain::model::download::Url::new("https://example.com/a.zip").unwrap(), + "a.zip".to_string(), + "/tmp/a.zip".to_string(), + ) + .with_queue_position(5); + repo.save(&pre_existing).unwrap(); + + let cmd = StartDownloadCommand { + url: "https://example.com/b.zip".to_string(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("b.zip".to_string()), + size_bytes: None, + resume_supported: None, + source_hostname_override: None, + module_name: None, + account_id: None, + }; + + let id = bus.handle_start_download(cmd).await.unwrap(); + let saved = repo.find_by_id(id).unwrap().unwrap(); + assert_eq!( + saved.queue_position(), + 5 + 1024, + "new download must append after the highest existing reorderable position" + ); +} + +#[tokio::test] +async fn test_source_hostname_override_replaces_cdn_hostname() { + // Regression for YouTube downloads: the download must store "youtube.com" + // (the origin) rather than "rr1---sn-n4g-cvq6.googlevideo.com" (the CDN). + let (bus, repo, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + + let cmd = StartDownloadCommand { + url: "https://rr1---sn-n4g-cvq6.googlevideo.com/videoplayback?expire=123".to_string(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("video.mp4".to_string()), + size_bytes: None, + resume_supported: None, + source_hostname_override: Some("www.youtube.com".to_string()), + module_name: None, + account_id: None, + }; + + let id = bus.handle_start_download(cmd).await.unwrap(); + + let saved = repo.find_by_id(id).unwrap().unwrap(); + assert_eq!( + saved.source_hostname(), + "www.youtube.com", + "source_hostname must reflect the origin, not the CDN" + ); +} + +#[tokio::test] +async fn hoster_metadata_is_preserved_when_the_stable_source_is_created() { + let (bus, repo, _) = build_download_bus(Arc::new(MockHttpClient::failing())); + + let id = bus + .handle_start_download(StartDownloadCommand { + url: "https://gofile.io/d/folder/file-a".into(), + destination: Some(PathBuf::from("/tmp")), + filename: Some("archive.zip".into()), + size_bytes: Some(42), + resume_supported: Some(true), + source_hostname_override: None, + module_name: Some("vortex-mod-gofile".into()), + account_id: None, + }) + .await + .expect("stable hoster download is created"); + + let saved = repo.find_by_id(id).unwrap().unwrap(); + assert_eq!(saved.url().as_str(), "https://gofile.io/d/folder/file-a"); + assert_eq!(saved.file_name(), "archive.zip"); + assert_eq!(saved.file_size().map(|size| size.0), Some(42)); + assert!(saved.resume_supported()); + assert_eq!(saved.module_name(), Some("vortex-mod-gofile")); +} diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index 5fb1e3a1..16250e2c 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -794,6 +794,45 @@ impl CredentialStore for InMemoryCredentialStore { } } +/// Build a [`CommandBus`] for download-command tests while allowing the +/// HTTP and plugin behaviors under test to be injected. +pub(crate) fn build_download_bus( + http_client: Arc, +) -> ( + CommandBus, + Arc, + Arc, +) { + build_download_bus_with_plugin_loader(http_client, Arc::new(StubPluginLoader)) +} + +pub(crate) fn build_download_bus_with_plugin_loader( + http_client: Arc, + plugin_loader: Arc, +) -> ( + CommandBus, + Arc, + Arc, +) { + let repo = Arc::new(InMemoryDownloadRepo::new()); + let event_bus = Arc::new(CapturingEventBus::new()); + let bus = CommandBus::new( + repo.clone(), + Arc::new(StubDownloadEngine), + event_bus.clone(), + Arc::new(StubFileStorage), + http_client, + plugin_loader, + Arc::new(StubConfigStore), + Arc::new(StubCredentialStore), + Arc::new(StubClipboardObserver), + Arc::new(StubArchiveExtractor), + Arc::new(NoopHistoryRepo), + None, + ); + (bus, repo, event_bus) +} + /// Build a [`CommandBus`] wired with the supplied account ports plus /// stubs for everything else. pub(crate) fn build_account_bus( diff --git a/src-tauri/src/application/services/download_source_policy.rs b/src-tauri/src/application/services/download_source_policy.rs new file mode 100644 index 00000000..71e8f54f --- /dev/null +++ b/src-tauri/src/application/services/download_source_policy.rs @@ -0,0 +1,111 @@ +//! Classifies persisted download modules before network access. + +use crate::domain::error::DomainError; +use crate::domain::model::plugin::PluginCategory; +use crate::domain::ports::driven::PluginLoader; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PersistedSourceKind { + Direct, + Protected, +} + +impl PersistedSourceKind { + pub(crate) fn is_protected(self) -> bool { + self == Self::Protected + } +} + +pub(crate) fn classify_download_module( + plugins: &dyn PluginLoader, + module_name: Option<&str>, +) -> Result { + let Some(module_name) = module_name else { + return Ok(PersistedSourceKind::Direct); + }; + let loaded = plugins + .list_loaded()? + .into_iter() + .find(|info| info.name() == module_name); + let info = match loaded { + Some(info) => Some(info), + None => plugins.find_installed_manifest(module_name)?, + }; + let Some(info) = info else { + return if matches!( + module_name, + "builtin-http" | "core-http" | "http" | "magnet" + ) { + Ok(PersistedSourceKind::Direct) + } else { + Err(DomainError::NotFound(format!( + "download plugin '{module_name}' is unavailable" + ))) + }; + }; + Ok(if is_protected_plugin_category(info.category()) { + PersistedSourceKind::Protected + } else { + PersistedSourceKind::Direct + }) +} + +pub(crate) fn is_protected_plugin_category(category: PluginCategory) -> bool { + matches!(category, PluginCategory::Hoster | PluginCategory::Debrid) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::plugin::{PluginInfo, PluginManifest}; + + struct NamedPluginLoader(PluginInfo); + + impl PluginLoader for NamedPluginLoader { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn resolve_url(&self, _: &str) -> Result, DomainError> { + Ok(None) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok(vec![self.0.clone()]) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + } + + #[test] + fn only_hoster_and_debrid_categories_are_protected() { + assert!(is_protected_plugin_category(PluginCategory::Hoster)); + assert!(is_protected_plugin_category(PluginCategory::Debrid)); + assert!(!is_protected_plugin_category(PluginCategory::Crawler)); + } + + #[test] + fn loaded_hoster_named_like_a_legacy_alias_remains_protected() { + for name in ["http", "magnet"] { + let loader = NamedPluginLoader(PluginInfo::new( + name.into(), + "1.0.0".into(), + name.into(), + "vortex".into(), + PluginCategory::Hoster, + )); + + assert_eq!( + classify_download_module(&loader, Some(name)).unwrap(), + PersistedSourceKind::Protected, + "{name}" + ); + } + } +} diff --git a/src-tauri/src/application/services/mod.rs b/src-tauri/src/application/services/mod.rs index f2953eec..c60c1bcb 100644 --- a/src-tauri/src/application/services/mod.rs +++ b/src-tauri/src/application/services/mod.rs @@ -3,6 +3,7 @@ pub mod account_rotator; pub mod account_selector; pub(crate) mod account_state; pub mod checksum_validator; +pub(crate) mod download_source_policy; pub mod engine_config_bridge; pub(crate) mod group_lock; pub mod history_backfill; diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs index 669637b4..416192b3 100644 --- a/src-tauri/src/domain/error.rs +++ b/src-tauri/src/domain/error.rs @@ -26,6 +26,10 @@ pub enum DomainError { AccountExpired, AccountCooldown, AccountQuotaExceeded, + HosterNoFile, + HosterAuthenticationRequired, + HosterDirectUrlExpired, + HosterUnexpectedHtml, AdaptiveStreamOnly, /// Computed checksum did not match the expected value. ChecksumMismatch { @@ -79,6 +83,16 @@ impl std::fmt::Display for DomainError { DomainError::AccountExpired => write!(f, "Account is expired"), DomainError::AccountCooldown => write!(f, "Account is temporarily rate-limited"), DomainError::AccountQuotaExceeded => write!(f, "Account quota is exhausted"), + DomainError::HosterNoFile => write!(f, "No downloadable file was found"), + DomainError::HosterAuthenticationRequired => { + write!(f, "Hoster authentication is required") + } + DomainError::HosterDirectUrlExpired => { + write!(f, "The direct download URL has expired") + } + DomainError::HosterUnexpectedHtml => { + write!(f, "Hoster returned an HTML page instead of file content") + } DomainError::AdaptiveStreamOnly => write!( f, "Video is only available as adaptive stream (DASH/HLS); use download_to_file" diff --git a/src-tauri/src/domain/model/download.rs b/src-tauri/src/domain/model/download.rs index 16581947..b40bdfb0 100644 --- a/src-tauri/src/domain/model/download.rs +++ b/src-tauri/src/domain/model/download.rs @@ -310,6 +310,18 @@ impl Download { self } + pub fn with_remote_metadata( + mut self, + file_size: Option, + resume_supported: Option, + ) -> Self { + self.file_size = file_size.map(FileSize); + if let Some(resume_supported) = resume_supported { + self.resume_supported = resume_supported; + } + self + } + pub fn with_module_name(mut self, name: String) -> Self { self.module_name = Some(name); self diff --git a/src-tauri/src/domain/ports/driven/download_source_resolver.rs b/src-tauri/src/domain/ports/driven/download_source_resolver.rs index dbabe237..e3dfe3ac 100644 --- a/src-tauri/src/domain/ports/driven/download_source_resolver.rs +++ b/src-tauri/src/domain/ports/driven/download_source_resolver.rs @@ -8,6 +8,11 @@ use crate::domain::model::download::Download; #[derive(Clone, PartialEq, Eq)] pub struct ResolvedDownloadSource { request_url: String, + request_headers: Vec<(String, String)>, + protected: bool, + filename: Option, + size_bytes: Option, + resumable: Option, } impl std::fmt::Debug for ResolvedDownloadSource { @@ -17,13 +22,64 @@ impl std::fmt::Debug for ResolvedDownloadSource { } impl ResolvedDownloadSource { - pub fn sensitive(request_url: String) -> Self { - Self { request_url } + pub fn protected(request_url: String) -> Self { + Self { + request_url, + request_headers: Vec::new(), + protected: true, + filename: None, + size_bytes: None, + resumable: None, + } + } + + pub fn direct(request_url: String) -> Self { + Self { + protected: false, + ..Self::protected(request_url) + } + } + + pub fn with_request_headers(mut self, request_headers: Vec<(String, String)>) -> Self { + self.request_headers = request_headers; + self + } + + pub fn with_metadata( + mut self, + filename: Option, + size_bytes: Option, + resumable: Option, + ) -> Self { + self.filename = filename; + self.size_bytes = size_bytes; + self.resumable = resumable; + self } pub fn request_url(&self) -> &str { &self.request_url } + + pub fn request_headers(&self) -> &[(String, String)] { + &self.request_headers + } + + pub fn is_protected(&self) -> bool { + self.protected + } + + pub fn filename(&self) -> Option<&str> { + self.filename.as_deref() + } + + pub fn size_bytes(&self) -> Option { + self.size_bytes + } + + pub fn resumable(&self) -> Option { + self.resumable + } } /// Linearizes cancellation with resolver-side persistence. The engine can @@ -64,7 +120,7 @@ impl ResolutionCancellation { operation: impl FnOnce() -> Result, ) -> Result { let cancelled = self.cancelled.lock().map_err(|_| { - DomainError::PluginError("premium source cancellation state unavailable".into()) + DomainError::PluginError("download source cancellation state unavailable".into()) })?; if *cancelled { return Err(cancelled_error()); @@ -74,12 +130,16 @@ impl ResolutionCancellation { } fn cancelled_error() -> DomainError { - DomainError::PluginError("premium source resolution cancelled".into()) + DomainError::PluginError("download source resolution cancelled".into()) } /// Called by the download engine immediately before opening the connection. /// Implementations must never persist or log the returned URL. pub trait DownloadSourceResolver: Send + Sync { + fn requires_resolution(&self, download: &Download) -> Result { + Ok(download.account_id().is_some()) + } + fn resolve(&self, download: &Download) -> Result; fn resolve_cancellable( diff --git a/src-tauri/src/domain/ports/driven/file_storage.rs b/src-tauri/src/domain/ports/driven/file_storage.rs index d4481a7b..3d062e34 100644 --- a/src-tauri/src/domain/ports/driven/file_storage.rs +++ b/src-tauri/src/domain/ports/driven/file_storage.rs @@ -19,6 +19,29 @@ pub trait FileStorage: Send + Sync { /// Write segment data at the specified byte offset. fn write_segment(&self, path: &Path, offset: u64, data: &[u8]) -> Result<(), DomainError>; + /// Write a chunk for a response whose final length is unknown, extending + /// the reserved file as needed. + fn write_growing_segment( + &self, + path: &Path, + offset: u64, + data: &[u8], + ) -> Result<(), DomainError> { + let minimum_size = offset.checked_add(data.len() as u64).ok_or_else(|| { + DomainError::StorageError("unknown-length write would overflow u64".into()) + })?; + self.grow_file(path, minimum_size)?; + self.write_segment(path, offset, data) + } + + /// Grow an already reserved file to at least `minimum_size` bytes. + /// Used only when the remote server did not advertise a content length. + fn grow_file(&self, _path: &Path, _minimum_size: u64) -> Result<(), DomainError> { + Err(DomainError::StorageError( + "FileStorage::grow_file is not implemented for this adapter".into(), + )) + } + /// Read the `.vortex-meta` resume metadata for a download. fn read_meta(&self, path: &Path) -> Result, DomainError>; @@ -28,6 +51,16 @@ pub trait FileStorage: Send + Sync { /// Delete the `.vortex-meta` file (called after successful completion). fn delete_meta(&self, path: &Path) -> Result<(), DomainError>; + /// Delete a download body and its resume metadata as one idempotent + /// adapter operation. Ownership metadata must remain recoverable until the + /// body is removed, and cleanup must never delete a newer sidecar created + /// after the destination path becomes available again. + fn delete_download_artifacts(&self, _path: &Path) -> Result<(), DomainError> { + Err(DomainError::StorageError( + "FileStorage::delete_download_artifacts is not implemented for this adapter".into(), + )) + } + /// Return `Ok(true)` when `path` points to an existing file or directory. /// Used by the `change_directory` handler to decide whether to skip the /// body move (e.g. for `Queued` items whose engine has not started yet). diff --git a/src-tauri/src/domain/ports/driven/hoster_link.rs b/src-tauri/src/domain/ports/driven/hoster_link.rs index 796ffd9f..4b9e1897 100644 --- a/src-tauri/src/domain/ports/driven/hoster_link.rs +++ b/src-tauri/src/domain/ports/driven/hoster_link.rs @@ -11,6 +11,9 @@ pub struct ExtractedHosterLink { pub size_bytes: Option, /// Ephemeral bearer URL produced for the selected account. pub direct_url: Option, + pub resumable: Option, + /// Ephemeral request headers required by the direct URL. + pub request_headers: Vec<(String, String)>, pub traffic_used_bytes: Option, pub traffic_total_bytes: Option, } @@ -24,6 +27,8 @@ impl std::fmt::Debug for ExtractedHosterLink { .field("filename", &self.filename) .field("size_bytes", &self.size_bytes) .field("direct_url", &direct_url) + .field("resumable", &self.resumable) + .field("request_headers", &"") .field("traffic_used_bytes", &self.traffic_used_bytes) .field("traffic_total_bytes", &self.traffic_total_bytes) .finish() @@ -41,6 +46,8 @@ mod tests { filename: Some("file.zip".into()), size_bytes: Some(42), direct_url: Some("https://cdn.example/secret-token".into()), + resumable: Some(true), + request_headers: vec![("Authorization".into(), "Bearer secret".into())], traffic_used_bytes: None, traffic_total_bytes: None, }; @@ -49,5 +56,6 @@ mod tests { assert!(debug.contains("source_url")); assert!(debug.contains("")); assert!(!debug.contains("secret-token")); + assert!(!debug.contains("Bearer secret")); } } diff --git a/src-tauri/src/domain/ports/driven/plugin_loader.rs b/src-tauri/src/domain/ports/driven/plugin_loader.rs index 696cf2b1..a302e69b 100644 --- a/src-tauri/src/domain/ports/driven/plugin_loader.rs +++ b/src-tauri/src/domain/ports/driven/plugin_loader.rs @@ -100,6 +100,20 @@ pub trait PluginLoader: Send + Sync { ))) } + /// Extract every hoster link from the exact named plugin. + /// + /// The default preserves source compatibility for loaders that only + /// support one file. Adapters with multi-file hosters should override it. + fn extract_hoster_links( + &self, + service_name: &str, + url: &str, + credential: Option<&Credential>, + ) -> Result, DomainError> { + self.extract_hoster_link(service_name, url, credential) + .map(|link| vec![link]) + } + /// Validate an account through the plugin matching `service_name`. fn validate_account( &self, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b148dfcf..c9195436 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -55,7 +55,7 @@ pub use adapters::driven::tray::{ spawn_tray_animator, }; pub use application::command_bus::CommandBus; -pub use application::commands::resolve_premium_source::ResolvePremiumSourceHandler; +pub use application::commands::resolve_premium_source::ResolveHosterSourceHandler; pub use application::commands::store_refresh::{read_cache, write_cache}; pub use application::error::AppError; pub use application::query_bus::QueryBus; @@ -261,7 +261,7 @@ pub fn run() { let account_operation_locks = Arc::new( application::services::account_operation_locks::AccountOperationLocks::default(), ); - let premium_source_handler = Arc::new(ResolvePremiumSourceHandler::new( + let download_source_handler = Arc::new(ResolveHosterSourceHandler::new( account_repo.clone(), account_credential_store.clone(), plugin_loader.clone(), @@ -272,8 +272,8 @@ pub fn run() { config_store.clone(), account_rotator.clone(), )); - let premium_source_resolver: Arc = - premium_source_handler.clone(); + let download_source_resolver: Arc = + download_source_handler.clone(); // ── Download engine ───────────────────────────────────── let initial_engine_config = config_store @@ -286,7 +286,7 @@ pub fn run() { event_bus.clone(), 4, ) - .with_source_resolver(premium_source_resolver) + .with_source_resolver(download_source_resolver) .with_dynamic_split( initial_engine_config.dynamic_split_enabled, initial_engine_config.dynamic_split_min_remaining_mb, diff --git a/src-tauri/tests/app_state_wiring.rs b/src-tauri/tests/app_state_wiring.rs index 4b628b00..18c3196c 100644 --- a/src-tauri/tests/app_state_wiring.rs +++ b/src-tauri/tests/app_state_wiring.rs @@ -14,7 +14,7 @@ use vortex_lib::domain::ports::driven::{ use vortex_lib::{ AccountOperationLocks, AccountRotator, AccountSelector, CommandBus, ExtismPluginLoader, ExtractionConfig, FsFileStorage, KeyringAccountStore, NoopCredentialStore, - PluginAccountValidator, QueryBus, ReqwestHttpClient, ResolvePremiumSourceHandler, + PluginAccountValidator, QueryBus, ReqwestHttpClient, ResolveHosterSourceHandler, SegmentedDownloadEngine, SharedHostResources, SqliteAccountRepo, SqliteDownloadReadRepo, SqliteDownloadRepo, SqliteHistoryRepo, SqliteStatsRepo, SystemClock, TokioEventBus, TomlConfigStore, VortexArchiveExtractor, connection, @@ -87,7 +87,7 @@ fn test_appstate_wiring_with_in_memory_db() { ); let account_validator = Arc::new(PluginAccountValidator::new(plugin_loader.clone())); let account_operation_locks = Arc::new(AccountOperationLocks::default()); - let premium_source_handler = Arc::new(ResolvePremiumSourceHandler::new( + let premium_source_handler = Arc::new(ResolveHosterSourceHandler::new( account_repo.clone(), account_credential_store.clone(), plugin_loader.clone(), diff --git a/src/stores/linkGrabberStore.ts b/src/stores/linkGrabberStore.ts index 40c7d4ad..2d67c159 100644 --- a/src/stores/linkGrabberStore.ts +++ b/src/stores/linkGrabberStore.ts @@ -19,7 +19,7 @@ export type LinkProbeStatus = interface LinkGrabberState { /** Live status keyed by the URL the user pasted. */ - statuses: Record; + statuses: Partial>; setStatus: (url: string, status: LinkProbeStatus) => void; setManyStatuses: (entries: Array<[string, LinkProbeStatus]>) => void; reset: () => void; diff --git a/src/views/LinkGrabberView/LinkGrabberView.tsx b/src/views/LinkGrabberView/LinkGrabberView.tsx index 6e1352fe..3b18fd95 100644 --- a/src/views/LinkGrabberView/LinkGrabberView.tsx +++ b/src/views/LinkGrabberView/LinkGrabberView.tsx @@ -151,9 +151,11 @@ export function LinkGrabberView() { // badge from an earlier paste does not bleed onto a new URL. resetLinkStatuses(); const eligibleUrls = resolved + .filter((link) => link.requiresOnlineProbe ?? true) .map((link) => link.originalUrl) .filter( - (u) => u.toLowerCase().startsWith("http://") || u.toLowerCase().startsWith("https://"), + (url) => + url.toLowerCase().startsWith("http://") || url.toLowerCase().startsWith("https://"), ); if (eligibleUrls.length > 0) { // Pre-seed every row with `checking` so the spinner appears @@ -197,7 +199,16 @@ export function LinkGrabberView() { const { mutate: startDownload } = useTauriMutation< unknown, - { url: string; moduleName: string; accountId: string | null } + { + url: string; + metadata: { + filename: string | null; + sizeBytes: number | null; + resumeSupported: boolean | null; + }; + moduleName: string; + accountId: string | null; + } >("download_start"); const { mutateAsync: startMediaDownloadAsync } = useTauriMutation< @@ -343,6 +354,11 @@ export function LinkGrabberView() { started.add(duplicateKey); startDownload({ url: link.originalUrl, + metadata: { + filename: link.filename, + sizeBytes: link.sizeBytes, + resumeSupported: link.resumable ?? null, + }, moduleName: link.moduleName, accountId: link.accountId, }); diff --git a/src/views/LinkGrabberView/LinkRow.tsx b/src/views/LinkGrabberView/LinkRow.tsx index 6f0187c3..5bc3f9fc 100644 --- a/src/views/LinkGrabberView/LinkRow.tsx +++ b/src/views/LinkGrabberView/LinkRow.tsx @@ -49,6 +49,7 @@ export function LinkRow({ link, selected, onSelect, onMediaClick, onRetry }: Lin const liveStatus = useLinkGrabberStore((s) => s.statuses[link.originalUrl]); const effectiveStatus: LinkStatus = liveStatus?.kind ?? link.status; const showRetry = effectiveStatus === "unknown" && onRetry !== undefined; + const errorMessage = effectiveStatus === "error" ? link.errorMessage : null; const duplicate = link.duplicate?.isDuplicate ? link.duplicate : null; const duplicateLabel = duplicate?.source ? t(duplicateLabelKeyMap[duplicate.source]) : null; @@ -72,7 +73,7 @@ export function LinkRow({ link, selected, onSelect, onMediaClick, onRetry }: Lin {statusIconMap[effectiveStatus]} @@ -101,6 +102,15 @@ export function LinkRow({ link, selected, onSelect, onMediaClick, onRetry }: Lin {link.originalUrl} + {errorMessage && ( + + {errorMessage} + + )} {link.moduleName} {link.sizeBytes !== null && ( diff --git a/src/views/LinkGrabberView/ResolvedLinksSection.tsx b/src/views/LinkGrabberView/ResolvedLinksSection.tsx index d0a393b0..f68f30e1 100644 --- a/src/views/LinkGrabberView/ResolvedLinksSection.tsx +++ b/src/views/LinkGrabberView/ResolvedLinksSection.tsx @@ -57,7 +57,10 @@ export function groupLinks( }, {}); } -function statusOf(link: ResolvedLink, liveStatuses: Record): LinkStatus { +function statusOf( + link: ResolvedLink, + liveStatuses: Partial>, +): LinkStatus { const live = liveStatuses[link.originalUrl]; return (live?.kind as LinkStatus | undefined) ?? link.status; } @@ -65,7 +68,7 @@ function statusOf(link: ResolvedLink, liveStatuses: Record = {}, + liveStatuses: Partial> = {}, ): ResolvedLink[] { if (filter === "all") return links; if (filter === "online") { diff --git a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx index 901a70b6..e6553da0 100644 --- a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx @@ -194,6 +194,7 @@ describe("LinkGrabberView", () => { resolvedUrl: directUrl, filename: "file.zip", sizeBytes: 42, + resumable: true, status: "online", moduleName: "vortex-mod-1fichier", accountId: "account-uuid", @@ -230,6 +231,11 @@ describe("LinkGrabberView", () => { await waitFor(() => { expect(mockInvoke).toHaveBeenCalledWith("download_start", { url: sourceUrl, + metadata: { + filename: "file.zip", + sizeBytes: 42, + resumeSupported: true, + }, moduleName: "vortex-mod-1fichier", accountId: "account-uuid", }); @@ -240,6 +246,137 @@ describe("LinkGrabberView", () => { expect(JSON.stringify(downloadStartCalls)).not.toContain(directUrl); }); + it("keeps plugin statuses authoritative instead of probing hoster page URLs", async () => { + mockInvoke.mockImplementation((command) => { + if (command === "link_resolve") { + return Promise.resolve([ + { + id: "mediafire-link", + originalUrl: "https://www.mediafire.com/file/abc/file.zip/file", + resolvedUrl: "https://www.mediafire.com/file/abc/file.zip/file", + filename: "file.zip", + sizeBytes: 42, + resumable: true, + status: "online", + errorKind: null, + errorMessage: null, + moduleName: "vortex-mod-mediafire", + accountId: null, + isMedia: false, + requiresOnlineProbe: false, + }, + { + id: "gofile-error", + originalUrl: "https://gofile.io/d/missing", + resolvedUrl: null, + filename: null, + sizeBytes: null, + resumable: null, + status: "error", + errorKind: "noFile", + errorMessage: "No downloadable file was found", + moduleName: "vortex-mod-gofile", + accountId: null, + isMedia: false, + requiresOnlineProbe: false, + }, + ]); + } + if (command === "link_detect_duplicates") return Promise.resolve([]); + return Promise.resolve(null); + }); + + const user = userEvent.setup(); + renderWithProviders(); + await user.type(screen.getByRole("textbox"), "https://www.mediafire.com/file/abc"); + await user.click(screen.getByRole("button", { name: "Analyze Links" })); + + await waitFor(() => { + expect(screen.getByText("No downloadable file was found")).toBeInTheDocument(); + }); + expect(mockInvoke.mock.calls.some(([command]) => command === "link_check_online")).toBe(false); + }); + + it("still probes links handled by the built-in HTTP module", async () => { + const url = "https://example.com/file.zip"; + mockInvoke.mockImplementation((command) => { + if (command === "link_resolve") { + return Promise.resolve([ + { + id: "builtin-link", + originalUrl: url, + resolvedUrl: url, + filename: "file.zip", + sizeBytes: 42, + status: "online", + moduleName: "builtin-http", + accountId: null, + isMedia: false, + }, + ]); + } + if (command === "link_detect_duplicates") return Promise.resolve([]); + return Promise.resolve(null); + }); + + const user = userEvent.setup(); + renderWithProviders(); + await user.type(screen.getByRole("textbox"), url); + await user.click(screen.getByRole("button", { name: "Analyze Links" })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("link_check_online", { urls: [url] }); + }); + }); + + it("retries live probes after transient core and crawler analysis errors", async () => { + const coreUrl = "https://example.com/transient.zip"; + const crawlerUrl = "https://gallery.example/album"; + mockInvoke.mockImplementation((command) => { + if (command === "link_resolve") { + return Promise.resolve([ + { + id: "core-error", + originalUrl: coreUrl, + resolvedUrl: null, + filename: null, + sizeBytes: null, + status: "error", + errorMessage: "Network request failed", + moduleName: "builtin-http", + accountId: null, + isMedia: false, + }, + { + id: "crawler-error", + originalUrl: crawlerUrl, + resolvedUrl: null, + filename: null, + sizeBytes: null, + status: "error", + errorMessage: "Network request failed", + moduleName: "vortex-mod-gallery", + accountId: null, + isMedia: false, + }, + ]); + } + if (command === "link_detect_duplicates") return Promise.resolve([]); + return Promise.resolve(null); + }); + + const user = userEvent.setup(); + renderWithProviders(); + await user.type(screen.getByRole("textbox"), `${coreUrl}\n${crawlerUrl}`); + await user.click(screen.getByRole("button", { name: "Analyze Links" })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("link_check_online", { + urls: [coreUrl, crawlerUrl], + }); + }); + }); + it("should surface error toast on failure and success toast on retry", async () => { mockInvoke.mockRejectedValueOnce(new Error("AppState not registered")).mockResolvedValueOnce([ { diff --git a/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx b/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx index 9a4b1209..044eba81 100644 --- a/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx @@ -76,3 +76,29 @@ describe("LinkRow duplicate badge", () => { expect(row.dataset.duplicate).toBe("none"); }); }); + +describe("LinkRow hoster errors", () => { + it("renders the typed plugin error message without replacing its status", () => { + renderRow({ + ...baseLink, + status: "error", + errorKind: "noFile", + errorMessage: "No downloadable file was found", + }); + + expect(screen.getByText("No downloadable file was found")).toBeInTheDocument(); + expect(screen.getByTestId(`link-row-${baseLink.originalUrl}`).dataset.status).toBe("error"); + }); + + it("clears a stale analysis error after a live probe recovers", () => { + useLinkGrabberStore.getState().setStatus(baseLink.originalUrl, { kind: "online" }); + renderRow({ + ...baseLink, + status: "error", + errorMessage: "Network request failed", + }); + + expect(screen.queryByText("Network request failed")).not.toBeInTheDocument(); + expect(screen.getByTestId(`link-row-${baseLink.originalUrl}`).dataset.status).toBe("online"); + }); +}); diff --git a/src/views/LinkGrabberView/types.ts b/src/views/LinkGrabberView/types.ts index 7e3a0c4e..705a434f 100644 --- a/src/views/LinkGrabberView/types.ts +++ b/src/views/LinkGrabberView/types.ts @@ -10,6 +10,15 @@ */ export type LinkStatus = "checking" | "online" | "offline" | "error" | "premiumOnly" | "unknown"; +export type LinkResolutionErrorKind = + | "invalidUrl" + | "noFile" + | "authenticationRequired" + | "expired" + | "accountUnavailable" + | "plugin" + | "network"; + /** * Where a duplicate of the URL was already found: * - `active` — an entry already lives in the downloads list @@ -35,12 +44,16 @@ export interface ResolvedLink { resolvedUrl: string | null; filename: string | null; sizeBytes: number | null; + resumable?: boolean | null; status: LinkStatus; - errorMessage?: string; + errorMessage?: string | null; + errorKind?: LinkResolutionErrorKind | null; moduleName: string; accountId: string | null; isMedia: boolean; mediaType?: "video" | "audio"; + /** Backend-owned policy: hoster pages are never probed as file URLs. */ + requiresOnlineProbe?: boolean; /** * Result of the duplicate-detection pass. `null` until the backend * has answered; `{ source: null, … }` once the probe has confirmed