Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- "Report broken plugin" action (PRD-v2 P0.16, task 16): plugins listed in *Plugins → Plugin Store* now expose a *Report broken plugin* item in their kebab menu. Clicking it opens the user's default browser at a pre-filled GitHub issue on the plugin's repository, with diagnostic metadata (plugin name + version, Vortex version, OS, optional URL under test, last 50 log lines) inlined into the issue body. Backend adds a `repository_url` field to `domain::model::plugin::PluginInfo` (parsed from the new `[plugin].repository` key in `plugin.toml`), a `domain::ports::driven::UrlOpener` port plus its platform-native `SystemUrlOpener` adapter (`xdg-open` / `open` / `cmd start`, `http(s)://` only by validation), the std-only `domain::model::plugin::build_report_broken_url` URL builder (RFC 3986 unreserved-set percent encoder, last 50 log lines, GitHub-only repository hosts, accepts `.git` suffix, rejects malformed URLs with `DomainError::ValidationError`), and a `ReportBrokenPluginCommand` handler that returns `AppError::Validation` when a manifest carries no `repository_url`. New Tauri IPC `plugin_report_broken(pluginName, logLines?, testedUrl?) → string` returns the issue URL so the UI can fall back to clipboard copy if the launcher fails. i18n (en/fr): `plugins.action.reportBroken`, `plugins.toast.reportBrokenSuccess`, `plugins.toast.reportBrokenError`. (task 16)
- Dynamic plugin configuration UI (PRD-v2 P0.15, task 15): plugins declaring a `[config]` block in their `plugin.toml` now expose their schema at runtime. Backend adds `ConfigField` / `ConfigFieldType` / `PluginConfigSchema` to `domain/model/plugin.rs` (typed validation, enum options, `min`/`max` bounds, regex via a std-only matcher — no external import in the domain), a `PluginConfigStore` port (`get_values` / `set_value` / `list_all` / `delete_all`) implemented by `SqlitePluginConfigRepo` backed by the new `plugin_configs (plugin_name, key, value)` table (migration `m20260425_000005_create_plugin_configs`, composite primary key). The manifest parser (`adapters/driven/plugin/manifest.rs`) now extracts `type`, `default`, `options`, `description`, `min`, `max`, `regex` on top of the existing defaults, and rejects defaults that fail their own field validation. CQRS gains `UpdatePluginConfigCommand` (validates against the schema, applies the runtime first then persists, rolls back on failure) and `GetPluginConfigQuery` (returns the schema plus persisted values, dropping any persisted entry that no longer matches the current schema and falling back to manifest defaults). `PluginLoader` is extended with `get_manifest()` and `set_runtime_config()`; `ExtismPluginLoader` implements both by reading from `PluginRegistry` and writing to `SharedHostResources::plugin_configs`, so `get_config(key)` calls from the WASM plugin observe the new value without a reload. At startup, `lib.rs` replays persisted configs onto the in-memory map before plugins are loaded. Frontend adds two components: `PluginConfigField.tsx` (dispatcher renderer: `string` → text input, `boolean` → shadcn switch, `integer`/`float` → numeric input with bounds, `url` → url input, `enum` (and `string` with options) → shadcn select; `aria-describedby` on the control points to the error message) and `PluginConfigDialog.tsx` (loads the schema via `useQuery`, validates each field on the UI side (rejects empty floats, validates JSON arrays) before sending, persists changed values sequentially, guards the schema-reset effect while a save is in flight to avoid clobbering the draft, invalidates the query on success). `PluginsView` queries `plugin_config_get` for each installed plugin (keyed off the unfiltered installed list to avoid churn while typing in search) to decide whether the *Configure* button (Settings icon, next to the *More* menu) should render: a plugin without `[config]` exposes no button. New IPC commands `plugin_config_get(name) → PluginConfigView` and `plugin_config_update(name, key, value)`. i18n (en/fr): `plugins.action.configure`, `plugins.config.{title,description,loading,error,noFields,toast.{saveSuccess,validationFailed}}`. (task 15)
- History retention with automatic daily purge (PRD-v2 P0.14, task 14): new `history_retention_days` setting (default 30, presets 7 / 30 / 90 / 365 / `0 = unlimited`) exposed in the *General* Settings tab as a `Select` dropdown wired to `settings_update`. Backend ships a `Clock` domain port (`SystemClock` adapter under `adapters/driven/scheduler/`) and a `HistoryPurgeWorker` daemon spawned during Tauri setup that hard-deletes `history` rows where `completed_at < now - retention_days * 86_400`. The worker persists its last run as a Unix-epoch timestamp inside `<app_data_dir>/.history_purge_state` (sentinel filename `HISTORY_PURGE_STATE_FILE`). On startup, the daemon reads the sentinel and either runs immediately (missing/stale) or sleeps for `SECS_PER_DAY - elapsed` so the first post-launch purge stays anchored to the previous successful run instead of drifting up to ~47h after a restart; the recurring loop then ticks every 24h via `tokio::time::interval` with `MissedTickBehavior::Skip`. `retention_days <= 0` is a no-op that does not write the sentinel, so the next run re-fires the moment the user re-enables retention; corrupt sentinels are treated as "never ran" so a stuck file never blocks the scheduler. The worker shares the same `Arc<dyn HistoryRepository>` and `Arc<dyn ConfigStore>` the IPC layer already mutates, so a settings change is observed without restart. Domain helper `normalize_history_retention_days` clamps negatives back to `0` and is now applied at every write boundary — `apply_patch` (so a crafted `settings_update` payload cannot persist a negative) and `From<ConfigDto> for AppConfig` (so a hand-edited `config.toml` is normalized at load) — plus the worker itself for defense-in-depth. (task 14)
- Change-directory action that moves a download's on-disk file (and its `.vortex-meta` sidecar when present) into a new destination folder (PRD-v2 P0.13, task 13). New Tauri IPC commands `download_change_directory(id, newDestinationDir)` and `download_change_directory_bulk(ids, newDestinationDir)` are backed by `ChangeDirectoryCommand` / `ChangeDirectoryBulkCommand` in the application layer; the bulk variant returns a structured `{ moved: number[], failed: { id, message }[] }` outcome so the UI can keep failed rows selected for retry instead of swallowing partial errors. The handler pauses the download engine for `Downloading` items, relocates the body and the `.vortex-meta` sidecar, persists the new path, then resumes — segments survive the move so the engine picks up exactly where it left off. `Extracting` and `Checking` downloads are rejected because another worker is actively reading the file. The `FileStorage` port grows `move_file`, `move_meta` and `file_exists`; the production `FsFileStorage` adapter prefers `fs::rename` for same-filesystem moves and falls back to copy + size-verify + delete-source for cross-device cases (EXDEV / `ErrorKind::CrossesDevices`), with rollback on any partial failure so the source file always stays intact. New `DomainEvent::DownloadDirectoryChanged { id, newDestinationPath }` is forwarded to the frontend as the `download-directory-changed` event. Frontend ships a reusable `<MoveDialog>` (folder picker via `useBrowseFolder`, current path + selected path preview, confirm disabled until a folder is picked) and a `Move to...` action in the downloads `ActionsBar` selection toolbar that wires the bulk IPC, surfaces success / partial-failure / error toasts and clears or re-narrows the selection accordingly. New i18n keys `downloads.actions.moveSelected`, `downloads.moveDialog.*` and `downloads.toast.{moveSucceeded,movePartial,moveError}` (en/fr). (task 13)
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/adapters/driven/filesystem/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ mod download_dir;
mod file_opener;
mod file_storage;
mod meta_storage;
mod url_opener;

