just-bash 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. just_bash/__init__.py +55 -0
  2. just_bash/ast/__init__.py +213 -0
  3. just_bash/ast/factory.py +320 -0
  4. just_bash/ast/types.py +953 -0
  5. just_bash/bash.py +220 -0
  6. just_bash/commands/__init__.py +23 -0
  7. just_bash/commands/argv/__init__.py +5 -0
  8. just_bash/commands/argv/argv.py +21 -0
  9. just_bash/commands/awk/__init__.py +5 -0
  10. just_bash/commands/awk/awk.py +1168 -0
  11. just_bash/commands/base64/__init__.py +5 -0
  12. just_bash/commands/base64/base64.py +138 -0
  13. just_bash/commands/basename/__init__.py +5 -0
  14. just_bash/commands/basename/basename.py +72 -0
  15. just_bash/commands/bash/__init__.py +5 -0
  16. just_bash/commands/bash/bash.py +188 -0
  17. just_bash/commands/cat/__init__.py +5 -0
  18. just_bash/commands/cat/cat.py +173 -0
  19. just_bash/commands/checksum/__init__.py +5 -0
  20. just_bash/commands/checksum/checksum.py +179 -0
  21. just_bash/commands/chmod/__init__.py +5 -0
  22. just_bash/commands/chmod/chmod.py +216 -0
  23. just_bash/commands/column/__init__.py +5 -0
  24. just_bash/commands/column/column.py +180 -0
  25. just_bash/commands/comm/__init__.py +5 -0
  26. just_bash/commands/comm/comm.py +150 -0
  27. just_bash/commands/compression/__init__.py +5 -0
  28. just_bash/commands/compression/compression.py +298 -0
  29. just_bash/commands/cp/__init__.py +5 -0
  30. just_bash/commands/cp/cp.py +149 -0
  31. just_bash/commands/curl/__init__.py +5 -0
  32. just_bash/commands/curl/curl.py +801 -0
  33. just_bash/commands/cut/__init__.py +5 -0
  34. just_bash/commands/cut/cut.py +327 -0
  35. just_bash/commands/date/__init__.py +5 -0
  36. just_bash/commands/date/date.py +258 -0
  37. just_bash/commands/diff/__init__.py +5 -0
  38. just_bash/commands/diff/diff.py +118 -0
  39. just_bash/commands/dirname/__init__.py +5 -0
  40. just_bash/commands/dirname/dirname.py +56 -0
  41. just_bash/commands/du/__init__.py +5 -0
  42. just_bash/commands/du/du.py +150 -0
  43. just_bash/commands/echo/__init__.py +5 -0
  44. just_bash/commands/echo/echo.py +125 -0
  45. just_bash/commands/env/__init__.py +5 -0
  46. just_bash/commands/env/env.py +163 -0
  47. just_bash/commands/expand/__init__.py +5 -0
  48. just_bash/commands/expand/expand.py +299 -0
  49. just_bash/commands/expr/__init__.py +5 -0
  50. just_bash/commands/expr/expr.py +273 -0
  51. just_bash/commands/file/__init__.py +5 -0
  52. just_bash/commands/file/file.py +274 -0
  53. just_bash/commands/find/__init__.py +5 -0
  54. just_bash/commands/find/find.py +623 -0
  55. just_bash/commands/fold/__init__.py +5 -0
  56. just_bash/commands/fold/fold.py +160 -0
  57. just_bash/commands/grep/__init__.py +5 -0
  58. just_bash/commands/grep/grep.py +418 -0
  59. just_bash/commands/head/__init__.py +5 -0
  60. just_bash/commands/head/head.py +167 -0
  61. just_bash/commands/help/__init__.py +5 -0
  62. just_bash/commands/help/help.py +67 -0
  63. just_bash/commands/hostname/__init__.py +5 -0
  64. just_bash/commands/hostname/hostname.py +21 -0
  65. just_bash/commands/html_to_markdown/__init__.py +5 -0
  66. just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
  67. just_bash/commands/join/__init__.py +5 -0
  68. just_bash/commands/join/join.py +252 -0
  69. just_bash/commands/jq/__init__.py +5 -0
  70. just_bash/commands/jq/jq.py +280 -0
  71. just_bash/commands/ln/__init__.py +5 -0
  72. just_bash/commands/ln/ln.py +127 -0
  73. just_bash/commands/ls/__init__.py +5 -0
  74. just_bash/commands/ls/ls.py +280 -0
  75. just_bash/commands/mkdir/__init__.py +5 -0
  76. just_bash/commands/mkdir/mkdir.py +92 -0
  77. just_bash/commands/mv/__init__.py +5 -0
  78. just_bash/commands/mv/mv.py +142 -0
  79. just_bash/commands/nl/__init__.py +5 -0
  80. just_bash/commands/nl/nl.py +180 -0
  81. just_bash/commands/od/__init__.py +5 -0
  82. just_bash/commands/od/od.py +157 -0
  83. just_bash/commands/paste/__init__.py +5 -0
  84. just_bash/commands/paste/paste.py +100 -0
  85. just_bash/commands/printf/__init__.py +5 -0
  86. just_bash/commands/printf/printf.py +157 -0
  87. just_bash/commands/pwd/__init__.py +5 -0
  88. just_bash/commands/pwd/pwd.py +23 -0
  89. just_bash/commands/read/__init__.py +5 -0
  90. just_bash/commands/read/read.py +185 -0
  91. just_bash/commands/readlink/__init__.py +5 -0
  92. just_bash/commands/readlink/readlink.py +86 -0
  93. just_bash/commands/registry.py +844 -0
  94. just_bash/commands/rev/__init__.py +5 -0
  95. just_bash/commands/rev/rev.py +74 -0
  96. just_bash/commands/rg/__init__.py +5 -0
  97. just_bash/commands/rg/rg.py +1048 -0
  98. just_bash/commands/rm/__init__.py +5 -0
  99. just_bash/commands/rm/rm.py +106 -0
  100. just_bash/commands/search_engine/__init__.py +13 -0
  101. just_bash/commands/search_engine/matcher.py +170 -0
  102. just_bash/commands/search_engine/regex.py +159 -0
  103. just_bash/commands/sed/__init__.py +5 -0
  104. just_bash/commands/sed/sed.py +863 -0
  105. just_bash/commands/seq/__init__.py +5 -0
  106. just_bash/commands/seq/seq.py +190 -0
  107. just_bash/commands/shell/__init__.py +5 -0
  108. just_bash/commands/shell/shell.py +206 -0
  109. just_bash/commands/sleep/__init__.py +5 -0
  110. just_bash/commands/sleep/sleep.py +62 -0
  111. just_bash/commands/sort/__init__.py +5 -0
  112. just_bash/commands/sort/sort.py +411 -0
  113. just_bash/commands/split/__init__.py +5 -0
  114. just_bash/commands/split/split.py +237 -0
  115. just_bash/commands/sqlite3/__init__.py +5 -0
  116. just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
  117. just_bash/commands/stat/__init__.py +5 -0
  118. just_bash/commands/stat/stat.py +150 -0
  119. just_bash/commands/strings/__init__.py +5 -0
  120. just_bash/commands/strings/strings.py +150 -0
  121. just_bash/commands/tac/__init__.py +5 -0
  122. just_bash/commands/tac/tac.py +158 -0
  123. just_bash/commands/tail/__init__.py +5 -0
  124. just_bash/commands/tail/tail.py +180 -0
  125. just_bash/commands/tar/__init__.py +5 -0
  126. just_bash/commands/tar/tar.py +1067 -0
  127. just_bash/commands/tee/__init__.py +5 -0
  128. just_bash/commands/tee/tee.py +63 -0
  129. just_bash/commands/timeout/__init__.py +5 -0
  130. just_bash/commands/timeout/timeout.py +188 -0
  131. just_bash/commands/touch/__init__.py +5 -0
  132. just_bash/commands/touch/touch.py +91 -0
  133. just_bash/commands/tr/__init__.py +5 -0
  134. just_bash/commands/tr/tr.py +297 -0
  135. just_bash/commands/tree/__init__.py +5 -0
  136. just_bash/commands/tree/tree.py +139 -0
  137. just_bash/commands/true/__init__.py +5 -0
  138. just_bash/commands/true/true.py +32 -0
  139. just_bash/commands/uniq/__init__.py +5 -0
  140. just_bash/commands/uniq/uniq.py +323 -0
  141. just_bash/commands/wc/__init__.py +5 -0
  142. just_bash/commands/wc/wc.py +169 -0
  143. just_bash/commands/which/__init__.py +5 -0
  144. just_bash/commands/which/which.py +52 -0
  145. just_bash/commands/xan/__init__.py +5 -0
  146. just_bash/commands/xan/xan.py +1663 -0
  147. just_bash/commands/xargs/__init__.py +5 -0
  148. just_bash/commands/xargs/xargs.py +136 -0
  149. just_bash/commands/yq/__init__.py +5 -0
  150. just_bash/commands/yq/yq.py +848 -0
  151. just_bash/fs/__init__.py +29 -0
  152. just_bash/fs/in_memory_fs.py +621 -0
  153. just_bash/fs/mountable_fs.py +504 -0
  154. just_bash/fs/overlay_fs.py +894 -0
  155. just_bash/fs/read_write_fs.py +455 -0
  156. just_bash/interpreter/__init__.py +37 -0
  157. just_bash/interpreter/builtins/__init__.py +92 -0
  158. just_bash/interpreter/builtins/alias.py +154 -0
  159. just_bash/interpreter/builtins/cd.py +76 -0
  160. just_bash/interpreter/builtins/control.py +127 -0
  161. just_bash/interpreter/builtins/declare.py +336 -0
  162. just_bash/interpreter/builtins/export.py +56 -0
  163. just_bash/interpreter/builtins/let.py +44 -0
  164. just_bash/interpreter/builtins/local.py +57 -0
  165. just_bash/interpreter/builtins/mapfile.py +152 -0
  166. just_bash/interpreter/builtins/misc.py +378 -0
  167. just_bash/interpreter/builtins/readonly.py +80 -0
  168. just_bash/interpreter/builtins/set.py +234 -0
  169. just_bash/interpreter/builtins/shopt.py +201 -0
  170. just_bash/interpreter/builtins/source.py +136 -0
  171. just_bash/interpreter/builtins/test.py +290 -0
  172. just_bash/interpreter/builtins/unset.py +53 -0
  173. just_bash/interpreter/conditionals.py +387 -0
  174. just_bash/interpreter/control_flow.py +381 -0
  175. just_bash/interpreter/errors.py +116 -0
  176. just_bash/interpreter/expansion.py +1156 -0
  177. just_bash/interpreter/interpreter.py +813 -0
  178. just_bash/interpreter/types.py +134 -0
  179. just_bash/network/__init__.py +1 -0
  180. just_bash/parser/__init__.py +39 -0
  181. just_bash/parser/lexer.py +948 -0
  182. just_bash/parser/parser.py +2162 -0
  183. just_bash/py.typed +0 -0
  184. just_bash/query_engine/__init__.py +83 -0
  185. just_bash/query_engine/builtins/__init__.py +1283 -0
  186. just_bash/query_engine/evaluator.py +578 -0
  187. just_bash/query_engine/parser.py +525 -0
  188. just_bash/query_engine/tokenizer.py +329 -0
  189. just_bash/query_engine/types.py +373 -0
  190. just_bash/types.py +180 -0
  191. just_bash-0.1.5.dist-info/METADATA +410 -0
  192. just_bash-0.1.5.dist-info/RECORD +193 -0
  193. just_bash-0.1.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,29 @@
