Skip to content

Commit 0cdbd5d

Browse files
authored
fix: sanitize windows editor launch paths safely (Dimillian#562)
1 parent db915a1 commit 0cdbd5d

3 files changed

Lines changed: 223 additions & 9 deletions

File tree

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

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::shared::process_core::tokio_command;
88
#[cfg(target_os = "windows")]
99
use crate::shared::process_core::{build_cmd_c_command, resolve_windows_executable};
1010
use crate::types::WorkspaceEntry;
11+
use crate::utils::normalize_windows_namespace_path;
1112

1213
use super::helpers::resolve_workspace_root;
1314

@@ -133,13 +134,16 @@ fn build_launch_args(
133134
) -> Vec<String> {
134135
let mut launch_args = args.to_vec();
135136
if let Some((line, column)) = normalize_open_location(line, column) {
136-
let located_path = format_path_with_location(path, line, column);
137137
match strategy {
138138
Some(LineAwareLaunchStrategy::GotoFlag) => {
139+
let sanitized_path = normalize_windows_namespace_path(path);
140+
let located_path = format_path_with_location(&sanitized_path, line, column);
139141
launch_args.push("--goto".to_string());
140142
launch_args.push(located_path);
141143
}
142144
Some(LineAwareLaunchStrategy::PathWithLineColumn) => {
145+
let sanitized_path = normalize_windows_namespace_path(path);
146+
let located_path = format_path_with_location(&sanitized_path, line, column);
143147
launch_args.push(located_path);
144148
}
145149
None => {
@@ -186,13 +190,8 @@ pub(crate) async fn open_workspace_in_core(
186190
if trimmed.is_empty() {
187191
return Err("Missing app or command".to_string());
188192
}
189-
let launch_args = build_launch_args(
190-
&path,
191-
&args,
192-
line,
193-
column,
194-
command_launch_strategy(trimmed),
195-
);
193+
let launch_args =
194+
build_launch_args(&path, &args, line, column, command_launch_strategy(trimmed));
196195

197196
#[cfg(target_os = "windows")]
198197
let mut cmd = {
@@ -380,6 +379,104 @@ mod tests {
380379
);
381380
}
382381

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