Files
nyaterm/scripts/tests/test_verify_native_package.py
Kang 7b2fcd887b feat(windows): add application icon resource handling for Windows builds
- Introduced `embed-resource` dependency to manage Windows application icons.
- Updated `build.rs` to compile Windows resources and include an application icon.
- Enhanced shell command execution to prevent console window from appearing during startup.
- Added validation for Windows application binaries to ensure GUI subsystem and required icon resources are present.
- Updated tests to verify the presence of necessary resources in the Windows executable.
2026-09-19 13:53:41 +08:00

292 lines
14 KiB
Python

from __future__ import annotations
import io
import plistlib
import struct
import sys
import tarfile
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
RELEASE_SCRIPTS = Path(__file__).resolve().parents[1] / "release"
sys.path.insert(0, str(RELEASE_SCRIPTS))
import verify_native_package # noqa: E402
def fake_pe(
machine: int, *, subsystem: int = 3, resource_ids: tuple[int, ...] = ()
) -> bytes:
data = bytearray(1024)
data[:2] = b"MZ"
struct.pack_into("<I", data, 0x3C, 0x80)
data[0x80:0x84] = b"PE\0\0"
struct.pack_into("<H", data, 0x84, machine)
struct.pack_into("<H", data, 0x86, 1)
struct.pack_into("<H", data, 0x94, 0xF0)
optional = 0x98
struct.pack_into("<H", data, optional, 0x20B)
struct.pack_into("<H", data, optional + 68, subsystem)
struct.pack_into("<I", data, optional + 108, 16)
if resource_ids:
struct.pack_into("<II", data, optional + 112 + 2 * 8, 0x1000, 0x100)
section = optional + 0xF0
data[section : section + 8] = b".rsrc\0\0\0"
struct.pack_into("<IIII", data, section + 8, 0x100, 0x1000, 0x200, 0x200)
if resource_ids:
struct.pack_into("<HH", data, 0x200 + 12, 0, len(resource_ids))
for index, resource_id in enumerate(resource_ids):
struct.pack_into("<II", data, 0x210 + index * 8, resource_id, 0)
return bytes(data)
def fake_macho(cpu_type: int) -> bytes:
return b"\xcf\xfa\xed\xfe" + cpu_type.to_bytes(4, "little") + bytes(504)
def newc_entry(name: str, content: bytes) -> bytes:
encoded_name = name.encode("utf-8") + b"\0"
fields = [0, 0, 0, 0, 1, 0, len(content), 0, 0, 0, 0, len(encoded_name), 0]
entry = b"070701" + b"".join(f"{value:08x}".encode() for value in fields)
entry += encoded_name
entry += bytes((-len(entry)) % 4)
entry += content
return entry + bytes((-len(entry)) % 4)
def write_portable(path: Path, machine: int, *, helper_machine: int | None = None) -> None:
"""Build a portable zip whose layout matches package_native's output."""
root = "NyaTerm-portable"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(
f"{root}/NyaTerm.exe",
fake_pe(machine, subsystem=2, resource_ids=(3, 14, 24)),
)
if helper_machine is not None:
for name in verify_native_package.helper_filenames(
"x86_64-pc-windows-msvc"
):
archive.writestr(f"{root}/{name}", fake_pe(helper_machine))
archive.writestr(f"{root}/nyaterm-portable", b"")
archive.writestr(f"{root}/LICENSE", b"license")
archive.writestr(f"{root}/VERSION", b"2.0.0\n")
archive.writestr(f"{root}/data/.keep", b"")
class VerifyNativePackageTests(unittest.TestCase):
def test_archive_paths_reject_parent_traversal_and_absolute_paths(self) -> None:
for path in ("../secret", "dir/../../secret", "/absolute/file"):
with self.subTest(path=path), self.assertRaises(RuntimeError):
verify_native_package.require_safe_archive_path(path)
verify_native_package.require_safe_archive_path("NyaTerm/dir/file")
def test_windows_portable_has_required_entries(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "portable.zip"
write_portable(path, 0x8664, helper_machine=0x8664)
verify_native_package.verify_windows_portable(
path, "x86_64-pc-windows-msvc", "2.0.0"
)
def test_windows_portable_rejects_wrong_architecture(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "portable.zip"
write_portable(path, 0xAA64, helper_machine=0x8664)
with self.assertRaisesRegex(RuntimeError, "PE machine"):
verify_native_package.verify_windows_portable(
path, "x86_64-pc-windows-msvc", "2.0.0"
)
def test_windows_portable_requires_every_helper_binary(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "portable.zip"
write_portable(path, 0x8664)
with self.assertRaisesRegex(RuntimeError, "nyaterm-rdp-helper.exe"):
verify_native_package.verify_windows_portable(
path, "x86_64-pc-windows-msvc", "2.0.0"
)
def test_windows_portable_rejects_helper_architecture_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "portable.zip"
write_portable(path, 0x8664, helper_machine=0xAA64)
with self.assertRaisesRegex(
RuntimeError, "PE machine 0xaa64 for nyaterm-rdp-helper.exe"
):
verify_native_package.verify_windows_portable(
path, "x86_64-pc-windows-msvc", "2.0.0"
)
def test_windows_application_requires_gui_subsystem_and_icon_resources(self) -> None:
valid = fake_pe(0x8664, subsystem=2, resource_ids=(3, 14, 24))
verify_native_package.verify_windows_application_binary(valid, "test.exe")
with self.assertRaisesRegex(RuntimeError, "GUI subsystem"):
verify_native_package.verify_windows_application_binary(
fake_pe(0x8664, subsystem=3, resource_ids=(3, 14)), "test.exe"
)
for resources in ((14,), (3,), (24,)):
with self.subTest(resources=resources), self.assertRaisesRegex(
RuntimeError, "icon resources"
):
verify_native_package.verify_windows_application_binary(
fake_pe(0x8664, subsystem=2, resource_ids=resources),
"test.exe",
)
def test_macos_archive_validates_both_identities_and_rejects_mismatch(self) -> None:
for version in ("2.0.0", "2.0.0-preview.1"):
identity = verify_native_package.package_native.release_identity(version)
bundle = identity.macos_bundle_name
with self.subTest(version=version), tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "NyaTerm.app.tar.gz"
plist = {
"CFBundleIdentifier": identity.macos_identifier,
"CFBundleDisplayName": identity.display_name,
"CFBundleName": identity.display_name,
"CFBundleExecutable": "NyaTerm",
"CFBundleShortVersionString": version,
"CFBundleVersion": version,
"CFBundleURLTypes": [{"CFBundleURLSchemes": [identity.desktop_id]}],
}
entries = {
f"{bundle}/Contents/MacOS/NyaTerm": fake_macho(0x0100000C),
**{f"{bundle}/Contents/MacOS/{name}": fake_macho(0x0100000C)
for name in verify_native_package.helper_filenames("aarch64-apple-darwin")},
f"{bundle}/Contents/Info.plist": plistlib.dumps(plist),
f"{bundle}/Contents/Resources/VERSION": f"{version}\n".encode(),
f"{bundle}/Contents/Resources/LICENSE": b"license",
f"{bundle}/Contents/Resources/icon.icns": b"icon",
}
def write_archive():
with tarfile.open(path, "w:gz") as archive:
for name, data in entries.items():
item = tarfile.TarInfo(name)
item.size = len(data)
archive.addfile(item, io.BytesIO(data))
write_archive()
verify_native_package.verify_macos_archive(path, "aarch64-apple-darwin", version)
other = "2.0.0-preview.1" if version == "2.0.0" else "2.0.0"
with self.assertRaisesRegex(RuntimeError, "is missing"):
verify_native_package.verify_macos_archive(path, "aarch64-apple-darwin", other)
for field, wrong in [("CFBundleIdentifier", "wrong.id"), ("CFBundleDisplayName", "Wrong"),
("CFBundleName", "Wrong"), ("CFBundleURLTypes", [{"CFBundleURLSchemes": ["ssh"]}])]:
original = plist[field]
plist[field] = wrong
entries[f"{bundle}/Contents/Info.plist"] = plistlib.dumps(plist)
write_archive()
with self.assertRaises(RuntimeError):
verify_native_package.verify_macos_archive(path, "aarch64-apple-darwin", version)
plist[field] = original
def test_macos_scheme_validation_rejects_extra_protocols(self) -> None:
with self.assertRaisesRegex(RuntimeError, "must register only"):
verify_native_package.verify_macos_url_scheme(
{
"CFBundleURLTypes": [
{"CFBundleURLSchemes": ["nyaterm", "ssh", "telnet"]}
]
},
"Info.plist",
)
def test_linux_desktop_validation_requires_nyaterm_scheme_and_percent_u(
self,
) -> None:
desktop = "\n".join(
(
"[Desktop Entry]",
"Type=Application",
"Name=NyaTerm",
"Icon=nyaterm",
"StartupWMClass=nyaterm",
"Exec=/opt/nyaterm/nyaterm %U",
"MimeType=x-scheme-handler/nyaterm;",
)
)
verify_native_package.verify_linux_desktop(
desktop, "/opt/nyaterm/nyaterm", "nyaterm.desktop"
)
with self.assertRaisesRegex(RuntimeError, "MimeType"):
verify_native_package.verify_linux_desktop(
desktop.replace(
"x-scheme-handler/nyaterm;",
"x-scheme-handler/nyaterm;x-scheme-handler/ssh;",
),
"/opt/nyaterm/nyaterm",
"nyaterm.desktop",
)
with self.assertRaisesRegex(RuntimeError, "Exec"):
verify_native_package.verify_linux_desktop(
desktop.replace(" %U", ""),
"/opt/nyaterm/nyaterm",
"nyaterm.desktop",
)
def test_preview_desktop_requires_preview_name_icon_wmclass_and_scheme(self) -> None:
identity = verify_native_package.package_native.PREVIEW_IDENTITY
desktop = "\n".join(("[Desktop Entry]", "Type=Application", "Name=NyaTerm Preview",
"Icon=nyaterm-preview", "StartupWMClass=nyaterm-preview",
"Exec=/opt/nyaterm-preview/nyaterm %U", "MimeType=x-scheme-handler/nyaterm-preview;"))
verify_native_package.verify_linux_desktop(desktop, "/opt/nyaterm-preview/nyaterm", "preview.desktop", identity)
for field in ("Name", "Icon", "StartupWMClass", "MimeType"):
broken = "\n".join(line if not line.startswith(f"{field}=") else f"{field}=stable" for line in desktop.splitlines())
with self.subTest(field=field), self.assertRaisesRegex(RuntimeError, field):
verify_native_package.verify_linux_desktop(broken, "/opt/nyaterm-preview/nyaterm", "preview.desktop", identity)
def test_deb_name_matching_is_exact_not_a_prefix(self) -> None:
with mock.patch.object(verify_native_package.subprocess, "check_output", return_value=
"Package: nyaterm-preview\nVersion: 2.0.0\nArchitecture: amd64\n"):
with self.assertRaisesRegex(RuntimeError, "wrong Debian package name"):
verify_native_package.verify_deb(Path("test.deb"), "x86_64-unknown-linux-gnu", "2.0.0")
def test_rpm_rejects_the_other_flavor_package_metadata(self) -> None:
with mock.patch.object(verify_native_package.subprocess, "check_output", return_value="nyaterm|2.0.0|0.preview.1|x86_64"):
with self.assertRaisesRegex(RuntimeError, "RPM metadata"):
verify_native_package.verify_rpm(Path("test.rpm"), "x86_64-unknown-linux-gnu", "2.0.0-preview.1")
def test_rpm_member_reader_extracts_desktop_from_newc_payload(self) -> None:
desktop = b"MimeType=x-scheme-handler/nyaterm;\n"
payload = newc_entry(
"./usr/share/applications/nyaterm.desktop", desktop
) + newc_entry("TRAILER!!!", b"")
with (
mock.patch.object(
verify_native_package.shutil, "which", return_value="rpm2cpio"
),
mock.patch.object(
verify_native_package.subprocess,
"check_output",
return_value=payload,
),
):
actual = verify_native_package.read_rpm_member(
Path("nyaterm.rpm"), "/usr/share/applications/nyaterm.desktop"
)
self.assertEqual(actual, desktop)
def test_release_verification_fails_before_platform_tools_when_asset_missing(self) -> None:
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(RuntimeError, "missing release artifacts"):
verify_native_package.verify_release(
Path(directory), "x86_64-unknown-linux-gnu", "2.0.0"
)
def test_binary_header_helpers_reject_invalid_formats(self) -> None:
with self.assertRaises(RuntimeError):
verify_native_package.pe_machine(b"not-pe")
with self.assertRaises(RuntimeError):
verify_native_package.elf_machine(b"not-elf")
with self.assertRaises(RuntimeError):
verify_native_package.macho_cpu_type(b"not-macho")
if __name__ == "__main__":
unittest.main()