Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 1 addition & 22 deletions crates/aft/src/inspect/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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};
Expand Down
31 changes: 1 addition & 30 deletions crates/aft/src/inspect/oxc_engine/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ fn remap_build_output_to_src(rel: &str) -> Option<String> {

#[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))]
Expand All @@ -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())
Expand Down
1 change: 1 addition & 0 deletions crates/aft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
14 changes: 7 additions & 7 deletions crates/aft/src/lsp/position.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,7 @@ pub fn uri_to_path(uri: &lsp_types::Uri) -> Option<PathBuf> {
}

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())
Comment thread
TreyThomasCodes marked this conversation as resolved.
Comment thread
TreyThomasCodes marked this conversation as resolved.
}

fn split_unc_path(path: &str) -> Option<(&str, &str)> {
Expand Down Expand Up @@ -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)]
Expand Down
54 changes: 6 additions & 48 deletions crates/aft/src/windows_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -119,66 +120,23 @@ 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::*;

#[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
);
}

Expand Down
114 changes: 114 additions & 0 deletions crates/aft/src/windows_path.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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());
Comment thread
TreyThomasCodes marked this conversation as resolved.
}
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}");
}
}
}
23 changes: 22 additions & 1 deletion packages/aft-bridge/src/__tests__/project-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
}
});
});
35 changes: 25 additions & 10 deletions packages/aft-bridge/src/project-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
TreyThomasCodes marked this conversation as resolved.
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;
}
Comment thread
TreyThomasCodes marked this conversation as resolved.
}
if (s.length >= 2 && s[1] === ":") {
const drive = s.charCodeAt(0);
Expand All @@ -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
Expand Down
Loading