Files
pristontale-eu-linux/src/ptlinux/diagnostics/crash_dumps.py

359 lines
10 KiB
Python

"""Bounded, dependency-free crash signatures for Windows minidumps."""
from __future__ import annotations
import base64
from dataclasses import dataclass
import gzip
import hashlib
from pathlib import Path, PureWindowsPath
import struct
_MINIDUMP_SIGNATURE = b"MDMP"
_HEADER_SIZE = 32
_DIRECTORY_ENTRY_SIZE = 12
_MODULE_ENTRY_SIZE = 108
_THREAD_ENTRY_SIZE = 48
_EXCEPTION_STREAM_TYPE = 6
_MODULE_LIST_STREAM_TYPE = 4
_SYSTEM_INFO_STREAM_TYPE = 7
_THREAD_LIST_STREAM_TYPE = 3
_MAX_DUMP_BYTES = 32 * 1024 * 1024
_MAX_STREAMS = 256
_MAX_MODULES = 4096
_MAX_THREADS = 4096
_MAX_STACK_BYTES = 64 * 1024
_MAX_STACK_SIGNATURES = 12
MAX_CRASH_DUMP_UPLOAD_BYTES = 2 * 1024 * 1024
MAX_CRASH_DUMP_COMPRESSED_BYTES = 30 * 1024
MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS = 40 * 1024
_ARCHITECTURE_NAMES = {
0: "x86",
5: "arm",
6: "ia64",
9: "x86_64",
12: "arm64",
}
_EXCEPTION_NAMES = {
0x80000003: "breakpoint",
0xC0000005: "access_violation",
0xC0000017: "no_memory",
0xC0000094: "integer_divide_by_zero",
0xC00000FD: "stack_overflow",
0xC0000409: "stack_buffer_overrun",
0xE06D7363: "msvc_cpp_exception",
}
@dataclass(frozen=True)
class _Module:
base: int
size: int
name: str
system: bool
def inspect_minidump(path: Path) -> dict[str, object] | None:
"""Return a privacy-safe crash signature, or ``None`` for an unusable dump.
The signature keeps stable exception and module offsets without absolute paths
or memory addresses. Full dump attachment is handled by the separate opt-in path.
"""
try:
with path.open("rb") as handle:
data = handle.read(_MAX_DUMP_BYTES + 1)
except OSError:
return None
if len(data) < _HEADER_SIZE or len(data) > _MAX_DUMP_BYTES:
return None
try:
return _inspect_minidump_bytes(data)
except (OverflowError, struct.error, UnicodeError, ValueError):
return None
def prepare_minidump_attachment(
path: Path,
) -> tuple[str, dict[str, object] | None]:
"""Compress one explicitly approved dump for bounded telemetry delivery."""
try:
with path.open("rb") as handle:
data = handle.read(MAX_CRASH_DUMP_UPLOAD_BYTES + 1)
except OSError:
return "unreadable", None
if len(data) > MAX_CRASH_DUMP_UPLOAD_BYTES:
return "source_too_large", None
try:
if _inspect_minidump_bytes(data) is None:
return "invalid_minidump", None
except (OverflowError, struct.error, UnicodeError, ValueError):
return "invalid_minidump", None
compressed = gzip.compress(data, compresslevel=9, mtime=0)
if len(compressed) > MAX_CRASH_DUMP_COMPRESSED_BYTES:
return "compressed_too_large", None
encoded = base64.urlsafe_b64encode(compressed).rstrip(b"=").decode("ascii")
if len(encoded) > MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS:
return "encoded_too_large", None
return (
"included",
{
"schema": 1,
"kind": "windows_minidump",
"encoding": "gzip+base64url",
"original_size": len(data),
"compressed_size": len(compressed),
"sha256": hashlib.sha256(data).hexdigest(),
"data_base64url": encoded,
},
)
def _inspect_minidump_bytes(data: bytes) -> dict[str, object] | None:
if len(data) < _HEADER_SIZE or data[:4] != _MINIDUMP_SIGNATURE:
return None
_stream_count, directory_rva = struct.unpack_from("<II", data, 8)
if _stream_count > _MAX_STREAMS:
return None
_require_range(data, directory_rva, _stream_count * _DIRECTORY_ENTRY_SIZE)
streams: dict[int, tuple[int, int]] = {}
for index in range(_stream_count):
offset = directory_rva + index * _DIRECTORY_ENTRY_SIZE
stream_type, size, rva = struct.unpack_from("<III", data, offset)
_require_range(data, rva, size)
streams.setdefault(stream_type, (rva, size))
exception_stream = streams.get(_EXCEPTION_STREAM_TYPE)
if exception_stream is None or exception_stream[1] < 40:
return None
exception_rva, _exception_size = exception_stream
thread_id = _read_u32(data, exception_rva)
exception_code = _read_u32(data, exception_rva + 8)
exception_flags = _read_u32(data, exception_rva + 12)
exception_address = _read_u64(data, exception_rva + 24)
exception_parameters = _read_exception_parameters(
data,
exception_rva,
_exception_size,
)
modules = _read_modules(data, streams.get(_MODULE_LIST_STREAM_TYPE))
exception_module = _module_for_address(modules, exception_address)
architecture = _read_architecture(data, streams.get(_SYSTEM_INFO_STREAM_TYPE))
result: dict[str, object] = {
"format": "minidump",
"architecture": architecture,
"exception_code": f"0x{exception_code:08x}",
"exception_kind": _EXCEPTION_NAMES.get(exception_code, "unknown"),
"exception_flags": f"0x{exception_flags:08x}",
"module_count": len(modules),
}
if exception_module is not None:
result.update(
{
"exception_module": exception_module.name,
"exception_module_offset": (
f"0x{exception_address - exception_module.base:x}"
),
}
)
stack_pointer = _read_exception_stack_pointer(
data,
exception_stream,
architecture,
)
stack = _read_exception_stack(
data,
streams.get(_THREAD_LIST_STREAM_TYPE),
thread_id,
stack_pointer=stack_pointer,
)
if stack is not None:
pointer_size = 8 if architecture in {"x86_64", "arm64", "ia64"} else 4
signatures = _application_stack_signatures(
stack,
pointer_size,
modules,
excluded_addresses=frozenset(exception_parameters),
)
if signatures:
result["application_stack"] = signatures
return result
def _read_modules(
data: bytes,
stream: tuple[int, int] | None,
) -> tuple[_Module, ...]:
if stream is None or stream[1] < 4:
return ()
rva, size = stream
count = _read_u32(data, rva)
if count > _MAX_MODULES or 4 + count * _MODULE_ENTRY_SIZE > size:
raise ValueError("invalid minidump module list")
modules: list[_Module] = []
for index in range(count):
offset = rva + 4 + index * _MODULE_ENTRY_SIZE
base = _read_u64(data, offset)
image_size = _read_u32(data, offset + 8)
name_rva = _read_u32(data, offset + 20)
module_path = _read_utf16_string(data, name_rva)
name = PureWindowsPath(module_path).name or "unknown"
folded_path = module_path.replace("/", "\\").casefold()
modules.append(
_Module(
base=base,
size=image_size,
name=name,
system="\\windows\\" in folded_path,
)
)
return tuple(modules)
def _read_architecture(
data: bytes,
stream: tuple[int, int] | None,
) -> str:
if stream is None or stream[1] < 2:
return "unknown"
architecture = struct.unpack_from("<H", data, stream[0])[0]
return _ARCHITECTURE_NAMES.get(architecture, f"architecture_{architecture}")
def _read_exception_stack(
data: bytes,
stream: tuple[int, int] | None,
thread_id: int,
*,
stack_pointer: int | None = None,
) -> bytes | None:
if stream is None or stream[1] < 4:
return None
rva, size = stream
count = _read_u32(data, rva)
if count > _MAX_THREADS or 4 + count * _THREAD_ENTRY_SIZE > size:
raise ValueError("invalid minidump thread list")
for index in range(count):
offset = rva + 4 + index * _THREAD_ENTRY_SIZE
if _read_u32(data, offset) != thread_id:
continue
stack_start = _read_u64(data, offset + 24)
reported_stack_size = _read_u32(data, offset + 32)
stack_rva = _read_u32(data, offset + 36)
stack_offset = 0
if (
stack_pointer is not None
and stack_start <= stack_pointer < stack_start + reported_stack_size
):
stack_offset = stack_pointer - stack_start
stack_size = min(
reported_stack_size - stack_offset,
_MAX_STACK_BYTES,
)
_require_range(data, stack_rva + stack_offset, stack_size)
return data[stack_rva + stack_offset : stack_rva + stack_offset + stack_size]
return None
def _read_exception_parameters(
data: bytes,
exception_rva: int,
exception_size: int,
) -> tuple[int, ...]:
parameter_count = _read_u32(data, exception_rva + 32)
if parameter_count > 15 or 40 + parameter_count * 8 > exception_size:
raise ValueError("invalid minidump exception parameters")
return tuple(
_read_u64(data, exception_rva + 40 + index * 8)
for index in range(parameter_count)
)
def _read_exception_stack_pointer(
data: bytes,
stream: tuple[int, int],
architecture: str,
) -> int | None:
rva, size = stream
if size < 168:
return None
context_size = _read_u32(data, rva + 160)
context_rva = _read_u32(data, rva + 164)
if context_size == 0 or context_rva == 0:
return None
_require_range(data, context_rva, context_size)
if architecture == "x86" and context_size >= 200:
return _read_u32(data, context_rva + 196)
if architecture == "x86_64" and context_size >= 160:
return _read_u64(data, context_rva + 152)
return None
def _application_stack_signatures(
stack: bytes,
pointer_size: int,
modules: tuple[_Module, ...],
*,
excluded_addresses: frozenset[int] = frozenset(),
) -> list[dict[str, str]]:
result: list[dict[str, str]] = []
seen: set[tuple[str, int]] = set()
format_code = "<Q" if pointer_size == 8 else "<I"
for offset in range(0, len(stack) - pointer_size + 1, pointer_size):
address = struct.unpack_from(format_code, stack, offset)[0]
if address in excluded_addresses:
continue
module = _module_for_address(modules, address)
if module is None or module.system:
continue
module_offset = address - module.base
key = (module.name.casefold(), module_offset)
if key in seen:
continue
seen.add(key)
result.append(
{
"module": module.name,
"offset": f"0x{module_offset:x}",
}
)
if len(result) >= _MAX_STACK_SIGNATURES:
break
return result
def _module_for_address(
modules: tuple[_Module, ...],
address: int,
) -> _Module | None:
for module in modules:
if module.base <= address < module.base + module.size:
return module
return None
def _read_utf16_string(data: bytes, rva: int) -> str:
byte_length = _read_u32(data, rva)
if byte_length > 64 * 1024 or byte_length % 2:
raise ValueError("invalid minidump string")
_require_range(data, rva + 4, byte_length)
return data[rva + 4 : rva + 4 + byte_length].decode("utf-16-le")
def _read_u32(data: bytes, offset: int) -> int:
_require_range(data, offset, 4)
return struct.unpack_from("<I", data, offset)[0]
def _read_u64(data: bytes, offset: int) -> int:
_require_range(data, offset, 8)
return struct.unpack_from("<Q", data, offset)[0]
def _require_range(data: bytes, offset: int, size: int) -> None:
if offset < 0 or size < 0 or offset > len(data) or size > len(data) - offset:
raise ValueError("minidump range is outside the file")