Repository navigation
Python: fix(core): prevent path collisions between files and directories in InMemoryAgentFileStore #8687
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
Python: fix(core): prevent path collisions between files and directories in InMemoryAgentFileStore #8687
Changes from all commits
05f1554
9b05a0f
7c47fc8
2b6c5cb
92785b5
2002bfe
b4bf58e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1067,12 +1067,24 @@ def __init__(self) -> None: | |
| # wrote, mirroring how :class:`FileSystemAgentFileStore` preserves the | ||
| # on-disk casing. | ||
| self._files: dict[str, tuple[str, str]] = {} | ||
| self._dir_counts: dict[str, int] = {} | ||
| self._explicit_dirs: dict[str, str] = {} | ||
| self._lock = asyncio.Lock() | ||
|
|
||
| @staticmethod | ||
| def _key(path: str) -> str: | ||
| return _normalize_relative_path(path).lower() | ||
|
|
||
| @staticmethod | ||
| def _ancestor_keys(key: str) -> list[str]: | ||
| """Return all strict ancestor directory keys in top-down order in linear time.""" | ||
| ancestors: list[str] = [] | ||
| idx = key.find("/") | ||
| while idx != -1: | ||
| ancestors.append(key[:idx]) | ||
| idx = key.find("/", idx + 1) | ||
| return ancestors | ||
|
|
||
| async def write(self, path: str, content: str, *, overwrite: bool = True) -> None: | ||
| """Write ``content`` to the file at ``path``. | ||
|
|
||
|
|
@@ -1083,9 +1095,19 @@ async def write(self, path: str, content: str, *, overwrite: bool = True) -> Non | |
| display = _normalize_relative_path(path) | ||
| key = display.lower() | ||
| async with self._lock: | ||
| ancestors = self._ancestor_keys(key) | ||
| for ancestor in ancestors: | ||
| if ancestor in self._files: | ||
| raise NotADirectoryError(f"Not a directory: {path!r}") | ||
| if self._dir_counts.get(key, 0) > 0 or key in self._explicit_dirs: | ||
| raise IsADirectoryError(f"Is a directory: {path!r}") | ||
| if not overwrite and key in self._files: | ||
| raise FileExistsError(f"File already exists: {path!r}") | ||
| is_new_file = key not in self._files | ||
| self._files[key] = (display, content) | ||
| if is_new_file: | ||
| for ancestor in ancestors: | ||
| self._dir_counts[ancestor] = self._dir_counts.get(ancestor, 0) + 1 | ||
|
|
||
| async def read(self, path: str) -> str | None: | ||
| """Return the file content, or ``None`` if the file does not exist.""" | ||
|
|
@@ -1095,10 +1117,33 @@ async def read(self, path: str) -> str | None: | |
| return entry[1] if entry is not None else None | ||
|
|
||
| async def delete(self, path: str) -> bool: | ||
| """Delete the file and return whether anything was removed.""" | ||
| """Delete the file and return whether anything was removed. | ||
|
|
||
| An explicitly created directory can also be deleted, but only when it is | ||
| empty: nothing is stored under it and no other explicit directory is | ||
| nested below it. This gives session-scoped directories a lifecycle instead | ||
| of staying in the shared map forever. | ||
| """ | ||
| key = self._key(path) | ||
| async with self._lock: | ||
| return self._files.pop(key, None) is not None | ||
| removed = self._files.pop(key, None) | ||
| if removed is not None: | ||
| for ancestor in self._ancestor_keys(key): | ||
| new_count = self._dir_counts[ancestor] - 1 | ||
| if new_count <= 0: | ||
| del self._dir_counts[ancestor] | ||
| else: | ||
| self._dir_counts[ancestor] = new_count | ||
| return True | ||
| if key in self._explicit_dirs: | ||
| if self._dir_counts.get(key, 0) > 0: | ||
| return False | ||
| prefix = key + "/" | ||
| if any(other != key and other.startswith(prefix) for other in self._explicit_dirs): | ||
| return False | ||
| del self._explicit_dirs[key] | ||
| return True | ||
| return False | ||
|
|
||
| async def list_children(self, directory: str = "") -> list[FileStoreEntry]: | ||
| """Return the direct child files and subdirectories of ``directory``. | ||
|
|
@@ -1118,11 +1163,12 @@ async def list_children(self, directory: str = "") -> list[FileStoreEntry]: | |
| prefix += "/" | ||
| prefix_depth = prefix.count("/") | ||
| async with self._lock: | ||
| entries = [(key, display) for key, (display, _) in self._files.items()] | ||
| file_entries = [(key, display) for key, (display, _) in self._files.items()] | ||
| dir_entries = list(self._explicit_dirs.items()) | ||
| files: list[str] = [] | ||
| directories: list[str] = [] | ||
| seen_dirs: set[str] = set() | ||
| for key, display in entries: | ||
| for key, display in file_entries: | ||
| if not key.startswith(prefix): | ||
| continue | ||
| # Unicode lowercasing can change character counts, so key offsets | ||
|
|
@@ -1137,6 +1183,17 @@ async def list_children(self, directory: str = "") -> list[FileStoreEntry]: | |
| continue | ||
| seen_dirs.add(segment_key) | ||
| directories.append(segment) | ||
| for key, display in dir_entries: | ||
| if not key.startswith(prefix) or key == prefix.rstrip("/"): | ||
| continue | ||
| remainder = display.split("/", prefix_depth)[-1] | ||
| segment, _, _ = remainder.partition("/") | ||
| if segment: | ||
| segment_key = segment.lower() | ||
| if segment_key in seen_dirs: | ||
| continue | ||
| seen_dirs.add(segment_key) | ||
| directories.append(segment) | ||
| results: list[FileStoreEntry] = [FileStoreEntry(name, FileStoreEntry.DIRECTORY) for name in directories] | ||
| results.extend(FileStoreEntry(name, FileStoreEntry.FILE) for name in files) | ||
| return results | ||
|
|
@@ -1194,8 +1251,30 @@ def scan() -> list[FileSearchResult]: | |
| return await _run_search_with_timeout(asyncio.to_thread(scan)) | ||
|
|
||
| async def create_directory(self, path: str) -> None: | ||
| """No-op: directories are implicit from file paths in the in-memory store.""" | ||
| del path | ||
| """Ensure the directory at ``path`` exists.""" | ||
| display = _normalize_relative_path(path, is_directory=True) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 9b05a0f. Explicit directories and their intermediate parents are now recorded in _explicit_dirs, included in directory listings, and reject subsequent file writes with IsADirectoryError. |
||
| key = display.lower() | ||
| if not key: | ||
| return | ||
| async with self._lock: | ||
| ancestors = self._ancestor_keys(key) | ||
| for ancestor in ancestors: | ||
| if ancestor in self._files: | ||
| raise FileExistsError(f"File already exists: {ancestor!r}") | ||
| if key in self._files: | ||
| raise FileExistsError(f"File already exists: {key!r}") | ||
| if key not in self._explicit_dirs: | ||
| idx_display = 0 | ||
| for ancestor in ancestors: | ||
| next_slash = display.find("/", idx_display) | ||
| if next_slash != -1: | ||
| ancestor_display = display[:next_slash] | ||
| idx_display = next_slash + 1 | ||
| else: | ||
| ancestor_display = display | ||
| if ancestor not in self._explicit_dirs: | ||
| self._explicit_dirs[ancestor] = ancestor_display | ||
| self._explicit_dirs[key] = display | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Each new session-scoped run can reach this assignment with a unique session ID, but |
||
|
|
||
|
|
||
| @experimental(feature_id=ExperimentalFeature.HARNESS) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deleting an empty explicit directory returns
Truein_file_access.py:1138-1145, despite the file-only contract at:798-805and filesystem behavior at:1516-1524; backend substitution is therefore observable, while any fix must preserve session-scoped directory cleanup.