utils: add pldm_fwup_pkg_parser.py

It is a script generated by Gemini to reverse the pack process done by
pldm_fwup_pkg_creator.py. It's helpful to extract the meta data out from
a PLDM firmware package from vendor and perform modification and
repackage if necessary.

Tested:
extract the metadata.json and component bin, repack with
pldm_fwup_pkg_creator.py, tested PLDM type5 download with the re-packed
firmware package.

Usage: ./pldm_fwup_pkg_parser.py pldm-fw-package.fwpkg -o extract
Found 1 components.
Header parsed successfully. Final Offset: 161
Extracting 1 component images...
  Extracted: extract/component_0_1.bin (Size: 9395200 bytes)
Metadata written to: extract/metadata.json

Change-Id: Ibb05cbe351d428ee30353578ff495257f36a371e
Signed-off-by: Jinliang Wang <jinliangw@google.com>
diff --git a/tools/fw-update/pldm_fwup_pkg_parser.py b/tools/fw-update/pldm_fwup_pkg_parser.py
new file mode 100755
index 0000000..a85a527
--- /dev/null
+++ b/tools/fw-update/pldm_fwup_pkg_parser.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+
+"""
+Script to unpack PLDM FW update package (DSP0267)
+Reverses the logic of pldm_fwup_pkg_creator.py
+"""
+
+import argparse
+import binascii
+import json
+import os
+import struct
+import sys
+from datetime import datetime
+
+def read_bytes(f, length):
+    data = f.read(length)
+    if len(data) != length:
+        raise ValueError(f"Unexpected EOF: expected {length} bytes, got {len(data)}")
+    return data
+
+def parse_bitfield(value, length):
+    """Convert an integer to a list of set bit indices (0-based)."""
+    flags = []
+    for i in range(length):
+        if (value >> i) & 1:
+            flags.append(i)
+    return flags
+
+def decode_pldm_string(str_data, str_type):
+    """Decodes bytes based on PLDM string type."""
+    try:
+        # Strip null terminators for cleaner JSON
+        if str_type == 1: # ASCII
+            return str_data.decode('ascii').strip('\x00')
+        elif str_type == 2: # UTF8
+            return str_data.decode('utf-8').strip('\x00')
+        elif str_type in [3, 4]: # UTF16/LE
+            return str_data.decode('utf-16le').strip('\x00')
+        elif str_type == 5: # UTF16BE
+            return str_data.decode('utf-16be').strip('\x00')
+        else:
+            return str_data.hex()
+    except UnicodeDecodeError:
+        return str_data.hex()
+
+def parse_contiguous_pldm_string(f):
+    """Reads Type, Length, and String data assuming they are contiguous."""
+    b_type = read_bytes(f, 1)
+    b_len = read_bytes(f, 1)
+
+    str_type = struct.unpack("<B", b_type)[0]
+    str_len = struct.unpack("<B", b_len)[0]
+
+    if str_len == 0:
+        return ""
+
+    str_data = read_bytes(f, str_len)
+    return decode_pldm_string(str_data, str_type)
+
+def parse_timestamp(f):
+    """Parses the 13-byte PLDM timestamp."""
+    data = read_bytes(f, 13)
+    us = int.from_bytes(data[2:5], byteorder='little')
+    sec = data[5]
+    minute = data[6]
+    hour = data[7]
+    day = data[8]
+    month = data[9]
+    year = int.from_bytes(data[10:12], byteorder='little')
+
+    try:
+        dt = datetime(year, month, day, hour, minute, sec, us)
+        return dt.strftime("%Y-%m-%d %H:%M:%S")
+    except ValueError:
+        return "Invalid DateTime"
+
+def unpack_package(pkg_path, output_dir):
+    if not os.path.exists(output_dir):
+        os.makedirs(output_dir)
+
+    metadata = {
+            "PackageHeaderInformation": {},
+            "FirmwareDeviceIdentificationArea": [],
+            "ComponentImageInformationArea": []
+            }
+
+    with open(pkg_path, "rb") as f:
+        # --- 1. Parse Package Header ---
+        uuid_bytes = read_bytes(f, 16)
+        metadata["PackageHeaderInformation"]["PackageHeaderIdentifier"] = uuid_bytes.hex().upper()
+
+        fmt_ver = struct.unpack("<B", read_bytes(f, 1))[0]
+        metadata["PackageHeaderInformation"]["PackageHeaderFormatVersion"] = fmt_ver
+
+        pkg_header_size = struct.unpack("<H", read_bytes(f, 2))[0]
+
+        timestamp = parse_timestamp(f)
+        metadata["PackageHeaderInformation"]["PackageReleaseDateTime"] = timestamp
+
+        comp_bitmap_len = struct.unpack("<H", read_bytes(f, 2))[0]
+
+        pkg_ver_str = parse_contiguous_pldm_string(f)
+        metadata["PackageHeaderInformation"]["PackageVersionString"] = pkg_ver_str
+
+        # --- 2. Parse Firmware Device Identification Area ---
+        dev_rec_count = struct.unpack("<B", read_bytes(f, 1))[0]
+
+        for i in range(dev_rec_count):
+            dev_record = {}
+
+            # Track start for alignment check
+            record_start_pos = f.tell()
+
+            # RecordLength - 2 bytes
+            rec_len = struct.unpack("<H", read_bytes(f, 2))[0]
+
+            # DescriptorCount - 1 byte
+            desc_count = struct.unpack("<B", read_bytes(f, 1))[0]
+
+            # DeviceUpdateOptionFlags - 4 bytes
+            opt_flags_int = struct.unpack("<I", read_bytes(f, 4))[0]
+            dev_record["DeviceUpdateOptionFlags"] = parse_bitfield(opt_flags_int, 32)
+
+            # --- CORRECTION START ---
+            # The creator script packs: [Type][Len][PkgDataLen][AppComp][StringData]
+
+            # 1. Read Version String Type (1B) and Length (1B)
+            ver_str_type = struct.unpack("<B", read_bytes(f, 1))[0]
+            ver_str_len = struct.unpack("<B", read_bytes(f, 1))[0]
+
+            # 2. Read FirmwareDevicePackageDataLength (2 bytes)
+            pkg_data_len = struct.unpack("<H", read_bytes(f, 2))[0]
+
+            # 3. Read ApplicableComponents (Bitmap)
+            app_comp_byte_len = (comp_bitmap_len + 7) // 8
+            app_comp_bytes = read_bytes(f, app_comp_byte_len)
+            app_comp_int = int.from_bytes(app_comp_bytes, byteorder='little')
+            dev_record["ApplicableComponents"] = parse_bitfield(app_comp_int, comp_bitmap_len)
+
+            # 4. NOW Read the Version String Data (ver_str_len bytes)
+            if ver_str_len > 0:
+                ver_str_data = read_bytes(f, ver_str_len)
+                dev_record["ComponentImageSetVersionString"] = decode_pldm_string(ver_str_data, ver_str_type)
+            else:
+                dev_record["ComponentImageSetVersionString"] = ""
+
+            # 5. Handle Package Data (if any) - Creator sets len to 0 usually
+            if pkg_data_len > 0:
+                read_bytes(f, pkg_data_len)
+
+            # --- CORRECTION END ---
+
+            # Descriptors
+            descriptors = []
+            for _ in range(desc_count):
+                desc = {}
+                d_type = struct.unpack("<H", read_bytes(f, 2))[0]
+                d_len = struct.unpack("<H", read_bytes(f, 2))[0]
+
+                if d_type == 0xFFFF: # Vendor Defined
+                    title_type = struct.unpack("<B", read_bytes(f, 1))[0]
+                    title_len = struct.unpack("<B", read_bytes(f, 1))[0]
+
+                    if title_len > 0:
+                        title_str = read_bytes(f, title_len).decode('ascii').strip('\x00')
+                    else:
+                        title_str = ""
+
+                    data_len = d_len - (1 + 1 + title_len)
+                    vendor_data = read_bytes(f, data_len)
+
+                    desc["DescriptorType"] = d_type
+                    desc["VendorDefinedDescriptorTitleString"] = title_str
+                    desc["VendorDefinedDescriptorData"] = vendor_data.hex().upper()
+                else:
+                    d_data = read_bytes(f, d_len)
+                    desc["DescriptorType"] = d_type
+                    desc["DescriptorData"] = d_data.hex().upper()
+
+                descriptors.append(desc)
+
+            dev_record["Descriptors"] = descriptors
+            metadata["FirmwareDeviceIdentificationArea"].append(dev_record)
+
+            # Force alignment to ensure robustness
+            bytes_consumed = f.tell() - record_start_pos
+            if bytes_consumed != rec_len:
+                print(f"  [Info] Re-aligning Record {i}. Consumed {bytes_consumed} / Expected {rec_len}")
+                f.seek(record_start_pos + rec_len)
+
+        # --- 3. Parse Component Image Information Area ---
+
+        comp_count = struct.unpack("<H", read_bytes(f, 2))[0]
+        print(f"Found {comp_count} components.")
+
+        extraction_list = []
+
+        for i in range(comp_count):
+            comp_info = {}
+            comp_info["ComponentClassification"] = struct.unpack("<H", read_bytes(f, 2))[0]
+            comp_id = struct.unpack("<H", read_bytes(f, 2))[0]
+            comp_info["ComponentIdentifier"] = comp_id
+
+            stamp = struct.unpack("<I", read_bytes(f, 4))[0]
+            comp_info["ComponentComparisonStamp"] = f"0x{stamp:08X}"
+
+            opts_int = struct.unpack("<H", read_bytes(f, 2))[0]
+            comp_info["ComponentOptions"] = parse_bitfield(opts_int, 16)
+
+            act_int = struct.unpack("<H", read_bytes(f, 2))[0]
+            comp_info["RequestedComponentActivationMethod"] = parse_bitfield(act_int, 16)
+
+            loc_offset = struct.unpack("<I", read_bytes(f, 4))[0]
+            size = struct.unpack("<I", read_bytes(f, 4))[0]
+
+            # For Component Info, Creator packs contiguous (Type, Len, String)
+            comp_info["ComponentVersionString"] = parse_contiguous_pldm_string(f)
+
+            metadata["ComponentImageInformationArea"].append(comp_info)
+            extraction_list.append((loc_offset, size, comp_id, i))
+
+        # --- 4. Verify Checksum ---
+        # Read Checksum to advance pointer, but we trust the unpacking logic
+        _ = read_bytes(f, 4)
+        print(f"Header parsed successfully. Final Offset: {f.tell()}")
+
+        # --- 5. Extract Images ---
+        print(f"Extracting {len(extraction_list)} component images...")
+
+        for offset, size, comp_id, idx in extraction_list:
+            f.seek(offset)
+            image_data = read_bytes(f, size)
+
+            filename = f"component_{idx}_{comp_id}.bin"
+            filepath = os.path.join(output_dir, filename)
+
+            with open(filepath, 'wb') as img_f:
+                img_f.write(image_data)
+            print(f"  Extracted: {filepath} (Size: {size} bytes)")
+
+    # --- 6. Write Metadata JSON ---
+    json_path = os.path.join(output_dir, "metadata.json")
+    with open(json_path, 'w') as json_f:
+        json.dump(metadata, json_f, indent=4)
+
+    print(f"Metadata written to: {json_path}")
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Unpack PLDM FW Update Package")
+    parser.add_argument("package_file", help="Path to the .pldm package file")
+    parser.add_argument("-o", "--output", default="output", help="Output directory")
+
+    args = parser.parse_args()
+
+    try:
+        unpack_package(args.package_file, args.output)
+    except Exception as e:
+        print(f"Error: {e}")
+        sys.exit(1)
+
+if __name__ == "__main__":
+    main()