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/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""ext2py - read-only ext2/ext3/ext4 filesystem reader in pure Python."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from ext2py.partitions import Partition, find_partitions, open_fs
|
|
6
|
+
from ext2py.reader import DirEntry, ExtError, ExtFS, Inode, Superblock
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"__version__", "ExtFS", "ExtError", "Inode", "DirEntry", "Superblock",
|
|
10
|
+
"Partition", "find_partitions", "open_fs",
|
|
11
|
+
]
|
ext2py/__main__.py
ADDED
ext2py/cli.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Command-line entry point for `ext2py` (also `python -m ext2py`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from ext2py import __version__
|
|
12
|
+
from ext2py.partitions import find_partitions, open_fs
|
|
13
|
+
from ext2py.reader import ExtError, ExtFS
|
|
14
|
+
from ext2py.shell import ExtShell, format_info, format_stat, human_size
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="ext2py",
|
|
20
|
+
description="Read-only ext2/ext3/ext4 filesystem browser. Never writes to the image.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
23
|
+
|
|
24
|
+
image = argparse.ArgumentParser(add_help=False)
|
|
25
|
+
image.add_argument("image", help="filesystem image, disk image or device (e.g. /dev/rdisk4)")
|
|
26
|
+
where = image.add_mutually_exclusive_group()
|
|
27
|
+
where.add_argument("-p", "--partition", type=int, metavar="N",
|
|
28
|
+
help="use partition N of a partitioned disk (see 'parts')")
|
|
29
|
+
where.add_argument("--offset", type=int, metavar="BYTES",
|
|
30
|
+
help="byte offset of the filesystem within the image")
|
|
31
|
+
|
|
32
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
33
|
+
sub.add_parser("shell", parents=[image], help="interactive shell: cd, ls, cat, get ...")
|
|
34
|
+
sub.add_parser("info", parents=[image], help="show filesystem information")
|
|
35
|
+
sub.add_parser("parts", help="list partitions of a disk image").add_argument("image")
|
|
36
|
+
p = sub.add_parser("ls", parents=[image], help="list a directory")
|
|
37
|
+
p.add_argument("path", nargs="?", default="/")
|
|
38
|
+
p.add_argument("-l", action="store_true", help="long listing")
|
|
39
|
+
p.add_argument("-a", action="store_true", help="include hidden entries")
|
|
40
|
+
p.add_argument("-H", "--human", action="store_true", help="human-readable sizes")
|
|
41
|
+
p = sub.add_parser("cat", parents=[image], help="print a file to stdout")
|
|
42
|
+
p.add_argument("path")
|
|
43
|
+
p = sub.add_parser("stat", parents=[image], help="show inode details")
|
|
44
|
+
p.add_argument("path")
|
|
45
|
+
p = sub.add_parser("get", parents=[image], help="copy a file or directory tree out")
|
|
46
|
+
p.add_argument("path")
|
|
47
|
+
p.add_argument("dest", nargs="?", default=".")
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(args: argparse.Namespace) -> int:
|
|
52
|
+
if args.command == "parts":
|
|
53
|
+
with open(args.image, "rb", buffering=0) as f:
|
|
54
|
+
parts = find_partitions(f)
|
|
55
|
+
if not parts:
|
|
56
|
+
print("no partition table found")
|
|
57
|
+
return 0
|
|
58
|
+
for part in parts:
|
|
59
|
+
print(f"{part.number:>3} offset={part.offset:<14} size={human_size(part.size):<8} "
|
|
60
|
+
f"type={part.type}{' (linux)' if part.is_linux else ''} {part.name}")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
with open_fs(args.image, partition=args.partition, offset=args.offset) as fs:
|
|
64
|
+
if args.command == "shell":
|
|
65
|
+
ExtShell(fs, label=os.path.basename(args.image)).cmdloop()
|
|
66
|
+
elif args.command == "info":
|
|
67
|
+
print("\n".join(format_info(fs)))
|
|
68
|
+
elif args.command == "ls":
|
|
69
|
+
flags = "".join(f for f, on in (("l", args.l), ("a", args.a), ("h", args.human)) if on)
|
|
70
|
+
ExtShell(fs).do_ls(shlex.join(([f"-{flags}"] if flags else []) + [args.path]))
|
|
71
|
+
elif args.command == "cat":
|
|
72
|
+
out = sys.stdout.buffer
|
|
73
|
+
for chunk in fs.iter_read(args.path):
|
|
74
|
+
out.write(chunk)
|
|
75
|
+
out.flush()
|
|
76
|
+
elif args.command == "stat":
|
|
77
|
+
print("\n".join(format_stat(fs, args.path)))
|
|
78
|
+
elif args.command == "get":
|
|
79
|
+
return _get(fs, args.path, args.dest)
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get(fs: ExtFS, path: str, dest: str) -> int:
|
|
84
|
+
errors = []
|
|
85
|
+
n = fs.extract(path, dest, on_error=lambda s, e: errors.append((s, e)))
|
|
86
|
+
for s, e in errors:
|
|
87
|
+
print(f"ext2py: {s}: {e}", file=sys.stderr)
|
|
88
|
+
print(f"{n} item(s) copied" + (f", {len(errors)} error(s)" if errors else ""), file=sys.stderr)
|
|
89
|
+
return 1 if errors else 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
93
|
+
parser = build_parser()
|
|
94
|
+
args = parser.parse_args(argv)
|
|
95
|
+
if args.command is None:
|
|
96
|
+
parser.print_help()
|
|
97
|
+
return 0
|
|
98
|
+
try:
|
|
99
|
+
return run(args)
|
|
100
|
+
except BrokenPipeError:
|
|
101
|
+
sys.stderr.close() # e.g. `ext2py cat img /big | head`
|
|
102
|
+
return 0
|
|
103
|
+
except KeyboardInterrupt:
|
|
104
|
+
return 130
|
|
105
|
+
except (OSError, ExtError) as exc:
|
|
106
|
+
if isinstance(exc, OSError) and exc.strerror:
|
|
107
|
+
msg = f"{exc.filename}: {exc.strerror}" if exc.filename else exc.strerror
|
|
108
|
+
else:
|
|
109
|
+
msg = str(exc)
|
|
110
|
+
print(f"ext2py: {msg}", file=sys.stderr)
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
sys.exit(main())
|
ext2py/partitions.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Minimal MBR / GPT partition table parsing, to find ext filesystems on whole disks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import struct
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import BinaryIO, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
from ext2py.reader import EXT_MAGIC, SUPERBLOCK_OFFSET, ExtError, ExtFS, Source
|
|
12
|
+
|
|
13
|
+
LINUX_FS_GUID = uuid.UUID("0FC63DAF-8483-4772-8E79-3D69D8477DE4")
|
|
14
|
+
_MBR_EXTENDED = (0x05, 0x0F, 0x85)
|
|
15
|
+
_MBR_GPT_PROTECTIVE = 0xEE
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Partition:
|
|
20
|
+
number: int # 1-based, as in /dev/sdaN or diskNsN
|
|
21
|
+
offset: int # bytes
|
|
22
|
+
size: int # bytes
|
|
23
|
+
type: str # MBR type byte (hex) or GPT type GUID
|
|
24
|
+
name: str = ""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def is_linux(self) -> bool:
|
|
28
|
+
return self.type in ("0x83", str(LINUX_FS_GUID))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _gpt(src: Source, sector: int) -> Optional[List[Partition]]:
|
|
32
|
+
hdr = src.pread(sector, 92)
|
|
33
|
+
if hdr[:8] != b"EFI PART":
|
|
34
|
+
return None
|
|
35
|
+
entries_lba, count, entry_size = struct.unpack_from("<QII", hdr, 72)
|
|
36
|
+
if entry_size < 128 or count > 1024:
|
|
37
|
+
return None
|
|
38
|
+
table = src.pread(entries_lba * sector, count * entry_size)
|
|
39
|
+
parts = []
|
|
40
|
+
for i in range(count):
|
|
41
|
+
e = table[i * entry_size:(i + 1) * entry_size]
|
|
42
|
+
type_guid = uuid.UUID(bytes_le=e[:16])
|
|
43
|
+
if type_guid.int == 0:
|
|
44
|
+
continue
|
|
45
|
+
first, last = struct.unpack_from("<QQ", e, 32)
|
|
46
|
+
name = e[56:128].decode("utf-16-le", "replace").split("\0", 1)[0]
|
|
47
|
+
parts.append(Partition(i + 1, first * sector, (last - first + 1) * sector, str(type_guid), name))
|
|
48
|
+
return parts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_partitions(f: BinaryIO) -> List[Partition]:
|
|
52
|
+
"""Return the partitions on a disk image or device (empty if none found).
|
|
53
|
+
|
|
54
|
+
Logical partitions inside MBR extended partitions are not listed.
|
|
55
|
+
"""
|
|
56
|
+
src = Source(f)
|
|
57
|
+
try:
|
|
58
|
+
mbr = src.pread(0, 512)
|
|
59
|
+
except ExtError:
|
|
60
|
+
return []
|
|
61
|
+
for sector in (512, 4096):
|
|
62
|
+
try:
|
|
63
|
+
parts = _gpt(src, sector)
|
|
64
|
+
except ExtError:
|
|
65
|
+
parts = None
|
|
66
|
+
if parts is not None:
|
|
67
|
+
return parts
|
|
68
|
+
if mbr[510:512] != b"\x55\xaa":
|
|
69
|
+
return []
|
|
70
|
+
parts = []
|
|
71
|
+
for i in range(4):
|
|
72
|
+
_status, ptype, start, count = struct.unpack_from("<B3xB3xII", mbr, 446 + 16 * i)
|
|
73
|
+
if ptype == 0 or ptype in _MBR_EXTENDED or ptype == _MBR_GPT_PROTECTIVE or count == 0:
|
|
74
|
+
continue
|
|
75
|
+
parts.append(Partition(i + 1, start * 512, count * 512, f"0x{ptype:02x}"))
|
|
76
|
+
return parts
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _has_ext_magic(f: BinaryIO, offset: int) -> bool:
|
|
80
|
+
try:
|
|
81
|
+
sb = Source(f, offset).pread(SUPERBLOCK_OFFSET, 64)
|
|
82
|
+
except ExtError:
|
|
83
|
+
return False
|
|
84
|
+
return struct.unpack_from("<H", sb, 56)[0] == EXT_MAGIC
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def open_fs(
|
|
88
|
+
path: Union[str, "os.PathLike[str]"],
|
|
89
|
+
partition: Optional[int] = None,
|
|
90
|
+
offset: Optional[int] = None,
|
|
91
|
+
) -> ExtFS:
|
|
92
|
+
"""Open an ext filesystem from a bare filesystem image or a partitioned disk.
|
|
93
|
+
|
|
94
|
+
With neither ``partition`` nor ``offset``, a bare filesystem is tried
|
|
95
|
+
first, then the partition table is searched for exactly one ext partition.
|
|
96
|
+
"""
|
|
97
|
+
f = open(path, "rb", buffering=0)
|
|
98
|
+
try:
|
|
99
|
+
if offset is None:
|
|
100
|
+
if partition is not None:
|
|
101
|
+
parts = {p.number: p for p in find_partitions(f)}
|
|
102
|
+
if partition not in parts:
|
|
103
|
+
raise ExtError(f"no partition {partition} (found: {sorted(parts) or 'none'})")
|
|
104
|
+
offset = parts[partition].offset
|
|
105
|
+
elif _has_ext_magic(f, 0):
|
|
106
|
+
offset = 0
|
|
107
|
+
else:
|
|
108
|
+
found = [p for p in find_partitions(f) if _has_ext_magic(f, p.offset)]
|
|
109
|
+
if not found:
|
|
110
|
+
raise ExtError("no ext2/3/4 filesystem found (not a bare image, no ext partition)")
|
|
111
|
+
if len(found) > 1:
|
|
112
|
+
nums = ", ".join(str(p.number) for p in found)
|
|
113
|
+
raise ExtError(f"several ext partitions found ({nums}); pick one with --partition")
|
|
114
|
+
offset = found[0].offset
|
|
115
|
+
fs = ExtFS(f, offset)
|
|
116
|
+
except BaseException:
|
|
117
|
+
f.close()
|
|
118
|
+
raise
|
|
119
|
+
fs._owns_file = True
|
|
120
|
+
return fs
|
ext2py/py.typed
ADDED
|
File without changes
|