[fix] decouple iOS license checks from minirtc revisions

This commit is contained in:
dijunkun
2026-09-28 14:16:47 +08:00
parent 51d392ef48
commit 3992ea56db
7 changed files with 232 additions and 23 deletions
+5
View File
@@ -422,6 +422,11 @@ jobs:
with: with:
python-version: "3.13" python-version: "3.13"
- name: Verify iOS license catalog
run: |
python3 -B -m unittest discover -s apps/ios/tests -p 'third_party_licenses_test.py' -v
python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Install xmake - name: Install xmake
uses: xmake-io/github-action-setup-xmake@v1 uses: xmake-io/github-action-setup-xmake@v1
with: with:
@@ -231,7 +231,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh; shellPath = /bin/zsh;
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\"\n"; shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\"\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\"\n";
}; };
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
@@ -20,7 +20,19 @@ private struct LicensedComponent: Decodable, Identifiable {
if id == "crossdesk" { if id == "crossdesk" {
return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
} }
return version.count == 40 ? String(version.prefix(12)) : version let currentVersion = SourceMetadata.bundled?.componentSources[id]?.version ?? version
return currentVersion.count == 40 ? String(currentVersion.prefix(12)) : currentVersion
}
var resolvedSourceURL: URL {
if id == "crossdesk" {
return SourceMetadata.bundled?.sourceURL ?? sourceURL
}
return SourceMetadata.bundled?.componentSources[id]?.sourceURL ?? sourceURL
}
var resolvedBuildSourceURL: URL? {
SourceMetadata.bundled?.componentSources[id]?.buildSourceURL ?? buildSourceURL
} }
} }
@@ -40,6 +52,12 @@ private struct LicenseCatalog: Decodable {
} }
} }
struct ComponentSourceMetadata: Decodable {
let version: String?
let sourceURL: URL?
let buildSourceURL: URL?
}
struct SourceMetadata: Decodable { struct SourceMetadata: Decodable {
let revision: String let revision: String
let tag: String? let tag: String?
@@ -47,6 +65,7 @@ struct SourceMetadata: Decodable {
let sourceURL: URL let sourceURL: URL
let buildInstructionsURL: URL let buildInstructionsURL: URL
let privacyPolicyURL: URL let privacyPolicyURL: URL
let componentSources: [String: ComponentSourceMetadata]
static let bundled: SourceMetadata? = { static let bundled: SourceMetadata? = {
guard let url = Bundle.main.url(forResource: "SourceMetadata", withExtension: "json"), guard let url = Bundle.main.url(forResource: "SourceMetadata", withExtension: "json"),
@@ -137,10 +156,8 @@ private struct ComponentLicenseView: View {
Text(component.name).font(.headline) Text(component.name).font(.headline)
Text(component.displayVersion).textSelection(.enabled) Text(component.displayVersion).textSelection(.enabled)
Text(component.license).foregroundStyle(.secondary) Text(component.license).foregroundStyle(.secondary)
Link("查看源码", destination: component.id == "crossdesk" Link("查看源码", destination: component.resolvedSourceURL)
? SourceMetadata.bundled?.sourceURL ?? component.sourceURL if let buildSourceURL = component.resolvedBuildSourceURL {
: component.sourceURL)
if let buildSourceURL = component.buildSourceURL {
Link("构建配方与项目补丁", destination: buildSourceURL) Link("构建配方与项目补丁", destination: buildSourceURL)
} }
} }
+21 -12
View File
@@ -98,8 +98,8 @@ top of the notices page, with its INRIA copyright, CeCILL-C terms and limited
warranty/liability notice. Its embedded BSD and CC-BY-SA notices are preserved warranty/liability notice. Its embedded BSD and CC-BY-SA notices are preserved
in the source-notices document as well. in the source-notices document as well.
`licenses/sources.json` pins the reviewed source archives (SHA-256), Git `licenses/sources.json` pins the reviewed source archives (SHA-256), notice-review
revisions, selected legal files and source-notice extraction rules. Git revisions, selected legal files and source-notice extraction rules.
`CrossDeskMobile/Resources/ThirdPartyLicenses.json` is the generated resource `CrossDeskMobile/Resources/ThirdPartyLicenses.json` is the generated resource
bundled by Xcode. The catalog covers 34 components for the default iOS build, bundled by Xcode. The catalog covers 34 components for the default iOS build,
including header-only dependencies, GLib's proxy-libintl, spdlog's bundled fmt, including header-only dependencies, GLib's proxy-libintl, spdlog's bundled fmt,
@@ -122,13 +122,16 @@ collected from the reviewed runtime source directories; the catalog is an
attribution superset, not a claim that every source file survives linker attribution superset, not a claim that every source file survives linker
dead stripping. dead stripping.
Normal Xcode builds verify the checked-in resource offline, compare all resolved Normal Xcode builds verify the checked-in resource, local license files and
dependency versions (including header-only packages) with the catalog, check package recipes offline before compiling native code. After compilation they
the merged archive's input hashes and local package recipes, and check the also compare all resolved dependency versions (including header-only packages)
MiniRTC revision. A missing, stale or altered catalog fails the build. When with the catalog and check the merged archive's input hashes and pinned
dependencies or MiniRTC change, review the corresponding source notices and toolchain. A missing, stale or altered catalog fails the build. Ordinary MiniRTC
update the lock before regenerating; regeneration alone does not select a new code changes do not require changing the notice-review revision or regenerating
dependency version. `MINIRTC_ENABLE_AOM=true` also requires a reviewed AOM entry. the catalog. Changes to dependencies, license texts or embedded third-party
notices still require reviewing and updating the affected catalog entries;
regeneration alone does not select a new dependency version.
`MINIRTC_ENABLE_AOM=true` also requires a reviewed AOM entry.
The source screen links to the exact application tag/commit, MiniRTC commit, The source screen links to the exact application tag/commit, MiniRTC commit,
and custom dependency recipes/patches. `SourceMetadata.json` is generated in and custom dependency recipes/patches. `SourceMetadata.json` is generated in
@@ -136,7 +139,12 @@ the app bundle, not in the checkout. Development builds with local edits are
explicitly identified as modified. Version-tag CI builds reject an unclean explicitly identified as modified. Version-tag CI builds reject an unclean
checkout or a mismatched tag. The release archive also carries checkout or a mismatched tag. The release archive also carries
`ThirdPartySources.json`, `SourceMetadata.json` and the open-source rights `ThirdPartySources.json`, `SourceMetadata.json` and the open-source rights
notice; release notes provide the corresponding source and build links. notice. `ThirdPartySources.json` preserves the notice-review catalog;
`SourceMetadata.json` records the actual build commit and per-component version,
source and recipe links used by the license screen, including MiniRTC, inih and
WebRTC-derived code. These links update automatically on every build without
rewriting the checked-in license resource. Release notes provide the
corresponding source and build links.
Keep the source tags, submodule commits and referenced dependency source Keep the source tags, submodule commits and referenced dependency source
archives available for recipients of that release. archives available for recipients of that release.
@@ -144,8 +152,9 @@ To change a library, edit MiniRTC in its submodule, or modify the relevant
package recipe/patch under `deps/submodules/minirtc/thirdparty`. If a package package recipe/patch under `deps/submodules/minirtc/thirdparty`. If a package
recipe changes, change its package configuration revision (or rebuild that recipe changes, change its package configuration revision (or rebuild that
package) so Xmake does not reuse a previous binary. Review affected notices, package) so Xmake does not reuse a previous binary. Review affected notices,
update recipe hashes, commit MiniRTC, update its revision in `sources.json`, update recipe hashes, and commit MiniRTC. When the notice catalog changes,
then regenerate the catalog. Build the application again to relink it with record the reviewed revision in `sources.json` and regenerate the catalog.
Build the application again to relink it with
the modified library. Use your own team and bundle identifier to install the the modified library. Use your own team and bundle identifier to install the
modified build; the maintainer's signing credentials are not required. modified build; the maintainer's signing credentials are not required.
@@ -178,11 +178,9 @@ def verify(lock, inputs, target_info, xmake_repository=None, xmake_version=None)
for line in inputs.read_text().splitlines(): for line in inputs.read_text().splitlines():
sha, path = line.split(maxsplit=1) sha, path = line.split(maxsplit=1)
checked(Path(path).read_bytes(), sha, path) checked(Path(path).read_bytes(), sha, path)
revision = subprocess.check_output( # Repository revisions identify the reviewed notices, not a required build
["git", "-C", str(REPO / "deps/submodules/minirtc"), "rev-parse", "HEAD"], text=True).strip() # commit. SourceMetadata records the actual MiniRTC revision and links for
mini = next(c for c in lock["components"] if c["id"] == "minirtc") # this build; the content and dependency checks above detect stale notices.
if revision != mini["source"]["revision"]:
raise ValueError("MiniRTC revision changed; review embedded notices and update sources.json")
def main(): def main():
@@ -19,6 +19,24 @@ def git(*args, root=REPO):
return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip()
def component_sources(lock, mini_revision):
"""Resolve MiniRTC source and recipe links without rewriting reviewed notices."""
mini_url = f"{MINIRTC_URL}/tree/{mini_revision}"
sources = {}
for component in lock["components"]:
source = component["source"]
entry = {}
if source["kind"] == "repository" and source["url"] == MINIRTC_URL:
entry.update(version=mini_revision, sourceURL=mini_url)
recipe = component.get("recipe_path", "")
if recipe.startswith("deps/submodules/minirtc/"):
directory = Path(recipe).relative_to("deps/submodules/minirtc").parent
entry["buildSourceURL"] = f"{mini_url}/{directory.as_posix()}"
if entry:
sources[component["id"]] = entry
return sources
def source_metadata(require_release=False, tag=None): def source_metadata(require_release=False, tag=None):
revision = git("rev-parse", "HEAD") revision = git("rev-parse", "HEAD")
dirty = bool(git("status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none")) dirty = bool(git("status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none"))
@@ -47,6 +65,7 @@ def source_metadata(require_release=False, tag=None):
"privacyPolicyURL": f"{REPOSITORY_URL}/blob/{ref}/PRIVACY.md", "privacyPolicyURL": f"{REPOSITORY_URL}/blob/{ref}/PRIVACY.md",
"miniRTCRevision": mini_revision, "miniRTCRevision": mini_revision,
"miniRTCSourceURL": f"{MINIRTC_URL}/tree/{mini_revision}", "miniRTCSourceURL": f"{MINIRTC_URL}/tree/{mini_revision}",
"componentSources": component_sources(lock, mini_revision),
"licenseCatalogSHA256": lock["bundle_sha256"], "licenseCatalogSHA256": lock["bundle_sha256"],
"sourceCatalogSHA256": hashlib.sha256(lock_path.read_bytes()).hexdigest(), "sourceCatalogSHA256": hashlib.sha256(lock_path.read_bytes()).hexdigest(),
"toolchain": lock["toolchain"], "toolchain": lock["toolchain"],
+161
View File
@@ -0,0 +1,161 @@
"""Regression coverage for notice validation and build-specific source links."""
import copy
import importlib.util
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
def load_script(name):
path = Path(__file__).resolve().parents[1] / "scripts" / f"{name}.py"
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
licenses = load_script("generate_third_party_licenses")
metadata = load_script("prepare_source_metadata")
class LicenseValidationTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="crossdesk-ios-licenses-")
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.mini = self.root / "deps/submodules/minirtc"
self.mini.mkdir(parents=True)
self.notice = self.mini / "LICENSE"
self.notice.write_text("Reviewed license text\n")
self.recipe = self.mini / "thirdparty/opus/xmake.lua"
self.recipe.parent.mkdir(parents=True)
self.recipe.write_text('package("opus")\n')
self.source = self.mini / "transport.cpp"
self.source.write_text("int transport() { return 1; }\n")
self.git("init", "-q")
self.git("add", ".")
self.git("commit", "-qm", "Reviewed source")
self.reviewed_revision = self.git("rev-parse", "HEAD")
self.bundle = self.root / "ThirdPartyLicenses.json"
self.bundle.write_text('{"schemaVersion": 1, "components": []}\n')
self.archive = self.root / "packages/o/opus/1.0/abcd/lib/libopus.a"
self.archive.parent.mkdir(parents=True)
self.archive.write_bytes(b"reviewed archive")
self.inputs = self.root / "inputs.sha256"
self.inputs.write_text(f"{licenses.digest(self.archive.read_bytes())} {self.archive}\n")
self.target = self.root / "target-info.txt"
self.target.write_text(str(self.root / "packages/a/asio/1.0/abcd/include") + "\n")
self.lock = {
"bundle_sha256": licenses.digest(self.bundle.read_bytes()),
"toolchain": {"xmake_version": "3.1.1"},
"components": [
{
"id": "minirtc", "version": self.reviewed_revision,
"source": {"kind": "repository", "url": metadata.MINIRTC_URL,
"revision": self.reviewed_revision},
"documents": [{"path": str(self.notice.relative_to(self.root)),
"sha256": licenses.digest(self.notice.read_bytes())}],
},
{
"id": "opus", "package": "opus", "version": "1.0",
"source": {"kind": "archive", "url": "https://example.com/opus.tar.gz"},
"recipe_path": str(self.recipe.relative_to(self.root)),
"recipe_sha256": licenses.digest(self.recipe.read_bytes()),
},
{"id": "asio", "package": "asio", "version": "1.0",
"source": {"kind": "archive", "url": "https://example.com/asio.tar.gz"}},
],
}
for name, value in (("REPO", self.root), ("OUTPUT", self.bundle)):
patcher = patch.object(licenses, name, value)
patcher.start()
self.addCleanup(patcher.stop)
def git(self, *args):
return subprocess.check_output(
["git", "-C", str(self.mini), "-c", "user.name=Test",
"-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", *args],
text=True).strip()
def verify(self, preflight=False, **kwargs):
licenses.verify(self.lock, None if preflight else self.inputs,
None if preflight else self.target, **kwargs)
def test_source_only_commit_keeps_reviewed_catalog_and_uses_actual_links(self):
self.source.write_text("int transport() { return 2; }\n")
self.git("add", ".")
self.git("commit", "-qm", "Change transport implementation")
current = self.git("rev-parse", "HEAD")
self.assertNotEqual(current, self.reviewed_revision)
original_lock = copy.deepcopy(self.lock)
original_bundle = self.bundle.read_bytes()
self.verify(preflight=True)
self.verify()
sources = metadata.component_sources(self.lock, current)
self.assertEqual(sources["minirtc"]["version"], current)
self.assertEqual(sources["minirtc"]["sourceURL"], f"{metadata.MINIRTC_URL}/tree/{current}")
self.assertEqual(sources["opus"]["buildSourceURL"],
f"{metadata.MINIRTC_URL}/tree/{current}/thirdparty/opus")
self.assertEqual(self.lock, original_lock)
self.assertEqual(self.bundle.read_bytes(), original_bundle)
def test_changed_license_is_rejected_before_compilation(self):
self.notice.write_text("Changed license text\n")
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*LICENSE"):
self.verify(preflight=True)
def test_changed_recipe_is_rejected_before_compilation(self):
self.recipe.write_text('package("different-opus")\n')
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*xmake.lua"):
self.verify(preflight=True)
def test_changed_bundle_is_rejected_before_compilation(self):
self.bundle.write_text("{}\n")
with self.assertRaisesRegex(ValueError, "Source checksum mismatch:"):
self.verify(preflight=True)
def test_unreviewed_header_only_dependency_is_rejected(self):
self.target.write_text(str(self.root / "packages/a/asio/2.0/abcd/include") + "\n")
with self.assertRaisesRegex(ValueError, "Unreviewed iOS dependency: asio 2.0"):
self.verify()
def test_missing_dependency_is_rejected(self):
self.target.write_text("")
with self.assertRaisesRegex(ValueError, "License/build dependency mismatch; missing:.*asio"):
self.verify()
def test_changed_native_archive_is_rejected(self):
self.archive.write_bytes(b"changed archive")
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*libopus.a"):
self.verify()
def test_unreviewed_toolchain_is_rejected(self):
version = self.root / "xmake-version.txt"
version.write_text("xmake v3.1.2\n")
with self.assertRaisesRegex(ValueError, "Xmake version differs"):
self.verify(xmake_version=version)
class ComponentSourceTest(unittest.TestCase):
def test_all_minirtc_components_and_custom_recipes_follow_build_revision(self):
lock_path = Path(__file__).resolve().parents[1] / "licenses/sources.json"
lock = json.loads(lock_path.read_text())
reviewed = copy.deepcopy(lock)
revision = "a" * 40
base = f"{metadata.MINIRTC_URL}/tree/{revision}"
sources = metadata.component_sources(lock, revision)
for name in ("minirtc", "inih", "webrtc"):
self.assertEqual(sources[name], {"version": revision, "sourceURL": base})
self.assertEqual(sources["openssl3"], {"buildSourceURL": base + "/thirdparty/openssl"})
self.assertNotIn("asio", sources)
self.assertNotIn("crossdesk", sources)
self.assertEqual(lock, reviewed)
if __name__ == "__main__":
unittest.main()