diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..d6986d4 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,124 @@ +name: auto-release + +# Merging a release note is the release. +# +# A pull request that adds docs/release-notes/.md (2.34.1.4.md) +# is stating that the merge it belongs to is a release: the note is hand-written, one per +# version, and nobody writes one by accident. +# So merging it tags the merge commit and starts the ordinary release run. Nothing else about the +# release path changes — the tag is a real tag at a real commit, release.yml resolves the version +# from it exactly as it does for a hand-pushed tag, and the guard job there still proves the +# commit is on the default branch before anything is published. +# +# Triggered on the push to main rather than on `pull_request: closed`, for two reasons: a push +# to the default branch carries a full-permission token whatever the pull request's origin was +# (a fork pull request's token is read-only and could not push the tag), and it sees the merge +# identically whether it arrived as a merge commit, a squash or a rebase. + +on: + push: + branches: ['main'] + paths: + - 'docs/release-notes/**' + +concurrency: + group: auto-release-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + # contents: write pushes the tag. actions: write dispatches release.yml, and that dispatch is + # not a stylistic choice: a tag pushed with GITHUB_TOKEN deliberately does not trigger + # `on: push: tags`, so release.yml would sit there and never start. workflow_dispatch is + # documented as an exception which always creates a run, which is why release.yml carries a + # workflow_dispatch trigger alongside its tag trigger. + contents: write + actions: write + +jobs: + release: + name: tag and release the notes added here + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The whole history, so the diff below can reach the previous commit and so + # `git rev-parse refs/tags/...` can see tags that already exist. + fetch-depth: 0 + + - name: Tag every release note this push added, and start its release + env: + GH_TOKEN: ${{ github.token }} + BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + + # A brand-new branch reports an all-zero "before" and there is nothing to diff against. + case "${BEFORE}" in + 0000000000000000000000000000000000000000|'') + echo "no previous commit to diff against; nothing to do" + exit 0 + ;; + esac + + # --diff-filter=A: added, not modified. Editing an existing note is a correction to a + # release that already happened, and must not tag anything. + added="$(git diff --name-status --diff-filter=A "${BEFORE}" "${GITHUB_SHA}" \ + -- 'docs/release-notes/*.md' | cut -f2)" + + if [ -z "${added}" ]; then + echo "no release notes added in this push; nothing to do" + exit 0 + fi + + # An annotated tag needs a tagger, and a runner has no git identity configured — without + # this, `git tag -a` fails with "Committer identity unknown". + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + count=0 + while IFS= read -r file; do + [ -n "${file}" ] || continue + base="$(basename "${file}" .md)" + + # README.md documents the folder in several of these repositories. + if [ "${base}" = 'README' ]; then + continue + fi + + tag="v${base}" + + # Four-part versions only. release.yml's own version job also accepts three parts, + # but every release tag this repository has ever carried is four-part, and the + # three-part notes that exist are series overviews rather than releases — tagging one + # of those would publish something nobody asked for. A genuine three-part release can + # still be tagged by hand; only this automatic path is strict. + if ! printf '%s' "${tag}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::warning::${file} does not name a release this repository publishes (would be '${tag}'); skipping" + continue + fi + + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "${tag} already exists; skipping" + continue + fi + + echo "==> tagging ${GITHUB_SHA} as ${tag} for ${file}" + git tag -a "${tag}" "${GITHUB_SHA}" -m "Release ${tag}" + git push origin "${tag}" + + # Dispatched at the tag's own ref, so github.ref_name inside release.yml is the tag + # and its version/track resolution, release-notes lookup and changelog range all + # behave exactly as they do for a hand-pushed tag. + gh workflow run release.yml --ref "${tag}" + echo "==> dispatched release.yml at ${tag}" + + { + echo "- \`${tag}\` tagged from ${file} and released" + } >> "$GITHUB_STEP_SUMMARY" + count=$((count + 1)) + done <<< "${added}" + + if [ "${count}" -eq 0 ]; then + echo "nothing tagged" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 79fde47..ffd1af2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,16 @@ name: build on: workflow_call: inputs: + verify: + description: > + Whether to run the verification jobs — package validation, the sample builds, the + Release link checks and the e2e suites. Pull requests leave it at true, and are the only + place any of this runs. Releases pass false: the tagged commit was already verified on + its pull request, so a tag packs and publishes and nothing more. The gate is the same + input, with the same name and the same meaning, in every repository. + required: false + default: true + type: boolean version: description: NuGet version to stamp on every package. required: true @@ -107,12 +117,14 @@ jobs: run: ./build/BuildNugets.sh "${{ inputs.version }}" "${{ steps.native.outputs.version }}" - name: Validate packages + if: ${{ inputs.verify }} run: dotnet test tests/DatadogNet.iOS.PackageTests --logger 'trx;LogFileName=package-tests.trx' # After the merge, on the finished artifact - the per-pass intermediates legitimately lack # the other band's frameworks, so validating them against the published baseline reports # every band-to-band difference as a break. See the script header. - name: Validate package API against the published baseline + if: ${{ inputs.verify }} run: ./build/ValidatePackageApi.sh - name: Upload packages @@ -126,7 +138,7 @@ jobs: retention-days: 7 - name: Upload test results - if: always() + if: ${{ inputs.verify && (always()) }} uses: actions/upload-artifact@v4 with: name: package-test-results @@ -136,6 +148,7 @@ jobs: sample: name: build sample app + if: ${{ inputs.verify }} timeout-minutes: 30 needs: pack runs-on: macos-15 @@ -172,8 +185,105 @@ jobs: -p:RuntimeIdentifier=iossimulator-arm64 \ -p:DatadogPackageVersion="${{ inputs.version }}" + # The Debug sample above restores, resolves and links, but never runs the linker or the AOT + # compiler — and those are what a consumer actually ships. DatadogNet.Mac has carried a Release + # leg for exactly this reason, with the note that "the ILLink/AOT behaviour Release turns on has + # broken binding consumers that built fine in Debug"; this is the same check for iOS. + # + # Affordable here in a way it is not everywhere: the Datadog xcframeworks are small, unlike the + # WebRTC-sized payloads in AntMedia.Net and Red5Pro.Streaming.Net, whose build files record a + # measured 38-minute iOS Release build and are deliberately left in Debug. + link-release: + name: Release link check (device, ${{ matrix.target-framework }}) + if: ${{ inputs.verify }} + timeout-minutes: 45 + needs: pack + runs-on: macos-15 + strategy: + # Both extremes, like the e2e matrix: net9 comes out of the same pack pass as net8, and + # net10 is the band new consumers start on. Run per band because a device link failure can + # be band-specific - and because the regression this job exists for (dd-sdk-ios device + # slices missing _OBJC_CLASS_$_ exports) is invisible to every simulator job. + fail-fast: false + matrix: + target-framework: [net9.0-ios18.0, net10.0-ios26.0] + include: + - target-framework: net9.0-ios18.0 + sdk-band: net9 + - target-framework: net10.0-ios26.0 + sdk-band: net10 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + uses: ./.github/actions/select-xcode + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 9.0.x + 10.0.x + + - name: Install MAUI workload + run: | + # The example is built for the band under test, so the workload must be installed for + # the SDK that owns it. global.json pins .NET 9; the net10 band needs .NET 10, which is + # steered the same way BuildNugets.sh does it - a scratch directory with its own + # global.json, since the SDK is resolved from the working directory. + if [ "${{ matrix.sdk-band }}" = "net10" ]; then + mkdir -p "${RUNNER_TEMP}/sdk10" + ( cd "${RUNNER_TEMP}/sdk10" \ + && dotnet new globaljson --sdk-version "$(dotnet --list-sdks | grep '^10\.' | tail -1 | cut -d' ' -f1)" --force \ + && dotnet workload install maui-ios ) + else + dotnet workload install maui-ios + fi + + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: artifacts + + # Code signing off, because a public runner has neither a certificate nor a provisioning + # profile, and neither affects whether the thing links. That leaves the .app itself as the + # assertion: AOT and the native link both run before the bundle is assembled, so a failure + # in either never gets far enough to produce one. + - name: Link the sample for a real device + run: | + tfm="${{ matrix.target-framework }}" + build_dir="$(pwd)" + if [ "${{ matrix.sdk-band }}" = "net10" ]; then + build_dir="${RUNNER_TEMP}/sdk10" + fi + ( cd "${build_dir}" && dotnet build "${GITHUB_WORKSPACE}/samples/DatadogNet.iOS.Example/DatadogNetExample.csproj" \ + --configuration Release \ + --framework "${tfm}" \ + -p:DatadogSdkBand="${{ matrix.sdk-band }}" \ + -p:RuntimeIdentifier=ios-arm64 \ + -p:EnableCodeSigning=false \ + -p:DatadogPackageVersion="${{ inputs.version }}" ) + + app="$(find "samples/DatadogNet.iOS.Example/bin/Release/${tfm}/ios-arm64" -maxdepth 1 -name '*.app' | head -1)" + if [ -z "${app}" ]; then + echo "::error::no .app under samples/DatadogNet.iOS.Example/bin/Release/${tfm}/ios-arm64 — the device build did not produce a bundle" + exit 1 + fi + + # Reported rather than asserted on a count: how many assemblies survive the linker is + # the linker's business and moves with every SDK update. Zero of them would mean the + # build produced a bundle without AOT compiling anything, which is worth failing on. + images="$(find "${app}" -name '*.aotdata.arm64' | wc -l | tr -d ' ')" + echo "==> ${app}: ${images} AOT images" + if [ "${images}" -eq 0 ]; then + echo "::error::no AOT images in ${app} — the device build did not AOT compile" + exit 1 + fi + e2e: name: simulator smoke test (${{ matrix.target-framework }}) + if: ${{ inputs.verify }} timeout-minutes: 45 needs: pack runs-on: macos-15 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdb94c5..b6a8b50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,11 @@ name: release on: + # Dispatched as well as pushed to: auto-release.yml creates the tag with GITHUB_TOKEN when a + # release note is merged, and a tag pushed with that token deliberately does not trigger + # `on: push: tags`. workflow_dispatch is documented as an exception that always creates a run. + # Dispatched at the tag's ref, so github.ref_name below is the tag either way. + workflow_dispatch: push: tags: ['v*'] @@ -12,6 +17,37 @@ permissions: contents: read jobs: + # The release path packs and publishes without re-running validate/sample/e2e, on the grounds + # that the tagged commit already went through them on its pull request. That reasoning only + # holds if the commit is genuinely on the default branch — a tag cut from an unmerged branch, + # or from a commit force-pushed away since, would ship having been verified by nothing. Two + # cheap ubuntu minutes to make the assumption explicit rather than implicit. + guard: + name: verify the tag is on the default branch + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Refuse a tag that never went through a pull request + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + # A tag push checks out the tag, and the default branch's ref is not necessarily among + # the refs fetched with it, so ask for it by name before testing ancestry. + git fetch --no-tags --quiet origin \ + "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + + if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/${DEFAULT_BRANCH}"; then + echo "::error::${GITHUB_REF_NAME} points at ${GITHUB_SHA}, which is not an ancestor of ${DEFAULT_BRANCH}. Releases skip the test suites because the tagged commit was verified on its pull request; this commit was not. Merge it first, then re-tag." + exit 1 + fi + + echo "${GITHUB_REF_NAME} -> ${GITHUB_SHA} is on ${DEFAULT_BRANCH}" >> "$GITHUB_STEP_SUMMARY" + version: name: resolve release version runs-on: ubuntu-latest @@ -78,11 +114,14 @@ jobs: build: name: build - needs: version + needs: [guard, version] uses: ./.github/workflows/build.yml with: version: ${{ needs.version.outputs.version }} native-version: ${{ needs.version.outputs.native-version }} + # Verification already happened on this commit's pull request, and the guard job above + # proved the tag points at that commit. A release packs and publishes, nothing more. + verify: false publish: name: publish to nuget.org and create release diff --git a/Directory.Build.props b/Directory.Build.props index 0032cf7..63a56ac 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,7 +16,7 @@ impossible to tell which Datadog release a given CrashReporter package belonged to. --> 3.14.0 - 4 + 5 $(DatadogNativeVersion).$(DatadogBindingRevision) - net8.0-ios18.0;net9.0-ios18.0 + net9 + net8.0-ios18.0;net9.0-ios18.0 + net10.0-ios26.0 Exe DatadogNetExample diff --git a/src/Datadog.Binding.props b/src/Datadog.Binding.props index 1aaf755..3e722aa 100644 --- a/src/Datadog.Binding.props +++ b/src/Datadog.Binding.props @@ -132,6 +132,27 @@ + + + $(MSBuildThisFileDirectory)../build/device-class-aliases/$(DatadogFramework).aliases + $([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText('$(DatadogDeviceClassAliasesFile)')), '\s+', ' ').Trim()) + + + + + Static + True + False diff --git a/tests/DatadogNet.iOS.PackageTests/PackageLayoutTests.cs b/tests/DatadogNet.iOS.PackageTests/PackageLayoutTests.cs index 1282eb8..3369217 100644 --- a/tests/DatadogNet.iOS.PackageTests/PackageLayoutTests.cs +++ b/tests/DatadogNet.iOS.PackageTests/PackageLayoutTests.cs @@ -105,10 +105,15 @@ public void Native_payload_carries_exactly_its_own_xcframework(string name) .Distinct() .ToList(); - // One package, one framework. A package that shipped two would mean the shared libs/ - // directory leaked into a NativeReference glob, and consumers would end up with the same - // framework embedded twice from two packages - a duplicate-symbol link failure. - Assert.Equal([$"{framework}.xcframework"], present); + // One package, one framework - plus, where the device slice is missing class symbols, + // this repository's own generated Realize.xcframework companion (see + // build/device-class-aliases/README.md). A package shipping a *different* Datadog + // framework would mean the shared libs/ directory leaked into a NativeReference glob, + // and consumers would end up with the same framework embedded twice from two packages - + // a duplicate-symbol link failure. + Assert.Contains($"{framework}.xcframework", present); + Assert.All(present, entry => Assert.Contains( + entry, new[] { $"{framework}.xcframework", $"{framework}Realize.xcframework" })); } [Theory] diff --git a/tests/DatadogNet.iOS.PackageTests/SymbolAuditTests.cs b/tests/DatadogNet.iOS.PackageTests/SymbolAuditTests.cs new file mode 100644 index 0000000..e140a2e --- /dev/null +++ b/tests/DatadogNet.iOS.PackageTests/SymbolAuditTests.cs @@ -0,0 +1,394 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Xml.Linq; + +namespace DatadogNet.iOS.PackageTests; + +/// +/// Asserts, at the Mach-O level, that a consuming app can link the classes these packages bind - +/// for a real device, not just the simulator. +/// +/// +/// dd-sdk-ios builds its prebuilt device slices with a 12.0 deployment target, below which Swift +/// withholds the static _OBJC_CLASS_$_<Name> registration for classes whose metadata +/// needs runtime fix-ups. The simulator slices (built at 14.0) export those symbols, so the gap +/// is invisible to every simulator build and surfaces only as "Undefined symbols for architecture +/// arm64" in a consumer's device link - which is exactly how 3.14.0 shipped broken. +/// build/GenerateDeviceClassAliases.sh repairs each missing name with a linker alias to the +/// class's exported Swift metadata symbol, and Datadog.Binding.props ships the flags inside each +/// package. These tests hold the three parts together: the bound API, the shipped binaries, and +/// the shipped alias flags. +/// +public class SymbolAuditTests +{ + /// + /// The payload is identical across target frameworks (asserted by + /// ), + /// so the audit runs against one of them. + /// + private const string PayloadTargetFramework = "net8.0-ios18.0"; + + private const string ClassSymbolPrefix = "_OBJC_CLASS_$_"; + + [Theory] + [MemberData(nameof(Packages.BindingNames), MemberType = typeof(Packages))] + public void Every_class_a_simulator_link_resolves_also_resolves_for_a_device_link(string name) + { + var spec = Packages.Spec(name); + var framework = spec.Framework!; // BindingNames excludes the meta-package + + using var package = Packages.OpenPackage(name); + using var payload = Packages.OpenNativePayload(package, name, PayloadTargetFramework); + + var device = ExportedSymbols(payload, framework, simulator: false); + var simulator = ExportedSymbols(payload, framework, simulator: true); + var aliases = ShippedAliases(payload); + + // The classes the binding registers are the classes a consumer's static registrar may + // reference; which of them it actually reaches depends on the app, so all of them must + // resolve. "Resolvable on the simulator" is the baseline rather than "bound", because a + // handful of bound classes have never been exported by either slice in any upstream + // release - a pre-existing upstream condition this repository cannot repair with aliases, + // and one no consumer can have a working dependency on. + var unreachable = RegisteredClasses(package, name) + .Where(cls => simulator.Contains(ClassSymbolPrefix + cls)) + .Where(cls => !device.Contains(ClassSymbolPrefix + cls)) + .Where(cls => !aliases.ContainsKey(ClassSymbolPrefix + cls)) + .OrderBy(cls => cls, StringComparer.Ordinal) + .ToList(); + + Assert.True( + unreachable.Count == 0, + $"{Packages.PackageId(name)} binds classes a device link cannot resolve: " + + $"{string.Join(", ", unreachable)}. The upstream device slice does not export them " + + "and no alias covers them - run ./build/GenerateDeviceClassAliases.sh and repack."); + } + + [Theory] + [MemberData(nameof(Packages.BindingNames), MemberType = typeof(Packages))] + public void Shipped_aliases_match_the_shipped_device_slice(string name) + { + var spec = Packages.Spec(name); + var framework = spec.Framework!; // BindingNames excludes the meta-package + + using var package = Packages.OpenPackage(name); + using var payload = Packages.OpenNativePayload(package, name, PayloadTargetFramework); + + var device = ExportedSymbols(payload, framework, simulator: false); + + foreach (var (objcName, swiftMetadata) in ShippedAliases(payload)) + { + // An alias whose target is gone means the Swift mangled names moved - a new native + // version was fetched without regenerating - and every consumer's device link would + // fail on the target instead of the class. + Assert.True( + device.Contains(swiftMetadata), + $"{Packages.PackageId(name)} aliases {objcName} to {swiftMetadata}, which the " + + "device slice does not export. The aliases are stale for these binaries - " + + "run ./build/GenerateDeviceClassAliases.sh and repack."); + + // An alias for a symbol the device slice now exports itself means upstream fixed its + // release build; the alias would shadow the real export. Regenerating removes it. + Assert.False( + device.Contains(objcName), + $"{Packages.PackageId(name)} aliases {objcName}, but the device slice now exports " + + "it directly - run ./build/GenerateDeviceClassAliases.sh and repack."); + } + } + + [Theory] + [MemberData(nameof(Packages.BindingNames), MemberType = typeof(Packages))] + public void Packages_with_aliases_ship_the_realization_library(string name) + { + var spec = Packages.Spec(name); + var framework = spec.Framework!; // BindingNames excludes the meta-package + + using var package = Packages.OpenPackage(name); + using var payload = Packages.OpenNativePayload(package, name, PayloadTargetFramework); + + var aliases = ShippedAliases(payload); + var archives = payload.Entries + .Where(entry => entry.FullName.StartsWith($"{framework}Realize.xcframework/", StringComparison.Ordinal)) + .Where(entry => entry.FullName.EndsWith(".a", StringComparison.Ordinal)) + .ToList(); + + if (aliases.Count == 0) + { + Assert.True( + archives.Count == 0, + $"{Packages.PackageId(name)} ships {framework}Realize.xcframework but no aliases - " + + "a stale companion; run ./build/GenerateDeviceClassAliases.sh and repack."); + return; + } + + // Aliases alone make the classes link; the metadata they point at starts out unrealized, + // and the static registrar messages every mapped class from main() - so each package with + // aliases must also carry the dyld-initializer archive that realizes them before main + // (a cold message to unrealized Swift class metadata is a segfault, measured on + // hardware). Its device slice must call exactly the metadata accessors of the aliased + // classes, or startup either crashes or realizes the wrong set. + var device = archives.SingleOrDefault(entry => + !entry.FullName.Contains("simulator", StringComparison.Ordinal)); + Assert.True( + device is not null, + $"{Packages.PackageId(name)} ships aliases but no device slice in " + + $"{framework}Realize.xcframework - the aliased classes would link and then crash at " + + "startup. Run ./build/GenerateDeviceClassAliases.sh and repack."); + + var expected = aliases.Values + .Select(symbol => symbol[..^1] + "Ma") // _$s…CN -> _$s…CMa + .OrderBy(symbol => symbol, StringComparer.Ordinal) + .ToList(); + + Assert.Equal(expected, UndefinedSymbols(device!)); + } + + /// + /// The Objective-C class names the binding assembly registers - the names a consuming app's + /// static registrar can emit hard _OBJC_CLASS_$_ references for. + /// + /// + /// Read from the compiled assembly's [Register] attributes rather than from + /// ApiDefinitions.cs, so Name = overrides and generator behaviour are the truth being + /// audited. [Model] and [Protocol] types are skipped: their managed classes are + /// registrar-provided skeletons, not references into the native binary. + /// + private static List RegisteredClasses(ZipArchive package, string name) + { + using var stream = Packages.ReadEntry( + package, $"lib/{PayloadTargetFramework}/{Packages.AssemblyName(name)}.dll"); + var buffer = new MemoryStream(); + stream.CopyTo(buffer); + buffer.Position = 0; + + using var pe = new PEReader(buffer); + var metadata = pe.GetMetadataReader(); + + var classes = new List(); + foreach (var handle in metadata.TypeDefinitions) + { + var type = metadata.GetTypeDefinition(handle); + if ((type.Attributes & TypeAttributes.Interface) != 0) + { + continue; + } + + string? registered = null; + var skip = false; + + foreach (var attributeHandle in type.GetCustomAttributes()) + { + var attribute = metadata.GetCustomAttribute(attributeHandle); + switch (AttributeName(metadata, attribute)) + { + case "Foundation.ModelAttribute": + case "Foundation.ProtocolAttribute": + skip = true; + break; + + case "Foundation.RegisterAttribute": + var value = attribute.DecodeValue(AttributeTypeProvider.Instance); + if (value.FixedArguments.Length > 0 && + value.FixedArguments[0].Value is string objcName) + { + registered = objcName; + } + + if (value.NamedArguments.Any(argument => + argument.Name == "SkipRegistration" && argument.Value is true)) + { + skip = true; + } + + break; + } + } + + if (registered is not null && !skip) + { + classes.Add(registered); + } + } + + return classes; + } + + private static string? AttributeName(MetadataReader metadata, CustomAttribute attribute) + { + switch (attribute.Constructor.Kind) + { + case HandleKind.MemberReference: + var member = metadata.GetMemberReference((MemberReferenceHandle)attribute.Constructor); + if (member.Parent.Kind != HandleKind.TypeReference) + { + return null; + } + + var reference = metadata.GetTypeReference((TypeReferenceHandle)member.Parent); + return $"{metadata.GetString(reference.Namespace)}.{metadata.GetString(reference.Name)}"; + + case HandleKind.MethodDefinition: + var method = metadata.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor); + var declaring = metadata.GetTypeDefinition(method.GetDeclaringType()); + return $"{metadata.GetString(declaring.Namespace)}.{metadata.GetString(declaring.Name)}"; + + default: + return null; + } + } + + /// + /// The defined external symbols of a payload slice's arm64 binary, via nm. The + /// packages are produced on macOS with Xcode, so the audit running there too is not a new + /// requirement. + /// + private static HashSet ExportedSymbols(ZipArchive payload, string framework, bool simulator) + { + var slices = payload.Entries + .Select(entry => entry.FullName.Split('/')) + .Where(parts => parts.Length > 2 && parts[0] == $"{framework}.xcframework") + .Select(parts => parts[1]) + .Where(Packages.IsIosSlice) + .Distinct() + .ToList(); + + var slice = slices.SingleOrDefault(s => Packages.IsSimulatorSlice(s) == simulator); + Assert.True(slice is not null, $"{framework}.xcframework has no {(simulator ? "simulator" : "device")} slice."); + + var binary = payload.GetEntry($"{framework}.xcframework/{slice}/{framework}.framework/{framework}"); + Assert.True(binary is not null, $"{framework}.xcframework/{slice} has no framework binary."); + + var extracted = Path.Combine(Path.GetTempPath(), $"symbol-audit-{Guid.NewGuid():N}"); + try + { + using (var source = binary!.Open()) + using (var destination = File.Create(extracted)) + { + source.CopyTo(destination); + } + + var nm = Process.Start(new ProcessStartInfo + { + FileName = "xcrun", + ArgumentList = { "nm", "-arch", "arm64", "-gU", extracted }, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + + var output = nm.StandardOutput.ReadToEnd(); + var errors = nm.StandardError.ReadToEnd(); + nm.WaitForExit(); + Assert.True(nm.ExitCode == 0, $"nm failed on {framework}/{slice}: {errors}"); + + var symbols = new HashSet(StringComparer.Ordinal); + foreach (var line in output.Split('\n')) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 3) + { + symbols.Add(parts[2]); + } + } + + return symbols; + } + finally + { + File.Delete(extracted); + } + } + + /// The undefined symbols of an archive entry's arm64 slice, via nm -u. + private static List UndefinedSymbols(ZipArchiveEntry archive) + { + var extracted = Path.Combine(Path.GetTempPath(), $"symbol-audit-{Guid.NewGuid():N}.a"); + try + { + using (var source = archive.Open()) + using (var destination = File.Create(extracted)) + { + source.CopyTo(destination); + } + + var nm = Process.Start(new ProcessStartInfo + { + FileName = "xcrun", + ArgumentList = { "nm", "-arch", "arm64", "-u", extracted }, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + + var output = nm.StandardOutput.ReadToEnd(); + var errors = nm.StandardError.ReadToEnd(); + nm.WaitForExit(); + Assert.True(nm.ExitCode == 0, $"nm failed on {archive.FullName}: {errors}"); + + return output.Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.EndsWith(':')) + .OrderBy(symbol => symbol, StringComparer.Ordinal) + .ToList(); + } + finally + { + File.Delete(extracted); + } + } + + /// + /// The -Wl,-alias,<swift metadata>,<objc class> pairs the package actually + /// ships, read from the binding manifest inside the payload - the same place a consuming + /// app's build reads them from. + /// + private static Dictionary ShippedAliases(ZipArchive payload) + { + var aliases = new Dictionary(StringComparer.Ordinal); + + var manifest = payload.GetEntry("manifest"); + if (manifest is null) + { + return aliases; + } + + using var stream = manifest.Open(); + var document = XDocument.Load(stream); + + var flags = document.Descendants("LinkerFlags").Select(element => element.Value); + foreach (var token in flags.SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries))) + { + var parts = token.Split(','); + if (parts is ["-Wl", "-alias", var swiftMetadata, var objcName]) + { + aliases[objcName] = swiftMetadata; + } + } + + return aliases; + } + + private sealed class AttributeTypeProvider : ICustomAttributeTypeProvider + { + public static readonly AttributeTypeProvider Instance = new(); + + public string GetPrimitiveType(PrimitiveTypeCode typeCode) => typeCode.ToString(); + + public string GetSystemType() => "System.Type"; + + public string GetSZArrayType(string elementType) => elementType + "[]"; + + public string GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => + reader.GetString(reader.GetTypeDefinition(handle).Name); + + public string GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) => + reader.GetString(reader.GetTypeReference(handle).Name); + + public string GetTypeFromSerializedName(string name) => name; + + public PrimitiveTypeCode GetUnderlyingEnumType(string type) => PrimitiveTypeCode.Int32; + + public bool IsSystemType(string type) => type == "System.Type"; + } +}