Files
HYPERHEN/scripts/analyze_rnps_dump.py

842 lines
29 KiB
Python
Executable File

#!/usr/bin/env python3
"""Extract and validate RNPS/HBC dump fingerprints used by HyperHEN profiles."""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
from typing import Any
HERMES_MAGIC = bytes([0xC6, 0x1F, 0xBC, 0x03, 0xC1, 0x03, 0x19, 0x1F])
LEGACY_BUNDLE_MAGIC = bytes([0xE5, 0xD1, 0x0B, 0xFB])
PLAIN_JS_PREFIX = b"/*! For license information"
RNPS_MAGIC = b"RNPSHEDR"
RNPS_PAYLOAD_OFFSET_FIELD = 0x1C
RNPS_FALLBACK_PAYLOAD_OFFSET = 0xB20
HBC_VERSION_OFFSET = 0x08
HBC_SOURCE_HASH_OFFSET = 0x0C
HBC_SOURCE_HASH_SIZE = 20
HBC_FILE_LENGTH_OFFSET = 0x20
KNOWN_HOMEUI_PROFILES = [
{
"name": "9.00 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1846E0,
"source_hash": "587635687e0a190e38425232c39092888da5adbe",
},
{
# 9.4 and 9.6 NPXS40002 dumps are byte-identical.
"name": "9.4/9.6 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1A3B54,
"source_hash": "81452e48a6937d9f1c13308ce6958f8f9f0c6938",
},
{
"name": "10.01 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B3318,
"source_hash": "ae256655beaeea9e752e23256bbdddf1af254aea",
},
{
# 10.2 NPXS40002 is byte-identical to the 10.4/10.6 dump.
"name": "10.4/10.6 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B3BCC,
"source_hash": "2cac5cc444ba0473ea8ee632a7942f281482a68a",
},
{
"name": "11.0 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B3010,
"source_hash": "e21110895e8fb6c85f49db972d51fc101bb8fc52",
},
{
# 11.2 NPXS40002 is byte-identical to the 11.4/11.6 dump.
"name": "11.4/11.6 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B2CC8,
"source_hash": "f321f83f9143035f5d97ee5ad98ceb75133c890e",
},
{
# 12.4 and 12.6 NPXS40002 are byte-identical to the 12.7 dump.
"name": "12.7 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B73BC,
"source_hash": "9dd2dc47c6024843f685af80ae9273e6a075337d",
},
{
# 12.0 and 12.02 NPXS40002 dumps are byte-identical to 12.20.
"name": "12.20 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B70E4,
"source_hash": "d9aa3ec2fcf7cc0bb0a7fe6362079c494948cf5e",
},
{
# Every known 13.x NPXS40002 dump (13.0, 13.2, 13.4, 13.42, 13.6)
# is byte-identical.
"name": "13.0/13.2/13.4/13.42/13.6 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B6584,
"source_hash": "80837fe66ccb00ba923f279c90b78c7c4813e543",
},
{
# 13.600.007 ShellUI loads the OTA bundle, not the /system_ex file above.
"name": "13.600.007 OTA NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B6594,
"source_hash": "f73c3e0102be92a4558baa510bd00c17f6fe5f96",
},
]
KNOWN_LEGACY_HOMEUI_PROFILES = [
{
# 2.30 and 2.50 NPXS40002 are byte-identical.
"name": "2.30/2.50 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x1631E0,
"file_size": 0x163D00,
"sha256": "3a50628e07431ae4eadfbda45cfc6882a4558c6f548382bc6bab81d769cccf4b",
},
{
# 3.00, 3.10, 3.20 and 3.21 NPXS40002 are byte-identical.
"name": "3.00/3.10/3.20/3.21 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x150130,
"file_size": 0x150C50,
"sha256": "21c91c7044c0d36ee26827260e956db085096fd93264f6d5cb5c0255fc8dcf6f",
},
{
# 4.00, 4.02, 4.03, 4.50 and 4.51 NPXS40002 are byte-identical.
"name": "4.00/4.02/4.03/4.50/4.51 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x152990,
"file_size": 0x1534B0,
"sha256": "6db944372cfe8b7d50328ed4bd47c8cae6917821fb30f20004e7f85c673fe00a",
},
{
# 5.00 and 5.02 NPXS40002 are byte-identical.
"name": "5.00/5.02 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x17A690,
"file_size": 0x17B1B0,
"sha256": "a0be894ce20f2769f43715b0ac75c135d9d0811d6d26d061d311671505b3d66f",
},
{
# 5.10 and 5.50 NPXS40002 are byte-identical.
"name": "5.10/5.50 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x185C30,
"file_size": 0x186750,
"sha256": "bfa53c6bd1fd4c468ebf7ee44955db0cf8286f116a8ab1870b756de0efbfc5fb",
},
{
# 5.50 ShellUI loads the OTA bundle, not the /system_ex file above.
"name": "5.50 OTA NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x185AD0,
"file_size": 0x1865F0,
"sha256": "42d215cb7f7dee9700f5e05c838be58668d9756728fbe0bcb5f53d11aa90cae3",
},
{
# 6.00 and 6.02 NPXS40002 are byte-identical.
"name": "6.00/6.02 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x185D00,
"file_size": 0x186820,
"sha256": "b376f7ead9140636beac99354a7d958fc18ea4da40580dda590927be936e0a18",
},
{
# 7.40 and 7.61 NPXS40002 are byte-identical.
"name": "7.40/7.61 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x19DC10,
"file_size": 0x19E730,
"sha256": "1f884701ec9a490b8149f9873ead915fc2d2d5ee95f55363beff39cc5872e93e",
},
]
KNOWN_PLAIN_JS_HOMEUI_PROFILES = [
{
"name": "8.00 NPXS40002 plain-JS HomeUI",
"payload_size": 0x158070,
"file_size": 0x158B90,
"sha256": "00ac38a2d7b7e11a3910865d206b6256593e0ff94f8d7c9d233ab983b25dac89",
},
{
"name": "8.40 NPXS40002 plain-JS HomeUI",
"payload_size": 0x158070,
"file_size": 0x158B90,
"sha256": "a57611dbd0e56ca80747d2862f5a9f651a4b3c1504dca9b8799cfeda718e22e2",
},
]
KNOWN_LEGACY_SETTINGS_PROFILES = [
{
# 3.00, 3.10, 3.20 and 3.21 NPXS40008 are byte-identical.
"name": "3.00/3.10/3.20/3.21 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x457210,
"file_size": 0x457D30,
"sha256": "a26b4097da235802f13a43f7b89fe9403a8c175e9f843b9b795f91bfc1403654",
},
{
# 4.00, 4.02 and 4.03 NPXS40008 are byte-identical.
"name": "4.00/4.02/4.03 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x483280,
"file_size": 0x483DB0,
"sha256": "3b8f1725ec1c0afe87cc72ecebc29b6f7239d03bd7397a621b7a314455d12bb7",
},
{
# 4.50 and 4.51 NPXS40008 are byte-identical.
"name": "4.50/4.51 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x483FC0,
"file_size": 0x484AF0,
"sha256": "8e016beed7584283c9719c49662fb5e1071083dc1e3dd8988e1b9fe9fd45b032",
},
{
# 5.00 and 5.02 NPXS40008 are byte-identical.
"name": "5.00/5.02 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x4B8770,
"file_size": 0x4B92A0,
"sha256": "814f7bf3b09a4532d5ad4c6b3600f2c5457e2a2edef04c6b61741e6af03a678f",
},
{
"name": "5.10 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x4B8AC0,
"file_size": 0x4B95F0,
"sha256": "f6fd5f1aba8de0ee3f56e821c69dcea795dec461998db6082a4f58a33ba88ad7",
},
{
"name": "5.50 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x4B9E50,
"file_size": 0x4BA980,
"sha256": "34d137c8e403d53e1dc09ad7c8b0a88634555f2e41508e3106e1bb315745f311",
},
{
# 6.00 and 6.02 NPXS40008 are byte-identical.
"name": "6.00/6.02 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x5524A0,
"file_size": 0x552FD0,
"sha256": "2acc1cfc8421c6cb24c25ad29b8433040f79043a068dd941cbf29e2e7daabc0d",
},
{
"name": "6.50 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x555280,
"file_size": 0x555DB0,
"sha256": "77ce1eeb90ab4c2f113b5af7b37a57f4cf62be76b5eb662a7540718cf3ea3daf",
},
{
# 7.00, 7.01 and 7.01.01 NPXS40008 are byte-identical.
"name": "7.00/7.01/7.01.01 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x5E7540,
"file_size": 0x5E8070,
"sha256": "69ddf29f8041ef013e628a449423a1e7d7b401a7ee68e417605e9eb4478f1b5c",
},
{
"name": "7.20 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x5E7940,
"file_size": 0x5E8470,
"sha256": "513da7906b016fd849750112ba50a110fa6ec3de4fb0ee9631007951d575361d",
},
{
# 7.40 and 7.61 NPXS40008 are byte-identical.
"name": "7.40/7.61 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x5E9D20,
"file_size": 0x5EA850,
"sha256": "469f723359ec07ab6ab72089328401301eb6f32dbc4a6776bf1421c7195ce853",
},
{
"name": "8.00 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x64BB80,
"file_size": 0x64C6B0,
"sha256": "a3c76ecf8e3e29cd373fb831f73498c7864d55c5adb6d8f7fc2f48032d58d6ee",
},
{
"name": "8.40 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x654AF0,
"file_size": 0x655620,
"sha256": "5fe90813275c75b280e424cc9c10139b380a9b223fe8c5712b7df86329553711",
},
{
"name": "8.60 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x6561E0,
"file_size": 0x656D10,
"sha256": "57d59334d99a101e92bbb470c1c271c7838f3b7a21ffebcf237a487cdc6665c4",
},
]
KNOWN_SETTINGS_PROFILES = [
{
"name": "9.00 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4B1934,
"source_hash": "72188b52b12bad6af90c90a848b7fd76e5af102d",
},
{
"name": "9.40 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4BA2C0,
"source_hash": "4f1ae4b6786cc96646e14eec923d09c3c031e980",
},
{
"name": "9.60 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4BA1F0,
"source_hash": "59506d7b5c595196b21661264bc9c1b487d21b51",
},
{
"name": "10.01 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4DDA8C,
"source_hash": "ad6cf2d6f8974ccd34b14e69bb6e340e8dec5dc5",
},
{
# 10.2 and 10.4 dumps share this Settings bundle fingerprint.
"name": "10.4 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4E089C,
"source_hash": "abb8fdf5a894ce6fd1e99381d0866b33f279c7b9",
},
{
"name": "10.6 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4E0954,
"source_hash": "31651a188d49b23b7635afa449395e0fbd9f682a",
},
{
"name": "11.0 NPXS40008 Settings",
"route": "old",
"file_length": 0x4FA540,
"source_hash": "1824c9fb562e31eef651bb3874c1c73f7f6e24b0",
},
{
"name": "11.2 NPXS40008 Settings",
"route": "old",
"file_length": 0x4F45B8,
"source_hash": "d03462a912c4b5b8db4a98d044b9d488a2dffc7a",
},
{
"name": "11.4 NPXS40008 Settings",
"route": "old",
"file_length": 0x4F45C4,
"source_hash": "a7b731571f84b6cdaf7c4227a980ba5ee20004a8",
},
{
"name": "11.6 NPXS40008 Settings",
"route": "old",
"file_length": 0x4F4BFC,
"source_hash": "92566124b6cfe0b0a7c812fc8a3bbfcf32ac4683",
},
{
# 12.0 and 12.02 dumps share this Settings bundle fingerprint.
"name": "12.02 NPXS40008 Settings",
"route": "old",
"file_length": 0x4E7BEC,
"source_hash": "fc7c4f15af42929e1d52420c2d174944b4a88043",
},
{
"name": "12.6 NPXS40008 Settings",
"route": "old",
"file_length": 0x4E9028,
"source_hash": "75747bb5fa7e3a4e22d557882f5281e4d1f12959",
},
{
"name": "12.7 NPXS40008 Settings",
"route": "old",
"file_length": 0x4E9048,
"source_hash": "445da8bcba93da165473d3da491d9b13f96316cd",
},
{
# 12.4 and 12.20 dumps share this Settings bundle fingerprint.
"name": "12.20 NPXS40008 Settings",
"route": "old",
"file_length": 0x4E8E54,
"source_hash": "5d4461858b0a38fc6e7b086dbdfdab619515908e",
},
{
"name": "13.0 NPXS40008 Settings",
"route": "old",
"file_length": 0x4EB9B4,
"source_hash": "5fb007c00f7ba34541032b2997bdabd19d534e78",
},
{
# 13.2, 13.4 and 13.42 NPXS40008 dumps are byte-identical.
"name": "13.2/13.4/13.42 NPXS40008 Settings",
"route": "old",
"file_length": 0x4EBE08,
"source_hash": "fb05751464291761de1fdcd9576fc26a02930e10",
},
{
"name": "13.6 NPXS40008 Settings",
"route": "old",
"file_length": 0x4EBC8C,
"source_hash": "a400a5ca932efc80cb6512610903100fa03a8717",
},
]
TRACKED_STRINGS = [
b"NPXS40002",
b"ApplicationErrorEventTrigger",
b"pshomeui:navigateToHome",
b"download_error",
b"homeui ApplicationErrorEvent test",
b"Trigger AppError",
b"Fps",
b"pssettings:play",
b"debug_settings_old",
b"debug_settings",
b"icon_setting",
]
def read_u32le(data: bytes, offset: int) -> int | None:
if offset < 0 or offset + 4 > len(data):
return None
return int.from_bytes(data[offset : offset + 4], "little")
def find_all(data: bytes, needle: bytes) -> list[int]:
offsets: list[int] = []
start = 0
while True:
offset = data.find(needle, start)
if offset < 0:
return offsets
offsets.append(offset)
start = offset + 1
def locate_hbc(data: bytes) -> tuple[int, str] | tuple[None, str]:
if data.startswith(HERMES_MAGIC):
return 0, "direct"
if data.startswith(RNPS_MAGIC):
declared = read_u32le(data, RNPS_PAYLOAD_OFFSET_FIELD)
if declared is not None and 0 < declared < len(data):
if data[declared : declared + len(HERMES_MAGIC)] == HERMES_MAGIC:
return declared, "rnps-declared"
if data[
RNPS_FALLBACK_PAYLOAD_OFFSET : RNPS_FALLBACK_PAYLOAD_OFFSET
+ len(HERMES_MAGIC)
] == HERMES_MAGIC:
return RNPS_FALLBACK_PAYLOAD_OFFSET, "rnps-fallback"
offset = data.find(HERMES_MAGIC)
if offset >= 0:
return offset, "scan"
return None, "missing"
def locate_legacy_bundle(data: bytes) -> tuple[int, str] | tuple[None, str]:
"""Locate the pre-Hermes RNPS JavaScript payload used by 4.x HomeUI."""
if data.startswith(LEGACY_BUNDLE_MAGIC):
return 0, "direct-legacy"
if data.startswith(RNPS_MAGIC):
declared = read_u32le(data, RNPS_PAYLOAD_OFFSET_FIELD)
candidates = []
if declared is not None and 0 < declared < len(data):
candidates.append((declared, "rnps-declared-legacy"))
candidates.append((RNPS_FALLBACK_PAYLOAD_OFFSET, "rnps-fallback-legacy"))
for offset, location in candidates:
if (
data[offset : offset + len(LEGACY_BUNDLE_MAGIC)]
== LEGACY_BUNDLE_MAGIC
):
return offset, location
return None, "missing"
def locate_plain_js_bundle(data: bytes) -> tuple[int, str] | tuple[None, str]:
if data.startswith(PLAIN_JS_PREFIX):
return 0, "direct-plain-js"
if data.startswith(RNPS_MAGIC):
declared = read_u32le(data, RNPS_PAYLOAD_OFFSET_FIELD)
candidates = []
if declared is not None and 0 < declared < len(data):
candidates.append((declared, "rnps-declared-plain-js"))
candidates.append((RNPS_FALLBACK_PAYLOAD_OFFSET, "rnps-fallback-plain-js"))
for offset, location in candidates:
if data[offset : offset + len(PLAIN_JS_PREFIX)] == PLAIN_JS_PREFIX:
return offset, location
return None, "missing"
def match_legacy_profile(
data: bytes,
payload_offset: int,
profiles: list[dict[str, Any]] = KNOWN_LEGACY_HOMEUI_PROFILES,
) -> dict[str, Any] | None:
payload = data[payload_offset:]
digest = hashlib.sha256(data).hexdigest()
for profile in profiles:
if len(data) != profile["file_size"]:
continue
if len(payload) != profile["payload_size"]:
continue
if payload[:4].hex() != profile["payload_magic"]:
continue
if digest != profile["sha256"]:
continue
return profile
return None
def match_plain_js_profile(data: bytes, payload_offset: int) -> dict[str, Any] | None:
payload = data[payload_offset:]
digest = hashlib.sha256(data).hexdigest()
for profile in KNOWN_PLAIN_JS_HOMEUI_PROFILES:
if len(data) != profile["file_size"]:
continue
if len(payload) != profile["payload_size"]:
continue
if not payload.startswith(PLAIN_JS_PREFIX):
continue
if digest != profile["sha256"]:
continue
return profile
return None
def match_profile(
profiles: list[dict[str, Any]],
hbc_version: int | None,
file_length: int | None,
source_hash: str,
) -> dict[str, Any] | None:
for profile in profiles:
if profile["file_length"] != file_length:
continue
if profile["source_hash"] != source_hash:
continue
expected_version = profile.get("hbc_version")
if expected_version is not None and expected_version != hbc_version:
continue
return profile
return None
def infer_settings_route(hbc: bytes) -> str:
if b"debug_settings_old" in hbc:
return "old"
if b"debug_settings" in hbc:
return "standard"
return "unknown"
def analyze_file(path: Path, app_id: str) -> dict[str, Any]:
data = path.read_bytes()
hbc_offset, hbc_location = locate_hbc(data)
result: dict[str, Any] = {
"app_id": app_id,
"path": str(path),
"size": path.stat().st_size,
"hbc_offset": hbc_offset,
"hbc_location": hbc_location,
}
if app_id in ("NPXS40002", "NPXS40008"):
legacy_offset, legacy_location = locate_legacy_bundle(data)
if legacy_offset is not None:
payload = data[legacy_offset:]
legacy_profiles = (
KNOWN_LEGACY_HOMEUI_PROFILES
if app_id == "NPXS40002"
else KNOWN_LEGACY_SETTINGS_PROFILES
)
profile = match_legacy_profile(data, legacy_offset, legacy_profiles)
result.update(
{
"legacy_offset": legacy_offset,
"legacy_location": legacy_location,
"legacy_payload_size": len(payload),
"legacy_magic": payload[:4].hex(),
"legacy_sha256": hashlib.sha256(data).hexdigest(),
"profile": profile["name"] if profile else None,
"supported": profile is not None,
"strings": {},
}
)
if app_id == "NPXS40008":
route = infer_settings_route(payload)
result["settings_route"] = route
result["profile_route"] = profile.get("route") if profile else None
result["route_matches_profile"] = bool(
profile and profile.get("route") == route
)
for needle in TRACKED_STRINGS:
offsets = find_all(payload, needle)
result["strings"][needle.decode("ascii")] = {
"count": len(offsets),
"first_offsets": [f"0x{offset:x}" for offset in offsets[:8]],
}
return result
if app_id == "NPXS40002":
plain_js_offset, plain_js_location = locate_plain_js_bundle(data)
if plain_js_offset is not None:
payload = data[plain_js_offset:]
profile = match_plain_js_profile(data, plain_js_offset)
result.update(
{
"plain_js_offset": plain_js_offset,
"plain_js_location": plain_js_location,
"plain_js_payload_size": len(payload),
"plain_js_sha256": hashlib.sha256(data).hexdigest(),
"profile": profile["name"] if profile else None,
"supported": profile is not None,
"strings": {},
}
)
for needle in TRACKED_STRINGS:
offsets = find_all(payload, needle)
result["strings"][needle.decode("ascii")] = {
"count": len(offsets),
"first_offsets": [f"0x{offset:x}" for offset in offsets[:8]],
}
return result
if hbc_offset is None:
result["supported"] = False
result["error"] = "HBC magic not found"
return result
hbc = data[hbc_offset:]
hbc_version = read_u32le(hbc, HBC_VERSION_OFFSET)
file_length = read_u32le(hbc, HBC_FILE_LENGTH_OFFSET)
source_hash = hbc[
HBC_SOURCE_HASH_OFFSET : HBC_SOURCE_HASH_OFFSET + HBC_SOURCE_HASH_SIZE
].hex()
profile_table = (
KNOWN_HOMEUI_PROFILES if app_id == "NPXS40002" else KNOWN_SETTINGS_PROFILES
)
profile = match_profile(profile_table, hbc_version, file_length, source_hash)
result.update(
{
"hbc_version": hbc_version,
"hbc_file_length": file_length,
"hbc_file_length_hex": f"0x{file_length:x}" if file_length else None,
"source_hash": source_hash,
"profile": profile["name"] if profile else None,
"supported": profile is not None,
"strings": {},
}
)
strings: dict[str, Any] = {}
for needle in TRACKED_STRINGS:
offsets = find_all(hbc, needle)
strings[needle.decode("ascii")] = {
"count": len(offsets),
"first_offsets": [f"0x{offset:x}" for offset in offsets[:8]],
}
result["strings"] = strings
if app_id == "NPXS40008":
route = infer_settings_route(hbc)
result["settings_route"] = route
result["profile_route"] = profile.get("route") if profile else None
result["route_matches_profile"] = bool(
profile and profile.get("route") == route
)
return result
def analyze_dump(dump_dir: Path) -> dict[str, Any]:
apps: list[dict[str, Any]] = []
for app_id in ("NPXS40002", "NPXS40008"):
path = dump_dir / f"{app_id}.bin"
if path.exists():
apps.append(analyze_file(path, app_id))
return {
"dump_dir": str(dump_dir),
"apps": apps,
}
def validation_errors(report: dict[str, Any]) -> list[str]:
errors: list[str] = []
apps = report["apps"]
if not apps:
errors.append("no NPXS40002.bin or NPXS40008.bin found")
return errors
for app in apps:
if not app.get("supported"):
errors.append(f"{app['app_id']} has no matching known profile")
continue
if app["app_id"] == "NPXS40008" and not app.get("route_matches_profile"):
errors.append(
f"{app['app_id']} route {app.get('settings_route')} does not "
f"match profile route {app.get('profile_route')}"
)
return errors
def print_text(report: dict[str, Any], errors: list[str]) -> None:
print(f"Dump: {report['dump_dir']}")
for app in report["apps"]:
print(f"\n{app['app_id']}: {app['path']}")
print(f" file_size: 0x{app['size']:x} ({app['size']})")
if app.get("legacy_offset") is not None:
print(
" legacy RNPS bundle: "
f"offset=0x{app['legacy_offset']:x} "
f"location={app['legacy_location']} "
f"magic={app['legacy_magic']} "
f"payload_size=0x{app['legacy_payload_size']:x} "
f"sha256={app['legacy_sha256']}"
)
print(
" profile: "
f"{app['profile'] if app['profile'] else 'unsupported'} "
f"(supported={'yes' if app['supported'] else 'no'})"
)
if app["app_id"] == "NPXS40008":
print(
" settings_route: "
f"{app.get('settings_route')} "
f"(profile={app.get('profile_route')})"
)
print(" strings:")
for name, detail in app["strings"].items():
if detail["count"] == 0:
continue
offsets = ", ".join(detail["first_offsets"])
print(f" {name}: count={detail['count']} first={offsets}")
continue
if app.get("plain_js_offset") is not None:
print(
" plain-JS RNPS bundle: "
f"offset=0x{app['plain_js_offset']:x} "
f"location={app['plain_js_location']} "
f"payload_size=0x{app['plain_js_payload_size']:x} "
f"sha256={app['plain_js_sha256']}"
)
print(
" profile: "
f"{app['profile'] if app['profile'] else 'unsupported'} "
f"(supported={'yes' if app['supported'] else 'no'})"
)
print(" strings:")
for name, detail in app["strings"].items():
if detail["count"] == 0:
continue
offsets = ", ".join(detail["first_offsets"])
print(f" {name}: count={detail['count']} first={offsets}")
continue
if app.get("hbc_offset") is None:
print(f" hbc: missing ({app.get('error')})")
continue
print(
" hbc: "
f"offset=0x{app['hbc_offset']:x} "
f"location={app['hbc_location']} "
f"version={app['hbc_version']} "
f"file_length={app['hbc_file_length_hex']} "
f"source_hash={app['source_hash']}"
)
print(
" profile: "
f"{app['profile'] if app['profile'] else 'unsupported'} "
f"(supported={'yes' if app['supported'] else 'no'})"
)
if app["app_id"] == "NPXS40008":
print(
" settings_route: "
f"{app.get('settings_route')} "
f"(profile={app.get('profile_route')})"
)
print(" strings:")
for name, detail in app["strings"].items():
if detail["count"] == 0:
continue
offsets = ", ".join(detail["first_offsets"])
print(f" {name}: count={detail['count']} first={offsets}")
if errors:
print("\nValidation: FAIL")
for error in errors:
print(f" - {error}")
else:
print("\nValidation: PASS")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description="Extract RNPS/HBC fingerprints and validate known profiles."
)
parser.add_argument("dump_dir", type=Path, help="Directory containing NPXS*.bin")
parser.add_argument("--json", action="store_true", help="Emit machine JSON")
parser.add_argument(
"--allow-unsupported",
action="store_true",
help="Return success even when a profile is unknown",
)
args = parser.parse_args(argv)
if not args.dump_dir.is_dir():
print(f"error: not a directory: {args.dump_dir}", file=sys.stderr)
return 2
report = analyze_dump(args.dump_dir)
errors = validation_errors(report)
if args.json:
print(json.dumps({**report, "validation_errors": errors}, indent=2))
else:
print_text(report, errors)
if errors and not args.allow_unsupported:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))