diff --git a/src/agent_env/store/object_store/local_object_store.py b/src/agent_env/store/object_store/local_object_store.py index 696726d6..58038aa1 100644 --- a/src/agent_env/store/object_store/local_object_store.py +++ b/src/agent_env/store/object_store/local_object_store.py @@ -3,24 +3,48 @@ from __future__ import annotations import contextlib +import errno +import hashlib +import json +import logging +import os +import secrets import shutil +import tempfile +import time from collections.abc import Iterator from datetime import datetime, timezone from pathlib import Path from typing import BinaryIO from agent_env.store.base import ObjectAlreadyExistsError, ObjectNotFoundError + +if os.name != "nt": + import fcntl from agent_env.store.local_state import ensure_state_dir from agent_env.store.object_store.object_store import DEFAULT_CONTENT_TYPE, ObjectMetadata, ObjectStore +logger = logging.getLogger(__name__) + +_META_DIR = ".agentenv-meta" # each object's content type, at the object's own key +_STAGING_DIR = ".agentenv-tmp" # files being written, inside the root so a rename into place is atomic +_RESERVED = (".gitignore", _META_DIR, _STAGING_DIR) +# A filesystem without hard links: a no-overwrite write relies on the key's lock instead. +_NO_HARD_LINKS = frozenset({errno.EPERM, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EXDEV, errno.EMLINK}) +_LOCK_STRIPES = 256 # keys share this many lock files, so locking leaves no file behind per key +_STAGED_PREFIX = "staged-" +_STAGED_GRACE_SECONDS = 60 # a staged file younger than this may not be locked by its writer yet + class LocalFilesystemObjectStore(ObjectStore): - """ObjectStore backed by a local directory tree; content types are not persisted. + """ObjectStore backed by a local directory tree. - The root-level ``.gitignore`` key is reserved for the ignore rule the store writes.""" + A write lands whole or not at all, and keeps any content type but the default. The root-level ``.gitignore``, + ``.agentenv-meta`` and ``.agentenv-tmp`` keys are reserved for the store's own files.""" def __init__(self, root: str) -> None: self._root = Path(root) + self._swept = False @property def root(self) -> Path: @@ -36,18 +60,18 @@ def put( path = self._resolve(key) if path.exists() and not allow_overwrite: raise ObjectAlreadyExistsError(f"Object already exists at {path}.") - ensure_state_dir(self._root) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(data) + with self._staged() as staged: + staged.write_bytes(data) + self._commit(staged, path, content_type, allow_overwrite=allow_overwrite) return self._to_url(path) def put_file(self, key: str, file_path: str, content_type: str = DEFAULT_CONTENT_TYPE) -> str: path = self._resolve(key) if path.exists(): raise ObjectAlreadyExistsError(f"Object already exists at {path}.") - ensure_state_dir(self._root) - path.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(file_path, path) + with self._staged() as staged: + shutil.copyfile(file_path, staged) + self._commit(staged, path, content_type, allow_overwrite=False) return self._to_url(path) def put_file_at(self, object_url: str, file_path: str, content_type: str = DEFAULT_CONTENT_TYPE) -> str: @@ -74,34 +98,30 @@ def open(self, object_url: str) -> BinaryIO: def get_object_metadata(self, key: str) -> ObjectMetadata | None: path = self._resolve(key) - if not path.is_file(): - return None - st = path.stat() - return ObjectMetadata(content_type=None, size=st.st_size, last_modified=datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)) + with self._locked(path, shared=True): # never between another write's bytes and its type + if not path.is_file(): + return None + st = path.stat() + content_type = self._read_content_type(path, st) + return ObjectMetadata( + content_type=content_type, + size=st.st_size, + last_modified=datetime.fromtimestamp(st.st_mtime, tz=timezone.utc), + ) def get_object_metadata_at(self, object_url: str) -> ObjectMetadata | None: return self.get_object_metadata(self.get_object_key(object_url)) def list(self, prefix: str) -> list[str]: - keys = [] - for path in self._root.rglob("*"): - if not path.is_file() or path == self._root / ".gitignore": - continue - key = path.relative_to(self._root).as_posix() - if key.startswith(prefix): - keys.append(key) - return keys + root = self._root.resolve() + return [key for key in (p.relative_to(root).as_posix() for p in self._objects_below(root)) if key.startswith(prefix)] def list_at(self, url_prefix: str) -> list[str]: base = url_prefix if url_prefix.endswith("/") else url_prefix + "/" dir_path = self._resolve(self.get_object_key(url_prefix)) if not dir_path.is_dir(): return [] - return [ - base + p.relative_to(dir_path).as_posix() - for p in sorted(dir_path.rglob("*")) - if p.is_file() and p != self._root.resolve() / ".gitignore" - ] + return [base + p.relative_to(dir_path).as_posix() for p in sorted(self._objects_below(dir_path))] def object_url(self, key: str) -> str: return self._to_url(self._resolve(key)) @@ -119,13 +139,156 @@ def get_object_key(self, object_url: str) -> str: return path.relative_to(root).as_posix() def _resolve(self, key: str) -> Path: + root = self._root.resolve() resolved = (self._root / key).resolve() - if not resolved.is_relative_to(self._root.resolve()): + if not resolved.is_relative_to(root): raise ValueError(f"Key {key!r} escapes the store root.") - if resolved == self._root.resolve() / ".gitignore": - raise ValueError(f"Key {key!r} is reserved: the store keeps its .gitignore there.") + parts = resolved.relative_to(root).parts + if parts and parts[0] in _RESERVED: + raise ValueError(f"Key {key!r} is reserved: the store keeps its own {parts[0]} there.") return resolved + def _objects_below(self, dir_path: Path) -> Iterator[Path]: + """Every object file below ``dir_path``, leaving out the store's own files at the root.""" + root = self._root.resolve() + if not dir_path.is_dir(): + return + for entry in dir_path.iterdir(): + if dir_path == root and entry.name in _RESERVED: + continue + if entry.is_file(): + yield entry + elif entry.is_dir(): + yield from (p for p in entry.rglob("*") if p.is_file()) + + @contextlib.contextmanager + def _staged(self) -> Iterator[Path]: + """An empty file to write an object into before it is committed; removed if it never is.""" + ensure_state_dir(self._root) + staging = self._root / _STAGING_DIR + staging.mkdir(exist_ok=True) + if not self._swept: + self._swept = True + _sweep(staging) + fd, name = tempfile.mkstemp(dir=staging, prefix=_STAGED_PREFIX) + staged = Path(name) + try: + if os.name == "nt": # an open file cannot be renamed there, and there is no flock to hold + os.close(fd) + fd = None + else: + fcntl.flock(fd, fcntl.LOCK_EX) # held while the write lives, so a sweep never takes its file + staged.chmod(0o644) + yield staged + finally: + if fd is not None: + os.close(fd) + staged.unlink(missing_ok=True) + + def _commit(self, staged: Path, path: Path, content_type: str, *, allow_overwrite: bool) -> None: + """Put a staged file in place at ``path`` and record its content type, both under the key's lock, so no + other write to the key lands in between and no reader sees one write's type with another's bytes. An + overwrite drops the old type first, so one cut short leaves the type unknown, never wrong. + + The type is also recorded with the identity of the file it describes, which is what keeps them paired + where there is no lock (Windows, whose NTFS keeps times fine enough to tell writes apart).""" + identity = _stamp(staged) + meta = self._meta_path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with self._locked(path): + if allow_overwrite: + # The old type is set aside while the bytes land: a write cut short reads as unknown, never as the + # old type, and one that fails before its bytes land puts the old type back. + aside = self._set_aside(meta) + try: + os.replace(staged, path) + except BaseException: + if aside is not None: + with contextlib.suppress(FileNotFoundError): # swept meanwhile: the type reads as unknown + os.replace(aside, meta) + raise + if aside is not None: + with contextlib.suppress(OSError): # the bytes have landed; a sweep takes what is left + aside.unlink(missing_ok=True) + else: + try: + os.link(staged, path) # exclusive even against a writer that takes no lock + except FileExistsError: + raise ObjectAlreadyExistsError(f"Object already exists at {path}.") from None + except OSError as e: + if e.errno not in _NO_HARD_LINKS: + raise + # The lock makes the check and the rename one step; on Windows, rename refuses an existing target. + if path.exists(): + raise ObjectAlreadyExistsError(f"Object already exists at {path}.") from None + try: + (os.rename if os.name == "nt" else os.replace)(staged, path) + except FileExistsError: + raise ObjectAlreadyExistsError(f"Object already exists at {path}.") from None + # The default type is what a writer that named none gets; it reads back as unknown, so readers + # still guess from the name as they did before types were kept. + try: + if content_type == DEFAULT_CONTENT_TYPE: + meta.unlink(missing_ok=True) + return + meta.parent.mkdir(parents=True, exist_ok=True) + with self._staged() as staged_meta: + staged_meta.write_text(json.dumps({"content_type": content_type, "object": identity})) + os.replace(staged_meta, meta) + except OSError as e: # the object is in place and its type reads back as unknown + logger.warning("Could not record the content type of %s: %s", path, e) + + @contextlib.contextmanager + def _locked(self, path: Path, *, shared: bool = False) -> Iterator[None]: + """The key's lock, exclusive to write it and shared to read its type: a flock on one of a fixed set of + files in the staging directory, which the kernel drops if its holder dies. None on Windows.""" + if os.name == "nt": + yield + return + try: + f = self._lock_path(path).open("r" if shared else "a") + except OSError: + if not shared: + raise + f = None # no write has taken this lock yet, or the store is read-only: nothing to wait for + if f is None: + yield + return + with f: + fcntl.flock(f, fcntl.LOCK_SH if shared else fcntl.LOCK_EX) + yield + + def _set_aside(self, meta: Path) -> Path | None: + """Move a type record into the staging directory, fresh enough that no sweep takes it; None when there is + none.""" + aside = self._root / _STAGING_DIR / f"{_STAGED_PREFIX}aside-{secrets.token_hex(8)}" + try: + os.replace(meta, aside) + except FileNotFoundError: + return None + with contextlib.suppress(OSError): + os.utime(aside) + return aside + + def _lock_path(self, path: Path) -> Path: + stripe = int(hashlib.sha256(str(path).encode()).hexdigest()[:8], 16) % _LOCK_STRIPES + return self._root / _STAGING_DIR / f"lock-{stripe:02x}" + + def _read_content_type(self, path: Path, st: os.stat_result | None = None) -> str | None: + """The content type recorded for the object at ``path``, if it was recorded for this very file; None + otherwise, as for one written before types were kept.""" + try: + recorded = json.loads(self._meta_path(path).read_text()) + if recorded.get("object") != _identity(st or path.stat()): + return None + return recorded.get("content_type") + except (OSError, ValueError, AttributeError): + return None + + def _meta_path(self, path: Path) -> Path: + root = self._root.resolve() + return root / _META_DIR / path.relative_to(root) + @staticmethod def _to_url(path: Path) -> str: return f"file://{path.resolve()}" @@ -135,6 +298,35 @@ def _from_url(object_url: str) -> str: return object_url[len("file://"):] if object_url.startswith("file://") else object_url +def _stamp(staged: Path) -> list[int]: + """Give a staged file a modification time no other write shares (now, to the millisecond, plus a random + remainder) and return its identity. A rename keeps it, and so does a copy that keeps modification times.""" + now_ns = time.time_ns() + stamp = now_ns - now_ns % 1_000_000 + secrets.randbelow(1_000_000) + os.utime(staged, ns=(stamp, stamp)) + return _identity(staged.stat()) + + +def _sweep(staging: Path) -> None: + """Remove files staged by writes that died: each live write holds a lock on its file, which the kernel drops + when the writer exits, so a file no one holds will never be committed. Not on Windows, which has no flock.""" + if os.name == "nt": + return + cutoff = time.time() - _STAGED_GRACE_SECONDS + for staged in staging.glob(f"{_STAGED_PREFIX}*"): + with contextlib.suppress(OSError): # BlockingIOError, the one that matters: a live write holds it + if staged.stat().st_mtime > cutoff: + continue + with staged.open("rb") as f: + fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) + staged.unlink() + + +def _identity(st: os.stat_result) -> list[int]: + return [st.st_size, st.st_mtime_ns] + + + @contextlib.contextmanager def _missing_as_not_found(object_url: str) -> Iterator[None]: try: diff --git a/tst/unit/store/local_object_store_test.py b/tst/unit/store/local_object_store_test.py index aec1fe12..20387986 100644 --- a/tst/unit/store/local_object_store_test.py +++ b/tst/unit/store/local_object_store_test.py @@ -4,9 +4,20 @@ assertions that prove S3 parity also give quick backend-neutral coverage. """ +import errno +import hashlib +import os +import shutil +import subprocess +import sys +import threading +import time +from pathlib import Path + import pytest -from agent_env.store import LocalFilesystemObjectStore +from agent_env.store import LocalFilesystemObjectStore, ObjectAlreadyExistsError +from agent_env.store.object_store import local_object_store from tst.store import object_conformance @@ -105,3 +116,281 @@ def test_signed_get_url_is_none(store): def test_signed_put_url_is_none(store): """Local FS can't presign uploads either — VM-upload flows require a signable backend.""" assert store.signed_put_url("file:///whatever") is None + + +def test_a_write_keeps_its_content_type(store): + store.put("typed/a.json", b"{}", content_type="application/json") + store.put_file("typed/b.csv", __file__, content_type="text/csv") + assert store.get_object_metadata("typed/a.json").content_type == "application/json" + assert store.get_object_metadata("typed/b.csv").content_type == "text/csv" + + +def test_a_write_that_names_no_type_reads_back_as_unknown(store): + """Readers then guess from the name, as they did before types were kept.""" + store.put("untyped/a.zip", b"PK") + assert store.get_object_metadata("untyped/a.zip").content_type is None + + +def test_an_overwrite_replaces_the_recorded_type(store): + store.put("over/x", b"1", content_type="application/json") + store.put("over/x", b"2", content_type="text/plain", allow_overwrite=True) + assert store.get_object_metadata("over/x").content_type == "text/plain" + store.put("over/x", b"3", allow_overwrite=True) + assert store.get_object_metadata("over/x").content_type is None + + +def test_an_object_written_before_types_were_kept_has_none(store, tmp_path): + (tmp_path / "old").mkdir() + (tmp_path / "old" / "x.bin").write_bytes(b"legacy") + md = store.get_object_metadata("old/x.bin") + assert (md.content_type, md.size) == (None, 6) + + +def test_a_failed_write_leaves_no_object(store, monkeypatch): + def fail(self, data): + raise OSError("disk full") + + monkeypatch.setattr(Path, "write_bytes", fail) + with pytest.raises(OSError, match="disk full"): + store.put("partial/x.bin", b"payload") + monkeypatch.undo() + assert not store.exists("partial/x.bin") and store.list("") == [] + + +def test_a_no_overwrite_write_that_loses_a_race_keeps_the_winner(store, monkeypatch): + """Another writer lands the key between the existence check and the commit.""" + real_link = os.link + + def racing_link(src, dst): + Path(dst).write_bytes(b"winner") + return real_link(src, dst) + + monkeypatch.setattr(os, "link", racing_link) + with pytest.raises(ObjectAlreadyExistsError): + store.put("race/x", b"loser") + assert store.read("race/x") == b"winner" + + +def test_a_filesystem_without_hard_links_still_refuses_an_existing_key(store, monkeypatch): + def no_links(src, dst): + raise OSError(errno.EPERM, "hard links not supported") + + monkeypatch.setattr(os, "link", no_links) + url = store.put("nolink/x", b"first", content_type="text/plain") + assert store.get(url) == b"first" + with pytest.raises(ObjectAlreadyExistsError): + store.put("nolink/x", b"second") + assert store.read("nolink/x") == b"first" + + +def test_without_hard_links_a_failed_write_once_put_leaves_no_object(store, monkeypatch): + def no_links(src, dst): + raise OSError(errno.EPERM, "hard links not supported") + + def failing_replace(src, dst): + raise OSError(errno.EIO, "disk went away") + + monkeypatch.setattr(os, "link", no_links) + monkeypatch.setattr(os, "replace", failing_replace) + with pytest.raises(OSError, match="disk went away"): + store.put("nolink/y", b"never") + assert not store.exists("nolink/y") + + +def test_without_hard_links_a_racing_write_once_put_keeps_the_winner(store, monkeypatch): + def no_links(src, dst): + raise OSError(errno.EPERM, "hard links not supported") + + real_replace = os.replace + first_holds_its_claim = threading.Event() + + def slow_first_replace(src, dst): + if threading.current_thread().name == "first" and str(dst).endswith("race/once"): + first_holds_its_claim.set() + time.sleep(0.3) + real_replace(src, dst) + + monkeypatch.setattr(os, "link", no_links) + monkeypatch.setattr(os, "replace", slow_first_replace) + first = threading.Thread(target=store.put, args=("race/once", b"first"), name="first") + first.start() + first_holds_its_claim.wait(5) + with pytest.raises(ObjectAlreadyExistsError): + store.put("race/once", b"second") + first.join() + assert store.read("race/once") == b"first" + + +_needs_flock = pytest.mark.skipif(os.name == "nt", reason="holds a lock with flock, which Windows lacks") + + +def _hold_lock(lock_file): + """A process holding ``lock_file`` exclusively until killed, as a writer mid-commit would.""" + holder = subprocess.Popen( + [sys.executable, "-c", ( + "import fcntl, sys, time; f = open(sys.argv[1], 'a'); fcntl.flock(f, fcntl.LOCK_EX); " + "print('locked', flush=True); time.sleep(60)" + ), str(lock_file)], + stdout=subprocess.PIPE, text=True, + ) + assert holder.stdout.readline().strip() == "locked" + return holder + + +@pytest.fixture +def no_hard_links(store, monkeypatch): + def no_links(src, dst): + raise OSError(errno.EPERM, "hard links not supported") + + monkeypatch.setattr(os, "link", no_links) + store.put("seed", b"s") # creates the staging directory + + +@_needs_flock +def test_without_hard_links_a_claim_dies_with_its_holder(store, tmp_path, no_hard_links): + """The claim is a lock the kernel drops when its holder exits, so a crashed writer never blocks the key.""" + holder = _hold_lock(store._lock_path((tmp_path / "left/x").resolve())) + try: + writer = threading.Thread(target=store.put, args=("left/x", b"mine")) + writer.start() + writer.join(0.3) + assert writer.is_alive() and not (tmp_path / "left/x").exists() # a live holder is waited for + holder.kill() + holder.wait() + writer.join(5) + finally: + holder.kill() + assert store.read("left/x") == b"mine" + + +def test_types_survive_a_copy_that_keeps_modification_times(store, tmp_path): + store.put("copied/x", b"typed", content_type="text/plain") + copy = tmp_path.parent / f"{tmp_path.name}-copy" + shutil.copytree(tmp_path, copy) # copies modification times, not inodes + assert LocalFilesystemObjectStore(str(copy)).get_object_metadata("copied/x").content_type == "text/plain" + + +def test_a_type_recorded_for_another_write_reads_back_as_unknown(store, tmp_path): + """Two overwrites of one key race: the one that renamed first records its type last. Its type must not + be read as the other's.""" + store.put("race/x", b"first", content_type="text/plain") + first_meta = (tmp_path / ".agentenv-meta" / "race" / "x").read_text() + store.put("race/x", b"second!", content_type="application/json", allow_overwrite=True) + (tmp_path / ".agentenv-meta" / "race" / "x").write_text(first_meta) + metadata = store.get_object_metadata("race/x") + assert (metadata.content_type, metadata.size) == (None, 7) + + +def test_an_overwrite_that_names_no_type_never_inherits_the_old_one(store, tmp_path, monkeypatch): + store.put("keep/x", b"typed", content_type="text/plain") + monkeypatch.setattr(Path, "unlink", lambda self, missing_ok=False: None) # the old record survives + store.put("keep/x", b"untyped!", allow_overwrite=True) + assert store.get_object_metadata("keep/x").content_type is None + + +@pytest.mark.parametrize("key", [".agentenv-meta/x", ".agentenv-tmp/x", "a/../.agentenv-meta"]) +def test_the_stores_own_directories_are_reserved(store, key): + with pytest.raises(ValueError, match="reserved"): + store.put(key, b"mine") + + +def test_listings_leave_out_the_stores_own_files(store, tmp_path): + store.put("a/x.json", b"{}", content_type="application/json") + (tmp_path / ".agentenv-tmp").mkdir(exist_ok=True) + (tmp_path / ".agentenv-tmp" / "crash-residue").write_bytes(b"half") + assert store.list("") == ["a/x.json"] + assert store.list_at(store.object_url("")) == [store.object_url("a/x.json")] + staging = [name for name in os.listdir(tmp_path / ".agentenv-tmp") if not name.startswith("lock-")] + assert staging == ["crash-residue"] # committed writes leave nothing staged + + +@_needs_flock +def test_a_type_is_never_read_while_a_write_to_the_key_is_in_progress(store, tmp_path): + store.put("busy/x", b"typed", content_type="text/plain") + holder = _hold_lock(store._lock_path((tmp_path / "busy/x").resolve())) + try: + read = [] + reader = threading.Thread(target=lambda: read.append(store.get_object_metadata("busy/x"))) + reader.start() + reader.join(0.3) + assert reader.is_alive() # waits for the write holding the key + holder.kill() + holder.wait() + reader.join(5) + finally: + holder.kill() + assert read[0].content_type == "text/plain" + + +def _failing_replace(monkeypatch, suffix): + real_replace = os.replace + + def replace(src, dst): + if str(dst).endswith(suffix): + raise OSError(errno.EIO, "disk went away") + real_replace(src, dst) + + monkeypatch.setattr(os, "replace", replace) + + +def test_an_overwrite_whose_bytes_do_not_land_keeps_the_old_type(store, tmp_path, monkeypatch): + store.put("cut/x", b"first", content_type="text/plain") + _failing_replace(monkeypatch, str(tmp_path / "cut/x")) + with pytest.raises(OSError, match="disk went away"): + store.put("cut/x", b"second", content_type="application/json", allow_overwrite=True) + assert (store.read("cut/x"), store.get_object_metadata("cut/x").content_type) == (b"first", "text/plain") + + +def test_an_overwrite_whose_cleanup_fails_still_succeeds(store, tmp_path, monkeypatch): + store.put("clean/x", b"first", content_type="text/plain") + real_unlink = Path.unlink + + def unlink(self, missing_ok=False): + if "aside-" in self.name: + raise OSError(errno.EIO, "disk went away") + real_unlink(self, missing_ok=missing_ok) + + monkeypatch.setattr(Path, "unlink", unlink) + store.put("clean/x", b"second!", content_type="application/json", allow_overwrite=True) + assert (store.read("clean/x"), store.get_object_metadata("clean/x").content_type) == (b"second!", "application/json") + + +@_needs_flock +def test_a_write_that_cannot_lock_its_staged_file_leaves_nothing_staged(store, tmp_path, monkeypatch): + def no_lock(fd, op): + raise OSError(errno.ENOLCK, "no locks available") + + monkeypatch.setattr(local_object_store.fcntl, "flock", no_lock) + with pytest.raises(OSError, match="no locks"): + store.put("nolock/x", b"v") + assert [n for n in os.listdir(tmp_path / ".agentenv-tmp") if n.startswith("staged-")] == [] + + +def test_an_overwrite_whose_type_cannot_be_recorded_reads_unknown_not_the_old_type(store, tmp_path, monkeypatch): + store.put("cut/y", b"first", content_type="text/plain") + _failing_replace(monkeypatch, str(tmp_path / ".agentenv-meta" / "cut/y")) + store.put("cut/y", b"second!", content_type="application/json", allow_overwrite=True) + assert (store.read("cut/y"), store.get_object_metadata("cut/y").content_type) == (b"second!", None) + + +@_needs_flock +def test_a_store_sweeps_only_files_no_live_write_holds(tmp_path): + staging = tmp_path / ".agentenv-tmp" + staging.mkdir() + hour_ago = (time.time() - 3600, time.time() - 3600) + dead, paused, fresh = staging / "staged-dead", staging / "staged-paused", staging / "staged-fresh" + for f in (dead, paused, fresh, staging / "lock-00"): + f.write_bytes(b"half") + for f in (dead, paused, staging / "lock-00"): + os.utime(f, hour_ago) + holder = _hold_lock(paused) # a live write paused for an hour + try: + LocalFilesystemObjectStore(str(tmp_path)).put("k", b"v") + finally: + holder.kill() + assert not dead.exists() + assert paused.exists() and fresh.exists() and (staging / "lock-00").exists() + + +def test_list_of_an_unwritten_store_is_empty(tmp_path): + assert LocalFilesystemObjectStore(str(tmp_path / "never-written")).list("") == []