From 6e40e0cfc02db875ccb8db30bbf53d564e9c4afd Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Sat, 29 Aug 2026 14:03:57 -0400 Subject: [PATCH 1/3] Validate Maps resource integrity --- .github/workflows/maps-resource-integrity.yml | 74 ++++ scripts/check-maps-consumers.sh | 393 ++++++++++++++++++ scripts/check-maps-resource-manifest.py | 338 +++++++++++++++ source/Google/Maps/Maps.targets | 5 +- 4 files changed, 806 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/maps-resource-integrity.yml create mode 100755 scripts/check-maps-consumers.sh create mode 100755 scripts/check-maps-resource-manifest.py diff --git a/.github/workflows/maps-resource-integrity.yml b/.github/workflows/maps-resource-integrity.yml new file mode 100644 index 00000000..9e2bbccf --- /dev/null +++ b/.github/workflows/maps-resource-integrity.yml @@ -0,0 +1,74 @@ +name: Maps Resource Integrity + +on: + pull_request: + paths: + - ".github/workflows/maps-resource-integrity.yml" + - "Directory.Build.props" + - "global.json" + - "scripts/check-maps-consumers.sh" + - "scripts/check-maps-resource-manifest.py" + - "source/Google/Maps/**" + push: + branches: + - main + paths: + - ".github/workflows/maps-resource-integrity.yml" + - "Directory.Build.props" + - "global.json" + - "scripts/check-maps-consumers.sh" + - "scripts/check-maps-resource-manifest.py" + - "source/Google/Maps/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + manifest: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compare Maps targets with the upstream archive + run: python3 scripts/check-maps-resource-manifest.py + + consumers: + needs: manifest + runs-on: macos-15 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Toolchain versions + run: | + xcodebuild -version + xcode-select -p + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore .NET workload + run: dotnet workload restore source/Google/Maps/Maps.csproj + + - name: Restore Maps package + run: dotnet restore source/Google/Maps/Maps.csproj + + - name: Pack Maps + run: dotnet pack source/Google/Maps/Maps.csproj --configuration Release --no-restore --output output + + - name: Direct and transitive consumer checks + run: scripts/check-maps-consumers.sh --package-dir output + + - name: Upload diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: maps-resource-integrity-diagnostics + path: artifacts/maps-resource-integrity/ + if-no-files-found: ignore diff --git a/scripts/check-maps-consumers.sh b/scripts/check-maps-consumers.sh new file mode 100755 index 00000000..c6a9af8e --- /dev/null +++ b/scripts/check-maps-consumers.sh @@ -0,0 +1,393 @@ +#!/bin/zsh +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/check-maps-consumers.sh [options] + + --package-dir Local NuGet feed (default: output) + --package-version AdamE.Google.iOS.Maps version to consume + --allow-xcode-mismatch Pass ValidateXcodeVersion=false for local diagnostics + --keep-work Retain the temporary consumer projects after a failure + +Builds direct and transitive net10.0-ios consumers of the locally packed Maps package. Verifies +the native symbol is linked and the app contains exactly one complete GoogleMaps.bundle. +EOF +} + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +source_targets="$repo_root/source/Google/Maps/Maps.targets" +package_id="AdamE.Google.iOS.Maps" +resource_bundle="GoogleMaps.bundle" +package_dir="$repo_root/output" +package_version="" +allow_xcode_mismatch="false" +keep_work="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --package-dir) package_dir="$2"; shift 2 ;; + --package-version) package_version="$2"; shift 2 ;; + --allow-xcode-mismatch) allow_xcode_mismatch="true"; shift ;; + --keep-work) keep_work="true"; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;; + esac +done + +[[ "$package_dir" != /* ]] && package_dir="$repo_root/$package_dir" + +if [[ -n "$package_version" ]]; then + nupkg="$package_dir/$package_id.$package_version.nupkg" +else + nupkgs=("$package_dir"/"$package_id".*.nupkg(N)) + if (( ${#nupkgs[@]} > 1 )); then + echo "Multiple $package_id packages found in $package_dir; pass --package-version." >&2 + printf ' %s\n' "${nupkgs[@]}" >&2 + exit 1 + fi + nupkg="${nupkgs[1]:-}" + package_version="${${nupkg:t}#$package_id.}" + package_version="${package_version%.nupkg}" +fi + +if [[ -z "${nupkg:-}" || ! -f "$nupkg" ]]; then + echo "No $package_id package found in $package_dir" >&2 + exit 1 +fi + +echo "Testing $package_id $package_version from $nupkg" + +# NuGet treats /var and /private/var as different project identities even though one is a symlink. +# Resolve mktemp's result so the ProjectReference graph in the transitive shape remains connected. +work="$(cd "$(mktemp -d)" && pwd -P)" +# Maps.targets concatenates this property while it is evaluated, before XBD normalizes it. +xbd_dir="$work/xbd/" +packages_dir="$work/packages" +artifacts_dir="$repo_root/artifacts/maps-resource-integrity" +mkdir -p "$xbd_dir" "$packages_dir" + +failures=0 +completed="false" +pass() { print -r -- " PASS $1"; } +fail() { print -r -- " FAIL $1" >&2; failures=$((failures + 1)); } + +cleanup() { + local exit_status=$? + + if [[ "$completed" != "true" ]]; then + mkdir -p "$artifacts_dir" + cp "$work"/*.diff(N) "$work"/*.log(N) "$work"/*.txt(N) "$artifacts_dir/" 2>/dev/null || true + print -r -- "Diagnostics copied to $artifacts_dir" >&2 + fi + + if [[ "$completed" != "true" && "$keep_work" == "true" ]]; then + print -r -- "Diagnostic scaffold kept at $work" >&2 + else + rm -rf "$work" + fi + + return "$exit_status" +} +trap cleanup EXIT + +echo +echo "Packaged MSBuild integration" +for folder in build buildTransitive; do + packaged_targets="$work/$folder.targets" + if unzip -p "$nupkg" "$folder/$package_id.targets" > "$packaged_targets" 2>/dev/null; then + if cmp -s "$source_targets" "$packaged_targets"; then + pass "$folder/$package_id.targets matches source" + else + fail "$folder/$package_id.targets differs from source/Google/Maps/Maps.targets" + fi + else + fail "$folder/$package_id.targets is missing from the package" + fi +done + +if (( failures > 0 )); then + echo "$failures package integration check(s) failed." >&2 + exit 1 +fi + +cat > "$work/NuGet.config" < + + + + + + + + + + + + + + + +EOF + +app_properties=' + net10.0-ios + Exe + enable + 15.0 + iossimulator-arm64 + iPhoneSimulator + false + manual' + +msbuild_args=("-p:XamarinBuildDownloadDir=$xbd_dir") +[[ "$allow_xcode_mismatch" == "true" ]] && msbuild_args+=("-p:ValidateXcodeVersion=false") + +write_app_sources() { + local directory="$1" + local distance_expression="$2" + mkdir -p "$directory" + + cat > "$directory/Main.cs" <<'EOF' +using UIKit; +UIApplication.Main(args, null, typeof(MapsConsumer.AppDelegate)); +EOF + + cat > "$directory/AppDelegate.cs" < "$directory/Info.plist" <<'EOF' + + + + + CFBundleIdentifier + com.googleapisforioscomponents.tests.mapsconsumer + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + +EOF +} + +expected_manifest="$work/expected-bundle-files.txt" + +locate_upstream_bundle() { + local matches count + matches="$(find "$xbd_dir" -type d -path '*/Maps/Resources/GoogleMapsResources/GoogleMaps.bundle' -print)" + count="$(print -r -- "$matches" | sed '/^$/d' | wc -l | tr -d ' ')" + if [[ "$count" != "1" ]]; then + print -r -- "expected one extracted upstream GoogleMaps.bundle, found $count" >&2 + return 1 + fi + print -r -- "$matches" +} + +write_file_manifest() { + local root="$1" + local destination="$2" + (cd "$root" && find . -type f -print | sed 's|^\./||' | LC_ALL=C sort) > "$destination" +} + +assert_app() { + local shape="$1" + local app_path="$2" + local upstream_bundle bundle_count app_bundle actual_manifest content_mismatches + local binary_name binary symbol_count relative_path + + if [[ -z "$app_path" || ! -d "$app_path" ]]; then + fail "$shape: app bundle was not produced" + return + fi + + if ! upstream_bundle="$(locate_upstream_bundle)"; then + fail "$shape: could not resolve the extracted upstream resource bundle" + return + fi + if [[ ! -s "$expected_manifest" ]]; then + write_file_manifest "$upstream_bundle" "$expected_manifest" + fi + + bundle_count="$(find "$app_path" -type d -name "$resource_bundle" -print | wc -l | tr -d ' ')" + if [[ "$bundle_count" != "1" ]]; then + fail "$shape: expected exactly one $resource_bundle, found $bundle_count" + return + fi + + app_bundle="$(find "$app_path" -type d -name "$resource_bundle" -print -quit)" + if [[ "$app_bundle" != "$app_path/$resource_bundle" ]]; then + fail "$shape: $resource_bundle is not at the app root ($app_bundle)" + else + pass "$shape: exactly one root $resource_bundle is present" + fi + + actual_manifest="$work/$shape-bundle-files.txt" + write_file_manifest "$app_bundle" "$actual_manifest" + if diff -u "$expected_manifest" "$actual_manifest" > "$work/$shape-bundle.diff"; then + pass "$shape: bundle file set matches upstream ($(wc -l < "$actual_manifest" | tr -d ' ') files)" + + content_mismatches="$work/$shape-content-mismatches.txt" + : > "$content_mismatches" + while IFS= read -r relative_path; do + if ! cmp -s "$upstream_bundle/$relative_path" "$app_bundle/$relative_path"; then + print -r -- "$relative_path" >> "$content_mismatches" + fi + done < "$expected_manifest" + + if [[ -s "$content_mismatches" ]]; then + fail "$shape: bundle contents differ from upstream (see $content_mismatches)" + else + pass "$shape: bundle contents are byte-for-byte identical to upstream" + fi + else + fail "$shape: bundle file set differs from upstream (see $work/$shape-bundle.diff)" + fi + + binary_name="$(/usr/bin/plutil -extract CFBundleExecutable raw -o - "$app_path/Info.plist" 2>/dev/null || true)" + [[ -z "$binary_name" ]] && binary_name="${${app_path:t}%.app}" + binary="$app_path/$binary_name" + if [[ ! -f "$binary" ]]; then + fail "$shape: app executable is missing at $binary" + return + fi + + symbol_count="$(nm -U "$binary" 2>/dev/null | awk '$NF == "_GMSGeometryDistance" { count++ } END { print count + 0 }')" + if [[ "${symbol_count:-0}" -gt 0 ]]; then + pass "$shape: GMSGeometryDistance is linked into the app" + else + fail "$shape: GMSGeometryDistance is absent from the app binary" + fi + + if find "$app_path/Frameworks" -type d -name 'GoogleMaps.framework' -print -quit 2>/dev/null | grep -c . >/dev/null; then + fail "$shape: static GoogleMaps.framework was unexpectedly copied into App.app/Frameworks" + else + pass "$shape: no dynamic GoogleMaps.framework copy is present" + fi +} + +build_and_assert() { + local shape="$1" + local project="$2" + local assembly_name="$3" + local project_directory="${project:h}" + local log="$work/$shape.log" + local restored_nupkg="$packages_dir/${package_id:l}/$package_version/${package_id:l}.$package_version.nupkg" + + if ! dotnet restore "$project" \ + --configfile "$work/NuGet.config" \ + --packages "$packages_dir" \ + "${msbuild_args[@]}" > "$log" 2>&1; then + fail "$shape: restore failed (see $log)" + tail -25 "$log" >&2 + return + fi + + if [[ ! -f "$restored_nupkg" ]]; then + fail "$shape: the restored package is missing at $restored_nupkg" + return + elif ! cmp -s "$nupkg" "$restored_nupkg"; then + fail "$shape: restore did not consume the locally packed Maps package" + return + else + pass "$shape: restore consumed the locally packed Maps package" + fi + + if ! dotnet build "$project" \ + --configuration Debug \ + --no-restore \ + "${msbuild_args[@]}" >> "$log" 2>&1; then + fail "$shape: build failed (see $log)" + tail -25 "$log" >&2 + return + fi + + assert_app "$shape" "$(find "$project_directory/bin" -type d -name "$assembly_name.app" -print -quit)" +} + +echo +echo "Shape: direct (app -> package)" +direct="$work/direct" +write_app_sources "$direct" 'Google.Maps.GeometryUtils.Distance(new CoreLocation.CLLocationCoordinate2D(0, 0), new CoreLocation.CLLocationCoordinate2D(1, 1))' +cat > "$direct/DirectApp.csproj" < + $app_properties + DirectApp + MapsConsumer + + + + + +EOF +build_and_assert "direct" "$direct/DirectApp.csproj" "DirectApp" + +echo +echo "Shape: library (app -> class library -> package)" +library_root="$work/library" +mkdir -p "$library_root/Lib" +cat > "$library_root/Lib/MapsLib.csproj" < + + net10.0-ios + Library + enable + 15.0 + false + + + + + +EOF +cat > "$library_root/Lib/Probe.cs" <<'EOF' +namespace MapsConsumer.Library; + +public static class Probe +{ + public static double Distance() => Google.Maps.GeometryUtils.Distance( + new CoreLocation.CLLocationCoordinate2D(0, 0), + new CoreLocation.CLLocationCoordinate2D(1, 1)); +} +EOF + +write_app_sources "$library_root/App" 'MapsConsumer.Library.Probe.Distance()' +cat > "$library_root/App/LibraryApp.csproj" < + $app_properties + LibraryApp + MapsConsumer + + + + + +EOF +build_and_assert "library" "$library_root/App/LibraryApp.csproj" "LibraryApp" + +echo +if (( failures > 0 )); then + echo "$failures Maps consumer check(s) failed." >&2 + exit 1 +fi + +completed="true" +echo "All Maps consumer checks passed." diff --git a/scripts/check-maps-resource-manifest.py b/scripts/check-maps-resource-manifest.py new file mode 100755 index 00000000..e993efdf --- /dev/null +++ b/scripts/check-maps-resource-manifest.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Validate Google Maps BundleResource declarations against the pinned SDK archive.""" + +from __future__ import annotations + +import argparse +from collections import Counter +import hashlib +from pathlib import Path, PurePosixPath +import shutil +import sys +import tarfile +import tempfile +import time +from urllib.error import URLError +from urllib.request import Request, urlopen +import xml.etree.ElementTree as ET + + +EXPECTED_ARCHIVE_SHA256 = ( + "81bbd92c2d627087ae222ae955e5f746590812d7389b9d800add15e4004b6431" +) +RESOURCE_PROPERTY = "_GoogleMapsResourcesBaseFolder" +RESOURCE_TOKEN = f"$({RESOURCE_PROPERTY})" +LOGICAL_ROOT = "GoogleMaps.bundle" +RESTORE_TARGET = "_GMpsDownloadedItems" +EXPECTED_APP_ITEM_CONDITION = "('$(OutputType)'!='Library' OR '$(IsAppExtension)'=='True')" + + +def repository_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--targets", + type=Path, + default=repository_root() / "source/Google/Maps/Maps.targets", + help="Maps.targets to inspect (default: repository source file)", + ) + parser.add_argument( + "--archive", + type=Path, + help="Use an existing Google Maps .tar.gz instead of downloading the declared URL", + ) + return parser.parse_args() + + +def elements(parent: ET.Element, name: str) -> list[ET.Element]: + return [element for element in parent.iter() if element.tag.rsplit("}", 1)[-1] == name] + + +def direct_children(parent: ET.Element, name: str) -> list[ET.Element]: + return [child for child in list(parent) if child.tag.rsplit("}", 1)[-1] == name] + + +def normalize_relative_path(value: str, label: str, errors: list[str]) -> str: + normalized = value.replace("\\", "/") + path = PurePosixPath(normalized) + if not normalized or normalized.startswith("/") or any(part in ("", ".", "..") for part in path.parts): + errors.append(f"{label} is not a normalized relative path: {value!r}") + return normalized + + +def one_text(parent: ET.Element, name: str, errors: list[str]) -> str: + matches = elements(parent, name) + if len(matches) != 1 or not (matches[0].text or "").strip(): + errors.append(f"expected exactly one non-empty {name}, found {len(matches)}") + return "" + return (matches[0].text or "").strip() + + +def parse_targets(targets_path: Path) -> tuple[str, str, list[str], list[str], list[str]]: + errors: list[str] = [] + try: + root = ET.parse(targets_path).getroot() + except (OSError, ET.ParseError) as exc: + raise RuntimeError(f"could not parse {targets_path}: {exc}") from exc + + downloads = elements(root, "XamarinBuildDownload") + if len(downloads) != 1: + errors.append(f"expected exactly one XamarinBuildDownload item, found {len(downloads)}") + download = downloads[0] if downloads else root + archive_url = one_text(download, "Url", errors) + archive_kind = one_text(download, "Kind", errors) + if archive_kind and archive_kind.lower() != "tgz": + errors.append(f"XamarinBuildDownload Kind is {archive_kind!r}, expected 'Tgz'") + if archive_url and not archive_url.startswith("https://"): + errors.append(f"archive URL must use HTTPS: {archive_url}") + + properties = elements(root, RESOURCE_PROPERTY) + if len(properties) != 1 or not (properties[0].text or "").strip(): + errors.append(f"expected exactly one non-empty {RESOURCE_PROPERTY}, found {len(properties)}") + archive_resource_root = "" + else: + resource_base = (properties[0].text or "").strip().replace("\\", "/") + prefix = "$(XamarinBuildDownloadDir)$(_GoogleMapsItemsFolder)/" + if not resource_base.startswith(prefix): + errors.append(f"{RESOURCE_PROPERTY} must start with {prefix!r}: {resource_base}") + archive_resource_root = "" + else: + archive_resource_root = resource_base[len(prefix) :].rstrip("/") + if not archive_resource_root.endswith(f"/{LOGICAL_ROOT}"): + errors.append( + f"{RESOURCE_PROPERTY} must resolve to {LOGICAL_ROOT}: {archive_resource_root}" + ) + + restore_targets = [ + target for target in direct_children(root, "Target") if target.get("Name") == RESTORE_TARGET + ] + if len(restore_targets) != 1: + errors.append( + f"expected exactly one project-level Target named {RESTORE_TARGET}, " + f"found {len(restore_targets)}" + ) + restore_target = restore_targets[0] if restore_targets else root + if restore_targets and restore_target.get("Condition", "").strip(): + errors.append(f"Target {RESTORE_TARGET} must not have a Condition") + + all_restore_hooks = [ + item + for item in elements(root, "XamarinBuildRestoreResources") + if item.get("Include") == RESTORE_TARGET + ] + if len(all_restore_hooks) != 1: + errors.append( + f"expected exactly one XamarinBuildRestoreResources hook for {RESTORE_TARGET}, " + f"found {len(all_restore_hooks)}" + ) + + hook_groups: list[tuple[ET.Element, ET.Element]] = [] + for item_group in direct_children(root, "ItemGroup"): + for item in direct_children(item_group, "XamarinBuildRestoreResources"): + if item.get("Include") == RESTORE_TARGET: + hook_groups.append((item_group, item)) + + if len(hook_groups) != 1: + errors.append( + f"expected one project-level ItemGroup to schedule {RESTORE_TARGET}, " + f"found {len(hook_groups)}" + ) + else: + hook_group, restore_hook = hook_groups[0] + if hook_group.get("Condition", "").strip() != EXPECTED_APP_ITEM_CONDITION: + errors.append( + f"{RESTORE_TARGET} ItemGroup Condition is {hook_group.get('Condition', '')!r}; " + f"expected {EXPECTED_APP_ITEM_CONDITION!r}" + ) + if restore_hook.get("Condition", "").strip(): + errors.append(f"XamarinBuildRestoreResources hook for {RESTORE_TARGET} must not have a Condition") + if downloads and download not in list(hook_group): + errors.append("XamarinBuildDownload and its restore hook must share the same ItemGroup") + + includes: list[str] = [] + logical_names: list[str] = [] + bundle_resources = elements(restore_target, "BundleResource") + all_bundle_resources = elements(root, "BundleResource") + if len(bundle_resources) != len(all_bundle_resources): + errors.append( + f"all BundleResource items must be declared by {RESTORE_TARGET}; " + f"found {len(all_bundle_resources) - len(bundle_resources)} elsewhere" + ) + if not bundle_resources: + errors.append(f"Target {RESTORE_TARGET} declares no BundleResource items") + + for index, resource in enumerate(bundle_resources, start=1): + include = resource.get("Include", "") + if not include.startswith(RESOURCE_TOKEN): + errors.append( + f"BundleResource #{index} Include must start with {RESOURCE_TOKEN}: {include!r}" + ) + include_suffix = include + else: + include_suffix = include[len(RESOURCE_TOKEN) :] + include_suffix = normalize_relative_path( + include_suffix, f"BundleResource #{index} Include", errors + ) + includes.append(include_suffix) + + if resource.get("Visible", "").lower() != "false": + errors.append(f"BundleResource #{index} must set Visible=\"False\"") + + logical_elements = [ + child for child in list(resource) if child.tag.rsplit("}", 1)[-1] == "LogicalName" + ] + if len(logical_elements) != 1 or not (logical_elements[0].text or "").strip(): + errors.append( + f"BundleResource #{index} must contain exactly one non-empty LogicalName" + ) + logical_name = "" + else: + logical_name = normalize_relative_path( + (logical_elements[0].text or "").strip(), + f"BundleResource #{index} LogicalName", + errors, + ) + logical_names.append(logical_name) + + expected_logical_name = f"{LOGICAL_ROOT}/{include_suffix}" + if logical_name and logical_name != expected_logical_name: + errors.append( + f"BundleResource #{index} maps {include_suffix!r} to {logical_name!r}; " + f"expected {expected_logical_name!r}" + ) + + return archive_url, archive_resource_root, includes, logical_names, errors + + +def download_archive(url: str, destination: Path) -> None: + last_error: Exception | None = None + for attempt in range(1, 4): + try: + request = Request(url, headers={"User-Agent": "GoogleApisForiOSComponents-resource-audit"}) + with urlopen(request, timeout=120) as response, destination.open("wb") as output: + shutil.copyfileobj(response, output) + return + except (OSError, URLError) as exc: + last_error = exc + destination.unlink(missing_ok=True) + if attempt < 3: + time.sleep(attempt * 2) + raise RuntimeError(f"could not download {url} after 3 attempts: {last_error}") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def archive_resource_files(archive_path: Path, resource_root: str) -> tuple[list[str], list[str]]: + errors: list[str] = [] + files: list[str] = [] + normalized_root = resource_root.strip("/") + prefix = f"{normalized_root}/" + + try: + with tarfile.open(archive_path, "r:gz") as archive: + for member in archive.getmembers(): + name = member.name + while name.startswith("./"): + name = name[2:] + if not name.startswith(prefix): + continue + relative = name[len(prefix) :] + if not relative: + continue + if member.isfile(): + files.append(normalize_relative_path(relative, "archive member", errors)) + elif not member.isdir(): + errors.append(f"archive contains a non-file resource member: {name}") + except (OSError, tarfile.TarError) as exc: + raise RuntimeError(f"could not inspect {archive_path}: {exc}") from exc + + if not files: + errors.append(f"archive contains no regular files below {normalized_root}") + return files, errors + + +def duplicate_messages(values: list[str], label: str) -> list[str]: + return [ + f"duplicate {label} ({count} occurrences): {value}" + for value, count in sorted(Counter(values).items()) + if count > 1 + ] + + +def compare_sets(actual: set[str], expected: set[str], actual_label: str) -> list[str]: + errors: list[str] = [] + for value in sorted(expected - actual): + errors.append(f"missing {actual_label}: {value}") + for value in sorted(actual - expected): + errors.append(f"stale {actual_label}: {value}") + return errors + + +def main() -> int: + args = parse_arguments() + targets_path = args.targets.resolve() + + try: + archive_url, resource_root, includes, logical_names, errors = parse_targets(targets_path) + with tempfile.TemporaryDirectory(prefix="maps-resource-manifest-") as temp_dir: + if args.archive: + archive_path = args.archive.resolve() + else: + if not archive_url: + raise RuntimeError("cannot download archive because Maps.targets has no valid URL") + archive_path = Path(temp_dir) / "GoogleMaps.tar.gz" + download_archive(archive_url, archive_path) + + actual_sha256 = sha256(archive_path) + if actual_sha256 != EXPECTED_ARCHIVE_SHA256: + errors.append( + f"archive SHA-256 is {actual_sha256}, expected {EXPECTED_ARCHIVE_SHA256}" + ) + + archive_files, archive_errors = archive_resource_files(archive_path, resource_root) + errors.extend(archive_errors) + except (OSError, RuntimeError) as exc: + print(f"Maps resource manifest check failed: {exc}", file=sys.stderr) + return 1 + + errors.extend(duplicate_messages(includes, "BundleResource Include")) + errors.extend(duplicate_messages(logical_names, "LogicalName")) + errors.extend(duplicate_messages(archive_files, "archive resource path")) + + include_set = set(includes) + archive_set = set(archive_files) + errors.extend(compare_sets(include_set, archive_set, "BundleResource Include")) + expected_logical_names = {f"{LOGICAL_ROOT}/{path}" for path in archive_set} + errors.extend( + compare_sets(set(logical_names), expected_logical_names, "LogicalName") + ) + + print(f"Targets: {targets_path}") + print(f"Archive: {archive_url}") + print(f"SHA-256: {actual_sha256}") + print( + f"Resources: {len(includes)} declarations, {len(include_set)} unique target paths, " + f"{len(archive_files)} archive files" + ) + + if errors: + print(f"Maps resource manifest check failed with {len(errors)} error(s):", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + print("Maps resource manifest check passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/source/Google/Maps/Maps.targets b/source/Google/Maps/Maps.targets index 93c58ec3..96c005e4 100644 --- a/source/Google/Maps/Maps.targets +++ b/source/Google/Maps/Maps.targets @@ -558,9 +558,6 @@ GoogleMaps.bundle\GMSCoreResources.bundle\en_IN.lproj\GMSCore.strings - - GoogleMaps.bundle\GMSCoreResources.bundle\en.lproj\GMSCore.strings - GoogleMaps.bundle\GMSCoreResources.bundle\es.lproj\GMSCore.strings @@ -683,4 +680,4 @@ - \ No newline at end of file + From a6d1b6bd63465d617ac64aa3c0e44ddef5c4b709 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Sat, 29 Aug 2026 14:08:07 -0400 Subject: [PATCH 2/3] Pin Maps consumers to repository workload set --- scripts/check-maps-consumers.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/check-maps-consumers.sh b/scripts/check-maps-consumers.sh index c6a9af8e..3bb9c383 100755 --- a/scripts/check-maps-consumers.sh +++ b/scripts/check-maps-consumers.sh @@ -66,6 +66,9 @@ xbd_dir="$work/xbd/" packages_dir="$work/packages" artifacts_dir="$repo_root/artifacts/maps-resource-integrity" mkdir -p "$xbd_dir" "$packages_dir" +# Keep the generated projects on the same pinned SDK/workload set as the repository. The +# .NET SDK resolves global.json from the project tree, not from this script's working directory. +cp "$repo_root/global.json" "$work/global.json" failures=0 completed="false" From 0f3ef56fc7c946c13f75cc956062de1fbf752c6f Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Sat, 29 Aug 2026 14:10:59 -0400 Subject: [PATCH 3/3] Allow pinned iOS workload on macOS runner --- .github/workflows/maps-resource-integrity.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maps-resource-integrity.yml b/.github/workflows/maps-resource-integrity.yml index 9e2bbccf..6621cabc 100644 --- a/.github/workflows/maps-resource-integrity.yml +++ b/.github/workflows/maps-resource-integrity.yml @@ -63,7 +63,7 @@ jobs: run: dotnet pack source/Google/Maps/Maps.csproj --configuration Release --no-restore --output output - name: Direct and transitive consumer checks - run: scripts/check-maps-consumers.sh --package-dir output + run: scripts/check-maps-consumers.sh --package-dir output --allow-xcode-mismatch - name: Upload diagnostics if: failure()