8000 [3.9] bpo-43757: Make pathlib use os.path.realpath() to resolve symlinks in a path (GH-25264) by ambv · Pull Request #135035 · python/cpython · GitHub
[go: up one dir, main page]

Skip to content

[3.9] bpo-43757: Make pathlib use os.path.realpath() to resolve symlinks in a path (GH-25264) #135035

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 2, 2025
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
18 changes: 15 additions & 3 deletions Doc/library/os.path.rst
Original file line number Diff line number Diff line change
Expand Up @@ -345,22 +345,34 @@ the :mod:`glob` module.)
Accepts a :term:`path-like object`.


.. function:: realpath(path)
.. function:: realpath(path, *, strict=False)

Return the canonical path of the specified filename, eliminating any symbolic
links encountered in the path (if they are supported by the operating
system).

If a path doesn't exist or a symlink loop is encountered, and *strict* is
``True``, :exc:`OSError` is raised. If *strict* is ``False``, the path is
resolved as far as possible and any remainder is appended without checking
whether it exists.

.. note::
When symbolic link cycles occur, the returned path will be one member of
the cycle, but no guarantee is made about which member that will be.
This function emulates the operating system's procedure for making a path
canonical, which differs slightly between Windows and UNIX with respect
to how links and subsequent path components interact.

Operating system APIs make paths canonical as needed, so it's not
normally necessary to call this function.

.. versionchanged:: 3.6
Accepts a :term:`path-like object`.

.. versionchanged:: 3.8
Symbolic links and junctions are now resolved on Windows.

.. versionchanged:: 3.9.23
The *strict* parameter was added.


.. function:: relpath(path, start=os.curdir)

Expand Down
4 changes: 3 additions & 1 deletion Lib/ntpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ def _getfinalpathname_nonstrict(path):
tail = join(name, tail) if tail else name
return tail

def realpath(path):
def realpath(path, *, strict=False):
path = normpath(path)
if isinstance(path, bytes):
prefix = b'\\\\?\\'
Expand All @@ -647,6 +647,8 @@ def realpath(path):
path = _getfinalpathname(path)
initial_winerror = 0
except OSError as ex:
if strict:
raise
initial_winerror = ex.winerror
path = _getfinalpathname_nonstrict(path)
# The path returned by _getfinalpathname will always start with \\?\ -
Expand Down
148 changes: 38 additions & 110 deletions Lib/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,6 @@


supports_symlinks = True
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is left for backward compatibility in 3.9, but it's always True because the only way for it to get False is a pre-Vista version of Windows. Python 3.9 only supports Windows 8.1+.

if os.name == 'nt':
import nt
if sys.getwindowsversion()[:2] >= (6, 0):
from nt import _getfinalpathname
else:
supports_symlinks = False
_getfinalpathname = None
else:
nt = None


__all__ = [
Expand All @@ -34,14 +25,17 @@
# Internals
#

_WINERROR_NOT_READY = 21 # drive exists but is not accessible
_WINERROR_INVALID_NAME = 123 # fix for bpo-35306
_WINERROR_CANT_RESOLVE_FILENAME = 1921 # broken symlink pointing to itself

# EBADF - guard against macOS `stat` throwing EBADF
_IGNORED_ERROS = (ENOENT, ENOTDIR, EBADF, ELOOP)

_IGNORED_WINERRORS = (
21, # ERROR_NOT_READY - drive exists but is not accessible
123, # ERROR_INVALID_NAME - fix for bpo-35306
1921, # ERROR_CANT_RESOLVE_FILENAME - fix for broken symlink pointing to itself
)
_WINERROR_NOT_READY,
_WINERROR_INVALID_NAME,
_WINERROR_CANT_RESOLVE_FILENAME)

