#!/usr/bin/env python3 """准备嵌入式图片协商候选版本;本工具不停止进程、不替换在用程序、不修改在用配置。""" import argparse import configparser import hashlib import io import ipaddress import json import os from pathlib import Path import platform import re import shutil import subprocess import tarfile import time import uuid # 本次仅交付图片地址协商代码;其他业务源码必须以当前开发板为基线。 # 不整份上传历史工程,避免覆盖最新日志保留、串口或RTP收流修改。 PATCH_FILES = ( "util/PlatformWireCodec.h", "util/PlatformWireCodec.cpp", "util/PlatformImageRouteClient.h", "util/PlatformImageRouteClient.cpp", "util/PlatformImageSender.h", "util/PlatformImageSender.cpp", ) # 测试只运行临时目录与loopback,不使用实际设备密钥和采集端口。 TEST_FILES = ( "tests/image_route/native_image_route_check.pro", "tests/image_route/native_image_route_check.cpp", "tests/logging/native_logging_check.pro", "tests/logging/native_logging_check.cpp", ) ALLOWED = set(PATCH_FILES + TEST_FILES) # 这些文件只记录哈希,以验证部署保留现有功能;本工具不会覆盖它们。 PRESERVED = ( "main.cpp", "util/log.cpp", "util/log.h", "util/CollectorLogRetention.cpp", "util/CollectorLogRetention.h", "start.sh", "Go2Controller/network/rtpreceiver.cpp", "Fly2Controller/network/RtpReceiver.cpp", "util/ModuleVideoReceiver.cpp", "CJ2Controller/serialworker.cpp", "mainwindow.cpp", ) def digest(path): # 分块计算,避免相机SDK等大文件一次读入内存。 value = hashlib.sha256() with path.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): value.update(block) return value.hexdigest() def write_json(path, value): # 部署记录留在私有stage;不输出配置全文、密钥文件或进程环境。 path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8") def package(args): # 使用当前工作空间源码,禁止调用旧prepare-board-deployment脚本重建全量包。 # 包只携带本次代码与测试,不携带平台IP、上传配置、密钥或旧可执行文件。 # 压缩包不含旧可执行文件,必须经实际ARM工具链重新构建。 # 不读取本机现网INI,候选IP必须在板端stage时显式指定。 source = args.source.resolve(strict=True) # 同名产物不覆盖,方便将每次候选与原始校验记录一一对应。 if args.package.exists(): raise RuntimeError("Package already exists; choose a new output filename") args.package.parent.mkdir(parents=True, exist_ok=True) manifest = {"protocol": "COLLECTOR_IMAGE_ROUTE", "format": 1, "files": []} # 精确白名单不包含任何ini/密钥/运行目录,也不递归打包未知文件。 with tarfile.open(str(args.package), "w:gz") as archive: for relative in PATCH_FILES + TEST_FILES: path = source / relative if path.is_symlink() or not path.resolve(strict=True).is_relative_to(source): raise RuntimeError("Unexpected source path: " + relative) data = path.read_bytes() item = tarfile.TarInfo(relative) item.size = len(data) item.mode = 0o644 archive.addfile(item, io.BytesIO(data)) manifest["files"].append({"path": relative, "sha256": hashlib.sha256(data).hexdigest()}) data = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8") item = tarfile.TarInfo("image-route-manifest.json") item.size = len(data) item.mode = 0o644 archive.addfile(item, io.BytesIO(data)) print(json.dumps({"package": str(args.package.resolve()), "files": len(ALLOWED), "sha256": digest(args.package)}, ensure_ascii=False)) def live_root(args): # 路径必须是已存在工程,禁止根目录及符号链接冒充部署目录。 if args.root.is_symlink(): raise RuntimeError("Collector root cannot be a symbolic link") # 所有后续路径由规范化根目录拼接,不接收任意远端目标路径。 root = args.root.resolve(strict=True) if root == Path(root.anchor) or not (root / "px_Collect.pro").is_file(): raise RuntimeError("Collector project root is invalid") return root def process_ids(root): target = str(root / "bin/px_JKGL") # 没有/proc时只用于本地安全测试,不声称发现了实际板端进程。 matches = [] # 仅核对可执行文件真实路径,不用进程名字匹配其他同名程序。 for entry in Path("/proc").iterdir() if Path("/proc").is_dir() else []: if not entry.name.isdigit(): continue try: if os.readlink(str(entry / "exe")).replace(" (deleted)", "") == target: matches.append(int(entry.name)) except (FileNotFoundError, PermissionError): pass return sorted(matches) def inspect(args): # 不主动建立SSH,操作者负责将工具上传到已确认的目标板再运行。 # inspect不改网络、时钟、配置、库或进程,输出可供root比对的哈希。 # 输出源码哈希供当前板与本机差异核对,不能把历史记录当成现网事实。 root = live_root(args) # 地址和目标路径来自root确认,inspect只给非敏感状态与源码哈希。 print(json.dumps({"root": str(root), "architecture": platform.machine(), "collectorPids": process_ids(root), "binaryPresent": (root / "bin/px_JKGL").is_file(), "qmake": shutil.which("qmake"), "make": shutil.which("make"), "preservedSources": {name: digest(root / name) for name in PRESERVED if (root / name).is_file()}, "sourceHashes": {name: digest(root / name) if (root / name).is_file() else None for name in PATCH_FILES}, "projectSha256": digest(root / "px_Collect.pro")}, ensure_ascii=False, indent=2)) def ignore_files(directory, names): # 不在stage复制在用配置和密钥;测试自行生成临时密钥。 # 对bin/runtime/logs/build的排除也避免复制采集历史和巨量日志。 ignored = [] for name in names: lower = name.lower() if name in {"bin", "runtime", "logs", "build", "debug", "release", ".git", "__pycache__"} \ or name.startswith(("Makefile", "moc_", ".qmake")) \ or lower.endswith((".ini", ".o", ".obj", ".user", ".pyc")) \ or ("key" in lower and lower.endswith((".json", ".properties"))): ignored.append(name) return ignored def payload(args): # 逐个读取白名单文件,不调用tar.extract,拒绝目录穿越、链接或重复条目。 # 文件总数和每项大小均受限,未知条目即使名称看似安全也不接受。 with tarfile.open(str(args.package), "r:gz") as archive: names = archive.getnames() if len(names) != len(set(names)) or set(names) != ALLOWED | {"image-route-manifest.json"}: raise RuntimeError("Unexpected archive contents") content = {} for name in names: item = archive.getmember(name) if not item.isfile() or item.size <= 0 or item.size > 2 * 1024 * 1024: raise RuntimeError("Invalid archive entry: " + name) content[name] = archive.extractfile(item).read() # manifest也受大小和类型约束,只有白名单完整且逐文件哈希匹配才继续。 manifest = json.loads(content.pop("image-route-manifest.json")) if manifest.get("protocol") != "COLLECTOR_IMAGE_ROUTE" or manifest.get("format") != 1: raise RuntimeError("Unknown package format") if len(manifest.get("files", [])) != len(ALLOWED): raise RuntimeError("Manifest entry count is invalid") checks = {item["path"]: item["sha256"] for item in manifest["files"]} if set(checks) != ALLOWED or any(hashlib.sha256(data).hexdigest() != checks[name] for name, data in content.items()): raise RuntimeError("Package hash validation failed") return content def project_append(project): text = project.read_text(encoding="utf-8") # 保留板上已有依赖/DEFINES,尤其GST、日志保留与hiredis真实库位置。 # 仅补缺少的HEADERS/SOURCES,不拿本机历史pro覆盖当前ARM工程。 # 注释提到文件名不算真实注册;追加动作仅发生在stage副本。 uncommented = "\n".join(line.split("#", 1)[0] for line in text.splitlines()) # 六个编译单元的类型由白名单后缀决定,不接受manifest提供qmake指令。 additions = [] for relative in PATCH_FILES: if not re.search(r"(?