ext2py 0.1.0__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.
- ext2py/__init__.py +11 -0
- ext2py/__main__.py +3 -0
- ext2py/cli.py +115 -0
- ext2py/partitions.py +120 -0
- ext2py/py.typed +0 -0
- ext2py/reader.py +804 -0
- ext2py/shell.py +380 -0
- ext2py-0.1.0.dist-info/METADATA +126 -0
- ext2py-0.1.0.dist-info/RECORD +13 -0
- ext2py-0.1.0.dist-info/WHEEL +5 -0
- ext2py-0.1.0.dist-info/entry_points.txt +2 -0
- ext2py-0.1.0.dist-info/licenses/LICENSE +21 -0
- ext2py-0.1.0.dist-info/top_level.txt +1 -0
ext2py/shell.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Interactive, read-only shell for browsing an ext filesystem (``ext2py shell``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cmd
|
|
6
|
+
import fnmatch
|
|
7
|
+
import os
|
|
8
|
+
import posixpath
|
|
9
|
+
import shlex
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from typing import List, Optional, TextIO
|
|
13
|
+
|
|
14
|
+
from ext2py.reader import DirEntry, ExtError, ExtFS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def human_size(n: int) -> str:
|
|
18
|
+
for unit in ("B", "K", "M", "G", "T"):
|
|
19
|
+
if n < 1024 or unit == "T":
|
|
20
|
+
return f"{n}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
|
21
|
+
n /= 1024
|
|
22
|
+
return str(n)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_time(ts: float) -> str:
|
|
26
|
+
t = time.localtime(ts)
|
|
27
|
+
if abs(time.time() - ts) > 180 * 86400:
|
|
28
|
+
return time.strftime("%b %d %Y", t)
|
|
29
|
+
return time.strftime("%b %d %H:%M", t)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def long_listing(fs: ExtFS, entries: List[tuple], human: bool = False) -> List[str]:
|
|
33
|
+
"""Format ``(name, inode)`` pairs like ``ls -l``."""
|
|
34
|
+
rows = []
|
|
35
|
+
for name, inode in entries:
|
|
36
|
+
size = human_size(inode.size) if human else str(inode.size)
|
|
37
|
+
if inode.is_symlink:
|
|
38
|
+
try:
|
|
39
|
+
name = f"{name} -> {fs._readlink(inode).decode('utf-8', 'replace')}"
|
|
40
|
+
except (OSError, ExtError):
|
|
41
|
+
pass
|
|
42
|
+
rows.append((inode.filemode, str(inode.links_count), str(inode.uid), str(inode.gid),
|
|
43
|
+
size, format_time(inode.mtime), name))
|
|
44
|
+
if not rows:
|
|
45
|
+
return []
|
|
46
|
+
widths = [max(len(r[i]) for r in rows) for i in range(4)]
|
|
47
|
+
size_w = max(len(r[4]) for r in rows)
|
|
48
|
+
return [
|
|
49
|
+
f"{r[0]} {r[1]:>{widths[1]}} {r[2]:<{widths[2]}} {r[3]:<{widths[3]}} "
|
|
50
|
+
f"{r[4]:>{size_w}} {r[5]} {r[6]}"
|
|
51
|
+
for r in rows
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ExtShell(cmd.Cmd):
|
|
56
|
+
"""``cd`` / ``ls`` / ``get`` around a read-only :class:`ExtFS`."""
|
|
57
|
+
|
|
58
|
+
doc_header = "Commands (type help <command>):"
|
|
59
|
+
|
|
60
|
+
def __init__(self, fs: ExtFS, label: str = "", stdin: Optional[TextIO] = None,
|
|
61
|
+
stdout: Optional[TextIO] = None):
|
|
62
|
+
super().__init__(stdin=stdin, stdout=stdout)
|
|
63
|
+
if stdin is not None:
|
|
64
|
+
self.use_rawinput = False
|
|
65
|
+
self.fs = fs
|
|
66
|
+
self.label = label
|
|
67
|
+
self.cwd = "/"
|
|
68
|
+
self.local_dir = os.getcwd()
|
|
69
|
+
self.intro = (
|
|
70
|
+
f"ext2py shell: {label} ({fs.sb.fs_type}, read-only). "
|
|
71
|
+
"Type 'help' for commands, 'exit' to quit."
|
|
72
|
+
)
|
|
73
|
+
if fs.needs_recovery:
|
|
74
|
+
self.intro += ("\nwarning: the journal needs recovery; the most recent "
|
|
75
|
+
"writes may be missing or inconsistent.")
|
|
76
|
+
|
|
77
|
+
# -- plumbing ---------------------------------------------------------- #
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def prompt(self) -> str: # type: ignore[override]
|
|
81
|
+
return f"ext2py:{self.cwd}$ "
|
|
82
|
+
|
|
83
|
+
def print(self, *args) -> None:
|
|
84
|
+
print(*args, file=self.stdout)
|
|
85
|
+
|
|
86
|
+
def error(self, msg: str) -> None:
|
|
87
|
+
print(f"error: {msg}", file=self.stdout)
|
|
88
|
+
|
|
89
|
+
def resolve(self, arg: str) -> str:
|
|
90
|
+
"""Lexically resolve ``arg`` against the current directory (like ``cd`` in a shell)."""
|
|
91
|
+
path = posixpath.normpath(posixpath.join(self.cwd, arg or "."))
|
|
92
|
+
return "/" + path.lstrip("/")
|
|
93
|
+
|
|
94
|
+
def local_path(self, arg: str) -> str:
|
|
95
|
+
return os.path.normpath(os.path.join(self.local_dir, os.path.expanduser(arg)))
|
|
96
|
+
|
|
97
|
+
def args(self, line: str) -> List[str]:
|
|
98
|
+
return shlex.split(line)
|
|
99
|
+
|
|
100
|
+
def onecmd(self, line: str) -> bool:
|
|
101
|
+
try:
|
|
102
|
+
return bool(super().onecmd(line))
|
|
103
|
+
except KeyboardInterrupt:
|
|
104
|
+
self.print("^C")
|
|
105
|
+
except ValueError as exc: # shlex: unbalanced quotes
|
|
106
|
+
self.error(str(exc))
|
|
107
|
+
except (OSError, ExtError) as exc:
|
|
108
|
+
self.error(exc.strerror if isinstance(exc, OSError) and exc.strerror else str(exc))
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def emptyline(self) -> bool:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def default(self, line: str) -> bool:
|
|
115
|
+
self.error(f"unknown command: {line.split()[0]} (try 'help')")
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def preloop(self) -> None:
|
|
119
|
+
try:
|
|
120
|
+
import readline
|
|
121
|
+
except ImportError:
|
|
122
|
+
return
|
|
123
|
+
readline.set_completer_delims(" \t\n")
|
|
124
|
+
if "libedit" in (readline.__doc__ or "") and sys.version_info < (3, 13):
|
|
125
|
+
readline.parse_and_bind("bind ^I rl_complete")
|
|
126
|
+
|
|
127
|
+
# -- completion -------------------------------------------------------- #
|
|
128
|
+
|
|
129
|
+
def _complete_remote(self, text: str, dirs_only: bool = False) -> List[str]:
|
|
130
|
+
head, _, tail = text.rpartition("/")
|
|
131
|
+
base = self.resolve(head + "/" if head or text.startswith("/") else ".")
|
|
132
|
+
try:
|
|
133
|
+
entries = self.fs.scandir(base)
|
|
134
|
+
except (OSError, ExtError):
|
|
135
|
+
return []
|
|
136
|
+
out = []
|
|
137
|
+
for e in entries:
|
|
138
|
+
if not e.name.startswith(tail):
|
|
139
|
+
continue
|
|
140
|
+
is_dir = self._entry_is_dir(base, e)
|
|
141
|
+
if dirs_only and not is_dir:
|
|
142
|
+
continue
|
|
143
|
+
prefix = head + "/" if head or text.startswith("/") else ""
|
|
144
|
+
out.append(prefix + e.name + ("/" if is_dir else ""))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
def _entry_is_dir(self, base: str, e: DirEntry) -> bool:
|
|
148
|
+
try:
|
|
149
|
+
return self.fs.isdir(posixpath.join(base, e.name))
|
|
150
|
+
except (OSError, ExtError):
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
def _complete_paths(self, text, line, begidx, endidx):
|
|
154
|
+
return self._complete_remote(text)
|
|
155
|
+
|
|
156
|
+
def complete_cd(self, text, line, begidx, endidx):
|
|
157
|
+
return self._complete_remote(text, dirs_only=True)
|
|
158
|
+
|
|
159
|
+
complete_ls = complete_ll = complete_cat = complete_stat = _complete_paths
|
|
160
|
+
complete_get = complete_find = complete_tree = complete_readlink = _complete_paths
|
|
161
|
+
|
|
162
|
+
def complete_lcd(self, text, line, begidx, endidx):
|
|
163
|
+
base = self.local_path(os.path.dirname(text) or ".")
|
|
164
|
+
try:
|
|
165
|
+
names = os.listdir(base)
|
|
166
|
+
except OSError:
|
|
167
|
+
return []
|
|
168
|
+
head = os.path.dirname(text)
|
|
169
|
+
return [os.path.join(head, n) + "/" for n in names
|
|
170
|
+
if n.startswith(os.path.basename(text)) and os.path.isdir(os.path.join(base, n))]
|
|
171
|
+
|
|
172
|
+
# -- navigation -------------------------------------------------------- #
|
|
173
|
+
|
|
174
|
+
def do_pwd(self, line: str) -> None:
|
|
175
|
+
"""pwd: print the current directory inside the filesystem."""
|
|
176
|
+
self.print(self.cwd)
|
|
177
|
+
|
|
178
|
+
def do_cd(self, line: str) -> None:
|
|
179
|
+
"""cd [DIR]: change directory inside the filesystem (default: /)."""
|
|
180
|
+
args = self.args(line)
|
|
181
|
+
path = self.resolve(args[0] if args else "/")
|
|
182
|
+
if not self.fs.stat(path).is_dir:
|
|
183
|
+
raise NotADirectoryError(20, "Not a directory")
|
|
184
|
+
self.cwd = path
|
|
185
|
+
|
|
186
|
+
def do_ls(self, line: str) -> None:
|
|
187
|
+
"""ls [-l] [-a] [-h] [PATH...]: list directory contents."""
|
|
188
|
+
flags, paths = set(), []
|
|
189
|
+
for a in self.args(line):
|
|
190
|
+
if a.startswith("-") and len(a) > 1:
|
|
191
|
+
flags.update(a[1:])
|
|
192
|
+
else:
|
|
193
|
+
paths.append(a)
|
|
194
|
+
unknown = flags - set("lah")
|
|
195
|
+
if unknown:
|
|
196
|
+
return self.error(f"ls: unknown option -{''.join(sorted(unknown))}")
|
|
197
|
+
paths = paths or ["."]
|
|
198
|
+
for i, p in enumerate(paths):
|
|
199
|
+
path = self.resolve(p)
|
|
200
|
+
inode = self.fs.stat(path)
|
|
201
|
+
if len(paths) > 1 and inode.is_dir:
|
|
202
|
+
self.print(("\n" if i else "") + f"{p}:")
|
|
203
|
+
if not inode.is_dir:
|
|
204
|
+
self._print_listing([(p, self.fs.lstat(path))], flags)
|
|
205
|
+
continue
|
|
206
|
+
entries = sorted(self.fs._dir_entries(inode.ino), key=lambda e: e.name)
|
|
207
|
+
if "a" not in flags:
|
|
208
|
+
entries = [e for e in entries if not e.name.startswith(".")]
|
|
209
|
+
items = []
|
|
210
|
+
for e in entries:
|
|
211
|
+
try:
|
|
212
|
+
items.append((e.name, self.fs.inode(e.inode)))
|
|
213
|
+
except ExtError as exc:
|
|
214
|
+
self.error(f"{e.name}: {exc}")
|
|
215
|
+
self._print_listing(items, flags)
|
|
216
|
+
|
|
217
|
+
def _print_listing(self, items: List[tuple], flags: set) -> None:
|
|
218
|
+
if "l" in flags:
|
|
219
|
+
for row in long_listing(self.fs, items, human="h" in flags):
|
|
220
|
+
self.print(row)
|
|
221
|
+
else:
|
|
222
|
+
for name, inode in items:
|
|
223
|
+
suffix = "/" if inode.is_dir else "@" if inode.is_symlink else ""
|
|
224
|
+
self.print(name + suffix)
|
|
225
|
+
|
|
226
|
+
def do_ll(self, line: str) -> None:
|
|
227
|
+
"""ll [PATH...]: shorthand for ls -lh."""
|
|
228
|
+
self.do_ls("-lh " + line)
|
|
229
|
+
|
|
230
|
+
def do_tree(self, line: str) -> None:
|
|
231
|
+
"""tree [PATH]: show the directory tree."""
|
|
232
|
+
args = self.args(line)
|
|
233
|
+
root = self.resolve(args[0] if args else ".")
|
|
234
|
+
self.print(root)
|
|
235
|
+
self._tree(root, "")
|
|
236
|
+
|
|
237
|
+
def _tree(self, path: str, indent: str) -> None:
|
|
238
|
+
entries = sorted(self.fs.scandir(path), key=lambda e: e.name)
|
|
239
|
+
for i, e in enumerate(entries):
|
|
240
|
+
last = i == len(entries) - 1
|
|
241
|
+
child = posixpath.join(path, e.name)
|
|
242
|
+
inode = self.fs.inode(e.inode)
|
|
243
|
+
self.print(f"{indent}{'└── ' if last else '├── '}{e.name}{'/' if inode.is_dir else ''}")
|
|
244
|
+
if inode.is_dir:
|
|
245
|
+
self._tree(child, indent + (" " if last else "│ "))
|
|
246
|
+
|
|
247
|
+
def do_find(self, line: str) -> None:
|
|
248
|
+
"""find [PATH] [-name PATTERN]: list files recursively, optionally matching a glob."""
|
|
249
|
+
args = self.args(line)
|
|
250
|
+
pattern = None
|
|
251
|
+
if "-name" in args:
|
|
252
|
+
i = args.index("-name")
|
|
253
|
+
if i + 1 >= len(args):
|
|
254
|
+
return self.error("find: -name needs a pattern")
|
|
255
|
+
pattern = args[i + 1]
|
|
256
|
+
del args[i:i + 2]
|
|
257
|
+
root = self.resolve(args[0] if args else ".")
|
|
258
|
+
for top, dirs, files in self.fs.walk(root):
|
|
259
|
+
for name in dirs + files:
|
|
260
|
+
if pattern is None or fnmatch.fnmatch(name, pattern):
|
|
261
|
+
self.print(posixpath.join(top, name))
|
|
262
|
+
|
|
263
|
+
# -- inspection -------------------------------------------------------- #
|
|
264
|
+
|
|
265
|
+
def do_cat(self, line: str) -> None:
|
|
266
|
+
"""cat FILE...: print file contents."""
|
|
267
|
+
out = getattr(self.stdout, "buffer", None)
|
|
268
|
+
for p in self.args(line):
|
|
269
|
+
for chunk in self.fs.iter_read(self.resolve(p)):
|
|
270
|
+
if out is not None:
|
|
271
|
+
out.write(chunk)
|
|
272
|
+
else:
|
|
273
|
+
self.stdout.write(chunk.decode("utf-8", "replace"))
|
|
274
|
+
if out is not None:
|
|
275
|
+
out.flush()
|
|
276
|
+
|
|
277
|
+
def do_stat(self, line: str) -> None:
|
|
278
|
+
"""stat PATH...: show inode details (does not follow a final symlink)."""
|
|
279
|
+
for p in self.args(line):
|
|
280
|
+
for row in format_stat(self.fs, self.resolve(p)):
|
|
281
|
+
self.print(row)
|
|
282
|
+
|
|
283
|
+
def do_readlink(self, line: str) -> None:
|
|
284
|
+
"""readlink PATH: print a symlink's target."""
|
|
285
|
+
for p in self.args(line):
|
|
286
|
+
self.print(self.fs.readlink(self.resolve(p)))
|
|
287
|
+
|
|
288
|
+
def do_info(self, line: str) -> None:
|
|
289
|
+
"""info: show filesystem (superblock) information."""
|
|
290
|
+
for row in format_info(self.fs):
|
|
291
|
+
self.print(row)
|
|
292
|
+
|
|
293
|
+
# -- local side and downloads ----------------------------------------- #
|
|
294
|
+
|
|
295
|
+
def do_lpwd(self, line: str) -> None:
|
|
296
|
+
"""lpwd: print the local download directory."""
|
|
297
|
+
self.print(self.local_dir)
|
|
298
|
+
|
|
299
|
+
def do_lcd(self, line: str) -> None:
|
|
300
|
+
"""lcd [DIR]: change the local download directory (default: home)."""
|
|
301
|
+
args = self.args(line)
|
|
302
|
+
path = self.local_path(args[0] if args else "~")
|
|
303
|
+
if not os.path.isdir(path):
|
|
304
|
+
raise NotADirectoryError(20, f"Not a local directory: {path}")
|
|
305
|
+
self.local_dir = path
|
|
306
|
+
|
|
307
|
+
def do_lls(self, line: str) -> None:
|
|
308
|
+
"""lls [DIR]: list the local download directory."""
|
|
309
|
+
args = self.args(line)
|
|
310
|
+
for name in sorted(os.listdir(self.local_path(args[0] if args else "."))):
|
|
311
|
+
self.print(name)
|
|
312
|
+
|
|
313
|
+
def do_get(self, line: str) -> None:
|
|
314
|
+
"""get PATH [LOCAL]: download a file or directory (recursively) to the local side.
|
|
315
|
+
|
|
316
|
+
LOCAL defaults to the local directory (see lcd / lpwd)."""
|
|
317
|
+
args = self.args(line)
|
|
318
|
+
if not args or len(args) > 2:
|
|
319
|
+
return self.error("usage: get PATH [LOCAL]")
|
|
320
|
+
src = self.resolve(args[0])
|
|
321
|
+
dest = self.local_path(args[1] if len(args) > 1 else ".")
|
|
322
|
+
errors = []
|
|
323
|
+
n = self.fs.extract(
|
|
324
|
+
src, dest,
|
|
325
|
+
on_file=lambda s, d: self.print(f"{s} -> {d}"),
|
|
326
|
+
on_error=lambda s, e: errors.append((s, e)),
|
|
327
|
+
)
|
|
328
|
+
for s, e in errors:
|
|
329
|
+
self.error(f"{s}: {e}")
|
|
330
|
+
self.print(f"{n} item(s) copied" + (f", {len(errors)} error(s)" if errors else ""))
|
|
331
|
+
|
|
332
|
+
# -- exit -------------------------------------------------------------- #
|
|
333
|
+
|
|
334
|
+
def do_exit(self, line: str) -> bool:
|
|
335
|
+
"""exit: leave the shell."""
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
do_quit = do_exit
|
|
339
|
+
|
|
340
|
+
def do_EOF(self, line: str) -> bool:
|
|
341
|
+
self.print()
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def format_stat(fs: ExtFS, path: str) -> List[str]:
|
|
346
|
+
inode = fs.lstat(path)
|
|
347
|
+
kind = ("directory" if inode.is_dir else "symlink" if inode.is_symlink
|
|
348
|
+
else "regular file" if inode.is_file else "special file")
|
|
349
|
+
rows = [
|
|
350
|
+
f" File: {path}" + (f" -> {fs.readlink(path)}" if inode.is_symlink else ""),
|
|
351
|
+
f" Type: {kind}",
|
|
352
|
+
f" Size: {inode.size} Blocks: {inode.blocks} Inode: {inode.ino} Links: {inode.links_count}",
|
|
353
|
+
f" Mode: ({inode.mode & 0o7777:04o}/{inode.filemode}) Uid: {inode.uid} Gid: {inode.gid}",
|
|
354
|
+
f"Access: {time.ctime(inode.atime)}",
|
|
355
|
+
f"Modify: {time.ctime(inode.mtime)}",
|
|
356
|
+
f"Change: {time.ctime(inode.ctime)}",
|
|
357
|
+
]
|
|
358
|
+
if inode.crtime is not None:
|
|
359
|
+
rows.append(f" Birth: {time.ctime(inode.crtime)}")
|
|
360
|
+
rows.append(f" Flags: 0x{inode.flags:08x}")
|
|
361
|
+
return rows
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def format_info(fs: ExtFS) -> List[str]:
|
|
365
|
+
sb = fs.sb
|
|
366
|
+
return [
|
|
367
|
+
f"Filesystem: {sb.fs_type}",
|
|
368
|
+
f"Volume name: {sb.volume_name or '<none>'}",
|
|
369
|
+
f"UUID: {sb.uuid_str}",
|
|
370
|
+
f"Last mounted: {sb.last_mounted or '<not available>'}",
|
|
371
|
+
f"Block size: {sb.block_size}",
|
|
372
|
+
f"Blocks: {sb.blocks_count} ({human_size(sb.blocks_count * sb.block_size)})",
|
|
373
|
+
f"Free blocks: {sb.free_blocks_count} ({human_size(sb.free_blocks_count * sb.block_size)})",
|
|
374
|
+
f"Inodes: {sb.inodes_count} (free: {sb.free_inodes_count})",
|
|
375
|
+
f"Inode size: {sb.inode_size}",
|
|
376
|
+
f"Block groups: {sb.group_count}",
|
|
377
|
+
f"Features: {' '.join(sb.incompat_names()) or '<none>'}",
|
|
378
|
+
f"Last written: {time.ctime(sb.wtime) if sb.wtime else '<never>'}",
|
|
379
|
+
f"Needs recovery: {'yes' if fs.needs_recovery else 'no'}",
|
|
380
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ext2py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python, read-only ext2/ext3/ext4 filesystem reader and browsing shell, no root or kernel drivers needed
|
|
5
|
+
Author-email: Santiago Ciciliani <santiago.ciciliani@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sciciliani/ext2py
|
|
8
|
+
Project-URL: Repository, https://github.com/sciciliani/ext2py
|
|
9
|
+
Project-URL: Issues, https://github.com/sciciliani/ext2py/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/sciciliani/ext2py/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: ext2,ext3,ext4,filesystem,disk-image,shell,forensics,linux,macos,read-only
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: System :: Filesystems
|
|
20
|
+
Classifier: Topic :: System :: Recovery Tools
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
28
|
+
Requires-Dist: build; extra == "dev"
|
|
29
|
+
Requires-Dist: twine; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# ext2py
|
|
33
|
+
|
|
34
|
+
**Browse ext2 / ext3 / ext4 Linux filesystems from Python on macOS, Linux or Windows: no kernel drivers, no FUSE, no root for disk images.**
|
|
35
|
+
|
|
36
|
+
`ext2py` is a pure-Python, zero-dependency, **read-only** ext2/3/4 parser. It ships with
|
|
37
|
+
an interactive shell to `cd`, `ls` and download files out of Linux disk images,
|
|
38
|
+
partitions and devices.
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/ext2py/)
|
|
41
|
+
[](https://pypi.org/project/ext2py/)
|
|
42
|
+
[](https://github.com/sciciliani/ext2py/actions/workflows/ci.yml)
|
|
43
|
+
[](LICENSE)
|
|
44
|
+
|
|
45
|
+
> ⚠️ Early alpha. ext2py never writes to the source image or device.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install ext2py
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Interactive shell
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
$ ext2py shell disk.img
|
|
57
|
+
ext2py shell: disk.img (ext4, read-only). Type 'help' for commands, 'exit' to quit.
|
|
58
|
+
ext2py:/$ cd home/me
|
|
59
|
+
ext2py:/home/me$ ls -l
|
|
60
|
+
-rw-r--r-- 1 1000 1000 16 Nov 14 2023 notes.txt
|
|
61
|
+
ext2py:/home/me$ get notes.txt
|
|
62
|
+
/home/me/notes.txt -> /Users/you/notes.txt
|
|
63
|
+
1 item(s) copied
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Commands: `ls [-lah]`, `ll`, `cd`, `pwd`, `tree`, `find [-name GLOB]`, `cat`, `stat`,
|
|
67
|
+
`readlink`, `info`, `get PATH [LOCAL]` (recursive for directories), and the local-side
|
|
68
|
+
`lcd`, `lpwd`, `lls`. Tab completion works on paths inside the image.
|
|
69
|
+
|
|
70
|
+
## One-shot commands
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
ext2py info disk.img # superblock summary
|
|
74
|
+
ext2py parts disk.img # partition table (MBR / GPT)
|
|
75
|
+
ext2py ls disk.img /home -l # list a directory
|
|
76
|
+
ext2py cat disk.img /etc/hostname # print a file
|
|
77
|
+
ext2py stat disk.img /etc/passwd # inode details
|
|
78
|
+
ext2py get disk.img /home/me ./out # copy files out
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Whole-disk images are handled automatically when they contain a single ext partition;
|
|
82
|
+
otherwise use `--partition N` (see `ext2py parts`) or `--offset BYTES`.
|
|
83
|
+
|
|
84
|
+
Physical disks on macOS: find the disk with `diskutil list`, then point at the raw
|
|
85
|
+
device (`sudo ext2py shell /dev/rdisk4`). Reading a device needs read permission on
|
|
86
|
+
it, which is the only reason `sudo` is involved. Image files need no privileges.
|
|
87
|
+
|
|
88
|
+
## Python API
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from ext2py import ExtFS, open_fs
|
|
92
|
+
|
|
93
|
+
with ExtFS("partition.img") as fs: # or open_fs("disk.img") for partitioned disks
|
|
94
|
+
print(fs.listdir("/etc"))
|
|
95
|
+
data = fs.read_file("/etc/os-release")
|
|
96
|
+
st = fs.stat("/etc/passwd") # Inode: mode, uid, gid, size, mtime, ...
|
|
97
|
+
for top, dirs, files in fs.walk("/home"):
|
|
98
|
+
...
|
|
99
|
+
fs.extract("/home/me", "./out") # like cp -R
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Features
|
|
103
|
+
|
|
104
|
+
- [x] ext2, ext3 and ext4 (the journal is ignored; reads the on-disk state)
|
|
105
|
+
- [x] Direct, indirect, double- and triple-indirect block maps; sparse files
|
|
106
|
+
- [x] ext4 extent trees, uninitialized extents, inline data, 64bit, flex_bg, meta_bg
|
|
107
|
+
- [x] Hashed (htree) directories, fast and slow symlinks
|
|
108
|
+
- [x] Partitioned images (MBR / GPT)
|
|
109
|
+
- [ ] Encrypted directories (names are shown raw, contents are not decrypted)
|
|
110
|
+
- [ ] Extended attributes outside the inode, MBR logical partitions
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
git clone https://github.com/sciciliani/ext2py && cd ext2py
|
|
116
|
+
python -m venv .venv && source .venv/bin/activate
|
|
117
|
+
pip install -e ".[dev]"
|
|
118
|
+
pytest
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The tests build ext2/ext4 images in pure Python (`tests/imagebuilder.py`), so no
|
|
122
|
+
`mke2fs` is required.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
ext2py/__init__.py,sha256=_FijcqrFGHtrwilC2PqT27RKPDTFQmVHUHydWMwB7Wo,372
|
|
2
|
+
ext2py/__main__.py,sha256=rCpxNVxxM_DN11PqB6c2XBWECcdK3_COdyFjhMZUQKE,54
|
|
3
|
+
ext2py/cli.py,sha256=TznMfVcXl2Cq3bdp_1Mf5ICusUv21zGSG1RvfpJmhXs,4689
|
|
4
|
+
ext2py/partitions.py,sha256=Ff5AH6o2MNHV_-Uz1qSv96F1Bq1Cz1Ed7raWdDb37dI,4143
|
|
5
|
+
ext2py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
ext2py/reader.py,sha256=Tpx9-RmtsZsvgukprSyRN0-X2P2DvgbVGN5sn9KZsOg,30380
|
|
7
|
+
ext2py/shell.py,sha256=XhlStQnmoYHvkcPgP8qhNEsY-vrA0DsZxK4oSX8YbFU,14583
|
|
8
|
+
ext2py-0.1.0.dist-info/licenses/LICENSE,sha256=Kn4QoRBSiTQ3mbTE9nI3yRUXGAYATn19XVnVEzL9ybA,1075
|
|
9
|
+
ext2py-0.1.0.dist-info/METADATA,sha256=HPbO2SgYi-tt3P2oojuAIyQ8gl5RXhiGMLW9kdrUgUQ,4821
|
|
10
|
+
ext2py-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
ext2py-0.1.0.dist-info/entry_points.txt,sha256=RTFc6CHQP1trbvF2TIrpQyH-9WlCPtaB_n0wt0sP64w,43
|
|
12
|
+
ext2py-0.1.0.dist-info/top_level.txt,sha256=byf7DwC3q56XkogUSGhtBURO_3tw6bx5wagy0m21mm4,7
|
|
13
|
+
ext2py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Santiago Ciciliani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ext2py
|