def _ignore_error(exception):
return (getattr(exception, 'errno', None) in _IGNORED_ERROS or
Expand Down Expand Up @@ -200,30 +194,6 @@ def casefold_parts(self, parts):
def compile_pattern(self, pattern):
return re.compile(fnmatch.translate(pattern), re.IGNORECASE).fullmatch

def resolve(self, path, strict=False):
s = str(path)
if not s:
return os.getcwd()
previous_s = None
if _getfinalpathname is not None:
if strict:
return self._ext_to_normal(_getfinalpathname(s))
else:
tail_parts = [] # End of the path after the first one not found
while True:
try:
s = self._ext_to_normal(_getfinalpathname(s))
except FileNotFoundError:
previous_s = s
s, tail = os.path.split(s)
tail_parts.append(tail)
if previous_s == s:
return path
else:
return os.path.join(s, *reversed(tail_parts))
# Means fallback on absolute
return None

def _split_extended_path(self, s, ext_prefix=ext_namespace_prefix):
prefix = ''
if s.startswith(ext_prefix):
Expand All @@ -234,10 +204,6 @@ def _split_extended_path(self, s, ext_prefix=ext_namespace_prefix):
s = '\\' + s[3:]
return prefix, s

def _ext_to_normal(self, s):
# Turn back an extended path into a normal DOS-like path
return self._split_extended_path(s)[1]

def is_reserved(self, parts):
# NOTE: the rules for reserved names seem somewhat complicated
# (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not
Expand Down Expand Up @@ -324,54 +290,6 @@ def casefold_parts(self, parts):
def compile_pattern(self, pattern):
return re.compile(fnmatch.translate(pattern)).fullmatch

def resolve(self, path, strict=False):
sep = self.sep
accessor = path._accessor
seen = {}
def _resolve(path, rest):
if rest.startswith(sep):
path = ''

for name in rest.split(sep):
if not name or name == '.':
# current dir
continue
if name == '..':
# parent dir
path, _, _ = path.rpartition(sep)
continue
if path.endswith(sep):
newpath = path + name
else:
newpath = path + sep + name
if newpath in seen:
# Already seen this path
path = seen[newpath]
if path is not None:
# use cached value
continue
# The symlink is not resolved, so we must have a symlink loop.
raise RuntimeError("Symlink loop from %r" % newpath)
# Resolve the symbolic link
try:
target = accessor.readlink(newpath)
except OSError as e:
if e.errno != EINVAL and strict:
raise
# Not a symlink, or non-strict mode. We just leave the path
# untouched.
path = newpath
else:
seen[newpath] = None # not resolved symlink
path = _resolve(path, target)
seen[newpath] = path # resolved symlink

return path
# NOTE: according to POSIX, getcwd() cannot contain path components
# which are symlinks.
base = '' if path.is_absolute() else os.getcwd()
return _resolve(base, str(path)) or sep

def is_reserved(self, parts):
return False

Expand Down Expand Up @@ -443,17 +361,11 @@ def link_to(self, target):

replace = os.replace

if nt:
if supports_symlinks:
symlink = os.symlink
else:
def symlink(a, b, target_is_directory):
raise NotImplementedError("symlink() not available on this system")
if hasattr(os, "symlink"):
symlink = os.symlink
else:
# Under POSIX, os.symlink() takes two args
@staticmethod
def symlink(a, b, target_is_directory):
return os.symlink(a, b)
def symlink(self, src, dst, target_is_directory=False):
raise NotImplementedError("os.symlink() not available on this system")

utime = os.utime

Expand All @@ -475,6 +387,12 @@ def group(self, path):
except ImportError:
raise NotImplementedError("Path.group() is unsupported on this system")

getcwd = os.getcwd

expanduser = staticmethod(os.path.expanduser)

realpath = staticmethod(os.path.realpath)


_normal_accessor = _NormalAccessor()

Expand Down Expand Up @@ -1212,17 +1130,27 @@ def resolve(self, strict=False):
normalizing it (for example turning slashes into backslashes under
Windows).
"""
s = self._flavour.resolve(self, strict=strict)
if s is None:
# No symlink resolution => for consistency, raise an error if
# the path doesn't exist or is forbidden
self.stat()
s = str(self.absolute())
# Now we have no symlinks in the path, it's safe to normalize it.
normed = self._flavour.pathmod.normpath(s)
obj = self._from_parts((normed,), init=False)
obj._init(template=self)
return obj

def check_eloop(e):
winerror = getattr(e, 'winerror', 0)
if e.errno == ELOOP or winerror == _WINERROR_CANT_RESOLVE_FILENAME:
raise RuntimeError("Symlink loop from %r" % e.filename)

try:
s = self._accessor.realpath(self, strict=strict)
except OSError as e:
check_eloop(e)
raise
p = self._from_parts((s,))

# In non-strict mode, realpath() doesn't raise on symlink loops.
# Ensure we get an exception by calling stat()
if not strict:
try:
p.stat()
except OSError as e:
check_eloop(e)
return p

def stat(self):
"""
Expand Down
26 changes: 19 additions & 7 deletions Lib/posixpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,16 +385,16 @@ def abspath(path):
# Return a canonical path (i.e. the absolute location of a file on the
# filesystem).

def realpath(filename):
def realpath(filename, *, strict=False):
"""Return the canonical path of the specified filename, eliminating any
symbolic links encountered in the path."""
filename = os.fspath(filename)
path, ok = _joinrealpath(filename[:0], filename, {})
path, ok = _joinrealpath(filename[:0], filename, strict, {})
return abspath(path)

# Join two paths, normalizing and eliminating any symbolic links
# encountered in the second path.
def _joinrealpath(path, rest, seen):
def _joinrealpath(path, rest, strict, seen):
if isinstance(path, bytes):
sep = b'/'
curdir = b'.'
Expand Down Expand Up @@ -423,7 +423,15 @@ def _joinrealpath(path, rest, seen):
path = pardir
continue
newpath = join(path, name)
if not islink(newpath):
try:
st = os.lstat(newpath)
except OSError:
if strict:
raise
is_link = False
else:
is_link = stat.S_ISLNK(st.st_mode)
if not is_link:
path = newpath
continue
# Resolve the symbolic link
Expand All @@ -434,10 +442,14 @@ def _joinrealpath(path, rest, seen):
# use cached value
continue< 78CE /td>
# The symlink is not resolved, so we must have a symlink loop.
# Return already resolved part + rest of the path unchanged.
return join(newpath, rest), False
if strict:
# Raise OSError(errno.ELOOP)
os.stat(newpath)
else:
# Return already resolved part + rest of the path unchanged.
return join(newpath, rest), False
seen[newpath] = None # not resolved symlink
path, ok = _joinrealpath(path, os.readlink(newpath), seen)
path, ok = _joinrealpath(path, os.readlink(newpath), strict, seen)
if not ok:
return join(path, rest), False
seen[newpath] = path # resolved symlink
Expand Down
Loading
Loading
0