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,894 @@
1
+ """
2
+ OverlayFs Implementation
3
+
4
+ A copy-on-write overlay filesystem. Reads fall back to the real filesystem,
5
+ but all writes go to an in-memory layer. The real filesystem is never modified.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Literal, Union
15
+
16
+ import aiofiles # type: ignore[import-untyped]
17
+
18
+ from ..types import FsStat
19
+
20
+
21
+ @dataclass
22
+ class FileEntry:
23
+ """A file in the memory layer."""
24
+
25
+ type: Literal["file"] = "file"
26
+ content: bytes = b""
27
+ mode: int = 0o644
28
+ mtime: float = field(default_factory=time.time)
29
+
30
+
31
+ @dataclass
32
+ class DirectoryEntry:
33
+ """A directory in the memory layer."""
34
+
35
+ type: Literal["directory"] = "directory"
36
+ mode: int = 0o755
37
+ mtime: float = field(default_factory=time.time)
38
+
39
+
40
+ @dataclass
41
+ class SymlinkEntry:
42
+ """A symbolic link in the memory layer."""
43
+
44
+ type: Literal["symlink"] = "symlink"
45
+ target: str = ""
46
+ mode: int = 0o777
47
+ mtime: float = field(default_factory=time.time)
48
+
49
+
50
+ MemoryEntry = Union[FileEntry, DirectoryEntry, SymlinkEntry]
51
+
52
+
53
+ @dataclass
54
+ class DirentEntry:
55
+ """Directory entry information."""
56
+
57
+ name: str
58
+ is_file: bool = False
59
+ is_directory: bool = False
60
+ is_symbolic_link: bool = False
61
+
62
+
63
+ @dataclass
64
+ class OverlayFsOptions:
65
+ """Options for OverlayFs."""
66
+
67
+ root: str
68
+ """Root directory on the real filesystem to overlay."""
69
+
70
+ mount_point: str = "/home/user/project"
71
+ """Virtual path where the overlay is mounted."""
72
+
73
+ read_only: bool = False
74
+ """If True, all write operations raise EROFS error."""
75
+
76
+
77
+ class OverlayFs:
78
+ """
79
+ Copy-on-write overlay filesystem.
80
+
81
+ Reads fall back to the real filesystem (under root), but all writes
82
+ go to an in-memory layer. The real filesystem is never modified.
83
+
84
+ Files can be "deleted" which marks them as non-existent in the overlay
85
+ even though they still exist on disk.
86
+ """
87
+
88
+ def __init__(self, options: OverlayFsOptions) -> None:
89
+ """
90
+ Initialize the overlay filesystem.
91
+
92
+ Args:
93
+ options: Configuration options
94
+
95
+ Raises:
96
+ FileNotFoundError: If root directory does not exist
97
+ NotADirectoryError: If root is not a directory
98
+ """
99
+ root_path = Path(options.root)
100
+
101
+ if not root_path.exists():
102
+ raise FileNotFoundError(
103
+ f"ENOENT: no such file or directory, root '{options.root}'"
104
+ )
105
+
106
+ if not root_path.is_dir():
107
+ raise NotADirectoryError(
108
+ f"ENOTDIR: not a directory, root '{options.root}'"
109
+ )
110
+
111
+ self._root = root_path.resolve()
112
+ self._mount_point = self._normalize_path(options.mount_point)
113
+ self._read_only = options.read_only
114
+
115
+ # Memory layer: virtual path -> entry
116
+ self._memory: dict[str, MemoryEntry] = {}
117
+
118
+ # Deleted paths: paths that should appear as non-existent
119
+ self._deleted: set[str] = set()
120
+
121
+ # Create the mount point directory in memory
122
+ self._memory[self._mount_point] = DirectoryEntry()
123
+
124
+ def get_mount_point(self) -> str:
125
+ """Get the virtual mount point path."""
126
+ return self._mount_point
127
+
128
+ def _normalize_path(self, path: str) -> str:
129
+ """Normalize a virtual path (resolve ., .., trailing slashes)."""
130
+ if not path or path == "/":
131
+ return "/"
132
+
133
+ # Remove trailing slash
134
+ if path.endswith("/") and path != "/":
135
+ path = path[:-1]
136
+
137
+ # Ensure starts with /
138
+ if not path.startswith("/"):
139
+ path = "/" + path
140
+
141
+ # Resolve . and ..
142
+ parts = path.split("/")
143
+ resolved: list[str] = []
144
+
145
+ for part in parts:
146
+ if part == "" or part == ".":
147
+ continue
148
+ elif part == "..":
149
+ if resolved:
150
+ resolved.pop()
151
+ else:
152
+ resolved.append(part)
153
+
154
+ return "/" + "/".join(resolved) if resolved else "/"
155
+
156
+ def _is_under_mount(self, path: str) -> bool:
157
+ """Check if a normalized path is under the mount point."""
158
+ return path == self._mount_point or path.startswith(self._mount_point + "/")
159
+
160
+ def _to_real_path(self, virtual_path: str) -> Path | None:
161
+ """
162
+ Convert a virtual path to a real filesystem path.
163
+
164
+ Returns None if the path is not under the mount point.
165
+ """
166
+ normalized = self._normalize_path(virtual_path)
167
+
168
+ if not self._is_under_mount(normalized):
169
+ return None
170
+
171
+ if normalized == self._mount_point:
172
+ return self._root
173
+
174
+ # Strip mount point prefix
175
+ relative = normalized[len(self._mount_point) + 1:] # +1 for the /
176
+ return self._root / relative
177
+
178
+ def _is_deleted(self, path: str) -> bool:
179
+ """Check if a path or any of its parents are marked deleted."""
180
+ normalized = self._normalize_path(path)
181
+
182
+ # Check the path itself
183
+ if normalized in self._deleted:
184
+ return True
185
+
186
+ # Check all parent paths
187
+ parts = normalized.split("/")
188
+ for i in range(1, len(parts)):
189
+ parent = "/".join(parts[:i]) or "/"
190
+ if parent in self._deleted:
191
+ return True
192
+
193
+ return False
194
+
195
+ def _assert_writable(self, operation: str) -> None:
196
+ """Raise EROFS error if in read-only mode."""
197
+ if self._read_only:
198
+ raise OSError(f"EROFS: read-only file system, {operation}")
199
+
200
+ def _dirname(self, path: str) -> str:
201
+ """Get the directory name of a path."""
202
+ normalized = self._normalize_path(path)
203
+ if normalized == "/":
204
+ return "/"
205
+ last_slash = normalized.rfind("/")
206
+ return "/" if last_slash == 0 else normalized[:last_slash]
207
+
208
+ def _basename(self, path: str) -> str:
209
+ """Get the base name of a path."""
210
+ normalized = self._normalize_path(path)
211
+ if normalized == "/":
212
+ return ""
213
+ return normalized.rsplit("/", 1)[-1]
214
+
215
+ def _ensure_parent_dirs(self, path: str) -> None:
216
+ """Ensure all parent directories exist in memory."""
217
+ dir_path = self._dirname(path)
218
+ if dir_path == "/" or dir_path == self._mount_point:
219
+ return
220
+
221
+ if dir_path not in self._memory:
222
+ self._ensure_parent_dirs(dir_path)
223
+ self._memory[dir_path] = DirectoryEntry()
224
+
225
+ # Remove from deleted if it was deleted
226
+ if dir_path in self._deleted:
227
+ self._deleted.discard(dir_path)
228
+
229
+ def _resolve_symlink(self, symlink_path: str, target: str) -> str:
230
+ """Resolve a symlink target to an absolute virtual path."""
231
+ if target.startswith("/"):
232
+ return self._normalize_path(target)
233
+ dir_path = self._dirname(symlink_path)
234
+ if dir_path == "/":
235
+ return self._normalize_path("/" + target)
236
+ return self._normalize_path(dir_path + "/" + target)
237
+
238
+ def _resolve_path_with_symlinks(self, path: str, max_loops: int = 40) -> str:
239
+ """Resolve all symlinks in a path, including intermediate components."""
240
+ normalized = self._normalize_path(path)
241
+ if normalized == "/":
242
+ return "/"
243
+
244
+ parts = normalized[1:].split("/") # Skip leading /
245
+ resolved_path = ""
246
+ seen: set[str] = set()
247
+
248
+ for part in parts:
249
+ resolved_path = f"{resolved_path}/{part}"
250
+
251
+ # Check if deleted
252
+ if self._is_deleted(resolved_path):
253
+ return resolved_path # Return as-is, caller will handle ENOENT
254
+
255
+ # Check memory first, then real fs for symlinks
256
+ entry = self._memory.get(resolved_path)
257
+ loop_count = 0
258
+
259
+ while entry and entry.type == "symlink" and loop_count < max_loops:
260
+ if resolved_path in seen:
261
+ raise OSError(
262
+ f"ELOOP: too many levels of symbolic links, open '{path}'"
263
+ )
264
+ seen.add(resolved_path)
265
+ resolved_path = self._resolve_symlink(resolved_path, entry.target)
266
+
267
+ # Check if the resolved target is under mount and valid
268
+ if self._is_deleted(resolved_path):
269
+ return resolved_path
270
+
271
+ entry = self._memory.get(resolved_path)
272
+ loop_count += 1
273
+
274
+ if loop_count >= max_loops:
275
+ raise OSError(
276
+ f"ELOOP: too many levels of symbolic links, open '{path}'"
277
+ )
278
+
279
+ return resolved_path
280
+
281
+ async def _get_entry(self, path: str) -> tuple[MemoryEntry | None, bool]:
282
+ """
283
+ Get entry for a path.
284
+
285
+ Returns (entry, is_from_memory).
286
+ """
287
+ normalized = self._normalize_path(path)
288
+
289
+ # Check if deleted
290
+ if self._is_deleted(normalized):
291
+ return None, False
292
+
293
+ # Check memory first
294
+ if normalized in self._memory:
295
+ return self._memory[normalized], True
296
+
297
+ # Fall back to real fs
298
+ real_path = self._to_real_path(normalized)
299
+ if real_path is None:
300
+ return None, False
301
+
302
+ if not real_path.exists():
303
+ return None, False
304
+
305
+ # Create a transient entry representing the real file
306
+ if real_path.is_symlink():
307
+ # Handle real symlinks - need to check if target is safe
308
+ try:
309
+ target = real_path.readlink()
310
+ if target.is_absolute():
311
+ # Check if target is under our root
312
+ try:
313
+ target.relative_to(self._root)
314
+ except ValueError:
315
+ # Symlink points outside - treat as non-existent
316
+ return None, False
317
+ except OSError:
318
+ return None, False
319
+ return SymlinkEntry(target=str(target)), False
320
+ elif real_path.is_dir():
321
+ return DirectoryEntry(mode=real_path.stat().st_mode & 0o777), False
322
+ elif real_path.is_file():
323
+ stat = real_path.stat()
324
+ return FileEntry(
325
+ content=b"", # Content loaded on demand
326
+ mode=stat.st_mode & 0o777,
327
+ mtime=stat.st_mtime,
328
+ ), False
329
+
330
+ return None, False
331
+
332
+ async def read_file(self, path: str, encoding: str = "utf-8") -> str:
333
+ """Read file contents as string."""
334
+ content = await self.read_file_bytes(path)
335
+ return content.decode(encoding)
336
+
337
+ async def read_file_bytes(self, path: str) -> bytes:
338
+ """Read file contents as bytes."""
339
+ resolved = self._resolve_path_with_symlinks(path)
340
+ normalized = self._normalize_path(resolved)
341
+
342
+ # Check if deleted
343
+ if self._is_deleted(normalized):
344
+ raise FileNotFoundError(
345
+ f"ENOENT: no such file or directory, open '{path}'"
346
+ )
347
+
348
+ # Check memory first
349
+ if normalized in self._memory:
350
+ entry = self._memory[normalized]
351
+ if entry.type == "directory":
352
+ raise IsADirectoryError(
353
+ f"EISDIR: illegal operation on a directory, read '{path}'"
354
+ )
355
+ if entry.type == "symlink":
356
+ # Resolve and read target
357
+ target = self._resolve_symlink(normalized, entry.target)
358
+ return await self.read_file_bytes(target)
359
+ return entry.content
360
+
361
+ # Fall back to real fs
362
+ real_path = self._to_real_path(normalized)
363
+ if real_path is None or not real_path.exists():
364
+ raise FileNotFoundError(
365
+ f"ENOENT: no such file or directory, open '{path}'"
366
+ )
367
+
368
+ if real_path.is_dir():
369
+ raise IsADirectoryError(
370
+ f"EISDIR: illegal operation on a directory, read '{path}'"
371
+ )
372
+
373
+ if real_path.is_symlink():
374
+ # Follow symlink but validate it stays within bounds
375
+ target_path = real_path.readlink()
376
+ if target_path.is_absolute():
377
+ try:
378
+ target_path.relative_to(self._root)
379
+ except ValueError:
380
+ raise FileNotFoundError(
381
+ f"ENOENT: no such file or directory, open '{path}'"
382
+ )
383
+
384
+ async with aiofiles.open(real_path, "rb") as f:
385
+ content: bytes = await f.read()
386
+ return content
387
+
388
+ async def write_file(
389
+ self,
390
+ path: str,
391
+ content: str | bytes,
392
+ encoding: str = "utf-8",
393
+ ) -> None:
394
+ """Write content to file (in memory only)."""
395
+ self._assert_writable("write")
396
+
397
+ normalized = self._normalize_path(path)
398
+
399
+ # Ensure path is under mount point or just store in memory
400
+ self._ensure_parent_dirs(normalized)
401
+
402
+ # Remove from deleted set
403
+ self._deleted.discard(normalized)
404
+
405
+ # Convert to bytes
406
+ if isinstance(content, str):
407
+ content_bytes = content.encode(encoding)
408
+ else:
409
+ content_bytes = content
410
+
411
+ self._memory[normalized] = FileEntry(content=content_bytes)
412
+
413
+ async def append_file(
414
+ self,
415
+ path: str,
416
+ content: str | bytes,
417
+ encoding: str = "utf-8",
418
+ ) -> None:
419
+ """Append content to file (copy-on-write)."""
420
+ self._assert_writable("append")
421
+
422
+ normalized = self._normalize_path(path)
423
+
424
+ # Get existing content
425
+ try:
426
+ existing = await self.read_file_bytes(normalized)
427
+ except FileNotFoundError:
428
+ existing = b""
429
+
430
+ # Convert new content to bytes
431
+ if isinstance(content, str):
432
+ new_bytes = content.encode(encoding)
433
+ else:
434
+ new_bytes = content
435
+
436
+ # Write combined content
437
+ await self.write_file(path, existing + new_bytes)
438
+
439
+ async def exists(self, path: str) -> bool:
440
+ """Check if path exists."""
441
+ try:
442
+ normalized = self._resolve_path_with_symlinks(path)
443
+ except OSError:
444
+ return False
445
+
446
+ if self._is_deleted(normalized):
447
+ return False
448
+
449
+ if normalized in self._memory:
450
+ return True
451
+
452
+ real_path = self._to_real_path(normalized)
453
+ if real_path is None:
454
+ return False
455
+
456
+ return real_path.exists()
457
+
458
+ async def is_file(self, path: str) -> bool:
459
+ """Check if path is a file."""
460
+ try:
461
+ normalized = self._resolve_path_with_symlinks(path)
462
+ except OSError:
463
+ return False
464
+
465
+ if self._is_deleted(normalized):
466
+ return False
467
+
468
+ if normalized in self._memory:
469
+ return self._memory[normalized].type == "file"
470
+
471
+ real_path = self._to_real_path(normalized)
472
+ if real_path is None:
473
+ return False
474
+
475
+ return real_path.is_file()
476
+
477
+ async def is_directory(self, path: str) -> bool:
478
+ """Check if path is a directory."""
479
+ try:
480
+ normalized = self._resolve_path_with_symlinks(path)
481
+ except OSError:
482
+ return False
483
+
484
+ if self._is_deleted(normalized):
485
+ return False
486
+
487
+ if normalized in self._memory:
488
+ return self._memory[normalized].type == "directory"
489
+
490
+ real_path = self._to_real_path(normalized)
491
+ if real_path is None:
492
+ return False
493
+
494
+ return real_path.is_dir()
495
+
496
+ async def stat(self, path: str) -> FsStat:
497
+ """Get file/directory stats (follows symlinks)."""
498
+ resolved = self._resolve_path_with_symlinks(path)
499
+ normalized = self._normalize_path(resolved)
500
+
501
+ if self._is_deleted(normalized):
502
+ raise FileNotFoundError(
503
+ f"ENOENT: no such file or directory, stat '{path}'"
504
+ )
505
+
506
+ # Check memory first
507
+ if normalized in self._memory:
508
+ entry = self._memory[normalized]
509
+ if entry.type == "symlink":
510
+ # Follow symlink
511
+ target = self._resolve_symlink(normalized, entry.target)
512
+ return await self.stat(target)
513
+
514
+ size = 0
515
+ if entry.type == "file":
516
+ size = len(entry.content)
517
+
518
+ return FsStat(
519
+ is_file=entry.type == "file",
520
+ is_directory=entry.type == "directory",
521
+ is_symbolic_link=False, # stat follows symlinks
522
+ mode=entry.mode,
523
+ size=size,
524
+ mtime=entry.mtime,
525
+ )
526
+
527
+ # Fall back to real fs
528
+ real_path = self._to_real_path(normalized)
529
+ if real_path is None or not real_path.exists():
530
+ raise FileNotFoundError(
531
+ f"ENOENT: no such file or directory, stat '{path}'"
532
+ )
533
+
534
+ stat_result = real_path.stat()
535
+ return FsStat(
536
+ is_file=real_path.is_file(),
537
+ is_directory=real_path.is_dir(),
538
+ is_symbolic_link=False,
539
+ mode=stat_result.st_mode & 0o777,
540
+ size=stat_result.st_size,
541
+ mtime=stat_result.st_mtime,
542
+ )
543
+
544
+ async def lstat(self, path: str) -> FsStat:
545
+ """Get file/directory stats (does not follow final symlink)."""
546
+ normalized = self._normalize_path(path)
547
+
548
+ if self._is_deleted(normalized):
549
+ raise FileNotFoundError(
550
+ f"ENOENT: no such file or directory, lstat '{path}'"
551
+ )
552
+
553
+ # Check memory first
554
+ if normalized in self._memory:
555
+ entry = self._memory[normalized]
556
+ if entry.type == "symlink":
557
+ return FsStat(
558
+ is_file=False,
559
+ is_directory=False,
560
+ is_symbolic_link=True,
561
+ mode=entry.mode,
562
+ size=len(entry.target),
563
+ mtime=entry.mtime,
564
+ )
565
+
566
+ size = 0
567
+ if entry.type == "file":
568
+ size = len(entry.content)
569
+
570
+ return FsStat(
571
+ is_file=entry.type == "file",
572
+ is_directory=entry.type == "directory",
573
+ is_symbolic_link=False,
574
+ mode=entry.mode,
575
+ size=size,
576
+ mtime=entry.mtime,
577
+ )
578
+
579
+ # Fall back to real fs
580
+ real_path = self._to_real_path(normalized)
581
+ if real_path is None or not (real_path.exists() or real_path.is_symlink()):
582
+ raise FileNotFoundError(
583
+ f"ENOENT: no such file or directory, lstat '{path}'"
584
+ )
585
+
586
+ stat_result = real_path.lstat()
587
+ is_symlink = real_path.is_symlink()
588
+
589
+ return FsStat(
590
+ is_file=not is_symlink and real_path.is_file(),
591
+ is_directory=not is_symlink and real_path.is_dir(),
592
+ is_symbolic_link=is_symlink,
593
+ mode=stat_result.st_mode & 0o777,
594
+ size=stat_result.st_size,
595
+ mtime=stat_result.st_mtime,
596
+ )
597
+
598
+ async def mkdir(self, path: str, recursive: bool = False) -> None:
599
+ """Create a directory (in memory only)."""
600
+ self._assert_writable("mkdir")
601
+
602
+ normalized = self._normalize_path(path)
603
+
604
+ # Check if already exists
605
+ if await self.exists(normalized):
606
+ if await self.is_file(normalized):
607
+ raise OSError(f"EEXIST: file already exists, mkdir '{path}'")
608
+ if not recursive:
609
+ raise OSError(f"EEXIST: directory already exists, mkdir '{path}'")
610
+ return
611
+
612
+ # Check parent
613
+ parent = self._dirname(normalized)
614
+ if not await self.exists(parent):
615
+ if recursive:
616
+ await self.mkdir(parent, recursive=True)
617
+ else:
618
+ raise OSError(f"ENOENT: no such file or directory, mkdir '{path}'")
619
+
620
+ # Remove from deleted
621
+ self._deleted.discard(normalized)
622
+
623
+ # Create directory in memory
624
+ self._memory[normalized] = DirectoryEntry()
625
+
626
+ async def readdir(self, path: str) -> list[str]:
627
+ """List directory contents."""
628
+ entries = await self.readdir_with_file_types(path)
629
+ return [e.name for e in entries]
630
+
631
+ async def readdir_with_file_types(self, path: str) -> list[DirentEntry]:
632
+ """List directory contents with type information."""
633
+ normalized = self._normalize_path(path)
634
+
635
+ if self._is_deleted(normalized):
636
+ raise FileNotFoundError(
637
+ f"ENOENT: no such file or directory, scandir '{path}'"
638
+ )
639
+
640
+ # Check if it's a directory
641
+ if not await self.is_directory(normalized):
642
+ if await self.exists(normalized):
643
+ raise NotADirectoryError(
644
+ f"ENOTDIR: not a directory, scandir '{path}'"
645
+ )
646
+ raise FileNotFoundError(
647
+ f"ENOENT: no such file or directory, scandir '{path}'"
648
+ )
649
+
650
+ entries_map: dict[str, DirentEntry] = {}
651
+ prefix = normalized + "/" if normalized != "/" else "/"
652
+
653
+ # Get entries from memory
654
+ for p, entry in self._memory.items():
655
+ if p.startswith(prefix) and p != normalized:
656
+ rest = p[len(prefix):]
657
+ name = rest.split("/")[0]
658
+ if name and "/" not in rest[len(name):] and name not in entries_map:
659
+ # Skip if deleted
660
+ full_path = f"{prefix}{name}" if normalized != "/" else f"/{name}"
661
+ if not self._is_deleted(full_path):
662
+ entries_map[name] = DirentEntry(
663
+ name=name,
664
+ is_file=entry.type == "file",
665
+ is_directory=entry.type == "directory",
666
+ is_symbolic_link=entry.type == "symlink",
667
+ )
668
+
669
+ # Get entries from real fs (if under mount point)
670
+ real_path = self._to_real_path(normalized)
671
+ if real_path is not None and real_path.exists() and real_path.is_dir():
672
+ for item in real_path.iterdir():
673
+ name = item.name
674
+ if name not in entries_map:
675
+ full_path = f"{prefix}{name}" if normalized != "/" else f"/{name}"
676
+ if not self._is_deleted(full_path):
677
+ entries_map[name] = DirentEntry(
678
+ name=name,
679
+ is_file=item.is_file(),
680
+ is_directory=item.is_dir(),
681
+ is_symbolic_link=item.is_symlink(),
682
+ )
683
+
684
+ return sorted(entries_map.values(), key=lambda e: e.name)
685
+
686
+ async def rm(
687
+ self, path: str, recursive: bool = False, force: bool = False
688
+ ) -> None:
689
+ """Remove a file or directory (marks as deleted, doesn't touch disk)."""
690
+ self._assert_writable("rm")
691
+
692
+ normalized = self._normalize_path(path)
693
+
694
+ if not await self.exists(normalized):
695
+ if force:
696
+ return
697
+ raise FileNotFoundError(
698
+ f"ENOENT: no such file or directory, rm '{path}'"
699
+ )
700
+
701
+ # If directory, check if we need recursive
702
+ if await self.is_directory(normalized):
703
+ children = await self.readdir(normalized)
704
+ if children:
705
+ if not recursive:
706
+ raise OSError(f"ENOTEMPTY: directory not empty, rm '{path}'")
707
+ # Mark all children as deleted
708
+ for child in children:
709
+ child_path = f"{normalized}/{child}"
710
+ await self.rm(child_path, recursive=recursive, force=force)
711
+
712
+ # Remove from memory if present
713
+ if normalized in self._memory:
714
+ del self._memory[normalized]
715
+
716
+ # Mark as deleted
717
+ self._deleted.add(normalized)
718
+
719
+ async def cp(self, src: str, dest: str, recursive: bool = False) -> None:
720
+ """Copy a file or directory (to memory layer)."""
721
+ self._assert_writable("cp")
722
+
723
+ src_norm = self._normalize_path(src)
724
+ dest_norm = self._normalize_path(dest)
725
+
726
+ if not await self.exists(src_norm):
727
+ raise FileNotFoundError(
728
+ f"ENOENT: no such file or directory, cp '{src}'"
729
+ )
730
+
731
+ if await self.is_directory(src_norm):
732
+ if not recursive:
733
+ raise IsADirectoryError(f"EISDIR: is a directory, cp '{src}'")
734
+ await self.mkdir(dest_norm, recursive=True)
735
+ for child in await self.readdir(src_norm):
736
+ await self.cp(f"{src_norm}/{child}", f"{dest_norm}/{child}", recursive=True)
737
+ else:
738
+ content = await self.read_file_bytes(src_norm)
739
+ await self.write_file(dest_norm, content)
740
+
741
+ async def mv(self, src: str, dest: str) -> None:
742
+ """Move a file or directory."""
743
+ self._assert_writable("mv")
744
+
745
+ await self.cp(src, dest, recursive=True)
746
+ await self.rm(src, recursive=True)
747
+
748
+ async def chmod(self, path: str, mode: int) -> None:
749
+ """Change file/directory permissions (in memory)."""
750
+ self._assert_writable("chmod")
751
+
752
+ normalized = self._normalize_path(path)
753
+
754
+ if not await self.exists(normalized):
755
+ raise FileNotFoundError(
756
+ f"ENOENT: no such file or directory, chmod '{path}'"
757
+ )
758
+
759
+ # If in memory, update it
760
+ if normalized in self._memory:
761
+ entry = self._memory[normalized]
762
+ if entry.type == "file":
763
+ self._memory[normalized] = FileEntry(
764
+ content=entry.content,
765
+ mode=mode,
766
+ mtime=entry.mtime,
767
+ )
768
+ elif entry.type == "directory":
769
+ self._memory[normalized] = DirectoryEntry(
770
+ mode=mode,
771
+ mtime=entry.mtime,
772
+ )
773
+ elif entry.type == "symlink":
774
+ self._memory[normalized] = SymlinkEntry(
775
+ target=entry.target,
776
+ mode=mode,
777
+ mtime=entry.mtime,
778
+ )
779
+ else:
780
+ # Copy from real fs to memory with new mode
781
+ if await self.is_file(normalized):
782
+ content = await self.read_file_bytes(normalized)
783
+ self._memory[normalized] = FileEntry(content=content, mode=mode)
784
+ elif await self.is_directory(normalized):
785
+ self._memory[normalized] = DirectoryEntry(mode=mode)
786
+
787
+ async def symlink(self, target: str, link_path: str) -> None:
788
+ """Create a symbolic link (in memory)."""
789
+ self._assert_writable("symlink")
790
+
791
+ link_norm = self._normalize_path(link_path)
792
+
793
+ if await self.exists(link_norm):
794
+ raise FileExistsError(
795
+ f"EEXIST: file already exists, symlink '{link_path}'"
796
+ )
797
+
798
+ self._ensure_parent_dirs(link_norm)
799
+ self._deleted.discard(link_norm)
800
+ self._memory[link_norm] = SymlinkEntry(target=target)
801
+
802
+ async def link(self, existing_path: str, new_path: str) -> None:
803
+ """Create a hard link (copies content to memory)."""
804
+ self._assert_writable("link")
805
+
806
+ existing_norm = self._normalize_path(existing_path)
807
+ new_norm = self._normalize_path(new_path)
808
+
809
+ if not await self.exists(existing_norm):
810
+ raise FileNotFoundError(
811
+ f"ENOENT: no such file or directory, link '{existing_path}'"
812
+ )
813
+
814
+ if not await self.is_file(existing_norm):
815
+ raise PermissionError(
816
+ f"EPERM: operation not permitted, link '{existing_path}'"
817
+ )
818
+
819
+ if await self.exists(new_norm):
820
+ raise FileExistsError(
821
+ f"EEXIST: file already exists, link '{new_path}'"
822
+ )
823
+
824
+ # Read content and copy
825
+ content = await self.read_file_bytes(existing_norm)
826
+ self._ensure_parent_dirs(new_norm)
827
+ self._deleted.discard(new_norm)
828
+ self._memory[new_norm] = FileEntry(content=content)
829
+
830
+ async def readlink(self, path: str) -> str:
831
+ """Read the target of a symbolic link."""
832
+ normalized = self._normalize_path(path)
833
+
834
+ if self._is_deleted(normalized):
835
+ raise FileNotFoundError(
836
+ f"ENOENT: no such file or directory, readlink '{path}'"
837
+ )
838
+
839
+ # Check memory
840
+ if normalized in self._memory:
841
+ entry = self._memory[normalized]
842
+ if entry.type != "symlink":
843
+ raise OSError(f"EINVAL: invalid argument, readlink '{path}'")
844
+ return entry.target
845
+
846
+ # Check real fs
847
+ real_path = self._to_real_path(normalized)
848
+ if real_path is None or not real_path.is_symlink():
849
+ if real_path is not None and real_path.exists():
850
+ raise OSError(f"EINVAL: invalid argument, readlink '{path}'")
851
+ raise FileNotFoundError(
852
+ f"ENOENT: no such file or directory, readlink '{path}'"
853
+ )
854
+
855
+ target = real_path.readlink()
856
+ return str(target)
857
+
858
+ def resolve_path(self, base: str, path: str) -> str:
859
+ """Resolve a path relative to a base."""
860
+ if path.startswith("/"):
861
+ return self._normalize_path(path)
862
+ combined = f"/{path}" if base == "/" else f"{base}/{path}"
863
+ return self._normalize_path(combined)
864
+
865
+ def get_all_paths(self) -> list[str]:
866
+ """Get all paths in the overlay (memory + real fs)."""
867
+ paths: set[str] = set(self._memory.keys())
868
+
869
+ # Add real fs paths under mount
870
+ if self._root.exists():
871
+ for root_str, dirs, files in os.walk(self._root):
872
+ root_path = Path(root_str)
873
+ rel = root_path.relative_to(self._root)
874
+ if str(rel) == ".":
875
+ virtual_base = self._mount_point
876
+ else:
877
+ virtual_base = f"{self._mount_point}/{rel}"
878
+
879
+ if virtual_base not in self._deleted:
880
+ paths.add(virtual_base)
881
+
882
+ for d in dirs:
883
+ vpath = f"{virtual_base}/{d}"
884
+ if vpath not in self._deleted:
885
+ paths.add(vpath)
886
+ for f in files:
887
+ vpath = f"{virtual_base}/{f}"
888
+ if vpath not in self._deleted:
889
+ paths.add(vpath)
890
+
891
+ # Remove deleted paths
892
+ paths -= self._deleted
893
+
894
+ return sorted(paths)