Skip to content
Open
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
48 changes: 46 additions & 2 deletions cle/backends/static_archive.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,52 @@
from __future__ import annotations

import logging
from io import SEEK_END, BufferedReader

import arpy

from cle.errors import CLEInvalidBinaryError

from .backend import Backend, register_backend

log = logging.getLogger(__name__)

# An ar member header is 16 bytes of name, 12 + 6 + 6 + 8 of metadata, 10 bytes of decimal size, then this magic.
AR_HEADER_LEN = 60
AR_HEADER_MAGIC = b"`\n"


def _skip_sym64_symbol_table(stream: BufferedReader, offset: int) -> int:
"""
Return the offset of the first member of an ar archive, skipping the symbol table at ``offset`` if it is the 64-bit
variant.

The symbol table is the first member of the archive, named "/" for the 32-bit index and "/SYM64/" for the 64-bit
one. arpy only knows the first spelling: it classifies "/SYM64/" as a member whose real name lives in the long
filename table, then raises ValueError parsing "SYM64" as the offset into that table. arpy discards the symbol
table for either spelling, so skipping the member loses nothing.

Anything that does not look like a 64-bit symbol table lying inside the file leaves the offset alone, so arpy
reports malformed archives as usual.
"""
stream.seek(0, SEEK_END)
file_len = stream.tell()

stream.seek(offset)
header = stream.read(AR_HEADER_LEN)
if len(header) < AR_HEADER_LEN or header[58:60] != AR_HEADER_MAGIC or header[:16].rstrip() != b"/SYM64/":
return offset

try:
size = int(header[48:58])
except ValueError:
return offset

end = offset + AR_HEADER_LEN + size
if size < 0 or end > file_len:
return offset
return end + end % 2 # members start at an even offset


class StaticArchive(Backend):
@classmethod
Expand All @@ -27,8 +66,13 @@ def __init__(self, *args, **kwargs):
if self.loader._main_object is None:
self.loader._main_object = self

ar = arpy.Archive(fileobj=self._binary_stream)
ar.read_all_headers()
try:
ar = arpy.Archive(fileobj=self._binary_stream)
ar.next_header_offset = _skip_sym64_symbol_table(self._binary_stream, ar.next_header_offset)
ar.read_all_headers()
except (arpy.ArchiveFormatError, arpy.ArchiveAccessError, ValueError) as e:
raise CLEInvalidBinaryError(f"Malformed static archive {self.binary_basename}") from e

for name, stream in ar.archived_files.items():
child = self.loader._load_object_isolated(stream)
child.binary = child.binary_basename = name.decode()
Expand Down
99 changes: 99 additions & 0 deletions tests/test_static_archive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
from __future__ import annotations

import os
import tempfile
import unittest

import cle

TEST_BASE = os.path.join(os.path.dirname(os.path.realpath(__file__)), os.path.join("..", "..", "binaries"))

# A MIPS64 big-endian static library indexed by a 64-bit "/SYM64/" symbol table, holding one member.
SYM64_ARCHIVE = os.path.join(TEST_BASE, "tests", "mips64", "sym64_archive.a")

# An ARM static library indexed by an ordinary 32-bit "/" symbol table, with a "//" long filename table.
GNU_ARCHIVE = os.path.join(
TEST_BASE,
"tests_src",
"i2c_master_read-nucleol152re",
"mbed",
"TARGET_NUCLEO_L152RE",
"TOOLCHAIN_GCC_ARM",
"libmbed.a",
)

# The symbol table is the first member of an archive, so its header follows the 8-byte global header. Inside a 60-byte
# member header the decimal size occupies bytes 48 to 58 and the terminating magic occupies the last two.
FIRST_HEADER = 8
SIZE_FIELD = slice(48, 58)
MAGIC_FIELD = slice(58, 60)


def patched(path: str, header_offset: int, field: slice, value: bytes) -> bytes:
"""
Read an archive and overwrite one field of the member header at `header_offset`, to get a malformed archive that
differs from a real one in exactly that field.
"""
with open(path, "rb") as f:
data = bytearray(f.read())
data[header_offset + field.start : header_offset + field.stop] = value
return bytes(data)


class TestStaticArchive(unittest.TestCase):
"""
Test the AR backend.
"""

@staticmethod
def _load_bytes(data: bytes) -> cle.Loader:
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "libtest.a")
with open(path, "wb") as f:
f.write(data)
return cle.Loader(path, auto_load_libs=False)

@staticmethod
def _load_archive(path: str, **options) -> cle.StaticArchive:
"""
Load an archive from disk, which the AR backend has to claim.
"""
ld = cle.Loader(path, auto_load_libs=False, **options)
assert isinstance(ld.main_object, cle.StaticArchive)
return ld.main_object

def test_symbol_table(self):
# An ordinary archive must keep loading: the 64-bit symbol table is skipped by name, so nothing else moves.
# rebase_granularity works around an unrelated R_ARM_THM_CALL range failure on this library, as cle's own error
# message for it suggests.
archive = self._load_archive(GNU_ARCHIVE, rebase_granularity=0x1000)
assert archive.arch.name == "ARMCortexM"
children = [child.binary_basename for child in archive.child_objects]
assert children[:3] == ["AnalogIn.o", "BusIn.o", "BusOut.o"]
# Names too long for the 16-byte header field come out of the "//" table.
assert "mbed_wait_api_no_rtos.o" in children

def test_sym64_symbol_table(self):
archive = self._load_archive(SYM64_ARCHIVE)
assert archive.arch.name == "MIPS64"
assert archive.arch.memory_endness == "Iend_BE"
children = [child.binary_basename for child in archive.child_objects]
assert children == ["x11_xcb.o"]
symbols = {symbol.name for symbol in archive.child_objects[0].symbols}
assert "XGetXCBConnection" in symbols

def test_bad_header_magic(self):
# A member header with no terminating magic. arpy raises ArchiveFormatError, which is not a CLEError.
with self.assertRaises(cle.CLEInvalidBinaryError):
self._load_bytes(patched(GNU_ARCHIVE, FIRST_HEADER, MAGIC_FIELD, b"XX"))

def test_sym64_size_past_end(self):
# A 64-bit symbol table whose header claims a size running past the end of the file. Trusting that size would
# skip the whole archive and drop every member, so leave the table for arpy, which rejects the archive: it
# reads "/SYM64/" as a member whose name is an offset into the long filename table and raises ValueError.
with self.assertRaises(cle.CLEInvalidBinaryError):
self._load_bytes(patched(SYM64_ARCHIVE, FIRST_HEADER, SIZE_FIELD, b"999999999 "))


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