pub use download_dir::resolve_system_download_dir;
pub use file_opener::SystemFileOpener;
pub use file_storage::FsFileStorage;
pub use url_opener::SystemUrlOpener;
259 changes: 259 additions & 0 deletions src-tauri/src/adapters/driven/filesystem/url_opener.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
//! Platform-backed [`UrlOpener`] implementation.
//!
//! Mirrors [`super::SystemFileOpener`] but takes a URL string instead of a
//! filesystem path. The validation rule is conservative: only `http://`
//! and `https://` are accepted so the OS launcher never receives
//! `javascript:`, `file://`, or `data:` payloads from a rogue caller.

use std::process::Command;

use crate::domain::error::DomainError;
use crate::domain::ports::driven::UrlOpener;

pub struct SystemUrlOpener;

impl Default for SystemUrlOpener {
fn default() -> Self {
Self
}
}

impl SystemUrlOpener {
pub fn new() -> Self {
Self
}
}

impl UrlOpener for SystemUrlOpener {
fn open_url(&self, url: &str) -> Result<(), DomainError> {
validate_http_url(url)?;

#[cfg(target_os = "linux")]
let (program, args): (&str, Vec<std::ffi::OsString>) =
("xdg-open", vec![std::ffi::OsString::from(url)]);
#[cfg(target_os = "macos")]
let (program, args): (&str, Vec<std::ffi::OsString>) =
("open", vec![std::ffi::OsString::from(url)]);
// `rundll32 url.dll,FileProtocolHandler <URL>` is the canonical
// Windows shortcut for "open this URL in the default browser".
// Unlike `cmd /C start`, it does NOT pass the URL through the
// command interpreter, so query strings containing `&` (issue
// body separators) or `%` (percent-encoded characters) reach the
// shell-execute call intact.
#[cfg(target_os = "windows")]
let (program, args): (&str, Vec<std::ffi::OsString>) = (
"rundll32",
vec![
std::ffi::OsString::from("url.dll,FileProtocolHandler"),
std::ffi::OsString::from(url),
],
);

run_launcher(program, &args)
}
}

