Repository: pytest-dev/pyfakefs
Pull request #1135 (2025-03-09 07:08:00)

Ignore permissions in FakeFilesystem.get_object()
- restores the behavior before version 5.4.0
- fix permission error on reading file type
- fix os.access() behavior for root user
- fix os.scandir(None) behavior
- see #1122

I had first tried to add more tests to `FakeFilesystemVsRealTest`, but have decided to leave that for later as it got a bit out of hand.
This is a minimal fix for the observed problems, and a reversal in the behavior of `FakeFilesystem.get_object()` so that it can be used as before independent of permissions.


#### Tasks
- [x] Unit tests added that reproduce the issue or prove feature is working
- [x] Fix or feature added
- [x] Entry to release notes added
- [x] Pre-commit CI shows no errors
- [x] Unit tests passing
- [ ] For documentation changes: The Read the Docs preview builds and looks as expected



Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 3ac40e4..88a3bc9 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -16,6 +16,8 @@ The released versions correspond to PyPI releases.
 
 ### Changes
 * added some preliminary support for Python 3.14
+* change behavior of `FakeFilesystem.get_object()` to ignore permissions as it has been
+  before version 5.4.0 (see [#1122](../../issues/1122))
 
 ### Fixes
 * fixed a problem with flushing if writing over the buffer end
@@ -23,6 +25,7 @@ The released versions correspond to PyPI releases.
 * fixed a regression that could break tests under Posix in Python 3.12
   (see [#1126](../../issues/1126))
 * fixed behavior for `os.access` for symlinks under Windows
+* fixes permission problem on querying file properties (see [#1122](../../issues/1122))
 
 ## [Version 5.7.4](https://pypi.python.org/pypi/pyfakefs/5.7.4) (2025-01-14)
 Minor bugfix release.
diff --git a/pyfakefs/fake_filesystem.py b/pyfakefs/fake_filesystem.py
index 53d5265..08f45dd 100644
--- a/pyfakefs/fake_filesystem.py
+++ b/pyfakefs/fake_filesystem.py
@@ -745,7 +745,7 @@ class FakeFilesystem:
             # make sure stat raises if a parent dir is not readable
             parent_dir = file_object.parent_dir
             if parent_dir:
-                self.get_object(parent_dir.path, check_read_perm=False)  # type: ignore[arg-type]
+                self._get_object(parent_dir.path, check_read_perm=False)  # type: ignore[arg-type]
 
         self.raise_for_filepath_ending_with_separator(
             entry_path, file_object, follow_symlinks
@@ -1775,15 +1775,22 @@ class FakeFilesystem:
             return True
         return target.has_permission(permission)
 
-    def get_object(self, file_path: AnyPath, check_read_perm: bool = True) -> FakeFile:
+    def _get_object(
+        self,
+        file_path: AnyPath,
+        check_read_perm: bool = True,
+        check_exe_perm: bool = True,
+    ) -> FakeFile:
         """Search for the specified filesystem object within the fake
-        filesystem.
+        filesystem. By default, consider read and execute permissions.
 
         Args:
             file_path: Specifies the target
                 :py:class:`FakeFile<pyfakefs.fake_file.FakeFile>` object to retrieve.
             check_read_perm: If True, raises OSError if a parent directory
                 does not have read permission
+            check_exe_perm: If True, raises OSError if a parent directory
+                does not have execute (e.g. search) permission
 
         Returns:
             The :py:class:`FakeFile<pyfakefs.fake_file.FakeFile>` object corresponding
@@ -1794,7 +1801,24 @@ class FakeFilesystem:
         """
         path = make_string_path(file_path)
         path = self.absnormpath(self._original_path(path))
-        return self.get_object_from_normpath(path, check_read_perm)
+        return self.get_object_from_normpath(path, check_read_perm, check_exe_perm)
+
+    def get_object(self, file_path: AnyPath) -> FakeFile:
+        """Search for the specified filesystem object within the fake
+        filesystem and return it. Ignore any read permissions.
+
+        Args:
+            file_path: Specifies the target
+                :py:class:`FakeFile<pyfakefs.fake_file.FakeFile>` object to retrieve.
+
+        Returns:
+            The :py:class:`FakeFile<pyfakefs.fake_file.FakeFile>` object corresponding
+            to `file_path`.
+
+        Raises:
+            OSError: if the object is not found.
+        """
+        return self._get_object(file_path, check_read_perm=False, check_exe_perm=False)
 
     def resolve(
         self,
@@ -2051,7 +2075,7 @@ class FakeFilesystem:
         old_object: FakeFile,
         ends_with_sep: bool,
     ) -> Optional[AnyStr]:
-        new_object = self.get_object(new_file_path)
+        new_object = self._get_object(new_file_path)
         if old_file_path == new_file_path:
             if not S_ISLNK(new_object.st_mode) and ends_with_sep:
                 error = errno.EINVAL if self.is_windows_fs else errno.ENOTDIR
@@ -2418,7 +2442,7 @@ class FakeFilesystem:
             )
         else:
             self._create_fake_from_real_dir(source_path_str, target_path_str, read_only)
-        return cast(FakeDirectory, self.get_object(target_path_str))
+        return cast(FakeDirectory, self._get_object(target_path_str))
 
     def _create_fake_from_real_dir(self, source_path_str, target_path_str, read_only):
         if not self.exists(target_path_str):
@@ -2456,11 +2480,11 @@ class FakeFilesystem:
                     self.add_real_symlink(src_entry_path, target_entry_path)
                 elif os.path.isfile(src_entry_path):
                     self.add_real_file(src_entry_path, read_only, target_entry_path)
-            return self.get_object(target_path_str)
+            return self._get_object(target_path_str)
 
         parent_path = os.path.split(target_path_str)[0]
         if self.exists(parent_path):
-            parent_dir = self.get_object(parent_path)
+            parent_dir = self._get_object(parent_path)
         else:
             parent_dir = self.create_dir(parent_path)
         new_dir = FakeDirectoryFromRealDirectory(
@@ -2913,7 +2937,6 @@ class FakeFilesystem:
         path: AnyPath,
         st_flag: int,
         follow_symlinks: bool = True,
-        check_read_perm: bool = True,
     ) -> bool:
         """Helper function to implement isdir(), islink(), etc.
 
@@ -2922,8 +2945,8 @@ class FakeFilesystem:
         Args:
             path: Path to file to stat and test
             st_flag: The stat.S_I* flag checked for the file's st_mode
-            check_read_perm: If True (default) False is returned for
-                existing but unreadable file paths.
+            follow_symlinks: If `False` and path points to a symlink,
+                the link itself is checked instead of the linked object.
 
         Returns:
             (boolean) `True` if the st_flag is set in path's st_mode.
@@ -2935,9 +2958,7 @@ class FakeFilesystem:
             raise TypeError
         file_path = make_string_path(path)
         try:
-            obj = self.resolve(
-                file_path, follow_symlinks, check_read_perm=check_read_perm
-            )
+            obj = self.resolve(file_path, follow_symlinks, check_read_perm=False)
             if obj:
                 self.raise_for_filepath_ending_with_separator(
                     file_path, obj, macos_handling=not follow_symlinks
@@ -2952,6 +2973,8 @@ class FakeFilesystem:
 
         Args:
             path: Path to filesystem object.
+            follow_symlinks: If `False` and path points to a symlink,
+                the link itself is checked instead of the linked object.
 
         Returns:
             `True` if path points to a directory (following symlinks).
@@ -2966,6 +2989,8 @@ class FakeFilesystem:
 
         Args:
             path: Path to filesystem object.
+            follow_symlinks: If `False` and path points to a symlink,
+                the link itself is checked instead of the linked object.
 
         Returns:
             `True` if path points to a regular file (following symlinks).
@@ -2973,7 +2998,7 @@ class FakeFilesystem:
         Raises:
             TypeError: if path is None.
         """
-        return self._is_of_type(path, S_IFREG, follow_symlinks, check_read_perm=False)
+        return self._is_of_type(path, S_IFREG, follow_symlinks)
 
     def islink(self, path: AnyPath) -> bool:
         """Determine if path identifies a symbolic link.
@@ -3121,6 +3146,8 @@ class FakeFilesystem:
         Raises:
             OSError: if the target is not a directory.
         """
+        if target_directory is None:
+            return []
         target_directory = self.resolve_path(target_directory, allow_fd=True)
         directory = self.confirmdir(target_directory, check_exe_perm=False)
         directory_contents = list(directory.entries.keys())
diff --git a/pyfakefs/fake_os.py b/pyfakefs/fake_os.py
index 59a7690..b446026 100644
--- a/pyfakefs/fake_os.py
+++ b/pyfakefs/fake_os.py
@@ -453,8 +453,20 @@ class FakeOsModule:
         except OSError as exc:
             if self.filesystem.is_macos and exc.errno == errno.EBADF:
                 raise OSError(errno.ENOTDIR, "Not a directory: " + str(path))
+            elif (
+                self.filesystem.is_windows_fs
+                and exc.errno == errno.ENOENT
+                and path == ""
+            ):
+                raise OSError(errno.EINVAL, "Invalid argument:  + str(path)")
+            raise
+        try:
+            self.filesystem.confirmdir(path)
+        except OSError as exc:
+            if exc.errno == errno.EACCES:
+                # no access rights to the parent directory - do nothing
+                return
             raise
-        self.filesystem.confirmdir(path)
         directory = self.filesystem.resolve(path)
         # A full implementation would check permissions all the way
         # up the tree.
@@ -1058,7 +1070,9 @@ class FakeOsModule:
                 return False
             raise
         if is_root():
-            mode &= ~os.W_OK
+            if mode & os.X_OK:
+                return stat_result.st_mode & 0o111 != 0
+            return True
         return (mode & ((stat_result.st_mode >> 6) & 7)) == mode
 
     def fchmod(
diff --git a/pyfakefs/fake_scandir.py b/pyfakefs/fake_scandir.py
index 308dff6..ae69601 100644
--- a/pyfakefs/fake_scandir.py
+++ b/pyfakefs/fake_scandir.py
@@ -133,7 +133,10 @@ class ScanDirIter:
                 self.filesystem.get_open_file(path).get_object().path
             )
             self.path = ""
+            self.entry_iter = iter(tuple())
         else:
+            if path is None:
+                path = "."
             path = make_string_path(path)
             self.abspath = self.filesystem.absnormpath(path)
             self.path = to_string(path)


Test patch:
diff --git a/pyfakefs/tests/fake_filesystem_vs_real_test.py b/pyfakefs/tests/fake_filesystem_vs_real_test.py
index 7ca4281..a829938 100644
--- a/pyfakefs/tests/fake_filesystem_vs_real_test.py
+++ b/pyfakefs/tests/fake_filesystem_vs_real_test.py
@@ -41,12 +41,10 @@ def _get_errno(raised_error):
             pass
 
 
-class TestCase(unittest.TestCase):
+class FakeFilesystemVsRealTest(unittest.TestCase):
     is_windows = sys.platform.startswith("win")
-    _FAKE_FS_BASE = "C:\\fakefs" if is_windows else "/fakefs"
+    fake_base = "C:\\fakefs" if is_windows else "/fakefs"
 
-
-class FakeFilesystemVsRealTest(TestCase):
     def _paths(self, path):
         """For a given path, return paths in the real and fake filesystems."""
         if not path:
@@ -106,7 +104,6 @@ class FakeFilesystemVsRealTest(TestCase):
         if os.path.isdir(self.real_base):
             shutil.rmtree(self.real_base)
         os.mkdir(self.real_base)
-        self.fake_base = self._FAKE_FS_BASE
 
         # Make sure we can write to the physical testing temp directory.
         self.assertTrue(os.access(self.real_base, os.W_OK))
@@ -167,7 +164,7 @@ class FakeFilesystemVsRealTest(TestCase):
                 to method.
             real: built-in system library or method from the built-in system
                 library which takes a path as an arg and returns some value.
-            fake: fake_filsystem object or method from a fake_filesystem class
+            fake: fake_filesystem object or method from a fake_filesystem class
                 which takes a path as an arg and returns some value.
             method_returns_path: True if the method returns a path, and thus we
                 must compensate for expected difference between real and fake.
@@ -302,7 +299,7 @@ class FakeFilesystemVsRealTest(TestCase):
     def diff_open_method_behavior(
         self, method_name, path, mode, data, method_returns_data=True
     ):
-        """Invoke an open method in both real and fkae contexts and compare.
+        """Invoke an open method in both real and fake contexts and compare.
 
         Args:
             method_name: Name of method being tested.
@@ -335,7 +332,7 @@ class FakeFilesystemVsRealTest(TestCase):
 
         For a given method name (from the os.path module) and a path, compare
         the behavior of the system provided module against the
-        fake_filesytem module.
+        fake_filesystem module.
         We expect results and/or Exceptions raised to be identical.
 
         Args:
@@ -374,24 +371,35 @@ class FakeFilesystemVsRealTest(TestCase):
         if diff:
             self.fail(diff)
 
-    def assertAllOsBehaviorsMatch(self, path):
+    def assertAllOsBehaviorsMatch(self, path, excludes=None):
+        excludes = excludes or []
         path = sep(path)
         os_method_names = ["readlink"]
         os_method_names_no_args = ["getcwd"]
-        os_path_method_names = [
+        os_path_method_names = {
             "isabs",
             "isdir",
             "islink",
             "lexists",
             "isfile",
             "exists",
-        ]
+        } - set(excludes)
 
         wrapped_methods = [
             ["access", self._access_real, self._access_fake],
-            ["stat.size", self._stat_size_real, self._stat_size_fake],
+            [
+                "stat.size",
+                lambda p: self._stat_result_real(p, "st_size", False),
+                lambda p: self._stat_result_fake(p, "st_size", False),
+            ],
+            [
+                "stat.mode",
+                lambda p: self._stat_result_real(p, "st_mode"),
+                lambda p: self._stat_result_fake(p, "st_mode"),
+            ],
             ["lstat.size", self._lstat_size_real, self._lstat_size_fake],
         ]
+        wrapped_methods = [m for m in wrapped_methods if m[0] not in excludes]
 
         differences = []
         for method_name in os_method_names:
@@ -492,19 +500,18 @@ class FakeFilesystemVsRealTest(TestCase):
     def _access_fake(self, path):
         return self.fake_os.access(path, os.R_OK)
 
-    def _stat_size_real(self, path):
+    def _stat_result_real(self, path, prop, support_dir=True):
         real_path, unused_fake_path = self._paths(path)
         # fake_filesystem.py does not implement stat().st_size for directories
-        if os.path.isdir(real_path):
+        if not support_dir and os.path.isdir(real_path):
             return None
-        return os.stat(real_path).st_size
+        return getattr(os.stat(real_path), prop)
 
-    def _stat_size_fake(self, path):
-        unused_real_path, fake_path = self._paths(path)
-        # fake_filesystem.py does not implement stat().st_size for directories
-        if self.fake_os.path.isdir(fake_path):
+    def _stat_result_fake(self, path, prop, support_dir=True):
+        _, fake_path = self._paths(path)
+        if not support_dir and self.fake_os.path.isdir(fake_path):
             return None
-        return self.fake_os.stat(fake_path).st_size
+        return getattr(self.fake_os.stat(fake_path), prop)
 
     def _lstat_size_real(self, path):
         real_path, unused_fake_path = self._paths(path)
@@ -544,9 +551,9 @@ class FakeFilesystemVsRealTest(TestCase):
         self.assertAllOsBehaviorsMatch("")
 
     def test_root_path(self):
-        self.assertAllOsBehaviorsMatch("/")
+        self.assertAllOsBehaviorsMatch("/", excludes=["stat.mode"])
 
-    def test_non_existant_file(self):
+    def test_non_existent_file(self):
         self.assertAllOsBehaviorsMatch("foo")
 
     def test_empty_file(self):
@@ -690,7 +697,7 @@ class FakeFilesystemVsRealTest(TestCase):
         self._create_test_file("f", "a/sibling_of_b/target", "contents")
         self.assertAllOsBehaviorsMatch("a/b/../broken/../target")
 
-    def test_getmtime_nonexistant_path(self):
+    def test_getmtime_nonexistent_path(self):
         self.assertOsPathMethodBehaviorMatches("getmtime", "no/such/path")
 
     def test_builtin_open_modes(self):
@@ -724,6 +731,27 @@ class FakeFilesystemVsRealTest(TestCase):
         self.assertFileHandleOpenBehaviorsMatch("write", "w", encoding="utf-8")
         self.assertFileHandleOpenBehaviorsMatch("append", "a", encoding="utf-8")
 
+    def test_directory_permissions(self):
+        self._create_test_file("d", "a")
+        self._create_test_file("f", "a/write")
+        self._create_test_file("d", "b")
+        self._create_test_file("f", "b/write")
+        self._create_test_file("d", "b/stat_dir")
+        for path, mode in [("b", 0o555), ("b/stat_dir", 0o111)]:
+            real_path, fake_path = self._paths(path)
+            os.chmod(real_path, mode)
+            self.fake_os.chmod(fake_path, mode)
+
+        try:
+            self.assertFileHandleOpenBehaviorsMatch("a/write", "w")
+            self.assertFileHandleOpenBehaviorsMatch("b/write", "w")
+            self.assertAllOsBehaviorsMatch("b/stat_dir")
+        finally:
+            for path in ["b", "b/stat_dir"]:
+                real_path, fake_path = self._paths(path)
+                os.chmod(real_path, 0o777)
+                self.fake_os.chmod(fake_path, 0o777)
+
 
 def main(_):
     unittest.main()
diff --git a/pyfakefs/tests/fake_os_test.py b/pyfakefs/tests/fake_os_test.py
index 9a0904c..e026af8 100644
--- a/pyfakefs/tests/fake_os_test.py
+++ b/pyfakefs/tests/fake_os_test.py
@@ -1461,7 +1461,7 @@ class FakeOsModuleTest(FakeOsModuleTestBase):
         new_file = self.filesystem.get_object(new_file_path)
         self.assertNotEqual(new_file.st_mtime, old_file.st_mtime)
         self.os.rename(old_file_path, new_file_path)
-        new_file = self.filesystem.get_object(new_file_path, check_read_perm=False)
+        new_file = self.filesystem.get_object(new_file_path)
         self.assertEqual(new_file.st_mtime, old_file.st_mtime)
         self.assertEqual(new_file.st_mode, old_file.st_mode)
         self.assertEqual(new_file.st_uid, old_file.st_uid)
@@ -5440,6 +5440,11 @@ class FakeScandirTest(FakeOsModuleTestBase):
                 self.dir_entries[5].inode(),
             )
 
+    def test_scandir_none(self):
+        result1 = [entry.path for entry in self.scandir(None)]
+        result2 = [entry.path for entry in self.scandir(".")]
+        self.assertEqual(result1, result2)
+
     def test_scandir_stat_nlink(self):
         # regression test for #350
         stat_nlink = self.os.stat(self.file_path).st_nlink