mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-11-06 00:07:41 +00:00
239 lines
14 KiB
Python
239 lines
14 KiB
Python
"""Check MSI release contracts; integration tests never install the packages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "release"))
|
|
import package_native
|
|
import verify_native_package
|
|
import windows_msi
|
|
from scripts.tests.test_verify_native_package import fake_pe
|
|
|
|
|
|
def stage_payload(root: Path, target: str) -> None:
|
|
root.mkdir(parents=True)
|
|
machine = 0x8664 if target.startswith("x86_64") else 0xAA64
|
|
(root / "NyaTerm.exe").write_bytes(fake_pe(machine, subsystem=2, resource_ids=(3, 14, 16)))
|
|
for name in package_native.HELPER_BINS:
|
|
(root / f"{name}.exe").write_bytes(fake_pe(machine))
|
|
(root / "VERSION").write_text("2.0.0\n", encoding="utf-8")
|
|
(root / "LICENSE").write_text("test license", encoding="utf-8")
|
|
shutil.copy2(package_native.ICON_DIR / "icon.ico", root / "icon.ico")
|
|
for relative in package_native.conpty_files(target):
|
|
path = root / "conpty" / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(fake_pe(0xAA64 if "arm64" in relative.parts else 0x8664))
|
|
(root / "conpty/LICENSE.txt").write_text("ConPTY test license", encoding="utf-8")
|
|
|
|
|
|
class WindowsMsiTests(unittest.TestCase):
|
|
def test_only_stable_releases_include_msi(self) -> None:
|
|
for version, expected in (("2.0.0", True), ("2.0.0+build-with-hyphen", True),
|
|
("2.0.0-preview.1", False), ("2.0.0-rc.1", False),
|
|
("main-snapshot", False)):
|
|
with self.subTest(version=version):
|
|
self.assertEqual(package_native.includes_windows_msi(version), expected)
|
|
for target in ("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"):
|
|
names = package_native.artifact_names(target, version)
|
|
self.assertEqual(sum(name.endswith(".msi") for name in names), int(expected))
|
|
self.assertEqual(len(names), 3 if expected else 2)
|
|
|
|
def test_msi_version_obeys_three_field_windows_installer_limits(self) -> None:
|
|
for version, expected in (("v2.0.0", "2.0.0"), ("2.0.0+build.7", "2.0.0"),
|
|
("255.255.65535", "255.255.65535")):
|
|
self.assertEqual(package_native.windows_msi_version(version), expected)
|
|
for version in ("256.0.0", "2.256.0", "2.0.65536", "2.0.0-preview.9", "main-snapshot"):
|
|
with self.subTest(version=version), self.assertRaises(ValueError):
|
|
package_native.windows_msi_version(version)
|
|
|
|
def test_wix_version_is_pinned_and_checked(self) -> None:
|
|
with mock.patch.object(windows_msi.shutil, "which", return_value="wix"):
|
|
for version, accepted in (("6.0.2+commit\n", True), ("7.0.0\n", False)):
|
|
with mock.patch.object(windows_msi.subprocess, "check_output", return_value=version):
|
|
if accepted:
|
|
self.assertEqual(windows_msi.find_wix(), "wix")
|
|
else:
|
|
with self.assertRaisesRegex(RuntimeError, "expected WiX 6.0.2"):
|
|
windows_msi.find_wix()
|
|
|
|
def test_component_identity_is_stable_and_product_identity_changes(self) -> None:
|
|
self.assertEqual(windows_msi.product_code("2.0.0", "x64"), "2A20BF91-AE77-5C55-B7B5-4EA06FF5C4A0")
|
|
self.assertNotEqual(windows_msi.product_code("2.0.0", "x64"),
|
|
windows_msi.product_code("2.0.1", "x64"))
|
|
self.assertNotEqual(windows_msi.product_code("2.0.0", "x64"),
|
|
windows_msi.product_code("2.0.0", "arm64"))
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
staging = root / 'payload with spaces & symbols'
|
|
stage_payload(staging, "x86_64-pc-windows-msvc")
|
|
components = []
|
|
ns = {"w": windows_msi.WIX_NAMESPACE}
|
|
for version in ("2.0.0", "2.0.1"):
|
|
source = root / f"{version}.wxs"
|
|
windows_msi.write_source(staging, source, version, "x64")
|
|
xml = ET.parse(source)
|
|
components.append({node.attrib["Id"]: node.attrib["Guid"]
|
|
for node in xml.findall(".//w:Component", ns)})
|
|
files = xml.findall(".//w:File", ns)
|
|
self.assertEqual(len(files), sum(path.is_file() for path in staging.rglob("*")))
|
|
self.assertTrue(all(Path(node.attrib["Source"]).is_file() for node in files))
|
|
removed = xml.find(".//w:RemoveFile[@Id='RemoveLegacyActiveXHelper']", ns)
|
|
self.assertIsNotNone(removed)
|
|
self.assertEqual(removed.attrib["Name"], "nyaterm-activex-helper.exe")
|
|
self.assertEqual(removed.attrib["On"], "install")
|
|
self.assertEqual(components[0], components[1])
|
|
|
|
|
|
class WindowsMsiCleanupTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.rows = [
|
|
{"FileKey": "RemoveLegacyActiveXHelper", "Component_": "ApplicationComponent",
|
|
"FileName": "nyaterm-activex-helper.exe", "DirProperty": "INSTALLDIR", "InstallMode": "1"},
|
|
{"FileKey": "RemoveStartMenuFolder", "Component_": "ApplicationComponent",
|
|
"FileName": "", "DirProperty": "ApplicationProgramsFolder", "InstallMode": "2"},
|
|
]
|
|
|
|
def test_allows_legacy_helper_and_empty_start_menu_cleanup(self) -> None:
|
|
for name in ("nyaterm-activex-helper.exe", "NYATER~1.EXE|nyaterm-activex-helper.exe"):
|
|
for rows in (self.rows, list(reversed(self.rows))):
|
|
with self.subTest(name=name, order=[row["FileKey"] for row in rows]):
|
|
self.rows[0]["FileName"] = name
|
|
verify_native_package.verify_windows_msi_cleanup(rows, "ApplicationComponent")
|
|
|
|
def test_rejects_changed_cleanup_targets_owners_and_install_modes(self) -> None:
|
|
for index in range(len(self.rows)):
|
|
for field, value in (
|
|
("FileKey", "UnexpectedCleanup"), ("Component_", "OtherComponent"),
|
|
("DirProperty", "APPDATA"), ("InstallMode", "3"),
|
|
("FileName", "*"), ("FileName", "*.exe"), ("FileName", "settings.json"),
|
|
("FileName", "../nyaterm-activex-helper.exe"),
|
|
("FileName", "*|nyaterm-activex-helper.exe"),
|
|
("FileName", "../bad.exe|nyaterm-activex-helper.exe"),
|
|
("FileName", "a|b|nyaterm-activex-helper.exe"),
|
|
):
|
|
changed = copy.deepcopy(self.rows)
|
|
changed[index][field] = value
|
|
with self.subTest(rule=index, field=field, value=value), self.assertRaises(RuntimeError):
|
|
verify_native_package.verify_windows_msi_cleanup(changed, "ApplicationComponent")
|
|
for index, name in ((0, ""), (1, "nyaterm-activex-helper.exe")):
|
|
changed = copy.deepcopy(self.rows)
|
|
changed[index]["FileName"] = name
|
|
with self.subTest(rule=index, name=name), self.assertRaises(RuntimeError):
|
|
verify_native_package.verify_windows_msi_cleanup(changed, "ApplicationComponent")
|
|
|
|
def test_requires_exactly_the_two_authorized_cleanup_rules(self) -> None:
|
|
for rows in ([], self.rows[:1], self.rows[1:], self.rows + [self.rows[0]],
|
|
[self.rows[0], self.rows[0]], [self.rows[1], self.rows[1]]):
|
|
with self.subTest(rules=[row["FileKey"] for row in rows]), self.assertRaises(RuntimeError):
|
|
verify_native_package.verify_windows_msi_cleanup(rows, "ApplicationComponent")
|
|
|
|
|
|
@unittest.skipUnless(sys.platform == "win32" and shutil.which("wix"), "requires Windows and WiX 6.0.2")
|
|
class CompiledWindowsMsiTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.temporary = tempfile.TemporaryDirectory(prefix="nyaterm-msi-test-")
|
|
cls.addClassCleanup(cls.temporary.cleanup)
|
|
cls.root = Path(cls.temporary.name)
|
|
cls.packages = {}
|
|
for target in ("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"):
|
|
root = cls.root / target
|
|
staging = root / "payload with spaces & symbols"
|
|
stage_payload(staging, target)
|
|
work, dist = root / "work", root / "dist"
|
|
work.mkdir()
|
|
dist.mkdir()
|
|
info = package_native.target_info(target)
|
|
with (mock.patch.object(package_native, "WORK_DIR", work),
|
|
mock.patch.object(package_native, "DIST_DIR", dist)):
|
|
package_native.create_windows_msi(staging, info, "2.0.0", "2.0.0")
|
|
path = dist / f"NyaTerm_2.0.0_{info.label}.msi"
|
|
cls.packages[target] = (path, windows_msi.read_database(path), staging)
|
|
|
|
def test_compiles_and_extracts_complete_packages_for_both_architectures(self) -> None:
|
|
# Synthetic PEs test cabinet packaging and architecture checks. The real
|
|
# application resource build has its own test_windows_resources coverage.
|
|
for target, (path, _, _) in self.packages.items():
|
|
with (self.subTest(target=target),
|
|
mock.patch.object(verify_native_package, "verify_windows_application_version")):
|
|
verify_native_package.verify_windows_msi(path, target, "2.0.0")
|
|
|
|
def test_compiled_database_rejects_wrong_identity_scope_architecture_and_version(self) -> None:
|
|
for target, (_, baseline, _) in self.packages.items():
|
|
for property_name in ("UpgradeCode", "ProductCode", "ProductVersion", "ALLUSERS"):
|
|
changed = copy.deepcopy(baseline)
|
|
next(row for row in changed["tables"]["Property"]
|
|
if row["Property"] == property_name)["Value"] = "wrong"
|
|
with self.subTest(target=target, property=property_name), self.assertRaises(RuntimeError):
|
|
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
|
|
changed = copy.deepcopy(baseline)
|
|
changed["template"] = "wrong;1033"
|
|
with self.assertRaisesRegex(RuntimeError, "architecture"):
|
|
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
|
|
|
|
def test_compiled_database_requires_all_helpers_and_conpty_files(self) -> None:
|
|
for target, (_, baseline, _) in self.packages.items():
|
|
files = verify_native_package.verify_windows_msi_database(baseline, target, "2.0.0")
|
|
for name in (*verify_native_package.helper_filenames(target),
|
|
*(f"conpty/{p.as_posix()}" for p in package_native.conpty_files(target))):
|
|
changed = copy.deepcopy(baseline)
|
|
changed["tables"]["File"] = [row for row in changed["tables"]["File"] if row["File"] != files[name]]
|
|
with self.subTest(target=target, missing=name), self.assertRaisesRegex(RuntimeError, "file set mismatch"):
|
|
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
|
|
|
|
def test_compiled_database_rejects_unsafe_cleanup_and_upgrade_policy(self) -> None:
|
|
target = "x86_64-pc-windows-msvc"
|
|
baseline = self.packages[target][1]
|
|
for table, field, value in (
|
|
("Component", "ComponentId", "wrong"), ("Directory", "DefaultDir", "../data"),
|
|
("Upgrade", "Attributes", "0"), ("RemoveFile", "FileName", "*"),
|
|
("Shortcut", "Target", "wrong.exe"), ("CustomAction", "Target", "--unsafe-command"),
|
|
):
|
|
changed = copy.deepcopy(baseline)
|
|
changed["tables"][table][0][field] = value
|
|
with self.subTest(table=table), self.assertRaises(RuntimeError):
|
|
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
|
|
changed = copy.deepcopy(baseline)
|
|
next(row for row in changed["tables"]["InstallExecuteSequence"]
|
|
if row["Action"] == "RemoveExistingProducts")["Sequence"] = "6700"
|
|
with self.assertRaisesRegex(RuntimeError, "InstallInitialize"):
|
|
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
|
|
changed = copy.deepcopy(baseline)
|
|
changed["tables"]["LaunchCondition"] = []
|
|
with self.assertRaisesRegex(RuntimeError, "same-version"):
|
|
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
|
|
|
|
def test_extracted_payload_rejects_wrong_pe_architecture_and_version(self) -> None:
|
|
target = "x86_64-pc-windows-msvc"
|
|
for relative, content, message in (
|
|
("NyaTerm.exe", fake_pe(0xAA64), "PE machine"),
|
|
("nyaterm-vnc-helper.exe", fake_pe(0xAA64), "PE machine"),
|
|
("conpty/x64/conpty.dll", fake_pe(0xAA64), "ConPTY PE machine"),
|
|
("VERSION", b"2.0.1\n", "installed version"),
|
|
):
|
|
with self.subTest(file=relative), tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
staging = root / "payload"
|
|
stage_payload(staging, target)
|
|
(staging / relative).write_bytes(content)
|
|
source, output = root / "package.wxs", root / "package.msi"
|
|
windows_msi.write_source(staging, source, "2.0.0", "x64")
|
|
subprocess.run([windows_msi.find_wix(), "build", "-arch", "x64", "-pdbtype", "none",
|
|
"-o", str(output), str(source)], check=True)
|
|
with (mock.patch.object(verify_native_package, "verify_windows_application_version"),
|
|
self.assertRaisesRegex(RuntimeError, message)):
|
|
verify_native_package.verify_windows_msi(output, target, "2.0.0")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|