mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-20 11:06:46 +08:00
[fix] improve iOS privacy and App Store distribution compliance
This commit is contained in:
@@ -57,6 +57,10 @@ fi
|
||||
XMAKE_DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}"
|
||||
XMAKE_TOOLCHAIN_BIN="${XMAKE_DEVELOPER_DIR}/Toolchains/XcodeDefault.xctoolchain/usr/bin"
|
||||
run_xmake() {
|
||||
local xmake_env=()
|
||||
if [[ -n "${XMAKE_GLOBALDIR:-}" ]]; then
|
||||
xmake_env+=("XMAKE_GLOBALDIR=${XMAKE_GLOBALDIR}")
|
||||
fi
|
||||
/usr/bin/env -i \
|
||||
HOME="${HOME}" \
|
||||
PATH="${XMAKE_TOOLCHAIN_BIN}:${PATH}" \
|
||||
@@ -69,11 +73,16 @@ run_xmake() {
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" \
|
||||
XMAKE_PKG_INSTALLDIR="${XMAKE_PKG_INSTALLDIR}" \
|
||||
CROSSDESK_SOURCE_DIR="${REPO_DIR}" \
|
||||
"${xmake_env[@]}" \
|
||||
"${XMAKE_BIN}" "$@"
|
||||
}
|
||||
|
||||
OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}"
|
||||
OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a"
|
||||
XMAKE_REPOSITORY_DIR="$(run_xmake lua -c 'import("core.base.global"); print(path.join(global.directory(), "repositories", "xmake-repo"))')"
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
print -r -- "${XMAKE_REPOSITORY_DIR}" > "${OUTPUT_DIR}/xmake-repository.txt"
|
||||
run_xmake --version > "${OUTPUT_DIR}/xmake-version.txt"
|
||||
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build"
|
||||
MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a"
|
||||
WIRE_BUILD_DIR="${IOS_DIR}/.xmake/wire-build"
|
||||
@@ -106,7 +115,8 @@ configure_xmake() {
|
||||
clean_flags=(-c)
|
||||
fi
|
||||
run_xmake f -P "${project_dir}" "${clean_flags[@]}" -o "${build_dir}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" --target_minver=16.0 -y "$@"
|
||||
-p iphoneos -a arm64 -m "${MODE}" --target_minver=16.0 \
|
||||
--policies=package.precompiled:n -y "$@"
|
||||
mkdir -p "${build_dir}"
|
||||
print -r -- "${CONFIG_SIGNATURE}" > "${stamp}"
|
||||
}
|
||||
@@ -148,6 +158,8 @@ TARGET_INFO="$(cd "${MINIRTC_DIR}" && TERM=dumb NO_COLOR=1 \
|
||||
CLEAN_INFO="$(print -r -- "${TARGET_INFO}" | sed $'s/\033\\[[0-9;]*[[:alpha:]]//g')"
|
||||
LINK_DIRS=("${(@f)$(print -r -- "${CLEAN_INFO}" | sed -nE 's|.*-> (/.*)/lib -> package.*|\1/lib|p' | sort -u)}")
|
||||
|
||||
print -r -- "${CLEAN_INFO}" > "${OUTPUT_DIR}/minirtc-target-info.txt"
|
||||
|
||||
REQUIRED_LINKS=(
|
||||
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
|
||||
gupnp-igd-1.6 gupnp-1.6 gssdp-1.6 soup-3.0 nghttp2 sqlite3 psl xml2
|
||||
@@ -158,6 +170,7 @@ if [[ "${MINIRTC_ENABLE_AOM}" == "true" ]]; then
|
||||
REQUIRED_LINKS+=(aom)
|
||||
fi
|
||||
DEPENDENCY_ARCHIVES=()
|
||||
MERGE_ARCHIVES=()
|
||||
|
||||
for link_name in "${REQUIRED_LINKS[@]}"; do
|
||||
archive_path=""
|
||||
@@ -173,6 +186,11 @@ for link_name in "${REQUIRED_LINKS[@]}"; do
|
||||
exit 66
|
||||
fi
|
||||
DEPENDENCY_ARCHIVES+=("${archive_path}")
|
||||
# OpenSSL is linked separately as a static framework with its SDK privacy
|
||||
# resources. Keep both archives in the input manifest for source validation.
|
||||
if [[ "${link_name}" != "ssl" && "${link_name}" != "crypto" ]]; then
|
||||
MERGE_ARCHIVES+=("${archive_path}")
|
||||
fi
|
||||
done
|
||||
|
||||
TEMP_LIBRARY="${OUTPUT_LIBRARY}.tmp"
|
||||
@@ -204,7 +222,7 @@ if [[ -f "${OUTPUT_LIBRARY}" ]] && cmp -s "${TEMP_MANIFEST}" "${MERGE_MANIFEST}"
|
||||
fi
|
||||
|
||||
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
|
||||
"${MINIRTC_LIBRARY}" "${DEPENDENCY_ARCHIVES[@]}" \
|
||||
"${MINIRTC_LIBRARY}" "${MERGE_ARCHIVES[@]}" \
|
||||
"${WIRE_LIBRARY}"
|
||||
verify_no_openh264 "${TEMP_LIBRARY}"
|
||||
mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}"
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect iOS notices from pinned source archives; validate them offline at build time."""
|
||||
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
IOS = Path(__file__).resolve().parents[1]
|
||||
REPO = IOS.parents[1]
|
||||
LOCK = IOS / "licenses/sources.json"
|
||||
OUTPUT = IOS / "CrossDeskMobile/Resources/ThirdPartyLicenses.json"
|
||||
COMMENT = re.compile(rb"/\*.*?\*/|(?://[^\n]*(?:\n|$))+", re.S)
|
||||
RIGHTS = re.compile(rb"copyright|SPDX-License|permission is hereby|author disclaims|public domain", re.I)
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def json_bytes(value):
|
||||
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
|
||||
|
||||
|
||||
def checked(data, expected, label):
|
||||
if digest(data) != expected:
|
||||
raise ValueError(f"Source checksum mismatch: {label}")
|
||||
return data
|
||||
|
||||
|
||||
def archive_path(component, cache):
|
||||
source = component["source"]
|
||||
target = cache / source["sha256"]
|
||||
if target.exists():
|
||||
checked(target.read_bytes(), source["sha256"], component["id"])
|
||||
return target
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
# Xmake may remove extracted sources or COPYING; read the original archive.
|
||||
candidates = (Path.home() / ".xmake/cache/packages").glob(
|
||||
f"*/{component['id'][0]}/{component['id']}/{component['version']}/*")
|
||||
for candidate in candidates:
|
||||
if candidate.is_file() and candidate.name.endswith((".tar.gz", ".tar.xz", ".zip")):
|
||||
data = candidate.read_bytes()
|
||||
if digest(data) == source["sha256"]:
|
||||
target.write_bytes(data)
|
||||
return target
|
||||
request = urllib.request.Request(source["url"], headers={"User-Agent": "CrossDesk-license-collector"})
|
||||
with urllib.request.urlopen(request, timeout=90) as response:
|
||||
data = checked(response.read(), source["sha256"], component["id"])
|
||||
target.write_bytes(data)
|
||||
return target
|
||||
|
||||
|
||||
@contextmanager
|
||||
def source_files(component, cache):
|
||||
if component["source"]["kind"] == "repository":
|
||||
yield {}, lambda name: (REPO / name).read_bytes()
|
||||
return
|
||||
path = archive_path(component, cache)
|
||||
if zipfile.is_zipfile(path):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = {name.split("/", 1)[1]: name for name in archive.namelist()
|
||||
if "/" in name and not name.endswith("/")}
|
||||
yield names, lambda name: archive.read(names[name])
|
||||
else:
|
||||
with tarfile.open(path) as archive:
|
||||
names = {member.name.split("/", 1)[1]: member.name for member in archive
|
||||
if "/" in member.name and not member.isdir()}
|
||||
# No filesystem extraction. tarfile resolves COPYING symlinks within
|
||||
# the verified archive, including GLib's LICENSES directory.
|
||||
yield names, lambda name: archive.extractfile(names[name]).read()
|
||||
|
||||
|
||||
def component_documents(component, names, read):
|
||||
documents = []
|
||||
for item in component["documents"]:
|
||||
data = checked(read(item["path"]), item["sha256"], item["path"])
|
||||
text = data.decode(item.get("encoding", "utf-8"))
|
||||
if "end_before" in item:
|
||||
if item["end_before"] not in text:
|
||||
raise ValueError(f"Missing notice boundary: {item['path']}")
|
||||
text = text.split(item["end_before"], 1)[0]
|
||||
documents.append({"title": item["path"], "text": text})
|
||||
notices = defaultdict(list)
|
||||
for name in sorted(names):
|
||||
path = Path(name)
|
||||
selected = any(name.startswith(prefix) for prefix in component.get("notice_prefixes", []))
|
||||
selected |= component.get("notice_root_files", False) and "/" not in name
|
||||
if not selected or path.suffix not in (".h", ".hpp", ".c", ".cc", ".cpp", ".S", ".s", ".inc"):
|
||||
continue
|
||||
if any(part.lower() in ("test", "tests", "benchmarks", "examples") for part in path.parts):
|
||||
continue
|
||||
for block in COMMENT.findall(read(name)[:16384]):
|
||||
if RIGHTS.search(block):
|
||||
# Preserve complete original comment blocks, including bundled
|
||||
# fmt, semaphore and per-file copyright/permission exceptions.
|
||||
encoding = component.get("notice_encodings", {}).get(name, "utf-8")
|
||||
notices[block.decode(encoding)].append(name)
|
||||
if notices:
|
||||
documents.append({"title": "Source copyright and license notices", "text": "\n\n".join(
|
||||
"Files: " + ", ".join(files) + "\n\n" + text for text, files in notices.items())})
|
||||
if not documents:
|
||||
raise ValueError(f"No notices collected for {component['id']}")
|
||||
return documents
|
||||
|
||||
|
||||
def generate(lock, cache):
|
||||
components = []
|
||||
for component in lock["components"]:
|
||||
with source_files(component, cache) as (names, read):
|
||||
documents = component_documents(component, names, read)
|
||||
source = component["source"]
|
||||
source_url = source["url"]
|
||||
if source["kind"] == "repository" and source.get("revision"):
|
||||
source_url += "/tree/" + source["revision"]
|
||||
entry = {key: component[key] for key in ("id", "name", "version", "license")} | {
|
||||
"sourceURL": source_url, "documents": documents}
|
||||
recipe = component.get("recipe_path", "")
|
||||
if recipe.startswith("deps/submodules/minirtc/"):
|
||||
mini = next(c for c in lock["components"] if c["id"] == "minirtc")
|
||||
entry["buildSourceURL"] = (mini["source"]["url"] + "/tree/" +
|
||||
mini["source"]["revision"] + "/" +
|
||||
str(Path(recipe).relative_to("deps/submodules/minirtc").parent))
|
||||
components.append(entry)
|
||||
print(f"Collected {component['name']} {component['version']}")
|
||||
return json_bytes({"schemaVersion": 1, "components": components})
|
||||
|
||||
|
||||
def verify(lock, inputs, target_info, xmake_repository=None, xmake_version=None):
|
||||
checked(OUTPUT.read_bytes(), lock["bundle_sha256"], str(OUTPUT))
|
||||
if xmake_version is not None:
|
||||
version = re.search(r"xmake v(\d+\.\d+\.\d+)", xmake_version.read_text())
|
||||
if not version or version[1] != lock["toolchain"]["xmake_version"]:
|
||||
raise ValueError("Xmake version differs from the reviewed iOS toolchain; see apps/ios/README.md")
|
||||
if xmake_repository is not None:
|
||||
revision = subprocess.check_output(
|
||||
["git", "-C", str(xmake_repository), "rev-parse", "HEAD"], text=True).strip()
|
||||
if revision != lock["toolchain"]["xmake_repository_revision"]:
|
||||
raise ValueError("Xmake package repository differs from the reviewed iOS source catalog")
|
||||
changes = subprocess.check_output(
|
||||
["git", "-C", str(xmake_repository), "status", "--porcelain", "--untracked-files=normal"],
|
||||
text=True).strip()
|
||||
if changes:
|
||||
raise ValueError("Xmake package repository has local changes; use the unmodified pinned revision")
|
||||
for component in lock["components"]:
|
||||
recipe = component.get("recipe_path", "")
|
||||
if recipe.startswith("deps/"):
|
||||
checked((REPO / recipe).read_bytes(), component["recipe_sha256"], recipe)
|
||||
elif recipe.startswith("xmake-repo/") and xmake_repository is not None:
|
||||
checked((xmake_repository / Path(recipe).relative_to("xmake-repo")).read_bytes(),
|
||||
component["recipe_sha256"], recipe)
|
||||
if component["source"]["kind"] == "repository":
|
||||
for item in component["documents"]:
|
||||
checked((REPO / item["path"]).read_bytes(), item["sha256"], item["path"])
|
||||
if inputs is None:
|
||||
return
|
||||
if target_info is None:
|
||||
raise ValueError("--target-info is required with --inputs (includes header-only dependencies)")
|
||||
known = {c["package"]: c["version"] for c in lock["components"] if "package" in c}
|
||||
seen = {}
|
||||
# Package names/versions come from resolved Xmake link and include paths,
|
||||
# never from an inventory of all packages left in the installation cache.
|
||||
pattern = r"/packages/[^/\s]+/([^/\s]+)/([^/\s]+)/[a-f0-9]+/"
|
||||
for name, version in re.findall(pattern, inputs.read_text() + target_info.read_text()):
|
||||
if name not in known or known[name] != version:
|
||||
raise ValueError(f"Unreviewed iOS dependency: {name} {version}; regenerate licenses from its source")
|
||||
seen[name] = version
|
||||
if set(seen) != set(known):
|
||||
raise ValueError(f"License/build dependency mismatch; missing: {sorted(set(known) - set(seen))}")
|
||||
for line in inputs.read_text().splitlines():
|
||||
sha, path = line.split(maxsplit=1)
|
||||
checked(Path(path).read_bytes(), sha, path)
|
||||
revision = subprocess.check_output(
|
||||
["git", "-C", str(REPO / "deps/submodules/minirtc"), "rev-parse", "HEAD"], text=True).strip()
|
||||
mini = next(c for c in lock["components"] if c["id"] == "minirtc")
|
||||
if revision != mini["source"]["revision"]:
|
||||
raise ValueError("MiniRTC revision changed; review embedded notices and update sources.json")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--generate", action="store_true", help="download/check pinned sources and regenerate the bundled JSON")
|
||||
parser.add_argument("--cache", type=Path, default=Path.home() / ".cache/crossdesk/licenses")
|
||||
parser.add_argument("--inputs", type=Path, help="native archive .inputs.sha256 manifest")
|
||||
parser.add_argument("--target-info", type=Path, help="resolved Xmake minirtc target information")
|
||||
parser.add_argument("--xmake-repository", type=Path, help="verify the active pinned package repository and recipes")
|
||||
parser.add_argument("--xmake-version", type=Path, help="Xmake version output recorded by the native build")
|
||||
args = parser.parse_args()
|
||||
lock = json.loads(LOCK.read_text())
|
||||
if args.generate:
|
||||
data = generate(lock, args.cache)
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT.write_bytes(data)
|
||||
lock["bundle_sha256"] = digest(data)
|
||||
LOCK.write_bytes(json_bytes(lock))
|
||||
verify(lock, args.inputs, args.target_info, args.xmake_repository, args.xmake_version)
|
||||
print(f"Verified {len(lock['components'])} iOS license components")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
|
||||
print(f"Third-party notices: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package source-built iOS OpenSSL as a static framework with privacy resources."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
IOS = Path(__file__).resolve().parents[1]
|
||||
PRIVACY = IOS / "OpenSSL/PrivacyInfo.xcprivacy"
|
||||
# The reviewed iOS configuration removes these calls rather than declaring
|
||||
# unrelated purposes for system entropy devices and certificate directories.
|
||||
FORBIDDEN_APIS = {
|
||||
"stat", "fstat", "lstat", "fstatat", "stat64", "fstat64", "lstat64",
|
||||
"getattrlist", "fgetattrlist", "getattrlistbulk", "getdirentriesattr",
|
||||
"statfs", "fstatfs", "statvfs", "fstatvfs", "getfsstat",
|
||||
"mach_absolute_time", "mach_continuous_time", "sysctl",
|
||||
}
|
||||
|
||||
|
||||
def package(inputs, output):
|
||||
catalog = json.loads((IOS / "CrossDeskMobile/Resources/ThirdPartyLicenses.json").read_text())
|
||||
component = next(c for c in catalog["components"] if c["id"] == "openssl3")
|
||||
archives = {}
|
||||
for line in inputs.read_text().splitlines():
|
||||
sha, name = line.split(maxsplit=1)
|
||||
path = Path(name)
|
||||
if path.name in {"libssl.a", "libcrypto.a"}:
|
||||
if path.name in archives:
|
||||
raise ValueError(f"Ambiguous OpenSSL input: {path.name}")
|
||||
if hashlib.sha256(path.read_bytes()).hexdigest() != sha:
|
||||
raise ValueError(f"OpenSSL input checksum mismatch: {path}")
|
||||
archives[path.name] = (path, sha)
|
||||
if set(archives) != {"libssl.a", "libcrypto.a"}:
|
||||
raise ValueError("Both source-built OpenSSL archives must be present")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="openssl-framework-", dir=output.parent) as temp:
|
||||
binary = Path(temp) / "OpenSSL"
|
||||
subprocess.run(["/usr/bin/libtool", "-static", "-o", str(binary),
|
||||
*(str(archives[name][0]) for name in sorted(archives))], check=True)
|
||||
subprocess.run(["xcrun", "lipo", str(binary), "-verify_arch", "arm64"], check=True)
|
||||
symbols = subprocess.check_output(["xcrun", "nm", "-u", str(binary)], text=True)
|
||||
imports = {line.split()[-1].lstrip("_").split("$")[0]
|
||||
for line in symbols.splitlines() if line.strip() and not line.endswith(":")}
|
||||
unexpected = imports & FORBIDDEN_APIS
|
||||
if unexpected:
|
||||
raise ValueError(f"OpenSSL privacy configuration has unreviewed APIs: {sorted(unexpected)}")
|
||||
# armcap.c queries only CPU instruction support/brand to select crypto
|
||||
# implementations. This is not boot time, disk space or fingerprinting.
|
||||
member = ""
|
||||
for line in symbols.splitlines():
|
||||
if line.endswith(":"):
|
||||
member = line
|
||||
elif "_sysctlbyname" in line and member != "libcrypto-lib-armcap.o:":
|
||||
raise ValueError(f"Unreviewed OpenSSL sysctl caller: {member}")
|
||||
# getrandom uses Darwin's getentropy via OpenSSL's existing DSO lookup;
|
||||
# the random device fallback is excluded at compile time.
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
files = {
|
||||
"OpenSSL": binary.read_bytes(),
|
||||
"PrivacyInfo.xcprivacy": PRIVACY.read_bytes(),
|
||||
"Info.plist": plistlib.dumps({
|
||||
"CFBundleIdentifier": "cn.crossdesk.OpenSSL",
|
||||
"CFBundleName": "OpenSSL",
|
||||
"CFBundleExecutable": "OpenSSL",
|
||||
"CFBundlePackageType": "FMWK",
|
||||
"CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleVersion": component["version"],
|
||||
"CFBundleShortVersionString": component["version"],
|
||||
"CFBundleSupportedPlatforms": ["iPhoneOS"],
|
||||
"MinimumOSVersion": "16.0",
|
||||
}),
|
||||
"LICENSE.txt": "\n\n".join(d["text"] for d in component["documents"]).encode(),
|
||||
"BuildMetadata.json": (json.dumps({
|
||||
"version": component["version"],
|
||||
"sourceURL": component["sourceURL"],
|
||||
"buildSourceURL": component["buildSourceURL"],
|
||||
"archives": {name: sha for name, (_, sha) in sorted(archives.items())},
|
||||
"privacySHA256": hashlib.sha256(PRIVACY.read_bytes()).hexdigest(),
|
||||
}, indent=2) + "\n").encode(),
|
||||
}
|
||||
for name, data in files.items():
|
||||
target = output / name
|
||||
if not target.exists() or target.read_bytes() != data:
|
||||
target.write_bytes(data)
|
||||
print(f"Packaged source-built OpenSSL {component['version']}: {output}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--inputs", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
package(args.inputs, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, KeyError, StopIteration, subprocess.CalledProcessError) as error:
|
||||
print(f"OpenSSL framework: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record the source corresponding to an iOS build without changing the checkout."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
REPOSITORY_URL = "https://github.com/kunkundi/crossdesk"
|
||||
MINIRTC_URL = "https://github.com/kunkundi/minirtc"
|
||||
|
||||
|
||||
def git(*args, root=REPO):
|
||||
return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip()
|
||||
|
||||
|
||||
def source_metadata(require_release=False, tag=None):
|
||||
revision = git("rev-parse", "HEAD")
|
||||
dirty = bool(git("status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none"))
|
||||
tags = git("tag", "--points-at", "HEAD").splitlines()
|
||||
if tag:
|
||||
if tag not in tags:
|
||||
raise ValueError(f"Source tag {tag!r} does not point to the build commit")
|
||||
else:
|
||||
# 'latest' is a moving alias, never a corresponding-source identifier.
|
||||
versions = sorted(t for t in tags if t.startswith("v"))
|
||||
tag = versions[0] if versions else None
|
||||
if require_release and (dirty or not tag or not tag.startswith("v")):
|
||||
raise ValueError("Release source must be a clean checkout of a version tag, with matching submodules")
|
||||
mini_revision = git("rev-parse", "HEAD", root=REPO / "deps/submodules/minirtc")
|
||||
lock_path = REPO / "apps/ios/licenses/sources.json"
|
||||
lock = json.loads(lock_path.read_text())
|
||||
ref = quote(tag if tag and not dirty else revision, safe="")
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"repositoryURL": REPOSITORY_URL,
|
||||
"revision": revision,
|
||||
"tag": tag,
|
||||
"isModified": dirty,
|
||||
"sourceURL": f"{REPOSITORY_URL}/tree/{ref}",
|
||||
"buildInstructionsURL": f"{REPOSITORY_URL}/blob/{ref}/apps/ios/README.md",
|
||||
"privacyPolicyURL": f"{REPOSITORY_URL}/blob/{ref}/PRIVACY.md",
|
||||
"miniRTCRevision": mini_revision,
|
||||
"miniRTCSourceURL": f"{MINIRTC_URL}/tree/{mini_revision}",
|
||||
"licenseCatalogSHA256": lock["bundle_sha256"],
|
||||
"sourceCatalogSHA256": hashlib.sha256(lock_path.read_bytes()).hexdigest(),
|
||||
"toolchain": lock["toolchain"],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--tag", default=os.environ.get("CROSSDESK_SOURCE_TAG") or None)
|
||||
parser.add_argument("--require-release", action="store_true",
|
||||
default=os.environ.get("CROSSDESK_RELEASE_BUILD") == "YES")
|
||||
args = parser.parse_args()
|
||||
metadata = source_metadata(args.require_release, args.tag)
|
||||
data = (json.dumps(metadata, ensure_ascii=False, indent=2) + "\n").encode()
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not args.output.exists() or args.output.read_bytes() != data:
|
||||
args.output.write_bytes(data)
|
||||
print(f"Recorded iOS source: {metadata['revision']} (modified: {metadata['isModified']})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, subprocess.CalledProcessError) as error:
|
||||
print(f"iOS source metadata: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check release resources and known restricted imports in a built iOS app."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def verify(app, require_release=False):
|
||||
info = plistlib.loads((app / "Info.plist").read_bytes())
|
||||
binary = app / info["CFBundleExecutable"]
|
||||
subprocess.run(["xcrun", "lipo", str(binary), "-verify_arch", "arm64"], check=True)
|
||||
symbols = subprocess.check_output(["xcrun", "nm", "-u", str(binary)], text=True)
|
||||
imports = set(line.split()[-1] for line in symbols.splitlines() if line.strip())
|
||||
disk_apis = {"_statfs", "_fstatfs", "_statvfs", "_fstatvfs", "_getfsstat"}
|
||||
if imports & disk_apis:
|
||||
raise ValueError(f"Unreviewed disk-space APIs remain in iOS app: {sorted(imports & disk_apis)}")
|
||||
defined = subprocess.check_output(["xcrun", "nm", "-g", str(binary)], text=True)
|
||||
if re.search(r"\b_Wels(?:CreateDecoder|CreateSVCEncoder|GetCodecVersion)\b", defined):
|
||||
raise ValueError("OpenH264 must not be present in the iOS app")
|
||||
if re.search(r"\b_of_(?:hweight\w*|hw8table|popcount_3)\b", defined):
|
||||
raise ValueError("OpenFEC's optional CC-BY-SA-3.0 hamming-weight code requires a separate distribution review")
|
||||
privacy = plistlib.loads((app / "PrivacyInfo.xcprivacy").read_bytes())
|
||||
reasons = {entry["NSPrivacyAccessedAPIType"]: entry["NSPrivacyAccessedAPITypeReasons"]
|
||||
for entry in privacy["NSPrivacyAccessedAPITypes"]}
|
||||
required = {
|
||||
"NSPrivacyAccessedAPICategoryUserDefaults": {"CA92.1"},
|
||||
"NSPrivacyAccessedAPICategoryFileTimestamp": {"C617.1", "3B52.1"},
|
||||
"NSPrivacyAccessedAPICategorySystemBootTime": {"35F9.1", "8FFB.1"},
|
||||
}
|
||||
for category, codes in required.items():
|
||||
if set(reasons.get(category, [])) != codes:
|
||||
raise ValueError(f"Missing or changed reviewed privacy reasons: {category}")
|
||||
data = (app / "ThirdPartyLicenses.json").read_bytes()
|
||||
catalog = json.loads(data)
|
||||
ids = {c["id"] for c in catalog["components"]}
|
||||
if not {"crossdesk", "minirtc", "openfec"} <= ids or "openh264" in ids:
|
||||
raise ValueError("Unexpected iOS license catalog")
|
||||
openssl = next(c for c in catalog["components"] if c["id"] == "openssl3")
|
||||
framework = app / "Frameworks/OpenSSL.framework"
|
||||
sdk_info = plistlib.loads((framework / "Info.plist").read_bytes())
|
||||
sdk_privacy_data = (framework / "PrivacyInfo.xcprivacy").read_bytes()
|
||||
sdk_privacy = plistlib.loads(sdk_privacy_data)
|
||||
expected_privacy = {
|
||||
"NSPrivacyTracking": False,
|
||||
"NSPrivacyTrackingDomains": [],
|
||||
"NSPrivacyCollectedDataTypes": [],
|
||||
"NSPrivacyAccessedAPITypes": [],
|
||||
}
|
||||
if sdk_privacy != expected_privacy:
|
||||
raise ValueError("OpenSSL SDK privacy declaration differs from the reviewed iOS configuration")
|
||||
sdk_build = json.loads((framework / "BuildMetadata.json").read_text())
|
||||
if (sdk_info["CFBundleShortVersionString"] != openssl["version"]
|
||||
or sdk_build["version"] != openssl["version"]
|
||||
or sdk_build["sourceURL"] != openssl["sourceURL"]
|
||||
or sdk_build["buildSourceURL"] != openssl["buildSourceURL"]
|
||||
or sdk_build["privacySHA256"] != hashlib.sha256(sdk_privacy_data).hexdigest()):
|
||||
raise ValueError("OpenSSL SDK resources do not match the source/license catalog")
|
||||
if (framework / "LICENSE.txt").read_text() != "\n\n".join(d["text"] for d in openssl["documents"]):
|
||||
raise ValueError("OpenSSL SDK license resource differs from its source notices")
|
||||
embedded_binary = framework / sdk_info["CFBundleExecutable"]
|
||||
# Xcode removes the archive and may generate a small codeless-framework
|
||||
# stub. It must not ship a second static archive or dynamic OpenSSL copy.
|
||||
if embedded_binary.exists():
|
||||
if embedded_binary.read_bytes().startswith(b"!<arch>\n"):
|
||||
raise ValueError("Xcode must remove the static OpenSSL archive when embedding its resources")
|
||||
sdk_symbols = subprocess.check_output(["xcrun", "nm", "-gU", str(embedded_binary)], text=True)
|
||||
if re.search(r"\b_(?:SSL_new|OPENSSL_init_crypto)\b", sdk_symbols):
|
||||
raise ValueError("Unexpected dynamic OpenSSL implementation in resource framework")
|
||||
linked = subprocess.check_output(["xcrun", "otool", "-L", str(binary)], text=True)
|
||||
if "OpenSSL.framework" in linked:
|
||||
raise ValueError("OpenSSL must be statically linked into the application")
|
||||
source = json.loads((app / "SourceMetadata.json").read_text())
|
||||
if source["licenseCatalogSHA256"] != hashlib.sha256(data).hexdigest():
|
||||
raise ValueError("Bundled licenses differ from the recorded source metadata")
|
||||
if require_release and (source["isModified"] or not source["tag"]):
|
||||
raise ValueError("The app was not built from an unmodified version tag")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", source["revision"]):
|
||||
raise ValueError("Invalid source revision")
|
||||
print("Verified arm64 app, source/license resources, app privacy declarations and OpenSSL SDK privacy packaging")
|
||||
print("Signing, App Store validation and license authorization require separate review.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("app", type=Path)
|
||||
parser.add_argument("--require-release", action="store_true")
|
||||
args = parser.parse_args()
|
||||
verify(args.app, args.require_release)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
|
||||
print(f"iOS bundle verification: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user