From 1b6d585e3e92e97b6a03a77d9b42547d45fd8f88 Mon Sep 17 00:00:00 2001 From: deadman96385 Date: Thu, 2 Apr 2026 13:21:51 -0500 Subject: [PATCH 01/10] dumpyara: Add firmware-parsers Rust/PyO3 crate and replace simg2img Add a new Rust + PyO3 native extension (firmware-parsers/) that provides Android sparse image conversion, replacing the external simg2img binary dependency. The crate exposes sparse_to_raw, sparse_chunks_to_raw, and is_sparse functions to Python via the firmware_parsers module. All simg2img subprocess calls in multipartitions.py, raw_image.py, and sparsed_images.py now use the native Rust implementation when available, with graceful fallback to the simg2img binary when firmware_parsers is not installed. The simg2img tool requirement is also conditionally removed from REQUIRED_TOOLS in dumpyara.py. --- dumpyara/dumpyara.py | 10 +++- dumpyara/utils/multipartitions.py | 11 +++- dumpyara/utils/raw_image.py | 11 +++- dumpyara/utils/sparsed_images.py | 14 ++++- firmware-parsers/Cargo.toml | 42 +++++++++++++++ firmware-parsers/pyproject.toml | 12 +++++ firmware-parsers/src/detect.rs | 35 +++++++++++++ firmware-parsers/src/lib.rs | 13 +++++ firmware-parsers/src/sparse.rs | 86 +++++++++++++++++++++++++++++++ 9 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 firmware-parsers/Cargo.toml create mode 100644 firmware-parsers/pyproject.toml create mode 100644 firmware-parsers/src/detect.rs create mode 100644 firmware-parsers/src/lib.rs create mode 100644 firmware-parsers/src/sparse.rs diff --git a/dumpyara/dumpyara.py b/dumpyara/dumpyara.py index a32ae13..7257e13 100644 --- a/dumpyara/dumpyara.py +++ b/dumpyara/dumpyara.py @@ -15,13 +15,21 @@ from dumpyara.steps.extract_images import extract_images from dumpyara.steps.prepare_images import prepare_images +try: + import firmware_parsers + _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/utils/multipartitions.py b/dumpyara/utils/multipartitions.py index 6ebdb35..b1942f7 100644 --- a/dumpyara/utils/multipartitions.py +++ b/dumpyara/utils/multipartitions.py @@ -14,6 +14,12 @@ 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) @@ -21,7 +27,10 @@ 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) except Exception: LOGI(f"Failed to unsparse {image.name}") else: diff --git a/dumpyara/utils/raw_image.py b/dumpyara/utils/raw_image.py index cdc6b3e..fd99d50 100644 --- a/dumpyara/utils/raw_image.py +++ b/dumpyara/utils/raw_image.py @@ -13,6 +13,12 @@ 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): """ Convert a partition image to a raw image. @@ -59,7 +65,10 @@ 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) 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 c18a748..16fe154 100644 --- a/dumpyara/utils/sparsed_images.py +++ b/dumpyara/utils/sparsed_images.py @@ -10,6 +10,12 @@ 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): """ Prepare sparse images for conversion. @@ -31,6 +37,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.toml b/firmware-parsers/Cargo.toml new file mode 100644 index 0000000..d629d68 --- /dev/null +++ b/firmware-parsers/Cargo.toml @@ -0,0 +1,42 @@ +[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" +thiserror = "1" + +# Crypto +aes = "0.8" +cipher = "0.4" +hex-literal = "0.4" + +# Compression +flate2 = "1" +tar = "0.4" +zip = "2" + +# Binary parsing +memmap2 = "0.9" +bytemuck = { version = "1", features = ["derive"] } +byteorder = "1" + +# XML (QFIL) +quick-xml = { version = "0.37", features = ["serialize"] } +serde = { version = "1", features = ["derive"] } + +# 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/detect.rs b/firmware-parsers/src/detect.rs new file mode 100644 index 0000000..cfdbb72 --- /dev/null +++ b/firmware-parsers/src/detect.rs @@ -0,0 +1,35 @@ +use pyo3::prelude::*; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use crate::sparse; + +/// 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 mut buf = [0u8; 64]; + let _ = f.read(&mut buf); + + if sparse::check_sparse(path) { + return "sparse"; + } + + // Additional probes will be added in later phases: + // Phase 2: nb0, pac + // Phase 3: ozip, sin + // Phase 4: amlogic, rockchip + // Phase 5: qfil, zte, kddi (zip-based, need archive inspection) + + "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/lib.rs b/firmware-parsers/src/lib.rs new file mode 100644 index 0000000..9c68cd1 --- /dev/null +++ b/firmware-parsers/src/lib.rs @@ -0,0 +1,13 @@ +use pyo3::prelude::*; + +pub mod detect; +pub mod sparse; + +#[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)?)?; + Ok(()) +} 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))) +} From fa6f9dbcdbd99af38c1eed2d4ce655c41db7c695 Mon Sep 17 00:00:00 2001 From: deadman96385 Date: Thu, 2 Apr 2026 13:37:20 -0500 Subject: [PATCH 02/10] Add NB0, PAC, and MTK signed image parsers Add Rust extractors for NB0, PAC, and MTK signed image formats and expose them through the PyO3 module. Update format detection to recognize PAC and NB0 files directly and inspect zip archives for MTK signed image payloads. --- firmware-parsers/src/detect.rs | 27 ++++++- firmware-parsers/src/lib.rs | 6 ++ firmware-parsers/src/mtk_sign.rs | 118 +++++++++++++++++++++++++++++ firmware-parsers/src/nb0.rs | 113 +++++++++++++++++++++++++++ firmware-parsers/src/pac.rs | 126 +++++++++++++++++++++++++++++++ 5 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 firmware-parsers/src/mtk_sign.rs create mode 100644 firmware-parsers/src/nb0.rs create mode 100644 firmware-parsers/src/pac.rs diff --git a/firmware-parsers/src/detect.rs b/firmware-parsers/src/detect.rs index cfdbb72..82a4574 100644 --- a/firmware-parsers/src/detect.rs +++ b/firmware-parsers/src/detect.rs @@ -3,7 +3,7 @@ use std::fs::File; use std::io::Read; use std::path::Path; -use crate::sparse; +use crate::{mtk_sign, nb0, pac, sparse}; /// Read the first 64 bytes of a file and identify the format. /// Returns the format name as a string, or "unknown". @@ -11,18 +11,37 @@ 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 pac::probe(&buf) { + return "pac"; + } if sparse::check_sparse(path) { return "sparse"; } + if nb0::probe(&buf, file_size) { + return "nb0"; + } + + // Zip-based probes — if file starts with PK magic, open the archive + // and inspect entry names to identify the format. + if buf[0..2] == *b"PK" { + if let Ok(zip_file) = File::open(path) { + if let Ok(archive) = zip::ZipArchive::new(zip_file) { + if mtk_sign::probe_zip(&archive) { + return "mtk_sign"; + } + // Phase 5: qfil, zte, kddi probes go here + } + } + } - // Additional probes will be added in later phases: - // Phase 2: nb0, pac + // Additional probes added in later phases: // Phase 3: ozip, sin // Phase 4: amlogic, rockchip - // Phase 5: qfil, zte, kddi (zip-based, need archive inspection) "unknown" } diff --git a/firmware-parsers/src/lib.rs b/firmware-parsers/src/lib.rs index 9c68cd1..71a0ac5 100644 --- a/firmware-parsers/src/lib.rs +++ b/firmware-parsers/src/lib.rs @@ -1,6 +1,9 @@ use pyo3::prelude::*; pub mod detect; +pub mod mtk_sign; +pub mod nb0; +pub mod pac; pub mod sparse; #[pymodule] @@ -9,5 +12,8 @@ fn firmware_parsers(m: &Bound<'_, PyModule>) -> PyResult<()> { 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)?)?; Ok(()) } diff --git a/firmware-parsers/src/mtk_sign.rs b/firmware-parsers/src/mtk_sign.rs new file mode 100644 index 0000000..528b85f --- /dev/null +++ b/firmware-parsers/src/mtk_sign.rs @@ -0,0 +1,118 @@ +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 { + // BFBF or unknown: 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..77cdeac --- /dev/null +++ b/firmware-parsers/src/nb0.rs @@ -0,0 +1,113 @@ +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 and the file is large enough to hold the partition table. +/// `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 + count > 0 && count < 256 && file_size >= (4 + count * ENTRY_SIZE) as u64 +} + +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 + let out_name = if entry.filename.contains('.') { + entry.filename.clone() + } else { + format!("{}.img", entry.filename) + }; + 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/pac.rs b/firmware-parsers/src/pac.rs new file mode 100644 index 0000000..a08ba1f --- /dev/null +++ b/firmware-parsers/src/pac.rs @@ -0,0 +1,126 @@ +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; + +const PAC_MAGIC: u32 = 0xFFFAFFFF; +const ENTRY_SIZE: usize = 0x184; +const PARTITION_TABLE_OFFSET: usize = 0x0110; +const ENTRY_COUNT_OFFSET: usize = 0x000C; + +/// Check if a file looks like a Unisoc .pac container. +pub fn probe(data: &[u8]) -> bool { + if data.len() < 4 { + return false; + } + u32::from_le_bytes(data[0..4].try_into().unwrap()) == PAC_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 { + name: String, + filename: String, + offset: u64, + size: u64, +} + +fn parse_entry(buf: &[u8]) -> Result { + if buf.len() < ENTRY_SIZE { + bail!("PAC entry buffer too small"); + } + let name = decode_utf16le(&buf[0x00..0x40]); + let filename = decode_utf16le(&buf[0x40..0x80]); + let offset = u32::from_le_bytes(buf[0x80..0x84].try_into()?) as u64; + let size = u32::from_le_bytes(buf[0x84..0x88].try_into()?) as u64; + Ok(PacEntry { + name, + filename, + offset, + size, + }) +} + +pub fn extract(input: &Path, output_dir: &Path) -> Result> { + let mut f = File::open(input).context("failed to open PAC file")?; + + // Read header to get entry count + let mut header = [0u8; PARTITION_TABLE_OFFSET]; + f.read_exact(&mut header)?; + + let magic = u32::from_le_bytes(header[0..4].try_into()?); + if magic != PAC_MAGIC { + bail!("not a PAC file: bad magic 0x{magic:08X}"); + } + + let entry_count = u32::from_le_bytes( + header[ENTRY_COUNT_OFFSET..ENTRY_COUNT_OFFSET + 4].try_into()?, + ) as usize; + + if entry_count == 0 || entry_count > 1024 { + bail!("invalid PAC entry count: {entry_count}"); + } + + // Read partition table + let mut entries = Vec::with_capacity(entry_count); + let mut entry_buf = vec![0u8; ENTRY_SIZE]; + for _ in 0..entry_count { + f.read_exact(&mut entry_buf)?; + entries.push(parse_entry(&entry_buf)?); + } + + let mut extracted = Vec::new(); + + for entry in &entries { + if entry.size == 0 || entry.filename.is_empty() { + continue; + } + + // Determine output filename + let out_name = if entry.filename.ends_with(".img") { + entry.filename.clone() + } else if entry.name.is_empty() { + entry.filename.clone() + } else { + format!("{}.img", entry.name) + }; + let out_path = output_dir.join(&out_name); + + f.seek(SeekFrom::Start(entry.offset))?; + + // Stream copy + let mut out_file = File::create(&out_path)?; + let mut remaining = entry.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()) +} From c00f375cd64a0f4a002c78046ccb2bd47d9ad6ef Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Fri, 3 Apr 2026 01:31:59 +0530 Subject: [PATCH 03/10] Add remaining firmware format parsers and dumpyara integration Implement Phases 3-6 of the firmware-parsers plan: - Phase 3: OPPO ozip (AES-128-ECB decrypt, 35 keys, mode-1 direct and mode-2 zip-wrapped with inner .ozip entry decryption) and Sony SIN/FTF (v3/v4/v5 + legacy SSSS/BFBF + Sony sparse chunks 0xCAC1-0xCAC5) - Phase 4: Amlogic USB burning tool and Rockchip RKFW/AFP containers - Phase 5: QFIL rawprogram XML, ZTE update.zip, KDDI .bin extractors - Phase 6: Wire firmware_parsers.detect() into extract_archive.py with graceful fallback to shutil.unpack_archive on failure Also fix issues found in review of Phase 1-2 code: - Remove "sparse" from detect() returns (no matching Python function) - Strengthen NB0 probe with printable-ASCII filename validation - Validate BFBF/MTK header magic before 0x4040 byte strip - Sanitize output filenames in nb0/pac/amlogic/rockchip/qfil (path traversal) - Fix SIN tar entry processing (per-entry instead of concatenation) - Use static OnceLock in ZTE/KDDI instead of per-call compilation - Expand partition name whitelist for ZTE/KDDI bin detection Format-specific fixes: - OZIP: handle PK-wrapped mode-2 inputs, decrypt inner .ozip entries, strip .ozip extension so downstream recognizes decrypted payloads; preserve decrypted filename for non-zip payloads (e.g. system.new.dat.br.ozip -> system.new.dat.br, not .br.img); use temp work directory for zip extraction to avoid leaked temp files; propagate decryption failure instead of silently renaming ciphertext - QFIL: group entries by label to merge sparsechunks instead of overwriting; resolve filenames by basename for flattened layouts; sanitize XML labels against path traversal; honor file_sector_offset when rebuilding partitions from shared backing files; branch on all_same_file without requiring non-zero first offset - Amlogic: detect tar.bz2 wrappers by content (BZh magic) instead of filename extension; verify Amlogic magic before accepting tar entries to avoid selecting non-Amlogic .img files that precede the payload - SIN: stream-scan for gzip/tar offset instead of reading entire file into memory (avoids OOM on multi-GB files); strip .sin extension case-insensitively to match case-insensitive FTF detection - detect.rs: scan all tar.bz2 members for Amlogic magic (not just the first entry, since packages may have readme/manifest first); tighten bzip2 check from 2-byte "BZ" to 3-byte "BZh" Housekeeping: - Remove unused Cargo dependencies (thiserror, memmap2, bytemuck, byteorder) --- dumpyara/steps/extract_archive.py | 35 + firmware-parsers/Cargo.lock | 1306 +++++++++++++++++++++++++++++ firmware-parsers/Cargo.toml | 7 +- firmware-parsers/src/amlogic.rs | 202 +++++ firmware-parsers/src/detect.rs | 73 +- firmware-parsers/src/kddi.rs | 97 +++ firmware-parsers/src/lib.rs | 14 + firmware-parsers/src/mtk_sign.rs | 8 +- firmware-parsers/src/nb0.rs | 37 +- firmware-parsers/src/ozip.rs | 292 +++++++ firmware-parsers/src/pac.rs | 10 +- firmware-parsers/src/qfil.rs | 321 +++++++ firmware-parsers/src/rockchip.rs | 154 ++++ firmware-parsers/src/sin.rs | 402 +++++++++ firmware-parsers/src/zte.rs | 223 +++++ 15 files changed, 3156 insertions(+), 25 deletions(-) create mode 100644 firmware-parsers/Cargo.lock create mode 100644 firmware-parsers/src/amlogic.rs create mode 100644 firmware-parsers/src/kddi.rs create mode 100644 firmware-parsers/src/ozip.rs create mode 100644 firmware-parsers/src/qfil.rs create mode 100644 firmware-parsers/src/rockchip.rs create mode 100644 firmware-parsers/src/sin.rs create mode 100644 firmware-parsers/src/zte.rs diff --git a/dumpyara/steps/extract_archive.py b/dumpyara/steps/extract_archive.py index c7df043..b9f81c3 100644 --- a/dumpyara/steps/extract_archive.py +++ b/dumpyara/steps/extract_archive.py @@ -17,12 +17,33 @@ from dumpyara.utils.files import get_recursive_files_list +try: + import firmware_parsers + _HAS_FIRMWARE_PARSERS = True +except ImportError: + _HAS_FIRMWARE_PARSERS = False + def extract_archive(archive_path: Path, extracted_archive_path: Path, is_nested: bool = False): """ Extract the archive into a folder. """ 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) if is_nested: @@ -36,6 +57,20 @@ 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}") + # Check for nested archives extracted_archive_tempdir_files_list = list(get_recursive_files_list(extracted_archive_path, True)) for pattern, func in NESTED_ARCHIVES.items(): diff --git a/firmware-parsers/Cargo.lock b/firmware-parsers/Cargo.lock new file mode 100644 index 0000000..d7698ba --- /dev/null +++ b/firmware-parsers/Cargo.lock @@ -0,0 +1,1306 @@ +# 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", + "serde", + "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", + "serde", +] + +[[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", + "serde_derive", +] + +[[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 index d629d68..c2eb80e 100644 --- a/firmware-parsers/Cargo.toml +++ b/firmware-parsers/Cargo.toml @@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] pyo3 = { version = "0.22", features = ["extension-module"] } anyhow = "1" -thiserror = "1" # Crypto aes = "0.8" @@ -21,11 +20,7 @@ hex-literal = "0.4" flate2 = "1" tar = "0.4" zip = "2" - -# Binary parsing -memmap2 = "0.9" -bytemuck = { version = "1", features = ["derive"] } -byteorder = "1" +bzip2 = "0.5" # XML (QFIL) quick-xml = { version = "0.37", features = ["serialize"] } 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 index 82a4574..ee9a144 100644 --- a/firmware-parsers/src/detect.rs +++ b/firmware-parsers/src/detect.rs @@ -3,7 +3,35 @@ use std::fs::File; use std::io::Read; use std::path::Path; -use crate::{mtk_sign, nb0, pac, sparse}; +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". @@ -16,33 +44,62 @@ pub fn probe_magic(path: &Path) -> &'static str { 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 sparse::check_sparse(path) { - return "sparse"; + 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"; } - // Phase 5: qfil, zte, kddi probes go here + if zte::probe_zip(&archive).is_some() { + return "zte"; + } + if kddi::probe_zip(&archive) { + return "kddi"; + } } } } - // Additional probes added in later phases: - // Phase 3: ozip, sin - // Phase 4: amlogic, rockchip - "unknown" } 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 index 71a0ac5..f6d271a 100644 --- a/firmware-parsers/src/lib.rs +++ b/firmware-parsers/src/lib.rs @@ -1,10 +1,17 @@ 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<()> { @@ -15,5 +22,12 @@ fn firmware_parsers(m: &Bound<'_, PyModule>) -> PyResult<()> { 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 index 528b85f..45ffefd 100644 --- a/firmware-parsers/src/mtk_sign.rs +++ b/firmware-parsers/src/mtk_sign.rs @@ -23,8 +23,12 @@ fn strip_header(data: &[u8]) -> Result<(usize, usize)> { ); } Ok((offset, payload_size)) - } else if data.len() > 0x4040 { - // BFBF or unknown: skip first 0x4040 bytes + } 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)) diff --git a/firmware-parsers/src/nb0.rs b/firmware-parsers/src/nb0.rs index 77cdeac..4d38edb 100644 --- a/firmware-parsers/src/nb0.rs +++ b/firmware-parsers/src/nb0.rs @@ -11,7 +11,7 @@ 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 and the file is large enough to hold the partition table. +/// 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 { @@ -19,7 +19,29 @@ pub fn probe(data: &[u8], file_size: u64) -> bool { } 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 - count > 0 && count < 256 && file_size >= (4 + count * ENTRY_SIZE) as u64 + 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 { @@ -76,10 +98,15 @@ pub fn extract(input: &Path, output_dir: &Path) -> Result> { f.seek(SeekFrom::Start(abs_offset))?; // Determine output filename — ensure it ends with .img - let out_name = if entry.filename.contains('.') { - entry.filename.clone() + // 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!("{}.img", entry.filename) + format!("{safe_name}.img") }; let out_path = output_dir.join(&out_name); 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 index a08ba1f..fa2afe3 100644 --- a/firmware-parsers/src/pac.rs +++ b/firmware-parsers/src/pac.rs @@ -85,14 +85,16 @@ pub fn extract(input: &Path, output_dir: &Path) -> Result> { continue; } - // Determine output filename - let out_name = if entry.filename.ends_with(".img") { - entry.filename.clone() - } else if entry.name.is_empty() { + // Determine output filename — sanitize path to prevent traversal + let raw_name = if entry.filename.ends_with(".img") || entry.name.is_empty() { entry.filename.clone() } else { format!("{}.img", entry.name) }; + 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(entry.offset))?; diff --git a/firmware-parsers/src/qfil.rs b/firmware-parsers/src/qfil.rs new file mode 100644 index 0000000..31069d4 --- /dev/null +++ b/firmware-parsers/src/qfil.rs @@ -0,0 +1,321 @@ +use anyhow::{Context, Result}; +use pyo3::exceptions::PyIOError; +use pyo3::prelude::*; +use quick_xml::de::from_str; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::sparse; + +/// A single entry from rawprogram*.xml. +#[derive(Debug, Deserialize)] +struct Program { + #[serde(default)] + filename: String, + #[serde(default)] + label: String, + #[serde(default)] + num_partition_sectors: u64, + #[serde(default)] + start_sector: u64, + #[serde(default)] + file_sector_offset: u64, + #[serde(default, rename = "SECTOR_SIZE_IN_BYTES")] + sector_size: Option, +} + +/// Root element wrapping entries. +#[derive(Debug, Deserialize)] +struct Data { + #[serde(rename = "program", default)] + programs: Vec, +} + +/// 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 data: Data = from_str(&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 &data.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); + + let default_sector_size: u64 = 512; + + if programs.len() == 1 { + let program = programs[0]; + let sector_size = program.sector_size.unwrap_or(default_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.unwrap_or(default_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_unsparse0.xml over other variants. +fn find_rawprogram_xml(dir: &Path) -> Result { + let preferred = dir.join("rawprogram_unsparse0.xml"); + if preferred.is_file() { + return Ok(preferred); + } + + 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") { + return Ok(entry.path()); + } + } + + anyhow::bail!("no rawprogram*.xml found in archive"); +} + +/// 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..09c6d1d --- /dev/null +++ b/firmware-parsers/src/rockchip.rs @@ -0,0 +1,154 @@ +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"; + +const AFP_ENTRY_SIZE: usize = 0x48; // 72 bytes + +/// 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"); + } + let name = read_null_terminated(&buf[0x00..0x20]); + let offset = u32::from_le_bytes(buf[0x20..0x24].try_into()?); + // 0x24..0x28 is padding/user data + let size = u32::from_le_bytes(buf[0x28..0x2C].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 + let mut rkfw_header = [0u8; 0x24]; + f.read_exact(&mut rkfw_header)?; + + if &rkfw_header[0..4] != RKFW_MAGIC { + bail!("not an RKFW image: bad magic"); + } + + let firmware_offset = u32::from_le_bytes(rkfw_header[0x1C..0x20].try_into()?) as u64; + let firmware_size = u32::from_le_bytes(rkfw_header[0x20..0x24].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 + let mut afp_header = [0u8; 0x4C]; + 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] + ); + } + + let entry_count = u32::from_le_bytes(afp_header[0x44..0x48].try_into()?) as usize; + if entry_count == 0 || entry_count > 1024 { + bail!("invalid AFP entry count: {entry_count}"); + } + + // Read entry table + 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..38d43ec --- /dev/null +++ b/firmware-parsers/src/sin.rs @@ -0,0 +1,402 @@ +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); + + extract_sin_data(&data, &out_path)?; + extracted.push(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/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()) +} From 551f71122322f78f79ccc2e8226ce9774dd35b71 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Sat, 4 Apr 2026 00:33:36 +0530 Subject: [PATCH 04/10] Fix Rockchip RKFW/AFP header layout to match real firmware Rockchip RKFW header uses packed/unaligned fields: - AFP container offset at 0x21 (not 0x1C) - AFP container size at 0x25 (not 0x20) AFP container structure corrected: - AFP header is 0x8C bytes (not 0x4C) - entry_count at offset 0x88 (not 0x44) - Each entry is 0x70 / 112 bytes (not 0x48 / 72) - Entry offset at 0x60, size at 0x6C (not 0x20/0x28) --- firmware-parsers/src/rockchip.rs | 39 ++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/firmware-parsers/src/rockchip.rs b/firmware-parsers/src/rockchip.rs index 09c6d1d..e1dc728 100644 --- a/firmware-parsers/src/rockchip.rs +++ b/firmware-parsers/src/rockchip.rs @@ -11,7 +11,10 @@ const RKFW_MAGIC: &[u8; 4] = b"RKFW"; const AFP_MAGIC_RKAF: &[u8; 4] = b"RKAF"; const AFP_MAGIC_RKAS: &[u8; 4] = b"RKAS"; -const AFP_ENTRY_SIZE: usize = 0x48; // 72 bytes +// 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 { @@ -33,10 +36,16 @@ 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[0x20..0x24].try_into()?); - // 0x24..0x28 is padding/user data - let size = u32::from_le_bytes(buf[0x28..0x2C].try_into()?); + 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 }) } @@ -55,16 +64,21 @@ fn strip_ab_suffix(name: &str) -> 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 - let mut rkfw_header = [0u8; 0x24]; + // 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"); } - let firmware_offset = u32::from_le_bytes(rkfw_header[0x1C..0x20].try_into()?) as u64; - let firmware_size = u32::from_le_bytes(rkfw_header[0x20..0x24].try_into()?) as u64; + // 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"); @@ -73,8 +87,8 @@ pub fn extract(input: &Path, output_dir: &Path) -> Result> { // Seek to firmware.img (AFP container) f.seek(SeekFrom::Start(firmware_offset))?; - // Read AFP header - let mut afp_header = [0u8; 0x4C]; + // Read AFP header (0x8C bytes) + let mut afp_header = [0u8; AFP_HEADER_SIZE]; f.read_exact(&mut afp_header)?; // Validate AFP magic @@ -86,12 +100,13 @@ pub fn extract(input: &Path, output_dir: &Path) -> Result> { ); } - let entry_count = u32::from_le_bytes(afp_header[0x44..0x48].try_into()?) as usize; + // 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 + // 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 { From ea82f5e8902ea4044ebdd04f539cb1a3d6535eae Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Sat, 4 Apr 2026 01:46:49 +0530 Subject: [PATCH 05/10] extract_archive: add zip fallback and vendor prefix stripping --- dumpyara/steps/extract_archive.py | 40 ++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/dumpyara/steps/extract_archive.py b/dumpyara/steps/extract_archive.py index b9f81c3..90ef725 100644 --- a/dumpyara/steps/extract_archive.py +++ b/dumpyara/steps/extract_archive.py @@ -23,6 +23,31 @@ 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.""" + import re + 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 extract_archive(archive_path: Path, extracted_archive_path: Path, is_nested: bool = False): """ Extract the archive into a folder. @@ -45,7 +70,17 @@ def extract_archive(archive_path: Path, extracted_archive_path: Path, is_nested: 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.) + from zipfile import ZipFile, is_zipfile + 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() @@ -71,6 +106,9 @@ def extract_archive(archive_path: Path, extracted_archive_path: Path, is_nested: 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)) for pattern, func in NESTED_ARCHIVES.items(): From 53b3edc1c7ecd5138809edc3c0cfd5fd5851b23b Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Sat, 4 Apr 2026 01:46:51 +0530 Subject: [PATCH 06/10] pac: rewrite for PAC v2 format with BP_R magic and 64-bit offsets --- firmware-parsers/src/pac.rs | 117 ++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 44 deletions(-) diff --git a/firmware-parsers/src/pac.rs b/firmware-parsers/src/pac.rs index fa2afe3..331ea3e 100644 --- a/firmware-parsers/src/pac.rs +++ b/firmware-parsers/src/pac.rs @@ -8,17 +8,40 @@ use std::path::{Path, PathBuf}; use crate::sparse; -const PAC_MAGIC: u32 = 0xFFFAFFFF; -const ENTRY_SIZE: usize = 0x184; -const PARTITION_TABLE_OFFSET: usize = 0x0110; -const ENTRY_COUNT_OFFSET: usize = 0x000C; +/// 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 { - if data.len() < 4 { - return false; - } - u32::from_le_bytes(data[0..4].try_into().unwrap()) == PAC_MAGIC + data.len() >= 8 && &data[0..8] == PAC_V2_MAGIC } /// Decode a UTF-16LE null-terminated string from a fixed-size buffer. @@ -28,52 +51,63 @@ fn decode_utf16le(buf: &[u8]) -> String { } struct PacEntry { - name: String, + partition_name: String, filename: String, - offset: u64, - size: u64, + 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"); + bail!("PAC entry buffer too small: {} < {}", buf.len(), ENTRY_SIZE); } - let name = decode_utf16le(&buf[0x00..0x40]); - let filename = decode_utf16le(&buf[0x40..0x80]); - let offset = u32::from_le_bytes(buf[0x80..0x84].try_into()?) as u64; - let size = u32::from_le_bytes(buf[0x84..0x88].try_into()?) as u64; + 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 { - name, + partition_name, filename, - offset, - size, + 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 to get entry count - let mut header = [0u8; PARTITION_TABLE_OFFSET]; + // Read header + let mut header = vec![0u8; PAC_HEADER_SIZE]; f.read_exact(&mut header)?; - let magic = u32::from_le_bytes(header[0..4].try_into()?); - if magic != PAC_MAGIC { - bail!("not a PAC file: bad magic 0x{magic:08X}"); + if &header[0..8] != PAC_V2_MAGIC { + bail!("not a PAC v2 file: bad version string"); } - let entry_count = u32::from_le_bytes( - header[ENTRY_COUNT_OFFSET..ENTRY_COUNT_OFFSET + 4].try_into()?, + 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 entry_count == 0 || entry_count > 1024 { - bail!("invalid PAC entry count: {entry_count}"); + if partition_count == 0 || partition_count > 1024 { + bail!("invalid PAC partition count: {partition_count}"); } - // Read partition table - let mut entries = Vec::with_capacity(entry_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..entry_count { + for _ in 0..partition_count { f.read_exact(&mut entry_buf)?; entries.push(parse_entry(&entry_buf)?); } @@ -81,27 +115,23 @@ pub fn extract(input: &Path, output_dir: &Path) -> Result> { let mut extracted = Vec::new(); for entry in &entries { - if entry.size == 0 || entry.filename.is_empty() { + // Skip entries with no data or no file + if entry.partition_size == 0 || entry.filename.is_empty() || entry.file_flag == 0 { continue; } - // Determine output filename — sanitize path to prevent traversal - let raw_name = if entry.filename.ends_with(".img") || entry.name.is_empty() { - entry.filename.clone() - } else { - format!("{}.img", entry.name) - }; - let out_name = Path::new(&raw_name) + // Sanitize output filename + let safe_name = Path::new(&entry.filename) .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); + .unwrap_or_else(|| format!("{}.img", entry.partition_name)); + let out_path = output_dir.join(&safe_name); - f.seek(SeekFrom::Start(entry.offset))?; + f.seek(SeekFrom::Start(entry.data_offset))?; // Stream copy let mut out_file = File::create(&out_path)?; - let mut remaining = entry.size; + 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; @@ -112,7 +142,6 @@ pub fn extract(input: &Path, output_dir: &Path) -> Result> { drop(out_file); let _ = sparse::maybe_unsparse(&out_path); - extracted.push(out_path); } From 5dfb41a2f627609e713fb93533fa098dcf0de4b1 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Sat, 4 Apr 2026 01:46:53 +0530 Subject: [PATCH 07/10] qfil: replace serde XML deserialization with manual event parser --- firmware-parsers/Cargo.lock | 3 - firmware-parsers/Cargo.toml | 3 +- firmware-parsers/src/qfil.rs | 108 +++++++++++++++++++++++++---------- 3 files changed, 79 insertions(+), 35 deletions(-) diff --git a/firmware-parsers/Cargo.lock b/firmware-parsers/Cargo.lock index d7698ba..fb55f9b 100644 --- a/firmware-parsers/Cargo.lock +++ b/firmware-parsers/Cargo.lock @@ -332,7 +332,6 @@ dependencies = [ "pyo3", "quick-xml", "regex", - "serde", "tar", "tempfile", "zip", @@ -706,7 +705,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", - "serde", ] [[package]] @@ -800,7 +798,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", - "serde_derive", ] [[package]] diff --git a/firmware-parsers/Cargo.toml b/firmware-parsers/Cargo.toml index c2eb80e..bb3413f 100644 --- a/firmware-parsers/Cargo.toml +++ b/firmware-parsers/Cargo.toml @@ -23,8 +23,7 @@ zip = "2" bzip2 = "0.5" # XML (QFIL) -quick-xml = { version = "0.37", features = ["serialize"] } -serde = { version = "1", features = ["derive"] } +quick-xml = "0.37" # String handling encoding_rs = "0.8" diff --git a/firmware-parsers/src/qfil.rs b/firmware-parsers/src/qfil.rs index 31069d4..e48c39f 100644 --- a/firmware-parsers/src/qfil.rs +++ b/firmware-parsers/src/qfil.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result}; use pyo3::exceptions::PyIOError; use pyo3::prelude::*; -use quick_xml::de::from_str; -use serde::Deserialize; +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}; @@ -10,28 +10,62 @@ use std::path::{Path, PathBuf}; use crate::sparse; -/// A single entry from rawprogram*.xml. -#[derive(Debug, Deserialize)] +#[derive(Debug)] struct Program { - #[serde(default)] filename: String, - #[serde(default)] label: String, - #[serde(default)] num_partition_sectors: u64, - #[serde(default)] start_sector: u64, - #[serde(default)] file_sector_offset: u64, - #[serde(default, rename = "SECTOR_SIZE_IN_BYTES")] - sector_size: Option, + sector_size: u64, } -/// Root element wrapping entries. -#[derive(Debug, Deserialize)] -struct Data { - #[serde(rename = "program", default)] - programs: Vec, +/// 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). @@ -95,14 +129,14 @@ pub fn extract(input: &Path, output_dir: &Path) -> Result> { let xml_content = fs::read_to_string(&rawprogram_xml) .context("failed to read rawprogram XML")?; - let data: Data = from_str(&xml_content) + 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 &data.programs { + for program in &programs { if program.filename.is_empty() || program.label.is_empty() { continue; } @@ -126,11 +160,9 @@ pub fn extract(input: &Path, output_dir: &Path) -> Result> { let out_name = format!("{safe_label}.img"); let out_path = output_dir.join(&out_name); - let default_sector_size: u64 = 512; - if programs.len() == 1 { let program = programs[0]; - let sector_size = program.sector_size.unwrap_or(default_sector_size); + 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 @@ -166,7 +198,7 @@ pub fn extract(input: &Path, output_dir: &Path) -> Result> { }; let mut out_file = File::create(&out_path)?; for p in &sorted { - let ss = p.sector_size.unwrap_or(default_sector_size); + 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)?; @@ -250,22 +282,38 @@ fn write_chunks(chunks: &[PathBuf], out_path: &Path) -> Result<()> { } /// Find the rawprogram XML file in the temp directory. -/// Prefers rawprogram_unsparse0.xml over other variants. +/// Prefers rawprogram_unsparse variants, then rawprogram0, then any rawprogram*.xml. fn find_rawprogram_xml(dir: &Path) -> Result { - let preferred = dir.join("rawprogram_unsparse0.xml"); - if preferred.is_file() { - return Ok(preferred); - } - + // 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") { - return Ok(entry.path()); + candidates.push(entry.path()); } } - anyhow::bail!("no rawprogram*.xml found in archive"); + 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. From 563e669252d3ea07020b48a76ca5ae758a31fe08 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Sat, 4 Apr 2026 01:46:54 +0530 Subject: [PATCH 08/10] sin: skip failing .sin entries in FTF extraction instead of aborting --- firmware-parsers/src/sin.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/firmware-parsers/src/sin.rs b/firmware-parsers/src/sin.rs index 38d43ec..9efe58c 100644 --- a/firmware-parsers/src/sin.rs +++ b/firmware-parsers/src/sin.rs @@ -284,8 +284,13 @@ fn extract_ftf(input: &Path, output_dir: &Path) -> Result> { }; let out_path = output_dir.join(&out_name); - extract_sin_data(&data, &out_path)?; - extracted.push(out_path); + 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) From 3b0d470e7493c2a3bf5437324da2ec4e71123db7 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Fri, 5 Jun 2026 18:31:05 +0530 Subject: [PATCH 09/10] fix(partitions): skip alias move when canonical partition exists When a firmware ships both a real partition and its alias (e.g. modem.img alongside NON-HLOS.img on recent Oppo/ColorOS builds), fix_aliases logged "Ignoring ( already extracted)" and unlinked the alias, but then fell through and still attempted move(alias, partition). Since the alias was just deleted, this raised FileNotFoundError and aborted the dump. Add the missing continue so the redundant alias is dropped and the real partition is left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- dumpyara/utils/partitions.py | 1 + 1 file changed, 1 insertion(+) 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) From c4f4070c6e99fd69c8baa01c64c8e26930eaa350 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Fri, 5 Jun 2026 18:50:12 +0530 Subject: [PATCH 10/10] feat(extract): recurse into nested zips containing partition markers Some firmware (e.g. aftermarket car head units) wrap the real dumpable ROM one zip deeper -- the outer archive holds vendor blobs and APK dirs, while the actual block-based OTA (system.new.dat.br + *.transfer.list, payload.bin, super*.img, *.tar.md5) lives inside an inner .zip such as update_car.zip. dumpyara only scanned the outer archive's top level, found no recognized partition container, and aborted with "System folder doesn't exist". Extend nested-archive handling: for each top-level .zip not already covered by NESTED_ARCHIVES, peek its central directory (zipfile namelist, no extraction) and recurse only when it contains a partition marker. The marker patterns are anchored to a path boundary and the *.new.dat.br / *.transfer.list markers are restricted to known partition names, so APK/config zips (which carry none of these) are never exploded. Co-Authored-By: Claude Opus 4.8 (1M context) --- dumpyara/steps/extract_archive.py | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/dumpyara/steps/extract_archive.py b/dumpyara/steps/extract_archive.py index f88957d..1cf71c3 100644 --- a/dumpyara/steps/extract_archive.py +++ b/dumpyara/steps/extract_archive.py @@ -53,6 +53,25 @@ def _strip_vendor_prefix(directory: Path): 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): """ Extract the archive into a folder. @@ -138,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 {