Skip to content

Commit 3ba7e42

Browse files
committed
fix: sanitize windows editor launch paths safely
1 parent aecee91 commit 3ba7e42

3 files changed

Lines changed: 208 additions & 11 deletions

File tree

src-tauri/src/shared/workspaces_core/io.rs

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::collections::HashMap;
22
use std::env;
3+
use std::borrow::Cow;
34
use std::path::{Path, PathBuf};
45

56
use tokio::sync::Mutex;
@@ -8,6 +9,7 @@ use crate::shared::process_core::tokio_command;
89
#[cfg(target_os = "windows")]
910
use crate::shared::process_core::{build_cmd_c_command, resolve_windows_executable};
1011
use crate::types::WorkspaceEntry;
12+
use crate::utils::normalize_windows_namespace_path;
1113

1214
use super::helpers::resolve_workspace_root;
1315

@@ -132,8 +134,12 @@ fn build_launch_args(
132134
strategy: Option<LineAwareLaunchStrategy>,
133135
) -> Vec<String> {
134136
let mut launch_args = args.to_vec();
137+
let path: Cow<'_, str> = match strategy {
138+
Some(_) => Cow::Owned(normalize_windows_namespace_path(path)),
139+
None => Cow::Borrowed(path),
140+
};
135141
if let Some((line, column)) = normalize_open_location(line, column) {
136-
let located_path = format_path_with_location(path, line, column);
142+
let located_path = format_path_with_location(&path, line, column);
137143
match strategy {
138144
Some(LineAwareLaunchStrategy::GotoFlag) => {
139145
launch_args.push("--goto".to_string());
@@ -143,12 +149,12 @@ fn build_launch_args(
143149
launch_args.push(located_path);
144150
}
145151
None => {
146-
launch_args.push(path.to_string());
152+
launch_args.push(path.into_owned());
147153
}
148154
}
149155
return launch_args;
150156
}
151-
launch_args.push(path.to_string());
157+
launch_args.push(path.into_owned());
152158
launch_args
153159
}
154160

