from __future__ import annotations import hashlib import json import re from pathlib import Path from typing import Iterable, Optional, Tuple from .models import DeviceCheckConfig _MAC_RE = re.compile(r"(?i)([0-9a-f]{2}:){5}[0-9a-f]{2}") _MAC_OUTPUT_RE = re.compile(r"(?i)\bMAC:\s*([0-9a-f]{2}(?::[0-9a-f]{2}){5})") _ESP_IMAGE_MAGIC = 0xE9 def _sha256_hex(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _wipe_bytearray(buf: Optional[bytearray]) -> None: if not buf: return for idx in range(len(buf)): buf[idx] = 0 def collect_error_hints(output: str) -> list[str]: lowered = output.lower() hints: list[str] = [] if "secure boot detected" in lowered or "writing to flash regions < 0x8000 is disabled" in lowered: hints.append("检测到写入限制:请按工艺选择模式。") hints.append("请按工艺选择模式后重试。") if "secure download mode is enabled" in lowered: hints.append("检测到下载限制:请按工艺选择模式。") if "could not open port" in lowered or "permissionerror" in lowered or "access is denied" in lowered: hints.append("串口被占用或权限不足,关闭串口工具后重试。") if "no serial data received" in lowered or "failed to connect to esp32" in lowered: hints.append("确认串口号正确,未被其他软件占用。") hints.append("按住 BOOT,点一下 RESET 进入下载模式。") if "download mode successfully detected" in lowered and "no sync reply" in lowered: hints.append("下载模式已进入但无法同步,通常是 TX/RX 线序或供电问题。") if "serial tx path seems to be down" in lowered: hints.append("检查 USB 转串口的 TX/RX 线序、焊接和接触。") if "invalid head of packet" in lowered or "possible serial noise" in lowered: hints.append("串口噪声/干扰,尝试更换 USB 线/接口或降低波特率(如 115200)。") if "stopiteration" in lowered: hints.append("串口通信异常中断,重新插拔设备后重试。") if not hints: hints.append("请检查串口号、线缆/供电,并按住 BOOT 点一下 RESET 进入下载模式。") deduped: list[str] = [] seen = set() for hint in hints: if hint in seen: continue seen.add(hint) deduped.append(hint) return deduped def detect_secure_boot_block(output: str) -> bool: lowered = output.lower() return ( "secure boot detected" in lowered or "writing to flash regions < 0x8000 is disabled" in lowered ) def detect_boot_chain_header_state(header_bytes: bytes) -> tuple[bool, str]: if not header_bytes: return False, "empty" if all(value == 0xFF for value in header_bytes): return False, "blank_ff" if all(value == 0x00 for value in header_bytes): return False, "blank_00" first = header_bytes[0] if first != _ESP_IMAGE_MAGIC: return False, f"invalid_magic_0x{first:02x}" return True, "ok" def _extract_json_blob(output: str) -> Optional[dict]: start = output.find("{") end = output.rfind("}") if start < 0 or end <= start: return None try: return json.loads(output[start : end + 1]) except json.JSONDecodeError: return None def _json_field_value(data: Optional[dict], name: str) -> Optional[object]: if not isinstance(data, dict): return None field = data.get(name) if not isinstance(field, dict): return None return field.get("value") def _value_to_int(value: object) -> Optional[int]: if isinstance(value, bool): return int(value) if isinstance(value, (int, float)): return int(value) if isinstance(value, str): stripped = value.strip() lowered = stripped.lower() if lowered in ("true", "yes", "enable", "enabled", "on"): return 1 if lowered in ("false", "no", "disable", "disabled", "off"): return 0 if lowered.startswith("0x"): try: return int(stripped, 16) except ValueError: return None if lowered.startswith("0b"): try: return int(stripped, 2) except ValueError: return None if stripped.isdigit(): return int(stripped) return None def get_efuse_flag(data: Optional[dict], *field_names: str) -> Optional[bool]: if not isinstance(data, dict): return None for name in field_names: if not isinstance(name, str) or not name: continue value = _json_field_value(data, name) value_int = _value_to_int(value) if value_int is None: continue return bool(value_int) return None def _parse_offset(value: object) -> int: parsed = _value_to_int(value) if parsed is None: raise ValueError(f"无效偏移: {value}") return parsed def recommend_mode_from_json( data: dict, crypt_cnt_fields: Iterable[str] ) -> Tuple[Optional[str], Optional[str]]: targets = tuple(crypt_cnt_fields) if not targets: return None, "CRYPT_CNT_NOT_CONFIGURED" for name in targets: value = _json_field_value(data, name) if value is None: continue value_int = _value_to_int(value) if value_int is not None: ones = bin(value_int).count("1") if ones == 0: return "1", f"{name}_ZERO" if ones % 2 == 1: return "2", f"{name}_ODD" return None, f"{name}_UNEXPECTED" if isinstance(value, str): lowered = value.strip().lower() if lowered in ("enable", "enabled"): return "2", f"{name}_ENABLE" if lowered in ("disable", "disabled"): return "1", f"{name}_DISABLE" return None, "CRYPT_CNT_NOT_FOUND" def _crypt_cnt_parity(data: dict, crypt_cnt_fields: Iterable[str]) -> Optional[int]: for name in crypt_cnt_fields: value = _json_field_value(data, name) value_int = _value_to_int(value) if value_int is None: continue ones = bin(value_int).count("1") return ones % 2 return None def _purpose_matches(value: object, match_text: Optional[str]) -> Optional[bool]: if match_text is None: return None if isinstance(value, str): return match_text.upper() in value.upper() if isinstance(value, (int, float)): if int(value) == 0: return False return None def _match_secure_boot_purpose(value: object) -> bool: if not isinstance(value, str): return False return value.upper().startswith("SECURE_BOOT_DIGEST") def _key_index_from_purpose_name(name: Optional[str]) -> Optional[int]: if not name: return None if not name.startswith("KEY_PURPOSE_"): return None suffix = name[len("KEY_PURPOSE_") :] if not suffix.isdigit(): return None return int(suffix) def _rd_dis_for_key(rd_dis_value: Optional[int], key_name: Optional[str]) -> Optional[bool]: if rd_dis_value is None: return None key_index = _key_index_from_purpose_name(key_name) if key_index is None: return None return bool(rd_dis_value & (1 << key_index)) def _iter_key_purpose_fields(data: dict) -> list[tuple[str, object]]: rows: list[tuple[str, object]] = [] if not isinstance(data, dict): return rows for key_name, raw_field in data.items(): if not isinstance(key_name, str): continue if not key_name.startswith("KEY_PURPOSE_"): continue value = None if isinstance(raw_field, dict): value = raw_field.get("value") rows.append((key_name, value)) def _sort_key(row: tuple[str, object]) -> tuple[int, str]: key_name = row[0] idx = _key_index_from_purpose_name(key_name) if idx is None: return (1_000_000, key_name) return (idx, key_name) rows.sort(key=_sort_key) return rows def _purpose_value_matches( value: object, expected_match: Optional[str], allow_secure_boot_prefix: bool = False, ) -> bool: matched = _purpose_matches(value, expected_match) if matched is True: return True if not allow_secure_boot_prefix or not expected_match: return False if "SECURE_BOOT_DIGEST" not in expected_match.upper(): return False return _match_secure_boot_purpose(value) def _collect_expected_purpose_matches(cfg: DeviceCheckConfig) -> tuple[list[str], list[str]]: flash_expected: list[str] = [] secure_expected: list[str] = [] flash_match = cfg.flash_key_purpose_match if flash_match: flash_match = flash_match.strip() if flash_match: flash_expected.append(flash_match) secure_boot_match = cfg.secure_boot_key_purpose_match if secure_boot_match: secure_boot_match = secure_boot_match.strip() if secure_boot_match: secure_expected.append(secure_boot_match) return flash_expected, secure_expected def _match_expected_keys( key_values: list[tuple[str, object]], expected_matches: list[str], allow_secure_boot_prefix: bool = False, ) -> tuple[list[dict], list[str]]: matches: list[dict] = [] missing: list[str] = [] used_keys: set[str] = set() for expected in expected_matches: found: Optional[dict] = None for key_name, value in key_values: if key_name in used_keys: continue if _purpose_value_matches( value, expected, allow_secure_boot_prefix=allow_secure_boot_prefix, ): found = {"key": key_name, "value": value} break if found is None: missing.append(expected) continue used_keys.add(found["key"]) matches.append(found) return matches, missing def evaluate_post_flash_status( data: dict, cfg: DeviceCheckConfig ) -> Tuple[bool, dict, list[str]]: details: dict = {} errors: list[str] = [] crypt_cnt_mode, crypt_cnt_reason = recommend_mode_from_json(data, cfg.crypt_cnt_fields) details["crypt_cnt_mode"] = crypt_cnt_mode details["crypt_cnt_reason"] = crypt_cnt_reason parity = _crypt_cnt_parity(data, cfg.crypt_cnt_fields) if parity is not None: details["crypt_cnt_parity"] = "odd" if parity == 1 else "even" if cfg.crypt_cnt_expect and cfg.crypt_cnt_expect != "any": expect = cfg.crypt_cnt_expect if expect == "odd" and crypt_cnt_mode != "2": errors.append("crypt_cnt_not_odd") elif expect == "zero" and crypt_cnt_mode != "1": errors.append("crypt_cnt_not_zero") elif expect == "even" and crypt_cnt_mode == "2": errors.append("crypt_cnt_unexpected_odd") secure_boot_value = _value_to_int(_json_field_value(data, cfg.secure_boot_field)) secure_boot_enabled: Optional[bool] = None if secure_boot_value is not None: secure_boot_enabled = bool(secure_boot_value) details["secure_boot_enabled"] = secure_boot_enabled if cfg.secure_boot_expected is not None and secure_boot_enabled is not None: if secure_boot_enabled != cfg.secure_boot_expected: errors.append("secure_boot_unexpected") elif cfg.secure_boot_expected is not None and secure_boot_enabled is None: errors.append("secure_boot_unknown") rd_dis_value = _value_to_int(_json_field_value(data, cfg.key_read_disable_field)) details["rd_dis_value"] = rd_dis_value key_details: dict = {} key_values = _iter_key_purpose_fields(data) flash_expected, secure_expected = _collect_expected_purpose_matches(cfg) flash_matches, flash_missing = _match_expected_keys( key_values, flash_expected, allow_secure_boot_prefix=False ) secure_matches, secure_missing = _match_expected_keys( key_values, secure_expected, allow_secure_boot_prefix=True ) flash_rd_details: list[dict] = [] secure_rd_details: list[dict] = [] flash_rd_missing = False flash_rd_unknown = False secure_rd_blocked = False secure_rd_unknown = False for matched in flash_matches: key_name = matched["key"] rd_ok = _rd_dis_for_key(rd_dis_value, key_name) flash_rd_details.append({"key": key_name, "rd_protected": rd_ok}) if rd_ok is False: flash_rd_missing = True elif rd_ok is None: flash_rd_unknown = True for matched in secure_matches: key_name = matched["key"] rd_ok = _rd_dis_for_key(rd_dis_value, key_name) secure_rd_details.append({"key": key_name, "rd_protected": rd_ok}) if rd_ok is True: secure_rd_blocked = True elif rd_ok is None: secure_rd_unknown = True if flash_expected and flash_missing: errors.append("flash_key_missing") if secure_expected and secure_missing: errors.append("secure_boot_key_missing") if flash_matches and flash_rd_missing: errors.append("flash_key_rd_dis_missing") elif flash_matches and flash_rd_unknown: errors.append("flash_key_rd_dis_unknown") if secure_matches and secure_rd_blocked: errors.append("secure_boot_key_rd_dis_set") elif secure_matches and secure_rd_unknown: errors.append("secure_boot_key_rd_dis_unknown") key_details["flash_enc_keys"] = flash_matches key_details["secure_boot_keys"] = secure_matches key_details["flash_enc_rd_protected"] = flash_rd_details key_details["secure_boot_rd_protected"] = secure_rd_details details["keys"] = key_details return len(errors) == 0, details, errors def build_flash_entries( bin_dir: Path, layout: Iterable[Tuple[str, str]], app_only: bool, app_partition_offset: str, app_bin_name: str, ) -> list[Tuple[object, Path]]: if app_only: return [(app_partition_offset, bin_dir / app_bin_name)] return [(offset, bin_dir / name) for offset, name in layout] def recommend_mode( summary_text: str, crypt_cnt_fields: Iterable[str] ) -> Tuple[Optional[str], Optional[str]]: targets = tuple(crypt_cnt_fields) if not targets: return None, "CRYPT_CNT_NOT_CONFIGURED" for line in summary_text.splitlines(): if "CRYPT_CNT_DISABLE" in line: continue if not any(target in line for target in targets): continue match = re.search(r"\(0b([01]+)\)", line) if match: ones = match.group(1).count("1") else: match = re.search(r"=\s*([0-9]+)", line) if not match: return None, "CRYPT_CNT_PARSE_FAIL" value = int(match.group(1), 10) ones = bin(value).count("1") if ones == 0: return "1", "CRYPT_CNT_ZERO" if ones % 2 == 1: return "2", "CRYPT_CNT_ODD" return None, "CRYPT_CNT_UNEXPECTED" return None, "CRYPT_CNT_NOT_FOUND" def extract_mac_from_output(output: str) -> Optional[str]: match = _MAC_OUTPUT_RE.search(output) or _MAC_RE.search(output) if not match: return None mac = match.group(1) if match.re is _MAC_OUTPUT_RE else match.group(0) return mac.lower()