Files
SpotiFLAC-Mobile/scripts/check_backend_ios.py
T
zarzet aa99726439 feat(backend): migrate production backend to Rust
Replace the Go backend with the Rust workspace and route Android/iOS through
UniFFI bindings. Include the migrated extension runtime, network, providers,
media metadata, downloads and library operations, with version 5.0.0+147.

Remove Go sources, adapters, native build selection and CI dependencies.
Build Rust unconditionally and lock the iOS Rust pod using a relative path.
Retain legacy source and migration evidence in a local ignored archive.

Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch
equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and
diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI
build checkpoint; no new APK or iOS application build for this cleanup.

Known follow-up: URL/URLSearchParams globals are missing from the Rust JS
runtime. A controlled extension replay confirms a URL-resolution regression;
this commit does not fix that runtime gap.
2026-09-14 22:58:27 +07:00

219 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Audit an iOS application bundle for the selected native backend."""
import argparse
import hashlib
import plistlib
import re
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Sequence, Set, Tuple
MACHO_MAGICS = {
b"\xfe\xed\xfa\xce",
b"\xce\xfa\xed\xfe",
b"\xfe\xed\xfa\xcf",
b"\xcf\xfa\xed\xfe",
b"\xca\xfe\xba\xbe",
b"\xbe\xba\xfe\xca",
b"\xca\xfe\xba\xbf",
b"\xbf\xba\xfe\xca",
}
VALID_ARCHES = {"arm64", "x86_64"}
PLATFORM_NAMES = {"ios": "IOS", "ios-simulator": "IOSSIMULATOR"}
GO_SECTIONS = ("__gopclntab", "__go_buildinfo")
GO_SYMBOLS = ("_GobackendSetAppVersion", "_crosscall2")
RUST_SYMBOL = "_uniffi_spotiflac_mobile_"
class AuditError(Exception):
pass
def parse_archs(raw: str) -> Tuple[str, ...]:
archs = tuple(part.strip() for part in raw.split(","))
if not archs or any(not arch for arch in archs):
raise AuditError("--archs must be a comma-separated list")
if len(set(archs)) != len(archs):
raise AuditError("--archs contains a duplicate architecture")
unknown = sorted(set(archs) - VALID_ARCHES)
if unknown:
raise AuditError("unsupported architecture(s): " + ", ".join(unknown))
return archs
def run_xcrun(*args: str) -> str:
try:
completed = subprocess.run(
["xcrun", *args],
capture_output=True,
check=False,
errors="replace",
text=True,
timeout=30,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise AuditError("xcrun " + " ".join(args) + " failed: " + str(exc)) from exc
if completed.returncode:
detail = " ".join(completed.stderr.split())[:240]
raise AuditError(
"xcrun " + " ".join(args) + " failed" + (": " + detail if detail else "")
)
return completed.stdout
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def read_main_executable(app: Path) -> Path:
plist_path = app / "Info.plist"
if not plist_path.is_file():
raise AuditError("missing Info.plist")
try:
with plist_path.open("rb") as stream:
info = plistlib.load(stream)
except (OSError, ValueError) as exc:
raise AuditError("invalid Info.plist: " + str(exc)) from exc
name = info.get("CFBundleExecutable") if isinstance(info, dict) else None
if not isinstance(name, str) or not name or name in (".", "..") or any(
char in name for char in ("\x00", "/", "\\")
):
raise AuditError("CFBundleExecutable must be a simple filename")
executable = app / name
if executable.is_symlink() or not executable.is_file():
raise AuditError("missing main executable: " + name)
return executable
def check_main_arches(executable: Path, expected: Sequence[str], platform: str) -> None:
actual = tuple(run_xcrun("lipo", "-archs", str(executable)).split())
if set(actual) != set(expected) or len(actual) != len(expected):
raise AuditError("main executable architectures are " + ",".join(actual)
+ "; expected " + ",".join(expected))
output = run_xcrun("vtool", "-show-build", str(executable))
architecture = re.compile(r"\(architecture\s+([^)]*)\):")
platform_line = re.compile(r"^\s*platform\s+(\S+)")
observed: Dict[str, Set[str]] = {}
current = None
for line in output.splitlines():
match = architecture.search(line)
if match:
current = match.group(1).strip()
match = platform_line.match(line)
if match:
if current is None and len(expected) == 1:
current = expected[0]
if current in expected:
observed.setdefault(current, set()).add(match.group(1))
wanted = PLATFORM_NAMES[platform]
for arch in expected:
values = observed.get(arch, set())
if values != {wanted}:
found = ",".join(sorted(values)) or "missing"
raise AuditError(
f"{arch} main slice platform is {found}; expected {wanted}"
)
def is_macho(path: Path) -> bool:
try:
with path.open("rb") as stream:
return stream.read(4) in MACHO_MAGICS
except OSError as exc:
raise AuditError("cannot read bundle file " + str(path)) from exc
def defined_symbol(nm_output: str, marker: str) -> bool:
for line in nm_output.splitlines():
fields = line.split()
if (len(fields) == 3 and re.fullmatch(r"[0-9a-fA-F]+", fields[0])
and marker in fields[2] and fields[1] not in {"U", "u"}):
return True
return False
def audit(app: Path, backend: str, archs: Sequence[str], platform: str, release: bool) -> str:
if not app.is_dir():
raise AuditError(".app is not a directory: " + str(app))
executable = read_main_executable(app)
if not is_macho(executable):
raise AuditError("main executable is not a Mach-O file")
check_main_arches(executable, archs, platform)
digest = sha256(executable)
try:
paths = sorted(app.rglob("*"), key=lambda path: path.as_posix())
except OSError as exc:
raise AuditError("cannot enumerate app bundle: " + str(exc)) from exc
macho: List[Tuple[str, str, str]] = []
rust_framework_path = False
for path in paths:
relative = path.relative_to(app)
parts = relative.parts
if "Gobackend.framework" in parts and backend == "rust":
raise AuditError("Rust app contains Gobackend.framework: " + relative.as_posix())
rust_framework_path |= any(
part == "SpotiFLACBackend" or part.startswith("SpotiFLACBackend.")
or part == "SpotiFLACBackendFFI" or part.startswith("SpotiFLACBackendFFI.")
for part in parts
)
if release and path.name in {"kernel_blob.bin", "Runner.debug.dylib"}:
raise AuditError("release app contains forbidden " + path.name)
if path.is_symlink() or not path.is_file() or not is_macho(path):
continue
otool = run_xcrun("otool", "-l", str(path))
nm = run_xcrun("nm", "-gU", str(path))
macho.append((relative.as_posix(), otool, nm))
go_section = any(section in otool for _, otool, _ in macho for section in GO_SECTIONS)
go_symbol = any(
defined_symbol(nm, symbol) for _, _, nm in macho for symbol in GO_SYMBOLS
)
rust_symbol = any(RUST_SYMBOL in nm for _, _, nm in macho)
rust_defined_symbol = any(defined_symbol(nm, RUST_SYMBOL) for _, _, nm in macho)
if backend == "rust":
if not rust_defined_symbol:
raise AuditError("Rust app has no defined " + RUST_SYMBOL + " symbol")
if go_section or go_symbol:
raise AuditError("Rust app contains Go sections or defined Go symbols")
else:
if rust_symbol or rust_framework_path:
raise AuditError("Go app contains Rust symbols or framework paths")
if not (go_section or go_symbol):
raise AuditError("Go app has no Go section or defined symbol evidence")
return digest
def make_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("app", type=Path, help=".app directory to audit")
parser.add_argument("--backend", choices=("rust", "go"), required=True)
parser.add_argument("--archs", required=True, metavar="ARCH[,ARCH...]")
parser.add_argument("--platform", choices=tuple(PLATFORM_NAMES), required=True)
parser.add_argument("--release", action="store_true", help="apply release artifact checks")
return parser
def main(argv: Sequence[str] = None) -> int:
args = make_parser().parse_args(argv)
try:
archs = parse_archs(args.archs)
digest = audit(args.app, args.backend, archs, args.platform, args.release)
except (AuditError, OSError) as exc:
print("error: " + str(exc), file=sys.stderr)
return 1
print(f"OK sha256={digest} backend={args.backend} archs={','.join(archs)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())