diff --git a/src-tauri/src/commands/clipboard.rs b/src-tauri/src/commands/clipboard.rs index 8489559..7094338 100644 --- a/src-tauri/src/commands/clipboard.rs +++ b/src-tauri/src/commands/clipboard.rs @@ -1,6 +1,6 @@ use arboard::Clipboard; -use crate::commands::security::ensure_main_window; +use crate::commands::security::ensure_window_in_set; /// Refuse-to-set-clipboard limit. 1 MiB is comfortably above any /// dictation output and any sensible "copy raw transcript" — anything @@ -11,13 +11,24 @@ use crate::commands::security::ensure_main_window; /// identically. pub(crate) const MAX_CLIPBOARD_BYTES: usize = 1024 * 1024; +/// Windows that are allowed to call `copy_to_clipboard`. The transcript +/// viewer and transcription preview both have legitimate "copy raw text" +/// buttons; mirror the secondary-windows capability grant in +/// `src-tauri/capabilities/secondary-windows.json` so the IPC trust +/// boundary and the permission set stay in lock-step. +const CLIPBOARD_ALLOWED_WINDOWS: &[&str] = + &["main", "transcript-viewer", "transcription-preview"]; + /// Copy text to the system clipboard via arboard. Restricted to the -/// main window and capped at `MAX_CLIPBOARD_BYTES` to keep the IPC -/// surface symmetric with the rest of the codebase (Trust-6, -/// 2026-05-12 code-atomiser fix). +/// documented "clipboard-capable" windows (main + transcript-viewer + +/// transcription-preview) and capped at `MAX_CLIPBOARD_BYTES` to keep the +/// IPC surface symmetric with the rest of the codebase (Trust-6, +/// 2026-05-12 code-atomiser fix; broadened on 2026-05-13 to cover the +/// in-app preview/viewer copy buttons that were collateral-damaged by the +/// original main-only restriction). #[tauri::command] pub fn copy_to_clipboard(window: tauri::WebviewWindow, text: String) -> Result<(), String> { - ensure_main_window(&window)?; + ensure_window_in_set(&window, CLIPBOARD_ALLOWED_WINDOWS)?; if text.len() > MAX_CLIPBOARD_BYTES { return Err(format!( "Clipboard payload too large ({} bytes; limit {} bytes).", diff --git a/src-tauri/src/commands/paste.rs b/src-tauri/src/commands/paste.rs index 7c46e9e..289ab35 100644 --- a/src-tauri/src/commands/paste.rs +++ b/src-tauri/src/commands/paste.rs @@ -21,7 +21,13 @@ use arboard::Clipboard; use serde::Serialize; use tauri::Manager; -use crate::commands::security::ensure_main_window; +use crate::commands::security::{ensure_main_window, ensure_window_in_set}; + +/// Windows allowed to invoke `paste_text_replacing`. The +/// `transcription-preview` window's paste-to-foreground flow legitimately +/// uses this command; mirror the secondary-windows capability grant in +/// `src-tauri/capabilities/secondary-windows.json`. +const PASTE_REPLACING_ALLOWED_WINDOWS: &[&str] = &["main", "transcription-preview"]; /// Refuse-to-paste limit. Matches `commands::clipboard::MAX_CLIPBOARD_BYTES` /// (1 MiB) so paste and copy surfaces share a single rejection rule. @@ -208,7 +214,7 @@ pub async fn paste_text_replacing( app: tauri::AppHandle, text: String, ) -> Result { - ensure_main_window(&window)?; + ensure_window_in_set(&window, PASTE_REPLACING_ALLOWED_WINDOWS)?; if text.len() > MAX_PASTE_BYTES { return Err(format!( "Paste payload too large ({} bytes; limit {} bytes).", diff --git a/src-tauri/src/commands/security.rs b/src-tauri/src/commands/security.rs index 882b67a..06fbbaa 100644 --- a/src-tauri/src/commands/security.rs +++ b/src-tauri/src/commands/security.rs @@ -12,9 +12,35 @@ pub fn ensure_main_window_label(label: &str) -> Result<(), String> { } } +/// Reject the call unless the invoking window's label is one of `allowed`. +/// +/// Use this for commands that are legitimately reachable from a small set of +/// documented secondary windows (e.g. the transcript viewer or the +/// transcription preview), where `ensure_main_window` would be over-broad +/// and break in-app flows. The allowed set should always be a fixed +/// `&[&'static str]` and should mirror an entry in +/// `src-tauri/capabilities/*.json` — keeping the IPC trust boundary and the +/// permission grant in lock-step. +pub fn ensure_window_in_set( + window: &tauri::WebviewWindow, + allowed: &[&str], +) -> Result<(), String> { + ensure_window_in_set_label(window.label(), allowed) +} + +pub fn ensure_window_in_set_label(label: &str, allowed: &[&str]) -> Result<(), String> { + if allowed.iter().any(|a| *a == label) { + Ok(()) + } else { + Err(format!( + "This command is only available from {allowed:?} (got {label})." + )) + } +} + #[cfg(test)] mod tests { - use super::ensure_main_window_label; + use super::{ensure_main_window_label, ensure_window_in_set_label}; #[test] fn accepts_main_window() { @@ -27,4 +53,19 @@ mod tests { assert!(ensure_main_window_label(label).is_err()); } } + + #[test] + fn ensure_window_in_set_accepts_listed_labels() { + let allowed = &["main", "transcript-viewer", "transcription-preview"]; + for label in allowed { + assert!(ensure_window_in_set_label(label, allowed).is_ok()); + } + } + + #[test] + fn ensure_window_in_set_rejects_unlisted_label() { + let allowed = &["main", "transcript-viewer"]; + assert!(ensure_window_in_set_label("tasks-float", allowed).is_err()); + assert!(ensure_window_in_set_label("attacker-popup", allowed).is_err()); + } }