diff --git a/crates/aft/src/inspect/job.rs b/crates/aft/src/inspect/job.rs index d9e318bde..162b14b96 100644 --- a/crates/aft/src/inspect/job.rs +++ b/crates/aft/src/inspect/job.rs @@ -814,7 +814,7 @@ pub(crate) fn normalize_path(path: &Path) -> PathBuf { // normalizers must stay in agreement or membership/strip_prefix checks // that cross the engine boundary silently miss on Windows. #[cfg(windows)] - let path = &windows_non_verbatim_path(path); + let path = &crate::windows_path::normalize_windows_path(path); let mut result = PathBuf::new(); for component in path.components() { @@ -844,27 +844,6 @@ pub(crate) fn canonicalize_normalized(path: &Path) -> PathBuf { } } -#[cfg(windows)] -fn windows_non_verbatim_path(path: &Path) -> PathBuf { - let mut raw = path.to_string_lossy().replace('/', "\\"); - if let Some(stripped) = raw.strip_prefix("\\\\?\\UNC\\") { - raw = format!("\\\\{stripped}"); - } else if let Some(stripped) = raw.strip_prefix("\\\\?\\") { - raw = stripped.to_string(); - } else if let Some(stripped) = raw.strip_prefix("\\\\??\\") { - raw = stripped.to_string(); - } - - if raw.as_bytes().get(1) == Some(&b':') { - let drive = raw.as_bytes()[0]; - if drive.is_ascii_lowercase() { - raw.replace_range(0..1, &(drive as char).to_ascii_uppercase().to_string()); - } - } - - PathBuf::from(raw) -} - #[cfg(test)] mod test_support_tests { use super::{is_test_file, is_test_support_file}; diff --git a/crates/aft/src/inspect/oxc_engine/resolver.rs b/crates/aft/src/inspect/oxc_engine/resolver.rs index 2050a1444..efd696f4d 100644 --- a/crates/aft/src/inspect/oxc_engine/resolver.rs +++ b/crates/aft/src/inspect/oxc_engine/resolver.rs @@ -639,7 +639,7 @@ fn remap_build_output_to_src(rel: &str) -> Option { #[cfg(windows)] pub fn normalize_path(path: &Path) -> PathBuf { - normalize_path_components(&windows_non_verbatim_path(path)) + normalize_path_components(&crate::windows_path::normalize_windows_path(path)) } #[cfg(not(windows))] @@ -661,35 +661,6 @@ fn normalize_path_components(path: &Path) -> PathBuf { normalized } -#[cfg(windows)] -fn windows_non_verbatim_path(path: &Path) -> PathBuf { - let mut raw = path.to_string_lossy().replace('/', "\\"); - if let Some(stripped) = strip_ascii_prefix(&raw, "\\\\?\\UNC\\") { - raw = format!("\\\\{}", stripped); - } else if let Some(stripped) = strip_ascii_prefix(&raw, "\\\\?\\") { - raw = stripped.to_string(); - } else if let Some(stripped) = strip_ascii_prefix(&raw, "\\\\??\\") { - raw = stripped.to_string(); - } - - if raw.as_bytes().get(1) == Some(&b':') { - let drive = raw.as_bytes()[0]; - if drive.is_ascii_lowercase() { - raw.replace_range(0..1, &(drive as char).to_ascii_uppercase().to_string()); - } - } - - PathBuf::from(raw) -} - -#[cfg(windows)] -fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { - value - .get(..prefix.len()) - .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) - .then(|| &value[prefix.len()..]) -} - fn slash_path(path: &Path) -> String { path.components() .map(|component| component.as_os_str().to_string_lossy()) diff --git a/crates/aft/src/lib.rs b/crates/aft/src/lib.rs index 10a37b31f..bad32840e 100644 --- a/crates/aft/src/lib.rs +++ b/crates/aft/src/lib.rs @@ -148,6 +148,7 @@ pub mod watcher_filter; // decision logic without a real Windows runtime. The module itself only // uses portable APIs; only its callers are Windows-gated. pub(crate) mod windows_command; +pub mod windows_path; pub mod windows_shell; #[cfg(test)] diff --git a/crates/aft/src/lsp/position.rs b/crates/aft/src/lsp/position.rs index e93ccaa45..216567595 100644 --- a/crates/aft/src/lsp/position.rs +++ b/crates/aft/src/lsp/position.rs @@ -171,13 +171,7 @@ pub fn uri_to_path(uri: &lsp_types::Uri) -> Option { } fn normalize_windows_path_for_uri(path: &str) -> String { - if let Some(stripped) = path.strip_prefix(r"\\?\UNC\") { - format!(r"\\{}", stripped) - } else if let Some(stripped) = path.strip_prefix(r"\\?\") { - stripped.to_string() - } else { - path.to_string() - } + crate::windows_path::non_verbatim_path_text(path).unwrap_or_else(|| path.to_string()) } fn split_unc_path(path: &str) -> Option<(&str, &str)> { @@ -341,6 +335,12 @@ mod tests { ); } + #[test] + fn windows_extended_volume_path_is_not_misrepresented_as_a_file_uri() { + let path = Path::new(r"\\?\Volume{1234}\repo\main.rs"); + assert!(path_to_uri(path).is_err()); + } + // Unix-absolute path syntax; not a valid Windows absolute path so the // round-trip can only be verified on Unix-like targets. #[cfg(unix)] diff --git a/crates/aft/src/windows_command.rs b/crates/aft/src/windows_command.rs index 5c959bcea..06dd555c5 100644 --- a/crates/aft/src/windows_command.rs +++ b/crates/aft/src/windows_command.rs @@ -77,7 +77,8 @@ where // batch file through that namespace, and npm shims additionally derive // `%~dp0` paths that fail with "The system cannot find the path specified." // Convert only the namespace spelling; the path remains canonical. - let command_path = cmd_compatible_path(command_path); + let command_path = crate::windows_path::non_verbatim_path_text(command_path) + .unwrap_or_else(|| command_path.to_string()); let mut command_line = format!("\"\"%{BATCH_COMMAND_ENV}%\""); let mut argument_env = Vec::new(); @@ -119,49 +120,6 @@ where Ok(command) } -#[cfg(windows)] -fn cmd_compatible_path(path: &str) -> String { - for prefix in [r"\\?\UNC\", r"\\??\UNC\", r"\??\UNC\"] { - if let Some(tail) = strip_ascii_prefix(path, prefix) { - let mut components = tail - .split(['\\', '/']) - .filter(|component| !component.is_empty()); - if components.next().is_some() && components.next().is_some() { - return format!(r"\\{tail}"); - } - return path.to_string(); - } - } - - for prefix in [r"\\?\", r"\\??\", r"\??\"] { - if let Some(tail) = strip_ascii_prefix(path, prefix) { - let bytes = tail.as_bytes(); - if bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && matches!(bytes[2], b'\\' | b'/') - { - return tail.to_string(); - } - // Namespaces such as `\\?\Volume{GUID}\` cannot be safely - // converted into a DOS path by dropping their prefix. - return path.to_string(); - } - } - - path.to_string() -} - -#[cfg(windows)] -fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { - let head = value.get(..prefix.len())?; - if head.eq_ignore_ascii_case(prefix) { - value.get(prefix.len()..) - } else { - None - } -} - #[cfg(all(test, windows))] mod tests { use super::*; @@ -169,16 +127,16 @@ mod tests { #[test] fn cmd_compatible_path_only_converts_dos_and_unc_namespaces() { assert_eq!( - cmd_compatible_path(r"\\?\C:\cache\server.cmd"), + crate::windows_path::non_verbatim_path_text(r"\\?\C:\cache\server.cmd").unwrap(), r"C:\cache\server.cmd" ); assert_eq!( - cmd_compatible_path(r"\\?\unc\host\share\server.cmd"), + crate::windows_path::non_verbatim_path_text(r"\\?\unc\host\share\server.cmd").unwrap(), r"\\host\share\server.cmd" ); assert_eq!( - cmd_compatible_path(r"\\?\Volume{1234}\server.cmd"), - r"\\?\Volume{1234}\server.cmd" + crate::windows_path::non_verbatim_path_text(r"\\?\Volume{1234}\server.cmd"), + None ); } diff --git a/crates/aft/src/windows_path.rs b/crates/aft/src/windows_path.rs new file mode 100644 index 000000000..ee4e1774c --- /dev/null +++ b/crates/aft/src/windows_path.rs @@ -0,0 +1,114 @@ +//! Windows extended-length path normalization. +//! +//! `std::fs::canonicalize` returns paths in the extended-length namespace on +//! Windows. Only DOS-drive and UNC names have a safe non-verbatim spelling; +//! other namespaces (for example `\\?\Volume{GUID}\`) must retain their prefix. + +use std::path::{Path, PathBuf}; + +/// Normalize a Windows path for comparisons and Win32 APIs. +/// +/// Valid DOS and UNC extended-length paths lose their verbatim prefix. Other +/// namespaces remain untouched because they cannot be represented safely +/// without that prefix. Separators and drive letter casing are also normalized. +pub fn normalize_windows_path(path: &Path) -> PathBuf { + let raw = path.to_string_lossy().replace('/', "\\"); + let mut normalized = non_verbatim_path_text(&raw).unwrap_or(raw); + if normalized.as_bytes().get(1) == Some(&b':') { + let drive = normalized.as_bytes()[0]; + if drive.is_ascii_lowercase() { + normalized.replace_range(0..1, &(drive as char).to_ascii_uppercase().to_string()); + } + } + PathBuf::from(normalized) +} + +/// String form of the strict verbatim-prefix conversion, for APIs that require a command line +/// or URI rather than a [`Path`]. +pub fn non_verbatim_path_text(path: &str) -> Option { + for prefix in [r"\\?\UNC\", r"\\??\UNC\", r"\??\UNC\"] { + if let Some(tail) = strip_ascii_prefix(path, prefix) { + if is_safe_unc_tail(tail) { + return Some(format!(r"\\{tail}")); + } + return None; + } + } + + for prefix in [r"\\?\", r"\\??\", r"\??\"] { + if let Some(tail) = strip_ascii_prefix(path, prefix) { + let bytes = tail.as_bytes(); + if bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/') + && !has_dot_component(tail) + { + return Some(tail.to_string()); + } + return None; + } + } + + None +} + +fn is_safe_unc_tail(tail: &str) -> bool { + let mut components = tail.split(['\\', '/']); + components.next().is_some_and(|server| !server.is_empty()) + && components.next().is_some_and(|share| !share.is_empty()) + && !has_dot_component(tail) +} + +fn has_dot_component(path: &str) -> bool { + path.split(['\\', '/']) + .any(|component| matches!(component, "." | "..")) +} + +fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + let head = value.get(..prefix.len())?; + if head.eq_ignore_ascii_case(prefix) { + value.get(prefix.len()..) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::{non_verbatim_path_text, normalize_windows_path}; + use std::path::{Path, PathBuf}; + + #[test] + fn converts_only_valid_dos_and_unc_verbatim_paths() { + assert_eq!( + non_verbatim_path_text(r"\\?\C:\cache\server.cmd"), + Some(r"C:\cache\server.cmd".to_string()) + ); + assert_eq!( + non_verbatim_path_text(r"\\?\unc\host\share\server.cmd"), + Some(r"\\host\share\server.cmd".to_string()) + ); + assert_eq!( + normalize_windows_path(Path::new(r"\\??\d:\repo")), + PathBuf::from(r"D:\repo") + ); + } + + #[test] + fn preserves_unsupported_or_malformed_verbatim_namespaces() { + for path in [ + r"\\?\Volume{1234}\server.cmd", + r"\\?\UNC\host", + r"\\?\UNC\\host\share", + r"\\??\UNC\\host\share", + r"\\?\UNC\host\share\..\file", + r"\\?\C:\repo\..\other", + r"\\?\C:relative", + r"\\?\relative", + r"C:\ordinary\path", + ] { + assert_eq!(non_verbatim_path_text(path), None, "{path}"); + } + } +} diff --git a/packages/aft-bridge/src/__tests__/project-identity.test.ts b/packages/aft-bridge/src/__tests__/project-identity.test.ts index 388560b46..485c31144 100644 --- a/packages/aft-bridge/src/__tests__/project-identity.test.ts +++ b/packages/aft-bridge/src/__tests__/project-identity.test.ts @@ -2,7 +2,11 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { canonicalizeProjectRoot, projectRootKeyHash } from "../project-identity.js"; +import { + canonicalizeProjectRoot, + normalizeWindowsRoot, + projectRootKeyHash, +} from "../project-identity.js"; describe("project-identity canonicalization", () => { test("trailing separators collapse to one identity", () => { @@ -78,4 +82,21 @@ describe("project-identity canonicalization", () => { expect(() => canonicalizeProjectRoot(missing)).not.toThrow(); expect(projectRootKeyHash(missing)).toMatch(/^[0-9a-f]{16}$/); }); + + test("Windows verbatim normalization converts only safe DOS and UNC namespaces", () => { + const win32 = "win32"; + expect(normalizeWindowsRoot("\\\\?\\c:\\repo", win32)).toBe("C:\\repo"); + expect(normalizeWindowsRoot("\\\\?\\UNC\\server\\share\\repo", win32)).toBe( + "\\\\server\\share\\repo", + ); + for (const path of [ + "\\\\?\\Volume{1234}\\repo", + "\\\\?\\UNC\\\\server\\share", + "\\\\??\\UNC\\\\server\\share", + "\\\\?\\C:\\repo\\..\\other", + "\\\\?\\UNC\\server\\share\\.\\repo", + ]) { + expect(normalizeWindowsRoot(path, win32)).toBe(path); + } + }); }); diff --git a/packages/aft-bridge/src/project-identity.ts b/packages/aft-bridge/src/project-identity.ts index 36d876c88..b22eaab9c 100644 --- a/packages/aft-bridge/src/project-identity.ts +++ b/packages/aft-bridge/src/project-identity.ts @@ -35,18 +35,29 @@ export function canonicalizeProjectRoot(dir: string): string { } /** - * Strip Windows extended-length verbatim prefixes (`\\?\`, `\\?\UNC\`) and - * uppercase a lowercase drive letter so `c:\x` and `C:\x` collapse to one - * identity. Mirrors `cortexkit-paths`' `windows_non_verbatim_path`. No-op off - * Windows. + * Strip only safely convertible Windows extended-length DOS and UNC prefixes, + * and uppercase a lowercase drive letter so `c:\x` and `C:\x` collapse to one + * identity. Namespaces such as `\\?\Volume{GUID}\` must retain their prefix. + * `platform` is injectable for cross-platform regression tests. No-op off Windows. */ -function normalizeWindowsRoot(p: string): string { - if (process.platform !== "win32") return p; +export function normalizeWindowsRoot(p: string, platform = process.platform): string { + if (platform !== "win32") return p; let s = p; - if (s.startsWith("\\\\?\\UNC\\")) { - s = `\\\\${s.slice("\\\\?\\UNC\\".length)}`; - } else if (s.startsWith("\\\\?\\")) { - s = s.slice("\\\\?\\".length); + const lower = s.toLowerCase(); + const uncPrefix = ["\\\\?\\unc\\", "\\\\??\\unc\\", "\\??\\unc\\"].find((prefix) => + lower.startsWith(prefix), + ); + if (uncPrefix) { + const tail = s.slice(uncPrefix.length); + if (/^[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/.test(tail) && !hasDotComponent(tail)) { + s = `\\\\${tail}`; + } + } else { + const dosPrefix = ["\\\\?\\", "\\\\??\\", "\\??\\"].find((prefix) => lower.startsWith(prefix)); + if (dosPrefix) { + const tail = s.slice(dosPrefix.length); + if (/^[a-z]:[\\/]/i.test(tail) && !hasDotComponent(tail)) s = tail; + } } if (s.length >= 2 && s[1] === ":") { const drive = s.charCodeAt(0); @@ -57,6 +68,10 @@ function normalizeWindowsRoot(p: string): string { return s; } +function hasDotComponent(path: string): boolean { + return path.split(/[\\/]/).some((component) => component === "." || component === ".."); +} + /** * Stable 16-hex scope hash of the canonical project root. Used for RPC * port-file directory scoping; because it canonicalizes first, the server