#!/usr/bin/env python3 import argparse import datetime as dt import os import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import plistlib @dataclass class ProfileInfo: path: Path uuid: str name: str team_id: str expiration: Optional[dt.datetime] app_id: str platforms: List[str] def run(cmd: List[str]) -> Tuple[int, bytes, bytes]: p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = p.communicate() return p.returncode, out, err def decode_profile_plist_bytes(profile_path: Path) -> Optional[bytes]: # Provisioning profiles are typically CMS/PKCS#7 SignedData blobs whose payload is a plist. # However, some tools store them as plain XML plists already. Also, LibreSSL/OpenSSL behavior # differs: LibreSSL usually requires an explicit '-verify' to emit the embedded content. data = profile_path.read_bytes() # Fast path: already a plist (XML). if b" Dict[str, Any]: return plistlib.loads(plist_bytes) def iso(dt_obj: Optional[dt.datetime]) -> str: if not dt_obj: return "(unknown)" # Force UTC-ish display if tz-aware, otherwise as-is. try: return dt_obj.isoformat().replace("+00:00", "Z") except Exception: return str(dt_obj) def safe_str(v: Any) -> str: if v is None: return "" if isinstance(v, bytes): try: return v.decode("utf-8", errors="replace") except Exception: return repr(v) return str(v) def profile_info_from_plist(path: Path, p: Dict[str, Any]) -> ProfileInfo: uuid = safe_str(p.get("UUID", "")) or "(unknown)" name = safe_str(p.get("Name", "")) or "(unknown)" team_ids = p.get("TeamIdentifier") or [] team_id = safe_str(team_ids[0]) if isinstance(team_ids, list) and team_ids else "" if not team_id: prefixes = p.get("ApplicationIdentifierPrefix") or [] team_id = safe_str(prefixes[0]) if isinstance(prefixes, list) and prefixes else "(unknown)" exp = p.get("ExpirationDate") expiration = exp if isinstance(exp, dt.datetime) else None ent = p.get("Entitlements") or {} app_id = safe_str(ent.get("application-identifier") or ent.get("com.apple.application-identifier") or "") or "(unknown)" platforms = p.get("Platform") or [] if isinstance(platforms, str): platforms = [platforms] platforms = [safe_str(x) for x in platforms if x is not None] return ProfileInfo(path=path, uuid=uuid, name=name, team_id=team_id or "(unknown)", expiration=expiration, app_id=app_id, platforms=platforms) def score(profile: ProfileInfo, bundle_id: str, now: dt.datetime) -> Tuple[int, str]: # Prefer non-expired. if profile.expiration and profile.expiration < now: return (-1, "expired") score = 0 reason = [] # Prefer exact app id match TEAMID.bundle_id if profile.team_id != "(unknown)" and profile.app_id != "(unknown)": exact = f"{profile.team_id}.{bundle_id}" if profile.app_id == exact: score += 100 reason.append(f"exact {profile.app_id}") elif profile.app_id.endswith(f".{bundle_id}"): score += 60 reason.append(f"endswith {profile.app_id}") elif "*" in profile.app_id and profile.app_id.startswith(f"{profile.team_id}."): score += 40 reason.append(f"wildcard {profile.app_id}") # Heuristic: name suggests MAS. n = profile.name.lower() if "mac app store" in n or "app store" in n or "appstore" in n: score += 5 reason.append("name looks like MAS") # Prefer macOS platform if present. plats = [p.lower() for p in profile.platforms] if any("macos" in p for p in plats): score += 2 reason.append("platform macos") return (score, ", ".join(reason) if reason else "") def find_profiles() -> List[Path]: dirs = [ Path.home() / "Library" / "Developer" / "Xcode" / "UserData" / "Provisioning Profiles", Path.home() / "Library" / "MobileDevice" / "Provisioning Profiles", ] out: List[Path] = [] for d in dirs: if not d.is_dir(): continue for p in d.iterdir(): if p.is_file() and (p.name.endswith(".provisionprofile") or p.name.endswith(".mobileprovision")): out.append(p) return sorted(out) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--bundle-id", required=True) args = ap.parse_args() bundle_id = args.bundle_id if not Path("/usr/bin/openssl").exists(): print("Error: /usr/bin/openssl not found.", file=sys.stderr) return 1 candidates = find_profiles() if not candidates: print("No provisioning profiles found in common locations.", file=sys.stderr) return 1 print(f"Scanning {len(candidates)} provisioning profile(s) for bundle id: {bundle_id}") print() print(f"{'No.':<4} {'UUID':<36} {'TeamID':<10} {'Expires':<25} {'AppID':<45} Path") print(f"{'-'*4} {'-'*36} {'-'*10} {'-'*25} {'-'*45} ----") infos: List[ProfileInfo] = [] for i, p in enumerate(candidates, start=1): plist_bytes = decode_profile_plist_bytes(p) if not plist_bytes: continue try: pl = parse_plist(plist_bytes) info = profile_info_from_plist(p, pl) infos.append(info) print(f"{i:<4} {info.uuid:<36} {info.team_id:<10} {iso(info.expiration):<25} {info.app_id:<45} {info.path}") except Exception: continue if not infos: print("\nCould not decode any provisioning profiles with openssl cms.", file=sys.stderr) return 2 now = dt.datetime.now(dt.timezone.utc) best: Optional[Tuple[int, str, ProfileInfo]] = None for info in infos: sc, why = score(info, bundle_id, now) if best is None or sc > best[0]: best = (sc, why, info) print() if best is None or best[0] <= 0: print(f"Could not confidently auto-select a profile for {bundle_id}.", file=sys.stderr) print("Pick the profile whose AppID is TEAMID. and is a macOS Mac App Store profile.", file=sys.stderr) return 2 _, why, info = best print("Recommended profile:") print(f" {info.path}") print(f" reason: {why}") return 0 if __name__ == "__main__": raise SystemExit(main())