Skip to main content

victauri_plugin/
lib.rs

1#![deny(missing_docs)]
2// In RELEASE builds the entire MCP server is gated out via `#[cfg(debug_assertions)]`
3// (`init()` is a zero-cost no-op), so the imports/constants/helpers it uses are
4// intentionally dead. Don't fail a `-Dwarnings` release build on that expected
5// dead code — without this, `RUSTFLAGS=-Dwarnings cargo build --release` errors.
6#![cfg_attr(not(debug_assertions), allow(unused_imports, dead_code))]
7//! Victauri — full-stack introspection for Tauri apps via an embedded MCP server.
8//!
9//! Add this plugin to your Tauri app for AI-agent-driven testing and debugging:
10//! DOM snapshots, IPC tracing, cross-boundary verification, and more tools —
11//! all accessible over the Model Context Protocol.
12//!
13//! # Quick Start
14//!
15//! ```ignore
16//! tauri::Builder::default()
17//!     .plugin(victauri_plugin::init())
18//!     .run(tauri::generate_context!())
19//!     .unwrap();
20//! ```
21//!
22//! In debug builds this starts an MCP server on port 7373. In release builds
23//! the plugin is a no-op with zero overhead.
24//!
25//! # Configuration
26//!
27//! Authentication is **enabled by default** with an auto-generated Bearer token
28//! written to `<temp>/victauri/<pid>/token` for client auto-discovery. The MCP
29//! server listens on `127.0.0.1` only and is gated behind `#[cfg(debug_assertions)]`.
30//! Use `.auth_token("...")` for a fixed token, or `.auth_disabled()` to opt out.
31//!
32//! ```ignore
33//! tauri::Builder::default()
34//!     .plugin(
35//!         victauri_plugin::VictauriBuilder::new()
36//!             .port(8080)
37//!             .strict_privacy_mode()
38//!             .build(),
39//!     )
40//!     .run(tauri::generate_context!())
41//!     .unwrap();
42//! ```
43
44/// Runtime-erased webview bridge trait and its Tauri implementation.
45pub mod bridge;
46/// Backend database access (`SQLite` read-only queries).
47#[cfg(feature = "sqlite")]
48pub mod database;
49pub mod error;
50/// JS bridge script generation for webview injection.
51pub mod js_bridge;
52/// MCP server, tool handler, and parameter types.
53pub mod mcp;
54mod memory;
55/// Privacy controls: command allowlists, blocklists, and tool disabling.
56pub mod privacy;
57
58/// Compose captured frames into a single contact-sheet PNG ("filmstrip").
59pub mod filmstrip;
60/// Output redaction for API keys, tokens, emails, and sensitive JSON keys.
61pub mod redaction;
62pub mod screencast;
63pub(crate) mod screenshot;
64mod tools;
65
66/// Bearer-token authentication, rate limiting, and security middlewares.
67pub mod auth;
68/// Backend introspection and chaos engineering (profiling, fault injection, contracts).
69pub mod introspection;
70
71use std::collections::{HashMap, HashSet};
72use std::sync::Arc;
73use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64};
74use tauri::plugin::{Builder, TauriPlugin};
75use tauri::{Listener, Manager, RunEvent, Runtime};
76use tokio::sync::{Mutex, oneshot, watch};
77use victauri_core::{CommandRegistry, EventLog, EventRecorder};
78
79pub use error::BuilderError;
80pub use privacy::PrivacyProfile;
81
82pub use victauri_core::CommandInfo;
83pub use victauri_macros::inspectable;
84
85/// Register command schemas with the Victauri plugin at app setup time.
86///
87/// Pass the `__schema()` function calls generated by `#[inspectable]`.
88/// This populates the `CommandRegistry` so that `get_registry`, `resolve_command`,
89/// and `detect_ghost_commands` return real results.
90///
91/// # Example
92///
93/// ```rust,ignore
94/// use victauri_plugin::register_commands;
95///
96/// .setup(|app| {
97///     register_commands!(app,
98///         greet__schema(),
99///         increment__schema(),
100///         add_todo__schema(),
101///     );
102///     Ok(())
103/// })
104/// ```
105#[macro_export]
106macro_rules! register_commands {
107    ($app:expr, $($schema_call:expr),+ $(,)?) => {{
108        // `try_state` (not `state`) so this is a no-op in RELEASE builds: there
109        // `init()` returns a no-op plugin that never manages `VictauriState`, and
110        // `state()` would PANIC on the missing type. Registration is debug-only.
111        if let Some(state) = $app.try_state::<std::sync::Arc<$crate::VictauriState>>() {
112            $(
113                state.registry.register($schema_call);
114            )+
115        }
116    }};
117}
118
119const DEFAULT_PORT: u16 = 7373;
120const DEFAULT_EVENT_CAPACITY: usize = 10_000;
121const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
122const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
123const MAX_EVENT_CAPACITY: usize = 1_000_000;
124const MAX_RECORDER_CAPACITY: usize = 1_000_000;
125const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
126
127/// Map of pending JavaScript eval callbacks, keyed by request ID.
128/// Each entry holds a oneshot sender that resolves when the webview returns a result.
129pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
130
131/// Runtime state shared between the MCP server and all tool handlers.
132pub struct VictauriState {
133    /// Ring-buffer event log for IPC calls, state changes, and DOM mutations.
134    pub event_log: EventLog,
135    /// Registry of all discovered Tauri commands with metadata.
136    pub registry: CommandRegistry,
137    /// TCP port the MCP server listens on (may differ from configured port if fallback was used).
138    pub port: AtomicU16,
139    /// Pending JavaScript eval callbacks awaiting webview responses.
140    pub pending_evals: PendingCallbacks,
141    /// Session recorder for time-travel debugging.
142    pub recorder: EventRecorder,
143    /// Privacy configuration (tool disabling, command filtering, output redaction).
144    pub privacy: privacy::PrivacyConfig,
145    /// Timeout for JavaScript eval operations.
146    pub eval_timeout: std::time::Duration,
147    /// Sends `true` to signal graceful MCP server shutdown.
148    pub shutdown_tx: watch::Sender<bool>,
149    /// Instant the plugin was initialized, for uptime tracking.
150    pub started_at: std::time::Instant,
151    /// Total number of MCP tool invocations since startup.
152    pub tool_invocations: AtomicU64,
153    /// Whether `file:` URLs are allowed in the `navigate` tool's `go_to` action.
154    /// Defaults to `false` — only `http` and `https` are permitted unless opted in
155    /// via [`VictauriBuilder::allow_file_navigation`].
156    pub allow_file_navigation: bool,
157    /// Per-command execution timing for Rust-side profiling.
158    pub command_timings: introspection::CommandTimings,
159    /// Active fault injection rules for chaos engineering.
160    pub fault_registry: introspection::FaultRegistry,
161    /// Recorded IPC contract baselines for schema drift detection.
162    pub contract_store: introspection::ContractStore,
163    /// Plugin startup phase timestamps for performance analysis.
164    pub startup_timeline: introspection::StartupTimeline,
165    /// Captured Tauri event bus events (from `listen_any`).
166    pub event_bus: introspection::EventBusMonitor,
167    /// Tracker for Victauri's own spawned async tasks.
168    pub task_tracker: introspection::TaskTracker,
169    /// Set to `true` when any webview's JS bridge sends its ready signal.
170    pub bridge_ready: AtomicBool,
171    /// Notifies waiters when the JS bridge ready signal arrives.
172    pub bridge_notify: tokio::sync::Notify,
173    /// Screencast state for the `trace` tool (background frame capture buffer).
174    pub screencast: Arc<screencast::Screencast>,
175    /// Extra absolute directories searched by `query_db` / `introspect db_health`
176    /// in addition to the OS app directories. Lets Victauri reach databases an
177    /// app stores outside the Tauri app-data dir (e.g. a project or working
178    /// directory). Opt in via [`VictauriBuilder::db_search_paths`]. Absolute
179    /// `query_db` paths are permitted only when they resolve within one of these
180    /// roots (or an app directory).
181    pub db_search_paths: Vec<std::path::PathBuf>,
182    /// Application-defined state probes surfaced via the `app_state` tool.
183    /// Registered through [`VictauriBuilder::probe`].
184    pub probes: introspection::AppStateProbes,
185}
186
187/// Builder for configuring the Victauri plugin before adding it to a Tauri app.
188///
189/// Supports port selection, authentication, privacy controls, output redaction,
190/// and capacity tuning. All settings have sensible defaults and can be overridden
191/// via environment variables.
192///
193/// **Authentication is enabled by default** with an auto-generated token written to
194/// the discovery directory (`<temp>/victauri/<pid>/token`). MCP clients that read
195/// this file get seamless access. Set `VICTAURI_AUTH_TOKEN` for a fixed token, or
196/// call [`auth_disabled()`](VictauriBuilder::auth_disabled) to opt out for local-only
197/// single-user development.
198pub struct VictauriBuilder {
199    port: Option<u16>,
200    event_capacity: usize,
201    recorder_capacity: usize,
202    eval_timeout: std::time::Duration,
203    auth_token: Option<String>,
204    auth_explicitly_enabled: bool,
205    auth_explicitly_disabled: bool,
206    disabled_tools: Vec<String>,
207    command_allowlist: Option<Vec<String>>,
208    command_blocklist: Vec<String>,
209    storage_key_blocklist: Vec<String>,
210    redaction_patterns: Vec<String>,
211    redaction_enabled: bool,
212    strict_privacy: bool,
213    privacy_profile: Option<privacy::PrivacyProfile>,
214    bridge_capacities: js_bridge::BridgeCapacities,
215    on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
216    commands: Vec<victauri_core::CommandInfo>,
217    allow_file_navigation: bool,
218    listen_events: Vec<String>,
219    db_search_paths: Vec<std::path::PathBuf>,
220    probes: Vec<(String, std::sync::Arc<introspection::ProbeFn>)>,
221}
222
223impl Default for VictauriBuilder {
224    fn default() -> Self {
225        Self {
226            port: None,
227            event_capacity: DEFAULT_EVENT_CAPACITY,
228            recorder_capacity: DEFAULT_RECORDER_CAPACITY,
229            eval_timeout: DEFAULT_EVAL_TIMEOUT,
230            auth_token: None,
231            auth_explicitly_enabled: false,
232            auth_explicitly_disabled: false,
233            disabled_tools: Vec::new(),
234            command_allowlist: None,
235            command_blocklist: Vec::new(),
236            storage_key_blocklist: Vec::new(),
237            redaction_patterns: Vec::new(),
238            redaction_enabled: false,
239            strict_privacy: false,
240            privacy_profile: None,
241            bridge_capacities: js_bridge::BridgeCapacities::default(),
242            on_ready: None,
243            commands: Vec::new(),
244            allow_file_navigation: false,
245            listen_events: Vec::new(),
246            db_search_paths: Vec::new(),
247            probes: Vec::new(),
248        }
249    }
250}
251
252impl VictauriBuilder {
253    /// Create a new builder with default settings.
254    #[must_use]
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    /// Set the TCP port for the MCP server (default: 7373, env: `VICTAURI_PORT`).
260    #[must_use]
261    pub fn port(mut self, port: u16) -> Self {
262        self.port = Some(port);
263        self
264    }
265
266    /// Set the maximum number of events in the ring-buffer log (default: 10,000).
267    #[must_use]
268    pub fn event_capacity(mut self, capacity: usize) -> Self {
269        self.event_capacity = capacity;
270        self
271    }
272
273    /// Set the maximum events kept during session recording (default: 50,000).
274    #[must_use]
275    pub fn recorder_capacity(mut self, capacity: usize) -> Self {
276        self.recorder_capacity = capacity;
277        self
278    }
279
280    /// Set the timeout for JavaScript eval operations (default: 30s, env: `VICTAURI_EVAL_TIMEOUT`).
281    #[must_use]
282    pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
283        self.eval_timeout = timeout;
284        self
285    }
286
287    /// Set an explicit auth token for the MCP server (env: `VICTAURI_AUTH_TOKEN`).
288    ///
289    /// Setting a token implicitly enables authentication.
290    #[must_use]
291    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
292        self.auth_token = Some(token.into());
293        self
294    }
295
296    /// Explicitly enable authentication with an auto-generated `UUID` v4 token.
297    ///
298    /// **Authentication is already enabled by default** (see [`Self::auth_disabled`]),
299    /// so this call is redundant in normal use and is kept for explicitness and
300    /// backward compatibility. The token is printed to the log on startup and written
301    /// to the discovery directory (`<temp>/victauri/<pid>/token`) for client
302    /// auto-discovery.
303    #[must_use]
304    pub fn auth_enabled(mut self) -> Self {
305        self.auth_explicitly_enabled = true;
306        self
307    }
308
309    /// Generate a random `UUID` v4 auth token (alias for [`auth_enabled`](Self::auth_enabled)).
310    #[must_use]
311    pub fn generate_auth_token(mut self) -> Self {
312        self.auth_explicitly_enabled = true;
313        self
314    }
315
316    /// Disable authentication entirely.
317    ///
318    /// By default, Victauri auto-generates a Bearer token and writes it to the
319    /// discovery directory. Call this method to opt out of authentication for
320    /// single-user local development where convenience outweighs the risk.
321    ///
322    /// **Warning:** Without auth, any process on localhost can invoke all MCP
323    /// tools including `eval_js` and `invoke_command`. Only disable auth on
324    /// machines where you trust every running process.
325    #[must_use]
326    pub fn auth_disabled(mut self) -> Self {
327        self.auth_explicitly_disabled = true;
328        self
329    }
330
331    /// Disable specific MCP tools by name (e.g., `["eval_js", "screenshot"]`).
332    #[must_use]
333    pub fn disable_tools(mut self, tools: &[&str]) -> Self {
334        self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
335        self
336    }
337
338    /// Only allow these Tauri commands to be invoked via MCP (positive allowlist).
339    #[must_use]
340    pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
341        self.command_allowlist = Some(
342            commands
343                .iter()
344                .map(std::string::ToString::to_string)
345                .collect(),
346        );
347        self
348    }
349
350    /// Block specific Tauri commands from being invoked via MCP.
351    #[must_use]
352    pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
353        self.command_blocklist = commands
354            .iter()
355            .map(std::string::ToString::to_string)
356            .collect();
357        self
358    }
359
360    /// Block `storage.set` from writing these `localStorage`/`sessionStorage` keys.
361    /// Use it to protect keys your app trusts for auth/role/tier/feature decisions
362    /// so an agent cannot escalate privilege by poisoning them (audit #33). Default
363    /// is empty (no keys blocked), preserving existing behaviour.
364    #[must_use]
365    pub fn storage_key_blocklist(mut self, keys: &[&str]) -> Self {
366        self.storage_key_blocklist = keys.iter().map(std::string::ToString::to_string).collect();
367        self
368    }
369
370    /// Add a regex pattern for output redaction (e.g., `r"SECRET_\w+"`).
371    #[must_use]
372    pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
373        self.redaction_patterns.push(pattern.into());
374        self
375    }
376
377    /// Enable output redaction with built-in patterns (API keys, emails, tokens).
378    #[must_use]
379    pub fn enable_redaction(mut self) -> Self {
380        self.redaction_enabled = true;
381        self
382    }
383
384    /// Enable strict privacy mode (equivalent to [`PrivacyProfile::Observe`]).
385    ///
386    /// Disables all mutation tools (`eval_js`, screenshot, interactions, input,
387    /// storage writes, navigation, CSS injection) and enables output redaction.
388    ///
389    /// Prefer [`privacy_profile()`](Self::privacy_profile) for finer control.
390    #[must_use]
391    pub fn strict_privacy_mode(mut self) -> Self {
392        self.strict_privacy = true;
393        self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
394        self
395    }
396
397    /// Set the privacy profile tier.
398    ///
399    /// - [`Observe`](privacy::PrivacyProfile::Observe) — read-only (snapshots, logs, registry)
400    /// - [`Test`](privacy::PrivacyProfile::Test) — observe + interactions + input + storage writes + recording
401    /// - [`FullControl`](privacy::PrivacyProfile::FullControl) — everything (default)
402    ///
403    /// Profiles automatically enable redaction for `Observe` and `Test`.
404    /// Use [`disable_tools()`](Self::disable_tools) to apply overrides on top of a profile.
405    #[must_use]
406    pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
407        self.privacy_profile = Some(profile);
408        if matches!(
409            profile,
410            privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
411        ) {
412            self.redaction_enabled = true;
413        }
414        self
415    }
416
417    /// Set the maximum console log entries kept in the JS bridge (default: 1000).
418    #[must_use]
419    pub fn console_log_capacity(mut self, capacity: usize) -> Self {
420        self.bridge_capacities.console_logs = capacity;
421        self
422    }
423
424    /// Set the maximum network log entries kept in the JS bridge (default: 1000).
425    #[must_use]
426    pub fn network_log_capacity(mut self, capacity: usize) -> Self {
427        self.bridge_capacities.network_log = capacity;
428        self
429    }
430
431    /// Set the maximum navigation log entries kept in the JS bridge (default: 200).
432    #[must_use]
433    pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
434        self.bridge_capacities.navigation_log = capacity;
435        self
436    }
437
438    /// Pre-register command schemas so `get_registry`, `resolve_command`, and
439    /// `detect_ghost_commands` return real results from the moment the server starts.
440    ///
441    /// Pass the `__schema()` functions generated by `#[inspectable]`.
442    ///
443    /// ```rust,ignore
444    /// VictauriBuilder::new()
445    ///     .commands(&[
446    ///         greet__schema(),
447    ///         increment__schema(),
448    ///         add_todo__schema(),
449    ///     ])
450    ///     .build()
451    /// ```
452    #[must_use]
453    pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
454        self.commands = schemas.to_vec();
455        self
456    }
457
458    /// Register command names without full schemas.
459    ///
460    /// Use this when your commands are decorated with `#[tauri::command]` but not
461    /// `#[inspectable]`. Ghost detection will recognize these commands as registered,
462    /// eliminating false positives.
463    ///
464    /// ```rust,ignore
465    /// VictauriBuilder::new()
466    ///     .register_command_names(&[
467    ///         "get_settings",
468    ///         "save_settings",
469    ///         "run_analysis",
470    ///     ])
471    ///     .build()
472    /// ```
473    #[must_use]
474    pub fn register_command_names(mut self, names: &[&str]) -> Self {
475        self.commands
476            .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
477        self
478    }
479
480    /// Auto-discover all `#[inspectable]` commands in the binary.
481    ///
482    /// Uses `inventory` to collect every command marked with `#[inspectable]`
483    /// at link time — no manual listing required. This replaces both
484    /// `.commands(&[...])` and `register_commands!()`.
485    ///
486    /// ```rust,ignore
487    /// tauri::Builder::default()
488    ///     .plugin(
489    ///         VictauriBuilder::new()
490    ///             .auto_discover()
491    ///             .build()
492    ///             .unwrap(),
493    ///     )
494    /// ```
495    #[must_use]
496    pub fn auto_discover(mut self) -> Self {
497        self.commands
498            .extend(victauri_core::auto_discovered_commands());
499        self
500    }
501
502    /// Register custom Tauri event names to capture in the event bus.
503    ///
504    /// Window lifecycle events (focus, blur, close, resize, move, theme change) are
505    /// captured automatically. Use this for app-specific events emitted via `app.emit()`.
506    ///
507    /// ```rust,ignore
508    /// VictauriBuilder::new()
509    ///     .listen_events(&["notification-added", "settings-changed", "sync-complete"])
510    ///     .build()
511    /// ```
512    #[must_use]
513    pub fn listen_events(mut self, events: &[&str]) -> Self {
514        self.listen_events = events
515            .iter()
516            .map(std::string::ToString::to_string)
517            .collect();
518        self
519    }
520
521    /// Allow `file:` URLs in the `navigate` tool's `go_to` action.
522    ///
523    /// By default, only `http` and `https` schemes are permitted. Calling this
524    /// method opts in to `file:` navigation, which grants the MCP client access
525    /// to local filesystem paths via the webview.
526    ///
527    /// **Warning:** Enabling this in untrusted environments exposes local files
528    /// to any process that can reach the MCP server.
529    #[must_use]
530    pub fn allow_file_navigation(mut self) -> Self {
531        self.allow_file_navigation = true;
532        self
533    }
534
535    /// Add extra directories for `query_db` and `introspect db_health` to search
536    /// for `SQLite` databases, beyond the OS app directories.
537    ///
538    /// Many apps store their database outside the Tauri app-data directory (in a
539    /// project/working directory, a user-chosen location, or a configured path).
540    /// By default Victauri only searches the OS app directories, so those
541    /// databases are unreachable. Registering their containing directory here
542    /// makes them discoverable by relative name and permits absolute `query_db`
543    /// paths that resolve within these roots.
544    ///
545    /// Access remains read-only and path-traversal-guarded. Paths are added
546    /// as-is; relative paths are resolved against the process working directory.
547    #[must_use]
548    pub fn db_search_paths<I, P>(mut self, paths: I) -> Self
549    where
550        I: IntoIterator<Item = P>,
551        P: Into<std::path::PathBuf>,
552    {
553        self.db_search_paths
554            .extend(paths.into_iter().map(Into::into));
555        self
556    }
557
558    /// Register an application state probe surfaced through the `app_state` tool.
559    ///
560    /// A probe is a named closure returning a JSON snapshot of domain-specific
561    /// backend state. It gives an agent first-class, discoverable access to state
562    /// that would otherwise only be reachable by `query_db` + grepping logs —
563    /// e.g. a scoring pipeline's version and queue depth, a cache's hit rate, or
564    /// a scheduler's next-run time. The closure runs in the Rust process with no
565    /// IPC round-trip, so it can read backend state the frontend never sees.
566    ///
567    /// The idiomatic pattern is to build your shared state as an `Arc` first, then
568    /// clone it into both Tauri's `.manage()` and the probe:
569    ///
570    /// ```no_run
571    /// # use std::sync::Arc;
572    /// # use serde_json::json;
573    /// # struct Scoring; impl Scoring { fn version(&self) -> u32 { 5 } fn stale(&self) -> u64 { 0 } }
574    /// let scoring = Arc::new(Scoring);
575    /// let probe_state = Arc::clone(&scoring);
576    /// let builder = victauri_plugin::VictauriBuilder::new().probe("scoring", move || {
577    ///     json!({ "pipeline_version": probe_state.version(), "stale_items": probe_state.stale() })
578    /// });
579    /// # let _ = builder;
580    /// ```
581    #[must_use]
582    pub fn probe<F>(mut self, name: impl Into<String>, probe: F) -> Self
583    where
584        F: Fn() -> serde_json::Value + Send + Sync + 'static,
585    {
586        self.probes.push((name.into(), std::sync::Arc::new(probe)));
587        self
588    }
589
590    /// Register a callback invoked once the MCP server is listening.
591    /// The callback receives the port number.
592    #[must_use]
593    pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
594        self.on_ready = Some(Box::new(f));
595        self
596    }
597
598    fn resolve_port(&self) -> u16 {
599        self.port
600            .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
601            .unwrap_or(DEFAULT_PORT)
602    }
603
604    fn resolve_auth_token(&self) -> Option<String> {
605        if self.auth_explicitly_disabled {
606            return None;
607        }
608        // An EMPTY/whitespace configured token or env var is treated as "not configured" and
609        // falls through to a freshly generated token — it must NOT disable auth. The contract is
610        // "auth is on by default unless `auth_disabled()`"; a botched `VICTAURI_AUTH_TOKEN=""`
611        // (or `.auth_token("")`) previously collapsed to no-auth downstream, a silent fail-open
612        // that bypassed that contract without the caller ever invoking `auth_disabled()`.
613        if let Some(ref token) = self.auth_token
614            && !token.trim().is_empty()
615        {
616            return Some(token.clone());
617        }
618        if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN")
619            && !token.trim().is_empty()
620        {
621            return Some(token);
622        }
623        Some(auth::generate_token())
624    }
625
626    fn resolve_eval_timeout(&self) -> std::time::Duration {
627        std::env::var("VICTAURI_EVAL_TIMEOUT")
628            .ok()
629            .and_then(|s| s.parse::<u64>().ok())
630            .map_or(self.eval_timeout, std::time::Duration::from_secs)
631    }
632
633    fn build_privacy_config(&self) -> privacy::PrivacyConfig {
634        let profile = self
635            .privacy_profile
636            .unwrap_or(privacy::PrivacyProfile::FullControl);
637
638        let redaction_enabled = self.redaction_enabled
639            || self.strict_privacy
640            || matches!(
641                profile,
642                privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
643            );
644
645        privacy::PrivacyConfig {
646            profile,
647            command_allowlist: self
648                .command_allowlist
649                .as_ref()
650                .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
651            command_blocklist: self.command_blocklist.iter().cloned().collect(),
652            disabled_tools: self.disabled_tools.iter().cloned().collect(),
653            storage_key_blocklist: self.storage_key_blocklist.iter().cloned().collect(),
654            redactor: redaction::Redactor::new(&self.redaction_patterns),
655            redaction_enabled,
656        }
657    }
658
659    fn validate(&self) -> Result<(), BuilderError> {
660        let port = self.resolve_port();
661        if port == 0 {
662            return Err(BuilderError::InvalidPort {
663                port,
664                reason: "port 0 is reserved".to_string(),
665            });
666        }
667
668        if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
669            return Err(BuilderError::InvalidEventCapacity {
670                capacity: self.event_capacity,
671                reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
672            });
673        }
674
675        if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
676            return Err(BuilderError::InvalidRecorderCapacity {
677                capacity: self.recorder_capacity,
678                reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
679            });
680        }
681
682        let timeout = self.resolve_eval_timeout();
683        if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
684            return Err(BuilderError::InvalidEvalTimeout {
685                timeout_secs: timeout.as_secs(),
686                reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
687            });
688        }
689
690        Ok(())
691    }
692
693    /// Consume the builder and produce a Tauri plugin.
694    ///
695    /// In release builds this always succeeds. In debug builds the builder configuration is
696    /// validated first.
697    ///
698    /// # Errors
699    ///
700    /// Returns [`BuilderError`] if the port, event capacity, recorder capacity, or eval
701    /// timeout are outside their valid ranges (debug builds only).
702    pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
703        #[cfg(not(debug_assertions))]
704        {
705            Ok(Builder::new("victauri").build())
706        }
707
708        #[cfg(debug_assertions)]
709        {
710            // Hard kill-switch: lets a team force the no-op plugin even in a debug
711            // build (e.g. a release profile compiled with `debug-assertions = true`,
712            // or a shared environment where the introspection server must never bind).
713            if env_truthy("VICTAURI_DISABLE") {
714                tracing::info!("Victauri disabled via VICTAURI_DISABLE — returning no-op plugin");
715                return Ok(Builder::new("victauri").build());
716            }
717
718            self.validate()?;
719
720            let port = self.resolve_port();
721            let event_capacity = self.event_capacity;
722            let recorder_capacity = self.recorder_capacity;
723            let eval_timeout = self.resolve_eval_timeout();
724            let auth_token = self.resolve_auth_token();
725            let privacy_config = self.build_privacy_config();
726            let allow_file_navigation = self.allow_file_navigation;
727            let db_search_paths = self.db_search_paths;
728            let on_ready = self.on_ready;
729            let commands = self.commands;
730            let listen_events = self.listen_events;
731            let probes = self.probes;
732            let js_init = js_bridge::init_script(&self.bridge_capacities);
733
734            Ok(Builder::new("victauri")
735                .setup(move |app, _api| {
736                    let startup_timeline = introspection::StartupTimeline::new();
737                    let event_log = EventLog::new(event_capacity);
738                    startup_timeline.mark("event_log_created");
739                    let registry = CommandRegistry::new();
740                    startup_timeline.mark("registry_created");
741                    let (shutdown_tx, shutdown_rx) = watch::channel(false);
742
743                    // Resolve configured db search paths against multiple anchors so a
744                    // CWD-relative path (e.g. 4DA's "../data", relative to src-tauri/) is
745                    // found regardless of the launch CWD — the binary usually runs from
746                    // target/debug/, so CWD-only resolution silently misses the database.
747                    let db_search_paths = resolve_db_search_paths(&db_search_paths);
748
749                    let state = Arc::new(VictauriState {
750                        event_log,
751                        registry,
752                        port: AtomicU16::new(port),
753                        pending_evals: Arc::new(Mutex::new(HashMap::new())),
754                        recorder: EventRecorder::new(recorder_capacity),
755                        privacy: privacy_config,
756                        eval_timeout,
757                        shutdown_tx,
758                        started_at: std::time::Instant::now(),
759                        tool_invocations: AtomicU64::new(0),
760                        allow_file_navigation,
761                        command_timings: introspection::CommandTimings::new(),
762                        fault_registry: introspection::FaultRegistry::new(),
763                        contract_store: introspection::ContractStore::new(),
764                        startup_timeline,
765                        event_bus: introspection::EventBusMonitor::default(),
766                        task_tracker: introspection::TaskTracker::new(),
767                        bridge_ready: AtomicBool::new(false),
768                        bridge_notify: tokio::sync::Notify::new(),
769                        screencast: Arc::new(screencast::Screencast::default()),
770                        db_search_paths,
771                        probes: introspection::AppStateProbes::default(),
772                    });
773                    state.startup_timeline.mark("state_created");
774
775                    for (name, probe) in probes {
776                        state.probes.register(name, probe);
777                    }
778
779                    app.manage(state.clone());
780
781                    for cmd in commands {
782                        state.registry.register(cmd);
783                    }
784                    state.startup_timeline.mark("commands_registered");
785
786                    // Register listeners for custom event names specified via builder.
787                    for event_name in &listen_events {
788                        let bus = state.event_bus.clone();
789                        let name = event_name.clone();
790                        app.listen_any(event_name.clone(), move |event| {
791                            let payload =
792                                serde_json::from_str::<serde_json::Value>(event.payload())
793                                    .map_or_else(
794                                        |_| event.payload().to_string(),
795                                        |v| v.to_string(),
796                                    );
797                            bus.push(introspection::CapturedTauriEvent {
798                                name: name.clone(),
799                                payload,
800                                timestamp: chrono::Utc::now()
801                                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
802                            });
803                        });
804                    }
805                    state
806                        .startup_timeline
807                        .mark("event_bus_listeners_registered");
808
809                    if let Some(ref token) = auth_token {
810                        let prefix_len = token.len().min(8);
811                        let suffix_start = token.len().saturating_sub(4);
812                        tracing::info!(
813                            "Victauri MCP server auth enabled — token: {}…{}",
814                            &token[..prefix_len],
815                            &token[suffix_start..]
816                        );
817                    } else {
818                        tracing::warn!(
819                            "Victauri MCP server running WITHOUT auth — any localhost process can \
820                             access all tools. Use VictauriBuilder::auth_enabled() or set \
821                             VICTAURI_AUTH_TOKEN for shared/CI environments."
822                        );
823                    }
824
825                    state.startup_timeline.mark("server_spawning");
826                    let app_handle = app.clone();
827                    let ready_state = state.clone();
828                    let server_finished = state.task_tracker.track("mcp_server");
829                    tauri::async_runtime::spawn(async move {
830                        match mcp::start_server_with_options(
831                            app_handle,
832                            state,
833                            port,
834                            auth_token,
835                            shutdown_rx,
836                        )
837                        .await
838                        {
839                            Ok(()) => {
840                                tracing::info!("Victauri MCP server stopped");
841                            }
842                            Err(e) => {
843                                tracing::error!("Victauri MCP server failed: {e}");
844                            }
845                        }
846                        server_finished.store(true, std::sync::atomic::Ordering::Relaxed);
847                    });
848
849                    if let Some(cb) = on_ready {
850                        let ready_finished = ready_state.task_tracker.track("on_ready_probe");
851                        tauri::async_runtime::spawn(async move {
852                            for _ in 0..50 {
853                                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
854                                let actual_port =
855                                    ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
856                                if tokio::net::TcpStream::connect(format!(
857                                    "127.0.0.1:{actual_port}"
858                                ))
859                                .await
860                                .is_ok()
861                                {
862                                    cb(actual_port);
863                                    ready_finished
864                                        .store(true, std::sync::atomic::Ordering::Relaxed);
865                                    return;
866                                }
867                            }
868                            let actual_port =
869                                ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
870                            tracing::warn!(
871                                "Victauri on_ready: server did not become ready within 5s"
872                            );
873                            cb(actual_port);
874                            ready_finished.store(true, std::sync::atomic::Ordering::Relaxed);
875                        });
876                    }
877
878                    emit_security_banner(port);
879                    Ok(())
880                })
881                .on_event(|app, event| {
882                    let Some(state) = app.try_state::<Arc<VictauriState>>() else {
883                        return;
884                    };
885                    match event {
886                        RunEvent::Exit => {
887                            let _ = state.shutdown_tx.send(true);
888                            tracing::info!("Victauri shutdown signal sent");
889                        }
890                        RunEvent::ExitRequested { .. } => {
891                            state.event_bus.push(introspection::CapturedTauriEvent {
892                                name: "tauri://exit-requested".to_string(),
893                                payload: String::new(),
894                                timestamp: chrono::Utc::now()
895                                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
896                            });
897                        }
898                        RunEvent::WindowEvent {
899                            label,
900                            event: win_event,
901                            ..
902                        } => {
903                            let (name, payload) = format_window_event(label, win_event);
904                            state.event_bus.push(introspection::CapturedTauriEvent {
905                                name,
906                                payload,
907                                timestamp: chrono::Utc::now()
908                                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
909                            });
910                        }
911                        _ => {}
912                    }
913                })
914                .js_init_script(js_init)
915                .invoke_handler(tauri::generate_handler![
916                    tools::victauri_eval_js,
917                    tools::victauri_eval_callback,
918                    tools::victauri_get_window_state,
919                    tools::victauri_list_windows,
920                    tools::victauri_get_ipc_log,
921                    tools::victauri_get_registry,
922                    tools::victauri_get_memory_stats,
923                    tools::victauri_dom_snapshot,
924                    tools::victauri_verify_state,
925                    tools::victauri_detect_ghost_commands,
926                    tools::victauri_check_ipc_integrity,
927                ])
928                .build())
929        }
930    }
931}
932
933#[cfg(debug_assertions)]
934fn format_window_event(label: &str, event: &tauri::WindowEvent) -> (String, String) {
935    match event {
936        tauri::WindowEvent::Resized(size) => (
937            format!("window:{label}:resized"),
938            serde_json::json!({"width": size.width, "height": size.height}).to_string(),
939        ),
940        tauri::WindowEvent::Moved(pos) => (
941            format!("window:{label}:moved"),
942            serde_json::json!({"x": pos.x, "y": pos.y}).to_string(),
943        ),
944        tauri::WindowEvent::CloseRequested { .. } => {
945            (format!("window:{label}:close-requested"), String::new())
946        }
947        tauri::WindowEvent::Destroyed => (format!("window:{label}:destroyed"), String::new()),
948        tauri::WindowEvent::Focused(focused) => (
949            format!("window:{label}:focused"),
950            serde_json::json!({"focused": focused}).to_string(),
951        ),
952        tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => (
953            format!("window:{label}:scale-factor-changed"),
954            serde_json::json!({"scale_factor": scale_factor}).to_string(),
955        ),
956        tauri::WindowEvent::ThemeChanged(theme) => (
957            format!("window:{label}:theme-changed"),
958            serde_json::json!({"theme": format!("{theme:?}")}).to_string(),
959        ),
960        tauri::WindowEvent::DragDrop(drag_event) => (
961            format!("window:{label}:drag-drop"),
962            format!("{drag_event:?}"),
963        ),
964        _ => (format!("window:{label}:other"), format!("{event:?}")),
965    }
966}
967
968/// Returns `true` if the named environment variable is set to a truthy value
969/// (`1`, `true`, `yes`, or `on`, case-insensitive, surrounding whitespace ignored).
970/// Unset, empty, or any other value is `false`.
971#[cfg(debug_assertions)]
972fn env_truthy(name: &str) -> bool {
973    std::env::var(name).is_ok_and(|v| {
974        matches!(
975            v.trim().to_ascii_lowercase().as_str(),
976            "1" | "true" | "yes" | "on"
977        )
978    })
979}
980
981/// Emits an unmissable startup banner whenever the introspection server activates.
982///
983/// Victauri is a debug-only tool that exposes JS eval, IPC, the filesystem, and
984/// `SQLite` over localhost. Logging this loudly on every start guarantees it can
985/// never run *silently* — most importantly catching the dangerous case of a
986/// release build accidentally compiled with `debug-assertions = true`, where the
987/// `#[cfg(debug_assertions)]` gate would otherwise let the full server bind with
988/// no visible signal.
989#[cfg(debug_assertions)]
990fn emit_security_banner(port: u16) {
991    tracing::warn!(
992        "┌─ VICTAURI INTROSPECTION SERVER ACTIVE ─────────────────────────────\n\
993         │ Listening on http://127.0.0.1:{port} — exposes JS eval, IPC, the\n\
994         │ filesystem, and SQLite to local clients. DEBUG-ONLY developer tool;\n\
995         │ it must never reach end users.\n\
996         │ Seeing this in a shipped/release build means your release profile has\n\
997         │ `debug-assertions = true`. Turn that off, or hard-disable Victauri\n\
998         │ with the VICTAURI_DISABLE=1 environment variable.\n\
999         └────────────────────────────────────────────────────────────────────"
1000    );
1001}
1002
1003/// Initialize the Victauri plugin with default settings (port 7373 or `VICTAURI_PORT` env var).
1004///
1005/// In debug builds: starts the embedded MCP server with **authentication enabled**
1006/// (auto-generated token written to `<temp>/victauri/<pid>/token`), injects the JS
1007/// bridge, and registers all Tauri command handlers.
1008///
1009/// In release builds: returns a no-op plugin. The MCP server, JS bridge, and
1010/// all introspection tools are completely stripped — zero overhead, zero attack surface.
1011///
1012/// Setting the `VICTAURI_DISABLE=1` environment variable forces the no-op plugin
1013/// even in a debug build — a hard kill-switch for shared environments or a release
1014/// profile compiled with `debug-assertions = true`. When the server does activate
1015/// Resolve configured `db_search_paths` against multiple stable anchors so a
1016/// relative path is found regardless of the launch CWD.
1017///
1018/// Real apps register CWD-relative paths — e.g. 4DA's `../data` is relative to
1019/// `src-tauri/` — but the binary usually runs from `target/debug/` or a bundle,
1020/// so resolving only against the current directory silently misses the database
1021/// (the single most valuable backend surface). We resolve each relative path
1022/// against the CWD **and every ancestor of the executable**, keeping each
1023/// candidate that exists as a directory. Absolute paths pass through. The result
1024/// is deduped canonical directories.
1025fn resolve_db_search_paths(configured: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
1026    resolve_db_search_paths_against(configured, &db_search_anchors())
1027}
1028
1029/// Collect stable anchor directories for resolving relative db search paths: the
1030/// CWD plus every ancestor of the executable.
1031fn db_search_anchors() -> Vec<std::path::PathBuf> {
1032    use std::path::PathBuf;
1033    let mut anchors: Vec<PathBuf> = Vec::new();
1034    if let Ok(cwd) = std::env::current_dir() {
1035        anchors.push(cwd);
1036    }
1037    if let Ok(exe) = std::env::current_exe() {
1038        let mut dir = exe.parent().map(PathBuf::from);
1039        // Walk up the executable's ancestors (target/debug -> target -> src-tauri
1040        // -> project root -> ...), so a path relative to any of them resolves.
1041        for _ in 0..8 {
1042            match dir {
1043                Some(d) => {
1044                    anchors.push(d.clone());
1045                    dir = d.parent().map(PathBuf::from);
1046                }
1047                None => break,
1048            }
1049        }
1050    }
1051    anchors
1052}
1053
1054/// Pure core (testable): resolve each configured path against the given anchors,
1055/// keeping every candidate that exists as a directory. Absolute paths pass
1056/// through. Result is deduped canonical directories.
1057fn resolve_db_search_paths_against(
1058    configured: &[std::path::PathBuf],
1059    anchors: &[std::path::PathBuf],
1060) -> Vec<std::path::PathBuf> {
1061    let mut out: Vec<std::path::PathBuf> = Vec::new();
1062    for p in configured {
1063        if p.is_absolute() {
1064            out.push(p.canonicalize().unwrap_or_else(|_| p.clone()));
1065        } else {
1066            for a in anchors {
1067                if let Ok(c) = a.join(p).canonicalize()
1068                    && c.is_dir()
1069                {
1070                    out.push(c);
1071                }
1072            }
1073        }
1074    }
1075    out.sort();
1076    out.dedup();
1077    out
1078}
1079
1080/// it always logs a prominent startup banner, so it can never run silently.
1081///
1082/// For custom configuration, use `VictauriBuilder::new().port(8080).build()`.
1083///
1084/// # Panics
1085///
1086/// Panics if the default builder configuration is invalid (this is a bug).
1087#[must_use]
1088pub fn init<R: Runtime>() -> TauriPlugin<R> {
1089    VictauriBuilder::new()
1090        .build()
1091        .expect("default Victauri configuration is always valid")
1092}
1093
1094/// Initialize the Victauri plugin with auto-discovery of all `#[inspectable]` commands.
1095///
1096/// Equivalent to `VictauriBuilder::new().auto_discover().build()` — all commands
1097/// marked with `#[inspectable]` are registered automatically without manual listing.
1098///
1099/// # Panics
1100///
1101/// Panics if the default builder configuration is invalid (this is a bug).
1102#[must_use]
1103pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
1104    VictauriBuilder::new()
1105        .auto_discover()
1106        .build()
1107        .expect("default Victauri configuration is always valid")
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113
1114    #[test]
1115    fn db_search_resolves_relative_path_via_a_non_cwd_anchor() {
1116        // Reproduces the live-4DA gap: the DB lives under one anchor (e.g. the
1117        // project dir) via a relative path, but the process CWD is elsewhere
1118        // (e.g. target/debug). Multi-anchor resolution must still find it.
1119        use std::path::PathBuf;
1120        let tmp = tempfile::tempdir().unwrap();
1121        let project = tmp.path().join("myapp");
1122        let data = project.join("data");
1123        std::fs::create_dir_all(&data).unwrap();
1124        std::fs::write(data.join("app.db"), b"x").unwrap();
1125        // Simulate the binary running from project/src-tauri/target/debug: the CWD
1126        // anchor (an unrelated dir) does NOT contain "../data", but the
1127        // "src-tauri" ancestor does.
1128        let wrong_cwd = tmp.path().join("elsewhere");
1129        std::fs::create_dir_all(&wrong_cwd).unwrap();
1130        let src_tauri = project.join("src-tauri");
1131        std::fs::create_dir_all(&src_tauri).unwrap();
1132
1133        let configured = vec![PathBuf::from("../data")]; // 4DA's exact config
1134        // CWD-only resolution (the old behavior) finds nothing:
1135        let cwd_only =
1136            resolve_db_search_paths_against(&configured, std::slice::from_ref(&wrong_cwd));
1137        assert!(
1138            cwd_only.is_empty(),
1139            "CWD-only should miss the db, got {cwd_only:?}"
1140        );
1141        // Multi-anchor resolution (CWD + the src-tauri ancestor) finds it:
1142        let multi = resolve_db_search_paths_against(&configured, &[wrong_cwd, src_tauri]);
1143        let want = data.canonicalize().unwrap();
1144        assert!(
1145            multi.contains(&want),
1146            "multi-anchor should resolve ../data to {want:?}, got {multi:?}"
1147        );
1148    }
1149
1150    #[test]
1151    fn db_search_passes_absolute_and_dedups() {
1152        use std::path::PathBuf;
1153        let tmp = tempfile::tempdir().unwrap();
1154        let data = tmp.path().join("data");
1155        std::fs::create_dir_all(&data).unwrap();
1156        let abs = data.canonicalize().unwrap();
1157        // Same dir reachable two ways must dedupe to one canonical entry.
1158        let out = resolve_db_search_paths_against(
1159            &[abs.clone(), PathBuf::from("data")],
1160            &[tmp.path().to_path_buf(), tmp.path().to_path_buf()],
1161        );
1162        assert_eq!(
1163            out.iter().filter(|p| **p == abs).count(),
1164            1,
1165            "must dedupe: {out:?}"
1166        );
1167    }
1168
1169    #[test]
1170    fn builder_default_values() {
1171        let builder = VictauriBuilder::new();
1172        assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
1173        assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
1174        assert!(builder.auth_token.is_none());
1175        assert!(!builder.auth_explicitly_enabled);
1176        assert!(!builder.auth_explicitly_disabled);
1177        let resolved = builder.resolve_auth_token();
1178        assert!(
1179            resolved.is_some(),
1180            "auth should be enabled by default (auto-generated token)"
1181        );
1182        assert_eq!(
1183            resolved.unwrap().len(),
1184            36,
1185            "auto-generated token should be UUID v4"
1186        );
1187        assert!(builder.disabled_tools.is_empty());
1188        assert!(builder.command_allowlist.is_none());
1189        assert!(builder.command_blocklist.is_empty());
1190        assert!(!builder.redaction_enabled);
1191        assert!(!builder.strict_privacy);
1192    }
1193
1194    #[test]
1195    fn builder_port_override() {
1196        let builder = VictauriBuilder::new().port(9090);
1197        assert_eq!(builder.resolve_port(), 9090);
1198    }
1199
1200    #[test]
1201    #[allow(unsafe_code)]
1202    fn builder_default_port() {
1203        let builder = VictauriBuilder::new();
1204        // SAFETY: test-only — no concurrent env reads in this test binary.
1205        unsafe { std::env::remove_var("VICTAURI_PORT") };
1206        assert_eq!(builder.resolve_port(), DEFAULT_PORT);
1207    }
1208
1209    #[test]
1210    fn builder_auth_token_explicit() {
1211        let builder = VictauriBuilder::new().auth_token("my-secret");
1212        assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
1213    }
1214
1215    // `env_truthy` is part of the debug-only kill-switch path, so this test can
1216    // only reference it in debug builds (otherwise `cargo test --release` fails to
1217    // compile — a config our CI doesn't normally run).
1218    #[cfg(debug_assertions)]
1219    #[test]
1220    #[allow(unsafe_code)]
1221    fn env_truthy_recognizes_kill_switch_values() {
1222        // Unique var name so this never races with other env-touching tests.
1223        let key = "VICTAURI_TEST_KILL_SWITCH";
1224        // SAFETY: test-only — this var is unique to this test, no concurrent readers.
1225        unsafe { std::env::remove_var(key) };
1226        assert!(!env_truthy(key), "unset should be false");
1227
1228        for v in ["1", "true", "TRUE", " yes ", "On"] {
1229            // SAFETY: test-only — unique var, no concurrent readers.
1230            unsafe { std::env::set_var(key, v) };
1231            assert!(env_truthy(key), "{v:?} should be truthy");
1232        }
1233        for v in ["0", "false", "", "nope"] {
1234            // SAFETY: test-only — unique var, no concurrent readers.
1235            unsafe { std::env::set_var(key, v) };
1236            assert!(!env_truthy(key), "{v:?} should be falsy");
1237        }
1238        // SAFETY: test-only — restore clean state.
1239        unsafe { std::env::remove_var(key) };
1240    }
1241
1242    #[test]
1243    fn builder_auth_enabled() {
1244        let builder = VictauriBuilder::new().auth_enabled();
1245        assert!(builder.auth_explicitly_enabled);
1246        let token = builder.resolve_auth_token().unwrap();
1247        assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
1248    }
1249
1250    #[test]
1251    fn builder_auth_generate_token() {
1252        let builder = VictauriBuilder::new().generate_auth_token();
1253        let token = builder.resolve_auth_token().unwrap();
1254        assert_eq!(token.len(), 36);
1255    }
1256
1257    #[test]
1258    fn builder_auth_disabled_suppresses_default_token() {
1259        let builder = VictauriBuilder::new().auth_disabled();
1260        assert!(
1261            builder.resolve_auth_token().is_none(),
1262            "auth_disabled must suppress the default auto-generated token (auth is ON by default)"
1263        );
1264    }
1265
1266    #[test]
1267    fn builder_auth_disabled_returns_none() {
1268        let builder = VictauriBuilder::new().auth_disabled();
1269        assert!(
1270            builder.resolve_auth_token().is_none(),
1271            "auth_disabled should suppress auto-generated token"
1272        );
1273    }
1274
1275    #[test]
1276    fn builder_auth_disabled_overrides_explicit_token() {
1277        let builder = VictauriBuilder::new()
1278            .auth_token("my-secret")
1279            .auth_disabled();
1280        assert!(
1281            builder.resolve_auth_token().is_none(),
1282            "auth_disabled should override explicit token"
1283        );
1284    }
1285
1286    #[test]
1287    fn builder_empty_explicit_token_does_not_disable_auth() {
1288        // Fail-open guard: an empty/whitespace token must NOT become "no auth"; it falls
1289        // through to a freshly generated token. Only `auth_disabled()` disables auth.
1290        for blank in ["", "   ", "\t\n"] {
1291            let resolved = VictauriBuilder::new()
1292                .auth_token(blank)
1293                .resolve_auth_token();
1294            assert!(
1295                resolved.as_deref().is_some_and(|t| !t.trim().is_empty()),
1296                "empty explicit token {blank:?} must resolve to a generated token, not no-auth"
1297            );
1298        }
1299    }
1300
1301    #[test]
1302    fn builder_capacities() {
1303        let builder = VictauriBuilder::new()
1304            .event_capacity(500)
1305            .recorder_capacity(2000);
1306        assert_eq!(builder.event_capacity, 500);
1307        assert_eq!(builder.recorder_capacity, 2000);
1308    }
1309
1310    #[test]
1311    fn builder_disable_tools() {
1312        let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
1313        assert_eq!(builder.disabled_tools.len(), 2);
1314        assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
1315    }
1316
1317    #[test]
1318    fn builder_command_allowlist() {
1319        let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
1320        assert!(builder.command_allowlist.is_some());
1321        assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
1322    }
1323
1324    #[test]
1325    fn builder_command_blocklist() {
1326        let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
1327        assert_eq!(builder.command_blocklist.len(), 1);
1328    }
1329
1330    #[test]
1331    fn builder_redaction() {
1332        let builder = VictauriBuilder::new()
1333            .add_redaction_pattern(r"SECRET_\w+")
1334            .enable_redaction();
1335        assert!(builder.redaction_enabled);
1336        assert_eq!(builder.redaction_patterns.len(), 1);
1337    }
1338
1339    #[test]
1340    fn builder_strict_privacy_config() {
1341        let builder = VictauriBuilder::new().strict_privacy_mode();
1342        let config = builder.build_privacy_config();
1343        assert!(config.redaction_enabled);
1344        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
1345        assert!(!config.is_tool_enabled("eval_js"));
1346        assert!(!config.is_tool_enabled("screenshot"));
1347        assert!(!config.is_tool_enabled("interact"));
1348        assert!(config.is_tool_enabled("dom_snapshot"));
1349    }
1350
1351    #[test]
1352    fn builder_normal_privacy_config() {
1353        let builder = VictauriBuilder::new()
1354            .command_blocklist(&["secret_cmd"])
1355            .disable_tools(&["eval_js"]);
1356        let config = builder.build_privacy_config();
1357        assert!(config.command_blocklist.contains("secret_cmd"));
1358        assert!(!config.is_tool_enabled("eval_js"));
1359        assert!(!config.redaction_enabled);
1360    }
1361
1362    #[test]
1363    fn builder_strict_with_extra_blocklist() {
1364        let builder = VictauriBuilder::new()
1365            .strict_privacy_mode()
1366            .command_blocklist(&["extra_dangerous"]);
1367        let config = builder.build_privacy_config();
1368        assert!(config.command_blocklist.contains("extra_dangerous"));
1369        assert!(!config.is_tool_enabled("eval_js"));
1370    }
1371
1372    #[test]
1373    fn builder_test_profile() {
1374        let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
1375        let config = builder.build_privacy_config();
1376        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
1377        assert!(config.redaction_enabled);
1378        assert!(config.is_tool_enabled("interact"));
1379        assert!(config.is_tool_enabled("fill"));
1380        assert!(config.is_tool_enabled("recording"));
1381        assert!(!config.is_tool_enabled("eval_js"));
1382        assert!(!config.is_tool_enabled("screenshot"));
1383        // The bare `navigate` tool surfaces (read actions allowed), but the
1384        // mutating `go_to` action stays FullControl-only.
1385        assert!(!config.is_tool_enabled("navigate.go_to"));
1386    }
1387
1388    #[test]
1389    fn builder_profile_with_extra_disables() {
1390        let builder = VictauriBuilder::new()
1391            .privacy_profile(crate::privacy::PrivacyProfile::Test)
1392            .disable_tools(&["interact"]);
1393        let config = builder.build_privacy_config();
1394        assert!(!config.is_tool_enabled("interact"));
1395        assert!(config.is_tool_enabled("fill"));
1396    }
1397
1398    #[test]
1399    fn builder_bridge_capacities() {
1400        let builder = VictauriBuilder::new()
1401            .console_log_capacity(5000)
1402            .network_log_capacity(2000)
1403            .navigation_log_capacity(500);
1404        assert_eq!(builder.bridge_capacities.console_logs, 5000);
1405        assert_eq!(builder.bridge_capacities.network_log, 2000);
1406        assert_eq!(builder.bridge_capacities.navigation_log, 500);
1407        assert_eq!(builder.bridge_capacities.mutation_log, 500);
1408        assert_eq!(builder.bridge_capacities.dialog_log, 100);
1409    }
1410
1411    #[test]
1412    fn builder_on_ready_sets_callback() {
1413        let builder = VictauriBuilder::new().on_ready(|_port| {});
1414        assert!(builder.on_ready.is_some());
1415    }
1416
1417    #[test]
1418    fn builder_file_navigation_disabled_by_default() {
1419        let builder = VictauriBuilder::new();
1420        assert!(
1421            !builder.allow_file_navigation,
1422            "file navigation should be disabled by default"
1423        );
1424    }
1425
1426    #[test]
1427    fn builder_allow_file_navigation() {
1428        let builder = VictauriBuilder::new().allow_file_navigation();
1429        assert!(builder.allow_file_navigation);
1430    }
1431
1432    #[test]
1433    fn builder_listen_events() {
1434        let builder =
1435            VictauriBuilder::new().listen_events(&["notification-added", "settings-changed"]);
1436        assert_eq!(builder.listen_events.len(), 2);
1437        assert!(
1438            builder
1439                .listen_events
1440                .contains(&"notification-added".to_string())
1441        );
1442        assert!(
1443            builder
1444                .listen_events
1445                .contains(&"settings-changed".to_string())
1446        );
1447    }
1448
1449    #[test]
1450    fn builder_listen_events_empty_by_default() {
1451        let builder = VictauriBuilder::new();
1452        assert!(builder.listen_events.is_empty());
1453    }
1454
1455    #[test]
1456    fn init_script_contains_custom_capacities() {
1457        let caps = js_bridge::BridgeCapacities {
1458            console_logs: 3000,
1459            mutation_log: 750,
1460            network_log: 5000,
1461            navigation_log: 400,
1462            dialog_log: 250,
1463            long_tasks: 200,
1464        };
1465        let script = js_bridge::init_script(&caps);
1466        assert!(script.contains("CAP_CONSOLE = 3000"));
1467        assert!(script.contains("CAP_MUTATION = 750"));
1468        assert!(script.contains("CAP_NETWORK = 5000"));
1469        assert!(script.contains("CAP_NAVIGATION = 400"));
1470        assert!(script.contains("CAP_DIALOG = 250"));
1471        assert!(script.contains("CAP_LONG_TASKS = 200"));
1472    }
1473
1474    #[test]
1475    fn init_script_default_contains_standard_capacities() {
1476        let caps = js_bridge::BridgeCapacities::default();
1477        let script = js_bridge::init_script(&caps);
1478        assert!(script.contains("CAP_CONSOLE = 1000"));
1479        assert!(script.contains("CAP_NETWORK = 1000"));
1480        assert!(script.contains("window.__VICTAURI__"));
1481    }
1482
1483    #[test]
1484    fn builder_validates_defaults() {
1485        let builder = VictauriBuilder::new();
1486        assert!(builder.validate().is_ok());
1487    }
1488
1489    #[test]
1490    fn builder_rejects_zero_port() {
1491        let builder = VictauriBuilder::new().port(0);
1492        let err = builder.validate().unwrap_err();
1493        assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
1494    }
1495
1496    #[test]
1497    fn builder_rejects_zero_event_capacity() {
1498        let builder = VictauriBuilder::new().event_capacity(0);
1499        let err = builder.validate().unwrap_err();
1500        assert!(matches!(
1501            err,
1502            BuilderError::InvalidEventCapacity { capacity: 0, .. }
1503        ));
1504    }
1505
1506    #[test]
1507    fn builder_rejects_excessive_event_capacity() {
1508        let builder = VictauriBuilder::new().event_capacity(2_000_000);
1509        assert!(builder.validate().is_err());
1510    }
1511
1512    #[test]
1513    fn builder_rejects_zero_recorder_capacity() {
1514        let builder = VictauriBuilder::new().recorder_capacity(0);
1515        assert!(builder.validate().is_err());
1516    }
1517
1518    #[test]
1519    fn builder_rejects_zero_eval_timeout() {
1520        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
1521        assert!(builder.validate().is_err());
1522    }
1523
1524    #[test]
1525    fn builder_rejects_excessive_eval_timeout() {
1526        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1527        assert!(builder.validate().is_err());
1528    }
1529
1530    #[test]
1531    fn builder_accepts_edge_values() {
1532        let builder = VictauriBuilder::new()
1533            .port(1)
1534            .event_capacity(1)
1535            .recorder_capacity(1)
1536            .eval_timeout(std::time::Duration::from_secs(1));
1537        assert!(builder.validate().is_ok());
1538
1539        let builder = VictauriBuilder::new()
1540            .port(65535)
1541            .event_capacity(MAX_EVENT_CAPACITY)
1542            .recorder_capacity(MAX_RECORDER_CAPACITY)
1543            .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1544        assert!(builder.validate().is_ok());
1545    }
1546}