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.
- fs_explorer-0.1.0/.gitattributes +4 -0
- fs_explorer-0.1.0/.gitignore +23 -0
- fs_explorer-0.1.0/LICENSE +28 -0
- fs_explorer-0.1.0/PKG-INFO +290 -0
- fs_explorer-0.1.0/README.md +276 -0
- fs_explorer-0.1.0/pyproject.toml +50 -0
- fs_explorer-0.1.0/src/fs_explorer/__init__.py +35 -0
- fs_explorer-0.1.0/src/fs_explorer/__main__.py +7 -0
- fs_explorer-0.1.0/src/fs_explorer/api.py +899 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/ar.py +136 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/bom.py +211 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/cab.py +302 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/cpio.py +336 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/rar4.py +455 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/rar5.py +635 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/rpm.py +281 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/sevenzip.py +1302 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/tar.py +437 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/xar.py +652 -0
- fs_explorer-0.1.0/src/fs_explorer/archives/zip.py +914 -0
- fs_explorer-0.1.0/src/fs_explorer/cli.py +383 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/adc.py +65 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/bcj2.py +123 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/crc32c.py +101 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/deflate64.py +354 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/heavy/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/heavy/lzx.py +548 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/heavy/ppmd.py +1930 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/heavy/xpress.py +249 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/lz4.py +471 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/lzfse.py +455 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/lznt1.py +104 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/lzo.py +230 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/lzvn.py +192 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/mszip.py +62 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/native.py +84 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/pbzx.py +172 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/registry.py +352 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/zstd.py +349 -0
- fs_explorer-0.1.0/src/fs_explorer/codecs/zstd_pure.py +874 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/_diskutil.py +489 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/android_sparse.py +119 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/cdraw.py +277 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/dmg_crypt.py +320 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/qcow2.py +334 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/sparsebundle.py +168 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/sparseimage.py +85 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/streams.py +480 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/udif.py +333 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/vdi.py +105 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/vhd.py +237 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/vhdx.py +471 -0
- fs_explorer-0.1.0/src/fs_explorer/containers/vmdk.py +302 -0
- fs_explorer-0.1.0/src/fs_explorer/crypto/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/crypto/aes.py +231 -0
- fs_explorer-0.1.0/src/fs_explorer/crypto/backend.py +446 -0
- fs_explorer-0.1.0/src/fs_explorer/crypto/der.py +139 -0
- fs_explorer-0.1.0/src/fs_explorer/crypto/des.py +202 -0
- fs_explorer-0.1.0/src/fs_explorer/crypto/kdf.py +128 -0
- fs_explorer-0.1.0/src/fs_explorer/crypto/keywrap.py +47 -0
- fs_explorer-0.1.0/src/fs_explorer/crypto/modes.py +146 -0
- fs_explorer-0.1.0/src/fs_explorer/crypto/zipcrypto.py +99 -0
- fs_explorer-0.1.0/src/fs_explorer/detect.py +136 -0
- fs_explorer-0.1.0/src/fs_explorer/entry.py +143 -0
- fs_explorer-0.1.0/src/fs_explorer/errors.py +63 -0
- fs_explorer-0.1.0/src/fs_explorer/extract.py +408 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/btree.py +257 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/container.py +297 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/fs.py +592 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/keybag.py +184 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/apfs/omap.py +75 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/btree.py +164 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/chunks.py +138 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/fs.py +474 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/btrfs/super.py +164 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/decmpfs.py +258 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/exfat.py +362 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/ext.py +724 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/fat.py +484 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/hfsplus.py +932 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/iso9660.py +630 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/ntfs.py +1068 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/squashfs.py +544 -0
- fs_explorer-0.1.0/src/fs_explorer/filesystems/udf.py +728 -0
- fs_explorer-0.1.0/src/fs_explorer/io/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/io/blockmap.py +185 -0
- fs_explorer-0.1.0/src/fs_explorer/io/cache.py +83 -0
- fs_explorer-0.1.0/src/fs_explorer/io/imagefile.py +196 -0
- fs_explorer-0.1.0/src/fs_explorer/io/source.py +290 -0
- fs_explorer-0.1.0/src/fs_explorer/io/spool.py +51 -0
- fs_explorer-0.1.0/src/fs_explorer/io/stats.py +45 -0
- fs_explorer-0.1.0/src/fs_explorer/io/view.py +100 -0
- fs_explorer-0.1.0/src/fs_explorer/modebits.py +47 -0
- fs_explorer-0.1.0/src/fs_explorer/partitions/__init__.py +1 -0
- fs_explorer-0.1.0/src/fs_explorer/partitions/apm.py +130 -0
- fs_explorer-0.1.0/src/fs_explorer/partitions/gpt.py +205 -0
- fs_explorer-0.1.0/src/fs_explorer/partitions/mbr.py +167 -0
- fs_explorer-0.1.0/src/fs_explorer/partitions/types.py +146 -0
- fs_explorer-0.1.0/src/fs_explorer/py.typed +0 -0
- fs_explorer-0.1.0/src/fs_explorer/tree.py +450 -0
|
@@ -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`).
|