Skip to content
Open
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
1 change: 1 addition & 0 deletions src/rez/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions src/rez/rezconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
212 changes: 212 additions & 0 deletions src/rez/tests/test_package_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}"
)
Loading
Loading