fn validate_http_url(url: &str) -> Result<(), DomainError> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.ok_or_else(|| {
DomainError::ValidationError(format!("URL must start with http(s)://, got '{url}'"))
})?;

// Reject scheme-only inputs (`https://`), missing-authority shapes
// (`https:///foo`, `https://?x`, `https://#x`) and any whitespace,
// which would derail the OS launcher even though the prefix check
// passed.
if rest.is_empty()
|| rest.starts_with('/')
|| rest.starts_with('?')
|| rest.starts_with('#')
|| url.chars().any(char::is_whitespace)
{
return Err(DomainError::ValidationError(format!(
"invalid http(s) URL: '{url}'"
)));
}

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// Authority MUST carry a non-empty host. RFC 3986 leaves the door
// open for `https://:443/x` (port-only) and `https://user@/x`
// (userinfo without host) — both are accepted by the prefix check
// but mean nothing to a browser and just produce a launcher error.
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host_port = authority.rsplit('@').next().unwrap_or_default();
let (host, port) = if let Some(rest) = host_port.strip_prefix('[') {
// Bracketed IPv6 host: require `]`, non-empty literal, and an
// optional `:port` tail — anything else (`[::1]oops`) is junk.
let end = rest
.find(']')
.ok_or_else(|| DomainError::ValidationError(format!("invalid http(s) URL: '{url}'")))?;
if end == 0 {
return Err(DomainError::ValidationError(format!(
"http(s) URL has empty host: '{url}'"
)));
}
let tail = &rest[end + 1..];
if !tail.is_empty() && !tail.starts_with(':') {
return Err(DomainError::ValidationError(format!(
"invalid http(s) URL: '{url}'"
)));
}
(&rest[..end], tail.strip_prefix(':'))
} else {
match host_port.split_once(':') {
Some((h, p)) => (h, Some(p)),
None => (host_port, None),
}
};
if host.is_empty() {
return Err(DomainError::ValidationError(format!(
"http(s) URL has empty host: '{url}'"
)));
}
if let Some(p) = port
&& (p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()))
{
return Err(DomainError::ValidationError(format!(
"http(s) URL has non-numeric port: '{url}'"
)));
}

Ok(())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[cfg(not(target_os = "windows"))]
fn run_launcher(program: &str, args: &[std::ffi::OsString]) -> Result<(), DomainError> {
let status = Command::new(program)
.args(args)
.status()
.map_err(|e| DomainError::StorageError(format!("failed to launch {program}: {e}")))?;
if !status.success() {
return Err(DomainError::StorageError(format!(
"{program} exited with status {status}"
)));
}
Ok(())
}