1
+ """Filesystem implementations for just-bash."""
2
+
3
+ from .in_memory_fs import (
4
+ InMemoryFs,
5
+ FileEntry,
6
+ DirectoryEntry,
7
+ SymlinkEntry,
8
+ FsEntry,
9
+ DirentEntry,
10
+ )
11
+ from .read_write_fs import ReadWriteFs, ReadWriteFsOptions
12
+ from .overlay_fs import OverlayFs, OverlayFsOptions
13
+ from .mountable_fs import MountableFs, MountableFsOptions, MountConfig
14
+
15
+ __all__ = [
16
+ "InMemoryFs",
17
+ "FileEntry",
18
+ "DirectoryEntry",
19
+ "SymlinkEntry",
20
+ "FsEntry",
21
+ "DirentEntry",
22
+ "ReadWriteFs",
23
+ "ReadWriteFsOptions",
24
+ "OverlayFs",
25
+ "OverlayFsOptions",
26
+ "MountableFs",
27
+ "MountableFsOptions",
28
+ "MountConfig",
29
+ ]
@@ -0,0 +1,621 @@
1
+ """
2
+ In-Memory Filesystem Implementation
3
+
4
+ A complete virtual filesystem that stores all files and directories in memory.
5
+ Designed for sandboxed execution without touching the real filesystem.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Union, Optional, Literal
13
+ from ..types import FsStat
14
+
15
+
16
+ @dataclass
17
+ class FileEntry:
18
+ """A file in the virtual filesystem."""
19
+
20
+ type: Literal["file"] = "file"
21
+ content: bytes = b""
22
+ mode: int = 0o644
23
+ mtime: float = field(default_factory=time.time)
24
+
25
+
26
+ @dataclass
27
+ class DirectoryEntry:
28
+ """A directory in the virtual filesystem."""
29
+
30
+ type: Literal["directory"] = "directory"
31
+ mode: int = 0o755
32
+ mtime: float = field(default_factory=time.time)
33
+
34
+
35
+ @dataclass
36
+ class SymlinkEntry:
37
+ """A symbolic link in the virtual filesystem."""
38
+
39
+ type: Literal["symlink"] = "symlink"
40
+ target: str = ""
41
+ mode: int = 0o777
42
+ mtime: float = field(default_factory=time.time)
43
+
44
+
45
+ FsEntry = Union[FileEntry, DirectoryEntry, SymlinkEntry]
46
+
47
+
48
+ @dataclass
49
+ class DirentEntry:
50
+ """Directory entry information."""
51
+
52
+ name: str
53
+ is_file: bool = False
54
+ is_directory: bool = False
55
+ is_symbolic_link: bool = False
56
+
57
+
58
+ class InMemoryFs:
59
+ """In-memory filesystem implementation."""
60
+
61
+ def __init__(self, initial_files: dict[str, str | bytes] | None = None) -> None:
62
+ """
63
+ Initialize the filesystem.
64
+
65
+ Args:
66
+ initial_files: Optional dict mapping paths to file contents
67
+ """
68
+ self._data: dict[str, FsEntry] = {}
69
+
70
+ # Create root directory
71
+ self._data["/"] = DirectoryEntry()
72
+
73
+ # Create default directory structure
74
+ self._create_default_structure()
75
+
76
+ # Add initial files
77
+ if initial_files:
78
+ for path, content in initial_files.items():
79
+ self._write_file_sync(path, content)
80
+
81
+ def _create_default_structure(self) -> None:
82
+ """Create default Unix-like directory structure."""
83
+ default_dirs = [
84
+ "/home",
85
+ "/home/user",
86
+ "/tmp",
87
+ "/bin",
88
+ "/usr",
89
+ "/usr/bin",
90
+ ]
91
+ for dir_path in default_dirs:
92
+ self._mkdir_sync(dir_path, recursive=True)
93
+
94
+ def _normalize_path(self, path: str) -> str:
95
+ """Normalize a path (resolve ., .., trailing slashes)."""
96
+ if not path or path == "/":
97
+ return "/"
98
+
99
+ # Remove trailing slash
100
+ if path.endswith("/") and path != "/":
101
+ path = path[:-1]
102
+
103
+ # Ensure starts with /
104
+ if not path.startswith("/"):
105
+ path = "/" + path
106
+
107
+ # Resolve . and ..
108
+ parts = path.split("/")
109
+ resolved: list[str] = []
110
+
111
+ for part in parts:
112
+ if part == "" or part == ".":
113
+ continue
114
+ elif part == "..":
115
+ if resolved:
116
+ resolved.pop()
117
+ else:
118
+ resolved.append(part)
119
+
120
+ return "/" + "/".join(resolved) if resolved else "/"
121
+
122
+ def _dirname(self, path: str) -> str:
123
+ """Get the directory name of a path."""
124
+ normalized = self._normalize_path(path)
125
+ if normalized == "/":
126
+ return "/"
127
+ last_slash = normalized.rfind("/")
128
+ return "/" if last_slash == 0 else normalized[:last_slash]
129
+
130
+ def _basename(self, path: str) -> str:
131
+ """Get the base name of a path."""
132
+ normalized = self._normalize_path(path)
133
+ if normalized == "/":
134
+ return ""
135
+ return normalized.rsplit("/", 1)[-1]
136
+
137
+ def _ensure_parent_dirs(self, path: str) -> None:
138
+ """Ensure all parent directories exist."""
139
+ dir_path = self._dirname(path)
140
+ if dir_path == "/":
141
+ return
142
+
143
+ if dir_path not in self._data:
144
+ self._ensure_parent_dirs(dir_path)
145
+ self._data[dir_path] = DirectoryEntry()
146
+
147
+ def _resolve_symlink(self, symlink_path: str, target: str) -> str:
148
+ """Resolve a symlink target to an absolute path."""
149
+ if target.startswith("/"):
150
+ return self._normalize_path(target)
151
+ # Relative target: resolve from symlink's directory
152
+ dir_path = self._dirname(symlink_path)
153
+ if dir_path == "/":
154
+ return self._normalize_path("/" + target)
155
+ return self._normalize_path(dir_path + "/" + target)
156
+
157
+ def _resolve_path_with_symlinks(self, path: str, max_loops: int = 40) -> str:
158
+ """
159
+ Resolve all symlinks in a path, including intermediate components.
160
+ """
161
+ normalized = self._normalize_path(path)
162
+ if normalized == "/":
163
+ return "/"
164
+
165
+ parts = normalized[1:].split("/") # Skip leading /
166
+ resolved_path = ""
167
+ seen: set[str] = set()
168
+
169
+ for part in parts:
170
+ resolved_path = f"{resolved_path}/{part}"
171
+
172
+ # Check if this path component is a symlink
173
+ entry = self._data.get(resolved_path)
174
+ loop_count = 0
175
+
176
+ while entry and entry.type == "symlink" and loop_count < max_loops:
177
+ if resolved_path in seen:
178
+ raise OSError(
179
+ f"ELOOP: too many levels of symbolic links, open '{path}'"
180
+ )
181
+ seen.add(resolved_path)
182
+ resolved_path = self._resolve_symlink(resolved_path, entry.target)
183
+ entry = self._data.get(resolved_path)
184
+ loop_count += 1
185
+
186
+ if loop_count >= max_loops:
187
+ raise OSError(
188
+ f"ELOOP: too many levels of symbolic links, open '{path}'"
189
+ )
190
+
191
+ return resolved_path
192
+
193
+ def _resolve_intermediate_symlinks(self, path: str, max_loops: int = 40) -> str:
194
+ """
195
+ Resolve symlinks in intermediate path components only (not the final).
196
+ Used by lstat which should not follow the final symlink.
197
+ """
198
+ normalized = self._normalize_path(path)
199
+ if normalized == "/":
200
+ return "/"
201
+
202
+ parts = normalized[1:].split("/")
203
+ if len(parts) <= 1:
204
+ return normalized
205
+
206
+ resolved_path = ""
207
+ seen: set[str] = set()
208
+
209
+ # Process all but the last component
210
+ for i in range(len(parts) - 1):
211
+ part = parts[i]
212
+ resolved_path = f"{resolved_path}/{part}"
213
+
214
+ entry = self._data.get(resolved_path)
215
+ loop_count = 0
216
+
217
+ while entry and entry.type == "symlink" and loop_count < max_loops:
218
+ if resolved_path in seen:
219
+ raise OSError(
220
+ f"ELOOP: too many levels of symbolic links, lstat '{path}'"
221
+ )
222
+ seen.add(resolved_path)
223
+ resolved_path = self._resolve_symlink(resolved_path, entry.target)
224
+ entry = self._data.get(resolved_path)
225
+ loop_count += 1
226
+
227
+ if loop_count >= max_loops:
228
+ raise OSError(
229
+ f"ELOOP: too many levels of symbolic links, lstat '{path}'"
230
+ )
231
+
232
+ # Append the final component without resolving
233
+ return f"{resolved_path}/{parts[-1]}"
234
+
235
+ # =========================================================================
236
+ # Sync methods (internal)
237
+ # =========================================================================
238
+
239
+ def _write_file_sync(
240
+ self,
241
+ path: str,
242
+ content: str | bytes,
243
+ encoding: str = "utf-8",
244
+ ) -> None:
245
+ """Synchronously write a file."""
246
+ normalized = self._normalize_path(path)
247
+ self._ensure_parent_dirs(normalized)
248
+
249
+ # Convert content to bytes
250
+ if isinstance(content, str):
251
+ content_bytes = content.encode(encoding)
252
+ else:
253
+ content_bytes = content
254
+
255
+ self._data[normalized] = FileEntry(content=content_bytes)
256
+
257
+ def _mkdir_sync(self, path: str, recursive: bool = False) -> None:
258
+ """Synchronously create a directory."""
259
+ normalized = self._normalize_path(path)
260
+
261
+ if normalized in self._data:
262
+ entry = self._data[normalized]
263
+ if entry.type == "file":
264
+ raise OSError(f"EEXIST: file already exists, mkdir '{path}'")
265
+ if not recursive:
266
+ raise OSError(f"EEXIST: directory already exists, mkdir '{path}'")
267
+ return # With recursive, silently succeed if directory exists
268
+
269
+ parent = self._dirname(normalized)
270
+ if parent != "/" and parent not in self._data:
271
+ if recursive:
272
+ self._mkdir_sync(parent, recursive=True)
273
+ else:
274
+ raise OSError(f"ENOENT: no such file or directory, mkdir '{path}'")
275
+
276
+ self._data[normalized] = DirectoryEntry()
277
+
278
+ # =========================================================================
279
+ # Async public API
280
+ # =========================================================================
281
+
282
+ async def read_file(self, path: str, encoding: str = "utf-8") -> str:
283
+ """Read file contents as string."""
284
+ buffer = await self.read_file_bytes(path)
285
+ return buffer.decode(encoding)
286
+
287
+ async def read_file_bytes(self, path: str) -> bytes:
288
+ """Read file contents as bytes."""
289
+ resolved_path = self._resolve_path_with_symlinks(path)
290
+ entry = self._data.get(resolved_path)
291
+
292
+ if entry is None:
293
+ raise FileNotFoundError(f"ENOENT: no such file or directory, open '{path}'")
294
+ if entry.type != "file":
295
+ raise IsADirectoryError(
296
+ f"EISDIR: illegal operation on a directory, read '{path}'"
297
+ )
298
+
299
+ return entry.content
300
+
301
+ async def write_file(
302
+ self,
303
+ path: str,
304
+ content: str | bytes,
305
+ encoding: str = "utf-8",
306
+ ) -> None:
307
+ """Write content to file."""
308
+ self._write_file_sync(path, content, encoding)
309
+
310
+ async def append_file(
311
+ self,
312
+ path: str,
313
+ content: str | bytes,
314
+ encoding: str = "utf-8",
315
+ ) -> None:
316
+ """Append content to file."""
317
+ normalized = self._normalize_path(path)
318
+ existing = self._data.get(normalized)
319
+
320
+ if existing and existing.type == "directory":
321
+ raise IsADirectoryError(
322
+ f"EISDIR: illegal operation on a directory, write '{path}'"
323
+ )
324
+
325
+ # Convert content to bytes
326
+ if isinstance(content, str):
327
+ new_bytes = content.encode(encoding)
328
+ else:
329
+ new_bytes = content
330
+
331
+ if existing and existing.type == "file":
332
+ combined = existing.content + new_bytes
333
+ self._data[normalized] = FileEntry(
334
+ content=combined,
335
+ mode=existing.mode,
336
+ )
337
+ else:
338
+ self._write_file_sync(path, content, encoding)
339
+
340
+ async def exists(self, path: str) -> bool:
341
+ """Check if path exists."""
342
+ try:
343
+ resolved_path = self._resolve_path_with_symlinks(path)
344
+ return resolved_path in self._data
345
+ except OSError:
346
+ # Path resolution failed (e.g., broken symlink)
347
+ return False
348
+
349
+ async def is_file(self, path: str) -> bool:
350
+ """Check if path is a file."""
351
+ try:
352
+ resolved_path = self._resolve_path_with_symlinks(path)
353
+ entry = self._data.get(resolved_path)
354
+ return entry is not None and entry.type == "file"
355
+ except OSError:
356
+ return False
357
+
358
+ async def is_directory(self, path: str) -> bool:
359
+ """Check if path is a directory."""
360
+ try:
361
+ resolved_path = self._resolve_path_with_symlinks(path)
362
+ entry = self._data.get(resolved_path)
363
+ return entry is not None and entry.type == "directory"
364
+ except OSError:
365
+ return False
366
+
367
+ async def stat(self, path: str) -> FsStat:
368
+ """Get file/directory stats (follows symlinks)."""
369
+ resolved_path = self._resolve_path_with_symlinks(path)
370
+ entry = self._data.get(resolved_path)
371
+
372
+ if entry is None:
373
+ raise FileNotFoundError(f"ENOENT: no such file or directory, stat '{path}'")
374
+
375
+ size = 0
376
+ if entry.type == "file":
377
+ size = len(entry.content)
378
+
379
+ return FsStat(
380
+ is_file=entry.type == "file",
381
+ is_directory=entry.type == "directory",
382
+ is_symbolic_link=False, # stat follows symlinks
383
+ mode=entry.mode,
384
+ size=size,
385
+ mtime=entry.mtime,
386
+ )
387
+
388
+ async def lstat(self, path: str) -> FsStat:
389
+ """Get file/directory stats (does not follow final symlink)."""
390
+ resolved_path = self._resolve_intermediate_symlinks(path)
391
+ entry = self._data.get(resolved_path)
392
+
393
+ if entry is None:
394
+ raise FileNotFoundError(
395
+ f"ENOENT: no such file or directory, lstat '{path}'"
396
+ )
397
+
398
+ if entry.type == "symlink":
399
+ return FsStat(
400
+ is_file=False,
401
+ is_directory=False,
402
+ is_symbolic_link=True,
403
+ mode=entry.mode,
404
+ size=len(entry.target),
405
+ mtime=entry.mtime,
406
+ )
407
+
408
+ size = 0
409
+ if entry.type == "file":
410
+ size = len(entry.content)
411
+
412
+ return FsStat(
413
+ is_file=entry.type == "file",
414
+ is_directory=entry.type == "directory",
415
+ is_symbolic_link=False,
416
+ mode=entry.mode,
417
+ size=size,
418
+ mtime=entry.mtime,
419
+ )
420
+
421
+ async def mkdir(self, path: str, recursive: bool = False) -> None:
422
+ """Create a directory."""
423
+ self._mkdir_sync(path, recursive=recursive)
424
+
425
+ async def readdir(self, path: str) -> list[str]:
426
+ """List directory contents."""
427
+ entries = await self.readdir_with_file_types(path)
428
+ return [e.name for e in entries]
429
+
430
+ async def readdir_with_file_types(self, path: str) -> list[DirentEntry]:
431
+ """List directory contents with type information."""
432
+ normalized = self._normalize_path(path)
433
+ entry = self._data.get(normalized)
434
+
435
+ if entry is None:
436
+ raise FileNotFoundError(
437
+ f"ENOENT: no such file or directory, scandir '{path}'"
438
+ )
439
+
440
+ # Follow symlinks to get to the actual directory
441
+ seen: set[str] = set()
442
+ while entry and entry.type == "symlink":
443
+ if normalized in seen:
444
+ raise OSError(
445
+ f"ELOOP: too many levels of symbolic links, scandir '{path}'"
446
+ )
447
+ seen.add(normalized)
448
+ normalized = self._resolve_symlink(normalized, entry.target)
449
+ entry = self._data.get(normalized)
450
+
451
+ if entry is None:
452
+ raise FileNotFoundError(
453
+ f"ENOENT: no such file or directory, scandir '{path}'"
454
+ )
455
+ if entry.type != "directory":
456
+ raise NotADirectoryError(f"ENOTDIR: not a directory, scandir '{path}'")
457
+
458
+ prefix = "/" if normalized == "/" else f"{normalized}/"
459
+ entries_map: dict[str, DirentEntry] = {}
460
+
461
+ for p, fs_entry in self._data.items():
462
+ if p == normalized:
463
+ continue
464
+ if p.startswith(prefix):
465
+ rest = p[len(prefix) :]
466
+ name = rest.split("/")[0]
467
+ # Only add direct children
468
+ if name and "/" not in rest[len(name) :] and name not in entries_map:
469
+ entries_map[name] = DirentEntry(
470
+ name=name,
471
+ is_file=fs_entry.type == "file",
472
+ is_directory=fs_entry.type == "directory",
473
+ is_symbolic_link=fs_entry.type == "symlink",
474
+ )
475
+
476
+ # Sort by name
477
+ return sorted(entries_map.values(), key=lambda e: e.name)
478
+
479
+ async def rm(
480
+ self, path: str, recursive: bool = False, force: bool = False
481
+ ) -> None:
482
+ """Remove a file or directory."""
483
+ normalized = self._normalize_path(path)
484
+ entry = self._data.get(normalized)
485
+
486
+ if entry is None:
487
+ if force:
488
+ return
489
+ raise FileNotFoundError(f"ENOENT: no such file or directory, rm '{path}'")
490
+
491
+ if entry.type == "directory":
492
+ children = await self.readdir(normalized)
493
+ if children:
494
+ if not recursive:
495
+ raise OSError(f"ENOTEMPTY: directory not empty, rm '{path}'")
496
+ for child in children:
497
+ child_path = (
498
+ f"/{child}" if normalized == "/" else f"{normalized}/{child}"
499
+ )
500
+ await self.rm(child_path, recursive=recursive, force=force)
501
+
502
+ del self._data[normalized]
503
+
504
+ async def cp(self, src: str, dest: str, recursive: bool = False) -> None:
505
+ """Copy a file or directory."""
506
+ src_norm = self._normalize_path(src)
507
+ dest_norm = self._normalize_path(dest)
508
+ src_entry = self._data.get(src_norm)
509
+
510
+ if src_entry is None:
511
+ raise FileNotFoundError(f"ENOENT: no such file or directory, cp '{src}'")
512
+
513
+ if src_entry.type == "file":
514
+ self._ensure_parent_dirs(dest_norm)
515
+ self._data[dest_norm] = FileEntry(
516
+ content=src_entry.content,
517
+ mode=src_entry.mode,
518
+ )
519
+ elif src_entry.type == "directory":
520
+ if not recursive:
521
+ raise IsADirectoryError(f"EISDIR: is a directory, cp '{src}'")
522
+ await self.mkdir(dest_norm, recursive=True)
523
+ children = await self.readdir(src_norm)
524
+ for child in children:
525
+ src_child = (
526
+ f"/{child}" if src_norm == "/" else f"{src_norm}/{child}"
527
+ )
528
+ dest_child = (
529
+ f"/{child}" if dest_norm == "/" else f"{dest_norm}/{child}"
530
+ )
531
+ await self.cp(src_child, dest_child, recursive=recursive)
532
+
533
+ async def mv(self, src: str, dest: str) -> None:
534
+ """Move a file or directory."""
535
+ await self.cp(src, dest, recursive=True)
536
+ await self.rm(src, recursive=True)
537
+
538
+ async def chmod(self, path: str, mode: int) -> None:
539
+ """Change file/directory permissions."""
540
+ normalized = self._normalize_path(path)
541
+ entry = self._data.get(normalized)
542
+
543
+ if entry is None:
544
+ raise FileNotFoundError(
545
+ f"ENOENT: no such file or directory, chmod '{path}'"
546
+ )
547
+
548
+ # Create a new entry with updated mode (since FsEntry is a dataclass)
549
+ if entry.type == "file":
550
+ self._data[normalized] = FileEntry(
551
+ content=entry.content, mode=mode, mtime=entry.mtime
552
+ )
553
+ elif entry.type == "directory":
554
+ self._data[normalized] = DirectoryEntry(mode=mode, mtime=entry.mtime)
555
+ elif entry.type == "symlink":
556
+ self._data[normalized] = SymlinkEntry(
557
+ target=entry.target, mode=mode, mtime=entry.mtime
558
+ )
559
+
560
+ async def symlink(self, target: str, link_path: str) -> None:
561
+ """Create a symbolic link."""
562
+ normalized = self._normalize_path(link_path)
563
+
564
+ if normalized in self._data:
565
+ raise FileExistsError(f"EEXIST: file already exists, symlink '{link_path}'")
566
+
567
+ self._ensure_parent_dirs(normalized)
568
+ self._data[normalized] = SymlinkEntry(target=target)
569
+
570
+ async def link(self, existing_path: str, new_path: str) -> None:
571
+ """Create a hard link."""
572
+ existing_norm = self._normalize_path(existing_path)
573
+ new_norm = self._normalize_path(new_path)
574
+
575
+ entry = self._data.get(existing_norm)
576
+ if entry is None:
577
+ raise FileNotFoundError(
578
+ f"ENOENT: no such file or directory, link '{existing_path}'"
579
+ )
580
+
581
+ if entry.type != "file":
582
+ raise PermissionError(
583
+ f"EPERM: operation not permitted, link '{existing_path}'"
584
+ )
585
+
586
+ if new_norm in self._data:
587
+ raise FileExistsError(f"EEXIST: file already exists, link '{new_path}'")
588
+
589
+ self._ensure_parent_dirs(new_norm)
590
+ # For hard links, we create a copy (simulating inode sharing)
591
+ self._data[new_norm] = FileEntry(
592
+ content=entry.content,
593
+ mode=entry.mode,
594
+ mtime=entry.mtime,
595
+ )
596
+
597
+ async def readlink(self, path: str) -> str:
598
+ """Read the target of a symbolic link."""
599
+ normalized = self._normalize_path(path)
600
+ entry = self._data.get(normalized)
601
+
602
+ if entry is None:
603
+ raise FileNotFoundError(
604
+ f"ENOENT: no such file or directory, readlink '{path}'"
605
+ )
606
+
607
+ if entry.type != "symlink":
608
+ raise OSError(f"EINVAL: invalid argument, readlink '{path}'")
609
+
610
+ return entry.target
611
+
612
+ def resolve_path(self, base: str, path: str) -> str:
613
+ """Resolve a path relative to a base."""
614
+ if path.startswith("/"):
615
+ return self._normalize_path(path)
616
+ combined = f"/{path}" if base == "/" else f"{base}/{path}"
617
+ return self._normalize_path(combined)
618
+
619
+ def get_all_paths(self) -> list[str]:
620
+ """Get all paths in the filesystem (useful for debugging/glob)."""
621
+ return list(self._data.keys())