1#![deny(missing_docs)]
2#![cfg_attr(not(debug_assertions), allow(unused_imports, dead_code))]
7pub mod bridge;
46#[cfg(feature = "sqlite")]
48pub mod database;
49pub mod error;
50pub mod js_bridge;
52pub mod mcp;
54mod memory;
55pub mod privacy;
57
58pub mod filmstrip;
60pub mod redaction;
62pub mod screencast;
63pub(crate) mod screenshot;
64mod tools;
65
66pub mod auth;
68pub 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#[macro_export]
106macro_rules! register_commands {
107 ($app:expr, $($schema_call:expr),+ $(,)?) => {{
108 let state = $app.state::<std::sync::Arc<$crate::VictauriState>>();
109 $(
110 state.registry.register($schema_call);
111 )+
112 }};
113}
114
115const DEFAULT_PORT: u16 = 7373;
116const DEFAULT_EVENT_CAPACITY: usize = 10_000;
117const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
118const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
119const MAX_EVENT_CAPACITY: usize = 1_000_000;
120const MAX_RECORDER_CAPACITY: usize = 1_000_000;
121const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
122
123pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
126
127pub struct VictauriState {
129 pub event_log: EventLog,
131 pub registry: CommandRegistry,
133 pub port: AtomicU16,
135 pub pending_evals: PendingCallbacks,
137 pub recorder: EventRecorder,
139 pub privacy: privacy::PrivacyConfig,
141 pub eval_timeout: std::time::Duration,
143 pub shutdown_tx: watch::Sender<bool>,
145 pub started_at: std::time::Instant,
147 pub tool_invocations: AtomicU64,
149 pub allow_file_navigation: bool,
153 pub command_timings: introspection::CommandTimings,
155 pub fault_registry: introspection::FaultRegistry,
157 pub contract_store: introspection::ContractStore,
159 pub startup_timeline: introspection::StartupTimeline,
161 pub event_bus: introspection::EventBusMonitor,
163 pub task_tracker: introspection::TaskTracker,
165 pub bridge_ready: AtomicBool,
167 pub bridge_notify: tokio::sync::Notify,
169 pub screencast: Arc<screencast::Screencast>,
171 pub db_search_paths: Vec<std::path::PathBuf>,
178 pub probes: introspection::AppStateProbes,
181}
182
183pub struct VictauriBuilder {
195 port: Option<u16>,
196 event_capacity: usize,
197 recorder_capacity: usize,
198 eval_timeout: std::time::Duration,
199 auth_token: Option<String>,
200 auth_explicitly_enabled: bool,
201 auth_explicitly_disabled: bool,
202 disabled_tools: Vec<String>,
203 command_allowlist: Option<Vec<String>>,
204 command_blocklist: Vec<String>,
205 storage_key_blocklist: Vec<String>,
206 redaction_patterns: Vec<String>,
207 redaction_enabled: bool,
208 strict_privacy: bool,
209 privacy_profile: Option<privacy::PrivacyProfile>,
210 bridge_capacities: js_bridge::BridgeCapacities,
211 on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
212 commands: Vec<victauri_core::CommandInfo>,
213 allow_file_navigation: bool,
214 listen_events: Vec<String>,
215 db_search_paths: Vec<std::path::PathBuf>,
216 probes: Vec<(String, std::sync::Arc<introspection::ProbeFn>)>,
217}
218
219impl Default for VictauriBuilder {
220 fn default() -> Self {
221 Self {
222 port: None,
223 event_capacity: DEFAULT_EVENT_CAPACITY,
224 recorder_capacity: DEFAULT_RECORDER_CAPACITY,
225 eval_timeout: DEFAULT_EVAL_TIMEOUT,
226 auth_token: None,
227 auth_explicitly_enabled: false,
228 auth_explicitly_disabled: false,
229 disabled_tools: Vec::new(),
230 command_allowlist: None,
231 command_blocklist: Vec::new(),
232 storage_key_blocklist: Vec::new(),
233 redaction_patterns: Vec::new(),
234 redaction_enabled: false,
235 strict_privacy: false,
236 privacy_profile: None,
237 bridge_capacities: js_bridge::BridgeCapacities::default(),
238 on_ready: None,
239 commands: Vec::new(),
240 allow_file_navigation: false,
241 listen_events: Vec::new(),
242 db_search_paths: Vec::new(),
243 probes: Vec::new(),
244 }
245 }
246}
247
248impl VictauriBuilder {
249 #[must_use]
251 pub fn new() -> Self {
252 Self::default()
253 }
254
255 #[must_use]
257 pub fn port(mut self, port: u16) -> Self {
258 self.port = Some(port);
259 self
260 }
261
262 #[must_use]
264 pub fn event_capacity(mut self, capacity: usize) -> Self {
265 self.event_capacity = capacity;
266 self
267 }
268
269 #[must_use]
271 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
272 self.recorder_capacity = capacity;
273 self
274 }
275
276 #[must_use]
278 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
279 self.eval_timeout = timeout;
280 self
281 }
282
283 #[must_use]
287 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
288 self.auth_token = Some(token.into());
289 self
290 }
291
292 #[must_use]
300 pub fn auth_enabled(mut self) -> Self {
301 self.auth_explicitly_enabled = true;
302 self
303 }
304
305 #[must_use]
307 pub fn generate_auth_token(mut self) -> Self {
308 self.auth_explicitly_enabled = true;
309 self
310 }
311
312 #[must_use]
322 pub fn auth_disabled(mut self) -> Self {
323 self.auth_explicitly_disabled = true;
324 self
325 }
326
327 #[must_use]
329 pub fn disable_tools(mut self, tools: &[&str]) -> Self {
330 self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
331 self
332 }
333
334 #[must_use]
336 pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
337 self.command_allowlist = Some(
338 commands
339 .iter()
340 .map(std::string::ToString::to_string)
341 .collect(),
342 );
343 self
344 }
345
346 #[must_use]
348 pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
349 self.command_blocklist = commands
350 .iter()
351 .map(std::string::ToString::to_string)
352 .collect();
353 self
354 }
355
356 #[must_use]
361 pub fn storage_key_blocklist(mut self, keys: &[&str]) -> Self {
362 self.storage_key_blocklist = keys.iter().map(std::string::ToString::to_string).collect();
363 self
364 }
365
366 #[must_use]
368 pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
369 self.redaction_patterns.push(pattern.into());
370 self
371 }
372
373 #[must_use]
375 pub fn enable_redaction(mut self) -> Self {
376 self.redaction_enabled = true;
377 self
378 }
379
380 #[must_use]
387 pub fn strict_privacy_mode(mut self) -> Self {
388 self.strict_privacy = true;
389 self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
390 self
391 }
392
393 #[must_use]
402 pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
403 self.privacy_profile = Some(profile);
404 if matches!(
405 profile,
406 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
407 ) {
408 self.redaction_enabled = true;
409 }
410 self
411 }
412
413 #[must_use]
415 pub fn console_log_capacity(mut self, capacity: usize) -> Self {
416 self.bridge_capacities.console_logs = capacity;
417 self
418 }
419
420 #[must_use]
422 pub fn network_log_capacity(mut self, capacity: usize) -> Self {
423 self.bridge_capacities.network_log = capacity;
424 self
425 }
426
427 #[must_use]
429 pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
430 self.bridge_capacities.navigation_log = capacity;
431 self
432 }
433
434 #[must_use]
449 pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
450 self.commands = schemas.to_vec();
451 self
452 }
453
454 #[must_use]
470 pub fn register_command_names(mut self, names: &[&str]) -> Self {
471 self.commands
472 .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
473 self
474 }
475
476 #[must_use]
492 pub fn auto_discover(mut self) -> Self {
493 self.commands
494 .extend(victauri_core::auto_discovered_commands());
495 self
496 }
497
498 #[must_use]
509 pub fn listen_events(mut self, events: &[&str]) -> Self {
510 self.listen_events = events
511 .iter()
512 .map(std::string::ToString::to_string)
513 .collect();
514 self
515 }
516
517 #[must_use]
526 pub fn allow_file_navigation(mut self) -> Self {
527 self.allow_file_navigation = true;
528 self
529 }
530
531 #[must_use]
544 pub fn db_search_paths<I, P>(mut self, paths: I) -> Self
545 where
546 I: IntoIterator<Item = P>,
547 P: Into<std::path::PathBuf>,
548 {
549 self.db_search_paths
550 .extend(paths.into_iter().map(Into::into));
551 self
552 }
553
554 #[must_use]
578 pub fn probe<F>(mut self, name: impl Into<String>, probe: F) -> Self
579 where
580 F: Fn() -> serde_json::Value + Send + Sync + 'static,
581 {
582 self.probes.push((name.into(), std::sync::Arc::new(probe)));
583 self
584 }
585
586 #[must_use]
589 pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
590 self.on_ready = Some(Box::new(f));
591 self
592 }
593
594 fn resolve_port(&self) -> u16 {
595 self.port
596 .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
597 .unwrap_or(DEFAULT_PORT)
598 }
599
600 fn resolve_auth_token(&self) -> Option<String> {
601 if self.auth_explicitly_disabled {
602 return None;
603 }
604 if let Some(ref token) = self.auth_token {
605 return Some(token.clone());
606 }
607 if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
608 return Some(token);
609 }
610 Some(auth::generate_token())
611 }
612
613 fn resolve_eval_timeout(&self) -> std::time::Duration {
614 std::env::var("VICTAURI_EVAL_TIMEOUT")
615 .ok()
616 .and_then(|s| s.parse::<u64>().ok())
617 .map_or(self.eval_timeout, std::time::Duration::from_secs)
618 }
619
620 fn build_privacy_config(&self) -> privacy::PrivacyConfig {
621 let profile = self
622 .privacy_profile
623 .unwrap_or(privacy::PrivacyProfile::FullControl);
624
625 let redaction_enabled = self.redaction_enabled
626 || self.strict_privacy
627 || matches!(
628 profile,
629 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
630 );
631
632 privacy::PrivacyConfig {
633 profile,
634 command_allowlist: self
635 .command_allowlist
636 .as_ref()
637 .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
638 command_blocklist: self.command_blocklist.iter().cloned().collect(),
639 disabled_tools: self.disabled_tools.iter().cloned().collect(),
640 storage_key_blocklist: self.storage_key_blocklist.iter().cloned().collect(),
641 redactor: redaction::Redactor::new(&self.redaction_patterns),
642 redaction_enabled,
643 }
644 }
645
646 fn validate(&self) -> Result<(), BuilderError> {
647 let port = self.resolve_port();
648 if port == 0 {
649 return Err(BuilderError::InvalidPort {
650 port,
651 reason: "port 0 is reserved".to_string(),
652 });
653 }
654
655 if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
656 return Err(BuilderError::InvalidEventCapacity {
657 capacity: self.event_capacity,
658 reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
659 });
660 }
661
662 if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
663 return Err(BuilderError::InvalidRecorderCapacity {
664 capacity: self.recorder_capacity,
665 reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
666 });
667 }
668
669 let timeout = self.resolve_eval_timeout();
670 if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
671 return Err(BuilderError::InvalidEvalTimeout {
672 timeout_secs: timeout.as_secs(),
673 reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
674 });
675 }
676
677 Ok(())
678 }
679
680 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
690 #[cfg(not(debug_assertions))]
691 {
692 Ok(Builder::new("victauri").build())
693 }
694
695 #[cfg(debug_assertions)]
696 {
697 if env_truthy("VICTAURI_DISABLE") {
701 tracing::info!("Victauri disabled via VICTAURI_DISABLE — returning no-op plugin");
702 return Ok(Builder::new("victauri").build());
703 }
704
705 self.validate()?;
706
707 let port = self.resolve_port();
708 let event_capacity = self.event_capacity;
709 let recorder_capacity = self.recorder_capacity;
710 let eval_timeout = self.resolve_eval_timeout();
711 let auth_token = self.resolve_auth_token();
712 let privacy_config = self.build_privacy_config();
713 let allow_file_navigation = self.allow_file_navigation;
714 let db_search_paths = self.db_search_paths;
715 let on_ready = self.on_ready;
716 let commands = self.commands;
717 let listen_events = self.listen_events;
718 let probes = self.probes;
719 let js_init = js_bridge::init_script(&self.bridge_capacities);
720
721 Ok(Builder::new("victauri")
722 .setup(move |app, _api| {
723 let startup_timeline = introspection::StartupTimeline::new();
724 let event_log = EventLog::new(event_capacity);
725 startup_timeline.mark("event_log_created");
726 let registry = CommandRegistry::new();
727 startup_timeline.mark("registry_created");
728 let (shutdown_tx, shutdown_rx) = watch::channel(false);
729
730 let state = Arc::new(VictauriState {
731 event_log,
732 registry,
733 port: AtomicU16::new(port),
734 pending_evals: Arc::new(Mutex::new(HashMap::new())),
735 recorder: EventRecorder::new(recorder_capacity),
736 privacy: privacy_config,
737 eval_timeout,
738 shutdown_tx,
739 started_at: std::time::Instant::now(),
740 tool_invocations: AtomicU64::new(0),
741 allow_file_navigation,
742 command_timings: introspection::CommandTimings::new(),
743 fault_registry: introspection::FaultRegistry::new(),
744 contract_store: introspection::ContractStore::new(),
745 startup_timeline,
746 event_bus: introspection::EventBusMonitor::default(),
747 task_tracker: introspection::TaskTracker::new(),
748 bridge_ready: AtomicBool::new(false),
749 bridge_notify: tokio::sync::Notify::new(),
750 screencast: Arc::new(screencast::Screencast::default()),
751 db_search_paths,
752 probes: introspection::AppStateProbes::default(),
753 });
754 state.startup_timeline.mark("state_created");
755
756 for (name, probe) in probes {
757 state.probes.register(name, probe);
758 }
759
760 app.manage(state.clone());
761
762 for cmd in commands {
763 state.registry.register(cmd);
764 }
765 state.startup_timeline.mark("commands_registered");
766
767 for event_name in &listen_events {
769 let bus = state.event_bus.clone();
770 let name = event_name.clone();
771 app.listen_any(event_name.clone(), move |event| {
772 let payload =
773 serde_json::from_str::<serde_json::Value>(event.payload())
774 .map_or_else(
775 |_| event.payload().to_string(),
776 |v| v.to_string(),
777 );
778 bus.push(introspection::CapturedTauriEvent {
779 name: name.clone(),
780 payload,
781 timestamp: chrono::Utc::now()
782 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
783 });
784 });
785 }
786 state
787 .startup_timeline
788 .mark("event_bus_listeners_registered");
789
790 if let Some(ref token) = auth_token {
791 let prefix_len = token.len().min(8);
792 let suffix_start = token.len().saturating_sub(4);
793 tracing::info!(
794 "Victauri MCP server auth enabled — token: {}…{}",
795 &token[..prefix_len],
796 &token[suffix_start..]
797 );
798 } else {
799 tracing::warn!(
800 "Victauri MCP server running WITHOUT auth — any localhost process can \
801 access all tools. Use VictauriBuilder::auth_enabled() or set \
802 VICTAURI_AUTH_TOKEN for shared/CI environments."
803 );
804 }
805
806 state.startup_timeline.mark("server_spawning");
807 let app_handle = app.clone();
808 let ready_state = state.clone();
809 let server_finished = state.task_tracker.track("mcp_server");
810 tauri::async_runtime::spawn(async move {
811 match mcp::start_server_with_options(
812 app_handle,
813 state,
814 port,
815 auth_token,
816 shutdown_rx,
817 )
818 .await
819 {
820 Ok(()) => {
821 tracing::info!("Victauri MCP server stopped");
822 }
823 Err(e) => {
824 tracing::error!("Victauri MCP server failed: {e}");
825 }
826 }
827 server_finished.store(true, std::sync::atomic::Ordering::Relaxed);
828 });
829
830 if let Some(cb) = on_ready {
831 let ready_finished = ready_state.task_tracker.track("on_ready_probe");
832 tauri::async_runtime::spawn(async move {
833 for _ in 0..50 {
834 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
835 let actual_port =
836 ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
837 if tokio::net::TcpStream::connect(format!(
838 "127.0.0.1:{actual_port}"
839 ))
840 .await
841 .is_ok()
842 {
843 cb(actual_port);
844 ready_finished
845 .store(true, std::sync::atomic::Ordering::Relaxed);
846 return;
847 }
848 }
849 let actual_port =
850 ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
851 tracing::warn!(
852 "Victauri on_ready: server did not become ready within 5s"
853 );
854 cb(actual_port);
855 ready_finished.store(true, std::sync::atomic::Ordering::Relaxed);
856 });
857 }
858
859 emit_security_banner(port);
860 Ok(())
861 })
862 .on_event(|app, event| {
863 let Some(state) = app.try_state::<Arc<VictauriState>>() else {
864 return;
865 };
866 match event {
867 RunEvent::Exit => {
868 let _ = state.shutdown_tx.send(true);
869 tracing::info!("Victauri shutdown signal sent");
870 }
871 RunEvent::ExitRequested { .. } => {
872 state.event_bus.push(introspection::CapturedTauriEvent {
873 name: "tauri://exit-requested".to_string(),
874 payload: String::new(),
875 timestamp: chrono::Utc::now()
876 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
877 });
878 }
879 RunEvent::WindowEvent {
880 label,
881 event: win_event,
882 ..
883 } => {
884 let (name, payload) = format_window_event(label, win_event);
885 state.event_bus.push(introspection::CapturedTauriEvent {
886 name,
887 payload,
888 timestamp: chrono::Utc::now()
889 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
890 });
891 }
892 _ => {}
893 }
894 })
895 .js_init_script(js_init)
896 .invoke_handler(tauri::generate_handler![
897 tools::victauri_eval_js,
898 tools::victauri_eval_callback,
899 tools::victauri_get_window_state,
900 tools::victauri_list_windows,
901 tools::victauri_get_ipc_log,
902 tools::victauri_get_registry,
903 tools::victauri_get_memory_stats,
904 tools::victauri_dom_snapshot,
905 tools::victauri_verify_state,
906 tools::victauri_detect_ghost_commands,
907 tools::victauri_check_ipc_integrity,
908 ])
909 .build())
910 }
911 }
912}
913
914#[cfg(debug_assertions)]
915fn format_window_event(label: &str, event: &tauri::WindowEvent) -> (String, String) {
916 match event {
917 tauri::WindowEvent::Resized(size) => (
918 format!("window:{label}:resized"),
919 serde_json::json!({"width": size.width, "height": size.height}).to_string(),
920 ),
921 tauri::WindowEvent::Moved(pos) => (
922 format!("window:{label}:moved"),
923 serde_json::json!({"x": pos.x, "y": pos.y}).to_string(),
924 ),
925 tauri::WindowEvent::CloseRequested { .. } => {
926 (format!("window:{label}:close-requested"), String::new())
927 }
928 tauri::WindowEvent::Destroyed => (format!("window:{label}:destroyed"), String::new()),
929 tauri::WindowEvent::Focused(focused) => (
930 format!("window:{label}:focused"),
931 serde_json::json!({"focused": focused}).to_string(),
932 ),
933 tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => (
934 format!("window:{label}:scale-factor-changed"),
935 serde_json::json!({"scale_factor": scale_factor}).to_string(),
936 ),
937 tauri::WindowEvent::ThemeChanged(theme) => (
938 format!("window:{label}:theme-changed"),
939 serde_json::json!({"theme": format!("{theme:?}")}).to_string(),
940 ),
941 tauri::WindowEvent::DragDrop(drag_event) => (
942 format!("window:{label}:drag-drop"),
943 format!("{drag_event:?}"),
944 ),
945 _ => (format!("window:{label}:other"), format!("{event:?}")),
946 }
947}
948
949#[cfg(debug_assertions)]
953fn env_truthy(name: &str) -> bool {
954 std::env::var(name).is_ok_and(|v| {
955 matches!(
956 v.trim().to_ascii_lowercase().as_str(),
957 "1" | "true" | "yes" | "on"
958 )
959 })
960}
961
962#[cfg(debug_assertions)]
971fn emit_security_banner(port: u16) {
972 tracing::warn!(
973 "┌─ VICTAURI INTROSPECTION SERVER ACTIVE ─────────────────────────────\n\
974 │ Listening on http://127.0.0.1:{port} — exposes JS eval, IPC, the\n\
975 │ filesystem, and SQLite to local clients. DEBUG-ONLY developer tool;\n\
976 │ it must never reach end users.\n\
977 │ Seeing this in a shipped/release build means your release profile has\n\
978 │ `debug-assertions = true`. Turn that off, or hard-disable Victauri\n\
979 │ with the VICTAURI_DISABLE=1 environment variable.\n\
980 └────────────────────────────────────────────────────────────────────"
981 );
982}
983
984#[must_use]
1004pub fn init<R: Runtime>() -> TauriPlugin<R> {
1005 VictauriBuilder::new()
1006 .build()
1007 .expect("default Victauri configuration is always valid")
1008}
1009
1010#[must_use]
1019pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
1020 VictauriBuilder::new()
1021 .auto_discover()
1022 .build()
1023 .expect("default Victauri configuration is always valid")
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use super::*;
1029
1030 #[test]
1031 fn builder_default_values() {
1032 let builder = VictauriBuilder::new();
1033 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
1034 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
1035 assert!(builder.auth_token.is_none());
1036 assert!(!builder.auth_explicitly_enabled);
1037 assert!(!builder.auth_explicitly_disabled);
1038 let resolved = builder.resolve_auth_token();
1039 assert!(
1040 resolved.is_some(),
1041 "auth should be enabled by default (auto-generated token)"
1042 );
1043 assert_eq!(
1044 resolved.unwrap().len(),
1045 36,
1046 "auto-generated token should be UUID v4"
1047 );
1048 assert!(builder.disabled_tools.is_empty());
1049 assert!(builder.command_allowlist.is_none());
1050 assert!(builder.command_blocklist.is_empty());
1051 assert!(!builder.redaction_enabled);
1052 assert!(!builder.strict_privacy);
1053 }
1054
1055 #[test]
1056 fn builder_port_override() {
1057 let builder = VictauriBuilder::new().port(9090);
1058 assert_eq!(builder.resolve_port(), 9090);
1059 }
1060
1061 #[test]
1062 #[allow(unsafe_code)]
1063 fn builder_default_port() {
1064 let builder = VictauriBuilder::new();
1065 unsafe { std::env::remove_var("VICTAURI_PORT") };
1067 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
1068 }
1069
1070 #[test]
1071 fn builder_auth_token_explicit() {
1072 let builder = VictauriBuilder::new().auth_token("my-secret");
1073 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
1074 }
1075
1076 #[cfg(debug_assertions)]
1080 #[test]
1081 #[allow(unsafe_code)]
1082 fn env_truthy_recognizes_kill_switch_values() {
1083 let key = "VICTAURI_TEST_KILL_SWITCH";
1085 unsafe { std::env::remove_var(key) };
1087 assert!(!env_truthy(key), "unset should be false");
1088
1089 for v in ["1", "true", "TRUE", " yes ", "On"] {
1090 unsafe { std::env::set_var(key, v) };
1092 assert!(env_truthy(key), "{v:?} should be truthy");
1093 }
1094 for v in ["0", "false", "", "nope"] {
1095 unsafe { std::env::set_var(key, v) };
1097 assert!(!env_truthy(key), "{v:?} should be falsy");
1098 }
1099 unsafe { std::env::remove_var(key) };
1101 }
1102
1103 #[test]
1104 fn builder_auth_enabled() {
1105 let builder = VictauriBuilder::new().auth_enabled();
1106 assert!(builder.auth_explicitly_enabled);
1107 let token = builder.resolve_auth_token().unwrap();
1108 assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
1109 }
1110
1111 #[test]
1112 fn builder_auth_generate_token() {
1113 let builder = VictauriBuilder::new().generate_auth_token();
1114 let token = builder.resolve_auth_token().unwrap();
1115 assert_eq!(token.len(), 36);
1116 }
1117
1118 #[test]
1119 fn builder_auth_disabled_suppresses_default_token() {
1120 let builder = VictauriBuilder::new().auth_disabled();
1121 assert!(
1122 builder.resolve_auth_token().is_none(),
1123 "auth_disabled must suppress the default auto-generated token (auth is ON by default)"
1124 );
1125 }
1126
1127 #[test]
1128 fn builder_auth_disabled_returns_none() {
1129 let builder = VictauriBuilder::new().auth_disabled();
1130 assert!(
1131 builder.resolve_auth_token().is_none(),
1132 "auth_disabled should suppress auto-generated token"
1133 );
1134 }
1135
1136 #[test]
1137 fn builder_auth_disabled_overrides_explicit_token() {
1138 let builder = VictauriBuilder::new()
1139 .auth_token("my-secret")
1140 .auth_disabled();
1141 assert!(
1142 builder.resolve_auth_token().is_none(),
1143 "auth_disabled should override explicit token"
1144 );
1145 }
1146
1147 #[test]
1148 fn builder_capacities() {
1149 let builder = VictauriBuilder::new()
1150 .event_capacity(500)
1151 .recorder_capacity(2000);
1152 assert_eq!(builder.event_capacity, 500);
1153 assert_eq!(builder.recorder_capacity, 2000);
1154 }
1155
1156 #[test]
1157 fn builder_disable_tools() {
1158 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
1159 assert_eq!(builder.disabled_tools.len(), 2);
1160 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
1161 }
1162
1163 #[test]
1164 fn builder_command_allowlist() {
1165 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
1166 assert!(builder.command_allowlist.is_some());
1167 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
1168 }
1169
1170 #[test]
1171 fn builder_command_blocklist() {
1172 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
1173 assert_eq!(builder.command_blocklist.len(), 1);
1174 }
1175
1176 #[test]
1177 fn builder_redaction() {
1178 let builder = VictauriBuilder::new()
1179 .add_redaction_pattern(r"SECRET_\w+")
1180 .enable_redaction();
1181 assert!(builder.redaction_enabled);
1182 assert_eq!(builder.redaction_patterns.len(), 1);
1183 }
1184
1185 #[test]
1186 fn builder_strict_privacy_config() {
1187 let builder = VictauriBuilder::new().strict_privacy_mode();
1188 let config = builder.build_privacy_config();
1189 assert!(config.redaction_enabled);
1190 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
1191 assert!(!config.is_tool_enabled("eval_js"));
1192 assert!(!config.is_tool_enabled("screenshot"));
1193 assert!(!config.is_tool_enabled("interact"));
1194 assert!(config.is_tool_enabled("dom_snapshot"));
1195 }
1196
1197 #[test]
1198 fn builder_normal_privacy_config() {
1199 let builder = VictauriBuilder::new()
1200 .command_blocklist(&["secret_cmd"])
1201 .disable_tools(&["eval_js"]);
1202 let config = builder.build_privacy_config();
1203 assert!(config.command_blocklist.contains("secret_cmd"));
1204 assert!(!config.is_tool_enabled("eval_js"));
1205 assert!(!config.redaction_enabled);
1206 }
1207
1208 #[test]
1209 fn builder_strict_with_extra_blocklist() {
1210 let builder = VictauriBuilder::new()
1211 .strict_privacy_mode()
1212 .command_blocklist(&["extra_dangerous"]);
1213 let config = builder.build_privacy_config();
1214 assert!(config.command_blocklist.contains("extra_dangerous"));
1215 assert!(!config.is_tool_enabled("eval_js"));
1216 }
1217
1218 #[test]
1219 fn builder_test_profile() {
1220 let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
1221 let config = builder.build_privacy_config();
1222 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
1223 assert!(config.redaction_enabled);
1224 assert!(config.is_tool_enabled("interact"));
1225 assert!(config.is_tool_enabled("fill"));
1226 assert!(config.is_tool_enabled("recording"));
1227 assert!(!config.is_tool_enabled("eval_js"));
1228 assert!(!config.is_tool_enabled("screenshot"));
1229 assert!(!config.is_tool_enabled("navigate"));
1230 }
1231
1232 #[test]
1233 fn builder_profile_with_extra_disables() {
1234 let builder = VictauriBuilder::new()
1235 .privacy_profile(crate::privacy::PrivacyProfile::Test)
1236 .disable_tools(&["interact"]);
1237 let config = builder.build_privacy_config();
1238 assert!(!config.is_tool_enabled("interact"));
1239 assert!(config.is_tool_enabled("fill"));
1240 }
1241
1242 #[test]
1243 fn builder_bridge_capacities() {
1244 let builder = VictauriBuilder::new()
1245 .console_log_capacity(5000)
1246 .network_log_capacity(2000)
1247 .navigation_log_capacity(500);
1248 assert_eq!(builder.bridge_capacities.console_logs, 5000);
1249 assert_eq!(builder.bridge_capacities.network_log, 2000);
1250 assert_eq!(builder.bridge_capacities.navigation_log, 500);
1251 assert_eq!(builder.bridge_capacities.mutation_log, 500);
1252 assert_eq!(builder.bridge_capacities.dialog_log, 100);
1253 }
1254
1255 #[test]
1256 fn builder_on_ready_sets_callback() {
1257 let builder = VictauriBuilder::new().on_ready(|_port| {});
1258 assert!(builder.on_ready.is_some());
1259 }
1260
1261 #[test]
1262 fn builder_file_navigation_disabled_by_default() {
1263 let builder = VictauriBuilder::new();
1264 assert!(
1265 !builder.allow_file_navigation,
1266 "file navigation should be disabled by default"
1267 );
1268 }
1269
1270 #[test]
1271 fn builder_allow_file_navigation() {
1272 let builder = VictauriBuilder::new().allow_file_navigation();
1273 assert!(builder.allow_file_navigation);
1274 }
1275
1276 #[test]
1277 fn builder_listen_events() {
1278 let builder =
1279 VictauriBuilder::new().listen_events(&["notification-added", "settings-changed"]);
1280 assert_eq!(builder.listen_events.len(), 2);
1281 assert!(
1282 builder
1283 .listen_events
1284 .contains(&"notification-added".to_string())
1285 );
1286 assert!(
1287 builder
1288 .listen_events
1289 .contains(&"settings-changed".to_string())
1290 );
1291 }
1292
1293 #[test]
1294 fn builder_listen_events_empty_by_default() {
1295 let builder = VictauriBuilder::new();
1296 assert!(builder.listen_events.is_empty());
1297 }
1298
1299 #[test]
1300 fn init_script_contains_custom_capacities() {
1301 let caps = js_bridge::BridgeCapacities {
1302 console_logs: 3000,
1303 mutation_log: 750,
1304 network_log: 5000,
1305 navigation_log: 400,
1306 dialog_log: 250,
1307 long_tasks: 200,
1308 };
1309 let script = js_bridge::init_script(&caps);
1310 assert!(script.contains("CAP_CONSOLE = 3000"));
1311 assert!(script.contains("CAP_MUTATION = 750"));
1312 assert!(script.contains("CAP_NETWORK = 5000"));
1313 assert!(script.contains("CAP_NAVIGATION = 400"));
1314 assert!(script.contains("CAP_DIALOG = 250"));
1315 assert!(script.contains("CAP_LONG_TASKS = 200"));
1316 }
1317
1318 #[test]
1319 fn init_script_default_contains_standard_capacities() {
1320 let caps = js_bridge::BridgeCapacities::default();
1321 let script = js_bridge::init_script(&caps);
1322 assert!(script.contains("CAP_CONSOLE = 1000"));
1323 assert!(script.contains("CAP_NETWORK = 1000"));
1324 assert!(script.contains("window.__VICTAURI__"));
1325 }
1326
1327 #[test]
1328 fn builder_validates_defaults() {
1329 let builder = VictauriBuilder::new();
1330 assert!(builder.validate().is_ok());
1331 }
1332
1333 #[test]
1334 fn builder_rejects_zero_port() {
1335 let builder = VictauriBuilder::new().port(0);
1336 let err = builder.validate().unwrap_err();
1337 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
1338 }
1339
1340 #[test]
1341 fn builder_rejects_zero_event_capacity() {
1342 let builder = VictauriBuilder::new().event_capacity(0);
1343 let err = builder.validate().unwrap_err();
1344 assert!(matches!(
1345 err,
1346 BuilderError::InvalidEventCapacity { capacity: 0, .. }
1347 ));
1348 }
1349
1350 #[test]
1351 fn builder_rejects_excessive_event_capacity() {
1352 let builder = VictauriBuilder::new().event_capacity(2_000_000);
1353 assert!(builder.validate().is_err());
1354 }
1355
1356 #[test]
1357 fn builder_rejects_zero_recorder_capacity() {
1358 let builder = VictauriBuilder::new().recorder_capacity(0);
1359 assert!(builder.validate().is_err());
1360 }
1361
1362 #[test]
1363 fn builder_rejects_zero_eval_timeout() {
1364 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
1365 assert!(builder.validate().is_err());
1366 }
1367
1368 #[test]
1369 fn builder_rejects_excessive_eval_timeout() {
1370 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1371 assert!(builder.validate().is_err());
1372 }
1373
1374 #[test]
1375 fn builder_accepts_edge_values() {
1376 let builder = VictauriBuilder::new()
1377 .port(1)
1378 .event_capacity(1)
1379 .recorder_capacity(1)
1380 .eval_timeout(std::time::Duration::from_secs(1));
1381 assert!(builder.validate().is_ok());
1382
1383 let builder = VictauriBuilder::new()
1384 .port(65535)
1385 .event_capacity(MAX_EVENT_CAPACITY)
1386 .recorder_capacity(MAX_RECORDER_CAPACITY)
1387 .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1388 assert!(builder.validate().is_ok());
1389 }
1390}