#[cfg(target_os = "windows")]
fn run_launcher(program: &str, args: &[std::ffi::OsString]) -> Result<(), DomainError> {
// `rundll32` returns 0 even when the user has no default browser, so
// the exit code carries no signal worth checking. We only surface
// process-spawn failures (missing binary, sandboxing) — those are
// the cases where the URL really did not reach Windows. This mirrors
// the rationale documented next to `SystemFileOpener` for the same
// reason.
let _status = Command::new(program)
.args(args)
.status()
.map_err(|e| DomainError::StorageError(format!("failed to launch {program}: {e}")))?;
Ok(())
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn open_url_rejects_non_http_scheme() {
let opener = SystemUrlOpener::new();
for bad in [
"javascript:alert(1)",
"file:///etc/passwd",
"data:text/html,<script>",
"",
"github.com/foo/bar",
] {
let err = opener.open_url(bad).unwrap_err();
assert!(
matches!(err, DomainError::ValidationError(_)),
"expected ValidationError for {bad:?}, got {err:?}"
);
}
}

#[test]
fn open_url_accepts_http_and_https() {
// Validation only — we don't actually launch anything in CI.
assert!(validate_http_url("http://example.com").is_ok());
assert!(validate_http_url("https://github.com/foo/bar/issues/new?title=x").is_ok());
}

#[test]
fn validate_http_url_rejects_missing_authority() {
// Scheme-only or no-host shapes used to slip past the prefix check
// and bubble up as a useless launcher error.
for bad in [
"https://",
"http://",
"https:///etc/passwd",
"https://?title=x",
"https://#frag",
"https:// example.com",
] {
let err = validate_http_url(bad).unwrap_err();
assert!(
matches!(err, DomainError::ValidationError(_)),
"expected ValidationError for {bad:?}, got {err:?}"
);
}
}

#[test]
fn validate_http_url_rejects_empty_host_authority() {
// Authority shapes that survive the prefix / leading-char check
// but still carry no usable host: a stray port, a userinfo block
// without a host, or an unclosed/empty IPv6 literal.
for bad in [
"https://:443/path",
"https://@/x",
"https://user@/x",
"https://user:pwd@/x",
"https://[]/foo",
"https://[/foo",
] {
let err = validate_http_url(bad).unwrap_err();
assert!(
matches!(err, DomainError::ValidationError(_)),
"expected ValidationError for {bad:?}, got {err:?}"
);
}
}

#[test]
fn validate_http_url_accepts_userinfo_and_ipv6() {
// Valid authorities should still pass — a userinfo prefix or an
// IPv6 literal with a real host must not be classified as empty.
for good in [
"https://user:pass@example.com/path",
"https://[::1]/path",
"https://[2001:db8::1]:8080/foo",
"http://example.com:8080/x",
] {
assert!(
validate_http_url(good).is_ok(),
"expected {good:?} to validate"
);
}
}

#[test]
fn validate_http_url_rejects_malformed_port_or_bracket_tail() {
// Port must be all-digits and an IPv6 literal must be followed
// by either nothing or `:port` — junk after `]` is invalid.
for bad in [
"https://example.com:abc/x",
"https://example.com:/x",
"https://example.com:80a/x",
"https://[::1]oops/x",
"https://[::1]:abc/x",
] {
let err = validate_http_url(bad).unwrap_err();
assert!(
matches!(err, DomainError::ValidationError(_)),
"expected ValidationError for {bad:?}, got {err:?}"
);
}
}
}
51 changes: 50 additions & 1 deletion src-tauri/src/adapters/driven/plugin/extism_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::domain::ports::driven::plugin_loader::DownloadedFileInfo;

use super::builtin::HttpModule;
use super::capabilities::{SharedHostResources, build_host_functions};
use super::manifest::{find_wasm_file, parse_manifest};
use super::manifest::{find_wasm_file, parse_manifest, parse_manifest_metadata};
use super::registry::{LoadedPlugin, PluginRegistry};

/// Per-plugin install coordination.
Expand Down Expand Up @@ -248,6 +248,55 @@ impl PluginLoader for ExtismPluginLoader {
Ok(self.registry.list_info())
}

fn find_installed_manifest(&self, name: &str) -> Result<Option<PluginInfo>, DomainError> {
// Reject any name that could escape `plugins_dir/` so a hostile
// caller can't read foreign manifests by passing `../etc/passwd`.
if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(DomainError::ValidationError(format!(
"invalid plugin name: '{name}'"
)));
}
let dir = self.plugins_dir.join(name);
if !dir.is_dir() {
return Ok(None);
}

// Symlink containment: even though `name` itself is sanitized,
// `plugins_dir/<name>` could be a symlink pointing outside the
// root. We canonicalize both sides and require the resolved
// plugin directory to live inside the resolved plugins root
// before reading anything from it.
let canon_root = self.plugins_dir.canonicalize().map_err(|e| {
DomainError::PluginError(format!(
"failed to canonicalize plugins_dir '{}': {e}",
self.plugins_dir.display()
))
})?;
let canon_dir = dir.canonicalize().map_err(|e| {
DomainError::PluginError(format!(
"failed to canonicalize plugin dir '{}': {e}",
dir.display()
))
})?;
if !canon_dir.starts_with(&canon_root) {
return Err(DomainError::ValidationError(format!(
"invalid plugin path outside plugins_dir: '{}'",
dir.display()
)));
}

// Use the metadata-only parser so a missing/corrupt `.wasm`
// file doesn't hide the very plugin the user wants to report.
match parse_manifest_metadata(&canon_dir) {
Ok(manifest) => Ok(Some(manifest.info().clone())),
Err(DomainError::PluginError(msg)) => {
tracing::debug!("find_installed_manifest('{name}'): manifest unreadable: {msg}");
Ok(None)
}
Err(e) => Err(e),
}
}

fn set_enabled(&self, name: &str, enabled: bool) -> Result<(), DomainError> {
self.registry.set_enabled(name, enabled)
}
Expand Down
Loading
Loading