|
1 | 1 | use std::borrow::Cow; |
2 | | -use std::path::{Component, Path, PathBuf}; |
| 2 | +use std::ffi::OsString; |
| 3 | +use std::path::{Component, Path, PathBuf, Prefix}; |
3 | 4 | use std::sync::LazyLock; |
4 | 5 |
|
5 | 6 | use either::Either; |
@@ -351,6 +352,107 @@ pub fn try_relative_to_if( |
351 | 352 | } |
352 | 353 | } |
353 | 354 |
|
| 355 | +/// Convert a [`Path`] to a Windows `verbatim` path (prefixed with `\\?\`) when possible to bypass |
| 356 | +/// Win32 path normalization such as [`MAX_PATH`] and removed trailing characters (dot, space). |
| 357 | +/// Other characters as defined by [`Path.GetInvalidFileNameChars`] are still prohibited. This |
| 358 | +/// function will attempt to perform path normalization similar to Win32 default normalization |
| 359 | +/// without triggering the existing Win32 limitations. |
| 360 | +/// |
| 361 | +/// Only [`Prefix::UNC`] and [`Prefix::Disk`] conversion compatible components are supported. |
| 362 | +/// * [`Prefix::UNC`] `\\server\share` becomes `\\?\UNC\server\share` |
| 363 | +/// * [`Prefix::Disk`] `DriveLetter:` becomes `\\?\DriveLetter:` |
| 364 | +/// |
| 365 | +/// Other representations do not yield a `verbatim` path. The following cases are returned as-is: |
| 366 | +/// * Non-Windows systems. |
| 367 | +/// * Device paths such as those starting with `\\.\`. |
| 368 | +/// * Paths already prefixed with `\\?\` or `\\?\UNC\`. |
| 369 | +/// |
| 370 | +/// WARNING: Adding the `\\?\` prefix effectively skips Win32 default path normalization. Even |
| 371 | +/// though it allows operations on paths that are normally unavailable, it can also be used to |
| 372 | +/// create entries that can potentially lead to further issues with operations that expect |
| 373 | +/// normalization such as symbolic links, junctions or reparse points. |
| 374 | +/// |
| 375 | +/// [`MAX_PATH`]: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation |
| 376 | +/// [`Path.GetInvalidFileNameChars`]: https://learn.microsoft.com/en-us/dotnet/api/system.io.path.getinvalidfilenamechars |
| 377 | +/// |
| 378 | +/// See: |
| 379 | +/// * <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file> |
| 380 | +/// * <https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats> |
| 381 | +pub fn verbatim_path(path: &Path) -> Cow<'_, Path> { |
| 382 | + if !cfg!(windows) { |
| 383 | + return Cow::Borrowed(path); |
| 384 | + } |
| 385 | + |
| 386 | + // Attempt to resolve a fully qualified path just like Win32 path normalization would. |
| 387 | + // std::path::absolute calls GetFullPathNameW which defeats the purpose of this function |
| 388 | + // as it results in Win32 default path normalization. |
| 389 | + let resolved_path = if path.is_relative() { |
| 390 | + Cow::Owned(CWD.join(path)) |
| 391 | + } else { |
| 392 | + Cow::Borrowed(path) |
| 393 | + }; |
| 394 | + |
| 395 | + // Fast Path: we only support verbatim conversion for Prefix::UNC and Prefix::Disk |
| 396 | + if let Some(Component::Prefix(prefix)) = resolved_path.components().next() { |
| 397 | + match prefix.kind() { |
| 398 | + Prefix::UNC(..) | Prefix::Disk(_) => {}, |
| 399 | + // return as-is as there's no verbatim equivalent for `\\.\device` |
| 400 | + Prefix::DeviceNS(_) |
| 401 | + // return as-is as its already verbatim |
| 402 | + | Prefix::Verbatim(_) |
| 403 | + | Prefix::VerbatimDisk(_) |
| 404 | + | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path) |
| 405 | + } |
| 406 | + } |
| 407 | + |
| 408 | + // Resolve relative directory components while avoiding default Win32 path normalization |
| 409 | + let normalized_path = normalized(&resolved_path); |
| 410 | + |
| 411 | + let mut components = normalized_path.components(); |
| 412 | + let Some(Component::Prefix(prefix)) = components.next() else { |
| 413 | + return Cow::Borrowed(path); |
| 414 | + }; |
| 415 | + |
| 416 | + match prefix.kind() { |
| 417 | + // `DriveLetter:` -> `\\?\DriveLetter:` |
| 418 | + Prefix::Disk(_) => { |
| 419 | + let mut result = OsString::from(r"\\?\"); |
| 420 | + result.push(normalized_path.as_os_str()); // e.g. "C:" |
| 421 | + Cow::Owned(PathBuf::from(result)) |
| 422 | + } |
| 423 | + // `\\server\share` -> `\\?\UNC\server\share` |
| 424 | + Prefix::UNC(server, share) => { |
| 425 | + let mut result = OsString::from(r"\\?\UNC\"); |
| 426 | + result.push(server); |
| 427 | + result.push(r"\"); |
| 428 | + result.push(share); |
| 429 | + for component in components { |
| 430 | + match component { |
| 431 | + Component::RootDir => {} // being cautious |
| 432 | + Component::Prefix(_) => { |
| 433 | + debug_assert!(false, "prefix already consumed"); |
| 434 | + } |
| 435 | + Component::CurDir | Component::ParentDir => { |
| 436 | + debug_assert!(false, "path already normalized"); |
| 437 | + } |
| 438 | + Component::Normal(_) => { |
| 439 | + result.push(r"\"); |
| 440 | + result.push(component.as_os_str()); |
| 441 | + } |
| 442 | + } |
| 443 | + } |
| 444 | + Cow::Owned(PathBuf::from(result)) |
| 445 | + } |
| 446 | + Prefix::DeviceNS(_) |
| 447 | + | Prefix::Verbatim(_) |
| 448 | + | Prefix::VerbatimDisk(_) |
| 449 | + | Prefix::VerbatimUNC(..) => { |
| 450 | + debug_assert!(false, "skipped via fast path"); |
| 451 | + Cow::Borrowed(path) |
| 452 | + } |
| 453 | + } |
| 454 | +} |
| 455 | + |
354 | 456 | /// A path that can be serialized and deserialized in a portable way by converting Windows-style |
355 | 457 | /// backslashes to forward slashes, and using a `.` for an empty path. |
356 | 458 | /// |
@@ -610,4 +712,60 @@ mod tests { |
610 | 712 | assert_eq!(normalize_path(Path::new(input)), Path::new(expected)); |
611 | 713 | } |
612 | 714 | } |
| 715 | + |
| 716 | + #[cfg(windows)] |
| 717 | + #[test] |
| 718 | + fn test_verbatim_path() { |
| 719 | + let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display()); |
| 720 | + let relative_root = format!( |
| 721 | + r"\\?\{}\path\to\logging.", |
| 722 | + CWD.components() |
| 723 | + .next() |
| 724 | + .expect("expected a drive letter prefix") |
| 725 | + .simplified_display() |
| 726 | + ); |
| 727 | + let cases = [ |
| 728 | + // Non-Verbatim disk |
| 729 | + (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."), |
| 730 | + (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."), |
| 731 | + (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."), |
| 732 | + (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."), |
| 733 | + (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), // @TODO(samypr100) we do not support expanding drive-relative paths |
| 734 | + (r".\path\to\.\logging.", relative_path.as_str()), |
| 735 | + (r"path\to\..\to\logging.", relative_path.as_str()), |
| 736 | + (r"./path/to/logging.", relative_path.as_str()), |
| 737 | + (r"\path\to\logging.", relative_root.as_str()), |
| 738 | + // Non-Verbatim UNC |
| 739 | + ( |
| 740 | + r"\\127.0.0.1\c$\path\to\logging.", |
| 741 | + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", |
| 742 | + ), |
| 743 | + ( |
| 744 | + r"\\127.0.0.1\c$\path\to\.\logging.", |
| 745 | + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", |
| 746 | + ), |
| 747 | + ( |
| 748 | + r"\\127.0.0.1\c$\path\to\..\to\logging.", |
| 749 | + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", |
| 750 | + ), |
| 751 | + ( |
| 752 | + r"//127.0.0.1/c$/path/to/../to/./logging.", |
| 753 | + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", |
| 754 | + ), |
| 755 | + // Verbatim Disk |
| 756 | + (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."), |
| 757 | + // Verbatim UNC |
| 758 | + ( |
| 759 | + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", |
| 760 | + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", |
| 761 | + ), |
| 762 | + // Device Namespace |
| 763 | + (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"), |
| 764 | + (r"\\.\NUL", r"\\.\NUL"), |
| 765 | + ]; |
| 766 | + |
| 767 | + for (input, expected) in cases { |
| 768 | + assert_eq!(verbatim_path(Path::new(input)), Path::new(expected)); |
| 769 | + } |
| 770 | + } |
613 | 771 | } |
0 commit comments