1use std::sync::Arc;
2use std::sync::atomic::Ordering;
3
4use axum::extract::DefaultBodyLimit;
5use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
6use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
7use tauri::Runtime;
8use tower::limit::GlobalConcurrencyLimitLayer;
9
10use crate::VictauriState;
11use crate::bridge::WebviewBridge;
12
13use super::{MAX_PENDING_EVALS, VictauriMcpHandler};
14
15const DEFAULT_WEBVIEW_LABEL: &str = "main";
16
17pub fn build_app(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> axum::Router {
21 build_app_with_options(state, bridge, None)
22}
23
24#[must_use]
32fn normalize_auth_token(auth_token: Option<String>) -> Option<String> {
33 match auth_token {
34 Some(t) if t.trim().is_empty() => {
35 tracing::warn!(
36 "Victauri: configured auth token is empty/whitespace — treating as NO auth. \
37 Set a non-empty VICTAURI_AUTH_TOKEN / auth_token(), or use auth_disabled() \
38 to intentionally run without authentication."
39 );
40 None
41 }
42 other => other,
43 }
44}
45
46async fn backfill_stateless_session_id(
58 req: axum::extract::Request,
59 next: axum::middleware::Next,
60) -> axum::response::Response {
61 let mut resp = next.run(req).await;
62 resp.headers_mut()
63 .entry(axum::http::HeaderName::from_static("mcp-session-id"))
64 .or_insert(axum::http::HeaderValue::from_static("stateless"));
65 resp
66}
67
68pub fn build_app_with_options(
70 state: Arc<VictauriState>,
71 bridge: Arc<dyn WebviewBridge>,
72 auth_token: Option<String>,
73) -> axum::Router {
74 build_app_full(state, bridge, auth_token, None)
75}
76
77pub fn build_app_full(
83 state: Arc<VictauriState>,
84 bridge: Arc<dyn WebviewBridge>,
85 auth_token: Option<String>,
86 rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
87) -> axum::Router {
88 build_app_full_inner(state, bridge, auth_token, rate_limiter, false)
89}
90
91#[doc(hidden)]
102pub fn build_app_stateful(
103 state: Arc<VictauriState>,
104 bridge: Arc<dyn WebviewBridge>,
105 auth_token: Option<String>,
106) -> axum::Router {
107 build_app_full_inner(state, bridge, auth_token, None, true)
108}
109
110fn build_app_full_inner(
111 state: Arc<VictauriState>,
112 bridge: Arc<dyn WebviewBridge>,
113 auth_token: Option<String>,
114 rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
115 stateful: bool,
116) -> axum::Router {
117 let auth_token = normalize_auth_token(auth_token);
120
121 let tauri_cfg = bridge.tauri_config();
124 let app_identifier = tauri_cfg
125 .get("identifier")
126 .and_then(|v| v.as_str())
127 .map(String::from);
128 let app_product_name = tauri_cfg
129 .get("product_name")
130 .and_then(|v| v.as_str())
131 .map(String::from);
132
133 let handler = VictauriMcpHandler::new(state.clone(), bridge);
134 let rest = super::rest::router(handler.clone());
135
136 let mcp_config = if stateful {
163 StreamableHttpServerConfig::default()
164 } else {
165 StreamableHttpServerConfig::default()
166 .with_legacy_session_mode(false)
167 .with_json_response(true)
168 }
169 .with_max_request_body_bytes(super::hardening::MAX_REQUEST_BODY_BYTES);
172 let mcp_service = StreamableHttpService::new(
173 move || Ok(handler.clone()),
174 Arc::new(LocalSessionManager::default()),
175 mcp_config,
176 );
177
178 let auth_state = Arc::new(crate::auth::AuthState {
179 token: auth_token.clone(),
180 });
181 let info_state = state.clone();
182 let info_auth = auth_token.is_some();
183
184 let privacy_enabled = !state.privacy.disabled_tools.is_empty()
185 || state.privacy.command_allowlist.is_some()
186 || !state.privacy.command_blocklist.is_empty()
187 || state.privacy.redaction_enabled;
188
189 let mut mcp_router = axum::Router::new().route_service("/mcp", mcp_service);
193 if !stateful {
194 mcp_router = mcp_router.layer(axum::middleware::from_fn(backfill_stateless_session_id));
195 }
196
197 let mut router = mcp_router
198 .nest("/api/tools", rest)
199 .route(
200 "/info",
201 axum::routing::get(move || {
202 let s = info_state.clone();
203 let app_id = app_identifier.clone();
204 let app_name = app_product_name.clone();
205 async move {
206 axum::Json(serde_json::json!({
207 "name": "victauri",
208 "description": "Full-stack Tauri app inspection: webview + IPC + Rust backend + SQLite",
209 "version": env!("CARGO_PKG_VERSION"),
210 "protocol": "mcp",
211 "app_identifier": app_id,
213 "app_product_name": app_name,
214 "capabilities": ["webview", "ipc", "backend", "database", "filesystem"],
215 "commands_registered": s.registry.count(),
216 "events_captured": s.event_log.len(),
217 "port": s.port.load(Ordering::Relaxed),
218 "auth_required": info_auth,
219 "privacy_mode": privacy_enabled,
220 }))
221 }
222 }),
223 );
224
225 router = router.layer(axum::middleware::from_fn(
229 super::hardening::read_body_with_deadline,
230 ));
231 if auth_token.is_some() {
232 router = router.layer(axum::middleware::from_fn_with_state(
233 auth_state,
234 crate::auth::require_auth,
235 ));
236 }
237
238 router = router
244 .layer(DefaultBodyLimit::max(
245 super::hardening::MAX_REQUEST_BODY_BYTES,
246 ))
247 .layer(GlobalConcurrencyLimitLayer::new(64));
248
249 router = router.route(
254 "/health",
255 axum::routing::get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }),
256 );
257
258 let limiter = rate_limiter.unwrap_or_else(crate::auth::default_rate_limiter);
262 let authenticated = Arc::new(crate::auth::RateLimiterState::new(limiter.max_tokens()));
263 router = router.layer(axum::middleware::from_fn_with_state(
264 Arc::new(super::hardening::SplitRateLimit {
265 token: auth_token,
266 public: limiter,
267 authenticated,
268 }),
269 super::hardening::split_rate_limit,
270 ));
271
272 router
273 .layer(axum::middleware::from_fn(require_json_from_browsers))
274 .layer(axum::middleware::from_fn(crate::auth::security_headers))
275 .layer(axum::middleware::from_fn(crate::auth::origin_guard))
276 .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
277 .layer(axum::middleware::from_fn(
279 super::hardening::close_on_rejection,
280 ))
281}
282
283async fn require_json_from_browsers(
291 req: axum::extract::Request,
292 next: axum::middleware::Next,
293) -> axum::response::Response {
294 use axum::http::{Method, StatusCode, header};
295 use axum::response::IntoResponse;
296 let has_body_method = !matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS);
297 if has_body_method && req.headers().contains_key(header::ORIGIN) {
298 let is_json = req
299 .headers()
300 .get(header::CONTENT_TYPE)
301 .and_then(|v| v.to_str().ok())
302 .and_then(|ct| ct.split(';').next())
303 .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
304 if !is_json {
305 return (
306 StatusCode::UNSUPPORTED_MEDIA_TYPE,
307 "browser-originated requests must send Content-Type: application/json",
308 )
309 .into_response();
310 }
311 }
312 next.run(req).await
313}
314
315#[doc(hidden)]
316#[allow(dead_code)]
317pub mod tests_support {
318 #[must_use]
320 pub fn get_memory_stats() -> serde_json::Value {
321 crate::memory::current_stats()
322 }
323}
324
325const PORT_FALLBACK_RANGE: u16 = 10;
326
327pub async fn start_server<R: Runtime>(
334 app_handle: tauri::AppHandle<R>,
335 state: Arc<VictauriState>,
336 port: u16,
337 shutdown_rx: tokio::sync::watch::Receiver<bool>,
338) -> anyhow::Result<()> {
339 start_server_with_options(app_handle, state, port, None, shutdown_rx).await
340}
341
342pub async fn start_server_with_options<R: Runtime>(
349 app_handle: tauri::AppHandle<R>,
350 state: Arc<VictauriState>,
351 port: u16,
352 auth_token: Option<String>,
353 shutdown_rx: tokio::sync::watch::Receiver<bool>,
354) -> anyhow::Result<()> {
355 start_server_reporting_port(app_handle, state, port, auth_token, shutdown_rx, None).await
356}
357
358#[doc(hidden)]
365pub async fn start_server_reporting_port<R: Runtime>(
366 app_handle: tauri::AppHandle<R>,
367 state: Arc<VictauriState>,
368 port: u16,
369 auth_token: Option<String>,
370 mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
371 bound: Option<tokio::sync::oneshot::Sender<Result<u16, String>>>,
372) -> anyhow::Result<()> {
373 let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
374 std::mem::forget(Arc::clone(&bridge));
379 let auth_token = normalize_auth_token(auth_token);
382 let token_for_file = auth_token.clone();
383 let app = build_app_with_options(state.clone(), bridge.clone(), auth_token);
384
385 let (listener, actual_port) = match try_bind(port).await {
386 Ok(bound_listener) => bound_listener,
387 Err(e) => {
388 if let Some(bound) = bound {
389 let _ = bound.send(Err(e.to_string()));
390 }
391 return Err(e);
392 }
393 };
394
395 if actual_port != port {
396 tracing::warn!("Victauri: port {port} in use, fell back to {actual_port}");
397 }
398
399 state.port.store(actual_port, Ordering::Relaxed);
400 let cfg = bridge.tauri_config();
401 let app_identifier = cfg.get("identifier").and_then(|v| v.as_str());
402 let app_product_name = cfg.get("product_name").and_then(|v| v.as_str());
403 let discovery_token = token_for_file
412 .as_deref()
413 .map_or_else(crate::auth::generate_token, String::from);
414 write_token_file(&discovery_token);
415 write_port_file(actual_port, app_identifier, app_product_name);
416 if let Some(bound) = bound {
417 let _ = bound.send(Ok(actual_port));
418 }
419
420 tracing::info!("Victauri MCP server listening on 127.0.0.1:{actual_port}");
421
422 let drain_state = state.clone();
423 let drain_bridge = bridge;
424 let drain_shutdown = state.shutdown_tx.subscribe();
425 let drain_finished = state.task_tracker.track_guarded("event_drain_loop");
426 tokio::spawn(async move {
427 let _finished = drain_finished;
428 event_drain_loop(drain_state, drain_bridge, drain_shutdown).await;
429 });
430
431 let mut shutdown_rx2 = shutdown_rx.clone();
432 let server = super::hardening::serve_hardened(
433 listener,
434 app,
435 super::hardening::ServeLimits::DEFAULT,
436 async move {
437 let _ = shutdown_rx.wait_for(|&v| v).await;
438 remove_port_file();
439 tracing::info!("Victauri MCP server shutting down gracefully");
440 },
441 );
442
443 tokio::select! {
444 () = server => {}
445 _ = async {
446 let _ = shutdown_rx2.wait_for(|&v| v).await;
447 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
448 } => {
449 tracing::warn!("Victauri MCP server shutdown timeout — forcing exit");
450 }
451 }
452 Ok(())
453}
454
455async fn try_bind(preferred: u16) -> anyhow::Result<(tokio::net::TcpListener, u16)> {
456 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{preferred}")).await {
457 return Ok((listener, preferred));
458 }
459
460 for offset in 1..=PORT_FALLBACK_RANGE {
461 let Some(port) = preferred.checked_add(offset) else {
464 break;
465 };
466 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await {
467 return Ok((listener, port));
468 }
469 }
470
471 anyhow::bail!(
472 "could not bind to any port in range {preferred}-{}",
473 preferred.saturating_add(PORT_FALLBACK_RANGE)
474 )
475}
476
477fn discovery_dir() -> std::path::PathBuf {
478 discovery_root().join(std::process::id().to_string())
479}
480
481fn discovery_root() -> std::path::PathBuf {
497 static ROOT: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
498 ROOT.get_or_init(|| {
499 #[cfg(unix)]
500 {
501 let euid = current_euid();
502 select_discovery_root(
503 &discovery_root_from(std::env::var_os("XDG_RUNTIME_DIR"), euid),
504 euid.and_then(|_| {
505 home_state_root(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME"))
506 }),
507 )
508 }
509 #[cfg(not(unix))]
510 {
511 std::env::temp_dir().join("victauri")
512 }
513 })
514 .clone()
515}
516
517#[cfg(unix)]
521fn select_discovery_root(
522 primary: &std::path::Path,
523 fallback: Option<std::path::PathBuf>,
524) -> std::path::PathBuf {
525 if ensure_unix_private_dir(primary) {
526 return primary.to_path_buf();
527 }
528 if let Some(fallback) = fallback
529 && ensure_home_state_root(&fallback)
530 {
531 tracing::warn!(
532 "discovery root {} is not usable (another user may have created it); \
533 registering under {} instead",
534 primary.display(),
535 fallback.display()
536 );
537 return fallback;
538 }
539 primary.to_path_buf()
540}
541
542#[cfg(unix)]
545fn home_state_root(
546 xdg_state_home: Option<std::ffi::OsString>,
547 home: Option<std::ffi::OsString>,
548) -> Option<std::path::PathBuf> {
549 let absolute =
550 |p: std::ffi::OsString| Some(std::path::PathBuf::from(p)).filter(|p| p.is_absolute());
551 xdg_state_home
552 .and_then(absolute)
553 .or_else(|| {
554 home.and_then(absolute)
555 .map(|h| h.join(".local").join("state"))
556 })
557 .map(|state| state.join("victauri"))
558}
559
560#[cfg(unix)]
564fn ensure_home_state_root(root: &std::path::Path) -> bool {
565 use std::os::unix::fs::{DirBuilderExt, MetadataExt};
566
567 let (Some(parent), Some(euid)) = (root.parent(), current_euid()) else {
568 return false;
569 };
570 if std::fs::DirBuilder::new()
572 .recursive(true)
573 .mode(0o700)
574 .create(parent)
575 .is_err()
576 {
577 return false;
578 }
579 let parent_ok = std::fs::metadata(parent)
580 .is_ok_and(|m| m.is_dir() && m.uid() == euid && m.mode() & 0o002 == 0);
581 parent_ok && ensure_unix_private_dir(root)
582}
583
584#[cfg(unix)]
585fn discovery_root_from(
586 xdg_runtime_dir: Option<std::ffi::OsString>,
587 euid: Option<u32>,
588) -> std::path::PathBuf {
589 use std::os::unix::fs::{MetadataExt, PermissionsExt};
590 let Some(euid) = euid else {
591 return std::env::temp_dir().join("victauri");
593 };
594 let private_runtime_dir = xdg_runtime_dir
595 .map(std::path::PathBuf::from)
596 .filter(|dir| dir.is_absolute())
597 .filter(|dir| {
598 std::fs::symlink_metadata(dir).is_ok_and(|m| {
599 m.file_type().is_dir() && m.uid() == euid && m.permissions().mode() & 0o077 == 0
600 })
601 });
602 private_runtime_dir.map_or_else(
603 || std::env::temp_dir().join(format!("victauri-{euid}")),
604 |dir| dir.join("victauri"),
605 )
606}
607
608#[cfg(unix)]
609fn current_euid() -> Option<u32> {
610 current_euid_in(&std::env::temp_dir())
611}
612
613#[cfg(unix)]
614fn current_euid_in(dir: &std::path::Path) -> Option<u32> {
615 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
616
617 for _ in 0..16 {
621 let probe = dir.join(format!(
622 ".victauri_plugin_uidprobe_{}",
623 uuid::Uuid::new_v4().simple()
624 ));
625 let file = std::fs::OpenOptions::new()
626 .write(true)
627 .create_new(true)
628 .mode(0o600)
629 .open(&probe)
630 .ok();
631 if let Some(file) = file {
632 let uid = file.metadata().ok().map(|m| m.uid());
633 drop(file);
634 let _ = std::fs::remove_file(probe);
635 if uid.is_some() {
636 return uid;
637 }
638 }
639 }
640 None
641}
642
643#[cfg(unix)]
644fn ensure_unix_private_dir(path: &std::path::Path) -> bool {
645 use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
646
647 let Some(euid) = current_euid() else {
648 return false;
649 };
650 match std::fs::symlink_metadata(path) {
651 Ok(meta) => {
652 if !meta.file_type().is_dir() || meta.uid() != euid {
653 return false;
654 }
655 if std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).is_err() {
656 return false;
657 }
658 }
659 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
660 let mut builder = std::fs::DirBuilder::new();
661 builder.mode(0o700);
662 if builder.create(path).is_err() {
663 return false;
664 }
665 }
666 Err(_) => return false,
667 }
668 unix_private_dir_is_trusted(path)
669}
670
671#[cfg(unix)]
672fn unix_private_dir_is_trusted(path: &std::path::Path) -> bool {
673 use std::os::unix::fs::{MetadataExt, PermissionsExt};
674
675 let Some(euid) = current_euid() else {
676 return false;
677 };
678 std::fs::symlink_metadata(path).is_ok_and(|meta| {
679 meta.file_type().is_dir() && meta.uid() == euid && (meta.permissions().mode() & 0o077) == 0
680 })
681}
682
683#[cfg(windows)]
685#[allow(unsafe_code)]
686fn current_windows_username() -> Option<String> {
687 use windows::Win32::System::WindowsProgramming::GetUserNameW;
688 use windows::core::PWSTR;
689
690 let mut buffer = [0_u16; 257];
691 let mut len = buffer.len() as u32;
692 unsafe {
695 GetUserNameW(Some(PWSTR(buffer.as_mut_ptr())), &raw mut len).ok()?;
696 }
697 let end = buffer
698 .iter()
699 .position(|unit| *unit == 0)
700 .unwrap_or(len as usize);
701 String::from_utf16(&buffer[..end])
702 .ok()
703 .filter(|name| !name.is_empty())
704}
705
706#[cfg(windows)]
708fn to_wide(path: &std::path::Path) -> Vec<u16> {
709 use std::os::windows::ffi::OsStrExt;
710 path.as_os_str().encode_wide().chain(Some(0)).collect()
711}
712
713#[cfg(windows)]
719struct OwnedSid(Vec<u8>);
720
721#[cfg(windows)]
722impl OwnedSid {
723 fn as_psid(&self) -> windows::Win32::Security::PSID {
724 windows::Win32::Security::PSID(self.0.as_ptr() as *mut core::ffi::c_void)
725 }
726}
727
728#[cfg(windows)]
735#[allow(unsafe_code)]
736fn token_sid(class: windows::Win32::Security::TOKEN_INFORMATION_CLASS) -> Option<OwnedSid> {
737 use windows::Win32::Foundation::{CloseHandle, HANDLE};
738 use windows::Win32::Security::{GetLengthSid, GetTokenInformation, PSID, TOKEN_QUERY};
739 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
740
741 struct TokenGuard(HANDLE);
742 impl Drop for TokenGuard {
743 fn drop(&mut self) {
744 unsafe {
746 let _ = CloseHandle(self.0);
747 }
748 }
749 }
750
751 let mut token = HANDLE::default();
752 unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? };
755 let _guard = TokenGuard(token);
756
757 let mut len = 0_u32;
758 unsafe {
761 let _ = GetTokenInformation(token, class, None, 0, &raw mut len);
762 }
763 if len == 0 {
764 return None;
765 }
766 let mut buf = vec![0_u8; len as usize];
767 unsafe {
769 GetTokenInformation(
770 token,
771 class,
772 Some(buf.as_mut_ptr().cast::<core::ffi::c_void>()),
773 len,
774 &raw mut len,
775 )
776 .ok()?;
777 }
778 let sid_ptr = unsafe { *buf.as_ptr().cast::<PSID>() };
781 let sid_len = unsafe { GetLengthSid(sid_ptr) };
783 if sid_len == 0 {
784 return None;
785 }
786 let mut sid = vec![0_u8; sid_len as usize];
787 unsafe {
789 core::ptr::copy_nonoverlapping(sid_ptr.0.cast::<u8>(), sid.as_mut_ptr(), sid_len as usize);
790 }
791 Some(OwnedSid(sid))
792}
793
794#[cfg(windows)]
809fn acceptable_owner_sids() -> Vec<OwnedSid> {
810 use windows::Win32::Security::{TokenOwner, TokenUser};
811 let mut sids: Vec<OwnedSid> = [TokenUser, TokenOwner]
812 .into_iter()
813 .filter_map(token_sid)
814 .collect();
815 sids.extend(builtin_administrators_sid_if_member());
816 sids
817}
818
819#[cfg(windows)]
822#[allow(unsafe_code)]
823fn builtin_administrators_sid_if_member() -> Option<OwnedSid> {
824 use windows::Win32::Foundation::{CloseHandle, HANDLE};
825 use windows::Win32::Security::{
826 CreateWellKnownSid, EqualSid, GetTokenInformation, PSID, SECURITY_MAX_SID_SIZE,
827 TOKEN_GROUPS, TOKEN_QUERY, TokenGroups, WinBuiltinAdministratorsSid,
828 };
829 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
830
831 struct TokenGuard(HANDLE);
832 impl Drop for TokenGuard {
833 fn drop(&mut self) {
834 unsafe {
836 let _ = CloseHandle(self.0);
837 }
838 }
839 }
840
841 let mut admins = vec![0_u8; SECURITY_MAX_SID_SIZE as usize];
842 let mut admins_len = SECURITY_MAX_SID_SIZE;
843 unsafe {
845 CreateWellKnownSid(
846 WinBuiltinAdministratorsSid,
847 None,
848 Some(PSID(admins.as_mut_ptr().cast::<core::ffi::c_void>())),
849 &raw mut admins_len,
850 )
851 .ok()?;
852 }
853 admins.truncate(admins_len as usize);
854 let admins = OwnedSid(admins);
855
856 let mut token = HANDLE::default();
857 unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? };
860 let _guard = TokenGuard(token);
861 let mut len = 0_u32;
862 unsafe {
864 let _ = GetTokenInformation(token, TokenGroups, None, 0, &raw mut len);
865 }
866 if (len as usize) < std::mem::size_of::<TOKEN_GROUPS>() {
867 return None;
868 }
869 let mut buf = vec![0_u64; (len as usize).div_ceil(8)];
871 unsafe {
873 GetTokenInformation(
874 token,
875 TokenGroups,
876 Some(buf.as_mut_ptr().cast::<core::ffi::c_void>()),
877 len,
878 &raw mut len,
879 )
880 .ok()?;
881 }
882 let member = unsafe {
885 let groups = &*buf.as_ptr().cast::<TOKEN_GROUPS>();
886 std::slice::from_raw_parts(groups.Groups.as_ptr(), groups.GroupCount as usize)
887 .iter()
888 .any(|g| EqualSid(g.Sid, admins.as_psid()).is_ok())
889 };
890 member.then_some(admins)
891}
892
893#[cfg(windows)]
900#[allow(unsafe_code)]
901fn dir_owned_by_current_user(path: &std::path::Path) -> bool {
902 use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
903 use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
904 use windows::Win32::Security::{
905 EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
906 };
907 use windows::core::PCWSTR;
908
909 let acceptable = acceptable_owner_sids();
910 if acceptable.is_empty() {
911 return false;
912 }
913 let wide = to_wide(path);
914 let mut owner = PSID::default();
915 let mut psd = PSECURITY_DESCRIPTOR::default();
916 let rc = unsafe {
919 GetNamedSecurityInfoW(
920 PCWSTR(wide.as_ptr()),
921 SE_FILE_OBJECT,
922 OWNER_SECURITY_INFORMATION,
923 Some(&raw mut owner),
924 None,
925 None,
926 None,
927 &raw mut psd,
928 )
929 };
930 if rc != ERROR_SUCCESS {
931 return false;
932 }
933 let owned = acceptable
935 .iter()
936 .any(|sid| unsafe { EqualSid(owner, sid.as_psid()).is_ok() });
937 unsafe {
939 let _ = LocalFree(Some(HLOCAL(psd.0)));
940 }
941 owned
942}
943
944#[cfg(windows)]
953#[allow(unsafe_code)]
954fn apply_owner_only_dacl(path: &std::path::Path) -> bool {
955 use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
956 use windows::Win32::Security::Authorization::{
957 EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW,
958 SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
959 };
960 use windows::Win32::Security::{
961 ACE_FLAGS, ACL, DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION,
962 };
963 use windows::core::PWSTR;
964
965 use windows::Win32::Security::TokenUser;
966
967 const GENERIC_ALL_RIGHTS: u32 = 0x1000_0000;
969 const SUB_CONTAINERS_AND_OBJECTS_INHERIT: u32 = 0x3;
970
971 let Some(me) = token_sid(TokenUser) else {
974 return false;
975 };
976
977 let explicit = EXPLICIT_ACCESS_W {
978 grfAccessPermissions: GENERIC_ALL_RIGHTS,
979 grfAccessMode: SET_ACCESS,
980 grfInheritance: ACE_FLAGS(SUB_CONTAINERS_AND_OBJECTS_INHERIT),
981 Trustee: TRUSTEE_W {
982 pMultipleTrustee: core::ptr::null_mut(),
983 MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE,
984 TrusteeForm: TRUSTEE_IS_SID,
985 TrusteeType: TRUSTEE_IS_USER,
986 ptstrName: PWSTR(me.as_psid().0.cast::<u16>()),
987 },
988 };
989
990 let mut new_acl: *mut ACL = core::ptr::null_mut();
991 let rc = unsafe { SetEntriesInAclW(Some(&[explicit]), None, &raw mut new_acl) };
994 if rc != ERROR_SUCCESS || new_acl.is_null() {
995 return false;
996 }
997
998 let mut wide = to_wide(path);
999 let set_rc = unsafe {
1002 SetNamedSecurityInfoW(
1003 PWSTR(wide.as_mut_ptr()),
1004 SE_FILE_OBJECT,
1005 DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
1006 None,
1007 None,
1008 Some(new_acl),
1009 None,
1010 )
1011 };
1012 unsafe {
1014 let _ = LocalFree(Some(HLOCAL(new_acl.cast::<core::ffi::c_void>())));
1015 }
1016 set_rc == ERROR_SUCCESS
1017}
1018
1019#[cfg(windows)]
1024fn icacls_restrict_to_current_user(path: &std::path::Path) -> bool {
1025 let Some(username) = current_windows_username() else {
1026 return false;
1027 };
1028 let path_str = path.to_string_lossy();
1029 std::process::Command::new("icacls")
1030 .args([
1031 &*path_str,
1032 "/inheritance:r",
1033 "/remove",
1034 "*S-1-1-0",
1035 "*S-1-5-32-545",
1036 "*S-1-5-11",
1037 "/grant:r",
1038 &format!("{username}:F"),
1039 "/q",
1040 ])
1041 .stdin(std::process::Stdio::null())
1042 .stdout(std::process::Stdio::null())
1043 .stderr(std::process::Stdio::null())
1044 .status()
1045 .is_ok_and(|status| status.success())
1046}
1047
1048#[cfg(windows)]
1052fn restrict_to_current_user(path: &std::path::Path) -> bool {
1053 if apply_owner_only_dacl(path) {
1054 return true;
1055 }
1056 tracing::warn!(
1057 "owner-only DACL apply failed for {}; falling back to icacls",
1058 path.display()
1059 );
1060 icacls_restrict_to_current_user(path)
1061}
1062
1063#[cfg(windows)]
1064fn ensure_windows_private_dir(path: &std::path::Path, remove_on_acl_failure: bool) -> bool {
1065 let mut created = false;
1066 match std::fs::symlink_metadata(path) {
1067 Ok(meta) => {
1068 if !meta.file_type().is_dir() {
1069 return false;
1070 }
1071 }
1072 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1073 if std::fs::create_dir(path).is_err() {
1074 return false;
1075 }
1076 created = true;
1077 }
1078 Err(_) => return false,
1079 }
1080
1081 if !dir_owned_by_current_user(path) {
1082 tracing::warn!(
1083 "refusing discovery directory not owned by current user: {}",
1084 path.display()
1085 );
1086 if created || remove_on_acl_failure {
1087 let _ = std::fs::remove_dir_all(path);
1088 }
1089 return false;
1090 }
1091
1092 if !restrict_to_current_user(path) {
1093 if created || remove_on_acl_failure {
1094 let _ = std::fs::remove_dir_all(path);
1095 }
1096 return false;
1097 }
1098
1099 true
1100}
1101
1102fn ensure_private_dir(dir: &std::path::Path) -> bool {
1105 #[cfg(unix)]
1106 {
1107 let Some(root) = dir.parent() else {
1108 return false;
1109 };
1110 if !ensure_unix_private_dir(root) || !ensure_unix_private_dir(dir) {
1111 tracing::warn!("refusing untrusted discovery path {}", dir.display());
1112 return false;
1113 }
1114 }
1115 #[cfg(windows)]
1116 {
1117 let Some(root) = dir.parent() else {
1118 return false;
1119 };
1120 if !ensure_windows_private_dir(root, false) {
1123 tracing::warn!("refusing untrusted discovery root {}", root.display());
1124 return false;
1125 }
1126 if !ensure_windows_private_dir(dir, true) {
1131 tracing::warn!("refusing untrusted discovery path {}", dir.display());
1132 return false;
1133 }
1134 }
1135 #[cfg(all(not(unix), not(windows)))]
1136 if std::fs::create_dir_all(dir).is_err() {
1137 return false;
1138 }
1139 true
1140}
1141
1142fn write_private_file(path: &std::path::Path, contents: &str) {
1147 if std::fs::symlink_metadata(path).is_ok() {
1151 let _ = std::fs::remove_file(path);
1152 }
1153 #[cfg(unix)]
1154 let result = {
1155 use std::io::Write;
1156 use std::os::unix::fs::OpenOptionsExt;
1157 std::fs::OpenOptions::new()
1158 .write(true)
1159 .create_new(true)
1160 .mode(0o600)
1161 .open(path)
1162 .and_then(|mut f| f.write_all(contents.as_bytes()))
1163 };
1164 #[cfg(not(unix))]
1165 let result = {
1166 use std::io::Write;
1167 std::fs::OpenOptions::new()
1168 .write(true)
1169 .create_new(true)
1170 .open(path)
1171 .and_then(|mut f| f.write_all(contents.as_bytes()))
1172 };
1173 #[cfg(windows)]
1178 match result {
1179 Ok(()) => {
1180 if !restrict_to_current_user(path) {
1181 let _ = std::fs::remove_file(path);
1182 tracing::warn!("could not restrict discovery file {}", path.display());
1183 }
1184 }
1185 Err(e) => {
1186 tracing::debug!("could not write discovery file {}: {e}", path.display());
1187 }
1188 }
1189 #[cfg(not(windows))]
1190 if let Err(e) = result {
1191 tracing::debug!("could not write discovery file {}: {e}", path.display());
1192 }
1193}
1194
1195fn write_port_file(port: u16, identifier: Option<&str>, product_name: Option<&str>) {
1196 let dir = discovery_dir();
1197 if !ensure_private_dir(&dir) {
1198 return;
1199 }
1200 write_private_file(&dir.join("port"), &port.to_string());
1201 let metadata = serde_json::json!({
1206 "pid": std::process::id(),
1207 "port": port,
1208 "identifier": identifier,
1209 "product_name": product_name,
1210 "started_at": chrono::Utc::now().to_rfc3339(),
1211 "version": env!("CARGO_PKG_VERSION"),
1212 });
1213 write_private_file(&dir.join("metadata.json"), &metadata.to_string());
1214}
1215
1216fn write_token_file(token: &str) {
1217 let dir = discovery_dir();
1218 if !ensure_private_dir(&dir) {
1219 return;
1220 }
1221 write_private_file(&dir.join("token"), token);
1222}
1223
1224fn remove_port_file() {
1225 let dir = discovery_dir();
1226 #[cfg(unix)]
1227 {
1228 let Some(root) = dir.parent() else {
1229 return;
1230 };
1231 if !unix_private_dir_is_trusted(root) || !unix_private_dir_is_trusted(&dir) {
1232 return;
1233 }
1234 }
1235 let _ = std::fs::remove_dir_all(dir);
1236}
1237
1238#[must_use]
1242pub fn parse_bridge_event(ev: &serde_json::Value) -> Option<victauri_core::AppEvent> {
1243 parse_bridge_event_from(ev, DEFAULT_WEBVIEW_LABEL)
1244}
1245
1246pub(super) fn parse_bridge_event_from(
1250 ev: &serde_json::Value,
1251 label: &str,
1252) -> Option<victauri_core::AppEvent> {
1253 use chrono::{TimeZone, Utc};
1254 use victauri_core::AppEvent;
1255
1256 let event_type = ev.get("type").and_then(|t| t.as_str()).unwrap_or("");
1257 #[allow(clippy::cast_possible_truncation)]
1258 let now = ev
1259 .get("timestamp")
1260 .and_then(serde_json::Value::as_f64)
1261 .filter(|ms| ms.is_finite() && *ms > 0.0)
1262 .and_then(|ms| Utc.timestamp_millis_opt(ms as i64).single())
1263 .unwrap_or_else(Utc::now);
1264
1265 let app_event = match event_type {
1266 "console" => AppEvent::console(
1267 ev.get("level")
1268 .and_then(|l| l.as_str())
1269 .unwrap_or("log")
1270 .to_string(),
1271 ev.get("message")
1272 .and_then(|m| m.as_str())
1273 .unwrap_or("")
1274 .to_string(),
1275 now,
1276 ),
1277 "dom_mutation" => AppEvent::dom_mutation(
1278 label.to_string(),
1279 now,
1280 ev.get("count")
1281 .and_then(serde_json::Value::as_u64)
1282 .unwrap_or(0) as u32,
1283 ),
1284 "ipc" => {
1285 let cmd = ev
1286 .get("command")
1287 .and_then(|c| c.as_str())
1288 .unwrap_or("unknown");
1289 AppEvent::Ipc(
1290 victauri_core::IpcCall::new(
1291 uuid::Uuid::new_v4().to_string(),
1292 cmd.to_string(),
1293 now,
1294 match ev.get("status").and_then(|s| s.as_str()) {
1295 Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null),
1296 Some("error") => victauri_core::IpcResult::Err("error".to_string()),
1297 _ => victauri_core::IpcResult::Pending,
1298 },
1299 ev.get("duration_ms")
1300 .and_then(serde_json::Value::as_f64)
1301 .map(|d| d as u64),
1302 ev.get("arg_size_bytes")
1303 .and_then(serde_json::Value::as_u64)
1304 .map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX)),
1305 label.to_string(),
1306 )
1307 .with_mocked(ev.get("mocked").and_then(serde_json::Value::as_bool) == Some(true))
1308 .with_transport(
1311 (ev.get("transport").and_then(|t| t.as_str()) == Some("post_message"))
1312 .then(|| "post_message".to_string()),
1313 ),
1314 )
1315 }
1316 "network" => AppEvent::state_change(
1317 format!(
1318 "network.{}",
1319 ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET")
1320 ),
1321 now,
1322 ev.get("url")
1323 .and_then(|u| u.as_str())
1324 .map(std::string::ToString::to_string),
1325 ),
1326 "navigation" => AppEvent::window_event(
1327 label.to_string(),
1328 format!(
1329 "navigation.{}",
1330 ev.get("nav_type")
1331 .and_then(|n| n.as_str())
1332 .unwrap_or("unknown")
1333 ),
1334 now,
1335 ),
1336 "dom_interaction" => {
1337 let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click");
1338 let action = match action_str {
1339 "click" => victauri_core::InteractionKind::Click,
1340 "double_click" => victauri_core::InteractionKind::DoubleClick,
1341 "fill" => victauri_core::InteractionKind::Fill,
1342 "key_press" => victauri_core::InteractionKind::KeyPress,
1343 "select" => victauri_core::InteractionKind::Select,
1344 "navigate" => victauri_core::InteractionKind::Navigate,
1345 "scroll" => victauri_core::InteractionKind::Scroll,
1346 _ => victauri_core::InteractionKind::Click,
1347 };
1348 AppEvent::dom_interaction(
1349 action,
1350 ev.get("selector")
1351 .and_then(|s| s.as_str())
1352 .unwrap_or("body")
1353 .to_string(),
1354 ev.get("value")
1355 .and_then(|v| v.as_str())
1356 .map(std::string::ToString::to_string),
1357 now,
1358 label.to_string(),
1359 )
1360 }
1361 _ => return None,
1362 };
1363
1364 Some(app_event)
1365}
1366
1367async fn event_drain_loop(
1368 state: Arc<VictauriState>,
1369 bridge: Arc<dyn WebviewBridge>,
1370 mut shutdown: tokio::sync::watch::Receiver<bool>,
1371) {
1372 loop {
1375 tokio::select! {
1376 _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1377 _ = shutdown.changed() => break,
1378 }
1379
1380 if !state.recorder.is_recording() {
1391 continue;
1392 }
1393
1394 let labels = bridge.list_window_labels();
1395 if labels.is_empty() {
1396 continue;
1397 }
1398 state.drain_watermarks.retain(&labels);
1401
1402 let mut set = tokio::task::JoinSet::new();
1407 for label in &labels {
1408 let state = Arc::clone(&state);
1409 let bridge = Arc::clone(&bridge);
1410 let label = label.clone();
1411 set.spawn(async move { drain_window_into_recording(&state, &bridge, &label).await });
1412 }
1413 while set.join_next().await.is_some() {}
1414 }
1415}
1416
1417pub(super) async fn drain_window_into_recording(
1423 state: &Arc<VictauriState>,
1424 bridge: &Arc<dyn WebviewBridge>,
1425 label: &str,
1426) -> Option<usize> {
1427 let lock = state.drain_watermarks.lock_for(label);
1428 let _serialized = lock.lock().await;
1429 let Some(generation) = state.recorder.generation() else {
1432 return Some(0);
1433 };
1434 let cursor = state.drain_watermarks.cursor(label);
1435 if cursor.epoch != generation {
1436 return Some(0);
1440 }
1441 let (mark, recorded) = drain_window(state, bridge, label, &cursor, generation).await?;
1442 state.drain_watermarks.advance(label, cursor.epoch, mark);
1443 Some(recorded)
1444}
1445
1446async fn drain_window(
1451 state: &Arc<VictauriState>,
1452 bridge: &Arc<dyn WebviewBridge>,
1453 label: &str,
1454 cursor: &crate::introspection::DrainCursor,
1455 generation: u64,
1456) -> Option<(crate::introspection::DrainMark, usize)> {
1457 let (after_seq, instance_js, floor_ms) = match &cursor.mark {
1460 Some(m) => (m.seq, super::helpers::js_string(&m.instance), 0.0),
1461 None => (0, "null".to_string(), cursor.floor_ms),
1462 };
1463 let id = uuid::Uuid::new_v4().to_string();
1464 let (tx, rx) = tokio::sync::oneshot::channel();
1465
1466 let _slot = {
1468 let mut pending = state.pending_evals.lock().await;
1469 if pending.len() >= MAX_PENDING_EVALS {
1470 return None;
1471 }
1472 let slot = crate::PendingSlot::insert(&state.pending_evals, &mut pending, id.clone(), tx);
1473 slot.bind_window(Some(label));
1474 slot
1475 };
1476
1477 let id_js = super::helpers::js_string(&id);
1481 let inject = format!(
1482 r"
1483 (function() {{
1484 var v = window.__VICTAURI__;
1485 if (!v) return;
1486 var r;
1487 try {{ r = v.drainEvents({after_seq}, {instance_js}, {floor_ms}); }}
1488 catch (e) {{ r = {{ __error: String(e && e.message) }}; }}
1489 v._evalSettle({id_js}, r);
1490 }})();
1491 "
1492 );
1493
1494 if bridge.eval_webview(Some(label), &inject).is_err() {
1495 return None;
1496 }
1497
1498 let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await else {
1499 return None;
1500 };
1501
1502 let reply: serde_json::Value = super::page_json::parse_page_json(&result).ok()?;
1505 let mark = crate::introspection::DrainMark {
1506 instance: reply.get("instance")?.as_str()?.to_string(),
1507 seq: reply.get("seq")?.as_u64()?,
1508 };
1509 let mut recorded = 0usize;
1510 for ev in reply.get("events")?.as_array()? {
1513 if let Some(app_event) = parse_bridge_event_from(ev, label)
1514 && state
1515 .recorder
1516 .record_event_if(generation, app_event.clone())
1517 {
1518 state.event_log.push(app_event);
1519 recorded += 1;
1520 }
1521 }
1522 Some((mark, recorded))
1523}
1524
1525#[cfg(test)]
1526mod tests {
1527 use super::*;
1528 use victauri_core::{AppEvent, InteractionKind, IpcResult};
1529
1530 #[cfg(unix)]
1534 #[test]
1535 fn uid_probe_survives_pre_planted_probe_names() {
1536 use std::os::unix::fs::MetadataExt;
1537 let dir = tempfile::tempdir().unwrap();
1538 for seq in 0..4096 {
1539 std::fs::create_dir(dir.path().join(format!(
1540 ".victauri_plugin_uidprobe_{}_{seq}",
1541 std::process::id()
1542 )))
1543 .unwrap();
1544 }
1545 let expected = std::fs::metadata(dir.path()).unwrap().uid();
1546 assert_eq!(current_euid_in(dir.path()), Some(expected));
1547 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 4096);
1549 }
1550
1551 #[cfg(windows)]
1555 #[test]
1556 fn owner_only_dacl_removes_pre_planted_guests_ace() {
1557 use std::process::Command;
1558 let dir = std::env::temp_dir()
1559 .join("victauri_acl_test")
1560 .join(format!("p{}", std::process::id()));
1561 let _ = std::fs::remove_dir_all(&dir);
1562 std::fs::create_dir_all(&dir).expect("create test dir");
1563
1564 assert!(
1567 dir_owned_by_current_user(&dir),
1568 "a freshly created dir must be recognized as owned by this process"
1569 );
1570
1571 let path_str = dir.to_string_lossy().to_string();
1572
1573 let Ok(grant) = Command::new("icacls")
1575 .args([path_str.as_str(), "/grant", "*S-1-5-32-546:(OI)(CI)F", "/q"])
1576 .output()
1577 else {
1578 let _ = std::fs::remove_dir_all(&dir);
1579 return; };
1581 if !grant.status.success() {
1582 let _ = std::fs::remove_dir_all(&dir);
1583 return; }
1585
1586 let before = Command::new("icacls")
1587 .arg(path_str.as_str())
1588 .output()
1589 .expect("icacls read");
1590 let before_s = String::from_utf8_lossy(&before.stdout);
1591 assert!(
1592 before_s.contains("Guests"),
1593 "pre-condition: the planted Guests ACE should be visible, got:\n{before_s}"
1594 );
1595
1596 assert!(
1598 apply_owner_only_dacl(&dir),
1599 "apply_owner_only_dacl must succeed on a directory we own"
1600 );
1601
1602 let after = Command::new("icacls")
1603 .arg(path_str.as_str())
1604 .output()
1605 .expect("icacls read");
1606 let after_s = String::from_utf8_lossy(&after.stdout);
1607 assert!(
1608 !after_s.contains("Guests"),
1609 "the pre-planted Guests ACE must NOT survive the owner-only DACL, got:\n{after_s}"
1610 );
1611
1612 let _ = std::fs::remove_dir_all(&dir);
1613 }
1614
1615 #[test]
1616 fn normalize_auth_token_collapses_empty() {
1617 assert_eq!(normalize_auth_token(Some(String::new())), None);
1620 assert_eq!(normalize_auth_token(Some(" ".to_string())), None);
1621 assert_eq!(normalize_auth_token(Some("\t\n".to_string())), None);
1622 assert_eq!(
1624 normalize_auth_token(Some("secret-123".to_string())).as_deref(),
1625 Some("secret-123")
1626 );
1627 assert_eq!(normalize_auth_token(None), None);
1628 }
1629
1630 #[tokio::test]
1631 async fn try_bind_preferred_port_available() {
1632 let (listener, port) = try_bind(0).await.unwrap();
1633 let addr = listener.local_addr().unwrap();
1634 assert_eq!(port, 0);
1635 assert_ne!(addr.port(), 0); }
1637
1638 #[tokio::test]
1639 async fn try_bind_falls_back_when_taken() {
1640 let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1641 let blocked_port = blocker.local_addr().unwrap().port();
1642
1643 let (_, actual) = try_bind(blocked_port).await.unwrap();
1644 assert_ne!(actual, blocked_port);
1645 assert!(actual > blocked_port);
1646 assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
1647 }
1648
1649 #[test]
1650 fn port_file_roundtrip() {
1651 write_port_file(7777, Some("com.example.app"), Some("Example"));
1652 let dir = discovery_dir();
1653 let content = std::fs::read_to_string(dir.join("port")).unwrap();
1654 assert_eq!(content, "7777");
1655 let meta: serde_json::Value =
1657 serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
1658 .unwrap();
1659 assert_eq!(meta["port"], 7777);
1660 assert_eq!(meta["pid"], std::process::id());
1661 assert_eq!(meta["identifier"], "com.example.app");
1663 assert_eq!(meta["product_name"], "Example");
1664 remove_port_file();
1665 assert!(!dir.exists());
1666 }
1667
1668 #[cfg(windows)]
1669 #[test]
1670 fn private_dir_restricts_shared_root_and_pid_dir() {
1671 let base = std::env::temp_dir()
1672 .join("victauri_private_root_test")
1673 .join(format!("p{}", std::process::id()));
1674 let dir = base.join("victauri").join("12345");
1675 let _ = std::fs::remove_dir_all(&base);
1676 std::fs::create_dir_all(&base).expect("create parent test dir");
1677
1678 assert!(
1679 ensure_private_dir(&dir),
1680 "a fresh discovery root and pid dir owned by this user should be accepted"
1681 );
1682 assert!(
1683 dir_owned_by_current_user(&base.join("victauri")),
1684 "shared discovery root must be owned by this process user"
1685 );
1686 assert!(
1687 dir_owned_by_current_user(&dir),
1688 "pid discovery dir must be owned by this process user"
1689 );
1690
1691 let _ = std::fs::remove_dir_all(&base);
1692 }
1693
1694 #[cfg(unix)]
1697 #[test]
1698 fn discovery_root_is_per_user() {
1699 use std::os::unix::fs::PermissionsExt;
1700 let euid = current_euid().expect("euid");
1701 let shared = std::env::temp_dir().join("victauri");
1702 let fallback = std::env::temp_dir().join(format!("victauri-{euid}"));
1703 assert_ne!(discovery_root(), shared);
1704 assert_eq!(discovery_root_from(None, Some(euid)), fallback);
1705
1706 let runtime = tempfile::tempdir().unwrap();
1708 std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1709 assert_eq!(
1710 discovery_root_from(Some(runtime.path().into()), Some(euid)),
1711 runtime.path().join("victauri")
1712 );
1713 std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
1715 assert_eq!(
1716 discovery_root_from(Some(runtime.path().into()), Some(euid)),
1717 fallback
1718 );
1719 std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1720 assert_eq!(
1721 discovery_root_from(Some(runtime.path().into()), Some(euid.wrapping_add(1))),
1722 std::env::temp_dir().join(format!("victauri-{}", euid.wrapping_add(1)))
1723 );
1724 assert_eq!(
1725 discovery_root_from(Some("relative/run".into()), Some(euid)),
1726 fallback
1727 );
1728 }
1729
1730 #[cfg(unix)]
1736 #[test]
1737 fn an_untrusted_primary_root_falls_back_to_the_home_state_root() {
1738 use std::os::unix::fs::PermissionsExt;
1739 let base = tempfile::tempdir().unwrap();
1740 let home = base.path().join("home");
1741 std::fs::create_dir(&home).unwrap();
1742 std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap();
1743 let fallback = home_state_root(None, Some(home.clone().into()));
1744 let expected = home.join(".local").join("state").join("victauri");
1745 assert_eq!(fallback.as_deref(), Some(expected.as_path()));
1746
1747 let elsewhere = base.path().join("elsewhere");
1749 std::fs::create_dir(&elsewhere).unwrap();
1750 let hijacked = base.path().join("victauri-hijacked");
1751 std::os::unix::fs::symlink(&elsewhere, &hijacked).unwrap();
1752 assert_eq!(select_discovery_root(&hijacked, fallback.clone()), expected);
1753 assert!(unix_private_dir_is_trusted(&expected));
1754 let mode = std::fs::metadata(&expected).unwrap().permissions().mode() & 0o777;
1755 assert_eq!(mode, 0o700);
1756 let blocked = base.path().join("victauri-blocked");
1758 std::fs::write(&blocked, "x").unwrap();
1759 assert_eq!(select_discovery_root(&blocked, fallback.clone()), expected);
1760
1761 let fine = base.path().join("victauri-fine");
1763 assert_eq!(select_discovery_root(&fine, fallback), fine);
1764
1765 assert_eq!(select_discovery_root(&hijacked, None), hijacked);
1767 }
1768
1769 #[cfg(unix)]
1770 #[test]
1771 fn home_state_root_prefers_an_absolute_xdg_state_home_and_guards_its_parent() {
1772 use std::os::unix::fs::PermissionsExt;
1773 assert_eq!(
1774 home_state_root(Some("/x/state".into()), Some("/home/u".into())),
1775 Some(std::path::PathBuf::from("/x/state/victauri"))
1776 );
1777 assert_eq!(
1778 home_state_root(Some("rel/state".into()), Some("/home/u".into())),
1779 Some(std::path::PathBuf::from("/home/u/.local/state/victauri"))
1780 );
1781 assert_eq!(home_state_root(None, Some("rel".into())), None);
1782 assert_eq!(home_state_root(None, None), None);
1783
1784 let base = tempfile::tempdir().unwrap();
1786 let open = base.path().join("open");
1787 std::fs::create_dir(&open).unwrap();
1788 std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
1789 assert!(!ensure_home_state_root(&open.join("victauri")));
1790 }
1791
1792 #[cfg(unix)]
1793 #[test]
1794 fn private_dir_refuses_symlink_without_chmodding_target() {
1795 use std::os::unix::fs::PermissionsExt;
1796
1797 let base = tempfile::tempdir().unwrap();
1798 let target = base.path().join("target");
1799 let link = base.path().join("link");
1800 std::fs::create_dir(&target).unwrap();
1801 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1802 std::os::unix::fs::symlink(&target, &link).unwrap();
1803
1804 assert!(!ensure_unix_private_dir(&link));
1805 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1806 assert_eq!(mode, 0o755, "symlink target permissions must be untouched");
1807 }
1808
1809 #[test]
1812 fn parse_dom_interaction_click() {
1813 let ev = serde_json::json!({
1814 "type": "dom_interaction",
1815 "action": "click",
1816 "selector": "#submit-btn",
1817 });
1818 let result = parse_bridge_event(&ev).expect("should produce an event");
1819 match result {
1820 AppEvent::DomInteraction {
1821 action,
1822 selector,
1823 value,
1824 webview_label,
1825 ..
1826 } => {
1827 assert_eq!(action, InteractionKind::Click);
1828 assert_eq!(selector, "#submit-btn");
1829 assert!(value.is_none());
1830 assert_eq!(webview_label, "main");
1831 }
1832 other => panic!("expected DomInteraction, got {other:?}"),
1833 }
1834 }
1835
1836 #[test]
1837 fn parse_dom_interaction_fill_with_value() {
1838 let ev = serde_json::json!({
1839 "type": "dom_interaction",
1840 "action": "fill",
1841 "selector": "input[name=email]",
1842 "value": "test@example.com",
1843 });
1844 let result = parse_bridge_event(&ev).expect("should produce an event");
1845 match result {
1846 AppEvent::DomInteraction {
1847 action,
1848 selector,
1849 value,
1850 ..
1851 } => {
1852 assert_eq!(action, InteractionKind::Fill);
1853 assert_eq!(selector, "input[name=email]");
1854 assert_eq!(value.as_deref(), Some("test@example.com"));
1855 }
1856 other => panic!("expected DomInteraction, got {other:?}"),
1857 }
1858 }
1859
1860 #[test]
1861 fn parse_dom_interaction_key_press() {
1862 let ev = serde_json::json!({
1863 "type": "dom_interaction",
1864 "action": "key_press",
1865 "selector": "body",
1866 "value": "Enter",
1867 });
1868 let result = parse_bridge_event(&ev).expect("should produce an event");
1869 match result {
1870 AppEvent::DomInteraction { action, value, .. } => {
1871 assert_eq!(action, InteractionKind::KeyPress);
1872 assert_eq!(value.as_deref(), Some("Enter"));
1873 }
1874 other => panic!("expected DomInteraction, got {other:?}"),
1875 }
1876 }
1877
1878 #[test]
1879 fn parse_dom_interaction_unknown_action_defaults_to_click() {
1880 let ev = serde_json::json!({
1881 "type": "dom_interaction",
1882 "action": "swipe_left",
1883 "selector": ".card",
1884 });
1885 let result = parse_bridge_event(&ev).expect("should produce an event");
1886 match result {
1887 AppEvent::DomInteraction { action, .. } => {
1888 assert_eq!(action, InteractionKind::Click);
1889 }
1890 other => panic!("expected DomInteraction, got {other:?}"),
1891 }
1892 }
1893
1894 #[test]
1895 fn parse_dom_interaction_missing_action_defaults_to_click() {
1896 let ev = serde_json::json!({
1897 "type": "dom_interaction",
1898 "selector": "button",
1899 });
1900 let result = parse_bridge_event(&ev).expect("should produce an event");
1901 match result {
1902 AppEvent::DomInteraction { action, .. } => {
1903 assert_eq!(action, InteractionKind::Click);
1904 }
1905 other => panic!("expected DomInteraction, got {other:?}"),
1906 }
1907 }
1908
1909 #[test]
1910 fn parse_dom_interaction_missing_selector_defaults_to_body() {
1911 let ev = serde_json::json!({
1912 "type": "dom_interaction",
1913 "action": "scroll",
1914 });
1915 let result = parse_bridge_event(&ev).expect("should produce an event");
1916 match result {
1917 AppEvent::DomInteraction {
1918 action, selector, ..
1919 } => {
1920 assert_eq!(action, InteractionKind::Scroll);
1921 assert_eq!(selector, "body");
1922 }
1923 other => panic!("expected DomInteraction, got {other:?}"),
1924 }
1925 }
1926
1927 #[test]
1928 fn parse_dom_interaction_all_action_kinds() {
1929 let cases = [
1930 ("click", InteractionKind::Click),
1931 ("double_click", InteractionKind::DoubleClick),
1932 ("fill", InteractionKind::Fill),
1933 ("key_press", InteractionKind::KeyPress),
1934 ("select", InteractionKind::Select),
1935 ("navigate", InteractionKind::Navigate),
1936 ("scroll", InteractionKind::Scroll),
1937 ];
1938 for (action_str, expected_kind) in cases {
1939 let ev = serde_json::json!({
1940 "type": "dom_interaction",
1941 "action": action_str,
1942 "selector": "body",
1943 });
1944 let result = parse_bridge_event(&ev)
1945 .unwrap_or_else(|| panic!("should produce event for action {action_str}"));
1946 match result {
1947 AppEvent::DomInteraction { action, .. } => {
1948 assert_eq!(action, expected_kind, "mismatch for action {action_str}");
1949 }
1950 other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
1951 }
1952 }
1953 }
1954
1955 #[test]
1958 fn parse_ipc_status_ok() {
1959 let ev = serde_json::json!({
1960 "type": "ipc",
1961 "command": "greet",
1962 "status": "ok",
1963 "duration_ms": 42.0,
1964 });
1965 let result = parse_bridge_event(&ev).expect("should produce an event");
1966 match result {
1967 AppEvent::Ipc(call) => {
1968 assert_eq!(call.command, "greet");
1969 assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
1970 assert_eq!(call.duration_ms, Some(42));
1971 assert_eq!(call.webview_label, "main");
1972 }
1973 other => panic!("expected Ipc, got {other:?}"),
1974 }
1975 }
1976
1977 #[test]
1978 fn parse_ipc_status_error() {
1979 let ev = serde_json::json!({
1980 "type": "ipc",
1981 "command": "save_file",
1982 "status": "error",
1983 });
1984 let result = parse_bridge_event(&ev).expect("should produce an event");
1985 match result {
1986 AppEvent::Ipc(call) => {
1987 assert_eq!(call.command, "save_file");
1988 assert_eq!(call.result, IpcResult::Err("error".to_string()));
1989 }
1990 other => panic!("expected Ipc, got {other:?}"),
1991 }
1992 }
1993
1994 #[test]
1995 fn parse_ipc_status_pending() {
1996 let ev = serde_json::json!({
1997 "type": "ipc",
1998 "command": "long_task",
1999 });
2000 let result = parse_bridge_event(&ev).expect("should produce an event");
2001 match result {
2002 AppEvent::Ipc(call) => {
2003 assert_eq!(call.result, IpcResult::Pending);
2004 assert!(call.duration_ms.is_none());
2005 }
2006 other => panic!("expected Ipc, got {other:?}"),
2007 }
2008 }
2009
2010 #[test]
2013 fn parse_console_event() {
2014 let ev = serde_json::json!({
2015 "type": "console",
2016 "level": "warn",
2017 "message": "deprecated API usage",
2018 });
2019 let result = parse_bridge_event(&ev).expect("should produce an event");
2020 match result {
2021 AppEvent::Console { level, message, .. } => {
2022 assert_eq!(level, "warn");
2023 assert_eq!(message, "deprecated API usage");
2024 }
2025 other => panic!("expected Console, got {other:?}"),
2026 }
2027 }
2028
2029 #[test]
2030 fn parse_console_default_level() {
2031 let ev = serde_json::json!({
2032 "type": "console",
2033 "message": "hello",
2034 });
2035 let result = parse_bridge_event(&ev).expect("should produce an event");
2036 match result {
2037 AppEvent::Console { level, message, .. } => {
2038 assert_eq!(level, "log");
2039 assert_eq!(message, "hello");
2040 }
2041 other => panic!("expected Console, got {other:?}"),
2042 }
2043 }
2044
2045 #[test]
2048 fn parse_navigation_event() {
2049 let ev = serde_json::json!({
2050 "type": "navigation",
2051 "nav_type": "push",
2052 });
2053 let result = parse_bridge_event(&ev).expect("should produce an event");
2054 match result {
2055 AppEvent::WindowEvent { label, event, .. } => {
2056 assert_eq!(label, "main");
2057 assert_eq!(event, "navigation.push");
2058 }
2059 other => panic!("expected WindowEvent, got {other:?}"),
2060 }
2061 }
2062
2063 #[test]
2064 fn parse_navigation_default_nav_type() {
2065 let ev = serde_json::json!({ "type": "navigation" });
2066 let result = parse_bridge_event(&ev).expect("should produce an event");
2067 match result {
2068 AppEvent::WindowEvent { event, .. } => {
2069 assert_eq!(event, "navigation.unknown");
2070 }
2071 other => panic!("expected WindowEvent, got {other:?}"),
2072 }
2073 }
2074
2075 #[test]
2078 fn parse_dom_mutation_event() {
2079 let ev = serde_json::json!({
2080 "type": "dom_mutation",
2081 "count": 15,
2082 });
2083 let result = parse_bridge_event(&ev).expect("should produce an event");
2084 match result {
2085 AppEvent::DomMutation {
2086 webview_label,
2087 mutation_count,
2088 ..
2089 } => {
2090 assert_eq!(webview_label, "main");
2091 assert_eq!(mutation_count, 15);
2092 }
2093 other => panic!("expected DomMutation, got {other:?}"),
2094 }
2095 }
2096
2097 #[test]
2100 fn parse_network_event() {
2101 let ev = serde_json::json!({
2102 "type": "network",
2103 "method": "POST",
2104 "url": "https://api.example.com/data",
2105 });
2106 let result = parse_bridge_event(&ev).expect("should produce an event");
2107 match result {
2108 AppEvent::StateChange { key, caused_by, .. } => {
2109 assert_eq!(key, "network.POST");
2110 assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
2111 }
2112 other => panic!("expected StateChange, got {other:?}"),
2113 }
2114 }
2115
2116 #[test]
2119 fn parse_unknown_type_returns_none() {
2120 let ev = serde_json::json!({
2121 "type": "custom_telemetry",
2122 "payload": 42,
2123 });
2124 assert!(parse_bridge_event(&ev).is_none());
2125 }
2126
2127 #[test]
2128 fn parse_missing_type_field_returns_none() {
2129 let ev = serde_json::json!({ "data": "no type here" });
2130 assert!(parse_bridge_event(&ev).is_none());
2131 }
2132
2133 #[test]
2134 fn parse_empty_object_returns_none() {
2135 let ev = serde_json::json!({});
2136 assert!(parse_bridge_event(&ev).is_none());
2137 }
2138}