diff --git a/README.md b/README.md index e808e84..45dd051 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Snagboot currently supports the following families of System-On-Chips (SoCs): * [Broadcom](https://www.broadcom.com/) BCM2711 and BCM2712, used in [Raspberry Pi 4 & 5](https://www.raspberrypi.com/documentation/computers/processors.html) * [AMLogic](https://www.amlogic.com/#Products) series: G12A (eg S905D2), G12B (eg A311D), SM1 (eg S905D3) and series: GXL (eg S905D), GXM (eg S912), GXBB (eg S905), AXG (eg A113D) * [Renesas](https://www.renesas.com/en/products/microcontrollers-microprocessors/rz-mpus) RZ/N1 series - + * [Qualcomm](https://www.qualcomm.com/) [IQ9075](https://www.qualcomm.com/internet-of-things/products/iq9-series/iq-9075) Please check [supported_socs.yaml](https://github.com/bootlin/snagboot/blob/main/src/snagrecover/supported_socs.yaml) or run `snagrecover --list-socs` for a more precise list of supported SoCs. diff --git a/docs/snagrecover.md b/docs/snagrecover.md index baa7506..636e901 100644 --- a/docs/snagrecover.md +++ b/docs/snagrecover.md @@ -175,6 +175,13 @@ This USB cable needs to power up the board, ie it needs to have its internal VBU Set up your board to boot from USB DFU, connect the board to the USB device port, power the board if necessary. A new USB device should appear on your host system. See the U-Boot RZ/N1 [documentation](https://docs.u-boot.org/en/latest/board/renesas/rzn1.html) for more information. +### Qualcomm IQ9075 + +To set up the board in recovery mode, refer to the section *"Force the device +into Emergency Download mode"* in the reference [documentation](https://docs.qualcomm.com/doc/80-70023-261/topic/iq9-ug-update-the-sw.html#panel-0-VWJ1bnR1tab$force-the-device-into-emergency-download-mode) + +**Note:** The steps described in the reference documentation above are the same for both EVK and non-EVK boards, except for the DIP switch position. For the exact DIP switch position required to enable EDL mode on the IQ9075 EVK, refer to the board schematic. Other boards using this SoC may use a different method to enter EDL mode; refer to your board vendor's documentation. + ## Preparing recovery firmware Snagrecover requires firmware binaries to successfully recover the board. Each @@ -607,6 +614,24 @@ cd u-boot **u-boot:** U-Boot proper in SPKG format. Must fit in internal RAM. +configuration: + * path + +### For Qualcomm IQ9075 devices + +**xbl:** XBL is a Qualcomm proprietary image, which can be downloaded using the following steps: + +1. Download the [ZIP](https://softwarecenter.qualcomm.com/nexus/generic/product/chip/tech-package/QCS9100_bootbinaries.1.0/qcs9100_bootbinaries.1.0-test-device-public/00133/QCS9100_bootbinaries.zip) file + +2. Extract the ZIP file and locate the XBL image: + prog_snagboot_ddr.elf + + +configuration: + * path + +**u-boot:** TBD: U-Boot support for Snagboot recovery is currently under upstream review. See the [patch series](https://marc.info/?l=u-boot&m=178577211945398&w=2) for more information. + configuration: * path @@ -647,4 +672,3 @@ Examples: snagrecover -s stm32mp15 -f stm32mp15.yaml snagrecover -s stm32mp15 -F "{'tf-a': {'path': 'binaries/tf-a-stm32.bin'}}" -F "{'fip': {'path': 'binaries/u-boot.stm32'}}" ``` - diff --git a/src/snagflash/android_sparse_file/utils.py b/src/snagflash/android_sparse_file/utils.py index fb0a1ce..644e720 100644 --- a/src/snagflash/android_sparse_file/utils.py +++ b/src/snagflash/android_sparse_file/utils.py @@ -3,60 +3,411 @@ # SPDX-License-Identifier: GPL-2.0+ # # Author: Arnaud Patard +# +# Modified-by: Bakyaraj Moorthy +# +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import os +import logging from snagflash.android_sparse_file.sparse import ( AndroidSparseFile, + AndroidChunkHeader, SPARSE_CHUNKHEADER_LEN, + SPARSE_FILEHEADER_LEN, CHUNK_TYPE_DONTCARE, CHUNK_TYPE_RAW, + CHUNK_TYPE_FILL, + CHUNK_TYPE_CRC32, ) +logger = logging.getLogger("snagflash") + +# Reserve space for the trailing DONT_CARE suffix chunk header +SUFFIX_RESERVE = SPARSE_CHUNKHEADER_LEN + +# Human-readable names for chunk type constants, used in log messages +CHUNK_TYPE_NAMES = { + CHUNK_TYPE_RAW: "RAW", + CHUNK_TYPE_FILL: "FILL", + CHUNK_TYPE_DONTCARE: "DONTCARE", + CHUNK_TYPE_CRC32: "CRC32", +} + + +def chunk_type_name(chunk_type): + """ + Return a human-readable name for a chunk type constant, for logging. + """ + return CHUNK_TYPE_NAMES.get(chunk_type, f"UNKNOWN(0x{chunk_type:04X})") + + +class SplitFragmentState: + """ + Holds the accumulated state for the sparse fragment currently being built + by split_streaming(). Using an explicit state object (instead of closures + with 'nonlocal') keeps the helper functions below at module level and + testable in isolation. + """ + + def __init__(self): + self.pending = [] # List of (chunk_type, num_blocks, payload) for current fragment + self.pending_payload_bytes = 0 # Running sum of payload bytes in pending + self.piece_blocks_sum = 0 # Total logical blocks covered by pending + + +def ensure_prefix_skip(state, blocks_done): + """ + Ensure current fragment starts with DONT_CARE prefix covering all + blocks written in previous fragments. + + This maintains the logical block addressing across split files by + inserting a DONT_CARE chunk that spans all previously written blocks. + Only adds prefix if fragment has no content yet. + + Args: + state: SplitFragmentState for the fragment currently being built + blocks_done: Cumulative blocks already written across all fragments + """ + if state.pending: + return # Already has content — prefix already established + if blocks_done > 0: + # Insert a DONT_CARE chunk spanning all previously written blocks + state.pending.append((CHUNK_TYPE_DONTCARE, blocks_done, None)) + state.piece_blocks_sum += blocks_done + logger.debug(f"Added DONT_CARE prefix covering {blocks_done} blocks") + + +def flush_fragment(state, dest, block_size, original_total_blks): + """ + Serialize all staged chunks in state.pending into a complete sparse image + fragment, append the trailing DONT_CARE suffix, write to file, and reset + the fragment state for the next fragment. + + Each fragment is a valid sparse file that maintains the original total block count + by padding with DONT_CARE chunks as needed. + + Args: + state: SplitFragmentState for the fragment currently being built (reset in place) + dest: Output path for the fragment file + block_size: Sparse image block size in bytes + original_total_blks: Total blocks in the original (unsplit) sparse image + + Returns: + Path to the flushed fragment file, or None if nothing to flush. + """ + if not state.pending: + return None # Nothing to flush + + # Safety check + if state.piece_blocks_sum > original_total_blks: + raise IOError( + f"Internal error: piece_blocks_sum {state.piece_blocks_sum} > " + f"original_total_blks {original_total_blks}" + ) + + # Add DONT_CARE suffix to pad to original_total_blks + suffix_blocks = original_total_blks - state.piece_blocks_sum + if suffix_blocks > 0: + state.pending.append((CHUNK_TYPE_DONTCARE, suffix_blocks, None)) + state.piece_blocks_sum += suffix_blocks + logger.debug(f"Added DONT_CARE suffix: {suffix_blocks} blocks (total: {state.piece_blocks_sum})") + + # Serialize the fragment: file header + all chunk headers + payloads + outf = AndroidSparseFile(False) + outf.open(dest, block_size) + + for ctype, blks, payload in state.pending: + chunk_bytes = blks * block_size + logger.debug( + f"Writing chunk: type={chunk_type_name(ctype)} " + f"size={blks} blocks ({chunk_bytes} bytes)" + ) + if ctype == CHUNK_TYPE_RAW: + # RAW: chunk header + full block payload bytes + outf.write_chunk(ctype, payload, blks) + elif ctype == CHUNK_TYPE_FILL: + # FILL: chunk header + 4-byte fill pattern + outf.write_chunk(ctype, payload, blks) + else: + # DONT_CARE (and any future zero-payload types): header only + outf.write_chunk(ctype, [], blks) + + outf.close() + + # Reset accumulation state for the next fragment + state.pending = [] + state.pending_payload_bytes = 0 + state.piece_blocks_sum = 0 + + return dest + + +def process_raw_chunk(input_fd, header, state, blocks_done, bufsize, block_size, dest, original_total_blks): + """ + Stage (and flush as needed) a RAW chunk, which may need to be split + across multiple fragments since RAW payloads can be large. + + Args: + input_fd: Input sparse file handle, positioned at the start of the chunk payload + header: AndroidChunkHeader for this RAW chunk + state: SplitFragmentState for the fragment currently being built + blocks_done: Cumulative blocks already written across all fragments + bufsize: Maximum size for each output fragment file + block_size: Sparse image block size in bytes + dest: Output path for fragment files + original_total_blks: Total blocks in the original (unsplit) sparse image + + Yields: + Path to each fragment flushed while processing this chunk + Returns via StopIteration value: updated blocks_done + """ + total = header.size # Total blocks in this RAW chunk + off = 0 # Current block offset within this RAW chunk + + while off < total: + ensure_prefix_skip(state, blocks_done) + + # Compute bytes already committed in this fragment (overhead) + overhead = ( + SPARSE_FILEHEADER_LEN + + (len(state.pending) + 1) * SPARSE_CHUNKHEADER_LEN + + state.pending_payload_bytes + + SUFFIX_RESERVE + ) + avail = bufsize - overhead # Available bytes for new RAW payload + + if avail < block_size: + # Not enough room for even one block — flush and yield + flushed_fragment = flush_fragment(state, dest, block_size, original_total_blks) + if flushed_fragment: + yield flushed_fragment + continue # Re-enter loop: recalculate overhead after flush + + # Determine how many blocks fit and read the matching payload + max_blks = min(avail // block_size, total - off) + + # Read only the data we need (streaming) + chunk_data_size = max_blks * block_size + part = input_fd.read(chunk_data_size) -def split(path, dest, bufsize): - flist = [] + if len(part) < chunk_data_size: + raise IOError("Unexpected end of file while reading RAW chunk data") + + # Stage the slice as a RAW chunk in the current fragment + state.pending.append((CHUNK_TYPE_RAW, max_blks, part)) + state.pending_payload_bytes += len(part) + state.piece_blocks_sum += max_blks + blocks_done += max_blks + off += max_blks + + logger.debug(f"Staged RAW chunk: {max_blks} blocks ({total - off} remaining)") + + return blocks_done + + +def process_dontcare_chunk(header, state, blocks_done, bufsize, block_size, dest, original_total_blks): + """ + Stage a DONT_CARE chunk, flushing the current fragment first if the + chunk header doesn't fit within bufsize. + + Returns: + (updated blocks_done, flushed_fragment path or None) + """ + ensure_prefix_skip(state, blocks_done) + + # Check if adding this chunk header would exceed bufsize + overhead = ( + SPARSE_FILEHEADER_LEN + + (len(state.pending) + 1) * SPARSE_CHUNKHEADER_LEN + + state.pending_payload_bytes + + SUFFIX_RESERVE + ) + + flushed_fragment = None + if overhead > bufsize: + # Doesn't fit - flush current fragment + flushed_fragment = flush_fragment(state, dest, block_size, original_total_blks) + ensure_prefix_skip(state, blocks_done) + + # Stage DONT_CARE chunk + state.pending.append((CHUNK_TYPE_DONTCARE, header.size, None)) + state.piece_blocks_sum += header.size + blocks_done += header.size + logger.debug(f"Staged DONT_CARE chunk: {header.size} blocks") + + return blocks_done, flushed_fragment + + +def process_fill_chunk(input_fd, header, state, blocks_done, bufsize, block_size, dest, original_total_blks): + """ + Stage a FILL chunk (always exactly 4 bytes of payload), flushing the + current fragment first if it doesn't fit within bufsize. + + Returns: + (updated blocks_done, flushed_fragment path or None) + """ + fill_value = input_fd.read(4) + if len(fill_value) != 4: + raise IOError("Truncated FILL payload") + + ensure_prefix_skip(state, blocks_done) + + # Check if adding this 4-byte payload + header fits within bufsize + overhead = ( + SPARSE_FILEHEADER_LEN + + (len(state.pending) + 1) * SPARSE_CHUNKHEADER_LEN + + state.pending_payload_bytes + 4 + # Include the 4-byte FILL payload + SUFFIX_RESERVE + ) + + flushed_fragment = None + if overhead > bufsize: + # Doesn't fit - flush current fragment + flushed_fragment = flush_fragment(state, dest, block_size, original_total_blks) + ensure_prefix_skip(state, blocks_done) + + # Stage FILL chunk + state.pending.append((CHUNK_TYPE_FILL, header.size, fill_value)) + state.pending_payload_bytes += 4 + state.piece_blocks_sum += header.size + blocks_done += header.size + logger.debug(f"Staged FILL chunk: {header.size} blocks") + + return blocks_done, flushed_fragment + + +def split_streaming(path, dest, bufsize): + """ + Generator that yields one split sparse file at a time for immediate processing. + + This streaming approach minimizes memory usage by: + - Reading RAW chunk data incrementally (not loading entire chunk into RAM) + - Yielding each split file immediately after creation + - Reusing the same temporary file location + - Proper overhead calculation accounting for all headers + + This allows processing of arbitrarily large sparse files with constant memory usage. + + Args: + path: Path to input sparse file + dest: Path for temporary output file (will be reused for each split) + bufsize: Maximum size for each output file + + Yields: + Path to each split file (same path, but content changes each iteration) + """ sparse_file = AndroidSparseFile(True) sparse_file.open(path) - split_count = 0 - outf = AndroidSparseFile(False) - (root, ext) = os.path.splitext(dest) - new_fname = f"{root}{split_count}{ext}" - outf.open(new_fname, sparse_file.file_header.block_size) - flist.append(new_fname) - - while True: - (header, data) = sparse_file.read_chunk() - if header is None: - outf.close() - break - rem_space = bufsize - outf.size - if header.total_size > rem_space: - split_blocks = int(rem_space / outf.file_header.block_size) - split = split_blocks * outf.file_header.block_size - rem = header.total_size - split - SPARSE_CHUNKHEADER_LEN - rem_blocks = int(rem / outf.file_header.block_size) - # non RAW chunks. can't split them. - if header.type != CHUNK_TYPE_RAW: - split = 0 - rem = header.total_size - else: - outf.write_chunk(header.type, data[0:split], split_blocks) - written_blocks = outf.file_header.blocks - outf.close() - split_count += 1 + # Store original total blocks for all output files + original_total_blks = sparse_file.file_header.blocks + block_size = sparse_file.file_header.block_size + + # Pre-flight validation: ensure bufsize can hold at least one block with all headers + min_required = ( + SPARSE_FILEHEADER_LEN + # 28 bytes: file header + 2 * SPARSE_CHUNKHEADER_LEN + # 24 bytes: prefix + one data chunk header + block_size + # At least one block of data + SPARSE_CHUNKHEADER_LEN # 12 bytes: suffix reserve + ) + if bufsize <= min_required: + sparse_file.close() + raise IOError( + f"Buffer size {bufsize} too small. Need at least {min_required} bytes " + f"to fit one {block_size}-byte block with headers" + ) + + # Cumulative blocks across ALL fragments so far (used for DONT_CARE prefix) + blocks_done = 0 + state = SplitFragmentState() - outf = AndroidSparseFile(False) - new_fname = f"{root}{split_count}{ext}" - outf.open(new_fname, sparse_file.file_header.block_size) - flist.append(new_fname) - outf.write_chunk(CHUNK_TYPE_DONTCARE, [], written_blocks) - outf.write_chunk(header.type, data[split:], rem_blocks) + input_fd = sparse_file.fd # Direct file handle for streaming reads - continue - outf.write_chunk(header.type, data, header.size) + logger.debug(f"Starting streaming split: total_blocks={original_total_blks}, block_size={block_size}") + + try: + while True: + # Read chunk header (not data yet) + chunk_header_bytes = input_fd.read(SPARSE_CHUNKHEADER_LEN) + if not chunk_header_bytes or len(chunk_header_bytes) < SPARSE_CHUNKHEADER_LEN: + # End of input file - finalize current output + result = flush_fragment(state, dest, block_size, original_total_blks) + if result: + yield result + break + + # Parse chunk header + header = AndroidChunkHeader.read(chunk_header_bytes, 0) + header.check() + + logger.debug( + f"Processing chunk: type={chunk_type_name(header.type)} " + f"size={header.size} blocks ({header.total_size} bytes)" + ) + + if header.type == CHUNK_TYPE_RAW: + raw_gen = process_raw_chunk( + input_fd, header, state, blocks_done, bufsize, block_size, dest, original_total_blks + ) + # process_raw_chunk is a generator that yields flushed fragment + # paths and returns the updated blocks_done via StopIteration.value + while True: + try: + flushed_fragment = next(raw_gen) + yield flushed_fragment + except StopIteration as stop: + blocks_done = stop.value + break + + elif header.type == CHUNK_TYPE_DONTCARE: + blocks_done, flushed_fragment = process_dontcare_chunk( + header, state, blocks_done, bufsize, block_size, dest, original_total_blks + ) + if flushed_fragment: + yield flushed_fragment + + elif header.type == CHUNK_TYPE_FILL: + blocks_done, flushed_fragment = process_fill_chunk( + input_fd, header, state, blocks_done, bufsize, block_size, dest, original_total_blks + ) + if flushed_fragment: + yield flushed_fragment + + elif header.type == CHUNK_TYPE_CRC32: + crc_data = input_fd.read(4) + if len(crc_data) != 4: + raise IOError("Truncated CRC32 payload") + logger.debug("Skipping CRC32 chunk (validation only)") + continue + + else: + # Unknown chunk type + logger.warning(f"Unknown chunk type 0x{header.type:04X}, skipping") + # Skip the data portion + data_size = header.get_data_size(block_size) + if data_size > 0: + input_fd.read(data_size) - sparse_file.close() + # Final flush: emit any remaining staged chunks as the last fragment + frag = flush_fragment(state, dest, block_size, original_total_blks) + if frag: + yield frag - return flist + finally: + sparse_file.close() \ No newline at end of file diff --git a/src/snagrecover/50-snagboot.rules b/src/snagrecover/50-snagboot.rules index 01b74e1..aaa321a 100644 --- a/src/snagrecover/50-snagboot.rules +++ b/src/snagrecover/50-snagboot.rules @@ -95,3 +95,6 @@ SUBSYSTEM=="usb", ATTRS{idVendor}=="1b8e", ATTRS{idProduct}=="c003", MODE="0660" # Renesas rules SUBSYSTEM=="usb", ATTRS{idVendor}=="045b", ATTRS{idProduct}=="0239", MODE="0660", TAG+="uaccess" + +# Qualcomm rules +SUBSYSTEM=="usb", ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9008", MODE="0660", TAG+="uaccess" diff --git a/src/snagrecover/config.py b/src/snagrecover/config.py index 29e7c88..71cdaa4 100644 --- a/src/snagrecover/config.py +++ b/src/snagrecover/config.py @@ -98,6 +98,7 @@ "bcm": {"bcm2711": "0a5c:2711", "bcm2712": "0a5c:2712"}, "amlogic": "1b8e:c003", "rzn1": "045b:0239", + "qcom": "05c6:9008", } recovery_config = {} # Global immutable config to be initialized with CLI args diff --git a/src/snagrecover/firmware/firmware.py b/src/snagrecover/firmware/firmware.py index f80b4e0..3dee89f 100644 --- a/src/snagrecover/firmware/firmware.py +++ b/src/snagrecover/firmware/firmware.py @@ -242,6 +242,10 @@ def run_firmware(port, fw_name: str, subfw_name: str = ""): amlogic_run(port, fw_name, fw_blob, subfw_name) elif soc_family == "rzn1": rzn1_run(port, fw_name, fw_blob) + elif soc_family == "qcom": + from snagrecover.firmware.qcom_fw import qcom_run + + qcom_run(port, fw_name, fw_blob) else: raise Exception(f"Unsupported SoC family {soc_family}") logger.info(f"Done installing firmware {fw_name}") diff --git a/src/snagrecover/firmware/qcom_fw.py b/src/snagrecover/firmware/qcom_fw.py new file mode 100644 index 0000000..661c646 --- /dev/null +++ b/src/snagrecover/firmware/qcom_fw.py @@ -0,0 +1,62 @@ +# This file is part of Snagboot +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +""" +Qualcomm firmware handling for Sahara protocol. +""" + +import logging +from snagrecover.config import recovery_config +from snagrecover.utils import cli_error + +logger = logging.getLogger("snagrecover") + + +def qcom_run(sahara, fw_name: str, fw_blob: bytes): + """ + Transfer Qualcomm firmware via Sahara protocol. + + This function: + Transfers the firmware to the device via QSahara protocol along with + image_id + + Args: + sahara: QSahara protocol instance + fw_name: Firmware name (e.g., 'xbl', 'u-boot') + fw_blob: Firmware binary data (bytes) + + Raises: + cli_error: If firmware name is unknown or firmware is empty + Exception: If transfer fails + """ + # Get Sahara image ID for this firmware + image_id = 0 + for key, value in recovery_config['firmware'].items(): + if key == fw_name: + image_id = value['image_id'] + logger.debug(f"Firmware '{fw_name}' maps to image ID {image_id:#x}") + + # Validate firmware size + if len(fw_blob) == 0: + cli_error(f"Firmware {fw_name} is empty") + + # Transfer via Sahara protocol + try: + sahara.transfer_image(image_id, fw_blob) + except Exception as e: + logger.error(f"Failed to transfer {fw_name}: {e}") + raise diff --git a/src/snagrecover/protocols/fastboot.py b/src/snagrecover/protocols/fastboot.py index b601f12..7956d8e 100644 --- a/src/snagrecover/protocols/fastboot.py +++ b/src/snagrecover/protocols/fastboot.py @@ -24,7 +24,7 @@ from typing import Optional, Union from snagrecover import utils -from snagflash.android_sparse_file.utils import split +from snagflash.android_sparse_file.utils import split_streaming import logging @@ -253,7 +253,7 @@ def flash_sparse(self, args: str): ) from e if maxsize == 0: raise FastbootError("Fastboot variable max-download-size is 0") - arg_list = args.split(":") + arg_list = args.split(":", 1) cnt = len(arg_list) if cnt != 2: raise FastbootError( @@ -262,22 +262,53 @@ def flash_sparse(self, args: str): fname = arg_list[0] if not os.path.exists(fname): raise FastbootError(f"File {fname} does not exist") + + # Verify the file is a valid Android sparse file by checking magic cookie + try: + with open(fname, "rb") as f: + magic_bytes = f.read(4) + if len(magic_bytes) < 4: + raise FastbootError(f"File {fname} is too small to be a valid sparse file") + magic = int.from_bytes(magic_bytes, byteorder='little') + if magic != 0xED26FF3A: + raise FastbootError( + f"File {fname} is not a valid Android sparse file. " + f"Expected magic 0xED26FF3A, got 0x{magic:08X}" + ) + logger.info(f"Verified {fname} is a valid Android sparse file") + except IOError as e: + raise FastbootError(f"Failed to read file {fname}: {e}") from e + part = arg_list[1] + + # Use streaming approach to minimize memory usage + # Each split file is created, downloaded, flashed, and then reused for the next split + # This allows processing of arbitrarily large sparse files with constant memory usage with tempfile.TemporaryDirectory() as tmp: temppath = os.path.join(tmp, "sparse.img") try: - splitfiles = split(fname, temppath, maxsize) - logger.info(f"Split fastboot file into {len(splitfiles)} file(s)") - for f in splitfiles: - logger.info(f"Downloading {f}") + # Count total splits upfront for "split X/N" logging below + total_splits = sum(1 for _ in split_streaming(fname, temppath, maxsize)) + + split_count = 0 + logger.info(f"Starting streaming sparse file flash ({total_splits} split(s))...") + + for split_file in split_streaming(fname, temppath, maxsize): + split_count += 1 + logger.info(f"Processing split {split_count}/{total_splits}: Downloading {split_file}") try: - self.download(f) + self.download(split_file) except Exception as e: - raise FastbootError(f"Failed to download: {e}") from e - logger.info(f"Flashing {f}") + raise FastbootError(f"Failed to download split {split_count}/{total_splits}: {e}") from e + + logger.info(f"Processing split {split_count}/{total_splits}: Flashing to {part}") try: self.flash(part) except Exception as e: - raise FastbootError(f"Failed to flash: {e}") from e + raise FastbootError(f"Failed to flash split {split_count}/{total_splits}: {e}") from e + + logger.debug(f"Split {split_count}/{total_splits} completed successfully") + + logger.info(f"Successfully flashed {split_count}/{total_splits} split file(s) to {part}") except Exception as e: - raise FastbootError(f"{e}") from e + raise FastbootError(f"Streaming sparse flash failed: {e}") from e diff --git a/src/snagrecover/protocols/qcom_sahara.py b/src/snagrecover/protocols/qcom_sahara.py new file mode 100644 index 0000000..1a24830 --- /dev/null +++ b/src/snagrecover/protocols/qcom_sahara.py @@ -0,0 +1,715 @@ +# This file is part of Snagboot +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +""" +Qualcomm Sahara protocol implementation for EDL mode recovery. + +Implements the Sahara protocol for transferring firmware images to +Qualcomm devices in Emergency Download (EDL) mode. +""" + +import usb.core +import usb.util +import struct +import logging +import time +from dataclasses import dataclass + +from snagrecover.utils import BinFileHeader as Header, dnload_iter + +logger = logging.getLogger("snagrecover") + + +@dataclass +class SaharaHelloReq(Header): + """ + Sahara HELLO packet sent by the device (48 bytes, 12 little-endian uint32 fields). + """ + + command: int + length: int + version: int + version_min: int + max_cmd_packet_length: int + mode: int + reserved1: int + reserved2: int + reserved3: int + reserved4: int + reserved5: int + reserved6: int + + fmt = " 24: + logger.debug(f" Last 24 bytes: {packet[-24:].hex(' ')}") + + return packet + + def dispatch_packet(self, packet): + """ + Dispatch packet to appropriate handler based on command. + Similar to Linux kernel switch statement in sahara_processing(). + + Args: + packet: Raw packet data + + Raises: + ValueError: If command is unknown + """ + command = struct.unpack('= len(self.current_image_data): + raise ValueError(f"Invalid offset {offset}, image size {len(self.current_image_data)}") + + if offset + length > len(self.current_image_data): + raise ValueError( + f"Invalid read: offset={offset} length={length}, " + f"image size={len(self.current_image_data)}" + ) + + # Extract requested data + data = self.current_image_data[offset:offset+length] + + # Send data in chunks to avoid USB buffer limitations in the USB stack + # Chunking ensures all data is actually transmitted + total_bytes_sent = 0 + chunk_count = 0 + + logger.debug(f"Sending {len(data)} bytes to device") + if len(data) > 0: + logger.debug(f" First 24 bytes: {data[:24].hex(' ')}") + if len(data) > 24: + logger.debug(f" Last 24 bytes: {data[-24:].hex(' ')}") + + for chunk_data in dnload_iter(data, self.SAHARA_PACKET_MAX_SIZE + 1): + chunk_count += 1 + # Send chunk using helper function (no ZLP yet) + bytes_written = self.send_chunk(chunk_data) + total_bytes_sent += bytes_written + + logger.debug(f"Successfully sent {total_bytes_sent} bytes in {chunk_count} chunks") + + # Send Zero-Length Packet (ZLP) if needed - ONCE after ALL chunks + # ZLP is required when TOTAL data size is a multiple of USB max packet size + # to signal end of transfer. + self.send_zlp_if_needed(total_bytes_sent) + + def handle_read_data_64(self, packet): + """ + Handle 64-bit READ_DATA request from device. + + This is similar to handle_read_data but uses 64-bit fields for + image_id, offset, and length to support larger images. + + Args: + packet: READ_DATA_64 packet data (32 bytes) + """ + req = SaharaReadData64Req.read(packet) + image_id = req.image_id + offset = req.data_offset + length = req.data_length + + # Validate image ID. First READ_DATA sets the active image ID + # All subsequent READ_DATA must use the same ID until END_IMAGE_TX + if self.active_image_id is None: + # First READ_DATA for this image - set active image ID + self.active_image_id = image_id + logger.info(f"Received first READ DATA (64-bit) message from device") + logger.debug(f"Device requesting image ID {image_id:#x}") + + # Validate it matches what we're trying to send + if image_id != self.current_image_id: + raise ValueError( + f"Image ID mismatch: device requested {image_id:#x}, " + f"but we're trying to send {self.current_image_id:#x}" + ) + else: + # Subsequent READ_DATA - must match active image ID + if image_id != self.active_image_id: + raise ValueError( + f"Image ID mismatch: active image is {self.active_image_id:#x}, " + f"but device requested {image_id:#x}" + ) + + logger.debug(f"Device requests data (64-bit): offset={offset} length={length} bytes") + + # Validate offset and length (with 64-bit support) + if offset >= len(self.current_image_data): + raise ValueError(f"Invalid offset {offset}, image size {len(self.current_image_data)}") + + if offset + length > len(self.current_image_data): + raise ValueError( + f"Invalid read: offset={offset} length={length}, " + f"image size={len(self.current_image_data)}" + ) + + # Extract requested data + data = self.current_image_data[offset:offset+length] + + # Send data in chunks to avoid Linux USB buffer limitations + # Linux USB subsystem may truncate large transfers (e.g., 1MB → 240KB) + # Chunking ensures all data is actually transmitted + total_bytes_sent = 0 + chunk_count = 0 + + logger.debug(f"Sending {len(data)} bytes to device") + if len(data) > 0: + logger.debug(f" First 24 bytes: {data[:24].hex(' ')}") + if len(data) > 24: + logger.debug(f" Last 24 bytes: {data[-24:].hex(' ')}") + + for chunk_data in dnload_iter(data, self.SAHARA_PACKET_MAX_SIZE + 1): + chunk_count += 1 + # Send chunk using helper function (no ZLP yet) + bytes_written = self.send_chunk(chunk_data) + total_bytes_sent += bytes_written + + logger.debug(f"Successfully sent {total_bytes_sent} bytes in {chunk_count} chunks") + + # Send Zero-Length Packet (ZLP) if needed - ONCE after ALL chunks + # ZLP is required when TOTAL data size is a multiple of USB max packet size + # to signal end of transfer. + self.send_zlp_if_needed(total_bytes_sent) + + def handle_end_image_tx(self, packet): + """ + Handle END_IMAGE_TX from device. + + Args: + packet: END_IMAGE_TX packet data (16 bytes) + """ + end_tx = SaharaEndImageTx.read(packet) + image_id = end_tx.image_id + status = end_tx.status + + logger.info(f"Received END IMAGE TX message from device. Status: {status}") + logger.debug(f"Device finished receiving image. Image ID: {image_id:#x}") + + # Validate image ID matches active image + if self.active_image_id is not None and image_id != self.active_image_id: + raise ValueError( + f"END_IMAGE_TX image ID {image_id:#x} does not match " + f"active image {self.active_image_id:#x}" + ) + + # Release the active image This allows the next image transfer + # to set a new active_image_id + logger.debug(f"Releasing image ID {self.active_image_id:#x}") + self.active_image_id = None + + if status != 0: + raise RuntimeError(f"Image transfer failed with status {status}") + + # Device will now process the image + logger.info("Sending DONE message to device") + self.send_done() + logger.debug("Waiting for DONE RESPONSE message from device") + + def handle_done_resp(self, packet): + """ + Handle DONE_RESP from device - transfer complete! + + Args: + packet: DONE_RESP packet data (12 bytes) + """ + done_resp = SaharaDoneResp.read(packet) + status = done_resp.status + + logger.info(f"Received DONE RESPONSE message from device. Status: {status}") + + # Note: Intentional do nothing as we don't need to exit an app, + # until all the images are transferred. + self.running = False + + def send_hello_response(self, version, mode): + """ + Send HELLO_RESP packet to device. + + Args: + version: Protocol version from device + mode: Mode from device + """ + packet = struct.pack( + '