@@ -186,13 +192,8 @@ pub(crate) async fn open_workspace_in_core(
186192
if trimmed.is_empty() {
187193
return Err("Missing app or command".to_string());
188194
}
189-
let launch_args = build_launch_args(
190-
&path,
191-
&args,
192-
line,
193-
column,
194-
command_launch_strategy(trimmed),
195-
);
195+
let launch_args =
196+
build_launch_args(&path, &args, line, column, command_launch_strategy(trimmed));
196197

197198
#[cfg(target_os = "windows")]
198199
let mut cmd = {
@@ -380,6 +381,85 @@ mod tests {
380381
);
381382
}
382383

384+
#[test]
385+
fn builds_goto_args_with_windows_namespace_path_sanitized() {
386+
let args = build_launch_args(
387+
r"\\?\I:\gpt-projects\json-composer\src\App.tsx",
388+
&["--reuse-window".to_string()],
389+
Some(33),
390+
Some(7),
391+
Some(LineAwareLaunchStrategy::GotoFlag),
392+
);
393+
394+
assert_eq!(
395+
args,
396+
vec![
397+
"--reuse-window".to_string(),
398+
"--goto".to_string(),
399+
r"I:\gpt-projects\json-composer\src\App.tsx:33:7".to_string(),
400+
]
401+
);
402+
}
403+
404+
#[test]
405+
fn builds_goto_args_with_lowercase_unc_namespace_path_sanitized() {
406+
let args = build_launch_args(
407+
r"\\?\unc\server\share\repo\src\App.tsx",
408+
&["--reuse-window".to_string()],
409+
Some(12),
410+
Some(2),
411+
Some(LineAwareLaunchStrategy::GotoFlag),
412+
);
413+
414+
assert_eq!(
415+
args,
416+
vec![
417+
"--reuse-window".to_string(),
418+
"--goto".to_string(),
419+
r"\\server\share\repo\src\App.tsx:12:2".to_string(),
420+
]
421+
);
422+
}
423+
424+
#[test]
425+
fn preserves_namespace_path_for_unknown_targets() {
426+
let args = build_launch_args(
427+
r"\\?\I:\very\long\workspace",
428+
&["--foreground".to_string()],
429+
None,
430+
None,
431+
None,
432+
);
433+
434+
assert_eq!(
435+
args,
436+
vec![
437+
"--foreground".to_string(),
438+
r"\\?\I:\very\long\workspace".to_string(),
439+
]
440+
);
441+
}
442+
443+
#[test]
444+
fn preserves_non_drive_namespace_path_for_line_aware_targets() {
445+
let args = build_launch_args(
446+
r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo\src\App.tsx",
447+
&[],
448+
Some(5),
449+
None,
450+
Some(LineAwareLaunchStrategy::GotoFlag),
451+
);
452+
453+
assert_eq!(
454+
args,
455+
vec![
456+
"--goto".to_string(),
457+
r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo\src\App.tsx:5"
458+
.to_string(),
459+
]
460+
);
461+
}
462+
383463
#[test]
384464
fn builds_line_suffixed_path_for_zed_targets() {
385465
let args = build_launch_args(

src-tauri/src/storage.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,29 @@ mod tests {
129129
assert_eq!(stored.settings.git_root.as_deref(), Some("/tmp"));
130130
}
131131

132+
#[test]
133+
fn write_read_workspaces_preserves_windows_namespace_paths() {
134+
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
135+
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
136+
let path = temp_dir.join("workspaces.json");
137+
138+
let entry = WorkspaceEntry {
139+
id: "w1".to_string(),
140+
name: "Workspace".to_string(),
141+
path: r"\\?\I:\gpt-projects\json-composer".to_string(),
142+
kind: WorkspaceKind::Main,
143+
parent_id: None,
144+
worktree: None,
145+
settings: WorkspaceSettings::default(),
146+
};
147+
148+
write_workspaces(&path, &[entry]).expect("write workspaces");
149+
150+
let read = read_workspaces(&path).expect("read workspaces");
151+
let stored = read.get("w1").expect("stored workspace");
152+
assert_eq!(stored.path, r"\\?\I:\gpt-projects\json-composer");
153+
}
154+
132155
#[test]
133156
fn read_settings_sanitizes_non_tcp_remote_provider() {
134157
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));

src-tauri/src/utils.rs

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,48 @@ pub(crate) fn normalize_git_path(path: &str) -> String {
66
path.replace('\\', "/")
77
}
88

9+
pub(crate) fn normalize_windows_namespace_path(path: &str) -> String {
10+
if path.is_empty() {
11+
return String::new();
12+
}
13+
14+
fn strip_prefix_ascii_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
15+
value
16+
.get(..prefix.len())
17+
.filter(|candidate| candidate.eq_ignore_ascii_case(prefix))
18+
.map(|_| &value[prefix.len()..])
19+
}
20+
21+
fn starts_with_drive_path(value: &str) -> bool {
22+
let bytes = value.as_bytes();
23+
bytes.len() >= 3
24+
&& bytes[0].is_ascii_alphabetic()
25+
&& bytes[1] == b':'
26+
&& (bytes[2] == b'\\' || bytes[2] == b'/')
27+
}
28+
29+
if let Some(rest) = strip_prefix_ascii_case(path, r"\\?\UNC\") {
30+
return format!(r"\\{rest}");
31+
}
32+
if let Some(rest) = strip_prefix_ascii_case(path, "//?/UNC/") {
33+
return format!("//{rest}");
34+
}
35+
if let Some(rest) = strip_prefix_ascii_case(path, r"\\?\").filter(|rest| starts_with_drive_path(rest)) {
36+
return rest.to_string();
37+
}
38+
if let Some(rest) = strip_prefix_ascii_case(path, "//?/").filter(|rest| starts_with_drive_path(rest)) {
39+
return rest.to_string();
40+
}
41+
if let Some(rest) = strip_prefix_ascii_case(path, r"\\.\").filter(|rest| starts_with_drive_path(rest)) {
42+
return rest.to_string();
43+
}
44+
if let Some(rest) = strip_prefix_ascii_case(path, "//./").filter(|rest| starts_with_drive_path(rest)) {
45+
return rest.to_string();
46+
}
47+
48+
path.to_string()
49+
}
50+
951
fn find_in_path(binary: &str) -> Option<PathBuf> {
1052
let path_var = env::var_os("PATH")?;
1153
for dir in env::split_paths(&path_var) {
@@ -86,10 +128,62 @@ pub(crate) fn git_env_path() -> String {
86128

87129
#[cfg(test)]
88130
mod tests {
89-
use super::normalize_git_path;
131+
use super::{normalize_git_path, normalize_windows_namespace_path};
90132

91133
#[test]
92134
fn normalize_git_path_replaces_backslashes() {
93135
assert_eq!(normalize_git_path("foo\\bar\\baz"), "foo/bar/baz");
94136
}
137+
138+
#[test]
139+
fn normalize_windows_namespace_path_strips_drive_prefix() {
140+
assert_eq!(
141+
normalize_windows_namespace_path(r"\\?\I:\gpt-projects\json-composer"),
142+
r"I:\gpt-projects\json-composer"
143+
);
144+
assert_eq!(
145+
normalize_windows_namespace_path("//?/I:/gpt-projects/json-composer"),
146+
"I:/gpt-projects/json-composer"
147+
);
148+
}
149+
150+
#[test]
151+
fn normalize_windows_namespace_path_strips_unc_prefix() {
152+
assert_eq!(
153+
normalize_windows_namespace_path(r"\\?\UNC\SERVER\Share\Repo"),
154+
r"\\SERVER\Share\Repo"
155+
);
156+
assert_eq!(
157+
normalize_windows_namespace_path("//?/UNC/SERVER/Share/Repo"),
158+
"//SERVER/Share/Repo"
159+
);
160+
assert_eq!(
161+
normalize_windows_namespace_path(r"\\?\unc\SERVER\Share\Repo"),
162+
r"\\SERVER\Share\Repo"
163+
);
164+
assert_eq!(
165+
normalize_windows_namespace_path("//?/unc/SERVER/Share/Repo"),
166+
"//SERVER/Share/Repo"
167+
);
168+
}
169+
170+
#[test]
171+
fn normalize_windows_namespace_path_preserves_whitespace_for_plain_paths() {
172+
assert_eq!(
173+
normalize_windows_namespace_path(" /tmp/workspace "),
174+
" /tmp/workspace "
175+
);
176+
}
177+
178+
#[test]
179+
fn normalize_windows_namespace_path_preserves_other_namespace_forms() {
180+
assert_eq!(
181+
normalize_windows_namespace_path(r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo"),
182+
r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo"
183+
);
184+
assert_eq!(
185+
normalize_windows_namespace_path(r"\\.\pipe\codex-monitor"),
186+
r"\\.\pipe\codex-monitor"
187+
);
188+
}
95189
}

0 commit comments

Comments
 (0)