mirror of
https://github.com/ZoiteChat/zoitechat.git
synced 2026-10-18 19:07:14 +00:00
rebuild macos build
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""Dependency parsing, relocation guards and release audit tests without a Mac SDK."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import bundle
|
||||
|
||||
|
||||
class BundleTests(unittest.TestCase):
|
||||
@unittest.skipUnless(sys.platform == "darwin", "requires Apple's Mach-O tools")
|
||||
def test_signed_binary_relocation_and_resigning(self):
|
||||
with tempfile.TemporaryDirectory(prefix="zoitechat relocation ") as temporary:
|
||||
directory = Path(temporary)
|
||||
library_source = directory / "answer.c"
|
||||
library_source.write_text("int answer(void) { return 42; }\n")
|
||||
library = directory / "libanswer.dylib"
|
||||
main_source = directory / "main.c"
|
||||
main_source.write_text(
|
||||
"#include <stdio.h>\n"
|
||||
"extern int answer(void);\n"
|
||||
"int main(void) { printf(\"%d\\n\", answer()); return 0; }\n"
|
||||
)
|
||||
executable = directory / "original"
|
||||
bundle.run("xcrun", "clang", "-dynamiclib", "-Wl,-headerpad_max_install_names",
|
||||
"-Wl,-install_name," + str(library), library_source, "-o", library)
|
||||
bundle.run("xcrun", "clang", "-Wl,-headerpad_max_install_names",
|
||||
"-Wl,-rpath," + str(directory), main_source, library, "-o", executable)
|
||||
for binary in (library, executable):
|
||||
bundle.run("codesign", "--force", "--sign", "-", binary)
|
||||
app = directory / "Test.app"
|
||||
target = app / "Contents/MacOS/zoitechat"
|
||||
target.parent.mkdir(parents=True)
|
||||
shutil.copy2(executable, target)
|
||||
relocator = bundle.Relocator(app, executable)
|
||||
relocator.relocate(executable, target)
|
||||
# Remove the original dependency so execution proves relocation.
|
||||
library.unlink()
|
||||
for binary in (app / "Contents/Frameworks/libanswer.dylib", target):
|
||||
bundle.run("codesign", "--force", "--sign", "-", binary)
|
||||
bundle.run("codesign", "--verify", "--strict", binary)
|
||||
self.assertEqual(bundle.run(target), "42")
|
||||
self.assertEqual(len(bundle.audit(app)["mach_o"]), 2)
|
||||
|
||||
def test_otool_preserves_paths_with_spaces(self):
|
||||
output = "app:\n\t/opt/Build Dependencies/lib/liba.dylib (compatibility version 1.0.0, current version 1.0.0)\n\t/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1.0.0)"
|
||||
with patch.object(bundle, "run", return_value=output):
|
||||
self.assertEqual(bundle.dependencies(Path("app")), ["/opt/Build Dependencies/lib/liba.dylib", "/usr/lib/libSystem.B.dylib"])
|
||||
|
||||
def test_lc_rpaths_preserve_spaces(self):
|
||||
with patch.object(bundle, "run", return_value="cmd LC_RPATH\n cmdsize 48\n path @loader_path/../Build Dependencies (offset 12)\n"):
|
||||
self.assertEqual(bundle.rpaths(Path("app")), ["@loader_path/../Build Dependencies"])
|
||||
|
||||
def test_inherited_rpath_resolution(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
library = directory / "with spaces/libtest.dylib"
|
||||
library.parent.mkdir()
|
||||
library.write_bytes(b"library")
|
||||
self.assertEqual(bundle.resolve_dependency("@rpath/libtest.dylib", directory / "libparent.dylib", directory / "app", [library.parent]), library.resolve())
|
||||
|
||||
def test_unresolved_rpath_fails(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "Cannot resolve"):
|
||||
bundle.resolve_dependency("@rpath/missing.dylib", Path("/tmp/parent"), Path("/tmp/app"), [])
|
||||
|
||||
def test_conflicting_basename_fails(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
sources = [directory / name / "libtest.dylib" for name in ("one", "two")]
|
||||
for source in sources:
|
||||
source.parent.mkdir()
|
||||
source.write_bytes(b"library")
|
||||
relocator = bundle.Relocator(directory / "App.app", directory / "executable")
|
||||
relocator.copy_dependency(sources[0])
|
||||
with self.assertRaisesRegex(RuntimeError, "Conflicting library basename"):
|
||||
relocator.copy_dependency(sources[1])
|
||||
|
||||
def test_audit_rejects_external_symlink(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
app = Path(temporary) / "App.app"
|
||||
app.mkdir()
|
||||
(app / "outside").symlink_to("/usr/lib")
|
||||
with patch.object(bundle, "run", return_value="arm64"), self.assertRaisesRegex(RuntimeError, "External symlink"):
|
||||
bundle.audit(app)
|
||||
|
||||
def test_audit_raises_minimum_for_dependencies(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
app = Path(temporary) / "App.app"
|
||||
binary = app / "Contents/MacOS/zoitechat"
|
||||
library = app / "Contents/Frameworks/libtest.dylib"
|
||||
for path in (binary, library):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(bytes.fromhex("cffaedfe") + b"payload")
|
||||
def fake_run(*args):
|
||||
if args[0] == "lipo":
|
||||
return "arm64"
|
||||
if args[1] == "-D":
|
||||
return str(args[2]) + ("\n@rpath/libtest.dylib" if args[2] == library else "")
|
||||
if args[1] == "-L":
|
||||
name = "@loader_path/../Frameworks/libtest.dylib" if args[2] == binary else "@rpath/libtest.dylib"
|
||||
return f"{args[2]}:\n\t{name} (compatibility version 1.0.0, current version 1.0.0)"
|
||||
return "cmd LC_BUILD_VERSION\n cmdsize 32\n platform 1\n minos " + ("15.0.1" if args[2] == library else "11.0")
|
||||
with patch.object(bundle, "run", side_effect=fake_run):
|
||||
report = bundle.audit(app)
|
||||
self.assertEqual(report["minimum_macos"], "15.0.1")
|
||||
self.assertEqual(report["architectures"], ["arm64"])
|
||||
self.assertEqual(len(report["mach_o"]), 2)
|
||||
|
||||
def test_audit_rejects_wrong_architecture(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
app = Path(temporary) / "App.app"
|
||||
binary = app / "Contents/MacOS/zoitechat"
|
||||
library = app / "Contents/Frameworks/libtest.dylib"
|
||||
for path in (binary, library):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(bytes.fromhex("cffaedfe"))
|
||||
with patch.object(bundle, "run", side_effect=["arm64", "x86_64"]), self.assertRaisesRegex(RuntimeError, "Architecture mismatch"):
|
||||
bundle.audit(app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Release failure gates, private-key handling and notarization result checks."""
|
||||
from contextlib import redirect_stdout
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import release
|
||||
|
||||
FINGERPRINT = "A" * 40
|
||||
IDENTITY = "Developer ID Application: Test Only (NOTAREALID)"
|
||||
SUBMISSION = "a8c9438a-b972-455e-aedc-278070349394"
|
||||
ENVIRONMENT = {
|
||||
"MACOS_CERTIFICATE_P12_BASE64": base64.b64encode(b"not-a-real-certificate").decode(),
|
||||
"MACOS_CERTIFICATE_PASSWORD": "unit-test-password",
|
||||
"MACOS_SIGNING_IDENTITY": IDENTITY,
|
||||
"APPLE_API_KEY_P8": "-----BEGIN PRIVATE KEY-----\nunit-test-only\n-----END PRIVATE KEY-----",
|
||||
"APPLE_API_KEY_ID": "TESTKEY123",
|
||||
"APPLE_API_ISSUER_ID": SUBMISSION,
|
||||
}
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def credentials(self):
|
||||
with patch.dict(os.environ, ENVIRONMENT, clear=True):
|
||||
return release.Credentials.from_environment()
|
||||
|
||||
def test_config_reports_names_and_never_values(self):
|
||||
with patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(RuntimeError, "MACOS_CERTIFICATE_P12_BASE64"):
|
||||
release.Credentials.from_environment()
|
||||
|
||||
def test_config_accepts_multiline_certificate_base64(self):
|
||||
env = ENVIRONMENT.copy()
|
||||
text = env["MACOS_CERTIFICATE_P12_BASE64"]
|
||||
env["MACOS_CERTIFICATE_P12_BASE64"] = text[:10] + "\n" + text[10:]
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertEqual(release.Credentials.from_environment().certificate, b"not-a-real-certificate")
|
||||
|
||||
def test_config_rejects_development_certificate(self):
|
||||
env = {**ENVIRONMENT, "MACOS_SIGNING_IDENTITY": "Apple Development: Test"}
|
||||
with patch.dict(os.environ, env, clear=True), self.assertRaisesRegex(RuntimeError, "Developer ID Application"):
|
||||
release.Credentials.from_environment()
|
||||
|
||||
def test_config_rejects_base64_instead_of_plain_p8(self):
|
||||
env = {**ENVIRONMENT, "APPLE_API_KEY_P8": "not-the-pem-contents"}
|
||||
with patch.dict(os.environ, env, clear=True), self.assertRaisesRegex(RuntimeError, "complete, plain-text"):
|
||||
release.Credentials.from_environment()
|
||||
|
||||
def test_execute_does_not_expose_secret_argv_or_stderr(self):
|
||||
result = subprocess.CompletedProcess([], 1, stdout="", stderr="secret-password")
|
||||
with patch.object(release.subprocess, "run", return_value=result):
|
||||
with self.assertRaises(RuntimeError) as error:
|
||||
release.execute(["security", "import", "-P", "secret-password"], "Import certificate")
|
||||
self.assertNotIn("secret-password", str(error.exception))
|
||||
|
||||
def test_matching_identity_requires_developer_id_and_private_key(self):
|
||||
output = f' 1) {FINGERPRINT} "{IDENTITY}"\n'
|
||||
self.assertEqual(release.resolve_identity(output, IDENTITY), FINGERPRINT)
|
||||
with self.assertRaisesRegex(RuntimeError, "no valid Developer ID"):
|
||||
release.resolve_identity("0 valid identities found", IDENTITY)
|
||||
|
||||
def test_private_files_have_restricted_permissions(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "private.p8"
|
||||
release.write_private(path, b"test-only")
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_keychain_and_private_files_are_cleaned_up_after_signing_failure(self):
|
||||
calls = []
|
||||
def fake_execute(args, operation, **kwargs):
|
||||
calls.append([str(value) for value in args])
|
||||
output = ''
|
||||
if args[:3] == ["security", "list-keychains", "-d"] and len(args) == 4:
|
||||
output = '"/tmp/original.keychain-db"\n'
|
||||
if args[:2] == ["security", "create-keychain"]:
|
||||
Path(args[-1]).touch()
|
||||
if args[:2] == ["security", "find-identity"]:
|
||||
output = f' 1) {FINGERPRINT} "{IDENTITY}"\n'
|
||||
return subprocess.CompletedProcess(args, 0, output, "")
|
||||
temporary_path = None
|
||||
with patch.object(release, "execute", side_effect=fake_execute), patch.object(release.urllib.request, "urlopen", side_effect=lambda *args, **kwargs: io.BytesIO(b"public-test-ca")):
|
||||
with self.assertRaisesRegex(RuntimeError, "test signing failed"):
|
||||
with release.signing_keychain(self.credentials()) as (keychain, api_key, identity):
|
||||
temporary_path = keychain.parent
|
||||
self.assertEqual(identity, FINGERPRINT)
|
||||
self.assertTrue(api_key.is_file())
|
||||
raise RuntimeError("test signing failed")
|
||||
self.assertFalse(temporary_path.exists())
|
||||
self.assertIn(["security", "list-keychains", "-d", "user", "-s", "/tmp/original.keychain-db"], calls)
|
||||
self.assertTrue(any(call[:2] == ["security", "delete-keychain"] for call in calls))
|
||||
|
||||
def run_notary(self, status, returncode=0):
|
||||
calls = []
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
directory = Path(temporary.name)
|
||||
app = directory / "ZoiteChat.app"
|
||||
app.mkdir()
|
||||
output = directory / "release.zip"
|
||||
diagnostics = directory / "diagnostics"
|
||||
def fake_execute(args, operation, **kwargs):
|
||||
calls.append([str(value) for value in args])
|
||||
text = ""
|
||||
code = 0
|
||||
if args[:3] == ["xcrun", "notarytool", "submit"]:
|
||||
text = json.dumps({"id": SUBMISSION})
|
||||
elif args[:3] == ["xcrun", "notarytool", "wait"]:
|
||||
text = json.dumps({"id": SUBMISSION, "status": status})
|
||||
code = returncode
|
||||
elif args[:3] == ["xcrun", "notarytool", "log"]:
|
||||
Path(args[-1]).write_text('{"issues": []}')
|
||||
elif args[0] == "ditto":
|
||||
Path(args[-1]).write_bytes(b"test-zip")
|
||||
return subprocess.CompletedProcess(args, code, text, "")
|
||||
with patch.object(release, "execute", side_effect=fake_execute), redirect_stdout(io.StringIO()):
|
||||
if status == "Accepted" and not returncode:
|
||||
release.notarize(app, output, diagnostics, self.credentials(), directory / "test.p8", "60m")
|
||||
else:
|
||||
with self.assertRaisesRegex(RuntimeError, "not accepted"):
|
||||
release.notarize(app, output, diagnostics, self.credentials(), directory / "test.p8", "60m")
|
||||
return calls, output, diagnostics
|
||||
|
||||
def test_accepted_notarization_staples_and_checks_before_final_zip(self):
|
||||
calls, output, diagnostics = self.run_notary("Accepted")
|
||||
self.assertTrue(output.is_file())
|
||||
staple = next(i for i, call in enumerate(calls) if call[:3] == ["xcrun", "stapler", "staple"])
|
||||
assess = next(i for i, call in enumerate(calls) if call[0] == "spctl")
|
||||
final_zip = max(i for i, call in enumerate(calls) if call[0] == "ditto")
|
||||
self.assertLess(staple, assess)
|
||||
self.assertLess(assess, final_zip)
|
||||
self.assertEqual(json.loads((diagnostics / "status.json").read_text())["status"], "Accepted")
|
||||
|
||||
def test_rejected_notarization_never_staples_or_publishes(self):
|
||||
calls, output, diagnostics = self.run_notary("Invalid")
|
||||
self.assertFalse(output.exists())
|
||||
self.assertTrue((diagnostics / "notary-log.json").is_file())
|
||||
self.assertFalse(any(call[:2] == ["xcrun", "stapler"] for call in calls))
|
||||
|
||||
def test_timeout_retains_submission_id_and_never_publishes(self):
|
||||
calls, output, diagnostics = self.run_notary("In Progress", 1)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(json.loads((diagnostics / "submission.json").read_text())["id"], SUBMISSION)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user