Files
bonaminandClaude Opus 5.5 82cf95a9af feat(firmware): ELF symbol store for server-side crash decoding
Crash reports carry elf_sha256 (first 16 hex chars of the crashing build's
ELF SHA-256). ELFs are now stored under that key in
{firmware_storage_path}/elf/, uploaded either with a firmware release
(optional elf_file on POST /upload and PUT /{id}) or standalone via
POST /api/firmware/elf. The ELF is validated before the release is created.

GET /api/firmware/elf/{sha} reports whether that build's ELF exists and
whether the decoder is available; POST /api/firmware/elf/{sha}/decode runs
xtensa-esp32-elf-addr2line -pfiaC over the addresses. The decoder is looked
up via settings.addr2line_path or PATH. It is NOT in the Docker image yet,
so decoding reports "not installed" until the toolchain is added; the
console falls back to a copy-paste addr2line command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:28:41 +03:00

130 lines
4.5 KiB
Python

"""Firmware ELF symbol store + server-side crash decoding (firmware F-070).
Crash reports carry `elf_sha256`: the first 16 hex chars of the SHA-256 of the
crashing build's firmware.elf. ELFs uploaded here are stored under that same
key, so a crash can be matched to its exact build and its PC/backtrace
addresses decoded to function + file:line with xtensa addr2line.
Decoding is optional: it needs a matching ELF AND the xtensa-esp32-elf-addr2line
binary in the backend container (settings.addr2line_path, or on PATH). When
either is missing the console falls back to a copy-paste addr2line command.
"""
import hashlib
import json
import re
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from config import settings
from shared.exceptions import NotFoundError, ValidationError
ADDR2LINE_NAME = "xtensa-esp32-elf-addr2line"
_SHA_PREFIX = re.compile(r"^[0-9a-f]{16}$")
_MAX_ADDRESSES = 40
def _elf_dir() -> Path:
return Path(settings.firmware_storage_path) / "elf"
def elf_sha_prefix(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()[:16]
def _check_sha(sha: str) -> str:
sha = (sha or "").lower()
if not _SHA_PREFIX.match(sha):
raise ValidationError("elf_sha256 must be 16 hex characters.")
return sha
def addr2line_path() -> str | None:
configured = getattr(settings, "addr2line_path", "") or ""
if configured and Path(configured).is_file():
return configured
return shutil.which(ADDR2LINE_NAME)
def validate_elf(data: bytes) -> None:
if not data.startswith(b"\x7fELF"):
raise ValidationError("Not an ELF file — upload the firmware.elf from the build, not the .bin.")
def save_elf(data: bytes, filename: str | None, uploaded_by: str | None,
firmware: dict | None = None) -> dict:
"""Store an ELF keyed by its sha256 prefix. Re-uploading the same file is a
no-op overwrite. `firmware` optionally records which release it belongs to."""
validate_elf(data)
sha = elf_sha_prefix(data)
d = _elf_dir()
d.mkdir(parents=True, exist_ok=True)
(d / f"{sha}.elf").write_bytes(data)
meta = {
"elf_sha256": sha,
"filename": filename or "firmware.elf",
"size": len(data),
"uploaded_at": datetime.now(timezone.utc).isoformat(),
"uploaded_by": uploaded_by,
"firmware": firmware,
}
(d / f"{sha}.json").write_text(json.dumps(meta), encoding="utf-8")
return meta
def get_elf_info(sha: str) -> dict:
"""Whether a matching ELF exists and whether the decoder is installed —
the crash view uses this to pick decode-in-console vs. copy-command."""
sha = _check_sha(sha)
meta_path = _elf_dir() / f"{sha}.json"
meta = None
if (_elf_dir() / f"{sha}.elf").is_file():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
meta = {"elf_sha256": sha}
return {
"elf_sha256": sha,
"exists": meta is not None,
"meta": meta,
"decoder_available": addr2line_path() is not None,
}
def decode(sha: str, addresses: list[int]) -> list[dict]:
"""Run addr2line -pfiaC over the addresses. Returns one entry per address:
{"address": "0x400D8A3F", "frames": ["func at file:line", "(inlined by) ..."]}."""
sha = _check_sha(sha)
elf = _elf_dir() / f"{sha}.elf"
if not elf.is_file():
raise NotFoundError("ELF")
tool = addr2line_path()
if tool is None:
raise ValidationError(f"{ADDR2LINE_NAME} is not installed on the server.")
addrs = [a for a in addresses if isinstance(a, int) and 0 <= a <= 0xFFFFFFFF][:_MAX_ADDRESSES]
if not addrs:
return []
hex_addrs = [f"0x{a:08X}" for a in addrs]
proc = subprocess.run(
[tool, "-pfiaC", "-e", str(elf), *hex_addrs],
capture_output=True, text=True, timeout=20,
)
if proc.returncode != 0:
raise ValidationError(f"addr2line failed: {proc.stderr.strip()[:300]}")
# With -a each address starts a new block "0x400d8a3f: func at file:line";
# -i adds " (inlined by) ..." continuation lines under it.
results: list[dict] = []
for line in proc.stdout.splitlines():
if not line.strip():
continue
m = re.match(r"^(0x[0-9a-fA-F]+):\s*(.*)$", line)
if m:
results.append({"address": f"0x{int(m.group(1), 16):08X}", "frames": [m.group(2).strip()]})
elif results:
results[-1]["frames"].append(line.strip())
return results