diff --git a/dumpyara/dumpyara.py b/dumpyara/dumpyara.py index c35aa66..4707de4 100644 --- a/dumpyara/dumpyara.py +++ b/dumpyara/dumpyara.py @@ -14,13 +14,22 @@ from dumpyara.steps.extract_images import extract_images from dumpyara.steps.prepare_images import prepare_images +try: + import firmware_parsers # noqa: F401 + + _HAS_FIRMWARE_PARSERS = True +except ImportError: + _HAS_FIRMWARE_PARSERS = False + # Package name to package commands REQUIRED_TOOLS = { "7-zip or p7zip": [SEVEN_ZIP_EXECUTABLE, P7ZIP_EXECUTABLE], "erofs-utils": ["fsck.erofs"], - "android-sdk-libsparse-utils or platform-utils": ["simg2img"], } +if not _HAS_FIRMWARE_PARSERS: + REQUIRED_TOOLS["android-sdk-libsparse-utils or platform-utils"] = ["simg2img"] + def dumpyara(file: Path, output_path: Path, debug: bool = False): """Dump an Android firmware.""" diff --git a/dumpyara/steps/extract_archive.py b/dumpyara/steps/extract_archive.py index 39ae92d..1cf71c3 100644 --- a/dumpyara/steps/extract_archive.py +++ b/dumpyara/steps/extract_archive.py @@ -8,14 +8,69 @@ This step will extract the archive into a folder. """ +import re from pathlib import Path from re import Pattern, compile from shutil import unpack_archive from sebaubuntu_libs.liblogging import LOGD, LOGI from typing import Callable, Dict +from zipfile import ZipFile, is_zipfile from dumpyara.utils.files import get_recursive_files_list +try: + import firmware_parsers + + _HAS_FIRMWARE_PARSERS = True +except ImportError: + _HAS_FIRMWARE_PARSERS = False + + +def _strip_vendor_prefix(directory: Path): + """Strip common vendor prefixes from extracted filenames. + + Detects patterns like Nokia NB0 'LFC-X-YYYY-ZZZZ-name.ext' and renames to 'name.ext'. + Only acts when a clear majority of files share the same prefix pattern. + """ + files = [f for f in directory.iterdir() if f.is_file()] + if len(files) < 3: + return + + # Match Nokia-style prefix: LFC-{seg}-{seg}-{seg}- or LFC-{seg}-{seg}- + prefix_pattern = re.compile(r"^[A-Z]{2,4}(?:-[A-Za-z0-9]{1,6}){2,4}-") + prefixed = {} + for f in files: + m = prefix_pattern.match(f.name) + if m: + new_name = f.name[m.end() :] + if new_name and not (directory / new_name).exists(): + prefixed[f] = directory / new_name + + # Only rename if most files have the prefix (avoid false positives) + if len(prefixed) >= len(files) * 0.6: + for old, new in prefixed.items(): + LOGD(f"Stripping vendor prefix: {old.name} → {new.name}") + old.rename(new) + + +def _has_nested_partition_markers(archive_path: Path) -> bool: + """Return True when a nested zip contains dumpable partition container markers.""" + if not is_zipfile(archive_path): + LOGD(f"Skipping nested zip scan for non-zip archive: {archive_path.name}") + return False + + try: + with ZipFile(archive_path, "r") as zip_file: + for file_name in zip_file.namelist(): + for pattern in NESTED_ZIP_PARTITION_MARKERS: + if pattern.search(file_name): + return True + except Exception as e: + LOGD(f"Failed to inspect nested zip {archive_path.name}: {e}") + return False + + return False + def extract_archive(archive_path: Path, extracted_archive_path: Path, is_nested: bool = False): """ @@ -23,8 +78,32 @@ def extract_archive(archive_path: Path, extracted_archive_path: Path, is_nested: """ LOGD(f"Extracting archive: {archive_path.name}") + # Try firmware_parsers detection first + if _HAS_FIRMWARE_PARSERS: + try: + fmt = firmware_parsers.detect(str(archive_path)) + if fmt != "unknown": + extractor = getattr(firmware_parsers, fmt, None) + if extractor is not None: + LOGI(f"Detected firmware format: {fmt}") + extractor(str(archive_path), str(extracted_archive_path)) + if is_nested: + archive_path.unlink() + return + except Exception as e: + LOGI(f"firmware_parsers failed ({e}), falling back to generic extraction") + # Extract the archive - unpack_archive(archive_path, extracted_archive_path) + try: + unpack_archive(archive_path, extracted_archive_path) + except Exception: + # Fallback: try as zip for non-standard extensions (.ozip, .ftf, etc.) + if is_zipfile(archive_path): + LOGD(f"Falling back to zipfile for {archive_path.name}") + with ZipFile(archive_path, "r") as zf: + zf.extractall(extracted_archive_path) + else: + raise if is_nested: LOGD("Archive is nested, unlinking") archive_path.unlink() @@ -36,6 +115,24 @@ def extract_archive(archive_path: Path, extracted_archive_path: Path, is_nested: file.rename(extracted_archive_path / file.name) + # Re-detect firmware formats in extracted files + if _HAS_FIRMWARE_PARSERS: + for file in list(get_recursive_files_list(extracted_archive_path)): + try: + fmt = firmware_parsers.detect(str(file)) + if fmt != "unknown": + extractor = getattr(firmware_parsers, fmt, None) + if extractor is not None: + LOGI(f"Detected nested firmware format: {fmt} in {file.name}") + extractor(str(file), str(extracted_archive_path)) + file.unlink() + except Exception as e: + LOGD(f"firmware_parsers failed on {file.name}: {e}") + + # Strip common vendor prefixes from filenames + # (e.g., Nokia NB0 "LFC-0-1060-00WW-boot.img" -> "boot.img") + _strip_vendor_prefix(extracted_archive_path) + # Check for nested archives extracted_archive_tempdir_files_list = list( get_recursive_files_list(extracted_archive_path, True) @@ -60,9 +157,58 @@ def extract_archive(archive_path: Path, extracted_archive_path: Path, is_nested: func(nested_archive, extracted_archive_path, True) + nested_archive_patterns = tuple(NESTED_ARCHIVES.keys()) + for file in extracted_archive_tempdir_files_list: + if any(pattern.match(str(file)) for pattern in nested_archive_patterns): + continue + + if not NESTED_ZIP_PATTERN.match(str(file)): + continue + + nested_archive = extracted_archive_path / file + LOGI(f"Found nested zip candidate: {nested_archive.name}") + + if not nested_archive.is_file(): + LOGD(f"Nested zip {nested_archive.name} probably already handled, skipping") + continue + + if not _has_nested_partition_markers(nested_archive): + LOGD(f"Skipping nested zip {nested_archive.name}: no partition markers") + continue + + extract_archive(nested_archive, extracted_archive_path, True) + LOGD(f"Extracted archive: {archive_path.name}") +NESTED_ZIP_PARTITION_MARKERS = ( + compile( + r"(?:^|/)" + r"(?:boot|boot-debug|boot-verified|cust|dtbo|dtbo-verified|exaid|factory|india|" + r"init_boot|mi_ext|modem|my_bigball|my_carrier|my_company|my_country|my_custom|" + r"my_engineering|my_heytap|my_manifest|my_odm|my_operator|my_preload|my_product|" + r"my_region|my_stock|my_version|NON-HLOS|odm|odm_dlkm|odm_ext|oem|oppo_product|" + r"opproduct|preas|preavs|preload|preload_common|product|product_h|recovery|rescue|" + r"reserve|special_preload|super|system|system_dlkm|system_ext|system_other|" + r"systemex|tz|vendor|vendor_boot|vendor_boot-debug|vendor_dlkm|" + r"vendor_kernel_boot|xrom)(?:_[ab])?\.new\.dat\.br$" + ), + compile( + r"(?:^|/)" + r"(?:boot|boot-debug|boot-verified|cust|dtbo|dtbo-verified|exaid|factory|india|" + r"init_boot|mi_ext|modem|my_bigball|my_carrier|my_company|my_country|my_custom|" + r"my_engineering|my_heytap|my_manifest|my_odm|my_operator|my_preload|my_product|" + r"my_region|my_stock|my_version|NON-HLOS|odm|odm_dlkm|odm_ext|oem|oppo_product|" + r"opproduct|preas|preavs|preload|preload_common|product|product_h|recovery|rescue|" + r"reserve|special_preload|super|system|system_dlkm|system_ext|system_other|" + r"systemex|tz|vendor|vendor_boot|vendor_boot-debug|vendor_dlkm|" + r"vendor_kernel_boot|xrom)(?:_[ab])?\.transfer\.list$" + ), + compile(r"(?:^|/)payload\.bin$"), + compile(r"(?:^|/)super(?!.*(_empty)).*\.img$"), + compile(r"(?:^|/)[^/]+\.tar\.md5$"), +) +NESTED_ZIP_PATTERN = compile(r".*\.zip$") NESTED_ARCHIVES: Dict[Pattern[str], Callable[[Path, Path, bool], None]] = { compile(key): value for key, value in { diff --git a/dumpyara/utils/multipartitions.py b/dumpyara/utils/multipartitions.py index d9b8aab..e96624f 100644 --- a/dumpyara/utils/multipartitions.py +++ b/dumpyara/utils/multipartitions.py @@ -13,6 +13,13 @@ from dumpyara.lib.libpayload import extract_android_ota_payload +try: + import firmware_parsers + + _HAS_FIRMWARE_PARSERS = True +except ImportError: + _HAS_FIRMWARE_PARSERS = False + def extract_payload(image: Path, output_dir: Path): extract_android_ota_payload(image, output_dir) @@ -22,9 +29,12 @@ def extract_super(image: Path, output_dir: Path): unsparsed_super = output_dir / "super.unsparsed.img" try: - check_output( - ["simg2img", image, unsparsed_super], stderr=STDOUT - ) # TODO: Rewrite libsparse... + if _HAS_FIRMWARE_PARSERS: + firmware_parsers.sparse_to_raw(str(image), str(unsparsed_super)) + else: + check_output( + ["simg2img", image, unsparsed_super], stderr=STDOUT + ) # TODO: Rewrite libsparse... except Exception: LOGI(f"Failed to unsparse {image.name}") else: diff --git a/dumpyara/utils/partitions.py b/dumpyara/utils/partitions.py index 17df35e..fbfd4dd 100644 --- a/dumpyara/utils/partitions.py +++ b/dumpyara/utils/partitions.py @@ -142,6 +142,7 @@ def fix_aliases(images_path: Path): if partition_path.exists(): LOGI(f"Ignoring {alt_name} ({name} already extracted)") alt_path.unlink() + continue LOGI(f"Fixing alias {alt_name} -> {name}") move(alt_path, partition_path) diff --git a/dumpyara/utils/raw_image.py b/dumpyara/utils/raw_image.py index b3b65ef..c7181e9 100644 --- a/dumpyara/utils/raw_image.py +++ b/dumpyara/utils/raw_image.py @@ -12,6 +12,13 @@ from shutil import copyfile, move from subprocess import STDOUT, check_output +try: + import firmware_parsers + + _HAS_FIRMWARE_PARSERS = True +except ImportError: + _HAS_FIRMWARE_PARSERS = False + def get_raw_image(partition: str, files_path: Path, output_image_path: Path): """ @@ -59,9 +66,12 @@ def get_raw_image(partition: str, files_path: Path, output_image_path: Path): continue try: - check_output( - ["simg2img", image_path, unsparsed_image], stderr=STDOUT - ) # TODO: Rewrite libsparse... + if _HAS_FIRMWARE_PARSERS: + firmware_parsers.sparse_to_raw(str(image_path), str(unsparsed_image)) + else: + check_output( + ["simg2img", image_path, unsparsed_image], stderr=STDOUT + ) # TODO: Rewrite libsparse... except Exception: LOGD(f"Failed to unsparse {image_path.name}, should be a raw image") pass diff --git a/dumpyara/utils/sparsed_images.py b/dumpyara/utils/sparsed_images.py index 9a8190c..6f2bce2 100644 --- a/dumpyara/utils/sparsed_images.py +++ b/dumpyara/utils/sparsed_images.py @@ -9,6 +9,13 @@ from dumpyara.utils.partitions import get_partition_names_with_alias +try: + import firmware_parsers + + _HAS_FIRMWARE_PARSERS = True +except ImportError: + _HAS_FIRMWARE_PARSERS = False + def prepare_sparsed_images(files_path: Path): """ @@ -31,6 +38,12 @@ def prepare_sparsed_images(files_path: Path): if sparsechunk_image_files: LOGI(f"Preparing sparsechunk images for {partition}") LOGI(f"Converting {sparsechunk_image_files[0]} to {output_image.name}") - check_output(["simg2img", *sparsechunk_image_files, output_image], stderr=STDOUT) + if _HAS_FIRMWARE_PARSERS: + firmware_parsers.sparse_chunks_to_raw( + [str(f) for f in sparsechunk_image_files], + str(output_image), + ) + else: + check_output(["simg2img", *sparsechunk_image_files, output_image], stderr=STDOUT) for sparsechunk_image_file in sparsechunk_image_files: sparsechunk_image_file.unlink() diff --git a/firmware-parsers/Cargo.lock b/firmware-parsers/Cargo.lock new file mode 100644 index 0000000..fb55f9b --- /dev/null +++ b/firmware-parsers/Cargo.lock @@ -0,0 +1,1303 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-sparse" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c76c5759b0af9dea95a769ff5fe140cec5afb5fa65019817f3731b3232afe44" +dependencies = [ + "byteorder", + "clap", + "crc 1.8.1", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "build_const" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ae4235e6dac0694637c763029ecea1a2ec9e4e06ec2729bd21ba4d9c863eb7" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "bitflags 1.3.2", + "textwrap", + "unicode-width", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d663548de7f5cca343f1e0a48d14dcfb0e9eb4e079ec58883b7251539fa10aeb" +dependencies = [ + "build_const", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "firmware-parsers" +version = "0.1.0" +dependencies = [ + "aes", + "android-sparse", + "anyhow", + "bzip2", + "cipher", + "encoding_rs", + "flate2", + "hex-literal", + "pyo3", + "quick-xml", + "regex", + "tar", + "tempfile", + "zip", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc 3.4.0", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/firmware-parsers/Cargo.toml b/firmware-parsers/Cargo.toml new file mode 100644 index 0000000..bb3413f --- /dev/null +++ b/firmware-parsers/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "firmware-parsers" +version = "0.1.0" +edition = "2021" + +[lib] +name = "firmware_parsers" +crate-type = ["cdylib", "rlib"] + +[dependencies] +pyo3 = { version = "0.22", features = ["extension-module"] } +anyhow = "1" + +# Crypto +aes = "0.8" +cipher = "0.4" +hex-literal = "0.4" + +# Compression +flate2 = "1" +tar = "0.4" +zip = "2" +bzip2 = "0.5" + +# XML (QFIL) +quick-xml = "0.37" + +# String handling +encoding_rs = "0.8" +regex = "1" + +# Sparse images +android-sparse = "0.6" + +[dev-dependencies] +tempfile = "3" diff --git a/firmware-parsers/pyproject.toml b/firmware-parsers/pyproject.toml new file mode 100644 index 0000000..9f99696 --- /dev/null +++ b/firmware-parsers/pyproject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["maturin>=1.7,<2"] +build-backend = "maturin" + +[project] +name = "firmware-parsers" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/firmware-parsers/src/amlogic.rs b/firmware-parsers/src/amlogic.rs new file mode 100644 index 0000000..36e2c82 --- /dev/null +++ b/firmware-parsers/src/amlogic.rs @@ -0,0 +1,202 @@ +use anyhow::{bail, Context, Result}; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::sparse; + +const AML_MAGIC: u32 = 0x27051956; +const HEADER_SIZE: usize = 0x40; +const ITEM_SIZE: usize = 0x240; + +/// Check if a file looks like an Amlogic USB Burning Tool image. +pub fn probe(data: &[u8]) -> bool { + data.len() >= 4 && u32::from_be_bytes(data[0..4].try_into().unwrap()) == AML_MAGIC +} + +struct AmlItem { + offset: u64, + file_size: u64, + extension: String, + name: String, +} + +fn read_null_terminated(buf: &[u8]) -> String { + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..end]).to_string() +} + +fn parse_item(buf: &[u8]) -> Result { + if buf.len() < ITEM_SIZE { + bail!("Amlogic item buffer too small"); + } + // offset at 0x10 (big-endian u32), file_size at 0x18 (big-endian u32) + let offset = u32::from_be_bytes(buf[0x10..0x14].try_into()?) as u64; + let file_size = u32::from_be_bytes(buf[0x18..0x1C].try_into()?) as u64; + let extension = read_null_terminated(&buf[0x20..0x40]); + let name = read_null_terminated(&buf[0x120..0x140]); + + Ok(AmlItem { + offset, + file_size, + extension, + name, + }) +} + +/// Apply Amlogic-specific output name renaming rules. +fn rename_output(name: &str, ext: &str) -> String { + let raw_name = format!("{name}.{ext}"); + + // *.PARTITION → *.img + if ext.eq_ignore_ascii_case("PARTITION") { + let img_name = format!("{name}.img"); + // Strip _a suffix (A/B slot) + if let Some(base) = img_name.strip_suffix("_a.img") { + return format!("{base}.img"); + } + return img_name; + } + + // *_aml_dtb.img → dtb.img + if raw_name.ends_with("_aml_dtb.img") || raw_name.ends_with("_aml_dtb.PARTITION") { + return "dtb.img".to_string(); + } + + // Strip _a suffix + if let Some(base) = raw_name.strip_suffix("_a.img") { + return format!("{base}.img"); + } + + raw_name +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + // Check if it's a tar.bz2 wrapper — if so, decompress first + let data_path = maybe_unwrap_tar_bz2(input, output_dir)?; + let actual_input = data_path.as_deref().unwrap_or(input); + + let mut f = File::open(actual_input).context("failed to open Amlogic image")?; + + // Read and validate header + let mut header = [0u8; HEADER_SIZE]; + f.read_exact(&mut header)?; + + let magic = u32::from_be_bytes(header[0..4].try_into()?); + if magic != AML_MAGIC { + bail!("not an Amlogic image: bad magic 0x{magic:08X}"); + } + + let item_count = u32::from_be_bytes(header[0x14..0x18].try_into()?) as usize; + if item_count == 0 || item_count > 1024 { + bail!("invalid Amlogic item count: {item_count}"); + } + + // Read item table + let mut items = Vec::with_capacity(item_count); + let mut item_buf = vec![0u8; ITEM_SIZE]; + for _ in 0..item_count { + f.read_exact(&mut item_buf)?; + items.push(parse_item(&item_buf)?); + } + + let mut extracted = Vec::new(); + + for item in &items { + if item.file_size == 0 || item.name.is_empty() { + continue; + } + + let raw_name = rename_output(&item.name, &item.extension); + // Sanitize: strip path components to prevent directory traversal + let out_name = Path::new(&raw_name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| format!("partition_{}.img", extracted.len())); + let out_path = output_dir.join(&out_name); + + f.seek(SeekFrom::Start(item.offset))?; + + // Stream copy + let mut out_file = File::create(&out_path)?; + let mut remaining = item.file_size; + let mut buf = vec![0u8; 8 * 1024 * 1024]; + while remaining > 0 { + let to_read = remaining.min(buf.len() as u64) as usize; + f.read_exact(&mut buf[..to_read])?; + out_file.write_all(&buf[..to_read])?; + remaining -= to_read as u64; + } + drop(out_file); + + let _ = sparse::maybe_unsparse(&out_path); + extracted.push(out_path); + } + + // Clean up temp decompressed file if we had a tar.bz2 wrapper + if let Some(tmp) = data_path { + let _ = std::fs::remove_file(&tmp); + } + + Ok(extracted) +} + +/// If the input is a bzip2-compressed tar, decompress it and return the path +/// to the inner .img. Detection is content-based (bzip2 magic "BZh") to match +/// how detect.rs identifies tar.bz2-wrapped Amlogic images. +fn maybe_unwrap_tar_bz2(input: &Path, output_dir: &Path) -> Result> { + // Check content for bzip2 magic rather than relying on filename extension, + // since detect.rs identifies these by content. + let mut f = File::open(input)?; + let mut magic = [0u8; 3]; + if f.read_exact(&mut magic).is_err() || &magic != b"BZh" { + return Ok(None); + } + drop(f); + + let f = File::open(input)?; + let decoder = bzip2::read::BzDecoder::new(f); + let mut archive = tar::Archive::new(decoder); + + for entry in archive.entries()? { + let mut entry = entry?; + let entry_path = entry.path()?.into_owned(); + let entry_name = entry_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if entry_name.ends_with(".img") { + // Verify Amlogic magic before accepting — archives may contain + // non-Amlogic .img files (readmes, configs) before the real payload. + let mut magic = [0u8; 4]; + if entry.read_exact(&mut magic).is_err() { + continue; + } + if u32::from_be_bytes(magic) != AML_MAGIC { + continue; + } + // Rewind isn't possible on tar entries, so write magic + rest + let tmp_path = output_dir.join(format!("_aml_tmp_{entry_name}")); + let mut out = File::create(&tmp_path)?; + out.write_all(&magic)?; + std::io::copy(&mut entry, &mut out)?; + return Ok(Some(tmp_path)); + } + } + + Ok(None) +} + +#[pyfunction] +#[pyo3(name = "amlogic")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect()) +} diff --git a/firmware-parsers/src/detect.rs b/firmware-parsers/src/detect.rs new file mode 100644 index 0000000..ee9a144 --- /dev/null +++ b/firmware-parsers/src/detect.rs @@ -0,0 +1,111 @@ +use pyo3::prelude::*; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use crate::{amlogic, kddi, mtk_sign, nb0, ozip, pac, qfil, rockchip, sin, zte}; + +/// Peek inside a bzip2-compressed tar for Amlogic magic in any entry. +/// Scans all tar members (not just the first) since packages may contain +/// readme/manifest files before the actual .img payload. +fn tar_bz2_contains_amlogic(path: &Path) -> bool { + let f = match File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + let decoder = bzip2::read::BzDecoder::new(f); + let mut archive = tar::Archive::new(decoder); + let mut entries = match archive.entries() { + Ok(e) => e, + Err(_) => return false, + }; + while let Some(Ok(mut entry)) = entries.next() { + if entry.size() < 4 { + continue; + } + let mut magic = [0u8; 4]; + if entry.read_exact(&mut magic).is_ok() + && u32::from_be_bytes(magic) == 0x27051956 + { + return true; + } + } + false +} + +/// Read the first 64 bytes of a file and identify the format. +/// Returns the format name as a string, or "unknown". +pub fn probe_magic(path: &Path) -> &'static str { + let Ok(mut f) = File::open(path) else { + return "unknown"; + }; + let file_size = f.metadata().map(|m| m.len()).unwrap_or(0); + let mut buf = [0u8; 64]; + let _ = f.read(&mut buf); + + // Magic-based probes (order matters for disambiguation) + if ozip::probe(&buf) { + return "ozip"; + } + if pac::probe(&buf) { + return "pac"; + } + if rockchip::probe(&buf) { + return "rockchip"; + } + if amlogic::probe(&buf) { + return "amlogic"; + } + if sin::probe(&buf) { + return "sin"; + } + // Note: sparse images are NOT returned as a top-level format here. + // They are handled per-file by dumpyara's raw_image.py/sparsed_images.py + // which call sparse_to_raw() directly. + + if nb0::probe(&buf, file_size) { + return "nb0"; + } + + // Bzip2-wrapped tar — peek inside the first entry for known magics + if buf.len() >= 3 && &buf[0..3] == b"BZh" && tar_bz2_contains_amlogic(path) { + return "amlogic"; + } + + // Zip-based probes — if file starts with PK magic, open the archive + // and inspect entry names to identify the format. + // Priority: ozip(mode2) → sin(ftf) → qfil → mtk_sign → zte → kddi + if buf[0..2] == *b"PK" { + if let Ok(zip_file) = File::open(path) { + if let Ok(archive) = zip::ZipArchive::new(zip_file) { + if ozip::probe_zip(&archive) { + return "ozip"; + } + if sin::probe_zip(&archive) { + return "sin"; + } + if qfil::probe_zip(&archive) { + return "qfil"; + } + if mtk_sign::probe_zip(&archive) { + return "mtk_sign"; + } + if zte::probe_zip(&archive).is_some() { + return "zte"; + } + if kddi::probe_zip(&archive) { + return "kddi"; + } + } + } + } + + "unknown" +} + +/// Python-exposed: detect the firmware format of a file. +#[pyfunction] +#[pyo3(name = "detect")] +pub fn py_detect(path: &str) -> PyResult { + Ok(probe_magic(Path::new(path)).to_string()) +} diff --git a/firmware-parsers/src/kddi.rs b/firmware-parsers/src/kddi.rs new file mode 100644 index 0000000..10de9d6 --- /dev/null +++ b/firmware-parsers/src/kddi.rs @@ -0,0 +1,97 @@ +use anyhow::{Context, Result}; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::sparse; +use crate::zte; + +fn p_suffix_regex() -> &'static regex::Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| regex::Regex::new(r"(?i)-p\d+$").unwrap()) +} + +/// Check if a zip archive is a KDDI package. +/// Distinguished from ZTE by absence of rawprogram XML and p-suffix entries. +pub fn probe_zip(archive: &zip::ZipArchive) -> bool { + let mut has_bins = false; + let mut has_rawprogram = false; + let mut has_p_suffix = false; + + let re = p_suffix_regex(); + + for i in 0..archive.len() { + if let Some(name) = archive.name_for_index(i) { + let lower = name.to_lowercase(); + let base = Path::new(name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if lower.contains("rawprogram") && lower.ends_with(".xml") { + has_rawprogram = true; + } + if base.ends_with(".bin") && zte::is_partition_name(&base) { + has_bins = true; + } + if re.is_match(&base) { + has_p_suffix = true; + } + } + } + + has_bins && !has_rawprogram && !has_p_suffix +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + let zip_file = File::open(input).context("failed to open KDDI zip")?; + let mut archive = zip::ZipArchive::new(zip_file).context("failed to read zip archive")?; + + let mut extracted = Vec::new(); + + // Collect .bin entries + let bin_entries: Vec<(usize, String)> = (0..archive.len()) + .filter_map(|i| { + let name = archive.name_for_index(i)?.to_string(); + let base = Path::new(&name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if base.ends_with(".bin") && zte::is_partition_name(&base) { + Some((i, base)) + } else { + None + } + }) + .collect(); + + for (idx, base_name) in bin_entries { + let mut entry = archive.by_index(idx)?; + + // Rename .bin → .img + let out_name = base_name.replace(".bin", ".img"); + let out_path = output_dir.join(&out_name); + + let mut out_file = File::create(&out_path)?; + std::io::copy(&mut entry, &mut out_file)?; + drop(out_file); + + let _ = sparse::maybe_unsparse(&out_path); + extracted.push(out_path); + } + + Ok(extracted) +} + +#[pyfunction] +#[pyo3(name = "kddi")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect()) +} diff --git a/firmware-parsers/src/lib.rs b/firmware-parsers/src/lib.rs new file mode 100644 index 0000000..f6d271a --- /dev/null +++ b/firmware-parsers/src/lib.rs @@ -0,0 +1,33 @@ +use pyo3::prelude::*; + +pub mod amlogic; +pub mod detect; +pub mod kddi; +pub mod mtk_sign; +pub mod nb0; +pub mod ozip; +pub mod pac; +pub mod qfil; +pub mod rockchip; +pub mod sin; +pub mod sparse; +pub mod zte; + +#[pymodule] +fn firmware_parsers(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(detect::py_detect, m)?)?; + m.add_function(wrap_pyfunction!(sparse::py_sparse_to_raw, m)?)?; + m.add_function(wrap_pyfunction!(sparse::py_sparse_chunks_to_raw, m)?)?; + m.add_function(wrap_pyfunction!(sparse::py_is_sparse, m)?)?; + m.add_function(wrap_pyfunction!(nb0::py_extract, m)?)?; + m.add_function(wrap_pyfunction!(pac::py_extract, m)?)?; + m.add_function(wrap_pyfunction!(mtk_sign::py_extract, m)?)?; + m.add_function(wrap_pyfunction!(ozip::py_extract, m)?)?; + m.add_function(wrap_pyfunction!(sin::py_extract, m)?)?; + m.add_function(wrap_pyfunction!(amlogic::py_extract, m)?)?; + m.add_function(wrap_pyfunction!(rockchip::py_extract, m)?)?; + m.add_function(wrap_pyfunction!(qfil::py_extract, m)?)?; + m.add_function(wrap_pyfunction!(zte::py_extract, m)?)?; + m.add_function(wrap_pyfunction!(kddi::py_extract, m)?)?; + Ok(()) +} diff --git a/firmware-parsers/src/mtk_sign.rs b/firmware-parsers/src/mtk_sign.rs new file mode 100644 index 0000000..45ffefd --- /dev/null +++ b/firmware-parsers/src/mtk_sign.rs @@ -0,0 +1,122 @@ +use anyhow::{bail, Context, Result}; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use crate::sparse; + +/// Determine the payload offset and size from a *-sign.img header. +/// Returns (offset, length) of the payload within the data. +fn strip_header(data: &[u8]) -> Result<(usize, usize)> { + if data.len() >= 64 && &data[0..4] == b"SSSS" { + // SSSS magic: payload size encoded in bytes 60..64 + let lo = u16::from_le_bytes(data[60..62].try_into()?) as usize; + let hi = u16::from_le_bytes(data[62..64].try_into()?) as usize; + let payload_size = hi * 65536 + lo; + let offset = 64; + if offset + payload_size > data.len() { + bail!( + "SSSS payload extends beyond file: offset={offset}, size={payload_size}, file={}", + data.len() + ); + } + Ok((offset, payload_size)) + } else if data.len() > 0x4040 + && (data[0] == 0xBF && data[1] == 0xBF + || data[0] == 0x88 && data[1] == 0x16 // known MTK header variant + || data[0..4] == [0x00, 0x00, 0x00, 0x00]) // null-padded MTK header + { + // BFBF or known MTK header variants: skip first 0x4040 bytes + let offset = 0x4040; + let payload_size = data.len() - offset; + Ok((offset, payload_size)) + } else { + bail!( + "file too small for MTK signed image ({} bytes, need > 0x4040)", + data.len() + ); + } +} + +/// Rename "*-sign.img" to "*.img" by stripping the "-sign" suffix. +fn strip_sign_suffix(name: &str) -> String { + if let Some(base) = name.strip_suffix("-sign.img") { + format!("{base}.img") + } else if let Some(base) = name.strip_suffix("-sign.IMG") { + format!("{base}.img") + } else { + name.to_string() + } +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + let zip_file = File::open(input).context("failed to open MTK sign zip")?; + let mut archive = zip::ZipArchive::new(zip_file).context("failed to read zip archive")?; + + let mut extracted = Vec::new(); + + // Collect names first to avoid borrow issues + let sign_entries: Vec<(usize, String)> = (0..archive.len()) + .filter_map(|i| { + let name = archive.by_index(i).ok()?.name().to_string(); + let lower = name.to_lowercase(); + if lower.ends_with("-sign.img") { + Some((i, name)) + } else { + None + } + }) + .collect(); + + for (idx, original_name) in sign_entries { + let mut entry = archive.by_index(idx)?; + + // Read entire entry into memory (needed for header parsing) + let mut data = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut data)?; + + // Strip the signed header + let (offset, len) = strip_header(&data)?; + let payload = &data[offset..offset + len]; + + // Strip path components, keep just the filename + let base_name = Path::new(&original_name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or(original_name.clone()); + let out_name = strip_sign_suffix(&base_name); + let out_path = output_dir.join(&out_name); + + // Write payload + let mut out_file = File::create(&out_path)?; + out_file.write_all(payload)?; + drop(out_file); + + // Convert sparse images if needed + let _ = sparse::maybe_unsparse(&out_path); + + extracted.push(out_path); + } + + Ok(extracted) +} + +/// Check if a zip archive contains *-sign.img entries (for detection). +pub fn probe_zip(archive: &zip::ZipArchive) -> bool { + (0..archive.len()).any(|i| { + archive + .name_for_index(i) + .map(|n| n.to_lowercase().ends_with("-sign.img")) + .unwrap_or(false) + }) +} + +#[pyfunction] +#[pyo3(name = "mtk_sign")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results.into_iter().map(|p| p.to_string_lossy().into_owned()).collect()) +} diff --git a/firmware-parsers/src/nb0.rs b/firmware-parsers/src/nb0.rs new file mode 100644 index 0000000..4d38edb --- /dev/null +++ b/firmware-parsers/src/nb0.rs @@ -0,0 +1,140 @@ +use anyhow::{bail, Context, Result}; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::sparse; + +const ENTRY_SIZE: usize = 64; + +/// Check if a file looks like an NB0 container. +/// Reads file_count from first 4 bytes; sanity checks that count is +/// reasonable, the file is large enough, and entry filenames are printable. +/// `file_size` is the total size of the file on disk. +pub fn probe(data: &[u8], file_size: u64) -> bool { + if data.len() < 4 { + return false; + } + let count = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize; + // Sanity: count must be 1..255 and the file must be large enough for the header + if count == 0 || count >= 256 || file_size < (4 + count * ENTRY_SIZE) as u64 { + return false; + } + + // Secondary validation: check that at least the first entry has a + // printable ASCII filename (NB0 has no magic bytes, so this reduces + // false positives on arbitrary binary files). + let min_required = 4 + ENTRY_SIZE; + if data.len() >= min_required { + let name_bytes = &data[4 + 16..4 + ENTRY_SIZE]; // filename field of first entry + let name_end = name_bytes.iter().position(|&b| b == 0).unwrap_or(name_bytes.len()); + if name_end == 0 { + return false; // empty filename + } + let all_printable = name_bytes[..name_end] + .iter() + .all(|&b| b >= 0x20 && b <= 0x7E); + if !all_printable { + return false; + } + } + + true +} + +struct Nb0Entry { + data_offset: u32, + data_size: u32, + filename: String, +} + +fn parse_entry(buf: &[u8; ENTRY_SIZE]) -> Result { + let data_offset = u32::from_le_bytes(buf[0..4].try_into()?); + let data_size = u32::from_le_bytes(buf[4..8].try_into()?); + // bytes 8..16 are unknown fields + let name_bytes = &buf[16..64]; + let name_end = name_bytes.iter().position(|&b| b == 0).unwrap_or(name_bytes.len()); + let filename = std::str::from_utf8(&name_bytes[..name_end]) + .context("invalid UTF-8 in NB0 entry filename")? + .to_string(); + Ok(Nb0Entry { + data_offset, + data_size, + filename, + }) +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + let mut f = File::open(input).context("failed to open NB0 file")?; + + // Read file count + let mut header = [0u8; 4]; + f.read_exact(&mut header)?; + let count = u32::from_le_bytes(header) as usize; + + if count == 0 || count >= 256 { + bail!("invalid NB0 file count: {count}"); + } + + // Read partition table + let mut entries = Vec::with_capacity(count); + let mut entry_buf = [0u8; ENTRY_SIZE]; + for _ in 0..count { + f.read_exact(&mut entry_buf)?; + entries.push(parse_entry(&entry_buf)?); + } + + let data_start = (4 + count * ENTRY_SIZE) as u64; + let mut extracted = Vec::new(); + + for entry in &entries { + if entry.data_size == 0 || entry.filename.is_empty() { + continue; + } + + let abs_offset = data_start + entry.data_offset as u64; + f.seek(SeekFrom::Start(abs_offset))?; + + // Determine output filename — ensure it ends with .img + // Sanitize: strip path components to prevent directory traversal + let safe_name = Path::new(&entry.filename) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| format!("partition_{}", extracted.len())); + let out_name = if safe_name.contains('.') { + safe_name + } else { + format!("{safe_name}.img") + }; + let out_path = output_dir.join(&out_name); + + // Stream copy in chunks + let mut out_file = File::create(&out_path)?; + let mut remaining = entry.data_size as u64; + let mut buf = vec![0u8; 8 * 1024 * 1024]; // 8MB buffer + while remaining > 0 { + let to_read = remaining.min(buf.len() as u64) as usize; + f.read_exact(&mut buf[..to_read])?; + out_file.write_all(&mut buf[..to_read])?; + remaining -= to_read as u64; + } + drop(out_file); + + // Convert sparse images if needed + let _ = sparse::maybe_unsparse(&out_path); + + extracted.push(out_path); + } + + Ok(extracted) +} + +#[pyfunction] +#[pyo3(name = "nb0")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results.into_iter().map(|p| p.to_string_lossy().into_owned()).collect()) +} diff --git a/firmware-parsers/src/ozip.rs b/firmware-parsers/src/ozip.rs new file mode 100644 index 0000000..f328e4a --- /dev/null +++ b/firmware-parsers/src/ozip.rs @@ -0,0 +1,292 @@ +use aes::cipher::{BlockDecrypt, KeyInit}; +use aes::Aes128; +use anyhow::{bail, Context, Result}; +use cipher::generic_array::GenericArray; +use hex_literal::hex; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use std::fs::File; +use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +const OZIP_MAGIC: &[u8; 12] = b"OPPOENCRYPT!"; +const HEADER_SIZE: u64 = 0x1050; +const BLOCK_ENC: usize = 16; +const BLOCK_PLAIN: usize = 0x4000; + +/// Known AES-128-ECB keys from ozipdecrypt.py (open-source). +const KNOWN_KEYS: &[[u8; 16]] = &[ + hex!("D6EECF0AE5ACD4E0E9FE522DE7CE381E"), + hex!("D6ECCF0AE5ACD4E0E92E522DE7C1381E"), + hex!("D6DCCF0AD5ACD4E0292E522DB7C1381E"), + hex!("D7DCCE1AD4AFDCE2393E5161CBDC4321"), + hex!("D7DBCE2AD4ADDCE1393E5521CBDC4321"), + hex!("D7DBCE1AD4AFDCE1393E5121CBDC4321"), + hex!("D4D2CD61D4AFDCE13B5E01221BD14D20"), + hex!("261CC7131D7C1481294E532DB752381E"), + hex!("1CA21E12271335AE33AB81B2A7B14622"), + hex!("D4D2CE11D4AFDCE13B3E0121CBD14D20"), + hex!("1C4C1EA3A12531AE491B21BB31613C11"), + hex!("1C4C1EA3A12531AE4A1B21BB31C13C21"), + hex!("1C4A11A3A12513AE441B23BB31513121"), + hex!("1C4A11A3A12589AE441A23BB31517733"), + hex!("1C4A11A3A22513AE541B53BB31513121"), + hex!("2442CE821A4F352E33AE81B22BC1462E"), + hex!("14C2CD6214CFDC2733AE81B22BC1462C"), + hex!("1E38C1B72D522E29E0D4ACD50ACFDCD6"), + hex!("12341EAAC4C123CE193556A1BBCC232D"), + hex!("2143DCCB21513E39E1DCAFD41ACEDBD7"), + hex!("2D23CCBBA1563519CE23C1C4AA1E3412"), + hex!("172B3E14E46F3CE13E2B5121CBDC4321"), + hex!("ACAA1E12A71431CE4A1B21BBA1C1C6A2"), + hex!("ACAC1E13A72531AE4A1B22BB31C1CC22"), + hex!("1C4411A3A12533AE441B21BB31613C11"), + hex!("1C4416A8A42717AE441523B336513121"), + hex!("55EEAA33112133AE441B23BB31513121"), + hex!("ACAC1E13A12531AE4A1B21BB31C13C21"), + hex!("ACAC1E13A72431AE4A1B22BBA1C1C6A2"), + hex!("12CAC11211AAC3AEA2658690122C1E81"), + hex!("1CA21E12271435AE331B81BBA7C14612"), + hex!("D1DACF24351CE428A9CE32ED87323216"), + hex!("A1CC75115CAECB890E4A563CA1AC67C8"), + hex!("2132321EA2CA86621A11241ABA512722"), + hex!("22A21E821743E5EE33AE81B227B1462E"), +]; + +/// Check if data starts with OPPOENCRYPT! magic. +pub fn probe(data: &[u8]) -> bool { + data.len() >= 12 && &data[0..12] == OZIP_MAGIC +} + +/// Try each known key and check if decrypted first block looks valid. +fn find_key(first_enc_block: &[u8; 16]) -> Option<[u8; 16]> { + for &key in KNOWN_KEYS { + let cipher = Aes128::new(GenericArray::from_slice(&key)); + let mut block = GenericArray::clone_from_slice(first_enc_block); + cipher.decrypt_block(&mut block); + // Check for zip PK magic, ANDROID!, or AVB0 + if block.starts_with(b"PK\x03\x04") + || block.starts_with(b"ANDR") + || block.starts_with(b"AVB0") + { + return Some(key); + } + } + None +} + +/// Decrypt a mode-1 ozip (direct encrypted payload) to a file. +fn decrypt_mode1(input: &Path, output: &Path) -> Result<()> { + let mut reader = BufReader::new(File::open(input)?); + + // Skip header + reader.seek(SeekFrom::Start(HEADER_SIZE))?; + + // Read first encrypted block to find the key + let mut first_block = [0u8; BLOCK_ENC]; + reader.read_exact(&mut first_block)?; + reader.seek(SeekFrom::Start(HEADER_SIZE))?; + + let key = find_key(&first_block).context("no matching OPPO decryption key found")?; + let cipher = Aes128::new(GenericArray::from_slice(&key)); + + let file_size = std::fs::metadata(input)?.len(); + let payload_size = file_size - HEADER_SIZE; + + let mut writer = BufWriter::new(File::create(output)?); + let mut remaining = payload_size; + + while remaining > 0 { + // Encrypted block (16 bytes) + if remaining >= BLOCK_ENC as u64 { + let mut enc_buf = [0u8; BLOCK_ENC]; + reader.read_exact(&mut enc_buf)?; + let mut block = GenericArray::clone_from_slice(&enc_buf); + cipher.decrypt_block(&mut block); + writer.write_all(&block)?; + remaining -= BLOCK_ENC as u64; + } else { + // Remaining bytes less than a block — copy as-is + let mut tail = vec![0u8; remaining as usize]; + reader.read_exact(&mut tail)?; + writer.write_all(&tail)?; + break; + } + + // Plaintext block (0x4000 bytes) + if remaining > 0 { + let plain_len = remaining.min(BLOCK_PLAIN as u64) as usize; + let mut plain_buf = vec![0u8; plain_len]; + reader.read_exact(&mut plain_buf)?; + writer.write_all(&plain_buf)?; + remaining -= plain_len as u64; + } + } + + Ok(()) +} + +/// Strip .ozip extension and return a useful output name. +/// "boot.ozip" → "boot", "system.new.dat.br.ozip" → "system.new.dat.br" +fn strip_ozip_ext(name: &str) -> &str { + name.strip_suffix(".ozip") + .or_else(|| name.strip_suffix(".OZIP")) + .unwrap_or(name) +} + +/// Extract a mode-1 OPPOENCRYPT! file: decrypt, then unpack inner zip if applicable. +fn extract_mode1(input: &Path, output_dir: &Path) -> Result> { + let stem = input + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "output".to_string()); + + // Decrypt to a temporary file + let decrypted_path = output_dir.join(format!("_ozip_tmp_{stem}.zip")); + decrypt_mode1(input, &decrypted_path)?; + + // Check what we got + let mut decrypted = File::open(&decrypted_path)?; + let mut zip_magic = [0u8; 4]; + let _ = decrypted.read(&mut zip_magic); + drop(decrypted); + + let mut extracted = Vec::new(); + + if &zip_magic[0..2] == b"PK" { + // Decrypted to a zip — extract and decrypt any inner .ozip members + extracted = extract_zip_contents(&decrypted_path, output_dir)?; + let _ = std::fs::remove_file(&decrypted_path); + } else { + // Raw decrypted payload — preserve the stem name as-is. + // stem is the input name minus .ozip (e.g. "system.new.dat.br"), + // so we must NOT append .img or we'll break downstream matching. + let final_path = output_dir.join(&stem); + std::fs::rename(&decrypted_path, &final_path)?; + extracted.push(final_path); + } + + Ok(extracted) +} + +/// Extract a mode-2 zip-wrapped ozip: the outer file is a standard zip +/// containing .ozip entries that each carry the OPPOENCRYPT! magic. +fn extract_mode2(input: &Path, output_dir: &Path) -> Result> { + extract_zip_contents(input, output_dir) +} + +/// Common helper: given a zip file, extract all entries. For any entry +/// whose content starts with OPPOENCRYPT!, decrypt it in-place. +/// Strips the .ozip extension from decrypted member names. +fn extract_zip_contents(zip_path: &Path, output_dir: &Path) -> Result> { + let zip_file = File::open(zip_path)?; + let mut archive = zip::ZipArchive::new(zip_file)?; + + // Use a temp directory for intermediate files so cleanup is automatic + let work_dir = output_dir.join("_ozip_work"); + std::fs::create_dir_all(&work_dir)?; + + let result = extract_zip_contents_inner(&mut archive, &work_dir, output_dir); + + // Always clean up the work directory + let _ = std::fs::remove_dir_all(&work_dir); + + result +} + +fn extract_zip_contents_inner( + archive: &mut zip::ZipArchive, + work_dir: &Path, + output_dir: &Path, +) -> Result> { + let mut extracted = Vec::new(); + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + if entry.is_dir() { + continue; + } + + let entry_name = entry.name().to_string(); + let base_name = Path::new(&entry_name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or(entry_name.clone()); + + // Write entry to work dir (need to inspect magic) + let tmp_path = work_dir.join(&base_name); + { + let mut out_file = File::create(&tmp_path)?; + std::io::copy(&mut entry, &mut out_file)?; + } + + // Check if it's an encrypted ozip + let mut inner_magic = [0u8; 12]; + let is_ozip = if let Ok(mut f) = File::open(&tmp_path) { + f.read_exact(&mut inner_magic).is_ok() && &inner_magic == OZIP_MAGIC + } else { + false + }; + + if is_ozip { + // Decrypt it — use name without .ozip extension + let final_name = strip_ozip_ext(&base_name); + let final_path = output_dir.join(final_name); + let decrypted_tmp = work_dir.join(format!("_dec_{base_name}")); + + // Propagate decryption failure — do not silently rename ciphertext + // to the output name, as downstream would try to parse encrypted bytes. + decrypt_mode1(&tmp_path, &decrypted_tmp) + .with_context(|| format!("failed to decrypt inner ozip member: {base_name}"))?; + std::fs::rename(&decrypted_tmp, &final_path)?; + extracted.push(final_path); + } else { + // Not encrypted — move to final name + let final_path = output_dir.join(&base_name); + std::fs::rename(&tmp_path, &final_path)?; + extracted.push(final_path); + } + } + + Ok(extracted) +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + let mut f = File::open(input)?; + let mut magic = [0u8; 12]; + f.read_exact(&mut magic)?; + drop(f); + + // Mode 2: outer file is a standard zip containing .ozip entries + if &magic[0..2] == b"PK" { + return extract_mode2(input, output_dir); + } + + // Mode 1: direct OPPOENCRYPT! payload + if &magic == OZIP_MAGIC { + return extract_mode1(input, output_dir); + } + + bail!("not an OPPO ozip file (magic: {:?})", &magic[0..4]); +} + +/// Check if a zip archive contains .ozip entries (mode 2: zip-wrapped ozip). +pub fn probe_zip(archive: &zip::ZipArchive) -> bool { + (0..archive.len()).any(|i| { + archive + .name_for_index(i) + .map(|n| n.to_lowercase().ends_with(".ozip")) + .unwrap_or(false) + }) +} + +#[pyfunction] +#[pyo3(name = "ozip")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect()) +} diff --git a/firmware-parsers/src/pac.rs b/firmware-parsers/src/pac.rs new file mode 100644 index 0000000..331ea3e --- /dev/null +++ b/firmware-parsers/src/pac.rs @@ -0,0 +1,157 @@ +use anyhow::{bail, Context, Result}; +use encoding_rs::UTF_16LE; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::sparse; + +/// PAC v2 version string "BP_R" encoded as UTF-16LE at offset 0. +const PAC_V2_MAGIC: &[u8; 8] = b"B\x00P\x00_\x00R\x00"; + +// PAC v2 header layout (2124 = 0x84C bytes): +// 0x000: szVersion (44 bytes, UTF-16LE) +// 0x02C: dwHiSize (4 bytes) +// 0x030: dwLoSize (4 bytes) +// 0x034: productName (512 bytes, UTF-16LE) +// 0x234: firmwareName (512 bytes, UTF-16LE) +// 0x434: partitionCount (4 bytes) +// 0x438: partitionsListStart (4 bytes) +// ... rest is flags/CRC +const PAC_HEADER_SIZE: usize = 0x84C; +const PARTITION_COUNT_OFFSET: usize = 0x434; +const PARTITIONS_LIST_START_OFFSET: usize = 0x438; + +// File entry layout (2580 = 0xA14 bytes): +// 0x000: length (4 bytes, should be 0xA14) +// 0x004: partitionName (512 bytes, UTF-16LE) +// 0x204: fileName (512 bytes, UTF-16LE) +// 0x404: szFileName (504 bytes, UTF-16LE, reserved) +// 0x5FC: hiPartitionSize (4 bytes) +// 0x600: hiDataOffset (4 bytes) +// 0x604: loPartitionSize (4 bytes) +// 0x608: nFileFlag (4 bytes) +// 0x60C: nCheckFlag (4 bytes) +// 0x610: loDataOffset (4 bytes) +// ... rest is padding +const ENTRY_SIZE: usize = 0xA14; + +/// Check if a file looks like a Unisoc .pac container. +/// PAC v2 files start with the UTF-16LE string "BP_R" (version prefix). +pub fn probe(data: &[u8]) -> bool { + data.len() >= 8 && &data[0..8] == PAC_V2_MAGIC +} + +/// Decode a UTF-16LE null-terminated string from a fixed-size buffer. +fn decode_utf16le(buf: &[u8]) -> String { + let (decoded, _, _) = UTF_16LE.decode(buf); + decoded.trim_end_matches('\0').to_string() +} + +struct PacEntry { + partition_name: String, + filename: String, + data_offset: u64, + partition_size: u64, + file_flag: u32, +} + +fn parse_entry(buf: &[u8]) -> Result { + if buf.len() < ENTRY_SIZE { + bail!("PAC entry buffer too small: {} < {}", buf.len(), ENTRY_SIZE); + } + let partition_name = decode_utf16le(&buf[0x004..0x204]); + let filename = decode_utf16le(&buf[0x204..0x404]); + let hi_partition_size = u32::from_le_bytes(buf[0x5FC..0x600].try_into()?) as u64; + let hi_data_offset = u32::from_le_bytes(buf[0x600..0x604].try_into()?) as u64; + let lo_partition_size = u32::from_le_bytes(buf[0x604..0x608].try_into()?) as u64; + let file_flag = u32::from_le_bytes(buf[0x608..0x60C].try_into()?); + let lo_data_offset = u32::from_le_bytes(buf[0x610..0x614].try_into()?) as u64; + + Ok(PacEntry { + partition_name, + filename, + data_offset: (hi_data_offset << 32) | lo_data_offset, + partition_size: (hi_partition_size << 32) | lo_partition_size, + file_flag, + }) +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + let mut f = File::open(input).context("failed to open PAC file")?; + + // Read header + let mut header = vec![0u8; PAC_HEADER_SIZE]; + f.read_exact(&mut header)?; + + if &header[0..8] != PAC_V2_MAGIC { + bail!("not a PAC v2 file: bad version string"); + } + + let partition_count = u32::from_le_bytes( + header[PARTITION_COUNT_OFFSET..PARTITION_COUNT_OFFSET + 4].try_into()?, + ) as usize; + let partitions_list_start = u32::from_le_bytes( + header[PARTITIONS_LIST_START_OFFSET..PARTITIONS_LIST_START_OFFSET + 4].try_into()?, + ) as u64; + + if partition_count == 0 || partition_count > 1024 { + bail!("invalid PAC partition count: {partition_count}"); + } + + // Seek to partition table + f.seek(SeekFrom::Start(partitions_list_start))?; + + // Read entries + let mut entries = Vec::with_capacity(partition_count); + let mut entry_buf = vec![0u8; ENTRY_SIZE]; + for _ in 0..partition_count { + f.read_exact(&mut entry_buf)?; + entries.push(parse_entry(&entry_buf)?); + } + + let mut extracted = Vec::new(); + + for entry in &entries { + // Skip entries with no data or no file + if entry.partition_size == 0 || entry.filename.is_empty() || entry.file_flag == 0 { + continue; + } + + // Sanitize output filename + let safe_name = Path::new(&entry.filename) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| format!("{}.img", entry.partition_name)); + let out_path = output_dir.join(&safe_name); + + f.seek(SeekFrom::Start(entry.data_offset))?; + + // Stream copy + let mut out_file = File::create(&out_path)?; + let mut remaining = entry.partition_size; + let mut buf = vec![0u8; 8 * 1024 * 1024]; + while remaining > 0 { + let to_read = remaining.min(buf.len() as u64) as usize; + f.read_exact(&mut buf[..to_read])?; + out_file.write_all(&buf[..to_read])?; + remaining -= to_read as u64; + } + drop(out_file); + + let _ = sparse::maybe_unsparse(&out_path); + extracted.push(out_path); + } + + Ok(extracted) +} + +#[pyfunction] +#[pyo3(name = "pac")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results.into_iter().map(|p| p.to_string_lossy().into_owned()).collect()) +} diff --git a/firmware-parsers/src/qfil.rs b/firmware-parsers/src/qfil.rs new file mode 100644 index 0000000..e48c39f --- /dev/null +++ b/firmware-parsers/src/qfil.rs @@ -0,0 +1,369 @@ +use anyhow::{Context, Result}; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use quick_xml::events::Event; +use quick_xml::Reader; +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::sparse; + +#[derive(Debug)] +struct Program { + filename: String, + label: String, + num_partition_sectors: u64, + start_sector: u64, + file_sector_offset: u64, + sector_size: u64, +} + +/// Parse rawprogram XML manually to handle arbitrary attributes gracefully. +fn parse_rawprogram_xml(xml: &str) -> Result> { + let mut reader = Reader::from_str(xml); + let mut programs = Vec::new(); + + loop { + match reader.read_event() { + Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) => { + if e.name().as_ref() != b"program" { + continue; + } + let mut prog = Program { + filename: String::new(), + label: String::new(), + num_partition_sectors: 0, + start_sector: 0, + file_sector_offset: 0, + sector_size: 512, + }; + for attr in e.attributes().flatten() { + let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); + let val = std::str::from_utf8(&attr.value).unwrap_or(""); + match key { + "filename" => prog.filename = val.to_string(), + "label" => prog.label = val.to_string(), + "num_partition_sectors" => { + prog.num_partition_sectors = val.parse().unwrap_or(0) + } + "start_sector" => prog.start_sector = val.parse().unwrap_or(0), + "file_sector_offset" => { + prog.file_sector_offset = val.parse().unwrap_or(0) + } + "SECTOR_SIZE_IN_BYTES" => { + prog.sector_size = val.parse().unwrap_or(512) + } + _ => {} // ignore unknown attributes + } + } + programs.push(prog); + } + Ok(Event::Eof) => break, + Err(e) => anyhow::bail!("XML parse error: {e}"), + _ => {} + } + } + Ok(programs) +} + +/// Check if a zip archive contains rawprogram*.xml (QFIL format). +pub fn probe_zip(archive: &zip::ZipArchive) -> bool { + (0..archive.len()).any(|i| { + archive.name_for_index(i).map_or(false, |n| { + let base = Path::new(n) + .file_name() + .map(|f| f.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + base.starts_with("rawprogram") && base.ends_with(".xml") + }) + }) +} + +/// Resolve a filename from rawprogram XML against the temp directory. +/// Tries the full path first, then falls back to just the basename, +/// since zip extraction flattens all entries to their basenames. +fn resolve_file(temp_dir: &Path, filename: &str) -> Option { + // Try verbatim first + let full = temp_dir.join(filename); + if full.is_file() { + return Some(full); + } + // Fall back to basename (handles "images/system.img" → "system.img") + let base = Path::new(filename).file_name()?; + let flat = temp_dir.join(base); + if flat.is_file() { + return Some(flat); + } + None +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + let zip_file = File::open(input).context("failed to open QFIL zip")?; + let mut archive = zip::ZipArchive::new(zip_file).context("failed to read zip archive")?; + + // Extract all files to a temp directory (flattened to basenames) + let temp_dir = output_dir.join("_qfil_temp"); + fs::create_dir_all(&temp_dir)?; + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + if entry.is_dir() { + continue; + } + + let entry_name = entry.name().to_string(); + let base_name = Path::new(&entry_name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or(entry_name.clone()); + + let out_path = temp_dir.join(&base_name); + let mut out_file = File::create(&out_path)?; + std::io::copy(&mut entry, &mut out_file)?; + } + + // Find and parse rawprogram XML + let rawprogram_xml = find_rawprogram_xml(&temp_dir)?; + let xml_content = fs::read_to_string(&rawprogram_xml) + .context("failed to read rawprogram XML")?; + + let programs = parse_rawprogram_xml(&xml_content) + .context("failed to parse rawprogram XML")?; + + // Group programs by label so that multi-chunk partitions (sparsechunk + // entries listed as separate elements with the same label) + // are merged instead of overwriting each other. + let mut label_groups: BTreeMap> = BTreeMap::new(); + for program in &programs { + if program.filename.is_empty() || program.label.is_empty() { + continue; + } + if program.num_partition_sectors == 0 { + continue; + } + label_groups + .entry(program.label.clone()) + .or_default() + .push(program); + } + + let mut extracted = Vec::new(); + + for (label, programs) in &label_groups { + // Sanitize label to prevent path traversal from crafted XML + let safe_label = Path::new(label) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| format!("partition_{}", extracted.len())); + let out_name = format!("{safe_label}.img"); + let out_path = output_dir.join(&out_name); + + if programs.len() == 1 { + let program = programs[0]; + let sector_size = program.sector_size; + + if let Some(src) = resolve_file(&temp_dir, &program.filename) { + // Honor file_sector_offset: read from the specified offset + if program.file_sector_offset > 0 && program.num_partition_sectors > 0 { + let offset = program.file_sector_offset * sector_size; + let length = program.num_partition_sectors * sector_size; + copy_range(&src, &out_path, offset, length)?; + } else { + fs::copy(&src, &out_path)?; + } + } else { + // No direct file — try finding sparsechunks by label + let chunks = find_sparse_chunks(&temp_dir, &program.filename, label); + if chunks.is_empty() { + continue; + } + write_chunks(&chunks, &out_path)?; + } + } else { + // Multiple program entries for the same label. + // Sort by start_sector so chunks are in the right order. + let mut sorted: Vec<&Program> = programs.clone(); + sorted.sort_by_key(|p| p.start_sector); + + // Check if all entries reference the same backing file + let all_same_file = sorted.windows(2).all(|w| w[0].filename == w[1].filename); + + if all_same_file { + // Same backing file — extract each slice at its offset + let src = match resolve_file(&temp_dir, &sorted[0].filename) { + Some(s) => s, + None => continue, + }; + let mut out_file = File::create(&out_path)?; + for p in &sorted { + let ss = p.sector_size; + let offset = p.file_sector_offset * ss; + let length = p.num_partition_sectors * ss; + let mut f = File::open(&src)?; + f.seek(SeekFrom::Start(offset))?; + let mut remaining = length; + let mut buf = vec![0u8; 8 * 1024 * 1024]; + while remaining > 0 { + let to_read = remaining.min(buf.len() as u64) as usize; + let n = f.read(&mut buf[..to_read])?; + if n == 0 { + break; + } + out_file.write_all(&buf[..n])?; + remaining -= n as u64; + } + } + } else { + // Distinct files per entry — collect and merge + let files: Vec = sorted + .iter() + .filter_map(|p| resolve_file(&temp_dir, &p.filename)) + .collect(); + + if files.is_empty() { + let p0 = sorted[0]; + let chunks = find_sparse_chunks(&temp_dir, &p0.filename, label); + if chunks.is_empty() { + continue; + } + write_chunks(&chunks, &out_path)?; + } else { + write_chunks(&files, &out_path)?; + } + } + } + + let _ = sparse::maybe_unsparse(&out_path); + extracted.push(out_path); + } + + // Clean up temp directory + let _ = fs::remove_dir_all(&temp_dir); + + Ok(extracted) +} + +/// Copy `length` bytes from `src` starting at `offset` into a new file at `dst`. +fn copy_range(src: &Path, dst: &Path, offset: u64, length: u64) -> Result<()> { + let mut f = File::open(src)?; + f.seek(SeekFrom::Start(offset))?; + let mut out = File::create(dst)?; + let mut remaining = length; + let mut buf = vec![0u8; 8 * 1024 * 1024]; + while remaining > 0 { + let to_read = remaining.min(buf.len() as u64) as usize; + let n = f.read(&mut buf[..to_read])?; + if n == 0 { + break; + } + out.write_all(&buf[..n])?; + remaining -= n as u64; + } + Ok(()) +} + +/// Write a list of chunk files to an output path. +/// If the first chunk is Android sparse, use the sparse decoder to merge. +/// Otherwise, concatenate raw bytes. +fn write_chunks(chunks: &[PathBuf], out_path: &Path) -> Result<()> { + if sparse::check_sparse(&chunks[0]) { + let paths: Vec<&Path> = chunks.iter().map(|p| p.as_path()).collect(); + sparse::convert_sparse_chunks_to_raw(&paths, out_path)?; + } else { + let mut out_file = File::create(out_path)?; + for chunk in chunks { + let mut f = File::open(chunk)?; + std::io::copy(&mut f, &mut out_file)?; + } + } + Ok(()) +} + +/// Find the rawprogram XML file in the temp directory. +/// Prefers rawprogram_unsparse variants, then rawprogram0, then any rawprogram*.xml. +fn find_rawprogram_xml(dir: &Path) -> Result { + // Collect all rawprogram*.xml candidates + let mut candidates: Vec = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_lowercase(); + if name.starts_with("rawprogram") && name.ends_with(".xml") { + candidates.push(entry.path()); + } + } + + if candidates.is_empty() { + anyhow::bail!("no rawprogram*.xml found in archive"); + } + + // Prefer rawprogram_unsparse (has reassembled entries) + for c in &candidates { + let name = c.file_name().unwrap().to_string_lossy().to_lowercase(); + if name.contains("unsparse") { + return Ok(c.clone()); + } + } + // Then rawprogram0 + for c in &candidates { + let name = c.file_name().unwrap().to_string_lossy().to_lowercase(); + if name == "rawprogram0.xml" { + return Ok(c.clone()); + } + } + // Fall back to first available + Ok(candidates.into_iter().next().unwrap()) +} + +/// Find sparse chunk files for a given partition. +/// Tries both the XML filename stem and label-based patterns. +fn find_sparse_chunks(dir: &Path, filename: &str, label: &str) -> Vec { + let stem = Path::new(filename) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + + let patterns = [ + format!("{stem}_sparsechunk."), + format!("{stem}.img_sparsechunk."), + format!("{label}_sparsechunk."), + format!("{label}.img_sparsechunk."), + ]; + + if let Ok(entries) = fs::read_dir(dir) { + let mut matching: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name().to_string_lossy().to_string(); + patterns.iter().any(|p| name.starts_with(p)) + }) + .map(|e| e.path()) + .collect(); + + // Sort by chunk index (the number after the last dot) + matching.sort_by_key(|p| { + p.extension() + .and_then(|e| e.to_str()) + .and_then(|e| e.parse::().ok()) + .unwrap_or(0) + }); + + if !matching.is_empty() { + return matching; + } + } + + Vec::new() +} + +#[pyfunction] +#[pyo3(name = "qfil")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect()) +} diff --git a/firmware-parsers/src/rockchip.rs b/firmware-parsers/src/rockchip.rs new file mode 100644 index 0000000..e1dc728 --- /dev/null +++ b/firmware-parsers/src/rockchip.rs @@ -0,0 +1,169 @@ +use anyhow::{bail, Context, Result}; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::sparse; + +const RKFW_MAGIC: &[u8; 4] = b"RKFW"; +const AFP_MAGIC_RKAF: &[u8; 4] = b"RKAF"; +const AFP_MAGIC_RKAS: &[u8; 4] = b"RKAS"; + +// AFP (RKAF) entry: name(32) + file(64) + offset(4) + flash_offset(4) + usespace(4) + size(4) = 112 +const AFP_ENTRY_SIZE: usize = 0x70; +// AFP header: magic(4) + size(4) + model(64) + manufacturer(60) + version(4) + item_count(4) = 140 +const AFP_HEADER_SIZE: usize = 0x8C; + +/// Check if a file looks like a Rockchip RKFW image. +pub fn probe(data: &[u8]) -> bool { + data.len() >= 4 && &data[0..4] == RKFW_MAGIC +} + +struct AfpEntry { + name: String, + offset: u32, + size: u32, +} + +fn read_null_terminated(buf: &[u8]) -> String { + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..end]).to_string() +} + +fn parse_afp_entry(buf: &[u8]) -> Result { + if buf.len() < AFP_ENTRY_SIZE { + bail!("AFP entry buffer too small"); + } + // Entry layout (0x70 = 112 bytes): + // 0x00..0x20 name (32 bytes, null-terminated) + // 0x20..0x60 file path (64 bytes, null-terminated) + // 0x60..0x64 offset (u32 LE) — relative to AFP container start + // 0x64..0x68 flash_offset (u32 LE) + // 0x68..0x6C usespace (u32 LE) + // 0x6C..0x70 size (u32 LE) + let name = read_null_terminated(&buf[0x00..0x20]); + let offset = u32::from_le_bytes(buf[0x60..0x64].try_into()?); + let size = u32::from_le_bytes(buf[0x6C..0x70].try_into()?); + + Ok(AfpEntry { name, offset, size }) +} + +/// Strip A/B slot suffix: *_a → * +fn strip_ab_suffix(name: &str) -> String { + if let Some(base) = name.strip_suffix("_a.img") { + format!("{base}.img") + } else if let Some(base) = name.strip_suffix("_b.img") { + format!("{base}.img") + } else { + name.to_string() + } +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + let mut f = File::open(input).context("failed to open Rockchip image")?; + + // Parse RKFW header (packed/unaligned layout): + // 0x00: magic (4), 0x04: header_size (2), 0x06: version (4), + // 0x0A: code (4), 0x0E: date (7), 0x15: chip (4), + // 0x19: loader_offset (4), 0x1D: loader_size (4), + // 0x21: image_offset (4), 0x25: image_size (4) + let mut rkfw_header = [0u8; 0x29]; + f.read_exact(&mut rkfw_header)?; + + if &rkfw_header[0..4] != RKFW_MAGIC { + bail!("not an RKFW image: bad magic"); + } + + // image_offset at 0x21, image_size at 0x25 (packed, not aligned) + let firmware_offset = u32::from_le_bytes(rkfw_header[0x21..0x25].try_into()?) as u64; + let firmware_size = u32::from_le_bytes(rkfw_header[0x25..0x29].try_into()?) as u64; + + if firmware_size == 0 { + bail!("RKFW firmware size is 0"); + } + + // Seek to firmware.img (AFP container) + f.seek(SeekFrom::Start(firmware_offset))?; + + // Read AFP header (0x8C bytes) + let mut afp_header = [0u8; AFP_HEADER_SIZE]; + f.read_exact(&mut afp_header)?; + + // Validate AFP magic + let afp_magic = &afp_header[0..4]; + if afp_magic != AFP_MAGIC_RKAF && afp_magic != AFP_MAGIC_RKAS { + bail!( + "not an AFP container: bad magic {:?}", + &afp_header[0..4] + ); + } + + // entry_count at offset 0x88 within AFP header + let entry_count = u32::from_le_bytes(afp_header[0x88..0x8C].try_into()?) as usize; + if entry_count == 0 || entry_count > 1024 { + bail!("invalid AFP entry count: {entry_count}"); + } + + // Read entry table (each entry is 0x70 bytes) + let mut entries = Vec::with_capacity(entry_count); + let mut entry_buf = [0u8; AFP_ENTRY_SIZE]; + for _ in 0..entry_count { + f.read_exact(&mut entry_buf)?; + entries.push(parse_afp_entry(&entry_buf)?); + } + + let mut extracted = Vec::new(); + + for entry in &entries { + if entry.size == 0 || entry.name.is_empty() { + continue; + } + + // AFP offsets are relative to the AFP container start + let abs_offset = firmware_offset + entry.offset as u64; + f.seek(SeekFrom::Start(abs_offset))?; + + // Determine output filename — sanitize path to prevent traversal + let raw_name = if entry.name.contains('.') { + entry.name.clone() + } else { + format!("{}.img", entry.name) + }; + let out_name = strip_ab_suffix(&raw_name); + let safe_name = Path::new(&out_name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| format!("partition_{}.img", extracted.len())); + let out_path = output_dir.join(&safe_name); + + // Stream copy + let mut out_file = File::create(&out_path)?; + let mut remaining = entry.size as u64; + let mut buf = vec![0u8; 8 * 1024 * 1024]; + while remaining > 0 { + let to_read = remaining.min(buf.len() as u64) as usize; + f.read_exact(&mut buf[..to_read])?; + out_file.write_all(&buf[..to_read])?; + remaining -= to_read as u64; + } + drop(out_file); + + let _ = sparse::maybe_unsparse(&out_path); + extracted.push(out_path); + } + + Ok(extracted) +} + +#[pyfunction] +#[pyo3(name = "rockchip")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect()) +} diff --git a/firmware-parsers/src/sin.rs b/firmware-parsers/src/sin.rs new file mode 100644 index 0000000..9efe58c --- /dev/null +++ b/firmware-parsers/src/sin.rs @@ -0,0 +1,407 @@ +use anyhow::{bail, Context, Result}; +use flate2::read::GzDecoder; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use std::fs::File; +use std::io::{BufReader, Cursor, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::sparse; + +/// Sony sparse chunk type identifiers. +const CAC_RAW: u16 = 0xCAC1; +const CAC_FILL: u16 = 0xCAC2; +const CAC_DONT_CARE: u16 = 0xCAC3; +const CAC_CRC: u16 = 0xCAC4; +const CAC_SKIP: u16 = 0xCAC5; + +/// Block size used by Sony sparse format. +const SONY_BLOCK_SIZE: u32 = 4096; + +#[derive(Debug)] +enum SinVersion { + V3, + V4, + V5, + LegacySSSS, + LegacyOther, +} + +/// Detect SIN version from header bytes. +fn detect_version(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + if &data[0..3] == b"SIN" { + return match data[3] { + 0x03 => Some(SinVersion::V3), + 0x04 => Some(SinVersion::V4), + 0x05 => Some(SinVersion::V5), + _ => None, + }; + } + if &data[0..4] == b"SSSS" { + return Some(SinVersion::LegacySSSS); + } + // BFBF or other legacy + if data.len() > 0x4040 { + return Some(SinVersion::LegacyOther); + } + None +} + +/// Check if raw data starts with a SIN or legacy header. +pub fn probe(data: &[u8]) -> bool { + if data.len() < 4 { + return false; + } + &data[0..3] == b"SIN" + || &data[0..4] == b"SSSS" + || (data[0] == 0xBF && data[1] == 0xBF) +} + +/// Reassemble Sony sparse chunks into a raw image. +/// The input is the raw chunk data (after gzip/tar extraction). +fn reassemble_sony_sparse(data: &[u8], output: &mut File) -> Result<()> { + let mut pos = 0; + + while pos + 12 <= data.len() { + let chunk_type = u16::from_le_bytes(data[pos..pos + 2].try_into()?); + // Validate chunk type + if !matches!(chunk_type, CAC_RAW | CAC_FILL | CAC_DONT_CARE | CAC_CRC | CAC_SKIP) { + // Not a Sony sparse chunk — treat remaining data as raw + output.write_all(&data[pos..])?; + break; + } + + let chunk_blocks = u32::from_le_bytes(data[pos + 4..pos + 8].try_into()?); + let chunk_data_len = u32::from_le_bytes(data[pos + 8..pos + 12].try_into()?) as usize; + pos += 12; + + match chunk_type { + CAC_RAW => { + if pos + chunk_data_len > data.len() { + bail!("Sony sparse raw chunk extends beyond data"); + } + output.write_all(&data[pos..pos + chunk_data_len])?; + pos += chunk_data_len; + } + CAC_FILL => { + if chunk_data_len < 4 || pos + chunk_data_len > data.len() { + bail!("Sony sparse fill chunk invalid"); + } + let fill_val = &data[pos..pos + 4]; + let fill_block = fill_val.repeat(SONY_BLOCK_SIZE as usize / 4); + for _ in 0..chunk_blocks { + output.write_all(&fill_block)?; + } + pos += chunk_data_len; + } + CAC_DONT_CARE | CAC_SKIP => { + // Write zeros for the specified size + let zeros = vec![0u8; SONY_BLOCK_SIZE as usize]; + for _ in 0..chunk_blocks { + output.write_all(&zeros)?; + } + pos += chunk_data_len; + } + CAC_CRC => { + // Skip CRC chunk data + pos += chunk_data_len; + } + _ => { + pos += chunk_data_len; + } + } + } + + Ok(()) +} + +/// Write tar entry data to output, handling Sony sparse if detected. +fn write_entry_data(entry_data: &[u8], output: &mut File) -> Result<()> { + if entry_data.len() >= 2 { + let first_u16 = u16::from_le_bytes(entry_data[0..2].try_into().unwrap_or([0, 0])); + if matches!(first_u16, CAC_RAW | CAC_FILL | CAC_DONT_CARE | CAC_CRC | CAC_SKIP) { + reassemble_sony_sparse(entry_data, output)?; + return Ok(()); + } + } + output.write_all(entry_data)?; + Ok(()) +} + +/// Extract SIN v3/v4 data: gzip → tar → Sony sparse → raw. +fn extract_sin_v3_v4(data: &[u8], output: &mut File) -> Result<()> { + // Find the start of gzip data (skip the SIN header) + // SIN header varies in size; scan for gzip magic 0x1F 0x8B + let gz_start = find_gzip_start(data).context("no gzip data found in SIN v3/v4")?; + + let decoder = GzDecoder::new(&data[gz_start..]); + let mut archive = tar::Archive::new(decoder); + + // Process each tar entry individually to preserve boundaries + for entry in archive.entries()? { + let mut entry = entry?; + if entry.size() == 0 { + continue; + } + let mut entry_data = Vec::with_capacity(entry.size().min(256 * 1024 * 1024) as usize); + entry.read_to_end(&mut entry_data)?; + write_entry_data(&entry_data, output)?; + } + + Ok(()) +} + +/// Extract SIN v5 data: tar (no gzip) → Sony sparse → raw. +fn extract_sin_v5(data: &[u8], output: &mut File) -> Result<()> { + // Skip SIN header, find tar data + let tar_start = find_tar_start(data).context("no tar data found in SIN v5")?; + + let mut archive = tar::Archive::new(Cursor::new(&data[tar_start..])); + + // Process each tar entry individually to preserve boundaries + for entry in archive.entries()? { + let mut entry = entry?; + if entry.size() == 0 { + continue; + } + let mut entry_data = Vec::with_capacity(entry.size().min(256 * 1024 * 1024) as usize); + entry.read_to_end(&mut entry_data)?; + write_entry_data(&entry_data, output)?; + } + + Ok(()) +} + +/// Extract legacy SIN with SSSS header. +fn extract_legacy_ssss(data: &[u8], output: &mut File) -> Result<()> { + if data.len() < 64 { + bail!("SSSS data too small"); + } + let lo = u16::from_le_bytes(data[60..62].try_into()?) as usize; + let hi = u16::from_le_bytes(data[62..64].try_into()?) as usize; + let payload_size = hi * 65536 + lo; + let offset = 64; + + if offset + payload_size > data.len() { + bail!("SSSS payload extends beyond file"); + } + + output.write_all(&data[offset..offset + payload_size])?; + Ok(()) +} + +/// Extract legacy SIN with BFBF or unknown header. +fn extract_legacy_other(data: &[u8], output: &mut File) -> Result<()> { + if data.len() <= 0x4040 { + bail!("legacy SIN data too small"); + } + output.write_all(&data[0x4040..])?; + Ok(()) +} + +/// Find gzip magic (0x1F 0x8B) in data. +fn find_gzip_start(data: &[u8]) -> Option { + for i in 0..data.len().saturating_sub(1) { + if data[i] == 0x1F && data[i + 1] == 0x8B { + return Some(i); + } + } + None +} + +/// Find tar header — look for "ustar" at offset 257. +fn find_tar_start(data: &[u8]) -> Option { + // Tar blocks are 512 bytes; "ustar" appears at offset 257 within a tar header + for offset in (0..data.len().saturating_sub(512)).step_by(512) { + if offset + 262 <= data.len() && &data[offset + 257..offset + 262] == b"ustar" { + return Some(offset); + } + } + // If no ustar found, try byte-by-byte (some SIN variants have odd alignment) + for i in 0..data.len().saturating_sub(262) { + if &data[i + 257..i + 262] == b"ustar" { + return Some(i); + } + } + None +} + +/// Extract a single SIN file to a raw image. +fn extract_sin_data(data: &[u8], out_path: &Path) -> Result<()> { + let version = detect_version(data).context("cannot detect SIN version")?; + + let mut out_file = File::create(out_path)?; + match version { + SinVersion::V3 | SinVersion::V4 => extract_sin_v3_v4(data, &mut out_file)?, + SinVersion::V5 => extract_sin_v5(data, &mut out_file)?, + SinVersion::LegacySSSS => extract_legacy_ssss(data, &mut out_file)?, + SinVersion::LegacyOther => extract_legacy_other(data, &mut out_file)?, + } + drop(out_file); + + // Convert Android sparse if needed + let _ = sparse::maybe_unsparse(out_path); + + Ok(()) +} + +/// Extract an FTF file (zip containing .sin files). +fn extract_ftf(input: &Path, output_dir: &Path) -> Result> { + let zip_file = File::open(input)?; + let mut archive = zip::ZipArchive::new(zip_file)?; + + let mut extracted = Vec::new(); + + // Collect .sin entry indices and names + let sin_entries: Vec<(usize, String)> = (0..archive.len()) + .filter_map(|i| { + let name = archive.by_index(i).ok()?.name().to_string(); + if name.to_lowercase().ends_with(".sin") { + Some((i, name)) + } else { + None + } + }) + .collect(); + + for (idx, name) in sin_entries { + let mut entry = archive.by_index(idx)?; + let mut data = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut data)?; + + let base_name = Path::new(&name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or(name.clone()); + // Replace .sin with .img (case-insensitive — detection accepts any case) + let out_name = if base_name.to_lowercase().ends_with(".sin") { + format!("{}.img", &base_name[..base_name.len() - 4]) + } else { + base_name + }; + let out_path = output_dir.join(&out_name); + + match extract_sin_data(&data, &out_path) { + Ok(()) => extracted.push(out_path), + Err(_) => { + // Skip unrecognized .sin entries (e.g. non-SIN files with .sin extension) + let _ = std::fs::remove_file(&out_path); + } + } + } + + Ok(extracted) +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + // Check if this is an FTF (zip containing .sin files) + if let Ok(zip_file) = File::open(input) { + if let Ok(archive) = zip::ZipArchive::new(zip_file) { + let has_sin = (0..archive.len()).any(|i| { + archive + .name_for_index(i) + .map(|n| n.to_lowercase().ends_with(".sin")) + .unwrap_or(false) + }); + if has_sin { + return extract_ftf(input, output_dir); + } + } + } + + // Direct SIN file — stream-scan for gzip/tar offset instead of + // reading the entire file into memory (avoids OOM on multi-GB files). + let stem = input + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "output".to_string()); + let out_path = output_dir.join(format!("{stem}.img")); + + let mut f = File::open(input)?; + let file_size = f.metadata()?.len(); + + // Read the header (first 4KB is more than enough for any SIN header) + let header_cap = file_size.min(4096) as usize; + let mut header = vec![0u8; header_cap]; + f.read_exact(&mut header)?; + + let version = detect_version(&header); + match version { + Some(SinVersion::V3) | Some(SinVersion::V4) => { + // Scan header region for gzip magic, then stream from that offset + let gz_start = find_gzip_start(&header) + .context("no gzip data found in SIN v3/v4 header")?; + f.seek(SeekFrom::Start(gz_start as u64))?; + let decoder = GzDecoder::new(BufReader::new(f)); + let mut archive = tar::Archive::new(decoder); + let mut out_file = File::create(&out_path)?; + for entry in archive.entries()? { + let mut entry = entry?; + if entry.size() == 0 { + continue; + } + let mut entry_data = + Vec::with_capacity(entry.size().min(256 * 1024 * 1024) as usize); + entry.read_to_end(&mut entry_data)?; + write_entry_data(&entry_data, &mut out_file)?; + } + drop(out_file); + let _ = sparse::maybe_unsparse(&out_path); + } + Some(SinVersion::V5) => { + // Scan header for tar "ustar" signature, then stream from that offset + let tar_start = find_tar_start(&header) + .context("no tar data found in SIN v5 header")?; + f.seek(SeekFrom::Start(tar_start as u64))?; + let mut archive = tar::Archive::new(BufReader::new(f)); + let mut out_file = File::create(&out_path)?; + for entry in archive.entries()? { + let mut entry = entry?; + if entry.size() == 0 { + continue; + } + let mut entry_data = + Vec::with_capacity(entry.size().min(256 * 1024 * 1024) as usize); + entry.read_to_end(&mut entry_data)?; + write_entry_data(&entry_data, &mut out_file)?; + } + drop(out_file); + let _ = sparse::maybe_unsparse(&out_path); + } + Some(SinVersion::LegacySSSS) | Some(SinVersion::LegacyOther) => { + // Legacy formats have small headers — safe to read fully + let mut data = header; + f.read_to_end(&mut data)?; + extract_sin_data(&data, &out_path)?; + } + None => { + bail!("cannot detect SIN version"); + } + } + + Ok(vec![out_path]) +} + +/// Check if a zip archive is an FTF (contains .sin files). +pub fn probe_zip(archive: &zip::ZipArchive) -> bool { + (0..archive.len()).any(|i| { + archive + .name_for_index(i) + .map(|n| n.to_lowercase().ends_with(".sin")) + .unwrap_or(false) + }) +} + +#[pyfunction] +#[pyo3(name = "sin")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect()) +} diff --git a/firmware-parsers/src/sparse.rs b/firmware-parsers/src/sparse.rs new file mode 100644 index 0000000..ce67039 --- /dev/null +++ b/firmware-parsers/src/sparse.rs @@ -0,0 +1,86 @@ +use android_sparse::read::Reader; +use android_sparse::write::Decoder; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use std::fs::File; +use std::io::{BufReader, BufWriter, Read}; +use std::path::Path; + +const SPARSE_MAGIC: u32 = 0xED26FF3A; + +/// Check if a file is an Android sparse image by reading the first 4 bytes. +pub fn check_sparse(path: &Path) -> bool { + let Ok(mut f) = File::open(path) else { + return false; + }; + let mut buf = [0u8; 4]; + if f.read_exact(&mut buf).is_err() { + return false; + } + u32::from_le_bytes(buf) == SPARSE_MAGIC +} + +/// Convert an Android sparse image to a raw image. +pub fn convert_sparse_to_raw(input: &Path, output: &Path) -> anyhow::Result<()> { + let reader = Reader::new(BufReader::new(File::open(input)?))?; + + let out_file = File::create(output)?; + let mut decoder = Decoder::new(BufWriter::new(out_file))?; + for block in reader { + decoder.write_block(&block?)?; + } + decoder.close()?; + + Ok(()) +} + +/// If the file at `path` is a sparse image, convert it to raw in-place. +/// Returns true if conversion happened. +pub fn maybe_unsparse(path: &Path) -> anyhow::Result { + if !check_sparse(path) { + return Ok(false); + } + + let tmp = path.with_extension("raw.tmp"); + convert_sparse_to_raw(path, &tmp)?; + std::fs::rename(&tmp, path)?; + Ok(true) +} + +/// Convert multiple sparse chunk files into a single raw image. +pub fn convert_sparse_chunks_to_raw(inputs: &[&Path], output: &Path) -> anyhow::Result<()> { + let out_file = File::create(output)?; + let mut decoder = Decoder::new(BufWriter::new(out_file))?; + for input in inputs { + let reader = Reader::new(BufReader::new(File::open(input)?))?; + for block in reader { + decoder.write_block(&block?)?; + } + } + decoder.close()?; + Ok(()) +} + +/// Python-exposed: convert a sparse image to a raw image. +#[pyfunction] +#[pyo3(name = "sparse_to_raw")] +pub fn py_sparse_to_raw(input: &str, output: &str) -> PyResult<()> { + convert_sparse_to_raw(Path::new(input), Path::new(output)) + .map_err(|e| PyIOError::new_err(e.to_string())) +} + +/// Python-exposed: convert multiple sparse chunk files into a single raw image. +#[pyfunction] +#[pyo3(name = "sparse_chunks_to_raw")] +pub fn py_sparse_chunks_to_raw(inputs: Vec, output: &str) -> PyResult<()> { + let paths: Vec<&Path> = inputs.iter().map(|s| Path::new(s.as_str())).collect(); + convert_sparse_chunks_to_raw(&paths, Path::new(output)) + .map_err(|e| PyIOError::new_err(e.to_string())) +} + +/// Python-exposed: check if a file is an Android sparse image. +#[pyfunction] +#[pyo3(name = "is_sparse")] +pub fn py_is_sparse(path: &str) -> PyResult { + Ok(check_sparse(Path::new(path))) +} diff --git a/firmware-parsers/src/zte.rs b/firmware-parsers/src/zte.rs new file mode 100644 index 0000000..bc22b7f --- /dev/null +++ b/firmware-parsers/src/zte.rs @@ -0,0 +1,223 @@ +use anyhow::{Context, Result}; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use regex::Regex; +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::sparse; + +#[derive(Debug, Clone, Copy)] +pub enum ZteMode { + PChunks, + Bin, +} + +fn p_suffix_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?i)^([^/]*?)-p(\d+)$").unwrap()) +} + +/// Check if a zip archive is a ZTE update.zip. +/// Returns the detected mode or None. +pub fn probe_zip(archive: &zip::ZipArchive) -> Option { + let re = p_suffix_regex(); + + let mut has_p_suffix = false; + let mut has_bin = false; + + for i in 0..archive.len() { + if let Some(name) = archive.name_for_index(i) { + let base = Path::new(name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if re.is_match(&base) { + has_p_suffix = true; + } + if base.ends_with(".bin") && is_partition_name(&base) { + has_bin = true; + } + } + } + + if has_p_suffix { + return Some(ZteMode::PChunks); + } + if has_bin { + return Some(ZteMode::Bin); + } + None +} + +/// Check if a filename looks like a known partition name. +pub fn is_partition_name(name: &str) -> bool { + let lower = name.to_lowercase(); + let stem = lower + .strip_suffix(".bin") + .or_else(|| lower.strip_suffix(".img")) + .unwrap_or(&lower); + matches!( + stem, + "system" + | "vendor" + | "boot" + | "recovery" + | "modem" + | "userdata" + | "cache" + | "dtbo" + | "vbmeta" + | "vbmeta_system" + | "vbmeta_vendor" + | "super" + | "product" + | "system_ext" + | "system_other" + | "odm" + | "odm_dlkm" + | "vendor_dlkm" + | "vendor_boot" + | "init_boot" + | "metadata" + | "persist" + | "splash" + | "aboot" + | "preloader" + | "lk" + | "logo" + | "tz" + | "sbl1" + | "rpm" + | "hyp" + | "pmic" + | "abl" + | "xbl" + | "devcfg" + | "cmnlib" + | "cmnlib64" + | "keymaster" + | "sec" + ) +} + +/// Extract p-suffix chunk mode: concatenate system-p00, system-p01, ... → system.img +fn extract_p_chunks( + archive: &mut zip::ZipArchive, + output_dir: &Path, +) -> Result> { + let re = p_suffix_regex(); + + // Group entries by partition name — use name_for_index to avoid decompression + let mut partitions: BTreeMap> = BTreeMap::new(); + + for i in 0..archive.len() { + if let Some(name) = archive.name_for_index(i) { + let base = Path::new(name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if let Some(caps) = re.captures(&base) { + let partition = caps.get(1).unwrap().as_str().to_lowercase(); + let chunk_idx: u32 = caps.get(2).unwrap().as_str().parse().unwrap_or(0); + partitions + .entry(partition) + .or_default() + .push((chunk_idx, i)); + } + } + } + + let mut extracted = Vec::new(); + + for (partition, mut chunks) in partitions { + // Sort by chunk index + chunks.sort_by_key(|(idx, _)| *idx); + + let out_path = output_dir.join(format!("{partition}.img")); + let mut out_file = File::create(&out_path)?; + + for (_, zip_idx) in &chunks { + let mut entry = archive.by_index(*zip_idx)?; + let mut buf = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut buf)?; + out_file.write_all(&buf)?; + } + drop(out_file); + + let _ = sparse::maybe_unsparse(&out_path); + extracted.push(out_path); + } + + Ok(extracted) +} + +/// Extract bin mode: rename *.bin → *.img +fn extract_bin( + archive: &mut zip::ZipArchive, + output_dir: &Path, +) -> Result> { + let mut extracted = Vec::new(); + + // Collect indices first using name_for_index (avoids decompression) + let bin_entries: Vec<(usize, String)> = (0..archive.len()) + .filter_map(|i| { + let name = archive.name_for_index(i)?.to_string(); + let base = Path::new(&name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if base.ends_with(".bin") && is_partition_name(&base) { + Some((i, base)) + } else { + None + } + }) + .collect(); + + for (idx, base_name) in bin_entries { + let mut entry = archive.by_index(idx)?; + + let out_name = base_name.replace(".bin", ".img"); + let out_path = output_dir.join(&out_name); + + let mut out_file = File::create(&out_path)?; + std::io::copy(&mut entry, &mut out_file)?; + drop(out_file); + + let _ = sparse::maybe_unsparse(&out_path); + extracted.push(out_path); + } + + Ok(extracted) +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + let zip_file = File::open(input).context("failed to open ZTE zip")?; + let mut archive = zip::ZipArchive::new(zip_file).context("failed to read zip archive")?; + + // Detect mode from the same archive (avoid re-opening) + let mode = probe_zip(&archive); + + match mode { + Some(ZteMode::PChunks) => extract_p_chunks(&mut archive, output_dir), + Some(ZteMode::Bin) => extract_bin(&mut archive, output_dir), + None => anyhow::bail!("not a ZTE update.zip"), + } +} + +#[pyfunction] +#[pyo3(name = "zte")] +pub fn py_extract(input: &str, output_dir: &str) -> PyResult> { + let results = extract(Path::new(input), Path::new(output_dir)) + .map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(results + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect()) +}