From eb10d91e48ccbd0930427461fb392337addb1ac0 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Wed, 2 Sep 2026 05:07:50 +0000 Subject: [PATCH] Add Windows voice runtime preparation (#42209) ## What changed - Add an x64 and ARM64 MSVC runtime preparer that uses `dumpbin` to validate PE32+ DLL metadata, selects the declared GStreamer plugins and dependency closure, and copies them unchanged into a private `bin/` directory. - Reject malformed or unsupported PE metadata, path-bearing imports, delayed imports, managed DLLs, forwarded exports, undeclared dependencies, and case-insensitive DLL identity conflicts. - Keep `third_party/voice/sources.json` line endings stable across Windows checkouts so native build receipts remain valid. ## Testing Add native Windows tests for relocated DLL loading with a restricted search path, receipt and digest failures, duplicate identities, malformed PE headers, unsupported loader features, and cleanup after failed preparation. GitOrigin-RevId: 5375f21fc54f5d597f3d4e5f8fbca3df307fa2a8 --- .gitattributes | 1 + third_party/voice/BUILD.bazel | 1 + third_party/voice/README.md | 16 ++ third_party/voice/test_windows_runtime.py | 241 ++++++++++++++++++++++ third_party/voice/windows_runtime.py | 145 +++++++++++++ 5 files changed, 404 insertions(+) create mode 100644 third_party/voice/test_windows_runtime.py create mode 100644 third_party/voice/windows_runtime.py diff --git a/.gitattributes b/.gitattributes index 57c5fe6e88..3f841bf7ca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ codex-rs/app-server-protocol/schema/** linguist-generated codex-rs/hooks/schema/generated/** linguist-generated +third_party/voice/sources.json text eol=lf diff --git a/third_party/voice/BUILD.bazel b/third_party/voice/BUILD.bazel index 4e6f3cb62c..c0d0e7d64e 100644 --- a/third_party/voice/BUILD.bazel +++ b/third_party/voice/BUILD.bazel @@ -36,6 +36,7 @@ filegroup( "linux_runtime.py", "macos_runtime.py", "runtime.py", + "windows_runtime.py", ":source_inputs", ], visibility = ["//visibility:public"], diff --git a/third_party/voice/README.md b/third_party/voice/README.md index efc73f2ddb..50df282e25 100644 --- a/third_party/voice/README.md +++ b/third_party/voice/README.md @@ -131,3 +131,19 @@ and `patchelf`; the latter constructs malformed inputs and is not needed during preparation or shipped in the runtime. The output is development-only, uses the host glibc, and does not establish musl or minimum-glibc support, dynamic-only dependency closure, helper loading policy or working voice. + +## Private Windows runtime preparation + +`windows_runtime.py` takes the same arguments for x64/ARM64 MSVC build prefixes. +MSVC's existing `dumpbin` reads PE headers, dependencies and exports; the Python +adapter applies package policy without walking binary structures. +It checks bounded PE32+ import tables, uses case-insensitive DLL identities, and +copies the seven plugins and their declared dependencies into one private `bin/` +directory without changing DLL bytes. Delayed imports, managed DLLs and forwarded +exports are unsupported and rejected. Native tests require MSVC and Python 3.12; +they load the moved DLLs using only the DLL directory and System32 search flags. +This development payload expects the Windows Universal CRT and the matching +Microsoft Visual C++ runtime (`VCRUNTIME140.dll`) already installed. The latter +is not a guaranteed OS component. Release redistribution/licensing, Authenticode +policy and actual helper loading remain separate requirements; this script does +not install or redistribute Microsoft runtime files or enable voice. diff --git a/third_party/voice/test_windows_runtime.py b/third_party/voice/test_windows_runtime.py new file mode 100644 index 0000000000..110d023b5d --- /dev/null +++ b/third_party/voice/test_windows_runtime.py @@ -0,0 +1,241 @@ +"""Exercise native MSVC DLL preparation and restricted-search loading.""" + +import json +import os +from pathlib import Path +import platform +import shutil +import struct +import subprocess +import sys +import tempfile +import unittest + +from runtime import PLUGINS, digest +from windows_runtime import inspect, project + + +@unittest.skipUnless(sys.platform == "win32", "Windows native runtime preparation") +class RuntimeTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="native voice ") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.prefix, self.receipts, self.output = ( + self.root / name for name in ("input", "receipts", "runtime") + ) + self.target = ( + "aarch64" + if platform.machine().lower() in ("arm64", "aarch64") + else "x86_64" + ) + "-pc-windows-msvc" + (self.prefix / "lib/gstreamer-1.0").mkdir(parents=True) + (self.prefix / "bin").mkdir() + (self.receipts / "inspection").mkdir(parents=True) + source = self.root / "fixture.c" + source.write_text( + "__declspec(dllexport) int voice_fixture(void) { return 42; }\n" + ) + self.library = self.prefix / "bin/fixture.dll" + import_library = self.root / "fixture.lib" + subprocess.run( + [ + "cl", + "/nologo", + "/LD", + "/MD", + str(source), + "/link", + f"/OUT:{self.library}", + f"/IMPLIB:{import_library}", + ], + check=True, + capture_output=True, + cwd=self.root, + ) + source = self.root / "plugin.c" + source.write_text( + "__declspec(dllimport) int voice_fixture(void); " + "__declspec(dllexport) int voice_plugin(void) { return voice_fixture(); }\n" + ) + for name in PLUGINS: + plugin = self.prefix / f"lib/gstreamer-1.0/gst{name}.dll" + subprocess.run( + [ + "cl", + "/nologo", + "/LD", + "/MD", + str(source), + str(import_library), + "/link", + f"/OUT:{plugin}", + f"/IMPLIB:{self.root / (name + '.lib')}", + ], + check=True, + capture_output=True, + cwd=self.root, + ) + self.inventory_path = self.receipts / "inspection/binaries.json" + self.inventory = [ + { + "path": str(p.relative_to(self.prefix)), + "sha256": digest(p), + "target": self.target, + } + for p in sorted(self.prefix.rglob("*.dll")) + ] + self.inventory_path.write_text(json.dumps(self.inventory)) + (self.receipts / "ci.json").write_text( + json.dumps( + { + "commit": "a" * 40, + "target": self.target, + "build_complete": True, + "inspection_complete": True, + "manifest_sha256": digest(Path(__file__).with_name("sources.json")), + } + ) + ) + + def test_receipts_match_across_checkout_line_endings(self): + source = Path(__file__).with_name("sources.json") + checkout = self.root / "checkout" + manifest = checkout / "third_party/voice/sources.json" + manifest.parent.mkdir(parents=True) + manifest.write_bytes(source.read_bytes().replace(b"\r\n", b"\n")) + shutil.copy2(source.parents[2] / ".gitattributes", checkout) + git = ["git", "-C", str(checkout), "-c", "core.autocrlf=false"] + subprocess.run([*git, "init", "-q"], check=True, capture_output=True) + subprocess.run([*git, "add", "."], check=True, capture_output=True) + hashes = [] + for autocrlf in ("true", "false"): + manifest.unlink() + subprocess.run( + [*git, "-c", f"core.autocrlf={autocrlf}", "checkout-index", "-a", "-f"], + check=True, + capture_output=True, + ) + hashes.append(digest(manifest)) + self.assertEqual(hashes, [digest(source), digest(source)]) + receipt_path = self.receipts / "ci.json" + receipt = json.loads(receipt_path.read_text()) + receipt["manifest_sha256"] = hashes[0] + receipt_path.write_text(json.dumps(receipt)) + project(self.prefix, self.receipts, self.target, self.output) + + def test_moved_dlls_load_with_private_directory_and_system_search_only(self): + subprocess.run( + [ + sys.executable, + str(Path(__file__).with_name("windows_runtime.py").resolve()), + "--prefix", + str(self.prefix), + "--receipts", + str(self.receipts), + "--target", + self.target, + "--output", + str(self.output), + ], + check=True, + capture_output=True, + cwd=self.root, + env={**os.environ, "PYTHONSAFEPATH": "1"}, + ) + self.assertEqual( + {r["path"]: digest(self.prefix / r["path"]) for r in self.inventory}, + {r["path"]: r["sha256"] for r in self.inventory}, + ) + moved = self.root / "moved runtime" + self.output.rename(moved) + shutil.rmtree(self.prefix) + result = subprocess.run( + [ + sys.executable, + "-c", + "import ctypes,json,pathlib,sys; root=pathlib.Path(sys.argv[1]); " + "manifest=json.loads((root/'runtime.json').read_text()); " + "print([ctypes.CDLL(str(root/p),winmode=0x100|0x800).voice_plugin() for p in manifest['plugins']])", + str(moved), + ], + check=True, + capture_output=True, + text=True, + cwd=self.root, + ) + self.assertEqual(json.loads(result.stdout), [42] * len(PLUGINS)) + manifest = json.loads((moved / "runtime.json").read_text()) + self.assertEqual(len(manifest["libraries"]), len(PLUGINS) + 1) + for record in manifest["libraries"]: + self.assertEqual(digest(moved / record["path"]), record["sourceSha256"]) + + def test_missing_dependency_and_digest_mismatch_leave_no_output(self): + for records in ( + [r for r in self.inventory if "fixture.dll" not in r["path"]], + [{**r, "sha256": "b" * 64} for r in self.inventory], + ): + with self.subTest(records=records), self.assertRaises(ValueError): + self.inventory_path.write_text(json.dumps(records)) + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + + def test_case_alias_dll_identities_are_rejected(self): + duplicate = self.prefix / "lib/FIXTURE.dll" + shutil.copy2(self.library, duplicate) + records = self.inventory + [ + { + "path": str(duplicate.relative_to(self.prefix)), + "sha256": digest(duplicate), + "target": self.target, + } + ] + self.inventory_path.write_text(json.dumps(records)) + with self.assertRaisesRegex(ValueError, "duplicate"): + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + + def test_malformed_headers_and_delayed_imports_are_rejected(self): + original = self.library.read_bytes() + pe = struct.unpack_from(" 1: + raise ValueError("ambiguous dumpbin dependencies") + imports = ( + tuple(line.strip().lower() for line in groups[0].splitlines()) if groups else () + ) + if any(not re.fullmatch(r"[a-z0-9_+.-]+\.dll", name) for name in imports): + raise ValueError("PE dependencies must be plain DLL names") + address, size = directories["Import Directory"] + # A descriptor per DLL plus its null terminator; reject truncated/injected output. + if (address, size) != (0, 0) and ( + not address or size != 20 * (len(imports) + 1) or len(imports) > 128 + ): + raise ValueError("inconsistent PE import directory") + return Binary(path.name.lower(), imports, ()) + + +def finalize_copy(destination, metadata, dependency_paths): + # DLL import names already resolve among siblings; do not alter signed bytes. + return metadata + + +def project(prefix, receipts, target, output): + if sys.platform != "win32" or target not in ( + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + ): + raise ValueError( + "runtime preparation requires Windows and an explicit MSVC target" + ) + format = RuntimeFormat( + tuple(Path(f"lib/gstreamer-1.0/gst{name}.dll") for name in sorted(PLUGINS)), + EXTERNAL_IMPORTS, + inspect, + finalize_copy, + library_dir="bin", + plugin_dir="bin", + ) + prepare(prefix, receipts, target, output, format) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prefix", type=Path, required=True) + parser.add_argument("--receipts", type=Path, required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + project(args.prefix, args.receipts, args.target, args.output)