Files
codex/third_party/voice/test_linux_runtime.py
Benjamin Carlsson 8d01cd42fa Add GNU Linux voice runtime preparation (#42208)
## Why

Native voice libraries need package-relative loader paths so a prepared runtime
can be moved without retaining references to its build prefix.

## What changed

- Configure CMake and GNU Linux Meson builds with relative runtime paths, while
  also setting relocatable install names and paths for CMake libraries on macOS.
- Add a GNU Linux runtime preparer for x64 and ARM64 that validates bounded ELF64
  metadata, selects the declared GStreamer plugins and dependency closure, and
  preserves the `lib/gstreamer-1.0/` layout.
- Reject malformed ELF metadata, path-bearing imports, unsupported loader
  dependencies, and native outputs that still contain incompatible runtime
  paths.

## Testing

Add native tests for relative build paths, relocated library loading, dependency
and digest failures, malformed ELF inputs, and cleanup after failed preparation.

GitOrigin-RevId: ed94819b07d20214b56363446b64f765e08d75fa
2026-09-02 05:14:37 +00:00

256 lines
10 KiB
Python

"""Exercise real ELF relocation and rejection of unsafe native build inputs."""
import json
import os
from pathlib import Path
import platform
import shutil
import struct
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
from linux_runtime import inspect, project
from runtime import PLUGINS, digest
@unittest.skipUnless(sys.platform == "linux", "GNU Linux 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() == "aarch64" else "x86_64"
) + "-unknown-linux-gnu"
(self.prefix / "lib/gstreamer-1.0").mkdir(parents=True)
(self.receipts / "inspection").mkdir(parents=True)
source = self.root / "fixture.c"
source.write_text("int voice_fixture(void) { return 42; }\n")
self.library = self.prefix / "lib/libfixture.so.1.2"
subprocess.run(
[
"cc",
"-shared",
"-fPIC",
str(source),
"-o",
str(self.library),
"-Wl,-soname,libfixture.so.1",
],
check=True,
capture_output=True,
)
source.write_text(
"extern int voice_fixture(void); int voice_plugin(void) { return voice_fixture(); }\n"
)
for name in PLUGINS:
plugin = self.prefix / f"lib/gstreamer-1.0/libgst{name}.so"
subprocess.run(
[
"cc",
"-shared",
"-fPIC",
str(source),
str(self.library),
"-o",
str(plugin),
f"-Wl,-soname,{plugin.name}",
"-Wl,-rpath,$ORIGIN:$ORIGIN/..",
],
check=True,
capture_output=True,
)
self.inventory_path = self.receipts / "inspection/binaries.json"
self.inventory = [
{
"path": p.relative_to(self.prefix).as_posix(),
"sha256": digest(p),
"target": self.target,
}
for p in sorted(self.prefix.rglob("*.so*"))
if p.is_file()
]
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_relocated_libraries_load_without_original_prefix(self):
subprocess.run(
[
sys.executable,
str(Path(__file__).with_name("linux_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)
environment = {k: v for k, v in os.environ.items() if not k.startswith("LD_")}
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)).voice_plugin() for p in manifest['plugins']])",
str(moved),
],
check=True,
capture_output=True,
text=True,
env=environment,
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"]:
path = moved / record["path"]
expected = (
("29=$ORIGIN:$ORIGIN/..",)
if path.parent.name == "gstreamer-1.0"
else ()
)
self.assertEqual(inspect(path, self.target).rpaths, expected)
self.assertEqual(digest(path), record["sourceSha256"])
self.assertEqual(record["sha256"], record["sourceSha256"])
def test_absolute_build_paths_require_a_new_native_build(self):
plugin = self.prefix / "lib/gstreamer-1.0/libgstapp.so"
subprocess.run(
["patchelf", "--set-rpath", str(self.prefix / "lib"), str(plugin)],
check=True,
capture_output=True,
)
for record in self.inventory:
record["sha256"] = digest(self.prefix / record["path"])
self.inventory_path.write_text(json.dumps(self.inventory))
with self.assertRaisesRegex(ValueError, "rebuild native libraries"):
project(self.prefix, self.receipts, self.target, self.output)
self.assertFalse(self.output.exists())
def test_missing_dependency_and_digest_mismatch_leave_no_output(self):
for records in (
[r for r in self.inventory if "libfixture" 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_path_import_is_not_treated_as_a_system_library(self):
plugin = self.prefix / "lib/gstreamer-1.0/libgstapp.so"
subprocess.run(
[
"patchelf",
"--replace-needed",
"libfixture.so.1",
"outside\nlibc.so.6",
str(plugin),
],
check=True,
capture_output=True,
)
with self.assertRaisesRegex(ValueError, "plain library names"):
inspect(plugin, self.target)
def test_malformed_header_and_segment_bounds_are_rejected(self):
original = self.library.read_bytes()
for offset, replacement in (
(18, b"\x00\x00"),
(32, struct.pack("<Q", len(original))),
(56, b"\xff\xff"),
):
data = bytearray(original)
data[offset : offset + len(replacement)] = replacement
self.library.write_bytes(data)
with self.subTest(offset=offset), self.assertRaises(ValueError):
inspect(self.library, self.target)
def test_additional_loader_dependencies_are_rejected(self):
data = bytearray(self.library.read_bytes())
header = struct.unpack_from("<16sHHIQQQIHHHHHH", data)
for index in range(header[10]):
segment = struct.unpack_from("<IIQQQQQQ", data, header[5] + index * 56)
if segment[0] == 2:
struct.pack_into("<qQ", data, segment[2], 0x7FFFFFFF, 0)
break
self.library.write_bytes(data)
with self.assertRaisesRegex(ValueError, "unsupported ELF loader"):
inspect(self.library, self.target)
def test_dynamic_table_mapping_is_checked_before_creating_output(self):
# An existing intended runpath can make patchelf leave this malformed table untouched.
subprocess.run(
["patchelf", "--set-rpath", "$ORIGIN", str(self.library)],
check=True,
capture_output=True,
)
original = self.library.read_bytes()
header = struct.unpack_from("<16sHHIQQQIHHHHHH", original)
dynamic_offset = next(
header[5] + index * 56
for index in range(header[10])
if struct.unpack_from("<I", original, header[5] + index * 56)[0] == 2
)
address = struct.unpack_from("<Q", original, dynamic_offset + 16)[0]
for invalid_address in (address + 8, 0x77770000):
data = bytearray(original)
struct.pack_into("<Q", data, dynamic_offset + 16, invalid_address)
self.library.write_bytes(data)
self.inventory_path.write_text(
json.dumps(
[
{**record, "sha256": digest(self.prefix / record["path"])}
for record in self.inventory
]
)
)
with (
self.subTest(address=invalid_address),
self.assertRaisesRegex(ValueError, "dynamic table.*mapping"),
):
project(self.prefix, self.receipts, self.target, self.output)
self.assertFalse(self.output.exists())
def test_failed_copy_removes_only_fresh_output(self):
before = {r["path"]: digest(self.prefix / r["path"]) for r in self.inventory}
with patch("runtime.shutil.copy2", side_effect=RuntimeError("copy failed")):
with self.assertRaisesRegex(RuntimeError, "copy failed"):
project(self.prefix, self.receipts, self.target, self.output)
self.assertFalse(self.output.exists())
self.assertEqual(
before, {r["path"]: digest(self.prefix / r["path"]) for r in self.inventory}
)