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/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