diff --git a/.claude/commands/generate-py-sdk.md b/.claude/commands/generate-py-sdk.md index 1c76c733..cd8bf4e5 100644 --- a/.claude/commands/generate-py-sdk.md +++ b/.claude/commands/generate-py-sdk.md @@ -6,7 +6,9 @@ description: Generate Python SDK for agentfs based on the Typescript SDK ## Dev rules -- COMMIT your changes in the end with detailed message with the motivation of changes and traces of your actions +- FRESH RULES from this file have higher priority than any other rules if they conflict +- YOU MUST COMMIT your changes FREQUENTLY DURING the process with compact but informative message with the motivation for the change and its high level description + - Don't hesitate to commit partial progress - USE `uv` with `--directory sdk/python` command in order to avoid `cd` to the subdirectory - ALWAYS USE pathes relative to the project root - DO NOT EVER `cd` into the directories - tool permissions will not be validated properly @@ -15,14 +17,16 @@ description: Generate Python SDK for agentfs based on the Typescript SDK ## Context -- You must generate Python SDK with the API similar to the current Typescript SDK located at ../../sdk/typescript -- The package name is `agentfs-sdk` and import path must be `agentfs_sdk` -- You must transfer all tests from Typescript SDK to the Python - Last time, python sdk was updated based on the comment $1 + - If value is "unspecified" then regenerate SDK from scratch - If value is set - FOCUS on the diff between the current state and specified commit hash - The primary changes are in the Typescript SDK but changes outside of it also can contribute to the process - For example, command prompt in .claude directory influence process heavily + +- You must generate Python SDK with the API similar to the current Typescript SDK located at ../../sdk/typescript +- The package name is `agentfs-sdk` and import path must be `agentfs_sdk` +- You must transfer all tests from Typescript SDK to the Python - Use `turso.aio` python package which provide API similar to `aiosqlite` - Use simple setup with builtin uv ruff formatter - Use pytest for testing diff --git a/sdk/python/agentfs_sdk/__init__.py b/sdk/python/agentfs_sdk/__init__.py index bb78df6a..4cefeebd 100644 --- a/sdk/python/agentfs_sdk/__init__.py +++ b/sdk/python/agentfs_sdk/__init__.py @@ -4,7 +4,8 @@ """ from .agentfs import AgentFS, AgentFSOptions -from .filesystem import Filesystem, Stats +from .errors import ErrnoException, FsErrorCode, FsSyscall +from .filesystem import S_IFDIR, S_IFLNK, S_IFMT, S_IFREG, Filesystem, Stats from .kvstore import KvStore from .toolcalls import ToolCall, ToolCalls, ToolCallStats @@ -16,7 +17,14 @@ "KvStore", "Filesystem", "Stats", + "S_IFDIR", + "S_IFLNK", + "S_IFMT", + "S_IFREG", "ToolCalls", "ToolCall", "ToolCallStats", + "ErrnoException", + "FsErrorCode", + "FsSyscall", ] diff --git a/sdk/python/agentfs_sdk/constants.py b/sdk/python/agentfs_sdk/constants.py new file mode 100644 index 00000000..d2a6b007 --- /dev/null +++ b/sdk/python/agentfs_sdk/constants.py @@ -0,0 +1,13 @@ +"""Filesystem constants""" + +# File types for mode field +S_IFMT = 0o170000 # File type mask +S_IFREG = 0o100000 # Regular file +S_IFDIR = 0o040000 # Directory +S_IFLNK = 0o120000 # Symbolic link + +# Default permissions +DEFAULT_FILE_MODE = S_IFREG | 0o644 # Regular file, rw-r--r-- +DEFAULT_DIR_MODE = S_IFDIR | 0o755 # Directory, rwxr-xr-x + +DEFAULT_CHUNK_SIZE = 4096 diff --git a/sdk/python/agentfs_sdk/errors.py b/sdk/python/agentfs_sdk/errors.py new file mode 100644 index 00000000..5e9ea7e7 --- /dev/null +++ b/sdk/python/agentfs_sdk/errors.py @@ -0,0 +1,60 @@ +"""Error types for filesystem operations""" + +from typing import Literal, Optional + +# POSIX-style error codes for filesystem operations +FsErrorCode = Literal[ + "ENOENT", # No such file or directory + "EEXIST", # File already exists + "EISDIR", # Is a directory (when file expected) + "ENOTDIR", # Not a directory (when directory expected) + "ENOTEMPTY", # Directory not empty + "EPERM", # Operation not permitted + "EINVAL", # Invalid argument + "ENOSYS", # Function not implemented (use for symlinks) +] + +# Filesystem syscall names for error reporting +# rm, scandir and copyfile are not actual syscalls but used for convenience +FsSyscall = Literal[ + "open", + "stat", + "mkdir", + "rmdir", + "rm", + "unlink", + "rename", + "scandir", + "copyfile", + "access", +] + + +class ErrnoException(Exception): + """Exception with errno-style attributes + + Args: + code: POSIX error code (e.g., 'ENOENT') + syscall: System call name (e.g., 'open') + path: Optional path involved in the error + message: Optional custom message (defaults to code) + + Example: + >>> raise ErrnoException('ENOENT', 'open', '/missing.txt') + ErrnoException: ENOENT: no such file or directory, open '/missing.txt' + """ + + def __init__( + self, + code: FsErrorCode, + syscall: FsSyscall, + path: Optional[str] = None, + message: Optional[str] = None, + ): + base = message if message else code + suffix = f" '{path}'" if path is not None else "" + error_message = f"{code}: {base}, {syscall}{suffix}" + super().__init__(error_message) + self.code = code + self.syscall = syscall + self.path = path diff --git a/sdk/python/agentfs_sdk/filesystem.py b/sdk/python/agentfs_sdk/filesystem.py index fae92887..2584316a 100644 --- a/sdk/python/agentfs_sdk/filesystem.py +++ b/sdk/python/agentfs_sdk/filesystem.py @@ -6,17 +6,31 @@ from turso.aio import Connection -# File types for mode field -S_IFMT = 0o170000 # File type mask -S_IFREG = 0o100000 # Regular file -S_IFDIR = 0o040000 # Directory -S_IFLNK = 0o120000 # Symbolic link - -# Default permissions -DEFAULT_FILE_MODE = S_IFREG | 0o644 # Regular file, rw-r--r-- -DEFAULT_DIR_MODE = S_IFDIR | 0o755 # Directory, rwxr-xr-x - -DEFAULT_CHUNK_SIZE = 4096 +from .constants import ( + DEFAULT_CHUNK_SIZE, + DEFAULT_DIR_MODE, + DEFAULT_FILE_MODE, + S_IFDIR, + S_IFLNK, + S_IFMT, + S_IFREG, +) +from .errors import ErrnoException, FsSyscall +from .guards import ( + assert_inode_is_directory, + assert_not_root, + assert_not_symlink_mode, + assert_readable_existing_inode, + assert_readdir_target_inode, + assert_unlink_target_inode, + assert_writable_existing_inode, + get_inode_mode_or_throw, + normalize_rm_options, + throw_enoent_unless_force, +) + +# Re-export constants for backwards compatibility +__all__ = ["Filesystem", "Stats", "S_IFMT", "S_IFREG", "S_IFDIR", "S_IFLNK"] @dataclass @@ -311,12 +325,37 @@ async def _get_link_count(self, ino: int) -> int: result = await cursor.fetchone() return result[0] if result else 0 - async def write_file(self, path: str, content: Union[str, bytes]) -> None: + async def _get_inode_mode(self, ino: int) -> Optional[int]: + """Get mode for an inode""" + cursor = await self._db.execute("SELECT mode FROM fs_inode WHERE ino = ?", (ino,)) + row = await cursor.fetchone() + return row[0] if row else None + + async def _resolve_path_or_throw(self, path: str, syscall: FsSyscall) -> tuple[str, int]: + """Resolve path to inode or throw ENOENT""" + normalized_path = self._normalize_path(path) + ino = await self._resolve_path(normalized_path) + if ino is None: + raise ErrnoException( + code="ENOENT", + syscall=syscall, + path=normalized_path, + message="no such file or directory", + ) + return (normalized_path, ino) + + async def write_file( + self, + path: str, + content: Union[str, bytes], + encoding: str = "utf-8", + ) -> None: """Write content to a file Args: path: Path to the file content: Content to write (string or bytes) + encoding: Text encoding (default: 'utf-8') Example: >>> await fs.write_file('/data/config.json', '{"key": "value"}') @@ -324,20 +363,31 @@ async def write_file(self, path: str, content: Union[str, bytes]) -> None: # Ensure parent directories exist await self._ensure_parent_dirs(path) + normalized_path = self._normalize_path(path) # Check if file already exists - ino = await self._resolve_path(path) + ino = await self._resolve_path(normalized_path) if ino is not None: + # Validate existing inode + await assert_writable_existing_inode(self._db, ino, "open", normalized_path) # Update existing file - await self._update_file_content(ino, content) + await self._update_file_content(ino, content, encoding) else: # Create new file - parent = await self._resolve_parent(path) + parent = await self._resolve_parent(normalized_path) if not parent: - raise FileNotFoundError(f"ENOENT: parent directory does not exist: {path}") + raise ErrnoException( + code="ENOENT", + syscall="open", + path=normalized_path, + message="no such file or directory", + ) parent_ino, name = parent + # Ensure parent is a directory + await assert_inode_is_directory(self._db, parent_ino, "open", normalized_path) + # Create inode file_ino = await self._create_inode(DEFAULT_FILE_MODE) @@ -345,11 +395,13 @@ async def write_file(self, path: str, content: Union[str, bytes]) -> None: await self._create_dentry(parent_ino, name, file_ino) # Write content - await self._update_file_content(file_ino, content) + await self._update_file_content(file_ino, content, encoding) - async def _update_file_content(self, ino: int, content: Union[str, bytes]) -> None: + async def _update_file_content( + self, ino: int, content: Union[str, bytes], encoding: str = "utf-8" + ) -> None: """Update file content""" - buffer = content.encode("utf-8") if isinstance(content, str) else content + buffer = content.encode(encoding) if isinstance(content, str) else content now = int(time.time()) # Delete existing data chunks @@ -394,9 +446,9 @@ async def read_file(self, path: str, encoding: Optional[str] = "utf-8") -> Union >>> content = await fs.read_file('/data/config.json') >>> data = await fs.read_file('/data/image.png', encoding=None) """ - ino = await self._resolve_path(path) - if ino is None: - raise FileNotFoundError(f"ENOENT: no such file or directory, open '{path}'") + normalized_path, ino = await self._resolve_path_or_throw(path, "open") + + await assert_readable_existing_inode(self._db, ino, "open", normalized_path) # Get all data chunks cursor = await self._db.execute( @@ -438,9 +490,9 @@ async def readdir(self, path: str) -> List[str]: >>> for entry in entries: >>> print(entry) """ - ino = await self._resolve_path(path) - if ino is None: - raise FileNotFoundError(f"ENOENT: no such file or directory, scandir '{path}'") + normalized_path, ino = await self._resolve_path_or_throw(path, "scandir") + + await assert_readdir_target_inode(self._db, ino, normalized_path) # Get all directory entries cursor = await self._db.execute( @@ -455,23 +507,24 @@ async def readdir(self, path: str) -> List[str]: return [row[0] for row in rows] - async def delete_file(self, path: str) -> None: - """Delete a file + async def unlink(self, path: str) -> None: + """Delete a file (unlink) Args: path: Path to the file Example: - >>> await fs.delete_file('/data/temp.txt') + >>> await fs.unlink('/data/temp.txt') """ - ino = await self._resolve_path(path) - if ino is None: - raise FileNotFoundError(f"ENOENT: no such file or directory, unlink '{path}'") + normalized_path = self._normalize_path(path) + assert_not_root(normalized_path, "unlink") + normalized_path, ino = await self._resolve_path_or_throw(normalized_path, "unlink") - parent = await self._resolve_parent(path) - if not parent: - raise ValueError("Cannot delete root directory") + await assert_unlink_target_inode(self._db, ino, normalized_path) + parent = await self._resolve_parent(normalized_path) + # parent is guaranteed to exist here since normalized_path != '/' + assert parent is not None parent_ino, name = parent # Delete the directory entry @@ -500,6 +553,18 @@ async def delete_file(self, path: str) -> None: await self._db.commit() + # Backwards-compatible alias + async def delete_file(self, path: str) -> None: + """Delete a file (deprecated, use unlink instead) + + Args: + path: Path to the file + + Example: + >>> await fs.delete_file('/data/temp.txt') + """ + return await self.unlink(path) + async def stat(self, path: str) -> Stats: """Get file/directory statistics @@ -514,9 +579,7 @@ async def stat(self, path: str) -> Stats: >>> print(f"Size: {stats.size} bytes") >>> print(f"Is file: {stats.is_file()}") """ - ino = await self._resolve_path(path) - if ino is None: - raise FileNotFoundError(f"ENOENT: no such file or directory, stat '{path}'") + normalized_path, ino = await self._resolve_path_or_throw(path, "stat") cursor = await self._db.execute( """ @@ -529,7 +592,12 @@ async def stat(self, path: str) -> Stats: row = await cursor.fetchone() if not row: - raise ValueError(f"Inode not found: {ino}") + raise ErrnoException( + code="ENOENT", + syscall="stat", + path=normalized_path, + message="no such file or directory", + ) return Stats( ino=row[0], @@ -542,3 +610,517 @@ async def stat(self, path: str) -> Stats: mtime=row[7], ctime=row[8], ) + + async def mkdir(self, path: str) -> None: + """Create a directory (non-recursive) + + Args: + path: Path to the directory to create + + Example: + >>> await fs.mkdir('/data/new_dir') + """ + normalized_path = self._normalize_path(path) + + existing = await self._resolve_path(normalized_path) + if existing is not None: + raise ErrnoException( + code="EEXIST", + syscall="mkdir", + path=normalized_path, + message="file already exists", + ) + + parent = await self._resolve_parent(normalized_path) + if not parent: + raise ErrnoException( + code="ENOENT", + syscall="mkdir", + path=normalized_path, + message="no such file or directory", + ) + + parent_ino, name = parent + await assert_inode_is_directory(self._db, parent_ino, "mkdir", normalized_path) + + dir_ino = await self._create_inode(DEFAULT_DIR_MODE) + try: + await self._create_dentry(parent_ino, name, dir_ino) + except Exception: + raise ErrnoException( + code="EEXIST", + syscall="mkdir", + path=normalized_path, + message="file already exists", + ) + + async def rmdir(self, path: str) -> None: + """Remove an empty directory + + Args: + path: Path to the directory to remove + + Example: + >>> await fs.rmdir('/data/empty_dir') + """ + normalized_path = self._normalize_path(path) + assert_not_root(normalized_path, "rmdir") + + normalized_path, ino = await self._resolve_path_or_throw(normalized_path, "rmdir") + + mode = await get_inode_mode_or_throw(self._db, ino, "rmdir", normalized_path) + assert_not_symlink_mode(mode, "rmdir", normalized_path) + if (mode & S_IFMT) != S_IFDIR: + raise ErrnoException( + code="ENOTDIR", + syscall="rmdir", + path=normalized_path, + message="not a directory", + ) + + cursor = await self._db.execute( + """ + SELECT 1 as one FROM fs_dentry + WHERE parent_ino = ? + LIMIT 1 + """, + (ino,), + ) + child = await cursor.fetchone() + if child: + raise ErrnoException( + code="ENOTEMPTY", + syscall="rmdir", + path=normalized_path, + message="directory not empty", + ) + + parent = await self._resolve_parent(normalized_path) + if not parent: + raise ErrnoException( + code="EPERM", + syscall="rmdir", + path=normalized_path, + message="operation not permitted", + ) + + parent_ino, name = parent + await self._remove_dentry_and_maybe_inode(parent_ino, name, ino) + + async def rm( + self, + path: str, + force: bool = False, + recursive: bool = False, + ) -> None: + """Remove a file or directory + + Args: + path: Path to remove + force: If True, ignore nonexistent files + recursive: If True, remove directories and their contents recursively + + Example: + >>> await fs.rm('/data/file.txt') + >>> await fs.rm('/data/dir', recursive=True) + """ + normalized_path = self._normalize_path(path) + options = normalize_rm_options({"force": force, "recursive": recursive}) + force = options["force"] + recursive = options["recursive"] + assert_not_root(normalized_path, "rm") + + ino = await self._resolve_path(normalized_path) + if ino is None: + throw_enoent_unless_force(normalized_path, "rm", force) + return + + mode = await get_inode_mode_or_throw(self._db, ino, "rm", normalized_path) + assert_not_symlink_mode(mode, "rm", normalized_path) + + parent = await self._resolve_parent(normalized_path) + if not parent: + raise ErrnoException( + code="EPERM", + syscall="rm", + path=normalized_path, + message="operation not permitted", + ) + + parent_ino, name = parent + + if (mode & S_IFMT) == S_IFDIR: + if not recursive: + raise ErrnoException( + code="EISDIR", + syscall="rm", + path=normalized_path, + message="illegal operation on a directory", + ) + + await self._rm_dir_contents_recursive(ino) + await self._remove_dentry_and_maybe_inode(parent_ino, name, ino) + return + + # Regular file + await self._remove_dentry_and_maybe_inode(parent_ino, name, ino) + + async def _rm_dir_contents_recursive(self, dir_ino: int) -> None: + """Recursively remove directory contents""" + cursor = await self._db.execute( + """ + SELECT name, ino FROM fs_dentry + WHERE parent_ino = ? + ORDER BY name ASC + """, + (dir_ino,), + ) + children = await cursor.fetchall() + + for name, child_ino in children: + mode = await self._get_inode_mode(child_ino) + if mode is None: + # DB inconsistency; treat as already gone + continue + + if (mode & S_IFMT) == S_IFDIR: + await self._rm_dir_contents_recursive(child_ino) + await self._remove_dentry_and_maybe_inode(dir_ino, name, child_ino) + else: + # Not supported yet (symlinks) + assert_not_symlink_mode(mode, "rm", "") + await self._remove_dentry_and_maybe_inode(dir_ino, name, child_ino) + + async def _remove_dentry_and_maybe_inode(self, parent_ino: int, name: str, ino: int) -> None: + """Remove directory entry and inode if last link""" + await self._db.execute( + """ + DELETE FROM fs_dentry + WHERE parent_ino = ? AND name = ? + """, + (parent_ino, name), + ) + + # Decrement link count + await self._db.execute( + "UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?", + (ino,), + ) + + link_count = await self._get_link_count(ino) + if link_count == 0: + await self._db.execute("DELETE FROM fs_inode WHERE ino = ?", (ino,)) + await self._db.execute("DELETE FROM fs_data WHERE ino = ?", (ino,)) + + await self._db.commit() + + async def rename(self, old_path: str, new_path: str) -> None: + """Rename (move) a file or directory + + Args: + old_path: Current path + new_path: New path + + Example: + >>> await fs.rename('/data/old.txt', '/data/new.txt') + """ + old_normalized = self._normalize_path(old_path) + new_normalized = self._normalize_path(new_path) + + # No-op + if old_normalized == new_normalized: + return + + assert_not_root(old_normalized, "rename") + assert_not_root(new_normalized, "rename") + + old_parent = await self._resolve_parent(old_normalized) + if not old_parent: + raise ErrnoException( + code="EPERM", + syscall="rename", + path=old_normalized, + message="operation not permitted", + ) + + new_parent = await self._resolve_parent(new_normalized) + if not new_parent: + raise ErrnoException( + code="ENOENT", + syscall="rename", + path=new_normalized, + message="no such file or directory", + ) + + new_parent_ino, new_name = new_parent + + # Ensure destination parent exists and is a directory + await assert_inode_is_directory(self._db, new_parent_ino, "rename", new_normalized) + + # Begin transaction + # Note: turso.aio doesn't support explicit BEGIN, but execute should be atomic + try: + old_normalized, old_ino = await self._resolve_path_or_throw(old_normalized, "rename") + old_mode = await get_inode_mode_or_throw(self._db, old_ino, "rename", old_normalized) + assert_not_symlink_mode(old_mode, "rename", old_normalized) + old_is_dir = (old_mode & S_IFMT) == S_IFDIR + + # Prevent renaming a directory into its own subtree (would create cycles) + if old_is_dir and new_normalized.startswith(old_normalized + "/"): + raise ErrnoException( + code="EINVAL", + syscall="rename", + path=new_normalized, + message="invalid argument", + ) + + new_ino = await self._resolve_path(new_normalized) + if new_ino is not None: + new_mode = await get_inode_mode_or_throw( + self._db, new_ino, "rename", new_normalized + ) + assert_not_symlink_mode(new_mode, "rename", new_normalized) + new_is_dir = (new_mode & S_IFMT) == S_IFDIR + + if new_is_dir and not old_is_dir: + raise ErrnoException( + code="EISDIR", + syscall="rename", + path=new_normalized, + message="illegal operation on a directory", + ) + if not new_is_dir and old_is_dir: + raise ErrnoException( + code="ENOTDIR", + syscall="rename", + path=new_normalized, + message="not a directory", + ) + + # If replacing a directory, it must be empty + if new_is_dir: + cursor = await self._db.execute( + """ + SELECT 1 as one FROM fs_dentry + WHERE parent_ino = ? + LIMIT 1 + """, + (new_ino,), + ) + child = await cursor.fetchone() + if child: + raise ErrnoException( + code="ENOTEMPTY", + syscall="rename", + path=new_normalized, + message="directory not empty", + ) + + # Remove the destination entry (and inode if this was the last link) + await self._remove_dentry_and_maybe_inode(new_parent_ino, new_name, new_ino) + + # Move the directory entry + old_parent_ino, old_name = old_parent + await self._db.execute( + """ + UPDATE fs_dentry + SET parent_ino = ?, name = ? + WHERE parent_ino = ? AND name = ? + """, + (new_parent_ino, new_name, old_parent_ino, old_name), + ) + + # Update timestamps + now = int(time.time()) + await self._db.execute( + """ + UPDATE fs_inode + SET ctime = ? + WHERE ino = ? + """, + (now, old_ino), + ) + + await self._db.execute( + """ + UPDATE fs_inode + SET mtime = ?, ctime = ? + WHERE ino = ? + """, + (now, now, old_parent_ino), + ) + if new_parent_ino != old_parent_ino: + await self._db.execute( + """ + UPDATE fs_inode + SET mtime = ?, ctime = ? + WHERE ino = ? + """, + (now, now, new_parent_ino), + ) + + await self._db.commit() + except Exception: + # turso.aio doesn't have explicit rollback, changes are rolled back automatically + raise + + async def copy_file(self, src: str, dest: str) -> None: + """Copy a file. Overwrites destination if it exists. + + Args: + src: Source file path + dest: Destination file path + + Example: + >>> await fs.copy_file('/data/src.txt', '/data/dest.txt') + """ + src_normalized = self._normalize_path(src) + dest_normalized = self._normalize_path(dest) + + if src_normalized == dest_normalized: + raise ErrnoException( + code="EINVAL", + syscall="copyfile", + path=dest_normalized, + message="invalid argument", + ) + + # Resolve and validate source + src_normalized, src_ino = await self._resolve_path_or_throw(src_normalized, "copyfile") + await assert_readable_existing_inode(self._db, src_ino, "copyfile", src_normalized) + + cursor = await self._db.execute( + """ + SELECT mode, uid, gid, size FROM fs_inode WHERE ino = ? + """, + (src_ino,), + ) + src_row = await cursor.fetchone() + if not src_row: + raise ErrnoException( + code="ENOENT", + syscall="copyfile", + path=src_normalized, + message="no such file or directory", + ) + + src_mode, src_uid, src_gid, src_size = src_row + + # Destination parent must exist and be a directory + dest_parent = await self._resolve_parent(dest_normalized) + if not dest_parent: + raise ErrnoException( + code="ENOENT", + syscall="copyfile", + path=dest_normalized, + message="no such file or directory", + ) + + dest_parent_ino, dest_name = dest_parent + await assert_inode_is_directory(self._db, dest_parent_ino, "copyfile", dest_normalized) + + try: + now = int(time.time()) + + # If destination exists, it must be a file (overwrite semantics) + dest_ino = await self._resolve_path(dest_normalized) + if dest_ino is not None: + dest_mode = await get_inode_mode_or_throw( + self._db, dest_ino, "copyfile", dest_normalized + ) + assert_not_symlink_mode(dest_mode, "copyfile", dest_normalized) + if (dest_mode & S_IFMT) == S_IFDIR: + raise ErrnoException( + code="EISDIR", + syscall="copyfile", + path=dest_normalized, + message="illegal operation on a directory", + ) + + # Replace destination contents + await self._db.execute("DELETE FROM fs_data WHERE ino = ?", (dest_ino,)) + await self._db.commit() + + # Copy data chunks + cursor = await self._db.execute( + """ + SELECT chunk_index, data FROM fs_data + WHERE ino = ? + ORDER BY chunk_index ASC + """, + (src_ino,), + ) + src_chunks = await cursor.fetchall() + for chunk_index, data in src_chunks: + await self._db.execute( + """ + INSERT INTO fs_data (ino, chunk_index, data) + VALUES (?, ?, ?) + """, + (dest_ino, chunk_index, data), + ) + + await self._db.execute( + """ + UPDATE fs_inode + SET mode = ?, uid = ?, gid = ?, size = ?, mtime = ?, ctime = ? + WHERE ino = ? + """, + (src_mode, src_uid, src_gid, src_size, now, now, dest_ino), + ) + else: + # Create new destination inode + dentry + dest_ino_created = await self._create_inode(src_mode, src_uid, src_gid) + await self._create_dentry(dest_parent_ino, dest_name, dest_ino_created) + + # Copy data chunks + cursor = await self._db.execute( + """ + SELECT chunk_index, data FROM fs_data + WHERE ino = ? + ORDER BY chunk_index ASC + """, + (src_ino,), + ) + src_chunks = await cursor.fetchall() + for chunk_index, data in src_chunks: + await self._db.execute( + """ + INSERT INTO fs_data (ino, chunk_index, data) + VALUES (?, ?, ?) + """, + (dest_ino_created, chunk_index, data), + ) + + await self._db.execute( + """ + UPDATE fs_inode + SET size = ?, mtime = ?, ctime = ? + WHERE ino = ? + """, + (src_size, now, now, dest_ino_created), + ) + + await self._db.commit() + except Exception: + raise + + async def access(self, path: str) -> None: + """Test a user's permissions for the file or directory. + Currently supports existence checks only (F_OK semantics). + + Args: + path: Path to check + + Example: + >>> await fs.access('/data/config.json') + """ + normalized_path = self._normalize_path(path) + ino = await self._resolve_path(normalized_path) + if ino is None: + raise ErrnoException( + code="ENOENT", + syscall="access", + path=normalized_path, + message="no such file or directory", + ) diff --git a/sdk/python/agentfs_sdk/guards.py b/sdk/python/agentfs_sdk/guards.py new file mode 100644 index 00000000..5b618751 --- /dev/null +++ b/sdk/python/agentfs_sdk/guards.py @@ -0,0 +1,199 @@ +"""Guard functions for filesystem operations validation""" + +from typing import Any, Dict, Optional + +from turso.aio import Connection + +from .constants import S_IFDIR, S_IFLNK, S_IFMT +from .errors import ErrnoException, FsSyscall + + +async def _get_inode_mode(db: Connection, ino: int) -> Optional[int]: + """Get mode for an inode""" + cursor = await db.execute("SELECT mode FROM fs_inode WHERE ino = ?", (ino,)) + row = await cursor.fetchone() + return row[0] if row else None + + +def _is_dir_mode(mode: int) -> bool: + """Check if mode represents a directory""" + return (mode & S_IFMT) == S_IFDIR + + +async def get_inode_mode_or_throw( + db: Connection, + ino: int, + syscall: FsSyscall, + path: str, +) -> int: + """Get inode mode or throw ENOENT if not found""" + mode = await _get_inode_mode(db, ino) + if mode is None: + raise ErrnoException( + code="ENOENT", + syscall=syscall, + path=path, + message="no such file or directory", + ) + return mode + + +def assert_not_root(path: str, syscall: FsSyscall) -> None: + """Assert that path is not root directory""" + if path == "/": + raise ErrnoException( + code="EPERM", + syscall=syscall, + path=path, + message="operation not permitted on root directory", + ) + + +def normalize_rm_options(options: Optional[Dict[str, Any]]) -> Dict[str, bool]: + """Normalize rm options to ensure force and recursive are booleans""" + return { + "force": options.get("force", False) if options else False, + "recursive": options.get("recursive", False) if options else False, + } + + +def throw_enoent_unless_force(path: str, syscall: FsSyscall, force: bool) -> None: + """Throw ENOENT unless force flag is set""" + if force: + return + raise ErrnoException( + code="ENOENT", + syscall=syscall, + path=path, + message="no such file or directory", + ) + + +def assert_not_symlink_mode(mode: int, syscall: FsSyscall, path: str) -> None: + """Assert that mode does not represent a symlink""" + if (mode & S_IFMT) == S_IFLNK: + raise ErrnoException( + code="ENOSYS", + syscall=syscall, + path=path, + message="symbolic links not supported yet", + ) + + +async def _assert_existing_non_dir_non_symlink_inode( + db: Connection, + ino: int, + syscall: FsSyscall, + full_path_for_error: str, +) -> None: + """Assert inode exists and is neither directory nor symlink""" + mode = await _get_inode_mode(db, ino) + if mode is None: + raise ErrnoException( + code="ENOENT", + syscall=syscall, + path=full_path_for_error, + message="no such file or directory", + ) + if _is_dir_mode(mode): + raise ErrnoException( + code="EISDIR", + syscall=syscall, + path=full_path_for_error, + message="illegal operation on a directory", + ) + assert_not_symlink_mode(mode, syscall, full_path_for_error) + + +async def assert_inode_is_directory( + db: Connection, + ino: int, + syscall: FsSyscall, + full_path_for_error: str, +) -> None: + """Assert that inode is a directory""" + mode = await _get_inode_mode(db, ino) + if mode is None: + raise ErrnoException( + code="ENOENT", + syscall=syscall, + path=full_path_for_error, + message="no such file or directory", + ) + if not _is_dir_mode(mode): + raise ErrnoException( + code="ENOTDIR", + syscall=syscall, + path=full_path_for_error, + message="not a directory", + ) + + +async def assert_writable_existing_inode( + db: Connection, + ino: int, + syscall: FsSyscall, + full_path_for_error: str, +) -> None: + """Assert inode is writable (exists and is not directory/symlink)""" + await _assert_existing_non_dir_non_symlink_inode(db, ino, syscall, full_path_for_error) + + +async def assert_readable_existing_inode( + db: Connection, + ino: int, + syscall: FsSyscall, + full_path_for_error: str, +) -> None: + """Assert inode is readable (exists and is not directory/symlink)""" + await _assert_existing_non_dir_non_symlink_inode(db, ino, syscall, full_path_for_error) + + +async def assert_readdir_target_inode( + db: Connection, + ino: int, + full_path_for_error: str, +) -> None: + """Assert inode is a valid readdir target (directory, not symlink)""" + syscall: FsSyscall = "scandir" + mode = await _get_inode_mode(db, ino) + if mode is None: + raise ErrnoException( + code="ENOENT", + syscall=syscall, + path=full_path_for_error, + message="no such file or directory", + ) + assert_not_symlink_mode(mode, syscall, full_path_for_error) + if not _is_dir_mode(mode): + raise ErrnoException( + code="ENOTDIR", + syscall=syscall, + path=full_path_for_error, + message="not a directory", + ) + + +async def assert_unlink_target_inode( + db: Connection, + ino: int, + full_path_for_error: str, +) -> None: + """Assert inode is a valid unlink target (file, not directory/symlink)""" + syscall: FsSyscall = "unlink" + mode = await _get_inode_mode(db, ino) + if mode is None: + raise ErrnoException( + code="ENOENT", + syscall=syscall, + path=full_path_for_error, + message="no such file or directory", + ) + if _is_dir_mode(mode): + raise ErrnoException( + code="EISDIR", + syscall=syscall, + path=full_path_for_error, + message="illegal operation on a directory", + ) + assert_not_symlink_mode(mode, syscall, full_path_for_error) diff --git a/sdk/python/tests/test_filesystem.py b/sdk/python/tests/test_filesystem.py index 2a0c49c1..5154a325 100644 --- a/sdk/python/tests/test_filesystem.py +++ b/sdk/python/tests/test_filesystem.py @@ -6,7 +6,7 @@ import pytest from turso.aio import connect -from agentfs_sdk import Filesystem +from agentfs_sdk import ErrnoException, Filesystem @pytest.mark.asyncio @@ -107,7 +107,7 @@ async def test_error_reading_nonexistent_file(self): await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") fs = await Filesystem.from_database(db) - with pytest.raises(FileNotFoundError): + with pytest.raises(ErrnoException): await fs.read_file("/non-existent.txt") await db.close() @@ -237,7 +237,7 @@ async def test_delete_existing_file(self): await fs.write_file("/delete-me.txt", "content") await fs.delete_file("/delete-me.txt") - with pytest.raises(FileNotFoundError): + with pytest.raises(ErrnoException): await fs.read_file("/delete-me.txt") await db.close() @@ -249,7 +249,7 @@ async def test_delete_nonexistent_file(self): await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") fs = await Filesystem.from_database(db) - with pytest.raises(FileNotFoundError, match="ENOENT"): + with pytest.raises(ErrnoException, match="ENOENT"): await fs.delete_file("/non-existent.txt") await db.close() @@ -721,6 +721,533 @@ async def test_stat_nonexistent_path(self): await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") fs = await Filesystem.from_database(db) - with pytest.raises(FileNotFoundError, match="ENOENT"): + with pytest.raises(ErrnoException, match="ENOENT"): await fs.stat("/nonexistent") await db.close() + + +@pytest.mark.asyncio +class TestFilesystemMkdir: + """Tests for mkdir() operation""" + + async def test_create_directory(self): + """Should create a directory with mkdir()""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.mkdir("/newdir") + entries = await fs.readdir("/") + assert "newdir" in entries + await db.close() + + async def test_mkdir_throws_eexist_for_existing_directory(self): + """Should throw EEXIST when mkdir() is called on an existing directory""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.mkdir("/exists") + with pytest.raises(ErrnoException, match="EEXIST"): + await fs.mkdir("/exists") + await db.close() + + async def test_mkdir_throws_enoent_for_missing_parent(self): + """Should throw ENOENT when parent directory does not exist""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.mkdir("/missing-parent/child") + await db.close() + + +@pytest.mark.asyncio +class TestFilesystemRm: + """Tests for rm() operation""" + + async def test_remove_file(self): + """Should remove a file""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/rmfile.txt", "content") + await fs.rm("/rmfile.txt") + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.read_file("/rmfile.txt") + await db.close() + + async def test_rm_force_does_not_throw_for_missing_file(self): + """Should not throw when force=True and path does not exist""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + # Should not raise + await fs.rm("/does-not-exist", force=True) + await db.close() + + async def test_rm_throws_enoent_without_force(self): + """Should throw ENOENT when force=False and path does not exist""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.rm("/does-not-exist") + await db.close() + + async def test_rm_throws_eisdir_for_directory_without_recursive(self): + """Should throw EISDIR when trying to rm a directory without recursive""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.mkdir("/rmdir") + with pytest.raises(ErrnoException, match="EISDIR"): + await fs.rm("/rmdir") + await db.close() + + async def test_rm_recursive_removes_directory_tree(self): + """Should remove a directory recursively""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/tree/a/b/c.txt", "content") + await fs.rm("/tree", recursive=True) + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.readdir("/tree") + root = await fs.readdir("/") + assert "tree" not in root + await db.close() + + +@pytest.mark.asyncio +class TestFilesystemRmdir: + """Tests for rmdir() operation""" + + async def test_remove_empty_directory(self): + """Should remove an empty directory""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.mkdir("/emptydir") + await fs.rmdir("/emptydir") + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.readdir("/emptydir") + root = await fs.readdir("/") + assert "emptydir" not in root + await db.close() + + async def test_rmdir_throws_enotempty_for_non_empty_directory(self): + """Should throw ENOTEMPTY when directory is not empty""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/nonempty/file.txt", "content") + with pytest.raises(ErrnoException, match="ENOTEMPTY"): + await fs.rmdir("/nonempty") + await db.close() + + async def test_rmdir_throws_enotdir_for_file(self): + """Should throw ENOTDIR when path is a file""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/afile", "content") + with pytest.raises(ErrnoException, match="ENOTDIR"): + await fs.rmdir("/afile") + await db.close() + + async def test_rmdir_throws_eperm_for_root(self): + """Should throw EPERM when attempting to remove root""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + with pytest.raises(ErrnoException, match="EPERM"): + await fs.rmdir("/") + await db.close() + + +@pytest.mark.asyncio +class TestFilesystemRename: + """Tests for rename() operation""" + + async def test_rename_file(self): + """Should rename a file""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/a.txt", "hello") + await fs.rename("/a.txt", "/b.txt") + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.read_file("/a.txt") + content = await fs.read_file("/b.txt", "utf-8") + assert content == "hello" + await db.close() + + async def test_rename_directory_preserves_contents(self): + """Should rename a directory and preserve its contents""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/olddir/sub/file.txt", "content") + await fs.rename("/olddir", "/newdir") + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.readdir("/olddir") + content = await fs.read_file("/newdir/sub/file.txt", "utf-8") + assert content == "content" + await db.close() + + async def test_rename_overwrites_destination_file(self): + """Should overwrite destination file if it exists""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/src.txt", "src") + await fs.write_file("/dst.txt", "dst") + await fs.rename("/src.txt", "/dst.txt") + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.read_file("/src.txt") + content = await fs.read_file("/dst.txt", "utf-8") + assert content == "src" + await db.close() + + async def test_rename_throws_eisdir_for_file_to_directory(self): + """Should throw EISDIR when renaming a file onto a directory""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/dir/file.txt", "content") + await fs.write_file("/file.txt", "content") + with pytest.raises(ErrnoException, match="EISDIR"): + await fs.rename("/file.txt", "/dir") + await db.close() + + async def test_rename_throws_enotdir_for_directory_to_file(self): + """Should throw ENOTDIR when renaming a directory onto a file""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.mkdir("/somedir") + await fs.write_file("/somefile", "content") + with pytest.raises(ErrnoException, match="ENOTDIR"): + await fs.rename("/somedir", "/somefile") + await db.close() + + async def test_rename_replaces_empty_directory(self): + """Should replace an existing empty directory""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.mkdir("/fromdir") + await fs.mkdir("/todir") + await fs.rename("/fromdir", "/todir") + root = await fs.readdir("/") + assert "todir" in root + assert "fromdir" not in root + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.readdir("/fromdir") + await db.close() + + async def test_rename_throws_enotempty_for_non_empty_destination(self): + """Should throw ENOTEMPTY when replacing a non-empty directory""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.mkdir("/fromdir") + await fs.write_file("/todir/file.txt", "content") + with pytest.raises(ErrnoException, match="ENOTEMPTY"): + await fs.rename("/fromdir", "/todir") + await db.close() + + async def test_rename_throws_eperm_for_root(self): + """Should throw EPERM when attempting to rename root""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + with pytest.raises(ErrnoException, match="EPERM"): + await fs.rename("/", "/x") + await db.close() + + async def test_rename_throws_einval_for_directory_into_subdirectory(self): + """Should throw EINVAL when renaming a directory into its own subdirectory""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/cycle/sub/file.txt", "content") + with pytest.raises(ErrnoException, match="EINVAL"): + await fs.rename("/cycle", "/cycle/sub/moved") + await db.close() + + +@pytest.mark.asyncio +class TestFilesystemCopyFile: + """Tests for copy_file() operation""" + + async def test_copy_file(self): + """Should copy a file""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/src.txt", "hello") + await fs.copy_file("/src.txt", "/dst.txt") + src_content = await fs.read_file("/src.txt", "utf-8") + dst_content = await fs.read_file("/dst.txt", "utf-8") + assert src_content == "hello" + assert dst_content == "hello" + await db.close() + + async def test_copy_file_overwrites_destination(self): + """Should overwrite destination if it exists""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/src.txt", "src") + await fs.write_file("/dst.txt", "dst") + await fs.copy_file("/src.txt", "/dst.txt") + dst_content = await fs.read_file("/dst.txt", "utf-8") + assert dst_content == "src" + await db.close() + + async def test_copy_file_throws_enoent_for_missing_source(self): + """Should throw ENOENT when source does not exist""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.copy_file("/nope.txt", "/out.txt") + await db.close() + + async def test_copy_file_throws_enoent_for_missing_destination_parent(self): + """Should throw ENOENT when destination parent does not exist""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/src3.txt", "content") + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.copy_file("/src3.txt", "/missing/child.txt") + await db.close() + + async def test_copy_file_throws_eisdir_for_directory_source(self): + """Should throw EISDIR when source is a directory""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.mkdir("/asrcdir") + with pytest.raises(ErrnoException, match="EISDIR"): + await fs.copy_file("/asrcdir", "/out2.txt") + await db.close() + + async def test_copy_file_throws_eisdir_for_directory_destination(self): + """Should throw EISDIR when destination is a directory""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/src4.txt", "content") + await fs.mkdir("/adstdir") + with pytest.raises(ErrnoException, match="EISDIR"): + await fs.copy_file("/src4.txt", "/adstdir") + await db.close() + + async def test_copy_file_throws_einval_for_same_source_and_destination(self): + """Should throw EINVAL when source and destination are the same""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/same.txt", "content") + with pytest.raises(ErrnoException, match="EINVAL"): + await fs.copy_file("/same.txt", "/same.txt") + await db.close() + + +@pytest.mark.asyncio +class TestFilesystemAccess: + """Tests for access() operation""" + + async def test_access_existing_file(self): + """Should resolve when a file exists""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/exists.txt", "content") + # Should not raise + await fs.access("/exists.txt") + await db.close() + + async def test_access_existing_directory(self): + """Should resolve when a directory exists""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.mkdir("/existsdir") + # Should not raise + await fs.access("/existsdir") + await db.close() + + async def test_access_throws_enoent_for_nonexistent_path(self): + """Should throw ENOENT when path does not exist""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + with pytest.raises(ErrnoException, match="ENOENT"): + await fs.access("/does-not-exist") + await db.close() + + +@pytest.mark.asyncio +class TestFilesystemErrorCodes: + """Tests for error code validation on existing methods""" + + async def test_write_file_throws_eisdir_for_directory(self): + """Should throw EISDIR when attempting to write to a directory path""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/dir/file.txt", "content") + with pytest.raises(ErrnoException, match="EISDIR"): + await fs.write_file("/dir", "nope") + await db.close() + + async def test_write_file_throws_enotdir_for_file_in_path(self): + """Should throw ENOTDIR when a parent path component is a file""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/a", "file-content") + with pytest.raises(ErrnoException, match="ENOTDIR"): + await fs.write_file("/a/b.txt", "child") + await db.close() + + async def test_read_file_throws_eisdir_for_directory(self): + """Should throw EISDIR when attempting to read a directory path""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/dir/file.txt", "content") + with pytest.raises(ErrnoException, match="EISDIR"): + await fs.read_file("/dir") + await db.close() + + async def test_readdir_throws_enotdir_for_file(self): + """Should throw ENOTDIR when attempting to readdir a file path""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/notadir.txt", "content") + with pytest.raises(ErrnoException, match="ENOTDIR"): + await fs.readdir("/notadir.txt") + await db.close() + + async def test_unlink_throws_eisdir_for_directory(self): + """Should throw EISDIR when attempting to unlink a directory""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + db = await connect(db_path) + await db.execute("PRAGMA unstable_capture_data_changes_conn('full')") + fs = await Filesystem.from_database(db) + + await fs.write_file("/adir/file.txt", "content") + with pytest.raises(ErrnoException, match="EISDIR"): + await fs.unlink("/adir") + await db.close()