diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8d0616..c109aa44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **MAT-132 transient premium URLs**: link analysis, IPC, queued downloads, and + resume sidecars now retain only the stable source URL plus opaque account id. + The engine resolves the direct capability immediately before connecting, + pins its public DNS answers, requires credential-free HTTPS, and rejects + redirects/private networks. Sensitive network diagnostics and all plugin + logs emitted while a credential is scoped are redacted. +- **MAT-132 credential boundary hardening**: account extraction now calls the + exact selected plugin instead of resolving the URL a second time, credential + slots are isolated per loaded plugin generation across hot reloads, hoster + JSON is parsed at the adapter boundary, contradictory validation states fail + closed, and untrusted plugin diagnostics cannot reach events or IPC. - Updated compatible Rust and frontend dependencies, including the patched `quinn-proto` release. Disabled unused SeaORM migration defaults so the MySQL/PostgreSQL drivers and vulnerable `rsa` crate are no longer included. @@ -42,6 +53,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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 + writes. Plugin calls enforce enablement at credential injection, retained + credential logs and bearer URL diagnostics stay redacted, plugin HTTP is + HTTPS-only, legacy numeric account IDs remain unassociated while UUID refs are + indexed, validation worker failures roll back the new account and secret, and + WASM calls no longer pin registry shards. +- **MAT-132 final concurrency and quota fixes**: JIT rotation now updates only + the existing download's account reference, preserving concurrent state and + never recreating a removed row. Cooldown and quota exhaustion remain distinct + when no backup exists, zero remaining traffic triggers typed rotation, and + account badges wake at subscription expiry without a backend event. +- **MAT-132 final acceptance coverage**: added contracts for strict + JIT-only credential use, runtime account rotation with association updates, + referenced-account deletion safety, observable missing credentials, cooldown + badge expiry, and operator-prefix NAT64 SSRF rejection. +- **MAT-132 final review hardening**: link analysis now selects only an opaque + account id without reading credentials or creating a one-shot token; the CQRS + resolver rotates accounts only when the engine requests the direct source. + Account failures persist before typed events, referenced accounts cannot be + deleted, cooldown badges wake at their deadline, and restricted HTTP clients + ignore system proxies while rejecting special IPv6 and discovered NAT64 + mappings to private IPv4. The registry now pins the published 1fichier + v1.1.0 release assets. +- **MAT-132 review regressions**: added coverage for fail-closed account state + persistence, premium account lifecycle races, account-event refreshes, lock + reclamation, and IPv6/proxy SSRF boundaries. +- **MAT-132 account-state hardening**: validation now derives validity from its + typed status, clears stale subscription expiries, bounds temporary failures, + and invalidates the rotator cache on recovery. Per-account async locks prevent + slow plugin calls from undoing deletes or edits; password rotation writes the + keyring first and restores the prior secret on SQLite failure. Cooldown checks + use an injected clock, and exhausted premium accounts no longer fall back to + anonymous extraction. - **Lot 1 install race fixes (MAT-131)**: adaptive downloads atomically reserve unique destination files before copying, and failed Store installs clean their staging directory before propagating loader or task errors. @@ -59,6 +105,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **MAT-132 account status contract**: account validation responses now expose + their exact typed status to the UI, rejected-validation toasts use its + localized label instead of plugin diagnostics, elapsed quota/cooldown markers + render as active, and account read models no longer expose backend keyring + references. - **Task 40 follow-up — `/simplify` cleanup** (scope `download`, sprint task 40): extracted `getHostname` + `getProtocol` to `src/lib/url.ts` and replaced the inlined copies in `MirrorsSection.tsx` + `SourceInfoSection.tsx` with the shared helpers. `run_mirror_attempt` swapped its 13 positional arguments for a `MirrorAttemptParams` struct mirroring the adjacent `SegmentParams` pattern; the `#[allow(clippy::too_many_arguments)]` suppression is gone. `entities/download.rs` + `download_read_repo.rs` use `safe_u32(model.current_mirror_index as i64)` instead of the ad-hoc `u32::try_from(...).unwrap_or(0)` so the cast goes through the SQLite adapter's standard helper. New `MAX_MIRRORS_PER_DOWNLOAD = 64` constant in `domain/model/download.rs`; `Download::set_mirrors` truncates after sorting so a malformed `.metalink` with thousands of entries cannot bloat `mirrors_json` or stall the failover loop. Doc comments on `Download::mirrors` / `current_mirror_index` and the entity's `mirrors_json` / `current_mirror_index` columns trimmed to the WHY clauses (cap rationale, future-extension hook); the persisted cursor's actual behaviour is now spelled out — the engine still drives failover with its in-task cursor, so a crash mid-failover restarts from slot 0, and the column is retained as a hook for the future call site that will mark a failing slot at the domain level. Frontend gates simplified: `DownloadDetailsPanel.tsx` drops the redundant `mirrors && …` (non-optional field in TS), `MirrorsSection.tsx` drops the redundant `!download.mirrors` short-circuit (dead code under the parent gate). - **Task 39 follow-up — PR #151 review fixes** (scope `download`): `WaitManager::schedule_wait` now aborts the previous `JoinHandle` returned by `guard.insert(id, handle)` instead of just dropping it (dropping a `JoinHandle` only detaches the task — `abort()` is what actually stops the timer), so calling `schedule_wait` twice for the same `DownloadId` no longer leaves a stale task that could fire `expire_wait` against an outdated deadline. `expire_wait` now short-circuits when the handle has already been removed by a peer `cancel_wait` / `skip_wait` (the abort is cooperative: once the sleep wakes and the task is past its last `.await`, it runs to completion regardless of `abort()`), preventing a spurious `resume_aggregate` race against the cancel flow. `download_cancel` IPC now runs the `CancelDownloadCommand` first and only invokes `wait_manager.cancel_wait` on success, so a fallible cancel command no longer strands the download in `Waiting` with no timer to resume it. `download_log_bridge` records `DownloadWaitingEnded { expired_naturally: false }` as `"ended early"` instead of `"skipped"` (the same branch fires for cancel-driven endings, where "skipped" was misleading). `useCountdown` short-circuits before scheduling `setInterval` when `untilUnixMs` is already in the past, dropping one no-op render + interval wakeup cycle for already-expired rows. New regression test `rescheduling_same_id_aborts_previous_timer` proves only the latest timer can fire after a reschedule + new `useCountdown` test asserts `setInterval` is not called for past deadlines. @@ -69,6 +120,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Premium account runtime wiring (MAT-132)**: validate configured accounts + through hoster plugins, keep selected credentials scoped to the plugin call, + persist typed account availability, rotate on account failures, and associate + premium downloads with their account without exposing secrets to SQLite, + logs, or frontend events. SQLite stores only typed status, cooldown deadline, + and the opaque account UUID reference; legacy downloads keep their unused + numeric account column while new associations use `account_ref` text. Each + plugin call receives an isolated, automatically cleared credential slot; + existing service-level credentials remain a fallback for legacy plugins. + Contract tests pin the exact service/credential handoff and typed plugin + authentication failures; the Extism bridge now maps stable account error + codes and validation metrics without placing secrets in the plugin ABI. + Command-level tests require add/update/explicit validation to persist the + resulting typed state, including after password rotation; rejected accounts + stay configured but are excluded from premium selection by that state. + Selector/rotator tests require persisted cooldowns to survive a restart and + distinguish unavailable credentials from temporarily exhausted accounts; + selection and rotation now enforce those persisted states. + Resolution/start contract tests carry only the stable source URL, selected + account UUID, and plugin name, and require JIT rotation to a second account. + JIT hoster resolution uses the configured selector/rotator, scopes each + keyring secret to its plugin call, persists typed account failures, retries + an eligible fallback account, and validates the opaque association before + persisting a download. + Read-model and Link Grabber contract tests require typed account states, + cooldown deadlines, and opaque premium associations across IPC. + Account read models now expose non-secret validation state and cooldown + deadlines; Accounts renders typed badges, while Link Grabber forwards the + selected plugin/account pair when starting the resolved direct URL. + Production wiring now composes the Extism-backed account validator with the + SQLite selector and persistent quota rotator using one system clock; the + AppState integration test verifies both command and query account paths. + Premium resolution integration tests now cover missing keyring entries plus + invalid, quota, and cooldown failures before rotation to a second account. + - **Task 42 — Link Grabber container import UI** (scope `link`, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop `.dlc` / `.ccf` / `.rsdf` / `.metalink` / `.meta4` files into the Link Grabber paste zone now decrypts the container through the loaded `vortex-mod-containers` plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command `link_import_container(file_name, file_bytes)` wraps `ImportContainerCommand` (`application/commands/import_container.rs`) — validates the extension against an allowlist, caps the payload at `MAX_CONTAINER_BYTES = 1 MiB` (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new `PluginLoader::decrypt_container(bytes) -> JSON` port, parses the plugin response, and creates a `Package { source_type: Container, name: }` so the imported batch is visible as one unit in the Packages view. The default trait impl returns `DomainError::NotFound` so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled `Container`-category plugin that exports `decrypt`, calls it via the new `PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])` helper (containers are binary blobs — the existing `call_plugin` would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" `NotFound` error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. `PasteZone.tsx` now exports `CONTAINER_EXTENSIONS` + an `isContainerFile(File)` predicate and forwards container drops through a dedicated `onContainerFiles(File[])` callback instead of synthesising fake `container:` URLs that `LinkGrabberView` then dropped on the floor (the original `LinkGrabberView.tsx:67` TODO). `LinkGrabberView::handleContainerFiles` reads each `File` via `arrayBuffer()`, ships the bytes as a `number[]`, surfaces an "Imported {N} links from {filename}" success toast (i18n keys `linkGrabber.toast.containerImported_one/_other` in `fr.json` + `en.json`), then reuses the existing `resolveLinks({ urls })` mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today — `vortex-mod-containers` v1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so no `password_required` state can flow through `decrypt_container` until the plugin gains the capability. 16 new tests: 8 backend (`import_container::tests` — golden path with a Metalink response, blank/extension/empty/oversize validation rejections, plugin `NotFound` propagation, zero-link response, malformed JSON), 1 port default (`plugin_loader::tests::test_decrypt_container_default_returns_not_found`), 1 adapter (`extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded`), 4 frontend `PasteZone.test.tsx` cases (drop forwards files via `onContainerFiles`, ignored when callback missing, text-only drops keep extracting URLs, `isContainerFile` accepts every supported extension + uppercase + rejects unrelated), 2 frontend `LinkGrabberView.test.tsx` cases (drop triggers `link_import_container` with the byte array + chains into `link_resolve` on success, IPC failure surfaces `toast.error` and skips `link_resolve`). `cargo test --workspace`: 1493 pass / 7 ignored. `cargo clippy --workspace -- -D warnings` + `cargo fmt --check` clean. `vitest run`: 702 pass. `oxlint` + `tsc -b` clean. - **Plugin `vortex-mod-containers` v1.0.0** (scope `plugin`, sprint task 41, PRD-v2 §P1.22 / PRD §4.4): new official Vortex plugin in a sibling repo `vortex-mod-containers/`. Container category, no `http` capability — pure transformation `bytes → Vec`, the host routes the resulting URLs through the regular hoster pipeline. Decrypts the four legacy link-container formats: **DLC v1** (JDownloader `cb99b5cbc24db398` AES-128-CBC key + `9bc24cb995cb98b3` IV, base64-wrapped XML carrying a base64-encrypted `` whose plaintext is `BASE64URLBASE64NAMENNN…`), **CCF v1** (Cryptload-style `CCF1\n` magic prefix + base64 AES-128-CBC ciphertext over a `…` XML payload, Vortex-specific embedded key documented for round-trip until a Cryptload v1/v2 corpus is captured), **RSDF** (RapidShare legacy `8C 35 19 2D 96 4D C3 18 2C 6F 84 F3 25 22 39 EB` key/IV pair, hex line per encrypted URL), and **Metalink** (RFC 5854 v4 + community v3 — namespaced `` root, `` carrying ``, `` plus dashed-form variants normalised to the same `ChecksumAlgo` enum, and one or more `` resolutions; first URL becomes `ContainerLink.url`, the rest stack into `ContainerLink.mirrors`). Format detection (`dispatch::detect`) is structural: CCF magic first (unambiguous), then `` substring, then base64 → DLC outer XML probe, then hex-line-shape RSDF probe; non-matching bytes return `Option::None` so the host can show "Unsupported container variant" instead of guessing wrong. Errors map to a typed `PluginError` (`UnsupportedFormat`, `Malformed(String)`, `Decrypt(String)`, `Xml(String)`, `Base64(String)`, `Hex(String)`, `Utf8(String)`, `MissingField(&'static str)`) with `From` impls for every upstream error type — no `.unwrap()` outside tests, all upstream parsers are funnelled through `?`. The crypto core (`src/crypto.rs`) wraps `cbc::Encryptor` / `Decryptor` against the in-place `encrypt_padded_mut::` / `decrypt_padded_mut::` API (pre-sized `Vec` buffer, no `alloc`-feature dependency on `cbc`/`cipher`); decryption rejects non-`% 16` ciphertext lengths up-front so a misaligned blob fails fast. Plugin contract exposes three exports through `extism-pdk`'s `#[plugin_fn]`: `can_decrypt(bytes) -> "true" | "false"` (magic-byte + structural detection), `detect(bytes) -> JSON DetectResponse` (explicit format report including `null` on no match), and `decrypt(bytes) -> JSON DecryptResponse { format, links: [{url, filename?, sizeBytes?, mirrors[], checksums[]}] }` — all DTOs `#[serde(rename_all = "camelCase")]` for the Tauri IPC bridge. Privacy hard-line documented in `docs/ADR-001-container-keys.md`: the plugin **never** reaches out to `service.jdownloader.org/dlcrypt.php` (the JD service path used for DLC v3 per-container keys), so the WASM module declares `http = false` and modern DLC v3 captures fail cleanly with `PluginError::Decrypt(...)` rather than silently leaking the container hash to a third party. WASM artefact weighs **206 KB** under `--release` (well below the ≤500 KB acceptance budget) thanks to the existing `opt-level = "z"` + `lto = true` + `codegen-units = 1` + `strip = true` profile. 54 native unit tests across `crypto` (AES round-trip, wrong-key rejection, block-size invariants, empty-plaintext padding, misaligned-input rejection), `metalink` (v3 + v4 + namespace + dashed hash type + multi-file + missing-`` rejection + zero-files rejection + magic-hint detection), `rsdf` (encode/decode round-trip, magic detection, garbage rejection, short-hex rejection, empty-input rejection, CRLF tolerance, blank-line tolerance, all-empty-decrypt rejection), `dlc` (encode/decode round-trip, magic detection, plain-XML rejection, random-base64 rejection, short-blob rejection, invalid-base64-outer rejection, missing-`` rejection, zero-files rejection, Unicode filename support), `ccf` (encode/decode round-trip, magic prefix detection, missing-magic rejection, invalid-base64 rejection, zero-files-after-decrypt rejection, XML-escape correctness for ampersand/angle-bracket URLs), `dispatch` (per-format detection routing + priority for CCF over Metalink + None on unknown), and `lib` (top-level `can_decrypt` / `detect` / `decrypt` smoke tests per format + `UnsupportedFormat` on garbage) + 8 integration tests in `tests/synthetic_corpus.rs` exercising a 20-container corpus (5 DLC + 5 CCF + 5 RSDF + 5 Metalink — the corpus generator covers single-file, multi-file packs of up to 10 volumes, Unicode filenames, ampersand-laden URLs, mirror-rich Metalink, and dashed-form `` variants) round-tripped through the public `decrypt()` API; specifically `corpus_has_at_least_twenty_containers_across_four_formats` enforces the ≥20 / ≥5-per-format invariant, `every_corpus_entry_decrypts_correctly` asserts format + link count + first-URL identity for each entry, `metalink_corpus_carries_checksums` proves every Metalink fixture surfaces at least one checksum, `metalink_corpus_recognises_sha256_when_present` proves dashed/upper SHA-256 attribute variants normalise to the `Sha256` algo enum, the per-format suites prove the magic-prefix and order-preservation invariants, and `unknown_blobs_are_rejected` proves `can_decrypt` + `decrypt` reject empty/HTML/binary inputs symmetrically. Test corpus is **synthetic** rather than redistributing JDownloader-era proprietary fixtures (rationale recorded in the ADR — historic captures point to dead hosters and many reference copyrighted material; future releases can drop real captures into a guarded `tests/fixtures/real-world/` if needed). `cargo clippy --all-targets -- -D warnings` clean (collapsible-if + manual-`is_multiple_of` clippy hits fixed up-front, complex-type alias `EncodeCase` extracted in the integration suite). `cargo fmt --check` clean. Registered in `vortex/registry/registry.toml` with `checksum_sha256 = 28ba16ce…12ede6` (wasm) and `checksum_sha256_toml = 6d7e0152…758dd6` (manifest); category `container`, official, `min_vortex_version = "0.1.0"`. Acceptance criteria status (5/5 verified) and per-criterion verification methods recorded in `.claude/output/sprints/prd-v2-roadmap/tasks/41-plugin-containers.md`. Unblocks task 42 (Link Grabber drop-zone container import UI). diff --git a/registry/registry.toml b/registry/registry.toml index 7ec90006..3e563a12 100644 --- a/registry/registry.toml +++ b/registry/registry.toml @@ -112,11 +112,11 @@ min_vortex_version = "0.1.0" name = "vortex-mod-1fichier" description = "1fichier hoster — free (60s wait + captcha stub) and premium (API key)" author = "vortex-community" -version = "1.0.0" +version = "1.1.0" category = "hoster" repository = "https://github.com/mpiton/vortex-mod-1fichier" -checksum_sha256 = "02035a49da81566cf7e4ff64cd0b5ed858e05dfdca643dbd14e53b359d93232c" -checksum_sha256_toml = "c16cb7d418ee6d261114b0ed66701629f7aa7b92317dc97fb7593ede9487c091" +checksum_sha256 = "bd2a19c45fd3681cb788ee43bdaf287028630a68c30bf8159692bd4bea087c23" +checksum_sha256_toml = "bcf3ba595c323d2d0a4ea680e4c6354285153020e07d1efe05c856d5e281dd6d" official = true min_vortex_version = "0.1.0" diff --git a/src-tauri/src/adapters/driven/network/download_engine.rs b/src-tauri/src/adapters/driven/network/download_engine.rs index ac65e4d3..47e25d46 100644 --- a/src-tauri/src/adapters/driven/network/download_engine.rs +++ b/src-tauri/src/adapters/driven/network/download_engine.rs @@ -11,13 +11,16 @@ use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::download::{Download, DownloadId}; use crate::domain::model::meta::{DownloadMeta, SegmentMeta}; -use crate::domain::ports::driven::{DownloadEngine, EventBus, FileStorage}; +use crate::domain::ports::driven::{ + DownloadEngine, DownloadSourceResolver, EventBus, FileStorage, ResolutionCancellation, +}; -use super::format_error_chain; use super::segment_worker::{SegmentError, SegmentParams, download_segment}; +use super::{format_error_chain, restricted_download_client}; struct ActiveDownload { cancel_token: CancellationToken, + resolution_cancellation: ResolutionCancellation, pause_sender: watch::Sender, } @@ -171,6 +174,7 @@ pub struct SegmentedDownloadEngine { dynamic_split_enabled: Arc, dynamic_split_min_remaining_bytes: Arc, active_downloads: Arc>>, + source_resolver: Option>, } impl SegmentedDownloadEngine { @@ -189,6 +193,7 @@ impl SegmentedDownloadEngine { dynamic_split_enabled: Arc::new(AtomicBool::new(true)), dynamic_split_min_remaining_bytes: Arc::new(AtomicU64::new(4 * 1024 * 1024)), active_downloads: Arc::new(Mutex::new(HashMap::new())), + source_resolver: None, } } @@ -197,6 +202,11 @@ impl SegmentedDownloadEngine { self } + pub fn with_source_resolver(mut self, resolver: Arc) -> Self { + self.source_resolver = Some(resolver); + 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. @@ -235,18 +245,13 @@ impl SegmentedDownloadEngine { Ok(response) if response.status().is_success() => response, Ok(response) => { tracing::warn!( - url, status = %response.status(), "HEAD probe returned non-success status, falling back to GET metadata probe" ); client.get(url).send().await? } - Err(err) => { - tracing::warn!( - url, - error = %format_error_chain(&err), - "HEAD probe failed, falling back to GET metadata probe" - ); + Err(_) => { + tracing::warn!("HEAD probe failed, falling back to GET metadata probe"); client.get(url).send().await? } }; @@ -281,6 +286,100 @@ enum AttemptOutcome { 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", + } + .to_string() +} + impl DownloadEngine for SegmentedDownloadEngine { fn start(&self, download: &Download) -> Result<(), DomainError> { let download_id = download.id(); @@ -293,23 +392,8 @@ impl DownloadEngine for SegmentedDownloadEngine { download.segments_count() }; - // Snapshot the candidate URLs ahead of `tokio::spawn` so the - // failover loop owns them. An empty mirror list collapses to the - // canonical URL — single-source downloads keep their pre-mirror - // behaviour and never observe `MirrorSwitched`. - let mirror_urls: Vec = if download.mirrors().is_empty() { - vec![download.url().as_str().to_string()] - } else { - download - .mirrors() - .iter() - .map(|m| m.url().as_str().to_string()) - .collect() - }; - let initial_mirror_idx = - (download.current_mirror_index() as usize).min(mirror_urls.len().saturating_sub(1)); - let cancel_token = CancellationToken::new(); + let resolution_cancellation = ResolutionCancellation::default(); let (pause_tx, pause_rx) = watch::channel(false); { @@ -327,6 +411,7 @@ impl DownloadEngine for SegmentedDownloadEngine { download_id, ActiveDownload { cancel_token: cancel_token.clone(), + resolution_cancellation: resolution_cancellation.clone(), pause_sender: pause_tx, }, ); @@ -339,9 +424,50 @@ impl DownloadEngine for SegmentedDownloadEngine { let min_segment_bytes = self.min_segment_bytes; 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 download = download.clone(); tokio::spawn(async move { - let mut mirror_idx = initial_mirror_idx; + let prepared = match prepare_sources( + download, + source_resolver, + client, + cancel_token.clone(), + resolution_cancellation, + ) + .await + { + Ok(prepared) => prepared, + Err(error) => { + let event = if cancel_token.is_cancelled() { + DomainEvent::DownloadCancelled { id: download_id } + } else { + DomainEvent::DownloadFailed { + id: download_id, + error: source_failure_message(&error), + } + }; + event_bus.publish(event); + active_downloads + .lock() + .expect("active_downloads lock poisoned") + .remove(&download_id); + return; + } + }; + let client = prepared.client; + let mirror_urls = prepared.urls; + let resume_url = prepared.resume_url; + let sensitive_url = prepared.sensitive; + if cancel_token.is_cancelled() { + event_bus.publish(DomainEvent::DownloadCancelled { id: download_id }); + active_downloads + .lock() + .expect("active_downloads lock poisoned") + .remove(&download_id); + return; + } + let mut mirror_idx = prepared.initial_index; loop { let url = mirror_urls[mirror_idx].clone(); // Each attempt gets a fresh attempt-scoped child token so @@ -364,6 +490,8 @@ impl DownloadEngine for SegmentedDownloadEngine { min_segment_bytes, 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, }) .await; @@ -392,13 +520,21 @@ impl DownloadEngine for SegmentedDownloadEngine { break; } mirror_idx = next; - tracing::info!( - download_id = download_id.0, - new_mirror_index = mirror_idx, - new_url = %mirror_urls[mirror_idx], - previous_error = %err, - "switching to next mirror after failure" - ); + if sensitive_url { + tracing::info!( + download_id = download_id.0, + new_mirror_index = mirror_idx, + "switching sensitive 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, + "switching to next mirror after failure" + ); + } // Wipe the previous mirror's partial file + meta so // the next attempt starts clean. The pre-allocation // step uses `create_new(true)` and would otherwise @@ -438,7 +574,11 @@ impl DownloadEngine for SegmentedDownloadEngine { event_bus.publish(DomainEvent::MirrorSwitched { id: download_id, new_mirror_index: mirror_idx as u32, - new_url: mirror_urls[mirror_idx].clone(), + new_url: if sensitive_url { + resume_url.clone() + } else { + mirror_urls[mirror_idx].clone() + }, }); continue; } @@ -509,6 +649,7 @@ impl DownloadEngine for SegmentedDownloadEngine { let active = map .get(&id) .ok_or_else(|| DomainError::NotFound(format!("download {}", id.0)))?; + active.resolution_cancellation.cancel(); active.cancel_token.cancel(); Ok(()) } @@ -531,6 +672,8 @@ struct MirrorAttemptParams { min_segment_bytes: u64, dynamic_split_enabled: Arc, dynamic_split_min_remaining_bytes: Arc, + resume_url: String, + sensitive_url: bool, } async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { @@ -548,24 +691,31 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { min_segment_bytes, dynamic_split_enabled, dynamic_split_min_remaining_bytes, + resume_url, + sensitive_url, } = params; let (total_size, supports_range) = match SegmentedDownloadEngine::probe_remote_metadata(&client, &url).await { Ok(metadata) => metadata, Err(e) => { - tracing::warn!( - download_id = download_id.0, - url = %url, - error = %format_error_chain(&e), - "metadata probe failed (mirror attempt)" - ); + 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(format!( - "metadata probe failed: {}", - format_error_chain(&e) - )); + return AttemptOutcome::Failed(if sensitive_url { + "metadata probe failed".into() + } else { + format!("metadata probe failed: {}", format_error_chain(&e)) + }); } }; @@ -676,6 +826,7 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { cancel_token: attempt_token.clone(), shared_downloaded: shared_downloaded.clone(), segment_progress: progress, + sensitive_url, }; let slot_idx = index; join_set.spawn(async move { (slot_idx, download_segment(params).await) }); @@ -739,6 +890,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, }; join_set.spawn(async move { (new_slot_idx, download_segment(params).await) }); active_segments.push(SegmentRuntimeState { @@ -754,7 +906,7 @@ async fn run_mirror_attempt(params: MirrorAttemptParams) -> AttemptOutcome { &file_storage, &dest_path, download_id, - &url, + &resume_url, total_size, &active_segments, ) @@ -806,15 +958,19 @@ mod tests { use super::*; use std::path::Path; - use std::sync::Mutex; + 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::{EventBus, FileStorage}; + use crate::domain::ports::driven::{ + DownloadSourceResolver, EventBus, FileStorage, ResolvedDownloadSource, + }; // --- Mock types --- @@ -918,6 +1074,131 @@ mod tests { SegmentedDownloadEngine::new(reqwest::Client::new(), storage, bus, 4) } + struct LoopbackSourceResolver { + calls: AtomicUsize, + } + + 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::sensitive( + "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::sensitive( + "https://127.0.0.1/secret-token".into(), + )) + } + } + + #[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] diff --git a/src-tauri/src/adapters/driven/network/mod.rs b/src-tauri/src/adapters/driven/network/mod.rs index 11782275..ef99f002 100644 --- a/src-tauri/src/adapters/driven/network/mod.rs +++ b/src-tauri/src/adapters/driven/network/mod.rs @@ -1,12 +1,15 @@ mod checksum; mod download_engine; +mod nat64; mod reqwest_client; +mod safe_url; mod segment_worker; mod wait_manager; pub use checksum::StreamingChecksumComputer; pub use download_engine::SegmentedDownloadEngine; pub use reqwest_client::ReqwestHttpClient; +pub(crate) use safe_url::{restricted_download_client, validate_public_url}; pub use wait_manager::WaitManager; pub(super) fn format_error_chain(err: &(dyn std::error::Error + 'static)) -> String { diff --git a/src-tauri/src/adapters/driven/network/nat64.rs b/src-tauri/src/adapters/driven/network/nat64.rs new file mode 100644 index 00000000..b9c385e9 --- /dev/null +++ b/src-tauri/src/adapters/driven/network/nat64.rs @@ -0,0 +1,115 @@ +use std::net::{Ipv4Addr, Ipv6Addr, ToSocketAddrs}; +use std::sync::OnceLock; + +const PREFIX_LENGTHS: [u8; 6] = [32, 40, 48, 56, 64, 96]; +const WELL_KNOWN_IPV4: [Ipv4Addr; 2] = + [Ipv4Addr::new(192, 0, 0, 170), Ipv4Addr::new(192, 0, 0, 171)]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Nat64Prefix { + network: u128, + length: u8, +} + +impl Nat64Prefix { + pub(super) fn new(address: Ipv6Addr, length: u8) -> Option { + if !PREFIX_LENGTHS.contains(&length) { + return None; + } + Some(Self { + network: u128::from(address) & prefix_mask(length), + length, + }) + } + + pub(super) fn embedded_ipv4(self, address: Ipv6Addr) -> Option { + if u128::from(address) & prefix_mask(self.length) != self.network { + return None; + } + let bytes = address.octets(); + if self.length < 96 && bytes[8] != 0 { + return None; + } + let octets = match self.length { + 32 => [bytes[4], bytes[5], bytes[6], bytes[7]], + 40 => [bytes[5], bytes[6], bytes[7], bytes[9]], + 48 => [bytes[6], bytes[7], bytes[9], bytes[10]], + 56 => [bytes[7], bytes[9], bytes[10], bytes[11]], + 64 => [bytes[9], bytes[10], bytes[11], bytes[12]], + 96 => [bytes[12], bytes[13], bytes[14], bytes[15]], + _ => return None, + }; + Some(Ipv4Addr::from(octets)) + } +} + +pub(super) fn discovered_prefixes() -> &'static [Nat64Prefix] { + static PREFIXES: OnceLock> = OnceLock::new(); + PREFIXES.get_or_init(|| { + let known = Ipv6Addr::new(0x64, 0xff9b, 0, 0, 0, 0, 0, 0); + let mut prefixes = Nat64Prefix::new(known, 96).into_iter().collect(); + if let Ok(addresses) = ("ipv4only.arpa", 80).to_socket_addrs() { + for address in addresses.filter_map(|address| match address.ip() { + std::net::IpAddr::V6(ip) => Some(ip), + std::net::IpAddr::V4(_) => None, + }) { + discover_from_address(address, &mut prefixes); + } + } + prefixes + }) +} + +fn discover_from_address(address: Ipv6Addr, prefixes: &mut Vec) { + for length in PREFIX_LENGTHS { + let Some(prefix) = Nat64Prefix::new(address, length) else { + continue; + }; + if prefix + .embedded_ipv4(address) + .is_some_and(|ipv4| WELL_KNOWN_IPV4.contains(&ipv4)) + && !prefixes.contains(&prefix) + { + prefixes.push(prefix); + } + } +} + +fn prefix_mask(length: u8) -> u128 { + u128::MAX << (128 - length) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_all_rfc_6052_layouts() { + for (address, length) in [ + ("2001:db8:c000:221::", 32), + ("2001:db8:1c0:2:21::", 40), + ("2001:db8:122:c000:2:2100::", 48), + ("2001:db8:122:3c0:0:221::", 56), + ("2001:db8:122:344:c0:2:2100::", 64), + ("2001:db8:122:344::c000:221", 96), + ] { + let address = address.parse().unwrap(); + let prefix = Nat64Prefix::new(address, length).unwrap(); + assert_eq!( + prefix.embedded_ipv4(address), + Some(Ipv4Addr::new(192, 0, 2, 33)) + ); + } + } + + #[test] + fn accepts_96_prefixes_with_nonzero_fifth_segment() { + let address: Ipv6Addr = "2606:4700:64:1:ab00:cd00:c0a8:1".parse().unwrap(); + let prefix = Nat64Prefix::new(address, 96).expect("valid /96 prefix"); + + assert_eq!( + prefix.embedded_ipv4(address), + Some(Ipv4Addr::new(192, 168, 0, 1)) + ); + } +} diff --git a/src-tauri/src/adapters/driven/network/safe_url.rs b/src-tauri/src/adapters/driven/network/safe_url.rs new file mode 100644 index 00000000..e1f74136 --- /dev/null +++ b/src-tauri/src/adapters/driven/network/safe_url.rs @@ -0,0 +1,131 @@ +//! Network policy for URLs supplied by plugins rather than users. + +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::time::Duration; + +use crate::domain::error::DomainError; + +use super::nat64::{Nat64Prefix, discovered_prefixes}; + +pub(crate) fn validate_public_url( + url: &reqwest::Url, +) -> Result>, DomainError> { + if url.scheme() != "https" { + return Err(DomainError::NetworkError( + "plugin URL must use HTTPS".into(), + )); + } + let host = url + .host_str() + .ok_or_else(|| DomainError::NetworkError("URL has no host".into()))?; + if host == "localhost" || host.ends_with(".localhost") { + return Err(blocked()); + } + if let Ok(ip) = host.parse::() { + return if is_forbidden_ip(&ip) { + Err(blocked()) + } else { + Ok(None) + }; + } + let port = url + .port_or_known_default() + .ok_or_else(|| DomainError::NetworkError("URL has no known port".into()))?; + let addresses = (host, port) + .to_socket_addrs() + .map_err(|_| DomainError::NetworkError("host resolution failed".into()))? + .collect::>(); + if addresses.is_empty() || addresses.iter().any(|addr| is_forbidden_ip(&addr.ip())) { + return Err(blocked()); + } + Ok(Some(addresses)) +} + +pub(crate) fn restricted_download_client( + url: &reqwest::Url, +) -> Result { + if !url.username().is_empty() || url.password().is_some() { + return Err(DomainError::NetworkError( + "plugin download URL must be credential-free HTTPS".into(), + )); + } + let addresses = validate_public_url(url)?; + 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)); + if let (Some(host), Some(addresses)) = (url.host_str(), addresses.as_deref()) { + builder = builder.resolve_to_addrs(host, addresses); + } + builder + .build() + .map_err(|_| DomainError::NetworkError("restricted HTTP client creation failed".into())) +} + +fn blocked() -> DomainError { + DomainError::NetworkError("plugin URL targets a non-public network".into()) +} + +pub(crate) fn is_forbidden_ip(ip: &IpAddr) -> bool { + let normalized = match ip { + IpAddr::V6(ip) if ip.to_ipv4_mapped().is_some() => IpAddr::V4( + ip.to_ipv4_mapped() + .unwrap_or(std::net::Ipv4Addr::UNSPECIFIED), + ), + other => *other, + }; + match normalized { + IpAddr::V4(ip) => { + let [a, b, c, d] = ip.octets(); + a == 0 + || a == 10 + || a == 127 + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 192 && b == 168) + || (a == 198 && (b == 18 || b == 19)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || a >= 224 + || (a == 255 && b == 255 && c == 255 && d == 255) + } + IpAddr::V6(ip) => is_forbidden_ipv6(&ip, discovered_prefixes()), + } +} + +fn is_forbidden_ipv6(ip: &std::net::Ipv6Addr, nat64: &[Nat64Prefix]) -> bool { + let segments = ip.segments(); + let first = segments[0]; + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80 + || (first & 0xffc0) == 0xfec0 + || matches!(segments, [0x0064, 0xff9b, 0x0001, ..]) + || matches!(segments, [0x0100, 0, 0, 0, ..]) + || is_orchid(&segments) + || matches!(segments, [0x2001, 0x0002, 0, ..]) + || matches!(segments, [0x2001, 0x0db8, ..]) + || matches!(segments, [0x2002, ..]) + || matches!(segments, [0x3ff0..=0x3fff, ..]) + || matches!(segments, [0x5f00, ..]) + || nat64.iter().any(|prefix| { + prefix + .embedded_ipv4(*ip) + .is_some_and(|ipv4| is_forbidden_ip(&IpAddr::V4(ipv4))) + }) +} + +fn is_orchid(segments: &[u16; 8]) -> bool { + segments[0] == 0x2001 && matches!(segments[1] & 0xfff0, 0x0010 | 0x0020 | 0x0030) +} + +#[cfg(test)] +#[path = "safe_url_tests.rs"] +mod tests; diff --git a/src-tauri/src/adapters/driven/network/safe_url_tests.rs b/src-tauri/src/adapters/driven/network/safe_url_tests.rs new file mode 100644 index 00000000..cafe37af --- /dev/null +++ b/src-tauri/src/adapters/driven/network/safe_url_tests.rs @@ -0,0 +1,46 @@ +use super::*; + +#[test] +fn rejects_loopback_private_link_local_and_mapped_addresses() { + for raw in [ + "127.0.0.1", + "10.0.0.1", + "169.254.169.254", + "::ffff:127.0.0.1", + "fec0::1", + "64:ff9b::c0a8:1", + "64:ff9b:1::c0a8:1", + "2001:10::1", + "2001:20::1", + ] { + assert!(is_forbidden_ip(&raw.parse().unwrap()), "{raw}"); + } +} + +#[test] +fn rejects_private_ipv4_embedded_in_discovered_operator_nat64_prefix() { + let prefix = Nat64Prefix::new("2606:4700:64::".parse().unwrap(), 96).unwrap(); + let target = "2606:4700:64::c0a8:1".parse().unwrap(); + + assert!(is_forbidden_ipv6(&target, &[prefix])); +} + +#[test] +fn accepts_globally_routable_ipv6_address() { + assert!(!is_forbidden_ip(&"2606:4700:4700::1111".parse().unwrap())); +} + +#[test] +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()); +} + +#[test] +fn plugin_http_validator_rejects_cleartext_urls() { + let url = reqwest::Url::parse("http://1.1.1.1/account").unwrap(); + + assert!(validate_public_url(&url).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 77a66c21..d58f9879 100644 --- a/src-tauri/src/adapters/driven/network/segment_worker.rs +++ b/src-tauri/src/adapters/driven/network/segment_worker.rs @@ -53,6 +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, } /// Downloads a single byte range and writes it to disk. @@ -76,6 +78,7 @@ pub(crate) async fn download_segment(params: SegmentParams) -> Result Result Result, +} + +impl PluginAccountValidator { + pub fn new(loader: Arc) -> Self { + Self { loader } + } +} + +impl AccountValidator for PluginAccountValidator { + fn validate( + &self, + service_name: &str, + username: &str, + password: &str, + ) -> Result { + let started_at = Instant::now(); + let credential = Credential::new(username, password); + let mut outcome = self.loader.validate_account(service_name, &credential)?; + if outcome.latency_ms.is_none() { + outcome.latency_ms = + Some(u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX)); + } + Ok(outcome) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use crate::domain::error::DomainError; + use crate::domain::model::account::AccountStatus; + use crate::domain::model::credential::Credential; + use crate::domain::model::plugin::{PluginInfo, PluginManifest}; + use crate::domain::ports::driven::{AccountValidator, PluginLoader, ValidationOutcome}; + + use super::PluginAccountValidator; + + struct CapturingLoader { + captured: Mutex>, + outcome: ValidationOutcome, + } + + impl PluginLoader for CapturingLoader { + 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::new()) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + + fn validate_account( + &self, + service_name: &str, + credential: &Credential, + ) -> Result { + *self.captured.lock().expect("capture mutex") = + Some((service_name.to_string(), credential.clone())); + Ok(self.outcome.clone()) + } + } + + #[test] + fn validate_scopes_exact_credentials_and_records_host_latency() { + let loader = Arc::new(CapturingLoader { + captured: Mutex::new(None), + outcome: ValidationOutcome::ok(), + }); + let validator = PluginAccountValidator::new(loader.clone()); + + let outcome = validator + .validate("vortex-mod-1fichier", "alice", "api-key") + .expect("validation succeeds"); + + assert_eq!(outcome.status, AccountStatus::Valid); + assert!(outcome.latency_ms.is_some()); + let captured = loader.captured.lock().expect("capture mutex"); + let (service, credential) = captured.as_ref().expect("credential captured"); + assert_eq!(service, "vortex-mod-1fichier"); + assert_eq!(credential.username(), "alice"); + assert_eq!(credential.password(), "api-key"); + } + + #[test] + fn validate_preserves_plugin_supplied_latency_and_typed_status() { + let loader = Arc::new(CapturingLoader { + captured: Mutex::new(None), + outcome: ValidationOutcome { + latency_ms: Some(42), + ..ValidationOutcome::rejected( + AccountStatus::InvalidCredentials, + "credential rejected", + ) + }, + }); + let validator = PluginAccountValidator::new(loader); + + let outcome = validator + .validate("vortex-mod-1fichier", "alice", "bad-key") + .expect("typed rejection is an outcome"); + + assert!(!outcome.is_valid()); + assert_eq!(outcome.status, AccountStatus::InvalidCredentials); + assert_eq!(outcome.latency_ms, Some(42)); + } +} diff --git a/src-tauri/src/adapters/driven/plugin/capabilities.rs b/src-tauri/src/adapters/driven/plugin/capabilities.rs index 289a488e..e039ce14 100644 --- a/src-tauri/src/adapters/driven/plugin/capabilities.rs +++ b/src-tauri/src/adapters/driven/plugin/capabilities.rs @@ -1,14 +1,40 @@ //! Capability-based host function registration for WASM plugins. use std::net::SocketAddr; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use dashmap::DashMap; +use crate::domain::model::credential::Credential; use crate::domain::model::plugin::PluginManifest; use crate::domain::ports::driven::CredentialStore; +#[derive(Default)] +pub struct CredentialSlotState { + credential: Mutex>, + exposed: AtomicBool, +} + +impl CredentialSlotState { + pub(crate) fn lock( + &self, + ) -> std::sync::LockResult>> { + self.credential.lock() + } + + pub(crate) fn mark_exposed(&self) { + self.exposed.store(true, Ordering::SeqCst); + } + + pub(crate) fn has_been_exposed(&self) -> bool { + self.exposed.load(Ordering::SeqCst) + } +} + +pub(crate) type CredentialSlot = Arc; + /// Shared resources across all plugins (singleton). pub struct SharedHostResources { pub(crate) http_client: reqwest::blocking::Client, @@ -28,6 +54,7 @@ impl SharedHostResources { // gives the remote side a way to identify traffic from Vortex // rather than generic scripted clients. reqwest::blocking::Client::builder() + .no_proxy() .user_agent("Vortex/0.1") .redirect(reqwest::redirect::Policy::none()) .timeout(timeout) @@ -92,6 +119,7 @@ pub struct PluginHostContext { pub(crate) plugin_name: String, pub(crate) capabilities: Vec, pub(crate) shared: Arc, + pub(crate) credential_slot: CredentialSlot, } /// Host-owned privileges established outside the plugin manifest. @@ -116,10 +144,31 @@ pub fn build_host_functions( build_host_functions_with_grants(manifest, shared, HostFunctionGrants::default()) } +#[cfg(test)] pub(super) fn build_host_functions_with_grants( manifest: &PluginManifest, shared: &Arc, grants: HostFunctionGrants, +) -> Vec { + build_host_functions_for_instance(manifest, shared, grants).0 +} + +pub(super) fn build_host_functions_for_instance( + manifest: &PluginManifest, + shared: &Arc, + grants: HostFunctionGrants, +) -> (Vec, CredentialSlot) { + let credential_slot = Arc::new(CredentialSlotState::default()); + let functions = + build_host_functions_with_slot(manifest, shared, grants, Arc::clone(&credential_slot)); + (functions, credential_slot) +} + +fn build_host_functions_with_slot( + manifest: &PluginManifest, + shared: &Arc, + grants: HostFunctionGrants, + credential_slot: CredentialSlot, ) -> Vec { let name = manifest.info().name().to_string(); @@ -163,6 +212,7 @@ pub(super) fn build_host_functions_with_grants( plugin_name: name, capabilities: manifest.capabilities().to_vec(), shared: Arc::clone(shared), + credential_slot, }; let user_data = extism::UserData::new(ctx); @@ -427,6 +477,20 @@ mod tests { assert!(missing.is_none()); } + #[test] + fn test_scoped_credential_slots_are_isolated_by_plugin_instance() { + let shared = Arc::new(SharedHostResources::new()); + let manifest = make_named_manifest_with_caps("vortex-mod-1fichier", vec![]); + let (_, first) = + build_host_functions_for_instance(&manifest, &shared, HostFunctionGrants::default()); + let (_, second) = + build_host_functions_for_instance(&manifest, &shared, HostFunctionGrants::default()); + + assert!(first.lock().unwrap().is_none()); + assert!(second.lock().unwrap().is_none()); + assert!(!Arc::ptr_eq(&first, &second)); + } + #[test] fn test_subprocess_denied_unauthorized_binary() { // Verify capability check logic: only declared binaries are allowed diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs index a59b1850..b913d7f2 100644 --- a/src-tauri/src/adapters/driven/plugin/extism_loader.rs +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -6,13 +6,16 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use crate::domain::error::DomainError; +use crate::domain::model::account::AccountStatus; +use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; -use crate::domain::ports::driven::PluginLoader; use crate::domain::ports::driven::plugin_loader::DownloadedFileInfo; use crate::domain::ports::driven::plugin_store_client::OfficialPluginProvenance; +use crate::domain::ports::driven::{ExtractedHosterLink, PluginLoader, ValidationOutcome}; use super::builtin::HttpModule; -use super::capabilities::{SharedHostResources, build_host_functions_with_grants}; +use super::capabilities::{SharedHostResources, build_host_functions_for_instance}; +use super::hoster_contract::parse_hoster_link; use super::manifest::{ find_wasm_file, parse_manifest, parse_manifest_metadata, parse_manifest_metadata_bytes, }; @@ -362,14 +365,15 @@ impl PluginLoader for ExtismPluginLoader { &manifest_bytes, ); let extism_manifest = extism::Manifest::new([extism::Wasm::data(wasm_bytes)]); - let host_functions = - build_host_functions_with_grants(&disk_manifest, &self.shared_resources, grants); + let (host_functions, credential_slot) = + build_host_functions_for_instance(&disk_manifest, &self.shared_resources, grants); let plugin = extism::Plugin::new(&extism_manifest, host_functions, true) .map_err(|e| DomainError::PluginError(format!("failed to load plugin: {e}")))?; let loaded = LoadedPlugin { manifest: disk_manifest, plugin: std::sync::Arc::new(std::sync::Mutex::new(plugin)), + credential_slot, enabled: true, }; @@ -487,6 +491,75 @@ impl PluginLoader for ExtismPluginLoader { self.call_url_plugin_function(url, "extract_links") } + fn extract_hoster_link( + &self, + service_name: &str, + url: &str, + credential: Option<&Credential>, + ) -> Result { + let info = self + .registry + .list_info() + .into_iter() + .find(|info| info.name() == service_name) + .ok_or_else(|| DomainError::NotFound(service_name.to_string()))?; + if !info.is_enabled() { + return Err(DomainError::NotFound(format!( + "plugin '{service_name}' is disabled" + ))); + } + if !self + .registry + .function_exists(service_name, "extract_links")? + { + return Err(DomainError::NotFound(format!( + "plugin '{service_name}' does not export 'extract_links'" + ))); + } + let output = match credential { + 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()))?, + None => self + .registry + .call_plugin(service_name, "extract_links", url)?, + }; + parse_hoster_link(&output) + } + + fn validate_account( + &self, + service_name: &str, + credential: &Credential, + ) -> Result { + let info = self + .registry + .list_info() + .into_iter() + .find(|info| info.name() == service_name) + .ok_or_else(|| DomainError::NotFound(service_name.to_string()))?; + if !info.is_enabled() { + return Err(DomainError::NotFound(format!( + "plugin '{service_name}' is disabled" + ))); + } + if !self + .registry + .function_exists(service_name, "validate_account")? + { + return Err(DomainError::NotFound(format!( + "plugin '{service_name}' does not export 'validate_account'" + ))); + } + + let output = self + .registry + .call_plugin_with_credential(service_name, "validate_account", "", credential.clone()) + .map_err(|error| classify_account_plugin_error(&error.to_string()))?; + parse_validation_outcome(&output) + } + fn get_media_variants(&self, url: &str) -> Result { self.call_url_plugin_function(url, "get_media_variants") } @@ -686,6 +759,79 @@ fn is_adaptive_stream_error(msg: &str) -> bool { msg.contains("adaptive stream (HLS/DASH)") } +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") { + DomainError::AccountInvalidCredentials + } else if has_code("ACCOUNT_EXPIRED") { + DomainError::AccountExpired + } else if has_code("ACCOUNT_COOLDOWN") { + DomainError::AccountCooldown + } else if has_code("ACCOUNT_QUOTA_EXCEEDED") { + DomainError::AccountQuotaExceeded + } else { + DomainError::PluginError("plugin account operation failed".into()) + } +} + +#[derive(serde::Deserialize)] +struct PluginValidationOutcome { + valid: bool, + #[serde(default)] + status: Option, + #[serde(default, alias = "latencyMs")] + latency_ms: Option, + #[serde(default, alias = "trafficLeft")] + traffic_left: Option, + #[serde(default, alias = "trafficTotal")] + traffic_total: Option, + #[serde(default, alias = "validUntil")] + valid_until: Option, +} + +fn parse_validation_outcome(output: &str) -> Result { + use std::str::FromStr; + + let parsed: PluginValidationOutcome = serde_json::from_str(output).map_err(|_| { + DomainError::PluginError("plugin account validation returned invalid JSON".into()) + })?; + let status = match parsed.status { + Some(status) => AccountStatus::from_str(&status).map_err(|_| { + DomainError::PluginError("plugin account validation returned unknown status".into()) + })?, + None if parsed.valid => AccountStatus::Valid, + None => AccountStatus::Error, + }; + if parsed.valid != (status == AccountStatus::Valid) { + return Err(DomainError::PluginError( + "plugin account validation returned contradictory state".into(), + )); + } + let error_message = match status { + AccountStatus::Valid => None, + AccountStatus::InvalidCredentials => Some("Account credentials were rejected".into()), + AccountStatus::MissingCredential => Some("Account credential is missing".into()), + AccountStatus::Expired => Some("Account is expired".into()), + AccountStatus::QuotaExhausted => Some("Account quota is exhausted".into()), + AccountStatus::Cooldown => Some("Account is temporarily rate-limited".into()), + AccountStatus::Unverified | AccountStatus::Error => { + Some("Account validation failed".into()) + } + }; + Ok(ValidationOutcome { + status, + latency_ms: parsed.latency_ms, + traffic_left: parsed.traffic_left, + traffic_total: parsed.traffic_total, + valid_until: parsed.valid_until, + error_message, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -704,6 +850,77 @@ mod tests { PluginManifest::new(info) } + #[test] + fn account_plugin_errors_map_to_typed_domain_errors() { + assert_eq!( + classify_account_plugin_error( + "ACCOUNT_INVALID_CREDENTIALS: configured key was rejected" + ), + DomainError::AccountInvalidCredentials + ); + assert_eq!( + classify_account_plugin_error("ACCOUNT_EXPIRED: subscription ended"), + DomainError::AccountExpired + ); + assert_eq!( + classify_account_plugin_error("ACCOUNT_COOLDOWN: flood detected"), + DomainError::AccountCooldown + ); + } + + #[test] + fn unknown_account_plugin_error_stays_a_plugin_error() { + let error = classify_account_plugin_error("PLUGIN_ERROR: malformed response"); + assert_eq!( + error, + DomainError::PluginError("plugin account operation failed".into()) + ); + } + + #[test] + fn unknown_account_plugin_error_does_not_expose_plugin_diagnostics() { + let error = classify_account_plugin_error( + "PLUGIN_ERROR: upstream echoed Authorization: Bearer super-secret-key", + ); + assert_eq!( + error, + DomainError::PluginError("plugin account operation failed".into()) + ); + assert!(!error.to_string().contains("super-secret-key")); + } + + #[test] + fn validation_response_defaults_success_to_valid_status() { + let outcome = parse_validation_outcome(r#"{"valid":true}"#).expect("valid outcome"); + assert!(outcome.is_valid()); + assert_eq!(outcome.status, AccountStatus::Valid); + } + + #[test] + fn validation_response_accepts_typed_metrics() { + let outcome = parse_validation_outcome( + r#"{"valid":false,"status":"quota_exhausted","trafficLeft":0,"trafficTotal":100,"errorMessage":"quota used"}"#, + ) + .expect("typed outcome"); + assert_eq!(outcome.status, AccountStatus::QuotaExhausted); + assert_eq!(outcome.traffic_left, Some(0)); + assert_eq!(outcome.traffic_total, Some(100)); + } + + #[test] + fn validation_response_rejects_invalid_flag_with_valid_status() { + let error = parse_validation_outcome(r#"{"valid":false,"status":"valid"}"#) + .expect_err("contradictory validation response must fail closed"); + assert!(matches!(error, DomainError::PluginError(_))); + } + + #[test] + fn validation_response_rejects_valid_flag_with_non_valid_status() { + let error = parse_validation_outcome(r#"{"valid":true,"status":"expired"}"#) + .expect_err("contradictory validation response must fail closed"); + assert!(matches!(error, DomainError::PluginError(_))); + } + fn setup_plugin_dir(plugins_dir: &Path, name: &str) { let plugin_dir = plugins_dir.join(name); std::fs::create_dir_all(&plugin_dir).unwrap(); diff --git a/src-tauri/src/adapters/driven/plugin/host_functions.rs b/src-tauri/src/adapters/driven/plugin/host_functions.rs index e68eea95..80e453a7 100644 --- a/src-tauri/src/adapters/driven/plugin/host_functions.rs +++ b/src-tauri/src/adapters/driven/plugin/host_functions.rs @@ -2,14 +2,15 @@ use std::collections::HashMap; use std::io::Read; -use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::sync::Arc; use serde::{Deserialize, Serialize}; -use super::capabilities::PluginHostContext; +use super::capabilities::{CredentialSlot, PluginHostContext}; use super::ytdlp_broker::{ LegacySubprocessRequest, PluginYtDlpRequest, run_legacy_request, run_plugin_request, }; +use crate::adapters::driven::network::validate_public_url; // ── JSON types ──────────────────────────────────────────────────────────────── @@ -67,107 +68,15 @@ fn write_output_string( plugin.memory_set_val(&mut outputs[0], value.as_bytes()) } -/// Reject URLs targeting internal/loopback networks (SSRF protection). -fn validate_url_not_internal(url: &reqwest::Url) -> Result>, extism::Error> { - if let Some(host) = url.host_str() { - // Reject localhost variants - if host == "localhost" || host.ends_with(".localhost") { - return Err(anyhow::anyhow!( - "http_request: requests to localhost are forbidden" - )); - } - - if let Ok(ip) = host.parse::() { - if is_forbidden_ip(&ip) { - return Err(anyhow::anyhow!( - "http_request: requests to internal networks are forbidden" - )); - } - return Ok(None); - } - - let port = url - .port_or_known_default() - .ok_or_else(|| anyhow::anyhow!("http_request: URL is missing a known port"))?; - - let resolved_addrs = (host, port) - .to_socket_addrs() - .map_err(|e| anyhow::anyhow!("http_request: failed to resolve host '{host}': {e}"))? - .collect::>(); - - if resolved_addrs.is_empty() { - return Err(anyhow::anyhow!( - "http_request: host '{host}' did not resolve to any addresses" - )); - } - - if resolved_addrs - .iter() - .any(|addr| is_forbidden_ip(&addr.ip())) - { - return Err(anyhow::anyhow!( - "http_request: requests to internal networks are forbidden" - )); - } - - return Ok(Some(resolved_addrs)); - } - Ok(None) -} - -fn normalize_ip(ip: &IpAddr) -> IpAddr { - match ip { - IpAddr::V4(v4) => IpAddr::V4(*v4), - IpAddr::V6(v6) => v6 - .to_ipv4_mapped() - .map(IpAddr::V4) - .unwrap_or(IpAddr::V6(*v6)), - } -} - -fn is_forbidden_ip(ip: &IpAddr) -> bool { - let normalized = normalize_ip(ip); - normalized.is_loopback() - || normalized.is_unspecified() - || is_private_ip(&normalized) - || is_link_local(&normalized) -} - -fn is_private_ip(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - let octets = v4.octets(); - octets[0] == 10 - || (octets[0] == 172 && (16..=31).contains(&octets[1])) - || (octets[0] == 192 && octets[1] == 168) - } - IpAddr::V6(v6) => { - if let Some(v4) = v6.to_ipv4_mapped() { - return is_private_ip(&IpAddr::V4(v4)); - } - let segments = v6.segments(); - // fc00::/7 (unique local) - (segments[0] & 0xfe00) == 0xfc00 - } - } -} - -fn is_link_local(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - let octets = v4.octets(); - // 169.254.0.0/16 (includes AWS metadata 169.254.169.254) - octets[0] == 169 && octets[1] == 254 - } - IpAddr::V6(v6) => { - if let Some(v4) = v6.to_ipv4_mapped() { - return is_link_local(&IpAddr::V4(v4)); - } - let segments = v6.segments(); - // fe80::/10 - (segments[0] & 0xffc0) == 0xfe80 - } - } +fn safe_plugin_log_message( + message: &str, + credential_slot: &CredentialSlot, +) -> Result { + Ok(if credential_slot.has_been_exposed() { + "plugin log redacted after credential access".into() + } else { + message.to_string() + }) } fn read_http_body_capped( @@ -206,11 +115,12 @@ pub fn make_log_function(user_data: extism::UserData) -> exti .lock() .map_err(|_| anyhow::anyhow!("log: mutex poisoned"))?; let plugin_name = ctx.plugin_name.as_str(); + let message = safe_plugin_log_message(&req.message, &ctx.credential_slot)?; match req.level.as_str() { - "error" => tracing::error!(plugin = plugin_name, "{}", req.message), - "warn" => tracing::warn!(plugin = plugin_name, "{}", req.message), - "debug" => tracing::debug!(plugin = plugin_name, "{}", req.message), - _ => tracing::info!(plugin = plugin_name, "{}", req.message), + "error" => tracing::error!(plugin = plugin_name, "{}", message), + "warn" => tracing::warn!(plugin = plugin_name, "{}", message), + "debug" => tracing::debug!(plugin = plugin_name, "{}", message), + _ => tracing::info!(plugin = plugin_name, "{}", message), } Ok(()) }, @@ -240,7 +150,8 @@ pub fn make_http_request_function( .map_err(|e| anyhow::anyhow!("http_request: invalid URL '{}': {e}", req.url))?; // F1: SSRF protection — reject internal/loopback destinations - let resolved_addrs = validate_url_not_internal(&url)?; + let resolved_addrs = + validate_public_url(&url).map_err(|e| anyhow::anyhow!("http_request: {e}"))?; // F6: Minimize mutex scope — prepare the client, then release the lock let client = { @@ -427,7 +338,7 @@ pub fn make_get_credential_function( let service = read_input_string(plugin, inputs)?; // F3: Scope credential access — plugins can only read credentials // matching their own name to prevent cross-plugin credential theft. - let (store, plugin_name) = { + let (slot, store, plugin_name) = { let guard = ud.get()?; let ctx = guard .lock() @@ -440,16 +351,28 @@ pub fn make_get_credential_function( )); } - let store = ctx.shared.credential_store().cloned().ok_or_else(|| { - anyhow::anyhow!("get_credential: no credential store configured") - })?; - (store, ctx.plugin_name.clone()) + ( + Arc::clone(&ctx.credential_slot), + ctx.shared.credential_store().cloned(), + ctx.plugin_name.clone(), + ) }; - let cred = store - .get(&plugin_name) - .map_err(|e| anyhow::anyhow!("get_credential: store error: {e}"))? - .ok_or_else(|| anyhow::anyhow!("get_credential: no credential found"))?; + let scoped = slot + .lock() + .map_err(|_| anyhow::anyhow!("get_credential: credential slot poisoned"))? + .clone(); + let cred = match scoped { + Some(credential) => credential, + None => store + .ok_or_else(|| { + anyhow::anyhow!("get_credential: no credential store configured") + })? + .get(&plugin_name) + .map_err(|e| anyhow::anyhow!("get_credential: store error: {e}"))? + .ok_or_else(|| anyhow::anyhow!("get_credential: no credential found"))?, + }; + slot.mark_exposed(); let resp = CredentialResponse { username: cred.username().to_string(), @@ -544,6 +467,7 @@ pub fn make_legacy_run_subprocess_function( mod tests { use super::*; use crate::adapters::driven::plugin::capabilities::SharedHostResources; + use crate::domain::model::credential::Credential; #[test] fn test_log_request_deserialization() { @@ -607,13 +531,31 @@ mod tests { } #[test] - fn test_ipv4_mapped_ipv6_is_forbidden() { - let loopback = "::ffff:127.0.0.1".parse::().unwrap(); - let private = "::ffff:10.0.0.1".parse::().unwrap(); - let link_local = "::ffff:169.254.169.254".parse::().unwrap(); - - assert!(is_forbidden_ip(&loopback)); - assert!(is_forbidden_ip(&private)); - assert!(is_forbidden_ip(&link_local)); + fn plugin_logs_are_fully_redacted_while_a_credential_is_scoped() { + let slot = Arc::new(super::super::capabilities::CredentialSlotState::default()); + *slot.lock().unwrap() = Some(Credential::new("alice", "super-secret")); + slot.mark_exposed(); + + let message = safe_plugin_log_message( + "Authorization: Bearer super-secret; direct=https://cdn/token", + &slot, + ) + .unwrap(); + + assert_eq!(message, "plugin log redacted after credential access"); + assert!(!message.contains("super-secret")); + assert!(!message.contains("cdn/token")); + } + + #[test] + fn plugin_logs_remain_redacted_after_the_credential_scope_is_cleared() { + let slot = Arc::new(super::super::capabilities::CredentialSlotState::default()); + slot.mark_exposed(); + assert!(slot.lock().unwrap().is_none()); + + let message = safe_plugin_log_message("retained secret", &slot).unwrap(); + + assert_eq!(message, "plugin log redacted after credential access"); + assert!(!message.contains("retained secret")); } } diff --git a/src-tauri/src/adapters/driven/plugin/hoster_contract.rs b/src-tauri/src/adapters/driven/plugin/hoster_contract.rs new file mode 100644 index 00000000..a8d73929 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/hoster_contract.rs @@ -0,0 +1,39 @@ +//! Deserialisation boundary for the generic hoster plugin wire format. + +use serde::Deserialize; + +use crate::domain::error::DomainError; +use crate::domain::ports::driven::ExtractedHosterLink; + +#[derive(Deserialize)] +struct HosterResponse { + files: Vec, +} + +#[derive(Deserialize)] +struct HosterFile { + url: String, + filename: Option, + size_bytes: Option, + direct_url: Option, + traffic_used_bytes: Option, + traffic_total_bytes: Option, +} + +pub(super) fn parse_hoster_link(payload: &str) -> Result { + let response: HosterResponse = serde_json::from_str(payload) + .map_err(|_| DomainError::PluginError("hoster returned an invalid response".into()))?; + let file = 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, + }) +} diff --git a/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs new file mode 100644 index 00000000..2be412d8 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/hoster_contract_tests.rs @@ -0,0 +1,109 @@ +use std::sync::Arc; + +use super::ExtismPluginLoader; +use super::capabilities::SharedHostResources; +use super::hoster_contract::parse_hoster_link; +use crate::domain::model::credential::Credential; +use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; +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}]}"#, + ) + .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.traffic_total_bytes, Some(100)); +} + +#[test] +fn test_parse_hoster_link_rejects_empty_file_list() { + assert!(parse_hoster_link(r#"{"files":[]}"#).is_err()); +} + +#[test] +fn test_extract_hoster_link_calls_exact_named_plugin_without_reresolving_url() { + let temp = tempfile::tempdir().unwrap(); + let name = "hoster-a"; + let plugin_dir = temp.path().join(name); + std::fs::create_dir(&plugin_dir).unwrap(); + std::fs::write( + plugin_dir.join("plugin.toml"), + format!( + "[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\ncategory = \"hoster\"\nauthor = \"tester\"\ndescription = \"test hoster\"\n" + ), + ) + .unwrap(); + let payload = r#"{"files":[{"url":"https://source.example/file","filename":"from-a.zip","size_bytes":1,"direct_url":"https://cdn.example/file","traffic_used_bytes":0,"traffic_total_bytes":10}]}"#; + std::fs::write( + plugin_dir.join(format!("{name}.wasm")), + output_plugin_wat("false", payload), + ) + .unwrap(); + + let loader = ExtismPluginLoader::new( + temp.path().to_path_buf(), + Arc::new(SharedHostResources::new()), + ) + .unwrap(); + let manifest = PluginManifest::new(PluginInfo::new( + name.into(), + "1.0.0".into(), + "test hoster".into(), + "tester".into(), + PluginCategory::Hoster, + )); + loader.load(&manifest).unwrap(); + + let link = loader + .extract_hoster_link( + name, + "https://url-not-claimed-by-a.example/file", + Some(&Credential::new("alice", "secret")), + ) + .expect("the named plugin must be called directly"); + + assert_eq!(link.filename.as_deref(), Some("from-a.zip")); +} + +fn output_plugin_wat(can_handle: &str, payload: &str) -> String { + format!( + r#"(module + (import "extism:host/env" "alloc" (func $alloc (param i64) (result i64))) + (import "extism:host/env" "store_u8" (func $store_u8 (param i64 i32))) + (import "extism:host/env" "output_set" (func $output_set (param i64 i64))) + {} + {} +)"#, + output_function("can_handle", can_handle), + output_function("extract_links", payload), + ) +} + +fn output_function(name: &str, value: &str) -> String { + let stores = value + .bytes() + .enumerate() + .map(|(index, byte)| { + format!( + "(call $store_u8 (i64.add (local.get $output) (i64.const {index})) (i32.const {byte}))" + ) + }) + .collect::>() + .join("\n"); + format!( + r#"(func (export "{name}") (result i32) + (local $output i64) + (local.set $output (call $alloc (i64.const {}))) + {stores} + (call $output_set (local.get $output) (i64.const {})) + (i32.const 0) +)"#, + value.len(), + value.len(), + ) +} diff --git a/src-tauri/src/adapters/driven/plugin/mod.rs b/src-tauri/src/adapters/driven/plugin/mod.rs index dc5d7a02..5fde9504 100644 --- a/src-tauri/src/adapters/driven/plugin/mod.rs +++ b/src-tauri/src/adapters/driven/plugin/mod.rs @@ -1,14 +1,19 @@ +pub mod account_validator; pub mod builtin; pub mod capabilities; pub mod extism_loader; pub mod github_store_client; pub mod host_functions; +mod hoster_contract; +#[cfg(test)] +mod hoster_contract_tests; pub mod manifest; mod provenance; pub mod registry; pub mod watcher; pub(crate) mod ytdlp_broker; +pub use account_validator::PluginAccountValidator; pub use extism_loader::ExtismPluginLoader; pub use github_store_client::GithubStoreClient; pub use registry::PluginRegistry; diff --git a/src-tauri/src/adapters/driven/plugin/registry.rs b/src-tauri/src/adapters/driven/plugin/registry.rs index a5e872c0..c4c71682 100644 --- a/src-tauri/src/adapters/driven/plugin/registry.rs +++ b/src-tauri/src/adapters/driven/plugin/registry.rs @@ -5,12 +5,47 @@ use std::sync::{Arc, Mutex}; use dashmap::DashMap; use crate::domain::error::DomainError; +use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::PluginReadRepository; +use super::capabilities::CredentialSlot; + +struct CredentialScope { + slot: CredentialSlot, +} + +impl CredentialScope { + fn new(slot: CredentialSlot, credential: Credential) -> Result { + { + let mut current = slot.lock().map_err(|_| { + DomainError::PluginError("plugin credential slot mutex poisoned".into()) + })?; + if current.is_some() { + return Err(DomainError::PluginError( + "plugin credential slot already active".into(), + )); + } + *current = Some(credential); + slot.mark_exposed(); + } + Ok(Self { slot }) + } +} + +impl Drop for CredentialScope { + fn drop(&mut self) { + match self.slot.lock() { + Ok(mut current) => *current = None, + Err(poisoned) => *poisoned.into_inner() = None, + } + } +} + pub struct LoadedPlugin { pub manifest: PluginManifest, pub plugin: Arc>, + pub credential_slot: CredentialSlot, pub enabled: bool, } @@ -96,7 +131,17 @@ impl PluginRegistry { } pub fn call_plugin(&self, name: &str, func: &str, input: &str) -> Result { - self.call_plugin_inner(name, func, input) + self.call_plugin_inner(name, func, input, None) + } + + pub fn call_plugin_with_credential( + &self, + name: &str, + func: &str, + input: &str, + credential: Credential, + ) -> Result { + self.call_plugin_inner(name, func, input, Some(credential)) } /// Container plugins decode binary blobs (DLC / CCF / RSDF / Metalink); @@ -107,7 +152,7 @@ impl PluginRegistry { func: &str, input: &[u8], ) -> Result { - self.call_plugin_inner(name, func, input) + self.call_plugin_inner(name, func, input, None) } fn call_plugin_inner<'a, I>( @@ -115,23 +160,50 @@ impl PluginRegistry { name: &str, func: &str, input: I, + scoped_credential: Option, ) -> Result where I: extism::convert::ToBytes<'a>, { - // Clone the Arc> and drop the DashMap shard guard - // before locking — holding the shard across a slow WASM call would - // block every other plugin lookup behind the same shard. let plugin_handle = { let entry = self .plugins .get(name) .ok_or_else(|| DomainError::NotFound(name.to_string()))?; + if !entry.enabled { + return Err(DomainError::NotFound(format!( + "plugin '{name}' is disabled" + ))); + } Arc::clone(&entry.plugin) }; let mut plugin = plugin_handle .lock() .map_err(|_| DomainError::PluginError(format!("plugin '{name}' mutex poisoned")))?; + // Re-check after taking the per-plugin lock. The short registry guard + // makes enablement and credential injection atomic without pinning a + // DashMap shard during the WASM call. + let _credential_scope = { + let entry = self + .plugins + .get(name) + .ok_or_else(|| DomainError::NotFound(name.to_string()))?; + if !Arc::ptr_eq(&entry.plugin, &plugin_handle) { + return Err(DomainError::NotFound(format!( + "plugin '{name}' was reloaded" + ))); + } + if !entry.enabled { + return Err(DomainError::NotFound(format!( + "plugin '{name}' is disabled" + ))); + } + scoped_credential + .map(|credential| { + CredentialScope::new(Arc::clone(&entry.credential_slot), credential) + }) + .transpose()? + }; let fn_exists = plugin.function_exists(func); tracing::debug!(plugin = name, func, fn_exists, "plugin call pre-call"); let result = plugin.call::(func, input).map_err(|e| { @@ -157,6 +229,12 @@ impl PluginReadRepository for PluginRegistry { #[cfg(test)] mod tests { + use std::sync::mpsc; + use std::thread; + use std::time::{Duration, Instant}; + + use dashmap::try_result::TryResult; + use super::*; use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; @@ -183,6 +261,7 @@ mod tests { LoadedPlugin { manifest: make_manifest(name), plugin: Arc::new(Mutex::new(make_extism_plugin())), + credential_slot: Arc::new(super::super::capabilities::CredentialSlotState::default()), enabled: true, } } @@ -219,6 +298,124 @@ mod tests { assert!(registry.contains("present")); } + #[test] + fn test_scoped_credential_is_cleared_when_plugin_call_fails() { + use crate::domain::model::credential::Credential; + + let registry = PluginRegistry::new(); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + let slot = Arc::clone( + ®istry + .plugins + .get("plug-a") + .expect("loaded plugin") + .credential_slot, + ); + + let error = registry + .call_plugin_with_credential( + "plug-a", + "missing", + "", + Credential::new("alice", "secret"), + ) + .expect_err("missing export"); + assert!(matches!(error, DomainError::PluginError(_))); + assert!(slot.lock().unwrap().is_none()); + } + + #[test] + fn disabled_plugin_is_rejected_before_credential_injection() { + let registry = PluginRegistry::new(); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + let slot = Arc::clone( + ®istry + .plugins + .get("plug-a") + .expect("loaded plugin") + .credential_slot, + ); + registry.set_enabled("plug-a", false).unwrap(); + + let error = registry + .call_plugin_with_credential( + "plug-a", + "missing", + "", + Credential::new("alice", "secret"), + ) + .expect_err("disabled plugin"); + + assert!(matches!(error, DomainError::NotFound(message) if message.contains("disabled"))); + assert!(slot.lock().unwrap().is_none()); + assert!(!slot.has_been_exposed()); + } + + #[test] + fn disabling_plugin_does_not_wait_for_blocked_plugin_call() { + let registry = Arc::new(PluginRegistry::new()); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + let plugin_handle = Arc::clone( + ®istry + .plugins + .get("plug-a") + .expect("loaded plugin") + .plugin, + ); + let plugin_guard = plugin_handle.lock().unwrap(); + + let caller_registry = Arc::clone(®istry); + let caller = thread::spawn(move || caller_registry.call_plugin("plug-a", "missing", "")); + + let deadline = Instant::now() + Duration::from_secs(1); + loop { + let registry_guard_held = + matches!(registry.plugins.try_get_mut("plug-a"), TryResult::Locked); + let plugin_handle_cloned = Arc::strong_count(&plugin_handle) >= 3; + if registry_guard_held || plugin_handle_cloned { + break; + } + assert!( + Instant::now() < deadline, + "plugin call did not reach the plugin mutex" + ); + thread::yield_now(); + } + + let (disable_tx, disable_rx) = mpsc::channel(); + let disabler_registry = Arc::clone(®istry); + let disabler = thread::spawn(move || { + disable_tx + .send(disabler_registry.set_enabled("plug-a", false)) + .expect("disable receiver remains connected"); + }); + + let prompt_disable = disable_rx.recv_timeout(Duration::from_secs(1)); + let completed_while_call_blocked = prompt_disable.is_ok(); + drop(plugin_guard); + + let disable_result = match prompt_disable { + Ok(result) => result, + Err(_) => disable_rx + .recv_timeout(Duration::from_secs(1)) + .expect("disable completes after plugin call unblocks"), + }; + disable_result.expect("disable succeeds"); + disabler.join().expect("disable thread does not panic"); + let call_error = caller + .join() + .expect("call thread does not panic") + .expect_err("disabled call is rejected"); + + assert!( + completed_while_call_blocked, + "disable must not wait for a blocked plugin call" + ); + assert!( + matches!(call_error, DomainError::NotFound(message) if message.contains("disabled")) + ); + } + #[test] fn test_set_enabled() { let registry = PluginRegistry::new(); diff --git a/src-tauri/src/adapters/driven/sqlite/account_repo.rs b/src-tauri/src/adapters/driven/sqlite/account_repo.rs index 189bd43f..23953fff 100644 --- a/src-tauri/src/adapters/driven/sqlite/account_repo.rs +++ b/src-tauri/src/adapters/driven/sqlite/account_repo.rs @@ -58,6 +58,8 @@ impl AccountRepository for SqliteAccountRepo { account::Column::TrafficTotal, account::Column::ValidUntil, account::Column::LastValidated, + account::Column::Status, + account::Column::CooldownUntil, ]) .to_owned(), ) @@ -149,7 +151,7 @@ fn is_unique_violation(msg: &str) -> bool { mod tests { use super::*; use crate::adapters::driven::sqlite::connection::setup_test_db; - use crate::domain::model::account::{Account, AccountId, AccountType}; + use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; fn make_account(id: &str, service: &str, user: &str) -> Account { Account::new( @@ -212,6 +214,24 @@ mod tests { assert_eq!(found.traffic_left(), Some(999)); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_save_upsert_persists_status_and_cooldown_deadline() { + let db = setup_test_db().await.expect("test db"); + let repo = SqliteAccountRepo::new(db); + let mut account = make_account("acc-1", "real-debrid", "alice"); + repo.save(&account).expect("first save"); + + account.mark_cooldown(1_700_000_060_000); + repo.save(&account).expect("upsert cooldown"); + + let found = repo + .find_by_id(account.id()) + .expect("find_by_id") + .expect("present"); + assert_eq!(found.status(), AccountStatus::Cooldown); + assert_eq!(found.exhausted_until(), Some(1_700_000_060_000)); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_save_upsert_preserves_original_created_at() { let db = setup_test_db().await.expect("test db"); diff --git a/src-tauri/src/adapters/driven/sqlite/connection.rs b/src-tauri/src/adapters/driven/sqlite/connection.rs index c845c968..9cfc3716 100644 --- a/src-tauri/src/adapters/driven/sqlite/connection.rs +++ b/src-tauri/src/adapters/driven/sqlite/connection.rs @@ -189,6 +189,90 @@ mod tests { assert!(other.is_ok(), "different service must be allowed"); } + #[tokio::test] + async fn test_premium_account_wiring_columns_exist() { + let db = setup_test_db().await.unwrap(); + + let account_columns = db + .query_all(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "PRAGMA table_info(accounts)".to_string(), + )) + .await + .unwrap(); + let account_names: Vec = account_columns + .iter() + .map(|row| row.try_get_by_index::(1).unwrap()) + .collect(); + assert!(account_names.contains(&"status".to_string())); + assert!(account_names.contains(&"cooldown_until".to_string())); + + let download_columns = db + .query_all(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "PRAGMA table_info(downloads)".to_string(), + )) + .await + .unwrap(); + let download_names: Vec = download_columns + .iter() + .map(|row| row.try_get_by_index::(1).unwrap()) + .collect(); + assert!(download_names.contains(&"account_ref".to_string())); + + let indexes = db + .query_all(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='downloads'" + .to_string(), + )) + .await + .unwrap(); + let index_names: Vec = indexes + .iter() + .map(|row| row.try_get_by_index::(0).unwrap()) + .collect(); + assert!(index_names.contains(&"idx_downloads_account_ref".to_string())); + } + + #[tokio::test] + async fn test_premium_account_migration_leaves_legacy_numeric_association_unset() { + let sqlite_opts = sea_orm::sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .pragma("foreign_keys", "ON"); + let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect_with(sqlite_opts) + .await + .unwrap(); + let db = sea_orm::SqlxSqliteConnector::from_sqlx_sqlite_pool(pool); + Migrator::up(&db, Some(9)) + .await + .expect("first 9 migrations"); + + db.execute(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "INSERT INTO downloads (id, url, file_name, state, priority, queue_position, downloaded_bytes, speed_bytes_per_sec, retry_count, max_retries, segments_count, source_hostname, protocol, resume_supported, account_id, destination_path, created_at, updated_at) VALUES (1, 'https://example.com/f.zip', 'f.zip', 'Queued', 5, 0, 0, 0, 0, 5, 1, 'example.com', 'https', 0, 7, '/tmp/f.zip', 1, 1)" + .to_string(), + )) + .await + .expect("seed legacy download"); + + Migrator::up(&db, None) + .await + .expect("premium wiring migration"); + + let row = db + .query_one(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT account_ref FROM downloads WHERE id = 1".to_string(), + )) + .await + .unwrap() + .expect("download remains"); + assert_eq!(row.try_get_by_index::>(0).unwrap(), None); + } + #[tokio::test] async fn test_packages_migration_applies_cleanly_on_existing_db() { // Stand up a DB at the schema state immediately before the diff --git a/src-tauri/src/adapters/driven/sqlite/download_repo.rs b/src-tauri/src/adapters/driven/sqlite/download_repo.rs index 1b9ab090..a8e35be0 100644 --- a/src-tauri/src/adapters/driven/sqlite/download_repo.rs +++ b/src-tauri/src/adapters/driven/sqlite/download_repo.rs @@ -8,6 +8,7 @@ use sea_orm::{ }; use crate::domain::error::DomainError; +use crate::domain::model::account::AccountId; use crate::domain::model::download::{Download, DownloadId, DownloadState}; use crate::domain::ports::driven::download_repository::DownloadRepository; @@ -74,6 +75,9 @@ async fn persist_download( // race with the event-driven write and overwrite a fresh // failover position with the cursor at the time the // aggregate was loaded. + // AccountId excluded: JIT premium rotation owns this column through + // compare_and_set_account_reference(), which is UPDATE-only and must not be + // reverted by a concurrent aggregate save carrying a stale account. .update_columns([ download::Column::Url, download::Column::FileName, @@ -91,7 +95,6 @@ async fn persist_download( download::Column::Protocol, download::Column::ResumeSupported, download::Column::ModuleName, - download::Column::AccountId, download::Column::DestinationPath, download::Column::MirrorsJson, ]); @@ -181,6 +184,27 @@ impl DownloadRepository for SqliteDownloadRepo { }) } + fn compare_and_set_account_reference( + &self, + id: DownloadId, + expected: &AccountId, + replacement: &AccountId, + ) -> Result { + block_on(async { + let result = download::Entity::update_many() + .col_expr( + download::Column::AccountId, + Expr::value(replacement.as_str()), + ) + .filter(download::Column::Id.eq(id.0 as i64)) + .filter(download::Column::AccountId.eq(expected.as_str())) + .exec(&self.db) + .await + .map_err(map_db_err)?; + Ok(result.rows_affected == 1) + }) + } + fn find_by_state(&self, state: DownloadState) -> Result, DomainError> { block_on(async { // Deterministic order so callers (e.g. queue_position @@ -198,6 +222,17 @@ impl DownloadRepository for SqliteDownloadRepo { models.into_iter().map(|m| m.into_domain()).collect() }) } + + fn has_account_reference(&self, account_id: &AccountId) -> Result { + block_on(async { + download::Entity::find() + .filter(download::Column::AccountId.eq(account_id.as_str())) + .one(&self.db) + .await + .map(|row| row.is_some()) + .map_err(map_db_err) + }) + } } #[cfg(test)] @@ -499,6 +534,74 @@ mod tests { assert!(found.is_none()); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn account_reference_query_tracks_persisted_downloads() { + let db = setup_test_db().await.expect("test db"); + let repo = SqliteDownloadRepo::new(db); + let account_id = AccountId::new("account-1"); + let download = make_download(1).with_account_id(account_id.clone()); + repo.save(&download).expect("save"); + + assert!(repo.has_account_reference(&account_id).unwrap()); + assert!( + !repo + .has_account_reference(&AccountId::new("other")) + .unwrap() + ); + repo.delete(download.id()).expect("delete"); + assert!(!repo.has_account_reference(&account_id).unwrap()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_update_account_reference_only_updates_an_existing_row() { + let db = setup_test_db().await.expect("test db"); + let repo = SqliteDownloadRepo::new(db); + let primary = AccountId::new("primary"); + let backup = AccountId::new("backup"); + let mut stale_snapshot = make_download(2).with_account_id(primary); + stale_snapshot.start().unwrap(); + repo.save(&stale_snapshot).expect("save active download"); + + assert!( + repo.compare_and_set_account_reference( + stale_snapshot.id(), + &AccountId::new("primary"), + &backup + ) + .unwrap() + ); + assert!( + !repo + .compare_and_set_account_reference( + stale_snapshot.id(), + &AccountId::new("primary"), + &AccountId::new("other"), + ) + .unwrap() + ); + stale_snapshot.pause().unwrap(); + repo.save(&stale_snapshot).expect("save stale snapshot"); + + let persisted = repo + .find_by_id(stale_snapshot.id()) + .unwrap() + .expect("download remains persisted"); + assert_eq!(persisted.state(), DownloadState::Paused); + assert_eq!(persisted.account_id(), Some(&backup)); + + repo.delete(stale_snapshot.id()).unwrap(); + assert!( + !repo + .compare_and_set_account_reference( + stale_snapshot.id(), + &AccountId::new("primary"), + &backup, + ) + .unwrap() + ); + assert!(repo.find_by_id(stale_snapshot.id()).unwrap().is_none()); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_find_by_state_filters_correctly() { let db = setup_test_db().await.expect("test db"); diff --git a/src-tauri/src/adapters/driven/sqlite/entities/account.rs b/src-tauri/src/adapters/driven/sqlite/entities/account.rs index b05e75ec..2e3949f5 100644 --- a/src-tauri/src/adapters/driven/sqlite/entities/account.rs +++ b/src-tauri/src/adapters/driven/sqlite/entities/account.rs @@ -1,7 +1,7 @@ use sea_orm::entity::prelude::*; use crate::domain::error::DomainError; -use crate::domain::model::account::{Account, AccountId, AccountType}; +use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; use crate::adapters::driven::sqlite::util::safe_u64; @@ -18,6 +18,8 @@ pub struct Model { pub traffic_total: Option, pub valid_until: Option, pub last_validated: Option, + pub status: String, + pub cooldown_until: Option, pub created_at: i64, } @@ -29,7 +31,8 @@ impl ActiveModelBehavior for ActiveModel {} impl Model { pub fn into_domain(self) -> Result { let account_type: AccountType = self.account_type.parse()?; - Ok(Account::reconstruct( + let status: AccountStatus = self.status.parse()?; + Ok(Account::reconstruct_with_status( AccountId::new(self.id), self.service_name, self.username, @@ -40,6 +43,8 @@ impl Model { self.valid_until.map(safe_u64), self.last_validated.map(safe_u64), safe_u64(self.created_at), + status, + self.cooldown_until.map(safe_u64), )) } } @@ -55,6 +60,8 @@ impl ActiveModel { let valid_until = checked_to_i64_opt(account.valid_until(), "valid_until", &id_str)?; let last_validated = checked_to_i64_opt(account.last_validated(), "last_validated", &id_str)?; + let cooldown_until = + checked_to_i64_opt(account.exhausted_until(), "cooldown_until", &id_str)?; let created_at = i64::try_from(account.created_at()).map_err(|_| { DomainError::ValidationError(format!("account {id_str}: created_at exceeds i64::MAX")) })?; @@ -69,6 +76,8 @@ impl ActiveModel { traffic_total: Set(traffic_total), valid_until: Set(valid_until), last_validated: Set(last_validated), + status: Set(account.status().to_string()), + cooldown_until: Set(cooldown_until), created_at: Set(created_at), }) } diff --git a/src-tauri/src/adapters/driven/sqlite/entities/download.rs b/src-tauri/src/adapters/driven/sqlite/entities/download.rs index 2852de9d..2bc9c216 100644 --- a/src-tauri/src/adapters/driven/sqlite/entities/download.rs +++ b/src-tauri/src/adapters/driven/sqlite/entities/download.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::adapters::driven::sqlite::util::safe_u32; use crate::domain::error::DomainError; +use crate::domain::model::account::AccountId; use crate::domain::model::checksum::ChecksumAlgorithm; use crate::domain::model::download::{Download, DownloadId, DownloadState, FileSize, Url}; use crate::domain::model::mirror::Mirror; @@ -79,7 +80,8 @@ pub struct Model { pub protocol: String, pub resume_supported: i32, pub module_name: Option, - pub account_id: Option, + #[sea_orm(column_name = "account_ref")] + pub account_id: Option, pub destination_path: String, pub error_message: Option, /// `None` keeps storage compact for the common single-source path — @@ -154,7 +156,7 @@ impl Model { self.protocol, self.resume_supported != 0, self.module_name, - self.account_id.map(|id| id as u64), + self.account_id.map(AccountId::new), self.destination_path, deserialize_mirrors(self.mirrors_json.as_deref())?, safe_u32(self.current_mirror_index as i64), @@ -188,7 +190,7 @@ impl ActiveModel { protocol: Set(download.protocol().to_string()), resume_supported: Set(if download.resume_supported() { 1 } else { 0 }), module_name: Set(download.module_name().map(|s| s.to_string())), - account_id: Set(download.account_id().map(|id| id as i64)), + account_id: Set(download.account_id().map(|id| id.as_str().to_string())), destination_path: Set(download.destination_path().to_string()), error_message: Set(None), mirrors_json: Set(serialize_mirrors(download.mirrors())), diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs b/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs new file mode 100644 index 00000000..7740136c --- /dev/null +++ b/src-tauri/src/adapters/driven/sqlite/migrations/m20260716_000010_wire_premium_accounts.rs @@ -0,0 +1,97 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Accounts::Table) + .add_column( + ColumnDef::new(Accounts::Status) + .text() + .not_null() + .default("unverified"), + ) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Accounts::Table) + .add_column(ColumnDef::new(Accounts::CooldownUntil).big_integer().null()) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Downloads::Table) + .add_column(ColumnDef::new(Downloads::AccountRef).text().null()) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_downloads_account_ref") + .table(Downloads::Table) + .col(Downloads::AccountRef) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_downloads_account_ref") + .table(Downloads::Table) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Downloads::Table) + .drop_column(Downloads::AccountRef) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Accounts::Table) + .drop_column(Accounts::CooldownUntil) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Accounts::Table) + .drop_column(Accounts::Status) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Accounts { + Table, + Status, + CooldownUntil, +} + +#[derive(DeriveIden)] +enum Downloads { + Table, + AccountRef, +} diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs b/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs index 6afba2fe..9b7cc7b1 100644 --- a/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs +++ b/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs @@ -9,6 +9,7 @@ mod m20260428_000006_create_accounts; mod m20260429_000007_create_packages; mod m20260430_000008_add_package_external_id; mod m20260505_000009_add_mirrors; +mod m20260716_000010_wire_premium_accounts; pub struct Migrator; @@ -25,6 +26,7 @@ impl MigratorTrait for Migrator { Box::new(m20260429_000007_create_packages::Migration), Box::new(m20260430_000008_add_package_external_id::Migration), Box::new(m20260505_000009_add_mirrors::Migration), + Box::new(m20260716_000010_wire_premium_accounts::Migration), ] } } diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index e48d38b0..c319f12b 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -94,12 +94,16 @@ pub async fn download_start( state: State<'_, AppState>, url: String, destination: Option, + module_name: Option, + account_id: Option, ) -> Result { let cmd = StartDownloadCommand { url, destination: destination.map(PathBuf::from), filename: None, source_hostname_override: None, + module_name, + account_id: account_id.map(AccountId::new), }; state .command_bus @@ -1750,6 +1754,8 @@ async fn start_media_download_for_url( destination: None, filename, source_hostname_override, + module_name: None, + account_id: None, }; command_bus .handle_start_download(cmd) @@ -3009,6 +3015,7 @@ impl AccountPatchDto { #[serde(rename_all = "camelCase")] pub struct ValidationOutcomeView { pub valid: bool, + pub status: String, pub latency_ms: Option, pub traffic_left: Option, pub traffic_total: Option, @@ -3020,6 +3027,7 @@ impl From for ValidationOutcomeView { fn from(o: ValidationOutcomeDto) -> Self { Self { valid: o.valid, + status: o.status.to_string(), latency_ms: o.latency_ms, traffic_left: o.traffic_left, traffic_total: o.traffic_total, @@ -3098,6 +3106,7 @@ pub async fn account_update( .handle_update_account(UpdateAccountCommand { id: AccountId::new(id), patch, + now_ms: now_unix_ms(), }) .await .map_err(|e| e.to_string()) @@ -3488,15 +3497,17 @@ pub async fn package_find_by_external_id( #[cfg(test)] mod tests { use super::{ - DEFAULT_DOWNLOAD_LOG_LIMIT, StreamResolution, configured_download_destination, - configured_status_bar_path, extract_hostname_from_url, load_plugin_media_metadata, - parse_plugin_video_metadata, parse_soundcloud_metadata, parse_soundcloud_playlist_targets, - parse_stats_period, read_available_space, resolve_download_log_limit, - resolve_existing_disk_path, resolve_media_stream, sanitize_extension, sanitize_filename, - soundcloud_track_download_title, unique_destination, + DEFAULT_DOWNLOAD_LOG_LIMIT, StreamResolution, ValidationOutcomeView, + configured_download_destination, configured_status_bar_path, extract_hostname_from_url, + load_plugin_media_metadata, parse_plugin_video_metadata, parse_soundcloud_metadata, + parse_soundcloud_playlist_targets, parse_stats_period, read_available_space, + resolve_download_log_limit, resolve_existing_disk_path, resolve_media_stream, + sanitize_extension, sanitize_filename, soundcloud_track_download_title, unique_destination, }; use crate::adapters::driven::logging::download_log_store::DownloadLogStore; + use crate::application::commands::ValidationOutcomeDto; use crate::domain::error::DomainError; + use crate::domain::model::account::AccountStatus; use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; use crate::domain::model::views::StatsPeriod; use crate::domain::ports::driven::PluginLoader; @@ -3506,6 +3517,19 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Barrier}; + #[test] + fn validation_outcome_view_serializes_typed_status() { + let view = ValidationOutcomeView::from(ValidationOutcomeDto { + status: AccountStatus::QuotaExhausted, + error_message: Some("quota exhausted".to_string()), + ..ValidationOutcomeDto::default() + }); + + let value = serde_json::to_value(view).expect("validation outcome serializes"); + assert_eq!(value["status"], "quota_exhausted"); + assert_eq!(value["valid"], false); + } + #[derive(Clone)] struct MetadataPluginLoader { resolved: Option, diff --git a/src-tauri/src/application/command_bus.rs b/src-tauri/src/application/command_bus.rs index 75444dfa..d76af40d 100644 --- a/src-tauri/src/application/command_bus.rs +++ b/src-tauri/src/application/command_bus.rs @@ -8,7 +8,10 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::Semaphore; +use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::application::services::{AccountRotator, AccountSelector}; +use crate::domain::error::DomainError; +use crate::domain::model::account::{Account, AccountId}; use crate::domain::model::config::{ DEFAULT_LINK_CHECK_PARALLELISM, normalize_link_check_parallelism, }; @@ -65,7 +68,7 @@ impl LinkCheckLimiter { } use crate::domain::ports::driven::{ AccountCredentialStore, AccountRepository, AccountValidator, ArchiveExtractor, - ChecksumComputer, ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, + ChecksumComputer, ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, EventBus, FileOpener, FileStorage, HistoryRepository, HttpClient, PackageRepository, PassphraseCodec, PluginConfigStore, PluginLoader, PluginStoreClient, UrlOpener, @@ -98,6 +101,8 @@ pub struct CommandBus { account_validator: Option>, account_selector: Option>, account_rotator: Option>, + account_operation_locks: Arc, + account_clock: Option>, passphrase_codec: Option>, /// Serializes queue-position allocation across handlers. Without this, /// two concurrent move-to-top/move-to-bottom/start-download calls can @@ -167,6 +172,8 @@ impl CommandBus { account_validator: None, account_selector: None, account_rotator: None, + account_operation_locks: Arc::new(AccountOperationLocks::default()), + account_clock: None, passphrase_codec: None, queue_position_lock: tokio::sync::Mutex::new(()), link_check_limiter, @@ -225,6 +232,16 @@ impl CommandBus { self } + pub fn with_account_clock(mut self, clock: Arc) -> Self { + self.account_clock = Some(clock); + self + } + + pub fn with_account_operation_locks(mut self, locks: Arc) -> Self { + self.account_operation_locks = locks; + self + } + /// Builder-style setter for the passphrase codec used by the /// import / export commands. pub fn with_passphrase_codec(mut self, codec: Arc) -> Self { @@ -244,6 +261,10 @@ impl CommandBus { self.account_validator.as_deref() } + pub(crate) fn account_validator_arc(&self) -> Option> { + self.account_validator.clone() + } + pub fn account_selector(&self) -> Option<&AccountSelector> { self.account_selector.as_deref() } @@ -252,6 +273,35 @@ impl CommandBus { self.account_rotator.as_deref() } + pub(crate) fn save_account_availability( + &self, + repo: &dyn AccountRepository, + account: &Account, + ) -> Result<(), DomainError> { + match self.account_rotator() { + Some(rotator) => rotator.save_account(account), + None => repo.save(account), + } + } + + pub(crate) fn account_operation_lock( + &self, + id: &AccountId, + ) -> Result>, crate::application::error::AppError> { + self.account_operation_locks.lock_for(id) + } + + pub(crate) fn account_now_ms(&self) -> Result { + self.account_clock + .as_deref() + .map(Clock::now_unix_ms) + .ok_or_else(|| { + crate::application::error::AppError::Validation( + "account clock not configured".into(), + ) + }) + } + pub fn passphrase_codec(&self) -> Option<&dyn PassphraseCodec> { self.passphrase_codec.as_deref() } diff --git a/src-tauri/src/application/commands/add_account.rs b/src-tauri/src/application/commands/add_account.rs index 3633bce5..51bed030 100644 --- a/src-tauri/src/application/commands/add_account.rs +++ b/src-tauri/src/application/commands/add_account.rs @@ -15,6 +15,10 @@ use crate::application::error::AppError; use crate::domain::event::DomainEvent; use crate::domain::model::account::{Account, AccountId}; +use super::validate_account::{ + apply_validation, publish_validation, validate_credentials_blocking, +}; + impl CommandBus { pub async fn handle_add_account( &self, @@ -34,6 +38,8 @@ impl CommandBus { })?; let id = AccountId::new(Uuid::new_v4().to_string()); + let operation_lock = self.account_operation_lock(&id)?; + let _operation_guard = operation_lock.lock().await; let account = Account::new( id.clone(), service_name, @@ -74,10 +80,64 @@ impl CommandBus { return Err(e.into()); } + let validation = match self.account_validator_arc() { + Some(validator) => { + match validate_credentials_blocking(validator, account.clone(), cmd.password).await + { + Ok(attempt) => Some(attempt), + Err(error) => { + if let Err(rollback_error) = repo.delete(&id) { + tracing::warn!( + account_id = %id.as_str(), + validation_error = %error, + rollback_error = %rollback_error, + "account validation worker failed and row rollback also failed" + ); + } + if let Err(cleanup_error) = store.delete_password(&id) { + tracing::warn!( + account_id = %id.as_str(), + validation_error = %error, + cleanup_error = %cleanup_error, + "account validation worker failed and credential cleanup also failed" + ); + } + return Err(error); + } + } + } + None => None, + }; + if let Some(attempt) = &validation { + let validated = apply_validation(&account, &attempt.outcome, cmd.created_at_ms); + if let Err(error) = self.save_account_availability(repo, &validated) { + if let Err(rollback_error) = repo.delete(&id) { + tracing::warn!( + account_id = %id.as_str(), + validation_error = %error, + rollback_error = %rollback_error, + "account validation state failed to persist and row rollback also failed" + ); + } + if let Err(cleanup_error) = store.delete_password(&id) { + tracing::warn!( + account_id = %id.as_str(), + validation_error = %error, + cleanup_error = %cleanup_error, + "account validation state failed to persist and credential cleanup also failed" + ); + } + return Err(error.into()); + } + } + self.event_bus().publish(DomainEvent::AccountAdded { id: id.clone(), service_name: account.service_name().to_string(), }); + if let Some(attempt) = validation { + publish_validation(self, id.clone(), &attempt.outcome); + } Ok(id) } @@ -97,13 +157,29 @@ mod tests { use super::super::AddAccountCommand; use crate::application::commands::tests_support::{ - CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, build_account_bus, + CapturingEventBus, FakeAccountCredentialStore, FakeAccountValidator, InMemoryAccountRepo, + ValidatorBehavior, build_account_bus, }; use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; - use crate::domain::model::account::AccountType; - use crate::domain::ports::driven::{AccountCredentialStore, AccountRepository}; + use crate::domain::model::account::{AccountStatus, AccountType}; + use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, AccountValidator, ValidationOutcome, + }; + + struct PanickingValidator; + + impl AccountValidator for PanickingValidator { + fn validate( + &self, + _service_name: &str, + _username: &str, + _password: &str, + ) -> Result { + panic!("simulated validation worker failure") + } + } fn add_command(service: &str, user: &str, password: &str) -> AddAccountCommand { AddAccountCommand { @@ -153,6 +229,51 @@ mod tests { } } + #[tokio::test] + async fn test_add_account_validates_and_persists_valid_status() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Ok(ValidationOutcome::ok()), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events, Some(validator.clone()), None); + + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier", "alice", "api-key")) + .await + .expect("invalid credentials do not discard account metadata"); + + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::Valid); + assert_eq!(stored.last_validated(), Some(1_700_000_000_000)); + assert_eq!(validator.calls().len(), 1); + } + + #[tokio::test] + async fn test_add_account_keeps_rejected_account_with_typed_status() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Reject("wrong key".into()), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events, Some(validator), None); + + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier", "alice", "bad-key")) + .await + .expect("rejected account remains configured"); + + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::InvalidCredentials); + assert_eq!(stored.last_validated(), Some(1_700_000_000_000)); + } + #[tokio::test] async fn test_add_account_blank_service_returns_validation() { let repo = Arc::new(InMemoryAccountRepo::new()); @@ -229,6 +350,34 @@ mod tests { assert!(events.snapshot().is_empty(), "no event on failure"); } + #[tokio::test] + async fn test_add_account_rolls_back_when_validation_worker_stops() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus( + repo.clone(), + creds.clone(), + events.clone(), + Some(Arc::new(PanickingValidator)), + None, + ); + + let err = bus + .handle_add_account(add_command("real-debrid", "alice", "pw")) + .await + .expect_err("validation worker failure surfaces"); + + assert!(matches!( + err, + AppError::Domain(DomainError::PluginError(message)) + if message.contains("validation worker stopped") + )); + assert!(repo.list().unwrap().is_empty(), "row must be rolled back"); + assert_eq!(creds.entry_count(), 0, "credential must be removed"); + assert!(events.snapshot().is_empty(), "no event on failure"); + } + #[tokio::test] async fn test_add_account_emits_no_event_when_repo_missing() { let creds = Arc::new(FakeAccountCredentialStore::new()); diff --git a/src-tauri/src/application/commands/delete_account.rs b/src-tauri/src/application/commands/delete_account.rs index 7230bea4..8ddfef8e 100644 --- a/src-tauri/src/application/commands/delete_account.rs +++ b/src-tauri/src/application/commands/delete_account.rs @@ -27,6 +27,13 @@ impl CommandBus { let store = self.account_credential_store().ok_or_else(|| { AppError::Validation("account credential store not configured".into()) })?; + let operation_lock = self.account_operation_lock(&cmd.id)?; + let _operation_guard = operation_lock.lock().await; + if self.download_repo().has_account_reference(&cmd.id)? { + return Err(AppError::Validation( + "account is still referenced by a download".into(), + )); + } repo.delete(&cmd.id)?; if let Err(e) = store.delete_password(&cmd.id) { diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index 180b5438..dd799bdb 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -37,6 +37,7 @@ mod remove_download; mod remove_download_from_package; mod report_broken_plugin; mod resolve_links; +pub mod resolve_premium_source; mod resume_all; mod resume_download; mod retry_download; @@ -59,7 +60,7 @@ mod verify_checksum; use std::path::PathBuf; -use crate::domain::model::account::{AccountId, AccountType}; +use crate::domain::model::account::{AccountId, AccountStatus, AccountType}; use crate::domain::model::config::ConfigPatch; use crate::domain::model::download::DownloadId; use crate::domain::model::package::{PackageId, PackageSourceType}; @@ -76,6 +77,10 @@ pub struct StartDownloadCommand { /// `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"). pub source_hostname_override: Option, + /// Plugin that resolved the direct URL, when applicable. + pub module_name: Option, + /// Opaque account UUID selected during resolution. Never a credential. + pub account_id: Option, } impl Command for StartDownloadCommand {} @@ -440,6 +445,7 @@ pub struct AccountPatch { pub struct UpdateAccountCommand { pub id: AccountId, pub patch: AccountPatch, + pub now_ms: u64, } impl Command for UpdateAccountCommand {} @@ -469,6 +475,7 @@ impl Command for ValidateAccountCommand {} #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ValidationOutcomeDto { pub valid: bool, + pub status: AccountStatus, pub latency_ms: Option, pub traffic_left: Option, pub traffic_total: Option, @@ -479,7 +486,8 @@ pub struct ValidationOutcomeDto { impl From for ValidationOutcomeDto { fn from(o: crate::domain::ports::driven::ValidationOutcome) -> Self { Self { - valid: o.valid, + valid: o.is_valid(), + status: o.status, latency_ms: o.latency_ms, traffic_left: o.traffic_left, traffic_total: o.traffic_total, diff --git a/src-tauri/src/application/commands/premium_account_resolution.rs b/src-tauri/src/application/commands/premium_account_resolution.rs new file mode 100644 index 00000000..935d2001 --- /dev/null +++ b/src-tauri/src/application/commands/premium_account_resolution.rs @@ -0,0 +1,160 @@ +use crate::application::services::account_state::{apply_status, status_for_plugin_error}; +use crate::domain::error::DomainError; +use crate::domain::event::DomainEvent; +use crate::domain::model::account::{Account, AccountStatus}; +use crate::domain::model::credential::Credential; +use crate::domain::ports::driven::{ExtractedHosterLink, ResolutionCancellation}; + +use super::{ResolvePremiumSourceCommand, ResolvePremiumSourceHandler}; + +impl ResolvePremiumSourceHandler { + pub(super) fn resolve_locked( + &self, + command: ResolvePremiumSourceCommand, + cancellation: &ResolutionCancellation, + ) -> Result { + cancellation.ensure_active()?; + let mut account = self.load_available_account(&command)?; + let credential = self.load_credential(&mut account, cancellation)?; + let link = match self.plugins.extract_hoster_link( + &command.service_name, + &command.source_url, + Some(&credential), + ) { + Ok(link) => link, + Err(error) => { + return self.reject_plugin_failure(&mut account, error, cancellation); + } + }; + self.persist_success(&mut account, &link, cancellation)?; + Ok(link) + } + + fn load_available_account( + &self, + command: &ResolvePremiumSourceCommand, + ) -> Result { + let account = self.repo.find_by_id(&command.account_id)?.ok_or_else(|| { + DomainError::NotFound(format!("account {}", command.account_id.as_str())) + })?; + if account.service_name() != command.service_name + || !account.is_selectable(self.clock.now_unix_ms()) + { + return Err(DomainError::ValidationError( + "premium account is unavailable".into(), + )); + } + Ok(account) + } + + fn load_credential( + &self, + account: &mut Account, + cancellation: &ResolutionCancellation, + ) -> Result { + let Some(password) = self.credentials.get_password(account.id())? else { + account.set_status(AccountStatus::MissingCredential); + cancellation.run_if_active(|| { + self.rotator.save_account(account)?; + self.publish_failure(account, "Account credential is unavailable"); + Ok(()) + })?; + return Err(DomainError::NotFound(format!( + "credential for account {}", + account.id().as_str() + ))); + }; + cancellation.ensure_active()?; + Ok(Credential::new(account.username(), password)) + } + + fn reject_plugin_failure( + &self, + account: &mut Account, + error: DomainError, + cancellation: &ResolutionCancellation, + ) -> Result { + let Some(status) = status_for_plugin_error(&error) else { + return Err(DomainError::PluginError( + "premium source resolution failed".into(), + )); + }; + apply_status(account, status, self.clock.now_unix_ms()); + cancellation.run_if_active(|| { + self.rotator.save_account(account)?; + self.publish_typed_failure(account, status); + Ok(()) + })?; + Err(error) + } + + fn persist_success( + &self, + account: &mut Account, + link: &ExtractedHosterLink, + cancellation: &ResolutionCancellation, + ) -> Result<(), DomainError> { + if link.direct_url.is_none() { + return Err(DomainError::PluginError( + "premium plugin returned no direct URL".into(), + )); + } + if let Some(total) = link.traffic_total_bytes { + account.set_traffic_total(total); + if let Some(used) = link.traffic_used_bytes { + let remaining = total.saturating_sub(used); + account.set_traffic_left(remaining); + if total > 0 && remaining == 0 { + apply_status( + account, + AccountStatus::QuotaExhausted, + self.clock.now_unix_ms(), + ); + cancellation.run_if_active(|| { + self.rotator.save_account(account)?; + self.publish_typed_failure(account, AccountStatus::QuotaExhausted); + Ok(()) + })?; + return Err(DomainError::AccountQuotaExceeded); + } + } + } + account.set_status(AccountStatus::Valid); + cancellation.run_if_active(|| self.rotator.save_account(account))?; + Ok(()) + } + + pub(super) fn publish_success(&self, id: &crate::domain::model::account::AccountId) { + self.events + .publish(DomainEvent::AccountUpdated { id: id.clone() }); + } + + fn publish_typed_failure(&self, account: &Account, status: AccountStatus) { + if status == AccountStatus::QuotaExhausted { + self.events.publish(DomainEvent::AccountExhausted { + id: account.id().clone(), + service_name: account.service_name().to_string(), + exhausted_until_ms: account.exhausted_until().unwrap_or_default(), + }); + } else { + self.publish_failure(account, account_failure_message(status)); + } + } + + fn publish_failure(&self, account: &Account, error: &str) { + self.events.publish(DomainEvent::AccountValidationFailed { + id: account.id().clone(), + error: error.to_string(), + }); + } +} + +fn account_failure_message(status: AccountStatus) -> &'static str { + match status { + AccountStatus::InvalidCredentials => "Account credentials were rejected", + AccountStatus::Expired => "Account is expired", + AccountStatus::Cooldown => "Account is temporarily rate-limited", + AccountStatus::QuotaExhausted => "Account quota is exhausted", + _ => "Account validation failed", + } +} diff --git a/src-tauri/src/application/commands/premium_account_rotation.rs b/src-tauri/src/application/commands/premium_account_rotation.rs new file mode 100644 index 00000000..9986d83e --- /dev/null +++ b/src-tauri/src/application/commands/premium_account_rotation.rs @@ -0,0 +1,137 @@ +use crate::application::error::AppError; +use crate::application::services::account_rotator::NextAccountOutcome; +use crate::domain::error::DomainError; +use crate::domain::model::account::AccountId; +use crate::domain::model::download::Download; +use crate::domain::ports::driven::{ + ExtractedHosterLink, ResolutionCancellation, ResolvedDownloadSource, +}; + +use super::{ResolvePremiumSourceCommand, ResolvePremiumSourceHandler}; + +impl ResolvePremiumSourceHandler { + pub(super) fn resolve_download( + &self, + download: &Download, + cancellation: &ResolutionCancellation, + ) -> Result { + cancellation.ensure_active()?; + let service = download.module_name().ok_or_else(|| { + DomainError::ValidationError("premium download has no plugin association".into()) + })?; + let mut account_id = download.account_id().cloned().ok_or_else(|| { + DomainError::ValidationError("premium download has no account association".into()) + })?; + let attempts = self.repo.list_by_service(service)?.len().saturating_add(1); + let mut last_error = None; + + for _ in 0..attempts { + cancellation.ensure_active()?; + match self.resolve_once(download, service, &account_id, cancellation) { + Ok(link) => { + cancellation.ensure_active()?; + return sensitive_source(link); + } + Err(error) if is_rotatable(&error) => last_error = Some(error), + Err(error) => return Err(error), + } + account_id = match self.next_account(service, cancellation)? { + NextAccountOutcome::Picked(account) => account.id().clone(), + NextAccountOutcome::AllExhausted { reason, .. } => { + return Err(last_error.unwrap_or_else(|| reason.into_domain_error())); + } + NextAccountOutcome::NoneAvailable => break, + }; + } + + Err(last_error.unwrap_or_else(|| { + DomainError::ValidationError("no premium account is available".into()) + })) + } + + fn resolve_once( + &self, + download: &Download, + service: &str, + account_id: &AccountId, + cancellation: &ResolutionCancellation, + ) -> Result { + let lock = self.account_lock(account_id)?; + let _guard = lock.blocking_lock(); + let command = ResolvePremiumSourceCommand::new( + account_id.clone(), + service.to_string(), + download.url().as_str().to_string(), + ); + let link = self.resolve_locked(command, cancellation)?; + let expected = download.account_id().ok_or_else(|| { + DomainError::ValidationError("premium download has no account association".into()) + })?; + let updated = cancellation.run_if_active(|| { + self.downloads + .compare_and_set_account_reference(download.id(), expected, account_id) + })?; + if !updated { + let current = self.downloads.find_by_id(download.id())?; + match current { + None => { + return Err(DomainError::NotFound(format!( + "download {}", + download.id().0 + ))); + } + Some(current) if current.account_id() == Some(account_id) => {} + Some(_) => { + return Err(DomainError::ValidationError( + "download account association changed during resolution".into(), + )); + } + } + } + cancellation.run_if_active(|| { + self.publish_success(account_id); + Ok(()) + })?; + Ok(link) + } + + fn next_account( + &self, + service: &str, + cancellation: &ResolutionCancellation, + ) -> Result { + cancellation.ensure_active()?; + let strategy = self.config.get_config()?.account_selection_strategy; + self.rotator + .next_account(service, strategy) + .map_err(app_error_to_domain) + } +} + +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 + | DomainError::AccountExpired + | DomainError::AccountCooldown + | DomainError::AccountQuotaExceeded => true, + DomainError::NotFound(message) => message.starts_with("credential for account "), + DomainError::ValidationError(message) => message == "premium account is unavailable", + _ => false, + } +} + +fn app_error_to_domain(error: AppError) -> DomainError { + match error { + AppError::Domain(error) => error, + AppError::NotFound(message) => DomainError::NotFound(message), + AppError::Validation(message) => DomainError::ValidationError(message), + other => DomainError::StorageError(other.to_string()), + } +} diff --git a/src-tauri/src/application/commands/redownload.rs b/src-tauri/src/application/commands/redownload.rs index ee9fd4f1..5036be9f 100644 --- a/src-tauri/src/application/commands/redownload.rs +++ b/src-tauri/src/application/commands/redownload.rs @@ -10,6 +10,7 @@ use crate::application::command_bus::CommandBus; use crate::application::commands::RedownloadSource; use crate::application::error::AppError; use crate::domain::event::DomainEvent; +use crate::domain::model::account::AccountId; use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; impl CommandBus { @@ -18,6 +19,19 @@ impl CommandBus { cmd: super::RedownloadCommand, ) -> Result { let template = self.load_template(&cmd.source)?; + let account_lock = template + .account_id + .as_ref() + .map(|id| self.account_operation_lock(id)) + .transpose()?; + let _account_guard = match account_lock { + Some(lock) => Some(lock.lock_owned().await), + None => None, + }; + self.validate_download_account( + template.module_name.as_deref(), + template.account_id.as_ref(), + )?; let url = Url::new(&template.url)?; let dest = cmd @@ -77,7 +91,7 @@ impl CommandBus { priority: Some(*download.priority()), segments_count: Some(download.segments_count()), module_name: download.module_name().map(str::to_string), - account_id: download.account_id(), + account_id: download.account_id().cloned(), }) } RedownloadSource::History(history_id) => { @@ -110,7 +124,7 @@ struct RedownloadTemplate { priority: Option, segments_count: Option, module_name: Option, - account_id: Option, + account_id: Option, } #[cfg(test)] @@ -120,12 +134,16 @@ mod tests { use std::sync::{Arc, Mutex}; use crate::application::command_bus::CommandBus; + use crate::application::commands::tests_support::{ + FakeAccountCredentialStore, InMemoryAccountRepo, + }; use crate::application::commands::{RedownloadCommand, RedownloadSource}; use crate::application::error::AppError; use crate::application::test_support::InMemoryHistoryRepo; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::Priority; + use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::credential::Credential; use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; @@ -134,8 +152,9 @@ mod tests { use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::model::views::HistoryEntry; use crate::domain::ports::driven::{ - ArchiveExtractor, ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, - DownloadRepository, EventBus, FileStorage, HistoryRepository, HttpClient, PluginLoader, + AccountCredentialStore, AccountRepository, ArchiveExtractor, ClipboardObserver, Clock, + ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, EventBus, FileStorage, + HistoryRepository, HttpClient, PluginLoader, }; struct MockDownloadRepo { @@ -339,6 +358,14 @@ mod tests { } } + struct FixedClock; + + impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } + } + fn make_bus( repo: Arc, events: Arc, @@ -369,8 +396,7 @@ mod tests { ) .with_segments_count(4) .with_priority(Priority::new(9).unwrap()) - .with_module_name("vortex-mod-example".to_string()) - .with_account_id(7); + .with_module_name("vortex-mod-example".to_string()); d.start().unwrap(); d.complete().unwrap(); d @@ -381,9 +407,24 @@ mod tests { let repo = Arc::new(MockDownloadRepo::new()); let events = Arc::new(MockEventBus::new()); let history: Arc = Arc::new(InMemoryHistoryRepo::new()); - let original = completed_download(1); + let original = completed_download(1).with_account_id(AccountId::new("account-7")); repo.save(&original).unwrap(); - let bus = make_bus(repo.clone(), events.clone(), history); + let account_repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let mut account = Account::new( + AccountId::new("account-7"), + "vortex-mod-example".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + account_repo.save(&account).unwrap(); + credentials.store_password(account.id(), "api-key").unwrap(); + let bus = make_bus(repo.clone(), events.clone(), history) + .with_account_repo(account_repo) + .with_account_credential_store(credentials) + .with_account_clock(Arc::new(FixedClock)); let new_id = bus .handle_redownload(RedownloadCommand { @@ -404,7 +445,10 @@ mod tests { assert_eq!(created.segments_count(), 4); assert_eq!(*created.priority(), Priority::new(9).unwrap()); assert_eq!(created.module_name(), Some("vortex-mod-example")); - assert_eq!(created.account_id(), Some(7)); + assert_eq!( + created.account_id().map(AccountId::as_str), + Some("account-7") + ); assert_eq!( created.state(), DownloadState::Queued, @@ -412,6 +456,28 @@ mod tests { ); } + #[tokio::test] + async fn redownload_rejects_an_orphaned_account_reference() { + let repo = Arc::new(MockDownloadRepo::new()); + let events = Arc::new(MockEventBus::new()); + let history: Arc = Arc::new(InMemoryHistoryRepo::new()); + let original = completed_download(10).with_account_id(AccountId::new("missing")); + repo.save(&original).unwrap(); + let bus = make_bus(repo, events, history) + .with_account_repo(Arc::new(InMemoryAccountRepo::new())) + .with_account_credential_store(Arc::new(FakeAccountCredentialStore::new())); + + let error = bus + .handle_redownload(RedownloadCommand { + source: RedownloadSource::Download(DownloadId(10)), + destination_override: None, + }) + .await + .expect_err("orphaned account must not be copied"); + + assert!(matches!(error, AppError::NotFound(_))); + } + #[tokio::test] async fn redownload_emits_download_created_event_with_new_id() { let repo = Arc::new(MockDownloadRepo::new()); diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 42a16cd5..6fbcd1bb 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -8,7 +8,11 @@ use uuid::Uuid; use crate::application::command_bus::CommandBus; use crate::application::error::AppError; +use crate::application::services::account_rotator::NextAccountOutcome; +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; @@ -25,10 +29,18 @@ pub struct ResolvedLinkDto { pub status: String, pub error_message: Option, pub module_name: String, + pub account_id: Option, pub is_media: bool, pub media_type: Option, } +struct HosterResolution { + resolved_url: String, + filename: Option, + size_bytes: Option, + account_id: Option, +} + impl CommandBus { pub async fn handle_resolve_links( &self, @@ -61,6 +73,7 @@ impl CommandBus { 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, }); @@ -77,6 +90,7 @@ impl CommandBus { status: "online".to_string(), error_message: None, module_name: "magnet".to_string(), + account_id: None, is_media: false, media_type: None, }); @@ -88,12 +102,46 @@ impl CommandBus { Ok(Some(info)) => info.name().to_string(), _ => "core-http".to_string(), }; - // Account dispatcher hook (task 24): plugins that need - // credentials must request them via - // `CommandBus::resolve_account_for(service_name)` so the - // selector strategy from `AppConfig` is honoured. Resolve - // is best-effort metadata only — no account is fetched here - // to avoid emitting `AccountSelected` once per probed URL. + + let is_hoster = matches!( + plugin_info.as_ref().ok().and_then(Option::as_ref), + Some(info) + if matches!(info.category(), PluginCategory::Hoster | PluginCategory::Debrid) + ); + 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, + }), + 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, + }); + } + } + continue; + } let is_media = is_media_url(url); let media_type = if is_media { @@ -115,6 +163,7 @@ impl CommandBus { status: "online".to_string(), error_message: None, module_name, + account_id: None, is_media, media_type, }); @@ -129,6 +178,7 @@ impl CommandBus { status: "offline".to_string(), error_message: None, module_name, + account_id: None, is_media, media_type, }); @@ -144,6 +194,7 @@ impl CommandBus { status: "error".to_string(), error_message: Some(sanitize_resolve_error(&e)), module_name, + account_id: None, is_media, media_type, }); @@ -153,6 +204,70 @@ impl CommandBus { Ok(results) } + + async fn resolve_hoster_link( + &self, + url: &str, + service_name: &str, + ) -> Result { + 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), + size_bytes: None, + account_id: Some(account.id().as_str().to_string()), + }); + } + NextAccountOutcome::AllExhausted { reason, .. } => { + return Err(reason.into_domain_error().into()); + } + NextAccountOutcome::NoneAvailable => {} + } + } + + let link = self + .plugin_loader() + .extract_hoster_link(service_name, url, None)?; + Ok(into_hoster_resolution(link, url)) + } + + fn next_hoster_account(&self, service_name: &str) -> Result { + let Some(rotator) = self.account_rotator() else { + return Ok(match self.resolve_account_for(service_name)? { + Some(account) => NextAccountOutcome::Picked(account), + None => NextAccountOutcome::NoneAvailable, + }); + }; + let strategy = self.config_store().get_config()?.account_selection_strategy; + rotator.next_account(service_name, strategy) + } +} + +fn into_hoster_resolution(link: ExtractedHosterLink, stable_url: &str) -> HosterResolution { + HosterResolution { + resolved_url: stable_url.to_string(), + filename: link.filename, + size_bytes: link.size_bytes, + account_id: None, + } +} + +fn sanitize_hoster_error(error: &AppError) -> 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(), + } } fn is_allowed_scheme(url: &str) -> bool { @@ -242,8 +357,240 @@ 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() { diff --git a/src-tauri/src/application/commands/resolve_premium_source.rs b/src-tauri/src/application/commands/resolve_premium_source.rs new file mode 100644 index 00000000..6eb79a3e --- /dev/null +++ b/src-tauri/src/application/commands/resolve_premium_source.rs @@ -0,0 +1,121 @@ +//! Command handler for one credential-scoped premium hoster resolution. + +use std::sync::Arc; + +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, +}; +use crate::domain::ports::driving::Command; + +#[path = "premium_account_resolution.rs"] +mod resolution; +#[path = "premium_account_rotation.rs"] +mod rotation; + +#[derive(Debug)] +pub struct ResolvePremiumSourceCommand { + account_id: AccountId, + service_name: String, + source_url: String, +} + +impl ResolvePremiumSourceCommand { + pub fn new(account_id: AccountId, service_name: String, source_url: String) -> Self { + Self { + account_id, + service_name, + source_url, + } + } +} + +impl Command for ResolvePremiumSourceCommand {} + +#[derive(Clone)] +pub struct ResolvePremiumSourceHandler { + repo: Arc, + credentials: Arc, + plugins: Arc, + events: Arc, + clock: Arc, + locks: Arc, + downloads: Arc, + config: Arc, + rotator: Arc, +} + +impl ResolvePremiumSourceHandler { + #[allow(clippy::too_many_arguments)] + pub fn new( + repo: Arc, + credentials: Arc, + plugins: Arc, + events: Arc, + clock: Arc, + locks: Arc, + downloads: Arc, + config: Arc, + rotator: Arc, + ) -> Self { + Self { + repo, + credentials, + plugins, + events, + clock, + locks, + downloads, + config, + rotator, + } + } + + pub async fn handle( + &self, + command: ResolvePremiumSourceCommand, + ) -> Result { + let account_id = command.account_id.clone(); + let handler = self.clone(); + let cancellation = ResolutionCancellation::default(); + let link = tokio::task::spawn_blocking(move || { + let lock = handler.account_lock(&command.account_id)?; + let _guard = lock.blocking_lock(); + handler.resolve_locked(command, &cancellation) + }) + .await + .map_err(|_| DomainError::PluginError("premium source resolver stopped".into()))??; + self.publish_success(&account_id); + Ok(link) + } + + fn account_lock(&self, id: &AccountId) -> Result>, DomainError> { + self.locks + .lock_for(id) + .map_err(|_| DomainError::StorageError("account lock unavailable".into())) + } +} + +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 new file mode 100644 index 00000000..5037df8f --- /dev/null +++ b/src-tauri/src/application/commands/resolve_premium_source_test_support.rs @@ -0,0 +1,200 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::application::commands::tests_support::{ + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, InMemoryDownloadRepo, +}; +use crate::application::services::account_operation_locks::AccountOperationLocks; +use crate::application::services::{AccountRotator, AccountSelector}; +use crate::domain::error::DomainError; +use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; +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::ports::driven::{ + AccountRepository, Clock, ConfigStore, DownloadRepository, ExtractedHosterLink, PluginLoader, +}; + +use super::ResolvePremiumSourceHandler; + +pub(super) struct FixedClock; + +impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } +} + +pub(super) struct DirectUrlPlugin { + pub(super) calls: Mutex>, +} + +impl DirectUrlPlugin { + pub(super) fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + } + } +} + +impl PluginLoader for DirectUrlPlugin { + 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::new()) + } + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + fn extract_hoster_link( + &self, + service: &str, + url: &str, + credential: Option<&Credential>, + ) -> Result { + let credential = credential.expect("premium credential"); + self.calls.lock().unwrap().push(( + service.to_string(), + url.to_string(), + credential.password().to_string(), + )); + if credential.password() == "quota-key" { + return Err(DomainError::AccountQuotaExceeded); + } + if credential.password() == "expired-key" { + return Err(DomainError::AccountExpired); + } + if credential.password() == "cooldown-key" { + return Err(DomainError::AccountCooldown); + } + let traffic_used_bytes = if credential.password() == "zero-traffic-key" { + Some(100) + } else { + Some(10) + }; + Ok(ExtractedHosterLink { + 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()), + traffic_used_bytes, + traffic_total_bytes: Some(100), + }) + } +} + +struct FixedConfigStore; + +impl ConfigStore for FixedConfigStore { + fn get_config(&self) -> Result { + Ok(AppConfig::default()) + } + + fn update_config(&self, _: ConfigPatch) -> Result { + Ok(AppConfig::default()) + } +} + +pub(super) struct SaveFailingRepo { + inner: InMemoryAccountRepo, + pub(super) fail_saves: AtomicBool, +} + +impl SaveFailingRepo { + pub(super) fn new() -> Self { + Self { + inner: InMemoryAccountRepo::new(), + fail_saves: AtomicBool::new(false), + } + } +} + +impl AccountRepository for SaveFailingRepo { + fn find_by_id(&self, id: &AccountId) -> Result, DomainError> { + self.inner.find_by_id(id) + } + fn save(&self, account: &Account) -> Result<(), DomainError> { + if self.fail_saves.load(Ordering::SeqCst) { + return Err(DomainError::StorageError("database unavailable".into())); + } + self.inner.save(account) + } + fn list(&self) -> Result, DomainError> { + self.inner.list() + } + fn list_by_service(&self, service_name: &str) -> Result, DomainError> { + self.inner.list_by_service(service_name) + } + fn delete(&self, id: &AccountId) -> Result<(), DomainError> { + self.inner.delete(id) + } +} + +pub(super) fn valid_account(id: &str) -> Account { + let mut account = Account::new( + AccountId::new(id), + "vortex-mod-1fichier".into(), + format!("user-{id}"), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + account +} + +pub(super) fn download(account_id: AccountId) -> Download { + Download::new( + DownloadId(1), + Url::new("https://1fichier.com/?abc123").unwrap(), + "file.zip".into(), + "/tmp/file.zip".into(), + ) + .with_module_name("vortex-mod-1fichier".into()) + .with_account_id(account_id) +} + +pub(super) fn handler( + repo: Arc, + credentials: Arc, + plugin: Arc, + events: Arc, +) -> Arc { + handler_with_downloads( + repo, + credentials, + plugin, + events, + Arc::new(InMemoryDownloadRepo::new()), + ) +} + +pub(super) fn handler_with_downloads( + repo: Arc, + credentials: Arc, + plugin: Arc, + events: Arc, + downloads: 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( + repo, + credentials, + plugin, + events, + clock, + Arc::new(AccountOperationLocks::default()), + downloads, + Arc::new(FixedConfigStore), + rotator, + )) +} diff --git a/src-tauri/src/application/commands/resolve_premium_source_tests.rs b/src-tauri/src/application/commands/resolve_premium_source_tests.rs new file mode 100644 index 00000000..cf5ead4c --- /dev/null +++ b/src-tauri/src/application/commands/resolve_premium_source_tests.rs @@ -0,0 +1,477 @@ +use std::sync::atomic::Ordering; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +use super::*; +use crate::application::commands::tests_support::{ + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, InMemoryDownloadRepo, +}; +use crate::domain::event::DomainEvent; +use crate::domain::model::account::AccountStatus; +use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, DownloadRepository, DownloadSourceResolver, +}; + +#[path = "resolve_premium_source_test_support.rs"] +mod support; +use support::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resolves_the_direct_url_only_when_the_engine_requests_it() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let events = Arc::new(CapturingEventBus::new()); + let account = valid_account("account-1"); + repo.save(&account).unwrap(); + credentials.store_password(account.id(), "api-key").unwrap(); + let download = download(account.id().clone()); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + downloads.seed(download.clone()); + let resolver = handler_with_downloads( + repo.clone(), + credentials, + plugin.clone(), + events.clone(), + downloads, + ); + + let source = tokio::task::spawn_blocking(move || resolver.resolve(&download)) + .await + .unwrap() + .unwrap(); + + assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + assert_eq!(plugin.calls.lock().unwrap()[0].2, "api-key"); + assert_eq!( + repo.find_by_id(account.id()) + .unwrap() + .unwrap() + .traffic_left(), + Some(90) + ); + assert!( + events + .snapshot() + .iter() + .any(|event| matches!(event, DomainEvent::AccountUpdated { id } if id == account.id())) + ); +} + +#[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 [ + ( + Some("quota-key"), + AccountStatus::QuotaExhausted, + vec!["quota-key", "working-key"], + ), + ( + Some("expired-key"), + AccountStatus::Expired, + vec!["expired-key", "working-key"], + ), + ( + Some("zero-traffic-key"), + AccountStatus::QuotaExhausted, + vec!["zero-traffic-key", "working-key"], + ), + (None, AccountStatus::MissingCredential, vec!["working-key"]), + ] { + assert_jit_rotation(password, expected_status, &expected_calls).await; + } +} + +async fn assert_jit_rotation( + primary_password: Option<&str>, + expected_status: AccountStatus, + expected_calls: &[&str], +) { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let primary = valid_account("primary"); + let backup = valid_account("backup"); + repo.save(&primary).unwrap(); + repo.save(&backup).unwrap(); + if let Some(password) = primary_password { + credentials.store_password(primary.id(), password).unwrap(); + } + credentials + .store_password(backup.id(), "working-key") + .unwrap(); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + let queued = download(primary.id().clone()); + let queued_id = queued.id(); + downloads.seed(queued.clone()); + let resolver = handler_with_downloads( + repo.clone(), + credentials, + plugin.clone(), + Arc::new(CapturingEventBus::new()), + downloads.clone(), + ); + + let source = tokio::task::spawn_blocking(move || resolver.resolve(&queued)) + .await + .unwrap() + .expect("backup account resolves the source"); + + assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + let calls = plugin.calls.lock().unwrap(); + assert_eq!( + calls.iter().map(|call| call.2.as_str()).collect::>(), + expected_calls + ); + assert_eq!( + repo.find_by_id(primary.id()).unwrap().unwrap().status(), + expected_status + ); + assert_eq!( + downloads + .find_by_id(queued_id) + .unwrap() + .unwrap() + .account_id(), + Some(backup.id()) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rotates_across_two_failed_accounts_before_committing_the_third() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let primary = valid_account("a-primary"); + let secondary = valid_account("b-secondary"); + let tertiary = valid_account("c-tertiary"); + for account in [&primary, &secondary, &tertiary] { + repo.save(account).unwrap(); + } + credentials + .store_password(primary.id(), "quota-key") + .unwrap(); + credentials + .store_password(secondary.id(), "quota-key") + .unwrap(); + credentials + .store_password(tertiary.id(), "working-key") + .unwrap(); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + let queued = download(primary.id().clone()); + let download_id = queued.id(); + downloads.seed(queued.clone()); + let resolver = handler_with_downloads( + repo, + credentials, + plugin.clone(), + Arc::new(CapturingEventBus::new()), + downloads.clone(), + ); + + let source = tokio::task::spawn_blocking(move || resolver.resolve(&queued)) + .await + .unwrap() + .expect("third account resolves the source"); + + assert_eq!(source.request_url(), "https://1.1.1.1/short-lived-token"); + assert_eq!( + plugin + .calls + .lock() + .unwrap() + .iter() + .map(|call| call.2.as_str()) + .collect::>(), + ["quota-key", "quota-key", "working-key"] + ); + assert_eq!( + downloads + .find_by_id(download_id) + .unwrap() + .unwrap() + .account_id(), + Some(tertiary.id()) + ); +} + +struct CasBarrierDownloadRepo { + inner: InMemoryDownloadRepo, + entered: Mutex>>, + release: Arc<(Mutex, Condvar)>, +} + +impl DownloadRepository for CasBarrierDownloadRepo { + fn find_by_id( + &self, + id: crate::domain::model::download::DownloadId, + ) -> Result, DomainError> { + self.inner.find_by_id(id) + } + + fn save(&self, download: &crate::domain::model::download::Download) -> Result<(), DomainError> { + self.inner.save(download) + } + + fn delete(&self, id: crate::domain::model::download::DownloadId) -> Result<(), DomainError> { + self.inner.delete(id) + } + + fn compare_and_set_account_reference( + &self, + id: crate::domain::model::download::DownloadId, + expected: &crate::domain::model::account::AccountId, + replacement: &crate::domain::model::account::AccountId, + ) -> 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(); + } + self.inner + .compare_and_set_account_reference(id, expected, replacement) + } + + fn find_by_state( + &self, + state: crate::domain::model::download::DownloadState, + ) -> Result, DomainError> { + self.inner.find_by_state(state) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_jit_rotation_does_not_recreate_a_concurrently_deleted_download() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let primary = valid_account("primary"); + let backup = valid_account("backup"); + repo.save(&primary).unwrap(); + repo.save(&backup).unwrap(); + credentials + .store_password(primary.id(), "quota-key") + .unwrap(); + credentials + .store_password(backup.id(), "working-key") + .unwrap(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let downloads = Arc::new(CasBarrierDownloadRepo { + inner: InMemoryDownloadRepo::new(), + entered: Mutex::new(Some(entered_tx)), + release: release.clone(), + }); + let stale_snapshot = download(primary.id().clone()); + let download_id = stale_snapshot.id(); + downloads.save(&stale_snapshot).unwrap(); + let resolver = handler_with_downloads( + repo, + credentials, + plugin.clone(), + Arc::new(CapturingEventBus::new()), + downloads.clone(), + ); + + let resolving = tokio::task::spawn_blocking(move || resolver.resolve(&stale_snapshot)); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(1))) + .await + .unwrap() + .expect("rotation reached account CAS"); + downloads.delete(download_id).unwrap(); + let (released, condition) = &*release; + *released.lock().unwrap() = true; + condition.notify_all(); + let result = resolving.await.unwrap(); + + assert!(matches!(result, Err(DomainError::NotFound(_)))); + assert!(downloads.find_by_id(download_id).unwrap().is_none()); + assert_eq!(plugin.calls.lock().unwrap().len(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn initial_success_rejects_a_concurrent_account_reassociation_without_rotating() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let primary = valid_account("primary"); + let replacement = valid_account("replacement"); + repo.save(&primary).unwrap(); + repo.save(&replacement).unwrap(); + credentials + .store_password(primary.id(), "working-key") + .unwrap(); + credentials + .store_password(replacement.id(), "working-key") + .unwrap(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let downloads = Arc::new(CasBarrierDownloadRepo { + inner: InMemoryDownloadRepo::new(), + entered: Mutex::new(Some(entered_tx)), + release: release.clone(), + }); + let snapshot = download(primary.id().clone()); + let download_id = snapshot.id(); + downloads.save(&snapshot).unwrap(); + let resolver = handler_with_downloads( + repo, + credentials, + plugin.clone(), + Arc::new(CapturingEventBus::new()), + downloads.clone(), + ); + + let resolving = tokio::task::spawn_blocking(move || resolver.resolve(&snapshot)); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(1))) + .await + .unwrap() + .expect("resolution reached account CAS"); + assert!( + downloads + .inner + .compare_and_set_account_reference(download_id, primary.id(), replacement.id()) + .unwrap() + ); + let (released, condition) = &*release; + *released.lock().unwrap() = true; + condition.notify_all(); + + let error = resolving.await.unwrap().expect_err("association conflict"); + assert!(matches!(error, DomainError::ValidationError(_))); + assert_eq!(plugin.calls.lock().unwrap().len(), 1); + assert_eq!( + downloads + .find_by_id(download_id) + .unwrap() + .unwrap() + .account_id(), + Some(replacement.id()) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_jit_rotation_updates_only_the_account_reference() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let plugin = Arc::new(DirectUrlPlugin::new()); + let primary = valid_account("primary"); + let backup = valid_account("backup"); + repo.save(&primary).unwrap(); + repo.save(&backup).unwrap(); + credentials + .store_password(primary.id(), "quota-key") + .unwrap(); + credentials + .store_password(backup.id(), "working-key") + .unwrap(); + let downloads = Arc::new(InMemoryDownloadRepo::new()); + let mut stale_snapshot = download(primary.id().clone()); + stale_snapshot.start().unwrap(); + let mut paused = stale_snapshot.clone(); + paused.pause().unwrap(); + downloads.seed(paused); + let download_id = stale_snapshot.id(); + let resolver = handler_with_downloads( + repo, + credentials, + plugin, + Arc::new(CapturingEventBus::new()), + downloads.clone(), + ); + + tokio::task::spawn_blocking(move || resolver.resolve(&stale_snapshot)) + .await + .unwrap() + .expect("backup account resolves the source"); + + let persisted = downloads.find_by_id(download_id).unwrap().unwrap(); + assert_eq!( + persisted.state(), + crate::domain::model::download::DownloadState::Paused + ); + assert_eq!(persisted.account_id(), Some(backup.id())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_jit_resolution_preserves_cooldown_when_no_backup_exists() { + 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(), "cooldown-key") + .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("the typed cooldown must reach the engine"); + + assert!(matches!(error, DomainError::AccountCooldown)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_credential_persistence_failure_is_not_suppressed() { + let repo = Arc::new(SaveFailingRepo::new()); + let account = valid_account("account-1"); + repo.save(&account).unwrap(); + repo.fail_saves.store(true, Ordering::SeqCst); + let resolver = handler( + repo, + Arc::new(FakeAccountCredentialStore::new()), + 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("storage failure must win over missing credential"); + + assert!(matches!(error, DomainError::StorageError(_))); +} + +#[tokio::test] +async fn missing_credential_state_is_persisted_before_frontend_event() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let account = valid_account("account-1"); + repo.save(&account).unwrap(); + let events = Arc::new(CapturingEventBus::new()); + let resolver = handler( + repo.clone(), + Arc::new(FakeAccountCredentialStore::new()), + Arc::new(DirectUrlPlugin::new()), + events.clone(), + ); + + let error = resolver + .handle(ResolvePremiumSourceCommand::new( + account.id().clone(), + account.service_name().to_string(), + "https://1fichier.com/?abc123".into(), + )) + .await + .expect_err("missing credential"); + + assert!(matches!(error, DomainError::NotFound(_))); + assert_eq!( + repo.find_by_id(account.id()).unwrap().unwrap().status(), + AccountStatus::MissingCredential + ); + assert!(events.snapshot().iter().any(|event| matches!( + event, + DomainEvent::AccountValidationFailed { id, .. } if id == account.id() + ))); +} diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index cfb44d16..b723d322 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -10,6 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use crate::application::command_bus::CommandBus; use crate::application::error::AppError; use crate::domain::event::DomainEvent; +use crate::domain::model::account::{AccountId, AccountStatus}; use crate::domain::model::download::{Download, DownloadId, Url}; use crate::domain::model::http::HttpResponse; @@ -64,6 +65,16 @@ impl CommandBus { let dest = dest_dir.join(&file_name); let id = next_download_id(); + let account_lock = cmd + .account_id + .as_ref() + .map(|id| self.account_operation_lock(id)) + .transpose()?; + let _account_guard = match account_lock { + Some(lock) => Some(lock.lock_owned().await), + None => None, + }; + self.validate_download_account(cmd.module_name.as_deref(), cmd.account_id.as_ref())?; // Append to the back of the queue so a freshly added download // does not jump in front of items the user has explicitly // reordered (default queue_position 0 would sort before 1..N). @@ -78,6 +89,12 @@ impl CommandBus { if let Some(hostname) = cmd.source_hostname_override { download = download.with_source_hostname(hostname); } + if let Some(module_name) = cmd.module_name { + download = download.with_module_name(module_name); + } + if let Some(account_id) = cmd.account_id { + download = download.with_account_id(account_id); + } self.download_repo().save(&download)?; self.event_bus() @@ -85,6 +102,56 @@ impl CommandBus { Ok(id) } + + pub(super) fn validate_download_account( + &self, + module_name: Option<&str>, + account_id: Option<&AccountId>, + ) -> Result<(), AppError> { + let (Some(module_name), Some(account_id)) = (module_name, account_id) else { + if account_id.is_some() { + return Err(AppError::Validation( + "account association requires a plugin name".into(), + )); + } + return Ok(()); + }; + let repo = self + .account_repo() + .ok_or_else(|| AppError::Validation("account repository not configured".into()))?; + let store = self.account_credential_store().ok_or_else(|| { + AppError::Validation("account credential store not configured".into()) + })?; + let mut account = repo.find_by_id(account_id)?.ok_or_else(|| { + AppError::NotFound(format!("account {} not found", account_id.as_str())) + })?; + if account.service_name() != module_name { + return Err(AppError::Validation(format!( + "account {} is not compatible with plugin {module_name}", + account_id.as_str() + ))); + } + if store.get_password(account_id)?.is_none() { + account.set_status(AccountStatus::MissingCredential); + self.save_account_availability(repo, &account)?; + self.event_bus() + .publish(DomainEvent::AccountValidationFailed { + id: account_id.clone(), + error: "Account credential is unavailable".into(), + }); + return Err(AppError::NotFound(format!( + "credential for account {} not found", + account_id.as_str() + ))); + } + if !account.is_selectable(self.account_now_ms()?) { + return Err(AppError::Validation(format!( + "account {} is not available", + account_id.as_str() + ))); + } + Ok(()) + } } /// Generate a restart-safe, collision-resistant download ID that fits @@ -144,7 +211,8 @@ mod tests { use std::sync::Mutex; use crate::application::command_bus::CommandBus; - use crate::application::commands::StartDownloadCommand; + 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}; @@ -159,6 +227,41 @@ mod tests { }; 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>, } @@ -433,6 +536,8 @@ mod tests { 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(); @@ -448,6 +553,198 @@ mod tests { 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())); @@ -457,6 +754,8 @@ mod tests { destination: None, filename: None, source_hostname_override: None, + module_name: None, + account_id: None, }; let result = bus.handle_start_download(cmd).await; @@ -472,6 +771,8 @@ mod tests { 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(); @@ -521,6 +822,8 @@ mod tests { 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(); @@ -554,6 +857,8 @@ mod tests { 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(); @@ -576,6 +881,8 @@ mod tests { 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(); diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index 71eb1a6a..5fb1e3a1 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -6,10 +6,11 @@ use std::collections::HashMap; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use crate::application::command_bus::CommandBus; +use crate::application::services::account_operation_locks::AccountOperationLocks; use crate::application::test_support::NoopHistoryRepo; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; @@ -24,20 +25,23 @@ use crate::domain::model::package::{Package, PackageId}; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::{ AccountCredentialStore, AccountRepository, AccountValidator, ArchiveExtractor, - ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, EventBus, - FileStorage, HttpClient, PackageRepository, PassphraseCodec, PluginLoader, ValidationOutcome, + ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, + EventBus, FileStorage, HttpClient, PackageRepository, PassphraseCodec, PluginLoader, + ValidationOutcome, }; // ── In-memory account repository ───────────────────────────────────── pub(crate) struct InMemoryAccountRepo { store: Mutex>, + save_count: AtomicUsize, } impl InMemoryAccountRepo { pub(crate) fn new() -> Self { Self { store: Mutex::new(HashMap::new()), + save_count: AtomicUsize::new(0), } } @@ -50,6 +54,10 @@ impl InMemoryAccountRepo { }); accounts } + + pub(crate) fn save_count(&self) -> usize { + self.save_count.load(Ordering::SeqCst) + } } impl AccountRepository for InMemoryAccountRepo { @@ -58,6 +66,7 @@ impl AccountRepository for InMemoryAccountRepo { } fn save(&self, account: &Account) -> Result<(), DomainError> { + self.save_count.fetch_add(1, Ordering::SeqCst); let mut guard = self.store.lock().unwrap(); for (id, existing) in guard.iter() { if id != account.id() @@ -72,7 +81,7 @@ impl AccountRepository for InMemoryAccountRepo { } } let stored = match guard.get(account.id()) { - Some(existing) => Account::reconstruct( + Some(existing) => Account::reconstruct_with_status( account.id().clone(), account.service_name().to_string(), account.username().to_string(), @@ -83,6 +92,8 @@ impl AccountRepository for InMemoryAccountRepo { account.valid_until(), account.last_validated(), existing.created_at(), + account.status(), + account.exhausted_until(), ), None => account.clone(), }; @@ -196,6 +207,15 @@ impl AccountCredentialStore for FakeAccountCredentialStore { pub(crate) struct FakeAccountValidator { behavior: Mutex>, + calls: Mutex>, +} + +struct FixedAccountClock; + +impl Clock for FixedAccountClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } } #[derive(Clone)] @@ -204,12 +224,14 @@ pub(crate) enum ValidatorBehavior { Reject(String), Missing, Storage(String), + Domain(DomainError), } impl FakeAccountValidator { pub(crate) fn new() -> Self { Self { behavior: Mutex::new(HashMap::new()), + calls: Mutex::new(Vec::new()), } } @@ -219,15 +241,24 @@ impl FakeAccountValidator { .unwrap() .insert(service_name.to_string(), behavior); } + + pub(crate) fn calls(&self) -> Vec<(String, String, String)> { + self.calls.lock().unwrap().clone() + } } impl AccountValidator for FakeAccountValidator { fn validate( &self, service_name: &str, - _username: &str, - _password: &str, + username: &str, + password: &str, ) -> Result { + self.calls.lock().unwrap().push(( + service_name.to_string(), + username.to_string(), + password.to_string(), + )); let behavior = self .behavior .lock() @@ -237,11 +268,15 @@ impl AccountValidator for FakeAccountValidator { .unwrap_or(ValidatorBehavior::Missing); match behavior { ValidatorBehavior::Ok(outcome) => Ok(outcome), - ValidatorBehavior::Reject(msg) => Ok(ValidationOutcome::rejected(msg)), + ValidatorBehavior::Reject(msg) => Ok(ValidationOutcome::rejected( + crate::domain::model::account::AccountStatus::InvalidCredentials, + msg, + )), ValidatorBehavior::Missing => Err(DomainError::NotFound(format!( "no plugin for service {service_name}" ))), ValidatorBehavior::Storage(msg) => Err(DomainError::StorageError(msg)), + ValidatorBehavior::Domain(error) => Err(error), } } } @@ -668,6 +703,23 @@ impl DownloadRepository for InMemoryDownloadRepo { Ok(()) } + fn compare_and_set_account_reference( + &self, + id: DownloadId, + expected: &AccountId, + replacement: &AccountId, + ) -> Result { + let mut store = self.store.lock().unwrap(); + let Some(download) = store.get_mut(&id) else { + return Ok(false); + }; + if download.account_id() != Some(expected) { + return Ok(false); + } + *download = download.clone().with_account_id(replacement.clone()); + Ok(true) + } + fn find_by_state(&self, state: DownloadState) -> Result, DomainError> { Ok(self .store @@ -751,13 +803,33 @@ pub(crate) fn build_account_bus( validator: Option>, codec: Option>, ) -> CommandBus { + build_account_bus_with_plugin_loader( + account_repo, + credential_store, + event_bus, + validator, + codec, + Arc::new(StubPluginLoader), + ) +} + +pub(crate) fn build_account_bus_with_plugin_loader( + account_repo: Arc, + credential_store: Arc, + event_bus: Arc, + validator: Option>, + codec: Option>, + plugin_loader: Arc, +) -> CommandBus { + let clock: Arc = Arc::new(FixedAccountClock); + let locks = Arc::new(AccountOperationLocks::default()); let mut bus = CommandBus::new( Arc::new(StubDownloadRepo), Arc::new(StubDownloadEngine), event_bus, Arc::new(StubFileStorage), Arc::new(StubHttpClient), - Arc::new(StubPluginLoader), + plugin_loader, Arc::new(StubConfigStore), Arc::new(StubCredentialStore), Arc::new(StubClipboardObserver), @@ -766,7 +838,9 @@ pub(crate) fn build_account_bus( None, ) .with_account_repo(account_repo) - .with_account_credential_store(credential_store); + .with_account_credential_store(credential_store) + .with_account_clock(clock) + .with_account_operation_locks(locks); if let Some(v) = validator { bus = bus.with_account_validator(v); diff --git a/src-tauri/src/application/commands/update_account.rs b/src-tauri/src/application/commands/update_account.rs index e31ba1c8..d10bb260 100644 --- a/src-tauri/src/application/commands/update_account.rs +++ b/src-tauri/src/application/commands/update_account.rs @@ -13,6 +13,11 @@ use crate::application::command_bus::CommandBus; use crate::application::error::AppError; use crate::domain::event::DomainEvent; use crate::domain::model::account::Account; +use crate::domain::ports::driven::ValidationOutcome; + +use super::validate_account::{ + apply_validation, publish_validation, validate_credentials_blocking, validation_error_to_app, +}; impl CommandBus { pub async fn handle_update_account( @@ -25,6 +30,8 @@ impl CommandBus { let store = self.account_credential_store().ok_or_else(|| { AppError::Validation("account credential store not configured".into()) })?; + let operation_lock = self.account_operation_lock(&cmd.id)?; + let _operation_guard = operation_lock.lock().await; let account = repo .find_by_id(&cmd.id)? @@ -49,7 +56,7 @@ impl CommandBus { return Err(AppError::Validation("password must not be empty".into())); } - let next = Account::reconstruct( + let mut next = Account::reconstruct_with_status( account.id().clone(), account.service_name().to_string(), username, @@ -60,6 +67,8 @@ impl CommandBus { account.valid_until(), account.last_validated(), account.created_at(), + account.status(), + account.exhausted_until(), ); // Capture the previous password BEFORE persisting the new row // so a keyring-rotation failure can restore it. The @@ -73,60 +82,98 @@ impl CommandBus { None }; - repo.save(&next)?; + let validation = if let Some(validator) = self.account_validator_arc() { + let password = match cmd.patch.password.as_deref() { + Some(password) => Some(password.to_string()), + None => store.get_password(&cmd.id)?, + }; + let attempt = match password { + Some(password) => { + validate_credentials_blocking(validator, next.clone(), password).await? + } + None => super::validate_account::AccountValidationAttempt { + outcome: ValidationOutcome::rejected( + crate::domain::model::account::AccountStatus::MissingCredential, + format!("no stored password for account {}", cmd.id.as_str()), + ), + error: None, + }, + }; + next = apply_validation(&next, &attempt.outcome, cmd.now_ms); + Some(attempt) + } else { + None + }; - // Apply password rotation after the row is persisted. If the - // keyring write fails we roll the row back to the original so - // callers never observe a row that says "password rotated" while - // the keyring still holds the previous secret. - if let Some(pw) = cmd.patch.password - && let Err(e) = store.store_password(&cmd.id, &pw) + if let Some(password) = cmd.patch.password.as_deref() + && let Err(error) = store.store_password(&cmd.id, password) { - if let Err(rollback_err) = repo.save(&account) { - tracing::warn!( - account_id = %cmd.id.as_str(), - keyring_error = %e, - rollback_error = %rollback_err, - "keyring rotation failed and row rollback also failed; row metadata diverges from keyring" - ); + restore_password(store, &cmd.id, previous_password.as_deref(), &error); + return Err(error.into()); + } + + if let Err(error) = self.save_account_availability(repo, &next) { + if cmd.patch.password.is_some() { + restore_password(store, &cmd.id, previous_password.as_deref(), &error); } - // Restore the previous password (or wipe the entry if the - // account had none) so a partially-completed write doesn't - // leave a half-rotated credential in the keyring. - let restore_result = match previous_password { - Some(prev) => store.store_password(&cmd.id, &prev), - None => store.delete_password(&cmd.id), - }; - if let Err(restore_err) = restore_result { + if let Err(rollback_error) = self.save_account_availability(repo, &account) { tracing::warn!( account_id = %cmd.id.as_str(), - keyring_error = %e, - restore_error = %restore_err, - "keyring rotation failed and the password-restore step also failed; keyring may hold a partially rotated secret" + save_error = %error, + rollback_error = %rollback_error, + "account row save failed and rollback also failed" ); } - return Err(e.into()); + return Err(error.into()); } - self.event_bus() - .publish(DomainEvent::AccountUpdated { id: cmd.id }); + .publish(DomainEvent::AccountUpdated { id: cmd.id.clone() }); + if let Some(attempt) = validation { + publish_validation(self, cmd.id, &attempt.outcome); + if let Some(error) = attempt.error { + return Err(validation_error_to_app(error)); + } + } Ok(()) } } +fn restore_password( + store: &dyn crate::domain::ports::driven::AccountCredentialStore, + id: &crate::domain::model::account::AccountId, + previous_password: Option<&str>, + cause: &crate::domain::error::DomainError, +) { + let restored = match previous_password { + Some(password) => store.store_password(id, password), + None => store.delete_password(id), + }; + if let Err(restore_error) = restored { + tracing::warn!( + account_id = %id.as_str(), + error = %cause, + restore_error = %restore_error, + "account password restore failed; keyring may be inconsistent" + ); + } +} + #[cfg(test)] mod tests { use std::sync::Arc; use super::super::{AccountPatch, AddAccountCommand, UpdateAccountCommand}; use crate::application::commands::tests_support::{ - CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, build_account_bus, + CapturingEventBus, FakeAccountCredentialStore, FakeAccountValidator, InMemoryAccountRepo, + ValidatorBehavior, build_account_bus, }; use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; - use crate::domain::model::account::{AccountId, AccountType}; - use crate::domain::ports::driven::{AccountCredentialStore, AccountRepository}; + use crate::domain::model::account::{AccountId, AccountStatus, AccountType}; + use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, ValidationOutcome, + }; fn add_command(service: &str, user: &str, pw: &str) -> AddAccountCommand { AddAccountCommand { @@ -151,6 +198,7 @@ mod tests { bus.handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { enabled: Some(false), ..AccountPatch::default() @@ -179,6 +227,7 @@ mod tests { bus.handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { password: Some("new-pw".into()), ..AccountPatch::default() @@ -190,6 +239,83 @@ mod tests { assert_eq!(creds.get_password(&id).unwrap().as_deref(), Some("new-pw")); } + #[tokio::test] + async fn test_update_account_validates_rotated_secret_and_persists_status() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Ok(ValidationOutcome::ok()), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events, Some(validator.clone()), None); + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier", "alice", "old-key")) + .await + .expect("add succeeds"); + + bus.handle_update_account(UpdateAccountCommand { + id: id.clone(), + now_ms: 1_800_000_000_000, + patch: AccountPatch { + password: Some("new-key".into()), + ..AccountPatch::default() + }, + }) + .await + .expect("update succeeds"); + + let calls = validator.calls(); + assert_eq!(calls.len(), 2, "add and update must both validate"); + assert_eq!(calls[1].2, "new-key"); + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::Valid); + assert_eq!(stored.last_validated(), Some(1_800_000_000_000)); + } + + #[tokio::test] + async fn test_update_account_returns_validator_infrastructure_error_after_persisting_outcome() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Storage("upstream unavailable".into()), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events.clone(), Some(validator), None); + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier", "alice", "old-key")) + .await + .expect("account remains configured"); + + let error = bus + .handle_update_account(UpdateAccountCommand { + id: id.clone(), + now_ms: 1_800_000_000_000, + patch: AccountPatch { + password: Some("new-key".into()), + ..AccountPatch::default() + }, + }) + .await + .expect_err("validator infrastructure failure must surface"); + + assert!(matches!( + error, + AppError::Domain(DomainError::StorageError(_)) + )); + assert_eq!( + repo.find_by_id(&id).unwrap().unwrap().status(), + AccountStatus::Error + ); + assert!(events.snapshot().iter().any(|event| matches!( + event, + DomainEvent::AccountValidationFailed { id: event_id, .. } if event_id == &id + ))); + } + #[tokio::test] async fn test_update_account_unknown_id_returns_not_found() { let repo = Arc::new(InMemoryAccountRepo::new()); @@ -200,6 +326,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: AccountId::new("missing"), + now_ms: 1_800_000_000_000, patch: AccountPatch::default(), }) .await @@ -225,6 +352,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { password: Some("new-pw".into()), enabled: Some(false), @@ -273,6 +401,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { username: Some(" ".into()), ..AccountPatch::default() @@ -299,6 +428,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { password: Some("".into()), ..AccountPatch::default() @@ -324,6 +454,7 @@ mod tests { bus.handle_update_account(UpdateAccountCommand { id: id.clone(), + now_ms: 1_800_000_000_000, patch: AccountPatch { account_type: Some(AccountType::Debrid), ..AccountPatch::default() @@ -369,6 +500,7 @@ mod tests { let err = bus .handle_update_account(UpdateAccountCommand { id: id1, + now_ms: 1_800_000_000_000, patch: AccountPatch { username: Some("bob".into()), ..AccountPatch::default() @@ -381,4 +513,41 @@ mod tests { AppError::Domain(DomainError::AlreadyExists(_)) )); } + + #[tokio::test] + async fn repo_failure_after_password_rotation_restores_the_previous_secret() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo, creds.clone(), events, None, None); + let id = bus + .handle_add_account(add_command("real-debrid", "alice", "old-pw")) + .await + .expect("first account"); + bus.handle_add_account(add_command("real-debrid", "bob", "other-pw")) + .await + .expect("conflicting account"); + + let error = bus + .handle_update_account(UpdateAccountCommand { + id: id.clone(), + now_ms: 1_800_000_000_000, + patch: AccountPatch { + username: Some("bob".into()), + password: Some("new-pw".into()), + ..AccountPatch::default() + }, + }) + .await + .expect_err("duplicate username must reject the row"); + + assert!(matches!( + error, + AppError::Domain(DomainError::AlreadyExists(_)) + )); + assert_eq!(creds.get_password(&id).unwrap().as_deref(), Some("old-pw")); + let attempts = creds.write_attempts(); + assert_eq!(attempts[attempts.len() - 2].1, "new-pw"); + assert_eq!(attempts[attempts.len() - 1].1, "old-pw"); + } } diff --git a/src-tauri/src/application/commands/validate_account.rs b/src-tauri/src/application/commands/validate_account.rs index c771c53f..6539c4b7 100644 --- a/src-tauri/src/application/commands/validate_account.rs +++ b/src-tauri/src/application/commands/validate_account.rs @@ -8,11 +8,115 @@ //! (full latency + traffic readout) without re-reading the row. use super::ValidationOutcomeDto; +use std::sync::Arc; + use crate::application::command_bus::CommandBus; use crate::application::error::AppError; +use crate::application::services::account_state::{apply_status, status_for_plugin_error}; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; -use crate::domain::model::account::Account; +use crate::domain::model::account::{Account, AccountStatus}; +use crate::domain::ports::driven::{AccountValidator, ValidationOutcome}; + +pub(super) struct AccountValidationAttempt { + pub outcome: ValidationOutcome, + pub error: Option, +} + +pub(super) fn validate_credentials( + validator: &dyn AccountValidator, + account: &Account, + password: &str, +) -> AccountValidationAttempt { + match validator.validate(account.service_name(), account.username(), password) { + Ok(outcome) => AccountValidationAttempt { + outcome, + error: None, + }, + Err(error) => match status_for_plugin_error(&error) { + Some(status) => typed_rejection(status, error), + None => AccountValidationAttempt { + outcome: ValidationOutcome::rejected(AccountStatus::Error, error.to_string()), + error: Some(error), + }, + }, + } +} + +pub(super) async fn validate_credentials_blocking( + validator: Arc, + account: Account, + password: String, +) -> Result { + tokio::task::spawn_blocking(move || { + validate_credentials(validator.as_ref(), &account, &password) + }) + .await + .map_err(|_| { + AppError::Domain(DomainError::PluginError( + "account validation worker stopped".into(), + )) + }) +} + +fn typed_rejection(status: AccountStatus, error: DomainError) -> AccountValidationAttempt { + AccountValidationAttempt { + outcome: ValidationOutcome::rejected(status, error.to_string()), + error: None, + } +} + +pub(super) fn validation_error_to_app(error: DomainError) -> AppError { + match error { + DomainError::NotFound(message) => AppError::NotFound(message), + other => other.into(), + } +} + +pub(super) fn apply_validation( + account: &Account, + outcome: &ValidationOutcome, + now_ms: u64, +) -> Account { + let mut next = account.clone(); + next.set_last_validated(now_ms); + apply_status(&mut next, outcome.status, now_ms); + if outcome.is_valid() { + if let Some(traffic_left) = outcome.traffic_left { + next.set_traffic_left(traffic_left); + } + if let Some(traffic_total) = outcome.traffic_total { + next.set_traffic_total(traffic_total); + } + next.replace_valid_until(outcome.valid_until); + } + next +} + +pub(super) fn publish_validation( + bus: &CommandBus, + id: crate::domain::model::account::AccountId, + outcome: &ValidationOutcome, +) { + if outcome.is_valid() { + bus.event_bus().publish(DomainEvent::AccountValidated { + id, + latency_ms: outcome.latency_ms, + traffic_left: outcome.traffic_left, + traffic_total: outcome.traffic_total, + valid_until: outcome.valid_until, + }); + } else { + bus.event_bus() + .publish(DomainEvent::AccountValidationFailed { + id, + error: outcome + .error_message + .clone() + .unwrap_or_else(|| "validation rejected".into()), + }); + } +} impl CommandBus { pub async fn handle_validate_account( @@ -26,116 +130,93 @@ impl CommandBus { AppError::Validation("account credential store not configured".into()) })?; let validator = self - .account_validator() + .account_validator_arc() .ok_or_else(|| AppError::Validation("account validator not configured".into()))?; + let operation_lock = self.account_operation_lock(&cmd.id)?; + let _operation_guard = operation_lock.lock().await; let account = repo .find_by_id(&cmd.id)? .ok_or_else(|| AppError::NotFound(format!("account {} not found", cmd.id.as_str())))?; - let password = store.get_password(&cmd.id)?.ok_or_else(|| { - AppError::NotFound(format!( - "no stored password for account {}", - cmd.id.as_str() - )) - })?; - - let outcome = - match validator.validate(account.service_name(), account.username(), &password) { - Ok(o) => o, - Err(DomainError::NotFound(msg)) => { - self.event_bus() - .publish(DomainEvent::AccountValidationFailed { - id: cmd.id.clone(), - error: msg.clone(), - }); - return Err(AppError::NotFound(msg)); - } - Err(other) => { - // Network failures, keyring read errors, and any - // other domain error coming back from the validator - // are surfaced as `AccountValidationFailed` so the - // UI can react identically whether the upstream - // service rejected the credentials or was simply - // unreachable. - self.event_bus() - .publish(DomainEvent::AccountValidationFailed { - id: cmd.id.clone(), - error: other.to_string(), - }); - return Err(other.into()); - } - }; - - let mut next = clone_account(&account); - next.set_last_validated(cmd.now_ms); - if outcome.valid { - if let Some(t) = outcome.traffic_left { - next.set_traffic_left(t); + let password = match store.get_password(&cmd.id)? { + Some(password) => password, + None => { + let outcome = ValidationOutcome::rejected( + AccountStatus::MissingCredential, + format!("no stored password for account {}", cmd.id.as_str()), + ); + self.save_account_availability( + repo, + &apply_validation(&account, &outcome, cmd.now_ms), + )?; + publish_validation(self, cmd.id.clone(), &outcome); + return Err(AppError::NotFound( + outcome.error_message.unwrap_or_default(), + )); } - if let Some(t) = outcome.traffic_total { - next.set_traffic_total(t); - } - if let Some(v) = outcome.valid_until { - next.set_valid_until(v); - } - } - repo.save(&next)?; - - if outcome.valid { - self.event_bus().publish(DomainEvent::AccountValidated { - id: cmd.id, - latency_ms: outcome.latency_ms, - traffic_left: outcome.traffic_left, - traffic_total: outcome.traffic_total, - valid_until: outcome.valid_until, - }); - } else { - self.event_bus() - .publish(DomainEvent::AccountValidationFailed { - id: cmd.id, - error: outcome - .error_message - .clone() - .unwrap_or_else(|| "validation rejected".into()), - }); - } + }; - Ok(outcome.into()) - } -} + let attempt = validate_credentials_blocking(validator, account.clone(), password).await?; + self.save_account_availability( + repo, + &apply_validation(&account, &attempt.outcome, cmd.now_ms), + )?; + publish_validation(self, cmd.id, &attempt.outcome); -fn clone_account(account: &Account) -> Account { - Account::reconstruct( - account.id().clone(), - account.service_name().to_string(), - account.username().to_string(), - account.account_type(), - account.is_enabled(), - account.traffic_left(), - account.traffic_total(), - account.valid_until(), - account.last_validated(), - account.created_at(), - ) + match attempt.error { + Some(error) => Err(validation_error_to_app(error)), + None => Ok(attempt.outcome.into()), + } + } } #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Condvar, Mutex}; + use std::time::Duration; - use super::super::{AddAccountCommand, ValidateAccountCommand}; + use super::super::{AddAccountCommand, DeleteAccountCommand, ValidateAccountCommand}; use crate::application::commands::tests_support::{ CapturingEventBus, FakeAccountCredentialStore, FakeAccountValidator, InMemoryAccountRepo, ValidatorBehavior, build_account_bus, }; use crate::application::error::AppError; + use crate::application::services::{AccountRotator, AccountSelector}; + use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; - use crate::domain::model::account::{AccountId, AccountType}; + use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; use crate::domain::ports::driven::{ - AccountCredentialStore, AccountRepository, ValidationOutcome, + AccountCredentialStore, AccountRepository, AccountValidator, Clock, ValidationOutcome, }; + struct ValidationClock; + + impl Clock for ValidationClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } + } + + struct BlockingValidator { + entered: Mutex>>, + release: Arc<(Mutex, Condvar)>, + } + + impl AccountValidator for BlockingValidator { + fn validate(&self, _: &str, _: &str, _: &str) -> Result { + if let Some(sender) = self.entered.lock().expect("entered mutex").take() { + sender.send(()).expect("test receiver remains alive"); + } + let (mutex, condition) = &*self.release; + let mut released = mutex.lock().expect("release mutex"); + while !*released { + released = condition.wait(released).expect("release wait"); + } + Ok(ValidationOutcome::ok()) + } + } + fn add_command(service: &str) -> AddAccountCommand { AddAccountCommand { service_name: service.into(), @@ -146,6 +227,282 @@ mod tests { } } + #[test] + fn successful_validation_clears_a_stale_expiry() { + let mut account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + account.set_valid_until(100); + + let validated = super::apply_validation(&account, &ValidationOutcome::ok(), 200); + + assert_eq!(validated.valid_until(), None); + } + + #[test] + fn temporary_validation_failure_records_a_retry_deadline() { + let account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + let outcome = ValidationOutcome::rejected( + AccountStatus::Cooldown, + DomainError::AccountCooldown.to_string(), + ); + + let validated = super::apply_validation(&account, &outcome, 200); + + assert_eq!(validated.status(), AccountStatus::Cooldown); + assert_eq!(validated.exhausted_until(), Some(60_200)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_delete_cannot_be_undone_by_a_slow_validation() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + repo.save(&account).expect("seed account"); + credentials + .store_password(account.id(), "api-key") + .expect("seed credential"); + + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let validator = Arc::new(BlockingValidator { + entered: Mutex::new(Some(entered_tx)), + release: release.clone(), + }); + let events = Arc::new(CapturingEventBus::new()); + let bus = Arc::new(build_account_bus( + repo.clone(), + credentials, + events, + Some(validator), + None, + )); + + let validating_bus = bus.clone(); + let validating = tokio::spawn(async move { + validating_bus + .handle_validate_account(ValidateAccountCommand { + id: AccountId::new("account-1"), + now_ms: 2, + }) + .await + }); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(1))) + .await + .expect("join entered wait") + .expect("validator entered"); + + let deleting_bus = bus.clone(); + let mut deleting = tokio::spawn(async move { + deleting_bus + .handle_delete_account(DeleteAccountCommand { + id: AccountId::new("account-1"), + }) + .await + }); + let deleted_while_validation_blocked = + tokio::time::timeout(Duration::from_millis(100), &mut deleting) + .await + .ok(); + + let (mutex, condition) = &*release; + *mutex.lock().expect("release mutex") = true; + condition.notify_all(); + validating + .await + .expect("validation join") + .expect("validation"); + match deleted_while_validation_blocked { + Some(result) => result.expect("delete join").expect("delete"), + None => deleting.await.expect("delete join").expect("delete"), + } + + assert!( + repo.find_by_id(account.id()) + .expect("read account") + .is_none(), + "a validation that began before deletion must not recreate the row" + ); + } + + #[tokio::test] + async fn blocking_plugin_validation_does_not_stall_the_async_worker() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + repo.save(&account).expect("seed account"); + credentials + .store_password(account.id(), "api-key") + .expect("seed credential"); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let validator = Arc::new(BlockingValidator { + entered: Mutex::new(None), + release: release.clone(), + }); + let bus = Arc::new(build_account_bus( + repo, + credentials, + Arc::new(CapturingEventBus::new()), + Some(validator), + None, + )); + let failsafe_release = release.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(500)); + let (released, condition) = &*failsafe_release; + *released.lock().expect("release mutex") = true; + condition.notify_all(); + }); + let validating = tokio::spawn(async move { + bus.handle_validate_account(ValidateAccountCommand { + id: AccountId::new("account-1"), + now_ms: 2, + }) + .await + }); + + let started = std::time::Instant::now(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + started.elapsed() < Duration::from_millis(250), + "synchronous plugin validation blocked the Tokio worker" + ); + + let (released, condition) = &*release; + *released.lock().expect("release mutex") = true; + condition.notify_all(); + validating + .await + .expect("validation join") + .expect("validation succeeds"); + } + + #[tokio::test] + async fn successful_validation_clears_the_rotator_cooldown_cache() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Ok(ValidationOutcome::ok()), + ); + let mut account = Account::reconstruct_with_status( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + Some(u64::MAX), + Some(1), + 1, + AccountStatus::Valid, + None, + ); + account.mark_exhausted(1_700_000_060_000); + repo.save(&account).expect("seed account"); + credentials + .store_password(account.id(), "api-key") + .expect("seed credential"); + let clock: Arc = Arc::new(ValidationClock); + let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo.clone(), events.clone(), clock); + let saves_before_validation = repo.save_count(); + let bus = build_account_bus(repo.clone(), credentials, events, Some(validator), None) + .with_account_rotator(rotator.clone()); + + bus.handle_validate_account(ValidateAccountCommand { + id: account.id().clone(), + now_ms: 1_700_000_000_000, + }) + .await + .expect("validation succeeds"); + + assert!(!rotator.is_exhausted(account.id()).expect("rotator state")); + assert_eq!(repo.save_count(), saves_before_validation + 1); + assert_eq!( + repo.find_by_id(account.id()).unwrap().unwrap().status(), + AccountStatus::Valid + ); + } + + #[tokio::test] + async fn temporary_validation_failure_commits_the_rotator_exclusion_immediately() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + let events = Arc::new(CapturingEventBus::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Ok(ValidationOutcome::rejected( + AccountStatus::Cooldown, + "rate limited", + )), + ); + let mut account = Account::new( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + repo.save(&account).expect("seed account"); + credentials + .store_password(account.id(), "api-key") + .expect("seed credential"); + let clock: Arc = Arc::new(ValidationClock); + let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo.clone(), events.clone(), clock); + let bus = build_account_bus(repo, credentials, events, Some(validator), None) + .with_account_rotator(rotator.clone()); + + let outcome = bus + .handle_validate_account(ValidateAccountCommand { + id: account.id().clone(), + now_ms: 1_700_000_000_000, + }) + .await + .expect("typed cooldown is a validation outcome"); + + assert!(!outcome.valid); + assert!(rotator.is_exhausted(account.id()).unwrap()); + assert!(matches!( + rotator + .next_account( + account.service_name(), + crate::domain::model::account::AccountSelectionStrategy::BestTraffic, + ) + .unwrap(), + crate::application::services::account_rotator::NextAccountOutcome::AllExhausted { .. } + )); + } + #[tokio::test] async fn test_validate_account_unknown_service_returns_not_found() { let repo = Arc::new(InMemoryAccountRepo::new()); @@ -185,7 +542,7 @@ mod tests { validator.set( "real-debrid", ValidatorBehavior::Ok(ValidationOutcome { - valid: true, + status: crate::domain::model::account::AccountStatus::Valid, latency_ms: Some(120), traffic_left: Some(50_000), traffic_total: Some(100_000), @@ -254,6 +611,7 @@ mod tests { let after = repo.find_by_id(&id).unwrap().unwrap(); assert_eq!(after.last_validated(), Some(1_900_000_000_000)); + assert_eq!(after.status(), AccountStatus::InvalidCredentials); assert!(after.traffic_left().is_none(), "no traffic on reject"); assert!( @@ -264,6 +622,36 @@ mod tests { ); } + #[tokio::test] + async fn test_validate_account_maps_typed_plugin_error_to_persisted_status() { + let repo = Arc::new(InMemoryAccountRepo::new()); + let creds = Arc::new(FakeAccountCredentialStore::new()); + let validator = Arc::new(FakeAccountValidator::new()); + validator.set( + "vortex-mod-1fichier", + ValidatorBehavior::Domain(DomainError::AccountExpired), + ); + let events = Arc::new(CapturingEventBus::new()); + let bus = build_account_bus(repo.clone(), creds, events, Some(validator), None); + let id = bus + .handle_add_account(add_command("vortex-mod-1fichier")) + .await + .expect("account remains configured"); + + let outcome = bus + .handle_validate_account(ValidateAccountCommand { + id: id.clone(), + now_ms: 1_900_000_000_000, + }) + .await + .expect("typed account failure is a validation outcome"); + + assert!(!outcome.valid); + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::Expired); + assert_eq!(stored.last_validated(), Some(1_900_000_000_000)); + } + #[tokio::test] async fn test_validate_account_storage_error_emits_validation_failed_event() { let repo = Arc::new(InMemoryAccountRepo::new()); @@ -340,5 +728,7 @@ mod tests { .await .expect_err("missing pw"); assert!(matches!(err, AppError::NotFound(_))); + let stored = repo.find_by_id(&id).unwrap().expect("account persisted"); + assert_eq!(stored.status(), AccountStatus::MissingCredential); } } diff --git a/src-tauri/src/application/queries/list_accounts.rs b/src-tauri/src/application/queries/list_accounts.rs index d9d374ec..b56312d8 100644 --- a/src-tauri/src/application/queries/list_accounts.rs +++ b/src-tauri/src/application/queries/list_accounts.rs @@ -3,9 +3,8 @@ //! Returns persisted accounts as [`AccountViewDto`] read models. //! Filters AND together — service + type + enabled all match. The DTO //! carries no password or raw secret material, so no plaintext secret -//! can leak through this read path. Non-secret identifiers (username, -//! opaque `credential_ref`) are present and intentional — only the -//! credential itself is fetched server-side via the keyring. +//! or keyring locator can leak through this read path. The credential +//! itself is fetched server-side by opaque account id. use crate::application::error::AppError; use crate::application::query_bus::QueryBus; diff --git a/src-tauri/src/application/read_models/account_view.rs b/src-tauri/src/application/read_models/account_view.rs index 7d58289f..03e3f4bf 100644 --- a/src-tauri/src/application/read_models/account_view.rs +++ b/src-tauri/src/application/read_models/account_view.rs @@ -12,12 +12,9 @@ use crate::domain::model::account::Account; /// Read model for the Accounts list and detail panels. /// -/// Mirrors the persisted columns of the `accounts` table, including the -/// non-secret [`Self::credential_ref`] (an opaque keyring URI such as -/// `keyring://service/user`). The reference itself is never a password -/// or token — the actual secret is fetched server-side from the OS -/// keyring when the "test connection" surface needs it. Passwords and -/// raw credential material never appear on this DTO. +/// Mirrors the non-secret persisted columns of the `accounts` table. +/// Passwords, tokens, and backend keyring identifiers never appear on +/// this DTO. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountViewDto { @@ -31,10 +28,8 @@ pub struct AccountViewDto { pub valid_until: Option, pub last_validated: Option, pub created_at: u64, - /// Opaque keyring URI (`keyring://service/user`) — never the - /// password itself. Lets the frontend correlate two `AccountView` - /// rows that share the same stored credential. - pub credential_ref: String, + pub status: String, + pub exhausted_until: Option, } impl From for AccountViewDto { @@ -50,7 +45,8 @@ impl From for AccountViewDto { valid_until: account.valid_until(), last_validated: account.last_validated(), created_at: account.created_at(), - credential_ref: account.credential_ref(), + status: account.status().to_string(), + exhausted_until: account.exhausted_until(), } } } @@ -115,7 +111,9 @@ mod tests { assert_eq!(dto.valid_until, Some(2_500_000_000_000)); assert_eq!(dto.last_validated, Some(1_900_000_000_000)); assert_eq!(dto.created_at, 1_700_000_000_000); - assert_eq!(dto.credential_ref, "keyring://real-debrid/alice"); + let value = serde_json::to_value(&dto).unwrap(); + assert_eq!(value["status"], "valid"); + assert!(value["exhaustedUntil"].is_null()); } #[test] @@ -133,6 +131,10 @@ mod tests { !object.contains_key("credential"), "AccountViewDto must never expose a raw credential field" ); + assert!( + !object.contains_key("credentialRef"), + "AccountViewDto must keep keyring references inside the backend" + ); } #[test] @@ -151,7 +153,8 @@ mod tests { "validUntil", "lastValidated", "createdAt", - "credentialRef", + "status", + "exhaustedUntil", ] { assert!( object.contains_key(camel_field), diff --git a/src-tauri/src/application/services/account_operation_locks.rs b/src-tauri/src/application/services/account_operation_locks.rs new file mode 100644 index 00000000..8c93f92e --- /dev/null +++ b/src-tauri/src/application/services/account_operation_locks.rs @@ -0,0 +1,58 @@ +//! Per-account serialization for metadata, keyring, and plugin operations. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, Weak}; +use tokio::sync::Mutex as AsyncMutex; + +use crate::application::error::AppError; +use crate::domain::model::account::AccountId; + +#[derive(Default)] +pub struct AccountOperationLocks { + entries: Mutex>>>, +} + +impl AccountOperationLocks { + pub fn lock_for(&self, id: &AccountId) -> Result>, AppError> { + let mut entries = self + .entries + .lock() + .map_err(|_| AppError::Validation("account operation locks mutex poisoned".into()))?; + entries.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = entries.get(id).and_then(Weak::upgrade) { + return Ok(lock); + } + let lock = Arc::new(AsyncMutex::new(())); + entries.insert(id.clone(), Arc::downgrade(&lock)); + Ok(lock) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_account_reuses_one_lock_while_distinct_accounts_do_not() { + let locks = AccountOperationLocks::default(); + let first = locks.lock_for(&AccountId::new("first")).unwrap(); + let same = locks.lock_for(&AccountId::new("first")).unwrap(); + let other = locks.lock_for(&AccountId::new("other")).unwrap(); + + assert!(Arc::ptr_eq(&first, &same)); + assert!(!Arc::ptr_eq(&first, &other)); + } + + #[test] + fn abandoned_account_locks_are_pruned() { + let locks = AccountOperationLocks::default(); + let abandoned = locks.lock_for(&AccountId::new("abandoned")).unwrap(); + drop(abandoned); + + let live = locks.lock_for(&AccountId::new("live")).unwrap(); + let _new = locks.lock_for(&AccountId::new("new")).unwrap(); + + assert_eq!(locks.entries.lock().unwrap().len(), 2); + assert!(Arc::strong_count(&live) >= 1); + } +} diff --git a/src-tauri/src/application/services/account_rotator.rs b/src-tauri/src/application/services/account_rotator.rs index f46911c6..cdeda4cf 100644 --- a/src-tauri/src/application/services/account_rotator.rs +++ b/src-tauri/src/application/services/account_rotator.rs @@ -2,19 +2,12 @@ //! //! PRD §6.4 ("Rotation si quota atteint") — when a hoster signals //! quota exhaustion (HTTP 429, `traffic_left` below threshold, …) the -//! rotator pulls the offending account out of the rotation for a -//! cooldown window, asks the [`AccountSelector`] for the next best -//! candidate, and emits a `DomainEvent::AccountExhausted` so the UI -//! can warn the user. +//! rotator pulls the offending account out of the rotation and asks the +//! [`AccountSelector`] for the next best candidate. //! -//! The exhaustion state is held entirely in memory: the SQLite-backed -//! [`Account`] aggregate intentionally does not persist -//! `exhausted_until` (a fresh `Account::reconstruct` always returns -//! `exhausted_until == None`). Storing it in a process-local map means -//! a restart wipes the cooldown — that is the desired behaviour for a -//! 5-to-15 minute window. Persisting it would need a new SQLite column -//! plus a purge job, neither of which buys correctness when the -//! upstream hoster will simply re-send the same 429. +//! Cooldown status and deadline are persisted on the [`Account`] so the UI +//! and a restarted process observe the same availability. Command handlers +//! own aggregate writes; the in-memory map is only a selection cache. //! //! Concurrency: the map sits behind a `std::sync::Mutex`. Every public //! method that takes the lock surfaces a poisoned mutex as @@ -27,11 +20,11 @@ use std::sync::{Arc, Mutex}; use crate::application::error::AppError; use crate::application::services::AccountSelector; +use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; -use crate::domain::model::account::{Account, AccountId, AccountSelectionStrategy}; -use crate::domain::ports::driven::AccountRepository; +use crate::domain::model::account::{Account, AccountId, AccountSelectionStrategy, AccountStatus}; use crate::domain::ports::driven::clock::Clock; -use crate::domain::ports::driven::event_bus::EventBus; +use crate::domain::ports::driven::{AccountRepository, EventBus}; /// Outcome of [`AccountRotator::next_account`]. /// @@ -51,7 +44,32 @@ pub enum NextAccountOutcome { /// `Waiting` until `next_eligible_at_ms` (Unix epoch ms — the /// earliest cooldown deadline among the exhausted set) so the /// scheduler can retry without busy-looping. - AllExhausted { next_eligible_at_ms: u64 }, + AllExhausted { + next_eligible_at_ms: u64, + reason: AccountExhaustionReason, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountExhaustionReason { + Quota, + Cooldown, +} + +impl AccountExhaustionReason { + pub fn into_domain_error(self) -> DomainError { + match self { + Self::Quota => DomainError::AccountQuotaExceeded, + Self::Cooldown => DomainError::AccountCooldown, + } + } + + fn from_status(status: AccountStatus) -> Self { + match status { + AccountStatus::Cooldown => Self::Cooldown, + _ => Self::Quota, + } + } } impl NextAccountOutcome { @@ -76,6 +94,10 @@ pub struct AccountRotator { repo: Arc, event_bus: Arc, clock: Arc, + /// Serializes persisted availability changes with account selection. + /// The cache and repository remain separate stores, but every transition + /// that can affect selection crosses this single boundary. + availability_transition: Mutex<()>, /// `account_id → cooldown deadline (Unix epoch ms)`. An entry whose /// deadline is `<= now_ms` is considered expired and pruned on the /// next read. This avoids needing a background sweeper. @@ -94,6 +116,7 @@ impl AccountRotator { repo, event_bus, clock, + availability_transition: Mutex::new(()), exhausted: Mutex::new(HashMap::new()), }) } @@ -110,17 +133,18 @@ impl AccountRotator { service_name: &str, strategy: AccountSelectionStrategy, ) -> Result { + let transition = self.lock_availability_transition()?; let now_ms = self.now_ms(); let mut exhausted_ids = self.snapshot_exhausted(now_ms)?; - // Linearise with concurrent `mark_exhausted` / `clear_exhausted`: + // Linearise with command-handler cache updates: // - On every pick, re-check the chosen id under the lock and // retry with that id added to the exclude list when a - // parallel `mark_exhausted` landed in the gap. + // parallel committed cooldown landed in the gap. // - When the selector exhausts options, re-snapshot the // cooldown map and retry once more if any id from the full // current exclude list (initial snapshot ∪ race-pushed ids) - // has since been cleared via `clear_exhausted` or - // `record_traffic_refresh`. Otherwise we'd return + // has since been cleared after successful validation. Otherwise + // we'd return // `AllExhausted` while a live account is in fact selectable. loop { let picked = self.selector.select_best_excluding_quiet( @@ -129,23 +153,30 @@ impl AccountRotator { &exhausted_ids, )?; if let Some(account) = picked { - let still_available = { + let cache_available = { let guard = self.lock_exhausted()?; guard .get(account.id()) .is_none_or(|deadline| now_ms >= *deadline) }; - if still_available { + let persisted = self.repo.find_by_id(account.id())?; + if cache_available + && let Some(persisted) = persisted + && persisted.service_name() == service_name + && persisted.is_selectable(now_ms) + { // Emit AccountSelected only on the committed pick, not // on probes that lose the race to a parallel - // `mark_exhausted`. Otherwise UI/telemetry would see + // a committed cooldown. Otherwise UI/telemetry would see // "selected" for an account never returned to the caller. + let selected_id = persisted.id().clone(); + drop(transition); self.event_bus.publish(DomainEvent::AccountSelected { - id: account.id().clone(), + id: selected_id, service_name: service_name.to_string(), strategy: strategy.to_string(), }); - return Ok(NextAccountOutcome::Picked(account)); + return Ok(NextAccountOutcome::Picked(persisted)); } exhausted_ids.push(account.id().clone()); continue; @@ -160,65 +191,60 @@ impl AccountRotator { } exhausted_ids = fresh; } - // No pick after stable re-snapshot. Decide between NoneAvailable - // and AllExhausted by looking at the repo directly: if there's - // at least one enabled, non-expired account for this service, - // the rotation is the blocker, not the absence of credentials. + // No pick after stable re-snapshot. Only validated accounts with a + // temporary quota/cooldown state count as exhausted. Invalid, + // missing, expired, and unverified accounts are unavailable. let candidates = self.repo.list_by_service(service_name)?; let live: Vec<&Account> = candidates .iter() - .filter(|a| a.is_enabled() && !a.is_expired(now_ms)) + .filter(|account| { + account.is_enabled() && account.is_premium() && !account.is_expired(now_ms) + }) + .filter(|account| match account.status() { + AccountStatus::Valid => true, + AccountStatus::QuotaExhausted | AccountStatus::Cooldown => { + account.is_exhausted(now_ms) + } + AccountStatus::Unverified + | AccountStatus::InvalidCredentials + | AccountStatus::MissingCredential + | AccountStatus::Expired + | AccountStatus::Error => false, + }) .collect(); if live.is_empty() { return Ok(NextAccountOutcome::NoneAvailable); } - let next_eligible_at_ms = self.earliest_deadline_for_service(&live, now_ms)?; + let (next_eligible_at_ms, reason) = self.earliest_exhaustion_for_service(&live, now_ms)?; Ok(NextAccountOutcome::AllExhausted { next_eligible_at_ms, + reason, }) } - /// Mark `account_id` as quota-exhausted for `ttl_secs` seconds. - /// Callers pass a hoster-specific cooldown (typical range: a few - /// hundred seconds for free plans, longer for daily caps). Emits - /// [`DomainEvent::AccountExhausted`] carrying the committed deadline. - /// - /// If a cooldown entry already exists and its deadline is further - /// in the future than the proposed one, the existing deadline - /// wins. This prevents a short retry-driven TTL from accidentally - /// shortening a longer daily-cap cooldown set by a previous - /// signal. - pub fn mark_exhausted( - &self, - account_id: &AccountId, - service_name: &str, - ttl_secs: u64, - ) -> Result<(), AppError> { - let now_ms = self.now_ms(); - let proposed = now_ms.saturating_add(ttl_secs.saturating_mul(1_000)); - let committed = { - let mut guard = self.lock_exhausted()?; - let final_deadline = match guard.get(account_id) { - Some(existing) if *existing > proposed => *existing, - _ => proposed, - }; - guard.insert(account_id.clone(), final_deadline); - final_deadline - }; - self.event_bus.publish(DomainEvent::AccountExhausted { - id: account_id.clone(), - service_name: service_name.to_string(), - exhausted_until_ms: committed, - }); - Ok(()) - } - - /// Drop any cooldown entry for `account_id` regardless of its - /// remaining TTL. Idempotent — calling on an unknown id is a - /// no-op. - pub fn clear_exhausted(&self, account_id: &AccountId) -> Result<(), AppError> { - let mut guard = self.lock_exhausted()?; - guard.remove(account_id); + /// Persist an account and synchronize the selection cache atomically with + /// respect to `next_account`. On repository failure the cache is left + /// untouched, so the two stores never advertise an uncommitted state. + pub(crate) fn save_account(&self, account: &Account) -> Result<(), DomainError> { + let _transition = self.availability_transition.lock().map_err(|_| { + DomainError::StorageError("account availability transition mutex poisoned".into()) + })?; + let mut exhausted = self + .exhausted + .lock() + .map_err(|_| DomainError::StorageError("exhausted accounts mutex poisoned".into()))?; + self.repo.save(account)?; + match account + .exhausted_until() + .filter(|deadline| self.now_ms() < *deadline) + { + Some(deadline) => { + exhausted.insert(account.id().clone(), deadline); + } + None => { + exhausted.remove(account.id()); + } + } Ok(()) } @@ -228,11 +254,17 @@ impl AccountRotator { /// `snapshot_exhausted`. The check is read-only by design so it can /// be called from log paths without surprising state changes. pub fn is_exhausted(&self, account_id: &AccountId) -> Result { + let _transition = self.lock_availability_transition()?; let now_ms = self.now_ms(); let guard = self.lock_exhausted()?; - Ok(guard + let in_memory = guard .get(account_id) - .is_some_and(|deadline| now_ms < *deadline)) + .is_some_and(|deadline| now_ms < *deadline); + let persisted = self + .repo + .find_by_id(account_id)? + .is_some_and(|account| account.is_exhausted(now_ms)); + Ok(in_memory || persisted) } /// Hoster-agnostic quota signal. Returns `true` when an HTTP @@ -256,47 +288,38 @@ impl AccountRotator { matches!(traffic_left, Some(left) if left < threshold_bytes) } - /// Reconcile a freshly observed `traffic_left` against the - /// exhaustion map. When the upstream confirms `traffic_left` is at - /// or above `threshold_bytes`, drop the cooldown so the next - /// `next_account` call can pick the account again. When the - /// observation is below the threshold OR `None` (unknown), the - /// cooldown is left untouched — `mark_exhausted` is the canonical - /// way to extend it. - pub fn record_traffic_refresh( - &self, - account_id: &AccountId, - traffic_left: Option, - threshold_bytes: u64, - ) -> Result<(), AppError> { - let confirms_available = matches!(traffic_left, Some(left) if left >= threshold_bytes); - if !confirms_available { - return Ok(()); - } - self.clear_exhausted(account_id) - } - fn snapshot_exhausted(&self, now_ms: u64) -> Result, AppError> { let mut guard = self.lock_exhausted()?; guard.retain(|_, deadline| now_ms < *deadline); Ok(guard.keys().cloned().collect()) } - fn earliest_deadline_for_service( + fn earliest_exhaustion_for_service( &self, live_candidates: &[&Account], now_ms: u64, - ) -> Result { + ) -> Result<(u64, AccountExhaustionReason), AppError> { // Restrict the deadline scan to accounts that actually belong // to the queried service so a parallel-service entry cannot // leak its cooldown into an unrelated `AllExhausted` answer. let guard = self.lock_exhausted()?; let next = live_candidates .iter() - .filter_map(|acc| guard.get(acc.id()).copied()) - .filter(|deadline| now_ms < *deadline) - .min() - .unwrap_or(now_ms); + .filter_map(|account| { + let deadline = guard + .get(account.id()) + .copied() + .into_iter() + .chain(account.exhausted_until()) + .filter(|deadline| now_ms < *deadline) + .max()?; + Some(( + deadline, + AccountExhaustionReason::from_status(account.status()), + )) + }) + .min_by_key(|(deadline, _)| *deadline) + .unwrap_or((now_ms, AccountExhaustionReason::Quota)); Ok(next) } @@ -308,6 +331,12 @@ impl AccountRotator { .map_err(|_| AppError::Validation("exhausted accounts mutex poisoned".to_string())) } + fn lock_availability_transition(&self) -> Result, AppError> { + self.availability_transition.lock().map_err(|_| { + AppError::Validation("account availability transition mutex poisoned".to_string()) + }) + } + fn now_ms(&self) -> u64 { self.clock.now_unix_secs().saturating_mul(1_000) } @@ -318,20 +347,23 @@ mod tests { use super::*; use crate::application::services::AccountSelector; use crate::domain::error::DomainError; - use crate::domain::model::account::{Account, AccountType}; + use crate::domain::model::account::{Account, AccountStatus, AccountType}; use crate::domain::ports::driven::AccountRepository; use std::sync::Mutex as StdMutex; + use std::sync::atomic::{AtomicBool, Ordering}; // --- Inline mocks (mirroring account_selector tests) --- struct InMemoryRepo { accounts: StdMutex>, + fail_saves: AtomicBool, } impl InMemoryRepo { fn new(accounts: Vec) -> Self { Self { accounts: StdMutex::new(accounts), + fail_saves: AtomicBool::new(false), } } } @@ -348,6 +380,9 @@ mod tests { } fn save(&self, account: &Account) -> Result<(), DomainError> { + if self.fail_saves.load(Ordering::SeqCst) { + return Err(DomainError::StorageError("save failed".into())); + } let mut guard = self.accounts.lock().unwrap(); if let Some(existing) = guard.iter_mut().find(|a| a.id() == account.id()) { *existing = account.clone(); @@ -437,7 +472,7 @@ mod tests { } fn account(id: &str, service: &str, traffic_left: Option, enabled: bool) -> Account { - Account::reconstruct( + Account::reconstruct_with_status( AccountId::new(id), service.to_string(), format!("user-{id}"), @@ -450,9 +485,68 @@ mod tests { Some(u64::MAX), Some(0), 0, + AccountStatus::Valid, + None, ) } + trait PersistCooldownForTest { + fn mark_exhausted( + &self, + account_id: &AccountId, + service_name: &str, + ttl_secs: u64, + ) -> Result<(), AppError>; + fn clear_exhausted(&self, account_id: &AccountId) -> Result<(), AppError>; + fn record_traffic_refresh( + &self, + account_id: &AccountId, + traffic_left: Option, + threshold_bytes: u64, + ) -> Result<(), AppError>; + } + + impl PersistCooldownForTest for AccountRotator { + fn mark_exhausted( + &self, + account_id: &AccountId, + service_name: &str, + ttl_secs: u64, + ) -> Result<(), AppError> { + let mut account = self.repo.find_by_id(account_id)?.ok_or_else(|| { + AppError::NotFound(format!("account {} not found", account_id.as_str())) + })?; + if account.service_name() != service_name { + return Err(AppError::Validation("account service mismatch".into())); + } + let proposed = self.now_ms().saturating_add(ttl_secs.saturating_mul(1_000)); + let deadline = account.exhausted_until().unwrap_or_default().max(proposed); + account.mark_exhausted(deadline); + self.save_account(&account)?; + Ok(()) + } + + fn clear_exhausted(&self, account_id: &AccountId) -> Result<(), AppError> { + if let Some(mut account) = self.repo.find_by_id(account_id)? { + account.clear_exhausted(); + self.save_account(&account)?; + } + Ok(()) + } + + fn record_traffic_refresh( + &self, + account_id: &AccountId, + traffic_left: Option, + threshold_bytes: u64, + ) -> Result<(), AppError> { + if matches!(traffic_left, Some(left) if left >= threshold_bytes) { + self.clear_exhausted(account_id)?; + } + Ok(()) + } + } + fn build_rotator( accounts: Vec, clock_secs: u64, @@ -465,12 +559,126 @@ mod tests { (rotator, bus, clock) } + struct PersistThenBlockRepo { + account: StdMutex, + entered: StdMutex>>, + release: Arc<(StdMutex, std::sync::Condvar)>, + } + + impl AccountRepository for PersistThenBlockRepo { + fn find_by_id(&self, id: &AccountId) -> Result, DomainError> { + let account = self.account.lock().unwrap(); + Ok((account.id() == id).then(|| account.clone())) + } + + fn save(&self, account: &Account) -> Result<(), DomainError> { + *self.account.lock().unwrap() = account.clone(); + 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(); + } + Ok(()) + } + + fn list(&self) -> Result, DomainError> { + Ok(vec![self.account.lock().unwrap().clone()]) + } + + fn list_by_service(&self, service_name: &str) -> Result, DomainError> { + Ok(self + .list()? + .into_iter() + .filter(|account| account.service_name() == service_name) + .collect()) + } + + fn delete(&self, _: &AccountId) -> Result<(), DomainError> { + Ok(()) + } + } + + #[test] + fn selection_waits_until_persisted_exhaustion_and_cache_commit_together() { + let initial = account("a", "Uploaded", Some(50), true); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let release = Arc::new((StdMutex::new(false), std::sync::Condvar::new())); + let repo = Arc::new(PersistThenBlockRepo { + account: StdMutex::new(initial.clone()), + entered: StdMutex::new(Some(entered_tx)), + release: release.clone(), + }); + let bus = Arc::new(CollectingBus::new()); + let clock = TestClock::new(1_700_000_000); + let selector = AccountSelector::new(repo.clone(), bus.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo, bus, clock); + let mut exhausted = initial; + exhausted.mark_exhausted(1_700_000_600_000); + + let saving_rotator = rotator.clone(); + let saving = std::thread::spawn(move || saving_rotator.save_account(&exhausted)); + entered_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("persisted row reached pre-cache gap"); + let (selected_tx, selected_rx) = std::sync::mpsc::channel(); + let selecting_rotator = rotator.clone(); + let selecting = std::thread::spawn(move || { + selected_tx + .send( + selecting_rotator + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic), + ) + .unwrap(); + }); + assert!( + selected_rx + .recv_timeout(std::time::Duration::from_millis(50)) + .is_err(), + "selection must wait for the coordinated cache update" + ); + + let (released, condition) = &*release; + *released.lock().unwrap() = true; + condition.notify_all(); + saving.join().unwrap().unwrap(); + let outcome = selected_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("selection completes") + .unwrap(); + selecting.join().unwrap(); + assert!(matches!(outcome, NextAccountOutcome::AllExhausted { .. })); + } + + #[test] + fn failed_exhaustion_persistence_leaves_the_selection_cache_unchanged() { + let initial = account("a", "Uploaded", Some(50), true); + let repo = Arc::new(InMemoryRepo::new(vec![initial.clone()])); + let bus = Arc::new(CollectingBus::new()); + let clock = TestClock::new(1_700_000_000); + let selector = AccountSelector::new(repo.clone(), bus.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo.clone(), bus, clock); + let mut exhausted = initial; + exhausted.mark_exhausted(1_700_000_600_000); + repo.fail_saves.store(true, Ordering::SeqCst); + + assert!(rotator.save_account(&exhausted).is_err()); + repo.fail_saves.store(false, Ordering::SeqCst); + + let outcome = rotator + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) + .unwrap(); + assert!(matches!(outcome, NextAccountOutcome::Picked(_))); + } + // --- AC #1: 429 → rotation vers 2ème account visible --- #[test] fn test_mark_exhausted_routes_next_account_to_remaining_candidate() { let a = account("a", "Uploaded", Some(50_000_000_000), true); let b = account("b", "Uploaded", Some(40_000_000_000), true); - let (rotator, bus, _clock) = build_rotator(vec![a, b], 1_700_000_000); + let (rotator, _bus, _clock) = build_rotator(vec![a, b], 1_700_000_000); let first = rotator .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) @@ -492,13 +700,79 @@ mod tests { NextAccountOutcome::Picked(acc) => assert_eq!(acc.id().as_str(), "b"), other => panic!("expected Picked(b), got {other:?}"), } + } + + #[test] + fn test_mark_exhausted_persists_status_and_deadline() { + let a = account("a", "Uploaded", Some(50), true); + let (rotator, _bus, _clock) = build_rotator(vec![a], 1_700_000_000); - let events = bus.events(); - assert!(events.iter().any(|e| matches!( - e, - DomainEvent::AccountExhausted { id, service_name, exhausted_until_ms: _ } - if id.as_str() == "a" && service_name == "Uploaded" - ))); + rotator + .mark_exhausted(&AccountId::new("a"), "Uploaded", 600) + .expect("mark succeeds"); + + let stored = rotator + .repo + .find_by_id(&AccountId::new("a")) + .unwrap() + .expect("account remains persisted"); + assert_eq!(stored.status(), AccountStatus::QuotaExhausted); + assert_eq!(stored.exhausted_until(), Some(1_700_000_600_000)); + } + + #[test] + fn test_persisted_cooldown_survives_rotator_recreation() { + let a = account("a", "Uploaded", Some(50), true); + let (rotator, bus, clock) = build_rotator(vec![a], 1_700_000_000); + rotator + .mark_exhausted(&AccountId::new("a"), "Uploaded", 600) + .expect("mark succeeds"); + + let selector = AccountSelector::new(rotator.repo.clone(), bus.clone(), clock.clone()); + let restarted = AccountRotator::new(selector, rotator.repo.clone(), bus, clock); + let outcome = restarted + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) + .expect("rotation succeeds"); + + assert_eq!( + outcome, + NextAccountOutcome::AllExhausted { + next_eligible_at_ms: 1_700_000_600_000, + reason: AccountExhaustionReason::Quota, + } + ); + } + + #[test] + fn test_all_exhausted_preserves_the_temporary_failure_reason() { + let mut cooling_down = account("a", "Uploaded", Some(50), true); + cooling_down.mark_cooldown(1_700_000_600_000); + let (rotator, _bus, _clock) = build_rotator(vec![cooling_down], 1_700_000_000); + + let outcome = rotator + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) + .expect("rotation succeeds"); + + assert_eq!( + outcome, + NextAccountOutcome::AllExhausted { + next_eligible_at_ms: 1_700_000_600_000, + reason: AccountExhaustionReason::Cooldown, + } + ); + } + + #[test] + fn test_invalid_account_is_none_available_not_exhausted() { + let mut invalid = account("a", "Uploaded", Some(50), true); + invalid.set_status(AccountStatus::InvalidCredentials); + let (rotator, _bus, _clock) = build_rotator(vec![invalid], 1_700_000_000); + + let outcome = rotator + .next_account("Uploaded", AccountSelectionStrategy::BestTraffic) + .expect("rotation succeeds"); + + assert_eq!(outcome, NextAccountOutcome::NoneAvailable); } // --- AC #2: tous accounts 429 → AllExhausted --- @@ -521,6 +795,7 @@ mod tests { match outcome { NextAccountOutcome::AllExhausted { next_eligible_at_ms, + .. } => { let now_ms = 1_700_000_000_u64.saturating_mul(1_000); let earliest = now_ms.saturating_add(600 * 1_000); @@ -613,6 +888,13 @@ mod tests { .unwrap(); rotator.clear_exhausted(&AccountId::new("a")).unwrap(); assert!(!rotator.is_exhausted(&AccountId::new("a")).unwrap()); + let stored = rotator + .repo + .find_by_id(&AccountId::new("a")) + .unwrap() + .expect("account exists"); + assert_eq!(stored.status(), AccountStatus::Valid); + assert!(stored.exhausted_until().is_none()); } #[test] @@ -682,7 +964,7 @@ mod tests { fn test_quota_detection_to_rotation_full_flow() { let a = account("primary", "Uploaded", Some(50_000_000), true); let b = account("backup", "Uploaded", Some(500), true); - let (rotator, bus, _clock) = build_rotator(vec![a, b], 1_700_000_000); + let (rotator, _bus, _clock) = build_rotator(vec![a, b], 1_700_000_000); // Step 1: caller picks the primary (more traffic wins). let first = rotator @@ -714,16 +996,6 @@ mod tests { NextAccountOutcome::Picked(acc) => assert_eq!(acc.id().as_str(), "backup"), other => panic!("expected Picked(backup), got {other:?}"), } - - let event_count = bus - .events() - .iter() - .filter(|e| matches!(e, DomainEvent::AccountExhausted { .. })) - .count(); - assert_eq!( - event_count, 1, - "exactly one AccountExhausted should have been emitted" - ); } #[test] @@ -750,14 +1022,10 @@ mod tests { // wins, and the AccountExhausted event publishes it verbatim // so subscribers don't see a phantom shorter window. let a = account("a", "S", Some(50), true); - let (rotator, bus, clock) = build_rotator(vec![a], 1_700_000_000); - let now_ms = 1_700_000_000_u64 * 1_000; - + let (rotator, _bus, clock) = build_rotator(vec![a], 1_700_000_000); rotator .mark_exhausted(&AccountId::new("a"), "S", 600) .unwrap(); - let long_deadline = now_ms + 600 * 1_000; - rotator .mark_exhausted(&AccountId::new("a"), "S", 60) .unwrap(); @@ -771,22 +1039,6 @@ mod tests { // Advance past the long deadline; cooldown finally clears. clock.advance_secs(600); assert!(!rotator.is_exhausted(&AccountId::new("a")).unwrap()); - - let payloads: Vec = bus - .events() - .iter() - .filter_map(|e| match e { - DomainEvent::AccountExhausted { - exhausted_until_ms, .. - } => Some(*exhausted_until_ms), - _ => None, - }) - .collect(); - assert_eq!( - payloads, - vec![long_deadline, long_deadline], - "second AccountExhausted must republish the still-active longer deadline, not the shorter proposed one" - ); } /// PRD §6.4 freezes the human-facing message format. Callers that @@ -797,6 +1049,7 @@ mod tests { fn test_outcome_error_message_uses_prd_wording() { let outcome = NextAccountOutcome::AllExhausted { next_eligible_at_ms: 1_700_000_000_000, + reason: AccountExhaustionReason::Quota, }; assert_eq!( outcome.error_message("Uploaded"), @@ -838,6 +1091,7 @@ mod tests { match outcome { NextAccountOutcome::AllExhausted { next_eligible_at_ms, + .. } => { let now_ms = 1_700_000_000_u64 * 1_000; assert_eq!( diff --git a/src-tauri/src/application/services/account_selector.rs b/src-tauri/src/application/services/account_selector.rs index bc957179..614f72b3 100644 --- a/src-tauri/src/application/services/account_selector.rs +++ b/src-tauri/src/application/services/account_selector.rs @@ -4,7 +4,7 @@ //! service, the engine asks the selector for the one to use *now*. The //! selector applies the strategy currently set in `AppConfig`: //! -//! - `BestTraffic` (default): rank candidates by *enabled* → *not expired* +//! - `BestTraffic` (default): rank validated, currently selectable candidates //! → most `traffic_left` (unlimited > finite) → most recent //! `last_validated`. //! - `RoundRobin`: alternate over enabled, non-expired candidates ordered @@ -79,8 +79,7 @@ impl AccountSelector { /// Same contract as `select_best` but skips any account whose id is /// listed in `exclude`. Used by `AccountRotator` to filter out - /// quota-exhausted accounts without persisting transient state in - /// the repository. + /// quota-exhausted accounts that raced with a selection probe. /// /// Emits `NoAccountAvailable` only when the *post-exclude* eligible /// set is empty — that mirrors the caller-facing semantics: from @@ -124,7 +123,7 @@ impl AccountSelector { let now_ms = self.now_ms(); let base_eligible: Vec<&Account> = candidates .iter() - .filter(|a| a.is_enabled() && !a.is_expired(now_ms)) + .filter(|account| account.is_selectable(now_ms)) .collect(); let eligible: Vec<&Account> = base_eligible .iter() @@ -137,9 +136,21 @@ impl AccountSelector { // is reported as AllExhausted upstream and must not be // collapsed into "no account configured". if base_eligible.is_empty() { - self.event_bus.publish(DomainEvent::NoAccountAvailable { - service_name: service_name.to_string(), + let temporarily_unavailable = candidates.iter().any(|account| { + account.is_enabled() + && !account.is_expired(now_ms) + && matches!( + account.status(), + crate::domain::model::account::AccountStatus::QuotaExhausted + | crate::domain::model::account::AccountStatus::Cooldown + ) + && account.is_exhausted(now_ms) }); + if !temporarily_unavailable { + self.event_bus.publish(DomainEvent::NoAccountAvailable { + service_name: service_name.to_string(), + }); + } } return Ok(None); } @@ -224,7 +235,7 @@ enum TrafficRank { mod tests { use super::*; use crate::domain::error::DomainError; - use crate::domain::model::account::{AccountId, AccountType}; + use crate::domain::model::account::{AccountId, AccountStatus, AccountType}; use std::sync::Mutex as StdMutex; // --- Inline mocks --- @@ -332,7 +343,7 @@ mod tests { last_validated_ms: Option, enabled: bool, ) -> Account { - Account::reconstruct( + Account::reconstruct_with_status( AccountId::new(id), service.to_string(), format!("user-{id}"), @@ -343,6 +354,8 @@ mod tests { valid_until_ms, last_validated_ms, 0, + AccountStatus::Valid, + None, ) } @@ -532,6 +545,56 @@ mod tests { assert_eq!(chosen.id().as_str(), "enabled"); } + #[test] + fn test_select_best_skips_unverified_and_invalid_accounts() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let mut unverified = account( + "unverified", + "S", + Some(u64::MAX), + Some(now_ms + 1), + None, + true, + ); + unverified.set_status(AccountStatus::Unverified); + let mut invalid = account("invalid", "S", Some(u64::MAX), Some(now_ms + 1), None, true); + invalid.set_status(AccountStatus::InvalidCredentials); + let valid = account("valid", "S", Some(1), Some(now_ms + 1), None, true); + + let (selector, _bus) = build_selector(vec![unverified, invalid, valid], now_secs); + + let chosen = selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .expect("one valid account remains"); + assert_eq!(chosen.id().as_str(), "valid"); + } + + #[test] + fn test_select_best_skips_active_persisted_cooldown() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let mut cooling = account( + "cooling", + "S", + Some(u64::MAX), + Some(now_ms + 60_000), + Some(now_ms), + true, + ); + cooling.mark_cooldown(now_ms + 30_000); + + let (selector, _bus) = build_selector(vec![cooling], now_secs); + + assert!( + selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .is_none() + ); + } + // --- Acceptance criterion 4: RoundRobin alternance --- #[test] fn test_round_robin_alternates_across_eligible_accounts() { diff --git a/src-tauri/src/application/services/account_state.rs b/src-tauri/src/application/services/account_state.rs new file mode 100644 index 00000000..6d6b619b --- /dev/null +++ b/src-tauri/src/application/services/account_state.rs @@ -0,0 +1,25 @@ +//! Shared account transitions for typed plugin failures. + +use crate::domain::error::DomainError; +use crate::domain::model::account::{Account, AccountStatus}; + +pub const TEMPORARY_ACCOUNT_FAILURE_MS: u64 = 60_000; + +pub fn status_for_plugin_error(error: &DomainError) -> Option { + match error { + DomainError::AccountInvalidCredentials => Some(AccountStatus::InvalidCredentials), + DomainError::AccountExpired => Some(AccountStatus::Expired), + DomainError::AccountCooldown => Some(AccountStatus::Cooldown), + DomainError::AccountQuotaExceeded => Some(AccountStatus::QuotaExhausted), + _ => None, + } +} + +pub fn apply_status(account: &mut Account, status: AccountStatus, now_ms: u64) { + let until_ms = now_ms.saturating_add(TEMPORARY_ACCOUNT_FAILURE_MS); + match status { + AccountStatus::QuotaExhausted => account.mark_exhausted(until_ms), + AccountStatus::Cooldown => account.mark_cooldown(until_ms), + other => account.set_status(other), + } +} diff --git a/src-tauri/src/application/services/mod.rs b/src-tauri/src/application/services/mod.rs index 4b68ac78..f2953eec 100644 --- a/src-tauri/src/application/services/mod.rs +++ b/src-tauri/src/application/services/mod.rs @@ -1,5 +1,7 @@ +pub mod account_operation_locks; pub mod account_rotator; pub mod account_selector; +pub(crate) mod account_state; pub mod checksum_validator; pub mod engine_config_bridge; pub(crate) mod group_lock; @@ -12,6 +14,7 @@ pub mod split_archive_grouper; pub mod startup_recovery; pub mod url_normalizer; +pub use account_operation_locks::AccountOperationLocks; pub use account_rotator::AccountRotator; pub use account_selector::AccountSelector; pub use checksum_validator::{ChecksumOutcome, ChecksumValidatorService}; diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs index 3e42e7ee..669637b4 100644 --- a/src-tauri/src/domain/error.rs +++ b/src-tauri/src/domain/error.rs @@ -22,6 +22,10 @@ pub enum DomainError { NetworkError(String), ValidationError(String), PluginError(String), + AccountInvalidCredentials, + AccountExpired, + AccountCooldown, + AccountQuotaExceeded, AdaptiveStreamOnly, /// Computed checksum did not match the expected value. ChecksumMismatch { @@ -69,6 +73,12 @@ impl std::fmt::Display for DomainError { DomainError::PluginError(msg) => { write!(f, "Plugin error: {msg}") } + DomainError::AccountInvalidCredentials => { + write!(f, "Account credentials were rejected") + } + 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::AdaptiveStreamOnly => write!( f, "Video is only available as adaptive stream (DASH/HLS); use download_to_file" diff --git a/src-tauri/src/domain/event.rs b/src-tauri/src/domain/event.rs index 8a9f3fb3..f06b0120 100644 --- a/src-tauri/src/domain/event.rs +++ b/src-tauri/src/domain/event.rs @@ -361,11 +361,9 @@ pub enum DomainEvent { /// One of `"best_traffic"`, `"round_robin"`, `"manual"`. strategy: String, }, - /// Emitted by `AccountRotator::mark_exhausted` when a hoster signals - /// quota exhaustion (HTTP 429, low `traffic_left`, …) so the account - /// is taken out of the rotation until the cooldown expires or the - /// next traffic refresh confirms availability. Carries `service_name` - /// so the UI can group notifications per hoster. + /// Emitted after a command handler persists quota exhaustion so the + /// account is excluded until its cooldown expires. Carries + /// `service_name` so the UI can group notifications per hoster. AccountExhausted { id: AccountId, service_name: String, diff --git a/src-tauri/src/domain/model/account.rs b/src-tauri/src/domain/model/account.rs index 06aac749..d385f898 100644 --- a/src-tauri/src/domain/model/account.rs +++ b/src-tauri/src/domain/model/account.rs @@ -55,6 +55,61 @@ impl FromStr for AccountType { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AccountStatus { + #[default] + Unverified, + Valid, + InvalidCredentials, + MissingCredential, + Expired, + QuotaExhausted, + Cooldown, + Error, +} + +impl AccountStatus { + pub fn is_temporary(self) -> bool { + matches!(self, Self::QuotaExhausted | Self::Cooldown) + } +} + +impl fmt::Display for AccountStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = match self { + Self::Unverified => "unverified", + Self::Valid => "valid", + Self::InvalidCredentials => "invalid_credentials", + Self::MissingCredential => "missing_credential", + Self::Expired => "expired", + Self::QuotaExhausted => "quota_exhausted", + Self::Cooldown => "cooldown", + Self::Error => "error", + }; + f.write_str(value) + } +} + +impl FromStr for AccountStatus { + type Err = DomainError; + + fn from_str(value: &str) -> Result { + match value { + "unverified" => Ok(Self::Unverified), + "valid" => Ok(Self::Valid), + "invalid_credentials" => Ok(Self::InvalidCredentials), + "missing_credential" => Ok(Self::MissingCredential), + "expired" => Ok(Self::Expired), + "quota_exhausted" => Ok(Self::QuotaExhausted), + "cooldown" => Ok(Self::Cooldown), + "error" => Ok(Self::Error), + other => Err(DomainError::ValidationError(format!( + "invalid account status: {other}" + ))), + } + } +} + /// Strategy used by `AccountSelector` to pick the next account when several /// exist for the same service. `BestTraffic` is the default. /// @@ -115,11 +170,9 @@ pub struct Account { valid_until: Option, last_validated: Option, created_at: u64, - /// Transient quota-exhaustion deadline (Unix epoch ms). Set by the - /// `AccountRotator` when the upstream signals quota exhaustion - /// (HTTP 429, traffic below threshold, …) and cleared when a - /// fresh traffic refresh confirms the account is usable again. - /// NOT persisted in SQLite — always `None` after `reconstruct`. + status: AccountStatus, + /// Quota/rate-limit deadline (Unix epoch ms), persisted so the Accounts + /// view and a restarted selector observe the same availability state. exhausted_until: Option, } @@ -142,6 +195,7 @@ impl Account { valid_until: None, last_validated: None, created_at, + status: AccountStatus::Unverified, exhausted_until: None, } } @@ -158,6 +212,42 @@ impl Account { valid_until: Option, last_validated: Option, created_at: u64, + ) -> Self { + let status = if last_validated.is_some() { + AccountStatus::Valid + } else { + AccountStatus::Unverified + }; + Self::reconstruct_with_status( + id, + service_name, + username, + account_type, + enabled, + traffic_left, + traffic_total, + valid_until, + last_validated, + created_at, + status, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn reconstruct_with_status( + id: AccountId, + service_name: String, + username: String, + account_type: AccountType, + enabled: bool, + traffic_left: Option, + traffic_total: Option, + valid_until: Option, + last_validated: Option, + created_at: u64, + status: AccountStatus, + exhausted_until: Option, ) -> Self { Self { id, @@ -170,7 +260,8 @@ impl Account { valid_until, last_validated, created_at, - exhausted_until: None, + status, + exhausted_until, } } @@ -205,10 +296,47 @@ impl Account { self.valid_until = Some(timestamp); } + pub fn replace_valid_until(&mut self, timestamp: Option) { + self.valid_until = timestamp; + } + pub fn set_last_validated(&mut self, timestamp: u64) { self.last_validated = Some(timestamp); } + pub fn set_status(&mut self, status: AccountStatus) { + self.status = status; + if !status.is_temporary() { + self.exhausted_until = None; + } + } + + pub fn status(&self) -> AccountStatus { + self.status + } + + fn mark_temporarily_unavailable(&mut self, status: AccountStatus, until_ms: u64) { + self.status = status; + self.exhausted_until = Some(until_ms); + } + + pub fn is_selectable(&self, now_ms: u64) -> bool { + if !self.enabled || !self.is_premium() || self.is_expired(now_ms) { + return false; + } + match self.status { + AccountStatus::Valid => true, + AccountStatus::QuotaExhausted | AccountStatus::Cooldown => { + self.exhausted_until.is_some_and(|until| now_ms >= until) + } + AccountStatus::Unverified + | AccountStatus::InvalidCredentials + | AccountStatus::MissingCredential + | AccountStatus::Expired + | AccountStatus::Error => false, + } + } + pub fn is_expired(&self, now: u64) -> bool { match self.valid_until { Some(expiry) => now > expiry, @@ -217,15 +345,23 @@ impl Account { } /// Mark this account as quota-exhausted until `until_ms` (Unix epoch - /// ms). Transient — never persisted in SQLite. + /// ms). Adapters persist the status and deadline with the aggregate. pub fn mark_exhausted(&mut self, until_ms: u64) { - self.exhausted_until = Some(until_ms); + self.mark_temporarily_unavailable(AccountStatus::QuotaExhausted, until_ms); + } + + /// Mark this account as rate-limited until `until_ms` (Unix epoch ms). + pub fn mark_cooldown(&mut self, until_ms: u64) { + self.mark_temporarily_unavailable(AccountStatus::Cooldown, until_ms); } /// Drop any pending quota-exhaustion marker, regardless of the /// remaining cooldown. pub fn clear_exhausted(&mut self) { self.exhausted_until = None; + if self.status.is_temporary() { + self.status = AccountStatus::Valid; + } } /// Active quota-exhaustion deadline (Unix epoch ms) when set, else @@ -245,18 +381,6 @@ impl Account { } } - /// Reference used to look up the credential in the system keyring. - /// Format: `keyring://{service_name}/{username}`. Both segments are - /// percent-encoded so reserved characters (`/`, `?`, `#`, `@`...) cannot - /// produce ambiguous refs that point at the wrong stored credential. - pub fn credential_ref(&self) -> String { - format!( - "keyring://{}/{}", - percent_encode_segment(&self.service_name), - percent_encode_segment(&self.username) - ) - } - pub fn id(&self) -> &AccountId { &self.id } @@ -294,24 +418,6 @@ impl Account { } } -/// Percent-encode a string so it can be safely embedded as a path segment in -/// `keyring://...` refs. Only RFC 3986 unreserved characters survive -/// untouched; everything else is rendered as `%XX` per UTF-8 byte. -fn percent_encode_segment(s: &str) -> String { - use std::fmt::Write; - - let mut out = String::with_capacity(s.len()); - for byte in s.bytes() { - let unreserved = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~'); - if unreserved { - out.push(byte as char); - } else { - let _ = write!(out, "%{byte:02X}"); - } - } - out -} - #[cfg(test)] mod tests { use super::*; @@ -408,55 +514,6 @@ mod tests { assert_eq!(acc.last_validated(), Some(1_700_000_500_000)); } - #[test] - fn test_account_credential_ref_uses_keyring_scheme() { - let acc = make_account(); - // `@` in `user@example.com` is reserved → percent-encoded as %40. - assert_eq!( - acc.credential_ref(), - "keyring://ExampleHost/user%40example.com" - ); - } - - #[test] - fn test_account_credential_ref_percent_encodes_reserved_chars() { - // A `/` in the service or username could otherwise collide with the - // path separator and point two distinct accounts at the same ref. - let acc = Account::new( - AccountId::new("acc-collision"), - "real-debrid/eu".to_string(), - "alice/admin".to_string(), - AccountType::Debrid, - 0, - ); - assert_eq!( - acc.credential_ref(), - "keyring://real-debrid%2Feu/alice%2Fadmin" - ); - - let other = Account::new( - AccountId::new("acc-other"), - "real-debrid".to_string(), - "eu/alice/admin".to_string(), - AccountType::Debrid, - 0, - ); - assert_ne!(acc.credential_ref(), other.credential_ref()); - } - - #[test] - fn test_account_credential_ref_handles_unicode_username() { - let acc = Account::new( - AccountId::new("acc-utf8"), - "host".to_string(), - "café".to_string(), - AccountType::Free, - 0, - ); - // `é` is `0xC3 0xA9` in UTF-8. - assert_eq!(acc.credential_ref(), "keyring://host/caf%C3%A9"); - } - #[test] fn test_account_type_round_trip_via_string() { for t in [AccountType::Free, AccountType::Premium, AccountType::Debrid] { @@ -506,6 +563,78 @@ mod tests { assert!(matches!(result, Err(DomainError::ValidationError(_)))); } + #[test] + fn test_account_status_round_trip_via_string() { + for status in [ + AccountStatus::Unverified, + AccountStatus::Valid, + AccountStatus::InvalidCredentials, + AccountStatus::MissingCredential, + AccountStatus::Expired, + AccountStatus::QuotaExhausted, + AccountStatus::Cooldown, + AccountStatus::Error, + ] { + let rendered = status.to_string(); + let parsed: AccountStatus = rendered.parse().expect("round trip"); + assert_eq!(parsed, status); + } + } + + #[test] + fn test_only_valid_or_elapsed_cooldown_accounts_are_selectable() { + let mut account = Account::new( + AccountId::new("premium-1"), + "ExampleHost".to_string(), + "user@example.com".to_string(), + AccountType::Premium, + 1_700_000_000_000, + ); + assert!(!account.is_selectable(1_000)); + + account.set_status(AccountStatus::Valid); + assert!(account.is_selectable(1_000)); + + account.set_status(AccountStatus::InvalidCredentials); + assert!(!account.is_selectable(1_000)); + + account.mark_cooldown(2_000); + assert!(!account.is_selectable(1_999)); + assert!(account.is_selectable(2_000)); + + account.mark_exhausted(3_000); + assert!(!account.is_selectable(2_999)); + assert!(account.is_selectable(3_000)); + } + + #[test] + fn test_free_account_is_never_selectable_for_premium_resolution() { + let mut account = make_account(); + account.set_status(AccountStatus::Valid); + + assert!(!account.is_selectable(1_000)); + } + + #[test] + fn test_reconstruct_with_status_preserves_operational_state() { + let account = Account::reconstruct_with_status( + AccountId::new("account-1"), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + true, + None, + None, + None, + Some(500), + 100, + AccountStatus::Cooldown, + Some(2_000), + ); + assert_eq!(account.status(), AccountStatus::Cooldown); + assert_eq!(account.exhausted_until(), Some(2_000)); + } + #[test] fn test_account_reconstruct_preserves_all_fields() { let acc = Account::reconstruct( diff --git a/src-tauri/src/domain/model/download.rs b/src-tauri/src/domain/model/download.rs index a7d8a93d..16581947 100644 --- a/src-tauri/src/domain/model/download.rs +++ b/src-tauri/src/domain/model/download.rs @@ -1,5 +1,6 @@ use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; +use crate::domain::model::account::AccountId; use crate::domain::model::checksum::ChecksumAlgorithm; use crate::domain::model::mirror::{Mirror, sort_by_priority as sort_mirrors_by_priority}; use crate::domain::model::queue::Priority; @@ -171,7 +172,7 @@ pub struct Download { protocol: String, resume_supported: bool, module_name: Option, - account_id: Option, + account_id: Option, destination_path: String, /// Empty when the download has a single source — in that case /// [`Download::active_url`] returns the canonical `url` field instead. @@ -238,7 +239,7 @@ impl Download { protocol: String, resume_supported: bool, module_name: Option, - account_id: Option, + account_id: Option, destination_path: String, mirrors: Vec, current_mirror_index: u32, @@ -314,7 +315,7 @@ impl Download { self } - pub fn with_account_id(mut self, id: u64) -> Self { + pub fn with_account_id(mut self, id: AccountId) -> Self { self.account_id = Some(id); self } @@ -506,8 +507,8 @@ impl Download { self.module_name.as_deref() } - pub fn account_id(&self) -> Option { - self.account_id + pub fn account_id(&self) -> Option<&AccountId> { + self.account_id.as_ref() } pub fn destination_path(&self) -> &str { @@ -1152,8 +1153,8 @@ mod tests { fn test_with_account_id_stores_account_link() { let d = make_download(); assert_eq!(d.account_id(), None); - let d = d.with_account_id(42); - assert_eq!(d.account_id(), Some(42)); + let d = d.with_account_id(crate::domain::model::account::AccountId::new("account-42")); + assert_eq!(d.account_id().map(|id| id.as_str()), Some("account-42")); } fn mk_mirror(host: &str, priority: u8) -> Mirror { diff --git a/src-tauri/src/domain/ports/driven/account_repository.rs b/src-tauri/src/domain/ports/driven/account_repository.rs index 346665a9..cd73bb42 100644 --- a/src-tauri/src/domain/ports/driven/account_repository.rs +++ b/src-tauri/src/domain/ports/driven/account_repository.rs @@ -1,8 +1,8 @@ //! Write repository for the `Account` aggregate (CQRS write side). //! //! Persists account metadata only. Credentials (passwords / tokens) live -//! in the OS keyring and are looked up via `Account::credential_ref()` — -//! never through this port. +//! in the OS keyring and are looked up by opaque `AccountId` — never +//! through this port. use crate::domain::error::DomainError; use crate::domain::model::account::{Account, AccountId}; diff --git a/src-tauri/src/domain/ports/driven/account_validator.rs b/src-tauri/src/domain/ports/driven/account_validator.rs index aeec1e25..fea16b86 100644 --- a/src-tauri/src/domain/ports/driven/account_validator.rs +++ b/src-tauri/src/domain/ports/driven/account_validator.rs @@ -8,6 +8,7 @@ //! error to the user. use crate::domain::error::DomainError; +use crate::domain::model::account::AccountStatus; /// Result of an account validation attempt. /// @@ -16,7 +17,7 @@ use crate::domain::error::DomainError; /// explain *why* (wrong password, expired, rate-limited, ...). #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ValidationOutcome { - pub valid: bool, + pub status: AccountStatus, pub latency_ms: Option, pub traffic_left: Option, pub traffic_total: Option, @@ -27,18 +28,22 @@ pub struct ValidationOutcome { impl ValidationOutcome { pub fn ok() -> Self { Self { - valid: true, + status: AccountStatus::Valid, ..Self::default() } } - pub fn rejected(error_message: impl Into) -> Self { + pub fn rejected(status: AccountStatus, error_message: impl Into) -> Self { Self { - valid: false, + status, error_message: Some(error_message.into()), ..Self::default() } } + + pub fn is_valid(&self) -> bool { + self.status == AccountStatus::Valid + } } /// Validates an account's credentials by attempting to connect to the @@ -64,7 +69,8 @@ mod tests { #[test] fn test_validation_outcome_ok_marks_valid_with_no_error() { let out = ValidationOutcome::ok(); - assert!(out.valid); + assert!(out.is_valid()); + assert_eq!(out.status, AccountStatus::Valid); assert!(out.error_message.is_none()); assert!(out.latency_ms.is_none()); assert!(out.traffic_left.is_none()); @@ -72,15 +78,16 @@ mod tests { #[test] fn test_validation_outcome_rejected_records_message_and_invalid_flag() { - let out = ValidationOutcome::rejected("wrong password"); - assert!(!out.valid); + let out = ValidationOutcome::rejected(AccountStatus::InvalidCredentials, "wrong password"); + assert!(!out.is_valid()); + assert_eq!(out.status, AccountStatus::InvalidCredentials); assert_eq!(out.error_message.as_deref(), Some("wrong password")); } #[test] fn test_validation_outcome_default_is_invalid_and_empty() { let out = ValidationOutcome::default(); - assert!(!out.valid); + assert!(!out.is_valid()); assert!(out.latency_ms.is_none()); assert!(out.error_message.is_none()); } diff --git a/src-tauri/src/domain/ports/driven/download_repository.rs b/src-tauri/src/domain/ports/driven/download_repository.rs index 7f0a8464..e92f693c 100644 --- a/src-tauri/src/domain/ports/driven/download_repository.rs +++ b/src-tauri/src/domain/ports/driven/download_repository.rs @@ -4,6 +4,7 @@ //! to load, persist, and delete downloads. use crate::domain::error::DomainError; +use crate::domain::model::account::AccountId; use crate::domain::model::download::{Download, DownloadId, DownloadState}; /// Persists and retrieves `Download` aggregates. @@ -38,6 +39,55 @@ pub trait DownloadRepository: Send + Sync { /// Delete a download by its identifier. fn delete(&self, id: DownloadId) -> Result<(), DomainError>; + /// Change only the account association of an existing download. + /// + /// Implementations must not insert a missing row or overwrite any other + /// aggregate field. The boolean reports whether the expected reference + /// matched and was replaced atomically. + fn compare_and_set_account_reference( + &self, + _id: DownloadId, + _expected: &AccountId, + _replacement: &AccountId, + ) -> Result { + Err(DomainError::StorageError( + "atomic account reference updates are unavailable".into(), + )) + } + /// Find all downloads in a given state. fn find_by_state(&self, state: DownloadState) -> Result, DomainError>; + + /// Whether any persisted download still depends on an account. + /// Implementations must query independently of mutable download state so + /// a concurrent state transition cannot create a false negative. + #[cfg(not(test))] + fn has_account_reference(&self, account_id: &AccountId) -> Result; + + /// Unit-test fakes are allowed a compatibility scan so every focused + /// command test does not need an unrelated persistence primitive. + #[cfg(test)] + fn has_account_reference(&self, account_id: &AccountId) -> Result { + const STATES: [DownloadState; 9] = [ + DownloadState::Queued, + DownloadState::Downloading, + DownloadState::Paused, + DownloadState::Waiting, + DownloadState::Retry, + DownloadState::Error, + DownloadState::Extracting, + DownloadState::Completed, + DownloadState::Checking, + ]; + for state in STATES { + if self + .find_by_state(state)? + .iter() + .any(|download| download.account_id() == Some(account_id)) + { + return Ok(true); + } + } + Ok(false) + } } diff --git a/src-tauri/src/domain/ports/driven/download_source_resolver.rs b/src-tauri/src/domain/ports/driven/download_source_resolver.rs new file mode 100644 index 00000000..dbabe237 --- /dev/null +++ b/src-tauri/src/domain/ports/driven/download_source_resolver.rs @@ -0,0 +1,95 @@ +//! Resolves a persisted source URL into an ephemeral download capability. + +use std::sync::{Arc, Mutex}; + +use crate::domain::error::DomainError; +use crate::domain::model::download::Download; + +#[derive(Clone, PartialEq, Eq)] +pub struct ResolvedDownloadSource { + request_url: String, +} + +impl std::fmt::Debug for ResolvedDownloadSource { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ResolvedDownloadSource()") + } +} + +impl ResolvedDownloadSource { + pub fn sensitive(request_url: String) -> Self { + Self { request_url } + } + + pub fn request_url(&self) -> &str { + &self.request_url + } +} + +/// Linearizes cancellation with resolver-side persistence. The engine can +/// return promptly while a blocking plugin call is still unwinding, and the +/// resolver uses `run_if_active` around each later write so that detached work +/// cannot commit after cancellation won. +#[derive(Clone, Default)] +pub struct ResolutionCancellation { + cancelled: Arc>, +} + +impl ResolutionCancellation { + pub fn cancel(&self) { + let mut cancelled = self + .cancelled + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *cancelled = true; + } + + pub fn is_cancelled(&self) -> bool { + *self + .cancelled + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn ensure_active(&self) -> Result<(), DomainError> { + if self.is_cancelled() { + Err(cancelled_error()) + } else { + Ok(()) + } + } + + pub fn run_if_active( + &self, + operation: impl FnOnce() -> Result, + ) -> Result { + let cancelled = self.cancelled.lock().map_err(|_| { + DomainError::PluginError("premium source cancellation state unavailable".into()) + })?; + if *cancelled { + return Err(cancelled_error()); + } + operation() + } +} + +fn cancelled_error() -> DomainError { + DomainError::PluginError("premium 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 resolve(&self, download: &Download) -> Result; + + fn resolve_cancellable( + &self, + download: &Download, + cancellation: &ResolutionCancellation, + ) -> Result { + cancellation.ensure_active()?; + let source = self.resolve(download)?; + cancellation.ensure_active()?; + Ok(source) + } +} diff --git a/src-tauri/src/domain/ports/driven/hoster_link.rs b/src-tauri/src/domain/ports/driven/hoster_link.rs new file mode 100644 index 00000000..796ffd9f --- /dev/null +++ b/src-tauri/src/domain/ports/driven/hoster_link.rs @@ -0,0 +1,53 @@ +//! Typed output of a hoster plugin extraction. + +/// One hoster file resolved by a plugin adapter. +/// +/// The adapter owns deserialisation of the plugin wire format. Application +/// services receive this std-only type and never depend on JSON field names. +#[derive(Clone, PartialEq, Eq)] +pub struct ExtractedHosterLink { + pub source_url: String, + pub filename: Option, + pub size_bytes: Option, + /// Ephemeral bearer URL produced for the selected account. + pub direct_url: Option, + pub traffic_used_bytes: Option, + pub traffic_total_bytes: Option, +} + +impl std::fmt::Debug for ExtractedHosterLink { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let direct_url = self.direct_url.as_ref().map(|_| ""); + formatter + .debug_struct("ExtractedHosterLink") + .field("source_url", &self.source_url) + .field("filename", &self.filename) + .field("size_bytes", &self.size_bytes) + .field("direct_url", &direct_url) + .field("traffic_used_bytes", &self.traffic_used_bytes) + .field("traffic_total_bytes", &self.traffic_total_bytes) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_output_redacts_the_direct_url() { + let link = ExtractedHosterLink { + source_url: "https://1fichier.com/?abc".into(), + filename: Some("file.zip".into()), + size_bytes: Some(42), + direct_url: Some("https://cdn.example/secret-token".into()), + traffic_used_bytes: None, + traffic_total_bytes: None, + }; + + let debug = format!("{link:?}"); + assert!(debug.contains("source_url")); + assert!(debug.contains("")); + assert!(!debug.contains("secret-token")); + } +} diff --git a/src-tauri/src/domain/ports/driven/mod.rs b/src-tauri/src/domain/ports/driven/mod.rs index 8a2e0a1f..c9f1a6c2 100644 --- a/src-tauri/src/domain/ports/driven/mod.rs +++ b/src-tauri/src/domain/ports/driven/mod.rs @@ -13,10 +13,12 @@ pub mod credential_store; pub mod download_engine; pub mod download_read_repository; pub mod download_repository; +pub mod download_source_resolver; pub mod event_bus; pub mod file_opener; pub mod file_storage; pub mod history_repository; +pub mod hoster_link; pub mod http_client; pub mod package_read_repository; pub mod package_repository; @@ -40,10 +42,14 @@ pub use credential_store::CredentialStore; pub use download_engine::DownloadEngine; pub use download_read_repository::DownloadReadRepository; pub use download_repository::DownloadRepository; +pub use download_source_resolver::{ + DownloadSourceResolver, ResolutionCancellation, ResolvedDownloadSource, +}; pub use event_bus::EventBus; pub use file_opener::FileOpener; pub use file_storage::FileStorage; pub use history_repository::HistoryRepository; +pub use hoster_link::ExtractedHosterLink; pub use http_client::HttpClient; pub use package_read_repository::PackageReadRepository; pub use package_repository::PackageRepository; diff --git a/src-tauri/src/domain/ports/driven/plugin_loader.rs b/src-tauri/src/domain/ports/driven/plugin_loader.rs index f3be5d16..696cf2b1 100644 --- a/src-tauri/src/domain/ports/driven/plugin_loader.rs +++ b/src-tauri/src/domain/ports/driven/plugin_loader.rs @@ -4,7 +4,10 @@ //! to determine which plugin can handle a given URL. use crate::domain::error::DomainError; +use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::ports::driven::account_validator::ValidationOutcome; +use crate::domain::ports::driven::hoster_link::ExtractedHosterLink; use crate::domain::ports::driven::plugin_store_client::OfficialPluginProvenance; /// Result of a `download_to_file` plugin call. @@ -82,6 +85,32 @@ pub trait PluginLoader: Send + Sync { )) } + /// Extract one hoster link from the exact named plugin. + /// + /// `service_name` binds account selection to the called plugin. Adapters + /// must not resolve the URL again after the application selects an account. + fn extract_hoster_link( + &self, + service_name: &str, + _url: &str, + _credential: Option<&Credential>, + ) -> Result { + Err(DomainError::NotFound(format!( + "hoster extraction not supported for service '{service_name}'" + ))) + } + + /// Validate an account through the plugin matching `service_name`. + fn validate_account( + &self, + service_name: &str, + _credential: &Credential, + ) -> Result { + Err(DomainError::NotFound(format!( + "account validation not supported for service '{service_name}'" + ))) + } + /// Fetch selectable media variants from the plugin that claims the URL. fn get_media_variants(&self, _url: &str) -> Result { Err(DomainError::NotFound( @@ -213,6 +242,31 @@ mod tests { assert!(matches!(result, Err(DomainError::NotFound(_)))); } + #[test] + fn test_extract_hoster_link_default_fails_closed() { + let loader = MinimalLoader; + let credential = Credential::new("alice", "secret"); + let result = loader.extract_hoster_link( + "vortex-mod-1fichier", + "https://1fichier.com/?abc123", + Some(&credential), + ); + assert!(matches!(result, Err(DomainError::NotFound(_)))); + } + + #[test] + fn test_validate_account_default_names_unsupported_service() { + let loader = MinimalLoader; + let credential = Credential::new("alice", "secret"); + let error = loader + .validate_account("vortex-mod-1fichier", &credential) + .expect_err("minimal loader must reject account validation"); + assert!(matches!( + error, + DomainError::NotFound(message) if message.contains("vortex-mod-1fichier") + )); + } + #[test] fn test_get_media_variants_default_returns_not_found() { let loader = MinimalLoader; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81d515b4..b148dfcf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,8 +9,8 @@ use tauri::Manager; use domain::ports::driven::{ AccountCredentialStore, AccountRepository, ArchiveExtractor, ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, DownloadReadRepository, DownloadRepository, - EventBus, FileStorage, HistoryRepository, HttpClient, PassphraseCodec, PluginLoader, - PluginReadRepository, StatsRepository, + DownloadSourceResolver, EventBus, FileStorage, HistoryRepository, HttpClient, PassphraseCodec, + PluginLoader, PluginReadRepository, StatsRepository, }; // Public API — concrete types for app wiring (main.rs, Tauri setup, integration tests) @@ -39,7 +39,7 @@ pub use adapters::driven::notification::spawn_notification_bridge; pub use adapters::driven::plugin::builtin::HttpModule; pub use adapters::driven::plugin::capabilities::SharedHostResources; pub use adapters::driven::plugin::{ - ExtismPluginLoader, GithubStoreClient, PluginRegistry, PluginWatcher, + ExtismPluginLoader, GithubStoreClient, PluginAccountValidator, PluginRegistry, PluginWatcher, }; pub use adapters::driven::scheduler::{HISTORY_PURGE_STATE_FILE, HistoryPurgeWorker, SystemClock}; pub use adapters::driven::sqlite::account_repo::SqliteAccountRepo; @@ -55,6 +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::store_refresh::{read_cache, write_cache}; pub use application::error::AppError; pub use application::query_bus::QueryBus; @@ -65,8 +66,10 @@ pub use application::read_models::{ plugin_view::PluginViewDto, stats_view::{DailyVolumeDto, HostStatsDto, ModuleStatsDto, StatsViewDto}, }; -pub use application::services::QueueManager; pub use application::services::backfill_history_for_completed_downloads; +pub use application::services::{ + AccountOperationLocks, AccountRotator, AccountSelector, QueueManager, +}; pub use domain::model::ExtractionConfig; pub use adapters::driving::tauri_ipc::{ @@ -241,6 +244,36 @@ pub fn run() { let plugin_read_repo: Arc = plugin_loader_impl.registry().clone(); let plugin_loader: Arc = plugin_loader_impl.clone(); + let account_clock: Arc = Arc::new(SystemClock); + let account_selector = AccountSelector::new( + account_repo.clone(), + event_bus.clone(), + account_clock.clone(), + ); + let account_rotator = AccountRotator::new( + account_selector.clone(), + account_repo.clone(), + event_bus.clone(), + account_clock.clone(), + ); + let account_validator = + Arc::new(PluginAccountValidator::new(plugin_loader.clone())); + let account_operation_locks = Arc::new( + application::services::account_operation_locks::AccountOperationLocks::default(), + ); + let premium_source_handler = Arc::new(ResolvePremiumSourceHandler::new( + account_repo.clone(), + account_credential_store.clone(), + plugin_loader.clone(), + event_bus.clone(), + account_clock.clone(), + account_operation_locks.clone(), + download_repo.clone(), + config_store.clone(), + account_rotator.clone(), + )); + let premium_source_resolver: Arc = + premium_source_handler.clone(); // ── Download engine ───────────────────────────────────── let initial_engine_config = config_store @@ -253,6 +286,7 @@ pub fn run() { event_bus.clone(), 4, ) + .with_source_resolver(premium_source_resolver) .with_dynamic_split( initial_engine_config.dynamic_split_enabled, initial_engine_config.dynamic_split_min_remaining_mb, @@ -383,6 +417,11 @@ pub fn run() { .with_plugin_config_store(plugin_config_store.clone()) .with_account_repo(account_repo.clone()) .with_account_credential_store(account_credential_store) + .with_account_validator(account_validator) + .with_account_selector(account_selector) + .with_account_rotator(account_rotator) + .with_account_clock(account_clock) + .with_account_operation_locks(account_operation_locks) .with_package_repo(package_repo.clone()) .with_passphrase_codec(passphrase_codec), ); diff --git a/src-tauri/tests/app_state_wiring.rs b/src-tauri/tests/app_state_wiring.rs index 9bc489df..4b628b00 100644 --- a/src-tauri/tests/app_state_wiring.rs +++ b/src-tauri/tests/app_state_wiring.rs @@ -3,16 +3,21 @@ use std::sync::Arc; +use vortex_lib::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; +use vortex_lib::domain::model::download::{Download, DownloadId, Url}; use vortex_lib::domain::ports::driven::{ - ArchiveExtractor, ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, - DownloadReadRepository, DownloadRepository, EventBus, FileStorage, HistoryRepository, - HttpClient, PluginLoader, PluginReadRepository, StatsRepository, + AccountCredentialStore, AccountRepository, ArchiveExtractor, ClipboardObserver, Clock, + ConfigStore, CredentialStore, DownloadEngine, DownloadReadRepository, DownloadRepository, + DownloadSourceResolver, EventBus, FileStorage, HistoryRepository, HttpClient, PluginLoader, + PluginReadRepository, StatsRepository, }; use vortex_lib::{ - CommandBus, ExtismPluginLoader, ExtractionConfig, FsFileStorage, NoopCredentialStore, QueryBus, - ReqwestHttpClient, SegmentedDownloadEngine, SharedHostResources, SqliteDownloadReadRepo, - SqliteDownloadRepo, SqliteHistoryRepo, SqliteStatsRepo, TokioEventBus, TomlConfigStore, - VortexArchiveExtractor, connection, + AccountOperationLocks, AccountRotator, AccountSelector, CommandBus, ExtismPluginLoader, + ExtractionConfig, FsFileStorage, KeyringAccountStore, NoopCredentialStore, + PluginAccountValidator, QueryBus, ReqwestHttpClient, ResolvePremiumSourceHandler, + SegmentedDownloadEngine, SharedHostResources, SqliteAccountRepo, SqliteDownloadReadRepo, + SqliteDownloadRepo, SqliteHistoryRepo, SqliteStatsRepo, SystemClock, TokioEventBus, + TomlConfigStore, VortexArchiveExtractor, connection, }; /// Verifies that all driven adapters satisfy their port traits and that @@ -55,7 +60,9 @@ fn test_appstate_wiring_with_in_memory_db() { let download_read_repo: Arc = Arc::new(SqliteDownloadReadRepo::new(db.clone())); let history_repo: Arc = Arc::new(SqliteHistoryRepo::new(db.clone())); - let stats_repo: Arc = Arc::new(SqliteStatsRepo::new(db)); + let stats_repo: Arc = Arc::new(SqliteStatsRepo::new(db.clone())); + let account_repo: Arc = Arc::new(SqliteAccountRepo::new(db.clone())); + let account_credential_store: Arc = Arc::new(KeyringAccountStore); // Plugin system let plugins_dir = tempfile::tempdir().expect("plugins dir"); @@ -66,47 +73,89 @@ fn test_appstate_wiring_with_in_memory_db() { ); let plugin_read_repo: Arc = plugin_loader_impl.registry().clone(); let plugin_loader: Arc = plugin_loader_impl; - - // Download engine - let download_engine: Arc = Arc::new(SegmentedDownloadEngine::new( - reqwest_client, - file_storage.clone(), + let account_clock: Arc = Arc::new(SystemClock); + let account_selector = AccountSelector::new( + account_repo.clone(), + event_bus.clone(), + account_clock.clone(), + ); + let account_rotator = AccountRotator::new( + account_selector.clone(), + account_repo.clone(), event_bus.clone(), - 4, + account_clock.clone(), + ); + 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( + account_repo.clone(), + account_credential_store.clone(), + plugin_loader.clone(), + event_bus.clone(), + account_clock.clone(), + account_operation_locks.clone(), + download_repo.clone(), + config_store.clone(), + account_rotator.clone(), )); + let premium_source_resolver: Arc = premium_source_handler.clone(); + + // Download engine + let download_engine: Arc = Arc::new( + SegmentedDownloadEngine::new(reqwest_client, file_storage.clone(), event_bus.clone(), 4) + .with_source_resolver(premium_source_resolver), + ); // Clipboard stub (no Tauri AppHandle in tests) let clipboard_observer: Arc = Arc::new(StubClipboardObserver); // CQRS buses - let command_bus = Arc::new(CommandBus::new( - download_repo, - download_engine, - event_bus, - file_storage, - http_client, - plugin_loader, - config_store, - credential_store, - clipboard_observer, - archive_extractor.clone(), - history_repo.clone(), - None, - )); + let command_bus = Arc::new( + CommandBus::new( + download_repo.clone(), + download_engine, + event_bus, + file_storage, + http_client, + plugin_loader, + config_store, + credential_store, + clipboard_observer, + archive_extractor.clone(), + history_repo.clone(), + None, + ) + .with_account_repo(account_repo.clone()) + .with_account_credential_store(account_credential_store) + .with_account_validator(account_validator) + .with_account_selector(account_selector) + .with_account_rotator(account_rotator) + .with_account_clock(account_clock) + .with_account_operation_locks(account_operation_locks), + ); - let query_bus = Arc::new(QueryBus::new( - download_read_repo, - history_repo, - stats_repo, - plugin_read_repo, - archive_extractor, - )); + let query_bus = Arc::new( + QueryBus::new( + download_read_repo, + history_repo, + stats_repo, + plugin_read_repo, + archive_extractor, + ) + .with_account_repo(account_repo.clone()), + ); // Verify command bus is wired (exercise a read through it) let _config = command_bus .config_store() .get_config() .expect("config load"); + assert!(command_bus.account_repo().is_some()); + assert!(command_bus.account_credential_store().is_some()); + assert!(command_bus.account_validator().is_some()); + assert!(command_bus.account_selector().is_some()); + assert!(command_bus.account_rotator().is_some()); + assert!(query_bus.account_repo().is_some()); // Verify query bus can execute a read query (empty DB → empty results) let downloads = query_bus @@ -122,6 +171,33 @@ fn test_appstate_wiring_with_in_memory_db() { .expect("stats query"); assert_eq!(stats.total_files, 0); assert_eq!(stats.total_downloaded_bytes, 0); + + let account_id = AccountId::new("account-round-trip"); + let mut account = Account::new( + account_id.clone(), + "vortex-mod-1fichier".into(), + "alice".into(), + AccountType::Premium, + 1, + ); + account.set_status(AccountStatus::Valid); + account_repo.save(&account).expect("save account"); + let download = Download::new( + DownloadId(42), + Url::new("https://1fichier.com/?abc123").expect("source URL"), + "archive.zip".into(), + "/tmp/archive.zip".into(), + ) + .with_module_name("vortex-mod-1fichier".into()) + .with_account_id(account_id.clone()); + download_repo.save(&download).expect("save download"); + + let reloaded = download_repo + .find_by_id(download.id()) + .expect("read download") + .expect("stored download"); + assert_eq!(reloaded.account_id(), Some(&account_id)); + assert_eq!(reloaded.url().as_str(), "https://1fichier.com/?abc123"); } /// Minimal clipboard observer stub for tests without a Tauri runtime. diff --git a/src/hooks/__tests__/useAccountEvents.test.ts b/src/hooks/__tests__/useAccountEvents.test.ts new file mode 100644 index 00000000..a34a8021 --- /dev/null +++ b/src/hooks/__tests__/useAccountEvents.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useAccountEvents } from "@/hooks/useAccountEvents"; + +vi.mock("@/hooks/useTauriEvent", () => ({ + useTauriEvent: vi.fn(), +})); + +vi.mock("@/api/client", () => ({ + queryClient: { + invalidateQueries: vi.fn(), + }, +})); + +import { queryClient } from "@/api/client"; +import { useTauriEvent } from "@/hooks/useTauriEvent"; + +describe("useAccountEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("subscribes to every event that changes account availability", () => { + renderHook(() => useAccountEvents()); + + expect(vi.mocked(useTauriEvent).mock.calls.map(([event]) => event)).toEqual([ + "account-added", + "account-updated", + "account-deleted", + "account-validated", + "account-validation-failed", + "account-exhausted", + ]); + }); + + it("invalidates account queries when automatic validation fails", () => { + vi.mocked(useTauriEvent).mockImplementation((event, callback) => { + if (event === "account-validation-failed") { + callback({ id: "account-1", error: "expired" }); + } + }); + + renderHook(() => useAccountEvents()); + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["accounts"], + }); + }); +}); diff --git a/src/hooks/useAccountEvents.ts b/src/hooks/useAccountEvents.ts new file mode 100644 index 00000000..b7bf3968 --- /dev/null +++ b/src/hooks/useAccountEvents.ts @@ -0,0 +1,23 @@ +import { queryClient } from "@/api/client"; +import { accountQueries } from "@/api/queries"; +import { useTauriEvent } from "@/hooks/useTauriEvent"; +import type { + AccountAddedPayload, + AccountExhaustedPayload, + AccountIdPayload, + AccountValidatedPayload, + AccountValidationFailedPayload, +} from "@/types/events"; + +export function useAccountEvents(): void { + const invalidateAccounts = () => { + queryClient.invalidateQueries({ queryKey: accountQueries.all() }); + }; + + useTauriEvent("account-added", invalidateAccounts); + useTauriEvent("account-updated", invalidateAccounts); + useTauriEvent("account-deleted", invalidateAccounts); + useTauriEvent("account-validated", invalidateAccounts); + useTauriEvent("account-validation-failed", invalidateAccounts); + useTauriEvent("account-exhausted", invalidateAccounts); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e302c1db..29fa665a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -661,7 +661,12 @@ "active": "Active", "expired": "Expired", "disabled": "Disabled", - "unverified": "Unverified" + "unverified": "Unverified", + "invalidCredentials": "Invalid credentials", + "missingCredential": "Credential missing", + "quotaExhausted": "Quota exhausted", + "cooldown": "Rate limited", + "error": "Validation error" }, "traffic": { "ariaLabel": "Traffic remaining", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ad925e67..bfc3b51a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -661,7 +661,12 @@ "active": "Actif", "expired": "Expiré", "disabled": "Désactivé", - "unverified": "Non vérifié" + "unverified": "Non vérifié", + "invalidCredentials": "Identifiants invalides", + "missingCredential": "Identifiant manquant", + "quotaExhausted": "Quota épuisé", + "cooldown": "Limité temporairement", + "error": "Erreur de validation" }, "traffic": { "ariaLabel": "Trafic restant", diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 30f9f031..91312456 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -7,6 +7,7 @@ import { SkipLink } from "@/components/a11y/SkipLink"; import { ROUTES } from "@/types/layout"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; import { useDownloadEvents } from "@/hooks/useDownloadEvents"; +import { useAccountEvents } from "@/hooks/useAccountEvents"; import { useAppEffects } from "@/hooks/useAppEffects"; import { tauriInvoke } from "@/api/client"; import { useTauriQuery } from "@/api/hooks"; @@ -27,6 +28,7 @@ export function AppLayout() { const updateCountByState = useDownloadStore((s) => s.updateCountByState); useDownloadProgress(); useDownloadEvents(); + useAccountEvents(); useAppEffects(); const { data: config } = useTauriQuery("settings_get", undefined, { diff --git a/src/layouts/__tests__/AppLayout.test.tsx b/src/layouts/__tests__/AppLayout.test.tsx index 37f1169d..68d88ca2 100644 --- a/src/layouts/__tests__/AppLayout.test.tsx +++ b/src/layouts/__tests__/AppLayout.test.tsx @@ -6,6 +6,7 @@ import { invoke } from "@tauri-apps/api/core"; import { AppLayout } from "../AppLayout"; import { useUiStore } from "@/stores/uiStore"; import { useSettingsStore } from "@/stores/settingsStore"; +import { useAccountEvents } from "@/hooks/useAccountEvents"; import type { AppConfig } from "@/types/settings"; import enTranslations from "@/i18n/locales/en.json"; @@ -59,6 +60,10 @@ vi.mock("@/hooks/useDownloadEvents", () => ({ useDownloadEvents: vi.fn(), })); +vi.mock("@/hooks/useAccountEvents", () => ({ + useAccountEvents: vi.fn(), +})); + vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); @@ -126,6 +131,7 @@ describe("AppLayout", () => { it("should render Sidebar, main content, and StatusBar", () => { renderAppLayout(); + expect(useAccountEvents).toHaveBeenCalledOnce(); expect(screen.getByAltText("Vortex")).toBeInTheDocument(); expect(screen.getByText("Downloads Page")).toBeInTheDocument(); expect(screen.getByText(/vortex v0\.1\.0/)).toBeInTheDocument(); diff --git a/src/types/account.ts b/src/types/account.ts index 7a8c4e4a..2f042f52 100644 --- a/src/types/account.ts +++ b/src/types/account.ts @@ -1,4 +1,13 @@ export type AccountType = "free" | "premium" | "debrid"; +export type PersistedAccountStatus = + | "unverified" + | "valid" + | "invalid_credentials" + | "missing_credential" + | "expired" + | "quota_exhausted" + | "cooldown" + | "error"; export interface AccountView { id: string; @@ -11,7 +20,8 @@ export interface AccountView { validUntil: number | null; lastValidated: number | null; createdAt: number; - credentialRef: string; + status: PersistedAccountStatus; + exhaustedUntil: number | null; } export interface AccountTraffic { @@ -38,6 +48,7 @@ export interface AddAccountInput { export interface ValidationOutcome { valid: boolean; + status: PersistedAccountStatus; latencyMs: number | null; trafficLeft: number | null; trafficTotal: number | null; diff --git a/src/types/events.ts b/src/types/events.ts index dfb19e05..5087e63a 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -66,6 +66,30 @@ export interface ClipboardMonitoringChangedPayload { enabled: boolean; } +export interface AccountIdPayload { + id: string; +} + +export interface AccountAddedPayload extends AccountIdPayload { + serviceName: string; +} + +export interface AccountValidatedPayload extends AccountIdPayload { + latencyMs: number | null; + trafficLeft: number | null; + trafficTotal: number | null; + validUntil: number | null; +} + +export interface AccountValidationFailedPayload extends AccountIdPayload { + error: string; +} + +export interface AccountExhaustedPayload extends AccountIdPayload { + serviceName: string; + exhaustedUntilMs: number; +} + export type TauriEventMap = { "download-created": DownloadIdPayload; "download-started": DownloadIdPayload; @@ -91,6 +115,12 @@ export type TauriEventMap = { "package-created": PackageCreatedPayload; "clipboard-url-detected": ClipboardUrlDetectedPayload; "clipboard-monitoring-changed": ClipboardMonitoringChangedPayload; + "account-added": AccountAddedPayload; + "account-updated": AccountIdPayload; + "account-deleted": AccountIdPayload; + "account-validated": AccountValidatedPayload; + "account-validation-failed": AccountValidationFailedPayload; + "account-exhausted": AccountExhaustedPayload; }; export type TauriEventName = keyof TauriEventMap; diff --git a/src/views/AccountsView/AccountRow.tsx b/src/views/AccountsView/AccountRow.tsx index d7243620..fc54620f 100644 --- a/src/views/AccountsView/AccountRow.tsx +++ b/src/views/AccountsView/AccountRow.tsx @@ -14,6 +14,7 @@ import { useLanguage } from "@/hooks/useLanguage"; import { formatBytes, formatDate } from "@/lib/format"; import type { AccountView } from "@/types/account"; import { deriveAccountStatus, type AccountStatus } from "./statusUtils"; +import { useAccountStatusNow } from "./useAccountStatusNow"; export interface AccountRowActions { validate: (account: AccountView) => void; @@ -33,12 +34,18 @@ const STATUS_VARIANT: Record = ["all", "debrid", "premium", "free"]; const INVALIDATE_KEYS = [accountQueries.all()] as const; @@ -167,7 +168,7 @@ export function AccountsView() { } else { toast.error( t("accounts.toast.validateInvalid", { - reason: outcome.errorMessage ?? "—", + reason: t(`accounts.status.${persistedAccountStatusToUi(outcome.status)}`), }), ); } diff --git a/src/views/AccountsView/__tests__/AccountRow.test.tsx b/src/views/AccountsView/__tests__/AccountRow.test.tsx new file mode 100644 index 00000000..4e4b5494 --- /dev/null +++ b/src/views/AccountsView/__tests__/AccountRow.test.tsx @@ -0,0 +1,97 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AccountView } from "@/types/account"; +import { AccountRow, type AccountRowActions } from "../AccountRow"; + +const actions: AccountRowActions = { + validate: vi.fn(), + edit: vi.fn(), + delete: vi.fn(), + toggleEnabled: vi.fn(), +}; + +function renderRow(account: AccountView) { + render( + + + + +
, + ); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("AccountRow", () => { + it("wakes at the cooldown deadline without requiring a backend event", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_700_000_000_000); + renderRow({ + id: "account-1", + serviceName: "vortex-mod-1fichier", + username: "alice", + accountType: "premium", + enabled: true, + trafficLeft: null, + trafficTotal: null, + validUntil: null, + lastValidated: null, + createdAt: 1_699_999_000_000, + status: "cooldown", + exhaustedUntil: 1_700_000_001_000, + }); + + expect(screen.getByText("Rate limited")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1_000)); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("should wake when a valid account reaches its entitlement expiry", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_700_000_000_000); + renderRow({ + id: "account-1", + serviceName: "vortex-mod-1fichier", + username: "alice", + accountType: "premium", + enabled: true, + trafficLeft: null, + trafficTotal: null, + validUntil: 1_700_000_001_000, + lastValidated: 1_700_000_000_000, + createdAt: 1_699_999_000_000, + status: "valid", + exhaustedUntil: null, + }); + + expect(screen.getByText("Active")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1_001)); + expect(screen.getByText("Expired")).toBeInTheDocument(); + }); + + it("updates immediately when the deadline elapses before the effect is installed", () => { + const beforeDeadline = 1_700_000_000_999; + const deadline = beforeDeadline + 1; + vi.spyOn(Date, "now").mockReturnValueOnce(beforeDeadline).mockReturnValue(deadline); + + renderRow({ + id: "account-1", + serviceName: "vortex-mod-1fichier", + username: "alice", + accountType: "premium", + enabled: true, + trafficLeft: null, + trafficTotal: null, + validUntil: null, + lastValidated: null, + createdAt: 1_699_999_000_000, + status: "cooldown", + exhaustedUntil: deadline, + }); + + expect(screen.getByText("Active")).toBeInTheDocument(); + }); +}); diff --git a/src/views/AccountsView/__tests__/AccountsView.test.tsx b/src/views/AccountsView/__tests__/AccountsView.test.tsx index 46c10278..c40315b3 100644 --- a/src/views/AccountsView/__tests__/AccountsView.test.tsx +++ b/src/views/AccountsView/__tests__/AccountsView.test.tsx @@ -36,7 +36,8 @@ function sampleAccounts(): AccountView[] { validUntil: Date.now() + 86_400_000, lastValidated: Date.now() - 60_000, createdAt: Date.now() - 86_400_000, - credentialRef: "keyring://real-debrid/alice", + status: "valid", + exhaustedUntil: null, }, { id: "ad-1", @@ -49,7 +50,8 @@ function sampleAccounts(): AccountView[] { validUntil: null, lastValidated: null, createdAt: Date.now() - 172_800_000, - credentialRef: "keyring://alldebrid/bob", + status: "unverified", + exhaustedUntil: null, }, ]; } @@ -92,6 +94,48 @@ describe("AccountsView", () => { expect(mockInvoke).toHaveBeenCalledWith("account_list", expect.objectContaining({})); }); + it("renders a typed quota status returned by the account read model", async () => { + const exhausted = { + ...sampleAccounts()[0], + status: "quota_exhausted" as const, + exhaustedUntil: Date.now() + 60_000, + }; + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") return [exhausted]; + return null; + }); + + renderView(); + + expect(await screen.findByText("Quota exhausted")).toBeInTheDocument(); + }); + + it("surfaces the typed status returned by account validation", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") return sampleAccounts(); + if (command === "account_validate") { + return { + valid: false, + status: "cooldown", + latencyMs: null, + trafficLeft: null, + trafficTotal: null, + validUntil: null, + errorMessage: "untrusted plugin detail", + }; + } + return null; + }); + + renderView(); + const row = await screen.findByTestId("account-row-rd-1"); + await userEvent.click(within(row).getByText("Validate")); + + await waitFor(() => + expect(mockToastError).toHaveBeenCalledWith("Validation failed: Rate limited"), + ); + }); + it("renders the empty state when no accounts exist", async () => { mockInvoke.mockResolvedValue([]); renderView(); diff --git a/src/views/AccountsView/__tests__/statusUtils.test.ts b/src/views/AccountsView/__tests__/statusUtils.test.ts index 3554614a..82db9f59 100644 --- a/src/views/AccountsView/__tests__/statusUtils.test.ts +++ b/src/views/AccountsView/__tests__/statusUtils.test.ts @@ -14,7 +14,8 @@ function base(overrides: Partial = {}): AccountView { validUntil: null, lastValidated: null, createdAt: 0, - credentialRef: "keyring://real-debrid/alice", + status: "unverified", + exhaustedUntil: null, ...overrides, }; } @@ -23,6 +24,7 @@ describe("deriveAccountStatus", () => { it("returns 'disabled' when the account is disabled even if otherwise valid", () => { const account = base({ enabled: false, + status: "valid", lastValidated: 1_000, validUntil: 2_000_000_000_000, }); @@ -30,7 +32,7 @@ describe("deriveAccountStatus", () => { }); it("returns 'expired' when valid_until is in the past", () => { - const account = base({ validUntil: 1, lastValidated: 0 }); + const account = base({ status: "valid", validUntil: 1, lastValidated: 0 }); expect(deriveAccountStatus(account, 100)).toBe("expired"); }); @@ -40,12 +42,37 @@ describe("deriveAccountStatus", () => { }); it("returns 'active' when enabled, validated, not expired", () => { - const account = base({ lastValidated: 1, validUntil: 100_000 }); + const account = base({ status: "valid", lastValidated: 1, validUntil: 100_000 }); expect(deriveAccountStatus(account, 1)).toBe("active"); }); it("returns 'active' when validUntil is null but lastValidated set", () => { - const account = base({ lastValidated: 1, validUntil: null }); + const account = base({ status: "valid", lastValidated: 1, validUntil: null }); expect(deriveAccountStatus(account, 1)).toBe("active"); }); + + it.each(["quota_exhausted", "cooldown"] as const)( + "returns 'active' when temporary status '%s' has elapsed", + (status) => { + expect(deriveAccountStatus(base({ status, exhaustedUntil: 100 }), 100)).toBe("active"); + }, + ); + + it.each([ + ["quota_exhausted", "quotaExhausted"], + ["cooldown", "cooldown"], + ] as const)("keeps temporary status '%s' until its deadline", (status, expected) => { + expect(deriveAccountStatus(base({ status, exhaustedUntil: 101 }), 100)).toBe(expected); + }); + + it.each([ + ["invalid_credentials", "invalidCredentials"], + ["missing_credential", "missingCredential"], + ["expired", "expired"], + ["quota_exhausted", "quotaExhausted"], + ["cooldown", "cooldown"], + ["error", "error"], + ] as const)("maps persisted '%s' to '%s'", (persisted, expected) => { + expect(deriveAccountStatus(base({ status: persisted }), 1)).toBe(expected); + }); }); diff --git a/src/views/AccountsView/statusUtils.ts b/src/views/AccountsView/statusUtils.ts index f3d4cfd3..c7535203 100644 --- a/src/views/AccountsView/statusUtils.ts +++ b/src/views/AccountsView/statusUtils.ts @@ -1,6 +1,15 @@ -import type { AccountView } from "@/types/account"; +import type { AccountView, PersistedAccountStatus } from "@/types/account"; -export type AccountStatus = "active" | "expired" | "disabled" | "unverified"; +export type AccountStatus = + | "active" + | "expired" + | "disabled" + | "unverified" + | "invalidCredentials" + | "missingCredential" + | "quotaExhausted" + | "cooldown" + | "error"; /** * Derives a UI status badge for an account row from its persisted state. @@ -13,7 +22,36 @@ export function deriveAccountStatus( nowMs: number = Date.now(), ): AccountStatus { if (!account.enabled) return "disabled"; + if (account.status === "expired") return "expired"; if (account.validUntil !== null && account.validUntil < nowMs) return "expired"; - if (account.lastValidated === null) return "unverified"; - return "active"; + if ( + (account.status === "quota_exhausted" || account.status === "cooldown") && + account.exhaustedUntil !== null && + nowMs >= account.exhaustedUntil + ) { + return "active"; + } + + return persistedAccountStatusToUi(account.status); +} + +export function persistedAccountStatusToUi(status: PersistedAccountStatus): AccountStatus { + switch (status) { + case "valid": + return "active"; + case "expired": + return "expired"; + case "invalid_credentials": + return "invalidCredentials"; + case "missing_credential": + return "missingCredential"; + case "quota_exhausted": + return "quotaExhausted"; + case "cooldown": + return "cooldown"; + case "error": + return "error"; + case "unverified": + return "unverified"; + } } diff --git a/src/views/AccountsView/useAccountStatusNow.ts b/src/views/AccountsView/useAccountStatusNow.ts new file mode 100644 index 00000000..d47b0726 --- /dev/null +++ b/src/views/AccountsView/useAccountStatusNow.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import type { PersistedAccountStatus } from "@/types/account"; + +const MAX_TIMEOUT_MS = 2_147_483_647; + +export function useAccountStatusNow( + status: PersistedAccountStatus, + exhaustedUntil: number | null, + validUntil: number | null, +): number { + const [nowMs, setNowMs] = useState(Date.now); + + useEffect(() => { + const currentTime = Date.now(); + const deadlines = statusDeadlines(status, exhaustedUntil, validUntil); + if (deadlines.some((deadline) => nowMs < deadline && deadline <= currentTime)) { + setNowMs(currentTime); + return; + } + const nextDeadline = + deadlines.filter((deadline) => deadline > currentTime).sort((a, b) => a - b)[0] ?? null; + if (nextDeadline === null) return; + const timeout = window.setTimeout( + () => setNowMs(Date.now()), + Math.min(nextDeadline - currentTime, MAX_TIMEOUT_MS), + ); + return () => window.clearTimeout(timeout); + }, [status, exhaustedUntil, validUntil, nowMs]); + + return nowMs; +} + +function statusDeadlines( + status: PersistedAccountStatus, + exhaustedUntil: number | null, + validUntil: number | null, +): number[] { + return [ + isTemporary(status) ? exhaustedUntil : null, + validUntil === null ? null : validUntil + 1, + ].filter((deadline): deadline is number => deadline !== null); +} + +function isTemporary(status: PersistedAccountStatus): boolean { + return status === "quota_exhausted" || status === "cooldown"; +} diff --git a/src/views/LinkGrabberView/LinkGrabberView.tsx b/src/views/LinkGrabberView/LinkGrabberView.tsx index 0ed43d82..6e1352fe 100644 --- a/src/views/LinkGrabberView/LinkGrabberView.tsx +++ b/src/views/LinkGrabberView/LinkGrabberView.tsx @@ -55,12 +55,9 @@ export function LinkGrabberView() { "link_check_online", ); - // Single source of truth for which URL represents a row across both - // duplicate detection and `download_start`. A redirected link with a - // `resolvedUrl` of the post-redirect canonical form must dedupe on - // that same canonical — otherwise the row could pass dedupe (since - // `originalUrl` is unique) yet `startDownload` re-queues a URL that - // already exists in active/history. + // Single source of truth for duplicate detection and in-batch + // de-duplication. `download_start` still receives `originalUrl` so a + // short-lived resolved token is never persisted or exposed over IPC. const getDuplicateKey = (link: ResolvedLink) => link.resolvedUrl ?? link.originalUrl; // Monotonic counter so a stale `link_detect_duplicates` response from @@ -198,7 +195,10 @@ export function LinkGrabberView() { onSuccess: applyResolvedBatch, }); - const { mutate: startDownload } = useTauriMutation("download_start"); + const { mutate: startDownload } = useTauriMutation< + unknown, + { url: string; moduleName: string; accountId: string | null } + >("download_start"); const { mutateAsync: startMediaDownloadAsync } = useTauriMutation< MediaDownloadResult, @@ -337,11 +337,15 @@ export function LinkGrabberView() { const started = new Set(); for (const link of links) { if (!isStartable(link)) continue; - const url = getDuplicateKey(link); - if (!url) continue; - if (skipDuplicates && started.has(url)) continue; - started.add(url); - startDownload({ url }); + const duplicateKey = getDuplicateKey(link); + if (!duplicateKey || !link.originalUrl) continue; + if (skipDuplicates && started.has(duplicateKey)) continue; + started.add(duplicateKey); + startDownload({ + url: link.originalUrl, + moduleName: link.moduleName, + accountId: link.accountId, + }); } }; diff --git a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx index 375683cd..901a70b6 100644 --- a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx @@ -182,6 +182,64 @@ describe("LinkGrabberView", () => { }); }); + it("passes the source URL and premium account association to download_start", async () => { + const sourceUrl = "ftp://1fichier.com/file.zip"; + const directUrl = "https://download.1fichier.com/token/file.zip"; + mockInvoke.mockImplementation((command) => { + if (command === "link_resolve") { + return Promise.resolve([ + { + id: "premium-link", + originalUrl: sourceUrl, + resolvedUrl: directUrl, + filename: "file.zip", + sizeBytes: 42, + status: "online", + moduleName: "vortex-mod-1fichier", + accountId: "account-uuid", + isMedia: false, + }, + ]); + } + if (command === "link_detect_duplicates") { + return Promise.resolve([ + { + url: directUrl, + isDuplicate: false, + source: null, + existingId: null, + existingFilename: null, + }, + ]); + } + return Promise.resolve(null); + }); + + const user = userEvent.setup(); + renderWithProviders(); + await user.type(screen.getByRole("textbox"), sourceUrl); + await user.click(screen.getByRole("button", { name: "Analyze Links" })); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("link_detect_duplicates", { + urls: [directUrl], + }); + }); + + await user.click(screen.getByRole("button", { name: "Start All Online" })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("download_start", { + url: sourceUrl, + moduleName: "vortex-mod-1fichier", + accountId: "account-uuid", + }); + }); + const downloadStartCalls = mockInvoke.mock.calls.filter( + ([command]) => command === "download_start", + ); + expect(JSON.stringify(downloadStartCalls)).not.toContain(directUrl); + }); + 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 1ffb6e39..9a4b1209 100644 --- a/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkRow.test.tsx @@ -17,6 +17,7 @@ const baseLink: ResolvedLink = { sizeBytes: 1024, status: "online", moduleName: "core-http", + accountId: null, isMedia: false, }; diff --git a/src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx b/src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx index c3d6274d..83f6a251 100644 --- a/src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx +++ b/src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx @@ -26,6 +26,7 @@ const mockMediaLink: ResolvedLink = { sizeBytes: null, status: "online", moduleName: "youtube", + accountId: null, isMedia: true, mediaType: "video", }; diff --git a/src/views/LinkGrabberView/__tests__/ResolvedLinksSection.test.tsx b/src/views/LinkGrabberView/__tests__/ResolvedLinksSection.test.tsx index 6367f590..5ba6627e 100644 --- a/src/views/LinkGrabberView/__tests__/ResolvedLinksSection.test.tsx +++ b/src/views/LinkGrabberView/__tests__/ResolvedLinksSection.test.tsx @@ -19,6 +19,7 @@ const MOCK_LINKS: ResolvedLink[] = [ sizeBytes: 1048576, status: "online", moduleName: "core-http", + accountId: null, isMedia: false, }, { @@ -29,6 +30,7 @@ const MOCK_LINKS: ResolvedLink[] = [ sizeBytes: null, status: "online", moduleName: "youtube", + accountId: null, isMedia: true, mediaType: "video", }, @@ -40,6 +42,7 @@ const MOCK_LINKS: ResolvedLink[] = [ sizeBytes: null, status: "offline", moduleName: "core-http", + accountId: null, isMedia: false, }, ]; @@ -148,6 +151,7 @@ describe("ResolvedLinksSection", () => { sizeBytes: null, status: "online", moduleName: "core-http", + accountId: null, isMedia: false, }, { @@ -158,6 +162,7 @@ describe("ResolvedLinksSection", () => { sizeBytes: null, status: "online", moduleName: "core-http", + accountId: null, isMedia: false, }, ]; @@ -189,6 +194,7 @@ describe("ResolvedLinksSection", () => { // Static state says "checking" but the live event has flipped to "offline". status: "checking", moduleName: "core-http", + accountId: null, isMedia: false, }, ]; @@ -217,6 +223,7 @@ describe("applyFilter", () => { sizeBytes: null, status, moduleName: "core-http", + accountId: null, isMedia: false, }); diff --git a/src/views/LinkGrabberView/types.ts b/src/views/LinkGrabberView/types.ts index 21bd44e5..7e3a0c4e 100644 --- a/src/views/LinkGrabberView/types.ts +++ b/src/views/LinkGrabberView/types.ts @@ -38,6 +38,7 @@ export interface ResolvedLink { status: LinkStatus; errorMessage?: string; moduleName: string; + accountId: string | null; isMedia: boolean; mediaType?: "video" | "audio"; /**