fs-explorer 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.
Files changed (106) hide show
  1. fs_explorer-0.1.0/.gitattributes +4 -0
  2. fs_explorer-0.1.0/.gitignore +23 -0
  3. fs_explorer-0.1.0/LICENSE +28 -0
  4. fs_explorer-0.1.0/PKG-INFO +290 -0
  5. fs_explorer-0.1.0/README.md +276 -0
  6. fs_explorer-0.1.0/pyproject.toml +50 -0
  7. fs_explorer-0.1.0/src/fs_explorer/__init__.py +35 -0
  8. fs_explorer-0.1.0/src/fs_explorer/__main__.py +7 -0
  9. fs_explorer-0.1.0/src/fs_explorer/api.py +899 -0
  10. fs_explorer-0.1.0/src/fs_explorer/archives/__init__.py +1 -0
  11. fs_explorer-0.1.0/src/fs_explorer/archives/ar.py +136 -0
  12. fs_explorer-0.1.0/src/fs_explorer/archives/bom.py +211 -0
  13. fs_explorer-0.1.0/src/fs_explorer/archives/cab.py +302 -0
  14. fs_explorer-0.1.0/src/fs_explorer/archives/cpio.py +336 -0
  15. fs_explorer-0.1.0/src/fs_explorer/archives/rar4.py +455 -0
  16. fs_explorer-0.1.0/src/fs_explorer/archives/rar5.py +635 -0
  17. fs_explorer-0.1.0/src/fs_explorer/archives/rpm.py +281 -0
  18. fs_explorer-0.1.0/src/fs_explorer/archives/sevenzip.py +1302 -0
  19. fs_explorer-0.1.0/src/fs_explorer/archives/tar.py +437 -0
  20. fs_explorer-0.1.0/src/fs_explorer/archives/xar.py +652 -0
  21. fs_explorer-0.1.0/src/fs_explorer/archives/zip.py +914 -0
  22. fs_explorer-0.1.0/src/fs_explorer/cli.py +383 -0
  23. fs_explorer-0.1.0/src/fs_explorer/codecs/__init__.py +1 -0
  24. fs_explorer-0.1.0/src/fs_explorer/codecs/adc.py +65 -0
  25. fs_explorer-0.1.0/src/fs_explorer/codecs/bcj2.py +123 -0
  26. fs_explorer-0.1.0/src/fs_explorer/codecs/crc32c.py +101 -0
  27. fs_explorer-0.1.0/src/fs_explorer/codecs/deflate64.py +354 -0
  28. fs_explorer-0.1.0/src/fs_explorer/codecs/heavy/__init__.py +1 -0
  29. fs_explorer-0.1.0/src/fs_explorer/codecs/heavy/lzx.py +548 -0
  30. fs_explorer-0.1.0/src/fs_explorer/codecs/heavy/ppmd.py +1930 -0
  31. fs_explorer-0.1.0/src/fs_explorer/codecs/heavy/xpress.py +249 -0
  32. fs_explorer-0.1.0/src/fs_explorer/codecs/lz4.py +471 -0
  33. fs_explorer-0.1.0/src/fs_explorer/codecs/lzfse.py +455 -0
  34. fs_explorer-0.1.0/src/fs_explorer/codecs/lznt1.py +104 -0
  35. fs_explorer-0.1.0/src/fs_explorer/codecs/lzo.py +230 -0
  36. fs_explorer-0.1.0/src/fs_explorer/codecs/lzvn.py +192 -0
  37. fs_explorer-0.1.0/src/fs_explorer/codecs/mszip.py +62 -0
  38. fs_explorer-0.1.0/src/fs_explorer/codecs/native.py +84 -0
  39. fs_explorer-0.1.0/src/fs_explorer/codecs/pbzx.py +172 -0
  40. fs_explorer-0.1.0/src/fs_explorer/codecs/registry.py +352 -0
  41. fs_explorer-0.1.0/src/fs_explorer/codecs/zstd.py +349 -0
  42. fs_explorer-0.1.0/src/fs_explorer/codecs/zstd_pure.py +874 -0
  43. fs_explorer-0.1.0/src/fs_explorer/containers/__init__.py +1 -0
  44. fs_explorer-0.1.0/src/fs_explorer/containers/_diskutil.py +489 -0
  45. fs_explorer-0.1.0/src/fs_explorer/containers/android_sparse.py +119 -0
  46. fs_explorer-0.1.0/src/fs_explorer/containers/cdraw.py +277 -0
  47. fs_explorer-0.1.0/src/fs_explorer/containers/dmg_crypt.py +320 -0
  48. fs_explorer-0.1.0/src/fs_explorer/containers/qcow2.py +334 -0
  49. fs_explorer-0.1.0/src/fs_explorer/containers/sparsebundle.py +168 -0
  50. fs_explorer-0.1.0/src/fs_explorer/containers/sparseimage.py +85 -0
  51. fs_explorer-0.1.0/src/fs_explorer/containers/streams.py +480 -0
  52. fs_explorer-0.1.0/src/fs_explorer/containers/udif.py +333 -0
  53. fs_explorer-0.1.0/src/fs_explorer/containers/vdi.py +105 -0
  54. fs_explorer-0.1.0/src/fs_explorer/containers/vhd.py +237 -0
  55. fs_explorer-0.1.0/src/fs_explorer/containers/vhdx.py +471 -0
  56. fs_explorer-0.1.0/src/fs_explorer/containers/vmdk.py +302 -0
  57. fs_explorer-0.1.0/src/fs_explorer/crypto/__init__.py +1 -0
  58. fs_explorer-0.1.0/src/fs_explorer/crypto/aes.py +231 -0
  59. fs_explorer-0.1.0/src/fs_explorer/crypto/backend.py +446 -0
  60. fs_explorer-0.1.0/src/fs_explorer/crypto/der.py +139 -0
  61. fs_explorer-0.1.0/src/fs_explorer/crypto/des.py +202 -0
  62. fs_explorer-0.1.0/src/fs_explorer/crypto/kdf.py +128 -0
  63. fs_explorer-0.1.0/src/fs_explorer/crypto/keywrap.py +47 -0
  64. fs_explorer-0.1.0/src/fs_explorer/crypto/modes.py +146 -0
  65. fs_explorer-0.1.0/src/fs_explorer/crypto/zipcrypto.py +99 -0
  66. fs_explorer-0.1.0/src/fs_explorer/detect.py +136 -0
  67. fs_explorer-0.1.0/src/fs_explorer/entry.py +143 -0
  68. fs_explorer-0.1.0/src/fs_explorer/errors.py +63 -0
  69. fs_explorer-0.1.0/src/fs_explorer/extract.py +408 -0
  70. fs_explorer-0.1.0/src/fs_explorer/filesystems/__init__.py +1 -0
  71. fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/__init__.py +1 -0
  72. fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/btree.py +257 -0
  73. fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/container.py +297 -0
  74. fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/fs.py +592 -0
  75. fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/keybag.py +184 -0
  76. fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/omap.py +75 -0
  77. fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/__init__.py +1 -0
  78. fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/btree.py +164 -0
  79. fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/chunks.py +138 -0
  80. fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/fs.py +474 -0
  81. fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/super.py +164 -0
  82. fs_explorer-0.1.0/src/fs_explorer/filesystems/decmpfs.py +258 -0
  83. fs_explorer-0.1.0/src/fs_explorer/filesystems/exfat.py +362 -0
  84. fs_explorer-0.1.0/src/fs_explorer/filesystems/ext.py +724 -0
  85. fs_explorer-0.1.0/src/fs_explorer/filesystems/fat.py +484 -0
  86. fs_explorer-0.1.0/src/fs_explorer/filesystems/hfsplus.py +932 -0
  87. fs_explorer-0.1.0/src/fs_explorer/filesystems/iso9660.py +630 -0
  88. fs_explorer-0.1.0/src/fs_explorer/filesystems/ntfs.py +1068 -0
  89. fs_explorer-0.1.0/src/fs_explorer/filesystems/squashfs.py +544 -0
  90. fs_explorer-0.1.0/src/fs_explorer/filesystems/udf.py +728 -0
  91. fs_explorer-0.1.0/src/fs_explorer/io/__init__.py +1 -0
  92. fs_explorer-0.1.0/src/fs_explorer/io/blockmap.py +185 -0
  93. fs_explorer-0.1.0/src/fs_explorer/io/cache.py +83 -0
  94. fs_explorer-0.1.0/src/fs_explorer/io/imagefile.py +196 -0
  95. fs_explorer-0.1.0/src/fs_explorer/io/source.py +290 -0
  96. fs_explorer-0.1.0/src/fs_explorer/io/spool.py +51 -0
  97. fs_explorer-0.1.0/src/fs_explorer/io/stats.py +45 -0
  98. fs_explorer-0.1.0/src/fs_explorer/io/view.py +100 -0
  99. fs_explorer-0.1.0/src/fs_explorer/modebits.py +47 -0
  100. fs_explorer-0.1.0/src/fs_explorer/partitions/__init__.py +1 -0
  101. fs_explorer-0.1.0/src/fs_explorer/partitions/apm.py +130 -0
  102. fs_explorer-0.1.0/src/fs_explorer/partitions/gpt.py +205 -0
  103. fs_explorer-0.1.0/src/fs_explorer/partitions/mbr.py +167 -0
  104. fs_explorer-0.1.0/src/fs_explorer/partitions/types.py +146 -0
  105. fs_explorer-0.1.0/src/fs_explorer/py.typed +0 -0
  106. fs_explorer-0.1.0/src/fs_explorer/tree.py +450 -0
