From 8813bd4b00c0aed7ffe3e86d06503c075cc775f8 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Wed, 2 Sep 2026 04:30:52 +0000 Subject: [PATCH] Add macOS voice runtime projection (#42204) ## What changed - Add a shared runtime preparer that validates native build receipts, source and file digests, dependency closure, and output containment before copying the required voice plugins and libraries. - Add macOS-specific Mach-O inspection and relocation that removes build-time runpaths, rewrites non-system dependencies relative to each loader, applies development ad-hoc signatures, and records the resulting payload in `runtime.json`. - Close the state runtime explicitly in the clear-memories CLI test. ## Testing - Add macOS tests covering relocation after the source prefix is removed and fail-closed handling of invalid receipts, changed inputs, unsafe paths, dependency conflicts, malformed Mach-O metadata, and failed transforms. GitOrigin-RevId: 3be7a6fabf0135a57036cbb0a9a3e597096930fd --- codex-rs/cli/tests/debug_clear_memories.rs | 2 +- third_party/voice/BUILD.bazel | 2 + third_party/voice/README.md | 24 ++ third_party/voice/macos_runtime.py | 164 +++++++++++ third_party/voice/runtime.py | 181 ++++++++++++ third_party/voice/test_macos_runtime.py | 328 +++++++++++++++++++++ 6 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 third_party/voice/macos_runtime.py create mode 100644 third_party/voice/runtime.py create mode 100644 third_party/voice/test_macos_runtime.py diff --git a/codex-rs/cli/tests/debug_clear_memories.rs b/codex-rs/cli/tests/debug_clear_memories.rs index 449825219d..5b44c9772e 100644 --- a/codex-rs/cli/tests/debug_clear_memories.rs +++ b/codex-rs/cli/tests/debug_clear_memories.rs @@ -17,7 +17,7 @@ async fn debug_clear_memories_resets_state_and_removes_memory_dir() -> Result<() let codex_home = TempDir::new()?; let sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); let runtime = StateRuntime::init(sqlite.clone(), "test-provider".to_string()).await?; - drop(runtime); + runtime.close().await; let thread_id = "00000000-0000-0000-0000-000000000123"; let db_path = sqlite.state_db_path(); diff --git a/third_party/voice/BUILD.bazel b/third_party/voice/BUILD.bazel index ef95130234..33fac3e46a 100644 --- a/third_party/voice/BUILD.bazel +++ b/third_party/voice/BUILD.bazel @@ -33,6 +33,8 @@ filegroup( srcs = [ "assemble_package.py", "build_native.py", + "macos_runtime.py", + "runtime.py", ":source_inputs", ], visibility = ["//visibility:public"], diff --git a/third_party/voice/README.md b/third_party/voice/README.md index 3716ac5f78..b60234138a 100644 --- a/third_party/voice/README.md +++ b/third_party/voice/README.md @@ -86,3 +86,27 @@ may remain external; runtime closure inspection must verify that independently. `//third_party/voice:build_inputs` exposes the recipe and source inputs to Bazel. Neither this filegroup nor a successful prefix build proves final Cargo/Bazel linkage, safe private runtime loading, or an installed voice-capable Codex package. + +## Private macOS runtime projection + +`macos_runtime.py --prefix --receipts +--target --output ` verifies the native receipt, +source manifest, per-file digests and Mach-O architecture before projecting the +seven explicit plugins and their declared library dependencies. It removes SDK +aliases and build-machine runpaths, rewrites private imports relative to each +loader, and regenerates only development ad-hoc signatures. Inputs are untouched. +The output must be new and outside the input directories; failures remove only +that new output. `runtime.json` records source and transformed file identities. +Xcode's `xcrun llvm-objdump` inspects Mach-O headers and load commands; the +preparer reads its output and enforces the package dependency policy rather than +decoding binary structures itself. `install_name_tool` still rewrites paths. +`runtime.py` owns shared receipt checks, dependency selection, verified copying, +and cleanup; each platform owns its binary format and loader changes. Output +containment uses filesystem identity, and copied bytes are checked again before +transformation so input changes cannot silently invalidate the source receipt. + +This is a development-only payload, not a signed distribution package or proof of +audio behavior. Dynamic-only dependencies, native helper linkage, LGPL notices, +production signing/notarization, Windows/Linux loading and security approval remain +separate requirements. No microphone, device, plugin scanner or backend is started +by projection. Run its native relocation tests on macOS with Python 3.12 or newer. diff --git a/third_party/voice/macos_runtime.py b/third_party/voice/macos_runtime.py new file mode 100644 index 0000000000..25b3657266 --- /dev/null +++ b/third_party/voice/macos_runtime.py @@ -0,0 +1,164 @@ +"""Project a verified macOS prefix into a private, relocatable development runtime.""" + +import argparse +import os +from pathlib import Path +import re +import subprocess +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 + +SYSTEM_IMPORTS = frozenset( + { + "/usr/lib/libSystem.B.dylib", + "/usr/lib/libobjc.A.dylib", + "/usr/lib/libiconv.2.dylib", + "/usr/lib/libresolv.9.dylib", + "/System/Library/Frameworks/AppKit.framework/Versions/C/AppKit", + "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation", + "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices", + "/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation", + } +) + + +def run(command): + return subprocess.check_output( + command, text=True, stderr=subprocess.STDOUT, timeout=30 + ) + + +def inspect(path, target): + cpu = {"aarch64-apple-darwin": "ARM64", "x86_64-apple-darwin": "X86_64"}[target] + try: + output = run( + [ + "/usr/bin/xcrun", + "llvm-objdump", + "--macho", + "--universal-headers", + "--private-headers", + str(path), + ] + ) + except subprocess.CalledProcessError as error: + raise ValueError("LLVM rejected the Mach-O library") from error + header, *commands = re.split(r"(?m)^Load command [0-9]+\n", output) + match = re.fullmatch( + r"Mach header\n[^\n]+\nMH_MAGIC_64 +" + + cpu + + r" +\S+ +\S+ +DYLIB +(\d+) +(\d+) +[^\n]+\n", + header.removeprefix(str(path) + ":\n"), + ) + if not match or len(commands) != int(match[1]) or int(match[2]) > 1024 * 1024: + raise ValueError(f"expected a thin {target} dylib with bounded load commands") + identities, imports, rpaths = [], [], [] + for block in commands: + match = re.match(r" +cmd (\S+)\n +cmdsize ([0-9]+)\n", block) + if not match: + raise ValueError("unrecognized LLVM load-command output") + command = match[1] + if command in { + "LC_PREBOUND_DYLIB", + "LC_REEXPORT_DYLIB", + "LC_LAZY_LOAD_DYLIB", + "LC_LOAD_UPWARD_DYLIB", + "LC_DYLD_ENVIRONMENT", + "LC_DYLIB_CODE_SIGN_DRS", + "LC_LAZY_LOAD_DYLIB_INFO", + "?(0x0000003a)", + }: + raise ValueError(f"unsupported loader command: {command}") + if command not in { + "LC_ID_DYLIB", + "LC_LOAD_DYLIB", + "LC_LOAD_WEAK_DYLIB", + "LC_RPATH", + }: + continue + # Match the entire command to reject multiline names masquerading as metadata. + pattern = r" +path ([^\r\n]+) \(offset [0-9]+\)\n" + if command != "LC_RPATH": + pattern = ( + r" +name ([^\r\n]+) \(offset [0-9]+\)\n" + r" +time stamp [^\n]+\n +current version [^\n]+\ncompatibility version [^\n]+\n" + ) + value = re.fullmatch(pattern, block[match.end() :]) + if not value: + raise ValueError( + "undeclared native dependency or unrecognized LLVM loader output" + ) + if command == "LC_ID_DYLIB": + identities.append(value[1]) + elif command == "LC_RPATH": + rpaths.append(value[1]) + else: + imports.append(value[1]) + if len(identities) != 1 or not re.fullmatch( + r"[A-Za-z0-9_+.-]+\.dylib", Path(identities[0]).name + ): + raise ValueError("expected one valid native library identity") + return MachO(identities[0], tuple(imports), tuple(rpaths)) + + +def finalize_copy(destination, metadata, dependency_paths): + identity = f"@rpath/{destination.name}" + command = ["/usr/bin/install_name_tool", "-id", identity] + imports = [] + for dependency in metadata.imports: + rewritten = dependency + if dependency not in SYSTEM_IMPORTS: + rewritten = "@loader_path/" + os.path.relpath( + dependency_paths[dependency], destination.parent + ) + command.extend(["-change", dependency, rewritten]) + imports.append(rewritten) + for rpath in metadata.rpaths: + command.extend(["-delete_rpath", rpath]) + run([*command, str(destination)]) + # Development code signatures only; no identity, entitlement or trust-policy changes. + run( + [ + "/usr/bin/codesign", + "--force", + "--sign", + "-", + "--timestamp=none", + str(destination), + ] + ) + run(["/usr/bin/codesign", "--verify", "--strict", str(destination)]) + return MachO(identity, tuple(imports), ()) + + +def project(prefix, receipts, target, output): + if sys.platform != "darwin" or target not in ( + "aarch64-apple-darwin", + "x86_64-apple-darwin", + ): + raise ValueError( + "runtime projection requires macOS and an explicit macOS target" + ) + format = RuntimeFormat( + tuple( + Path(f"lib/gstreamer-1.0/libgst{name}.dylib") for name in sorted(PLUGINS) + ), + SYSTEM_IMPORTS, + inspect, + finalize_copy, + ) + 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) diff --git a/third_party/voice/runtime.py b/third_party/voice/runtime.py new file mode 100644 index 0000000000..c50055e18b --- /dev/null +++ b/third_party/voice/runtime.py @@ -0,0 +1,181 @@ +"""Prepare receipt-verified native libraries without changing their input trees.""" + +from collections.abc import Callable +from dataclasses import dataclass +import hashlib +import json +import os +from pathlib import Path +import re +import shutil + +PLUGINS = ( + "app", + "audioconvert", + "audioresample", + "coreelements", + "opus", + "rtp", + "rtpmanager", +) + + +@dataclass(frozen=True) +class Binary: + identity: str + imports: tuple[str, ...] + rpaths: tuple[str, ...] + + +@dataclass(frozen=True) +class RuntimeFormat: + """Platform loader policy; inspect reads metadata, finalize_copy transforms or checks a verified copy.""" + + plugins: tuple[Path, ...] + system_imports: frozenset[str] + inspect: Callable + finalize_copy: Callable + library_dir: str = "lib" + plugin_dir: str = "plugins" + + +def digest(path): + with path.open("rb") as source: + return hashlib.file_digest(source, "sha256").hexdigest() + + +def prepare(prefix, receipts, target, output, format): + prefix, receipts = prefix.resolve(strict=True), receipts.resolve(strict=True) + output = output.absolute() + if ( + output.exists() + or output.is_symlink() + or any( + parent.exists() and parent.samefile(source) + for parent in output.resolve().parents + for source in (prefix, receipts) + ) + ): + raise ValueError("output must be fresh and outside the inputs") + ci = json.loads((receipts / "ci.json").read_text()) + source_hash = digest(Path(__file__).with_name("sources.json")) + if ( + ci.get("target") != target + or ci.get("build_complete") is not True + or ci.get("inspection_complete") is not True + or ci.get("manifest_sha256") != source_hash + or not re.fullmatch(r"[0-9a-f]{40}", ci.get("commit", "")) + ): + raise ValueError( + "native build receipt does not match the pinned source inputs and target" + ) + inventory_path = receipts / "inspection/binaries.json" + inventory = json.loads(inventory_path.read_text()) + if not 1 <= len(inventory) <= 128: + raise ValueError("unexpected native inventory size") + binaries, identities, destinations = {}, {}, {} + for record in inventory: + spelling = ( + record["path"].replace("\\", "/") if os.name == "nt" else record["path"] + ) + relative = Path(spelling) + if relative.anchor or ".." in relative.parts or relative.as_posix() != spelling: + raise ValueError("native inventory path must be canonical and relative") + path = prefix / relative + if ( + path.is_symlink() + or not path.is_file() + or not path.resolve().is_relative_to(prefix) + ): + raise ValueError( + "native inventory entries must be regular files inside the prefix" + ) + if record["target"] != target or digest(path) != record["sha256"]: + raise ValueError(f"native input target/digest mismatch: {relative}") + metadata = format.inspect(path, target) + name = Path(metadata.identity).name + if not re.fullmatch(r"[A-Za-z0-9_+.-]+", name): + raise ValueError("invalid native library identity") + if relative in format.plugins and name != relative.name: + raise ValueError("explicit plugin identity must preserve its filename") + if ( + metadata.identity in identities + or metadata.identity in format.system_imports + or relative in binaries + ): + raise ValueError("duplicate or system native library identity") + destination = ( + Path( + format.plugin_dir if relative in format.plugins else format.library_dir + ) + / name + ) + if any( + destination.as_posix().casefold() == p.as_posix().casefold() + for p in destinations.values() + ): + raise ValueError("colliding runtime library filenames") + binaries[relative] = (record, metadata) + identities[metadata.identity] = relative + destinations[relative] = destination + pending, selected = list(format.plugins), set() + while pending: + relative = pending.pop() + if relative in selected: + continue + if relative not in binaries: + raise ValueError(f"missing explicit plugin: {relative}") + selected.add(relative) + for dependency in binaries[relative][1].imports: + if dependency in format.system_imports: + continue + if dependency not in identities: + raise ValueError(f"undeclared native dependency: {dependency}") + pending.append(identities[dependency]) + output.mkdir() # Only this exclusively created output may be removed on failure. + try: + records = [] + dependency_paths = { + identity: output / destinations[path] + for identity, path in identities.items() + } + for relative in sorted(selected): + record, metadata = binaries[relative] + destination = output / destinations[relative] + destination.parent.mkdir(exist_ok=True) + shutil.copy2(prefix / relative, destination) + if ( + digest(destination) != record["sha256"] + or format.inspect(destination, target) != metadata + ): + raise ValueError("copied native input changed after verification") + expected = format.finalize_copy(destination, metadata, dependency_paths) + if format.inspect(destination, target) != expected: + raise ValueError( + "finalized loader commands did not match the private runtime layout" + ) + records.append( + { + "path": destinations[relative].as_posix(), + "sourcePath": relative.as_posix(), + "sourceSha256": record["sha256"], + "sha256": digest(destination), + "imports": list(expected.imports), + } + ) + manifest = { + "schemaVersion": 1, + "developmentOnly": True, + "target": target, + "sourceCommit": ci["commit"], + "sourceManifestSha256": source_hash, + "inventorySha256": digest(inventory_path), + "libraries": records, + "plugins": [destinations[path].as_posix() for path in format.plugins], + } + (output / "runtime.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + except BaseException: + shutil.rmtree(output) + raise diff --git a/third_party/voice/test_macos_runtime.py b/third_party/voice/test_macos_runtime.py new file mode 100644 index 0000000000..214f455f72 --- /dev/null +++ b/third_party/voice/test_macos_runtime.py @@ -0,0 +1,328 @@ +"""Check real Mach-O relocation and fail-closed native input handling.""" + +import ctypes +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 macos_runtime import PLUGINS, digest, inspect, project + + +@unittest.skipUnless(sys.platform == "darwin", "macOS native runtime projection") +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.root / "input" + self.receipts = self.root / "receipts" + self.output = self.root / "runtime" + self.target = ( + "aarch64" if platform.machine() == "arm64" else "x86_64" + ) + "-apple-darwin" + (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.1.2.dylib" + subprocess.run( + [ + "/usr/bin/cc", + "-dynamiclib", + str(source), + "-o", + str(self.library), + "-Wl,-install_name,@rpath/libfixture.1.dylib", + "-Wl,-headerpad_max_install_names", + ], + check=True, + capture_output=True, + ) + (self.prefix / "lib/libfixture.1.dylib").symlink_to(self.library.name) + source.write_text( + "extern int voice_fixture(void); int voice_plugin(void) { return voice_fixture(); }\n" + ) + prototype = self.prefix / f"lib/gstreamer-1.0/libgst{PLUGINS[0]}.dylib" + subprocess.run( + [ + "/usr/bin/cc", + "-dynamiclib", + str(source), + str(self.library), + "-o", + str(prototype), + f"-Wl,-install_name,{prototype}", + f"-Wl,-rpath,{self.prefix / 'lib'}", + "-Wl,-headerpad_max_install_names", + ], + check=True, + capture_output=True, + ) + for name in PLUGINS[1:]: + path = self.prefix / f"lib/gstreamer-1.0/libgst{name}.dylib" + shutil.copy2(prototype, path) + subprocess.run( + ["/usr/bin/install_name_tool", "-id", str(path), str(path)], + check=True, + capture_output=True, + ) + self.ci = { + "commit": "a" * 40, + "target": self.target, + "build_complete": True, + "inspection_complete": True, + "manifest_sha256": digest(Path(__file__).with_name("sources.json")), + } + (self.receipts / "ci.json").write_text(json.dumps(self.ci)) + self.inventory = [ + { + "path": p.relative_to(self.prefix).as_posix(), + "target": self.target, + "sha256": digest(p), + } + for p in sorted(self.prefix.rglob("*.dylib")) + if not p.is_symlink() + ] + self.inventory_path = self.receipts / "inspection/binaries.json" + self.inventory_path.write_text(json.dumps(self.inventory)) + + def test_relocated_payload_loads_after_original_prefix_is_removed(self): + subprocess.run( + [ + sys.executable, + str(Path(__file__).with_name("macos_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"}, + ) + manifest = json.loads((self.output / "runtime.json").read_text()) + before = {r["path"]: digest(self.prefix / r["path"]) for r in self.inventory} + self.assertEqual(before, {r["path"]: r["sha256"] for r in self.inventory}) + self.assertEqual( + {r["path"] for r in manifest["libraries"]}, + { + "lib/libfixture.1.dylib", + *(f"plugins/libgst{name}.dylib" for name in PLUGINS), + }, + ) + moved = self.root / "relocated package" + self.output.rename(moved) + shutil.rmtree(self.prefix) + for relative in manifest["plugins"]: + plugin = ctypes.CDLL(str(moved / relative)) + self.assertEqual(plugin.voice_plugin(), 42) + for record in manifest["libraries"]: + metadata = inspect(moved / record["path"], self.target) + self.assertEqual(metadata.rpaths, ()) + self.assertEqual(metadata.imports, tuple(record["imports"])) + self.assertEqual(digest(moved / record["path"]), record["sha256"]) + + def test_input_digest_target_and_receipt_must_match(self): + for key, value in [ + ("build_complete", False), + ("manifest_sha256", "b" * 64), + ("target", "wrong"), + ]: + with self.subTest(key=key), self.assertRaises(ValueError): + (self.receipts / "ci.json").write_text( + json.dumps({**self.ci, key: value}) + ) + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + (self.receipts / "ci.json").write_text(json.dumps(self.ci)) + self.library.write_bytes(b"tampered") + with self.assertRaisesRegex(ValueError, "digest mismatch"): + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + + def test_case_alias_cannot_place_output_inside_inputs(self): + for source in (self.prefix, self.receipts): + alias = source.with_name(source.name.upper()) + if not alias.exists(): + self.skipTest("requires a case-insensitive filesystem") + with ( + self.subTest(source=source), + self.assertRaisesRegex(ValueError, "outside the inputs"), + ): + project(self.prefix, self.receipts, self.target, alias / "runtime") + self.assertFalse((source / "runtime").exists()) + + def test_case_colliding_library_destinations_are_rejected(self): + duplicate = self.prefix / "lib/another.dylib" + shutil.copy2(self.library, duplicate) + subprocess.run( + [ + "/usr/bin/install_name_tool", + "-id", + "@rpath/LIBFIXTURE.1.dylib", + str(duplicate), + ], + check=True, + capture_output=True, + ) + self.inventory.append( + { + "path": duplicate.relative_to(self.prefix).as_posix(), + "target": self.target, + "sha256": digest(duplicate), + } + ) + self.inventory_path.write_text(json.dumps(self.inventory)) + with self.assertRaisesRegex(ValueError, "colliding runtime library filenames"): + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + + def test_changed_source_at_copy_is_rejected(self): + copy = shutil.copy2 + + def change_then_copy(source, destination): + source.write_bytes(source.read_bytes() + b"changed after verification") + return copy(source, destination) + + with patch("runtime.shutil.copy2", side_effect=change_then_copy): + with self.assertRaisesRegex(ValueError, "copied native input changed"): + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + + 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 "libfixture" not in r["path"]]) + ) + with self.assertRaisesRegex(ValueError, "undeclared native dependency"): + project(self.prefix, self.receipts, self.target, self.output) + self.inventory_path.write_text(json.dumps([*self.inventory, self.inventory[0]])) + with self.assertRaisesRegex(ValueError, "duplicate"): + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + + def test_inventory_cannot_escape_prefix(self): + for relative in ( + "../fixture.c", + str(self.library), + "lib/../lib/libfixture.1.2.dylib", + ): + self.inventory_path.write_text( + json.dumps([{**self.inventory[0], "path": relative}]) + ) + with self.subTest(path=relative), self.assertRaises(ValueError): + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + + def test_multiline_import_cannot_impersonate_a_system_library(self): + external = self.root / "outside\n name /usr/lib/libSystem.B.dylib" + external.parent.mkdir(parents=True) + shutil.copy2(self.library, external) + plugin = self.prefix / f"lib/gstreamer-1.0/libgst{PLUGINS[0]}.dylib" + subprocess.run( + [ + "/usr/bin/install_name_tool", + "-change", + "@rpath/libfixture.1.dylib", + str(external), + 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, "undeclared native dependency"): + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + + def test_malformed_load_command_bounds_and_strings_are_rejected(self): + command = struct.pack("<6I", 0xD, 32, 24, 0, 0, 0) + b"test\0\0\0\0" + for count, size, payload in ( + (1, 1024 * 1024 + 1, command), + (2, 32, command), + (1, 32, command[:-1]), + (1, 32, struct.pack("<3I", 0xD, 40, 24) + command[12:]), + (1, 32, struct.pack("<3I", 0xD, 32, 8) + command[12:]), + (1, 32, command[:24] + b"no null!"), + ): + with self.subTest(count=count, size=size, payload=payload): + cpu = 0x100000C if self.target.startswith("aarch64") else 0x1000007 + self.library.write_bytes( + struct.pack("<8I", 0xFEEDFACF, cpu, 0, 6, count, size, 0, 0) + + payload + ) + with self.assertRaises(ValueError): + inspect(self.library, self.target) + + def test_tool_cannot_hide_other_architectures_or_loader_commands(self): + cpu = 0x100000C if self.target.startswith("aarch64") else 0x1000007 + name = b"@rpath/libtest.dylib\0" + command = ( + struct.pack("<6I", 0xD, 48, 24, 0, 0, 0) + name + bytes(24 - len(name)) + ) + thin = struct.pack("<8I", 0xFEEDFACF, cpu, 0, 6, 1, 48, 0, 0) + command + for tag in (0x2B, 0x3A): + self.library.write_bytes( + struct.pack("<8I", 0xFEEDFACF, cpu, 0, 6, 2, 64, 0, 0) + + command + + struct.pack("<4I", tag, 16, 0, 0) + ) + with self.subTest(tag=tag), self.assertRaises(ValueError): + inspect(self.library, self.target) + fat = struct.pack(">7I", 0xCAFEBABE, 1, cpu, 0, 4096, len(thin), 12) + self.library.write_bytes(fat + bytes(4096 - len(fat)) + thin) + with self.assertRaises(ValueError): + inspect(self.library, self.target) + fake_cpu = "X86_64" if cpu == 0x100000C else "ARM64" + fake_target = ( + "x86_64-apple-darwin" if cpu == 0x100000C else "aarch64-apple-darwin" + ) + path = ( + self.root + / f"fake\nMH_MAGIC_64 {fake_cpu} ALL 0x00 DYLIB 1 48 0x00000000\n\n.dylib" + ) + path.write_bytes(thin) + with self.assertRaises(ValueError): + inspect(path, fake_target) + + def test_existing_outputs_and_failed_transform_preserve_inputs(self): + for output in (self.prefix, self.prefix / "nested", self.receipts / "nested"): + with self.subTest(output=output), self.assertRaises(ValueError): + project(self.prefix, self.receipts, self.target, output) + import macos_runtime + + original_run = macos_runtime.run + + def fail_transform(command): + if command[0].endswith("install_name_tool"): + raise subprocess.CalledProcessError(1, command) + return original_run(command) + + with patch("macos_runtime.run", side_effect=fail_transform): + with self.assertRaises(subprocess.CalledProcessError): + project(self.prefix, self.receipts, self.target, self.output) + self.assertFalse(self.output.exists()) + self.assertEqual( + {r["path"]: digest(self.prefix / r["path"]) for r in self.inventory}, + {r["path"]: r["sha256"] for r in self.inventory}, + ) + + +if __name__ == "__main__": + unittest.main()