Files
matrix-screen-controller/核桃派软件源代码/app/ota/package.py
T

295 lines
13 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path, PurePosixPath
import shutil
import tarfile
import tempfile
from typing import Any, BinaryIO
import zipfile
from .versioning import SoftwareVersion
from .policy import check_upgrade, package_format, release_metadata
PRODUCT_ID = "matrix-screen-controller-walnutpi"
PACKAGE_FORMAT_VERSION = 1
MAX_OTA_UPLOAD_BYTES = 256 * 1024 * 1024
MAX_OTA_PAYLOAD_BYTES = 256 * 1024 * 1024
MAX_OTA_EXPANDED_BYTES = 1024 * 1024 * 1024
_MAX_MANIFEST_BYTES = 64 * 1024
_PACKAGE_MEMBERS = {"manifest.json", "payload.tar.gz"}
_NATIVE_BUILD_OUTPUTS = {
"app/display/native/libh618_hub75.so",
"app/display/native/hub75_benchmark",
"app/display/native/hub75_native_test",
"app/display/native/hub75_safeoff",
}
_EXCLUDED_PARTS = {".venv", "data", "__pycache__", ".pytest_cache", "node_modules"}
class OtaPackageError(ValueError):
"""The uploaded archive is not a supported complete release."""
@dataclass(frozen=True)
class OtaPackageInfo:
path: Path
target_version: SoftwareVersion
created_at: str
release_notes: str
payload_sha256: str
payload_bytes: int
expanded_bytes: int
def document(self) -> dict[str, Any]:
return {
"target_version": str(self.target_version),
"created_at": self.created_at,
"release_notes": self.release_notes,
"payload_sha256": self.payload_sha256,
"payload_bytes": self.payload_bytes,
"expanded_bytes": self.expanded_bytes,
**release_metadata(self.target_version),
}
def _validated_manifest(raw: bytes) -> tuple[dict[str, Any], SoftwareVersion]:
if len(raw) > _MAX_MANIFEST_BYTES:
raise OtaPackageError("OTA manifest is too large")
try:
manifest = json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise OtaPackageError("OTA manifest is not valid UTF-8 JSON") from exc
expected = {
"format_version",
"product",
"software_version",
"created_at",
"release_notes",
"payload_sha256",
"payload_bytes",
"expanded_bytes",
}
if not isinstance(manifest, dict) or set(manifest) != expected:
raise OtaPackageError("OTA manifest fields do not match format version 1")
if type(manifest["format_version"]) is not int or manifest["format_version"] not in (1, 2):
raise OtaPackageError("OTA package format is unsupported")
if manifest["product"] != PRODUCT_ID:
raise OtaPackageError("OTA package is for a different product")
try:
version = SoftwareVersion.parse(manifest["software_version"])
if manifest["format_version"] != package_format(version):
raise ValueError("OTA version requires a different package format")
except ValueError as exc:
raise OtaPackageError(str(exc)) from exc
if not isinstance(manifest["created_at"], str) or not manifest["created_at"].strip():
raise OtaPackageError("OTA package created_at is missing")
if not isinstance(manifest["release_notes"], str) or len(manifest["release_notes"]) > 4000:
raise OtaPackageError("OTA release notes are invalid")
digest = manifest["payload_sha256"]
if not isinstance(digest, str) or len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
raise OtaPackageError("OTA payload SHA-256 is invalid")
for key, maximum in (
("payload_bytes", MAX_OTA_PAYLOAD_BYTES),
("expanded_bytes", MAX_OTA_EXPANDED_BYTES),
):
value = manifest[key]
if type(value) is not int or value <= 0 or value > maximum:
raise OtaPackageError(f"OTA {key} is outside the supported limit")
return manifest, version
def _payload_digest(handle: BinaryIO) -> tuple[str, int]:
digest = hashlib.sha256()
total = 0
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_OTA_PAYLOAD_BYTES:
raise OtaPackageError("OTA payload exceeds the supported limit")
digest.update(chunk)
return digest.hexdigest(), total
def inspect_package(path: Path, *, current_version: SoftwareVersion | None = None) -> OtaPackageInfo:
package_path = Path(path)
try:
size = package_path.stat().st_size
except OSError as exc:
raise OtaPackageError("OTA package cannot be read") from exc
if size <= 0 or size > MAX_OTA_UPLOAD_BYTES:
raise OtaPackageError("OTA package exceeds the 256 MiB upload limit")
try:
with zipfile.ZipFile(package_path, "r") as archive:
entries = archive.infolist()
if {entry.filename for entry in entries} != _PACKAGE_MEMBERS or len(entries) != 2:
raise OtaPackageError("OTA package must contain only manifest.json and payload.tar.gz")
if any(entry.is_dir() or entry.flag_bits & 0x1 for entry in entries):
raise OtaPackageError("OTA package entries must be unencrypted files")
manifest_entry = archive.getinfo("manifest.json")
payload_entry = archive.getinfo("payload.tar.gz")
if manifest_entry.file_size > _MAX_MANIFEST_BYTES:
raise OtaPackageError("OTA manifest is too large")
if payload_entry.file_size > MAX_OTA_PAYLOAD_BYTES:
raise OtaPackageError("OTA payload exceeds the supported limit")
manifest, target_version = _validated_manifest(archive.read(manifest_entry))
with archive.open(payload_entry, "r") as payload:
digest, payload_bytes = _payload_digest(payload)
except (OtaPackageError, zipfile.BadZipFile):
raise
except (KeyError, OSError, RuntimeError) as exc:
raise OtaPackageError("OTA package cannot be inspected") from exc
if payload_bytes != manifest["payload_bytes"] or digest != manifest["payload_sha256"]:
raise OtaPackageError("OTA payload checksum or size does not match the manifest")
if current_version is not None:
try:
check_upgrade(current_version, target_version)
except ValueError as exc:
raise OtaPackageError(str(exc)) from exc
return OtaPackageInfo(
path=package_path,
target_version=target_version,
created_at=manifest["created_at"],
release_notes=manifest["release_notes"],
payload_sha256=digest,
payload_bytes=payload_bytes,
expanded_bytes=manifest["expanded_bytes"],
)
def _safe_member_path(name: str) -> Path:
pure = PurePosixPath(name)
if pure.is_absolute() or not pure.parts or any(part in {"", ".", ".."} for part in pure.parts):
raise OtaPackageError(f"unsafe OTA payload path: {name}")
if pure.parts[0] not in {"software", "wheelhouse", "system-dependencies"}:
raise OtaPackageError(f"unexpected OTA payload root: {pure.parts[0]}")
return Path(*pure.parts)
def extract_payload(package: OtaPackageInfo, destination: Path) -> None:
target = Path(destination)
target.mkdir(parents=True, exist_ok=False)
expanded = 0
try:
with zipfile.ZipFile(package.path, "r") as archive:
with archive.open("payload.tar.gz", "r") as payload:
with tarfile.open(fileobj=payload, mode="r|gz") as tar:
for member in tar:
relative = _safe_member_path(member.name)
output = target / relative
if member.isdir():
output.mkdir(parents=True, exist_ok=True)
continue
if not member.isfile():
raise OtaPackageError("OTA payload may contain only regular files and directories")
expanded += member.size
if expanded > MAX_OTA_EXPANDED_BYTES or expanded > package.expanded_bytes:
raise OtaPackageError("OTA payload expands beyond its declared limit")
output.parent.mkdir(parents=True, exist_ok=True)
source = tar.extractfile(member)
if source is None:
raise OtaPackageError(f"OTA payload member cannot be read: {member.name}")
with output.open("xb") as handle:
shutil.copyfileobj(source, handle, length=1024 * 1024)
output.chmod(member.mode & 0o777 or 0o644)
if expanded != package.expanded_bytes:
raise OtaPackageError("OTA expanded size does not match the manifest")
if not (target / "software" / "VERSION").is_file():
raise OtaPackageError("OTA payload does not contain software/VERSION")
if not (target / "wheelhouse" / "SHA256SUMS").is_file():
raise OtaPackageError("OTA payload does not contain the offline wheel manifest")
except BaseException:
shutil.rmtree(target, ignore_errors=True)
raise
def _source_file_allowed(path: Path, source_root: Path) -> bool:
relative = path.relative_to(source_root)
if any(part in _EXCLUDED_PARTS | {"system-dependencies", "output", ".playwright-cli"} for part in relative.parts):
return False
if relative.as_posix() in _NATIVE_BUILD_OUTPUTS:
return False
if path.suffix in {".pyc", ".pyo"}:
return False
return True
def _tar_add_file(tar: tarfile.TarFile, source: Path, archive_name: str) -> int:
info = tar.gettarinfo(str(source), arcname=archive_name)
info.uid = info.gid = 0
info.uname = info.gname = "root"
info.mtime = 0
info.mode = 0o755 if source.suffix == ".sh" else 0o644
with source.open("rb") as handle:
tar.addfile(info, handle)
return info.size
def build_package(
source_root: Path,
wheelhouse: Path,
output_path: Path,
*,
version: SoftwareVersion,
release_notes: str,
created_at: datetime | None = None,
system_dependencies: Path | None = None,
) -> OtaPackageInfo:
source_root = Path(source_root).resolve()
wheelhouse = Path(wheelhouse).resolve()
system_dependencies = Path(system_dependencies).resolve() if system_dependencies is not None else None
if version.patch != 0 and version >= SoftwareVersion(1, 1, 0) and system_dependencies is not None:
raise OtaPackageError("普通补丁包不得携带系统软件,请导出新的 minor .0 安装包")
if SoftwareVersion.parse((source_root / "VERSION").read_text(encoding="utf-8")) != version:
raise OtaPackageError("requested package version does not match source VERSION")
if not (wheelhouse / "SHA256SUMS").is_file():
raise OtaPackageError("offline wheelhouse SHA256SUMS is missing")
if system_dependencies is not None and not (system_dependencies / "SHA256SUMS").is_file():
raise OtaPackageError("offline system dependency SHA256SUMS is missing")
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
raise FileExistsError(output)
timestamp = (created_at or datetime.now(timezone.utc)).astimezone().isoformat(timespec="seconds")
with tempfile.TemporaryDirectory(prefix="matrix-ota-build-") as temporary:
payload_path = Path(temporary) / "payload.tar.gz"
expanded = 0
with tarfile.open(payload_path, "w:gz", format=tarfile.PAX_FORMAT) as tar:
for path in sorted(source_root.rglob("*"), key=lambda item: item.relative_to(source_root).as_posix()):
if path.is_file() and _source_file_allowed(path, source_root):
expanded += _tar_add_file(tar, path, f"software/{path.relative_to(source_root).as_posix()}")
for path in sorted(wheelhouse.rglob("*"), key=lambda item: item.relative_to(wheelhouse).as_posix()):
if path.is_file():
expanded += _tar_add_file(tar, path, f"wheelhouse/{path.relative_to(wheelhouse).as_posix()}")
if system_dependencies is not None:
for path in sorted(system_dependencies.rglob("*"), key=lambda item: item.relative_to(system_dependencies).as_posix()):
if path.is_file():
expanded += _tar_add_file(
tar,
path,
f"software/system-dependencies/frpc/{path.relative_to(system_dependencies).as_posix()}",
)
payload_bytes = payload_path.stat().st_size
if payload_bytes > MAX_OTA_PAYLOAD_BYTES or expanded > MAX_OTA_EXPANDED_BYTES:
raise OtaPackageError("generated OTA payload exceeds the supported limit")
digest = hashlib.sha256(payload_path.read_bytes()).hexdigest()
manifest = {
"format_version": package_format(version),
"product": PRODUCT_ID,
"software_version": str(version),
"created_at": timestamp,
"release_notes": str(release_notes).strip(),
"payload_sha256": digest,
"payload_bytes": payload_bytes,
"expanded_bytes": expanded,
}
with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
archive.write(payload_path, "payload.tar.gz")
return inspect_package(output)