]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-118289: Fix handling of non-directories in `posixpath.realpath()` (#120127)
authorBarney Gale <barney.gale@gmail.com>
Wed, 13 Nov 2024 22:59:32 +0000 (22:59 +0000)
committerGitHub <noreply@github.com>
Wed, 13 Nov 2024 22:59:32 +0000 (22:59 +0000)
In strict mode, raise `NotADirectoryError` if we encounter a non-directory
while we still have path parts left to process.

We use a `part_count` variable rather than `len(rest)` because the `rest`
stack also contains markers for unresolved symlinks.

Lib/posixpath.py
Lib/test/test_posixpath.py
Misc/NEWS.d/next/Library/2024-06-05-19-09-36.gh-issue-118289.moL9_d.rst [new file with mode: 0644]

index fccca4e066b76f1717cf6b9a25c44907f87448ab..db72ded88260565a7553b7a33fa7ba5b557f3886 100644 (file)
@@ -412,6 +412,10 @@ def _realpath(filename, strict=False, sep=sep, curdir=curdir, pardir=pardir,
     # very fast way of spelling list(reversed(...)).
     rest = filename.split(sep)[::-1]
 
+    # Number of unprocessed parts in 'rest'. This can differ from len(rest)
+    # later, because 'rest' might contain markers for unresolved symlinks.
+    part_count = len(rest)
+
     # The resolved path, which is absolute throughout this function.
     # Note: getcwd() returns a normalized and symlink-free path.
     path = sep if filename.startswith(sep) else getcwd()
@@ -426,12 +430,13 @@ def _realpath(filename, strict=False, sep=sep, curdir=curdir, pardir=pardir,
     # by *maxlinks*, this is used instead of *seen* to detect symlink loops.
     link_count = 0
 
-    while rest:
+    while part_count:
         name = rest.pop()
         if name is None:
             # resolved symlink target
             seen[rest.pop()] = path
             continue
+        part_count -= 1
         if not name or name == curdir:
             # current dir
             continue
@@ -444,8 +449,11 @@ def _realpath(filename, strict=False, sep=sep, curdir=curdir, pardir=pardir,
         else:
             newpath = path + sep + name
         try:
-            st = lstat(newpath)
-            if not stat.S_ISLNK(st.st_mode):
+            st_mode = lstat(newpath).st_mode
+            if not stat.S_ISLNK(st_mode):
+                if strict and part_count and not stat.S_ISDIR(st_mode):
+                    raise OSError(errno.ENOTDIR, os.strerror(errno.ENOTDIR),
+                                  newpath)
                 path = newpath
                 continue
             elif maxlinks is not None:
@@ -487,7 +495,9 @@ def _realpath(filename, strict=False, sep=sep, curdir=curdir, pardir=pardir,
             rest.append(newpath)
             rest.append(None)
         # Push the unresolved symlink target parts onto the stack.
-        rest.extend(target.split(sep)[::-1])
+        target_parts = target.split(sep)[::-1]
+        rest.extend(target_parts)
+        part_count += len(target_parts)
 
     return path
 
index ca5cf42f8fcd7105e502e1746906a354be289f71..b39255ebc79ac196b1dc543ef7c750e446b51921 100644 (file)
@@ -695,6 +695,65 @@ class PosixPathTest(unittest.TestCase):
             os.chmod(ABSTFN, 0o755, follow_symlinks=False)
             os.unlink(ABSTFN)
 
+    @skip_if_ABSTFN_contains_backslash
+    def test_realpath_nonterminal_file(self):
+        try:
+            with open(ABSTFN, 'w') as f:
+                f.write('test_posixpath wuz ere')
+            self.assertEqual(realpath(ABSTFN, strict=False), ABSTFN)
+            self.assertEqual(realpath(ABSTFN, strict=True), ABSTFN)
+            self.assertEqual(realpath(ABSTFN + "/", strict=False), ABSTFN)
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/", strict=True)
+            self.assertEqual(realpath(ABSTFN + "/.", strict=False), ABSTFN)
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/.", strict=True)
+            self.assertEqual(realpath(ABSTFN + "/..", strict=False), dirname(ABSTFN))
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/..", strict=True)
+            self.assertEqual(realpath(ABSTFN + "/subdir", strict=False), ABSTFN + "/subdir")
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/subdir", strict=True)
+        finally:
+            os_helper.unlink(ABSTFN)
+
+    @os_helper.skip_unless_symlink
+    @skip_if_ABSTFN_contains_backslash
+    def test_realpath_nonterminal_symlink_to_file(self):
+        try:
+            with open(ABSTFN + "1", 'w') as f:
+                f.write('test_posixpath wuz ere')
+            os.symlink(ABSTFN + "1", ABSTFN)
+            self.assertEqual(realpath(ABSTFN, strict=False), ABSTFN + "1")
+            self.assertEqual(realpath(ABSTFN, strict=True), ABSTFN + "1")
+            self.assertEqual(realpath(ABSTFN + "/", strict=False), ABSTFN + "1")
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/", strict=True)
+            self.assertEqual(realpath(ABSTFN + "/.", strict=False), ABSTFN + "1")
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/.", strict=True)
+            self.assertEqual(realpath(ABSTFN + "/..", strict=False), dirname(ABSTFN))
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/..", strict=True)
+            self.assertEqual(realpath(ABSTFN + "/subdir", strict=False), ABSTFN + "1/subdir")
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/subdir", strict=True)
+        finally:
+            os_helper.unlink(ABSTFN)
+
+    @os_helper.skip_unless_symlink
+    @skip_if_ABSTFN_contains_backslash
+    def test_realpath_nonterminal_symlink_to_symlinks_to_file(self):
+        try:
+            with open(ABSTFN + "2", 'w') as f:
+                f.write('test_posixpath wuz ere')
+            os.symlink(ABSTFN + "2", ABSTFN + "1")
+            os.symlink(ABSTFN + "1", ABSTFN)
+            self.assertEqual(realpath(ABSTFN, strict=False), ABSTFN + "2")
+            self.assertEqual(realpath(ABSTFN, strict=True), ABSTFN + "2")
+            self.assertEqual(realpath(ABSTFN + "/", strict=False), ABSTFN + "2")
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/", strict=True)
+            self.assertEqual(realpath(ABSTFN + "/.", strict=False), ABSTFN + "2")
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/.", strict=True)
+            self.assertEqual(realpath(ABSTFN + "/..", strict=False), dirname(ABSTFN))
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/..", strict=True)
+            self.assertEqual(realpath(ABSTFN + "/subdir", strict=False), ABSTFN + "2/subdir")
+            self.assertRaises(NotADirectoryError, realpath, ABSTFN + "/subdir", strict=True)
+        finally:
+            os_helper.unlink(ABSTFN)
+
     def test_relpath(self):
         (real_getcwd, os.getcwd) = (os.getcwd, lambda: r"/home/user/bar")
         try:
diff --git a/Misc/NEWS.d/next/Library/2024-06-05-19-09-36.gh-issue-118289.moL9_d.rst b/Misc/NEWS.d/next/Library/2024-06-05-19-09-36.gh-issue-118289.moL9_d.rst
new file mode 100644 (file)
index 0000000..522572e
--- /dev/null
@@ -0,0 +1,2 @@
+:func:`!posixpath.realpath` now raises :exc:`NotADirectoryError` when *strict*
+mode is enabled and a non-directory path with a trailing slash is supplied.