ext2py 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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.
ext2py-0.1.0/PKG-INFO ADDED
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/ext2py.svg)](https://pypi.org/project/ext2py/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/ext2py.svg)](https://pypi.org/project/ext2py/)
42
+ [![CI](https://github.com/sciciliani/ext2py/actions/workflows/ci.yml/badge.svg)](https://github.com/sciciliani/ext2py/actions/workflows/ci.yml)
43
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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).
ext2py-0.1.0/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # ext2py
2
+
3
+ **Browse ext2 / ext3 / ext4 Linux filesystems from Python on macOS, Linux or Windows: no kernel drivers, no FUSE, no root for disk images.**
4
+
5
+ `ext2py` is a pure-Python, zero-dependency, **read-only** ext2/3/4 parser. It ships with
6
+ an interactive shell to `cd`, `ls` and download files out of Linux disk images,
7
+ partitions and devices.
8
+
9
+ [![PyPI](https://img.shields.io/pypi/v/ext2py.svg)](https://pypi.org/project/ext2py/)
10
+ [![Python](https://img.shields.io/pypi/pyversions/ext2py.svg)](https://pypi.org/project/ext2py/)
11
+ [![CI](https://github.com/sciciliani/ext2py/actions/workflows/ci.yml/badge.svg)](https://github.com/sciciliani/ext2py/actions/workflows/ci.yml)
12
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
13
+
14
+ > ⚠️ Early alpha. ext2py never writes to the source image or device.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install ext2py
20
+ ```
21
+
22
+ ## Interactive shell
23
+
24
+ ```console
25
+ $ ext2py shell disk.img
26
+ ext2py shell: disk.img (ext4, read-only). Type 'help' for commands, 'exit' to quit.
27
+ ext2py:/$ cd home/me
28
+ ext2py:/home/me$ ls -l
29
+ -rw-r--r-- 1 1000 1000 16 Nov 14 2023 notes.txt
30
+ ext2py:/home/me$ get notes.txt
31
+ /home/me/notes.txt -> /Users/you/notes.txt
32
+ 1 item(s) copied
33
+ ```
34
+
35
+ Commands: `ls [-lah]`, `ll`, `cd`, `pwd`, `tree`, `find [-name GLOB]`, `cat`, `stat`,
36
+ `readlink`, `info`, `get PATH [LOCAL]` (recursive for directories), and the local-side
37
+ `lcd`, `lpwd`, `lls`. Tab completion works on paths inside the image.
38
+
39
+ ## One-shot commands
40
+
41
+ ```bash
42
+ ext2py info disk.img # superblock summary
43
+ ext2py parts disk.img # partition table (MBR / GPT)
44
+ ext2py ls disk.img /home -l # list a directory
45
+ ext2py cat disk.img /etc/hostname # print a file
46
+ ext2py stat disk.img /etc/passwd # inode details
47
+ ext2py get disk.img /home/me ./out # copy files out
48
+ ```
49
+
50
+ Whole-disk images are handled automatically when they contain a single ext partition;
51
+ otherwise use `--partition N` (see `ext2py parts`) or `--offset BYTES`.
52
+
53
+ Physical disks on macOS: find the disk with `diskutil list`, then point at the raw
54
+ device (`sudo ext2py shell /dev/rdisk4`). Reading a device needs read permission on
55
+ it, which is the only reason `sudo` is involved. Image files need no privileges.
56
+
57
+ ## Python API
58
+
59
+ ```python
60
+ from ext2py import ExtFS, open_fs
61
+
62
+ with ExtFS("partition.img") as fs: # or open_fs("disk.img") for partitioned disks
63
+ print(fs.listdir("/etc"))
64
+ data = fs.read_file("/etc/os-release")
65
+ st = fs.stat("/etc/passwd") # Inode: mode, uid, gid, size, mtime, ...
66
+ for top, dirs, files in fs.walk("/home"):
67
+ ...
68
+ fs.extract("/home/me", "./out") # like cp -R
69
+ ```
70
+
71
+ ## Features
72
+
73
+ - [x] ext2, ext3 and ext4 (the journal is ignored; reads the on-disk state)
74
+ - [x] Direct, indirect, double- and triple-indirect block maps; sparse files
75
+ - [x] ext4 extent trees, uninitialized extents, inline data, 64bit, flex_bg, meta_bg
76
+ - [x] Hashed (htree) directories, fast and slow symlinks
77
+ - [x] Partitioned images (MBR / GPT)
78
+ - [ ] Encrypted directories (names are shown raw, contents are not decrypted)
79
+ - [ ] Extended attributes outside the inode, MBR logical partitions
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ git clone https://github.com/sciciliani/ext2py && cd ext2py
85
+ python -m venv .venv && source .venv/bin/activate
86
+ pip install -e ".[dev]"
87
+ pytest
88
+ ```
89
+
90
+ The tests build ext2/ext4 images in pure Python (`tests/imagebuilder.py`), so no
91
+ `mke2fs` is required.
92
+
93
+ ## License
94
+
95
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ext2py"
7
+ dynamic = ["version"]
8
+ description = "Pure-Python, read-only ext2/ext3/ext4 filesystem reader and browsing shell, no root or kernel drivers needed"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Santiago Ciciliani", email="santiago.ciciliani@gmail.com" }
15
+ ]
16
+ keywords = [
17
+ "ext2", "ext3", "ext4", "filesystem", "disk-image", "shell", "forensics", "linux", "macos", "read-only",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Environment :: Console",
22
+ "Intended Audience :: Developers",
23
+ "Intended Audience :: System Administrators",
24
+ "Operating System :: OS Independent",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3 :: Only",
27
+ "Topic :: System :: Filesystems",
28
+ "Topic :: System :: Recovery Tools",
29
+ "Topic :: Utilities",
30
+ "Typing :: Typed",
31
+ ]
32
+ dependencies = []
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["pytest>=7", "build", "twine"]
36
+
37
+ [project.scripts]
38
+ ext2py = "ext2py.cli:main"
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/sciciliani/ext2py"
42
+ Repository = "https://github.com/sciciliani/ext2py"
43
+ Issues = "https://github.com/sciciliani/ext2py/issues"
44
+ Changelog = "https://github.com/sciciliani/ext2py/blob/main/CHANGELOG.md"
45
+
46
+ [tool.setuptools.dynamic]
47
+ version = { attr = "ext2py.__version__" }
48
+
49
+ [tool.setuptools.packages.find]
50
+ where = ["src"]
51
+
52
+ [tool.setuptools.package-data]
53
+ ext2py = ["py.typed"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
ext2py-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -0,0 +1,3 @@
1
+ from ext2py.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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())
@@ -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
File without changes