Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions psptool/blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class Blob(NestedBuffer):
_FIRMWARE_ENTRY_MAGIC = b'\xAA\x55\xAA\x55'
# All structures per Rom must be in 16MB windows
_MAX_PAGE_SIZE = 16 * 1024 * 1024
# Assumed only when a file entry points past the ROM size taken from the
# input size, so a 32 MB ROM inside a bigger file keeps its size
_BIG_ROM_SIZE = 64 * 1024 * 1024

# ROM-relative offsets at which a FET may legitimately appear, in the
# try-order used by the parser (the loop breaks on first successful
Expand Down Expand Up @@ -60,7 +63,7 @@ def __init__(self, buffer: bytearray, size: int, psptool):
rom_size = _rom_size * 1024 * 1024
self.psptool.ph.print_warning(f"Input file is {self.buffer_size:#x}, will assume ROM size of {_rom_size}M")

# For each FET, we try to create a 16MB ROM starting at `FET - offset`
# For each FET, we try to create a ROM starting at `FET - offset`
for fet_location in self._find_fets():
fet_parsed = False
for fet_offset in self.POSSIBLE_FET_OFFSETS:
Expand All @@ -75,7 +78,7 @@ def __init__(self, buffer: bytearray, size: int, psptool):
rom_offset = fet_location - fet_offset # e.g. 0x20800 - 0x20000 = 0x0800
# W/A for images with single ROMs that have entries crossing 16MB boundary
if rom_page == 0:
potential_rom = Rom(self, rom_size, rom_offset, fet_offset, psptool)
potential_rom = self._create_first_page_rom(rom_size, rom_offset, fet_offset)
else:
potential_rom = Rom(self, min(rom_size, self._MAX_PAGE_SIZE), rom_offset, fet_offset, psptool)
self.roms.append(potential_rom)
Expand All @@ -95,6 +98,26 @@ def __init__(self, buffer: bytearray, size: int, psptool):
def __repr__(self):
return f'Blob({self.roms=})'

def _create_first_page_rom(self, rom_size, rom_offset, fet_offset):
psptool = self.psptool
directories = dict(psptool.directories_by_offset)
files = dict(psptool.files_by_offset)
references = {file: len(file.references) for file in files.values()}
try:
return Rom(self, rom_size, rom_offset, fet_offset, psptool)
except AssertionError as e:
if 'overflows ROM bounds' not in str(e) or rom_offset + self._BIG_ROM_SIZE > self.buffer_size:
raise

# Drop what the failed attempt registered before parsing again
psptool.directories_by_offset = directories
psptool.files_by_offset = files
for file, count in references.items():
del file.references[count:]
psptool.ph.print_warning(f"File offset past {rom_size >> 20}M, will assume ROM size of "
f"{self._BIG_ROM_SIZE >> 20}M")
return Rom(self, self._BIG_ROM_SIZE, rom_offset, fet_offset, psptool)

def _construct_range_dict(self):
all_files = self.unique_files()

Expand Down
41 changes: 28 additions & 13 deletions psptool/directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ def create_directories_if_not_exist(cls, offset, fet, zen_generation=None) -> Li
if "Empty entry" in str(e):
fet.psptool.ph.print_warning(f"Skipping empty directory entry at offset 0x{offset:x}")
return []
else:
# Re-raise other parse errors
raise
# Some slot headers point at other data, such as an APCB
fet.psptool.ph.print_warning(f"Skipping non-directory at offset 0x{offset:x}")
return []

# 2. Recursively add secondary directories referenced by the just created directory, if applicable
for secondary_directory_offset in directory.secondary_directory_offsets:
Expand All @@ -101,17 +101,26 @@ def create_directories_if_not_exist(cls, offset, fet, zen_generation=None) -> Li
# 3. Recursively add tertiary directories (double references introduced in Zen 4), if applicable
for tertiary_directory_offset in directory.tertiary_directory_offsets:
directory_body = fet.rom.get_bytes(tertiary_directory_offset, 32)

