diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e21ced5b..6daec511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: - 'ios/**' - 'scripts/build_ios.sh' - 'scripts/check_backend_ios.py' + - 'scripts/test_check_backend_ios.py' - 'lib/**' - 'assets/**' - 'pubspec.yaml' @@ -178,6 +179,7 @@ jobs: - name: Build and audit Rust iOS release application run: | + python3 -m unittest discover -s scripts -p 'test_check_backend_ios.py' flutter build ios --release --no-codesign --target lib/main.dart python3 scripts/check_backend_ios.py build/ios/iphoneos/Runner.app \ --backend rust --archs arm64 --platform ios --release @@ -225,12 +227,14 @@ jobs: - name: Configure Flutter SDK for Gradle run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties - - name: Install Rust application build prerequisites + - name: Install Rust application build and media test prerequisites run: | cd rust_backend rustup show sudo apt-get update - sudo apt-get install -y libclang-dev + sudo apt-get install -y libclang-dev ffmpeg + ffmpeg -version + echo "SPOTIFLAC_TEST_FFMPEG=$(command -v ffmpeg)" >> "$GITHUB_ENV" - name: Package Rust application and run native unit tests run: gradle -p android :app:assembleDebug :app:testDebugUnitTest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbe81f55..fce62e76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -291,8 +291,10 @@ jobs: - name: Audit Rust iOS archive before packaging run: | + python3 -m unittest discover -s scripts -p 'test_check_backend_ios.py' python3 scripts/check_backend_ios.py \ ios/build/Runner.xcarchive/Products/Applications/Runner.app \ + --dsym ios/build/Runner.xcarchive/dSYMs/Runner.app.dSYM \ --backend rust --archs arm64 --platform ios --release - name: Create IPA diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFlacConversionTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFlacConversionTest.kt index f2e4e6cb..a96705fe 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFlacConversionTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFlacConversionTest.kt @@ -105,7 +105,10 @@ class NativeFlacConversionTest { @Test fun hostFlacInMp4RemuxAndFallbackPreserveDecodedPcmAndArtwork() { val ffmpeg = System.getenv("SPOTIFLAC_TEST_FFMPEG").orEmpty() - assumeTrue("Set SPOTIFLAC_TEST_FFMPEG for host media fixtures", File(ffmpeg).canExecute()) + assumeTrue( + "Set SPOTIFLAC_TEST_FFMPEG for host media fixtures", + File(ffmpeg).isFile && File(ffmpeg).canExecute(), + ) fun execute(arguments: Array): Pair { val process = ProcessBuilder(listOf(ffmpeg) + arguments).redirectErrorStream(true).start() val output = process.inputStream.bufferedReader().use { it.readText() } diff --git a/scripts/check_backend_ios.py b/scripts/check_backend_ios.py index 5ac9f4ff..cc7392b4 100644 --- a/scripts/check_backend_ios.py +++ b/scripts/check_backend_ios.py @@ -8,7 +8,7 @@ import re import subprocess import sys from pathlib import Path -from typing import Dict, List, Sequence, Set, Tuple +from typing import Dict, List, Optional, Sequence, Set, Tuple MACHO_MAGICS = { @@ -140,7 +140,26 @@ def defined_symbol(nm_output: str, marker: str) -> bool: return False -def audit(app: Path, backend: str, archs: Sequence[str], platform: str, release: bool) -> str: +def macho_uuids(path: Path) -> Dict[str, str]: + output = run_xcrun("dwarfdump", "--uuid", str(path)) + matches = re.findall(r"UUID: ([0-9A-Fa-f-]{36}) \(([^)]+)\)", output) + uuids = {arch: uuid.upper() for uuid, arch in matches} + if not uuids or len(uuids) != len(matches): + raise AuditError("missing or duplicate Mach-O UUIDs: " + str(path)) + return uuids + + +def read_dsym_symbols(executable: Path, dsym: Path) -> str: + dwarf = dsym / "Contents" / "Resources" / "DWARF" / executable.name + if dwarf.is_symlink() or not dwarf.is_file() or not is_macho(dwarf): + raise AuditError("missing Mach-O dSYM for main executable: " + str(dwarf)) + if macho_uuids(executable) != macho_uuids(dwarf): + raise AuditError("dSYM UUIDs/architectures do not match the main executable") + return run_xcrun("nm", "-gU", str(dwarf)) + + +def audit(app: Path, backend: str, archs: Sequence[str], platform: str, release: bool, + dsym: Optional[Path] = None) -> str: if not app.is_dir(): raise AuditError(".app is not a directory: " + str(app)) executable = read_main_executable(app) @@ -173,6 +192,12 @@ def audit(app: Path, backend: str, archs: Sequence[str], platform: str, release: nm = run_xcrun("nm", "-gU", str(path)) macho.append((relative.as_posix(), otool, nm)) + # Archive strips static Rust symbols from Runner after generating its dSYM. + # Only accept symbol evidence from the exact same executable (all UUIDs and + # architectures must match); continue scanning the shipped bundle for Go. + if dsym is not None: + macho.append(("main executable dSYM", "", read_dsym_symbols(executable, dsym))) + 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 @@ -199,6 +224,8 @@ def make_parser() -> argparse.ArgumentParser: 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") + parser.add_argument("--dsym", type=Path, + help="matching main executable .dSYM for a stripped archive") return parser @@ -206,7 +233,7 @@ 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) + digest = audit(args.app, args.backend, archs, args.platform, args.release, args.dsym) except (AuditError, OSError) as exc: print("error: " + str(exc), file=sys.stderr) return 1 diff --git a/scripts/test_check_backend_ios.py b/scripts/test_check_backend_ios.py new file mode 100644 index 00000000..28423355 --- /dev/null +++ b/scripts/test_check_backend_ios.py @@ -0,0 +1,102 @@ +"""Regression coverage for backend evidence in stripped iOS archives.""" + +import plistlib +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import check_backend_ios as checker + + +class BackendArchiveAuditTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name) + self.app = root / "Runner.app" + self.app.mkdir() + with (self.app / "Info.plist").open("wb") as stream: + plistlib.dump({"CFBundleExecutable": "Runner"}, stream) + self.binary = self.app / "Runner" + self.binary.write_bytes(b"\xcf\xfa\xed\xfe" + b"fixture") + self.dsym = root / "Runner.app.dSYM" + self.dwarf = self.dsym / "Contents/Resources/DWARF/Runner" + self.dwarf.parent.mkdir(parents=True) + self.dwarf.write_bytes(b"\xcf\xfa\xed\xfe" + b"symbols") + self.binary_symbols = "" + self.dsym_symbols = "0000000100010000 T _uniffi_spotiflac_mobile_fn_func_probe\n" + self.dsym_uuid = "12345678-1234-1234-1234-123456789ABC" + self.dsym_arch = "arm64" + self.sections = "" + mock = patch.object(checker, "run_xcrun", side_effect=self.xcrun) + mock.start() + self.addCleanup(mock.stop) + + def xcrun(self, tool, *args): + if tool == "lipo": + return "arm64\n" + if tool == "vtool": + return " platform IOS\n" + if tool == "otool": + return self.sections + if tool == "nm": + return self.dsym_symbols if Path(args[-1]) == self.dwarf else self.binary_symbols + if tool == "dwarfdump": + if Path(args[-1]) == self.dwarf: + return f"UUID: {self.dsym_uuid} ({self.dsym_arch}) {self.dwarf}\n" + return f"UUID: 12345678-1234-1234-1234-123456789ABC (arm64) {self.binary}\n" + self.fail("Unexpected xcrun tool: " + tool) + + def audit(self, dsym=None): + return checker.audit(self.app, "rust", ("arm64",), "ios", True, dsym) + + def test_unstripped_binary_needs_no_dsym(self): + self.binary_symbols = self.dsym_symbols + self.assertEqual(self.audit(), checker.sha256(self.binary)) + + def test_stripped_binary_requires_symbol_evidence(self): + with self.assertRaisesRegex(checker.AuditError, "no defined"): + self.audit() + + def test_matching_dsym_verifies_stripped_binary(self): + self.assertEqual(self.audit(self.dsym), checker.sha256(self.binary)) + + def test_dsym_from_another_build_is_rejected(self): + self.dsym_uuid = "ABCDEF01-1234-1234-1234-123456789ABC" + with self.assertRaisesRegex(checker.AuditError, "do not match"): + self.audit(self.dsym) + + def test_dsym_from_another_architecture_is_rejected(self): + self.dsym_arch = "x86_64" + with self.assertRaisesRegex(checker.AuditError, "do not match"): + self.audit(self.dsym) + + def test_missing_dsym_is_rejected(self): + self.dwarf.unlink() + with self.assertRaisesRegex(checker.AuditError, "missing Mach-O dSYM"): + self.audit(self.dsym) + + def test_matching_dsym_without_defined_rust_is_rejected(self): + self.dsym_symbols = " U _uniffi_spotiflac_mobile_fn_func_probe\n" + with self.assertRaisesRegex(checker.AuditError, "no defined"): + self.audit(self.dsym) + + def test_matching_dsym_does_not_hide_go_in_shipped_binary(self): + self.sections = "sectname __gopclntab\n" + with self.assertRaisesRegex(checker.AuditError, "contains Go"): + self.audit(self.dsym) + + def test_matching_dsym_with_go_symbols_is_rejected(self): + self.dsym_symbols += "0000000100020000 T _crosscall2\n" + with self.assertRaisesRegex(checker.AuditError, "contains Go"): + self.audit(self.dsym) + + def test_debug_artifacts_remain_forbidden_with_dsym(self): + (self.app / "kernel_blob.bin").write_bytes(b"fixture") + with self.assertRaisesRegex(checker.AuditError, "forbidden kernel_blob.bin"): + self.audit(self.dsym) + + +if __name__ == "__main__": + unittest.main()