Files
nyaterm/scripts/release/verify_native_package.py
T

892 lines
43 KiB
Python
Executable File

#!/usr/bin/env python3
"""Verify native NyaTerm release artifacts before they are published."""
from __future__ import annotations
import argparse
import json
import os
import plistlib
import shutil
import struct
import subprocess
import sys
import tarfile
import tempfile
import zipfile
from pathlib import Path, PurePosixPath
import package_native
import windows_msi
import windows_portable
MIN_ARTIFACT_SIZE = 1024
def helper_filenames(target: str) -> list[str]:
suffix = ".exe" if "windows" in target else ""
return [f"{name}{suffix}" for name in package_native.helper_bins(target)]
def require_safe_archive_path(name: str) -> None:
path = PurePosixPath(name.replace("\\", "/"))
if path.is_absolute() or ".." in path.parts:
raise RuntimeError(f"archive contains an unsafe path: {name}")
def verify_zip_paths(archive: zipfile.ZipFile) -> set[str]:
names = set()
for item in archive.infolist():
require_safe_archive_path(item.filename)
names.add(item.filename.rstrip("/"))
return names
def verify_tar_paths(archive: tarfile.TarFile) -> set[str]:
names = set()
for item in archive.getmembers():
require_safe_archive_path(item.name)
names.add(item.name.rstrip("/"))
if item.issym() or item.islnk():
require_safe_archive_path(item.linkname)
return names
def pe_machine(data: bytes) -> int:
pe_offset = pe_header_offset(data)
return struct.unpack_from("<H", data, pe_offset + 4)[0]
def pe_header_offset(data: bytes) -> int:
if len(data) < 64 or data[:2] != b"MZ":
raise RuntimeError("Windows executable is missing the MZ header")
pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
if len(data) < pe_offset + 6 or data[pe_offset : pe_offset + 4] != b"PE\0\0":
raise RuntimeError("Windows executable is missing the PE header")
return pe_offset
def pe_optional_header(data: bytes) -> tuple[int, int, int]:
pe_offset = pe_header_offset(data)
if len(data) < pe_offset + 24:
raise RuntimeError("Windows executable has a truncated COFF header")
section_count = struct.unpack_from("<H", data, pe_offset + 6)[0]
optional_size = struct.unpack_from("<H", data, pe_offset + 20)[0]
optional_offset = pe_offset + 24
if optional_size < 70 or len(data) < optional_offset + optional_size:
raise RuntimeError("Windows executable has a truncated optional header")
return optional_offset, optional_size, section_count
def pe_subsystem(data: bytes) -> int:
optional_offset, _, _ = pe_optional_header(data)
return struct.unpack_from("<H", data, optional_offset + 68)[0]
def pe_resource_type_ids(data: bytes) -> set[int]:
optional_offset, optional_size, section_count = pe_optional_header(data)
magic = struct.unpack_from("<H", data, optional_offset)[0]
if magic == 0x10B:
data_directories_offset = optional_offset + 96
elif magic == 0x20B:
data_directories_offset = optional_offset + 112
else:
raise RuntimeError(f"Windows executable has unknown optional header 0x{magic:04x}")
resource_entry = data_directories_offset + 2 * 8
if resource_entry + 8 > optional_offset + optional_size:
return set()
resource_rva, resource_size = struct.unpack_from("<II", data, resource_entry)
if resource_rva == 0 or resource_size < 16:
return set()
section_offset = optional_offset + optional_size
resource_offset = None
for index in range(section_count):
entry = section_offset + index * 40
if entry + 40 > len(data):
raise RuntimeError("Windows executable has a truncated section table")
virtual_size, virtual_address, raw_size, raw_offset = struct.unpack_from(
"<IIII", data, entry + 8
)
extent = max(virtual_size, raw_size)
if virtual_address <= resource_rva < virtual_address + extent:
resource_offset = raw_offset + resource_rva - virtual_address
break
if resource_offset is None or resource_offset + 16 > len(data):
raise RuntimeError("Windows executable resource directory is out of bounds")
named_count, id_count = struct.unpack_from("<HH", data, resource_offset + 12)
entries_offset = resource_offset + 16
entries_end = entries_offset + (named_count + id_count) * 8
if entries_end > len(data):
raise RuntimeError("Windows executable has a truncated resource directory")
ids = set()
for index in range(named_count, named_count + id_count):
resource_id = struct.unpack_from("<I", data, entries_offset + index * 8)[0]
if resource_id & 0x8000_0000 == 0:
ids.add(resource_id)
return ids
def verify_windows_application_binary(data: bytes, artifact: str) -> None:
if pe_subsystem(data) != 2:
raise RuntimeError(f"{artifact} application executable is not Windows GUI subsystem")
resource_ids = pe_resource_type_ids(data)
missing = {3, 14} - resource_ids
if missing:
raise RuntimeError(
f"{artifact} application executable is missing Windows icon resources: "
+ ", ".join(str(value) for value in sorted(missing))
)
if 16 not in resource_ids:
raise RuntimeError(f"{artifact} application executable is missing Windows version resource (16)")
def windows_version_info(path: Path) -> dict[str, object]:
# Pass the filename separately, never interpolate it into PowerShell.
return json.loads(subprocess.check_output([
"powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
"$ErrorActionPreference = 'Stop'; "
"$v = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($env:NYATERM_VERIFY_EXE); "
"$v | Select-Object ProductName,FileDescription,ProductVersion,FileVersion,"
"FileMajorPart,FileMinorPart,FileBuildPart,FilePrivatePart,"
"ProductMajorPart,ProductMinorPart,ProductBuildPart,ProductPrivatePart | ConvertTo-Json -Compress",
], text=True, env={**os.environ, "NYATERM_VERIFY_EXE": str(path.resolve())}))
def verify_windows_application_version(path: Path, version: str) -> None:
if sys.platform != "win32":
return
metadata = windows_version_info(path)
numeric = package_native.windows_numeric_version(version)
expected: dict[str, object] = {
"FileDescription": "NyaTerm",
"ProductName": "NyaTerm",
"FileVersion": numeric,
"ProductVersion": version,
}
for prefix in ("File", "Product"):
for part, value in zip(("Major", "Minor", "Build", "Private"), numeric.split(".")):
expected[f"{prefix}{part}Part"] = int(value)
for key, value in expected.items():
if metadata.get(key) != value:
raise RuntimeError(
f"{path.name} contains the wrong Windows application version metadata: "
f"{key}={metadata.get(key)!r}, expected {value!r}"
)
def elf_machine(data: bytes) -> int:
if len(data) < 20 or data[:4] != b"\x7fELF":
raise RuntimeError("Linux executable is missing the ELF header")
byte_order = "little" if data[5] == 1 else "big"
return int.from_bytes(data[18:20], byte_order)
def macho_cpu_type(data: bytes) -> int:
if len(data) < 8:
raise RuntimeError("macOS executable is too short")
magic = data[:4]
if magic == b"\xcf\xfa\xed\xfe":
byte_order = "little"
elif magic == b"\xfe\xed\xfa\xcf":
byte_order = "big"
else:
raise RuntimeError("macOS executable is not a 64-bit Mach-O file")
return int.from_bytes(data[4:8], byte_order)
def verify_macos_url_scheme(
plist: dict[str, object], artifact: str,
identity: package_native.PackageIdentity = package_native.STABLE_IDENTITY,
) -> None:
url_types = plist.get("CFBundleURLTypes")
if not isinstance(url_types, list) or len(url_types) != 1:
raise RuntimeError(f"{artifact} must register exactly one macOS URL type")
url_type = url_types[0]
if not isinstance(url_type, dict):
raise RuntimeError(f"{artifact} contains an invalid macOS URL type")
schemes = url_type.get("CFBundleURLSchemes")
if schemes != list(identity.url_schemes):
raise RuntimeError(
f"{artifact} must register URL schemes {identity.url_schemes}"
)
def verify_linux_desktop(
content: str, expected_executable: str, artifact: str,
identity: package_native.PackageIdentity = package_native.STABLE_IDENTITY,
) -> None:
fields: dict[str, str] = {}
for line in content.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith(("#", "[")):
continue
if "=" not in stripped:
raise RuntimeError(f"{artifact} contains an invalid desktop entry line")
key, value = stripped.split("=", 1)
if key in fields:
raise RuntimeError(f"{artifact} contains duplicate desktop field {key}")
fields[key] = value
expected = {
"Type": "Application",
"Name": identity.display_name,
"Icon": identity.desktop_id,
"StartupWMClass": identity.desktop_id,
"Exec": f"{expected_executable} %U",
"MimeType": "".join(f"x-scheme-handler/{scheme};" for scheme in identity.url_schemes),
}
for key, value in expected.items():
if fields.get(key) != value:
raise RuntimeError(
f"{artifact} desktop field {key} is {fields.get(key)!r}, expected {value!r}"
)
def read_rpm_member(path: Path, member: str) -> bytes:
rpm2cpio = shutil.which("rpm2cpio")
if not rpm2cpio:
raise RuntimeError("rpm2cpio is required to verify RPM contents")
archive = subprocess.check_output([rpm2cpio, str(path)])
offset = 0
while offset < len(archive):
header = archive[offset : offset + 110]
if len(header) != 110 or header[:6] not in (b"070701", b"070702"):
raise RuntimeError(f"{path.name} contains an invalid RPM cpio payload")
try:
fields = [
int(header[6 + index * 8 : 14 + index * 8], 16)
for index in range(13)
]
except ValueError as error:
raise RuntimeError(
f"{path.name} contains an invalid RPM cpio header"
) from error
file_size = fields[6]
name_size = fields[11]
offset += 110
name_end = offset + name_size
if name_size < 1 or name_end > len(archive):
raise RuntimeError(f"{path.name} contains an invalid RPM cpio name")
name = archive[offset : name_end - 1].decode("utf-8")
offset = (name_end + 3) & ~3
data_end = offset + file_size
if data_end > len(archive):
raise RuntimeError(f"{path.name} contains a truncated RPM cpio entry")
if name == "TRAILER!!!":
break
if name.removeprefix("./") == member.removeprefix("/"):
return archive[offset:data_end]
offset = (data_end + 3) & ~3
raise RuntimeError(f"{path.name} is missing {member}")
def verify_windows_portable(path: Path, target: str, version: str) -> None:
root = windows_portable.LAYOUT["root"]
executables = ["NyaTerm.exe", *helper_filenames(target)]
conpty_files = package_native.conpty_files(target)
required = windows_portable.archive_files(target)
with zipfile.ZipFile(path) as archive:
names = verify_zip_paths(archive)
seen = set()
directories = {str(parent) for name in required
for parent in PurePosixPath(name).parents if str(parent) != "."}
for entry in archive.infolist():
name = entry.filename.rstrip("/")
if name.casefold() in seen:
raise RuntimeError(f"{path.name} contains a duplicate entry: {name}")
seen.add(name.casefold())
if (entry.external_attr >> 16) & 0o170000 == 0o120000:
raise RuntimeError(f"{path.name} contains a symbolic link: {name}")
allowed = directories if entry.is_dir() else required
if name not in allowed:
raise RuntimeError(f"{path.name} contains an unexpected entry: {name}")
missing = required - names
if missing:
raise RuntimeError(f"{path.name} is missing: {', '.join(sorted(missing))}")
packaged_version = archive.read(f"{root}/VERSION").decode("utf-8").strip()
if packaged_version != version:
raise RuntimeError(f"{path.name} contains version {packaged_version}, expected {version}")
binaries = {name: archive.read(f"{root}/{name}") for name in executables}
machines = {name: pe_machine(data) for name, data in binaries.items()}
conpty_machines = {
relative: pe_machine(archive.read(f"{root}/conpty/{relative.as_posix()}"))
for relative in conpty_files
}
verify_windows_application_binary(binaries["NyaTerm.exe"], path.name)
if sys.platform == "win32":
with tempfile.TemporaryDirectory() as directory:
application = Path(directory) / "NyaTerm.exe"
application.write_bytes(binaries["NyaTerm.exe"])
verify_windows_application_version(application, version)
expected_machine = {
"x86_64-pc-windows-msvc": 0x8664,
"aarch64-pc-windows-msvc": 0xAA64,
}[target]
for name, machine in machines.items():
if machine != expected_machine:
raise RuntimeError(
f"{path.name} contains PE machine 0x{machine:04x} for {name}, "
f"expected 0x{expected_machine:04x}"
)
verify_conpty_machines(conpty_machines, path.name)
def verify_conpty_machines(machines: dict[Path, int], artifact: str) -> None:
for relative, actual in machines.items():
expected = 0xAA64 if "arm64" in relative.parts else 0x8664
if actual != expected:
raise RuntimeError(
f"{artifact} contains ConPTY PE machine 0x{actual:04x} for {relative}, "
f"expected 0x{expected:04x}"
)
def find_7zip() -> str:
for name in ("7z", "7z.exe"):
found = shutil.which(name)
if found:
return found
raise RuntimeError("7-Zip is required to verify the NSIS installer")
def verify_windows_installer_script(content: str, version: str) -> None:
identity = package_native.release_identity(version)
required = [
f'Name "{identity.display_name}"',
rf'InstallDir "$LOCALAPPDATA\Programs\{identity.display_name}"',
f'InstallDirRegKey HKCU "{identity.windows_registry_key}" "InstallDir"',
f'VIProductVersion "{package_native.windows_numeric_version(version)}"',
f'VIFileVersion "{package_native.windows_numeric_version(version)}"',
f'VIAddVersionKey "ProductName" "{identity.display_name}"',
f'VIAddVersionKey "ProductVersion" "{version}"',
f'VIAddVersionKey "FileVersion" "{version}"',
f'VIAddVersionKey "FileDescription" "{identity.display_name} native GPUI terminal"',
r'!define MUI_FINISHPAGE_RUN "$INSTDIR\NyaTerm.exe"',
f'!define MUI_FINISHPAGE_RUN_TEXT "Launch {identity.display_name}"',
f'WriteRegStr HKCU "{identity.windows_registry_key}" "InstallDir" "$INSTDIR"',
f'WriteRegStr HKCU "{identity.windows_uninstall_key}" "DisplayName" "{identity.display_name}"',
rf'WriteRegStr HKCU "Software\Classes\{identity.desktop_id}" "URL Protocol" ""',
rf'CreateDirectory "$SMPROGRAMS\{identity.display_name}"',
rf'CreateShortcut "$SMPROGRAMS\{identity.display_name}\{identity.display_name}.lnk" "$INSTDIR\NyaTerm.exe" "" "$INSTDIR\icon.ico"',
rf'CreateShortcut "$DESKTOP\{identity.display_name}.lnk" "$INSTDIR\NyaTerm.exe" "" "$INSTDIR\icon.ico"',
f'DeleteRegKey HKCU "{identity.windows_registry_key}"',
f'DeleteRegKey HKCU "{identity.windows_uninstall_key}"',
rf'DeleteRegKey HKCU "Software\Classes\{identity.desktop_id}"',
rf'Delete "$DESKTOP\{identity.display_name}.lnk"',
rf'RMDir "$SMPROGRAMS\{identity.display_name}"',
]
for scheme in identity.url_schemes:
required.extend([
rf'WriteRegStr HKCU "Software\Classes\{scheme}" "URL Protocol" ""',
rf'ReadRegStr $0 HKCU "Software\Classes\{scheme}\shell\open\command" ""',
rf'StrCmp $0 "$\"$INSTDIR\NyaTerm.exe$\" $\"%1$\"" 0 protocol_skip_{scheme}',
])
directives = {line.strip() for line in content.splitlines()}
for line in required:
if line not in directives:
raise RuntimeError(f"NSIS identity directive missing: {line}")
other = (package_native.STABLE_IDENTITY if identity == package_native.PREVIEW_IDENTITY
else package_native.PREVIEW_IDENTITY)
for forbidden in [
f'"{other.windows_registry_key}"', f'"{other.windows_uninstall_key}"',
rf'"Software\Classes\{other.desktop_id}"',
rf'"$LOCALAPPDATA\Programs\{other.display_name}"',
rf'"$SMPROGRAMS\{other.display_name}"',
rf'"$DESKTOP\{other.display_name}.lnk"',
]:
if forbidden in content:
raise RuntimeError(f"NSIS script targets the other application: {forbidden}")
def verify_windows_installer(path: Path, target: str, version: str) -> None:
identity = package_native.release_identity(version)
script = package_native.WORK_DIR / "nyaterm-installer.nsi"
verify_windows_installer_script(script.read_text(encoding="utf-8"), version)
if sys.platform == "win32":
metadata = windows_version_info(path)
expected_strings = {
"ProductName": identity.display_name,
"FileDescription": f"{identity.display_name} native GPUI terminal",
}
for key, expected in expected_strings.items():
if metadata.get(key) != expected:
raise RuntimeError(
f"{path.name} contains the wrong Windows version metadata: "
f"{key}={metadata.get(key)!r}, expected {expected!r}"
)
numeric_version = package_native.windows_numeric_version(version)
for key in ("ProductVersion", "FileVersion"):
if metadata.get(key) not in {version, numeric_version}:
raise RuntimeError(
f"{path.name} contains the wrong Windows version metadata: "
f"{key}={metadata.get(key)!r}, expected {version!r} or {numeric_version!r}"
)
with path.open("rb") as handle:
header = handle.read(2)
if header != b"MZ":
raise RuntimeError(f"{path.name} is not a Windows executable")
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "installer"
subprocess.run(
[find_7zip(), "x", "-y", f"-o{output}", str(path)],
check=True,
stdout=subprocess.DEVNULL,
)
files = {
candidate.relative_to(output).as_posix(): candidate
for candidate in output.rglob("*") if candidate.is_file()
}
names = {candidate.name for candidate in files.values()}
version_files = list(output.rglob("VERSION"))
if len(version_files) != 1 or version_files[0].read_text(encoding="utf-8").strip() != version:
raise RuntimeError(f"{path.name} contains the wrong installed version")
required = {"NyaTerm.exe", "LICENSE", "VERSION", "Uninstall.exe"}
required.update(helper_filenames(target))
missing = required - names
if missing:
raise RuntimeError(f"{path.name} is missing installed files: {', '.join(sorted(missing))}")
applications = [candidate for candidate in files.values() if candidate.name == "NyaTerm.exe"]
if len(applications) != 1:
raise RuntimeError(f"{path.name} must contain exactly one NyaTerm.exe")
verify_windows_application_binary(applications[0].read_bytes(), path.name)
verify_windows_application_version(applications[0], version)
conpty_required = [
f"conpty/{relative.as_posix()}" for relative in package_native.conpty_files(target)
] + ["conpty/LICENSE.txt"]
missing_conpty = set(conpty_required) - files.keys()
if missing_conpty:
raise RuntimeError(f"{path.name} is missing ConPTY files: {', '.join(sorted(missing_conpty))}")
# Read extracted payloads while the temporary directory still exists.
verify_conpty_machines(
{relative: pe_machine(files[f"conpty/{relative.as_posix()}"].read_bytes())
for relative in package_native.conpty_files(target)},
path.name,
)
def verify_windows_msi_cleanup(rows: list[dict[str, str]], application_component: str) -> None:
# Cleanup is limited to the legacy helper in INSTALLDIR and the empty Start
# Menu folder, with both rules owned by the installed application's component.
expected = {
("RemoveLegacyActiveXHelper", application_component, "nyaterm-activex-helper.exe", "INSTALLDIR", "1"),
("RemoveStartMenuFolder", application_component, "", "ApplicationProgramsFolder", "2"),
}
actual = {(row["FileKey"], row["Component_"], row["FileName"].split("|")[-1],
row["DirProperty"], row["InstallMode"]) for row in rows}
if (len(rows) != len(expected) or actual != expected or
any(any(char in row["FileName"] for char in "*?/\\:") or row["FileName"].count("|") > 1
for row in rows)):
raise RuntimeError("MSI must not delete user data or change required cleanup")
def verify_windows_msi_database(metadata: dict, target: str, version: str) -> dict[str, str]:
"""Validate the compiled database, returning installed paths -> cabinet file IDs."""
numeric = package_native.windows_msi_version(version)
architecture = package_native.target_info(target).arch
tables = metadata["tables"]
properties = {row["Property"]: row["Value"] for row in tables["Property"]}
expected = {
"UpgradeCode": "{" + windows_msi.UPGRADE_CODE + "}",
"ProductCode": "{" + windows_msi.product_code(numeric, architecture) + "}",
"ProductName": "NyaTerm", "Manufacturer": "kang", "ProductVersion": numeric,
"ALLUSERS": "1", "ARPNOMODIFY": "1", "ARPPRODUCTICON": "NyaTermIcon",
}
for key, value in expected.items():
if properties.get(key) != value:
raise RuntimeError(f"MSI contains the wrong {key}: expected {value}")
if properties.get("MSIINSTALLPERUSER") or metadata["template"].split(";", 1)[0].lower() != architecture:
raise RuntimeError("MSI contains the wrong architecture or installation scope")
directories = {row["Directory"]: row for row in tables["Directory"]}
for directory, parent in (("INSTALLDIR", "ProgramFiles64Folder"),
("ApplicationProgramsFolder", "ProgramMenuFolder")):
row = directories.get(directory, {})
if row.get("Directory_Parent") != parent or row.get("DefaultDir", "").split("|")[-1] != "NyaTerm":
raise RuntimeError(f"MSI contains the wrong {directory} directory")
def installed_directory(directory: str, visited: frozenset[str] = frozenset()) -> str:
if directory == "INSTALLDIR":
return ""
if directory in visited or directory not in directories:
raise RuntimeError("MSI contains an invalid directory tree")
row = directories[directory]
name = row["DefaultDir"].split(":", 1)[0].split("|")[-1]
if name in ("", ".", "..") or any(char in name for char in "/\\:"):
raise RuntimeError("MSI contains an unsafe directory")
parent = installed_directory(row["Directory_Parent"], visited | {directory})
return f"{parent}/{name}".lstrip("/")
components = {row["Component"]: row for row in tables["Component"]}
files: dict[str, str] = {}
for row in tables["File"]:
component = components[row["Component_"]]
name = row["FileName"].split("|")[-1]
if name in ("", ".", "..") or any(char in name for char in "/\\:"):
raise RuntimeError("MSI contains an unsafe file name")
relative = (installed_directory(component["Directory_"]) + "/" + name).lstrip("/")
expected_guid = "{" + windows_msi.guid(f"component:{architecture}:{relative}") + "}"
if (component["ComponentId"] != expected_guid or component["Attributes"] != "256"
or component["KeyPath"] != row["File"]):
raise RuntimeError(f"MSI contains an invalid component identity for {relative}")
if relative in files:
raise RuntimeError(f"MSI contains a duplicate installed path: {relative}")
files[relative] = row["File"]
required = {"NyaTerm.exe", "LICENSE", "VERSION", "icon.ico", *helper_filenames(target),
"conpty/LICENSE.txt", *(f"conpty/{p.as_posix()}" for p in package_native.conpty_files(target))}
if set(files) != required or len(components) != len(files):
raise RuntimeError(f"MSI installed file set mismatch: missing {sorted(required - files.keys())}, "
f"unexpected {sorted(files.keys() - required)}")
upgrades = {row["ActionProperty"]: row for row in tables["Upgrade"]}
upgrade_ranges = {
"WIX_UPGRADE_DETECTED": ("", numeric, "1"),
"WIX_DOWNGRADE_DETECTED": (numeric, "", "2"),
"NYATERM_SAME_VERSION_DETECTED": (numeric, numeric, "770"),
}
if len(tables["Upgrade"]) != len(upgrade_ranges) or set(upgrades) != set(upgrade_ranges):
raise RuntimeError("MSI has an invalid major upgrade policy")
for key, limits in upgrade_ranges.items():
row = upgrades[key]
if (row["UpgradeCode"] != expected["UpgradeCode"] or
(row["VersionMin"], row["VersionMax"], row["Attributes"]) != limits):
raise RuntimeError("MSI has an invalid major upgrade version range")
conditions = {row["Condition"] for row in tables["LaunchCondition"]}
if not {"NOT WIX_DOWNGRADE_DETECTED", "Installed OR NOT NYATERM_SAME_VERSION_DETECTED"} <= conditions:
raise RuntimeError("MSI must block downgrades and same-version upgrades")
sequence = {row["Action"]: int(row["Sequence"]) for row in tables["InstallExecuteSequence"]}
required_actions = ("FindRelatedProducts", "LaunchConditions", "InstallInitialize",
"RemoveExistingProducts", "ProcessComponents", "InstallFiles", "InstallFinalize")
if (not set(required_actions) <= sequence.keys() or
[sequence[a] for a in required_actions] != sorted(sequence[a] for a in required_actions) or
sequence["RemoveExistingProducts"] != sequence["InstallInitialize"] + 1):
raise RuntimeError("MSI must remove old products immediately after InstallInitialize")
actions = {(row["Action"], row["Type"], row["Source"], row["Target"]) for row in tables["CustomAction"]}
expected_actions = {(name, "3090", files["NyaTerm.exe"], argument) for name, argument in (
("RegisterUrlProtocols", "--register-url-protocols"), ("UnregisterUrlProtocols", "--unregister-url-protocols"))}
if actions != expected_actions:
raise RuntimeError("MSI contains unexpected protocol helper custom actions")
rows = {row["Action"]: row for row in tables["InstallExecuteSequence"]}
if (sequence.get("RegisterUrlProtocols") != sequence["InstallFiles"] + 1 or
sequence.get("UnregisterUrlProtocols") != sequence.get("RemoveFiles", 0) - 1 or
rows["RegisterUrlProtocols"]["Condition"] != 'NOT REMOVE~="ALL"' or
rows["UnregisterUrlProtocols"]["Condition"] != 'REMOVE~="ALL" AND NOT UPGRADINGPRODUCTCODE'):
raise RuntimeError("MSI protocol helpers have unsafe sequencing")
application_component = next(row["Component_"] for row in tables["File"]
if row["File"] == files["NyaTerm.exe"])
verify_windows_msi_cleanup(tables["RemoveFile"], application_component)
shortcuts = {(row["Directory_"], row["Name"].split("|")[-1], row["Target"])
for row in tables["Shortcut"]}
if shortcuts != {(directory, "NyaTerm", f"[#{files['NyaTerm.exe']}]")
for directory in ("DesktopFolder", "ApplicationProgramsFolder")}:
raise RuntimeError("MSI contains invalid shortcuts")
if tables["Registry"]:
raise RuntimeError("MSI must delegate ownership-aware protocol cleanup to the native helper")
return files
def verify_windows_msi(path: Path, target: str, version: str) -> None:
files = verify_windows_msi_database(windows_msi.read_database(path), target, version)
with tempfile.TemporaryDirectory(prefix="nyaterm-msi-") as directory:
root = Path(directory)
subprocess.run([windows_msi.find_wix(), "msi", "decompile", "-x", str(root / "payload"),
"-o", str(root / "package.wxs"), str(path.resolve())], check=True)
payload = {relative: root / "payload" / "File" / file_id for relative, file_id in files.items()}
if any(not file.is_file() for file in payload.values()):
raise RuntimeError("MSI is missing embedded payload files")
if payload["VERSION"].read_text(encoding="utf-8").strip() != version:
raise RuntimeError("MSI contains the wrong installed version")
machine = {"x64": 0x8664, "arm64": 0xAA64}[package_native.target_info(target).arch]
for name in ("NyaTerm.exe", *helper_filenames(target)):
if pe_machine(payload[name].read_bytes()) != machine:
raise RuntimeError(f"MSI contains the wrong PE machine for {name}")
verify_windows_application_binary(payload["NyaTerm.exe"].read_bytes(), path.name)
verify_windows_application_version(payload["NyaTerm.exe"], version)
verify_conpty_machines(
{relative: pe_machine(payload[f"conpty/{relative.as_posix()}"].read_bytes())
for relative in package_native.conpty_files(target)}, path.name,
)
def verify_macos_archive(path: Path, target: str, version: str) -> None:
identity = package_native.release_identity(version)
bundle = identity.macos_bundle_name
executable = f"{bundle}/Contents/MacOS/NyaTerm"
helpers = [f"{bundle}/Contents/MacOS/{name}" for name in helper_filenames(target)]
info_plist = f"{bundle}/Contents/Info.plist"
version_file = f"{bundle}/Contents/Resources/VERSION"
required = {
executable,
*helpers,
info_plist,
version_file,
f"{bundle}/Contents/Resources/LICENSE",
f"{bundle}/Contents/Resources/icon.icns",
}
with tarfile.open(path, "r:gz") as archive:
names = verify_tar_paths(archive)
missing = required - names
if missing:
raise RuntimeError(f"{path.name} is missing: {', '.join(sorted(missing))}")
packaged_version = archive.extractfile(version_file).read().decode().strip() # type: ignore[union-attr]
plist = plistlib.loads(archive.extractfile(info_plist).read()) # type: ignore[union-attr]
binary = archive.extractfile(executable).read() # type: ignore[union-attr]
helper_binaries = {
name: archive.extractfile(name).read() # type: ignore[union-attr]
for name in helpers
}
if packaged_version != version or plist.get("CFBundleShortVersionString") != version:
raise RuntimeError(f"{path.name} contains inconsistent version metadata")
if plist.get("CFBundleIdentifier") != identity.macos_identifier:
raise RuntimeError(f"{path.name} contains the wrong bundle identifier")
for field in ("CFBundleDisplayName", "CFBundleName"):
if plist.get(field) != identity.display_name:
raise RuntimeError(f"{path.name} contains the wrong {field}")
if plist.get("CFBundleExecutable") != "NyaTerm" or plist.get("CFBundleVersion") != version:
raise RuntimeError(f"{path.name} contains inconsistent bundle metadata")
verify_macos_url_scheme(plist, path.name, identity)
expected_cpu = {
"x86_64-apple-darwin": 0x01000007,
"aarch64-apple-darwin": 0x0100000C,
}[target]
for name, data in {executable: binary, **helper_binaries}.items():
actual_cpu = macho_cpu_type(data)
if actual_cpu != expected_cpu:
raise RuntimeError(
f"{path.name} contains Mach-O CPU 0x{actual_cpu:08x} for {name}, "
f"expected 0x{expected_cpu:08x}"
)
def verify_dmg(path: Path, version: str) -> None:
identity = package_native.release_identity(version)
if sys.platform != "darwin":
return
result = subprocess.run(
["hdiutil", "attach", "-plist", "-readonly", "-nobrowse", str(path)],
check=True,
stdout=subprocess.PIPE,
)
payload = plistlib.loads(result.stdout)
entities = payload.get("system-entities", [])
mount_point = next(
(item.get("mount-point") for item in entities if item.get("mount-point")), None
)
device = next(
(item.get("dev-entry") for item in reversed(entities) if item.get("dev-entry")), None
)
try:
if not mount_point:
raise RuntimeError(f"{path.name} did not expose a mounted volume")
executable = Path(mount_point) / identity.macos_bundle_name / "Contents" / "MacOS" / "NyaTerm"
bundle = Path(mount_point) / identity.macos_bundle_name
plist = plistlib.loads((bundle / "Contents" / "Info.plist").read_bytes())
if plist.get("CFBundleIdentifier") != identity.macos_identifier or plist.get("CFBundleDisplayName") != identity.display_name:
raise RuntimeError(f"{path.name} contains the wrong bundle identity")
verify_macos_url_scheme(plist, path.name, identity)
if not (Path(mount_point) / "Applications").is_symlink() or os.readlink(Path(mount_point) / "Applications") != "/Applications":
raise RuntimeError(f"{path.name} is missing the Applications link")
for name in helper_filenames("aarch64-apple-darwin"):
if not (bundle / "Contents" / "MacOS" / name).is_file():
raise RuntimeError(f"{path.name} is missing {name}")
if (bundle / "Contents" / "Resources" / "VERSION").read_text().strip() != version:
raise RuntimeError(f"{path.name} contains the wrong version")
if not executable.is_file():
raise RuntimeError(f"{path.name} does not contain the NyaTerm application")
finally:
if device:
subprocess.run(["hdiutil", "detach", device], check=True)
def verify_appimage(path: Path, target: str, version: str) -> None:
identity = package_native.release_identity(version)
expected_machine = {
"x86_64-unknown-linux-gnu": 62,
"aarch64-unknown-linux-gnu": 183,
}[target]
with path.open("rb") as handle:
machine = elf_machine(handle.read(64))
if machine != expected_machine:
raise RuntimeError(f"{path.name} contains ELF machine {machine}, expected {expected_machine}")
if not os.access(path, os.X_OK):
raise RuntimeError(f"{path.name} is not executable")
with tempfile.TemporaryDirectory() as directory:
subprocess.run(
[str(path.resolve()), "--appimage-extract"],
cwd=directory,
check=True,
stdout=subprocess.DEVNULL,
env={**os.environ, "APPIMAGE_EXTRACT_AND_RUN": "1"},
)
root = Path(directory) / "squashfs-root"
executables = [
root / "usr" / "bin" / name
for name in ("nyaterm", *helper_filenames(target))
]
version_file = root / "usr" / "share" / "doc" / identity.desktop_id / "VERSION"
desktop_file = root / "usr" / "share" / "applications" / identity.linux_desktop_file
required = [
root / "AppRun",
*executables,
desktop_file,
root / identity.linux_desktop_file,
root / f"{identity.desktop_id}.png",
root / "usr" / "share" / "doc" / identity.desktop_id / "LICENSE",
version_file,
]
missing = [item for item in required if not item.exists()]
if missing:
raise RuntimeError(f"{path.name} is missing AppImage entries: {missing}")
packaged_version = version_file.read_text(encoding="utf-8").strip()
desktop_content = desktop_file.read_text(encoding="utf-8")
verify_linux_desktop((root / identity.linux_desktop_file).read_text(encoding="utf-8"), package_native.APP_BIN, path.name, identity)
for size in ("32x32", "64x64", "128x128", "256x256"):
icon = root / "usr" / "share" / "icons" / "hicolor" / size / "apps" / f"{identity.desktop_id}.png"
if not icon.is_file():
raise RuntimeError(f"{path.name} is missing {icon.name}")
machines = {}
for executable in executables:
with executable.open("rb") as handle:
machines[executable.name] = elf_machine(handle.read(64))
if packaged_version != version:
raise RuntimeError(f"{path.name} contains inconsistent version")
verify_linux_desktop(desktop_content, package_native.APP_BIN, path.name, identity)
for name, binary_machine in machines.items():
if binary_machine != expected_machine:
raise RuntimeError(
f"{path.name} contains ELF machine {binary_machine} for {name}, "
f"expected {expected_machine}"
)
def verify_deb(path: Path, target: str, version: str) -> None:
identity = package_native.release_identity(version)
expected_arch = package_native.linux_deb_arch(target)
fields = subprocess.check_output(
["dpkg-deb", "--field", str(path), "Package", "Version", "Architecture"],
text=True,
)
metadata = dict(line.split(": ", 1) for line in fields.splitlines())
if metadata.get("Package") != identity.desktop_id:
raise RuntimeError(f"{path.name} has the wrong Debian package name")
if metadata.get("Version") != version.replace("-", "~"):
raise RuntimeError(f"{path.name} has the wrong Debian version")
if metadata.get("Architecture") != expected_arch:
raise RuntimeError(f"{path.name} has the wrong Debian architecture")
contents = subprocess.check_output(["dpkg-deb", "--contents", str(path)], text=True)
for required in (
f"./opt/{identity.desktop_id}/nyaterm",
f"./opt/{identity.desktop_id}/VERSION",
f"./usr/share/applications/{identity.linux_desktop_file}",
*(f"./opt/{identity.desktop_id}/{name}" for name in helper_filenames(target)),
):
if required not in contents:
raise RuntimeError(f"{path.name} is missing {required}")
with tempfile.TemporaryDirectory() as directory:
subprocess.run(
["dpkg-deb", "--extract", str(path), directory],
check=True,
stdout=subprocess.DEVNULL,
)
root = Path(directory)
desktop_content = (
root / "usr" / "share" / "applications" / identity.linux_desktop_file
).read_text(encoding="utf-8")
if (root / "opt" / identity.desktop_id / "VERSION").read_text(encoding="utf-8").strip() != version:
raise RuntimeError(f"{path.name} contains the wrong installed version")
for name in (package_native.APP_BIN, *helper_filenames(target)):
if not (root / "opt" / identity.desktop_id / name).is_file():
raise RuntimeError(f"{path.name} is missing {name}")
for size in ("32x32", "64x64", "128x128", "256x256"):
if not (root / "usr" / "share" / "icons" / "hicolor" / size / "apps" / f"{identity.desktop_id}.png").is_file():
raise RuntimeError(f"{path.name} is missing its {size} icon")
verify_linux_desktop(desktop_content, f"/opt/{identity.desktop_id}/nyaterm", path.name, identity)
def verify_rpm(path: Path, target: str, version: str) -> None:
identity = package_native.release_identity(version)
rpm_version, rpm_release = package_native.linux_rpm_version(version)
expected = f"{identity.desktop_id}|{rpm_version}|{rpm_release}|{package_native.linux_rpm_arch(target)}"
actual = subprocess.check_output(
["rpm", "-qp", "--qf", "%{NAME}|%{VERSION}|%{RELEASE}|%{ARCH}", str(path)],
text=True,
)
if actual != expected:
raise RuntimeError(f"{path.name} has RPM metadata {actual!r}, expected {expected!r}")
contents = subprocess.check_output(["rpm", "-qlp", str(path)], text=True)
for required in (
f"/opt/{identity.desktop_id}/nyaterm",
f"/opt/{identity.desktop_id}/VERSION",
f"/usr/share/applications/{identity.linux_desktop_file}",
*(f"/opt/{identity.desktop_id}/{name}" for name in helper_filenames(target)),
*(f"/usr/share/icons/hicolor/{size}/apps/{identity.desktop_id}.png"
for size in ("32x32", "64x64", "128x128", "256x256")),
):
if required not in contents.splitlines():
raise RuntimeError(f"{path.name} is missing {required}")
if read_rpm_member(path, f"/opt/{identity.desktop_id}/VERSION").decode("utf-8").strip() != version:
raise RuntimeError(f"{path.name} contains the wrong installed version")
desktop_content = read_rpm_member(
path, f"/usr/share/applications/{identity.linux_desktop_file}"
).decode("utf-8")
verify_linux_desktop(desktop_content, f"/opt/{identity.desktop_id}/nyaterm", path.name, identity)
def verify_release(
dist: Path, target: str, version: str, artifact_version: str | None = None
) -> dict[str, object]:
version = package_native.validate_version(version)
artifact_version = package_native.validate_artifact_version(
artifact_version or version
)
expected_names = package_native.artifact_names(target, artifact_version)
actual_names = {path.name for path in dist.iterdir() if path.is_file()}
missing = expected_names - actual_names
unexpected = actual_names - expected_names
if missing:
raise RuntimeError(f"missing release artifacts: {', '.join(sorted(missing))}")
if unexpected:
raise RuntimeError(f"unexpected release artifacts: {', '.join(sorted(unexpected))}")
for name in expected_names:
if (dist / name).stat().st_size < MIN_ARTIFACT_SIZE:
raise RuntimeError(f"release artifact is unexpectedly small: {name}")
info = package_native.target_info(target)
prefix = f"{package_native.APP_NAME}_{artifact_version}_{info.label}"
if info.os_name == "windows":
verify_windows_portable(dist / f"{prefix}_portable.zip", target, version)
verify_windows_installer(dist / f"{prefix}-setup.exe", target, version)
if package_native.includes_windows_msi(artifact_version):
verify_windows_msi(dist / f"{prefix}.msi", target, version)
elif info.os_name == "macos":
verify_macos_archive(dist / f"{prefix}.app.tar.gz", target, version)
verify_dmg(dist / f"{prefix}.dmg", version)
else:
verify_appimage(dist / f"{prefix}.AppImage", target, version)
verify_deb(dist / f"{prefix}.deb", target, version)
verify_rpm(dist / f"{prefix}.rpm", target, version)
return {"target": target, "version": version, "artifacts": sorted(expected_names)}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--target", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--artifact-version")
parser.add_argument("--dist", type=Path, default=Path("dist"))
args = parser.parse_args()
summary = verify_release(
args.dist.resolve(), args.target, args.version, args.artifact_version
)
print(
f"Verified {len(summary['artifacts'])} artifacts for "
f"{summary['target']} ({summary['version']})"
)
if __name__ == "__main__":
main()