From 054244268a2e9595772137ea8d11cd73d233ff27 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Mon, 26 Oct 2026 07:31:13 -0500 Subject: [PATCH 1/5] feat: stamp the DLL with NEVR-PLUGIN-BUILD and pack a mod as a signable zip The release signer (docs/plugin-release.md) takes a mod release as exactly three files and compares the DLL's build identity with the tag and the attested commit. - plugin.cpp carries one \0NEVR-PLUGIN-BUILD \0 literal, never NEVR-BUILD (nEVR's installer takes that for the runtime). Init logs it, so --gc-sections keeps it. CMake sets MOD_COMMIT from GITHUB_SHA, else git, else 40 zeros. - package.py packs a mod as -v-windows.zip (DLL, LICENSE, NOTICE if any, settings sheet, inner SHA256SUMS; flat, fixed dates) beside echo-mod.json (no sha256 or size, with package) and a SHA256SUMS of those two. - check_mod.py refuses a DLL without exactly one matching literal, and a release that is not that layout (extra files, directories, symlinks, dates, sums that disagree). - Tests written first; they failed (26 of 30) before the change. Co-Authored-By: Andrew Bates --- CMakeLists.txt | 27 +++- src/plugin.cpp | 7 + tests/test_check_mod.py | 286 +++++++++++++++++++++++++++++++++++++++- tools/check_mod.py | 153 +++++++++++++++++++-- tools/package.py | 40 ++++-- 5 files changed, 487 insertions(+), 26 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a8d842b..50ae273 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,30 @@ if(NOT MOD_FILE MATCHES "^([A-Za-z0-9._-]+)\\.dll$") endif() set(MOD_STEM ${CMAKE_MATCH_1}) +# The commit this DLL is built from, for the NEVR-PLUGIN-BUILD literal in src/plugin.cpp (release-signer +# compares it with the tag's commit): GITHUB_SHA in CI, `git rev-parse HEAD` on your machine, else 40 zeros. +set(MOD_COMMIT "0000000000000000000000000000000000000000") +set(mod_commit_from "$ENV{GITHUB_SHA}") +if(NOT mod_commit_from) + find_package(Git QUIET) + if(GIT_FOUND) + execute_process(COMMAND "${GIT_EXECUTABLE}" rev-parse HEAD + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + OUTPUT_VARIABLE mod_commit_from OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE git_status ERROR_QUIET) + if(NOT git_status EQUAL 0) + set(mod_commit_from "") + endif() + endif() +endif() +# (CMake's regex has no {40}: check the length apart.) +string(LENGTH "${mod_commit_from}" mod_commit_length) +if(mod_commit_length EQUAL 40 AND mod_commit_from MATCHES "^[0-9a-f]+$") + set(MOD_COMMIT "${mod_commit_from}") +elseif(mod_commit_from) + message(WARNING "not a full commit id: \"${mod_commit_from}\"; the build identity gets 40 zeros") +endif() + # capabilities -> NEVR_PLUGIN_CAP_* bits set(MOD_CAPS 0) string(JSON caps_count LENGTH "${MOD_JSON}" capabilities) @@ -56,7 +80,7 @@ if(caps_count GREATER 0) endif() project(${MOD_STEM} VERSION ${MOD_VERSION_MAJOR}.${MOD_VERSION_MINOR}.${MOD_VERSION_PATCH} LANGUAGES C CXX) -message(STATUS "${MOD_NAME} ${MOD_VERSION} -> ${MOD_FILE} (capabilities ${MOD_CAPS})") +message(STATUS "${MOD_NAME} ${MOD_VERSION} -> ${MOD_FILE} (capabilities ${MOD_CAPS}, commit ${MOD_COMMIT})") if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) @@ -84,6 +108,7 @@ target_compile_definitions(mod PRIVATE MOD_STEM="${MOD_STEM}" MOD_SUMMARY="${MOD_SUMMARY}" MOD_VERSION="${MOD_VERSION}" + MOD_COMMIT="${MOD_COMMIT}" MOD_VERSION_MAJOR=${MOD_VERSION_MAJOR} MOD_VERSION_MINOR=${MOD_VERSION_MINOR} MOD_VERSION_PATCH=${MOD_VERSION_PATCH} diff --git a/src/plugin.cpp b/src/plugin.cpp index 34746aa..92a55f4 100644 --- a/src/plugin.cpp +++ b/src/plugin.cpp @@ -10,6 +10,12 @@ namespace { +// The build identity: one NUL-bounded literal that release-signer compares with the release's tag and +// the commit it was built from. It is NEVR-PLUGIN-BUILD, never NEVR-BUILD (nEVR's installer takes any DLL +// holding "NEVR-BUILD " for the runtime itself). Keep the leading \0 and keep a use of it: Init logs it, and +// the linker's --gc-sections would drop an array nothing refers to. +const char kBuildIdentity[] = "\0NEVR-PLUGIN-BUILD " MOD_VERSION " " MOD_COMMIT; + bool g_started = false; // Start ran and returned true bool g_tried = false; // InitEx or Init was called: the host calls only one, but be safe @@ -18,6 +24,7 @@ int Init(const NvrGameContext* ctx, const char* args_json) { g_tried = true; mod::OpenLog(ctx); mod::Log("%s %s", MOD_STEM, MOD_VERSION); + mod::Log("%s", kBuildIdentity + 1); mod::SetArgs(args_json ? args_json : "{}"); uint32_t stamp = 0; diff --git a/tests/test_check_mod.py b/tests/test_check_mod.py index e69be77..8eabaf3 100644 --- a/tests/test_check_mod.py +++ b/tests/test_check_mod.py @@ -4,6 +4,7 @@ python3 tests/test_check_mod.py # needs build/mingw/ (tools/ci-build.sh builds it) """ +import hashlib import json import shutil import subprocess @@ -35,13 +36,16 @@ def checked(work, tag, prerelease=False): return report -def repo_copy(tmp, **changes): +def repo_copy(tmp, notice=None, **changes): repo = Path(tmp) / "repo" repo.mkdir() # A plain mod unless the test asks for more (a mod repo's own echo-mod.json may be a pack). m = {**{k: v for k, v in MANIFEST.items() if k != "pack"}, "kind": "mod", "id": "test-mod", "author": "Tester", **changes} (repo / "echo-mod.json").write_text(json.dumps(m), encoding="utf-8") + shutil.copy(ROOT / "LICENSE", repo / "LICENSE") + if notice: + (repo / "NOTICE").write_text(notice, encoding="utf-8") if isinstance(m.get("settings"), str): shutil.copy(ROOT / m["settings"], repo / m["settings"]) return repo @@ -56,6 +60,61 @@ def test_a_mod_passes(self): report = checked(Path(tmp) / "dist", f"v{MANIFEST['version']}") self.assertEqual(report.errors, []) + def test_a_mod_is_exactly_three_assets(self): + with tempfile.TemporaryDirectory() as tmp: + repo = repo_copy(tmp) + dist = Path(tmp) / "dist" + package(repo, dist) + zip_name = f"test-mod-v{MANIFEST['version']}-windows.zip" + self.assertEqual(sorted(p.name for p in dist.iterdir()), sorted([zip_name, "echo-mod.json", "SHA256SUMS"])) + published = json.loads((dist / "echo-mod.json").read_text(encoding="utf-8")) + self.assertEqual(published["package"], zip_name) + self.assertNotIn("sha256", published) + self.assertNotIn("size", published) + sums = (dist / "SHA256SUMS").read_text(encoding="utf-8").splitlines() + self.assertEqual(sums, sorted([f"{check_mod.sha256(dist / zip_name)} {zip_name}", + f"{check_mod.sha256(dist / 'echo-mod.json')} echo-mod.json"], + key=lambda line: line.split()[1])) + + def test_the_zip_is_flat_dated_and_has_inner_sums(self): + with tempfile.TemporaryDirectory() as tmp: + repo = repo_copy(tmp, notice="Credits.\n") + dist = Path(tmp) / "dist" + package(repo, dist) + with zipfile.ZipFile(dist / f"test-mod-v{MANIFEST['version']}-windows.zip") as zf: + self.assertEqual(zf.namelist(), [MANIFEST["file"], "LICENSE", "NOTICE", + MANIFEST["file"][:-4] + ".plugin.json", "SHA256SUMS"]) + self.assertEqual({i.date_time for i in zf.infolist()}, {(1980, 1, 1, 0, 0, 0)}) + inner = zf.read("SHA256SUMS").decode("utf-8") + for name in zf.namelist()[:-1]: + self.assertIn(f"{hashlib.sha256(zf.read(name)).hexdigest()} {name}\n", inner) + self.assertEqual(checked(dist, f"v{MANIFEST['version']}").errors, []) + + def test_notice_is_optional(self): + with tempfile.TemporaryDirectory() as tmp: + dist = Path(tmp) / "dist" + package(repo_copy(tmp), dist) + with zipfile.ZipFile(dist / f"test-mod-v{MANIFEST['version']}-windows.zip") as zf: + self.assertNotIn("NOTICE", zf.namelist()) + + def test_the_same_files_make_the_same_zip(self): + with tempfile.TemporaryDirectory() as tmp: + repo = repo_copy(tmp) + package(repo, Path(tmp) / "a") + package(repo, Path(tmp) / "b") + name = f"test-mod-v{MANIFEST['version']}-windows.zip" + self.assertEqual((Path(tmp) / "a" / name).read_bytes(), (Path(tmp) / "b" / name).read_bytes()) + + def test_a_mod_without_a_license_is_not_packed(self): + with tempfile.TemporaryDirectory() as tmp: + repo = repo_copy(tmp) + (repo / "LICENSE").unlink() + result = subprocess.run([sys.executable, str(ROOT / "tools" / "package.py"), "--dll", str(DLL), + "--manifest", str(repo / "echo-mod.json"), "--out", str(Path(tmp) / "dist")], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("LICENSE", result.stderr) + def test_a_pack_passes_and_keeps_its_plugin(self): with tempfile.TemporaryDirectory() as tmp: repo = repo_copy(tmp, kind="pack", pack={"content": "test-content"}) @@ -95,13 +154,226 @@ def test_tag_flag_and_sums(self): repo = repo_copy(tmp, version="1.0.0-beta.1") dist = Path(tmp) / "dist" package(repo, dist) - self.assertEqual(checked(dist, "v1.0.0-beta.1", prerelease=True).errors, []) - self.assertTrue(any("prerelease flag" in e for e in checked(dist, "v1.0.0-beta.1").errors)) + # (The DLL is the 0.1.0 build; its own version check has a test of its own, in Identity.) + def errors(*args, **kw): + return [e for e in checked(*args, **kw).errors if "NEVR-PLUGIN-BUILD" not in e] + self.assertEqual(errors(dist, "v1.0.0-beta.1", prerelease=True), []) + self.assertTrue(any("prerelease flag" in e for e in errors(dist, "v1.0.0-beta.1"))) self.assertTrue(any("doesn't match echo-mod.json's version" in e - for e in checked(dist, "v1.0.0", prerelease=True).errors)) - (dist / MANIFEST["file"]).write_bytes((dist / MANIFEST["file"]).read_bytes() + b"\0") - errors = checked(dist, "v1.0.0-beta.1", prerelease=True).errors - self.assertTrue(any("SHA256SUMS" in e for e in errors), errors) + for e in errors(dist, "v1.0.0", prerelease=True))) + zip_path = dist / "test-mod-v1.0.0-beta.1-windows.zip" + zip_path.write_bytes(zip_path.read_bytes() + b"\0") + bad = errors(dist, "v1.0.0-beta.1", prerelease=True) + self.assertTrue(any("SHA256SUMS" in e for e in bad), bad) + + +def rewrite_zip(dist, mutate): + """Rewrites the release's zip with `mutate(entries)` (a list of (ZipInfo, bytes)) and fixes the top-level + SHA256SUMS to match, so a test fails on the one thing it changed and not on the sums.""" + m = json.loads((dist / "echo-mod.json").read_text(encoding="utf-8")) + path = dist / m["package"] + with zipfile.ZipFile(path) as zf: + entries = [(i, zf.read(i.filename)) for i in zf.infolist()] + entries = mutate(entries) + with zipfile.ZipFile(path, "w") as zf: + for info, data in entries: + zf.writestr(info, data) + write_sums(dist) + + +def write_sums(dist): + (dist / "SHA256SUMS").write_text("".join(f"{check_mod.sha256(p)} {p.name}\n" for p in sorted(dist.iterdir()) + if p.is_file() and p.name != "SHA256SUMS"), encoding="utf-8") + + +def inner_sums(entries): + """A fresh inner SHA256SUMS for `entries` (every entry but SHA256SUMS itself).""" + return "".join(f"{hashlib.sha256(d).hexdigest()} {i.filename}\n" for i, d in entries + if i.filename != "SHA256SUMS").encode("utf-8") + + +@unittest.skipUnless(DLL.is_file(), f"no {DLL} (build first: tools/ci-build.sh)") +class Layout(unittest.TestCase): + """What the release signer needs of a release: three assets, a flat zip, sums that agree.""" + + def release(self, tmp, **changes): + dist = Path(tmp) / "dist" + package(repo_copy(tmp, notice="Credits.\n", **changes), dist) + return dist + + def errors(self, dist): + return checked(dist, f"v{MANIFEST['version']}").errors + + def test_an_extra_asset_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + (dist / "extra.txt").write_text("x", encoding="utf-8") + self.assertTrue(any("extra.txt" in e for e in self.errors(dist)), self.errors(dist)) + + def test_a_loose_dll_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + shutil.copy(DLL, dist / MANIFEST["file"]) + self.assertTrue(any(MANIFEST["file"] in e for e in self.errors(dist)), self.errors(dist)) + + def test_final_hashes_do_not_belong_in_echo_mod_json(self): + with tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + m = json.loads((dist / "echo-mod.json").read_text(encoding="utf-8")) + m["sha256"] = "0" * 64 + (dist / "echo-mod.json").write_text(json.dumps(m), encoding="utf-8") + write_sums(dist) + self.assertTrue(any("sha256" in e for e in self.errors(dist)), self.errors(dist)) + + def test_the_package_name_is_id_version_windows(self): + with tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + m = json.loads((dist / "echo-mod.json").read_text(encoding="utf-8")) + (dist / m["package"]).rename(dist / "other.zip") + m["package"] = "other.zip" + (dist / "echo-mod.json").write_text(json.dumps(m), encoding="utf-8") + write_sums(dist) + self.assertTrue(any("-windows.zip" in e for e in self.errors(dist)), self.errors(dist)) + + def test_the_top_level_sums_must_name_exactly_the_zip_and_the_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + with open(dist / "SHA256SUMS", "a", encoding="utf-8") as f: + f.write(f"{'0' * 64} something-else\n") + self.assertTrue(any("SHA256SUMS" in e for e in self.errors(dist)), self.errors(dist)) + + def test_zip_entries_are_exact(self): + cases = { + "a directory entry": lambda e: e + [(zipfile.ZipInfo("sub/"), b"")], + "a nested file": lambda e: e + [(zipfile.ZipInfo("sub/x.txt"), b"x")], + "a missing LICENSE": lambda e: [x for x in e if x[0].filename != "LICENSE"], + "an unknown file": lambda e: e + [(zipfile.ZipInfo("README.md"), b"x")], + "a Windows-colliding name": lambda e: e + [(zipfile.ZipInfo("license"), b"x")], + "a backslash": lambda e: e + [(zipfile.ZipInfo("a\\b"), b"x")], + } + for label, mutate in cases.items(): + with self.subTest(label), tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + rewrite_zip(dist, mutate) + self.assertTrue(self.errors(dist), f"{label} was accepted") + + def test_a_symlink_entry_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + + def mutate(entries): + link = zipfile.ZipInfo("NOTICE", date_time=(1980, 1, 1, 0, 0, 0)) + link.external_attr = 0o120777 << 16 + return [x for x in entries if x[0].filename != "NOTICE"] + [(link, b"/etc/passwd")] + rewrite_zip(dist, mutate) + self.assertTrue(any("symlink" in e for e in self.errors(dist)), self.errors(dist)) + + def test_dates_are_fixed(self): + with tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + + def mutate(entries): + out = [] + for info, data in entries: + info.date_time = (2026, 10, 26, 12, 0, 0) + out.append((info, data)) + return out + rewrite_zip(dist, mutate) + self.assertTrue(any("date" in e for e in self.errors(dist)), self.errors(dist)) + + def test_the_inner_sums_must_match(self): + with tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + + def mutate(entries): + return [(i, d if i.filename != "LICENSE" else d + b"tampered\n") for i, d in entries] + rewrite_zip(dist, mutate) + self.assertTrue(any("inner SHA256SUMS" in e for e in self.errors(dist)), self.errors(dist)) + + def test_a_dll_that_is_not_the_manifests_file_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + dist = self.release(tmp) + + def mutate(entries): + out = [] + for info, data in entries: + if info.filename == MANIFEST["file"]: + info.filename = "Other.dll" + out.append((info, data)) + sums = next(x for x in out if x[0].filename == "SHA256SUMS") + out.remove(sums) + return out + [(sums[0], inner_sums(out))] + rewrite_zip(dist, mutate) + self.assertTrue(self.errors(dist)) + + +@unittest.skipUnless(DLL.is_file(), f"no {DLL} (build first: tools/ci-build.sh)") +class Identity(unittest.TestCase): + """The NEVR-PLUGIN-BUILD literal the release signer compares with the tag and the attested commit.""" + COMMIT = "0123456789abcdef0123456789abcdef01234567" + + def literal(self, data): + found = check_mod.IDENTITY.findall(data) + self.assertEqual(len(found), 1, found) + return found[0] + + def dll_with(self, tmp, old, new): + data = DLL.read_bytes() + self.assertEqual(data.count(old), 1, old) + path = Path(tmp) / MANIFEST["file"] + path.write_bytes(data.replace(old, new)) + return path + + def errors(self, path, **kw): + report = check_mod.Report() + check_mod.check_dll(str(path), MANIFEST, report, **kw) + return report.errors + + def test_the_built_dll_carries_one_literal_with_its_version(self): + version, commit = self.literal(DLL.read_bytes()) + self.assertEqual(version.decode(), MANIFEST["version"]) + self.assertRegex(commit.decode(), r"^[0-9a-f]{40}$") + self.assertEqual(self.errors(DLL), []) + + def test_never_the_runtime_s_literal(self): + self.assertNotIn(b"NEVR-BUILD ", DLL.read_bytes()) + + def test_the_wrong_version_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + version, commit = self.literal(DLL.read_bytes()) + other = b"9" * len(version) # same length: the PE layout stays as it is + path = self.dll_with(tmp, b"\0NEVR-PLUGIN-BUILD " + version + b" ", b"\0NEVR-PLUGIN-BUILD " + other + b" ") + self.assertTrue(any(other.decode() in e for e in self.errors(path)), self.errors(path)) + + def test_the_wrong_commit_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + version, commit = self.literal(DLL.read_bytes()) + path = self.dll_with(tmp, commit + b"\0", self.COMMIT.encode() + b"\0") + self.assertEqual(self.errors(path), []) + self.assertTrue(any("commit" in e for e in self.errors(path, commit="f" * 40))) + + def test_the_runtime_s_literal_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + path = self.dll_with(tmp, b"\0NEVR-PLUGIN-BUILD ", b"\0NEVR-BUILD xxxxxxx") # same length + self.assertTrue(any("NEVR-BUILD" in e for e in self.errors(path)), self.errors(path)) + + def test_no_literal_and_two_literals_are_refused(self): + with tempfile.TemporaryDirectory() as tmp: + version, commit = self.literal(DLL.read_bytes()) + path = self.dll_with(tmp, b"\0NEVR-PLUGIN-BUILD ", b"\0NEVR-PLUGIN-BUILT ") + self.assertTrue(any("NEVR-PLUGIN-BUILD" in e for e in self.errors(path)), self.errors(path)) + twice = Path(tmp) / "Twice.dll" + twice.write_bytes(DLL.read_bytes() + b"\0NEVR-PLUGIN-BUILD " + version + b" " + commit + b"\0") + report = check_mod.Report() + check_mod.check_dll(str(twice), {**MANIFEST, "file": "Twice.dll"}, report) + self.assertTrue(any("exactly one" in e for e in report.errors), report.errors) + + def test_an_unset_commit_is_fine_locally_and_refused_for_a_release(self): + with tempfile.TemporaryDirectory() as tmp: + version, commit = self.literal(DLL.read_bytes()) + path = self.dll_with(tmp, commit + b"\0", b"0" * 40 + b"\0") + self.assertEqual(self.errors(path), []) + self.assertTrue(any("commit" in e for e in self.errors(path, commit="a" * 40))) class Manifest(unittest.TestCase): diff --git a/tools/check_mod.py b/tools/check_mod.py index 655b30d..4691c18 100644 --- a/tools/check_mod.py +++ b/tools/check_mod.py @@ -7,9 +7,11 @@ python3 tools/check_mod.py --dll ... --smoke smoke.json --tag v0.1.0 python3 tools/check_mod.py --published dist --tag v0.1.0 # a release as package.py made it ---published checks what a release publishes (echo-mod.json, the DLL or the pack's zip, -SHA256SUMS) by the same rules the echo-mods check and the launcher's catalogue apply to every -release: the release workflow runs it before it publishes anything. +--published checks what a release publishes (echo-mod.json, the mod's -v-windows.zip +or the pack's zip, SHA256SUMS) by the rules the echo-mods check, the launcher's catalogue and the +release signer apply to every release: the release workflow runs it before it creates the draft. +A mod's release is exactly three files, and the DLL inside the zip carries one +NEVR-PLUGIN-BUILD literal that release-signer compares with the tag. Exit code 0 when nothing is wrong. Warnings don't fail the check. Python 3.8+, no packages. """ @@ -19,6 +21,7 @@ import json import re import shutil +import stat import struct import sys import tempfile @@ -93,6 +96,16 @@ RUNTIME_DLLS = re.compile(r"^(libstdc\+\+|libgcc_s|libwinpthread|vcruntime|msvcp|concrt|vcomp|ucrtbased|msvcr\d)", re.I) +# The build identity every DLL carries (src/plugin.cpp): one NUL-bounded literal, which +# release-signer compares with the release's tag and the attested commit. Never NEVR-BUILD: the +# nEVR installer takes any DLL holding "NEVR-BUILD " for the runtime itself. +IDENTITY = re.compile(rb"\x00NEVR-PLUGIN-BUILD ([0-9A-Za-z.+-]+) ([0-9a-f]{40})\x00") +# What a mod's release zip may hold (flat, no directories), and the date every entry has. +ZIP_DATE = (1980, 1, 1, 0, 0, 0) +ZIP_ENTRY_MAX = 16 << 20 +# Names Windows treats as the same file, or can't create. +WINDOWS_BAD_NAME = re.compile(r'[<>:"|?*\x00-\x1f]|[. ]$') + class Report: def __init__(self): @@ -302,7 +315,26 @@ def cstr(rva): return exports, imports -def check_dll(path, manifest, report): +def check_identity(data, manifest, report, commit=None): + """The DLL holds exactly one NEVR-PLUGIN-BUILD literal; its version is the manifest's and, when + `commit` is given (a release), its commit is that one.""" + if b"NEVR-BUILD " in data: + report.error("the DLL contains \"NEVR-BUILD \": that is the nEVR runtime's own stamp (the installer would " + "take the DLL for the runtime); a mod carries NEVR-PLUGIN-BUILD") + found = IDENTITY.findall(data) + if len(found) != 1 or data.count(b"NEVR-PLUGIN-BUILD") != 1: + report.error("the DLL must contain exactly one \\0NEVR-PLUGIN-BUILD \\0 literal (src/plugin.cpp " + f"has it; it is referenced at run time so the linker keeps it): found {len(found)} well-formed, " + f"{data.count(b'NEVR-PLUGIN-BUILD')} mention(s)") + return + version, sha = (x.decode("ascii") for x in found[0]) + if version != manifest.get("version"): + report.error(f"the DLL's NEVR-PLUGIN-BUILD says version {version}, echo-mod.json {manifest.get('version')}") + if commit is not None and sha != commit: + report.error(f"the DLL's NEVR-PLUGIN-BUILD says commit {sha}, the release is built from {commit}") + + +def check_dll(path, manifest, report, commit=None): data = Path(path).read_bytes() if Path(path).name != manifest.get("file"): report.error(f"the DLL is called {Path(path).name}, echo-mod.json says {manifest.get('file')}") @@ -317,6 +349,7 @@ def check_dll(path, manifest, report): stray = sorted(e for e in exports if not e.startswith("Nvr")) if stray: report.warn("exports beyond the plugin's own: " + ", ".join(stray[:10]) + (" ..." if len(stray) > 10 else "")) + check_identity(data, manifest, report, commit) report.note(f"{len(data)} bytes, imports: " + ", ".join(imports)) caps = set(manifest.get("capabilities") or []) for dll in imports: @@ -365,6 +398,109 @@ def main_file(manifest): return manifest.get("file") +def parse_sums(text): + """{name: digest} of a SHA256SUMS text; None when a line isn't ` `.""" + sums = {} + for line in text.splitlines(): + parts = line.split(" ", 1) + if len(parts) != 2 or not re.fullmatch(r"[0-9a-f]{64}", parts[0]) or parts[1] in sums: + return None + sums[parts[1]] = parts[0] + return sums + + +def release_zip_name(manifest): + return f"{manifest.get('id')}-v{manifest.get('version')}-windows.zip" + + +def check_release(work, manifest, report): + """A mod's release as release-signer takes it: exactly three files in `work` -- + -v-windows.zip, echo-mod.json (without sha256 or size: signing changes the DLL's + bytes), SHA256SUMS (the zip and echo-mod.json) -- and in the zip, flat and with fixed dates: the + DLL, LICENSE, NOTICE if there is one, .plugin.json when the mod has settings, and a + SHA256SUMS over the others. Returns (manifest, the DLL to load-test, or None).""" + package = release_zip_name(manifest) + if manifest.get("package") != package: + report.error(f"echo-mod.json: \"package\" must be {package} (-v-windows.zip), not " + f"{manifest.get('package')!r}") + for key in ("sha256", "size"): + if key in manifest: + report.error(f"echo-mod.json: \"{key}\" doesn't belong in a release: signing changes the DLL's bytes, " + "the final hashes are in SHA256SUMS") + names = sorted(p.name for p in work.iterdir()) + for name in names: + if name not in (package, "echo-mod.json", "SHA256SUMS"): + report.error(f"the release has {name}: its files are exactly {package}, echo-mod.json and SHA256SUMS") + if not (work / package).is_file(): + report.error(f"the release lacks {package}") + return manifest, None + + try: + top = parse_sums((work / "SHA256SUMS").read_text(encoding="utf-8")) + except OSError: + top = None + report.error("the release has no SHA256SUMS") + else: + want = {package: sha256(work / package), "echo-mod.json": sha256(work / "echo-mod.json")} + if top != want: + report.error("SHA256SUMS must have exactly one line for the zip and one for echo-mod.json, matching them") + + settings = manifest.get("settings") + sheet = f"{str(manifest.get('file'))[:-4]}.plugin.json" + required = {str(manifest.get("file")), "LICENSE", "SHA256SUMS"} | ({sheet} if settings else set()) + allowed = required | {"NOTICE"} + try: + zf = zipfile.ZipFile(work / package) + except zipfile.BadZipFile: + report.error(f"{package} isn't a zip") + return manifest, None + with zf: + infos = zf.infolist() + seen = set() + for info in infos: + name = info.filename + if info.is_dir() or "/" in name or "\\" in name: + report.error(f"{package}: {name!r}: the zip is flat, files only") + elif name not in allowed: + report.error(f"{package}: {name!r} isn't one of: " + ", ".join(sorted(allowed))) + if WINDOWS_BAD_NAME.search(name) or name.lower() in seen: + report.error(f"{package}: {name!r} is not a name Windows can hold apart from the others") + seen.add(name.lower()) + mode = info.external_attr >> 16 + if mode and stat.S_IFMT(mode) not in (0, stat.S_IFREG): + kind = "a symlink" if stat.S_ISLNK(mode) else "a special entry" + report.error(f"{package}: {name!r} is {kind}") + if info.date_time != ZIP_DATE: + report.error(f"{package}: {name!r} has the date {info.date_time}, not the fixed {ZIP_DATE}") + if info.file_size > ZIP_ENTRY_MAX: + report.error(f"{package}: {name!r} is over {ZIP_ENTRY_MAX >> 20} MiB") + have = {i.filename for i in infos} + for name in sorted(required - have): + report.error(f"{package} lacks {name}") + if len(have) != len(infos): + report.error(f"{package} has an entry twice") + if "SHA256SUMS" in have: + inner = parse_sums(zf.read("SHA256SUMS").decode("utf-8", "replace")) + body = {i.filename: hashlib.sha256(zf.read(i.filename)).hexdigest() + for i in infos if i.filename != "SHA256SUMS" and i.file_size <= ZIP_ENTRY_MAX} + if inner != body: + report.error(f"{package}: the inner SHA256SUMS doesn't list exactly the other files, matching them") + file = str(manifest.get("file")) + if file not in have or any(i.file_size > ZIP_ENTRY_MAX for i in infos if i.filename == file): + return manifest, None + # Outside the release's folder: the release workflow uploads all of it. + out = Path(tempfile.mkdtemp(prefix="check_mod-")) + atexit.register(shutil.rmtree, out, True) + (out / file).write_bytes(zf.read(file)) + return manifest, out / file + + +def release_commit(manifest): + """The commit a release was built from (package.py records it from GITHUB_SHA), or None.""" + commit = (manifest.get("source") or {}).get("commit") + return commit if isinstance(commit, str) and commit else None + + def check_published(work, tag, prerelease, report): """A release as the release workflow publishes it, in folder `work`: echo-mod.json, the DLL or the pack's zip, SHA256SUMS. `tag` is the release's tag, `prerelease` its GitHub flag. @@ -385,6 +521,8 @@ def check_published(work, tag, prerelease, report): if SEMVER.match(version) and bool(prerelease) != is_prerelease(version): report.error("the GitHub prerelease flag doesn't match the version: a version with a prerelease part " "(1.2.0-beta.1) is a prerelease, any other isn't") + if manifest.get("kind", "mod") != "pack": + return check_release(work, manifest, report) main = main_file(manifest) if not isinstance(main, str) or not (work / main).is_file(): report.error(f"the release lacks {main}, the file its echo-mod.json names") @@ -401,11 +539,8 @@ def check_published(work, tag, prerelease, report): for name, have in ((main, digest), ("echo-mod.json", sha256(work / "echo-mod.json"))): if sums and sums.get(name) != have: report.error(f"{name} doesn't match SHA256SUMS") - want = manifest.get("sha256") if manifest.get("kind", "mod") == "mod" else (manifest.get("pack") or {}).get("sha256") - if want != digest: + if (manifest.get("pack") or {}).get("sha256") != digest: report.error(f"{main} doesn't match the sha256 in echo-mod.json") - if manifest.get("kind", "mod") != "pack": - return manifest, work / main # A pack: plugins/ and content//, nothing else, no path out of either. content = str((manifest.get("pack") or {}).get("content", "")) inner = f"plugins/{manifest.get('file')}" @@ -448,7 +583,7 @@ def main(): version = json.loads((Path(args.published) / "echo-mod.json").read_text(encoding="utf-8")).get("version", "") manifest, dll = check_published(args.published, args.tag, is_prerelease(str(version)), report) if dll is not None: - check_dll(str(dll), manifest, report) + check_dll(str(dll), manifest, report, commit=release_commit(manifest)) if args.smoke and manifest: check_smoke(args.smoke, manifest, report) report.print() diff --git a/tools/package.py b/tools/package.py index 5251203..1ed10c3 100644 --- a/tools/package.py +++ b/tools/package.py @@ -1,10 +1,14 @@ #!/usr/bin/env python3 """Packs a built mod into dist/ for a GitHub release: - dist/.dll a mod (kind "mod") - dist/-.zip a content pack (kind "pack"): plugins/ and content// - dist/echo-mod.json echo-mod.json with version, sha256, size, prerelease and the source filled in - dist/SHA256SUMS + dist/-v-windows.zip a mod (kind "mod"): .dll, LICENSE, NOTICE (if the repo has + one), .plugin.json (if the mod has settings) and a SHA256SUMS + over those. Flat, fixed dates, so the same files make the same zip. + dist/-.zip a content pack (kind "pack"): plugins/ and content// + dist/echo-mod.json echo-mod.json with version, prerelease, package and the source + filled in; for a pack also its sha256 and size. A mod's has no + sha256 or size: the release signer changes the DLL's bytes. + dist/SHA256SUMS the zip (or a pack's) and echo-mod.json python3 tools/package.py --dll build/mingw/ExampleMod.dll @@ -33,10 +37,14 @@ def sha256(path): def add(zf, path, name): """Adds a file with a fixed date, so the same files always make the same zip.""" + add_bytes(zf, Path(path).read_bytes(), name) + + +def add_bytes(zf, data, name): info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 - zf.writestr(info, Path(path).read_bytes()) + zf.writestr(info, data) def main(): @@ -81,9 +89,23 @@ def main(): "plugins": [manifest["file"]], } else: - shutil.copyfile(args.dll, out / manifest["file"]) - entry["sha256"] = sha256(out / manifest["file"]) - entry["size"] = (out / manifest["file"]).stat().st_size + # The release signer signs the DLL inside this zip and rewrites the zip: the final hashes are + # in SHA256SUMS, not in echo-mod.json. LICENSE and NOTICE travel in the zip (MIT: "in all copies"). + name = f"{manifest['id']}-v{manifest['version']}-windows.zip" + files = [(Path(args.dll), manifest["file"])] + for notice in ("LICENSE", "NOTICE"): + if (root / notice).is_file(): + files.append((root / notice, notice)) + elif notice == "LICENSE": + sys.exit(f"package.py: {root / notice} is missing: a release carries the mod's LICENSE") + if isinstance(settings, str) and settings: + files.append((root / settings, f"{stem}.plugin.json")) + inner = "".join(f"{sha256(path)} {arcname}\n" for path, arcname in files) + with zipfile.ZipFile(out / name, "w") as zf: + for path, arcname in files: + add(zf, path, arcname) + add_bytes(zf, inner.encode("utf-8"), "SHA256SUMS") + entry["package"] = name if os.environ.get("GITHUB_ACTIONS") == "true": entry["source"] = { @@ -95,7 +117,7 @@ def main(): } (out / "echo-mod.json").write_text(json.dumps(entry, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - sums = "".join(f"{sha256(p)} {p.name}\n" for p in sorted(out.iterdir())) + sums = "".join(f"{sha256(p)} {p.name}\n" for p in sorted(out.iterdir()) if p.name != "SHA256SUMS") (out / "SHA256SUMS").write_text(sums, encoding="utf-8") print(sums, end="") From e4a7ed3789d4937600bcec8dbc273622b0efc314 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Mon, 26 Oct 2026 07:31:13 -0500 Subject: [PATCH 2/5] ci: open a draft release from a split, SHA-pinned workflow, with no downloaded code release.yml's build job holds contents: read, id-token and attestations: write and nothing else, and runs this repo's own check_mod.py instead of curl-ing the template's main. A second job, with contents: write and no build code, creates the release as a draft: the signer writes only into drafts and publishes after signing. Co-Authored-By: Andrew Bates --- .github/workflows/release.yml | 73 ++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04a7dc4..d9975e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,37 @@ -# A tag v (the version in echo-mod.json, a semantic version: semver.org) publishes a -# GitHub release: the DLL (or the pack's zip), echo-mod.json with the checksums filled in, and -# SHA256SUMS, with a build attestation that shows they were built here, from this tag. -# A version with a prerelease part (1.2.0-beta.1) becomes a prerelease. +# A tag v (the version in echo-mod.json, a semantic version: semver.org) builds the mod, +# checks it, attests the release files (a build attestation that shows they were built here, from this +# tag) and opens a DRAFT GitHub release with exactly three files: # -# Keep this file as it is: the launcher only takes releases that this workflow built. +# -v-windows.zip the DLL, LICENSE, NOTICE, the settings sheet and a SHA256SUMS +# echo-mod.json the manifest, with the package name and the source +# SHA256SUMS the zip and echo-mod.json +# +# The draft is not live. The project's release signer signs the DLL inside the zip and publishes the +# release; you never upload anything. A version with a prerelease part (1.2.0-beta.1) becomes a +# prerelease. +# +# Keep this file as it is: the signer only takes releases that this workflow built, and it compares +# this file with the template's. Two jobs, on purpose: the one that builds and attests can't write to +# the repo, the one that writes the release runs no build code. name: release on: push: tags: ["v*"] -permissions: - contents: write - id-token: write - attestations: write +permissions: {} jobs: - release: + build: runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + attestations: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Toolchain run: | @@ -28,6 +40,7 @@ jobs: sudo update-alternatives --set x86_64-w64-mingw32-gcc /usr/bin/x86_64-w64-mingw32-gcc-posix sudo update-alternatives --set x86_64-w64-mingw32-g++ /usr/bin/x86_64-w64-mingw32-g++-posix + # GITHUB_SHA, the tag's commit, goes into the DLL as its NEVR-PLUGIN-BUILD identity. - name: Build, smoke test, check run: tools/ci-build.sh "$GITHUB_REF_NAME" @@ -36,22 +49,38 @@ jobs: file=$(python3 -c 'import json; print(json.load(open("echo-mod.json"))["file"])') python3 tools/package.py --dll "build/mingw/$file" - # What the launcher's catalogue checks for every release, by the template's current rules - # (they may be newer than this repo's copy): a release it would refuse is never published. + # What the launcher's catalogue and the release signer expect of every release, by this repo's own + # reviewed checker (nothing is fetched here: this job can attest). A release they would refuse is + # never drafted. - name: Check the release - run: | - curl -fsSL -m 30 -o build/check_mod_current.py \ - https://raw.githubusercontent.com/EchoTools/echo-mod-template-repo/main/tools/check_mod.py \ - || cp tools/check_mod.py build/check_mod_current.py - python3 build/check_mod_current.py --published dist --tag "$GITHUB_REF_NAME" --smoke build/smoke/smoke.json + run: python3 tools/check_mod.py --published dist --tag "$GITHUB_REF_NAME" --smoke build/smoke/smoke.json - - uses: actions/attest-build-provenance@v2 + - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-path: dist/* - - name: Release + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dist + path: dist + if-no-files-found: error + + draft: + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: dist + path: dist + + - name: Open the draft release env: GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + REPO: ${{ github.repository }} run: | - pre=$(python3 -c 'import json; print("--prerelease" if json.load(open("dist/echo-mod.json"))["prerelease"] else "")') - gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes --verify-tag $pre + pre=$(jq -r 'if .prerelease then "--prerelease" else "" end' dist/echo-mod.json) + gh release create "$TAG" dist/* -R "$REPO" --draft --verify-tag --title "$TAG" --generate-notes $pre From 40cc3f12837e6190d20d17163635071d21d63aec Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Mon, 26 Oct 2026 07:31:14 -0500 Subject: [PATCH 3/5] docs: the build identity rule, the draft release, and a NOTICE for credits Co-Authored-By: Andrew Bates --- AGENTS.md | 36 +++++++++++++++++++++++++----------- NOTICE | 10 ++++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 NOTICE diff --git a/AGENTS.md b/AGENTS.md index bf71094..d0027b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,12 +29,13 @@ mod, or nEVR won't load it. | `tests/test_check_mod.py`, `tests/test_check_pr_title.py` | Tests of the checks themselves | add to them | | `justfile` | `just verify` and the recipes under it | rarely | | `.clang-format` | The code style (4 spaces, 120 columns); `just fmt` applies it | no | -| `.github/workflows/release.yml` | Builds and publishes releases with a build attestation | **never** | +| `NOTICE` | Credits and notices that travel with the DLL in the release zip | yes | +| `.github/workflows/release.yml` | Builds, attests and drafts releases (the release signer publishes them) | **never** | | `.github/workflows/build.yml` | Format, lint, build, smoke test, checks and tests on every push | no | | `.github/workflows/pr-title-check.yml` | Fails a PR whose title isn't a Conventional Commits header | no | -The launcher only takes release files built and attested by this repo's unmodified -`release.yml`. Don't edit it, don't rename it, don't attach files to a release by hand. +The launcher and the release signer only take release files built and attested by this repo's +unmodified `release.yml`. Don't edit it, don't rename it, don't attach files to a release by hand. ## Commands @@ -173,6 +174,12 @@ beside its own plugins. A mod should work there too or return non-zero from init 12. **No cheats, no data collection,** no network use beyond what the mod is for. 13. **Never write into the game's folders** except the mod's own log folder and a settings file the `.plugin.json` names. Never touch other plugins' files, `config.yaml`, or `BugSplat64.dll`. +14. **The DLL carries its build identity:** exactly one `\0NEVR-PLUGIN-BUILD \0` + literal (`src/plugin.cpp`; CMake fills in the version from `echo-mod.json` and the commit from + `GITHUB_SHA`, or `git rev-parse HEAD` on your machine, or 40 zeros with no git). The release signer + compares it with the tag and the commit that was built. Never `NEVR-BUILD`: nEVR's installer takes any + DLL holding that for the runtime itself. `Init` logs the literal; keep a use of it, or the linker's + `--gc-sections` drops it. `check_mod.py` refuses a DLL with none, two, or the wrong version. ## Commits and PR titles @@ -271,14 +278,21 @@ launcher installs NvrContentOverlay itself, and packs need an nEVR with the earl 1. Bump `version` in `echo-mod.json` (semver: a breaking change for players or other mods bumps MAJOR, a feature MINOR, a fix PATCH; a test build gets a prerelease part: `1.3.0-beta.1`). 2. Commit, tag `v` on that commit, push the tag. Nothing else. -3. `release.yml` builds, smoke-tests, checks the tag against the manifest, packs `dist/` - (`` or the pack zip, `echo-mod.json` with `version`, `prerelease`, `sha256`, `size`, - `settings` and `source` filled in, `SHA256SUMS`), checks `dist/` with the template's - current `tools/check_mod.py --published` (the same check the launcher's catalogue applies), - attests the files and creates the GitHub release (a prerelease when the version has a - prerelease part). -4. Within about 5 minutes the catalogue has it; the result ("live", or why not) is a comment - on the mod's submission issue. +3. `release.yml` builds (the tag's commit goes into the DLL's identity, rule 14), smoke-tests, checks + the tag against the manifest, packs `dist/`, checks `dist/` with this repo's own + `tools/check_mod.py --published`, attests the files and opens a **draft** GitHub release (a + prerelease when the version has a prerelease part). The draft has exactly three files: + - `-v-windows.zip`: the DLL, `LICENSE`, `NOTICE` (when the repo has one), the + `.plugin.json` settings sheet (when the mod has settings) and a `SHA256SUMS` over those. + Flat, fixed dates, so the same files make the same zip. + - `echo-mod.json`: the manifest with `version`, `prerelease`, `settings`, `package` and `source` + filled in. It has no `sha256` or `size`: signing changes the DLL's bytes. + - `SHA256SUMS`: the zip and `echo-mod.json`. +4. A draft is not live. For a mod EchoTools releases, the project's release signer takes the + attested files, signs the DLL inside the zip, replaces the zip and its sums in the draft and + publishes it; the signer's plugin-release spec lists what it checks. Nobody uploads or edits files + by hand, the author included. Once the release is published the catalogue picks it up (about 5 + minutes) and the result ("live", or why not) is a comment on the mod's submission issue. Published versions are immutable: never move or reuse a tag, never replace release files. A broken release is fixed by a new version. A published version that must not be used any more diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..340bfe6 --- /dev/null +++ b/NOTICE @@ -0,0 +1,10 @@ +This mod is built from the nEVR mod template (https://github.com/EchoTools/nevr-runtime-plugin-template, MIT). + +It does not contain any part of the game, and it is not affiliated with or endorsed by the makers of Echo VR. + +Credits +------- +Replace this list with the people whose code, research or findings the mod builds on: who, what, and under +what licence. If you copy someone's code, keep their licence text here or beside it. + +- (name) - (what) - (licence, link) From 89f34331ba19ea72511062137ec738a60af33d57 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Mon, 26 Oct 2026 07:32:17 -0500 Subject: [PATCH 4/5] ci: pass the prerelease flag to gh as an array, not an unquoted word Co-Authored-By: Andrew Bates --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9975e2..28bc421 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,5 +82,6 @@ jobs: TAG: ${{ github.ref_name }} REPO: ${{ github.repository }} run: | - pre=$(jq -r 'if .prerelease then "--prerelease" else "" end' dist/echo-mod.json) - gh release create "$TAG" dist/* -R "$REPO" --draft --verify-tag --title "$TAG" --generate-notes $pre + flags=(--draft --verify-tag --title "$TAG" --generate-notes) + if [[ $(jq -r '.prerelease' dist/echo-mod.json) == true ]]; then flags+=(--prerelease); fi + gh release create "$TAG" dist/* -R "$REPO" "${flags[@]}" From 52676c5d82c6246e2b99f90161345a364ed19de9 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Wed, 28 Oct 2026 09:46:25 -0500 Subject: [PATCH 5/5] fix: check_mod refuses exports other than the Nvr* ABI AGENTS.md rule 7 says only the Nvr* exports leave the DLL, and the signer's RS3 check refuses anything else, but check_mod only warned. A release that passed the template's check could then be refused at signing. It is an error now. The new test renames one export in a copy of the built DLL (same length, so the PE layout stays) and expects an error naming it. It failed before the change (AssertionError: False is not true : []) and passes after. The built DLL still passes, and so does Aaliyah's tablet DLL. Co-Authored-By: Andrew Bates --- tests/test_check_mod.py | 23 +++++++++++++++++++++++ tools/check_mod.py | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_check_mod.py b/tests/test_check_mod.py index 7790496..ca37adf 100644 --- a/tests/test_check_mod.py +++ b/tests/test_check_mod.py @@ -376,6 +376,29 @@ def test_an_unset_commit_is_fine_locally_and_refused_for_a_release(self): self.assertTrue(any("commit" in e for e in self.errors(path, commit="a" * 40))) +@unittest.skipUnless(DLL.is_file(), f"no {DLL} (build first: tools/ci-build.sh)") +class Exports(unittest.TestCase): + """Only the Nvr* ABI leaves the DLL (AGENTS.md rule 7; the signer refuses anything else).""" + + def errors(self, path): + report = check_mod.Report() + check_mod.check_dll(str(path), MANIFEST, report) + return report.errors + + def test_the_built_dll_exports_only_nvr_symbols(self): + self.assertEqual(self.errors(DLL), []) + + def test_a_dll_exporting_an_extra_symbol_is_refused(self): + data = DLL.read_bytes() + old = b"\0NvrPluginShutdown\0" + self.assertEqual(data.count(old), 1, "export table entry not found exactly once") + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / MANIFEST["file"] + path.write_bytes(data.replace(old, b"\0XvrPluginShutdown\0")) # same length: the PE layout stays as it is + errors = self.errors(path) + self.assertTrue(any("XvrPluginShutdown" in e and "export" in e for e in errors), errors) + + class Manifest(unittest.TestCase): def errors(self, **changes): report = check_mod.Report() diff --git a/tools/check_mod.py b/tools/check_mod.py index 9e73b1f..6d3c4af 100644 --- a/tools/check_mod.py +++ b/tools/check_mod.py @@ -349,7 +349,8 @@ def check_dll(path, manifest, report, commit=None): report.warn(f"no export {name}") stray = sorted(e for e in exports if not e.startswith("Nvr")) if stray: - report.warn("exports beyond the plugin's own: " + ", ".join(stray[:10]) + (" ..." if len(stray) > 10 else "")) + report.error("exports other than the Nvr* plugin ABI (src/exports.def lists exactly what leaves the DLL): " + + ", ".join(stray[:10]) + (" ..." if len(stray) > 10 else "")) check_identity(data, manifest, report, commit) report.note(f"{len(data)} bytes, imports: " + ", ".join(imports)) caps = set(manifest.get("capabilities") or [])