diff --git a/third_party/voice/README.md b/third_party/voice/README.md index 61b3281c6f..0e864abb33 100644 --- a/third_party/voice/README.md +++ b/third_party/voice/README.md @@ -68,6 +68,12 @@ separate runtime projection and are never copied into users' Codex packages. Cargo/Bazel native providers, final helper linkage and moved-package execution remain separate integration work; exporting an SDK does not enable voice. +The Rust GStreamer bindings also link GLib's GIO library. Its pkg-config metadata +is included in the SDK, and runtime preparation treats its loader identity as an +explicit dependency root alongside the seven plugins. GIO comes from the same +pinned GLib build; it is not another source package or a GStreamer plugin. Its +transitive imports must satisfy the same private-library and system-import checks. + ## Native build recipe `build_native.py` runs the unmodified upstream build systems in a new output diff --git a/third_party/voice/linux_runtime.py b/third_party/voice/linux_runtime.py index 124e5d40e1..eb12e11880 100644 --- a/third_party/voice/linux_runtime.py +++ b/third_party/voice/linux_runtime.py @@ -9,7 +9,7 @@ import sys # Import only this script's siblings, including under PYTHONSAFEPATH. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from runtime import Binary, PLUGINS, RuntimeFormat, prepare +from runtime import Binary, PLUGINS, RuntimeFormat, prepare, required_library_paths SYSTEM_IMPORTS = frozenset( { @@ -138,6 +138,9 @@ def project(prefix, receipts, target, output): inspect, finalize_copy, plugin_dir="lib/gstreamer-1.0", + required_libraries=tuple( + Path(path).name for path in required_library_paths(target) + ), ) prepare(prefix, receipts, target, output, format) diff --git a/third_party/voice/macos_runtime.py b/third_party/voice/macos_runtime.py index 25b3657266..23b674207e 100644 --- a/third_party/voice/macos_runtime.py +++ b/third_party/voice/macos_runtime.py @@ -10,7 +10,7 @@ import sys # Import only this script's siblings, including under PYTHONSAFEPATH. sys.path.insert(0, str(Path(__file__).resolve().parent)) from runtime import Binary as MachO -from runtime import PLUGINS, RuntimeFormat, digest, prepare +from runtime import PLUGINS, RuntimeFormat, digest, prepare, required_library_paths SYSTEM_IMPORTS = frozenset( { @@ -150,6 +150,9 @@ def project(prefix, receipts, target, output): SYSTEM_IMPORTS, inspect, finalize_copy, + required_libraries=tuple( + Path(path).name for path in required_library_paths(target) + ), ) prepare(prefix, receipts, target, output, format) diff --git a/third_party/voice/package_runtime.py b/third_party/voice/package_runtime.py index 73bdc02dff..c70a4711eb 100644 --- a/third_party/voice/package_runtime.py +++ b/third_party/voice/package_runtime.py @@ -9,7 +9,7 @@ import json from pathlib import Path import re -from runtime import PLUGINS, digest +from runtime import PLUGINS, digest, required_library_paths def runtime_files(root: Path, target: str) -> dict[str, str]: @@ -71,5 +71,7 @@ def runtime_files(root: Path, target: str) -> dict[str, str]: files ): raise ValueError("runtime must include exactly the selected plugins") + if not set(required_library_paths(target)).issubset(files): + raise ValueError("runtime must include the required libraries") files["runtime.json"] = hashlib.sha256(data).hexdigest() return files diff --git a/third_party/voice/runtime.py b/third_party/voice/runtime.py index cbc51855bb..6d22c77fb6 100644 --- a/third_party/voice/runtime.py +++ b/third_party/voice/runtime.py @@ -20,6 +20,17 @@ PLUGINS = ( ) +def required_library_paths(target: str) -> tuple[str, ...]: + """Libraries needed by the bindings even when no selected plugin imports them.""" + if target.endswith("-apple-darwin"): + return ("lib/libgio-2.0.0.dylib",) + if target.endswith("-unknown-linux-gnu"): + return ("lib/libgio-2.0.so.0",) + if target.endswith("-pc-windows-msvc"): + return ("bin/gio-2.0-0.dll",) + raise ValueError("unsupported native runtime target") + + @dataclass(frozen=True) class Binary: identity: str @@ -37,6 +48,7 @@ class RuntimeFormat: finalize_copy: Callable library_dir: str = "lib" plugin_dir: str = "plugins" + required_libraries: tuple[str, ...] = () def digest(path): @@ -118,7 +130,14 @@ def prepare(prefix, receipts, target, output, format): binaries[relative] = (record, metadata) identities[metadata.identity] = relative destinations[relative] = destination - pending, selected = list(format.plugins), set() + libraries = {path.name: relative for relative, path in destinations.items()} + if any(name not in libraries for name in format.required_libraries): + raise ValueError("missing required native library") + pending = [ + *format.plugins, + *(libraries[name] for name in format.required_libraries), + ] + selected = set() while pending: relative = pending.pop() if relative in selected: diff --git a/third_party/voice/sdk.py b/third_party/voice/sdk.py index bc7f05037b..8819ce7319 100644 --- a/third_party/voice/sdk.py +++ b/third_party/voice/sdk.py @@ -15,6 +15,7 @@ from runtime import digest MODULES = ( "glib-2.0", "gobject-2.0", + "gio-2.0", "gmodule-no-export-2.0", "gstreamer-1.0", "gstreamer-base-1.0", diff --git a/third_party/voice/test_assemble_package.py b/third_party/voice/test_assemble_package.py index 862db02694..f3116272e8 100644 --- a/third_party/voice/test_assemble_package.py +++ b/third_party/voice/test_assemble_package.py @@ -12,7 +12,8 @@ import unittest from unittest.mock import patch from assemble_package import assemble -from runtime import PLUGINS, digest +from package_runtime import runtime_files +from runtime import PLUGINS, digest, required_library_paths class AssembleTests(unittest.TestCase): @@ -54,13 +55,19 @@ class AssembleTests(unittest.TestCase): libraries.append( {"path": path.relative_to(root).as_posix(), "sha256": digest(path)} ) + plugins = [record["path"] for record in libraries] + for name in required_library_paths(target): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"prepared required library") + libraries.append({"path": name, "sha256": digest(path)}) manifest = { "schemaVersion": 1, "developmentOnly": True, "target": target, "sourceCommit": "b" * 40, "sourceManifestSha256": digest(Path(__file__).with_name("sources.json")), - "plugins": [record["path"] for record in libraries], + "plugins": plugins, "libraries": libraries, } (root / "runtime.json").write_text(json.dumps(manifest)) @@ -129,6 +136,30 @@ class AssembleTests(unittest.TestCase): }, ) + def test_rejects_stale_runtime_without_gio_for_each_platform(self): + for target, plugin, gio in ( + ( + "aarch64-unknown-linux-gnu", + "lib/gstreamer-1.0/libgst{}.so", + "lib/libgio-2.0.so.0", + ), + ( + "aarch64-apple-darwin", + "plugins/libgst{}.dylib", + "lib/libgio-2.0.0.dylib", + ), + ("aarch64-pc-windows-msvc", "bin/gst{}.dll", "bin/gio-2.0-0.dll"), + ): + with self.subTest(target=target): + root, receipt = self.make_runtime(target, plugin) + receipt["libraries"] = [ + r for r in receipt["libraries"] if r["path"] != gio + ] + (root / gio).unlink() + (root / "runtime.json").write_text(json.dumps(receipt)) + with self.assertRaisesRegex(ValueError, "required libraries"): + runtime_files(root.resolve(), target) + def test_rejects_invalid_runtime_receipts_before_creating_package(self): runtime, original = self.make_runtime() changes = [ diff --git a/third_party/voice/test_linux_runtime.py b/third_party/voice/test_linux_runtime.py index 7cf8a8c44f..f4dc80576d 100644 --- a/third_party/voice/test_linux_runtime.py +++ b/third_party/voice/test_linux_runtime.py @@ -46,6 +46,40 @@ class RuntimeTests(unittest.TestCase): check=True, capture_output=True, ) + source.write_text("int voice_gio_dependency(void) { return 84; }\n") + gio_dependency = self.prefix / "lib/libgiofixture.so.1.2" + subprocess.run( + [ + "cc", + "-shared", + "-fPIC", + str(source), + "-o", + str(gio_dependency), + "-Wl,-soname,libgiofixture.so.1", + ], + check=True, + capture_output=True, + ) + source.write_text( + "extern int voice_gio_dependency(void); " + "int voice_gio_fixture(void) { return voice_gio_dependency(); }\n" + ) + subprocess.run( + [ + "cc", + "-shared", + "-fPIC", + str(source), + str(gio_dependency), + "-Wl,-rpath,$ORIGIN", + "-o", + str(self.prefix / "lib/libgio-2.0.so.0.8800.3"), + "-Wl,-soname,libgio-2.0.so.0", + ], + check=True, + capture_output=True, + ) source.write_text( "extern int voice_fixture(void); int voice_plugin(void) { return voice_fixture(); }\n" ) @@ -122,7 +156,8 @@ class RuntimeTests(unittest.TestCase): "-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)).voice_plugin() for p in manifest['plugins']])", + "print([ctypes.CDLL(str(root/p)).voice_plugin() for p in manifest['plugins']] + " + "[ctypes.CDLL(str(root/'lib/libgio-2.0.so.0')).voice_gio_fixture()])", str(moved), ], check=True, @@ -131,9 +166,17 @@ class RuntimeTests(unittest.TestCase): env=environment, cwd=self.root, ) - self.assertEqual(json.loads(result.stdout), [42] * len(PLUGINS)) + self.assertEqual(json.loads(result.stdout), [42] * len(PLUGINS) + [84]) manifest = json.loads((moved / "runtime.json").read_text()) - self.assertEqual(len(manifest["libraries"]), len(PLUGINS) + 1) + self.assertEqual( + {record["path"] for record in manifest["libraries"]}, + { + "lib/libfixture.so.1", + "lib/libgio-2.0.so.0", + "lib/libgiofixture.so.1", + *(f"lib/gstreamer-1.0/libgst{name}.so" for name in PLUGINS), + }, + ) for record in manifest["libraries"]: path = moved / record["path"] expected = ( @@ -141,6 +184,8 @@ class RuntimeTests(unittest.TestCase): if path.parent.name == "gstreamer-1.0" else () ) + if path.name == "libgio-2.0.so.0": + expected = ("29=$ORIGIN",) self.assertEqual(inspect(path, self.target).rpaths, expected) self.assertEqual(digest(path), record["sourceSha256"]) self.assertEqual(record["sha256"], record["sourceSha256"]) @@ -161,7 +206,9 @@ class RuntimeTests(unittest.TestCase): def test_missing_dependency_and_digest_mismatch_leave_no_output(self): for records in ( + [r for r in self.inventory if "libgio" not in r["path"]], [r for r in self.inventory if "libfixture" not in r["path"]], + [r for r in self.inventory if "libgiofixture" not in r["path"]], [{**r, "sha256": "b" * 64} for r in self.inventory], ): with self.subTest(records=records), self.assertRaises(ValueError): diff --git a/third_party/voice/test_macos_runtime.py b/third_party/voice/test_macos_runtime.py index 214f455f72..676e8beae1 100644 --- a/third_party/voice/test_macos_runtime.py +++ b/third_party/voice/test_macos_runtime.py @@ -47,6 +47,13 @@ class RuntimeTests(unittest.TestCase): capture_output=True, ) (self.prefix / "lib/libfixture.1.dylib").symlink_to(self.library.name) + gio = self.prefix / "lib/libgio-2.0.0.dylib" + shutil.copy2(self.library, gio) + subprocess.run( + ["/usr/bin/install_name_tool", "-id", "@rpath/" + gio.name, str(gio)], + check=True, + capture_output=True, + ) source.write_text( "extern int voice_fixture(void); int voice_plugin(void) { return voice_fixture(); }\n" ) @@ -120,6 +127,7 @@ class RuntimeTests(unittest.TestCase): {r["path"] for r in manifest["libraries"]}, { "lib/libfixture.1.dylib", + "lib/libgio-2.0.0.dylib", *(f"plugins/libgst{name}.dylib" for name in PLUGINS), }, ) @@ -205,6 +213,12 @@ class RuntimeTests(unittest.TestCase): def test_missing_dependency_and_duplicate_identity_fail_before_output_creation( self, ): + self.inventory_path.write_text( + json.dumps([r for r in self.inventory if "libgio" not in r["path"]]) + ) + with self.assertRaisesRegex(ValueError, "missing required native library"): + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) self.inventory_path.write_text( json.dumps([r for r in self.inventory if "libfixture" not in r["path"]]) ) diff --git a/third_party/voice/test_sdk.py b/third_party/voice/test_sdk.py index e31d543d04..d499a9e91f 100644 --- a/third_party/voice/test_sdk.py +++ b/third_party/voice/test_sdk.py @@ -93,6 +93,7 @@ class SdkTests(unittest.TestCase): "--cflags", "--libs", "gstreamer-audio-1.0", + "gio-2.0", ], env={ **os.environ, diff --git a/third_party/voice/test_windows_runtime.py b/third_party/voice/test_windows_runtime.py index 110d023b5d..572b1b9d9d 100644 --- a/third_party/voice/test_windows_runtime.py +++ b/third_party/voice/test_windows_runtime.py @@ -53,6 +53,7 @@ class RuntimeTests(unittest.TestCase): capture_output=True, cwd=self.root, ) + shutil.copy2(self.library, self.prefix / "bin/gio-2.0-0.dll") source = self.root / "plugin.c" source.write_text( "__declspec(dllimport) int voice_fixture(void); " @@ -166,12 +167,20 @@ class RuntimeTests(unittest.TestCase): ) 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) + self.assertEqual( + {record["path"] for record in manifest["libraries"]}, + { + "bin/fixture.dll", + "bin/gio-2.0-0.dll", + *(f"bin/gst{name}.dll" for name in PLUGINS), + }, + ) 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 "gio-2.0" not in r["path"]], [r for r in self.inventory if "fixture.dll" not in r["path"]], [{**r, "sha256": "b" * 64} for r in self.inventory], ): diff --git a/third_party/voice/windows_runtime.py b/third_party/voice/windows_runtime.py index f805093e2e..dc43bf92ba 100644 --- a/third_party/voice/windows_runtime.py +++ b/third_party/voice/windows_runtime.py @@ -8,7 +8,7 @@ import sys # Import only this script's siblings, including under PYTHONSAFEPATH. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from runtime import Binary, PLUGINS, RuntimeFormat, prepare +from runtime import Binary, PLUGINS, RuntimeFormat, prepare, required_library_paths # VCRUNTIME140 is a development prerequisite, not a guaranteed Windows component. EXTERNAL_IMPORTS = frozenset( @@ -131,6 +131,9 @@ def project(prefix, receipts, target, output): finalize_copy, library_dir="bin", plugin_dir="bin", + required_libraries=tuple( + Path(path).name for path in required_library_paths(target) + ), ) prepare(prefix, receipts, target, output, format)