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/reader.py
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
"""Pure-Python, read-only parser for ext2 / ext3 / ext4 filesystems.
|
|
2
|
+
|
|
3
|
+
On-disk layout reference: https://docs.kernel.org/filesystems/ext4/
|
|
4
|
+
|
|
5
|
+
Everything here only ever *reads* from the underlying file or device.
|
|
6
|
+
The ext3/ext4 journal is ignored: we read the on-disk state as-is.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import errno
|
|
12
|
+
import os
|
|
13
|
+
import posixpath
|
|
14
|
+
import stat as stat_mod
|
|
15
|
+
import struct
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from typing import BinaryIO, Callable, Iterator, List, Optional, Tuple, Union
|
|
19
|
+
|
|
20
|
+
EXT_MAGIC = 0xEF53
|
|
21
|
+
ROOT_INO = 2
|
|
22
|
+
SUPERBLOCK_OFFSET = 1024
|
|
23
|
+
|
|
24
|
+
# s_feature_compat
|
|
25
|
+
COMPAT_HAS_JOURNAL = 0x0004
|
|
26
|
+
COMPAT_DIR_INDEX = 0x0020
|
|
27
|
+
# s_feature_ro_compat
|
|
28
|
+
RO_COMPAT_SPARSE_SUPER = 0x0001
|
|
29
|
+
RO_COMPAT_HUGE_FILE = 0x0008
|
|
30
|
+
# s_feature_incompat
|
|
31
|
+
INCOMPAT_COMPRESSION = 0x0001
|
|
32
|
+
INCOMPAT_FILETYPE = 0x0002
|
|
33
|
+
INCOMPAT_RECOVER = 0x0004
|
|
34
|
+
INCOMPAT_JOURNAL_DEV = 0x0008
|
|
35
|
+
INCOMPAT_META_BG = 0x0010
|
|
36
|
+
INCOMPAT_EXTENTS = 0x0040
|
|
37
|
+
INCOMPAT_64BIT = 0x0080
|
|
38
|
+
INCOMPAT_MMP = 0x0100
|
|
39
|
+
INCOMPAT_FLEX_BG = 0x0200
|
|
40
|
+
INCOMPAT_EA_INODE = 0x0400
|
|
41
|
+
INCOMPAT_DIRDATA = 0x1000
|
|
42
|
+
INCOMPAT_CSUM_SEED = 0x2000
|
|
43
|
+
INCOMPAT_LARGEDIR = 0x4000
|
|
44
|
+
INCOMPAT_INLINE_DATA = 0x8000
|
|
45
|
+
INCOMPAT_ENCRYPT = 0x10000
|
|
46
|
+
INCOMPAT_CASEFOLD = 0x20000
|
|
47
|
+
|
|
48
|
+
INCOMPAT_SUPPORTED = (
|
|
49
|
+
INCOMPAT_FILETYPE | INCOMPAT_RECOVER | INCOMPAT_META_BG | INCOMPAT_EXTENTS
|
|
50
|
+
| INCOMPAT_64BIT | INCOMPAT_MMP | INCOMPAT_FLEX_BG | INCOMPAT_EA_INODE
|
|
51
|
+
| INCOMPAT_CSUM_SEED | INCOMPAT_LARGEDIR | INCOMPAT_INLINE_DATA
|
|
52
|
+
| INCOMPAT_ENCRYPT | INCOMPAT_CASEFOLD
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
INCOMPAT_NAMES = {
|
|
56
|
+
INCOMPAT_COMPRESSION: "compression", INCOMPAT_FILETYPE: "filetype",
|
|
57
|
+
INCOMPAT_RECOVER: "needs_recovery", INCOMPAT_JOURNAL_DEV: "journal_dev",
|
|
58
|
+
INCOMPAT_META_BG: "meta_bg", INCOMPAT_EXTENTS: "extent", INCOMPAT_64BIT: "64bit",
|
|
59
|
+
INCOMPAT_MMP: "mmp", INCOMPAT_FLEX_BG: "flex_bg", INCOMPAT_EA_INODE: "ea_inode",
|
|
60
|
+
INCOMPAT_DIRDATA: "dirdata", INCOMPAT_CSUM_SEED: "metadata_csum_seed",
|
|
61
|
+
INCOMPAT_LARGEDIR: "large_dir", INCOMPAT_INLINE_DATA: "inline_data",
|
|
62
|
+
INCOMPAT_ENCRYPT: "encrypt", INCOMPAT_CASEFOLD: "casefold",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# i_flags
|
|
66
|
+
INODE_INDEX_FL = 0x00001000
|
|
67
|
+
INODE_HUGE_FILE_FL = 0x00040000
|
|
68
|
+
INODE_EXTENTS_FL = 0x00080000
|
|
69
|
+
INODE_INLINE_DATA_FL = 0x10000000
|
|
70
|
+
|
|
71
|
+
EXTENT_MAGIC = 0xF30A
|
|
72
|
+
XATTR_MAGIC = 0xEA020000
|
|
73
|
+
XATTR_INDEX_SYSTEM = 7
|
|
74
|
+
|
|
75
|
+
# Directory entry file_type values
|
|
76
|
+
FT_NAMES = {1: "file", 2: "dir", 3: "chrdev", 4: "blkdev", 5: "fifo", 6: "socket", 7: "symlink"}
|
|
77
|
+
FT_DIR = 2
|
|
78
|
+
|
|
79
|
+
_ALIGN = 4096 # raw devices (/dev/rdisk*) only accept sector-aligned reads
|
|
80
|
+
_READ_CHUNK = 1 << 20
|
|
81
|
+
_MAX_SYMLINKS = 40
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ExtError(Exception):
|
|
85
|
+
"""The image is not a (supported) ext filesystem, or it is corrupt."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------- #
|
|
89
|
+
# Low-level source
|
|
90
|
+
# --------------------------------------------------------------------------- #
|
|
91
|
+
|
|
92
|
+
class Source:
|
|
93
|
+
"""Random-access reads from a file object, aligned for raw block devices."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, f: BinaryIO, offset: int = 0):
|
|
96
|
+
self.f = f
|
|
97
|
+
self.offset = offset
|
|
98
|
+
try:
|
|
99
|
+
self._fd: Optional[int] = f.fileno()
|
|
100
|
+
except (AttributeError, OSError, ValueError):
|
|
101
|
+
self._fd = None
|
|
102
|
+
|
|
103
|
+
def _raw(self, pos: int, n: int) -> bytes:
|
|
104
|
+
parts = []
|
|
105
|
+
while n > 0:
|
|
106
|
+
if self._fd is not None and hasattr(os, "pread"):
|
|
107
|
+
chunk = os.pread(self._fd, n, pos)
|
|
108
|
+
else:
|
|
109
|
+
self.f.seek(pos)
|
|
110
|
+
chunk = self.f.read(n)
|
|
111
|
+
if not chunk:
|
|
112
|
+
break
|
|
113
|
+
parts.append(chunk)
|
|
114
|
+
pos += len(chunk)
|
|
115
|
+
n -= len(chunk)
|
|
116
|
+
return b"".join(parts)
|
|
117
|
+
|
|
118
|
+
def pread(self, pos: int, length: int) -> bytes:
|
|
119
|
+
if length <= 0:
|
|
120
|
+
return b""
|
|
121
|
+
pos += self.offset
|
|
122
|
+
start = pos - pos % _ALIGN
|
|
123
|
+
end = -(-(pos + length) // _ALIGN) * _ALIGN
|
|
124
|
+
data = self._raw(start, end - start)[pos - start:pos - start + length]
|
|
125
|
+
if len(data) != length:
|
|
126
|
+
raise ExtError(f"short read at byte {pos} (image truncated?)")
|
|
127
|
+
return data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
# On-disk structures
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
|
|
134
|
+
def _cstr(raw: bytes) -> str:
|
|
135
|
+
return raw.split(b"\0", 1)[0].decode("utf-8", "replace")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class Superblock:
|
|
140
|
+
inodes_count: int
|
|
141
|
+
blocks_count: int
|
|
142
|
+
free_blocks_count: int
|
|
143
|
+
free_inodes_count: int
|
|
144
|
+
first_data_block: int
|
|
145
|
+
log_block_size: int
|
|
146
|
+
blocks_per_group: int
|
|
147
|
+
inodes_per_group: int
|
|
148
|
+
mtime: int
|
|
149
|
+
wtime: int
|
|
150
|
+
magic: int
|
|
151
|
+
state: int
|
|
152
|
+
rev_level: int
|
|
153
|
+
first_ino: int
|
|
154
|
+
inode_size: int
|
|
155
|
+
feature_compat: int
|
|
156
|
+
feature_incompat: int
|
|
157
|
+
feature_ro_compat: int
|
|
158
|
+
uuid: bytes
|
|
159
|
+
volume_name: str
|
|
160
|
+
last_mounted: str
|
|
161
|
+
desc_size: int
|
|
162
|
+
first_meta_bg: int
|
|
163
|
+
mkfs_time: int
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def parse(cls, raw: bytes) -> "Superblock":
|
|
167
|
+
magic = struct.unpack_from("<H", raw, 56)[0]
|
|
168
|
+
if magic != EXT_MAGIC:
|
|
169
|
+
raise ExtError("not an ext2/3/4 filesystem (bad superblock magic)")
|
|
170
|
+
(inodes_count, blocks_lo, _r_blocks_lo, free_blocks_lo, free_inodes,
|
|
171
|
+
first_data_block, log_block_size, _log_cluster, blocks_per_group,
|
|
172
|
+
_clusters_per_group, inodes_per_group, mtime, wtime) = struct.unpack_from("<13I", raw, 0)
|
|
173
|
+
state = struct.unpack_from("<H", raw, 58)[0]
|
|
174
|
+
rev_level = struct.unpack_from("<I", raw, 76)[0]
|
|
175
|
+
first_ino, inode_size = struct.unpack_from("<IH", raw, 84)
|
|
176
|
+
compat, incompat, ro_compat = struct.unpack_from("<3I", raw, 92)
|
|
177
|
+
desc_size = struct.unpack_from("<H", raw, 254)[0]
|
|
178
|
+
first_meta_bg, mkfs_time = struct.unpack_from("<II", raw, 260)
|
|
179
|
+
blocks_hi, _r_hi, free_blocks_hi = struct.unpack_from("<3I", raw, 336)
|
|
180
|
+
if rev_level == 0:
|
|
181
|
+
first_ino, inode_size = 11, 128
|
|
182
|
+
is64 = bool(incompat & INCOMPAT_64BIT)
|
|
183
|
+
return cls(
|
|
184
|
+
inodes_count=inodes_count,
|
|
185
|
+
blocks_count=blocks_lo | ((blocks_hi << 32) if is64 else 0),
|
|
186
|
+
free_blocks_count=free_blocks_lo | ((free_blocks_hi << 32) if is64 else 0),
|
|
187
|
+
free_inodes_count=free_inodes,
|
|
188
|
+
first_data_block=first_data_block,
|
|
189
|
+
log_block_size=log_block_size,
|
|
190
|
+
blocks_per_group=blocks_per_group,
|
|
191
|
+
inodes_per_group=inodes_per_group,
|
|
192
|
+
mtime=mtime,
|
|
193
|
+
wtime=wtime,
|
|
194
|
+
magic=magic,
|
|
195
|
+
state=state,
|
|
196
|
+
rev_level=rev_level,
|
|
197
|
+
first_ino=first_ino,
|
|
198
|
+
inode_size=inode_size,
|
|
199
|
+
feature_compat=compat,
|
|
200
|
+
feature_incompat=incompat,
|
|
201
|
+
feature_ro_compat=ro_compat,
|
|
202
|
+
uuid=raw[104:120],
|
|
203
|
+
volume_name=_cstr(raw[120:136]),
|
|
204
|
+
last_mounted=_cstr(raw[136:200]),
|
|
205
|
+
desc_size=(desc_size or 64) if is64 else 32,
|
|
206
|
+
first_meta_bg=first_meta_bg,
|
|
207
|
+
mkfs_time=mkfs_time,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def block_size(self) -> int:
|
|
212
|
+
return 1024 << self.log_block_size
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def group_count(self) -> int:
|
|
216
|
+
return -(-(self.blocks_count - self.first_data_block) // self.blocks_per_group)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def uuid_str(self) -> str:
|
|
220
|
+
h = self.uuid.hex()
|
|
221
|
+
return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def fs_type(self) -> str:
|
|
225
|
+
if self.feature_incompat & (INCOMPAT_EXTENTS | INCOMPAT_64BIT | INCOMPAT_FLEX_BG):
|
|
226
|
+
return "ext4"
|
|
227
|
+
if self.feature_compat & COMPAT_HAS_JOURNAL:
|
|
228
|
+
return "ext3"
|
|
229
|
+
return "ext2"
|
|
230
|
+
|
|
231
|
+
def incompat_names(self) -> List[str]:
|
|
232
|
+
return [n for bit, n in sorted(INCOMPAT_NAMES.items()) if self.feature_incompat & bit]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _ts(lo: int, extra: Optional[int]) -> float:
|
|
236
|
+
secs = lo - (1 << 32) if lo & 0x80000000 else lo
|
|
237
|
+
if extra is None:
|
|
238
|
+
return float(secs)
|
|
239
|
+
return secs + ((extra & 3) << 32) + (extra >> 2) / 1e9
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class Inode:
|
|
244
|
+
ino: int
|
|
245
|
+
mode: int
|
|
246
|
+
uid: int
|
|
247
|
+
gid: int
|
|
248
|
+
size: int
|
|
249
|
+
atime: float
|
|
250
|
+
ctime: float
|
|
251
|
+
mtime: float
|
|
252
|
+
crtime: Optional[float]
|
|
253
|
+
dtime: int
|
|
254
|
+
links_count: int
|
|
255
|
+
blocks: int # in 512-byte sectors
|
|
256
|
+
flags: int
|
|
257
|
+
generation: int
|
|
258
|
+
file_acl: int
|
|
259
|
+
block: bytes # raw i_block (60 bytes): block map, extent root, inline data or symlink
|
|
260
|
+
raw: bytes = field(repr=False)
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def parse(cls, ino: int, raw: bytes, sb: Superblock) -> "Inode":
|
|
264
|
+
(mode, uid_lo, size_lo, atime, ctime, mtime, dtime, gid_lo, links,
|
|
265
|
+
blocks_lo, flags) = struct.unpack_from("<2H5I2HII", raw, 0)
|
|
266
|
+
generation, file_acl_lo, size_hi = struct.unpack_from("<3I", raw, 100)
|
|
267
|
+
blocks_hi, file_acl_hi, uid_hi, gid_hi = struct.unpack_from("<4H", raw, 116)
|
|
268
|
+
|
|
269
|
+
extra_isize = struct.unpack_from("<H", raw, 128)[0] if len(raw) > 128 else 0
|
|
270
|
+
extra_end = 128 + extra_isize
|
|
271
|
+
|
|
272
|
+
def extra(off: int) -> Optional[int]:
|
|
273
|
+
return struct.unpack_from("<I", raw, off)[0] if off + 4 <= extra_end else None
|
|
274
|
+
|
|
275
|
+
blocks = blocks_lo
|
|
276
|
+
if sb.feature_ro_compat & RO_COMPAT_HUGE_FILE:
|
|
277
|
+
blocks |= blocks_hi << 32
|
|
278
|
+
if flags & INODE_HUGE_FILE_FL:
|
|
279
|
+
blocks *= sb.block_size // 512
|
|
280
|
+
crtime = extra(0x90)
|
|
281
|
+
return cls(
|
|
282
|
+
ino=ino,
|
|
283
|
+
mode=mode,
|
|
284
|
+
uid=uid_lo | (uid_hi << 16),
|
|
285
|
+
gid=gid_lo | (gid_hi << 16),
|
|
286
|
+
size=size_lo | (size_hi << 32),
|
|
287
|
+
atime=_ts(atime, extra(0x8C)),
|
|
288
|
+
ctime=_ts(ctime, extra(0x84)),
|
|
289
|
+
mtime=_ts(mtime, extra(0x88)),
|
|
290
|
+
crtime=None if crtime is None else _ts(crtime, extra(0x94)),
|
|
291
|
+
dtime=dtime,
|
|
292
|
+
links_count=links,
|
|
293
|
+
blocks=blocks,
|
|
294
|
+
flags=flags,
|
|
295
|
+
generation=generation,
|
|
296
|
+
file_acl=file_acl_lo | (file_acl_hi << 32),
|
|
297
|
+
block=raw[40:100],
|
|
298
|
+
raw=raw,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def is_dir(self) -> bool:
|
|
303
|
+
return stat_mod.S_ISDIR(self.mode)
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def is_file(self) -> bool:
|
|
307
|
+
return stat_mod.S_ISREG(self.mode)
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def is_symlink(self) -> bool:
|
|
311
|
+
return stat_mod.S_ISLNK(self.mode)
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def filemode(self) -> str:
|
|
315
|
+
"""``ls -l`` style mode string, e.g. ``drwxr-xr-x``."""
|
|
316
|
+
return stat_mod.filemode(self.mode)
|
|
317
|
+
|
|
318
|
+
def inline_xattrs(self) -> dict:
|
|
319
|
+
"""Extended attributes stored inside the inode body, as {(index, name): value}."""
|
|
320
|
+
raw = self.raw
|
|
321
|
+
if len(raw) <= 128:
|
|
322
|
+
return {}
|
|
323
|
+
start = 128 + struct.unpack_from("<H", raw, 128)[0]
|
|
324
|
+
if start + 4 > len(raw) or struct.unpack_from("<I", raw, start)[0] != XATTR_MAGIC:
|
|
325
|
+
return {}
|
|
326
|
+
base = start + 4
|
|
327
|
+
off = base
|
|
328
|
+
out = {}
|
|
329
|
+
while off + 16 <= len(raw) and struct.unpack_from("<I", raw, off)[0] != 0:
|
|
330
|
+
name_len, index, value_offs, value_inum, value_size, _hash = \
|
|
331
|
+
struct.unpack_from("<BBHIII", raw, off)
|
|
332
|
+
name = raw[off + 16:off + 16 + name_len]
|
|
333
|
+
if value_inum == 0:
|
|
334
|
+
out[(index, name)] = raw[base + value_offs:base + value_offs + value_size]
|
|
335
|
+
off += (16 + name_len + 3) & ~3
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass(frozen=True)
|
|
340
|
+
class DirEntry:
|
|
341
|
+
name: str
|
|
342
|
+
inode: int
|
|
343
|
+
file_type: int # 0 when the filesystem lacks the "filetype" feature
|
|
344
|
+
name_bytes: bytes = field(repr=False, default=b"")
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def is_dir(self) -> bool:
|
|
348
|
+
return self.file_type == FT_DIR
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def type_name(self) -> str:
|
|
352
|
+
return FT_NAMES.get(self.file_type, "unknown")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@dataclass(frozen=True)
|
|
356
|
+
class Run:
|
|
357
|
+
"""A contiguous mapping of ``length`` logical blocks to physical blocks."""
|
|
358
|
+
logical: int
|
|
359
|
+
physical: int
|
|
360
|
+
length: int
|
|
361
|
+
uninit: bool = False # ext4 preallocated extent: reads back as zeros
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# --------------------------------------------------------------------------- #
|
|
365
|
+
# Filesystem
|
|
366
|
+
# --------------------------------------------------------------------------- #
|
|
367
|
+
|
|
368
|
+
PathLike = Union[str, bytes, "os.PathLike[str]"]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _split(path: bytes) -> List[bytes]:
|
|
372
|
+
return [p for p in path.split(b"/") if p and p != b"."]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _safe_name(name: str) -> bool:
|
|
376
|
+
return name not in ("", ".", "..") and "/" not in name and "\0" not in name
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ExtFS:
|
|
380
|
+
"""A read-only ext2/ext3/ext4 filesystem.
|
|
381
|
+
|
|
382
|
+
``source`` is a path to an image / device, or an already-open binary file
|
|
383
|
+
object. ``offset`` is the byte offset of the filesystem within it (for
|
|
384
|
+
partitioned disks, see :func:`ext2py.partitions.open_fs`).
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def __init__(self, source: Union[PathLike, BinaryIO], offset: int = 0):
|
|
388
|
+
if isinstance(source, (str, bytes, os.PathLike)):
|
|
389
|
+
self._file: BinaryIO = open(source, "rb", buffering=0)
|
|
390
|
+
self._owns_file = True
|
|
391
|
+
else:
|
|
392
|
+
self._file = source
|
|
393
|
+
self._owns_file = False
|
|
394
|
+
try:
|
|
395
|
+
self._src = Source(self._file, offset)
|
|
396
|
+
self.sb = Superblock.parse(self._src.pread(SUPERBLOCK_OFFSET, 1024))
|
|
397
|
+
unsupported = self.sb.feature_incompat & ~INCOMPAT_SUPPORTED
|
|
398
|
+
if unsupported:
|
|
399
|
+
names = [n for b, n in INCOMPAT_NAMES.items() if unsupported & b] or [hex(unsupported)]
|
|
400
|
+
raise ExtError(f"unsupported filesystem features: {', '.join(names)}")
|
|
401
|
+
if self.sb.blocks_per_group == 0 or self.sb.inodes_per_group == 0:
|
|
402
|
+
raise ExtError("corrupt superblock")
|
|
403
|
+
except BaseException:
|
|
404
|
+
self.close()
|
|
405
|
+
raise
|
|
406
|
+
self.block_size = self.sb.block_size
|
|
407
|
+
self.inode = lru_cache(maxsize=4096)(self._read_inode)
|
|
408
|
+
self._read_meta_block = lru_cache(maxsize=256)(self._read_block)
|
|
409
|
+
self._dir_entries = lru_cache(maxsize=256)(self._read_dir)
|
|
410
|
+
|
|
411
|
+
# -- lifecycle -------------------------------------------------------- #
|
|
412
|
+
|
|
413
|
+
def close(self) -> None:
|
|
414
|
+
if getattr(self, "_owns_file", False):
|
|
415
|
+
self._file.close()
|
|
416
|
+
|
|
417
|
+
def __enter__(self) -> "ExtFS":
|
|
418
|
+
return self
|
|
419
|
+
|
|
420
|
+
def __exit__(self, *exc) -> None:
|
|
421
|
+
self.close()
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def needs_recovery(self) -> bool:
|
|
425
|
+
"""True if the journal has unreplayed transactions (recent writes may be missing)."""
|
|
426
|
+
return bool(self.sb.feature_incompat & INCOMPAT_RECOVER)
|
|
427
|
+
|
|
428
|
+
# -- blocks and group descriptors ------------------------------------- #
|
|
429
|
+
|
|
430
|
+
def _read_block(self, block: int) -> bytes:
|
|
431
|
+
return self._src.pread(block * self.block_size, self.block_size)
|
|
432
|
+
|
|
433
|
+
def _group_has_super(self, group: int) -> bool:
|
|
434
|
+
if not self.sb.feature_ro_compat & RO_COMPAT_SPARSE_SUPER or group <= 1:
|
|
435
|
+
return True
|
|
436
|
+
for base in (3, 5, 7):
|
|
437
|
+
n = base
|
|
438
|
+
while n < group:
|
|
439
|
+
n *= base
|
|
440
|
+
if n == group:
|
|
441
|
+
return True
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
def _group_desc(self, group: int) -> bytes:
|
|
445
|
+
bs, ds = self.block_size, self.sb.desc_size
|
|
446
|
+
per_block = bs // ds
|
|
447
|
+
index = group // per_block
|
|
448
|
+
if self.sb.feature_incompat & INCOMPAT_META_BG and index >= self.sb.first_meta_bg:
|
|
449
|
+
first = index * per_block
|
|
450
|
+
block = self.sb.first_data_block + first * self.sb.blocks_per_group
|
|
451
|
+
block += 1 if self._group_has_super(first) else 0
|
|
452
|
+
else:
|
|
453
|
+
block = self.sb.first_data_block + 1 + index
|
|
454
|
+
off = (group % per_block) * ds
|
|
455
|
+
return self._read_meta_block(block)[off:off + ds]
|
|
456
|
+
|
|
457
|
+
def _inode_table(self, group: int) -> int:
|
|
458
|
+
desc = self._group_desc(group)
|
|
459
|
+
table = struct.unpack_from("<I", desc, 8)[0]
|
|
460
|
+
if self.sb.desc_size >= 64:
|
|
461
|
+
table |= struct.unpack_from("<I", desc, 0x28)[0] << 32
|
|
462
|
+
return table
|
|
463
|
+
|
|
464
|
+
# -- inodes ------------------------------------------------------------ #
|
|
465
|
+
|
|
466
|
+
def _read_inode(self, ino: int) -> Inode:
|
|
467
|
+
if not 1 <= ino <= self.sb.inodes_count:
|
|
468
|
+
raise ExtError(f"inode number {ino} out of range")
|
|
469
|
+
group, index = divmod(ino - 1, self.sb.inodes_per_group)
|
|
470
|
+
if group >= self.sb.group_count:
|
|
471
|
+
raise ExtError(f"inode number {ino} out of range")
|
|
472
|
+
pos = self._inode_table(group) * self.block_size + index * self.sb.inode_size
|
|
473
|
+
return Inode.parse(ino, self._src.pread(pos, self.sb.inode_size), self.sb)
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def root(self) -> Inode:
|
|
477
|
+
return self.inode(ROOT_INO)
|
|
478
|
+
|
|
479
|
+
# -- block mapping ----------------------------------------------------- #
|
|
480
|
+
|
|
481
|
+
def runs(self, inode: Inode) -> List[Run]:
|
|
482
|
+
"""Map the inode's logical blocks to physical blocks (holes are omitted)."""
|
|
483
|
+
if inode.flags & INODE_EXTENTS_FL:
|
|
484
|
+
out: List[Run] = []
|
|
485
|
+
self._walk_extents(inode.block, out, None)
|
|
486
|
+
out.sort(key=lambda r: r.logical)
|
|
487
|
+
return out
|
|
488
|
+
return self._blockmap_runs(inode)
|
|
489
|
+
|
|
490
|
+
def _walk_extents(self, node: bytes, out: List[Run], expect_depth: Optional[int]) -> None:
|
|
491
|
+
magic, entries, max_entries, depth = struct.unpack_from("<4H", node, 0)
|
|
492
|
+
if magic != EXTENT_MAGIC:
|
|
493
|
+
raise ExtError("corrupt extent tree (bad magic)")
|
|
494
|
+
if depth > 5 or (expect_depth is not None and depth != expect_depth):
|
|
495
|
+
raise ExtError("corrupt extent tree (bad depth)")
|
|
496
|
+
if entries > max_entries or 12 + 12 * entries > len(node):
|
|
497
|
+
raise ExtError("corrupt extent tree (too many entries)")
|
|
498
|
+
for i in range(entries):
|
|
499
|
+
off = 12 + 12 * i
|
|
500
|
+
if depth == 0:
|
|
501
|
+
lblock, length, start_hi, start_lo = struct.unpack_from("<IHHI", node, off)
|
|
502
|
+
uninit = length > 32768
|
|
503
|
+
if uninit:
|
|
504
|
+
length -= 32768
|
|
505
|
+
out.append(Run(lblock, (start_hi << 32) | start_lo, length, uninit))
|
|
506
|
+
else:
|
|
507
|
+
_lblock, leaf_lo, leaf_hi = struct.unpack_from("<IIH", node, off)
|
|
508
|
+
child = self._read_meta_block((leaf_hi << 32) | leaf_lo)
|
|
509
|
+
self._walk_extents(child, out, depth - 1)
|
|
510
|
+
|
|
511
|
+
def _blockmap_runs(self, inode: Inode) -> List[Run]:
|
|
512
|
+
bs = self.block_size
|
|
513
|
+
per = bs // 4
|
|
514
|
+
nblocks = -(-inode.size // bs)
|
|
515
|
+
ptrs = struct.unpack("<15I", inode.block)
|
|
516
|
+
runs: List[List[int]] = []
|
|
517
|
+
|
|
518
|
+
def add(logical: int, physical: int) -> None:
|
|
519
|
+
if runs:
|
|
520
|
+
last = runs[-1]
|
|
521
|
+
if last[0] + last[2] == logical and last[1] + last[2] == physical:
|
|
522
|
+
last[2] += 1
|
|
523
|
+
return
|
|
524
|
+
runs.append([logical, physical, 1])
|
|
525
|
+
|
|
526
|
+
def walk(block: int, level: int, base: int) -> None:
|
|
527
|
+
span = per ** (level - 1)
|
|
528
|
+
for i, p in enumerate(struct.unpack(f"<{per}I", self._read_block(block))):
|
|
529
|
+
logical = base + i * span
|
|
530
|
+
if logical >= nblocks:
|
|
531
|
+
break
|
|
532
|
+
if p:
|
|
533
|
+
if level == 1:
|
|
534
|
+
add(logical, p)
|
|
535
|
+
else:
|
|
536
|
+
walk(p, level - 1, logical)
|
|
537
|
+
|
|
538
|
+
for i in range(min(12, nblocks)):
|
|
539
|
+
if ptrs[i]:
|
|
540
|
+
add(i, ptrs[i])
|
|
541
|
+
logical = 12
|
|
542
|
+
for level, ptr in ((1, ptrs[12]), (2, ptrs[13]), (3, ptrs[14])):
|
|
543
|
+
if logical >= nblocks:
|
|
544
|
+
break
|
|
545
|
+
if ptr:
|
|
546
|
+
walk(ptr, level, logical)
|
|
547
|
+
logical += per ** level
|
|
548
|
+
return [Run(*r) for r in runs]
|
|
549
|
+
|
|
550
|
+
# -- file contents ----------------------------------------------------- #
|
|
551
|
+
|
|
552
|
+
def _inline_data(self, inode: Inode) -> bytes:
|
|
553
|
+
return inode.block + inode.inline_xattrs().get((XATTR_INDEX_SYSTEM, b"data"), b"")
|
|
554
|
+
|
|
555
|
+
def iter_inode_data(self, inode: Inode, chunk_size: int = _READ_CHUNK) -> Iterator[bytes]:
|
|
556
|
+
"""Yield the contents of ``inode`` in chunks. Holes read as zeros."""
|
|
557
|
+
remaining = inode.size
|
|
558
|
+
if inode.flags & INODE_INLINE_DATA_FL:
|
|
559
|
+
yield self._inline_data(inode)[:remaining]
|
|
560
|
+
return
|
|
561
|
+
bs = self.block_size
|
|
562
|
+
pos = 0 # logical block we are at
|
|
563
|
+
for run in self.runs(inode):
|
|
564
|
+
if remaining <= 0:
|
|
565
|
+
break
|
|
566
|
+
if run.logical + run.length <= pos:
|
|
567
|
+
continue # overlapping extents: corrupt, ignore the overlap
|
|
568
|
+
skip = max(0, pos - run.logical)
|
|
569
|
+
if run.logical > pos:
|
|
570
|
+
hole = min((run.logical - pos) * bs, remaining)
|
|
571
|
+
yield from _zeros(hole, chunk_size)
|
|
572
|
+
remaining -= hole
|
|
573
|
+
nbytes = min((run.length - skip) * bs, remaining)
|
|
574
|
+
if run.uninit:
|
|
575
|
+
yield from _zeros(nbytes, chunk_size)
|
|
576
|
+
else:
|
|
577
|
+
start = (run.physical + skip) * bs
|
|
578
|
+
done = 0
|
|
579
|
+
while done < nbytes:
|
|
580
|
+
n = min(chunk_size, nbytes - done)
|
|
581
|
+
yield self._src.pread(start + done, n)
|
|
582
|
+
done += n
|
|
583
|
+
remaining -= nbytes
|
|
584
|
+
pos = run.logical + run.length
|
|
585
|
+
if remaining > 0:
|
|
586
|
+
yield from _zeros(remaining, chunk_size)
|
|
587
|
+
|
|
588
|
+
def read_inode_data(self, inode: Inode) -> bytes:
|
|
589
|
+
return b"".join(self.iter_inode_data(inode))
|
|
590
|
+
|
|
591
|
+
def _readlink(self, inode: Inode) -> bytes:
|
|
592
|
+
if not inode.is_symlink:
|
|
593
|
+
raise OSError(errno.EINVAL, "not a symlink")
|
|
594
|
+
ea_sectors = self.block_size // 512 if inode.file_acl else 0
|
|
595
|
+
if (not inode.flags & (INODE_EXTENTS_FL | INODE_INLINE_DATA_FL)
|
|
596
|
+
and inode.size < 60 and inode.blocks - ea_sectors <= 0):
|
|
597
|
+
return inode.block[:inode.size] # "fast" symlink: target stored in i_block
|
|
598
|
+
return self.read_inode_data(inode)
|
|
599
|
+
|
|
600
|
+
# -- directories ------------------------------------------------------- #
|
|
601
|
+
|
|
602
|
+
def _parse_dirents(self, data: bytes, out: List[DirEntry]) -> None:
|
|
603
|
+
has_ftype = bool(self.sb.feature_incompat & INCOMPAT_FILETYPE)
|
|
604
|
+
off, end = 0, len(data)
|
|
605
|
+
while off + 8 <= end:
|
|
606
|
+
ino, rec_len, name_len, ftype = struct.unpack_from("<IHBB", data, off)
|
|
607
|
+
if self.block_size >= 65536 and rec_len in (0, 65535):
|
|
608
|
+
rec_len = 65536
|
|
609
|
+
if not has_ftype:
|
|
610
|
+
name_len |= ftype << 8
|
|
611
|
+
ftype = 0
|
|
612
|
+
if rec_len < 8 or rec_len % 4 or off + rec_len > end:
|
|
613
|
+
break # corrupt; stop parsing this block
|
|
614
|
+
if ino and name_len and 8 + name_len <= rec_len:
|
|
615
|
+
raw = data[off + 8:off + 8 + name_len]
|
|
616
|
+
out.append(DirEntry(os.fsdecode(raw), ino, ftype, raw))
|
|
617
|
+
off += rec_len
|
|
618
|
+
|
|
619
|
+
def _read_dir(self, ino: int) -> Tuple[DirEntry, ...]:
|
|
620
|
+
inode = self.inode(ino)
|
|
621
|
+
if not inode.is_dir:
|
|
622
|
+
raise NotADirectoryError(errno.ENOTDIR, "Not a directory")
|
|
623
|
+
out: List[DirEntry] = []
|
|
624
|
+
if inode.flags & INODE_INLINE_DATA_FL:
|
|
625
|
+
parent = struct.unpack_from("<I", inode.block, 0)[0]
|
|
626
|
+
out.append(DirEntry(".", ino, FT_DIR, b"."))
|
|
627
|
+
out.append(DirEntry("..", parent, FT_DIR, b".."))
|
|
628
|
+
self._parse_dirents(inode.block[4:], out)
|
|
629
|
+
extra = inode.inline_xattrs().get((XATTR_INDEX_SYSTEM, b"data"), b"")
|
|
630
|
+
self._parse_dirents(extra, out)
|
|
631
|
+
else:
|
|
632
|
+
# Hashed (htree) directories are also readable linearly: index
|
|
633
|
+
# blocks look like empty dirents to a linear scan.
|
|
634
|
+
data = self.read_inode_data(inode)
|
|
635
|
+
bs = self.block_size
|
|
636
|
+
for off in range(0, len(data), bs):
|
|
637
|
+
self._parse_dirents(data[off:off + bs], out)
|
|
638
|
+
return tuple(out)
|
|
639
|
+
|
|
640
|
+
# -- path API ---------------------------------------------------------- #
|
|
641
|
+
|
|
642
|
+
def lookup(self, path: PathLike, follow_symlinks: bool = True) -> Inode:
|
|
643
|
+
"""Resolve an absolute path (relative paths are taken from ``/``)."""
|
|
644
|
+
parts = _split(os.fsencode(path))
|
|
645
|
+
cur = self.root
|
|
646
|
+
links = 0
|
|
647
|
+
while parts:
|
|
648
|
+
name = parts.pop(0)
|
|
649
|
+
if not cur.is_dir:
|
|
650
|
+
raise NotADirectoryError(errno.ENOTDIR, "Not a directory", os.fsdecode(path))
|
|
651
|
+
for entry in self._dir_entries(cur.ino):
|
|
652
|
+
if entry.name_bytes == name:
|
|
653
|
+
break
|
|
654
|
+
else:
|
|
655
|
+
raise FileNotFoundError(errno.ENOENT, "No such file or directory", os.fsdecode(path))
|
|
656
|
+
node = self.inode(entry.inode)
|
|
657
|
+
if node.is_symlink and (parts or follow_symlinks):
|
|
658
|
+
links += 1
|
|
659
|
+
if links > _MAX_SYMLINKS:
|
|
660
|
+
raise OSError(errno.ELOOP, "Too many levels of symbolic links", os.fsdecode(path))
|
|
661
|
+
target = self._readlink(node)
|
|
662
|
+
if target.startswith(b"/"):
|
|
663
|
+
cur = self.root
|
|
664
|
+
parts[:0] = _split(target)
|
|
665
|
+
continue
|
|
666
|
+
cur = node
|
|
667
|
+
return cur
|
|
668
|
+
|
|
669
|
+
def stat(self, path: PathLike) -> Inode:
|
|
670
|
+
return self.lookup(path, follow_symlinks=True)
|
|
671
|
+
|
|
672
|
+
def lstat(self, path: PathLike) -> Inode:
|
|
673
|
+
return self.lookup(path, follow_symlinks=False)
|
|
674
|
+
|
|
675
|
+
def exists(self, path: PathLike) -> bool:
|
|
676
|
+
try:
|
|
677
|
+
self.lookup(path)
|
|
678
|
+
return True
|
|
679
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
680
|
+
return False
|
|
681
|
+
|
|
682
|
+
def isdir(self, path: PathLike) -> bool:
|
|
683
|
+
try:
|
|
684
|
+
return self.lookup(path).is_dir
|
|
685
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
686
|
+
return False
|
|
687
|
+
|
|
688
|
+
def scandir(self, path: PathLike = "/") -> List[DirEntry]:
|
|
689
|
+
"""Directory entries of ``path``, without ``.`` and ``..``."""
|
|
690
|
+
inode = self.stat(path)
|
|
691
|
+
if not inode.is_dir:
|
|
692
|
+
raise NotADirectoryError(errno.ENOTDIR, "Not a directory", os.fsdecode(path))
|
|
693
|
+
return [e for e in self._dir_entries(inode.ino) if e.name not in (".", "..")]
|
|
694
|
+
|
|
695
|
+
def listdir(self, path: PathLike = "/") -> List[str]:
|
|
696
|
+
return [e.name for e in self.scandir(path)]
|
|
697
|
+
|
|
698
|
+
def readlink(self, path: PathLike) -> str:
|
|
699
|
+
return os.fsdecode(self._readlink(self.lstat(path)))
|
|
700
|
+
|
|
701
|
+
def iter_read(self, path: PathLike, chunk_size: int = _READ_CHUNK) -> Iterator[bytes]:
|
|
702
|
+
inode = self.stat(path)
|
|
703
|
+
if inode.is_dir:
|
|
704
|
+
raise IsADirectoryError(errno.EISDIR, "Is a directory", os.fsdecode(path))
|
|
705
|
+
return self.iter_inode_data(inode, chunk_size)
|
|
706
|
+
|
|
707
|
+
def read_file(self, path: PathLike) -> bytes:
|
|
708
|
+
return b"".join(self.iter_read(path))
|
|
709
|
+
|
|
710
|
+
def walk(self, top: PathLike = "/") -> Iterator[Tuple[str, List[str], List[str]]]:
|
|
711
|
+
"""Like :func:`os.walk` (top-down, symlinks to directories not followed)."""
|
|
712
|
+
top = os.fsdecode(top)
|
|
713
|
+
dirs, files = [], []
|
|
714
|
+
for e in self.scandir(top):
|
|
715
|
+
is_dir = e.is_dir if e.file_type else self.inode(e.inode).is_dir
|
|
716
|
+
(dirs if is_dir else files).append(e.name)
|
|
717
|
+
yield top, dirs, files
|
|
718
|
+
for d in dirs:
|
|
719
|
+
yield from self.walk(posixpath.join(top, d))
|
|
720
|
+
|
|
721
|
+
# -- extraction -------------------------------------------------------- #
|
|
722
|
+
|
|
723
|
+
def extract(
|
|
724
|
+
self,
|
|
725
|
+
path: PathLike,
|
|
726
|
+
dest: PathLike,
|
|
727
|
+
*,
|
|
728
|
+
on_file: Optional[Callable[[str, str], None]] = None,
|
|
729
|
+
on_error: Optional[Callable[[str, BaseException], None]] = None,
|
|
730
|
+
) -> int:
|
|
731
|
+
"""Copy ``path`` (file, symlink or directory tree) to the local ``dest``.
|
|
732
|
+
|
|
733
|
+
Like ``cp -R``: if ``dest`` is an existing directory the item is
|
|
734
|
+
copied *into* it. Symlinks are recreated as symlinks; device nodes,
|
|
735
|
+
FIFOs and sockets are skipped. Returns the number of items written.
|
|
736
|
+
``on_file(src, dst)`` is called for each item, ``on_error(src, exc)``
|
|
737
|
+
for each failure (if not given, the first failure is raised).
|
|
738
|
+
"""
|
|
739
|
+
src = "/" + posixpath.normpath("/" + os.fsdecode(path)).lstrip("/")
|
|
740
|
+
dest = os.fspath(dest)
|
|
741
|
+
inode = self.lstat(src)
|
|
742
|
+
if os.path.isdir(dest) and not os.path.islink(dest):
|
|
743
|
+
dest = os.path.join(dest, posixpath.basename(src) or "root")
|
|
744
|
+
counter = [0]
|
|
745
|
+
self._extract(inode, src, dest, counter, on_file, on_error)
|
|
746
|
+
return counter[0]
|
|
747
|
+
|
|
748
|
+
def _extract(self, inode, src, dest, counter, on_file, on_error) -> None:
|
|
749
|
+
try:
|
|
750
|
+
if inode.is_dir:
|
|
751
|
+
if os.path.islink(dest):
|
|
752
|
+
raise FileExistsError(errno.EEXIST, "refusing to write through symlink", dest)
|
|
753
|
+
os.makedirs(dest, exist_ok=True)
|
|
754
|
+
os.chmod(dest, 0o700)
|
|
755
|
+
if on_file:
|
|
756
|
+
on_file(src, dest)
|
|
757
|
+
counter[0] += 1
|
|
758
|
+
for e in self._dir_entries(inode.ino):
|
|
759
|
+
if e.name in (".", ".."):
|
|
760
|
+
continue
|
|
761
|
+
child_src = posixpath.join(src, e.name)
|
|
762
|
+
if not _safe_name(e.name):
|
|
763
|
+
_report(on_error, child_src, ExtError(f"unsafe file name {e.name!r}"))
|
|
764
|
+
continue
|
|
765
|
+
self._extract(self.inode(e.inode), child_src, os.path.join(dest, e.name),
|
|
766
|
+
counter, on_file, on_error)
|
|
767
|
+
os.chmod(dest, (inode.mode & 0o7777) | 0o700)
|
|
768
|
+
elif inode.is_symlink:
|
|
769
|
+
os.symlink(self._readlink(inode), dest)
|
|
770
|
+
counter[0] += 1
|
|
771
|
+
if on_file:
|
|
772
|
+
on_file(src, dest)
|
|
773
|
+
return
|
|
774
|
+
elif inode.is_file:
|
|
775
|
+
fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
776
|
+
| getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0), 0o600)
|
|
777
|
+
with os.fdopen(fd, "wb") as out:
|
|
778
|
+
for chunk in self.iter_inode_data(inode):
|
|
779
|
+
out.write(chunk)
|
|
780
|
+
os.chmod(dest, (inode.mode & 0o777) | 0o600)
|
|
781
|
+
counter[0] += 1
|
|
782
|
+
if on_file:
|
|
783
|
+
on_file(src, dest)
|
|
784
|
+
else:
|
|
785
|
+
raise ExtError(f"skipped special file ({stat_mod.filemode(inode.mode)[0]})")
|
|
786
|
+
os.utime(dest, (inode.atime, inode.mtime))
|
|
787
|
+
except (OSError, ExtError) as exc:
|
|
788
|
+
_report(on_error, src, exc)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _report(on_error, src: str, exc: BaseException) -> None:
|
|
792
|
+
if on_error is None:
|
|
793
|
+
raise exc
|
|
794
|
+
on_error(src, exc)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
_ZERO_CHUNK = bytes(_READ_CHUNK)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _zeros(n: int, chunk_size: int) -> Iterator[bytes]:
|
|
801
|
+
while n > 0:
|
|
802
|
+
k = min(n, chunk_size)
|
|
803
|
+
yield _ZERO_CHUNK[:k] if k <= len(_ZERO_CHUNK) else bytes(k)
|
|
804
|
+
n -= k
|