unsuper 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.
- unsuper-1.0.dist-info/METADATA +83 -0
- unsuper-1.0.dist-info/RECORD +7 -0
- unsuper-1.0.dist-info/WHEEL +5 -0
- unsuper-1.0.dist-info/entry_points.txt +2 -0
- unsuper-1.0.dist-info/licenses/LICENSE +21 -0
- unsuper-1.0.dist-info/top_level.txt +1 -0
- unsuper.py +775 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unsuper
|
|
3
|
+
Version: 1.0
|
|
4
|
+
Summary: The fastest Android super.img dumper, made in Python.
|
|
5
|
+
Author-email: fl0w <codefl0w@proton.me>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/codefl0w/unsuper
|
|
8
|
+
Project-URL: Repository, https://github.com/codefl0w/unsuper
|
|
9
|
+
Project-URL: Issues, https://github.com/codefl0w/unsuper/issues
|
|
10
|
+
Requires-Python: >=3.6
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# unsuper
|
|
17
|
+
The fastest Android super.img dumper, made in Python.
|
|
18
|
+
|
|
19
|
+
 
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- **Extremely fast** - Best in its class, see speeds below
|
|
25
|
+
- **Unsparse support** - Automatically unsparses images without any dependancies
|
|
26
|
+
- **Smart extraction** - Automatically skips empty partitions
|
|
27
|
+
- **Selective usage** - unsuper can extract only specific partitions or just unsparse the image
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Speed
|
|
32
|
+
unsuper can can unsparse a 6.67GB super.img in only 11 seconds, and dump all available partitions in ~27 seconds on an average NVMe disk with the default of 4 threads. This puts the total extraction time to just under 40 seconds.
|
|
33
|
+
|
|
34
|
+
Performance may vary based on the amount of threads and disk speeds. More threads doesn't always equal to higher speeds.
|
|
35
|
+
|
|
36
|
+
# Usage
|
|
37
|
+
|
|
38
|
+
unsuper's usage is extremely easy: just state the path to your super.img and start. If no output directory is stated, unsuper will create an "extracted_partitions" folder under your home directory and use it as default.
|
|
39
|
+
|
|
40
|
+
Extra arguements come AFTER the positional arguements.
|
|
41
|
+
|
|
42
|
+
positional arguments:
|
|
43
|
+
super_image Path to super.img file
|
|
44
|
+
output_dir Output directory for extracted partitions (default: extracted_partitions)
|
|
45
|
+
|
|
46
|
+
options:
|
|
47
|
+
-h, --help show this help message and exit
|
|
48
|
+
--version show program's version number and exit
|
|
49
|
+
-p, --partitions PARTITIONS [PARTITIONS ...]
|
|
50
|
+
Specific partition names to extract (vendor, product etc.) (default: extract all)
|
|
51
|
+
-j, --jobs JOBS Number of parallel extraction threads (default: 4)
|
|
52
|
+
-q, --quiet Suppress progress output
|
|
53
|
+
--list List available partitions and exit
|
|
54
|
+
--temp-dir TEMP_DIR Directory for temporary unsparse file (default: system temp directory)
|
|
55
|
+
--unsparse Unsparse image and save to output directory
|
|
56
|
+
|
|
57
|
+
For example, if you want to extract the system_a partition using 6 threads:
|
|
58
|
+
|
|
59
|
+
unsuper.py /path/to/super.img /output_path --partitions system_a --jobs 6
|
|
60
|
+
|
|
61
|
+
And so on.
|
|
62
|
+
|
|
63
|
+
unsuper can also simply unsparse an image and save it to a specified directory:
|
|
64
|
+
|
|
65
|
+
unsuper.py /path/to/super.img /output_path --unsparse
|
|
66
|
+
|
|
67
|
+
Or list all available partitions and clean after:
|
|
68
|
+
|
|
69
|
+
unsuper.py /path/to/super.img --list
|
|
70
|
+
|
|
71
|
+
### Notes
|
|
72
|
+
If the --list arguement is used with --partitions, it will calculate the specified partitions' sizes and exit.
|
|
73
|
+
|
|
74
|
+
If the --unsparse arguement is used along with --partitions or --list, the unsparse image will be saved to the output directory and reused for extraction rather than creating a temporary one.
|
|
75
|
+
|
|
76
|
+
### Extras
|
|
77
|
+
|
|
78
|
+
Enjoy my work? Please consider a small donation!
|
|
79
|
+
|
|
80
|
+
<a href="https://buymeacoffee.com/fl0w" target="_blank" rel="noopener noreferrer">
|
|
81
|
+
<img width="350" alt="yellow-button" src="https://github.com/user-attachments/assets/2e6d44c8-9640-4cb3-bcc8-989595d6b7e9"/>
|
|
82
|
+
</a>
|
|
83
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
unsuper.py,sha256=qQh3fTLHFJKRuZlQJBIWN46WDanODGRr0HwNpuk9N9s,33603
|
|
2
|
+
unsuper-1.0.dist-info/licenses/LICENSE,sha256=OHPrqaGCtOyKrJkJiAwk-GiTGGS_D23hKmYvt5Sczpw,1061
|
|
3
|
+
unsuper-1.0.dist-info/METADATA,sha256=EwhupTR26HQxVJf1Mh13fnrbGXaqLZe1YDnb8OjmVxg,3484
|
|
4
|
+
unsuper-1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
unsuper-1.0.dist-info/entry_points.txt,sha256=FvNrac_y7Vehjn7206YPl4pZ-ezndk9G9lEre-Tc9b4,41
|
|
6
|
+
unsuper-1.0.dist-info/top_level.txt,sha256=HaeaRAynFdwrPpq5cptT8uXLDJ9xRATulUuAQetKvBY,8
|
|
7
|
+
unsuper-1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 fl0w
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
unsuper
|
unsuper.py
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# unsuper, fastest Android super.img dumper
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import mmap
|
|
7
|
+
import os
|
|
8
|
+
import struct
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import BinaryIO, List, Optional, Tuple
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
VERSION = 1.0
|
|
18
|
+
|
|
19
|
+
# Constants
|
|
20
|
+
SPARSE_HEADER_MAGIC = 0xED26FF3A
|
|
21
|
+
SPARSE_HEADER_SIZE = 28
|
|
22
|
+
SPARSE_CHUNK_HEADER_SIZE = 12
|
|
23
|
+
|
|
24
|
+
LP_PARTITION_RESERVED_BYTES = 4096
|
|
25
|
+
LP_METADATA_GEOMETRY_MAGIC = 0x616c4467
|
|
26
|
+
LP_METADATA_GEOMETRY_SIZE = 4096
|
|
27
|
+
LP_METADATA_HEADER_MAGIC = 0x414C5030
|
|
28
|
+
LP_SECTOR_SIZE = 512
|
|
29
|
+
|
|
30
|
+
LP_TARGET_TYPE_LINEAR = 0
|
|
31
|
+
|
|
32
|
+
# Optimized buffer size for I/O operations (1MB)
|
|
33
|
+
BUFFER_SIZE = 1024 * 1024
|
|
34
|
+
|
|
35
|
+
class FastDumperError(Exception):
|
|
36
|
+
# Custom exception for dumper errors
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ExtentInfo:
|
|
41
|
+
# extent information
|
|
42
|
+
offset: int
|
|
43
|
+
size: int
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class PartitionInfo:
|
|
47
|
+
# partition information
|
|
48
|
+
name: str
|
|
49
|
+
extents: List[ExtentInfo]
|
|
50
|
+
total_size: int
|
|
51
|
+
|
|
52
|
+
class SparseHeader:
|
|
53
|
+
# Sparse image header parser
|
|
54
|
+
def __init__(self, buffer: bytes):
|
|
55
|
+
fmt = '<I4H4I'
|
|
56
|
+
required_size = struct.calcsize(fmt)
|
|
57
|
+
if len(buffer) < required_size:
|
|
58
|
+
raise FastDumperError(f"Sparse header too short: got {len(buffer)} bytes, need {required_size}")
|
|
59
|
+
|
|
60
|
+
(
|
|
61
|
+
self.magic,
|
|
62
|
+
self.major_version,
|
|
63
|
+
self.minor_version,
|
|
64
|
+
self.file_hdr_sz,
|
|
65
|
+
self.chunk_hdr_sz,
|
|
66
|
+
self.blk_sz,
|
|
67
|
+
self.total_blks,
|
|
68
|
+
self.total_chunks,
|
|
69
|
+
self.image_checksum
|
|
70
|
+
) = struct.unpack(fmt, buffer[:required_size])
|
|
71
|
+
|
|
72
|
+
class SparseChunkHeader:
|
|
73
|
+
# Sparse chunk header parser
|
|
74
|
+
def __init__(self, buffer: bytes):
|
|
75
|
+
fmt = '<2H2I'
|
|
76
|
+
required_size = struct.calcsize(fmt)
|
|
77
|
+
if len(buffer) < required_size:
|
|
78
|
+
raise FastDumperError(f"Chunk header too short: got {len(buffer)} bytes, need {required_size}")
|
|
79
|
+
|
|
80
|
+
(
|
|
81
|
+
self.chunk_type,
|
|
82
|
+
self.reserved,
|
|
83
|
+
self.chunk_sz,
|
|
84
|
+
self.total_sz,
|
|
85
|
+
) = struct.unpack(fmt, buffer[:required_size])
|
|
86
|
+
|
|
87
|
+
class FastSparseConverter:
|
|
88
|
+
# sparse image converter
|
|
89
|
+
|
|
90
|
+
def __init__(self, input_file: str, temp_dir: Optional[str] = None):
|
|
91
|
+
self.input_file = input_file
|
|
92
|
+
self.temp_dir = temp_dir
|
|
93
|
+
|
|
94
|
+
def convert(self) -> str:
|
|
95
|
+
|
|
96
|
+
# Create output file in temp directory
|
|
97
|
+
if self.temp_dir:
|
|
98
|
+
temp_dir = Path(self.temp_dir)
|
|
99
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
output_file = temp_dir / f"{Path(self.input_file).stem}.unsparse.img"
|
|
101
|
+
else:
|
|
102
|
+
# Use OS temp directory
|
|
103
|
+
fd, output_file = tempfile.mkstemp(
|
|
104
|
+
suffix='.unsparse.img',
|
|
105
|
+
prefix=f"{Path(self.input_file).stem}_",
|
|
106
|
+
dir=None
|
|
107
|
+
)
|
|
108
|
+
os.close(fd) # Close the file descriptor, we'll open it normally
|
|
109
|
+
output_file = str(output_file)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
with open(self.input_file, 'rb') as infile, open(output_file, 'wb') as outfile:
|
|
113
|
+
# Read and validate header
|
|
114
|
+
header_data = infile.read(SPARSE_HEADER_SIZE)
|
|
115
|
+
if len(header_data) < SPARSE_HEADER_SIZE:
|
|
116
|
+
raise FastDumperError(f"File too short: cannot read sparse header ({len(header_data)} < {SPARSE_HEADER_SIZE})")
|
|
117
|
+
|
|
118
|
+
header = SparseHeader(header_data)
|
|
119
|
+
|
|
120
|
+
if header.magic != SPARSE_HEADER_MAGIC:
|
|
121
|
+
raise FastDumperError(f"Invalid sparse image magic: 0x{header.magic:08x} (expected 0x{SPARSE_HEADER_MAGIC:08x})")
|
|
122
|
+
|
|
123
|
+
# Validate header values
|
|
124
|
+
if header.file_hdr_sz < SPARSE_HEADER_SIZE:
|
|
125
|
+
raise FastDumperError(f"Invalid file header size: {header.file_hdr_sz}")
|
|
126
|
+
|
|
127
|
+
if header.chunk_hdr_sz < SPARSE_CHUNK_HEADER_SIZE:
|
|
128
|
+
raise FastDumperError(f"Invalid chunk header size: {header.chunk_hdr_sz}")
|
|
129
|
+
|
|
130
|
+
if header.blk_sz == 0:
|
|
131
|
+
raise FastDumperError("Invalid block size: 0")
|
|
132
|
+
|
|
133
|
+
# Skip to chunk data
|
|
134
|
+
infile.seek(header.file_hdr_sz)
|
|
135
|
+
|
|
136
|
+
# Process chunks efficiently
|
|
137
|
+
for chunk_idx in range(header.total_chunks):
|
|
138
|
+
try:
|
|
139
|
+
# Read chunk header with bounds checking
|
|
140
|
+
chunk_header_data = infile.read(SPARSE_CHUNK_HEADER_SIZE)
|
|
141
|
+
if len(chunk_header_data) < SPARSE_CHUNK_HEADER_SIZE:
|
|
142
|
+
raise FastDumperError(f"Unexpected EOF while reading chunk {chunk_idx + 1}/{header.total_chunks} header: got {len(chunk_header_data)} bytes")
|
|
143
|
+
|
|
144
|
+
chunk_header = SparseChunkHeader(chunk_header_data)
|
|
145
|
+
|
|
146
|
+
# Validate chunk header
|
|
147
|
+
if chunk_header.total_sz < header.chunk_hdr_sz:
|
|
148
|
+
raise FastDumperError(f"Invalid chunk {chunk_idx + 1} total size: {chunk_header.total_sz} < {header.chunk_hdr_sz}")
|
|
149
|
+
|
|
150
|
+
# Skip extra header data if present
|
|
151
|
+
if header.chunk_hdr_sz > SPARSE_CHUNK_HEADER_SIZE:
|
|
152
|
+
extra_bytes = header.chunk_hdr_sz - SPARSE_CHUNK_HEADER_SIZE
|
|
153
|
+
skipped = infile.read(extra_bytes)
|
|
154
|
+
if len(skipped) < extra_bytes:
|
|
155
|
+
raise FastDumperError(f"Unexpected EOF while reading chunk {chunk_idx + 1} extra header data")
|
|
156
|
+
|
|
157
|
+
chunk_data_size = chunk_header.total_sz - header.chunk_hdr_sz
|
|
158
|
+
output_size = chunk_header.chunk_sz * header.blk_sz
|
|
159
|
+
|
|
160
|
+
if chunk_header.chunk_type == 0xCAC1: # Raw data
|
|
161
|
+
# Copy data in large chunks for better performance
|
|
162
|
+
remaining = chunk_data_size
|
|
163
|
+
while remaining > 0:
|
|
164
|
+
chunk_size = min(BUFFER_SIZE, remaining)
|
|
165
|
+
data = infile.read(chunk_size)
|
|
166
|
+
if len(data) == 0:
|
|
167
|
+
raise FastDumperError(f"Unexpected EOF while reading chunk {chunk_idx + 1} raw data")
|
|
168
|
+
outfile.write(data)
|
|
169
|
+
remaining -= len(data)
|
|
170
|
+
|
|
171
|
+
elif chunk_header.chunk_type == 0xCAC2: # Fill data
|
|
172
|
+
if chunk_data_size < 4:
|
|
173
|
+
raise FastDumperError(f"Incomplete fill chunk {chunk_idx + 1}: data size {chunk_data_size} < 4")
|
|
174
|
+
|
|
175
|
+
fill_data = infile.read(4)
|
|
176
|
+
if len(fill_data) < 4:
|
|
177
|
+
raise FastDumperError(f"Unexpected EOF while reading chunk {chunk_idx + 1} fill data")
|
|
178
|
+
|
|
179
|
+
# Skip remaining fill chunk data if any
|
|
180
|
+
if chunk_data_size > 4:
|
|
181
|
+
remaining_fill = chunk_data_size - 4
|
|
182
|
+
skipped = infile.read(remaining_fill)
|
|
183
|
+
if len(skipped) < remaining_fill:
|
|
184
|
+
raise FastDumperError(f"Unexpected EOF while reading chunk {chunk_idx + 1} remaining fill data")
|
|
185
|
+
|
|
186
|
+
# Write fill pattern efficiently
|
|
187
|
+
if output_size > 0:
|
|
188
|
+
pattern = fill_data * (BUFFER_SIZE // 4)
|
|
189
|
+
remaining = output_size
|
|
190
|
+
while remaining > 0:
|
|
191
|
+
write_size = min(len(pattern), remaining)
|
|
192
|
+
outfile.write(pattern[:write_size])
|
|
193
|
+
remaining -= write_size
|
|
194
|
+
|
|
195
|
+
elif chunk_header.chunk_type == 0xCAC3: # Don't care
|
|
196
|
+
# Skip chunk data and write zeros/seek
|
|
197
|
+
if chunk_data_size > 0:
|
|
198
|
+
skipped = infile.read(chunk_data_size)
|
|
199
|
+
if len(skipped) < chunk_data_size:
|
|
200
|
+
raise FastDumperError(f"Unexpected EOF while skipping chunk {chunk_idx + 1} don't care data")
|
|
201
|
+
|
|
202
|
+
# Seek forward in output file (creates sparse file if supported)
|
|
203
|
+
current_pos = outfile.tell()
|
|
204
|
+
outfile.seek(current_pos + output_size)
|
|
205
|
+
|
|
206
|
+
elif chunk_header.chunk_type == 0xCAC4: # CRC32
|
|
207
|
+
# Skip CRC chunk data
|
|
208
|
+
if chunk_data_size > 0:
|
|
209
|
+
skipped = infile.read(chunk_data_size)
|
|
210
|
+
if len(skipped) < chunk_data_size:
|
|
211
|
+
raise FastDumperError(f"Unexpected EOF while skipping chunk {chunk_idx + 1} CRC data")
|
|
212
|
+
# CRC chunks don't contribute to output
|
|
213
|
+
|
|
214
|
+
else:
|
|
215
|
+
# Unknown chunk type, skip data and output
|
|
216
|
+
print(f"Warning: Unknown chunk type 0x{chunk_header.chunk_type:04x} in chunk {chunk_idx + 1}, skipping...")
|
|
217
|
+
if chunk_data_size > 0:
|
|
218
|
+
skipped = infile.read(chunk_data_size)
|
|
219
|
+
if len(skipped) < chunk_data_size:
|
|
220
|
+
raise FastDumperError(f"Unexpected EOF while skipping chunk {chunk_idx + 1} unknown data")
|
|
221
|
+
|
|
222
|
+
# Seek forward in output
|
|
223
|
+
current_pos = outfile.tell()
|
|
224
|
+
outfile.seek(current_pos + output_size)
|
|
225
|
+
|
|
226
|
+
except FastDumperError:
|
|
227
|
+
raise
|
|
228
|
+
except Exception as e:
|
|
229
|
+
raise FastDumperError(f"Error processing chunk {chunk_idx + 1}: {e}")
|
|
230
|
+
|
|
231
|
+
except Exception as e:
|
|
232
|
+
# Clean up partial output file on error
|
|
233
|
+
if os.path.exists(output_file):
|
|
234
|
+
try:
|
|
235
|
+
os.remove(output_file)
|
|
236
|
+
except:
|
|
237
|
+
pass
|
|
238
|
+
raise
|
|
239
|
+
|
|
240
|
+
return output_file
|
|
241
|
+
|
|
242
|
+
class FastMetadataParser:
|
|
243
|
+
# Optimized metadata parser
|
|
244
|
+
|
|
245
|
+
def __init__(self, file_path: str):
|
|
246
|
+
self.file_path = file_path
|
|
247
|
+
self.file_size = os.path.getsize(file_path)
|
|
248
|
+
|
|
249
|
+
def parse_partitions(self, partition_names: Optional[List[str]] = None) -> List[PartitionInfo]:
|
|
250
|
+
# Parse partition metadata efficiently
|
|
251
|
+
|
|
252
|
+
with open(self.file_path, 'rb') as f:
|
|
253
|
+
# Use memory mapping for better performance on large files
|
|
254
|
+
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
|
|
255
|
+
return self._parse_with_mmap(mm, partition_names)
|
|
256
|
+
|
|
257
|
+
def _parse_with_mmap(self, mm: mmap.mmap, partition_names: Optional[List[str]]) -> List[PartitionInfo]:
|
|
258
|
+
# Parse metadata using memory mapping
|
|
259
|
+
|
|
260
|
+
# Check minimum file size
|
|
261
|
+
if len(mm) < LP_PARTITION_RESERVED_BYTES + 40:
|
|
262
|
+
raise FastDumperError("File too small to contain valid metadata")
|
|
263
|
+
|
|
264
|
+
# Read geometry
|
|
265
|
+
mm.seek(LP_PARTITION_RESERVED_BYTES)
|
|
266
|
+
geometry_data = mm.read(40) # Only read what we need
|
|
267
|
+
if len(geometry_data) < 40:
|
|
268
|
+
raise FastDumperError("Cannot read geometry data")
|
|
269
|
+
|
|
270
|
+
magic, struct_size = struct.unpack('<2I', geometry_data[:8])
|
|
271
|
+
|
|
272
|
+
if magic != LP_METADATA_GEOMETRY_MAGIC:
|
|
273
|
+
raise FastDumperError(f"Invalid geometry magic: 0x{magic:08x}")
|
|
274
|
+
|
|
275
|
+
# Read remaining geometry fields
|
|
276
|
+
if len(geometry_data) < 44:
|
|
277
|
+
mm.seek(LP_PARTITION_RESERVED_BYTES + 32)
|
|
278
|
+
extra_data = mm.read(12)
|
|
279
|
+
if len(extra_data) < 12:
|
|
280
|
+
raise FastDumperError("Cannot read complete geometry data")
|
|
281
|
+
geometry_data = geometry_data[:32] + extra_data
|
|
282
|
+
|
|
283
|
+
metadata_max_size, metadata_slot_count, logical_block_size = struct.unpack(
|
|
284
|
+
'<3I', geometry_data[32:44]
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Validate geometry values
|
|
288
|
+
if metadata_max_size == 0 or metadata_slot_count == 0:
|
|
289
|
+
raise FastDumperError("Invalid metadata geometry")
|
|
290
|
+
|
|
291
|
+
# Calculate header offset
|
|
292
|
+
base_offset = LP_PARTITION_RESERVED_BYTES + (LP_METADATA_GEOMETRY_SIZE * 2)
|
|
293
|
+
header_offset = base_offset
|
|
294
|
+
|
|
295
|
+
# Check if we have enough space for metadata
|
|
296
|
+
if header_offset + 128 > len(mm):
|
|
297
|
+
raise FastDumperError("File too small for metadata header")
|
|
298
|
+
|
|
299
|
+
# Read metadata header
|
|
300
|
+
mm.seek(header_offset)
|
|
301
|
+
header_data = mm.read(128) # Read enough for header + table descriptors
|
|
302
|
+
if len(header_data) < 128:
|
|
303
|
+
raise FastDumperError("Cannot read metadata header")
|
|
304
|
+
|
|
305
|
+
magic = struct.unpack('<I', header_data[:4])[0]
|
|
306
|
+
if magic != LP_METADATA_HEADER_MAGIC:
|
|
307
|
+
# Try backup header
|
|
308
|
+
backup_offset = base_offset + metadata_max_size * metadata_slot_count
|
|
309
|
+
if backup_offset + 128 > len(mm):
|
|
310
|
+
raise FastDumperError("File too small for backup metadata header")
|
|
311
|
+
|
|
312
|
+
mm.seek(backup_offset)
|
|
313
|
+
header_data = mm.read(128)
|
|
314
|
+
if len(header_data) < 128:
|
|
315
|
+
raise FastDumperError("Cannot read backup metadata header")
|
|
316
|
+
|
|
317
|
+
magic = struct.unpack('<I', header_data[:4])[0]
|
|
318
|
+
if magic != LP_METADATA_HEADER_MAGIC:
|
|
319
|
+
raise FastDumperError(f"Invalid metadata header magic: 0x{magic:08x}")
|
|
320
|
+
header_offset = backup_offset
|
|
321
|
+
|
|
322
|
+
# Parse header and table descriptors
|
|
323
|
+
header_size, tables_size = struct.unpack('<2I', header_data[8:16])
|
|
324
|
+
|
|
325
|
+
if header_size < 80 or tables_size == 0:
|
|
326
|
+
raise FastDumperError("Invalid metadata header values")
|
|
327
|
+
|
|
328
|
+
# Parse table descriptors (starting at offset 80 in header)
|
|
329
|
+
tables_start = header_offset + header_size
|
|
330
|
+
|
|
331
|
+
# Check bounds for table descriptors
|
|
332
|
+
if len(header_data) < 104:
|
|
333
|
+
raise FastDumperError("Header too small for table descriptors")
|
|
334
|
+
|
|
335
|
+
# Partition table descriptor
|
|
336
|
+
part_offset, part_count, part_entry_size = struct.unpack('<3I', header_data[80:92])
|
|
337
|
+
|
|
338
|
+
# Extent table descriptor
|
|
339
|
+
extent_offset, extent_count, extent_entry_size = struct.unpack('<3I', header_data[92:104])
|
|
340
|
+
|
|
341
|
+
# Validate table parameters
|
|
342
|
+
if part_entry_size < 52 or extent_entry_size < 24:
|
|
343
|
+
raise FastDumperError("Invalid table entry sizes")
|
|
344
|
+
|
|
345
|
+
# Check bounds for partition table
|
|
346
|
+
part_table_start = tables_start + part_offset
|
|
347
|
+
part_table_size = part_count * part_entry_size
|
|
348
|
+
if part_table_start + part_table_size > len(mm):
|
|
349
|
+
raise FastDumperError("Partition table extends beyond file")
|
|
350
|
+
|
|
351
|
+
# Check bounds for extent table
|
|
352
|
+
extent_table_start = tables_start + extent_offset
|
|
353
|
+
extent_table_size = extent_count * extent_entry_size
|
|
354
|
+
if extent_table_start + extent_table_size > len(mm):
|
|
355
|
+
raise FastDumperError("Extent table extends beyond file")
|
|
356
|
+
|
|
357
|
+
# Read partition table
|
|
358
|
+
mm.seek(part_table_start)
|
|
359
|
+
partitions_data = mm.read(part_table_size)
|
|
360
|
+
if len(partitions_data) < part_table_size:
|
|
361
|
+
raise FastDumperError("Cannot read complete partition table")
|
|
362
|
+
|
|
363
|
+
# Read extent table
|
|
364
|
+
mm.seek(extent_table_start)
|
|
365
|
+
extents_data = mm.read(extent_table_size)
|
|
366
|
+
if len(extents_data) < extent_table_size:
|
|
367
|
+
raise FastDumperError("Cannot read complete extent table")
|
|
368
|
+
|
|
369
|
+
# Parse partitions and extents efficiently
|
|
370
|
+
partitions = []
|
|
371
|
+
|
|
372
|
+
for i in range(part_count):
|
|
373
|
+
part_start = i * part_entry_size
|
|
374
|
+
part_data = partitions_data[part_start:part_start + part_entry_size]
|
|
375
|
+
|
|
376
|
+
if len(part_data) < 52:
|
|
377
|
+
continue # Skip malformed entries
|
|
378
|
+
|
|
379
|
+
# Parse partition entry
|
|
380
|
+
name_bytes = part_data[:36]
|
|
381
|
+
name = name_bytes.decode('utf-8').strip('\x00')
|
|
382
|
+
|
|
383
|
+
# Skip empty partition names
|
|
384
|
+
if not name:
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
# Skip if not in requested partitions
|
|
388
|
+
if partition_names and name not in partition_names:
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
attributes, first_extent_idx, num_extents = struct.unpack(
|
|
392
|
+
'<3I', part_data[36:48]
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Check for various empty partition conditions
|
|
396
|
+
if num_extents == 0:
|
|
397
|
+
print(f"Warning: Partition {name} has no extents, skipping...")
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
# Validate extent indices
|
|
401
|
+
if first_extent_idx >= extent_count or first_extent_idx + num_extents > extent_count:
|
|
402
|
+
print(f"Warning: Invalid extent indices for partition {name}, skipping...")
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
# Parse extents for this partition
|
|
406
|
+
extents = []
|
|
407
|
+
total_size = 0
|
|
408
|
+
skipped_extents = 0
|
|
409
|
+
|
|
410
|
+
for j in range(num_extents):
|
|
411
|
+
extent_idx = first_extent_idx + j
|
|
412
|
+
extent_start = extent_idx * extent_entry_size
|
|
413
|
+
extent_data = extents_data[extent_start:extent_start + extent_entry_size]
|
|
414
|
+
|
|
415
|
+
if len(extent_data) < 24:
|
|
416
|
+
skipped_extents += 1
|
|
417
|
+
continue # Skip malformed extents
|
|
418
|
+
|
|
419
|
+
num_sectors, target_type, target_data = struct.unpack(
|
|
420
|
+
'<QIQ', extent_data[:20]
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if target_type != LP_TARGET_TYPE_LINEAR:
|
|
424
|
+
skipped_extents += 1
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
offset = target_data * LP_SECTOR_SIZE
|
|
428
|
+
size = num_sectors * LP_SECTOR_SIZE
|
|
429
|
+
|
|
430
|
+
# Validate extent bounds
|
|
431
|
+
if offset + size > len(mm):
|
|
432
|
+
print(f"Warning: Extent for {name} extends beyond file, truncating...")
|
|
433
|
+
size = max(0, len(mm) - offset)
|
|
434
|
+
|
|
435
|
+
if size > 0:
|
|
436
|
+
extents.append(ExtentInfo(offset, size))
|
|
437
|
+
total_size += size
|
|
438
|
+
else:
|
|
439
|
+
skipped_extents += 1
|
|
440
|
+
|
|
441
|
+
# Check if partition ended up empty after processing extents
|
|
442
|
+
if not extents:
|
|
443
|
+
if skipped_extents > 0:
|
|
444
|
+
print(f"Warning: Partition {name} has no valid extents (skipped {skipped_extents} invalid extents), skipping...")
|
|
445
|
+
else:
|
|
446
|
+
print(f"Warning: Partition {name} has zero-sized extents, skipping...")
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
# Only add partitions with valid extents
|
|
450
|
+
partitions.append(PartitionInfo(name, extents, total_size))
|
|
451
|
+
|
|
452
|
+
return partitions
|
|
453
|
+
|
|
454
|
+
class FastExtractor:
|
|
455
|
+
# High-performance partition extractor
|
|
456
|
+
|
|
457
|
+
def __init__(self, input_file: str, output_dir: str, max_workers: int = 4):
|
|
458
|
+
self.input_file = input_file
|
|
459
|
+
self.output_dir = Path(output_dir)
|
|
460
|
+
self.max_workers = max_workers
|
|
461
|
+
|
|
462
|
+
# Create output directory
|
|
463
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
464
|
+
|
|
465
|
+
def extract_partition(self, partition: PartitionInfo) -> Tuple[str, float]:
|
|
466
|
+
# Extract a single partition
|
|
467
|
+
start_time = time.time()
|
|
468
|
+
output_file = self.output_dir / f"{partition.name}.img"
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
with open(self.input_file, 'rb') as infile, open(output_file, 'wb') as outfile:
|
|
472
|
+
# Use memory mapping for input file if it's large
|
|
473
|
+
if os.path.getsize(self.input_file) > 100 * 1024 * 1024: # 100MB threshold
|
|
474
|
+
with mmap.mmap(infile.fileno(), 0, access=mmap.ACCESS_READ) as mm:
|
|
475
|
+
self._extract_with_mmap(mm, outfile, partition)
|
|
476
|
+
else:
|
|
477
|
+
self._extract_with_seek(infile, outfile, partition)
|
|
478
|
+
except Exception as e:
|
|
479
|
+
# Clean up partial output file on error
|
|
480
|
+
if output_file.exists():
|
|
481
|
+
output_file.unlink()
|
|
482
|
+
raise FastDumperError(f"Failed to extract {partition.name}: {e}")
|
|
483
|
+
|
|
484
|
+
elapsed = time.time() - start_time
|
|
485
|
+
return partition.name, elapsed
|
|
486
|
+
|
|
487
|
+
def _extract_with_mmap(self, mm: mmap.mmap, outfile: BinaryIO, partition: PartitionInfo):
|
|
488
|
+
# Extract using memory mapping
|
|
489
|
+
for extent in partition.extents:
|
|
490
|
+
# Validate extent bounds
|
|
491
|
+
if extent.offset >= len(mm):
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
mm.seek(extent.offset)
|
|
495
|
+
remaining = min(extent.size, len(mm) - extent.offset)
|
|
496
|
+
|
|
497
|
+
while remaining > 0:
|
|
498
|
+
chunk_size = min(BUFFER_SIZE, remaining)
|
|
499
|
+
data = mm.read(chunk_size)
|
|
500
|
+
if not data:
|
|
501
|
+
break
|
|
502
|
+
outfile.write(data)
|
|
503
|
+
remaining -= len(data)
|
|
504
|
+
|
|
505
|
+
def _extract_with_seek(self, infile: BinaryIO, outfile: BinaryIO, partition: PartitionInfo):
|
|
506
|
+
# Extract using classic file operations
|
|
507
|
+
file_size = os.path.getsize(self.input_file)
|
|
508
|
+
|
|
509
|
+
for extent in partition.extents:
|
|
510
|
+
# Validate extent bounds
|
|
511
|
+
if extent.offset >= file_size:
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
infile.seek(extent.offset)
|
|
515
|
+
remaining = min(extent.size, file_size - extent.offset)
|
|
516
|
+
|
|
517
|
+
while remaining > 0:
|
|
518
|
+
chunk_size = min(BUFFER_SIZE, remaining)
|
|
519
|
+
data = infile.read(chunk_size)
|
|
520
|
+
if not data:
|
|
521
|
+
break
|
|
522
|
+
outfile.write(data)
|
|
523
|
+
remaining -= len(data)
|
|
524
|
+
|
|
525
|
+
def extract_all(self, partitions: List[PartitionInfo], show_progress: bool = True) -> None:
|
|
526
|
+
# Extract all partitions in parallel
|
|
527
|
+
|
|
528
|
+
if show_progress:
|
|
529
|
+
print(f"Extracting {len(partitions)} partitions using {self.max_workers} threads...")
|
|
530
|
+
|
|
531
|
+
# Use thread pool for parallel extraction
|
|
532
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
533
|
+
# Submit all extraction tasks
|
|
534
|
+
future_to_partition = {
|
|
535
|
+
executor.submit(self.extract_partition, partition): partition
|
|
536
|
+
for partition in partitions
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
# Process completed tasks
|
|
540
|
+
completed = 0
|
|
541
|
+
total_time = 0
|
|
542
|
+
|
|
543
|
+
for future in as_completed(future_to_partition):
|
|
544
|
+
partition = future_to_partition[future]
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
name, elapsed = future.result()
|
|
548
|
+
completed += 1
|
|
549
|
+
total_time += elapsed
|
|
550
|
+
|
|
551
|
+
if show_progress:
|
|
552
|
+
size_mb = partition.total_size / (1024 * 1024)
|
|
553
|
+
speed_mb = size_mb / elapsed if elapsed > 0 else 0
|
|
554
|
+
print(f"[{completed}/{len(partitions)}] {name}: "
|
|
555
|
+
f"{size_mb:.1f}MB in {elapsed:.2f}s ({speed_mb:.1f}MB/s)")
|
|
556
|
+
|
|
557
|
+
except Exception as e:
|
|
558
|
+
print(f"Error extracting {partition.name}: {e}")
|
|
559
|
+
|
|
560
|
+
if show_progress and len(partitions) > 1:
|
|
561
|
+
avg_time = total_time / len(partitions)
|
|
562
|
+
print(f"\nCompleted! Average extraction time: {avg_time:.2f}s per partition")
|
|
563
|
+
print(f"Total extraction time: {total_time:.2f}s")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def main():
|
|
567
|
+
ascii = r"""
|
|
568
|
+
|
|
569
|
+
/\ /\ _ __ ___ _ _ _ __ ___ _ __
|
|
570
|
+
/ / \ \ '_ \/ __| | | | '_ \ / _ \ '__|
|
|
571
|
+
\ \_/ / | | \__ \ |_| | |_) | __/ |
|
|
572
|
+
\___/|_| |_|___/\__,_| .__/ \___|_|
|
|
573
|
+
|_|
|
|
574
|
+
Fastest super.img dumper ever
|
|
575
|
+
|
|
576
|
+
"""
|
|
577
|
+
|
|
578
|
+
parser = argparse.ArgumentParser(
|
|
579
|
+
description=ascii,
|
|
580
|
+
usage="%(prog)s super_image [output_dir] [-h] [-p PARTITIONS [PARTITIONS ...]] [-j JOBS] [-q] [--list] [--temp-dir TEMP_DIR] [--version] [--unsparse]",
|
|
581
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
parser.add_argument(
|
|
585
|
+
'--version',
|
|
586
|
+
action='version',
|
|
587
|
+
version=f'%(prog)s V{VERSION}'
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
parser.add_argument(
|
|
591
|
+
'super_image',
|
|
592
|
+
help='Path to super.img file'
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
parser.add_argument(
|
|
596
|
+
'output_dir',
|
|
597
|
+
nargs='?',
|
|
598
|
+
default='extracted_partitions',
|
|
599
|
+
help='Output directory for extracted partitions (default: extracted_partitions)'
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
parser.add_argument(
|
|
603
|
+
'-p', '--partitions',
|
|
604
|
+
nargs='+',
|
|
605
|
+
help='Specific partition names to extract (vendor, product etc.) (default: extract all)'
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
parser.add_argument(
|
|
609
|
+
'-j', '--jobs',
|
|
610
|
+
type=int,
|
|
611
|
+
default=4,
|
|
612
|
+
help='Number of parallel extraction threads (default: 4)'
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
parser.add_argument(
|
|
616
|
+
'-q', '--quiet',
|
|
617
|
+
action='store_true',
|
|
618
|
+
help='Suppress progress output'
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
parser.add_argument(
|
|
622
|
+
'--list',
|
|
623
|
+
action='store_true',
|
|
624
|
+
help='List available partitions and exit'
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
parser.add_argument(
|
|
628
|
+
'--temp-dir',
|
|
629
|
+
help='Directory for temporary unsparse file (default: system temp directory)'
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
parser.add_argument(
|
|
633
|
+
'--unsparse',
|
|
634
|
+
action='store_true',
|
|
635
|
+
help='Unsparse image and save to output directory'
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
args = parser.parse_args()
|
|
639
|
+
|
|
640
|
+
# Validate input file
|
|
641
|
+
input_path = Path(args.super_image)
|
|
642
|
+
if not input_path.exists():
|
|
643
|
+
print(f"Error: {args.super_image} not found")
|
|
644
|
+
sys.exit(1)
|
|
645
|
+
|
|
646
|
+
if input_path.stat().st_size == 0:
|
|
647
|
+
print(f"Error: {args.super_image} is empty")
|
|
648
|
+
sys.exit(1)
|
|
649
|
+
|
|
650
|
+
unsparse_file = None # Track unsparse file for cleanup
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
input_file = args.super_image
|
|
654
|
+
unsparse_created_by_flag = False # Track if unsparse file was created by --unsparse flag
|
|
655
|
+
|
|
656
|
+
# Check if input is sparse and convert if needed
|
|
657
|
+
with open(input_file, 'rb') as f:
|
|
658
|
+
magic_data = f.read(4)
|
|
659
|
+
if len(magic_data) < 4:
|
|
660
|
+
raise FastDumperError("File too small to determine format")
|
|
661
|
+
magic = struct.unpack('<I', magic_data)[0]
|
|
662
|
+
|
|
663
|
+
if magic == SPARSE_HEADER_MAGIC:
|
|
664
|
+
if not args.quiet:
|
|
665
|
+
print("Sparse image detected, converting...")
|
|
666
|
+
|
|
667
|
+
# If --unsparse flag is used, save to output directory and use for extraction
|
|
668
|
+
if args.unsparse:
|
|
669
|
+
output_path = Path(args.output_dir)
|
|
670
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
671
|
+
unsparse_output = output_path / f"{Path(args.super_image).stem}.unsparse.img"
|
|
672
|
+
|
|
673
|
+
converter = FastSparseConverter(input_file, str(output_path))
|
|
674
|
+
converted_file = converter.convert()
|
|
675
|
+
|
|
676
|
+
# Move/rename to desired location if needed
|
|
677
|
+
if converted_file != str(unsparse_output):
|
|
678
|
+
import shutil
|
|
679
|
+
shutil.move(converted_file, unsparse_output)
|
|
680
|
+
|
|
681
|
+
# Use the unsparsed file for further processing
|
|
682
|
+
input_file = str(unsparse_output)
|
|
683
|
+
unsparse_file = str(unsparse_output)
|
|
684
|
+
unsparse_created_by_flag = True
|
|
685
|
+
|
|
686
|
+
if not args.quiet:
|
|
687
|
+
print(f"Unsparsed image saved to: {unsparse_output}")
|
|
688
|
+
else:
|
|
689
|
+
# Normal temp conversion for extraction
|
|
690
|
+
converter = FastSparseConverter(input_file, args.temp_dir)
|
|
691
|
+
unsparse_file = converter.convert()
|
|
692
|
+
input_file = unsparse_file
|
|
693
|
+
|
|
694
|
+
if not args.quiet:
|
|
695
|
+
print(f"Converted to temporary file: {input_file}")
|
|
696
|
+
elif args.unsparse:
|
|
697
|
+
# Image is already unsparsed, just copy it to output directory
|
|
698
|
+
output_path = Path(args.output_dir)
|
|
699
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
700
|
+
unsparse_output = output_path / f"{Path(args.super_image).stem}.unsparse.img"
|
|
701
|
+
|
|
702
|
+
import shutil
|
|
703
|
+
shutil.copy2(args.super_image, unsparse_output)
|
|
704
|
+
|
|
705
|
+
# Use the copied file for further processing
|
|
706
|
+
input_file = str(unsparse_output)
|
|
707
|
+
unsparse_file = str(unsparse_output)
|
|
708
|
+
unsparse_created_by_flag = True
|
|
709
|
+
|
|
710
|
+
if not args.quiet:
|
|
711
|
+
print(f"Image copied to: {unsparse_output} (was already unsparsed)")
|
|
712
|
+
|
|
713
|
+
# If --unsparse is used without any extraction arguments, exit after unsparsing
|
|
714
|
+
if args.unsparse and not args.partitions and not args.list:
|
|
715
|
+
if not args.quiet:
|
|
716
|
+
print("Unsparsing complete! Use --partitions to also extract partitions.")
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
# Parse metadata
|
|
720
|
+
if not args.quiet:
|
|
721
|
+
print("Parsing metadata...")
|
|
722
|
+
|
|
723
|
+
parser_obj = FastMetadataParser(input_file)
|
|
724
|
+
partitions = parser_obj.parse_partitions(args.partitions)
|
|
725
|
+
|
|
726
|
+
if not partitions:
|
|
727
|
+
if args.partitions:
|
|
728
|
+
print(f"No matching partitions found for: {', '.join(args.partitions)}")
|
|
729
|
+
else:
|
|
730
|
+
print("No partitions found in image")
|
|
731
|
+
sys.exit(1)
|
|
732
|
+
|
|
733
|
+
# List partitions if requested
|
|
734
|
+
if args.list:
|
|
735
|
+
print(f"\nFound {len(partitions)} partitions:")
|
|
736
|
+
total_size = 0
|
|
737
|
+
for partition in partitions:
|
|
738
|
+
size_mb = partition.total_size / (1024 * 1024)
|
|
739
|
+
total_size += partition.total_size
|
|
740
|
+
print(f" {partition.name}: {size_mb:.1f}MB ({len(partition.extents)} extents)")
|
|
741
|
+
|
|
742
|
+
total_mb = total_size / (1024 * 1024)
|
|
743
|
+
print(f"\nTotal size: {total_mb:.1f}MB")
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
# Extract partitions
|
|
747
|
+
extractor = FastExtractor(input_file, args.output_dir, args.jobs)
|
|
748
|
+
extractor.extract_all(partitions, not args.quiet)
|
|
749
|
+
|
|
750
|
+
if not args.quiet:
|
|
751
|
+
print(f"\nExtraction complete! Files saved to: {args.output_dir}")
|
|
752
|
+
if args.unsparse:
|
|
753
|
+
print(f"Unsparsed image saved to: {input_file}")
|
|
754
|
+
|
|
755
|
+
except FastDumperError as e:
|
|
756
|
+
print(f"Error: {e}")
|
|
757
|
+
sys.exit(1)
|
|
758
|
+
except KeyboardInterrupt:
|
|
759
|
+
print("\nOperation cancelled by user")
|
|
760
|
+
sys.exit(1)
|
|
761
|
+
except Exception as e:
|
|
762
|
+
print(f"Unexpected error: {e}")
|
|
763
|
+
sys.exit(1)
|
|
764
|
+
finally:
|
|
765
|
+
# Clean up temporary unsparse file only if it wasn't created by --unsparse flag
|
|
766
|
+
if unsparse_file and os.path.exists(unsparse_file) and not unsparse_created_by_flag:
|
|
767
|
+
try:
|
|
768
|
+
os.remove(unsparse_file)
|
|
769
|
+
if not args.quiet:
|
|
770
|
+
print(f"Temporary sparse image cleaned successfully.")
|
|
771
|
+
except Exception as e:
|
|
772
|
+
print(f"Warning: Could not remove temporary file {unsparse_file}: {e}")
|
|
773
|
+
|
|
774
|
+
if __name__ == "__main__":
|
|
775
|
+
main()
|