# Some ROMs point straight at the L2 directory instead of at a
# header holding its offset, so there is nothing to resolve
if directory_body[:4] in cls.DIRECTORY_MAGICS + BiosDirectory.DIRECTORY_MAGICS:
created_directories += cls.create_directories_if_not_exist(
tertiary_directory_offset, fet, zen_generation)
continue

actual_tertiary_offset = int.from_bytes(directory_body[16:20], 'little')
zen_generation_id = directory_body[21:24]
zen_generation = cls.get_possible_zen_generation(zen_generation_id)
if zen_generation == 'unknown':
tertiary_zen_generation = cls.get_possible_zen_generation(zen_generation_id)
if tertiary_zen_generation == 'unknown':
fet.psptool.ph.print_warning(f"Unknown {zen_generation_id=}")

zen_generation_id = hex(int.from_bytes(directory_body[20:24], 'little'))
zen_generation += f' (PSP ID {zen_generation_id})'
tertiary_zen_generation += f' (PSP ID {zen_generation_id})'

# Resolve one more indirection
tertiary_directories = cls.create_directories_if_not_exist(actual_tertiary_offset, fet, zen_generation)
tertiary_directories = cls.create_directories_if_not_exist(
actual_tertiary_offset, fet, tertiary_zen_generation)
created_directories += tertiary_directories

return created_directories
Expand Down Expand Up @@ -257,12 +266,18 @@ def update_entry_fields(self, file: File, type_, size, offset):
self.update_checksum()

def update_zen_generation(self, fet, zen_generation):
if zen_generation is not None:
if zen_generation not in self.zen_generation:
self.zen_generation += '\n' + zen_generation
for offset in self.secondary_directory_offsets:
dir = fet.psptool.directories_by_offset[offset]
dir.update_zen_generation(fet, zen_generation)
if zen_generation is None:
return
# A directory first reached without a generation takes the new one
if self.zen_generation is None:
self.zen_generation = zen_generation
elif zen_generation not in self.zen_generation:
self.zen_generation += '\n' + zen_generation
else:
return
for offset in self.secondary_directory_offsets:
dir = fet.psptool.directories_by_offset[offset]
dir.update_zen_generation(fet, zen_generation)


class BiosDirectory(Directory):
Expand Down
5 changes: 5 additions & 0 deletions psptool/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ def file_offset(self):
# entry address mode must be taken into account, otherwise ignored.
if addr_mode == 2 or addr_mode == 3:
addr_mode = self.address_mode
# coreboot's amdfwtool writes APOB NV entries on older SoCs as an x86
# physical address (entry address mode 0) in a mode 1 directory. No
# flash offset reaches 0xff000000.
elif addr_mode == 1 and self.address_mode == 0 and self.offset >= 0xFF000000:
addr_mode = 0

if addr_mode == 0:
# x86 physical address, should be in range 0xff000000 - 0xffffffff
Expand Down
4 changes: 4 additions & 0 deletions psptool/header_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ def _parse(self):
if self.has_sha256_checksum and self.has_sha384_checksum:
raise File.ParseError('File should not have both sha256 and sha384 checksum bits set!')

if self.buffer_offset + self.rom_size > self.parent_buffer.buffer_size:
raise File.ParseError(f'Header size 0x{self.rom_size:x} overflows the parent buffer, '
f'likely not a PSP header')

if self.rom_size == 0:
self.rom_size = self.buffer_size
elif self.rom_size > self.buffer_size:
Expand Down
189 changes: 189 additions & 0 deletions tests/unit/test_rom_layouts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# PSPTool - Display, extract and manipulate PSP firmware inside UEFI images
# Copyright (C) 2026 contributors
#
# 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 3 of the License, or
# (at your option) any later version.

"""Synthetic ROMs for layouts seen in Lenovo ThinkPad client firmware.

- A 64 MB flash whose BIOS L2 directory points past 32 MB (Z13 Gen 2),
and 32 MB ROMs in files of 64 MB that must keep their size.
- 0x48/0x4a entries pointing straight at a $PL2 directory rather than at
a header holding its offset (L13 Gen 4).
- A BIOS directory entry holding a plain PE image, whose bytes at the
header's size field are text (L13 Gen 4).
- A 0x4a slot header pointing at an APCB rather than at a $PL2 directory
(T14 Gen 7 AMD).
- An x86 physical address with entry address mode 0 in a directory with
address mode 1, as coreboot writes APOB NV entries (StarBook Mk VI AMD).
"""

