Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Convert long Windows paths to UNC non-normalised device paths in `urlToPath`
## What

When a Windows path is extracted from a `file://` URL in `FileUrlPathConverter.pathFromUrl`, and that path exceeds the Windows `MAX_PATH` limit, automatically transform the path to a non-normalised UNC device path (i.e. of the form `\\?\C:\`, or `\\?\UNC\host\share`).

## Why

A common problem in real-world scenarios is the inconsistent handling of long file paths on Windows hosts/applications. 

Currently, `pathToUrl` loses any information about the type of (UNC) path provided to it, and so `pathFromUrl` will convert a Windows file URL to either a Windows drive path or a standard UNC share path, irrespective of the length of the path.

This means that e.g. a path longer than `MAX_PATH` fed into `pathToUrl` as a `\\?\...` path will come back out via `pathFromUrl` as a standard `C:\...` or `\\host\share\...` path, which might be rejected by the Windows API, depending on the particular Windows machine configuration and application where it is used.


## Acceptance Criteria

- Windows paths longer than `MAX_PATH` are automatically converted to UNC non-normalised device paths, e.g. either `\\?\C:\` or `\\?\UNC\host\share`.
- Assume we won't add an option for this, and will automatically convert when MAX_PATH is broken.
- Update docs, including `file_path_url_converter.ipynb` in the MediaCreation repo



Discussion / hints:
We got direct feedback from the TSC that this is quite very important.
Looking at https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation it mentions drive paths, but doesn't say if UNC paths are similarly limited. 

**TLDR**: Yes, UNC share paths have the same MAX_PATH limit.

So, some experiments on my local Windows...

The `MAX_PATH` limit for a drive path is 260 chars, including the null terminator.

I found that I had long file names enabled locally, i.e. `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled` was `1`.

I also found that in my go-to terminal, cmder, where I tend to use Linux-style functions (e.g. `cat`), those functions are not affected by the `MAX_PATH`. However, Win32 functions, such as `type` (equivalent of `cat`) _is_ affected.

Even with `LongPathsEnabled`, a file _name_ cannot exceed 255 characters, but the absolute file _path_ can.

With `LongPathsEnabled`, creating a test 255 char filename in a directory called `C:\w\`, meaning a total length of 260 (261 including null terminator)
```
C:\w
λ echo "It worked!" > 0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789abcde
```
(an additional character results in `File name too long`)

Then, after disabling `LongPathsEnabled` and rebooting, the file is not readable, as expected
```
λ type C:\w\0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789abcde
The system cannot find the path specified.
```
(but , as noted before, the `cat`  command in cmder can print it just fine)

So lets try UNC, where the `w` directory is shared as `w`, so 

```
λ type \\127.0.0.1\w\0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789abcde
The system cannot find the path specified.
```
(again, `cat` can print it fine, so I know the path is correct)

The UNC prefix and share name is included in the path length limit, i.e. the following 260 char path (261 with null terminator) fails
```
λ type \\127.0.0.1\w\012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345
The system cannot find the path specified.
```

but copying the file to another with one less character succeeds
```
λ type \\127.0.0.1\w\01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234
"It worked!"
```

As expected, UNC non-normalised device paths work just fine

```
λ type \\?\C:\w\0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789abcde
"It worked!"

λ type \\?\UNC\127.0.0.1\w\0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789abcde
"It worked!"
```

Fix patch:
diff --git a/.editorconfig b/.editorconfig
index 432109432..2bb96f21e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -168,3 +168,8 @@ ij_python_use_continuation_indent_for_arguments = false
 ij_python_use_continuation_indent_for_collection_and_comprehensions = false
 ij_python_use_continuation_indent_for_parameters = true
 ij_python_wrap_long_lines = false
+
+[file_url_path_tests.json]
+# For consistent style with upstream source (swift-url).
+indent_size = 4
+max_line_length = 650
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 1dd804385..3f615665b 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,6 +1,16 @@
 Release Notes
 =============
 
+v1.0.0-beta.x.y
+---------------
+
+### Improvements
+
+- Updated `FileUrlPathConverter.pathFromUrl` to automatically convert
+  long Windows paths, which exceed the Windows `MAX_PATH` limit, to UNC
+  non-normalised device paths (i.e. `\\?\`-prefixed).
+  [#1257](https://github.com/OpenAssetIO/OpenAssetIO/issues/1257)
+
 v1.0.0-beta.2.0
 ---------------
 
diff --git a/src/openassetio-core/src/utils/path/windows.cpp b/src/openassetio-core/src/utils/path/windows.cpp
index a5de0f670..7ae946a7b 100644
--- a/src/openassetio-core/src/utils/path/windows.cpp
+++ b/src/openassetio-core/src/utils/path/windows.cpp
@@ -83,6 +83,12 @@ Str FileUrlPathConverter::pathFromUrl(const std::string_view& url) const {
 
   std::replace(windowsPath.begin(), windowsPath.end(), kForwardSlash, kBackSlash);
 
+  if (windowsPath.size() > kMaxPath) {
+    windowsPath = host.empty()
+                      ? pathTypes::UncUnnormalisedDeviceDrivePath::prefixDrivePath(windowsPath)
+                      : pathTypes::UncUnnormalisedDeviceSharePath::prefixUncSharePath(windowsPath);
+  }
+
   return windowsPath;
 }
 }  // namespace utils::path::windows
diff --git a/src/openassetio-core/src/utils/path/windows.hpp b/src/openassetio-core/src/utils/path/windows.hpp
index 195e726c6..32dfbf01f 100644
--- a/src/openassetio-core/src/utils/path/windows.hpp
+++ b/src/openassetio-core/src/utils/path/windows.hpp
@@ -22,6 +22,11 @@ namespace utils::path::windows {
  * Window path to/from a URL.
  */
 struct FileUrlPathConverter {
+  /// Windows maximum file path limit, aka MAX_PATH, is 260 chars
+  /// including null terminator. See
+  /// https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
+  static constexpr std::size_t kMaxPath = 259;
+
   detail::WindowsUrl& urlHandler;
   detail::DriveLetter& driveLetterHandler;
   detail::UncHost& uncHostHandler;
diff --git a/src/openassetio-core/src/utils/path/windows/pathTypes.cpp b/src/openassetio-core/src/utils/path/windows/pathTypes.cpp
index 6f21e8b03..90eb86cd8 100644
--- a/src/openassetio-core/src/utils/path/windows/pathTypes.cpp
+++ b/src/openassetio-core/src/utils/path/windows/pathTypes.cpp
@@ -171,7 +171,7 @@ std::optional<detail::UncDetails> UncUnnormalisedDeviceDrivePath::extractUncDeta
   const std::string_view shareNameAndPath =
       uncUnnormalisedDevicePathHandler.withoutTrailingSlashes(pathParts->group(path, 2));
   const std::string_view fullPath =
-      path.substr(kPathPrefixLength, hostOrDrive.size() + shareNameAndPath.size());
+      path.substr(kPrefix.size(), hostOrDrive.size() + shareNameAndPath.size());
   return detail::UncDetails{hostOrDrive, {}, {}, shareNameAndPath, fullPath};
 }
 
@@ -211,6 +211,14 @@ void UncUnnormalisedDeviceDrivePath::setUrlPath(const detail::UncDetails& uncDet
   }
 }
 
+Str UncUnnormalisedDeviceDrivePath::prefixDrivePath(const std::string_view drivePath) {
+  Str prefixedPath;
+  prefixedPath.reserve(kPrefix.size() + drivePath.size());
+  prefixedPath += kPrefix;
+  prefixedPath += drivePath;
+  return prefixedPath;
+}
+
 // ---------------------------------------------------------------------
 // UncUnnormalisedDeviceSharePath
 
@@ -238,7 +246,7 @@ std::optional<detail::UncDetails> UncUnnormalisedDeviceSharePath::extractUncDeta
   const auto [shareName, sharePath, shareNameAndPath] =
       extractShareNameAndPath(pathParts->group(path, 2));
   const std::string_view fullPath =
-      path.substr(kPrefixLength, hostOrDrive.size() + shareNameAndPath.size());
+      path.substr(kPrefix.size(), hostOrDrive.size() + shareNameAndPath.size());
   return detail::UncDetails{hostOrDrive, shareName, sharePath, shareNameAndPath, fullPath};
 }
 
@@ -277,6 +285,14 @@ void UncUnnormalisedDeviceSharePath::setUrlPath(const detail::UncDetails& uncDet
     GenericUrl::setUrlPath(urlPath, url);
   }
 }
+
+Str UncUnnormalisedDeviceSharePath::prefixUncSharePath(std::string_view uncSharePath) {
+  Str prefixedPath;
+  prefixedPath.reserve(kPrefix.size() + uncSharePath.size() - 2);
+  prefixedPath += kPrefix;
+  prefixedPath += uncSharePath.substr(2);  // strip leading `\`s.
+  return prefixedPath;
+}
 }  // namespace utils::path::windows::pathTypes
 }  // namespace OPENASSETIO_CORE_ABI_VERSION
 }  // namespace openassetio
diff --git a/src/openassetio-core/src/utils/path/windows/pathTypes.hpp b/src/openassetio-core/src/utils/path/windows/pathTypes.hpp
index 59b264b68..ad3bed183 100644
--- a/src/openassetio-core/src/utils/path/windows/pathTypes.hpp
+++ b/src/openassetio-core/src/utils/path/windows/pathTypes.hpp
@@ -146,7 +146,7 @@ struct UncUnnormalisedDeviceDrivePath {
   detail::DriveLetter& driveLetterHandler;
   detail::UncUnnormalisedDevicePath& uncUnnormalisedDevicePathHandler;
 
-  static constexpr std::size_t kPathPrefixLength = std::string_view{R"(\\?\)"}.size();
+  static constexpr std::string_view kPrefix = R"(\\?\)";
   Regex pathRegex{R"(^\\\\\?\\([^\\]*)(.*)$)"};
 
   /**
@@ -200,6 +200,20 @@ struct UncUnnormalisedDeviceDrivePath {
    * @param url URL object to update.
    */
   void setUrlPath(const detail::UncDetails& uncDetails, ada::url& url) const;
+
+  /**
+   * Prefix a (normalised) drive path to make it an unnormalised device
+   * path.
+   *
+   * E.g. `C:\path\to\file.ext` -> `\\?\C:\path\to\file.ext`
+   *
+   * Note: does not do any normalisation beforehand (e.g. converting
+   * `/` to `\` etc).
+   *
+   * @param drivePath Path to prefix.
+   * @return unnormalised device drive path.
+   */
+  static Str prefixDrivePath(std::string_view drivePath);
 };
 
 /**
@@ -210,7 +224,7 @@ struct UncUnnormalisedDeviceSharePath {
   detail::UncUnnormalisedDevicePath& uncUnnormalisedDevicePathHandler;
   detail::WindowsUrl& urlHandler;
 
-  static constexpr std::size_t kPrefixLength = std::string_view{R"(\\?\UNC\)"}.size();
+  static constexpr std::string_view kPrefix = R"(\\?\UNC\)";
   Regex pathRegex{R"(^\\\\\?\\UNC\\([^\\]*)(.*)$)"};
   Regex pathHeadAndTailRegex{R"(^(\\[^\\]+)(.*)$)"};
 
@@ -272,6 +286,20 @@ struct UncUnnormalisedDeviceSharePath {
    * @param url URL object to update.
    */
   void setUrlPath(const detail::UncDetails& uncDetails, ada::url& url) const;
+
+  /**
+   * Prefix a (normalised) share path to make it an unnormalised device
+   * path.
+   *
+   * E.g. `\\host\share\file.ext` -> `\\?\UNC\host\share\file.ext`
+   *
+   * Note: does not do any normalisation beforehand (e.g. converting
+   * `/` to `\` etc).
+   *
+   * @param uncSharePath Path to prefix.
+   * @return Unnormalised device share path.
+   */
+  static Str prefixUncSharePath(std::string_view uncSharePath);
 };
 
 }  // namespace utils::path::windows::pathTypes


Test patch:
diff --git a/src/openassetio-python/tests/package/utils/resources/file_url_path_tests.json b/src/openassetio-python/tests/package/utils/resources/file_url_path_tests.json
index a34930ed5..caa555635 100644
--- a/src/openassetio-python/tests/package/utils/resources/file_url_path_tests.json
+++ b/src/openassetio-python/tests/package/utils/resources/file_url_path_tests.json
@@ -4183,6 +4183,37 @@
 
 
 
+        { "__section__": "------------------------------ Windows MAX_PATH limit (https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation) ------------------------------" },
+
+
+
+        {
+            "comment": "URL just below MAX_PATH limit [with drive, no hostname]",
+            "URL": "file:///C:/w/01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123",
+            "file_path_posix": "/C:/w/01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123",
+            "file_path_windows": "C:\\w\\01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123"
+        },
+        {
+            "comment": "URL just above MAX_PATH limit [with drive, no hostname]",
+            "URL": "file:///C:/w/012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234",
+            "file_path_posix": "/C:/w/012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234",
+            "file_path_windows": "\\\\?\\C:\\w\\012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234"
+        },
+        {
+            "comment": "URL just below MAX_PATH limit [no drive, with hostname]",
+            "URL": "file://host/share/012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345",
+            "file_path_posix": { "failure-reason": "unsupported-non-local-file" },
+            "file_path_windows": "\\\\host\\share\\012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345"
+        },
+        {
+            "comment": "URL just above MAX_PATH limit [no drive, with hostname]",
+            "URL": "file://host/share/0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456",
+            "file_path_posix": { "failure-reason": "unsupported-non-local-file" },
+            "file_path_windows": "\\\\?\\UNC\\host\\share\\0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456"
+        },
+
+
+
         { "__section__": "------------------------------ The End ------------------------------" }
     ]
 }