From 58628eda64e8714af1144f62569596b05c9cde7a Mon Sep 17 00:00:00 2001 From: Stephen Mackenzie Date: Thu, 14 May 2026 13:08:51 -0400 Subject: [PATCH 1/6] chore: introduce tests which should correctly-fail if a drive-letter is remapped by rez to a UNC path Signed-off-by: Stephen Mackenzie --- src/rez/tests/test_package_repository.py | 212 +++++++++++++++++++++++ src/rez/tests/test_utils_filesystem.py | 111 +++++++++++- 2 files changed, 322 insertions(+), 1 deletion(-) diff --git a/src/rez/tests/test_package_repository.py b/src/rez/tests/test_package_repository.py index 92d302e29..336cf9fa0 100644 --- a/src/rez/tests/test_package_repository.py +++ b/src/rez/tests/test_package_repository.py @@ -6,11 +6,15 @@ Test package repository plugin. """ import unittest +import unittest.mock +from contextlib import contextmanager from rezplugins.package_repository import filesystem +from rez.exceptions import ResourceError from rez.packages import create_package from rez.tests.util import TestBase, TempdirMixin from rez.utils.platform_ import platform_ +from rez.utils.resources import ResourceHandle class TestFilesystemPackageRepository(TestBase, TempdirMixin): @@ -39,3 +43,211 @@ def test_mismatching_case(self): pkg_repository._create_variant(variant, overrides={}) with self.assertRaises(filesystem.PackageRepositoryError): pkg_repository._create_variant(case_mismatch_variant, overrides={}) + + +# --------------------------------------------------------------------------- +# Helpers shared by the Windows path-form tests below. +# --------------------------------------------------------------------------- + +_MOCK_DRIVE_TO_UNC = {"n": "\\\\nas\\studio"} + + +def _unc_expanding_realpath(path: str) -> str: + """Simulate py3.8+ Windows os.path.realpath: N:\\ expansion to \\\\nas\\studio\\.""" + norm = path.replace("/", "\\") + if len(norm) >= 2 and norm[1] == ":": + drive = norm[0].lower() + rest = norm[2:] + if drive in _MOCK_DRIVE_TO_UNC: + return _MOCK_DRIVE_TO_UNC[drive] + rest + return path + + +@contextmanager +def _simulate_py38_unc_expansion(): + """Patch os.path.realpath and the filesystem plugin's platform_ reference + to reproduce the py3.8+ Windows drive-letter → UNC expansion bug.""" + mock_plat = unittest.mock.Mock(spec=["has_case_sensitive_filesystem", "name"]) + mock_plat.has_case_sensitive_filesystem = False + mock_plat.name = "windows" + with unittest.mock.patch("os.path.realpath", side_effect=_unc_expanding_realpath): + with unittest.mock.patch.object(filesystem, "platform_", mock_plat): + yield + + +@unittest.skipIf( + platform_.name != "windows", + "Windows drive-letter / UNC path-consistency tests are Windows-only.", +) +class TestFilesystemRepoWindowsPathForms(TestBase, TempdirMixin): + """Verify that drive-letter and UNC path styles are preserved throughout + the repository lifecycle. + + Root cause of bugs #1438 / #2045: With os.path.realpath from py3.8 onward, + Windows silently converts mapped drive letters to a UNC equivalent. + canonical_path calls realpath, so FileSystemPackageRepository.__init__ + stores a UNC self.location even when the caller supplied a drive-letter + path. Subsequent make_resource_handle / get_resource_from_handle calls + that carry the original drive-letter path cause a ResourceError, due to + the apparent location mismatch. + """ + + @classmethod + def setUpClass(cls): + TempdirMixin.setUpClass() + cls.settings = {} + + @classmethod + def tearDownClass(cls): + TempdirMixin.tearDownClass() + + # ------------------------------------------------------------------ + # FileSystemPackageRepository.__init__ — self.location form + # ------------------------------------------------------------------ + + def test_repo_init_preserves_drive_letter_location(self): + """repo.location must maintain drive-letter input path to drive-letter output path. + + With the original realpath call, __init__ calls canonical_path, + converting N:\\ to \\\\nas\\studio\\. After the fix, canonical_path + on Windows must use abspath or configurably resolve symlinks, so + self.location stays as the caller supplied it. + """ + pool = filesystem.ResourcePool(cache_size=None) + with _simulate_py38_unc_expansion(): + repo = filesystem.FileSystemPackageRepository("N:\\packages", pool) + + self.assertFalse( + repo.location.startswith("\\\\"), + f"repo.location was unexpectedly expanded to UNC: {repo.location!r}", + ) + self.assertTrue( + repo.location.lower().startswith("n:\\"), + f"Expected drive-letter location starting with 'n:\\', got: {repo.location!r}", + ) + + def test_repo_init_preserves_unc_location(self): + """repo.location must maintain UNC input path to UNC output path.""" + pool = filesystem.ResourcePool(cache_size=None) + unc = "\\\\nas\\studio\\packages" + with _simulate_py38_unc_expansion(): + repo = filesystem.FileSystemPackageRepository(unc, pool) + + self.assertTrue( + repo.location.startswith("\\\\"), + f"UNC repo.location lost its UNC form: {repo.location!r}", + ) + + # ------------------------------------------------------------------ + # make_resource_handle — location comparison (base-class code path) + # ------------------------------------------------------------------ + + def test_make_resource_handle_drive_letter_no_mismatch(self): + """make_resource_handle must not raise when both the repo and the caller + use consistent drive-letter paths. + + __init__ used to UNC-expand self.location via realpath, so the base-class + make_resource_handle compared the caller's 'N:\\packages' against the + stored '\\\\nas\\studio\\packages' and raised ResourceError. + """ + pool = filesystem.ResourcePool(cache_size=None) + with _simulate_py38_unc_expansion(): + repo = filesystem.FileSystemPackageRepository("N:\\packages", pool) + try: + repo.make_resource_handle( + "filesystem.family", + location="N:\\packages", + name="mypkg", + ) + except ResourceError as exc: + self.fail( + f"make_resource_handle raised ResourceError for matching " + f"drive-letter paths: {exc}" + ) + + def test_make_resource_handle_unc_no_mismatch(self): + """make_resource_handle must not raise when both repo and caller use + consistent UNC paths.""" + pool = filesystem.ResourcePool(cache_size=None) + unc = "\\\\nas\\studio\\packages" + with _simulate_py38_unc_expansion(): + repo = filesystem.FileSystemPackageRepository(unc, pool) + try: + repo.make_resource_handle( + "filesystem.family", + location=unc, + name="mypkg", + ) + except ResourceError as exc: + self.fail( + f"make_resource_handle raised ResourceError for matching " + f"UNC paths: {exc}" + ) + + # ------------------------------------------------------------------ + # get_resource_from_handle — filesystem-plugin code path + # ------------------------------------------------------------------ + + def test_get_resource_from_handle_drive_letter_no_mismatch(self): + """get_resource_from_handle must not raise ResourceError when both + the handle and the repo use drive-letter pathing. + + The filesystem plugin overrides get_resource_from_handle and applies + canonical_path as a bridge for maintaining path-style consistency — + but that bridge cannot work if canonical_path itself expands the + drive-letter to UNC (making both sides UNC when the repo was created + with a drive-letter path, or vice-versa). + """ + pool = filesystem.ResourcePool(cache_size=None) + drive_letter_path = "N:\\packages" + + with _simulate_py38_unc_expansion(): + repo = filesystem.FileSystemPackageRepository(drive_letter_path, pool) + handle = ResourceHandle( + "filesystem.family", + { + "repository_type": "filesystem", + "location": drive_letter_path, + "name": "mypkg", + }, + ) + # Mock the pool's own get_resource_from_handle so we do not need + # actual packages on disk — we only want to exercise the location + # verification logic, not the resource loading. + with unittest.mock.patch.object( + repo.pool, "get_resource_from_handle", return_value=unittest.mock.Mock() + ): + try: + repo.get_resource_from_handle(handle, verify_repo=True) + except ResourceError as exc: + self.fail( + f"get_resource_from_handle raised ResourceError for a " + f"drive-letter handle against a drive-letter repo: {exc}" + ) + + def test_get_resource_from_handle_unc_no_mismatch(self): + """get_resource_from_handle must not raise ResourceError when both + handle and repo both use UNC paths.""" + pool = filesystem.ResourcePool(cache_size=None) + unc = "\\\\nas\\studio\\packages" + + with _simulate_py38_unc_expansion(): + repo = filesystem.FileSystemPackageRepository(unc, pool) + handle = ResourceHandle( + "filesystem.family", + { + "repository_type": "filesystem", + "location": unc, + "name": "mypkg", + }, + ) + with unittest.mock.patch.object( + repo.pool, "get_resource_from_handle", return_value=unittest.mock.Mock() + ): + try: + repo.get_resource_from_handle(handle, verify_repo=True) + except ResourceError as exc: + self.fail( + f"get_resource_from_handle raised ResourceError for a " + f"UNC handle against a UNC repo: {exc}" + ) diff --git a/src/rez/tests/test_utils_filesystem.py b/src/rez/tests/test_utils_filesystem.py index 99cbe6087..c2bbf1818 100644 --- a/src/rez/tests/test_utils_filesystem.py +++ b/src/rez/tests/test_utils_filesystem.py @@ -7,12 +7,14 @@ """ import os.path import tempfile +import unittest +import unittest.mock from rez.tests.util import TestBase from rez.tests.util import TempdirMixin from rez.utils import filesystem +from rez.utils.filesystem import canonical_path from rez.utils.platform_ import platform_ -import unittest class TestFileSystem(TestBase, TempdirMixin): @@ -86,3 +88,110 @@ def test_rename_file(self) -> None: filesystem.rename(src, dst) self.assertTrue(os.path.exists(dst)) self.assertFalse(os.path.exists(src)) + + +# Simulate py3.8+ Windows os.path.realpath: N:\ expands to \\nas\studio\ +_MOCK_DRIVE_TO_UNC = {"n": "\\\\nas\\studio"} + + +def _unc_expanding_realpath(path): + """Replicate the py3.8+ Windows realpath behaviour that converts a mapped + drive letter to the underlying UNC server path.""" + norm = path.replace("/", "\\") + if len(norm) >= 2 and norm[1] == ":": + drive = norm[0].lower() + rest = norm[2:] # e.g. "\\packages\\mypkg" + if drive in _MOCK_DRIVE_TO_UNC: + return _MOCK_DRIVE_TO_UNC[drive] + rest + return path + + +@unittest.skipIf( + platform_.name != "windows", + "Windows drive-letter / UNC path form tests are Windows-only.", +) +class TestCanonicalPathWindowsFormPreservation(TestBase): + """canonical_path must never change the *form* of a Windows path. + + A drive-letter path (N:\\...) must stay drive-letter. + A UNC path (\\\\server\\...) must stay UNC. + + Before the fix these tests fail because canonical_path calls + os.path.realpath, which on Python 3.8+ Windows silently converts + drive-letter paths to their underlying UNC equivalents. + """ + + def _mock_windows_platform(self): + """Return a mock Platform object that behaves like Windows.""" + m = unittest.mock.Mock(spec=["has_case_sensitive_filesystem", "name"]) + m.has_case_sensitive_filesystem = False + m.name = "windows" + return m + + # ------------------------------------------------------------------ + # Tests that must FAIL before the fix and PASS after. + # ------------------------------------------------------------------ + + def test_drive_letter_form_preserved(self): + """canonical_path on a drive-letter path must not return a UNC path. + + Fails today because os.path.realpath (py3.8+ Windows) expands + N:\\ → \\\\nas\\studio\\, so canonical_path returns a UNC string. + """ + mock_plat = self._mock_windows_platform() + with unittest.mock.patch("os.path.realpath", side_effect=_unc_expanding_realpath): + result = canonical_path("N:\\packages\\mypkg", platform=mock_plat) + + self.assertFalse( + result.startswith("\\\\"), + f"canonical_path UNC-expanded a drive-letter path: {result!r}", + ) + self.assertTrue( + result.lower().startswith("n:\\"), + f"Expected drive-letter result starting with 'n:\\', got: {result!r}", + ) + + def test_drive_letter_form_preserved_forward_slashes(self): + """Same as above but input uses forward slashes (N:/packages).""" + mock_plat = self._mock_windows_platform() + with unittest.mock.patch("os.path.realpath", side_effect=_unc_expanding_realpath): + result = canonical_path("N:/packages/mypkg", platform=mock_plat) + + self.assertFalse( + result.startswith("\\\\"), + f"canonical_path UNC-expanded a drive-letter path: {result!r}", + ) + self.assertTrue( + result.lower().startswith("n:\\") or result.lower().startswith("n:/"), + f"Expected drive-letter result, got: {result!r}", + ) + + # ------------------------------------------------------------------ + # Tests that already PASS today and must continue to PASS after the fix. + # ------------------------------------------------------------------ + + def test_unc_form_preserved(self): + """canonical_path on a UNC path must return a UNC path.""" + mock_plat = self._mock_windows_platform() + unc = "\\\\nas\\studio\\packages\\mypkg" + # realpath on an already-UNC path returns the same UNC path unchanged. + with unittest.mock.patch("os.path.realpath", return_value=unc): + result = canonical_path(unc, platform=mock_plat) + + self.assertTrue( + result.startswith("\\\\"), + f"canonical_path changed UNC form unexpectedly: {result!r}", + ) + + def test_drive_letter_case_folded(self): + """canonical_path lowercases drive-letter paths on Windows (case-insensitive FS).""" + mock_plat = self._mock_windows_platform() + # Use a local temp path that abspath can handle so we are not + # depending on UNC expansion behaviour here. + with unittest.mock.patch("os.path.realpath", side_effect=lambda p: p): + result = canonical_path("C:\\Packages\\MyPkg", platform=mock_plat) + + self.assertEqual( + result, result.lower(), + f"canonical_path did not lowercase on case-insensitive platform: {result!r}", + ) From 76cec19a17a310d576cc4e4936b1da6250500de9 Mon Sep 17 00:00:00 2001 From: Stephen Mackenzie Date: Thu, 14 May 2026 14:47:52 -0400 Subject: [PATCH 2/6] fix: canonical_path uses abspath on windows, with updates to package_repository filesystem to ensure its consumption Signed-off-by: Stephen Mackenzie --- src/rez/utils/filesystem.py | 11 ++++++++++- src/rezplugins/package_repository/filesystem.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/rez/utils/filesystem.py b/src/rez/utils/filesystem.py index 6e614142a..d72e8ffaf 100644 --- a/src/rez/utils/filesystem.py +++ b/src/rez/utils/filesystem.py @@ -12,6 +12,7 @@ from contextlib import contextmanager from uuid import uuid4 import errno +import sys import weakref import atexit import posixpath @@ -506,7 +507,15 @@ def canonical_path(path: str, platform=None): if platform is None: platform = platform_ - path = os.path.normpath(os.path.realpath(path)) + # On Windows, os.path.realpath from py3.8 onwards silently converts drive + # lettered paths to their UNC equivalents (N:\ → \\server\share\). By default, + # use abspath instead to preserve the caller's form. + # We check sys.platform rather than the platform argument because this is + # an OS-level behaviour of os.path.realpath, not a user-configurable choice. + if sys.platform == "win32": + path = os.path.normpath(os.path.abspath(path)) + else: + path = os.path.normpath(os.path.realpath(path)) if not platform.has_case_sensitive_filesystem: return path.lower() diff --git a/src/rezplugins/package_repository/filesystem.py b/src/rezplugins/package_repository/filesystem.py index 5d8e8276e..f4d098529 100644 --- a/src/rezplugins/package_repository/filesystem.py +++ b/src/rezplugins/package_repository/filesystem.py @@ -810,6 +810,20 @@ def _info(msg, *nargs) -> None: return num_removed + def make_resource_handle(self, resource_key, **variables): + # Normalize the caller-supplied location so the base-class raw string + # comparison succeeds, even when the caller uses a different case, or + # an equivalent path form (e.g. mixed-case drive letter on Windows). + # + # https://github.com/AcademySoftwareFoundation/rez/issues/2045 + # + location = variables.get("location") + if location is not None and location != self.location: + norm = canonical_path(location, platform_) + if norm == self.location: + variables["location"] = self.location + return super().make_resource_handle(resource_key, **variables) + def get_resource_from_handle(self, resource_handle, verify_repo: bool = True): if verify_repo: repository_type = resource_handle.variables.get("repository_type") From adc5b7ad6e28157c000ffda97f72f7cda63b2afe Mon Sep 17 00:00:00 2001 From: Stephen Mackenzie Date: Thu, 14 May 2026 14:51:05 -0400 Subject: [PATCH 3/6] chore: introduce tests intended to validate the behavior of a windows-compatible symlink/junction-point resolution behavior Signed-off-by: Stephen Mackenzie --- src/rez/tests/test_utils_filesystem.py | 72 ++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/rez/tests/test_utils_filesystem.py b/src/rez/tests/test_utils_filesystem.py index c2bbf1818..74ca98134 100644 --- a/src/rez/tests/test_utils_filesystem.py +++ b/src/rez/tests/test_utils_filesystem.py @@ -5,6 +5,7 @@ """ unit tests for 'rez.utils.filesystem' module """ +import os import os.path import tempfile import unittest @@ -195,3 +196,74 @@ def test_drive_letter_case_folded(self): result, result.lower(), f"canonical_path did not lowercase on case-insensitive platform: {result!r}", ) + + +@unittest.skipIf( + platform_.name != "windows", + "Windows symlink/junction resolution tests are Windows-only.", +) +class TestCanonicalPathWindowsSymlinkResolution(TestBase, TempdirMixin): + """canonical_path resolves symlinks/junctions on Windows when + resolve_links_on_windows=True, without expanding mapped drive letters.""" + + @classmethod + def setUpClass(cls): + TempdirMixin.setUpClass() + cls.settings = {"resolve_links_on_windows": True} + + # Probe for symlink capability. Dir symlinks require Developer + # Mode from Windows 10 Creators Update onward, or an elevated process. + probe_src = os.path.join(cls.root, "_symlink_probe_src") + probe_lnk = os.path.join(cls.root, "_symlink_probe_lnk") + os.makedirs(probe_src) + try: + os.symlink(probe_src, probe_lnk, target_is_directory=True) + os.unlink(probe_lnk) + except OSError: + raise unittest.SkipTest( + "Dir symlink creation not supported on this host " + "(enable Developer Mode or run as Admin)." + ) + finally: + if os.path.isdir(probe_src): + os.rmdir(probe_src) + + @classmethod + def tearDownClass(cls): + TempdirMixin.tearDownClass() + + def _mock_windows_platform(self): + m = unittest.mock.Mock(spec=["has_case_sensitive_filesystem", "name"]) + m.has_case_sensitive_filesystem = False + m.name = "windows" + return m + + def test_symlink_resolved(self): + """canonical_path follows a directory symlink when resolve_links_on_windows=True.""" + target = os.path.join(self.root, "real_target") + link = os.path.join(self.root, "link_to_target") + os.makedirs(target) + os.symlink(target, link, target_is_directory=True) + + result = canonical_path(link, platform=self._mock_windows_platform()) + self.assertEqual(result, target.lower()) + + def test_symlink_chain_resolved(self): + """canonical_path follows a chain of two symlinks.""" + target = os.path.join(self.root, "final_target") + mid = os.path.join(self.root, "mid_link") + link = os.path.join(self.root, "outer_link") + os.makedirs(target) + os.symlink(target, mid, target_is_directory=True) + os.symlink(mid, link, target_is_directory=True) + + result = canonical_path(link, platform=self._mock_windows_platform()) + self.assertEqual(result, target.lower()) + + def test_non_symlink_path_unchanged(self): + """canonical_path on a plain directory is stable with resolve_links_on_windows=True.""" + d = os.path.join(self.root, "plain_dir") + os.makedirs(d) + + result = canonical_path(d, platform=self._mock_windows_platform()) + self.assertEqual(result, d.lower()) From 06a78e537355dc7f2b17a295a61f4f6e2246ec34 Mon Sep 17 00:00:00 2001 From: Stephen Mackenzie Date: Thu, 14 May 2026 15:00:10 -0400 Subject: [PATCH 4/6] feat: introduce a config defaulted-False config-flag and link-resolution-behavior intended to produce realpath-like behavior Signed-off-by: Stephen Mackenzie --- src/rez/config.py | 1 + src/rez/rezconfig.py | 20 +++++++++++++++ src/rez/utils/filesystem.py | 49 ++++++++++++++++++++++++++++++++++--- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/rez/config.py b/src/rez/config.py index b1c5f67d6..4944aba17 100644 --- a/src/rez/config.py +++ b/src/rez/config.py @@ -415,6 +415,7 @@ def _parse_env_var(self, value): "suite_alias_prefix_char": Char, "cache_packages_path": OptionalStr, "package_definition_python_path": OptionalStr, + "resolve_links_on_windows": Bool, "tmpdir": OptionalStr, "context_tmpdir": OptionalStr, "default_shell": OptionalStr, diff --git a/src/rez/rezconfig.py b/src/rez/rezconfig.py index 7fa820646..ed0119e71 100644 --- a/src/rez/rezconfig.py +++ b/src/rez/rezconfig.py @@ -125,6 +125,26 @@ # For further information, see :ref:`package-definition-sharing-code`. package_definition_python_path = None +# On Windows, whether to resolve symbolic links and junction points when +# normalising filesystem paths (primarily inside ``canonical_path``). +# +# When ``False`` (default), rez uses ``os.path.abspath``, which normalises +# separators and ``.``/``..`` components without following symlinks and without +# expanding mapped drive letters to their UNC equivalents. This preserves the +# path style supplied by the caller (drive-letter input, drive-letter output, +# UNC input, UNC output), effectively defaulting to pre-Python-3.8 behaviour of +# ``os.path.realpath`` on Windows. +# +# When ``True``, rez performs a component-by-component walk using +# ``os.path.islink`` / ``os.readlink``. This resolves actual symlinks and +# junction points without the drive-letter-to-UNC side-effect that +# ``os.path.realpath`` introduced in Python 3.8. Useful when package +# repositories are accessed through directory symlinks or junctions. +# +# This setting is a no-op on non-Windows platforms, which always resolve +# symlinks via ``os.path.realpath``. +resolve_links_on_windows = False + ############################################################################### # Extensions diff --git a/src/rez/utils/filesystem.py b/src/rez/utils/filesystem.py index d72e8ffaf..e1a1db040 100644 --- a/src/rez/utils/filesystem.py +++ b/src/rez/utils/filesystem.py @@ -490,6 +490,42 @@ def to_posixpath(path: str): return posixpath.sep.join(path.split(ntpath.sep)) +def _windows_realpath(path: str) -> str: + """Resolve symlinks and junctions on Windows without expanding mapped drives. + + ``os.path.realpath`` on Python 3.8+ Windows uses ``GetFinalPathNameByHandle`` + which expands mapped drive letters (e.g. ``N:\\``) to their underlying UNC + server paths. This function resolves only actual filesystem symlinks and + junction points, walking the path component-by-component so that the drive + root (drive-letter or UNC prefix) is never touched. + """ + path = os.path.normpath(os.path.abspath(path)) + drive, rest = os.path.splitdrive(path) + # Preserve the root separator so UNC paths keep their leading "\\" and + # drive-letter paths keep their "\". + result = drive + (os.sep if rest.startswith(os.sep) else "") + for part in rest.lstrip(os.sep).split(os.sep): + if not part: + continue + candidate = os.path.join(result, part) + depth = 0 + while os.path.islink(candidate) and depth < 40: + target = os.readlink(candidate) + # os.readlink on Windows may return an extended-length path + # (\\?\C:\... or \\?\UNC\server\share\...). Strip the prefix so + # subsequent abspath/normpath calls produce ordinary paths. + if target.startswith("\\\\?\\UNC\\"): + target = "\\\\" + target[8:] + elif target.startswith("\\\\?\\"): + target = target[4:] + if not os.path.isabs(target): + target = os.path.join(os.path.dirname(candidate), target) + candidate = os.path.normpath(os.path.abspath(target)) + depth += 1 + result = candidate + return result + + def canonical_path(path: str, platform=None): r""" Resolves symlinks, and formats filepath. @@ -508,12 +544,19 @@ def canonical_path(path: str, platform=None): platform = platform_ # On Windows, os.path.realpath from py3.8 onwards silently converts drive - # lettered paths to their UNC equivalents (N:\ → \\server\share\). By default, - # use abspath instead to preserve the caller's form. + # lettered paths to their UNC equivalents (N:\ → \\server\share\). # We check sys.platform rather than the platform argument because this is # an OS-level behaviour of os.path.realpath, not a user-configurable choice. if sys.platform == "win32": - path = os.path.normpath(os.path.abspath(path)) + # Lazy import avoids a circular dependency (config imports filesystem). + from rez.config import config # noqa: PLC0415 + if config.resolve_links_on_windows: + path = _windows_realpath(path) + else: + # Default: abspath preserves the caller's path form (drive-letter + # stays drive-letter, UNC stays UNC) and restores the pre-3.8 + # behaviour where realpath on Windows was equivalent to abspath. + path = os.path.normpath(os.path.abspath(path)) else: path = os.path.normpath(os.path.realpath(path)) From 4c390b1dd95320a41a40ec247b9dd2ce0f64b79e Mon Sep 17 00:00:00 2001 From: Stephen Mackenzie Date: Thu, 14 May 2026 15:18:09 -0400 Subject: [PATCH 5/6] chore: introduce tests intended to validate the behavior of longpath-support in optional symlink-resolution for windows Signed-off-by: Stephen Mackenzie --- src/rez/tests/test_utils_filesystem.py | 100 ++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/src/rez/tests/test_utils_filesystem.py b/src/rez/tests/test_utils_filesystem.py index 74ca98134..f921c1140 100644 --- a/src/rez/tests/test_utils_filesystem.py +++ b/src/rez/tests/test_utils_filesystem.py @@ -14,7 +14,7 @@ from rez.tests.util import TestBase from rez.tests.util import TempdirMixin from rez.utils import filesystem -from rez.utils.filesystem import canonical_path +from rez.utils.filesystem import canonical_path, _windows_realpath from rez.utils.platform_ import platform_ @@ -198,6 +198,24 @@ def test_drive_letter_case_folded(self): ) +def _windows_longpaths_enabled() -> bool: + """Return True if the Windows LongPathsEnabled registry key is set. + + When False, the Win32 API cannot open paths longer than MAX_PATH (260 + chars) without an explicit ``\\?\\`` extended-length prefix. + """ + try: + import winreg + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\FileSystem", + ) as key: + value, _ = winreg.QueryValueEx(key, "LongPathsEnabled") + return bool(value) + except OSError: + return False + + @unittest.skipIf( platform_.name != "windows", "Windows symlink/junction resolution tests are Windows-only.", @@ -267,3 +285,83 @@ def test_non_symlink_path_unchanged(self): result = canonical_path(d, platform=self._mock_windows_platform()) self.assertEqual(result, d.lower()) + + def test_relative_symlink_resolved(self): + """canonical_path resolves a symlink whose target is a relative path.""" + target = os.path.join(self.root, "rel_target") + link_parent = os.path.join(self.root, "linkdir") + os.makedirs(target) + os.makedirs(link_parent) + link = os.path.join(link_parent, "rel_link") + os.symlink(os.path.join("..", "rel_target"), link, target_is_directory=True) + + result = canonical_path(link, platform=self._mock_windows_platform()) + self.assertEqual(result, target.lower()) + + def test_long_path_symlink_resolved(self): + """canonical_path resolves a symlink whose target exceeds MAX_PATH (260 + chars) on hosts with LongPathsEnabled set in the registry.""" + if not _windows_longpaths_enabled(): + self.skipTest( + "LongPathsEnabled not set in registry; skipping long-path test." + ) + + # self.root is typically ~60-70 chars; 220 'a' chars puts the full + # target path comfortably past the 260-char Win32 MAX_PATH limit. + target = os.path.join(self.root, "a" * 220) + link = os.path.join(self.root, "longpath_link") + os.makedirs(target) + os.symlink(target, link, target_is_directory=True) + + result = canonical_path(link, platform=self._mock_windows_platform()) + self.assertEqual(result, target.lower()) + + +@unittest.skipIf( + platform_.name != "windows", + "Windows _windows_realpath internal-branch tests are Windows-only.", +) +class TestWindowsRealpathInternals(TestBase): + """Unit tests for _windows_realpath edge-case branches. + + These use mocking so no real symlinks or network paths are required. + """ + + def test_readlink_extended_prefix_stripped(self): + """_windows_realpath strips a \\\\?\\ prefix returned by os.readlink.""" + link_path = "C:\\some\\link" + target_path = "C:\\real\\target" + + def _fake_islink(p): + return p == link_path + + with unittest.mock.patch("os.path.islink", side_effect=_fake_islink): + with unittest.mock.patch("os.readlink", return_value="\\\\?\\" + target_path): + result = _windows_realpath(link_path) + + self.assertEqual(result, os.path.normpath(target_path)) + + def test_readlink_unc_extended_prefix_stripped(self): + """_windows_realpath strips a \\\\?\\UNC\\ prefix returned by os.readlink.""" + link_path = "C:\\some\\link" + target_unc = "\\\\server\\share\\target" + + def _fake_islink(p): + return p == link_path + + with unittest.mock.patch("os.path.islink", side_effect=_fake_islink): + with unittest.mock.patch( + "os.readlink", + return_value="\\\\?\\UNC\\" + target_unc[2:], + ): + result = _windows_realpath(link_path) + + self.assertEqual(result, os.path.normpath(target_unc)) + + def test_symlink_depth_limit_terminates(self): + """_windows_realpath stops after 40 hops and returns without hanging.""" + with unittest.mock.patch("os.path.islink", return_value=True): + with unittest.mock.patch("os.readlink", return_value="C:\\loop"): + result = _windows_realpath("C:\\loop") + + self.assertIsInstance(result, str) From 1681466923c9c674c7f41b6da6fab22671ba3c77 Mon Sep 17 00:00:00 2001 From: Stephen Mackenzie Date: Thu, 14 May 2026 15:18:59 -0400 Subject: [PATCH 6/6] fix: make resolve_links_on_windows config-flagged functionality longpath-aware. Signed-off-by: Stephen Mackenzie --- src/rez/utils/filesystem.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/rez/utils/filesystem.py b/src/rez/utils/filesystem.py index e1a1db040..054e361b5 100644 --- a/src/rez/utils/filesystem.py +++ b/src/rez/utils/filesystem.py @@ -490,6 +490,9 @@ def to_posixpath(path: str): return posixpath.sep.join(path.split(ntpath.sep)) +_WINDOWS_MAX_PATH = 259 # Win32 MAX_PATH minus the null terminator + + def _windows_realpath(path: str) -> str: """Resolve symlinks and junctions on Windows without expanding mapped drives. @@ -513,14 +516,31 @@ def _windows_realpath(path: str) -> str: target = os.readlink(candidate) # os.readlink on Windows may return an extended-length path # (\\?\C:\... or \\?\UNC\server\share\...). Strip the prefix so - # subsequent abspath/normpath calls produce ordinary paths. + # we can work with ordinary path strings. if target.startswith("\\\\?\\UNC\\"): target = "\\\\" + target[8:] elif target.startswith("\\\\?\\"): target = target[4:] if not os.path.isabs(target): target = os.path.join(os.path.dirname(candidate), target) - candidate = os.path.normpath(os.path.abspath(target)) + # For paths that exceed MAX_PATH, re-add the extended-length + # prefix before calling abspath so the Win32 API (GetFullPathNameW) + # can handle the length on hosts without LongPathsEnabled in the + # registry. We strip the prefix again afterwards so the rest of + # the walk operates on ordinary path strings. + if len(target) > _WINDOWS_MAX_PATH: + if target.startswith("\\\\"): + target = "\\\\?\\UNC\\" + target[2:] + else: + target = "\\\\?\\" + target + candidate = os.path.abspath(target) + if candidate.startswith("\\\\?\\UNC\\"): + candidate = "\\\\" + candidate[8:] + elif candidate.startswith("\\\\?\\"): + candidate = candidate[4:] + candidate = os.path.normpath(candidate) + else: + candidate = os.path.normpath(os.path.abspath(target)) depth += 1 result = candidate return result