import contextlib
import io
import os
import struct
import tempfile
import unittest

from psptool import PSPTool
from psptool.blob import Blob
from psptool.utils import fletcher32

MB = 1024 * 1024
FET_OFFSET = Blob.POSSIBLE_FET_OFFSETS[0]
PSP_DIR_OFFSET = FET_OFFSET + 0x1000
HEADER_FILE_SIZE = 0x100

# Directory additional_info: version bit set, address mode 1 (flash offset)
FLASH_OFFSET_MODE = (1 << 31) | (1 << 24)


def directory(magic: bytes, entries) -> bytes:
body = struct.pack('<II', len(entries), FLASH_OFFSET_MODE)
for type_, size, offset in entries:
body += struct.pack('<BBHIII', type_, 0, 0, size, offset, 0)
return magic + fletcher32(body) + body


def header_file(rom_size: int = 0) -> bytes:
header = bytearray(HEADER_FILE_SIZE)
header[0x62] = 0x0C # boot loader major in BOOTLOADER_VERSION_TO_ZEN, else PSPTool warns
header[0x6c:0x70] = struct.pack('<I', rom_size)
return bytes(header)


def rom(size: int, psp_entries, extra=()) -> bytes:
blob = bytearray(size)
blob[FET_OFFSET - 4:FET_OFFSET] = b'\xff' * 4
blob[FET_OFFSET:FET_OFFSET + 4] = Blob._FIRMWARE_ENTRY_MAGIC
blob[FET_OFFSET + 4:FET_OFFSET + 8] = struct.pack('<I', PSP_DIR_OFFSET)
blob[FET_OFFSET + 8:FET_OFFSET + 24] = b'\xff' * 16
psp_dir = directory(b'$PSP', psp_entries)
blob[PSP_DIR_OFFSET:PSP_DIR_OFFSET + len(psp_dir)] = psp_dir
for offset, data in extra:
blob[offset:offset + len(data)] = data
return bytes(blob)


def parse(data: bytes):
with tempfile.NamedTemporaryFile(suffix='.rom', delete=False) as f:
f.write(data)
path = f.name
try:
with io.StringIO() as stderr, contextlib.redirect_stderr(stderr):
pt = PSPTool.from_file(path)
return pt, stderr.getvalue()
finally:
os.unlink(path)


def files(pt):
return {(f.type, f.get_address()) for f in pt.blob.unique_files()}


class TestRomLayouts(unittest.TestCase):

def test_64mb_rom_reaches_files_past_32mb(self):
file_offset = 48 * MB
data = rom(64 * MB, [(0x01, HEADER_FILE_SIZE, file_offset)],
[(file_offset, header_file())])
pt, warnings = parse(data)
self.assertIn('ROM size of 64M', warnings)
self.assertEqual(pt.blob.roms[0].addr_mask, 64 * MB - 1)
self.assertIn((0x01, file_offset), files(pt))

def test_32mb_rom_in_64mb_file_keeps_its_size(self):
file_offset = 16 * MB
data = rom(64 * MB, [(0x01, HEADER_FILE_SIZE, file_offset)],
[(file_offset, header_file())])
pt, warnings = parse(data)
self.assertNotIn('ROM size of 64M', warnings)
self.assertEqual(pt.blob.roms[0].addr_mask, 32 * MB - 1)
self.assertIn((0x01, file_offset), files(pt))

def test_32mb_rom_at_offset_in_64mb_file(self):
rom_offset = 0x320
file_offset = 16 * MB
data = bytes(rom_offset) + rom(64 * MB - rom_offset, [(0x01, HEADER_FILE_SIZE, file_offset)],
[(file_offset, header_file())])
pt, _ = parse(data)
self.assertEqual(len(pt.blob.roms), 1)
self.assertEqual(pt.blob.roms[0].addr_mask, 32 * MB - 1)
self.assertEqual({type_ for type_, _ in files(pt)}, {0x01})

