import hashlib
import io
import json
import os
import subprocess
import sys
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from assets import (
DARWIN_ARCHES,
SIGNING_MARKERS,
TARGETS,
digest,
main,
validate,
validate_notarized,
)
TAG = "v0.4.0"
SHA = "a" * 40
TEAM = "TEAM123456"
JOB_ID = "12345678-1234-4321-8765-123456789abc"
ASSETS_SCRIPT = Path(__file__).with_name("assets.py")
class ReleaseAssetTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.folder = Path(temporary.name)
for target in TARGETS:
self.archive(target)
def archive(self, target, sha=SHA, symlink=False, duplicate=False):
name = f"mitts-{TAG}-{target}.tar.gz"
archive = self.folder / name
contents = {
"mitts": b"fixture binary",
"LICENSE-MIT": b"fixture license",
"LICENSE-APACHE": b"fixture license",
"BUILD.txt": (
f"Mitts Build {TAG}\n"
f"Target: {target}\n"
f"Source: https://github.com/alex09x/mitts/tree/{sha}\n"
).encode(),
}
with tarfile.open(archive, "w:gz") as tar:
for entry, data in contents.items():
info = tarfile.TarInfo(entry)
info.size = len(data)
if symlink and entry == "mitts":
info.type = tarfile.SYMTYPE
info.linkname = "/unexpected"
info.size = 0
tar.addfile(info, io.BytesIO(data))
if duplicate:
info = tarfile.TarInfo("mitts")
tar.addfile(info, io.BytesIO())
(self.folder / (name + ".sha256")).write_text(f"{digest(archive)} {name}\n")
return archive
def test_complete_supported_set_has_seven_verified_assets(self):
manifest = validate(self.folder, TAG, SHA)
self.assertEqual(len(manifest), 7)
self.assertEqual(len((self.folder / "SHA256SUMS").read_text().splitlines()), 3)
def test_missing_native_target_prevents_publication(self):
self.archive(TARGETS[0]).unlink()
with self.assertRaises(FileNotFoundError):
validate(self.folder, TAG, SHA)
def test_wrong_source_provenance_is_rejected(self):
self.archive(TARGETS[0], sha="b" * 40)
with self.assertRaises(ValueError):
validate(self.folder, TAG, SHA)
def test_symlink_and_duplicate_members_are_rejected(self):
for mode in ["symlink", "duplicate"]:
with self.subTest(mode=mode):
self.archive(TARGETS[0], **{mode: True})
with self.assertRaises(ValueError):
validate(self.folder, TAG, SHA)
def test_wrong_checksum_is_rejected(self):
checksum = next(self.folder.glob("*.sha256"))
checksum.write_text("0" * 64 + " " + checksum.name.removesuffix(".sha256"))
with self.assertRaises(ValueError):
validate(self.folder, TAG, SHA)
def test_existing_manifest_is_not_silently_replaced(self):
(self.folder / "SHA256SUMS").write_text("stale manifest")
with self.assertRaises(ValueError):
validate(self.folder, TAG, SHA)
class NotarizedReleaseAssetTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.folder = Path(temporary.name)
self.cdhashes = {
"aarch64-apple-darwin": "c" * 40,
"x86_64-apple-darwin": "d" * 40,
}
self.build_set()
def ticket(self, path, arch="arm64", cdhash=None):
return {
"path": path,
"digestAlgorithm": "SHA-256",
"cdhash": cdhash or self.cdhashes["aarch64-apple-darwin"],
"arch": arch,
}
def receipt_dict(
self, cdhashes=None, status="Accepted", job_id=JOB_ID, issues=None, tickets=None
):
hashes = cdhashes or self.cdhashes
return {
"logFormatVersion": 1,
"jobId": job_id,
"status": status,
"issues": issues,
"ticketContents": (
tickets
if tickets is not None
else [
self.ticket(f"notarize.zip/{t}/mitts", arch, hashes[t])
for t, arch in DARWIN_ARCHES.items()
]
),
}
def write_archive(self, target, binary=None, extra_lines=()):
name = f"mitts-{TAG}-{target}.tar.gz"
archive = self.folder / name
payload = binary if binary is not None else f"binary-{target}".encode()
base_lines = [
f"Mitts Build {TAG}",
f"Target: {target}",
f"Source: https://github.com/alex09x/mitts/tree/{SHA}",
]
metadata = "\n".join([*base_lines, *extra_lines]) + "\n"
contents = {
"mitts": payload,
"LICENSE-MIT": b"fixture license",
"LICENSE-APACHE": b"fixture license",
"BUILD.txt": metadata.encode(),
}
with tarfile.open(archive, "w:gz") as tar:
for entry, data in contents.items():
info = tarfile.TarInfo(entry)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
(self.folder / (name + ".sha256")).write_text(f"{digest(archive)} {name}\n")
(self.folder / "SHA256SUMS").unlink(missing_ok=True)
return archive
def darwin_lines(self, target, binary=None, overrides=None, receipt=None):
payload = binary if binary is not None else f"binary-{target}".encode()
values = {
"Signing-Team": TEAM,
"Signed-Binary-SHA256": hashlib.sha256(payload).hexdigest(),
"CodeDirectory-Hash": self.cdhashes[target],
"Notarization-ID": JOB_ID,
"Notarization-Status": "Accepted",
"Notarization-Receipt": json.dumps(
receipt if receipt is not None else self.receipt_dict(),
separators=(",", ":"),
),
}
if overrides:
values.update(overrides)
return [f"{key}: {val}" for key, val in values.items() if val is not None]
def build_set(self, notarized=True):
for target in TARGETS:
lines = (
self.darwin_lines(target)
if notarized and target in DARWIN_ARCHES
else ()
)
self.write_archive(target, extra_lines=lines)
def run_cli(self, *extra_args, team=TEAM):
env = {k: v for k, v in os.environ.items() if k != "APPLE_TEAM_ID"}
if team is not None:
env["APPLE_TEAM_ID"] = team
return subprocess.run(
[
sys.executable,
str(ASSETS_SCRIPT),
*extra_args,
str(self.folder),
TAG,
SHA,
],
env=env,
text=True,
capture_output=True,
check=False,
)
def assert_rejected(self, team=TEAM):
with (
patch.dict(os.environ, {"APPLE_TEAM_ID": team}),
self.assertRaises(ValueError),
):
validate_notarized(self.folder, TAG, SHA)
def test_raw_set_passes_raw_mode_and_fails_notarized_without_side_effects(self):
self.build_set(notarized=False)
self.assert_rejected()
self.assertFalse((self.folder / "SHA256SUMS").exists())
self.assertNotEqual(self.run_cli("--require-notarized").returncode, 0)
self.assertFalse((self.folder / "SHA256SUMS").exists())
raw = self.run_cli()
self.assertEqual(raw.returncode, 0, raw.stderr)
self.assertEqual(len(json.loads(raw.stdout)), 7)
def test_complete_notarized_set_passes_validators_cli_and_cdhash_variants(self):
with patch.dict(os.environ, {"APPLE_TEAM_ID": TEAM}):
manifest = validate_notarized(self.folder, "0.4.0", SHA)
self.assertEqual(len(manifest), 7)
self.assertEqual(validate(self.folder, TAG, SHA), manifest)
with io.StringIO() as buf, patch("sys.stdout", buf):
main(["--require-notarized", str(self.folder), TAG, SHA])
self.assertEqual(json.loads(buf.getvalue()), manifest)
cli = self.run_cli("--require-notarized")
self.assertEqual(cli.returncode, 0, cli.stderr)
for target in TARGETS:
with tarfile.open(self.folder / f"mitts-{TAG}-{target}.tar.gz") as tar:
self.assertEqual(
{m.name for m in tar.getmembers()},
{"mitts", "BUILD.txt", "LICENSE-MIT", "LICENSE-APACHE"},
)
body = tar.extractfile("BUILD.txt").read().decode()
if target not in DARWIN_ARCHES:
self.assertNotIn("Signing-Team", body)
cd64 = {t: h[0] * 64 for t, h in self.cdhashes.items()}
alt_receipt = {
"jobId": JOB_ID,
"status": "Accepted",
"issues": [],
"ticketContents": [
{"path": f"{t}/mitts", "cdhash": cd64[t], "arch": arch}
for t, arch in DARWIN_ARCHES.items()
],
}
for target in DARWIN_ARCHES:
lines = self.darwin_lines(
target,
overrides={"CodeDirectory-Hash": cd64[target]},
receipt=alt_receipt,
)
self.write_archive(target, extra_lines=lines)
with patch.dict(os.environ, {"APPLE_TEAM_ID": TEAM}):
self.assertEqual(len(validate_notarized(self.folder, TAG, SHA)), 7)
def test_missing_or_invalid_team_env_and_team_mismatch_are_rejected(self):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("APPLE_TEAM_ID", None)
with self.assertRaises(ValueError):
validate_notarized(self.folder, TAG, SHA)
for bad_env in ["", "team123456", "TEAM12345", "TEAM1234567", "TEAM12345-"]:
with self.subTest(env=bad_env):
self.assert_rejected(team=bad_env)
for target in DARWIN_ARCHES:
with self.subTest(target=target):
self.build_set()
lines = self.darwin_lines(
target, overrides={"Signing-Team": "OTHER12345"}
)
self.write_archive(target, extra_lines=lines)
self.assert_rejected()
def test_missing_duplicate_and_malformed_markers_are_rejected(self):
for target in DARWIN_ARCHES:
for marker in SIGNING_MARKERS:
with self.subTest(target=target, missing=marker):
self.build_set()
lines = self.darwin_lines(target, overrides={marker: None})
self.write_archive(target, extra_lines=lines)
self.assert_rejected()
with self.subTest(target=target, duplicate=marker):
self.build_set()
base = self.darwin_lines(target)
dup = next(line for line in base if line.startswith(marker + ": "))
self.write_archive(target, extra_lines=[*base, dup])
self.assert_rejected()
target = "aarch64-apple-darwin"
base = self.darwin_lines(target)
dup_json = (
f'{{"status":"Invalid","status":"Accepted","jobId":"{JOB_ID}",'
'"issues":null,"ticketContents":[]}'
)
malformed_cases = [
[s.replace("Signing-Team: ", "Signing-Team:") for s in base],
[" " + s if s.startswith("Signing-Team:") else s for s in base],
[s.replace("Signing-Team:", "signing-team:") for s in base],
[*base, "Signing-Team EXTRA"],
[s + "\r" for s in base],
self.darwin_lines(target, overrides={"Signed-Binary-SHA256": "z" * 64}),
self.darwin_lines(target, overrides={"CodeDirectory-Hash": "C" * 40}),
self.darwin_lines(target, overrides={"CodeDirectory-Hash": "c" * 48}),
self.darwin_lines(target, overrides={"Notarization-ID": "not-a-uuid"}),
self.darwin_lines(target, overrides={"Notarization-Receipt": "not-json"}),
self.darwin_lines(target, overrides={"Notarization-Receipt": "[]"}),
self.darwin_lines(target, overrides={"Notarization-Receipt": dup_json}),
]
for index, lines in enumerate(malformed_cases):
with self.subTest(malformed=index):
self.build_set()
self.write_archive(target, extra_lines=lines)
self.assert_rejected()
def test_rejected_notarization_status_job_id_and_issues_are_rejected(self):
target = "x86_64-apple-darwin"
no_issues = {k: v for k, v in self.receipt_dict().items() if k != "issues"}
cases = [
self.darwin_lines(target, overrides={"Notarization-Status": "Invalid"}),
self.darwin_lines(target, overrides={"Notarization-Status": "In Progress"}),
self.darwin_lines(target, receipt=self.receipt_dict(status="Invalid")),
self.darwin_lines(
target,
receipt=self.receipt_dict(
job_id="00000000-0000-0000-0000-000000000000"
),
),
self.darwin_lines(
target, receipt=self.receipt_dict(issues=[{"severity": "error"}])
),
self.darwin_lines(target, receipt=self.receipt_dict(issues="none")),
self.darwin_lines(target, receipt=no_issues),
]
for index, lines in enumerate(cases):
with self.subTest(case=index):
self.build_set()
self.write_archive(target, extra_lines=lines)
self.assert_rejected()
def test_binary_hash_cdhash_arch_and_ticket_path_mismatches_are_rejected(self):
target = "aarch64-apple-darwin"
tampered = self.darwin_lines(target, binary=b"signed-before-tamper")
self.write_archive(target, binary=b"different-binary", extra_lines=tampered)
self.assert_rejected()
bad_receipts = [
self.receipt_dict(cdhashes={**self.cdhashes, target: "e" * 40}),
self.receipt_dict(
tickets=[self.ticket(f"notarize.zip/{target}/mitts", "x86_64")]
),
self.receipt_dict(tickets=[]),
self.receipt_dict(tickets=[self.ticket("notarize.zip/mitts")]),
self.receipt_dict(tickets=[self.ticket(f"evil-{target}/mitts")]),
self.receipt_dict(tickets=[self.ticket(f"../{target}/mitts")]),
self.receipt_dict(tickets=[self.ticket(f"/{target}/mitts")]),
self.receipt_dict(
tickets=[
*self.receipt_dict()["ticketContents"],
self.ticket(f"second.zip/{target}/mitts"),
]
),
]
for index, bad_receipt in enumerate(bad_receipts):
with self.subTest(receipt_case=index):
self.build_set()
lines = self.darwin_lines(target, receipt=bad_receipt)
self.write_archive(target, extra_lines=lines)
self.assert_rejected()
if __name__ == "__main__":
unittest.main()