klayout/macbuild/pureARM64.py

296 lines
9.5 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
File: "macbuild/pureARM64.py"
Author: ChatGPT + Kazzz-S
------------
Goal:
- Verify that Mach-O binaries inside a .app bundle (and optionally their external
dependencies) are *pure arm64* (no arm64e / no x86_64).
- Summarize results as OK / WARN / FAIL with an appropriate exit code.
Why:
- On macOS/Apple Silicon, mixing arm64e or x86_64 into a process that expects arm64
can trigger dyld / code-signing validation issues (e.g., SIGKILL: Invalid Page).
Requirements:
- macOS with /usr/bin/file, /usr/bin/otool, /usr/bin/lipo available.
Usage:
$ python3 pureARM64.py "/Applications/klayout.app"
# Do not follow external dependencies:
$ python3 pureARM64.py "/Applications/klayout.app" --no-deps
# Limit which external paths are checked (default covers Homebrew/MacPorts/Conda):
$ python3 pureARM64.py "/Applications/klayout.app" \
--deps-filter '^/(opt/homebrew|opt/local|usr/local|Users/.*/(mambaforge|miniforge|anaconda3))'
# Also show whether each Mach-O has LC_CODE_SIGNATURE:
$ python3 pureARM64.py "/Applications/klayout.app" --show-code-sign
Exit codes:
0 = all OK (pure arm64)
1 = warnings present (fat mix includes arm64; investigate)
2 = failures present (no arm64 slice, or only arm64e/x86_64)
"""
import argparse
import os
import re
import shlex
import subprocess
import sys
# Default external dependency filter (you can customize this per project)
DEFAULT_DEPS_FILTER = r'^/(opt/homebrew|opt/local|usr/local|Users/.*/(mambaforge|miniforge|anaconda3))'
# File name patterns that are likely to be Mach-O payloads
MACHO_EXTS = ('.dylib', '.so', '.bundle')
# ANSI color if stdout is a TTY
COLOR = sys.stdout.isatty()
def color(text: str, code: str) -> str:
if not COLOR:
return text
return f'\033[{code}m{text}\033[0m'
OKC = lambda s: color(s, '32') # green
WRNC = lambda s: color(s, '33') # yellow
ERRC = lambda s: color(s, '31') # red
DIM = lambda s: color(s, '2') # dim
def run_cmd(cmd: list[str]) -> tuple[int, str]:
"""Run a command and capture stdout+stderr as text."""
try:
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
return 0, out
except subprocess.CalledProcessError as e:
return e.returncode, e.output
except FileNotFoundError:
return 127, f"command not found: {' '.join(map(shlex.quote, cmd))}"
def is_executable(path: str) -> bool:
"""Return True if file is executable by mode bit."""
try:
st = os.stat(path)
return bool(st.st_mode & 0o111)
except FileNotFoundError:
return False
def looks_like_macho(path: str) -> bool:
"""Heuristic: use 'file' to check if the payload is Mach-O."""
rc, out = run_cmd(["/usr/bin/file", path])
return rc == 0 and "Mach-O" in out
def list_bundle_machos(app_path: str) -> list[str]:
"""
Enumerate Mach-O candidates inside a .app:
- Contents/MacOS
- Contents/Frameworks
- Contents/PlugIns
- Contents/Buddy
Follow symlinks to real paths; deduplicate by real path.
"""
roots = [
os.path.join(app_path, "Contents", "MacOS"),
os.path.join(app_path, "Contents", "Frameworks"),
os.path.join(app_path, "Contents", "PlugIns"),
os.path.join(app_path, "Contents", "Buddy"),
]
seen: set[str] = set()
results: list[str] = []
for root in roots:
if not os.path.isdir(root):
continue
for dirpath, _dirnames, filenames in os.walk(root):
try:
real_dirpath = os.path.realpath(dirpath)
except OSError:
real_dirpath = dirpath
for name in filenames:
p = os.path.join(real_dirpath, name)
rp = os.path.realpath(p)
if rp in seen:
continue
is_macho = looks_like_macho(p)
# Include only: explicit Mach-O, typical Mach-O extensions (.dylib/.so/.bundle),
# or executables that are confirmed Mach-O.
if name.endswith(MACHO_EXTS) or is_macho or (is_executable(p) and is_macho):
seen.add(rp)
results.append(rp)
results.sort()
return results
def parse_archs_via_lipo(path: str) -> list[str]:
"""
Parse architecture slices using lipo/file.
Prefer lipo -info. Fallback to 'file' if necessary.
Returns a sorted unique list like ['arm64'] or ['arm64', 'arm64e'] etc.
"""
rc, out = run_cmd(["/usr/bin/lipo", "-info", path])
if rc == 0 and "Architectures in the fat file" in out:
# e.g. "Architectures in the fat file: QtGui are: arm64 arm64e"
m = re.search(r'are:\s+(.+)$', out.strip())
if m:
return sorted(set(m.group(1).split()))
if rc == 0 and "Non-fat file" in out:
# e.g. "Non-fat file: foo is architecture: arm64"
m = re.search(r'architecture:\s+(\S+)', out)
if m:
return [m.group(1)]
# Fallback to 'file'
rc2, out2 = run_cmd(["/usr/bin/file", path])
if rc2 == 0:
# e.g. "Mach-O 64-bit dynamically linked shared library arm64"
archs = re.findall(r'\b(arm64e?|x86_64)\b', out2)
if archs:
return sorted(set(archs))
return []
_OT_LIB_RE = re.compile(r'^\s+(/.+?)\s+\(')
def deps_from_otool(path: str) -> list[str]:
"""
Extract absolute dependency paths from 'otool -L'.
Only returns absolute paths found in the listing.
"""
rc, out = run_cmd(["/usr/bin/otool", "-L", path])
deps: list[str] = []
if rc != 0:
return deps
for line in out.splitlines():
m = _OT_LIB_RE.match(line)
if m:
deps.append(m.group(1))
return deps
def classify_archs(archs: list[str]) -> tuple[str, str]:
"""
Classify architecture set:
- OK : exactly {'arm64'}
- WARN : contains 'arm64' plus others (e.g., {'arm64', 'arm64e'})
- FAIL : does not contain 'arm64' (e.g., {'arm64e'} or {'x86_64'})
Returns (state, reason).
"""
s = set(archs)
if not s:
return "FAIL", "no-arch-detected"
if s == {"arm64"}:
return "OK", "pure-arm64"
if "arm64" in s and (("arm64e" in s) or ("x86_64" in s)):
return "WARN", "fat-mix:" + ",".join(sorted(s))
return "FAIL", "unsupported-arch:" + ",".join(sorted(s))
def has_code_signature(path: str) -> bool:
"""
Check whether Mach-O has LC_CODE_SIGNATURE.
Note: this indicates a code signature load command exists; it does NOT validate it.
"""
rc, out = run_cmd(["/usr/bin/otool", "-l", path])
return (rc == 0 and "LC_CODE_SIGNATURE" in out)
def main() -> int:
parser = argparse.ArgumentParser(
description="Check Mach-O binaries for pure arm64 (no arm64e/x86_64)."
)
parser.add_argument("target", help=".app path or a single Mach-O path")
parser.add_argument(
"--no-deps", action="store_true",
help="Do not follow external dependencies (ignore 'otool -L')."
)
parser.add_argument(
"--deps-filter", default=DEFAULT_DEPS_FILTER,
help=f"Regex for which absolute dependency paths to include (default: {DEFAULT_DEPS_FILTER})"
)
parser.add_argument(
"--show-code-sign", action="store_true",
help="Also indicate whether LC_CODE_SIGNATURE exists (informational)."
)
args = parser.parse_args()
target = os.path.abspath(args.target)
is_app = target.endswith(".app") and os.path.isdir(target)
# Collect Mach-O files in the bundle or single target
if is_app:
macho_files = list_bundle_machos(target)
if not macho_files:
print(ERRC("No Mach-O files found under .app bundle"), file=sys.stderr)
return 2
else:
if not os.path.exists(target):
print(ERRC(f"Not found: {target}"), file=sys.stderr)
return 2
macho_files = [target]
# Optionally add external dependencies filtered by regex
deps_pat = None if args.no_deps else re.compile(args.deps_filter)
all_targets: set[str] = set(macho_files)
if deps_pat:
for mf in list(macho_files):
for dep in deps_from_otool(mf):
if deps_pat.search(dep):
all_targets.add(dep)
# Header
print(DIM("# pureARM64: verify pure arm64 (detect arm64e/x86_64)"))
print(DIM(f"# target: {target}"))
print(DIM(f"# deps: {'OFF' if deps_pat is None else 'ON'}"))
if deps_pat is not None:
print(DIM(f"# deps-filter: {deps_pat.pattern}"))
print()
# Evaluate each file
warn = 0
fail = 0
for p in sorted(all_targets):
archs = parse_archs_via_lipo(p)
state, reason = classify_archs(archs)
head = {"OK": OKC("[OK] "), "WARN": WRNC("[WARN] "), "FAIL": ERRC("[FAIL] ")}[state]
tail = f" ({reason})"
if args.show_code_sign:
tail += " " + (DIM("[csig]") if has_code_signature(p) else DIM("[no-sig]"))
arch_str = ",".join(archs) if archs else "?"
print(f"{head}{p}\n arch={arch_str}{tail}")
if state == "WARN":
warn += 1
elif state == "FAIL":
fail += 1
# Summary and exit code
total = len(all_targets)
ok = total - warn - fail
print()
print(f"Summary: {OKC('OK='+str(ok))}, {WRNC('WARN='+str(warn))}, {ERRC('FAIL='+str(fail))}")
if fail > 0:
return 2
if warn > 0:
return 1
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print(ERRC("\nInterrupted"), file=sys.stderr)
sys.exit(130)