@@ -0,0 +1,4 @@
1
+ # Fixtures are binary images and exact byte streams: never convert line endings.
2
+ tests/fixtures/** -text -diff
3
+ *.py text eol=lf
4
+ *.sh text eol=lf
@@ -0,0 +1,23 @@
1
+ # Python artifacts
2
+ __pycache__/
3
+ *.py[cod]
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .venv/
8
+
9
+ # Tool caches
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ .coverage
14
+ htmlcov/
15
+
16
+ # OS files
17
+ .DS_Store
18
+
19
+ # Local data
20
+ samples/
21
+ tests/fixtures/_build/
22
+ extract-out/
23
+ /local
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Arthur Gouhier
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,290 @@
1
+ Metadata-Version: 2.5
2
+ Name: fs_explorer
3
+ Version: 0.1.0
4
+ Summary: List and read disk images, filesystems, and archives using only the Python standard library
5
+ Author-email: Arthur Gouhier <ajgouhier@gmail.com>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Provides-Extra: dev
10
+ Requires-Dist: mypy; extra == 'dev'
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Requires-Dist: ruff; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # fs-explorer
16
+
17
+ List and read the contents of disk images, filesystems, and archives using only the Python standard
18
+ library.
19
+
20
+ ```
21
+ pip install fs-explorer # distribution
22
+ import fs_explorer # package
23
+ fs-explorer ls disk.dmg # command (also: python -m fs_explorer)
24
+ ```
25
+
26
+ ## Guarantees
27
+
28
+ - **No dependencies.** Python 3.10+, standard library only. System libraries (`libzstd`, `liblz4`,
29
+ `liblzo2`, `libcrypto`, Apple's `libcompression`/CommonCrypto) are used through `ctypes` when
30
+ present, always with a pure-Python fallback.
31
+ - **Listing reads metadata only.** Directory records, inode tables, B-tree nodes, central directories,
32
+ and headers. File data is not read. Formats that can only be listed by decompressing everything
33
+ (e.g. `.tar.gz`) are detected and refused with an explicit reason, unless you opt in with
34
+ `full_read_limit`.
35
+ - **Reading touches only what the file needs.** Only the blocks, chunks, extents, or solid-block prefix
36
+ that the requested file depends on are read and decoded.
37
+ - **Streaming.** Entries are yielded as they are parsed. Memory is bounded by the largest single
38
+ directory plus the cache budget (default 64 MiB), not by image size.
39
+ - **Read-only.** No mounting, no subprocesses, no writes to the source. `extract()` writes only under its
40
+ destination directory.
41
+
42
+ ## Supported formats
43
+
44
+ ### Containers and partition maps
45
+
46
+ | Format | Notes | What is read to list |
47
+ |---|---|---|
48
+ | Raw images (`.img`, `.iso`, `.bin`) | detection by content | superblocks |
49
+ | UDIF / DMG | raw, zero, zlib, bzip2, LZMA, ADC, LZFSE chunks | koly trailer + plist chunk table |
50
+ | Encrypted DMG v1 / v2 | AES-128/256, password | header, key blobs; data decrypted per 4 KiB chunk |
51
+ | `.sparseimage`, `.sparsebundle` | bands read lazily; missing bands are zeros | header / `Info.plist` |
52
+ | VHD (fixed, dynamic, differencing), VHDX (incl. log replay, differencing) | parents via sibling files | footer/headers, BAT |
53
+ | VMDK (monolithicSparse, streamOptimized, split, flat, snapshots) | | grain directory/tables |
54
+ | QCOW2 (v2/v3, compressed deflate/zstd, backing files, extended L2) | | L1/L2 tables |
55
+ | VDI, Android sparse, BIN/CUE (Mode 1, Mode 2 Form 1) | | block map / chunk list / cue sheet |
56
+ | GPT, APM, MBR with EBR chains | volumes named `p1`…`pN` | partition tables |
57
+
58
+ ### Filesystems
59
+
60
+ | Filesystem | Reading extras |
61
+ |---|---|
62
+ | HFS+ / HFSX | decmpfs compression (zlib, LZVN, LZFSE), hard links, resource forks, xattrs |
63
+ | APFS | decmpfs, clones, sparse files, **native encryption** (password or recovery key); volumes `v1`…`vN` |
64
+ | ISO 9660 + Joliet + Rock Ridge | multi-extent files, zisofs |
65
+ | UDF (1.02–2.60, sparable and metadata partitions) | embedded data, all allocation descriptor kinds |
66
+ | FAT12/16/32, exFAT | long names, NoFatChain |
67
+ | NTFS | LZNT1 compression, sparse files, named streams, reparse symlinks/junctions, WOF (XPRESS/LZX) |
68
+ | ext2/3/4 | extents, indirect blocks, inline data, holes, xattrs |
69
+ | SquashFS 4 | gzip, lzma, lzo, xz, lz4, zstd; fragments; xattrs |
70
+ | Btrfs (single, DUP, RAID1, RAID1C3/4) | subvolumes, compressed extents (zlib, zstd, LZO), holes |
71
+
72
+ ### Archives and streams
73
+
74
+ | Format | Listing | Reading |
75
+ |---|---|---|
76
+ | zip (Zip64) | central directory only | stored, deflate, Deflate64, bzip2, LZMA, xz, zstd, PPMd; ZipCrypto, WinZip AES |
77
+ | tar (ustar, GNU, PAX, sparse) | headers only | direct views; compressed tar readable forward-only |
78
+ | 7z | header (incl. encrypted header) | LZMA, LZMA2, BCJ/ARM/PPC/SPARC/IA64, Delta, BCJ2, bzip2, Deflate, Deflate64, PPMd, 7zAES |
79
+ | RAR5, RAR4 | headers (incl. encrypted headers) | **stored members only** (with decryption); compressed members raise `MissingCodecError` |
80
+ | xar and `.pkg` (via Bom) | TOC + Bom `Paths` tree | zlib/bzip2/xz members; Bom-backed payloads through pbzx + cpio |
81
+ | cpio (newc, crc, odc, binary), ar / `.deb`, RPM, CAB | headers | CAB: stored, MSZIP, LZX; RPM: payload (gzip, bzip2, xz, zstd) forward-only |
82
+ | gzip, xz, zstd, bzip2, lz4 streams | one entry: original name and size when stored | decompressed contents |
83
+
84
+ ### Codec tiers
85
+
86
+ | Tier | Codecs | Implementation |
87
+ |---|---|---|
88
+ | core | zlib/deflate, bzip2, LZMA/xz, BCJ, Delta | standard library |
89
+ | core | zstd | `compression.zstd` (3.14+) → `libzstd` → pure Python |
90
+ | core | LZFSE, LZVN, ADC, LZ4, pbzx, CRC32C | `libcompression`/`liblz4` where available → pure Python |
91
+ | common | LZO1X, LZNT1, MSZIP, BCJ2, Deflate64, zisofs, ZipCrypto, WinZip AES | `liblzo2` → pure Python |
92
+ | heavy | PPMd H and I, LZX, XPRESS Huffman | pure Python (slow: PPMd ≈ 0.1 MB/s) |
93
+ | crypto | AES (ECB, CBC, CTR, XTS), 3DES, RFC 3394, PBKDF2, 7z/RAR KDFs | `libcrypto` / CommonCrypto → pure Python |
94
+
95
+ ### Refused or out of scope
96
+
97
+ - **Refused for listing** (detected; allowed with `full_read_limit`): compressed tar and cpio, `.deb` data
98
+ payloads, `.pkg` payloads without a Bom, disk images wrapped in a compression stream (`.img.xz`).
99
+ Reading individual members still works by streaming.
100
+ - **Refused for reading:** NTFS EFS, ext4 fscrypt, APFS per-file (hardware-bound) keys, QCOW2
101
+ encryption, VDI differencing images, RAR compressed members.
102
+ - **Not detected:** NDIF, Disk Copy 4.2, E01, WIM/ESD, XFS, f2fs, multi-device Btrfs striping,
103
+ multi-volume archives, BitLocker, LUKS, StuffIt, plain (non-wrapped) HFS.
104
+
105
+ ## Command line
106
+
107
+ ```
108
+ fs-explorer ls [OPTIONS] SOURCE [TARGET]
109
+ fs-explorer cat [OPTIONS] SOURCE PATH
110
+ fs-explorer extract [OPTIONS] SOURCE [PATH] -o DEST
111
+ fs-explorer probe [OPTIONS] SOURCE
112
+ ```
113
+
114
+ ```console
115
+ $ fs-explorer probe Install.dmg
116
+ UDIF (UDZO) > GPT (512-byte sectors) > p2 "Installer" > HFS+
117
+
118
+ $ fs-explorer ls -l disk.img /p2/Users -r 0
119
+ drwxr-xr-x 501 20 2024-05-01 10:12 /p2/Users/alice
120
+ ...
121
+
122
+ $ fs-explorer ls -n 1 backup.tar /old # descend into nested containers
123
+ $ fs-explorer cat disk.vhdx /p1/Windows/win.ini
124
+ $ fs-explorer cat --offset 1M --length 4096 image.qcow2 /p1/big.bin | xxd
125
+ $ fs-explorer extract release.7z /docs -o out/ -v
126
+ $ fs-explorer ls --json encrypted.dmg --ask-password
127
+ $ fs-explorer ls outer.zip /disk.dmg/p1/Applications # paths cross containers
128
+ ```
129
+
130
+ Common options: `--password PW`, `--password-file PATH`, `--ask-password` (prompts once per
131
+ encrypted layer, showing the APFS hint), `$FS_EXPLORER_PASSWORD`, `--format NAME`,
132
+ `--backend auto|pure|system`, `--cache-size 64M`, `--full-read-limit SIZE`,
133
+ `--errors raise|yield|skip` (default `yield`: errors go to stderr, listing continues), `--stats`.
134
+
135
+ `ls`: `-r N` recursion depth (default unlimited, 0 = direct children), `-n N` nested container
136
+ descent, `--no-stat`, `-i/-x PATTERN` include/exclude (excluded directories are pruned),
137
+ `-T f,d,l,v` entry types, `-a` system files, `--no-flatten`, `--sort`, `-l`, `--json` (JSON Lines),
138
+ `-0`, `--relative`.
139
+
140
+ `cat`: `--offset`, `--length`, `--no-follow`, `--verify`.
141
+ `extract`: `-o DEST`, `-r/-i/-x/-T`, `--preserve mode,mtime,owner,symlinks,hardlinks,xattrs,resource_forks`,
142
+ `--on-conflict error|skip|overwrite|rename`, `--max-size`, `--max-ratio`, `--no-verify`, `--no-sparse`, `-v`.
143
+
144
+ Exit codes: 0 success; 1 some entries had errors; 2 usage error; 3 unsupported format or missing
145
+ codec; 4 missing or wrong password; 5 target not found; 6 unsafe path or limit exceeded.
146
+
147
+ ## Python API
148
+
149
+ ```python
150
+ import fs_explorer as fx
151
+
152
+ for e in fx.iter_entries("disk.dmg", target="/p2/Users", recursive=1):
153
+ print(e.path, e.type.name, e.size, e.mtime)
154
+
155
+ data = fx.read_file("backup.7z", "/docs/report.pdf", password="secret")
156
+
157
+ with fx.open_file("image.vhdx", "/p1/pagefile.sys") as f: # io.RawIOBase
158
+ f.seek(1 << 30)
159
+ chunk = f.read(4096)
160
+
161
+ for e in fx.extract("archive.zip", "/", "out/", on_conflict="rename"):
162
+ print("wrote", e.path)
163
+
164
+ print(" > ".join(map(str, fx.probe("Install.dmg"))))
165
+
166
+ # Reuse parsed layers across calls:
167
+ with fx.open_source("disk.img", password=lambda req: ask_user(req.description, req.hint)) as src:
168
+ names = [e.name for e in src.entries(stat=False, recursive=0)]
169
+ blob = src.read_file("/p1/etc/hostname")
170
+ print(src.stats.as_dict()) # bytes read, cache hits, decompressed, decrypted, backends
171
+ ```
172
+
173
+ `iter_entries(source, *, recursive=-1, target="/", password=None, nested=0, stat=True, include=(),
174
+ exclude=(), types=None, format_hint=None, errors="raise", system_files=False, flatten=True,
175
+ sort=False, full_read_limit=0, resolver=None, cache_size=64<<20, backend="auto")`.
176
+ A source is a path, a binary file object (seekable), `bytes`, or a directory (`.sparsebundle`).
177
+
178
+ `Entry` fields: `path`, `name`, `type` (`FILE`, `DIR`, `SYMLINK`, `HARDLINK`, `CHAR_DEVICE`,
179
+ `BLOCK_DEVICE`, `FIFO`, `SOCKET`, `VOLUME`, `OTHER`), `depth`, `size`, `stored_size`, `mode`, `uid`,
180
+ `gid`, `owner`, `group`, `mtime_ns`/`ctime_ns`/`atime_ns`/`btime_ns` (ns since the Unix epoch, UTC;
181
+ `.mtime` etc. give `datetime`s), `nlink`, `inode`, `link_target`, `format`, `container`, `extra`,
182
+ `error`. `entry.to_dict()` is JSON-ready.
183
+
184
+ Errors derive from `FsExplorerError`: `UnsupportedFormatError`, `PasswordRequiredError`,
185
+ `WrongPasswordError`, `CorruptDataError`, `MissingCodecError`, `TargetNotFoundError`,
186
+ `AmbiguousTargetError`, `NotAFileError`, `ChecksumError`, `UnsafePathError`, `LimitExceededError`.
187
+
188
+ ## Paths, volumes, and depth
189
+
190
+ - Paths are `/`-separated from the source root. Containers are unwrapped until a filesystem or archive
191
+ is reached. When a partition map, APFS container, or multi-volume layer has several listable
192
+ volumes, the root lists them as `VOLUME` entries with canonical names: `p1`…`pN` for partitions
193
+ (1-based, like `disk2s1`/`sda1`; MBR logical partitions start at `p5`) and `v1`…`vN` for APFS
194
+ volumes. Single-child layers are collapsed (`flatten=True`).
195
+ - Labels (filesystem volume name, else the GPT/APM partition name) are in `extra["label"]` and are
196
+ accepted as aliases in targets: `/Macintosh HD/Users`. Exact match first, then case-insensitive;
197
+ canonical names always win; a label shared by several volumes raises `AmbiguousTargetError`.
198
+ Labels are read lazily (only for `stat=True`, i.e. `ls -l`/`--json`, and alias lookups).
199
+ - Unrecognized partitions (swap, empty) appear as `VOLUME` entries with `error` set.
200
+ - A path segment that names a container file descends into it: `/Downloads/x.dmg/p1/Applications`.
201
+ As a listing target, `/x.dmg` yields the file entry itself; `/x.dmg/` lists its contents.
202
+ - Btrfs subvolumes appear as directories where they are linked; the root is the default subvolume.
203
+ - `depth` is relative to the target (0 = direct children). Each directory, volume boundary, and
204
+ (with `nested > 0`) container boundary adds one level.
205
+ - HFS+ names are converted from NFD to NFC (`keep_raw_names` keeps them); targets are normalized
206
+ before lookup. Symlinks inside images are followed within the image (loop limit 40); a link that
207
+ escapes the image root raises `TargetNotFoundError`.
208
+
209
+ ## Passwords and crypto
210
+
211
+ `password` may be a `str`/`bytes`, or a callable receiving a `PasswordRequest(description, hint,
212
+ attempt)` and returning a candidate or `None` to give up. Supported: encrypted DMG (v1/v2),
213
+ APFS native encryption (user passwords and the recovery key; the stored hint is passed to the
214
+ callable), 7z (content and header), RAR5 and RAR4 (stored members and encrypted headers), zip
215
+ (ZipCrypto, WinZip AES-128/192/256).
216
+
217
+ AES and 3DES use `libcrypto` (OpenSSL 1.1/3, including the copy shipped with CPython on Windows) or
218
+ CommonCrypto on macOS when available; otherwise a pure-Python T-table AES (~1 MB/s). For encrypted
219
+ APFS volumes and bulk reads from encrypted DMGs the native backend matters more than anything else;
220
+ `--stats` shows which backend served each codec and cipher. Setting `FS_EXPLORER_NO_NATIVE=1` disables
221
+ every system library, as if none were installed.
222
+
223
+ ## `full_read_limit` and spooling
224
+
225
+ Some members have no random access: a DMG inside a deflated zip member, a zip inside a `.tar.gz`, an
226
+ `.img.xz`. When such a member's stored size is within `full_read_limit`, it is spooled to a
227
+ `SpooledTemporaryFile` and opened normally; otherwise listing inside it raises `LimitExceededError`
228
+ or `UnsupportedFormatError` with the reason. The same limit enables listing formats that need full
229
+ decompression (compressed tar/cpio). Reading a single member of a compressed tar never needs it.
230
+
231
+ ## Extraction safety
232
+
233
+ - `..` components, absolute paths, drive letters, and NUL bytes are rejected (`UnsafePathError`);
234
+ member names that start with `/` are extracted relative to the destination.
235
+ - Symlinks are created last, and a symlink whose target is absolute or resolves outside the
236
+ destination is refused; files are never written through a symlink, pre-existing or created during
237
+ the same extraction.
238
+ - `max_size` (total bytes) and `max_ratio` (per member, default 1000×) are enforced while writing.
239
+ - Case-insensitive collisions, Windows-invalid characters, and reserved names are handled
240
+ (`on_conflict`); holes are skipped with `seek` (`sparse=True`); checksums are verified by default.
241
+ - Metadata: mode and mtime by default; `owner` (root only), `xattrs`, `resource_forks` (the named fork
242
+ on macOS, AppleDouble `._` files elsewhere), `symlinks` and `hardlinks` within the extraction set.
243
+ - Solid blocks (7z, CAB folders, RAR solid, pbzx, RPM payloads) are processed in storage order, so
244
+ extraction is linear.
245
+
246
+ ## Performance notes
247
+
248
+ Listing is dominated by B-tree walking and header parsing and typically runs at thousands to tens of
249
+ thousands of entries per second. Reading throughput depends on the codec:
250
+
251
+ | Path | Throughput (Python 3.11) |
252
+ |---|---|
253
+ | stdlib zlib, bzip2, xz; native zstd/lz4/lzo; libcrypto AES | hundreds of MB/s |
254
+ | pure LZ4, LZO | 15–25 MB/s |
255
+ | pure zstd, LZFSE, LZVN, Deflate64 | 8–15 MB/s |
256
+ | pure LZX, XPRESS | 5–7 MB/s |
257
+ | pure AES-CBC / XTS | ~1 MB/s |
258
+ | pure PPMd | 0.03–0.15 MB/s |
259
+
260
+ `tools/bench.py IMAGE --read-largest 3 --backend pure|system` measures entries/s and MB/s on your
261
+ own images.
262
+
263
+ ## Limitations
264
+
265
+ - RAR decompression is not implemented (stored members only).
266
+ - APFS: live tree only (no snapshot selection); sealed volumes are untested.
267
+ - HFS+: no journal replay; case-insensitive comparison approximates Apple's FastUnicodeCompare.
268
+ - Btrfs: no log-tree replay, no RAID0/10/5/6, no seed devices or zoned mode.
269
+ - UDF: VAT (virtual) partitions are refused.
270
+ - FAT timestamps are local time and reported as if UTC.
271
+ - Encrypted DMG handling was validated against images built by our fixture generator and reference
272
+ decryptors, not Apple-made images.
273
+
274
+ ## Development
275
+
276
+ ```
277
+ pip install -e .[dev]
278
+ pytest -q # ~950 tests, about a minute
279
+ ruff check src tests tools
280
+ mypy
281
+ ```
282
+
283
+ Fixtures live in `tests/fixtures/` (xz-compressed images decompressed on first use into
284
+ `tests/fixtures/_build/`, with JSON oracles recording paths, types, sizes, and SHA-256 of every
285
+ file). `tools/make_fixtures_linux.sh` regenerates them from the standard tree
286
+ (`tools/fixtures/tree.py`) with `mkfs.*`, `mksquashfs`, `xorriso`, `qemu-img`, `7z`, `rar`, `gcab`,
287
+ `rpmbuild`, and Python builders for Apple formats (HFS+, APFS, UDIF, encrypted DMG, sparse images,
288
+ pkg) that Linux tools cannot write. `tools/make_fixtures_macos.sh` builds Apple-native images with
289
+ `hdiutil`. Some reference images come from the dfVFS project (Apache 2.0, see
290
+ `tests/fixtures/dfvfs/NOTICE`).
@@ -0,0 +1,276 @@
1
+ # fs-explorer
2
+
3
+ List and read the contents of disk images, filesystems, and archives using only the Python standard
4
+ library.
5
+
6
+ ```
7
+ pip install fs-explorer # distribution
8
+ import fs_explorer # package
9
+ fs-explorer ls disk.dmg # command (also: python -m fs_explorer)
10
+ ```
11
+
12
+ ## Guarantees
13
+
14
+ - **No dependencies.** Python 3.10+, standard library only. System libraries (`libzstd`, `liblz4`,
15
+ `liblzo2`, `libcrypto`, Apple's `libcompression`/CommonCrypto) are used through `ctypes` when
16
+ present, always with a pure-Python fallback.
17
+ - **Listing reads metadata only.** Directory records, inode tables, B-tree nodes, central directories,
18
+ and headers. File data is not read. Formats that can only be listed by decompressing everything
19
+ (e.g. `.tar.gz`) are detected and refused with an explicit reason, unless you opt in with
20
+ `full_read_limit`.
21
+ - **Reading touches only what the file needs.** Only the blocks, chunks, extents, or solid-block prefix
22
+ that the requested file depends on are read and decoded.
23
+ - **Streaming.** Entries are yielded as they are parsed. Memory is bounded by the largest single
24
+ directory plus the cache budget (default 64 MiB), not by image size.
25
+ - **Read-only.** No mounting, no subprocesses, no writes to the source. `extract()` writes only under its
26
+ destination directory.
27
+
28
+ ## Supported formats
29
+
30
+ ### Containers and partition maps
31
+
32
+ | Format | Notes | What is read to list |
33
+ |---|---|---|
34
+ | Raw images (`.img`, `.iso`, `.bin`) | detection by content | superblocks |
35
+ | UDIF / DMG | raw, zero, zlib, bzip2, LZMA, ADC, LZFSE chunks | koly trailer + plist chunk table |
36
+ | Encrypted DMG v1 / v2 | AES-128/256, password | header, key blobs; data decrypted per 4 KiB chunk |
37
+ | `.sparseimage`, `.sparsebundle` | bands read lazily; missing bands are zeros | header / `Info.plist` |
38
+ | VHD (fixed, dynamic, differencing), VHDX (incl. log replay, differencing) | parents via sibling files | footer/headers, BAT |
39
+ | VMDK (monolithicSparse, streamOptimized, split, flat, snapshots) | | grain directory/tables |
40
+ | QCOW2 (v2/v3, compressed deflate/zstd, backing files, extended L2) | | L1/L2 tables |
41
+ | VDI, Android sparse, BIN/CUE (Mode 1, Mode 2 Form 1) | | block map / chunk list / cue sheet |
42
+ | GPT, APM, MBR with EBR chains | volumes named `p1`…`pN` | partition tables |
43
+
44
+ ### Filesystems
45
+
46
+ | Filesystem | Reading extras |
47
+ |---|---|
48
+ | HFS+ / HFSX | decmpfs compression (zlib, LZVN, LZFSE), hard links, resource forks, xattrs |
49
+ | APFS | decmpfs, clones, sparse files, **native encryption** (password or recovery key); volumes `v1`…`vN` |
50
+ | ISO 9660 + Joliet + Rock Ridge | multi-extent files, zisofs |
51
+ | UDF (1.02–2.60, sparable and metadata partitions) | embedded data, all allocation descriptor kinds |
52
+ | FAT12/16/32, exFAT | long names, NoFatChain |
53
+ | NTFS | LZNT1 compression, sparse files, named streams, reparse symlinks/junctions, WOF (XPRESS/LZX) |
54
+ | ext2/3/4 | extents, indirect blocks, inline data, holes, xattrs |
55
+ | SquashFS 4 | gzip, lzma, lzo, xz, lz4, zstd; fragments; xattrs |
56
+ | Btrfs (single, DUP, RAID1, RAID1C3/4) | subvolumes, compressed extents (zlib, zstd, LZO), holes |
57
+
58
+ ### Archives and streams
59
+
60
+ | Format | Listing | Reading |
61
+ |---|---|---|
62
+ | zip (Zip64) | central directory only | stored, deflate, Deflate64, bzip2, LZMA, xz, zstd, PPMd; ZipCrypto, WinZip AES |
63
+ | tar (ustar, GNU, PAX, sparse) | headers only | direct views; compressed tar readable forward-only |
64
+ | 7z | header (incl. encrypted header) | LZMA, LZMA2, BCJ/ARM/PPC/SPARC/IA64, Delta, BCJ2, bzip2, Deflate, Deflate64, PPMd, 7zAES |
65
+ | RAR5, RAR4 | headers (incl. encrypted headers) | **stored members only** (with decryption); compressed members raise `MissingCodecError` |
66
+ | xar and `.pkg` (via Bom) | TOC + Bom `Paths` tree | zlib/bzip2/xz members; Bom-backed payloads through pbzx + cpio |
67
+ | cpio (newc, crc, odc, binary), ar / `.deb`, RPM, CAB | headers | CAB: stored, MSZIP, LZX; RPM: payload (gzip, bzip2, xz, zstd) forward-only |
68
+ | gzip, xz, zstd, bzip2, lz4 streams | one entry: original name and size when stored | decompressed contents |
69
+
70
+ ### Codec tiers
71
+
72
+ | Tier | Codecs | Implementation |
73
+ |---|---|---|
74
+ | core | zlib/deflate, bzip2, LZMA/xz, BCJ, Delta | standard library |
75
+ | core | zstd | `compression.zstd` (3.14+) → `libzstd` → pure Python |
76
+ | core | LZFSE, LZVN, ADC, LZ4, pbzx, CRC32C | `libcompression`/`liblz4` where available → pure Python |
77
+ | common | LZO1X, LZNT1, MSZIP, BCJ2, Deflate64, zisofs, ZipCrypto, WinZip AES | `liblzo2` → pure Python |
78
+ | heavy | PPMd H and I, LZX, XPRESS Huffman | pure Python (slow: PPMd ≈ 0.1 MB/s) |
79
+ | crypto | AES (ECB, CBC, CTR, XTS), 3DES, RFC 3394, PBKDF2, 7z/RAR KDFs | `libcrypto` / CommonCrypto → pure Python |
80
+
81
+ ### Refused or out of scope
82
+
83
+ - **Refused for listing** (detected; allowed with `full_read_limit`): compressed tar and cpio, `.deb` data
84
+ payloads, `.pkg` payloads without a Bom, disk images wrapped in a compression stream (`.img.xz`).
85
+ Reading individual members still works by streaming.
86
+ - **Refused for reading:** NTFS EFS, ext4 fscrypt, APFS per-file (hardware-bound) keys, QCOW2
87
+ encryption, VDI differencing images, RAR compressed members.
88
+ - **Not detected:** NDIF, Disk Copy 4.2, E01, WIM/ESD, XFS, f2fs, multi-device Btrfs striping,
89
+ multi-volume archives, BitLocker, LUKS, StuffIt, plain (non-wrapped) HFS.
90
+
91
+ ## Command line
92
+
93
+ ```
94
+ fs-explorer ls [OPTIONS] SOURCE [TARGET]
95
+ fs-explorer cat [OPTIONS] SOURCE PATH
96
+ fs-explorer extract [OPTIONS] SOURCE [PATH] -o DEST
97
+ fs-explorer probe [OPTIONS] SOURCE
98
+ ```
99
+
100
+ ```console
101
+ $ fs-explorer probe Install.dmg
102
+ UDIF (UDZO) > GPT (512-byte sectors) > p2 "Installer" > HFS+
103
+
104
+ $ fs-explorer ls -l disk.img /p2/Users -r 0
105
+ drwxr-xr-x 501 20 2024-05-01 10:12 /p2/Users/alice
106
+ ...
107
+
108
+ $ fs-explorer ls -n 1 backup.tar /old # descend into nested containers
109
+ $ fs-explorer cat disk.vhdx /p1/Windows/win.ini
110
+ $ fs-explorer cat --offset 1M --length 4096 image.qcow2 /p1/big.bin | xxd
111
+ $ fs-explorer extract release.7z /docs -o out/ -v
112
+ $ fs-explorer ls --json encrypted.dmg --ask-password
113
+ $ fs-explorer ls outer.zip /disk.dmg/p1/Applications # paths cross containers
114
+ ```
115
+
116
+ Common options: `--password PW`, `--password-file PATH`, `--ask-password` (prompts once per
117
+ encrypted layer, showing the APFS hint), `$FS_EXPLORER_PASSWORD`, `--format NAME`,
118
+ `--backend auto|pure|system`, `--cache-size 64M`, `--full-read-limit SIZE`,
119
+ `--errors raise|yield|skip` (default `yield`: errors go to stderr, listing continues), `--stats`.
120
+
121
+ `ls`: `-r N` recursion depth (default unlimited, 0 = direct children), `-n N` nested container
122
+ descent, `--no-stat`, `-i/-x PATTERN` include/exclude (excluded directories are pruned),
123
+ `-T f,d,l,v` entry types, `-a` system files, `--no-flatten`, `--sort`, `-l`, `--json` (JSON Lines),
124
+ `-0`, `--relative`.
125
+
126
+ `cat`: `--offset`, `--length`, `--no-follow`, `--verify`.
127
+ `extract`: `-o DEST`, `-r/-i/-x/-T`, `--preserve mode,mtime,owner,symlinks,hardlinks,xattrs,resource_forks`,
128
+ `--on-conflict error|skip|overwrite|rename`, `--max-size`, `--max-ratio`, `--no-verify`, `--no-sparse`, `-v`.
129
+
130
+ Exit codes: 0 success; 1 some entries had errors; 2 usage error; 3 unsupported format or missing
131
+ codec; 4 missing or wrong password; 5 target not found; 6 unsafe path or limit exceeded.
132
+
133
+ ## Python API
134
+
135
+ ```python
136
+ import fs_explorer as fx
137
+
138
+ for e in fx.iter_entries("disk.dmg", target="/p2/Users", recursive=1):
139
+ print(e.path, e.type.name, e.size, e.mtime)
140
+
141
+ data = fx.read_file("backup.7z", "/docs/report.pdf", password="secret")
142
+
143
+ with fx.open_file("image.vhdx", "/p1/pagefile.sys") as f: # io.RawIOBase
144
+ f.seek(1 << 30)
145
+ chunk = f.read(4096)
146
+
147
+ for e in fx.extract("archive.zip", "/", "out/", on_conflict="rename"):
148
+ print("wrote", e.path)
149
+
150
+ print(" > ".join(map(str, fx.probe("Install.dmg"))))
151
+
152
+ # Reuse parsed layers across calls:
153
+ with fx.open_source("disk.img", password=lambda req: ask_user(req.description, req.hint)) as src:
154
+ names = [e.name for e in src.entries(stat=False, recursive=0)]
155
+ blob = src.read_file("/p1/etc/hostname")
156
+ print(src.stats.as_dict()) # bytes read, cache hits, decompressed, decrypted, backends
157
+ ```
158
+
159
+ `iter_entries(source, *, recursive=-1, target="/", password=None, nested=0, stat=True, include=(),
160
+ exclude=(), types=None, format_hint=None, errors="raise", system_files=False, flatten=True,
161
+ sort=False, full_read_limit=0, resolver=None, cache_size=64<<20, backend="auto")`.
162
+ A source is a path, a binary file object (seekable), `bytes`, or a directory (`.sparsebundle`).
163
+
164
+ `Entry` fields: `path`, `name`, `type` (`FILE`, `DIR`, `SYMLINK`, `HARDLINK`, `CHAR_DEVICE`,
165
+ `BLOCK_DEVICE`, `FIFO`, `SOCKET`, `VOLUME`, `OTHER`), `depth`, `size`, `stored_size`, `mode`, `uid`,
166
+ `gid`, `owner`, `group`, `mtime_ns`/`ctime_ns`/`atime_ns`/`btime_ns` (ns since the Unix epoch, UTC;
167
+ `.mtime` etc. give `datetime`s), `nlink`, `inode`, `link_target`, `format`, `container`, `extra`,
168
+ `error`. `entry.to_dict()` is JSON-ready.
169
+
170
+ Errors derive from `FsExplorerError`: `UnsupportedFormatError`, `PasswordRequiredError`,
171
+ `WrongPasswordError`, `CorruptDataError`, `MissingCodecError`, `TargetNotFoundError`,
172
+ `AmbiguousTargetError`, `NotAFileError`, `ChecksumError`, `UnsafePathError`, `LimitExceededError`.
173
+
174
+ ## Paths, volumes, and depth
175
+
176
+ - Paths are `/`-separated from the source root. Containers are unwrapped until a filesystem or archive
177
+ is reached. When a partition map, APFS container, or multi-volume layer has several listable
178
+ volumes, the root lists them as `VOLUME` entries with canonical names: `p1`…`pN` for partitions
179
+ (1-based, like `disk2s1`/`sda1`; MBR logical partitions start at `p5`) and `v1`…`vN` for APFS
180
+ volumes. Single-child layers are collapsed (`flatten=True`).
181
+ - Labels (filesystem volume name, else the GPT/APM partition name) are in `extra["label"]` and are
182
+ accepted as aliases in targets: `/Macintosh HD/Users`. Exact match first, then case-insensitive;
183
+ canonical names always win; a label shared by several volumes raises `AmbiguousTargetError`.
184
+ Labels are read lazily (only for `stat=True`, i.e. `ls -l`/`--json`, and alias lookups).
185
+ - Unrecognized partitions (swap, empty) appear as `VOLUME` entries with `error` set.
186
+ - A path segment that names a container file descends into it: `/Downloads/x.dmg/p1/Applications`.
187
+ As a listing target, `/x.dmg` yields the file entry itself; `/x.dmg/` lists its contents.
188
+ - Btrfs subvolumes appear as directories where they are linked; the root is the default subvolume.
189
+ - `depth` is relative to the target (0 = direct children). Each directory, volume boundary, and
190
+ (with `nested > 0`) container boundary adds one level.
191
+ - HFS+ names are converted from NFD to NFC (`keep_raw_names` keeps them); targets are normalized
192
+ before lookup. Symlinks inside images are followed within the image (loop limit 40); a link that
193
+ escapes the image root raises `TargetNotFoundError`.
194
+
195
+ ## Passwords and crypto
196
+
197
+ `password` may be a `str`/`bytes`, or a callable receiving a `PasswordRequest(description, hint,
198
+ attempt)` and returning a candidate or `None` to give up. Supported: encrypted DMG (v1/v2),
199
+ APFS native encryption (user passwords and the recovery key; the stored hint is passed to the
200
+ callable), 7z (content and header), RAR5 and RAR4 (stored members and encrypted headers), zip
201
+ (ZipCrypto, WinZip AES-128/192/256).
202
+
203
+ AES and 3DES use `libcrypto` (OpenSSL 1.1/3, including the copy shipped with CPython on Windows) or
204
+ CommonCrypto on macOS when available; otherwise a pure-Python T-table AES (~1 MB/s). For encrypted
205
+ APFS volumes and bulk reads from encrypted DMGs the native backend matters more than anything else;
206
+ `--stats` shows which backend served each codec and cipher. Setting `FS_EXPLORER_NO_NATIVE=1` disables
207
+ every system library, as if none were installed.
208
+
209
+ ## `full_read_limit` and spooling
210
+
211
+ Some members have no random access: a DMG inside a deflated zip member, a zip inside a `.tar.gz`, an
212
+ `.img.xz`. When such a member's stored size is within `full_read_limit`, it is spooled to a
213
+ `SpooledTemporaryFile` and opened normally; otherwise listing inside it raises `LimitExceededError`
214
+ or `UnsupportedFormatError` with the reason. The same limit enables listing formats that need full
215
+ decompression (compressed tar/cpio). Reading a single member of a compressed tar never needs it.
216
+
217
+ ## Extraction safety
218
+
219
+ - `..` components, absolute paths, drive letters, and NUL bytes are rejected (`UnsafePathError`);
220
+ member names that start with `/` are extracted relative to the destination.
221
+ - Symlinks are created last, and a symlink whose target is absolute or resolves outside the
222
+ destination is refused; files are never written through a symlink, pre-existing or created during
223
+ the same extraction.
224
+ - `max_size` (total bytes) and `max_ratio` (per member, default 1000×) are enforced while writing.
225
+ - Case-insensitive collisions, Windows-invalid characters, and reserved names are handled
226
+ (`on_conflict`); holes are skipped with `seek` (`sparse=True`); checksums are verified by default.
227
+ - Metadata: mode and mtime by default; `owner` (root only), `xattrs`, `resource_forks` (the named fork
228
+ on macOS, AppleDouble `._` files elsewhere), `symlinks` and `hardlinks` within the extraction set.
229
+ - Solid blocks (7z, CAB folders, RAR solid, pbzx, RPM payloads) are processed in storage order, so
230
+ extraction is linear.
231
+
232
+ ## Performance notes
233
+
234
+ Listing is dominated by B-tree walking and header parsing and typically runs at thousands to tens of
235
+ thousands of entries per second. Reading throughput depends on the codec:
236
+
237
+ | Path | Throughput (Python 3.11) |
238
+ |---|---|
239
+ | stdlib zlib, bzip2, xz; native zstd/lz4/lzo; libcrypto AES | hundreds of MB/s |
240
+ | pure LZ4, LZO | 15–25 MB/s |
241
+ | pure zstd, LZFSE, LZVN, Deflate64 | 8–15 MB/s |
242
+ | pure LZX, XPRESS | 5–7 MB/s |
243
+ | pure AES-CBC / XTS | ~1 MB/s |
244
+ | pure PPMd | 0.03–0.15 MB/s |
245
+
246
+ `tools/bench.py IMAGE --read-largest 3 --backend pure|system` measures entries/s and MB/s on your
247
+ own images.
248
+
249
+ ## Limitations
250
+
251
+ - RAR decompression is not implemented (stored members only).
252
+ - APFS: live tree only (no snapshot selection); sealed volumes are untested.
253
+ - HFS+: no journal replay; case-insensitive comparison approximates Apple's FastUnicodeCompare.
254
+ - Btrfs: no log-tree replay, no RAID0/10/5/6, no seed devices or zoned mode.
255
+ - UDF: VAT (virtual) partitions are refused.
256
+ - FAT timestamps are local time and reported as if UTC.
257
+ - Encrypted DMG handling was validated against images built by our fixture generator and reference
258
+ decryptors, not Apple-made images.
259
+
260
+ ## Development
261
+
262
+ ```
263
+ pip install -e .[dev]
264
+ pytest -q # ~950 tests, about a minute
265
+ ruff check src tests tools
266
+ mypy
267
+ ```
268
+
269
+ Fixtures live in `tests/fixtures/` (xz-compressed images decompressed on first use into
270
+ `tests/fixtures/_build/`, with JSON oracles recording paths, types, sizes, and SHA-256 of every
271
+ file). `tools/make_fixtures_linux.sh` regenerates them from the standard tree
272
+ (`tools/fixtures/tree.py`) with `mkfs.*`, `mksquashfs`, `xorriso`, `qemu-img`, `7z`, `rar`, `gcab`,
273
+ `rpmbuild`, and Python builders for Apple formats (HFS+, APFS, UDIF, encrypted DMG, sparse images,
274
+ pkg) that Linux tools cannot write. `tools/make_fixtures_macos.sh` builds Apple-native images with
275
+ `hdiutil`. Some reference images come from the dfVFS project (Apache 2.0, see
276
+ `tests/fixtures/dfvfs/NOTICE`).