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.
- just_bash/__init__.py +55 -0
- just_bash/ast/__init__.py +213 -0
- just_bash/ast/factory.py +320 -0
- just_bash/ast/types.py +953 -0
- just_bash/bash.py +220 -0
- just_bash/commands/__init__.py +23 -0
- just_bash/commands/argv/__init__.py +5 -0
- just_bash/commands/argv/argv.py +21 -0
- just_bash/commands/awk/__init__.py +5 -0
- just_bash/commands/awk/awk.py +1168 -0
- just_bash/commands/base64/__init__.py +5 -0
- just_bash/commands/base64/base64.py +138 -0
- just_bash/commands/basename/__init__.py +5 -0
- just_bash/commands/basename/basename.py +72 -0
- just_bash/commands/bash/__init__.py +5 -0
- just_bash/commands/bash/bash.py +188 -0
- just_bash/commands/cat/__init__.py +5 -0
- just_bash/commands/cat/cat.py +173 -0
- just_bash/commands/checksum/__init__.py +5 -0
- just_bash/commands/checksum/checksum.py +179 -0
- just_bash/commands/chmod/__init__.py +5 -0
- just_bash/commands/chmod/chmod.py +216 -0
- just_bash/commands/column/__init__.py +5 -0
- just_bash/commands/column/column.py +180 -0
- just_bash/commands/comm/__init__.py +5 -0
- just_bash/commands/comm/comm.py +150 -0
- just_bash/commands/compression/__init__.py +5 -0
- just_bash/commands/compression/compression.py +298 -0
- just_bash/commands/cp/__init__.py +5 -0
- just_bash/commands/cp/cp.py +149 -0
- just_bash/commands/curl/__init__.py +5 -0
- just_bash/commands/curl/curl.py +801 -0
- just_bash/commands/cut/__init__.py +5 -0
- just_bash/commands/cut/cut.py +327 -0
- just_bash/commands/date/__init__.py +5 -0
- just_bash/commands/date/date.py +258 -0
- just_bash/commands/diff/__init__.py +5 -0
- just_bash/commands/diff/diff.py +118 -0
- just_bash/commands/dirname/__init__.py +5 -0
- just_bash/commands/dirname/dirname.py +56 -0
- just_bash/commands/du/__init__.py +5 -0
- just_bash/commands/du/du.py +150 -0
- just_bash/commands/echo/__init__.py +5 -0
- just_bash/commands/echo/echo.py +125 -0
- just_bash/commands/env/__init__.py +5 -0
- just_bash/commands/env/env.py +163 -0
- just_bash/commands/expand/__init__.py +5 -0
- just_bash/commands/expand/expand.py +299 -0
- just_bash/commands/expr/__init__.py +5 -0
- just_bash/commands/expr/expr.py +273 -0
- just_bash/commands/file/__init__.py +5 -0
- just_bash/commands/file/file.py +274 -0
- just_bash/commands/find/__init__.py +5 -0
- just_bash/commands/find/find.py +623 -0
- just_bash/commands/fold/__init__.py +5 -0
- just_bash/commands/fold/fold.py +160 -0
- just_bash/commands/grep/__init__.py +5 -0
- just_bash/commands/grep/grep.py +418 -0
- just_bash/commands/head/__init__.py +5 -0
- just_bash/commands/head/head.py +167 -0
- just_bash/commands/help/__init__.py +5 -0
- just_bash/commands/help/help.py +67 -0
- just_bash/commands/hostname/__init__.py +5 -0
- just_bash/commands/hostname/hostname.py +21 -0
- just_bash/commands/html_to_markdown/__init__.py +5 -0
- just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
- just_bash/commands/join/__init__.py +5 -0
- just_bash/commands/join/join.py +252 -0
- just_bash/commands/jq/__init__.py +5 -0
- just_bash/commands/jq/jq.py +280 -0
- just_bash/commands/ln/__init__.py +5 -0
- just_bash/commands/ln/ln.py +127 -0
- just_bash/commands/ls/__init__.py +5 -0
- just_bash/commands/ls/ls.py +280 -0
- just_bash/commands/mkdir/__init__.py +5 -0
- just_bash/commands/mkdir/mkdir.py +92 -0
- just_bash/commands/mv/__init__.py +5 -0
- just_bash/commands/mv/mv.py +142 -0
- just_bash/commands/nl/__init__.py +5 -0
- just_bash/commands/nl/nl.py +180 -0
- just_bash/commands/od/__init__.py +5 -0
- just_bash/commands/od/od.py +157 -0
- just_bash/commands/paste/__init__.py +5 -0
- just_bash/commands/paste/paste.py +100 -0
- just_bash/commands/printf/__init__.py +5 -0
- just_bash/commands/printf/printf.py +157 -0
- just_bash/commands/pwd/__init__.py +5 -0
- just_bash/commands/pwd/pwd.py +23 -0
- just_bash/commands/read/__init__.py +5 -0
- just_bash/commands/read/read.py +185 -0
- just_bash/commands/readlink/__init__.py +5 -0
- just_bash/commands/readlink/readlink.py +86 -0
- just_bash/commands/registry.py +844 -0
- just_bash/commands/rev/__init__.py +5 -0
- just_bash/commands/rev/rev.py +74 -0
- just_bash/commands/rg/__init__.py +5 -0
- just_bash/commands/rg/rg.py +1048 -0
- just_bash/commands/rm/__init__.py +5 -0
- just_bash/commands/rm/rm.py +106 -0
- just_bash/commands/search_engine/__init__.py +13 -0
- just_bash/commands/search_engine/matcher.py +170 -0
- just_bash/commands/search_engine/regex.py +159 -0
- just_bash/commands/sed/__init__.py +5 -0
- just_bash/commands/sed/sed.py +863 -0
- just_bash/commands/seq/__init__.py +5 -0
- just_bash/commands/seq/seq.py +190 -0
- just_bash/commands/shell/__init__.py +5 -0
- just_bash/commands/shell/shell.py +206 -0
- just_bash/commands/sleep/__init__.py +5 -0
- just_bash/commands/sleep/sleep.py +62 -0
- just_bash/commands/sort/__init__.py +5 -0
- just_bash/commands/sort/sort.py +411 -0
- just_bash/commands/split/__init__.py +5 -0
- just_bash/commands/split/split.py +237 -0
- just_bash/commands/sqlite3/__init__.py +5 -0
- just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
- just_bash/commands/stat/__init__.py +5 -0
- just_bash/commands/stat/stat.py +150 -0
- just_bash/commands/strings/__init__.py +5 -0
- just_bash/commands/strings/strings.py +150 -0
- just_bash/commands/tac/__init__.py +5 -0
- just_bash/commands/tac/tac.py +158 -0
- just_bash/commands/tail/__init__.py +5 -0
- just_bash/commands/tail/tail.py +180 -0
- just_bash/commands/tar/__init__.py +5 -0
- just_bash/commands/tar/tar.py +1067 -0
- just_bash/commands/tee/__init__.py +5 -0
- just_bash/commands/tee/tee.py +63 -0
- just_bash/commands/timeout/__init__.py +5 -0
- just_bash/commands/timeout/timeout.py +188 -0
- just_bash/commands/touch/__init__.py +5 -0
- just_bash/commands/touch/touch.py +91 -0
- just_bash/commands/tr/__init__.py +5 -0
- just_bash/commands/tr/tr.py +297 -0
- just_bash/commands/tree/__init__.py +5 -0
- just_bash/commands/tree/tree.py +139 -0
- just_bash/commands/true/__init__.py +5 -0
- just_bash/commands/true/true.py +32 -0
- just_bash/commands/uniq/__init__.py +5 -0
- just_bash/commands/uniq/uniq.py +323 -0
- just_bash/commands/wc/__init__.py +5 -0
- just_bash/commands/wc/wc.py +169 -0
- just_bash/commands/which/__init__.py +5 -0
- just_bash/commands/which/which.py +52 -0
- just_bash/commands/xan/__init__.py +5 -0
- just_bash/commands/xan/xan.py +1663 -0
- just_bash/commands/xargs/__init__.py +5 -0
- just_bash/commands/xargs/xargs.py +136 -0
- just_bash/commands/yq/__init__.py +5 -0
- just_bash/commands/yq/yq.py +848 -0
- just_bash/fs/__init__.py +29 -0
- just_bash/fs/in_memory_fs.py +621 -0
- just_bash/fs/mountable_fs.py +504 -0
- just_bash/fs/overlay_fs.py +894 -0
- just_bash/fs/read_write_fs.py +455 -0
- just_bash/interpreter/__init__.py +37 -0
- just_bash/interpreter/builtins/__init__.py +92 -0
- just_bash/interpreter/builtins/alias.py +154 -0
- just_bash/interpreter/builtins/cd.py +76 -0
- just_bash/interpreter/builtins/control.py +127 -0
- just_bash/interpreter/builtins/declare.py +336 -0
- just_bash/interpreter/builtins/export.py +56 -0
- just_bash/interpreter/builtins/let.py +44 -0
- just_bash/interpreter/builtins/local.py +57 -0
- just_bash/interpreter/builtins/mapfile.py +152 -0
- just_bash/interpreter/builtins/misc.py +378 -0
- just_bash/interpreter/builtins/readonly.py +80 -0
- just_bash/interpreter/builtins/set.py +234 -0
- just_bash/interpreter/builtins/shopt.py +201 -0
- just_bash/interpreter/builtins/source.py +136 -0
- just_bash/interpreter/builtins/test.py +290 -0
- just_bash/interpreter/builtins/unset.py +53 -0
- just_bash/interpreter/conditionals.py +387 -0
- just_bash/interpreter/control_flow.py +381 -0
- just_bash/interpreter/errors.py +116 -0
- just_bash/interpreter/expansion.py +1156 -0
- just_bash/interpreter/interpreter.py +813 -0
- just_bash/interpreter/types.py +134 -0
- just_bash/network/__init__.py +1 -0
- just_bash/parser/__init__.py +39 -0
- just_bash/parser/lexer.py +948 -0
- just_bash/parser/parser.py +2162 -0
- just_bash/py.typed +0 -0
- just_bash/query_engine/__init__.py +83 -0
- just_bash/query_engine/builtins/__init__.py +1283 -0
- just_bash/query_engine/evaluator.py +578 -0
- just_bash/query_engine/parser.py +525 -0
- just_bash/query_engine/tokenizer.py +329 -0
- just_bash/query_engine/types.py +373 -0
- just_bash/types.py +180 -0
- just_bash-0.1.5.dist-info/METADATA +410 -0
- just_bash-0.1.5.dist-info/RECORD +193 -0
- 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)
|