From 5675e4978a8d18d4e7fdf4e88dae29d57fa03a4b Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 24 Sep 2026 20:03:33 +0200 Subject: [PATCH] posixfs: a name below an object does not exist Accessing a name whose parent is an object (a file), e.g. "config/config" when "config" is a file, raised a raw NotADirectoryError from info/load/delete/hash on POSIX, and the REST server turned that into an HTTP 500. On Windows, the OS reports such a path as not found, so the same access gave ObjectNotFound there. Treat NotADirectoryError like FileNotFoundError in info, load, delete and hash, so posixfs (and REST via posixfs) behaves the same on all platforms, like sftp and s3 already do. rmdir/list keep raising NotADirectoryError: there, it means that the name itself is not a directory, not that it does not exist. Co-Authored-By: Claude Opus 5.5 --- src/borgstore/backends/posixfs.py | 8 ++++---- tests/test_backends.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/borgstore/backends/posixfs.py b/src/borgstore/backends/posixfs.py index e187222..52dd5af 100644 --- a/src/borgstore/backends/posixfs.py +++ b/src/borgstore/backends/posixfs.py @@ -201,7 +201,7 @@ def info(self, name): self._check_permission(name, "lr") try: st = path.stat() - except FileNotFoundError: + except (FileNotFoundError, NotADirectoryError): return ItemInfo(name=path.name, exists=False, directory=False, size=0) else: is_dir = stat.S_ISDIR(st.st_mode) @@ -219,7 +219,7 @@ def load(self, name, *, size=None, offset=0): if offset != 0: f.seek(offset, os.SEEK_SET if offset >= 0 else os.SEEK_END) return f.read(-1 if size is None else size) - except FileNotFoundError: + except (FileNotFoundError, NotADirectoryError): raise ObjectNotFound(name) from None def _write_to_tempfile(self, path, value, suffix=TMP_SUFFIX, do_fsync=False): @@ -288,7 +288,7 @@ def delete(self, name): if self.quota_limit is not None: size = path.stat().st_size path.unlink() - except FileNotFoundError: + except (FileNotFoundError, NotADirectoryError): raise ObjectNotFound(name) from None if self.quota_limit is not None: self._quota_update(-size) @@ -344,7 +344,7 @@ def hash(self, name: str, algorithm: str = "sha256") -> str: try: with path.open("rb") as f: h = hashing.file_digest(f, algorithm) - except FileNotFoundError: + except (FileNotFoundError, NotADirectoryError): raise ObjectNotFound(name) from None return h.hexdigest() diff --git a/tests/test_backends.py b/tests/test_backends.py index 85b026f..bae530b 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -895,6 +895,24 @@ def test_missing_nesting_dir_move(tested_backends, request): backend.move("namespace2/nest2/key2", "namespace2a/nest2a/key2a") +def test_parent_is_an_object(tested_backends, request): + # a name below an object (not below a directory) does not exist, like on Windows, where the OS reports + # such a path as not found (on POSIX, it is ENOTDIR). + if tested_backends == "rclone_backend_created": + # TODO: rclone's local backend answers ENOTDIR with HTTP 500 (not 404), so we get a BackendError. + pytest.skip("rclone does not report a name below an object as not found") + with get_backend_from_fixture(tested_backends, request) as backend: + backend.store("key", b"value") + assert not backend.info("key/child").exists + with pytest.raises(ObjectNotFound): + backend.load("key/child") + with pytest.raises(ObjectNotFound): + backend.hash("key/child") + with pytest.raises(ObjectNotFound): + backend.delete("key/child") + assert backend.load("key") == b"value" + + def test_posixfs_missing_parent_dirs(tmp_path): be = PosixFS(tmp_path / "missing_parent_dir1" / "missing_parent_dir2" / "store") be.create() # this should work, auto-creating the missing parent dir(s)