Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **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: <file_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:<name>` 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<ContainerLink>`, 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 `<content>` whose plaintext is `<files><file><url>BASE64URL</url><filename>BASE64NAME</filename><size>NNN</size></file>…`), **CCF v1** (Cryptload-style `CCF1\n` magic prefix + base64 AES-128-CBC ciphertext over a `<package><file>…` 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 `<metalink>` root, `<file name>` carrying `<size>`, `<hash type="sha-256|sha1|md5">` plus dashed-form variants normalised to the same `ChecksumAlgo` enum, and one or more `<url>` resolutions; first URL becomes `ContainerLink.url`, the rest stack into `ContainerLink.mirrors`). Format detection (`dispatch::detect`) is structural: CCF magic first (unambiguous), then `<metalink>` 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<aes::Aes128>` / `Decryptor` against the in-place `encrypt_padded_mut::<Pkcs7>` / `decrypt_padded_mut::<Pkcs7>` API (pre-sized `Vec<u8>` 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-`<url>` 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-`<content>` 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 `<hash type="sha-256">` 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).

- **Metalink mirrors fallback in download engine** (scope `download`, sprint task 40, PRD-v2 §P1.21 / PRD §7.1): new `domain/model/mirror::Mirror` aggregate (`Url` + priority 1..=100 + optional ISO-3166 alpha-2 country) with `sort_by_priority` ordering highest-first and ties broken by URL string for deterministic re-tries; `Download` gained `mirrors: Vec<Mirror>` + `current_mirror_index: u32` plus the builder `with_mirrors`, the cursor mutators `advance_mirror` / `reset_mirror_cursor`, and the `active_url()` getter that resolves to `mirrors[current_mirror_index]` and falls back to the canonical `url` field when the list is empty (single-source downloads see no behaviour change). Engine restructured: `start()` snapshots the mirror URL list and the persisted cursor, then a tokio loop drives `run_mirror_attempt(...)` per candidate. Each attempt gets a fresh `attempt_token = cancel_token.child_token()` so an internal segment-failure teardown cancels peers without raising the user-cancel signal — `user_cancel_token.is_cancelled()` (the `ActiveDownload` token) remains the only way `AttemptOutcome::Cancelled` is reported. On `AttemptOutcome::Failed(_)` the loop publishes `DomainEvent::MirrorSwitched { id, new_mirror_index, new_url }` and retries the next slot; on full exhaustion publishes `DownloadFailed`. New SQLite migration `m20260505_000009_add_mirrors` adds `mirrors_json TEXT NULL` + `current_mirror_index INTEGER NOT NULL DEFAULT 0` to `downloads`; the entity layer ships an adapter-side `MirrorJsonDto` so the domain stays serde-free, with `serialize_mirrors` / `deserialize_mirrors` round-tripping the list — the read repo expands the JSON into a new `MirrorView` per entry and the IPC DTO (`DownloadDetailViewDto`) carries `mirrors: Vec<MirrorViewDto>` + `currentMirrorIndex` in camelCase. Frontend gained a `MirrorView` TS interface and a `MirrorsSection` panel that renders the active mirror (host + priority + country) plus a list of alternatives with the active row highlighted via `aria-current="true"`; `DownloadDetailsPanel` only inserts the section when `mirrors.length > 0` so non-Metalink downloads keep their compact layout. `DomainEvent::MirrorSwitched` is wired through `tauri_bridge` (event name `mirror-switched`, camelCase payload `id` / `newMirrorIndex` / `newUrl`) and the download log bridge ignores it (no log line spam on every failover). Coverage: 8 `Mirror` unit tests (validation + sort), 7 `Download` mirror-handling tests (active URL, advance/exhaust/reset, sorted getter), 3 engine wiremock tests proving the three acceptance criteria — `test_three_mirrors_first_404_triggers_failover_to_second` (HEAD 404 on mirror 1 → switch to mirror 2 → DownloadCompleted, exactly one MirrorSwitched event), `test_all_mirrors_fail_publishes_download_failed` (all mirrors return 5xx → DownloadFailed, exactly one switch between the two slots, no DownloadCompleted), `test_priority_respected_highest_first` (mirrors inserted as low/high/mid → engine sorts and tries `high` first, switches once to `mid`, never reaches `low`) — plus 2 SQLite round-trip tests (`test_save_round_trips_mirrors_with_priority_and_country`, `test_save_round_trips_current_mirror_index_after_advance`), 1 detail-DTO camelCase serialisation test, and 3 Vitest tests for `MirrorsSection` (no render when empty, active highlight + alternatives count, priority/country tags). Acceptance criteria status (4/4 verified) recorded in `.claude/output/sprints/prd-v2-roadmap/tasks/40-metalink-mirrors-fallback.md`.
Expand Down
58 changes: 58 additions & 0 deletions src-tauri/src/adapters/driven/plugin/extism_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,51 @@ impl PluginLoader for ExtismPluginLoader {
})
}

fn decrypt_container(&self, bytes: &[u8]) -> Result<String, DomainError> {
// Sort by name so a deterministic plugin wins when several
// container forks are loaded side-by-side.
let mut infos: Vec<_> = self
.registry
.list_info()
.into_iter()
.filter(|i| i.is_enabled())
.filter(|i| i.category() == crate::domain::model::plugin::PluginCategory::Container)
.collect();
infos.sort_by(|a, b| a.name().cmp(b.name()));
let mut probe_error: Option<DomainError> = None;

for info in &infos {
match self.registry.function_exists(info.name(), "decrypt") {
Ok(true) => {
return self
.registry
.call_plugin_bytes(info.name(), "decrypt", bytes)
.map_err(|e| {
DomainError::PluginError(format!(
"plugin '{}' decrypt failed: {e}",
info.name()
))
});
}
Ok(false) => {}
Err(e) => {
tracing::warn!(plugin = info.name(), error = %e, "decrypt probe failed");
if probe_error.is_none() {
probe_error = Some(DomainError::PluginError(format!(
"plugin '{}' decrypt probe failed: {e}",
info.name()
)));
}
}
}
}

if let Some(err) = probe_error {
return Err(err);
}
Err(DomainError::NotFound("no container plugin loaded".into()))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn load_from_dir(&self, dir: &std::path::Path) -> Result<(), DomainError> {
let (manifest, _wasm_path) = parse_manifest(dir)?;
let name = manifest.info().name().to_string();
Expand Down Expand Up @@ -619,6 +664,19 @@ description = "Test plugin"
assert!(result.unwrap().is_none());
}

#[test]
fn test_decrypt_container_returns_not_found_when_no_plugin_loaded() {
let tmp = TempDir::new().unwrap();
let loader = ExtismPluginLoader::new(
tmp.path().to_path_buf(),
Arc::new(SharedHostResources::new()),
)
.unwrap();

let result = loader.decrypt_container(b"DLC\x00random");
assert!(matches!(result, Err(DomainError::NotFound(_))));
}

#[test]
fn test_resolve_url_builtin_http_fallback() {
let tmp = TempDir::new().unwrap();
Expand Down
32 changes: 28 additions & 4 deletions src-tauri/src/adapters/driven/plugin/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,21 +96,45 @@ impl PluginRegistry {
}

pub fn call_plugin(&self, name: &str, func: &str, input: &str) -> Result<String, DomainError> {
self.call_plugin_inner(name, func, input)
}

/// Container plugins decode binary blobs (DLC / CCF / RSDF / Metalink);
/// shipping them as `&str` would lossy-convert non-UTF-8 bytes.
pub fn call_plugin_bytes(
&self,
name: &str,
func: &str,
input: &[u8],
) -> Result<String, DomainError> {
self.call_plugin_inner(name, func, input)
}

fn call_plugin_inner<'a, I>(
&self,
name: &str,
func: &str,
input: I,
) -> Result<String, DomainError>
where
I: extism::convert::ToBytes<'a>,
{
// Clone the Arc<Mutex<Plugin>> and drop the DashMap shard guard
// before locking. This prevents holding the shard during slow WASM execution.
// 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()))?;
Arc::clone(&entry.plugin)
}; // DashMap shard guard dropped here
};
let mut plugin = plugin_handle
.lock()
.map_err(|_| DomainError::PluginError(format!("plugin '{name}' mutex poisoned")))?;
let fn_exists = plugin.function_exists(func);
tracing::info!(plugin = name, func, fn_exists, "call_plugin pre-call");
let result = plugin.call::<&str, &str>(func, input).map_err(|e| {
tracing::debug!(plugin = name, func, fn_exists, "plugin call pre-call");
let result = plugin.call::<I, &str>(func, input).map_err(|e| {
DomainError::PluginError(format!(
"plugin call failed (function_exists={fn_exists}): {e}"
))
Expand Down
Loading
Loading