387 lines
20 KiB
Python
387 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Portable manifest/copy tests. Synthetic MZ files do not test native PE scanning."""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
SOURCE = Path(__file__).resolve().parents[1]
|
|
WRITER = SOURCE / "cmake/WriteWindowsRuntimeManifest.cmake"
|
|
STAGER = SOURCE / "cmake/StageWindowsWorker.cmake"
|
|
INSTALLER = SOURCE / "cmake/InstallWindowsWorker.cmake"
|
|
DEPLOYMENT = SOURCE / "cmake/WindowsDeployment.cmake"
|
|
CMAKE = shutil.which("cmake")
|
|
|
|
|
|
class RuntimeManifestTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory(prefix="docview runtime 日本語 ")
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.root = Path(self.temporary.name)
|
|
self.package = self.root / "package"
|
|
self.package.mkdir()
|
|
self.executable = self.file("package/worker.exe", b"MZworker")
|
|
self.manifest = Path(str(self.executable) + ".runtime.json")
|
|
|
|
def file(self, relative, data=b"MZfixture"):
|
|
path = self.root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
return path
|
|
|
|
def invoke(self, script, definitions=(), success=True):
|
|
command = [CMAKE, *[f"-D{name}={value}" for name, value in definitions], "-P", str(script)]
|
|
return self.run_command(command, success=success)
|
|
|
|
def run_command(self, command, success=True, env=None):
|
|
result = subprocess.run(command, capture_output=True, text=True, timeout=30, env=env)
|
|
if success:
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
else:
|
|
self.assertNotEqual(result.returncode, 0, "Invalid package input was accepted")
|
|
return result
|
|
|
|
def write(self, files, success=True):
|
|
return self.invoke(WRITER, [("WORKER_EXECUTABLE", self.executable),
|
|
("RUNTIME_FILES", ";".join(map(str, files)))], success)
|
|
|
|
def stage(self, sources, success=True):
|
|
# Import the portable copy function without invoking the native scanner.
|
|
script = self.root / "copy.cmake"
|
|
script.write_text(f'include([==[{STAGER.as_posix()}]==])\n'
|
|
f'docview_stage_runtime_files([==[{self.executable.as_posix()}]==] '
|
|
f'[==[{";".join(path.as_posix() for path in sources)}]==])\n', encoding="utf-8")
|
|
return self.invoke(script, success=success)
|
|
|
|
def assert_inventory(self, files):
|
|
result = json.loads(self.manifest.read_text())
|
|
self.assertEqual(set(result), {"schemaVersion", "executable", "files"})
|
|
self.assertEqual(result["schemaVersion"], 1)
|
|
self.assertEqual(result["executable"], self.executable.name)
|
|
expected = {path.name: path for path in files}
|
|
self.assertEqual({entry["path"] for entry in result["files"]}, set(expected))
|
|
for entry in result["files"]:
|
|
self.assertEqual(set(entry), {"path", "size", "sha256"})
|
|
path = expected[entry["path"]]
|
|
self.assertIsInstance(entry["size"], int)
|
|
self.assertEqual(entry["size"], path.stat().st_size)
|
|
self.assertEqual(entry["sha256"], hashlib.sha256(path.read_bytes()).hexdigest())
|
|
self.assertRegex(entry["sha256"], r"^[0-9a-f]{64}$")
|
|
self.assertFalse(list(self.package.glob("*.tmp")))
|
|
|
|
def test_exact_schema_hashes_and_stable_order(self):
|
|
files = [self.file("package/Qt6Core.dll"), self.executable, self.file("package/zlib1.dll", b"MZother")]
|
|
self.write(files)
|
|
self.assert_inventory(files)
|
|
original = self.manifest.read_bytes()
|
|
self.write(list(reversed(files)))
|
|
self.assertEqual(self.manifest.read_bytes(), original)
|
|
|
|
def test_executable_required_and_failed_write_preserves_manifest(self):
|
|
self.write([self.executable])
|
|
original = self.manifest.read_bytes()
|
|
dll = self.file("package/one.dll")
|
|
self.write([dll], success=False)
|
|
self.assertEqual(self.manifest.read_bytes(), original)
|
|
self.write([], success=False)
|
|
self.assertEqual(self.manifest.read_bytes(), original)
|
|
|
|
def test_casefold_and_exact_duplicates_rejected(self):
|
|
upper = self.file("package/Example.dll")
|
|
lower = self.file("package/example.dll")
|
|
self.write([self.executable, upper, lower], success=False)
|
|
self.write([self.executable, upper, upper], success=False)
|
|
|
|
def test_invalid_names_and_file_kinds(self):
|
|
for name in ["CON.dll", "lpt9.dll", "aux.lib.dll", "trailing.dll.", "space name.dll",
|
|
"unexpected.exe", "notes.txt", ".hidden.dll", "a" * 125 + ".dll"]:
|
|
with self.subTest(name=name):
|
|
bad = self.file("package/" + name)
|
|
self.write([self.executable, bad], success=False)
|
|
|
|
def test_missing_non_mz_empty_directory_and_relative_entries(self):
|
|
directory = self.package / "directory.dll"
|
|
directory.mkdir()
|
|
for path in [self.package / "missing.dll", self.file("package/invalid.dll", b"not PE"),
|
|
self.file("package/empty.dll", b""), directory, Path("relative.dll")]:
|
|
with self.subTest(path=path):
|
|
self.write([self.executable, path], success=False)
|
|
|
|
def test_outside_directory_and_symlink_rejected(self):
|
|
external = self.file("outside/external.dll")
|
|
self.write([self.executable, external], success=False)
|
|
link = self.package / "link.dll"
|
|
try:
|
|
link.symlink_to(external)
|
|
except OSError as error:
|
|
self.skipTest(f"Cannot create a symlink on this host: {error}")
|
|
self.write([self.executable, link], success=False)
|
|
|
|
def test_manifest_destination_symlink_rejected(self):
|
|
protected = self.file("protected.txt", b"unchanged")
|
|
try:
|
|
self.manifest.symlink_to(protected)
|
|
except OSError as error:
|
|
self.skipTest(f"Cannot create a symlink on this host: {error}")
|
|
self.write([self.executable], success=False)
|
|
self.assertEqual(protected.read_bytes(), b"unchanged")
|
|
|
|
def test_128_files_allowed_129_rejected(self):
|
|
files = [self.executable] + [self.file(f"package/library{i}.dll") for i in range(127)]
|
|
self.write(files)
|
|
self.assert_inventory(files)
|
|
original = self.manifest.read_bytes()
|
|
self.write(files + [self.file("package/overflow.dll")], success=False)
|
|
self.assertEqual(self.manifest.read_bytes(), original)
|
|
|
|
def test_individual_and_total_size_limits_before_hashing(self):
|
|
first = self.file("package/large.dll")
|
|
with first.open("r+b") as stream:
|
|
stream.truncate(256 * 1024 * 1024 + 1)
|
|
self.write([self.executable, first], success=False)
|
|
with first.open("r+b") as stream:
|
|
stream.truncate(256 * 1024 * 1024)
|
|
second = self.file("package/second.dll")
|
|
with second.open("r+b") as stream:
|
|
stream.truncate(256 * 1024 * 1024)
|
|
self.write([self.executable, first, second], success=False)
|
|
self.assertFalse(self.manifest.exists())
|
|
|
|
def test_copy_and_manifest_are_idempotent(self):
|
|
first = self.file("input/One.dll")
|
|
duplicate = self.file("another/One.dll")
|
|
second = self.file("input/Two.dll", b"MZsecond")
|
|
self.stage([first, duplicate, first, second])
|
|
copied = [self.executable, self.package / first.name, self.package / second.name]
|
|
self.assert_inventory(copied)
|
|
original = self.manifest.read_bytes()
|
|
self.stage([second, first])
|
|
self.assert_inventory(copied)
|
|
self.assertEqual(self.manifest.read_bytes(), original)
|
|
self.assertEqual(first.read_bytes(), duplicate.read_bytes())
|
|
|
|
def test_copy_executable_without_dlls(self):
|
|
self.stage([])
|
|
self.assert_inventory([self.executable])
|
|
|
|
def test_os_filter_uses_exact_case_insensitive_directory_boundaries(self):
|
|
script = self.root / "os-filter.cmake"
|
|
script.write_text(f'include([==[{STAGER.as_posix()}]==])\n' + r'''
|
|
docview_regex_case_path("C:/Win(dows).old" escaped)
|
|
if(NOT "c:/WIN(dows).OLD/System32/kernel32.dll" MATCHES "^${escaped}/")
|
|
message(FATAL_ERROR "The OS filter did not preserve literal punctuation")
|
|
endif()
|
|
if("C:/WindowsXold/System32/kernel32.dll" MATCHES "^${escaped}/")
|
|
message(FATAL_ERROR "The OS filter treated a literal path as a regex")
|
|
endif()
|
|
foreach(path IN ITEMS "C:/Windows/System32/kernel32.dll" "c:/WINDOWS/SysWOW64/old.dll" "C:/Windows/OS.dll")
|
|
docview_is_windows_os_runtime("${path}" "C:/Windows/" result)
|
|
if(NOT result)
|
|
message(FATAL_ERROR "Windows OS dependency was not excluded: ${path}")
|
|
endif()
|
|
endforeach()
|
|
foreach(path IN ITEMS "C:/Windows-copy/System32/app.dll" "C:/Windows/System32-copy/app.dll" "D:/SDK/redist/runtime.dll")
|
|
docview_is_windows_os_runtime("${path}" "C:/Windows" result)
|
|
if(result)
|
|
message(FATAL_ERROR "Non-OS dependency was mistaken for an OS file: ${path}")
|
|
endif()
|
|
endforeach()
|
|
''', encoding="utf-8")
|
|
self.invoke(script)
|
|
|
|
def test_copy_conflicting_sources_and_case_rejected(self):
|
|
first = self.file("input/one.dll")
|
|
different = self.file("another/one.dll", b"MZdifferent")
|
|
self.stage([first, different], success=False)
|
|
self.assertFalse((self.package / first.name).exists())
|
|
case_variant = self.file("another/One.dll")
|
|
self.stage([first, case_variant], success=False)
|
|
self.assertFalse(self.manifest.exists())
|
|
|
|
def test_copy_existing_destination_is_never_overwritten(self):
|
|
first = self.file("input/one.dll")
|
|
original = self.file("package/one.dll", b"MZkeep")
|
|
self.write([self.executable, original])
|
|
manifest_before = self.manifest.read_bytes()
|
|
self.stage([first], success=False)
|
|
self.assertEqual(original.read_bytes(), b"MZkeep")
|
|
self.assertEqual(self.manifest.read_bytes(), manifest_before)
|
|
|
|
def test_copy_casefold_destination_conflict_rejected(self):
|
|
first = self.file("input/one.dll")
|
|
original = self.file("package/One.dll")
|
|
self.stage([first], success=False)
|
|
self.assertEqual(original.read_bytes(), first.read_bytes())
|
|
|
|
def test_copy_symlink_and_wrong_extension_rejected(self):
|
|
self.stage([self.file("input/source.txt")], success=False)
|
|
first = self.file("input/one.dll")
|
|
link = self.package / first.name
|
|
try:
|
|
link.symlink_to(first)
|
|
except OSError as error:
|
|
self.skipTest(f"Cannot create a symlink on this host: {error}")
|
|
self.stage([first], success=False)
|
|
self.assertEqual(first.read_bytes(), b"MZfixture")
|
|
|
|
def test_install_and_installed_file_tamper_detection(self):
|
|
dll = self.file("package/Qt6Core.dll")
|
|
self.write([self.executable, dll])
|
|
destination = self.root / "installed bin"
|
|
self.invoke(INSTALLER, [("WORKER_EXECUTABLE", self.executable),
|
|
("WORKER_INSTALL_DIRECTORY", destination)])
|
|
self.assertEqual((destination / dll.name).read_bytes(), dll.read_bytes())
|
|
installed = destination / self.executable.name
|
|
self.assertEqual(Path(str(installed) + ".runtime.json").read_bytes(), self.manifest.read_bytes())
|
|
self.invoke(INSTALLER, [("WORKER_EXECUTABLE", installed), ("WORKER_VERIFY_ONLY", "TRUE")])
|
|
(destination / dll.name).write_bytes(b"MZpatched")
|
|
self.invoke(INSTALLER, [("WORKER_EXECUTABLE", installed), ("WORKER_VERIFY_ONLY", "TRUE")], success=False)
|
|
|
|
def test_install_source_tamper_is_rejected_before_copy(self):
|
|
dll = self.file("package/Qt6Core.dll")
|
|
self.write([self.executable, dll])
|
|
dll.write_bytes(b"MZchanged")
|
|
destination = self.root / "installed"
|
|
self.invoke(INSTALLER, [("WORKER_EXECUTABLE", self.executable),
|
|
("WORKER_INSTALL_DIRECTORY", destination)], success=False)
|
|
self.assertFalse(destination.exists())
|
|
|
|
def test_installer_rejects_noncanonical_manifest_schema(self):
|
|
dll = self.file("package/Qt6Core.dll")
|
|
self.write([self.executable, dll])
|
|
original = self.manifest.read_bytes()
|
|
mutations = [
|
|
("extra-root-field", lambda value: value.update(extra=True)),
|
|
("string-version", lambda value: value.update(schemaVersion="1")),
|
|
("wrong-version", lambda value: value.update(schemaVersion=2)),
|
|
("wrong-worker", lambda value: value.update(executable="other.exe")),
|
|
("extra-entry-field", lambda value: value["files"][0].update(extra=True)),
|
|
("string-size", lambda value: value["files"][0].update(size=str(value["files"][0]["size"]))),
|
|
("fractional-size", lambda value: value["files"][0].update(size=3.5)),
|
|
("uppercase-hash", lambda value: value["files"][0].update(sha256=value["files"][0]["sha256"].upper())),
|
|
("traversal", lambda value: value["files"][0].update(path="../outside.dll")),
|
|
("duplicate-name", lambda value: value["files"].append(value["files"][0].copy())),
|
|
("empty-inventory", lambda value: value.update(files=[])),
|
|
("missing-worker", lambda value: value.update(files=[entry for entry in value["files"] if entry["path"].endswith(".dll")])),
|
|
]
|
|
for name, mutate in mutations:
|
|
with self.subTest(name=name):
|
|
value = json.loads(original)
|
|
mutate(value)
|
|
self.manifest.write_text(json.dumps(value))
|
|
self.invoke(INSTALLER, [("WORKER_EXECUTABLE", self.executable),
|
|
("WORKER_VERIFY_ONLY", "TRUE")], success=False)
|
|
|
|
def configure_synthetic_deployment(self):
|
|
# Configure the generated CMake glue using the host compiler and fake Qt
|
|
# targets. No synthetic MZ file is compiled or run as native machine code.
|
|
project = self.root / "glue"
|
|
project.mkdir()
|
|
build = self.root / "glue-build"
|
|
qt = self.file("sdk/bin/Qt6Core.dll", b"MZQtCore")
|
|
archive = self.file("sdk/bin/zip.dll", b"MZzip")
|
|
qpdf = self.file("qpdf/bin/qpdf.dll", b"MZqpdf")
|
|
trace = self.root / "deploy-arguments.json"
|
|
tool = self.file("fake-windeployqt", (f"#!{sys.executable}\n" +
|
|
"import json, os, pathlib, sys\n"
|
|
"args = sys.argv[1:]\n"
|
|
"assert '--nopatchqt' in args and '--no-compiler-runtime' in args\n"
|
|
"destination = pathlib.Path(args[args.index('--dir') + 1])\n"
|
|
f"pathlib.Path({str(trace)!r}).write_text(json.dumps(args))\n"
|
|
"if os.environ.get('DOCVIEW_MANIFEST_TEST_TAMPER') == '1':\n"
|
|
" (destination / 'Qt6Core.dll').write_bytes(b'MZmodified by deploy tool')\n").encode())
|
|
tool.chmod(0o700)
|
|
for name in ["docview-pdf-worker.exe", "docview-archive-worker.exe"]:
|
|
worker = self.file("package/" + name, b"MZ" + name.encode())
|
|
shutil.copyfile(qt, self.package / qt.name)
|
|
self.invoke(WRITER, [("WORKER_EXECUTABLE", worker),
|
|
("RUNTIME_FILES", f"{worker};{self.package / qt.name}")])
|
|
self.file("package/docview.exe", b"MZgui")
|
|
(project / "dummy.cpp").write_text("int main() { return 0; }\n")
|
|
text = f'''
|
|
cmake_minimum_required(VERSION 3.24)
|
|
project(WindowsDeploymentGlue LANGUAGES CXX)
|
|
set(WIN32 TRUE)
|
|
set(DOCVIEW_DUMPBIN_EXECUTABLE [==[{tool.as_posix()}]==] CACHE FILEPATH "")
|
|
set(DOCVIEW_WINDEPLOYQT_EXECUTABLE [==[{tool.as_posix()}]==] CACHE FILEPATH "")
|
|
set(Qt6_DIR [==[{(self.root / "sdk/lib/cmake/Qt6").as_posix()}]==])
|
|
set(DOCVIEW_PDFIUM_ROOT [==[{(self.root / "sdk").as_posix()}]==])
|
|
set(DOCVIEW_ZIP_TARGET MockZip)
|
|
add_library(Qt6::Core SHARED IMPORTED)
|
|
set_target_properties(Qt6::Core PROPERTIES IMPORTED_LOCATION [==[{qt.as_posix()}]==])
|
|
add_library(MockZip SHARED IMPORTED)
|
|
set_target_properties(MockZip PROPERTIES IMPORTED_LOCATION [==[{archive.as_posix()}]==])
|
|
add_library(qpdf::libqpdf SHARED IMPORTED)
|
|
set_target_properties(qpdf::libqpdf PROPERTIES IMPORTED_LOCATION [==[{qpdf.as_posix()}]==])
|
|
include([==[{DEPLOYMENT.as_posix()}]==])
|
|
foreach(name docview-pdf-worker docview-archive-worker docview)
|
|
add_executable(${{name}} dummy.cpp)
|
|
set_target_properties(${{name}} PROPERTIES SUFFIX ".exe" RUNTIME_OUTPUT_DIRECTORY [==[{self.package.as_posix()}]==])
|
|
endforeach()
|
|
docview_configure_windows_worker(docview-pdf-worker)
|
|
docview_install_windows_worker(docview-pdf-worker)
|
|
docview_install_windows_worker(docview-archive-worker)
|
|
install(FILES [==[{(self.package / "docview.exe").as_posix()}]==] DESTINATION bin)
|
|
docview_install_windows_gui(docview)
|
|
'''
|
|
(project / "CMakeLists.txt").write_text(text, encoding="utf-8")
|
|
self.run_command([CMAKE, "-S", str(project), "-B", str(build), "-DCMAKE_BUILD_TYPE=Release",
|
|
f"-DCMAKE_INSTALL_PREFIX={self.root / 'configure-prefix'}"])
|
|
return build, trace
|
|
|
|
@unittest.skipIf(os.name == "nt", "Portable glue probe uses a Unix executable Python stand-in; native deployment is separate")
|
|
def test_generated_glue_uses_runtime_prefix_and_single_destdir(self):
|
|
build, trace = self.configure_synthetic_deployment()
|
|
stage = (build / "stage-docview-pdf-worker-Release.cmake").read_text()
|
|
self.assertIn((self.package / "docview-pdf-worker.exe").as_posix(), stage)
|
|
self.assertNotIn("$<", stage)
|
|
self.assertIn("CMAKE_GET_RUNTIME_DEPENDENCIES_TOOL dumpbin", stage)
|
|
self.assertIn((self.root / "qpdf/bin").as_posix(), stage)
|
|
install = (build / "install-docview-pdf-worker-Release.cmake").read_text()
|
|
self.assertIn('${CMAKE_INSTALL_PREFIX}/bin', install)
|
|
self.assertNotIn('$ENV{DESTDIR}', install)
|
|
prefix = self.root / "selected prefix"
|
|
destdir = self.root / "stage root"
|
|
environment = os.environ.copy()
|
|
environment["DESTDIR"] = str(destdir)
|
|
self.run_command([CMAKE, "--install", str(build), "--config", "Release", "--prefix", str(prefix)], env=environment)
|
|
actual = Path(str(destdir) + str(prefix)) / "bin"
|
|
self.assertTrue((actual / "docview-pdf-worker.exe").exists())
|
|
self.assertTrue((actual / "docview-archive-worker.exe").exists())
|
|
self.assertFalse(prefix.exists())
|
|
self.assertFalse((self.root / "configure-prefix").exists())
|
|
arguments = json.loads(trace.read_text())
|
|
self.assertEqual(arguments[arguments.index("--dir") + 1], str(actual))
|
|
self.assertIn("--nopatchqt", arguments)
|
|
self.assertIn("Plugins=plugins", (actual / "qt.conf").read_text())
|
|
|
|
@unittest.skipIf(os.name == "nt", "Portable glue probe uses a Unix executable Python stand-in; native deployment is separate")
|
|
def test_generated_glue_rejects_worker_dll_modified_by_deployment(self):
|
|
build, _ = self.configure_synthetic_deployment()
|
|
environment = os.environ.copy()
|
|
environment.pop("DESTDIR", None)
|
|
environment["DOCVIEW_MANIFEST_TEST_TAMPER"] = "1"
|
|
prefix = self.root / "tampered install"
|
|
result = self.run_command([CMAKE, "--install", str(build), "--config", "Release", "--prefix", str(prefix)],
|
|
success=False, env=environment)
|
|
self.assertIn("no longer matches", result.stderr)
|
|
|
|
@unittest.skipIf(sys.platform == "win32", "This assertion is for unsupported scanner hosts")
|
|
def test_non_windows_native_scanner_fails_explicitly(self):
|
|
result = self.invoke(STAGER, [("WORKER_EXECUTABLE", self.executable)], success=False)
|
|
self.assertIn("requires a Windows host", result.stderr)
|
|
self.assertFalse(self.manifest.exists())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if not CMAKE:
|
|
raise SystemExit("cmake is required")
|
|
unittest.main()
|