From 3ba7e4226b2f9e5030dc77ce26f7baa9b11e2ec8 Mon Sep 17 00:00:00 2001 From: Reekin Date: Wed, 18 Mar 2026 00:37:39 +0800 Subject: [PATCH 1/2] fix: sanitize windows editor launch paths safely --- src-tauri/src/shared/workspaces_core/io.rs | 100 ++++++++++++++++++--- src-tauri/src/storage.rs | 23 +++++ src-tauri/src/utils.rs | 96 +++++++++++++++++++- 3 files changed, 208 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/shared/workspaces_core/io.rs b/src-tauri/src/shared/workspaces_core/io.rs index 28c6778e4..703816212 100644 --- a/src-tauri/src/shared/workspaces_core/io.rs +++ b/src-tauri/src/shared/workspaces_core/io.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::env; +use std::borrow::Cow; use std::path::{Path, PathBuf}; use tokio::sync::Mutex; @@ -8,6 +9,7 @@ use crate::shared::process_core::tokio_command; #[cfg(target_os = "windows")] use crate::shared::process_core::{build_cmd_c_command, resolve_windows_executable}; use crate::types::WorkspaceEntry; +use crate::utils::normalize_windows_namespace_path; use super::helpers::resolve_workspace_root; @@ -132,8 +134,12 @@ fn build_launch_args( strategy: Option, ) -> Vec { let mut launch_args = args.to_vec(); + let path: Cow<'_, str> = match strategy { + Some(_) => Cow::Owned(normalize_windows_namespace_path(path)), + None => Cow::Borrowed(path), + }; if let Some((line, column)) = normalize_open_location(line, column) { - let located_path = format_path_with_location(path, line, column); + let located_path = format_path_with_location(&path, line, column); match strategy { Some(LineAwareLaunchStrategy::GotoFlag) => { launch_args.push("--goto".to_string()); @@ -143,12 +149,12 @@ fn build_launch_args( launch_args.push(located_path); } None => { - launch_args.push(path.to_string()); + launch_args.push(path.into_owned()); } } return launch_args; } - launch_args.push(path.to_string()); + launch_args.push(path.into_owned()); launch_args } @@ -186,13 +192,8 @@ pub(crate) async fn open_workspace_in_core( if trimmed.is_empty() { return Err("Missing app or command".to_string()); } - let launch_args = build_launch_args( - &path, - &args, - line, - column, - command_launch_strategy(trimmed), - ); + let launch_args = + build_launch_args(&path, &args, line, column, command_launch_strategy(trimmed)); #[cfg(target_os = "windows")] let mut cmd = { @@ -380,6 +381,85 @@ mod tests { ); } + #[test] + fn builds_goto_args_with_windows_namespace_path_sanitized() { + let args = build_launch_args( + r"\\?\I:\gpt-projects\json-composer\src\App.tsx", + &["--reuse-window".to_string()], + Some(33), + Some(7), + Some(LineAwareLaunchStrategy::GotoFlag), + ); + + assert_eq!( + args, + vec![ + "--reuse-window".to_string(), + "--goto".to_string(), + r"I:\gpt-projects\json-composer\src\App.tsx:33:7".to_string(), + ] + ); + } + + #[test] + fn builds_goto_args_with_lowercase_unc_namespace_path_sanitized() { + let args = build_launch_args( + r"\\?\unc\server\share\repo\src\App.tsx", + &["--reuse-window".to_string()], + Some(12), + Some(2), + Some(LineAwareLaunchStrategy::GotoFlag), + ); + + assert_eq!( + args, + vec![ + "--reuse-window".to_string(), + "--goto".to_string(), + r"\\server\share\repo\src\App.tsx:12:2".to_string(), + ] + ); + } + + #[test] + fn preserves_namespace_path_for_unknown_targets() { + let args = build_launch_args( + r"\\?\I:\very\long\workspace", + &["--foreground".to_string()], + None, + None, + None, + ); + + assert_eq!( + args, + vec![ + "--foreground".to_string(), + r"\\?\I:\very\long\workspace".to_string(), + ] + ); + } + + #[test] + fn preserves_non_drive_namespace_path_for_line_aware_targets() { + let args = build_launch_args( + r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo\src\App.tsx", + &[], + Some(5), + None, + Some(LineAwareLaunchStrategy::GotoFlag), + ); + + assert_eq!( + args, + vec![ + "--goto".to_string(), + r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo\src\App.tsx:5" + .to_string(), + ] + ); + } + #[test] fn builds_line_suffixed_path_for_zed_targets() { let args = build_launch_args( diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 354015df4..c29b9058c 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -129,6 +129,29 @@ mod tests { assert_eq!(stored.settings.git_root.as_deref(), Some("/tmp")); } + #[test] + fn write_read_workspaces_preserves_windows_namespace_paths() { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("workspaces.json"); + + let entry = WorkspaceEntry { + id: "w1".to_string(), + name: "Workspace".to_string(), + path: r"\\?\I:\gpt-projects\json-composer".to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + + write_workspaces(&path, &[entry]).expect("write workspaces"); + + let read = read_workspaces(&path).expect("read workspaces"); + let stored = read.get("w1").expect("stored workspace"); + assert_eq!(stored.path, r"\\?\I:\gpt-projects\json-composer"); + } + #[test] fn read_settings_sanitizes_non_tcp_remote_provider() { let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 6c9ff7fa7..d25760480 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -6,6 +6,48 @@ pub(crate) fn normalize_git_path(path: &str) -> String { path.replace('\\', "/") } +pub(crate) fn normalize_windows_namespace_path(path: &str) -> String { + if path.is_empty() { + return String::new(); + } + + fn strip_prefix_ascii_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + value + .get(..prefix.len()) + .filter(|candidate| candidate.eq_ignore_ascii_case(prefix)) + .map(|_| &value[prefix.len()..]) + } + + fn starts_with_drive_path(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'\\' || bytes[2] == b'/') + } + + if let Some(rest) = strip_prefix_ascii_case(path, r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + if let Some(rest) = strip_prefix_ascii_case(path, "//?/UNC/") { + return format!("//{rest}"); + } + if let Some(rest) = strip_prefix_ascii_case(path, r"\\?\").filter(|rest| starts_with_drive_path(rest)) { + return rest.to_string(); + } + if let Some(rest) = strip_prefix_ascii_case(path, "//?/").filter(|rest| starts_with_drive_path(rest)) { + return rest.to_string(); + } + if let Some(rest) = strip_prefix_ascii_case(path, r"\\.\").filter(|rest| starts_with_drive_path(rest)) { + return rest.to_string(); + } + if let Some(rest) = strip_prefix_ascii_case(path, "//./").filter(|rest| starts_with_drive_path(rest)) { + return rest.to_string(); + } + + path.to_string() +} + fn find_in_path(binary: &str) -> Option { let path_var = env::var_os("PATH")?; for dir in env::split_paths(&path_var) { @@ -86,10 +128,62 @@ pub(crate) fn git_env_path() -> String { #[cfg(test)] mod tests { - use super::normalize_git_path; + use super::{normalize_git_path, normalize_windows_namespace_path}; #[test] fn normalize_git_path_replaces_backslashes() { assert_eq!(normalize_git_path("foo\\bar\\baz"), "foo/bar/baz"); } + + #[test] + fn normalize_windows_namespace_path_strips_drive_prefix() { + assert_eq!( + normalize_windows_namespace_path(r"\\?\I:\gpt-projects\json-composer"), + r"I:\gpt-projects\json-composer" + ); + assert_eq!( + normalize_windows_namespace_path("//?/I:/gpt-projects/json-composer"), + "I:/gpt-projects/json-composer" + ); + } + + #[test] + fn normalize_windows_namespace_path_strips_unc_prefix() { + assert_eq!( + normalize_windows_namespace_path(r"\\?\UNC\SERVER\Share\Repo"), + r"\\SERVER\Share\Repo" + ); + assert_eq!( + normalize_windows_namespace_path("//?/UNC/SERVER/Share/Repo"), + "//SERVER/Share/Repo" + ); + assert_eq!( + normalize_windows_namespace_path(r"\\?\unc\SERVER\Share\Repo"), + r"\\SERVER\Share\Repo" + ); + assert_eq!( + normalize_windows_namespace_path("//?/unc/SERVER/Share/Repo"), + "//SERVER/Share/Repo" + ); + } + + #[test] + fn normalize_windows_namespace_path_preserves_whitespace_for_plain_paths() { + assert_eq!( + normalize_windows_namespace_path(" /tmp/workspace "), + " /tmp/workspace " + ); + } + + #[test] + fn normalize_windows_namespace_path_preserves_other_namespace_forms() { + assert_eq!( + normalize_windows_namespace_path(r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo"), + r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo" + ); + assert_eq!( + normalize_windows_namespace_path(r"\\.\pipe\codex-monitor"), + r"\\.\pipe\codex-monitor" + ); + } } From beddbd492744e5d062b7d69b1e64c89f8ad408a4 Mon Sep 17 00:00:00 2001 From: Reekin Date: Wed, 18 Mar 2026 03:07:59 +0800 Subject: [PATCH 2/2] fix: preserve namespace paths for normal editor launch --- src-tauri/src/shared/workspaces_core/io.rs | 33 ++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/shared/workspaces_core/io.rs b/src-tauri/src/shared/workspaces_core/io.rs index 703816212..68873e1fa 100644 --- a/src-tauri/src/shared/workspaces_core/io.rs +++ b/src-tauri/src/shared/workspaces_core/io.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::env; -use std::borrow::Cow; use std::path::{Path, PathBuf}; use tokio::sync::Mutex; @@ -134,27 +133,26 @@ fn build_launch_args( strategy: Option, ) -> Vec { let mut launch_args = args.to_vec(); - let path: Cow<'_, str> = match strategy { - Some(_) => Cow::Owned(normalize_windows_namespace_path(path)), - None => Cow::Borrowed(path), - }; if let Some((line, column)) = normalize_open_location(line, column) { - let located_path = format_path_with_location(&path, line, column); match strategy { Some(LineAwareLaunchStrategy::GotoFlag) => { + let sanitized_path = normalize_windows_namespace_path(path); + let located_path = format_path_with_location(&sanitized_path, line, column); launch_args.push("--goto".to_string()); launch_args.push(located_path); } Some(LineAwareLaunchStrategy::PathWithLineColumn) => { + let sanitized_path = normalize_windows_namespace_path(path); + let located_path = format_path_with_location(&sanitized_path, line, column); launch_args.push(located_path); } None => { - launch_args.push(path.into_owned()); + launch_args.push(path.to_string()); } } return launch_args; } - launch_args.push(path.into_owned()); + launch_args.push(path.to_string()); launch_args } @@ -440,6 +438,25 @@ mod tests { ); } + #[test] + fn preserves_namespace_path_for_line_aware_targets_without_location() { + let args = build_launch_args( + r"\\?\I:\very\long\workspace", + &["--reuse-window".to_string()], + None, + None, + Some(LineAwareLaunchStrategy::GotoFlag), + ); + + assert_eq!( + args, + vec![ + "--reuse-window".to_string(), + r"\\?\I:\very\long\workspace".to_string(), + ] + ); + } + #[test] fn preserves_non_drive_namespace_path_for_line_aware_targets() { let args = build_launch_args(