def test_l2_pointer_straight_at_directory(self):
l2_offset = FET_OFFSET + 0x4000
file_offset = FET_OFFSET + 0x5000
data = rom(8 * MB, [(0x48, 0x400, l2_offset)], [
(l2_offset, directory(b'$PL2', [(0x01, HEADER_FILE_SIZE, file_offset)])),
(file_offset, header_file()),
])
pt, _ = parse(data)
magics = [d.magic for d in pt.blob.roms[0].directories]
self.assertEqual(magics, [b'$PSP', b'$PL2'])
self.assertIn((0x01, file_offset), files(pt))

def test_l2_reached_directly_and_through_ish(self):
l2_offset = FET_OFFSET + 0x4000
ish_offset = FET_OFFSET + 0x3000
file_offset = FET_OFFSET + 0x5000
ish = bytes(16) + struct.pack('<I', l2_offset) + b'\x00\x01\x0C\xBC'
data = rom(8 * MB, [(0x48, 0x400, l2_offset), (0x4a, 0x20, ish_offset)], [
(ish_offset, ish),
(l2_offset, directory(b'$PL2', [(0x01, HEADER_FILE_SIZE, file_offset)])),
(file_offset, header_file()),
])
pt, _ = parse(data)
directories = pt.blob.roms[0].directories
self.assertEqual([d.magic for d in directories], [b'$PSP', b'$PL2'])
self.assertEqual(directories[1].zen_generation, 'Zen 3 (PSP ID 0xbc0c0100)')

def test_header_size_past_rom_skips_file(self):
bad_offset = FET_OFFSET + 0x4000
good_offset = FET_OFFSET + 0x5000
data = rom(8 * MB, [
(0x01, HEADER_FILE_SIZE, bad_offset),
(0x73, HEADER_FILE_SIZE, good_offset),
], [
(bad_offset, header_file(rom_size=0x6b20796e)),
(good_offset, header_file(rom_size=2 * HEADER_FILE_SIZE)),
])
pt, warnings = parse(data)
self.assertIn('overflows the parent buffer', warnings)
self.assertEqual(files(pt), {(0x73, good_offset)})
# A header size past the entry size but inside the ROM still counts
(good,) = pt.blob.unique_files()
self.assertEqual(good.buffer_size, 2 * HEADER_FILE_SIZE)

def test_slot_header_pointing_at_non_directory_is_skipped(self):
ish_a = FET_OFFSET + 0x2000
ish_b = FET_OFFSET + 0x3000
l2_offset = FET_OFFSET + 0x4000
apcb_offset = FET_OFFSET + 0x6000
file_offset = FET_OFFSET + 0x5000
zen_id = b'\x00\x01\x0C\xBC'
data = rom(8 * MB, [(0x48, 0x20, ish_a), (0x4a, 0x20, ish_b)], [
(ish_a, bytes(16) + struct.pack('<I', l2_offset) + zen_id),
(ish_b, bytes(16) + struct.pack('<I', apcb_offset) + zen_id),
(l2_offset, directory(b'$PL2', [(0x01, HEADER_FILE_SIZE, file_offset)])),
(apcb_offset, b'APCB' + bytes(12)),
(file_offset, header_file()),
])
pt, warnings = parse(data)
self.assertIn('Unknown directory magic', warnings)
magics = [d.magic for d in pt.blob.roms[0].directories]
self.assertEqual(magics, [b'$PSP', b'$PL2'])
self.assertIn((0x01, file_offset), files(pt))

def test_physical_address_in_flash_offset_directory(self):
file_offset = FET_OFFSET + 0x4000
data = rom(16 * MB, [(0x01, HEADER_FILE_SIZE, 0xFF000000 + file_offset)],
[(file_offset, header_file())])
pt, _ = parse(data)
self.assertEqual(files(pt), {(0x01, file_offset)})


if __name__ == '__main__':
unittest.main()
Loading