diff --git a/.github/actions/apple-release/Assert-TrackedAppleReleaseInputs.ps1 b/.github/actions/apple-release/Assert-TrackedAppleReleaseInputs.ps1 index f507b5b3b..9f5a6e7ef 100644 --- a/.github/actions/apple-release/Assert-TrackedAppleReleaseInputs.ps1 +++ b/.github/actions/apple-release/Assert-TrackedAppleReleaseInputs.ps1 @@ -54,7 +54,7 @@ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sourceRoot)) { $sourceRoot = [IO.Path]::GetFullPath($sourceRoot) if (-not [string]::IsNullOrWhiteSpace($SourceCommit)) { - if ($SourceCommit -notmatch '^[0-9A-Fa-f]{40}$') { throw 'source-commit must be an exact 40-character commit SHA.' } + if ($SourceCommit -notmatch '^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$') { throw 'source-commit must be a full SHA-1 or SHA-256 Git commit object id.' } $actualCommit = (& $GitPath -C $sourceRoot rev-parse HEAD).Trim() if ($LASTEXITCODE -ne 0 -or -not $actualCommit.Equals($SourceCommit, [StringComparison]::OrdinalIgnoreCase)) { throw "Checked-out source '$actualCommit' does not match source-commit '$SourceCommit'." diff --git a/.github/actions/apple-release/Invoke-PowerForgeAppleRelease.ps1 b/.github/actions/apple-release/Invoke-PowerForgeAppleRelease.ps1 index 46028ae66..641845432 100644 --- a/.github/actions/apple-release/Invoke-PowerForgeAppleRelease.ps1 +++ b/.github/actions/apple-release/Invoke-PowerForgeAppleRelease.ps1 @@ -386,8 +386,8 @@ if (-not [string]::IsNullOrWhiteSpace($env:INPUT_MARKETING_VERSION)) { $arguments += @('--apple-version', $env:INPUT_MARKETING_VERSION) } if (-not [string]::IsNullOrWhiteSpace($env:INPUT_SOURCE_COMMIT)) { - if ($env:INPUT_SOURCE_COMMIT -notmatch '^[0-9A-Fa-f]{40}$') { - throw 'source-commit must be an exact 40-character commit SHA.' + if ($env:INPUT_SOURCE_COMMIT -notmatch '^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$') { + throw 'source-commit must be a full SHA-1 or SHA-256 Git commit object id.' } $arguments += @('--apple-source-commit', $env:INPUT_SOURCE_COMMIT) } diff --git a/Docs/PSPublishModule.AppleRelease.md b/Docs/PSPublishModule.AppleRelease.md index 02b0bc0db..8145c5c4f 100644 --- a/Docs/PSPublishModule.AppleRelease.md +++ b/Docs/PSPublishModule.AppleRelease.md @@ -194,6 +194,7 @@ submitting a version. "Automation": { "WriteReceipt": true, "ReceiptPath": "build/powerforge/apple/release-receipt.json", + "ReceiptHistoryPath": "build/powerforge/apple/receipts", "PlanReceiptPath": "build/powerforge/apple/release-plan.json", "LockPath": "build/powerforge/apple/release.lock", "VersionSourcePath": "project.yml", @@ -254,8 +255,8 @@ Use the same entry point for each transition: | `Doctor` | Reads release state plus local topology, embedded-product evidence, metadata ownership, App Review details, age rating, pricing, availability, accessibility, encryption, monetization, webhook coverage, and TestFlight feedback. It does not mutate Apple state. | | `Version` | Updates the configured XcodeGen version source and chooses one build number above both local state and every configured App Store Connect platform. | | `Archive` | Creates signed archives without uploading. | -| `Upload` | Archives, uploads, waits for processing, and resumes an exact remote build when possible. | -| `UploadExisting` | Uploads existing archives and uses the same resume/wait behavior. | +| `Upload` | Archives, uploads, waits for processing, and resumes an exact remote build only when an immutable receipt binds it to the same source commit and archive SHA-256. | +| `UploadExisting` | Uploads existing archives and uses the same provenance-bound resume and wait behavior. | | `Prepare` | Creates/updates versions, metadata, app information, build selection, and readiness. | | `Screenshots` | Validates and syncs configured screenshot sets as a separate, deliberate transition. | | `TestFlight` | Assigns the processed build to configured groups and testers. | @@ -310,6 +311,57 @@ uses a separate plan receipt, checks the exact version/build remotely, and stops Screenshot replacement is opt-in during `Advance`. Keep `SyncScreenshots=false` when the protected `powerforge-apple-screenshots.yml` lane owns capture, approval, and immediate sync. +### Receipts and recovery + +Each executed Apple action writes an atomic latest receipt and one immutable attempt +receipt under `ReceiptHistoryPath`. The history is append-only: a later `Status` or +`Doctor` run does not erase the upload or notarization evidence needed by a retry. +Receipts contain a canonical hash and previous-receipt hash; PowerForge rejects changed, +missing, forked, linked, or otherwise incomplete history before it trusts that evidence. +Canonical hashes are derived from the JSON properties actually stored, so adding an +optional receipt field in a later PowerForge version does not invalidate old evidence. +The first schema-v4 write also preserves a schema-v3 latest receipt in history before +replacing it. + +Mutating actions validate the complete journal and append a `Started` checkpoint before +their first side effect. Upload and direct notarization append `UploadAttested` or +`NotarizationAttested` immediately after the external tool succeeds, before processing +polls, stapling, readiness checks, or cleanup can fail. A final `Completed` receipt then +records the reconciled outcome. This means a terminated process can be resumed from the +last durable fact without guessing whether the external mutation happened. + +For App Store uploads, the original attempt records the exact source commit, archive +path, archive SHA-256, build id or build-upload id, and attestation attempt id. A matching +version/build in App Store Connect is not enough by itself. If no retained attempt proves +that PowerForge uploaded that binary from the current source, the action stops instead +of silently treating an unrelated binary as the current release. + +Receipt hashes detect corruption and preserve continuity, but receipt files owned by the +same local account are not treated as operator authority. Resuming an upload or accepted +notarization in a later process therefore requires explicit adoption and confirmation, +even when the matching receipt is present. This prevents a writable local journal from +silently authorizing a remote release mutation. + +Run real publication through `scripts/Invoke-PinnedPowerForge.ps1`; it supplies the exact +consumer commit automatically. Direct CLI callers should pass the same full 40-character +SHA-1 or 64-character SHA-256 Git object ID with `--apple-source-commit`. If an older binary +must be recovered and independent evidence has already established its identity, adoption +is available as an explicit exception: + +```text +powerforge apple-release Upload --config powerforge.release.json --apple-source-commit --apple-adopt-existing-build --plan --summary --output json +powerforge apple-release Upload --config powerforge.release.json --apple-source-commit --apple-adopt-existing-build --confirm-apple-action --summary --output json +``` + +Adoption records the deliberate recovery decision. When no matching upload attestation +exists, it also emits `APPLE_BUILD_ADOPTED_WITHOUT_UPLOAD_ATTESTATION`; when attestation +does exist, it is retained as continuity evidence rather than being promoted to authority. +Prefer a new build number and a fresh upload whenever that is possible. + +`CleanupAfterProcessing` removes only artifacts older than `ArtifactRetentionDays` after +the remote build is valid. It deliberately retains the current archive and export so a +successful upload does not immediately destroy its local evidence. + ## Commercial and compliance governance Use one checked-in governance file per App Store Connect app. The file declares only diff --git a/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs b/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs index 5602ca90d..09cc4fe46 100644 --- a/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs +++ b/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs @@ -4,8 +4,6 @@ using System.IO; using System.Linq; using System.Management.Automation; -using System.Text.Json; -using System.Text.Json.Serialization; using PowerForge; using PowerForge.ConsoleShared; @@ -256,13 +254,33 @@ public sealed partial class InvokePowerForgeReleaseCommand : PSCmdlet [Parameter] public PowerForgeAppleReleaseAction AppleAction { get; set; } = PowerForgeAppleReleaseAction.Configured; + /// Overrides the Apple marketing version selected for this operation. + [Parameter] + public string? AppleVersion { get; set; } + + /// Binds Apple release evidence to a full 40-character SHA-1 or 64-character SHA-256 source commit. + [Parameter] + public string? AppleSourceCommit { get; set; } + + /// Requires the persisted Apple plan receipt to match this exact SHA-256. + [Parameter] + public string? AppleExpectedPlanSha256 { get; set; } + /// /// Explicitly confirms a risky Apple screenshot replacement, review submission, or public release action. /// [Parameter] public SwitchParameter ConfirmAppleAction { get; set; } - /// Forces exact remote-build reuse on this run. + /// + /// Explicitly adopts a verified remote Apple build or accepted notarization operation. + /// Local receipts provide continuity evidence but never authorize cross-process recovery by themselves; + /// this switch requires ConfirmAppleAction and is intended only for deliberate recovery. + /// + [Parameter] + public SwitchParameter AdoptExistingAppleBuild { get; set; } + + /// Enables recovery discovery; deliberate cross-process reuse also requires AdoptExistingAppleBuild and ConfirmAppleAction. [Parameter] public SwitchParameter AppleResume { get; set; } @@ -663,22 +681,7 @@ private static IEnumerable EnumerateSelfAndParents(string startDirectory } private static PowerForgeReleaseSpec LoadConfig(string configPath) - { - var json = File.ReadAllText(configPath); - var options = new JsonSerializerOptions - { - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip, - PropertyNameCaseInsensitive = true - }; - options.Converters.Add(new JsonStringEnumConverter()); - - var spec = JsonSerializer.Deserialize(json, options); - if (spec is null) - throw new InvalidOperationException($"Unable to deserialize unified release config: {configPath}"); - - return spec; - } + => PowerForgeReleaseService.LoadConfiguration(configPath); private static bool? ResolveRequestedFlag(IDictionary? boundParameters, string parameterName) { @@ -811,7 +814,11 @@ private PowerForgeReleaseInvocationOptions BuildInvocationOptions(IDictionaryExact source commit expected by the reviewed approval manifest. [Parameter] - [ValidatePattern("^[0-9A-Fa-f]{40}$")] + [ValidatePattern("^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$")] public string? SourceCommit { get; set; } /// Syncs local screenshot folders to App Store Connect screenshot sets. diff --git a/PSPublishModule/Cmdlets/TestAppStoreConnectScreenshotSyncConfigCommand.cs b/PSPublishModule/Cmdlets/TestAppStoreConnectScreenshotSyncConfigCommand.cs index ba7456350..c153e6706 100644 --- a/PSPublishModule/Cmdlets/TestAppStoreConnectScreenshotSyncConfigCommand.cs +++ b/PSPublishModule/Cmdlets/TestAppStoreConnectScreenshotSyncConfigCommand.cs @@ -30,7 +30,7 @@ public sealed class TestAppStoreConnectScreenshotSyncConfigCommand : PSCmdlet /// Exact source commit expected by the reviewed approval manifest. [Parameter] - [ValidatePattern("^[0-9A-Fa-f]{40}$")] + [ValidatePattern("^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$")] public string? SourceCommit { get; set; } /// Validates the screenshot sync configuration. diff --git a/PowerForge.Cli/AppleReleaseCliSummary.cs b/PowerForge.Cli/AppleReleaseCliSummary.cs index be5b45b37..cd11396cd 100644 --- a/PowerForge.Cli/AppleReleaseCliSummary.cs +++ b/PowerForge.Cli/AppleReleaseCliSummary.cs @@ -16,6 +16,8 @@ internal sealed class AppleReleaseCliPlanSummary public bool Resume { get; set; } + public bool AdoptExistingBuild { get; set; } + public bool WaitForProcessing { get; set; } public int ProcessingTimeoutSeconds { get; set; } diff --git a/PowerForge.Cli/Program.Command.AppleRelease.cs b/PowerForge.Cli/Program.Command.AppleRelease.cs index 8e6d5ac43..57398f88b 100644 --- a/PowerForge.Cli/Program.Command.AppleRelease.cs +++ b/PowerForge.Cli/Program.Command.AppleRelease.cs @@ -7,6 +7,7 @@ internal static partial class Program "Usage: powerforge apple-release " + "[--config ] [--plan] [--validate] [--confirm-apple-action] " + "[--apple-version ] [--apple-source-commit ] [--apple-expected-plan-sha256 ] " + + "[--apple-adopt-existing-build] " + "[--apple-resume|--no-apple-resume] [--apple-wait|--no-apple-wait] " + "[--apple-timeout-seconds ] [--apple-poll-seconds ] " + "[--target ] [--summary] [--output json]"; @@ -73,6 +74,7 @@ private static void ValidateAppleReleaseArguments(string[] argv) "--dry-run", "--validate", "--confirm-apple-action", + "--apple-adopt-existing-build", "--apple-resume", "--no-apple-resume", "--apple-wait", diff --git a/PowerForge.Cli/Program.Command.AppleScreenshots.cs b/PowerForge.Cli/Program.Command.AppleScreenshots.cs index d6ba88def..32000213b 100644 --- a/PowerForge.Cli/Program.Command.AppleScreenshots.cs +++ b/PowerForge.Cli/Program.Command.AppleScreenshots.cs @@ -182,8 +182,8 @@ static string RequiredString(JsonElement value, string name) var captureRunId = RequiredString(root, "captureRunId"); var sourceCommit = RequiredString(root, "sourceCommit").ToLowerInvariant(); - if (sourceCommit.Length != 40 || !sourceCommit.All(Uri.IsHexDigit)) - throw new InvalidOperationException("Screenshot capture provenance SourceCommit must be an exact 40-character Git commit SHA."); + if (!GitObjectId.IsFull(sourceCommit)) + throw new InvalidOperationException("Screenshot capture provenance SourceCommit must be a full SHA-1 or SHA-256 Git commit object id."); if (!root.TryGetProperty("screenshots", out var screenshotsElement) || screenshotsElement.ValueKind != JsonValueKind.Array) throw new InvalidOperationException("Screenshot capture provenance must contain an exact screenshots inventory."); diff --git a/PowerForge.Cli/Program.Command.Release.cs b/PowerForge.Cli/Program.Command.Release.cs index 12e1364df..21a3b75c2 100644 --- a/PowerForge.Cli/Program.Command.Release.cs +++ b/PowerForge.Cli/Program.Command.Release.cs @@ -7,7 +7,7 @@ internal static partial class Program { private const string ReleaseUsageBase = - "Usage: powerforge release [--config ] [--plan] [--validate] [--packages-only] [--module-only] [--tools-only] [--apple-action ] [--apple-version ] [--apple-source-commit ] [--apple-expected-plan-sha256 ] [--confirm-apple-action] [--apple-resume|--no-apple-resume] [--apple-wait|--no-apple-wait] [--apple-timeout-seconds ] [--apple-poll-seconds ] [--summary] [--configuration ] [--module-framework ] [--module-run-mode ] [--module-timeout-seconds ] [--module-no-dotnet-build] [--module-version ] [--module-prerelease-tag ] [--module-no-sign] [--module-sign] [--module-certificate-thumbprint ] [--module-sign-include-binaries|--module-no-sign-include-binaries] [--module-sign-include-internals|--module-no-sign-include-internals] [--module-sign-include-exe|--module-no-sign-include-exe] [--module-diagnostics-baseline ] [--module-diagnostics-baseline-generate|--module-no-diagnostics-baseline-generate] [--module-diagnostics-baseline-update|--module-no-diagnostics-baseline-update] [--module-fail-on-new-diagnostics|--module-no-fail-on-new-diagnostics] [--module-fail-on-diagnostics-severity ] [--skip-workspace-validation] [--workspace-config ] [--workspace-profile ] [--workspace-testimox-root ] [--workspace-enable-feature ] [--workspace-disable-feature ] [--publish-nuget] [--publish-project-github] [--publish-tool-github] [--submit-winget] [--skip-winget-submit] [--winget-submit-mode ] [--winget-tool-path ] [--winget-token-env ] [--winget-token-file ] [--winget-pr-title ] [--winget-open-browser] [--winget-replace [version]] [--winget-allow-interactive-auth] [--winget-timeout-seconds ] [--skip-restore] [--skip-build] [--output-root ] [--stage-root ] [--manifest-json ] [--allow-output-outside-project-root] [--allow-manifest-outside-project-root] [--checksums-path ] [--skip-release-checksums] [--keep-symbols] [--sign] [--sign-profile ] [--sign-tool-path ] [--sign-thumbprint ] [--sign-subject-name ] [--sign-on-missing-tool ] [--sign-on-failure ] [--sign-timeout-seconds ] [--sign-timestamp-url ] [--sign-description ] [--sign-url ] [--sign-csp ] [--sign-key-container ] [--package-sign-thumbprint ] [--package-sign-store ] [--package-sign-timestamp-url ] [--installer-property ] [--tool-output [,<...>]] [--skip-tool-output <...>] [--target ] [--rid ] [--framework ] [--style [,<...>]] [--flavor [,<...>]] [--output json]"; + "Usage: powerforge release [--config ] [--plan] [--validate] [--packages-only] [--module-only] [--tools-only] [--apple-action ] [--apple-version ] [--apple-source-commit ] [--apple-expected-plan-sha256 ] [--confirm-apple-action] [--apple-adopt-existing-build] [--apple-resume|--no-apple-resume] [--apple-wait|--no-apple-wait] [--apple-timeout-seconds ] [--apple-poll-seconds ] [--summary] [--configuration ] [--module-framework ] [--module-run-mode ] [--module-timeout-seconds ] [--module-no-dotnet-build] [--module-version ] [--module-prerelease-tag ] [--module-no-sign] [--module-sign] [--module-certificate-thumbprint ] [--module-sign-include-binaries|--module-no-sign-include-binaries] [--module-sign-include-internals|--module-no-sign-include-internals] [--module-sign-include-exe|--module-no-sign-include-exe] [--module-diagnostics-baseline ] [--module-diagnostics-baseline-generate|--module-no-diagnostics-baseline-generate] [--module-diagnostics-baseline-update|--module-no-diagnostics-baseline-update] [--module-fail-on-new-diagnostics|--module-no-fail-on-new-diagnostics] [--module-fail-on-diagnostics-severity ] [--skip-workspace-validation] [--workspace-config ] [--workspace-profile ] [--workspace-testimox-root ] [--workspace-enable-feature ] [--workspace-disable-feature ] [--publish-nuget] [--publish-project-github] [--publish-tool-github] [--submit-winget] [--skip-winget-submit] [--winget-submit-mode ] [--winget-tool-path ] [--winget-token-env ] [--winget-token-file ] [--winget-pr-title ] [--winget-open-browser] [--winget-replace [version]] [--winget-allow-interactive-auth] [--winget-timeout-seconds ] [--skip-restore] [--skip-build] [--output-root ] [--stage-root ] [--manifest-json ] [--allow-output-outside-project-root] [--allow-manifest-outside-project-root] [--checksums-path ] [--skip-release-checksums] [--keep-symbols] [--sign] [--sign-profile ] [--sign-tool-path ] [--sign-thumbprint ] [--sign-subject-name ] [--sign-on-missing-tool ] [--sign-on-failure ] [--sign-timeout-seconds ] [--sign-timestamp-url ] [--sign-description ] [--sign-url ] [--sign-csp ] [--sign-key-container ] [--package-sign-thumbprint ] [--package-sign-store ] [--package-sign-timestamp-url ] [--installer-property ] [--tool-output [,<...>]] [--skip-tool-output <...>] [--target ] [--rid ] [--framework ] [--style [,<...>]] [--flavor [,<...>]] [--output json]"; private const string ReleaseUsage = ReleaseUsageBase + " Tool-only version override: --release-version ."; @@ -378,8 +378,11 @@ internal static PowerForgeReleaseRequest BuildReleaseRequestFromArgs( request.AppleAction = ParseAppleReleaseAction(TryGetOptionValue(argv, "--apple-action")); request.AppleMarketingVersion = ChooseString(request.AppleMarketingVersion, TryGetOptionValue(argv, "--apple-version")); request.AppleSourceCommit = ChooseString(request.AppleSourceCommit, TryGetOptionValue(argv, "--apple-source-commit")); + request.RequireImmutableAppleSourceSnapshot = request.RequireImmutableAppleSourceSnapshot || + !string.IsNullOrWhiteSpace(request.AppleSourceCommit); request.AppleExpectedPlanSha256 = ChooseString(request.AppleExpectedPlanSha256, TryGetOptionValue(argv, "--apple-expected-plan-sha256")); request.AppleActionConfirmed = argv.Any(a => a.Equals("--confirm-apple-action", StringComparison.OrdinalIgnoreCase)); + request.AppleAdoptExistingBuild = argv.Any(a => a.Equals("--apple-adopt-existing-build", StringComparison.OrdinalIgnoreCase)); request.AppleResume = ResolveBooleanOverride(argv, "--apple-resume", "--no-apple-resume", request.AppleResume); request.AppleWaitForProcessing = ResolveBooleanOverride(argv, "--apple-wait", "--no-apple-wait", request.AppleWaitForProcessing); request.AppleSummaryOnly = argv.Any(a => a.Equals("--summary", StringComparison.OrdinalIgnoreCase)); @@ -583,11 +586,12 @@ private static JsonElement CreateAppleSummaryElement( request.PlanOnly ? plan.PlanReceiptPath : plan.ReceiptPath).Replace('\\', '/'), PlanSha256 = result.AppleReceipt?.PlanSha256, Resume = plan.Automation.Resume, + AdoptExistingBuild = plan.AdoptExistingBuild, WaitForProcessing = plan.Automation.WaitForProcessing, ProcessingTimeoutSeconds = plan.Automation.ProcessingTimeoutSeconds, PollIntervalSeconds = plan.Automation.PollIntervalSeconds, EnabledSteps = enabledSteps.ToArray(), - RequiresConfirmation = RequiresAppleActionConfirmation(plan), + RequiresConfirmation = RequiresAppleActionConfirmation(plan) || plan.AdoptExistingBuild, Targets = plan.Apps.Select(app => new AppleReleaseCliTargetSummary { Name = app.Name, diff --git a/PowerForge.Cli/Program.Helpers.IOAndJson.cs b/PowerForge.Cli/Program.Helpers.IOAndJson.cs index e6db02294..6ab6c4250 100644 --- a/PowerForge.Cli/Program.Helpers.IOAndJson.cs +++ b/PowerForge.Cli/Program.Helpers.IOAndJson.cs @@ -408,8 +408,7 @@ static string ResolveExistingFilePath(string path) static (PowerForgeReleaseSpec Value, string FullPath) LoadPowerForgeReleaseSpecWithPath(string path) { var full = ResolveExistingFilePath(path); - var json = File.ReadAllText(full); - var spec = CliJson.DeserializeOrThrow(json, CliJson.Context.PowerForgeReleaseSpec, full); + var spec = PowerForgeReleaseService.LoadConfiguration(full); return (spec, full); } diff --git a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs new file mode 100644 index 000000000..5145329bc --- /dev/null +++ b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs @@ -0,0 +1,428 @@ +using System.Net; +using System.Security.Cryptography; + +namespace PowerForge.Tests; + +public sealed partial class AppStoreConnectClientTests +{ + [Fact] + public async Task ReleasePreparationService_RejectsScreenshotInventoryDriftBeforeFirstMutation() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var screenshotFolder = Directory.CreateDirectory(Path.Combine(root.FullName, "iphone-6-5")); + var screenshotPath = Path.Combine(screenshotFolder.FullName, "01-home.png"); + await File.WriteAllBytesAsync(screenshotPath, new byte[] { 1, 2, 3 }); + var approvedInventorySha256 = AppStoreConnectScreenshotInventory.ComputeSha256( + [ + new AppStoreConnectReleaseScreenshotSetReadiness + { + ScreenshotDisplayType = "APP_IPHONE_65", + ScreenshotSetId = "set-1", + Count = 1, + Screenshots = + [ + new AppStoreConnectReleaseScreenshotAssetReadiness + { + Id = "shot-before", + FileName = "01-home.png", + FileSize = 3, + SourceFileChecksum = "approved-checksum", + AssetDeliveryState = "COMPLETE" + } + ] + } + ]); + var handler = new SequenceHandler( + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "build-5", "type": "builds", "attributes": { "version": "5", "processingState": "VALID", "expired": false }, "relationships": { "preReleaseVersion": { "data": { "id": "pre-1", "type": "preReleaseVersions" } } } }], "included": [{ "id": "pre-1", "type": "preReleaseVersions", "attributes": { "version": "1.0.0", "platform": "IOS" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": null }"""), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "loc-1", "type": "appStoreVersionLocalizations", "attributes": { "locale": "en-US" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "set-1", "type": "appScreenshotSets", "attributes": { "screenshotDisplayType": "APP_IPHONE_65" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "shot-after", "type": "appScreenshots", "attributes": { "fileName": "01-home.png", "fileSize": 3, "sourceFileChecksum": "changed-checksum", "assetDeliveryState": { "state": "COMPLETE" } } }] }""")); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + + var exception = await Assert.ThrowsAsync(() => + new AppStoreConnectReleasePreparationService(client).PrepareAsync(new AppStoreConnectReleasePreparationRequest + { + AppId = "app-1", + VersionString = "1.0.0", + BuildNumber = "5", + Platform = ApplePlatform.iOS, + CreateVersion = false, + SelectBuild = true, + ReplaceScreenshots = true, + BaseDirectory = root.FullName, + ExpectedScreenshotFileSha256 = new Dictionary(StringComparer.Ordinal) + { + [screenshotPath] = ComputeScreenshotSha256(screenshotPath) + }, + ExpectedScreenshotInventorySha256 = approvedInventorySha256, + ScreenshotSpec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + VersionId = "version-1", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "iphone-6-5" + } + ] + } + })); + + Assert.Contains("before any remote release mutation", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(5, handler.RequestUris.Count); + Assert.All(handler.Methods, method => Assert.Equal(HttpMethod.Get, method)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task ReleasePreparationService_ReusesApprovedScreenshotSnapshotAfterEarlierRemoteMutation() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var screenshotFolder = Directory.CreateDirectory(Path.Combine(root.FullName, "iphone-6-5")); + var screenshotPath = Path.Combine(screenshotFolder.FullName, "01-home.png"); + var approvedBytes = new byte[] { 1, 2, 3 }; + await File.WriteAllBytesAsync(screenshotPath, approvedBytes); + var handler = new SequenceHandler( + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "build-5", "type": "builds", "attributes": { "version": "5", "processingState": "VALID", "expired": false }, "relationships": { "preReleaseVersion": { "data": { "id": "pre-1", "type": "preReleaseVersions" } } } }], "included": [{ "id": "pre-1", "type": "preReleaseVersions", "attributes": { "version": "1.0.0", "platform": "IOS" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": null }"""), + new SequenceResponse(HttpStatusCode.NoContent, string.Empty), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "loc-1", "type": "appStoreVersionLocalizations", "attributes": { "locale": "en-US" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "set-1", "type": "appScreenshotSets", "attributes": { "screenshotDisplayType": "APP_IPHONE_65" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [] }"""), + ScreenshotReservation("shot-1", "01-home.png", approvedBytes.Length), + ScreenshotCommit("shot-1", "01-home.png", "5289df737df57326fcdd22597afb1fac"), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "shot-1", "type": "appScreenshots", "attributes": { "fileName": "01-home.png", "fileSize": 3, "sourceFileChecksum": "5289df737df57326fcdd22597afb1fac", "assetDeliveryState": { "state": "UPLOAD_COMPLETE" } } }] }""")); + handler.OnRequest = count => + { + if (count == 1) + File.WriteAllBytes(screenshotPath, new byte[] { 9, 9, 9 }); + }; + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + + var result = await new AppStoreConnectReleasePreparationService(client).PrepareAsync( + new AppStoreConnectReleasePreparationRequest + { + AppId = "app-1", + VersionString = "1.0.0", + BuildNumber = "5", + Platform = ApplePlatform.iOS, + CreateVersion = false, + SelectBuild = true, + ReplaceScreenshots = true, + BaseDirectory = root.FullName, + ExpectedScreenshotFileSha256 = new Dictionary(StringComparer.Ordinal) + { + [screenshotPath] = ComputeScreenshotSha256(screenshotPath) + }, + ScreenshotSpec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + VersionId = "version-1", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "iphone-6-5" + } + ] + } + }); + + Assert.True(result.SelectedBuild); + Assert.Equal(new HttpMethod("PATCH"), handler.Methods[2]); + Assert.Equal(new byte[] { 9, 9, 9 }, File.ReadAllBytes(screenshotPath)); + Assert.Equal(screenshotPath, Assert.Single(Assert.Single(result.Screenshots!.ScreenshotSets).Uploaded).FilePath); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task ScreenshotSyncService_PreservesSubdirectoryFiltersInApprovedInventory() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "screenshots")); + var nested = Directory.CreateDirectory(Path.Combine(folder.FullName, "iPhone")); + var screenshotPath = Path.Combine(nested.FullName, "01-home.png"); + await File.WriteAllBytesAsync(screenshotPath, new byte[] { 1, 2, 3 }); + var handler = new SequenceHandler( + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "version-1", "type": "appStoreVersions", "attributes": { "versionString": "1.0.0", "platform": "IOS" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "loc-1", "type": "appStoreVersionLocalizations", "attributes": { "locale": "en-US" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [] }"""), + new SequenceResponse(HttpStatusCode.Created, """{ "data": { "id": "set-1", "type": "appScreenshotSets", "attributes": { "screenshotDisplayType": "APP_IPHONE_65" } } }"""), + ScreenshotReservation("shot-1", "01-home.png", 3), + ScreenshotCommit("shot-1", "01-home.png", "5289df737df57326fcdd22597afb1fac")); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + + var result = await new AppStoreConnectScreenshotSyncService(client).SyncAsync(new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + ExpectedFileSha256 = new Dictionary(StringComparer.Ordinal) + { + [screenshotPath] = ComputeScreenshotSha256(screenshotPath) + }, + Spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "screenshots", + Filter = "iPhone/*.png" + } + ] + } + }); + + Assert.Equal(screenshotPath, Assert.Single(Assert.Single(result.ScreenshotSets).Uploaded).FilePath); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task ScreenshotSyncService_PreservesRelativePathsForDuplicateNestedBasenames() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "screenshots")); + var phoneFolder = Directory.CreateDirectory(Path.Combine(folder.FullName, "iPhone")); + var tabletFolder = Directory.CreateDirectory(Path.Combine(folder.FullName, "iPad")); + var phone = Path.Combine(phoneFolder.FullName, "shot.png"); + var tablet = Path.Combine(tabletFolder.FullName, "shot.png"); + await File.WriteAllBytesAsync(phone, new byte[] { 1, 2, 3 }); + await File.WriteAllBytesAsync(tablet, new byte[] { 4, 5, 6 }); + var handler = new SequenceHandler( + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "version-1", "type": "appStoreVersions", "attributes": { "versionString": "1.0.0", "platform": "IOS" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "loc-1", "type": "appStoreVersionLocalizations", "attributes": { "locale": "en-US" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [] }"""), + new SequenceResponse(HttpStatusCode.Created, """{ "data": { "id": "set-1", "type": "appScreenshotSets", "attributes": { "screenshotDisplayType": "APP_IPHONE_65" } } }"""), + ScreenshotReservation("shot-phone", "shot.png", 3), + ScreenshotCommit("shot-phone", "shot.png", "5289df737df57326fcdd22597afb1fac"), + ScreenshotReservation("shot-tablet", "shot.png", 3), + ScreenshotCommit("shot-tablet", "shot.png", "b4a3ba90641372b4e4eaa841a5a400ec")); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + + var result = await new AppStoreConnectScreenshotSyncService(client).SyncAsync(new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + ExpectedFileSha256 = new Dictionary(StringComparer.Ordinal) + { + [phone] = ComputeScreenshotSha256(phone), + [tablet] = ComputeScreenshotSha256(tablet) + }, + Spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "screenshots", + Filter = "*/shot.png" + } + ] + } + }); + + var uploaded = Assert.Single(result.ScreenshotSets).Uploaded; + Assert.Equal(2, uploaded.Length); + Assert.Equal(new[] { tablet, phone }, uploaded.Select(static item => item.FilePath).OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task ScreenshotUploadSnapshot_UsesPrivateFileBackedRanges() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var screenshotPath = Path.Combine(root.FullName, "large.png"); + var bytes = Enumerable.Range(0, 1024 * 1024).Select(static value => (byte)(value % 251)).ToArray(); + await File.WriteAllBytesAsync(screenshotPath, bytes); + using var snapshot = AppStoreConnectScreenshotUploadSnapshot.Capture( + screenshotPath, + ComputeScreenshotSha256(screenshotPath)); + File.WriteAllBytes(screenshotPath, new byte[] { 9, 9, 9 }); + using var content = snapshot.CreateRangeContent(700_000, 7); + + var range = await content.ReadAsByteArrayAsync(); + + Assert.Equal(bytes.Skip(700_000).Take(7), range); + Assert.NotEqual(Path.GetFullPath(screenshotPath), snapshot.FilePath); + Assert.Equal(bytes.LongLength, snapshot.Length); + Assert.Equal(7, content.Headers.ContentLength); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, + File.GetUnixFileMode(snapshot.RootPath)); + } + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void ScreenshotUploadSnapshot_rejects_restored_bytes_changed_through_a_removed_hard_link_alias() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + string? aliasRoot = null; + try + { + var screenshotPath = Path.Combine(root.FullName, "approved.png"); + File.WriteAllBytes(screenshotPath, new byte[] { 1, 2, 3 }); + using var snapshot = AppStoreConnectScreenshotUploadSnapshot.Capture( + screenshotPath, + ComputeScreenshotSha256(screenshotPath)); + aliasRoot = Path.Combine(Directory.GetParent(snapshot.RootPath)!.FullName, $"alias-{Guid.NewGuid():N}"); + Directory.CreateDirectory(aliasRoot); + var alias = Path.Combine(aliasRoot, "screenshot-alias"); + TestFileLink.CreateHardLink(alias, snapshot.FilePath); + File.WriteAllBytes(alias, new byte[] { 9, 9, 9 }); + File.WriteAllBytes(alias, new byte[] { 1, 2, 3 }); + File.Delete(alias); + + var exception = Assert.Throws(snapshot.ValidateUnchanged); + Assert.Contains("hard-link alias", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (!string.IsNullOrWhiteSpace(aliasRoot) && Directory.Exists(aliasRoot)) + Directory.Delete(aliasRoot, recursive: true); + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void ScreenshotUploadSnapshot_dispose_prepares_read_only_private_tree() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + AppStoreConnectScreenshotUploadSnapshot? snapshot = null; + try + { + var screenshotPath = Path.Combine(root.FullName, "approved.png"); + File.WriteAllBytes(screenshotPath, new byte[] { 1, 2, 3 }); + snapshot = AppStoreConnectScreenshotUploadSnapshot.Capture( + screenshotPath, + ComputeScreenshotSha256(screenshotPath)); + var snapshotRoot = snapshot.RootPath; +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(snapshot.FilePath, UnixFileMode.UserRead); + File.SetUnixFileMode(snapshotRoot, UnixFileMode.UserRead | UnixFileMode.UserExecute); + } + else +#endif + { + File.SetAttributes(snapshot.FilePath, File.GetAttributes(snapshot.FilePath) | FileAttributes.ReadOnly); + File.SetAttributes(snapshotRoot, File.GetAttributes(snapshotRoot) | FileAttributes.ReadOnly); + } + + var exception = Record.Exception(snapshot.Dispose); + snapshot = null; + + Assert.Null(exception); + Assert.False(Directory.Exists(snapshotRoot)); + } + finally + { + snapshot?.Dispose(); + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task UploadScreenshotAsync_RejectsPrivateSnapshotMutationDuringReservation() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var screenshotPath = Path.Combine(root.FullName, "01-home.png"); + await File.WriteAllBytesAsync(screenshotPath, new byte[] { 1, 2, 3 }); + var handler = new SequenceHandler(ScreenshotReservation("shot-1", "01-home.png", 3)); + handler.OnRequest = count => + { + if (count != 1) + return; + var snapshotBase = Path.Combine(Path.GetTempPath(), "PowerForge", "appstore-screenshot-upload"); + var snapshot = Directory.EnumerateFiles(snapshotBase, "screenshot-bytes", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .First(); + File.WriteAllBytes(snapshot, new byte[] { 9, 9, 9 }); + }; + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + + var exception = await Assert.ThrowsAsync(() => + client.UploadScreenshotAsync("set-1", screenshotPath)); + + Assert.Contains("private screenshot upload snapshot changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Single(handler.RequestUris); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + private static string ComputeScreenshotSha256(string filePath) + { + using var stream = File.OpenRead(filePath); + using var sha256 = SHA256.Create(); + return BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); + } +} diff --git a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs new file mode 100644 index 000000000..a3bae1d01 --- /dev/null +++ b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs @@ -0,0 +1,155 @@ +using System.Net; +using System.Text.Json; + +namespace PowerForge.Tests; + +public sealed partial class AppStoreConnectClientTests +{ + [Fact] + public void ScreenshotSyncService_binds_direct_approval_hashes_into_the_immutable_snapshot() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "screenshots")); + var sourcePath = Path.Combine(folder.FullName, "01-home.png"); + File.WriteAllBytes(sourcePath, Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2n1sAAAAASUVORK5CYII=")); + var spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "screenshots" + } + ], + Quality = new AppStoreConnectScreenshotQualitySpec + { + RequireApprovalManifest = true, + ApprovalManifestPath = "approval.json" + } + }; + var manifest = new AppStoreConnectScreenshotApprovalService().Create( + new AppStoreConnectScreenshotApprovalRequest + { + Spec = spec, + BaseDirectory = root.FullName, + AllowedRoot = folder.FullName, + VersionString = spec.VersionString!, + SourceCommit = sourceCommit, + ApprovedBy = "release-owner" + }); + File.WriteAllText( + Path.Combine(root.FullName, "approval.json"), + JsonSerializer.Serialize(manifest)); + var validation = new AppStoreConnectScreenshotSyncConfigValidator().Validate( + spec, + root.FullName, + expectedSourceCommit: sourceCommit); + Assert.True(validation.IsValid, string.Join(Environment.NewLine, validation.Messages)); + + File.WriteAllBytes(sourcePath, new byte[] { 9, 9, 9 }); + var handler = new SequenceHandler(); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + var service = new AppStoreConnectScreenshotSyncService(client); + var request = new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + ExpectedSourceCommit = sourceCommit, + Spec = spec + }; + + var exception = Assert.Throws(() => service.CreateSnapshot(request, validation)); + + Assert.Contains("changed after Apple plan approval", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(handler.RequestUris); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task ScreenshotSyncService_rejects_restored_bytes_changed_through_snapshot_hard_link_before_remote_selection() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + string? aliasRoot = null; + try + { + var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "screenshots")); + var sourcePath = Path.Combine(folder.FullName, "01-home.png"); + File.WriteAllBytes(sourcePath, new byte[] { 1, 2, 3 }); + var handler = new SequenceHandler(); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + var service = new AppStoreConnectScreenshotSyncService(client); + using var snapshot = service.CreateSnapshot(new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + Spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "screenshots" + } + ] + } + }); + var snapshotPath = Assert.Single(Assert.Single(snapshot.Sets).Files); + aliasRoot = Path.Combine(Directory.GetParent(Path.GetDirectoryName(snapshotPath)!)!.FullName, $"alias-{Guid.NewGuid():N}"); + Directory.CreateDirectory(aliasRoot); + var aliasPath = Path.Combine(aliasRoot, "screenshot-alias"); + TestFileLink.CreateHardLink(aliasPath, snapshotPath); + File.WriteAllBytes(aliasPath, new byte[] { 9, 9, 9 }); + File.WriteAllBytes(aliasPath, new byte[] { 1, 2, 3 }); + File.Delete(aliasPath); + + var exception = await Assert.ThrowsAsync(() => service.SyncAsync( + new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + Spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "screenshots" + } + ] + } + }, + snapshot)); + + Assert.Contains("screenshot snapshot", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(handler.RequestUris); + } + finally + { + if (!string.IsNullOrWhiteSpace(aliasRoot) && Directory.Exists(aliasRoot)) + Directory.Delete(aliasRoot, recursive: true); + try { root.Delete(recursive: true); } catch { } + } + } +} diff --git a/PowerForge.Tests/AppStoreConnectClientTests.cs b/PowerForge.Tests/AppStoreConnectClientTests.cs index 570718717..d3a64a33b 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.cs @@ -521,6 +521,8 @@ public async Task ReleaseReadinessService_RequiresSelectedBuildMetadataAndComple "type": "appScreenshots", "attributes": { "fileName": "01.png", + "fileSize": 2048, + "sourceFileChecksum": "checksum-1", "assetDeliveryState": { "state": "COMPLETE" } } } @@ -544,7 +546,14 @@ public async Task ReleaseReadinessService_RequiresSelectedBuildMetadataAndComple Assert.All(result.Checks, check => Assert.True(check.Passed, check.Message)); Assert.Equal("build-5", result.SelectedBuildId); Assert.Equal("Premium remote.", result.Localization?.Description); - Assert.Equal("COMPLETE", Assert.Single(Assert.Single(result.ScreenshotSets).AssetDeliveryStates)); + var screenshotSet = Assert.Single(result.ScreenshotSets); + Assert.Equal("COMPLETE", Assert.Single(screenshotSet.AssetDeliveryStates)); + var screenshot = Assert.Single(screenshotSet.Screenshots); + Assert.Equal("shot-1", screenshot.Id); + Assert.Equal("01.png", screenshot.FileName); + Assert.Equal(2048, screenshot.FileSize); + Assert.Equal("checksum-1", screenshot.SourceFileChecksum); + Assert.Equal("COMPLETE", screenshot.AssetDeliveryState); } [Fact] @@ -791,8 +800,7 @@ public async Task ReleasePreparationService_EnforcesScreenshotQualityBeforeRemot })); Assert.Contains("duplicate", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Single(handler.Methods); - Assert.All(handler.Methods, method => Assert.Equal(HttpMethod.Get, method)); + Assert.Empty(handler.Methods); } finally { @@ -915,6 +923,11 @@ public async Task UploadScreenshotAsync_CreatesReservationUploadsAssetAndCommits } } """)); + handler.OnRequest = count => + { + if (count == 1) + File.WriteAllBytes(screenshotPath, new byte[] { 9, 9, 9, 9, 9 }); + }; using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; using var client = new AppStoreConnectClient(CreateCredential(), http); @@ -949,6 +962,8 @@ public async Task ScreenshotSyncService_CreatesMissingSetAndUploadsMappedFolder( var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "iphone-6-5")); var screenshotPath = Path.Combine(folder.FullName, "01-home.png"); await File.WriteAllBytesAsync(screenshotPath, new byte[] { 9, 8, 7 }); + UnixFileMode? snapshotRootMode = null; + string? snapshotFilePath = null; var handler = new SequenceHandler( new SequenceResponse(HttpStatusCode.OK, @@ -1029,6 +1044,26 @@ public async Task ScreenshotSyncService_CreatesMissingSetAndUploadsMappedFolder( } """)); + handler.OnRequest = count => + { + if (count == 1) + { + var snapshotBase = Path.Combine(Path.GetTempPath(), "PowerForge", "appstore-screenshot-snapshot"); + snapshotFilePath = Directory.EnumerateFiles(snapshotBase, "01-home.png", SearchOption.AllDirectories) + .Where(path => File.ReadAllBytes(path).SequenceEqual(new byte[] { 9, 8, 7 })) + .OrderByDescending(File.GetLastWriteTimeUtc) + .First(); + if (!OperatingSystem.IsWindows()) + { + snapshotRootMode = File.GetUnixFileMode(Directory.GetParent(Path.GetDirectoryName(snapshotFilePath)!)!.FullName); + } + File.WriteAllBytes(screenshotPath, new byte[] { 6, 6, 6 }); + } + else if (count == 5) + { + File.WriteAllBytes(snapshotFilePath!, new byte[] { 5, 5, 5 }); + } + }; using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; using var client = new AppStoreConnectClient(CreateCredential(), http); var service = new AppStoreConnectScreenshotSyncService(client); @@ -1064,6 +1099,118 @@ public async Task ScreenshotSyncService_CreatesMissingSetAndUploadsMappedFolder( Assert.Contains("appStoreVersions/version-1/appStoreVersionLocalizations", handler.RequestUris[1].ToString(), StringComparison.Ordinal); Assert.Contains("appStoreVersionLocalizations/loc-1/appScreenshotSets", handler.RequestUris[2].ToString(), StringComparison.Ordinal); Assert.Equal("https://api.appstoreconnect.apple.com/v1/appScreenshotSets", handler.RequestUris[3].ToString()); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, + snapshotRootMode); + } + } + finally + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task ScreenshotSyncService_RejectsBytesChangedAfterApprovedPlanBeforeRemoteMutation() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "iphone-6-5")); + var screenshotPath = Path.Combine(folder.FullName, "01-home.png"); + await File.WriteAllBytesAsync(screenshotPath, new byte[] { 1, 2, 3 }); + string approvedSha256; + using (var stream = File.OpenRead(screenshotPath)) + using (var sha256 = System.Security.Cryptography.SHA256.Create()) + approvedSha256 = BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); + await File.WriteAllBytesAsync(screenshotPath, new byte[] { 9, 8, 7 }); + + var handler = new SequenceHandler(); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + var exception = await Assert.ThrowsAsync(() => + new AppStoreConnectScreenshotSyncService(client).SyncAsync(new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + ExpectedFileSha256 = new Dictionary(StringComparer.Ordinal) + { + [screenshotPath] = approvedSha256 + }, + Spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "iphone-6-5" + } + ] + } + })); + + Assert.Contains("changed after Apple plan approval", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(handler.RequestUris); + } + finally + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task ScreenshotSyncService_RejectsApprovedFileMissingFromImmutableSnapshot() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "iphone-6-5")); + var screenshotPath = Path.Combine(folder.FullName, "01-home.png"); + await File.WriteAllBytesAsync(screenshotPath, new byte[] { 1, 2, 3 }); + string screenshotSha256; + using (var stream = File.OpenRead(screenshotPath)) + using (var sha256 = System.Security.Cryptography.SHA256.Create()) + screenshotSha256 = BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); + + var missingPath = Path.Combine(folder.FullName, "02-rooms.png"); + var handler = new SequenceHandler(); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + var exception = await Assert.ThrowsAsync(() => + new AppStoreConnectScreenshotSyncService(client).SyncAsync(new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + ExpectedFileSha256 = new Dictionary(StringComparer.Ordinal) + { + [screenshotPath] = screenshotSha256, + [missingPath] = new string('0', 64) + }, + Spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "iphone-6-5" + } + ] + } + })); + + Assert.Contains("disappeared", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("02-rooms.png", exception.Message, StringComparison.Ordinal); + Assert.Empty(handler.RequestUris); } finally { @@ -1071,10 +1218,8 @@ public async Task ScreenshotSyncService_CreatesMissingSetAndUploadsMappedFolder( } } - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task ScreenshotSyncService_RetryRetainsMatchingChecksumWithoutReupload(bool replaceExisting) + [Fact] + public async Task ScreenshotSyncService_RetryRetainsMatchingChecksumWithoutReupload() { var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); try @@ -1096,7 +1241,7 @@ public async Task ScreenshotSyncService_RetryRetainsMatchingChecksumWithoutReupl var result = await new AppStoreConnectScreenshotSyncService(client).SyncAsync(new AppStoreConnectScreenshotSyncRequest { BaseDirectory = root.FullName, - ReplaceExisting = replaceExisting, + ReplaceExisting = false, Spec = new AppStoreConnectScreenshotSyncSpec { AppId = "app-1", @@ -1125,6 +1270,64 @@ public async Task ScreenshotSyncService_RetryRetainsMatchingChecksumWithoutReupl } } + [Fact] + public async Task ScreenshotSyncService_ReplaceExistingReuploadsMatchingChecksumWithFreshAssetIdentity() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "iphone-6-5")); + await File.WriteAllBytesAsync(Path.Combine(folder.FullName, "01-home.png"), new byte[] { 9, 8, 7 }); + var handler = new SequenceHandler( + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "version-1", "type": "appStoreVersions", "attributes": { "versionString": "1.0.0", "platform": "IOS" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "loc-1", "type": "appStoreVersionLocalizations", "attributes": { "locale": "en-US" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "set-1", "type": "appScreenshotSets", "attributes": { "screenshotDisplayType": "APP_IPHONE_65" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "old-shot", "type": "appScreenshots", "attributes": { "fileName": "01-home.png", "fileSize": 3, "sourceFileChecksum": "0c8e83d7bd4e4d5e9c170932482c3264", "assetDeliveryState": { "state": "UPLOAD_COMPLETE" } } }] }"""), + new SequenceResponse(HttpStatusCode.NoContent, string.Empty), + ScreenshotReservation("fresh-shot", "01-home.png", 3), + ScreenshotCommit("fresh-shot", "01-home.png", "0c8e83d7bd4e4d5e9c170932482c3264"), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "fresh-shot", "type": "appScreenshots", "attributes": { "fileName": "01-home.png", "fileSize": 3, "sourceFileChecksum": "0c8e83d7bd4e4d5e9c170932482c3264", "assetDeliveryState": { "state": "UPLOAD_COMPLETE" } } }] }""")); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + + var result = await new AppStoreConnectScreenshotSyncService(client).SyncAsync(new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + ReplaceExisting = true, + Spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "iphone-6-5" + } + ] + } + }); + + var set = Assert.Single(result.ScreenshotSets); + Assert.Equal(1, set.DeletedCount); + Assert.Equal("fresh-shot", Assert.Single(set.Uploaded).Screenshot.Id); + Assert.Equal(HttpMethod.Delete, handler.Methods[4]); + Assert.Equal(8, handler.RequestUris.Count); + } + finally + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + [Fact] public async Task ScreenshotSyncService_ReplaceExistingRebuildsChangedScreenshotOrder() { @@ -1145,7 +1348,9 @@ public async Task ScreenshotSyncService_ReplaceExistingRebuildsChangedScreenshot ScreenshotReservation("new-first", "01-first.png", 1), ScreenshotCommit("new-first", "01-first.png", "55a54008ad1ba589aa210d2629c1df41"), ScreenshotReservation("new-second", "02-second.png", 1), - ScreenshotCommit("new-second", "02-second.png", "9e688c58a5487b8eaf69c9e1005ad0bf")); + ScreenshotCommit("new-second", "02-second.png", "9e688c58a5487b8eaf69c9e1005ad0bf"), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "new-first", "type": "appScreenshots", "attributes": { "sourceFileChecksum": "55a54008ad1ba589aa210d2629c1df41" } }, { "id": "new-second", "type": "appScreenshots", "attributes": { "sourceFileChecksum": "9e688c58a5487b8eaf69c9e1005ad0bf" } }] }""")); using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; using var client = new AppStoreConnectClient(CreateCredential(), http); @@ -1182,6 +1387,128 @@ public async Task ScreenshotSyncService_ReplaceExistingRebuildsChangedScreenshot } } + [Fact] + public async Task ScreenshotSyncService_ReplaceExistingRejectsRemoteInventoryDriftBeforeDeletion() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "iphone-6-5")); + await File.WriteAllBytesAsync(Path.Combine(folder.FullName, "01-home.png"), new byte[] { 1, 2, 3 }); + var approvedInventorySha256 = AppStoreConnectScreenshotInventory.ComputeSha256( + [ + new AppStoreConnectReleaseScreenshotSetReadiness + { + ScreenshotDisplayType = "APP_IPHONE_65", + ScreenshotSetId = "set-1", + Count = 1, + Screenshots = + [ + new AppStoreConnectReleaseScreenshotAssetReadiness + { + Id = "shot-before", + FileName = "01-home.png", + FileSize = 3, + SourceFileChecksum = "approved-checksum", + AssetDeliveryState = "COMPLETE" + } + ] + } + ]); + var handler = new SequenceHandler( + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "version-1", "type": "appStoreVersions", "attributes": { "versionString": "1.0.0", "platform": "IOS" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "loc-1", "type": "appStoreVersionLocalizations", "attributes": { "locale": "en-US" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "set-1", "type": "appScreenshotSets", "attributes": { "screenshotDisplayType": "APP_IPHONE_65" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "shot-after", "type": "appScreenshots", "attributes": { "fileName": "01-home.png", "fileSize": 3, "sourceFileChecksum": "changed-checksum", "assetDeliveryState": { "state": "COMPLETE" } } }] }""")); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + + var exception = await Assert.ThrowsAsync(() => + new AppStoreConnectScreenshotSyncService(client).SyncAsync(new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + ReplaceExisting = true, + ExpectedRemoteInventorySha256 = approvedInventorySha256, + Spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "iphone-6-5" + } + ] + } + })); + + Assert.Contains("changed after Apple plan approval", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(4, handler.RequestUris.Count); + Assert.All(handler.Methods, method => Assert.Equal(HttpMethod.Get, method)); + } + finally + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task ScreenshotSyncService_ReplaceExistingRejectsConcurrentExtraScreenshotInFinalInventory() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var folder = Directory.CreateDirectory(Path.Combine(root.FullName, "iphone-6-5")); + await File.WriteAllBytesAsync(Path.Combine(folder.FullName, "01-home.png"), new byte[] { 1, 2, 3 }); + var handler = new SequenceHandler( + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "version-1", "type": "appStoreVersions", "attributes": { "versionString": "1.0.0", "platform": "IOS" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "loc-1", "type": "appStoreVersionLocalizations", "attributes": { "locale": "en-US" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "set-1", "type": "appScreenshotSets", "attributes": { "screenshotDisplayType": "APP_IPHONE_65" } }] }"""), + new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "old", "type": "appScreenshots", "attributes": { "sourceFileChecksum": "old" } }] }"""), + new SequenceResponse(HttpStatusCode.NoContent, string.Empty), + ScreenshotReservation("approved", "01-home.png", 3), + ScreenshotCommit("approved", "01-home.png", "5289df737df57326fcdd22597afb1fac"), + new SequenceResponse(HttpStatusCode.OK, + """{ "data": [{ "id": "approved", "type": "appScreenshots", "attributes": { "sourceFileChecksum": "5289df737df57326fcdd22597afb1fac" } }, { "id": "concurrent", "type": "appScreenshots", "attributes": { "sourceFileChecksum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } }] }""")); + using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; + using var client = new AppStoreConnectClient(CreateCredential(), http); + + var exception = await Assert.ThrowsAsync(() => + new AppStoreConnectScreenshotSyncService(client).SyncAsync(new AppStoreConnectScreenshotSyncRequest + { + BaseDirectory = root.FullName, + ReplaceExisting = true, + Spec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "app-1", + VersionString = "1.0.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_65", + Path = "iphone-6-5" + } + ] + } + })); + + Assert.Contains("final remote inventory", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(8, handler.RequestUris.Count); + } + finally + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + private static SequenceResponse ScreenshotReservation(string id, string fileName, long fileSize) => new(HttpStatusCode.Created, $$"""{ "data": { "id": "{{id}}", "type": "appScreenshots", "attributes": { "fileName": "{{fileName}}", "fileSize": {{fileSize}}, "uploadOperations": [] } } }"""); @@ -2423,11 +2750,14 @@ public SequenceHandler(params SequenceResponse[] responses) public List RequestBodyBytes { get; } = new(); + public Action? OnRequest { get; set; } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (_responses.Count == 0) throw new InvalidOperationException("No response was configured for request."); + OnRequest?.Invoke(RequestUris.Count + 1); Methods.Add(request.Method); RequestUris.Add(request.RequestUri!); if (request.Content is not null) diff --git a/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs b/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs index fad1b7c26..40d4cc4ad 100644 --- a/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs +++ b/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs @@ -25,7 +25,7 @@ public void Create_BindsReviewedCaptureFilesWithoutManualHashing() BaseDirectory = root.FullName, AllowedRoot = screenshotFolder.FullName, VersionString = "1.5.0", - SourceCommit = ApprovedSourceCommit, + SourceCommit = new string('a', 64), ApprovedBy = "release-owner", InitiatedBy = "workflow-initiator", ApprovalEvidence = "https://github.example/actions/runs/123", @@ -44,6 +44,7 @@ public void Create_BindsReviewedCaptureFilesWithoutManualHashing() Assert.Equal("release-owner", manifest.ApprovedBy); Assert.Equal("workflow-initiator", manifest.InitiatedBy); Assert.Equal("https://github.example/actions/runs/123", manifest.ApprovalEvidence); + Assert.Equal(new string('a', 64), manifest.SourceCommit); Assert.Equal(2, manifest.SchemaVersion); Assert.Equal("6778025328", manifest.AppId); Assert.Equal(ApplePlatform.iOS, manifest.Platform); @@ -219,6 +220,168 @@ public void Validate_RequiresExactApprovedScreenshotBytes() } } + [Fact] + public void Validate_MatchesRecursiveApprovalEntriesBySetRelativePath() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.ScreenshotApproval", Guid.NewGuid().ToString("N"))); + try + { + var screenshotFolder = Directory.CreateDirectory(Path.Combine(root.FullName, "screenshots")); + var phoneFolder = Directory.CreateDirectory(Path.Combine(screenshotFolder.FullName, "iPhone")); + var tabletFolder = Directory.CreateDirectory(Path.Combine(screenshotFolder.FullName, "iPad")); + var phone = Path.Combine(phoneFolder.FullName, "shot.png"); + var tablet = Path.Combine(tabletFolder.FullName, "shot.png"); + var png = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2n1sAAAAASUVORK5CYII="); + File.WriteAllBytes(phone, png); + File.WriteAllBytes(tablet, png.Concat(new byte[] { 1 }).ToArray()); + var spec = CreateSpec(); + spec.ScreenshotSets[0].Filter = "*/shot.png"; + + var manifest = new AppStoreConnectScreenshotApprovalService().Create( + new AppStoreConnectScreenshotApprovalRequest + { + Spec = spec, + BaseDirectory = root.FullName, + AllowedRoot = screenshotFolder.FullName, + VersionString = "1.5.0", + SourceCommit = ApprovedSourceCommit, + ApprovedBy = "release-owner" + }); + Assert.Equal(2, manifest.Screenshots.Length); + Assert.Contains(manifest.Screenshots, entry => entry.File == "screenshots/iPhone/shot.png"); + Assert.Contains(manifest.Screenshots, entry => entry.File == "screenshots/iPad/shot.png"); + File.WriteAllText(Path.Combine(root.FullName, "approval.json"), JsonSerializer.Serialize(manifest)); + + var result = new AppStoreConnectScreenshotSyncConfigValidator().Validate( + spec, + root.FullName, + expectedSourceCommit: ApprovedSourceCommit); + + Assert.True(result.IsValid, string.Join(Environment.NewLine, result.Messages)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Theory] + [InlineData("deleted")] + [InlineData("filtered")] + [InlineData("limited")] + public void Validate_RequiresEveryApprovedScreenshotToRemainSelected(string mutation) + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.ScreenshotApproval", Guid.NewGuid().ToString("N"))); + try + { + var screenshotFolder = Directory.CreateDirectory(Path.Combine(root.FullName, "screenshots")); + var first = Path.Combine(screenshotFolder.FullName, "01-home.png"); + var second = Path.Combine(screenshotFolder.FullName, "02-rooms.png"); + var png = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2n1sAAAAASUVORK5CYII="); + File.WriteAllBytes(first, png); + File.WriteAllBytes(second, png.Concat(new byte[] { 1 }).ToArray()); + var spec = CreateSpec(); + var manifest = new AppStoreConnectScreenshotApprovalService().Create( + new AppStoreConnectScreenshotApprovalRequest + { + Spec = spec, + BaseDirectory = root.FullName, + AllowedRoot = screenshotFolder.FullName, + VersionString = "1.5.0", + SourceCommit = ApprovedSourceCommit, + ApprovedBy = "release-owner" + }); + Assert.Equal(2, manifest.Screenshots.Length); + File.WriteAllText( + Path.Combine(root.FullName, "approval.json"), + JsonSerializer.Serialize(manifest)); + + switch (mutation) + { + case "deleted": + File.Delete(second); + break; + case "filtered": + spec.ScreenshotSets[0].Filter = "01-*.png"; + break; + case "limited": + spec.ScreenshotSets[0].MaxCount = 1; + break; + default: + throw new ArgumentOutOfRangeException(nameof(mutation)); + } + + var result = new AppStoreConnectScreenshotSyncConfigValidator().Validate( + spec, + root.FullName, + expectedSourceCommit: ApprovedSourceCommit); + + Assert.False(result.IsValid); + Assert.Contains( + result.Messages, + message => message.Contains("approved screenshot", StringComparison.OrdinalIgnoreCase) && + message.Contains("not selected", StringComparison.OrdinalIgnoreCase)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void Validate_UsesOwningVolumeCaseSemanticsForApprovalPaths() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.ScreenshotApproval", Guid.NewGuid().ToString("N"))); + try + { + if (FrameworkCompatibility.GetPathStringComparison(root.FullName) != StringComparison.OrdinalIgnoreCase) + return; + + var screenshotFolder = Directory.CreateDirectory(Path.Combine(root.FullName, "Screenshots")); + var screenshotPath = Path.Combine(screenshotFolder.FullName, "01-Home.png"); + File.WriteAllBytes(screenshotPath, Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2n1sAAAAASUVORK5CYII=")); + var spec = CreateSpec(); + spec.ScreenshotSets[0].Path = "Screenshots"; + File.WriteAllText( + Path.Combine(root.FullName, "approval.json"), + JsonSerializer.Serialize(new AppStoreConnectScreenshotApprovalManifest + { + AppId = spec.AppId, + Platform = spec.Platform, + VersionString = spec.VersionString!, + SourceCommit = ApprovedSourceCommit, + Locale = spec.Locale, + ApprovedAt = DateTimeOffset.Parse("2026-08-12T00:00:00Z"), + ApprovedBy = "release-owner", + Screenshots = + [ + new AppStoreConnectScreenshotApprovalEntry + { + ScreenshotDisplayType = spec.ScreenshotSets[0].ScreenshotDisplayType, + File = "screenshots/01-home.png", + Sha256 = ComputeSha256(screenshotPath), + Width = 1, + Height = 1 + } + ] + })); + + var result = new AppStoreConnectScreenshotSyncConfigValidator().Validate( + spec, + root.FullName, + expectedSourceCommit: ApprovedSourceCommit); + + Assert.True(result.IsValid, string.Join(Environment.NewLine, result.Messages)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + private static AppStoreConnectScreenshotSyncSpec CreateSpec() => new() { diff --git a/PowerForge.Tests/AppStoreConnectScreenshotCmdletSourceCommitTests.cs b/PowerForge.Tests/AppStoreConnectScreenshotCmdletSourceCommitTests.cs new file mode 100644 index 000000000..3a20c0d1c --- /dev/null +++ b/PowerForge.Tests/AppStoreConnectScreenshotCmdletSourceCommitTests.cs @@ -0,0 +1,24 @@ +using System.Management.Automation; +using System.Text.RegularExpressions; +using PSPublishModule; + +namespace PowerForge.Tests; + +public sealed class AppStoreConnectScreenshotCmdletSourceCommitTests +{ + [Theory] + [InlineData(typeof(SyncAppStoreConnectScreenshotsCommand))] + [InlineData(typeof(TestAppStoreConnectScreenshotSyncConfigCommand))] + public void SourceCommitParameter_AcceptsRepositoryNativeSha1AndSha256ObjectIds(Type commandType) + { + var property = commandType.GetProperty("SourceCommit")!; + var validation = Assert.Single(property.GetCustomAttributes(typeof(ValidatePatternAttribute), inherit: true) + .Cast()); + var pattern = validation.RegexPattern; + + Assert.Matches(pattern, new string('a', 40)); + Assert.Matches(pattern, new string('b', 64)); + Assert.DoesNotMatch(new Regex(pattern), new string('c', 39)); + Assert.DoesNotMatch(new Regex(pattern), new string('d', 65)); + } +} diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs new file mode 100644 index 000000000..9e6668af7 --- /dev/null +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs @@ -0,0 +1,172 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleAppArchiveServiceTests +{ + [Fact] + public async Task CreateArchiveAsync_rejects_success_without_bound_archive_output() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); + File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(new SuccessfulRunnerWithoutArchive()).CreateArchiveAsync( + new AppleAppArchiveRequest + { + ProjectPath = project.FullName, + Scheme = "App", + ArchivePath = Path.Combine(root.FullName, "App.xcarchive"), + XcodeBuildExecutable = "xcodebuild-test" + })); + + Assert.Contains("no exact private archive output", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task CreateArchiveAsync_rejects_archive_replaced_after_xcodebuild_completion_boundary() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); + File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); + var archivePath = Path.Combine(root.FullName, "App.xcarchive"); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(new PostCompletionArchiveReplacementRunner()).CreateArchiveAsync( + new AppleAppArchiveRequest + { + ProjectPath = project.FullName, + Scheme = "App", + ArchivePath = archivePath, + XcodeBuildExecutable = "xcodebuild-test" + })); + + Assert.Contains("private Apple archive output changed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task CreateArchiveAsync_rejects_restored_bytes_changed_through_external_alias_after_completion() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); + File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); + var archivePath = Path.Combine(root.FullName, "App.xcarchive"); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(new PostCompletionArchiveAliasMutationRunner()).CreateArchiveAsync( + new AppleAppArchiveRequest + { + ProjectPath = project.FullName, + Scheme = "App", + ArchivePath = archivePath, + XcodeBuildExecutable = "xcodebuild-test" + })); + + Assert.Contains("private Apple archive output changed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task UploadArchiveAsync_rejects_direct_export_replaced_after_xcodebuild_completion_boundary() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var archive = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcarchive")); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(new PostCompletionDirectExportReplacementRunner()).UploadArchiveAsync( + new AppleAppArchiveUploadRequest + { + ArchivePath = archive.FullName, + ExportPath = Path.Combine(root.FullName, "export"), + Destination = "export", + Method = "developer-id", + XcodeBuildExecutable = "xcodebuild-test" + })); + + Assert.Contains("changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Developer ID export", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + private sealed class PostCompletionArchiveReplacementRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var archiveIndex = request.Arguments.ToList().IndexOf("-archivePath"); + var archive = Directory.CreateDirectory(request.Arguments[archiveIndex + 1]); + var payload = Path.Combine(archive.FullName, "payload"); + File.WriteAllText(payload, "archive produced by xcodebuild"); + var result = new ProcessRunResult(0, "ok", string.Empty, request.FileName, TimeSpan.Zero, false); + request.InvokeCompletionBoundary(result); + File.WriteAllText(payload, "concurrent replacement"); + return Task.FromResult(result); + } + } + + private sealed class SuccessfulRunnerWithoutArchive : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(new ProcessRunResult(0, "ok", string.Empty, request.FileName, TimeSpan.Zero, false)); + } + + private sealed class PostCompletionArchiveAliasMutationRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var archiveIndex = request.Arguments.ToList().IndexOf("-archivePath"); + var archive = Directory.CreateDirectory(request.Arguments[archiveIndex + 1]); + var payload = Path.Combine(archive.FullName, "payload"); + File.WriteAllText(payload, "archive produced by xcodebuild"); + var result = new ProcessRunResult(0, "ok", string.Empty, request.FileName, TimeSpan.Zero, false); + request.InvokeCompletionBoundary(result); + + var aliasRoot = Directory.CreateDirectory(Path.Combine(Directory.GetParent(archive.FullName)!.FullName, "external-alias")); + var alias = Path.Combine(aliasRoot.FullName, "payload-alias"); + TestFileLink.CreateHardLink(alias, payload); + File.WriteAllText(alias, "transient attacker bytes"); + File.WriteAllText(alias, "archive produced by xcodebuild"); + File.Delete(alias); + return Task.FromResult(result); + } + } + + private sealed class PostCompletionDirectExportReplacementRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var exportIndex = request.Arguments.ToList().IndexOf("-exportPath"); + var artifact = Directory.CreateDirectory(Path.Combine(request.Arguments[exportIndex + 1], "App.app")); + var payload = Path.Combine(artifact.FullName, "payload"); + File.WriteAllText(payload, "export produced by xcodebuild"); + var result = new ProcessRunResult(0, "ok", string.Empty, request.FileName, TimeSpan.Zero, false); + request.InvokeCompletionBoundary(result); + File.WriteAllText(payload, "concurrent replacement"); + return Task.FromResult(result); + } + } +} diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs new file mode 100644 index 000000000..0363dcbf3 --- /dev/null +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs @@ -0,0 +1,421 @@ +using System.Diagnostics; + +namespace PowerForge.Tests; + +public sealed partial class AppleAppArchiveServiceTests +{ + [Fact] + public async Task CreateArchiveAsync_exact_source_builds_from_validated_private_package_checkouts() + { + if (!OperatingSystem.IsMacOS()) return; + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); + File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); + RunGit(root.FullName, "init", "--quiet"); + var runner = new ExactPackageProcessRunner(root.FullName); + WritePackageLock(root.FullName, runner.RemoteUrl, runner.ApprovedRevision); + CommitApprovedInputs(root.FullName); + + var result = await new AppleAppArchiveService(runner).CreateArchiveAsync(new AppleAppArchiveRequest + { + ProjectPath = project.FullName, + Scheme = "App", + ArchivePath = Path.Combine(root.FullName, "App.xcarchive"), + RequireExactPackageSnapshot = true + }); + + Assert.True(result.Succeeded); + Assert.Equal(64, result.ArchiveSha256?.Length); + Assert.Equal(2, runner.Requests.Count); + var resolve = runner.Requests[0]; + var archive = runner.Requests[1]; + Assert.Contains("-resolvePackageDependencies", resolve.Arguments); + Assert.Contains("-clonedSourcePackagesDirPath", resolve.Arguments); + Assert.Contains("-onlyUsePackageVersionsFromResolvedFile", resolve.Arguments); + Assert.Equal("1", resolve.EnvironmentVariables!["GIT_CONFIG_NOSYSTEM"]); + Assert.Equal("/usr/bin:/bin:/usr/sbin:/sbin", resolve.EnvironmentVariables["PATH"]); + Assert.False(resolve.InheritEnvironment); + Assert.Contains("-clonedSourcePackagesDirPath", archive.Arguments); + Assert.Contains("-derivedDataPath", resolve.Arguments); + Assert.Contains("-derivedDataPath", archive.Arguments); + Assert.Equal("/usr/bin:/bin:/usr/sbin:/sbin", archive.EnvironmentVariables!["PATH"]); + Assert.False(archive.InheritEnvironment); + Assert.Equal( + resolve.Arguments[Array.IndexOf(resolve.Arguments.ToArray(), "-clonedSourcePackagesDirPath") + 1], + archive.Arguments[Array.IndexOf(archive.Arguments.ToArray(), "-clonedSourcePackagesDirPath") + 1]); + Assert.NotEqual( + resolve.Arguments[Array.IndexOf(resolve.Arguments.ToArray(), "-derivedDataPath") + 1], + archive.Arguments[Array.IndexOf(archive.Arguments.ToArray(), "-derivedDataPath") + 1]); + Assert.False(Directory.Exists(runner.SourcePackagesRoot)); + Assert.False(Directory.Exists(runner.DerivedDataRoot)); + } + finally + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task CreateArchiveAsync_exact_source_rejects_transient_package_checkout_mutation() + { + if (!OperatingSystem.IsMacOS()) return; + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); + File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); + RunGit(root.FullName, "init", "--quiet"); + var runner = new ExactPackageProcessRunner(root.FullName, mutateDuringArchive: true); + WritePackageLock(root.FullName, runner.RemoteUrl, runner.ApprovedRevision); + CommitApprovedInputs(root.FullName); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(runner).CreateArchiveAsync(new AppleAppArchiveRequest + { + ProjectPath = project.FullName, + Scheme = "App", + ArchivePath = Path.Combine(root.FullName, "App.xcarchive"), + RequireExactPackageSnapshot = true + })); + + Assert.Contains("materialized Swift package root changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(Directory.Exists(runner.SourcePackagesRoot)); + } + finally + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task CreateArchiveAsync_exact_source_rejects_restored_package_bytes_changed_through_removed_hard_link_alias() + { + if (!OperatingSystem.IsMacOS()) return; + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); + File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); + RunGit(root.FullName, "init", "--quiet"); + var runner = new ExactPackageProcessRunner(root.FullName, mutatePackageViaHardLinkDuringArchive: true); + WritePackageLock(root.FullName, runner.RemoteUrl, runner.ApprovedRevision); + CommitApprovedInputs(root.FullName); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(runner).CreateArchiveAsync(new AppleAppArchiveRequest + { + ProjectPath = project.FullName, + Scheme = "App", + ArchivePath = Path.Combine(root.FullName, "App.xcarchive"), + RequireExactPackageSnapshot = true + })); + + Assert.Contains("hard-link alias", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(Directory.Exists(runner.SourcePackagesRoot)); + } + finally + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task CreateArchiveAsync_exact_source_rejects_materialized_package_revision_outside_lock() + { + if (!OperatingSystem.IsMacOS()) return; + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); + File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); + RunGit(root.FullName, "init", "--quiet"); + var runner = new ExactPackageProcessRunner(root.FullName, materializeWrongRevision: true); + WritePackageLock(root.FullName, runner.RemoteUrl, runner.ApprovedRevision); + CommitApprovedInputs(root.FullName); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(runner).CreateArchiveAsync(new AppleAppArchiveRequest + { + ProjectPath = project.FullName, + Scheme = "App", + ArchivePath = Path.Combine(root.FullName, "App.xcarchive"), + RequireExactPackageSnapshot = true + })); + + Assert.Contains("approved revision", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task CreateArchiveAsync_exact_source_rejects_materialized_binary_artifact_mutation() + { + if (!OperatingSystem.IsMacOS()) return; + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); + File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); + RunGit(root.FullName, "init", "--quiet"); + var runner = new ExactPackageProcessRunner( + root.FullName, + materializeBinaryArtifact: true, + mutateBinaryArtifactDuringArchive: true); + WritePackageLock(root.FullName, runner.RemoteUrl, runner.ApprovedRevision); + CommitApprovedInputs(root.FullName); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(runner).CreateArchiveAsync(new AppleAppArchiveRequest + { + ProjectPath = project.FullName, + Scheme = "App", + ArchivePath = Path.Combine(root.FullName, "App.xcarchive"), + RequireExactPackageSnapshot = true + })); + + Assert.Contains("materialized Swift package root changed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task CreateArchiveAsync_exact_source_rejects_binary_artifact_replacement_after_resolver_completion() + { + if (!OperatingSystem.IsMacOS()) return; + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); + File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); + RunGit(root.FullName, "init", "--quiet"); + var runner = new ExactPackageProcessRunner( + root.FullName, + materializeBinaryArtifact: true, + replaceBinaryArtifactAfterResolveCompletion: true); + WritePackageLock(root.FullName, runner.RemoteUrl, runner.ApprovedRevision); + CommitApprovedInputs(root.FullName); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(runner).CreateArchiveAsync(new AppleAppArchiveRequest + { + ProjectPath = project.FullName, + Scheme = "App", + ArchivePath = Path.Combine(root.FullName, "App.xcarchive"), + RequireExactPackageSnapshot = true + })); + + Assert.Contains("materialized Swift package root changed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + private sealed class ExactPackageProcessRunner : IProcessRunner + { + private readonly bool _mutateDuringArchive; + private readonly bool _materializeWrongRevision; + private readonly bool _materializeBinaryArtifact; + private readonly bool _mutateBinaryArtifactDuringArchive; + private readonly bool _replaceBinaryArtifactAfterResolveCompletion; + private readonly bool _mutatePackageViaHardLinkDuringArchive; + private readonly string _fixtureRoot; + private readonly string _remoteSourceRoot; + + internal ExactPackageProcessRunner( + string fixtureRoot, + bool mutateDuringArchive = false, + bool materializeWrongRevision = false, + bool materializeBinaryArtifact = false, + bool mutateBinaryArtifactDuringArchive = false, + bool replaceBinaryArtifactAfterResolveCompletion = false, + bool mutatePackageViaHardLinkDuringArchive = false) + { + _fixtureRoot = fixtureRoot; + _mutateDuringArchive = mutateDuringArchive; + _materializeWrongRevision = materializeWrongRevision; + _materializeBinaryArtifact = materializeBinaryArtifact; + _mutateBinaryArtifactDuringArchive = mutateBinaryArtifactDuringArchive; + _replaceBinaryArtifactAfterResolveCompletion = replaceBinaryArtifactAfterResolveCompletion; + _mutatePackageViaHardLinkDuringArchive = mutatePackageViaHardLinkDuringArchive; + _remoteSourceRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "RemoteShared")).FullName; + RunGit(_remoteSourceRoot, "init", "--quiet"); + RunGit(_remoteSourceRoot, "config", "user.name", "PowerForge Tests"); + RunGit(_remoteSourceRoot, "config", "user.email", "powerforge-tests@example.invalid"); + File.WriteAllText( + Path.Combine(_remoteSourceRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Shared\")\n"); + RunGit(_remoteSourceRoot, "add", "."); + RunGit(_remoteSourceRoot, "commit", "--quiet", "-m", "Package fixture"); + ApprovedRevision = ReadGit(_remoteSourceRoot, "rev-parse", "HEAD").Trim(); + } + + internal List Requests { get; } = new(); + + internal string SourcePackagesRoot { get; private set; } = string.Empty; + + internal string DerivedDataRoot { get; private set; } = string.Empty; + + internal string RemoteUrl { get; } = "https://example.invalid/Shared.git"; + + internal string ApprovedRevision { get; } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + Requests.Add(request); + if (request.Arguments.Contains("-resolvePackageDependencies")) + { + var index = Array.IndexOf(request.Arguments.ToArray(), "-clonedSourcePackagesDirPath"); + SourcePackagesRoot = request.Arguments[index + 1]; + var derivedIndex = Array.IndexOf(request.Arguments.ToArray(), "-derivedDataPath"); + DerivedDataRoot = request.Arguments[derivedIndex + 1]; + var checkouts = Directory.CreateDirectory(Path.Combine(SourcePackagesRoot, "checkouts")).FullName; + var checkout = Path.Combine(checkouts, "Shared"); + RunGit(checkouts, "clone", "--quiet", "--no-hardlinks", _remoteSourceRoot, checkout); + RunGit(checkout, "remote", "set-url", "origin", RemoteUrl); + if (_materializeWrongRevision) + { + RunGit(checkout, "config", "user.name", "PowerForge Tests"); + RunGit(checkout, "config", "user.email", "powerforge-tests@example.invalid"); + File.AppendAllText(Path.Combine(checkout, "Package.swift"), "// replacement\n"); + RunGit(checkout, "add", "."); + RunGit(checkout, "commit", "--quiet", "-m", "Unapproved replacement"); + } + if (_materializeBinaryArtifact) + { + var artifact = Directory.CreateDirectory(Path.Combine(SourcePackagesRoot, "artifacts", "Shared", "Framework.xcframework")); + File.WriteAllText(Path.Combine(artifact.FullName, "payload"), "approved binary artifact"); + } + } + else if (_mutateDuringArchive) + { + var manifest = Path.Combine(SourcePackagesRoot, "checkouts", "Shared", "Package.swift"); + var original = File.ReadAllText(manifest); + File.WriteAllText(manifest, original + "// injected\n"); + File.WriteAllText(manifest, original); + } + else if (_mutatePackageViaHardLinkDuringArchive) + { + var manifest = Path.Combine(SourcePackagesRoot, "checkouts", "Shared", "Package.swift"); + var original = File.ReadAllText(manifest); + var alias = Path.Combine(_fixtureRoot, $"package-alias-{Guid.NewGuid():N}"); + TestFileLink.CreateHardLink(alias, manifest); + try + { + File.WriteAllText(alias, original + "// injected through external alias\n"); + File.WriteAllText(alias, original); + } + finally + { + File.Delete(alias); + } + } + else if (_mutateBinaryArtifactDuringArchive) + { + var payload = Path.Combine(SourcePackagesRoot, "artifacts", "Shared", "Framework.xcframework", "payload"); + File.WriteAllText(payload, "replacement binary artifact"); + } + + if (!request.Arguments.Contains("-resolvePackageDependencies")) + { + var archiveIndex = Array.IndexOf(request.Arguments.ToArray(), "-archivePath"); + var archive = Directory.CreateDirectory(request.Arguments[archiveIndex + 1]); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "approved archive"); + } + + var result = new ProcessRunResult( + 0, + "ok", + string.Empty, + request.FileName, + TimeSpan.FromMilliseconds(1), + false); + if (request.Arguments.Contains("-resolvePackageDependencies") && + _replaceBinaryArtifactAfterResolveCompletion) + { + request.InvokeCompletionBoundary(result); + var payload = Path.Combine(SourcePackagesRoot, "artifacts", "Shared", "Framework.xcframework", "payload"); + File.WriteAllText(payload, "replacement after resolver completion"); + } + return Task.FromResult(result); + } + } + + private static void WritePackageLock(string root, string url, string revision) + { + File.WriteAllText( + Path.Combine(root, "Package.resolved"), + System.Text.Json.JsonSerializer.Serialize(new + { + pins = new[] + { + new + { + identity = "shared", + kind = "remoteSourceControl", + location = url, + state = new { revision, version = "1.0.0" } + } + }, + version = 3 + })); + } + + private static void CommitApprovedInputs(string root) + { + RunGit(root, "config", "user.name", "PowerForge Tests"); + RunGit(root, "config", "user.email", "powerforge-tests@example.invalid"); + RunGit(root, "add", "App.xcodeproj/project.pbxproj", "Package.resolved"); + RunGit(root, "commit", "--quiet", "-m", "Approved exact inputs"); + } + + private static string ReadGit(string workingDirectory, params string[] arguments) + { + var startInfo = new ProcessStartInfo("git") + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Unable to start git fixture process."); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"git {string.Join(' ', arguments)} failed: {output}{error}"); + return output; + } + + private static void RunGit(string workingDirectory, params string[] arguments) + { + var startInfo = new ProcessStartInfo("git") + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Unable to start git fixture process."); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"git {string.Join(' ', arguments)} failed: {output}{error}"); + } +} diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.TrustedExecution.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.TrustedExecution.cs new file mode 100644 index 000000000..c5279cfd0 --- /dev/null +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.TrustedExecution.cs @@ -0,0 +1,62 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleAppArchiveServiceTests +{ + [Fact] + public async Task UploadArchiveAsync_exact_source_uses_system_xcodebuild_without_parent_environment() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var archive = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcarchive")); + var runner = new CapturingProcessRunner(); + + var result = await new AppleAppArchiveService(runner).UploadArchiveAsync(new AppleAppArchiveUploadRequest + { + ArchivePath = archive.FullName, + ExportPath = Path.Combine(root.FullName, "export"), + XcodeBuildExecutable = "/usr/bin/xcodebuild", + RequireTrustedSystemTools = true + }); + + Assert.True(result.Succeeded); + var request = Assert.Single(runner.Requests); + Assert.Equal("/usr/bin/xcodebuild", request.FileName); + Assert.False(request.InheritEnvironment); + Assert.Equal("/usr/bin:/bin:/usr/sbin:/sbin", request.EnvironmentVariables?["PATH"]); + Assert.False(request.EnvironmentVariables?.ContainsKey("DEVELOPER_DIR")); + Assert.False(request.EnvironmentVariables?.ContainsKey("TOOLCHAINS")); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task UploadArchiveAsync_exact_source_rejects_custom_xcodebuild() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var archive = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcarchive")); + var runner = new CapturingProcessRunner(); + + var exception = await Assert.ThrowsAsync(() => + new AppleAppArchiveService(runner).UploadArchiveAsync(new AppleAppArchiveUploadRequest + { + ArchivePath = archive.FullName, + ExportPath = Path.Combine(root.FullName, "export"), + XcodeBuildExecutable = "/tmp/xcodebuild", + RequireTrustedSystemTools = true + })); + + Assert.Contains("system Xcode build tool", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(runner.Requests); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } +} diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.cs index 53087255f..0b6995a78 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.cs @@ -9,7 +9,7 @@ namespace PowerForge.Tests; -public sealed class AppleAppArchiveServiceTests +public sealed partial class AppleAppArchiveServiceTests { [Fact] public async Task CreateArchiveAsync_resolves_default_xcodebuild_to_system_binary_on_macOS() @@ -277,6 +277,41 @@ public async Task UploadArchiveAsync_writes_export_options_and_runs_export_archi } } + [Fact] + public async Task UploadArchiveAsync_captures_direct_export_identity_at_process_boundary() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var archive = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcarchive")); + var exportPath = Path.Combine(root.FullName, "export"); + var runner = new CapturingProcessRunner(beforeResult: request => + { + var exportIndex = request.Arguments.ToList().IndexOf("-exportPath"); + var artifact = Directory.CreateDirectory(Path.Combine(request.Arguments[exportIndex + 1], "App.app")); + File.WriteAllText(Path.Combine(artifact.FullName, "payload"), "signed export"); + }); + + var result = await new AppleAppArchiveService(runner).UploadArchiveAsync(new AppleAppArchiveUploadRequest + { + ArchivePath = archive.FullName, + ExportPath = exportPath, + Destination = "export", + Method = "developer-id" + }); + + Assert.True(result.Succeeded); + Assert.Equal(Path.Combine(exportPath, "App.app"), result.ExportArtifactPath); + Assert.Equal( + AppleNotarizationService.ComputeArtifactSha256(result.ExportArtifactPath!), + result.ExportArtifactSha256); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + [Fact] public async Task UploadArchiveAsync_captures_build_upload_id_from_distribution_log() { @@ -532,10 +567,12 @@ public async Task UploadArchiveAsync_blocks_upload_when_required_privacy_purpose private sealed class CapturingProcessRunner : IProcessRunner { private readonly ProcessRunResult _result; + private readonly Action? _beforeResult; - public CapturingProcessRunner(ProcessRunResult? result = null) + public CapturingProcessRunner(ProcessRunResult? result = null, Action? beforeResult = null) { _result = result ?? new ProcessRunResult(0, "ok", string.Empty, "xcodebuild", TimeSpan.FromMilliseconds(1), false); + _beforeResult = beforeResult; } public List Requests { get; } = new(); @@ -543,6 +580,23 @@ public CapturingProcessRunner(ProcessRunResult? result = null) public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) { Requests.Add(request); + request.InvokeStartBoundary(); + _beforeResult?.Invoke(request); + if (_result.Succeeded) + { + for (var index = 0; index + 1 < request.Arguments.Count; index++) + { + if (!request.Arguments[index].Equals("-archivePath", StringComparison.Ordinal) || + !request.Arguments.Contains("archive")) + { + continue; + } + + var archive = Directory.CreateDirectory(request.Arguments[index + 1]); + File.WriteAllText(Path.Combine(archive.FullName, "archive.bin"), "archive"); + break; + } + } return Task.FromResult(_result); } } diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs new file mode 100644 index 000000000..99528e452 --- /dev/null +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -0,0 +1,498 @@ +namespace PowerForge.Tests; + +public sealed class AppleArchiveBuildSnapshotTests +{ + [Fact] + public void Publish_rejects_successful_adapter_without_private_archive() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var destination = Path.Combine(root.FullName, "App.xcarchive"); + using var snapshot = AppleArchiveBuildSnapshot.Create(destination); + + var exception = Assert.Throws(() => snapshot.Publish(destination)); + + Assert.Contains("did not produce its private archive output", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(Directory.Exists(destination)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void CopyDirectory_restores_read_only_directory_modes_after_copying_descendants() + { +#if NET8_0_OR_GREATER + if (OperatingSystem.IsWindows()) + return; + + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + var source = Directory.CreateDirectory(Path.Combine(root.FullName, "source")); + var readOnly = Directory.CreateDirectory(Path.Combine(source.FullName, "Contents")); + File.WriteAllText(Path.Combine(readOnly.FullName, "payload"), "approved artifact"); + File.SetUnixFileMode( + readOnly.FullName, + UnixFileMode.UserRead | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + var destination = Path.Combine(root.FullName, "destination"); + try + { + AppleArtifactCopy.CopyDirectory(source.FullName, destination); + + Assert.Equal("approved artifact", File.ReadAllText(Path.Combine(destination, "Contents", "payload"))); + Assert.Equal(File.GetUnixFileMode(readOnly.FullName), File.GetUnixFileMode(Path.Combine(destination, "Contents"))); + } + finally + { + try + { + File.SetUnixFileMode(readOnly.FullName, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var copiedReadOnly = Path.Combine(destination, "Contents"); + if (Directory.Exists(copiedReadOnly)) + File.SetUnixFileMode(copiedReadOnly, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + root.Delete(recursive: true); + } + catch { } + } +#endif + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MoveExistingPathToBackupIfUnchanged_preserves_concurrent_replacement(bool directory) + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var destination = Path.Combine(root.FullName, directory ? "App.xcarchive" : "App.zip"); + if (directory) + { + Directory.CreateDirectory(destination); + File.WriteAllText(Path.Combine(destination, "payload"), "observed artifact"); + } + else + { + File.WriteAllText(destination, "observed artifact"); + } + var observed = AppleArtifactCopy.CaptureRegularPathIdentity(destination, "Apple artifact"); + if (directory) + { + Directory.Delete(destination, recursive: true); + Directory.CreateDirectory(destination); + File.WriteAllText(Path.Combine(destination, "payload"), "concurrent replacement"); + } + else + { + File.WriteAllText(destination, "concurrent replacement"); + } + var backup = Path.Combine(root.FullName, ".backup", Path.GetFileName(destination)); + + var exception = Assert.Throws(() => + AppleArtifactCopy.MoveExistingPathToBackupIfUnchanged( + destination, + backup, + observed, + "Apple artifact")); + + Assert.Contains("changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(Directory.Exists(backup)); + Assert.False(File.Exists(backup)); + var payload = directory + ? File.ReadAllText(Path.Combine(destination, "payload")) + : File.ReadAllText(destination); + Assert.Equal("concurrent replacement", payload); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void RemoveBackupIfUnchanged_preserves_concurrent_backup_replacement() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var backup = Directory.CreateDirectory(Path.Combine(root.FullName, ".backup")); + File.WriteAllText(Path.Combine(backup.FullName, "payload"), "observed artifact"); + var observed = AppleArtifactCopy.CaptureRegularPathIdentity(backup.FullName, "Apple artifact")!; + File.WriteAllText(Path.Combine(backup.FullName, "payload"), "concurrent replacement"); + var quarantine = Path.Combine(root.FullName, ".quarantine"); + + var exception = Assert.Throws(() => + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup.FullName, + quarantine, + observed, + "Apple artifact")); + + Assert.Contains("retained", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("concurrent replacement", File.ReadAllText(Path.Combine(backup.FullName, "payload"))); + Assert.False(Directory.Exists(quarantine)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void RemoveBackupIfUnchanged_deletes_verified_backup_with_read_only_nested_directory() + { +#if NET8_0_OR_GREATER + if (OperatingSystem.IsWindows()) + return; + + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + var backupParent = Directory.CreateDirectory(Path.Combine(root.FullName, ".App.powerforge-backup-test")); + var backup = Directory.CreateDirectory(Path.Combine(backupParent.FullName, "App.xcarchive")); + var readOnly = Directory.CreateDirectory(Path.Combine(backup.FullName, "Products")); + File.WriteAllText(Path.Combine(readOnly.FullName, "payload"), "previous archive"); + File.SetUnixFileMode( + readOnly.FullName, + UnixFileMode.UserRead | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + var observed = AppleArtifactCopy.CaptureRegularPathIdentity(backup.FullName, "Apple archive")!; + var quarantine = Path.Combine(root.FullName, ".App.powerforge-rollback-test"); + try + { + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup.FullName, + quarantine, + observed, + "Apple archive"); + + Assert.False(Directory.Exists(backupParent.FullName)); + Assert.False(Directory.Exists(quarantine)); + } + finally + { + try + { + if (Directory.Exists(readOnly.FullName)) + File.SetUnixFileMode(readOnly.FullName, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + root.Delete(recursive: true); + } + catch { } + } +#endif + } + + [Fact] + public void RemoveBackupIfUnchanged_does_not_fail_after_verified_backup_deletion_when_parent_cleanup_is_denied() + { +#if NET8_0_OR_GREATER + if (OperatingSystem.IsWindows()) + return; + + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + var backupParent = Directory.CreateDirectory(Path.Combine(root.FullName, ".App.powerforge-backup-test")); + var backup = Directory.CreateDirectory(Path.Combine(backupParent.FullName, "App.xcarchive")); + File.WriteAllText(Path.Combine(backup.FullName, "payload"), "previous archive"); + var observed = AppleArtifactCopy.CaptureRegularPathIdentity(backup.FullName, "Apple archive")!; + var quarantineRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + var quarantine = Path.Combine(quarantineRoot.FullName, ".App.powerforge-rollback-test"); + try + { + File.SetUnixFileMode( + root.FullName, + UnixFileMode.UserRead | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup.FullName, + quarantine, + observed, + "Apple archive"); + + Assert.False(Directory.Exists(backup.FullName)); + Assert.True(Directory.Exists(backupParent.FullName)); + Assert.False(Directory.Exists(quarantine)); + } + finally + { + try + { + File.SetUnixFileMode(root.FullName, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + root.Delete(recursive: true); + quarantineRoot.Delete(recursive: true); + } + catch { } + } +#endif + } + + [Fact] + public void RemoveBackupIfUnchanged_does_not_roll_back_after_quarantine_cleanup_is_denied() + { +#if NET8_0_OR_GREATER + if (OperatingSystem.IsWindows()) + return; + + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + var backupParent = Directory.CreateDirectory(Path.Combine(root.FullName, ".App.powerforge-backup-test")); + var backup = Directory.CreateDirectory(Path.Combine(backupParent.FullName, "App.xcarchive")); + File.WriteAllText(Path.Combine(backup.FullName, "payload"), "previous archive"); + var observed = AppleArtifactCopy.CaptureRegularPathIdentity(backup.FullName, "Apple archive")!; + var quarantineRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + var quarantine = Directory.CreateDirectory(Path.Combine(quarantineRoot.FullName, ".App.powerforge-rollback-test")); + try + { + File.SetUnixFileMode( + quarantineRoot.FullName, + UnixFileMode.UserRead | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup.FullName, + quarantine.FullName, + observed, + "Apple archive"); + + Assert.False(Directory.Exists(backup.FullName)); + Assert.True(Directory.Exists(quarantine.FullName)); + Assert.Empty(Directory.EnumerateFileSystemEntries(quarantine.FullName)); + } + finally + { + try + { + File.SetUnixFileMode(quarantineRoot.FullName, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + root.Delete(recursive: true); + quarantineRoot.Delete(recursive: true); + } + catch { } + } +#endif + } + + [Fact] + public void DirectExport_publish_rejects_artifact_replaced_after_xcodebuild_identity_was_observed() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var destination = Path.Combine(root.FullName, "export"); + using var snapshot = AppleDirectExportSnapshot.Create(); + var artifact = Directory.CreateDirectory(Path.Combine(snapshot.ExportPath, "App.app")); + var payload = Path.Combine(artifact.FullName, "payload"); + File.WriteAllText(payload, "approved export"); + var expected = AppleNotarizationService.ComputeArtifactSha256(artifact.FullName); + snapshot.BindProducedArtifact(artifact.FullName, expected); + File.WriteAllText(payload, "replacement export"); + + var exception = Assert.Throws(() => snapshot.Publish(destination)); + + Assert.Contains("changed after xcodebuild completed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(Directory.Exists(destination)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void DirectExport_publish_retains_previous_export_until_release_completion() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var destination = Directory.CreateDirectory(Path.Combine(root.FullName, "export")); + File.WriteAllText(Path.Combine(destination.FullName, "payload"), "previous export"); + using var snapshot = AppleDirectExportSnapshot.Create(); + var artifact = Directory.CreateDirectory(Path.Combine(snapshot.ExportPath, "CasaRay.app")); + File.WriteAllText(Path.Combine(artifact.FullName, "payload"), "candidate export"); + snapshot.BindProducedArtifact(artifact.FullName, AppleNotarizationService.ComputeArtifactSha256(artifact.FullName)); + + var published = snapshot.Publish(destination.FullName); + var backupParent = Assert.Single(Directory.EnumerateDirectories( + root.FullName, + ".export.powerforge-backup-*", + SearchOption.TopDirectoryOnly)); + Assert.Equal("previous export", File.ReadAllText(Path.Combine(backupParent, "export", "payload"))); + Assert.Equal("candidate export", File.ReadAllText(Path.Combine(published.ArtifactPath, "payload"))); + + snapshot.CommitPublication(); + + Assert.False(Directory.Exists(backupParent)); + Assert.Equal("candidate export", File.ReadAllText(Path.Combine(published.ArtifactPath, "payload"))); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void Publish_rejects_archive_replaced_after_xcodebuild_identity_was_observed() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var destination = Path.Combine(root.FullName, "App.xcarchive"); + using var snapshot = AppleArchiveBuildSnapshot.Create(destination); + var archive = Directory.CreateDirectory(snapshot.ArchivePath); + var payload = Path.Combine(archive.FullName, "payload"); + File.WriteAllText(payload, "approved archive"); + var expected = AppleNotarizationService.ComputeArtifactSha256(snapshot.ArchivePath); + File.WriteAllText(payload, "replacement archive"); + + var exception = Assert.Throws(() => snapshot.Publish(destination, expected)); + + Assert.Contains("changed after xcodebuild completed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(Directory.Exists(destination)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void RestoreDirectoryBackup_retains_previous_artifact_when_destination_was_recreated() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var destination = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcarchive")); + File.WriteAllText(Path.Combine(destination.FullName, "payload"), "concurrent artifact"); + var backup = Directory.CreateDirectory(Path.Combine(root.FullName, ".App.xcarchive.powerforge-backup-test")); + File.WriteAllText(Path.Combine(backup.FullName, "payload"), "previous artifact"); + + var exception = Assert.Throws(() => + AppleArtifactCopy.RestoreDirectoryBackup(destination.FullName, backup.FullName)); + + Assert.Contains("retained", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("concurrent artifact", File.ReadAllText(Path.Combine(destination.FullName, "payload"))); + Assert.Equal("previous artifact", File.ReadAllText(Path.Combine(backup.FullName, "payload"))); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void RollbackPublication_preserves_concurrently_replaced_archive_and_previous_backup() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var approved = Directory.CreateDirectory(Path.Combine(root.FullName, "approved")); + File.WriteAllText(Path.Combine(approved.FullName, "payload"), "published archive"); + var publishedSha256 = AppleNotarizationService.ComputeArtifactSha256(approved.FullName); + approved.Delete(recursive: true); + var destination = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcarchive")); + File.WriteAllText(Path.Combine(destination.FullName, "payload"), "concurrent archive"); + var backup = Directory.CreateDirectory(Path.Combine(root.FullName, ".App.xcarchive.powerforge-backup-test")); + File.WriteAllText(Path.Combine(backup.FullName, "payload"), "previous archive"); + var rollbackCandidate = Path.Combine(root.FullName, ".App.xcarchive.powerforge-failed-test"); + + var exception = Assert.Throws(() => + AppleArchiveBuildSnapshot.RollbackPublication( + destination.FullName, + backup.FullName, + rollbackCandidate, + publishedSha256, + published: true, + movedExisting: true)); + + Assert.Contains("no unrecognized archive bytes were deleted", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("concurrent archive", File.ReadAllText(Path.Combine(destination.FullName, "payload"))); + Assert.Equal("previous archive", File.ReadAllText(Path.Combine(backup.FullName, "payload"))); + Assert.False(Directory.Exists(rollbackCandidate)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void DirectExportRollback_preserves_concurrently_replaced_export_and_previous_backup() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var approved = Directory.CreateDirectory(Path.Combine(root.FullName, "approved-export")); + File.WriteAllText(Path.Combine(approved.FullName, "payload"), "published export"); + var publishedSha256 = AppleNotarizationService.ComputeArtifactSha256(approved.FullName); + approved.Delete(recursive: true); + var destination = Directory.CreateDirectory(Path.Combine(root.FullName, "export")); + File.WriteAllText(Path.Combine(destination.FullName, "payload"), "concurrent export"); + var backup = Directory.CreateDirectory(Path.Combine(root.FullName, ".export.powerforge-backup-test")); + File.WriteAllText(Path.Combine(backup.FullName, "payload"), "previous export"); + var rollbackCandidate = Path.Combine(root.FullName, ".export.powerforge-failed-test"); + + var exception = Assert.Throws(() => + AppleDirectExportSnapshot.RollbackPublication( + destination.FullName, + backup.FullName, + rollbackCandidate, + publishedSha256, + published: true, + movedExisting: true)); + + Assert.Contains("no unrecognized export bytes were deleted", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("concurrent export", File.ReadAllText(Path.Combine(destination.FullName, "payload"))); + Assert.Equal("previous export", File.ReadAllText(Path.Combine(backup.FullName, "payload"))); + Assert.False(Directory.Exists(rollbackCandidate)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void DirectExportRollback_preserves_concurrently_replaced_directory_link_without_traversing_it() + { + if (OperatingSystem.IsWindows()) + return; + + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + try + { + var approved = Directory.CreateDirectory(Path.Combine(root.FullName, "approved-export")); + File.WriteAllText(Path.Combine(approved.FullName, "payload"), "published export"); + var publishedSha256 = AppleNotarizationService.ComputeArtifactSha256(approved.FullName); + approved.Delete(recursive: true); + var concurrentTarget = Directory.CreateDirectory(Path.Combine(root.FullName, "concurrent-export")); + File.WriteAllText(Path.Combine(concurrentTarget.FullName, "payload"), "concurrent export"); + var destination = Path.Combine(root.FullName, "export"); + Directory.CreateSymbolicLink(destination, concurrentTarget.FullName); + var backup = Directory.CreateDirectory(Path.Combine(root.FullName, ".export.powerforge-backup-test")); + File.WriteAllText(Path.Combine(backup.FullName, "payload"), "previous export"); + var rollbackCandidate = Path.Combine(root.FullName, ".export.powerforge-failed-test"); + + var exception = Assert.Throws(() => + AppleDirectExportSnapshot.RollbackPublication( + destination, + backup.FullName, + rollbackCandidate, + publishedSha256, + published: true, + movedExisting: true)); + + Assert.Contains("no unrecognized export bytes were deleted", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(concurrentTarget.FullName, new DirectoryInfo(destination).LinkTarget); + Assert.Equal("concurrent export", File.ReadAllText(Path.Combine(concurrentTarget.FullName, "payload"))); + Assert.Equal("previous export", File.ReadAllText(Path.Combine(backup.FullName, "payload"))); + Assert.False(Directory.Exists(rollbackCandidate)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } +} diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs new file mode 100644 index 000000000..490a36067 --- /dev/null +++ b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs @@ -0,0 +1,210 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleNotarizationServiceTests +{ + [Theory] + [InlineData("{}", null, null, 0, false)] + [InlineData("{\"id\":\"pending-submission\",\"status\":\"In Progress\"}", "pending-submission", "In Progress", 0, false)] + [InlineData("", null, null, 1, false)] + [InlineData("", null, null, -1, true)] + public async Task NotarizeAsync_checkpoints_every_attempt_without_terminal_submission_evidence( + string response, + string? expectedId, + string? expectedStatus, + int exitCode, + bool timedOut) + { + var package = Path.GetTempFileName() + ".pkg"; + await File.WriteAllTextAsync(package, "approved-package"); + try + { + AppleNotarizationAmbiguousCheckpoint? checkpoint = null; + + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(new IncompleteSubmissionEvidenceRunner(response, exitCode, timedOut)).NotarizeAsync( + new AppleNotarizationRequest + { + ArtifactPath = package, + KeychainProfile = "powerforge-notary", + Staple = false, + Assess = false, + AmbiguousCheckpoint = ambiguous => checkpoint = ambiguous + })); + + Assert.Contains("ambiguous", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("do not resubmit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(checkpoint); + Assert.Equal(expectedId, checkpoint.SubmissionId); + Assert.Equal(expectedStatus, checkpoint.Status); + Assert.Equal(64, checkpoint.SubmissionSha256.Length); + } + finally + { + try { File.Delete(package); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_checkpoints_ambiguous_submission_when_runner_throws() + { + var package = Path.GetTempFileName() + ".pkg"; + await File.WriteAllTextAsync(package, "approved-package"); + try + { + AppleNotarizationAmbiguousCheckpoint? checkpoint = null; + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(new ThrowingSubmissionRunner()).NotarizeAsync( + new AppleNotarizationRequest + { + ArtifactPath = package, + KeychainProfile = "powerforge-notary", + Staple = false, + Assess = false, + AmbiguousCheckpoint = ambiguous => checkpoint = ambiguous + })); + + Assert.Contains("ambiguous", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.IsType(exception.InnerException); + Assert.NotNull(checkpoint); + Assert.Equal(64, checkpoint.SubmissionSha256.Length); + } + finally + { + try { File.Delete(package); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_rejects_private_app_root_replaced_after_acceptance() + { + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.NotaryTests", + Guid.NewGuid().ToString("N"))); + try + { + var app = Directory.CreateDirectory(Path.Combine(root.FullName, "Accepted.app")); + await File.WriteAllTextAsync(Path.Combine(app.FullName, "payload"), "approved-app"); + var runner = new AcceptedArtifactMutationRunner(); + + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = app.FullName, + KeychainProfile = "powerforge-notary", + Assess = false, + AcceptedCheckpoint = _ => + { + var privateApp = runner.PrivateArtifactPath!; + Directory.Move(privateApp, privateApp + ".replaced"); + Directory.CreateDirectory(privateApp); + File.WriteAllText(Path.Combine(privateApp, "payload"), "replacement-app"); + } + })); + + Assert.Contains("exact submitted file changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Do not resubmit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(runner.Commands, command => command.StartsWith("stapler staple", StringComparison.Ordinal)); + Assert.Equal("approved-app", await File.ReadAllTextAsync(Path.Combine(app.FullName, "payload"))); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_rejects_private_artifact_changed_after_acceptance_before_stapling() + { + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.NotaryTests", + Guid.NewGuid().ToString("N"))); + try + { + var package = Path.Combine(root.FullName, "Accepted.pkg"); + await File.WriteAllTextAsync(package, "approved-package"); + var runner = new AcceptedArtifactMutationRunner(); + + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = package, + KeychainProfile = "powerforge-notary", + Assess = false, + AcceptedCheckpoint = _ => File.WriteAllText( + runner.PrivateArtifactPath!, + "replacement-after-acceptance") + })); + + Assert.Contains("exact submitted file changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Do not resubmit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(runner.Commands, command => command.StartsWith("stapler staple", StringComparison.Ordinal)); + Assert.Equal("approved-package", await File.ReadAllTextAsync(package)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + private sealed class AcceptedArtifactMutationRunner : IProcessRunner + { + internal List Commands { get; } = []; + internal string? PrivateArtifactPath { get; private set; } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + Commands.Add(string.Join(" ", request.Arguments)); + if (request.Arguments.Count == 5 && request.Arguments[0] == "-c") + File.WriteAllText(request.Arguments[4], "exact-private-app-zip"); + var isSubmission = request.Arguments.Count > 2 && request.Arguments[0] == "notarytool"; + if (isSubmission) + PrivateArtifactPath = Directory.EnumerateDirectories(request.WorkingDirectory, "*.app").FirstOrDefault() ?? + request.Arguments[2]; + var result = new ProcessRunResult( + 0, + isSubmission ? "{\"id\":\"accepted-boundary\",\"status\":\"Accepted\"}" : "ok", + string.Empty, + request.FileName, + TimeSpan.Zero, + false); + request.InvokeCompletionBoundary(result); + return Task.FromResult(result); + } + } + + private sealed class IncompleteSubmissionEvidenceRunner : IProcessRunner + { + private readonly string _response; + private readonly int _exitCode; + private readonly bool _timedOut; + + internal IncompleteSubmissionEvidenceRunner(string response, int exitCode, bool timedOut) + { + _response = response; + _exitCode = exitCode; + _timedOut = timedOut; + } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var isSubmission = request.Arguments.Count > 0 && request.Arguments[0] == "notarytool"; + var result = new ProcessRunResult( + _exitCode, + isSubmission ? _response : "ok", + string.Empty, + request.FileName, + TimeSpan.Zero, + _timedOut); + request.InvokeCompletionBoundary(result); + return Task.FromResult(result); + } + } + + private sealed class ThrowingSubmissionRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + => throw new IOException("notarytool response channel closed after submission started"); + } +} diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.AssessmentBoundary.cs b/PowerForge.Tests/AppleNotarizationServiceTests.AssessmentBoundary.cs new file mode 100644 index 000000000..f17587eb8 --- /dev/null +++ b/PowerForge.Tests/AppleNotarizationServiceTests.AssessmentBoundary.cs @@ -0,0 +1,70 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleNotarizationServiceTests +{ + [Fact] + public async Task NotarizeAsync_rejects_restored_assessment_bytes_changed_through_external_hard_link() + { + var package = Path.GetTempFileName() + ".pkg"; + await File.WriteAllTextAsync(package, "approved-package"); + using var runner = new RestoringAssessmentHardLinkRunner(); + try + { + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = package, + KeychainProfile = "powerforge-notary", + Staple = false, + Assess = true + })); + + Assert.Contains("Gatekeeper assessment artifact changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("approved-package", await File.ReadAllTextAsync(package)); + } + finally + { + try { File.Delete(package); } catch { } + } + } + + private sealed class RestoringAssessmentHardLinkRunner : IProcessRunner, IDisposable + { + private readonly string _aliasRoot = Path.Combine( + Path.GetTempPath(), + "PowerForge.NotaryTests", + $"assessment-alias-{Guid.NewGuid():N}"); + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var isSubmission = request.Arguments.Count > 0 && request.Arguments[0] == "notarytool"; + var isAssessment = request.FileName.Contains("spctl", StringComparison.OrdinalIgnoreCase); + if (isAssessment) + { + Directory.CreateDirectory(_aliasRoot); + var artifactPath = request.Arguments[^1]; + var approvedBytes = File.ReadAllBytes(artifactPath); + var alias = Path.Combine(_aliasRoot, "assessment-alias"); + TestFileLink.CreateHardLink(alias, artifactPath); + File.WriteAllText(alias, "transient-assessment-bytes"); + File.WriteAllBytes(alias, approvedBytes); + File.Delete(alias); + } + + var result = new ProcessRunResult( + 0, + isSubmission ? "{\"id\":\"assessment-submission\",\"status\":\"Accepted\"}" : "ok", + string.Empty, + request.FileName, + TimeSpan.Zero, + false); + request.InvokeCompletionBoundary(result); + return Task.FromResult(result); + } + + public void Dispose() + { + try { Directory.Delete(_aliasRoot, recursive: true); } catch { } + } + } +} diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs new file mode 100644 index 000000000..97a441b57 --- /dev/null +++ b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs @@ -0,0 +1,402 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleNotarizationServiceTests +{ + [Fact] + public void FileSnapshot_rejects_mutation_before_submission_monitor_takes_ownership() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var package = Path.Combine(root.FullName, "Race.pkg"); + File.WriteAllText(package, "approved-package"); + var expected = AppleNotarizationService.ComputeArtifactSha256(package); + using var snapshot = AppleNotarizationInputSnapshot.Create(package, expected); + + File.WriteAllText(snapshot.ArtifactPath, "attacker-package"); + + var exception = Assert.Throws(() => snapshot.CompleteSubmissionCapture(expected)); + Assert.Contains("changed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void FileSnapshot_rejects_a_second_hard_link_path_before_submission() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + string? aliasRoot = null; + try + { + var package = Path.Combine(root.FullName, "Approved.pkg"); + File.WriteAllText(package, "approved-package"); + var expected = AppleNotarizationService.ComputeArtifactSha256(package); + using var snapshot = AppleNotarizationInputSnapshot.Create(package, expected); + aliasRoot = Path.Combine(Directory.GetParent(snapshot.RootPath)!.FullName, $"alias-{Guid.NewGuid():N}"); + Directory.CreateDirectory(aliasRoot); + TestFileLink.CreateHardLink(Path.Combine(aliasRoot, "package-alias"), snapshot.ArtifactPath); + + var exception = Assert.Throws(() => snapshot.CompleteSubmissionCapture(expected)); + Assert.Contains("hard links", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (!string.IsNullOrWhiteSpace(aliasRoot) && Directory.Exists(aliasRoot)) + Directory.Delete(aliasRoot, recursive: true); + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void DirectorySnapshot_rejects_restored_bytes_changed_through_external_alias_before_submission() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + string? aliasRoot = null; + try + { + var app = Directory.CreateDirectory(Path.Combine(root.FullName, "Approved.app")); + File.WriteAllText(Path.Combine(app.FullName, "payload"), "approved-app"); + var expected = AppleNotarizationService.ComputeArtifactSha256(app.FullName); + using var snapshot = AppleNotarizationInputSnapshot.Create(app.FullName, expected); + aliasRoot = Path.Combine(Directory.GetParent(snapshot.RootPath)!.FullName, $"alias-{Guid.NewGuid():N}"); + Directory.CreateDirectory(aliasRoot); + var privatePayload = Path.Combine(snapshot.ArtifactPath, "payload"); + var alias = Path.Combine(aliasRoot, "payload-alias"); + TestFileLink.CreateHardLink(alias, privatePayload); + File.WriteAllText(alias, "transient attacker bytes"); + File.WriteAllText(alias, "approved-app"); + File.Delete(alias); + + var exception = Assert.Throws(() => snapshot.CompleteSubmissionCapture(expected)); + Assert.Contains("hard-link alias", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (!string.IsNullOrWhiteSpace(aliasRoot) && Directory.Exists(aliasRoot)) + Directory.Delete(aliasRoot, recursive: true); + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_RejectsTransientAppMutationWhileDittoCreatesSubmissionZip() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var app = Directory.CreateDirectory(Path.Combine(root.FullName, "PackagingRace.app")); + await File.WriteAllTextAsync(Path.Combine(app.FullName, "payload"), "approved-app"); + var checkpointed = false; + + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(new MutatingDittoInputRunner()).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = app.FullName, + KeychainProfile = "powerforge-notary", + Staple = false, + Assess = false, + AcceptedCheckpoint = _ => checkpointed = true + })); + + Assert.Contains("changed while ditto", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(checkpointed); + Assert.Equal("approved-app", await File.ReadAllTextAsync(Path.Combine(app.FullName, "payload"))); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_BindsDittoZipAtProcessCompletionBoundary() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var app = Directory.CreateDirectory(Path.Combine(root.FullName, "DittoBoundary.app")); + await File.WriteAllTextAsync(Path.Combine(app.FullName, "payload"), "approved-app"); + var checkpointed = false; + + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(new MutatingDittoOutputAfterCompletionRunner()).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = app.FullName, + KeychainProfile = "powerforge-notary", + Staple = false, + Assess = false, + AcceptedCheckpoint = _ => checkpointed = true + })); + + Assert.Contains("changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(checkpointed); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_rejects_restored_submitted_bytes_changed_through_external_hard_link() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + var runner = new RestoringSubmissionHardLinkRunner(); + try + { + var package = Path.Combine(root.FullName, "Approved.pkg"); + await File.WriteAllTextAsync(package, "approved-package"); + AppleNotarizationAcceptedCheckpoint? checkpoint = null; + + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = package, + KeychainProfile = "powerforge-notary", + Staple = false, + Assess = false, + AcceptedCheckpoint = accepted => checkpoint = accepted + })); + + Assert.Contains("submitted file changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Do not resubmit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(checkpoint); + Assert.Equal("submission-hard-link", checkpoint.SubmissionId); + } + finally + { + runner.Dispose(); + try { root.Delete(recursive: true); } catch { } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PublicationRollback_preserves_concurrently_replaced_notarization_artifact(bool directoryArtifact) + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var destination = Path.Combine(root.FullName, directoryArtifact ? "Published.app" : "Published.pkg"); + var backup = Path.Combine(root.FullName, directoryArtifact ? "Previous.app" : "Previous.pkg"); + var quarantine = Path.Combine(root.FullName, directoryArtifact ? "Failed.app" : "Failed.pkg"); + if (directoryArtifact) + { + Directory.CreateDirectory(destination); + File.WriteAllText(Path.Combine(destination, "payload"), "published"); + Directory.CreateDirectory(backup); + File.WriteAllText(Path.Combine(backup, "payload"), "previous"); + } + else + { + File.WriteAllText(destination, "published"); + File.WriteAllText(backup, "previous"); + } + var publishedSha256 = AppleNotarizationService.ComputeArtifactSha256(destination); + if (directoryArtifact) + File.WriteAllText(Path.Combine(destination, "payload"), "concurrent replacement"); + else + File.WriteAllText(destination, "concurrent replacement"); + + var exception = Assert.Throws(() => + AppleNotarizationInputSnapshot.RollbackPublication( + destination, + backup, + quarantine, + publishedSha256, + published: true, + movedExisting: true)); + + Assert.Contains("replacement bytes", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(directoryArtifact ? Directory.Exists(destination) : File.Exists(destination)); + Assert.True(directoryArtifact ? Directory.Exists(backup) : File.Exists(backup)); + var destinationPayload = directoryArtifact + ? File.ReadAllText(Path.Combine(destination, "payload")) + : File.ReadAllText(destination); + Assert.Equal("concurrent replacement", destinationPayload); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PublicationRollback_removes_owned_bytes_and_restores_previous_notarization_artifact(bool directoryArtifact) + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var destination = Path.Combine(root.FullName, directoryArtifact ? "Published.app" : "Published.pkg"); + var backup = Path.Combine(root.FullName, directoryArtifact ? "Previous.app" : "Previous.pkg"); + var quarantine = Path.Combine(root.FullName, directoryArtifact ? "Failed.app" : "Failed.pkg"); + if (directoryArtifact) + { + Directory.CreateDirectory(destination); + File.WriteAllText(Path.Combine(destination, "payload"), "published"); + Directory.CreateDirectory(backup); + File.WriteAllText(Path.Combine(backup, "payload"), "previous"); + } + else + { + File.WriteAllText(destination, "published"); + File.WriteAllText(backup, "previous"); + } + var publishedSha256 = AppleNotarizationService.ComputeArtifactSha256(destination); + + AppleNotarizationInputSnapshot.RollbackPublication( + destination, + backup, + quarantine, + publishedSha256, + published: true, + movedExisting: true); + + var destinationPayload = directoryArtifact + ? File.ReadAllText(Path.Combine(destination, "payload")) + : File.ReadAllText(destination); + Assert.Equal("previous", destinationPayload); + Assert.False(directoryArtifact ? Directory.Exists(backup) : File.Exists(backup)); + Assert.False(directoryArtifact ? Directory.Exists(quarantine) : File.Exists(quarantine)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PublicationRollback_preserves_linked_notarization_replacement(bool directoryArtifact) + { + if (OperatingSystem.IsWindows()) return; + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var destination = Path.Combine(root.FullName, directoryArtifact ? "Published.app" : "Published.pkg"); + var backup = Path.Combine(root.FullName, directoryArtifact ? "Previous.app" : "Previous.pkg"); + var quarantine = Path.Combine(root.FullName, directoryArtifact ? "Failed.app" : "Failed.pkg"); + var external = Path.Combine(root.FullName, directoryArtifact ? "External.app" : "External.pkg"); + if (directoryArtifact) + { + Directory.CreateDirectory(external); + File.WriteAllText(Path.Combine(external, "payload"), "external replacement"); + Directory.CreateDirectory(backup); + File.WriteAllText(Path.Combine(backup, "payload"), "previous"); + Directory.CreateSymbolicLink(destination, external); + } + else + { + File.WriteAllText(external, "external replacement"); + File.WriteAllText(backup, "previous"); + File.CreateSymbolicLink(destination, external); + } + + var exception = Assert.Throws(() => + AppleNotarizationInputSnapshot.RollbackPublication( + destination, + backup, + quarantine, + new string('a', 64), + published: true, + movedExisting: true)); + + Assert.Contains("linked replacement", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True((File.GetAttributes(destination) & FileAttributes.ReparsePoint) != 0); + Assert.True(directoryArtifact ? Directory.Exists(backup) : File.Exists(backup)); + var externalPayload = directoryArtifact + ? File.ReadAllText(Path.Combine(external, "payload")) + : File.ReadAllText(external); + Assert.Equal("external replacement", externalPayload); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + private sealed class MutatingDittoInputRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + if (request.FileName.Contains("ditto", StringComparison.OrdinalIgnoreCase)) + { + var privateApp = request.Arguments[^2]; + var payload = Path.Combine(privateApp, "payload"); + File.WriteAllText(payload, "attacker-during-ditto"); + File.WriteAllText(request.Arguments[^1], "zip-created-from-mutated-app"); + File.WriteAllText(payload, "approved-app"); + } + + return Task.FromResult(new ProcessRunResult( + 0, + "ok", + string.Empty, + request.FileName, + TimeSpan.Zero, + false)); + } + } + + private sealed class MutatingDittoOutputAfterCompletionRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var result = new ProcessRunResult(0, "ok", string.Empty, request.FileName, TimeSpan.Zero, false); + if (request.FileName.Contains("ditto", StringComparison.OrdinalIgnoreCase)) + { + var zip = request.Arguments[^1]; + File.WriteAllText(zip, "approved-zip"); + request.InvokeCompletionBoundary(result); + File.WriteAllText(zip, "replacement-after-process-completion"); + } + return Task.FromResult(result); + } + } + + private sealed class RestoringSubmissionHardLinkRunner : IProcessRunner, IDisposable + { + private readonly string _aliasRoot = Path.Combine( + Path.GetTempPath(), + "PowerForge.NotaryTests", + $"external-alias-{Guid.NewGuid():N}"); + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var isSubmission = request.Arguments.Count > 2 && request.Arguments[0] == "notarytool"; + if (isSubmission) + { + Directory.CreateDirectory(_aliasRoot); + var submittedPath = request.Arguments[2]; + var approvedBytes = File.ReadAllBytes(submittedPath); + var alias = Path.Combine(_aliasRoot, "submitted-alias"); + TestFileLink.CreateHardLink(alias, submittedPath); + File.WriteAllText(alias, "attacker-bytes"); + File.WriteAllBytes(alias, approvedBytes); + File.Delete(alias); + } + + var result = new ProcessRunResult( + 0, + isSubmission ? "{\"id\":\"submission-hard-link\",\"status\":\"Accepted\"}" : "ok", + string.Empty, + request.FileName, + TimeSpan.Zero, + false); + request.InvokeCompletionBoundary(result); + return Task.FromResult(result); + } + + public void Dispose() + { + try { Directory.Delete(_aliasRoot, recursive: true); } catch { } + } + } +} diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.Retention.cs b/PowerForge.Tests/AppleNotarizationServiceTests.Retention.cs new file mode 100644 index 000000000..fd6b4fefa --- /dev/null +++ b/PowerForge.Tests/AppleNotarizationServiceTests.Retention.cs @@ -0,0 +1,39 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleNotarizationServiceTests +{ + [Fact] + public async Task NotarizeAsync_AtomicallyReplacesExistingRetainedAppSubmission() + { + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.NotaryTests", + Guid.NewGuid().ToString("N"))); + try + { + var app = Directory.CreateDirectory(Path.Combine(root.FullName, "EasyControlX Agent.app")); + var retainedPath = Path.Combine(root.FullName, "retained.notarization.zip"); + await File.WriteAllTextAsync(retainedPath, "previous accepted submission"); + var checkpointObservedPrevious = false; + + var result = await new AppleNotarizationService(new NotaryProcessRunner()).NotarizeAsync( + new AppleNotarizationRequest + { + ArtifactPath = app.FullName, + SubmissionPath = retainedPath, + KeychainProfile = "powerforge-notary", + AcceptedCheckpoint = _ => + checkpointObservedPrevious = File.ReadAllText(retainedPath) == "previous accepted submission" + }); + + Assert.True(checkpointObservedPrevious); + Assert.Equal(retainedPath, result.SubmissionPath); + Assert.Equal(result.SubmissionSha256, AppleNotarizationService.ComputeFileSha256(retainedPath)); + Assert.Empty(Directory.GetFiles(root.FullName, ".retained.notarization.zip.*.tmp")); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } +} diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.StapleBoundary.cs b/PowerForge.Tests/AppleNotarizationServiceTests.StapleBoundary.cs new file mode 100644 index 000000000..cda1103dc --- /dev/null +++ b/PowerForge.Tests/AppleNotarizationServiceTests.StapleBoundary.cs @@ -0,0 +1,60 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleNotarizationServiceTests +{ + [Fact] + public async Task NotarizeAsync_rejects_artifact_replaced_after_stapler_completion_boundary() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var package = Path.Combine(root.FullName, "Boundary.pkg"); + await File.WriteAllTextAsync(package, "approved-package"); + var checkpointed = false; + + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(new PostStapleReplacementRunner()).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = package, + KeychainProfile = "powerforge-notary", + Assess = false, + StapledCheckpoint = _ => checkpointed = true + })); + + Assert.Contains("changed after stapler completed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(checkpointed); + Assert.Equal("approved-package", await File.ReadAllTextAsync(package)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + private sealed class PostStapleReplacementRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var isSubmission = request.Arguments.Count > 0 && request.Arguments[0] == "notarytool"; + var isStaple = request.Arguments.Count > 2 && + request.Arguments[0] == "stapler" && + request.Arguments[1] == "staple"; + if (isStaple) + File.AppendAllText(request.Arguments[2], "-stapled"); + + var result = new ProcessRunResult( + 0, + isSubmission ? "{\"id\":\"submission-boundary\",\"status\":\"Accepted\"}" : "ok", + string.Empty, + request.FileName, + TimeSpan.Zero, + false); + if (isStaple) + { + request.InvokeCompletionBoundary(result); + File.WriteAllText(request.Arguments[2], "different-already-stapled-package"); + } + return Task.FromResult(result); + } + } +} diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 3e9568e5e..4c0b12d2f 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -2,7 +2,7 @@ namespace PowerForge.Tests; -public sealed class AppleNotarizationServiceTests +public sealed partial class AppleNotarizationServiceTests { [Fact] public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() @@ -12,19 +12,32 @@ public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() { var app = Directory.CreateDirectory(Path.Combine(root.FullName, "EasyControlX Agent.app")); var runner = new NotaryProcessRunner(); + string? checkpointSubmissionSha256 = null; var result = await new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest { ArtifactPath = app.FullName, KeychainProfile = "powerforge-notary", XcrunExecutable = "xcrun-test", DittoExecutable = "ditto-test", - SpctlExecutable = "spctl-test" + SpctlExecutable = "spctl-test", + AcceptedCheckpoint = checkpoint => + { + Assert.Equal("submission-1", checkpoint.SubmissionId); + Assert.Equal("Accepted", checkpoint.Status); + checkpointSubmissionSha256 = checkpoint.SubmissionSha256; + Assert.Equal(2, runner.Requests.Count); + } }); Assert.True(result.Succeeded); Assert.Equal("submission-1", result.SubmissionId); Assert.Equal("Accepted", result.Status); Assert.EndsWith(".notarization.zip", result.SubmissionPath, StringComparison.OrdinalIgnoreCase); + Assert.True(File.Exists(result.SubmissionPath)); + Assert.Equal(64, result.SubmissionSha256?.Length); + Assert.Equal(result.SubmissionSha256, checkpointSubmissionSha256); + Assert.Equal(result.SubmissionSha256, AppleNotarizationService.ComputeFileSha256(result.SubmissionPath)); + string? privateArtifactPath = null; Assert.Collection( runner.Requests, request => Assert.Equal("ditto-test", request.FileName), @@ -34,12 +47,18 @@ public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() Assert.Equal("notarytool", request.Arguments[0]); Assert.Contains("--keychain-profile", request.Arguments); }, - request => Assert.Equal(new[] { "stapler", "staple", app.FullName }, request.Arguments), - request => Assert.Equal(new[] { "stapler", "validate", app.FullName }, request.Arguments), + request => + { + privateArtifactPath = request.Arguments[2]; + Assert.NotEqual(app.FullName, privateArtifactPath); + Assert.Equal(new[] { "stapler", "staple", privateArtifactPath }, request.Arguments); + }, + request => Assert.Equal(new[] { "stapler", "validate", privateArtifactPath! }, request.Arguments), request => { Assert.Equal("spctl-test", request.FileName); Assert.Contains("execute", request.Arguments); + Assert.Equal(privateArtifactPath, request.Arguments[^1]); }); } finally @@ -48,6 +67,104 @@ public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() } } + [Fact] + public async Task NotarizeAsync_ExactSourceRejectsCustomAppleToolExecutable() + { + var artifact = Path.GetTempFileName() + ".pkg"; + await File.WriteAllTextAsync(artifact, "pkg"); + try + { + var runner = new NotaryProcessRunner(); + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = artifact, + KeychainProfile = "powerforge-notary", + XcrunExecutable = "/tmp/hostile-xcrun", + RequireTrustedSystemTools = true, + Staple = false, + Assess = false + })); + + Assert.Contains("trusted system tool '/usr/bin/xcrun'", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(runner.Requests); + } + finally + { + try { File.Delete(artifact); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_ExactSourceUsesFixedAppleToolPathAndSanitizedPath() + { + var artifact = Path.GetTempFileName() + ".pkg"; + await File.WriteAllTextAsync(artifact, "pkg"); + try + { + var runner = new NotaryProcessRunner(); + var result = await new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = artifact, + KeychainProfile = "powerforge-notary", + RequireTrustedSystemTools = true, + Staple = false, + Assess = true + }); + + Assert.True(result.Succeeded); + Assert.Collection( + runner.Requests, + request => + { + Assert.Equal("/usr/bin/xcrun", request.FileName); + Assert.Equal("/usr/bin:/bin:/usr/sbin:/sbin", request.EnvironmentVariables?["PATH"]); + Assert.False(request.InheritEnvironment); + Assert.False(request.EnvironmentVariables?.ContainsKey("DEVELOPER_DIR")); + }, + request => + { + Assert.Equal("/usr/sbin/spctl", request.FileName); + Assert.Equal("/usr/bin:/bin:/usr/sbin:/sbin", request.EnvironmentVariables?["PATH"]); + Assert.False(request.InheritEnvironment); + Assert.False(request.EnvironmentVariables?.ContainsKey("DEVELOPER_DIR")); + }); + } + finally + { + try { File.Delete(artifact); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_RejectsAcceptedAppSubmissionWhenPrivateZipChangesDuringUpload() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var app = Directory.CreateDirectory(Path.Combine(root.FullName, "Mutable.app")); + AppleNotarizationAcceptedCheckpoint? checkpoint = null; + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(new MutatingSubmissionRunner()).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = app.FullName, + KeychainProfile = "powerforge-notary", + Staple = false, + Assess = false, + AcceptedCheckpoint = accepted => checkpoint = accepted + })); + + Assert.Contains("exact submitted file changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Do not resubmit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(checkpoint); + Assert.Equal("submission-mutated", checkpoint.SubmissionId); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + [Fact] public async Task NotarizeAsync_InvalidSubmissionDoesNotStapleOrAssess() { @@ -72,6 +189,151 @@ public async Task NotarizeAsync_InvalidSubmissionDoesNotStapleOrAssess() } } + [Fact] + public async Task NotarizeAsync_SubmitsPrivateImmutableArtifactSnapshot() + { + var artifact = Path.GetTempFileName() + ".pkg"; + await File.WriteAllTextAsync(artifact, "approved-pkg"); + try + { + var runner = new SnapshotObservingNotaryRunner(artifact); + var result = await new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = artifact, + KeychainProfile = "powerforge-notary", + Staple = false, + Assess = false + }); + + Assert.True(result.Succeeded); + Assert.NotEqual(artifact, runner.SubmittedPath); + Assert.Equal("approved-pkg", runner.SubmittedContents); + Assert.Equal("approved-pkg", await File.ReadAllTextAsync(artifact)); + } + finally + { + try { File.Delete(artifact); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_StaplesPrivateBundleAndPublishesThoseExactBytes() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var app = Directory.CreateDirectory(Path.Combine(root.FullName, "Private.app")); + var payload = Path.Combine(app.FullName, "payload"); + await File.WriteAllTextAsync(payload, "approved"); + var runner = new MutatingBundleStapleRunner(app.FullName); + + var result = await new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = app.FullName, + KeychainProfile = "powerforge-notary" + }); + + Assert.True(result.Succeeded); + Assert.Equal("approved", await File.ReadAllTextAsync(payload)); + Assert.True(File.Exists(Path.Combine(app.FullName, ".notary-ticket"))); + Assert.NotEqual(app.FullName, runner.StapledPath); + Assert.Equal(runner.StapledPath, runner.AssessedPath); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_RejectsTransientPrivateArtifactReplacementAfterStaplerValidation() + { + var artifact = Path.GetTempFileName() + ".pkg"; + await File.WriteAllTextAsync(artifact, "approved-pkg"); + try + { + var checkpointed = false; + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(new TransientPostValidationMutationRunner()).NotarizeAsync( + new AppleNotarizationRequest + { + ArtifactPath = artifact, + KeychainProfile = "powerforge-notary", + Assess = false, + StapledCheckpoint = _ => checkpointed = true + })); + + Assert.Contains("validated private Apple notarization artifact changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("approved-pkg", await File.ReadAllTextAsync(artifact)); + Assert.False(checkpointed); + } + finally + { + try { File.Delete(artifact); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_AcceptedCheckpointFailureReportsSubmissionBeforeStapling() + { + var artifact = Path.GetTempFileName() + ".pkg"; + await File.WriteAllTextAsync(artifact, "pkg"); + try + { + var runner = new NotaryProcessRunner(); + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = artifact, + KeychainProfile = "powerforge-notary", + AcceptedCheckpoint = _ => throw new IOException("receipt storage unavailable") + })); + + Assert.Contains("submission-1", exception.Message, StringComparison.Ordinal); + Assert.Contains("Do not resubmit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Single(runner.Requests); + Assert.DoesNotContain(runner.Requests, request => + request.Arguments.Count > 1 && request.Arguments[0] == "stapler"); + } + finally + { + try { File.Delete(artifact); } catch { } + } + } + + [Fact] + public async Task NotarizeAsync_PersistsAcceptedCheckpointBeforeRetainingAppSubmission() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var app = Directory.CreateDirectory(Path.Combine(root.FullName, "EasyControlX Agent.app")); + var invalidRetainedPath = Directory.CreateDirectory(Path.Combine(root.FullName, "submission-is-a-directory")).FullName; + var checkpointed = false; + + var retentionFailure = await Record.ExceptionAsync(() => + new AppleNotarizationService(new NotaryProcessRunner()).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = app.FullName, + SubmissionPath = invalidRetainedPath, + KeychainProfile = "powerforge-notary", + AcceptedCheckpoint = checkpoint => + { + checkpointed = true; + Assert.Equal("submission-1", checkpoint.SubmissionId); + Assert.Equal(invalidRetainedPath, checkpoint.SubmissionPath); + } + })); + + Assert.NotNull(retentionFailure); + Assert.True(checkpointed); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + [Fact] public async Task NotarizeAsync_DiskImageUsesOpenAssessmentWithPrimarySignatureContext() { @@ -87,13 +349,19 @@ public async Task NotarizeAsync_DiskImageUsesOpenAssessmentWithPrimarySignatureC }); Assert.True(result.Succeeded); + string? privateArtifactPath = null; Assert.Collection( runner.Requests, request => Assert.Equal("notarytool", request.Arguments[0]), - request => Assert.Equal(new[] { "stapler", "staple", artifact }, request.Arguments), - request => Assert.Equal(new[] { "stapler", "validate", artifact }, request.Arguments), + request => + { + privateArtifactPath = request.Arguments[2]; + Assert.NotEqual(artifact, privateArtifactPath); + Assert.Equal(new[] { "stapler", "staple", privateArtifactPath }, request.Arguments); + }, + request => Assert.Equal(new[] { "stapler", "validate", privateArtifactPath! }, request.Arguments), request => Assert.Equal( - new[] { "--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=4", artifact }, + new[] { "--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=4", privateArtifactPath! }, request.Arguments)); } finally @@ -163,7 +431,8 @@ public async Task NotarizeAsync_ResumeRejectsChangedArtifactBytes() { ArtifactPath = artifact, AcceptedSubmissionId = "submission-existing", - ExpectedArtifactSha256 = new string('0', 64) + ExpectedArtifactSha256 = new string('0', 64), + Staple = false })); Assert.Contains("artifact changed", exception.Message, StringComparison.OrdinalIgnoreCase); @@ -261,6 +530,88 @@ public async Task NotarizeAsync_ResumeRejectsAddedEmptyBundleDirectory() } } + [Fact] + public void ComputeArtifactSha256_IsStableAcrossTimestampOnlyCopyChanges() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var app = Directory.CreateDirectory(Path.Combine(root.FullName, "Portable.app")); + var contents = Directory.CreateDirectory(Path.Combine(app.FullName, "Contents")); + var payload = Path.Combine(contents.FullName, "payload"); + File.WriteAllText(payload, "identical signed bytes"); + var expected = AppleNotarizationService.ComputeArtifactSha256(app.FullName); + + File.SetLastWriteTimeUtc(payload, DateTime.UtcNow.AddYears(-2)); + Directory.SetLastWriteTimeUtc(contents.FullName, DateTime.UtcNow.AddYears(-1)); + Directory.SetLastWriteTimeUtc(app.FullName, DateTime.UtcNow.AddMonths(-3)); + + Assert.Equal(expected, AppleNotarizationService.ComputeArtifactSha256(app.FullName)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void ComputeArtifactSha256_LengthFramesMetadataAndFileContents() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.NotaryTests", Guid.NewGuid().ToString("N"))); + try + { + var original = Directory.CreateDirectory(Path.Combine(root.FullName, "Original.app")); + var forged = Directory.CreateDirectory(Path.Combine(root.FullName, "Forged.app")); + var originalA = Path.Combine(original.FullName, "a"); + var originalB = Path.Combine(original.FullName, "b"); + var forgedA = Path.Combine(forged.FullName, "a"); + File.WriteAllBytes(originalA, new byte[] { 0x41 }); + File.WriteAllBytes(originalB, new byte[] { 0x42 }); + File.WriteAllBytes(forgedA, new byte[] { 0x41 }); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + { + var mode = File.GetUnixFileMode(originalA); + File.SetUnixFileMode(originalB, mode); + File.SetUnixFileMode(forgedA, mode); + } +#endif + + // This payload made the prior delimiter-only digest interpret one file's bytes as + // the entry boundary and metadata for a second file. + var collisionPayload = new List { 0x41, 0xff }; + static void AppendLegacyValue(List payload, string value) + { + payload.AddRange(System.Text.Encoding.UTF8.GetBytes(value)); + payload.Add(0); + } + AppendLegacyValue(collisionPayload, "b"); + AppendLegacyValue( + collisionPayload, + ((int)new FileInfo(originalB).Attributes).ToString(System.Globalization.CultureInfo.InvariantCulture)); +#if NET8_0_OR_GREATER + AppendLegacyValue( + collisionPayload, + OperatingSystem.IsWindows() + ? string.Empty + : ((int)File.GetUnixFileMode(originalB)).ToString(System.Globalization.CultureInfo.InvariantCulture)); +#else + AppendLegacyValue(collisionPayload, string.Empty); +#endif + AppendLegacyValue(collisionPayload, string.Empty); + collisionPayload.Add(0x42); + File.WriteAllBytes(forgedA, collisionPayload.ToArray()); + + Assert.NotEqual( + AppleNotarizationService.ComputeArtifactSha256(original.FullName), + AppleNotarizationService.ComputeArtifactSha256(forged.FullName)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + [Fact] public async Task NotarizeAsync_ResumeUsesPostStapleHashAndDoesNotStapleAgain() { @@ -278,6 +629,11 @@ public async Task NotarizeAsync_ResumeUsesPostStapleHashAndDoesNotStapleAgain() Assert.False(first.Succeeded); Assert.True(first.Staple?.Succeeded); Assert.False(first.Assessment?.Succeeded); + Assert.Equal("pkg-stapled", await File.ReadAllTextAsync(artifact)); + Assert.DoesNotContain(firstRunner.Requests, request => + request.Arguments.Count > 2 && + request.Arguments[0] == "stapler" && + request.Arguments[2] == artifact); var resumedRunner = new MutatingStapleRunner(artifact, failAssessment: false); var resumed = await new AppleNotarizationService(resumedRunner).NotarizeAsync(new AppleNotarizationRequest @@ -305,6 +661,34 @@ public async Task NotarizeAsync_ResumeUsesPostStapleHashAndDoesNotStapleAgain() } } + [Fact] + public async Task NotarizeAsync_ResumeRejectsChangedArtifactEvenWhenStaplerCouldValidateIt() + { + var artifact = Path.GetTempFileName() + ".pkg"; + await File.WriteAllTextAsync(artifact, "pkg-before-stapling"); + try + { + var acceptedHash = AppleNotarizationService.ComputeArtifactSha256(artifact); + await File.AppendAllTextAsync(artifact, "-ticket-stapled-before-crash"); + var runner = new MutatingStapleRunner(artifact, failAssessment: false); + + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(runner).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = artifact, + AcceptedSubmissionId = "accepted-before-crash", + ExpectedArtifactSha256 = acceptedHash + })); + + Assert.Contains("cannot prove artifact identity", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(runner.Requests); + } + finally + { + try { File.Delete(artifact); } catch { } + } + } + private sealed class NotaryProcessRunner : IProcessRunner { private readonly string _status; @@ -319,6 +703,8 @@ internal NotaryProcessRunner(string status = "Accepted") public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) { Requests.Add(request); + if (request.FileName.Contains("ditto", StringComparison.OrdinalIgnoreCase) && request.Arguments.Count > 0) + File.WriteAllText(request.Arguments[^1], "private notarization package"); var output = request.Arguments.Count > 0 && request.Arguments[0] == "notarytool" ? JsonSerializer.Serialize(new { id = "submission-1", status = _status }) : "ok"; @@ -326,6 +712,68 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo } } + private sealed class MutatingSubmissionRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + if (request.FileName.Contains("ditto", StringComparison.OrdinalIgnoreCase)) + File.WriteAllText(request.Arguments[^1], "approved private notarization package"); + if (request.Arguments.Count > 2 && request.Arguments[0] == "notarytool") + { + File.AppendAllText(request.Arguments[2], "-mutated-during-upload"); + return Task.FromResult(new ProcessRunResult( + 0, + JsonSerializer.Serialize(new { id = "submission-mutated", status = "Accepted" }), + string.Empty, + request.FileName, + TimeSpan.Zero, + false)); + } + + return Task.FromResult(new ProcessRunResult(0, "ok", string.Empty, request.FileName, TimeSpan.Zero, false)); + } + } + + private sealed class SnapshotObservingNotaryRunner : IProcessRunner + { + private readonly string _originalArtifact; + + internal SnapshotObservingNotaryRunner(string originalArtifact) + { + _originalArtifact = originalArtifact; + } + + internal string? SubmittedPath { get; private set; } + + internal string? SubmittedContents { get; private set; } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + if (request.Arguments.Count > 2 && request.Arguments[0] == "notarytool") + { + SubmittedPath = request.Arguments[2]; + File.WriteAllText(_originalArtifact, "transient-pkg"); + SubmittedContents = File.ReadAllText(SubmittedPath); + File.WriteAllText(_originalArtifact, "approved-pkg"); + return Task.FromResult(new ProcessRunResult( + 0, + JsonSerializer.Serialize(new { id = "submission-private", status = "Accepted" }), + string.Empty, + request.FileName, + TimeSpan.FromMilliseconds(1), + false)); + } + + return Task.FromResult(new ProcessRunResult( + 0, + "ok", + string.Empty, + request.FileName, + TimeSpan.FromMilliseconds(1), + false)); + } + } + private sealed class MutatingStapleRunner : IProcessRunner { private readonly string _artifact; @@ -342,11 +790,13 @@ internal MutatingStapleRunner(string artifact, bool failAssessment) public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) { Requests.Add(request); + if (request.Arguments.Count > 0 && request.Arguments[0] == "notarytool") + File.WriteAllText(_artifact, "attacker-replacement"); if (request.Arguments.Count > 1 && request.Arguments[0] == "stapler" && request.Arguments[1] == "staple") { - File.AppendAllText(_artifact, "-stapled"); + File.AppendAllText(request.Arguments[2], "-stapled"); } var notarySubmission = request.Arguments.Count > 0 && request.Arguments[0] == "notarytool"; @@ -361,4 +811,71 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo return Task.FromResult(result); } } + + private sealed class TransientPostValidationMutationRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var notarySubmission = request.Arguments.Count > 0 && request.Arguments[0] == "notarytool"; + if (request.Arguments.Count > 2 && + request.Arguments[0] == "stapler" && + request.Arguments[1] == "staple") + { + File.AppendAllText(request.Arguments[2], "-stapled"); + } + if (request.Arguments.Count > 2 && + request.Arguments[0] == "stapler" && + request.Arguments[1] == "validate") + { + var validatedBytes = File.ReadAllBytes(request.Arguments[2]); + File.WriteAllText(request.Arguments[2], "attacker-replacement"); + File.WriteAllBytes(request.Arguments[2], validatedBytes); + } + + var output = notarySubmission + ? JsonSerializer.Serialize(new { id = "submission-transient-replacement", status = "Accepted" }) + : "ok"; + return Task.FromResult(new ProcessRunResult( + 0, + output, + string.Empty, + request.FileName, + TimeSpan.FromMilliseconds(1), + false)); + } + } + + private sealed class MutatingBundleStapleRunner : IProcessRunner + { + private readonly string _publicArtifact; + + internal MutatingBundleStapleRunner(string publicArtifact) + { + _publicArtifact = publicArtifact; + } + + internal string? StapledPath { get; private set; } + + internal string? AssessedPath { get; private set; } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + if (request.FileName.Contains("ditto", StringComparison.OrdinalIgnoreCase)) + File.WriteAllText(request.Arguments[^1], "private notarization package"); + if (request.Arguments.Count > 0 && request.Arguments[0] == "notarytool") + File.WriteAllText(Path.Combine(_publicArtifact, "payload"), "attacker"); + if (request.Arguments.Count > 2 && request.Arguments[0] == "stapler" && request.Arguments[1] == "staple") + { + StapledPath = request.Arguments[2]; + File.WriteAllText(Path.Combine(StapledPath, ".notary-ticket"), "accepted"); + } + if (request.FileName.Contains("spctl", StringComparison.OrdinalIgnoreCase)) + AssessedPath = request.Arguments[^1]; + + var output = request.Arguments.Count > 0 && request.Arguments[0] == "notarytool" + ? JsonSerializer.Serialize(new { id = "submission-private-bundle", status = "Accepted" }) + : "ok"; + return Task.FromResult(new ProcessRunResult(0, output, string.Empty, request.FileName, TimeSpan.Zero, false)); + } + } } diff --git a/PowerForge.Tests/AppleReleaseArtifactServiceTests.cs b/PowerForge.Tests/AppleReleaseArtifactServiceTests.cs index 363fa63bb..871dd68ba 100644 --- a/PowerForge.Tests/AppleReleaseArtifactServiceTests.cs +++ b/PowerForge.Tests/AppleReleaseArtifactServiceTests.cs @@ -90,6 +90,50 @@ public void Preflight_RemovesOnlyStaleEntriesUnderConfiguredRoots() } } + [Fact] + public void RemoveStaleArtifacts_preserves_case_equivalent_protected_path_on_case_insensitive_volume() + { + var root = Path.Combine(Path.GetTempPath(), "PowerForge.AppleCleanup", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + if (FrameworkCompatibility.GetPathStringComparison(root) != StringComparison.OrdinalIgnoreCase) + return; + + var archiveRoot = Path.Combine(root, "archives"); + var exportRoot = Path.Combine(root, "exports"); + var accepted = Directory.CreateDirectory(Path.Combine(archiveRoot, "Accepted.xcarchive")); + Directory.CreateDirectory(exportRoot); + File.WriteAllText(Path.Combine(accepted.FullName, "payload"), "accepted notarization bytes"); + Directory.SetLastWriteTimeUtc(accepted.FullName, DateTime.UtcNow.AddDays(-30)); + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = root, + Automation = new PowerForgeAppleReleaseAutomationOptions { ArtifactRetentionDays = 7 }, + Apps = + [ + new PowerForgeAppleAppReleaseTargetPlan + { + ArchivePath = Path.Combine(archiveRoot, "App.xcarchive"), + ExportPath = Path.Combine(exportRoot, "App") + } + ] + }; + + var receipt = new AppleReleaseArtifactService(_ => long.MaxValue).RemoveStaleArtifacts( + plan, + [Path.Combine(archiveRoot, "accepted.xcarchive")]); + + Assert.True(Directory.Exists(accepted.FullName)); + Assert.Empty(receipt.RemovedPaths); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } + [Fact] public void RemoveCurrentArtifacts_RefusesSymbolicLinkArtifactRoots() { @@ -343,7 +387,7 @@ public void RemoveCurrentArtifacts_RejectsLinkOutsideFrameworkEvenWhenItStaysIns } [Fact] - public void RemoveCurrentArtifacts_UsesCaseSensitiveContainmentOnUnix() + public void RemoveCurrentArtifacts_UsesCaseSensitiveContainmentOnCaseSensitiveUnixVolume() { if (Path.DirectorySeparatorChar == '\\') return; @@ -352,9 +396,11 @@ public void RemoveCurrentArtifacts_UsesCaseSensitiveContainmentOnUnix() var root = Path.Combine(parent, "Project"); var caseVariant = Path.Combine(parent, "project"); Directory.CreateDirectory(root); - Directory.CreateDirectory(caseVariant); try { + if (FrameworkCompatibility.GetPathStringComparison(root) != StringComparison.Ordinal) + return; + Directory.CreateDirectory(caseVariant); var plan = new PowerForgeAppleReleasePlan { ProjectRoot = root, diff --git a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs new file mode 100644 index 000000000..816212d88 --- /dev/null +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -0,0 +1,403 @@ +namespace PowerForge.Tests; + +public sealed class AppleReleaseReceiptStoreTests +{ + [Fact] + public void WriteAttempt_PreservesEveryAttemptAndChainsTheLatestReceipt() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + var store = new AppleReleaseReceiptStore(); + var first = CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: true); + var second = CreateReceipt(PowerForgeAppleReleaseAction.Status, success: true); + + store.WriteAttempt(plan, first); + store.WriteAttempt(plan, second); + + var receipts = store.ReadAll(plan); + Assert.Equal(2, receipts.Length); + Assert.Equal(2, Directory.GetFiles(plan.ReceiptHistoryPath, "*.json").Length); + Assert.Equal(first.ReceiptSha256, second.PreviousReceiptSha256); + Assert.NotEqual(first.ReceiptSha256, second.ReceiptSha256); + Assert.Equal(second.ReceiptSha256, receipts[0].ReceiptSha256); + Assert.True(File.Exists(plan.ReceiptPath)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public async Task WriteAttempt_SerializesConcurrentWritersAcrossStoreInstances() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + var seed = CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: true); + seed.CheckedAt = DateTimeOffset.Parse("2026-08-11T18:00:00Z"); + new AppleReleaseReceiptStore().WriteAttempt(plan, seed); + + using var heldLease = AppleReleaseReceiptJournalLease.Acquire(plan); + var blockedWrite = Task.Factory.StartNew( + () => new AppleReleaseReceiptStore().WriteAttempt( + plan, + CreateReceipt(PowerForgeAppleReleaseAction.Status, success: true)), + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + await Task.Delay(150); + Assert.False(blockedWrite.IsCompleted); + heldLease.Dispose(); + await blockedWrite.WaitAsync(TimeSpan.FromSeconds(10)); + + new AppleReleaseReceiptStore().WriteAttempt( + plan, + CreateReceipt(PowerForgeAppleReleaseAction.Doctor, success: true)); + + var receipts = new AppleReleaseReceiptStore().ReadAll(plan); + Assert.Equal(3, receipts.Length); + Assert.Equal(3, receipts.Select(receipt => receipt.ReceiptSha256).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.Equal(2, receipts.Count(receipt => !string.IsNullOrWhiteSpace(receipt.PreviousReceiptSha256))); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void CreateLockPath_UsesTheProtectedResourceNamespace() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + + var receiptLock = AppleReleaseReceiptJournalLease.CreateLockPath(plan.ReceiptPath); + var historyLock = AppleReleaseReceiptJournalLease.CreateLockPath(plan.ReceiptHistoryPath); + + Assert.Equal( + Path.GetDirectoryName(Path.GetFullPath(plan.ReceiptPath)), + Path.GetDirectoryName(Path.GetDirectoryName(receiptLock)!)); + Assert.Equal( + Path.GetDirectoryName(Path.GetFullPath(plan.ReceiptHistoryPath)), + Path.GetDirectoryName(Path.GetDirectoryName(historyLock)!)); + Assert.NotEqual(receiptLock, historyLock); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void CreateLockPath_UsesVolumeCaseSemanticsForEquivalentResources() + { + var root = CreateSandbox(); + try + { + var upper = Path.Combine(root, "Receipt.json"); + var lower = Path.Combine(root, "receipt.json"); + var upperLock = AppleReleaseReceiptJournalLease.CreateLockPath(upper); + var lowerLock = AppleReleaseReceiptJournalLease.CreateLockPath(lower); + + if (FrameworkCompatibility.GetPathStringComparison(root) == StringComparison.OrdinalIgnoreCase) + Assert.Equal(upperLock, lowerLock); + else + Assert.NotEqual(upperLock, lowerLock); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void ReadAll_RejectsTamperedImmutableReceipt() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + var store = new AppleReleaseReceiptStore(); + store.WriteAttempt(plan, CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: false)); + var historyPath = Assert.Single(Directory.GetFiles(plan.ReceiptHistoryPath, "*.json")); + var json = File.ReadAllText(historyPath); + File.WriteAllText( + historyPath, + json.Replace("\"success\": false", "\"success\": true", StringComparison.Ordinal)); + + var exception = Assert.Throws(() => store.ReadAll(plan)); + Assert.Contains("integrity validation failed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void WriteAttempt_UsesIntegrityOnlySchemaWithoutClaimingSameAccountAuthentication() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + var store = new AppleReleaseReceiptStore(); + store.WriteAttempt(plan, CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: true)); + var receipt = Assert.Single(store.ReadAll(plan)); + + Assert.Equal(6, receipt.SchemaVersion); + Assert.Null(receipt.ReceiptAuthenticationSha256); + Assert.Matches("^[0-9a-f]{64}$", receipt.ReceiptSha256); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void ReadAll_RejectsLatestReceiptThatPointsAtDifferentValidHistoryEntry() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + var store = new AppleReleaseReceiptStore(); + store.WriteAttempt(plan, CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: true)); + store.WriteAttempt(plan, CreateReceipt(PowerForgeAppleReleaseAction.Status, success: true)); + var latest = store.ReadAll(plan)[0]; + var declaredHistory = Path.Combine(plan.ProjectRoot, latest.HistoryPath!); + var other = Assert.Single(Directory.GetFiles(plan.ReceiptHistoryPath, "*.json"), path => + !string.Equals(path, declaredHistory, StringComparison.OrdinalIgnoreCase)); + File.Copy(other, declaredHistory, overwrite: true); + + var exception = Assert.Throws(() => store.ReadAll(plan)); + + Assert.Contains("does not contain its declared receipt", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void WriteAttempt_ReconstructsLatestFromValidatedHistoryWhenLatestIsMissing() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + var store = new AppleReleaseReceiptStore(); + var first = CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: true); + store.WriteAttempt(plan, first); + File.Delete(plan.ReceiptPath); + + var second = CreateReceipt(PowerForgeAppleReleaseAction.Status, success: true); + store.WriteAttempt(plan, second); + + Assert.Equal(first.ReceiptSha256, second.PreviousReceiptSha256); + Assert.Equal(2, store.ReadAll(plan).Length); + Assert.True(File.Exists(plan.ReceiptPath)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void WriteAttempt_PublishesImmutableHistoryWithoutExposingTemporaryEntries() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + var checkedAt = DateTimeOffset.Parse("2026-08-09T20:00:00Z"); + var store = new AppleReleaseReceiptStore(() => checkedAt); + var receipt = CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: true); + receipt.AttemptId = "0123456789abcdef0123456789abcdef"; + + store.WriteAttempt(plan, receipt); + + var historyPath = Assert.Single(Directory.GetFiles(plan.ReceiptHistoryPath, "*.json")); + Assert.Equal(receipt.ReceiptSha256, Assert.Single(store.ReadAll(plan)).ReceiptSha256); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(plan.ReceiptHistoryPath)!, "*.receipt.tmp")); + Assert.True(new FileInfo(historyPath).Length > 0); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void ComputeReceiptSha256Json_IsStableForFuturePropertiesAndPropertyOrder() + { + const string receipt = + "{\"targets\":[],\"receiptSha256\":\"ignored\",\"schemaVersion\":4,\"futureFlag\":false,\"action\":\"Upload\"}"; + + Assert.Equal( + "827f9ae0f51e40e91517f677f7cd3d870dc20058c47d680af76918441e7b0f5f", + AppleReleaseReceiptStore.ComputeReceiptSha256Json(receipt)); + } + + [Fact] + public void WriteAttempt_PreservesLegacyLatestBeforeReplacingIt() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + Directory.CreateDirectory(Path.GetDirectoryName(plan.ReceiptPath)!); + const string legacy = + "{\"schemaVersion\":3,\"action\":\"Upload\",\"sourceCommit\":\"0123456789abcdef0123456789abcdef01234567\",\"checkedAt\":\"2026-08-01T10:00:00+00:00\",\"success\":false,\"targets\":[]}"; + File.WriteAllText(plan.ReceiptPath, legacy); + + var store = new AppleReleaseReceiptStore(); + store.WriteAttempt(plan, CreateReceipt(PowerForgeAppleReleaseAction.Status, success: true)); + + var receipts = store.ReadAll(plan); + Assert.Equal(2, receipts.Length); + var legacyPath = Assert.Single(Directory.GetFiles(plan.ReceiptHistoryPath, "*legacy*.json")); + Assert.Equal(legacy, File.ReadAllText(legacyPath)); + Assert.Contains(receipts, receipt => receipt.SchemaVersion == 3 && receipt.ReceiptSha256 is null); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void ReadAll_RejectsSchemaFourReceiptWithoutIntegrityHash() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + Directory.CreateDirectory(Path.GetDirectoryName(plan.ReceiptPath)!); + File.WriteAllText( + plan.ReceiptPath, + "{\"schemaVersion\":4,\"action\":\"Upload\",\"sourceCommit\":\"0123456789abcdef0123456789abcdef01234567\",\"targets\":[]}"); + + var exception = Assert.Throws(() => + new AppleReleaseReceiptStore().ReadAll(plan)); + + Assert.Contains("required integrity SHA-256", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void ReadAll_AllowsExplicitSchemaThreeReceiptWithoutIntegrityHash() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + Directory.CreateDirectory(Path.GetDirectoryName(plan.ReceiptPath)!); + File.WriteAllText( + plan.ReceiptPath, + "{\"schemaVersion\":3,\"action\":\"Upload\",\"sourceCommit\":\"0123456789abcdef0123456789abcdef01234567\",\"targets\":[]}"); + + var receipt = Assert.Single(new AppleReleaseReceiptStore().ReadAll(plan)); + + Assert.Equal(3, receipt.SchemaVersion); + Assert.Null(receipt.ReceiptSha256); + } + finally + { + TryDelete(root); + } + } + +#if NET8_0_OR_GREATER + [Fact] + public void WriteAttempt_RejectsLinkedReceiptHistoryDirectory() + { + if (OperatingSystem.IsWindows()) + return; + + var root = CreateSandbox(); + var outside = CreateSandbox(); + try + { + var plan = CreatePlan(root); + Directory.CreateDirectory(Path.GetDirectoryName(plan.ReceiptHistoryPath)!); + Directory.CreateSymbolicLink(plan.ReceiptHistoryPath, outside); + + var exception = Assert.Throws(() => + new AppleReleaseReceiptStore().WriteAttempt( + plan, + CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: true))); + Assert.Contains("symbolic link", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(Path.Combine(root, "build", "powerforge", "apple", "receipts"))) + Directory.Delete(Path.Combine(root, "build", "powerforge", "apple", "receipts")); + TryDelete(root); + TryDelete(outside); + } + } +#endif + + private static PowerForgeAppleReleasePlan CreatePlan(string root) + => new() + { + ProjectRoot = root, + ReceiptPath = Path.Combine(root, "build", "powerforge", "apple", "release-receipt.json"), + ReceiptHistoryPath = Path.Combine(root, "build", "powerforge", "apple", "receipts") + }; + + private static PowerForgeAppleReleaseReceipt CreateReceipt( + PowerForgeAppleReleaseAction action, + bool success) + => new() + { + Action = action, + SourceCommit = "0123456789abcdef0123456789abcdef01234567", + Success = success + }; + + private static string CreateSandbox() + { + var path = Path.Combine(Path.GetTempPath(), $"powerforge-receipts-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + var plan = CreatePlan(path); + foreach (var lockPath in new[] + { + AppleReleaseReceiptJournalLease.CreateLockPath(plan.ReceiptPath), + AppleReleaseReceiptJournalLease.CreateLockPath(plan.ReceiptHistoryPath) + }) + { + if (File.Exists(lockPath)) + File.Delete(lockPath); + } + + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + // Best-effort test cleanup. + } + } +} diff --git a/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs b/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs new file mode 100644 index 000000000..5ada53fc9 --- /dev/null +++ b/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs @@ -0,0 +1,156 @@ +namespace PowerForge.Tests; + +public sealed class AppleReleaseSourceMutationMonitorTests +{ + [Fact] + public void CaptureExpectedProducerOutput_arms_monitor_only_at_completion_boundary() + { + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.SourceMonitorTests", + Guid.NewGuid().ToString("N"))); + try + { + var artifact = Path.Combine(root.FullName, "Artifact.zip"); + using var monitor = new AppleReleaseSourceMutationMonitor( + root.FullName, + "producer output root", + "test producer", + "Discard the output.", + enableImmediately: false); + File.WriteAllText(artifact, "producer-output"); + + var identity = monitor.CaptureExpectedProducerOutput( + () => AppleNotarizationService.ComputeArtifactSha256(artifact), + "test producer"); + + Assert.Equal(AppleNotarizationService.ComputeArtifactSha256(artifact), identity); + monitor.ValidateNoChanges(); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void CaptureExpectedProducerOutput_rejects_replacement_after_first_identity_is_bound() + { + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.SourceMonitorTests", + Guid.NewGuid().ToString("N"))); + try + { + var artifact = Path.Combine(root.FullName, "Artifact.zip"); + File.WriteAllText(artifact, "producer-output"); + using var monitor = new AppleReleaseSourceMutationMonitor( + root.FullName, + "producer output root", + "test producer", + "Discard the output."); + var firstCapture = true; + + var exception = Assert.Throws(() => + monitor.CaptureExpectedProducerOutput( + () => + { + var identity = AppleNotarizationService.ComputeArtifactSha256(artifact); + if (firstCapture) + { + firstCapture = false; + File.WriteAllText(artifact, "replacement-output"); + } + return identity; + }, + "test producer")); + + Assert.Contains("changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("completed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public async Task CaptureExpectedProducerOutput_rejects_replacement_during_post_exit_drain() + { + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.SourceMonitorTests", + Guid.NewGuid().ToString("N"))); + try + { + var artifact = Path.Combine(root.FullName, "Artifact.zip"); + File.WriteAllText(artifact, "producer-output"); + using var monitor = new AppleReleaseSourceMutationMonitor( + root.FullName, + "producer output root", + "test producer", + "Discard the output."); + var replacement = Task.Run(async () => + { + await Task.Delay(50); + File.WriteAllText(artifact, "replacement-output"); + }); + + var exception = Assert.Throws(() => + monitor.CaptureExpectedProducerOutput( + () => AppleNotarizationService.ComputeArtifactSha256(artifact), + "test producer")); + await replacement; + + Assert.Contains("changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("completed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void CaptureExpectedProducerOutput_rejects_write_and_restore_during_final_arm_transition() + { + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.SourceMonitorTests", + Guid.NewGuid().ToString("N"))); + try + { + var artifact = Path.Combine(root.FullName, "Artifact.zip"); + File.WriteAllText(artifact, "producer-output"); + using var monitor = new AppleReleaseSourceMutationMonitor( + root.FullName, + "producer output root", + "test producer", + "Discard the output.", + enableImmediately: false); + var captures = 0; + + var exception = Assert.Throws(() => + monitor.CaptureExpectedProducerOutput( + () => + { + captures++; + if (captures == 2) + { + File.WriteAllText(artifact, "replacement-output"); + File.WriteAllText(artifact, "producer-output"); + Thread.Sleep(250); + } + return AppleNotarizationService.ComputeArtifactSha256(artifact); + }, + "test producer")); + + Assert.Contains("changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("bound", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } +} diff --git a/PowerForge.Tests/AppleReleaseSourceSnapshotTests.cs b/PowerForge.Tests/AppleReleaseSourceSnapshotTests.cs new file mode 100644 index 000000000..3854120ec --- /dev/null +++ b/PowerForge.Tests/AppleReleaseSourceSnapshotTests.cs @@ -0,0 +1,29 @@ +namespace PowerForge.Tests; + +public sealed class AppleReleaseSourceSnapshotTests +{ + [Fact] + public void RemoveWorktreeBestEffort_does_not_surface_git_cleanup_failure() + { + var git = new GitClient( + new FailingGitRunner(), + gitExecutable: "git-test"); + + AppleReleaseSourceSnapshot.RemoveWorktreeBestEffort( + git, + Path.GetTempPath(), + Path.Combine(Path.GetTempPath(), "missing-apple-source-snapshot")); + } + + private sealed class FailingGitRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(new ProcessRunResult( + 1, + string.Empty, + "simulated cleanup failure", + request.FileName, + TimeSpan.Zero, + false)); + } +} diff --git a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs index dda1c369b..a82b8cc7d 100644 --- a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs +++ b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs @@ -273,12 +273,41 @@ public void ApprovedApplePlanAndBoundAutomationOutputsDoNotRequireASecondCheckou const string planSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; try { + Directory.CreateDirectory(parent); + var authenticationKeyPath = Path.Combine(parent, "apple-receipt-auth.key"); + var authenticationKey = Enumerable.Range(1, 32).Select(static value => (byte)value).ToArray(); + File.WriteAllBytes(authenticationKeyPath, authenticationKey); + string Authenticate(string receiptSha256) + { + using var hmac = new HMACSHA256(authenticationKey); + return Convert.ToHexString(hmac.ComputeHash(System.Text.Encoding.ASCII.GetBytes(receiptSha256))).ToLowerInvariant(); + } var output = Path.Combine(sandbox, "build", "powerforge", "apple"); Directory.CreateDirectory(output); + var receiptHistory = Directory.CreateDirectory(Path.Combine(output, "receipts")); File.WriteAllText(Path.Combine(sandbox, "powerforge.release.json"), - """{ "AppleApps": { "ProjectRoot": ".", "Automation": { "ReceiptPath": "build/powerforge/apple/release-receipt.json", "PlanReceiptPath": "build/powerforge/apple/release-plan.json", "LockPath": "build/powerforge/apple/release.lock" }, "Apps": [ { "Enabled": true, "DistributionRoute": "AppStore", "ProjectPath": "Sample.xcodeproj" } ] } }"""); + """{ "AppleApps": { "ProjectRoot": ".", "Automation": { "ReceiptPath": "build/powerforge/apple/release-receipt.json", "ReceiptHistoryPath": "build/powerforge/apple/receipts", "PlanReceiptPath": "build/powerforge/apple/release-plan.json", "LockPath": "build/powerforge/apple/release.lock" }, "Apps": [ { "Enabled": true, "DistributionRoute": "AppStore", "ProjectPath": "Sample.xcodeproj" } ] } }"""); File.WriteAllText(Path.Combine(sandbox, ".gitignore"), "build/\n"); - File.WriteAllText(Path.Combine(output, "release-receipt.json"), JsonSerializer.Serialize(new { sourceCommit = commit })); + const string latestSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + File.WriteAllText(Path.Combine(output, "release-receipt.json"), JsonSerializer.Serialize(new { + schemaVersion = 6, attemptId = "00000000000000000000000000000000", receiptSha256 = latestSha, + sourceCommit = commit })); + File.WriteAllText(Path.Combine(receiptHistory.FullName, "prior-upload.json"), JsonSerializer.Serialize(new + { + schemaVersion = 5, + attemptId = "11111111111111111111111111111111", + receiptSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + receiptAuthenticationSha256 = Authenticate("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + sourceCommit = "89abcdef0123456789abcdef0123456789abcdef" + })); + File.WriteAllText(Path.Combine(receiptHistory.FullName, "local-status.json"), JsonSerializer.Serialize(new + { + schemaVersion = 5, + attemptId = "22222222222222222222222222222222", + receiptSha256 = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + receiptAuthenticationSha256 = Authenticate("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), + action = "Status" + })); File.WriteAllText(Path.Combine(output, "release-plan.json"), JsonSerializer.Serialize(new { planOnly = true, @@ -299,12 +328,14 @@ public void ApprovedApplePlanAndBoundAutomationOutputsDoNotRequireASecondCheckou $script:gitPath = '/usr/bin/git' $script:allowedConsumerEvidencePaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $consumer = [IO.Path]::GetFullPath($Consumer) + $env:POWERFORGE_APPLE_RECEIPT_AUTH_KEY_PATH = '{{authenticationKeyPath.Replace("'", "''", StringComparison.Ordinal)}}' $ArgumentList = @('apple-release','Advance','--config','powerforge.release.json','--apple-expected-plan-sha256','{{planSha256}}') function Invoke-GitText { param([string]$Root,[string[]]$Arguments); $o=@(& $script:gitPath -c core.quotePath=false -C $Root @Arguments 2>&1); if($LASTEXITCODE -ne 0){throw 'git failed'}; return ($o -join [Environment]::NewLine).Trim() } function Get-OptionValue { param([string]$Option); $i=[Array]::IndexOf($ArgumentList,$Option); if($i -ge 0 -and $i+1 -lt $ArgumentList.Count){return $ArgumentList[$i+1]}; return $null } function Resolve-OptionPath { param([string]$Value); if([IO.Path]::IsPathRooted($Value)){return [IO.Path]::GetFullPath($Value)}; return [IO.Path]::GetFullPath((Join-Path $consumer $Value)) } function Resolve-PathFromBase { param([string]$BasePath,[string]$Value); if([IO.Path]::IsPathRooted($Value)){return [IO.Path]::GetFullPath($Value)}; return [IO.Path]::GetFullPath((Join-Path $BasePath $Value)) } function Assert-UnlinkedPath { param([string]$Path,[string]$Name,[switch]$AllowMissingLeaf) } + function Assert-UnlinkedDirectory { param([string]$Path,[string]$Name) } . $Support Register-AppleAutomationEvidence -SourceCommit '{{commit}}' Assert-ConsumerRepositoryContent @@ -316,6 +347,23 @@ public void ApprovedApplePlanAndBoundAutomationOutputsDoNotRequireASecondCheckou Register-AppleAutomationEvidence -SourceCommit '{{commit}}' Assert-ConsumerRepositoryContent } + Set-Content -LiteralPath (Join-Path $consumer 'build/powerforge/apple/receipts/injected.bin') -Value 'not a receipt' + try { Register-AppleAutomationEvidence -SourceCommit '{{commit}}'; throw 'Unsupported receipt history was accepted.' } + catch { if ($_.Exception.Message -notlike '*unsupported entry*') { throw } } + Remove-Item -LiteralPath (Join-Path $consumer 'build/powerforge/apple/receipts/injected.bin') + $latestReceipt = Join-Path $consumer 'build/powerforge/apple/release-receipt.json' + $savedLatestReceipt = Get-Content -LiteralPath $latestReceipt -Raw + Set-Content -LiteralPath $latestReceipt -Value '{"schemaVersion":3,"sourceCommit":"89abcdef0123456789abcdef0123456789abcdef"}' + Register-AppleAutomationEvidence -SourceCommit '{{commit}}' + Set-Content -LiteralPath $latestReceipt -Value $savedLatestReceipt -NoNewline + $historyDirectory = Join-Path $consumer 'build/powerforge/apple/receipts' + $historyBackup = Join-Path (Split-Path -Parent $consumer) 'receipts-backup' + Move-Item -LiteralPath $historyDirectory -Destination $historyBackup + Set-Content -LiteralPath $latestReceipt -Value '{"schemaVersion":4,"attemptId":"ffffffffffffffffffffffffffffffff","receiptSha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","sourceCommit":"{{commit}}"}' + try { Register-AppleAutomationEvidence -SourceCommit '{{commit}}'; throw 'Forged self-hashed receipt was accepted.' } + catch { if ($_.Exception.Message -notlike '*without a supported current receipt chain*') { throw } } + Move-Item -LiteralPath $historyBackup -Destination $historyDirectory + Set-Content -LiteralPath $latestReceipt -Value $savedLatestReceipt -NoNewline Set-Content -LiteralPath (Join-Path $consumer 'build/powerforge/apple/injected.bin') -Value 'not reviewed' try { Assert-ConsumerRepositoryContent; throw 'Unreviewed file was accepted.' } catch { if ($_.Exception.Message -notlike '*non-reviewed content*') { throw }; 'PASS' } diff --git a/PowerForge.Tests/AppleReleaseWorkflowTests.RunnerLocalCredentials.cs b/PowerForge.Tests/AppleReleaseWorkflowTests.RunnerLocalCredentials.cs index 7ce53d6e9..d825c1ddd 100644 --- a/PowerForge.Tests/AppleReleaseWorkflowTests.RunnerLocalCredentials.cs +++ b/PowerForge.Tests/AppleReleaseWorkflowTests.RunnerLocalCredentials.cs @@ -14,6 +14,7 @@ public void PinnedLocalOperatorRequiresExactToolAndCleanMergedConsumerSources() var root = FindRepoRoot(); var script = Read(root, "scripts", "Invoke-PinnedPowerForge.ps1"); var evidence = Read(root, "scripts", "Invoke-PinnedPowerForge.Evidence.ps1"); + Assert.Contains("^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$", script, StringComparison.Ordinal); Assert.Contains("RequiredCommit $ExpectedCommit", script, StringComparison.Ordinal); Assert.Contains("ExpectedConsumerRepository", script, StringComparison.Ordinal); Assert.Contains("symbolic-ref', '--short', 'HEAD", script, StringComparison.Ordinal); diff --git a/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs b/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs new file mode 100644 index 000000000..2a90d99db --- /dev/null +++ b/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs @@ -0,0 +1,128 @@ +namespace PowerForge.Tests; + +public sealed class AppleRemotePackageMirrorLeaseTests +{ + [Fact] + public async Task AcquireRemotePackageMirrorLease_SerializesConcurrentUsersOfTheSameMirror() + { + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.RemotePackageMirrorLeaseTests", + Guid.NewGuid().ToString("N"))); + try + { + var mirrorPath = Path.Combine(root.FullName, "package.git"); + var firstLease = AppleReleaseSourceTrustService.AcquireRemotePackageMirrorLease(mirrorPath); + var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondLease = Task.Run(() => + { + secondStarted.SetResult(true); + using var lease = AppleReleaseSourceTrustService.AcquireRemotePackageMirrorLease(mirrorPath); + }); + + await secondStarted.Task; + await Task.Delay(150); + Assert.False(secondLease.IsCompleted); + + firstLease.Dispose(); + await secondLease.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void EnsureRemotePackageMirror_AcceptsRevisionMaterializedByConcurrentFetcher() + { + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.RemotePackageMirrorLeaseTests", + Guid.NewGuid().ToString("N"))); + try + { + var revisionAvailable = false; + var fetchCalls = 0; + var runner = new StubProcessRunner(request => + { + if (request.Arguments.Contains("fetch")) + { + fetchCalls++; + revisionAvailable = true; + return Failure(request, "simulated competing fetch lock"); + } + if (request.Arguments.Contains("cat-file")) + return revisionAvailable ? Success(request) : Failure(request, "missing revision"); + if (request.Arguments.Contains("--show-object-format")) + return Success(request, "sha1\n"); + return Success(request); + }); + var git = new GitClient(runner, "/usr/bin/git", TimeSpan.FromSeconds(30)); + var service = new AppleReleaseSourceTrustService(gitClient: git); + var mirrorPath = Path.Combine(root.FullName, "package.git"); + + service.EnsureRemotePackageMirror( + mirrorPath, + "https://example.invalid/package.git", + new string('a', 40)); + + Assert.Equal(1, fetchCalls); + Assert.True(Directory.Exists(mirrorPath)); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void AcquireRemotePackageMirrorLease_RejectsLinkedLockWithoutChangingTargetPermissions() + { + if (OperatingSystem.IsWindows()) return; + var root = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + "PowerForge.RemotePackageMirrorLeaseTests", + Guid.NewGuid().ToString("N"))); + try + { + var mirrorPath = Path.Combine(root.FullName, "package.git"); + var targetPath = Path.Combine(root.FullName, "unrelated.txt"); + File.WriteAllText(targetPath, "unrelated"); + File.SetUnixFileMode(targetPath, UnixFileMode.UserRead); + File.CreateSymbolicLink(mirrorPath + ".lock", targetPath); + + var exception = Assert.Throws(() => + AppleReleaseSourceTrustService.AcquireRemotePackageMirrorLease(mirrorPath)); + + Assert.Contains("symbolic link", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(UnixFileMode.UserRead, File.GetUnixFileMode(targetPath)); + } + finally + { + try + { + File.SetUnixFileMode(Path.Combine(root.FullName, "unrelated.txt"), UnixFileMode.UserRead | UnixFileMode.UserWrite); + root.Delete(recursive: true); + } + catch { } + } + } + + private static ProcessRunResult Success(ProcessRunRequest request) + => new(0, string.Empty, string.Empty, request.FileName, TimeSpan.Zero, timedOut: false); + + private static ProcessRunResult Success(ProcessRunRequest request, string output) + => new(0, output, string.Empty, request.FileName, TimeSpan.Zero, timedOut: false); + + private static ProcessRunResult Failure(ProcessRunRequest request, string error) + => new(1, string.Empty, error, request.FileName, TimeSpan.Zero, timedOut: false); + + private sealed class StubProcessRunner(Func execute) : IProcessRunner + { + public Task RunAsync( + ProcessRunRequest request, + CancellationToken cancellationToken = default) + => Task.FromResult(execute(request)); + } +} diff --git a/PowerForge.Tests/GitClientTests.cs b/PowerForge.Tests/GitClientTests.cs index 28c1de84f..871984b3c 100644 --- a/PowerForge.Tests/GitClientTests.cs +++ b/PowerForge.Tests/GitClientTests.cs @@ -4,6 +4,23 @@ namespace PowerForge.Tests; public sealed class GitClientTests { + [Fact] + public void ExistingFilePhysicalStatus_reports_identity_and_metadata_change_token() + { + var path = Path.GetTempFileName(); + try + { + var status = ExistingFilePathIdentityResolver.ResolveStatus(path); + + Assert.False(string.IsNullOrWhiteSpace(status.ChangeToken)); + Assert.False(string.IsNullOrWhiteSpace(status.Identity)); + } + finally + { + File.Delete(path); + } + } + [Fact] public async Task GetStatusAsync_ParsesBranchAheadBehindAndChangeCounts() { @@ -73,6 +90,62 @@ public async Task GetRemoteUrlAsync_BuildsTypedGitArguments() Assert.True(result.Succeeded); } + [Fact] + public async Task TrustedSystemClient_UsesFixedExecutableAndIsolatedGitEnvironment() + { + ProcessRunRequest? captured = null; + var runner = new StubProcessRunner(request => + { + captured = request; + return new ProcessRunResult(0, string.Empty, string.Empty, request.FileName, TimeSpan.Zero, timedOut: false); + }); + var client = GitClient.CreateTrustedSystemClient(runner, TimeSpan.FromSeconds(30)); + + await client.RunRawAsync(Directory.GetCurrentDirectory(), ["status", "--short"]); + + Assert.NotNull(captured); + Assert.True(Path.IsPathFullyQualified(captured!.FileName)); + Assert.Equal(Path.DirectorySeparatorChar == '\\' ? "git.exe" : "git", Path.GetFileName(captured.FileName)); + if (Path.DirectorySeparatorChar != '\\') + Assert.Equal("/usr/bin/git", captured.FileName); + Assert.False(captured.InheritEnvironment); + Assert.NotNull(captured.EnvironmentVariables); + Assert.False(captured.EnvironmentVariables!.ContainsKey("PATH")); + Assert.False(captured.EnvironmentVariables.ContainsKey("GIT_DIR")); + Assert.Equal("1", captured.EnvironmentVariables["GIT_CONFIG_NOSYSTEM"]); + Assert.Equal("0", captured.EnvironmentVariables["GIT_TERMINAL_PROMPT"]); + Assert.Equal("core.hooksPath", captured.EnvironmentVariables["GIT_CONFIG_KEY_0"]); + Assert.Equal("core.fsmonitor", captured.EnvironmentVariables["GIT_CONFIG_KEY_1"]); + } + + [Fact] + public async Task TrustedSystemClient_ForwardsSshAgentSocketWithoutInheritingOtherEnvironment() + { + const string expectedSocket = "/private/tmp/powerforge-test-ssh-agent.sock"; + var previousSocket = Environment.GetEnvironmentVariable("SSH_AUTH_SOCK"); + ProcessRunRequest? captured = null; + try + { + Environment.SetEnvironmentVariable("SSH_AUTH_SOCK", expectedSocket); + var runner = new StubProcessRunner(request => + { + captured = request; + return new ProcessRunResult(0, string.Empty, string.Empty, request.FileName, TimeSpan.Zero, timedOut: false); + }); + var client = GitClient.CreateTrustedSystemClient(runner, TimeSpan.FromSeconds(30)); + + await client.RunRawAsync(Directory.GetCurrentDirectory(), ["status", "--short"]); + + Assert.NotNull(captured); + Assert.False(captured!.InheritEnvironment); + Assert.Equal(expectedSocket, captured.EnvironmentVariables!["SSH_AUTH_SOCK"]); + } + finally + { + Environment.SetEnvironmentVariable("SSH_AUTH_SOCK", previousSocket); + } + } + private sealed class StubProcessRunner : IProcessRunner { private readonly Func _execute; diff --git a/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs index ef7d0121e..b3b7bce27 100644 --- a/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs +++ b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs @@ -102,15 +102,15 @@ public async Task AppleRelease_CliUsesDedicatedEnvelopeAndReportsLegacyConfirmat var advance = await RunCliAsync( repoRoot, $"\"{GetCliPath(repoRoot)}\" apple-release Advance --config \"{configPath}\" --plan --summary --output json"); - Assert.Equal(0, advance.ExitCode); + Assert.NotEqual(0, advance.ExitCode); using (var advanceDocument = JsonDocument.Parse(advance.StdOut)) { - var result = advanceDocument.RootElement.GetProperty("result"); - Assert.Equal("Advance", result.GetProperty("action").GetString()); - Assert.True(result.GetProperty("requiresConfirmation").GetBoolean()); + var rejected = advanceDocument.RootElement; + Assert.False(rejected.GetProperty("success").GetBoolean()); Assert.Contains( - result.GetProperty("enabledSteps").EnumerateArray(), - static step => step.GetString() == "stopBeforeReview"); + "No supported key formats", + rejected.GetProperty("error").GetString(), + StringComparison.OrdinalIgnoreCase); } var configuredDedicated = await RunCliAsync( @@ -141,10 +141,17 @@ public async Task AppleRelease_CliUsesDedicatedEnvelopeAndReportsLegacyConfirmat StringComparison.OrdinalIgnoreCase); } - WriteReleaseConfig(configPath, submitForReview: true, includeInvalidModule: false); + WriteReleaseConfig(configPath, submitForReview: false, includeInvalidModule: false); + File.WriteAllText(Path.Combine(tempRoot, "screenshots.json"), "{}"); + File.WriteAllText( + configPath, + File.ReadAllText(configPath).Replace( + "\"Archive\": false,", + "\"Archive\": false, \"SyncScreenshots\": true, \"ReplaceScreenshots\": true, \"ScreenshotConfigPath\": \"screenshots.json\",", + StringComparison.Ordinal)); var configured = await RunCliAsync( repoRoot, - $"\"{GetCliPath(repoRoot)}\" release --config \"{configPath}\" --plan --summary --output json"); + $"\"{GetCliPath(repoRoot)}\" release --config \"{configPath}\" --validate --summary --output json"); Assert.True( configured.ExitCode == 0, diff --git a/PowerForge.Tests/PowerForgeReleaseProgressAdaptersTests.cs b/PowerForge.Tests/PowerForgeReleaseProgressAdaptersTests.cs index c82d59506..eea3ba500 100644 --- a/PowerForge.Tests/PowerForgeReleaseProgressAdaptersTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseProgressAdaptersTests.cs @@ -309,6 +309,23 @@ public void ItemUpdated( public sealed class ProcessRunnerStreamingTests { + [Fact] + public async Task RunAsync_InvokesStartBoundaryAfterSuccessfulProcessStart() + { + var starts = 0; + var request = new ProcessRunRequest( + "dotnet", + Directory.GetCurrentDirectory(), + new[] { "--version" }, + TimeSpan.FromSeconds(30)); + request.SetStartBoundary(() => starts++); + + var result = await new ProcessRunner().RunAsync(request); + + Assert.True(result.Succeeded, result.StdErr); + Assert.Equal(1, starts); + } + [Fact] public async Task RunAsync_CapturesOutputAndForwardsLines() { diff --git a/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs b/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs index 1bcb8e593..a471362c8 100644 --- a/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs @@ -89,7 +89,11 @@ public void Build_MapsCompactAppleActionOverrides() new PSPublishModule.PowerForgeReleaseInvocationOptions { AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleMarketingVersion = "1.6", + AppleSourceCommit = "0123456789abcdef0123456789abcdef01234567", + AppleExpectedPlanSha256 = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", AppleActionConfirmed = true, + AppleAdoptExistingBuild = true, AppleResume = false, AppleWaitForProcessing = true, AppleProcessingTimeoutSeconds = 900, @@ -98,7 +102,12 @@ public void Build_MapsCompactAppleActionOverrides() }); Assert.Equal(PowerForgeAppleReleaseAction.Upload, request.AppleAction); + Assert.Equal("1.6", request.AppleMarketingVersion); + Assert.Equal("0123456789abcdef0123456789abcdef01234567", request.AppleSourceCommit); + Assert.True(request.RequireImmutableAppleSourceSnapshot); + Assert.Equal("abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", request.AppleExpectedPlanSha256); Assert.True(request.AppleActionConfirmed); + Assert.True(request.AppleAdoptExistingBuild); Assert.False(request.AppleResume); Assert.True(request.AppleWaitForProcessing); Assert.Equal(900, request.AppleProcessingTimeoutSeconds); @@ -106,6 +115,20 @@ public void Build_MapsCompactAppleActionOverrides() Assert.True(request.AppleSummaryOnly); } + [Fact] + public void Build_PreservesImmutableAppleSourceSnapshotFromDefaults() + { + var request = PSPublishModule.PowerForgeReleaseRequestMapper.Build( + "/repo/powerforge.release.json", + new PowerForgeReleaseRequest + { + RequireImmutableAppleSourceSnapshot = true + }, + new PSPublishModule.PowerForgeReleaseInvocationOptions()); + + Assert.True(request.RequireImmutableAppleSourceSnapshot); + } + [Fact] public void Build_MapsModuleRunMode() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppInfoMetadata.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppInfoMetadata.cs index f771d4bf9..881220480 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppInfoMetadata.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppInfoMetadata.cs @@ -189,6 +189,35 @@ public void Execute_AppleApps_AppliesMultipleAppInfoLocalesOnceForSharedApp() } } + [Fact] + public void Execute_AppleApps_RejectsUnconfirmedAppInfoMutationResult() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "Tactra.xcodeproj"); + var keyPath = Path.Combine(root, "AuthKey_ABC123DEFG.p8"); + File.WriteAllText(keyPath, "private-key"); + WriteAppInfoConfig(root, includeAppId: true); + var result = CreateAppInfoReleaseService( + new List(), + corruptResponse: true) + .Execute( + CreateAppInfoReleaseSpec(keyPath), + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json") + }); + + Assert.False(result.Success); + Assert.Contains("did not confirm App Information field", result.AppleReceipt!.ErrorMessage, StringComparison.Ordinal); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleApps_RejectsMissingAppInfoConfigMatch() { @@ -287,7 +316,8 @@ public void Execute_AppleApps_PreflightsEveryAppInfoPayloadBeforeRemoteMutation( } private static PowerForgeReleaseService CreateAppInfoReleaseService( - List requests) + List requests, + bool corruptResponse = false) => new( new NullLogger(), executePackages: (_, _, _) => throw new InvalidOperationException("Packages should not run."), @@ -306,15 +336,27 @@ private static PowerForgeReleaseService CreateAppInfoReleaseService( { AppId = request.AppId, Platform = request.Platform, - AppInfoMetadataResults = new[] - { - new AppStoreConnectAppInfoMetadataSyncResult + AppInfoMetadataResults = request.AppInfoMetadataSpecs + .Select((spec, index) => new AppStoreConnectAppInfoMetadataSyncResult { + AppInfo = new AppStoreConnectAppInformationInfo { Id = "app-info-1" }, + After = new AppStoreConnectAppInfoLocalizationInfo + { + Id = $"localization-{index + 1}", + Locale = spec.Locale, + Name = spec.Metadata.Name, + Subtitle = spec.Metadata.Subtitle, + PrivacyPolicyUrl = corruptResponse ? "https://unexpected.example.invalid/" : spec.Metadata.PrivacyPolicyUrl, + PrivacyChoicesUrl = spec.Metadata.PrivacyChoicesUrl, + PrivacyPolicyText = spec.Metadata.PrivacyPolicyText + }, UpdatedFields = new[] { "privacyPolicyUrl" } - } - } + }) + .ToArray() }; - }); + }, + getAppleReleaseState: _ => throw new InvalidOperationException( + "App Information-only mutation must not read version/build release state.")); private static PowerForgeReleaseSpec CreateAppInfoReleaseSpec(string keyPath) => new() diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs index 19ed9e3dd..35df90666 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs @@ -2,6 +2,54 @@ namespace PowerForge.Tests; public sealed partial class PowerForgeReleaseServiceTests { + [Fact] + public void Execute_AppleCheckpoint_writes_publish_shaped_plan_after_archive() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Archive = true; + spec.AppleApps.Upload = true; + spec.AppleApps.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var result = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Archive checkpoint must not query App Store Connect."), + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "signed archive"); + return CreateSuccessfulArchive(request); + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + CheckpointAppleApps = true, + AppleSourceCommit = sourceCommit + }); + + Assert.True(result.Success, result.ErrorMessage); + Assert.False(result.AppleAppPlan!.Archive); + var receipt = Assert.IsType(result.AppleReceipt); + Assert.True(receipt.PlanOnly); + Assert.Equal(sourceCommit, receipt.SourceCommit); + Assert.Matches("^[0-9A-Fa-f]{64}$", receipt.PlanSha256); + var target = Assert.Single(receipt.Targets); + Assert.EndsWith("/apple/archives/iOS/CasaRay-iOS.xcarchive", target.ArchivePath, StringComparison.Ordinal); + Assert.Matches("^[0-9A-Fa-f]{64}$", target.ArchiveSha256); + Assert.Equal(target.ArchiveSha256, Assert.Single(result.AppleAppPlan.Apps).ExpectedArchiveSha256); + Assert.True(File.Exists(result.AppleAppPlan.PlanReceiptPath)); + } + finally + { + TryDelete(root); + } + } + [Fact] public void PublishBuiltReleaseOutputs_ExecutesConfiguredAppleLane() { @@ -38,6 +86,61 @@ public void PublishBuiltReleaseOutputs_ExecutesConfiguredAppleLane() } } + [Fact] + public void PublishBuiltReleaseOutputs_rejects_replaced_checkpoint_archive_before_upload() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Archive = false; + spec.AppleApps.Upload = true; + spec.AppleApps.Automation.Resume = false; + var archivePath = Path.Combine( + root, + "build", + "powerforge", + "apple", + "archives", + "iOS", + "CasaRay-iOS.xcarchive"); + Directory.CreateDirectory(archivePath); + File.WriteAllText(Path.Combine(archivePath, "payload"), "replacement bytes"); + var uploadCalls = 0; + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Archive hash rejection must happen before remote state."), + uploadAppleApp: request => + { + uploadCalls++; + return CreateSuccessfulUpload(request); + }); + + var result = service.PublishBuiltReleaseOutputs( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleActionConfirmed = true, + AppleExpectedArchiveSha256ByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CasaRay iOS"] = new string('1', 64) + } + }, + new PowerForgeReleaseResult { Success = true }); + + Assert.False(result.Success); + Assert.Equal(0, uploadCalls); + Assert.Contains("changed before publish", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleUploadExisting_PreflightsEveryArchiveBeforeFirstUpload() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index a13465179..9d82d4d36 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -2,6 +2,102 @@ namespace PowerForge.Tests; public sealed partial class PowerForgeReleaseServiceTests { + [Fact] + public void Execute_AppleArchive_RejectsMalformedJournalBeforeArchiveMutation() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var history = Path.Combine(root, "build", "powerforge", "apple", "receipts"); + Directory.CreateDirectory(history); + File.WriteAllText(Path.Combine(history, "broken.json"), "{not-json"); + var archiveCalls = 0; + var service = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: request => + { + archiveCalls++; + return CreateSuccessfulArchive(request); + }); + + var result = service.Execute( + CreateAppleAutomationSpec(root, keyPath), + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive + }); + + Assert.False(result.Success); + Assert.Equal(0, archiveCalls); + Assert.Contains("not valid JSON", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleUpload_RejectsPrivateArchiveSnapshotMutation() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + spec.AppleApps.Automation.WaitForProcessing = false; + string? uploaderArchivePath = null; + var trustedUploadExecution = false; + var service = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "before"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: request => + { + uploaderArchivePath = request.ArchivePath; + trustedUploadExecution = request.RequireTrustedSystemTools; + File.WriteAllText(Path.Combine(request.ArchivePath!, "payload"), "after"); + return CreateSuccessfulUpload(request); + }); + + var result = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleSourceCommit = "0123456789abcdef0123456789abcdef01234567", + AppleAction = PowerForgeAppleReleaseAction.Upload + }); + + Assert.False(result.Success); + Assert.NotNull(uploaderArchivePath); + Assert.True(trustedUploadExecution); + Assert.NotEqual(Assert.Single(result.AppleAppPlan!.Apps).ArchivePath, uploaderArchivePath); + Assert.Equal("before", File.ReadAllText(Path.Combine(Assert.Single(result.AppleAppPlan.Apps).ArchivePath, "payload"))); + Assert.Contains("private Apple upload archive snapshot changed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + var uploadAttestation = Assert.Single( + new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested"); + Assert.True(Assert.Single(uploadAttestation.Targets).UploadPerformed); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleStatusPlan_ProducesReadOnlyExplicitPlan() { @@ -18,12 +114,14 @@ public void Execute_AppleStatusPlan_ProducesReadOnlyExplicitPlan() { ConfigPath = Path.Combine(root, "powerforge.release.json"), PlanOnly = true, - AppleAction = PowerForgeAppleReleaseAction.Status + AppleAction = PowerForgeAppleReleaseAction.Status, + AppleSourceCommit = new string('a', 64) }); - Assert.True(result.Success); + Assert.True(result.Success, result.ErrorMessage); var plan = Assert.IsType(result.AppleAppPlan); Assert.Equal(PowerForgeAppleReleaseAction.Status, plan.Action); + Assert.Equal(new string('a', 64), plan.SourceCommit); Assert.False(plan.Archive); Assert.False(plan.Upload); Assert.False(plan.PrepareDistribution); @@ -101,6 +199,78 @@ public void Execute_AppleProtectedPlan_BindsExactObservedAppleState() } } + [Fact] + public void Execute_ConfiguredProtectedPlan_BindsExactObservedAppleState() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var processingState = "PROCESSING"; + var service = CreateAppleAutomationService(request => CreateReleaseState(request, processingState)); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Archive = false; + spec.AppleApps.Upload = false; + spec.AppleApps.SubmitForReview = true; + spec.AppleApps.SkipReviewReadinessCheck = true; + var request = new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Configured + }; + + var first = service.Execute(spec, request); + processingState = "VALID"; + var changed = service.Execute(spec, request); + + Assert.Equal("PROCESSING", Assert.Single(first.AppleReceipt!.Targets).BuildProcessingState); + Assert.Equal("VALID", Assert.Single(changed.AppleReceipt!.Targets).BuildProcessingState); + Assert.NotEqual(first.AppleReceipt.PlanSha256, changed.AppleReceipt.PlanSha256); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ConfiguredPlan_BindsArchiveAndUploadExecutionFlags() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var service = CreateAppleAutomationService(request => CreateReleaseState(request, "VALID")); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Archive = false; + spec.AppleApps.Upload = false; + var request = new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Configured + }; + + var statusOnly = service.Execute(spec, request); + spec.AppleApps.Archive = true; + var archive = service.Execute(spec, request); + spec.AppleApps.Upload = true; + var archiveAndUpload = service.Execute(spec, request); + + Assert.NotEqual(statusOnly.AppleReceipt!.PlanSha256, archive.AppleReceipt!.PlanSha256); + Assert.NotEqual(archive.AppleReceipt.PlanSha256, archiveAndUpload.AppleReceipt!.PlanSha256); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleProtectedMutationRejectsStateChangedAfterPlanApproval() { @@ -173,6 +343,136 @@ public void Execute_AppleProtectedMutationRejectsStateChangedAfterPlanApproval() } } + [Fact] + public void Execute_AppleScreenshotReplacementRejectsRemoteInventoryChangedAfterPlanApproval() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var screenshotFolder = Directory.CreateDirectory(Path.Combine(root, "screenshots")); + File.WriteAllText(Path.Combine(screenshotFolder.FullName, "home.png"), "approved pixels"); + WriteScreenshotConfig(root, "screenshots.json", "6778025328", "1.2.0", "iOS", "screenshots", qualityEnabled: false); + var remoteScreenshotId = "screenshot-before"; + var prepareCalls = 0; + string? forwardedScreenshotInventorySha256 = null; + var service = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + prepareAppleDistribution: request => + { + prepareCalls++; + forwardedScreenshotInventorySha256 = request.ExpectedScreenshotInventorySha256; + return new AppStoreConnectReleasePreparationResult(); + }, + checkAppleReleaseReadiness: (_, request) => new AppStoreConnectReleaseReadinessResult + { + AppId = request.AppId, + VersionString = request.VersionString, + BuildNumber = request.BuildNumber, + Platform = request.Platform, + ScreenshotSets = + [ + new AppStoreConnectReleaseScreenshotSetReadiness + { + ScreenshotDisplayType = "APP_IPHONE_65", + ScreenshotSetId = "set-1", + Count = 1, + Screenshots = + [ + new AppStoreConnectReleaseScreenshotAssetReadiness + { + Id = remoteScreenshotId, + FileName = "remote.png", + FileSize = 1234, + SourceFileChecksum = remoteScreenshotId + "-checksum", + AssetDeliveryState = "COMPLETE" + } + ] + } + ] + }); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Archive = false; + spec.AppleApps.Upload = false; + spec.AppleApps!.SyncScreenshots = true; + spec.AppleApps.ReplaceScreenshots = true; + spec.AppleApps.ScreenshotConfigPath = "screenshots.json"; + + var approved = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Configured + }); + var approvedTarget = Assert.Single(approved.AppleReceipt!.Targets); + Assert.False(approvedTarget.ReadinessChecked); + Assert.Matches("^[0-9A-F]{64}$", approvedTarget.ScreenshotInventorySha256!); + var approvedPlan = Assert.IsType(approved.AppleAppPlan); + Assert.Equal( + approvedTarget.ScreenshotInventorySha256, + Assert.Single(approvedPlan.Apps).ExpectedScreenshotInventorySha256); + + var missingPlanApproval = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Configured, + AppleActionConfirmed = true + }); + Assert.False(missingPlanApproval.Success); + Assert.Contains("reviewed exact Apple plan", missingPlanApproval.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, prepareCalls); + + remoteScreenshotId = "screenshot-after"; + + var execution = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Configured, + AppleActionConfirmed = true, + AppleExpectedPlanSha256 = approved.AppleReceipt.PlanSha256 + }); + + Assert.False(execution.Success); + Assert.Equal(0, prepareCalls); + Assert.Contains("changed after plan approval", execution.ErrorMessage, StringComparison.OrdinalIgnoreCase); + + var refreshed = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Configured + }); + var refreshedInventorySha256 = Assert.Single(refreshed.AppleReceipt!.Targets).ScreenshotInventorySha256; + var successfulExecution = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Configured, + AppleActionConfirmed = true, + AppleExpectedPlanSha256 = refreshed.AppleReceipt.PlanSha256 + }); + + Assert.True(successfulExecution.Success, successfulExecution.ErrorMessage); + Assert.Equal(1, prepareCalls); + Assert.Equal(refreshedInventorySha256, forwardedScreenshotInventorySha256); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_ExplicitAppleAction_IgnoresEveryNonAppleReleaseSection() { @@ -238,19 +538,22 @@ public void Execute_ApplePreparePlan_EnablesConfiguredDistributionInputs() var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); File.WriteAllText(keyPath, "private-key"); File.WriteAllText(Path.Combine(root, "metadata.json"), "{}"); - File.WriteAllText(Path.Combine(root, "screenshots.json"), "{}"); + WriteScreenshotConfig(root, "screenshots.json", "6778025328", "1.2.0", "iOS", ".", qualityEnabled: false); var spec = CreateAppleAutomationSpec(root, keyPath); spec.AppleApps!.MetadataConfigPath = "metadata.json"; spec.AppleApps.ScreenshotConfigPath = "screenshots.json"; - var result = new PowerForgeReleaseService(new NullLogger()).Execute( - spec, - new PowerForgeReleaseRequest - { - ConfigPath = Path.Combine(root, "powerforge.release.json"), - PlanOnly = true, - AppleAction = PowerForgeAppleReleaseAction.Prepare - }); + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + checkAppleReleaseReadiness: (_, request) => CreateReadyReleaseReadiness(request)) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Prepare + }); var plan = Assert.IsType(result.AppleAppPlan); Assert.True(plan.PrepareDistribution); @@ -260,6 +563,7 @@ public void Execute_ApplePreparePlan_EnablesConfiguredDistributionInputs() Assert.True(plan.CheckReleaseReadiness); Assert.False(plan.Archive); Assert.False(plan.Upload); + Assert.Null(Assert.Single(result.AppleReceipt!.Targets).BuildId); } finally { @@ -280,19 +584,22 @@ public void Execute_AppleAdvancePlan_RequiresExplicitScreenshotOptIn( CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); File.WriteAllText(keyPath, "private-key"); - File.WriteAllText(Path.Combine(root, "screenshots.json"), "{}"); + WriteScreenshotConfig(root, "screenshots.json", "6778025328", "1.2.0", "iOS", ".", qualityEnabled: false); var spec = CreateAppleAutomationSpec(root, keyPath); spec.AppleApps!.ScreenshotConfigPath = "screenshots.json"; spec.AppleApps.SyncScreenshots = configuredSync; - var result = new PowerForgeReleaseService(new NullLogger()).Execute( - spec, - new PowerForgeReleaseRequest - { - ConfigPath = Path.Combine(root, "powerforge.release.json"), - PlanOnly = true, - AppleAction = PowerForgeAppleReleaseAction.Advance - }); + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + checkAppleReleaseReadiness: (_, request) => CreateReadyReleaseReadiness(request)) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Advance + }); var plan = Assert.IsType(result.AppleAppPlan); Assert.Equal(expectedSync, plan.SyncScreenshots); @@ -312,19 +619,23 @@ public void Execute_AppleScreenshotReplacementPlan_IsIsolatedAndRequiresConfirma CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); File.WriteAllText(keyPath, "private-key"); - File.WriteAllText(Path.Combine(root, "screenshots.json"), "{}"); + Directory.CreateDirectory(Path.Combine(root, "screenshots")); + WriteScreenshotConfig(root, "screenshots.json", "6778025328", "1.2.0", "iOS", "screenshots", qualityEnabled: false); var spec = CreateAppleAutomationSpec(root, keyPath); spec.AppleApps!.ScreenshotConfigPath = "screenshots.json"; spec.AppleApps.ReplaceScreenshots = true; - var result = new PowerForgeReleaseService(new NullLogger()).Execute( - spec, - new PowerForgeReleaseRequest - { - ConfigPath = Path.Combine(root, "powerforge.release.json"), - PlanOnly = true, - AppleAction = PowerForgeAppleReleaseAction.Screenshots - }); + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + checkAppleReleaseReadiness: (_, request) => CreateReadyReleaseReadiness(request)) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Screenshots + }); var plan = Assert.IsType(result.AppleAppPlan); Assert.True(plan.SyncScreenshots); @@ -476,15 +787,57 @@ public void Execute_AppleArchiveFailure_WritesActionableReceipt() [Theory] [InlineData(PowerForgeAppleReleaseAction.Upload)] [InlineData(PowerForgeAppleReleaseAction.UploadExisting)] - public void Execute_AppleUploadAction_ResumesExactValidRemoteBuildAndWritesCompactReceipt( + public void Execute_AppleUploadAction_RequiresExplicitAdoptionThenResumesExactRemoteBuild( PowerForgeAppleReleaseAction action) { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; var root = CreateSandbox(); try { CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); File.WriteAllText(keyPath, "private-key"); + var seedStateCalls = 0; + var seedService = CreateAppleAutomationService( + request => CreateReleaseState(request, ++seedStateCalls == 1 ? null : "VALID"), + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "archive.txt"), "signed archive"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: CreateSuccessfulUpload); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + spec.AppleApps.Automation.CleanupAfterProcessing = false; + var seeded = seedService.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleSourceCommit = sourceCommit, + AppleAction = PowerForgeAppleReleaseAction.Upload + }); + Assert.True(seeded.Success, seeded.ErrorMessage); + var seededTarget = Assert.Single(seeded.AppleReceipt!.Targets); + Assert.True(seededTarget.UploadPerformed); + Assert.NotNull(seededTarget.ArchiveSha256); + Assert.NotNull(seededTarget.UploadAttestationAttemptId); + + var status = CreateAppleAutomationService(request => CreateReleaseState(request, "VALID")) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleSourceCommit = sourceCommit, + AppleAction = PowerForgeAppleReleaseAction.Status + }); + Assert.True(status.Success, status.ErrorMessage); + Assert.Equal(PowerForgeAppleReleaseAction.Status, status.AppleReceipt!.Action); + Assert.False(Assert.Single(status.AppleReceipt.Targets).UploadPerformed); + var stateCalls = 0; var service = CreateAppleAutomationService( request => @@ -493,23 +846,38 @@ public void Execute_AppleUploadAction_ResumesExactValidRemoteBuildAndWritesCompa return CreateReleaseState(request, "VALID"); }, getAvailableBytes: _ => throw new InvalidOperationException("Resumed builds must skip archive preflight.")); - var spec = CreateAppleAutomationSpec(root, keyPath); - spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; - spec.AppleApps.Automation.CleanupBeforeArchive = false; - spec.AppleApps.Automation.CleanupAfterProcessing = false; - var result = service.Execute( + var blocked = service.Execute( spec, new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleSourceCommit = sourceCommit, AppleAction = action }); + Assert.False(blocked.Success); + Assert.Contains( + "continuity evidence, not authority", + Assert.Single(blocked.AppleReceipt!.Targets).ErrorMessage, + StringComparison.OrdinalIgnoreCase); + stateCalls = 0; - Assert.True(result.Success); + var result = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleSourceCommit = sourceCommit, + AppleAction = action, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true + }); + + Assert.True(result.Success, result.ErrorMessage); Assert.Equal(1, stateCalls); var app = Assert.Single(result.AppleApps); Assert.True(app.ResumedExistingBuild); + Assert.False(app.AdoptedExistingBuild); Assert.Null(app.Archive); Assert.Null(app.Upload); Assert.Equal(new[] { "archive", "upload" }, app.SkippedSteps); @@ -543,6 +911,125 @@ public void Execute_AppleUploadAction_ResumesExactValidRemoteBuildAndWritesCompa } } + [Fact] + public void Execute_AppleUpload_RejectsExistingBuildWithoutExactUploadAttestation() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + + var result = CreateAppleAutomationService(request => CreateReleaseState(request, "VALID")).Execute( + CreateAppleAutomationSpec(root, keyPath), + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleSourceCommit = "0123456789abcdef0123456789abcdef01234567", + AppleAction = PowerForgeAppleReleaseAction.Upload + }); + + Assert.False(result.Success); + Assert.Contains("no immutable local upload receipt", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + var target = Assert.Single(result.AppleReceipt!.Targets); + Assert.False(target.ResumedExistingBuild); + Assert.False(target.AdoptedExistingBuild); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleUpload_AdoptsExistingBuildOnlyWithExplicitConfirmedOverride() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var stateCalls = 0; + var service = CreateAppleAutomationService(request => + { + stateCalls++; + return CreateReleaseState(request, "VALID"); + }); + + var rejected = Assert.Throws(() => service.Execute( + CreateAppleAutomationSpec(root, keyPath), + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleAdoptExistingBuild = true + })); + Assert.Contains("explicit confirmation", rejected.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, stateCalls); + + var result = service.Execute( + CreateAppleAutomationSpec(root, keyPath), + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleSourceCommit = "0123456789abcdef0123456789abcdef01234567", + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true + }); + + Assert.True(result.Success, result.ErrorMessage); + var target = Assert.Single(result.AppleReceipt!.Targets); + Assert.True(target.ResumedExistingBuild); + Assert.True(target.AdoptedExistingBuild); + Assert.Null(target.ArchiveSha256); + Assert.Contains(target.Diagnostics, diagnostic => + diagnostic.Code == "APPLE_BUILD_ADOPTED_WITHOUT_UPLOAD_ATTESTATION"); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleAdoptionPlan_BindsTheObservedRemoteBuild() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var processingState = (string?)null; + var service = CreateAppleAutomationService(request => CreateReleaseState(request, processingState)); + var request = new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.UploadExisting, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true, + PlanOnly = true + }; + + var withoutBuild = Assert.Throws(() => + service.Execute(CreateAppleAutomationSpec(root, keyPath), request)); + processingState = "VALID"; + var withBuild = service.Execute(CreateAppleAutomationSpec(root, keyPath), request); + + var presentTarget = Assert.Single(withBuild.AppleReceipt!.Targets); + Assert.Contains("uniquely selected", withoutBuild.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("build-id", presentTarget.BuildId); + Assert.Equal("VALID", presentTarget.BuildProcessingState); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleStatus_PreservesBetaReviewActionWhenGroupIsConfigured() { @@ -583,11 +1070,18 @@ public void Execute_AppleUpload_WaitsForProcessingInsideSharedRunner() CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); File.WriteAllText(keyPath, "private-key"); - var states = new Queue(new[] { "PROCESSING", "VALID" }); + var states = new Queue(new string?[] { null, "PROCESSING", "VALID" }); var delays = 0; var service = CreateAppleAutomationService( request => CreateReleaseState(request, states.Dequeue()), - _ => delays++); + _ => delays++, + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "archive.txt"), "signed archive"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: CreateSuccessfulUpload); var spec = CreateAppleAutomationSpec(root, keyPath); spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; spec.AppleApps.Automation.CleanupBeforeArchive = false; @@ -719,9 +1213,17 @@ public void Execute_AppleUpload_ProcessingTimeoutWritesLastKnownStateReceipt() spec.AppleApps.Automation.ProcessingTimeoutSeconds = 1; var delays = 0; + var stateCalls = 0; var result = CreateAppleAutomationService( - request => CreateReleaseState(request, "PROCESSING"), - _ => delays++) + request => CreateReleaseState(request, ++stateCalls == 1 ? null : "PROCESSING"), + _ => delays++, + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "archive.txt"), "signed archive"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: CreateSuccessfulUpload) .Execute( spec, new PowerForgeReleaseRequest @@ -789,6 +1291,155 @@ public void Execute_AppleUpload_PostUploadStatusFailureWritesReceipt() } } + [Fact] + public void Execute_AppleUpload_ResumesFromImmediateAttestationBeforeRemoteBuildIsVisible() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var stateCalls = 0; + var seeded = CreateAppleAutomationService( + request => + { + if (++stateCalls == 1) + return CreateReleaseState(request, processingState: null); + throw new InvalidOperationException("final readback unavailable"); + }, + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "signed archive"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: request => + { + var upload = CreateSuccessfulUpload(request); + upload.BuildUploadId = "build-upload-9"; + return upload; + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit, + AppleWaitForProcessing = false + }); + Assert.False(seeded.Success); + Assert.Contains( + new AppleReleaseReceiptStore().ReadAll(seeded.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested"); + + var buildUploadQueries = 0; + var resumed = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: _ => throw new InvalidOperationException("Verified resume must skip archive."), + uploadAppleApp: _ => throw new InvalidOperationException("Verified resume must skip upload."), + getAppleBuildUpload: (_, id) => + { + buildUploadQueries++; + return new AppStoreConnectBuildUploadInfo { Id = id, State = "PROCESSING" }; + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.UploadExisting, + AppleSourceCommit = sourceCommit, + AppleWaitForProcessing = false, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true + }); + + Assert.True(resumed.Success, resumed.ErrorMessage); + Assert.Equal(1, buildUploadQueries); + Assert.True(Assert.Single(resumed.AppleApps).ResumedExistingBuild); + + var rejected = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: _ => throw new InvalidOperationException("Terminal resume must skip archive."), + uploadAppleApp: _ => throw new InvalidOperationException("Terminal resume must skip upload."), + getAppleBuildUpload: (_, id) => new AppStoreConnectBuildUploadInfo + { + Id = id, + State = "FAILED", + Errors = + [ + new AppStoreConnectBuildUploadIssue + { + Code = "90683", + Description = "Missing purpose string in Info.plist." + } + ] + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.UploadExisting, + AppleSourceCommit = sourceCommit, + AppleWaitForProcessing = false, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true + }); + + Assert.False(rejected.Success); + var rejectedTarget = Assert.Single(rejected.AppleReceipt!.Targets); + var rejectedError = Assert.IsType(rejectedTarget.ErrorMessage); + Assert.Contains("FAILED", rejectedError, StringComparison.OrdinalIgnoreCase); + Assert.Contains("90683", rejectedError, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ConfiguredAppleUpload_HonorsProcessingWait() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Archive = true; + spec.AppleApps.Upload = true; + spec.AppleApps.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var stateCalls = 0; + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, ++stateCalls == 1 ? null : "VALID"), + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "signed archive"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: CreateSuccessfulUpload) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Configured, + AppleSourceCommit = "0123456789abcdef0123456789abcdef01234567" + }); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Equal(2, stateCalls); + Assert.Equal("VALID", Assert.Single(result.AppleApps).RemoteState!.Platforms.Single().MatchedBuild!.ProcessingState); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_ApplePrepareReceiptUsesCompletedPreparationWhenRemoteReadbackIsStale() { @@ -959,8 +1610,24 @@ private static AppStoreConnectReleaseStateResult CreateReleaseState( } }; - private static AppleAppArchiveResult CreateSuccessfulArchive(AppleAppArchiveRequest request) + private static AppStoreConnectReleaseReadinessResult CreateReadyReleaseReadiness( + AppStoreConnectReleaseReadinessRequest request) => new() + { + AppId = request.AppId, + VersionString = request.VersionString, + BuildNumber = request.BuildNumber, + Platform = request.Platform, + IsReady = true + }; + + private static AppleAppArchiveResult CreateSuccessfulArchive(AppleAppArchiveRequest request) + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + var payload = Path.Combine(archive.FullName, "archive.bin"); + if (!File.Exists(payload)) + File.WriteAllText(payload, "archive"); + return new AppleAppArchiveResult { ArchivePath = request.ArchivePath!, Destination = request.Destination!, @@ -972,6 +1639,7 @@ private static AppleAppArchiveResult CreateSuccessfulArchive(AppleAppArchiveRequ TimeSpan.FromSeconds(1), false) }; + } private static AppleAppArchiveUploadResult CreateSuccessfulUpload(AppleAppArchiveUploadRequest request) => new() @@ -1011,6 +1679,111 @@ private static AppStoreConnectReleasePreparationResult CreateSuccessfulPreparati SelectedBuild = true }; + [Fact] + public void Execute_ApplePlan_RejectsChangedMetadataPayloadBeforeMutation() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var metadataPath = Path.Combine(root, "metadata.json"); + File.WriteAllText(metadataPath, "{ \"payload\": \"approved\" }"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.SyncMetadata = true; + spec.AppleApps.MetadataConfigPath = "metadata.json"; + var mutationCalls = 0; + var service = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + prepareAppleDistribution: request => + { + mutationCalls++; + return CreateSuccessfulPreparation(request); + }); + var plan = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Configured + }); + File.WriteAllText(metadataPath, "{ \"payload\": \"changed\" }"); + + var execution = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Configured, + AppleExpectedPlanSha256 = plan.AppleReceipt!.PlanSha256 + }); + + Assert.False(execution.Success); + Assert.Equal(0, mutationCalls); + Assert.Contains("changed after plan approval", execution.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ApplePlan_BindsScreenshotPixelBytes() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var screenshotFolder = Directory.CreateDirectory(Path.Combine(root, "screenshots")); + var screenshotPath = Path.Combine(screenshotFolder.FullName, "home.png"); + File.WriteAllText(screenshotPath, "approved pixels"); + WriteScreenshotConfig(root, "screenshots.json", "6778025328", "1.2.0", "iOS", "screenshots", qualityEnabled: false); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.SyncScreenshots = true; + spec.AppleApps.ScreenshotConfigPath = "screenshots.json"; + var service = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + checkAppleReleaseReadiness: (_, request) => new AppStoreConnectReleaseReadinessResult + { + AppId = request.AppId, + VersionString = request.VersionString, + BuildNumber = request.BuildNumber, + Platform = request.Platform, + IsReady = true + }); + + var approved = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Screenshots + }); + File.WriteAllText(screenshotPath, "different pixels"); + var changed = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Screenshots + }); + + Assert.NotEqual(approved.AppleReceipt!.PlanSha256, changed.AppleReceipt!.PlanSha256); + Assert.Contains("screenshots/home.png", approved.AppleReceipt.MutationInputFiles.Keys); + } + finally + { + TryDelete(root); + } + } + private static void WriteScreenshotConfig( string root, string fileName, diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleDirectRecoveryPathSemantics.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleDirectRecoveryPathSemantics.cs new file mode 100644 index 000000000..bf565c3dd --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleDirectRecoveryPathSemantics.cs @@ -0,0 +1,26 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [Fact] + public void DirectNotarizationResume_UsesOwningVolumeArtifactContainment() + { + var projectRoot = CreateSandbox(); + try + { + var exportRoot = Directory.CreateDirectory(Path.Combine(projectRoot, "Exports")); + Directory.CreateDirectory(Path.Combine(exportRoot.FullName, "CasaRay.app")); + var alternateArtifactPath = Path.Combine(projectRoot, "exports", "casaray.app"); + var caseInsensitive = FrameworkCompatibility.GetPathStringComparisonForPath(exportRoot.FullName) == + StringComparison.OrdinalIgnoreCase; + + Assert.Equal( + caseInsensitive, + AppleReleaseArtifactService.IsWithinRoot(alternateArtifactPath, exportRoot.FullName)); + } + finally + { + TryDelete(projectRoot); + } + } +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs new file mode 100644 index 000000000..4c63a76e7 --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs @@ -0,0 +1,128 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [Fact] + public void Execute_SourceBoundMetadataMutationWithoutPlanHash_CapturesApprovedInputBytes() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var metadataPath = Path.Combine(root, "metadata.json"); + var metadata = """ + { + "appId": "6778025328", + "versionString": "1.2.0", + "platform": "iOS", + "locale": "en-US", + "metadata": { "description": "approved" } + } + """; + File.WriteAllText(metadataPath, metadata); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.SyncMetadata = true; + spec.AppleApps.MetadataConfigPath = "metadata.json"; + + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + prepareAppleDistribution: CreateSuccessfulPreparation) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Prepare, + AppleSourceCommit = sourceCommit + }); + + Assert.True(result.Success, result.ErrorMessage); + var plan = Assert.IsType(result.AppleAppPlan); + Assert.Equal(metadata, PowerForgeReleaseService.ReadApprovedMutationInputText(plan, metadataPath)); + Assert.Contains("metadata.json", plan.ApprovedMutationInputFilesSha256.Keys); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData(PowerForgeAppleReleaseAction.Status)] + [InlineData(PowerForgeAppleReleaseAction.UploadExisting)] + [InlineData(PowerForgeAppleReleaseAction.Prepare)] + public void Execute_NonArchiveAppleAction_RejectsSourceCommitThatIsNotCurrentHead( + PowerForgeAppleReleaseAction action) + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + File.WriteAllText(Path.Combine(root, ".gitignore"), "build/\n"); + RunSnapshotGit(root, "init", "--quiet"); + RunSnapshotGit(root, "config", "user.name", "PowerForge Tests"); + RunSnapshotGit(root, "config", "user.email", "powerforge-tests@example.invalid"); + RunSnapshotGit(root, "add", "."); + RunSnapshotGit(root, "commit", "--quiet", "-m", "exact source"); + var service = CreateAppleAutomationService(request => CreateReleaseState(request, "VALID")); + + var result = service.Execute( + CreateAppleAutomationSpec(root, keyPath), + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = action, + AppleSourceCommit = "0000000000000000000000000000000000000000", + RequireImmutableAppleSourceSnapshot = true + }); + + Assert.False(result.Success); + Assert.Contains("instead of the approved commit", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void PublishBuiltReleaseOutputs_preserves_immutable_source_validation_for_the_Apple_publish_clone() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + File.WriteAllText(Path.Combine(root, ".gitignore"), "build/\n"); + RunSnapshotGit(root, "init", "--quiet"); + RunSnapshotGit(root, "config", "user.name", "PowerForge Tests"); + RunSnapshotGit(root, "config", "user.email", "powerforge-tests@example.invalid"); + RunSnapshotGit(root, "add", "."); + RunSnapshotGit(root, "commit", "--quiet", "-m", "exact source"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Archive = false; + + var result = CreateAppleAutomationService(request => CreateReleaseState(request, "VALID")) + .PublishBuiltReleaseOutputs( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Status, + AppleSourceCommit = "0000000000000000000000000000000000000000" + }, + new PowerForgeReleaseResult { Success = true }); + + Assert.False(result.Success); + Assert.Contains("instead of the approved commit", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs new file mode 100644 index 000000000..af66cbe49 --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs @@ -0,0 +1,713 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [Fact] + public void Execute_ConfiguredAppleUploadOnly_ResumesAfterAttestedArchiveWasRemoved() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var stateCalls = 0; + var seeded = CreateAppleAutomationService( + request => CreateReleaseState(request, ++stateCalls == 1 ? null : "VALID"), + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "attested archive"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: CreateSuccessfulUpload) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + Assert.True(seeded.Success, seeded.ErrorMessage); + var archivePath = Assert.Single(seeded.AppleAppPlan!.Apps).ArchivePath; + Directory.Delete(archivePath, recursive: true); + spec.AppleApps.Archive = false; + spec.AppleApps.Upload = true; + + var resumed = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + archiveAppleApp: _ => throw new InvalidOperationException("Verified configured recovery must skip archive."), + uploadAppleApp: _ => throw new InvalidOperationException("Verified configured recovery must skip upload.")) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Configured, + AppleSourceCommit = sourceCommit, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true + }); + + Assert.True(resumed.Success, resumed.ErrorMessage); + Assert.True(Assert.Single(resumed.AppleApps).ResumedExistingBuild); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleUploadExisting_RejectsAttestationForDifferentCheckpointArchive() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var stateCalls = 0; + var seeded = CreateAppleAutomationService( + request => CreateReleaseState(request, ++stateCalls == 1 ? null : "VALID"), + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "first archive"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: CreateSuccessfulUpload) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + Assert.True(seeded.Success, seeded.ErrorMessage); + var archivePath = Assert.Single(seeded.AppleAppPlan!.Apps).ArchivePath; + File.WriteAllText(Path.Combine(archivePath, "payload"), "second checkpoint archive"); + var expectedArchiveSha256 = AppleNotarizationService.ComputeArtifactSha256(archivePath); + spec.AppleApps.Archive = false; + spec.AppleApps.Upload = true; + + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + uploadAppleApp: _ => throw new InvalidOperationException("A mismatched remote build must not be uploaded or resumed.")) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.UploadExisting, + AppleSourceCommit = sourceCommit, + AppleExpectedArchiveSha256ByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CasaRay iOS"] = expectedArchiveSha256 + } + }); + + Assert.False(result.Success); + Assert.False(Assert.Single(result.AppleApps).ResumedExistingBuild); + Assert.Contains("no immutable local upload receipt", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ConfiguredRemoteMutation_RefreshesAuthoritativeFinalState() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Archive = false; + spec.AppleApps!.PrepareDistribution = true; + var reads = 0; + + var result = CreateAppleAutomationService( + request => + { + reads++; + return CreateReleaseState(request, "VALID"); + }, + prepareAppleDistribution: CreateSuccessfulPreparation) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Configured + }); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Equal(1, reads); + var target = Assert.Single(result.AppleReceipt!.Targets); + Assert.Equal("VALID", target.BuildProcessingState); + Assert.True(target.BuildSelected); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ConfiguredRemoteMutation_FailsReceiptWhenFinalStateCannotBeRead() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Archive = false; + spec.AppleApps!.PrepareDistribution = true; + var prepared = false; + + var result = CreateAppleAutomationService( + _ => throw new IOException("final read unavailable"), + prepareAppleDistribution: request => + { + prepared = true; + return CreateSuccessfulPreparation(request); + }) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Configured + }); + + Assert.True(prepared); + Assert.False(result.Success); + Assert.Contains("final App Store Connect state", result.AppleReceipt!.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("final read unavailable", result.AppleReceipt.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData(PowerForgeAppleReleaseAction.Upload)] + [InlineData(PowerForgeAppleReleaseAction.Configured)] + public void Execute_AppleUploadAttestation_UsesResolvedProjectVersionAndBuild( + PowerForgeAppleReleaseAction action) + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Upload = action == PowerForgeAppleReleaseAction.Configured; + spec.AppleApps.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + spec.AppleApps.Automation.CleanupAfterProcessing = false; + spec.AppleApps.Automation.PollIntervalSeconds = 1; + spec.AppleApps.Automation.ProcessingTimeoutSeconds = 2; + var states = new Queue(new string?[] { null, "PROCESSING", "VALID" }); + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, states.Dequeue()), + delay: _ => { }, + archiveAppleApp: request => + { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "archive.txt"), "signed archive"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: CreateSuccessfulUpload) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = action, + AppleSourceCommit = "0123456789abcdef0123456789abcdef01234567" + }); + + Assert.True(result.Success, result.ErrorMessage); + var journal = new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!); + var upload = Assert.Single(journal, receipt => receipt.OperationPhase == "UploadAttested"); + var uploadTarget = Assert.Single(upload.Targets); + Assert.Equal("1.2.0", uploadTarget.Version); + Assert.Equal("9", uploadTarget.Build); + Assert.Equal("Completed", result.AppleReceipt!.OperationPhase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleUpload_RejectsAdoptionWhenResumeIsDisabled() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + + var exception = Assert.Throws(() => + CreateAppleAutomationService(request => CreateReleaseState(request, "VALID")) + .Execute( + CreateAppleAutomationSpec(root, keyPath), + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true, + AppleResume = false + })); + + Assert.Contains("requires", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Resume=true", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ApplePlan_RejectsEmptyReceiptHistoryPath() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.ReceiptHistoryPath = " "; + + var exception = Assert.Throws(() => + new PowerForgeReleaseService(new NullLogger()).Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Upload + })); + + Assert.Contains("ReceiptHistoryPath is required", exception.Message, StringComparison.Ordinal); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData("history-equals-receipt")] + [InlineData("history-contains-plan")] + [InlineData("history-under-lock")] + [InlineData("receipt-equals-plan")] + [InlineData("receipt-contains-plan")] + [InlineData("lock-under-plan")] + [InlineData("history-under-archive-root")] + [InlineData("archive-overlaps-receipt-journal-lock")] + [InlineData("plan-overwrites-project")] + [InlineData("archive-root-overlaps-export-root")] + [InlineData("archive-root-overlaps-screenshot-set")] + [InlineData("archive-root-overlaps-screenshot-approval")] + public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string scenario) + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + var automation = spec.AppleApps!.Automation; + switch (scenario) + { + case "history-equals-receipt": + automation.ReceiptHistoryPath = automation.ReceiptPath; + break; + case "history-contains-plan": + automation.ReceiptHistoryPath = "build/powerforge/apple"; + break; + case "history-under-lock": + automation.LockPath = "build/powerforge/apple/release"; + automation.ReceiptHistoryPath = "build/powerforge/apple/release/history"; + break; + case "receipt-equals-plan": + automation.PlanReceiptPath = automation.ReceiptPath; + break; + case "receipt-contains-plan": + automation.ReceiptPath = "build/powerforge/apple/state"; + automation.PlanReceiptPath = "build/powerforge/apple/state/plan.json"; + break; + case "lock-under-plan": + automation.PlanReceiptPath = "build/powerforge/apple/plan"; + automation.LockPath = "build/powerforge/apple/plan/release.lock"; + break; + case "history-under-archive-root": + automation.ReceiptHistoryPath = "build/powerforge/apple/archives/receipts"; + break; + case "archive-overlaps-receipt-journal-lock": + spec.AppleApps.ArchiveRoot = FrameworkCompatibility.GetRelativePath( + root, + Path.GetDirectoryName(AppleReleaseReceiptJournalLease.CreateLockPath( + Path.Combine(root, automation.ReceiptPath!)))!); + break; + case "plan-overwrites-project": + automation.PlanReceiptPath = "CasaRay.xcodeproj/project.pbxproj"; + break; + case "archive-root-overlaps-export-root": + spec.AppleApps.ExportRoot = spec.AppleApps.ArchiveRoot; + break; + case "archive-root-overlaps-screenshot-set": + spec.AppleApps.ScreenshotConfigPath = "screenshots.json"; + Directory.CreateDirectory(Path.Combine(root, "build", "powerforge", "apple", "archives", "screenshots")); + File.WriteAllText( + Path.Combine(root, "build", "powerforge", "apple", "archives", "screenshots", "01-home.png"), + "approved screenshot"); + File.WriteAllText( + Path.Combine(root, "screenshots.json"), + """{ "ScreenshotSets": [ { "ScreenshotDisplayType": "APP_IPHONE_67", "Path": "build/powerforge/apple/archives/screenshots" } ] }"""); + break; + case "archive-root-overlaps-screenshot-approval": + spec.AppleApps.ScreenshotConfigPath = "screenshots.json"; + File.WriteAllText( + Path.Combine(root, "screenshots.json"), + """{ "Quality": { "RequireApprovalManifest": true, "ApprovalManifestPath": "build/powerforge/apple/archives/approval.json" } }"""); + break; + } + + var exception = Assert.Throws(() => + new PowerForgeReleaseService(new NullLogger()).Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Archive + })); + + Assert.True( + exception.Message.Contains("ReceiptHistoryPath", StringComparison.Ordinal) || + exception.Message.Contains("distinct paths", StringComparison.OrdinalIgnoreCase) || + exception.Message.Contains("automation output", StringComparison.OrdinalIgnoreCase) || + exception.Message.Contains("archive and export roots", StringComparison.OrdinalIgnoreCase) || + exception.Message.Contains("archive/export roots", StringComparison.OrdinalIgnoreCase), + exception.Message); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ApplePlan_RejectsCaseEquivalentNestedOutputsOnCaseInsensitiveVolume() + { + var root = CreateSandbox(); + try + { + if (FrameworkCompatibility.GetPathStringComparison(root) != StringComparison.OrdinalIgnoreCase) + return; + + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.ReceiptPath = "build/powerforge/apple/state"; + spec.AppleApps.Automation.PlanReceiptPath = "build/powerforge/apple/STATE/plan.json"; + + var exception = Assert.Throws(() => + new PowerForgeReleaseService(new NullLogger()).Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Archive + })); + + Assert.Contains("automation output files", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_DirectNotarizationCrash_PersistsAcceptedSubmissionBeforeLocalPostProcessing() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "EasyControlXAgent.xcodeproj", "1.0.0", "4"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.TeamId = "8ZPGZ79T7J"; + var app = Assert.Single(spec.AppleApps.Apps); + app.Name = "EasyControlX Agent"; + app.ProjectPath = "EasyControlXAgent.xcodeproj"; + app.Scheme = "EasyControlXAgent"; + app.Platform = ApplePlatform.macOS; + app.DistributionRoute = AppleDistributionRoute.DirectNotarized; + app.AppStoreConnectAppId = null; + + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: request => + { + Directory.CreateDirectory(Path.Combine(request.ExportPath!, "EasyControlX Agent.app")); + File.WriteAllText( + Path.Combine(request.ExportPath!, "EasyControlX Agent.app", "payload"), + "signed direct app"); + return CreateSuccessfulUpload(request); + }, + notarizeAppleArtifact: request => + { + request.AcceptedCheckpoint!(new AppleNotarizationAcceptedCheckpoint + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), + SubmissionPath = request.ArtifactPath + ".zip", + SubmissionSha256 = new string('b', 64), + SubmissionId = "accepted-before-local-crash", + Status = "Accepted" + }); + throw new InvalidOperationException("simulated process loss after Apple acceptance"); + }); + var result = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.False(result.Success); + var accepted = Assert.Single( + new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), + receipt => receipt.OperationPhase == "NotarizationAccepted"); + Assert.Equal(sourceCommit, accepted.SourceCommit); + var target = Assert.Single(accepted.Targets); + Assert.Equal("accepted-before-local-crash", target.NotarizationSubmissionId); + Assert.Equal(new string('b', 64), target.NotarizationSubmissionSha256); + Assert.Equal("Accepted", target.NotarizationStatus); + Assert.False(accepted.Success); + + var storedArtifact = Assert.IsType(target.DirectArtifactPath); + Assert.False(Path.IsPathRooted(storedArtifact)); + var protectedArtifact = Path.Combine(root, storedArtifact); + var cleanupCandidate = Directory.GetParent(protectedArtifact)!.FullName; + Directory.SetLastWriteTimeUtc(cleanupCandidate, DateTime.UtcNow.AddDays(-30)); + spec.AppleApps.Automation.ArtifactRetentionDays = 0; + var cleanup = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Cleanup, + AppleActionConfirmed = true, + AppleSourceCommit = sourceCommit + }); + + Assert.True(cleanup.Success, cleanup.ErrorMessage); + Assert.True(Directory.Exists(protectedArtifact) || File.Exists(protectedArtifact)); + Assert.DoesNotContain( + FrameworkCompatibility.GetRelativePath(root, cleanupCandidate).Replace('\\', '/'), + cleanup.AppleReceipt!.Cleanup.RemovedPaths); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_DirectNotarizationCrash_PersistsExactPostStapleCheckpoint() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "EasyControlXAgent.xcodeproj", "1.0.0", "4"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.TeamId = "8ZPGZ79T7J"; + var app = Assert.Single(spec.AppleApps.Apps); + app.Name = "EasyControlX Agent"; + app.ProjectPath = "EasyControlXAgent.xcodeproj"; + app.Scheme = "EasyControlXAgent"; + app.Platform = ApplePlatform.macOS; + app.DistributionRoute = AppleDistributionRoute.DirectNotarized; + app.AppStoreConnectAppId = null; + + var result = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: request => + { + Directory.CreateDirectory(Path.Combine(request.ExportPath!, "EasyControlX Agent.app")); + File.WriteAllText(Path.Combine(request.ExportPath!, "EasyControlX Agent.app", "payload"), "signed direct app"); + return CreateSuccessfulUpload(request); + }, + notarizeAppleArtifact: request => + { + var hash = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath); + request.AcceptedCheckpoint!(new AppleNotarizationAcceptedCheckpoint + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = hash, + SubmissionPath = request.ArtifactPath + ".zip", + SubmissionId = "accepted-and-stapled", + Status = "Accepted" + }); + File.AppendAllText(Path.Combine(request.ArtifactPath, "payload"), "-stapled-ticket"); + var stapledHash = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath); + request.StapledCheckpoint!(new AppleNotarizationStapledCheckpoint + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = stapledHash, + SubmissionId = "accepted-and-stapled", + Status = "Accepted" + }); + throw new InvalidOperationException("simulated process loss before Gatekeeper assessment"); + }) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.False(result.Success); + var stapled = Assert.Single( + new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), + receipt => receipt.OperationPhase == "NotarizationStapled"); + Assert.Equal(sourceCommit, stapled.SourceCommit); + var target = Assert.Single(stapled.Targets); + Assert.True(target.Stapled); + Assert.True(target.StapleValidated); + Assert.Equal("accepted-and-stapled", target.NotarizationSubmissionId); + Assert.False(Path.IsPathRooted(target.DirectArtifactPath)); + Assert.Equal( + AppleNotarizationService.ComputeArtifactSha256(Path.Combine(root, target.DirectArtifactPath!)), + target.DirectArtifactSha256); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_DirectExport_UsesPrivateInputAndPublishesVerifiedArtifactBeforeNotarization() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "EasyControlXAgent.xcodeproj", "1.0.0", "4"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.TeamId = "8ZPGZ79T7J"; + var app = Assert.Single(spec.AppleApps.Apps); + app.Name = "EasyControlX Agent"; + app.ProjectPath = "EasyControlXAgent.xcodeproj"; + app.Scheme = "EasyControlXAgent"; + app.Platform = ApplePlatform.macOS; + app.DistributionRoute = AppleDistributionRoute.DirectNotarized; + app.AppStoreConnectAppId = null; + string? privateExport = null; + AppleNotarizationRequest? notarizationRequest = null; + + var result = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: request => + { + privateExport = request.ExportPath; + Assert.Contains("apple-direct-exports", request.ExportPath!, StringComparison.Ordinal); + var package = Path.Combine(request.ExportPath!, "EasyControlX Agent.pkg"); + File.WriteAllText(package, "approved developer-id export"); + return CreateSuccessfulUpload(request); + }, + notarizeAppleArtifact: request => + { + notarizationRequest = request; + var hash = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath); + return new AppleNotarizationResult + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = hash, + SubmissionPath = request.ArtifactPath, + SubmissionId = "private-export-submission", + Status = "Accepted", + Submission = new ProcessRunResult(0, "accepted", string.Empty, "xcrun", TimeSpan.Zero, false), + Staple = new ProcessRunResult(0, "stapled", string.Empty, "xcrun", TimeSpan.Zero, false), + StapleValidation = new ProcessRunResult(0, "valid", string.Empty, "xcrun", TimeSpan.Zero, false), + Assessment = new ProcessRunResult(0, "accepted", string.Empty, "spctl", TimeSpan.Zero, false) + }; + }) + .Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(privateExport); + Assert.False(Directory.Exists(privateExport)); + Assert.NotNull(notarizationRequest); + var publicExport = result.AppleAppPlan!.Apps.Single().ExportPath; + Assert.StartsWith(Path.GetFullPath(publicExport) + Path.DirectorySeparatorChar, notarizationRequest!.ArtifactPath, StringComparison.Ordinal); + Assert.Equal("approved developer-id export", File.ReadAllText(notarizationRequest.ArtifactPath)); + Assert.Equal( + AppleNotarizationService.ComputeArtifactSha256(notarizationRequest.ArtifactPath), + notarizationRequest.ExpectedArtifactSha256); + Assert.Equal(Path.GetFullPath(publicExport), result.AppleApps.Single().Upload!.ExportPath); + Assert.Equal(notarizationRequest.ArtifactPath, result.AppleApps.Single().Upload!.ExportArtifactPath); + Assert.Equal(notarizationRequest.ExpectedArtifactSha256, result.AppleApps.Single().Upload!.ExportArtifactSha256); + } + finally + { + TryDelete(root); + } + } +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs new file mode 100644 index 000000000..2066c9988 --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -0,0 +1,561 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [Fact] + public void Execute_ApplePlan_BindsEffectiveAutomationPolicy() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Upload planning without adoption must not query App Store Connect.")); + + var resumable = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleResume = true, + PlanOnly = true + }); + var nonResumable = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleResume = false, + PlanOnly = true + }); + + Assert.NotEqual(resumable.AppleReceipt!.MutationInputsSha256, nonResumable.AppleReceipt!.MutationInputsSha256); + Assert.NotEqual(resumable.AppleReceipt.PlanSha256, nonResumable.AppleReceipt.PlanSha256); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ApplePlan_BindsEffectiveBuildConfiguration() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Planning must not query App Store Connect.")); + spec.AppleApps!.Configuration = "Release"; + var release = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + PlanOnly = true + }); + spec.AppleApps.Configuration = "Debug"; + var debug = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + PlanOnly = true + }); + + Assert.NotEqual(release.AppleReceipt!.MutationInputsSha256, debug.AppleReceipt!.MutationInputsSha256); + Assert.NotEqual(release.AppleReceipt.PlanSha256, debug.AppleReceipt.PlanSha256); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ApplePlan_BindsEffectiveXcodeTargetSelectors() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Planning must not query App Store Connect.")); + var approved = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive, + PlanOnly = true + }); + Assert.Single(spec.AppleApps!.Apps).Scheme = "CasaRay-Alternate"; + var changed = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive, + PlanOnly = true + }); + + Assert.NotEqual(approved.AppleReceipt!.PlanSha256, changed.AppleReceipt!.PlanSha256); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void ApprovedAppleMutationConfig_UsesCapturedBytesAfterSourceReplacement() + { + var root = CreateSandbox(); + try + { + var metadataPath = Path.Combine(root, "metadata.json"); + File.WriteAllText(metadataPath, "{ \"value\": \"approved\" }"); + var approvedHash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(metadataPath))) + .ToLowerInvariant(); + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = root, + SyncMetadata = true, + MetadataConfigPath = metadataPath, + ApprovedMutationInputFilesSha256 = new Dictionary(StringComparer.Ordinal) + { + ["metadata.json"] = approvedHash + } + }; + + PowerForgeReleaseService.CaptureApprovedMutationInputContents(plan); + File.WriteAllText(metadataPath, "{ \"value\": \"replaced\" }"); + + Assert.Equal( + "{ \"value\": \"approved\" }", + PowerForgeReleaseService.ReadApprovedMutationInputText(plan, metadataPath)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void ApprovedAppleMutationConfig_CapturesOnlyInputsUsedByTheAction() + { + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = CreateSandbox(), + Action = PowerForgeAppleReleaseAction.Archive, + MetadataConfigPath = "missing-metadata.json", + AppInfoConfigPath = "missing-app-info.json", + GovernanceConfigPath = "missing-governance.json", + ScreenshotConfigPath = "missing-screenshots.json", + VersionSourcePath = "missing-version-source.xcconfig" + }; + try + { + PowerForgeReleaseService.CaptureApprovedMutationInputContents(plan); + + Assert.Empty(plan.ApprovedMutationInputContents); + } + finally + { + TryDelete(plan.ProjectRoot); + } + } + + [Fact] + public void ApprovedAppleMutationConfig_PreservesPlatformPathIdentityAndRemovesUtf8Bom() + { + var root = CreateSandbox(); + try + { + var metadataPath = Path.Combine(root, "Metadata.json"); + var payload = System.Text.Encoding.UTF8.GetBytes("{ \"value\": \"approved\" }"); + File.WriteAllBytes(metadataPath, System.Text.Encoding.UTF8.GetPreamble().Concat(payload).ToArray()); + var approvedHash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(metadataPath))) + .ToLowerInvariant(); + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = root, + SyncMetadata = true, + MetadataConfigPath = metadataPath, + ApprovedMutationInputFilesSha256 = new Dictionary(StringComparer.Ordinal) + { + ["Metadata.json"] = approvedHash + } + }; + + PowerForgeReleaseService.CaptureApprovedMutationInputContents(plan); + + Assert.Equal("{ \"value\": \"approved\" }", PowerForgeReleaseService.ReadApprovedMutationInputText(plan, metadataPath)); + Assert.Equal( + Path.DirectorySeparatorChar == '\\', + plan.ApprovedMutationInputContents.ContainsKey(Path.Combine(root, "metadata.json"))); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ApplePlan_BindsDirectNotarizationControls() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + var app = Assert.Single(spec.AppleApps!.Apps); + app.Platform = ApplePlatform.macOS; + app.DistributionRoute = AppleDistributionRoute.DirectNotarized; + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct-distribution planning must not query App Store Connect.")); + var approved = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + PlanOnly = true + }); + spec.AppleApps.DirectDistribution.Staple = false; + spec.AppleApps.DirectDistribution.Assess = false; + var changed = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + PlanOnly = true + }); + + Assert.NotEqual(approved.AppleReceipt!.MutationInputsSha256, changed.AppleReceipt!.MutationInputsSha256); + Assert.NotEqual(approved.AppleReceipt.PlanSha256, changed.AppleReceipt.PlanSha256); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ApplePlan_BindsCompleteXcodeExecutionControls() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Archive planning must not query App Store Connect.")); + var approved = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive, + PlanOnly = true + }); + spec.AppleApps!.XcodeBuildExecutable = "/reviewed/tools/xcodebuild"; + spec.AppleApps.TeamId = "CHANGEDTEAM"; + var changed = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive, + PlanOnly = true + }); + + Assert.NotEqual(approved.AppleReceipt!.MutationInputsSha256, changed.AppleReceipt!.MutationInputsSha256); + Assert.NotEqual(approved.AppleReceipt.PlanSha256, changed.AppleReceipt.PlanSha256); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_ApplePlan_BindsRequiredScreenshotApprovalManifestBytes() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var screenshotFolder = Directory.CreateDirectory(Path.Combine(root, "screenshots")); + var screenshotPath = Path.Combine(screenshotFolder.FullName, "home.png"); + File.WriteAllBytes(screenshotPath, Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2n1sAAAAASUVORK5CYII=")); + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var screenshotSpec = new AppStoreConnectScreenshotSyncSpec + { + AppId = "6778025328", + VersionString = "1.2.0", + Platform = ApplePlatform.iOS, + Locale = "en-US", + ScreenshotSets = + [ + new AppStoreConnectScreenshotSetSyncSpec + { + ScreenshotDisplayType = "APP_IPHONE_67", + Path = "screenshots", + AllowedDimensions = ["1x1"] + } + ], + Quality = new AppStoreConnectScreenshotQualitySpec + { + Enabled = true, + MinimumFileBytes = 1, + MinimumKilobytesPerMegapixel = 0, + RequireApprovalManifest = true, + ApprovalManifestPath = "approval.json" + } + }; + File.WriteAllText( + Path.Combine(root, "screenshots.json"), + System.Text.Json.JsonSerializer.Serialize(screenshotSpec)); + var approval = new AppStoreConnectScreenshotApprovalService().Create( + new AppStoreConnectScreenshotApprovalRequest + { + Spec = screenshotSpec, + BaseDirectory = root, + AllowedRoot = screenshotFolder.FullName, + VersionString = "1.2.0", + SourceCommit = sourceCommit, + ApprovedBy = "release-owner" + }); + var approvalPath = Path.Combine(root, "approval.json"); + File.WriteAllText(approvalPath, System.Text.Json.JsonSerializer.Serialize(approval)); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.SyncScreenshots = true; + spec.AppleApps.ScreenshotConfigPath = "screenshots.json"; + var service = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + checkAppleReleaseReadiness: (_, request) => CreateReadyReleaseReadiness(request)); + + var approved = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Screenshots, + AppleSourceCommit = sourceCommit, + PlanOnly = true + }); + approval.ApprovalEvidence = "reviewed-evidence-changed"; + File.WriteAllText(approvalPath, System.Text.Json.JsonSerializer.Serialize(approval)); + var changed = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Screenshots, + AppleSourceCommit = sourceCommit, + PlanOnly = true + }); + + Assert.NotEqual(approved.AppleReceipt!.MutationInputsSha256, changed.AppleReceipt!.MutationInputsSha256); + Assert.Contains("approval.json", approved.AppleReceipt.MutationInputFiles.Keys); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData("generate")] + [InlineData("regenerate")] + [InlineData("executable")] + [InlineData("timeout")] + public void Execute_ApplePlan_BindsProjectGenerationControls(string changedControl) + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + File.WriteAllText(Path.Combine(root, "project.yml"), "name: CasaRay"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Archive planning must not query App Store Connect.")); + var approved = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive, + PlanOnly = true + }); + var app = Assert.Single(spec.AppleApps!.Apps); + switch (changedControl) + { + case "generate": + app.GenerateProjectIfMissing = true; + break; + case "regenerate": + app.RegenerateProject = true; + break; + case "executable": + app.XcodeGenExecutable = "/reviewed/tools/xcodegen"; + break; + case "timeout": + app.ProjectGenerationTimeoutSeconds++; + break; + } + var changed = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive, + PlanOnly = true + }); + + Assert.NotEqual(approved.AppleReceipt!.PlanSha256, changed.AppleReceipt!.PlanSha256); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData(PowerForgeAppleReleaseAction.Prepare)] + [InlineData(PowerForgeAppleReleaseAction.TestFlight)] + [InlineData(PowerForgeAppleReleaseAction.Advance)] + public void AppleDistributionPlanActions_RequireObservedRemoteBuildState(PowerForgeAppleReleaseAction action) + { + var plan = new PowerForgeAppleReleasePlan { Action = action }; + var app = new PowerForgeAppleAppReleaseTargetPlan + { + AppStoreConnectAppId = "6778025328", + DistributionRoute = AppleDistributionRoute.AppStore + }; + + Assert.True(PowerForgeReleaseService.RequiresObservedApplePlanState(plan, app)); + } + + [Fact] + public void Execute_AppleDistributionPlan_RejectsUnselectedRemoteBuild() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.TestFlightBetaGroupNames = ["Internal"]; + var service = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null)); + + var exception = Assert.Throws(() => service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.TestFlight, + PlanOnly = true + })); + + Assert.Contains("uniquely selected", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void AppleArchiveUploadSnapshot_RejectsEscapingSymbolicLinks() + { + if (OperatingSystem.IsWindows()) + return; + + var root = CreateSandbox(); + try + { + var archive = Directory.CreateDirectory(Path.Combine(root, "CasaRay.xcarchive")); + var outside = Path.Combine(root, "outside-payload"); + File.WriteAllText(outside, "outside"); + File.CreateSymbolicLink(Path.Combine(archive.FullName, "escaped"), outside); + var expected = AppleNotarizationService.ComputeArtifactSha256(archive.FullName); + + var exception = Assert.Throws(() => + AppleArchiveUploadSnapshot.Create(archive.FullName, expected)); + + Assert.Contains("inside the archive", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleCheckpoint_ArchivesFromDetachedExactSourceSnapshot() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var sourceFile = Path.Combine(root, "CasaRay.xcodeproj", "project.pbxproj"); + var committedContents = File.ReadAllText(sourceFile); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + RunSnapshotGit(root, "init", "--quiet"); + RunSnapshotGit(root, "config", "user.name", "PowerForge Tests"); + RunSnapshotGit(root, "config", "user.email", "powerforge-tests@example.invalid"); + RunSnapshotGit(root, "add", "."); + RunSnapshotGit(root, "commit", "--quiet", "-m", "exact source"); + var sourceCommit = RunSnapshotGit(root, "rev-parse", "HEAD").Trim(); + string? archivedProjectPath = null; + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Archive-only checkpoint must not query App Store Connect."), + archiveAppleApp: request => + { + archivedProjectPath = request.ProjectPath; + File.AppendAllText(sourceFile, "\n// concurrent original-worktree mutation"); + Assert.Equal(committedContents, File.ReadAllText(Path.Combine(request.ProjectPath, "project.pbxproj"))); + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "archive from immutable source"); + return CreateSuccessfulArchive(request); + }); + + var result = service.Execute( + CreateAppleAutomationSpec(root, keyPath), + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive, + AppleSourceCommit = sourceCommit, + RequireImmutableAppleSourceSnapshot = true + }); + + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(archivedProjectPath); + Assert.False(archivedProjectPath!.StartsWith(root, StringComparison.Ordinal)); + Assert.False(Directory.Exists(Path.GetDirectoryName(archivedProjectPath!)!)); + } + finally + { + TryDelete(root); + } + } + + private static string RunSnapshotGit(string root, params string[] arguments) + { + var result = new GitClient(defaultTimeout: TimeSpan.FromMinutes(1)) + .RunRawAsync(root, arguments, TimeSpan.FromMinutes(1)) + .GetAwaiter() + .GetResult(); + Assert.True(result.Succeeded, result.StdErr); + return result.StdOut; + } + +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs new file mode 100644 index 000000000..77f9b764a --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs @@ -0,0 +1,352 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests { + [Fact] + public void Execute_AppleCheckpoint_rejects_transient_exact_source_snapshot_mutation() { + var root = CreateSandbox(); + try { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + RunSnapshotGit(root, "init", "--quiet"); + RunSnapshotGit(root, "config", "user.name", "PowerForge Tests"); + RunSnapshotGit(root, "config", "user.email", "powerforge-tests@example.invalid"); + RunSnapshotGit(root, "add", "."); + RunSnapshotGit(root, "commit", "--quiet", "-m", "exact source"); + var sourceCommit = RunSnapshotGit(root, "rev-parse", "HEAD").Trim(); + + var result = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Archive-only checkpoint must not query App Store Connect."), + archiveAppleApp: request => { + var projectFile = Path.Combine(request.ProjectPath, "project.pbxproj"); + var original = File.ReadAllText(projectFile); + File.WriteAllText(projectFile, original + "\n// transient replacement"); + File.WriteAllText(projectFile, original); + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "untrusted archive"); + return CreateSuccessfulArchive(request); + }) + .Execute(CreateAppleAutomationSpec(root, keyPath), new PowerForgeReleaseRequest { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive, + AppleSourceCommit = sourceCommit, + RequireImmutableAppleSourceSnapshot = true + }); + + Assert.False(result.Success); + Assert.Contains("snapshot changed while xcodebuild", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } finally { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleArchive_builds_privately_and_publishes_exact_archive() { + var root = CreateSandbox(); + try { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var planned = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Archive planning must not query App Store Connect.")) + .Execute(spec, new PowerForgeReleaseRequest { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive, + PlanOnly = true + }); + var publicArchive = Assert.Single(planned.AppleAppPlan!.Apps).ArchivePath; + string? privateArchive = null; + + var result = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Archive-only execution must not query App Store Connect."), + archiveAppleApp: request => { + privateArchive = request.ArchivePath; + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "private exact archive"); + Directory.CreateDirectory(publicArchive); + File.WriteAllText(Path.Combine(publicArchive, "payload"), "public replacement"); + return CreateSuccessfulArchive(request); + }) + .Execute(spec, new PowerForgeReleaseRequest { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Archive + }); + + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(privateArchive); + Assert.NotEqual(publicArchive, privateArchive); + Assert.Equal("private exact archive", File.ReadAllText(Path.Combine(publicArchive, "payload"))); + Assert.False(Directory.Exists(Path.GetDirectoryName(privateArchive!)!)); + } finally { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleUpload_persists_attestation_before_public_archive_recheck() { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + spec.AppleApps.Automation.WaitForProcessing = false; + string? publicArchive = null; + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: request => { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "accepted bytes"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: request => { + publicArchive = Directory.EnumerateDirectories(root, "*.xcarchive", SearchOption.AllDirectories).Single(); + File.WriteAllText(Path.Combine(publicArchive, "payload"), "tampered after acceptance"); + return CreateSuccessfulUpload(request); + }) + .Execute(spec, new PowerForgeReleaseRequest { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.False(result.Success); + Assert.NotNull(publicArchive); + Assert.Contains("changed during upload", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested" && + Assert.Single(receipt.Targets).UploadExecutionSha256 is not null); + } finally { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleUpload_rejects_transient_private_archive_snapshot_mutation() { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + spec.AppleApps.Automation.WaitForProcessing = false; + string? privateArchive = null; + + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: request => { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "approved bytes"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: request => { + privateArchive = request.ArchivePath; + var payload = Path.Combine(request.ArchivePath, "payload"); + File.WriteAllText(payload, "transient unapproved bytes"); + File.WriteAllText(payload, "approved bytes"); + return CreateSuccessfulUpload(request); + }) + .Execute(spec, new PowerForgeReleaseRequest { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.False(result.Success); + Assert.NotNull(privateArchive); + Assert.Contains("private Apple upload archive snapshot changed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested" && + Assert.Single(receipt.Targets).UploadPerformed); + } finally { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleUpload_rejects_transient_private_archive_hard_link_alias_mutation() { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + string? aliasRoot = null; + try { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + spec.AppleApps.Automation.WaitForProcessing = false; + string? privateArchive = null; + + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: request => { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "approved bytes"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: request => { + privateArchive = request.ArchivePath; + var payload = Path.Combine(request.ArchivePath, "payload"); + var snapshotRoot = Directory.GetParent(request.ArchivePath)!.FullName; + aliasRoot = Path.Combine(Directory.GetParent(snapshotRoot)!.FullName, $"alias-{Guid.NewGuid():N}"); + Directory.CreateDirectory(aliasRoot); + var alias = Path.Combine(aliasRoot, "payload-alias"); + TestFileLink.CreateHardLink(alias, payload); + File.WriteAllText(alias, "transient unapproved bytes"); + File.WriteAllText(alias, "approved bytes"); + File.Delete(alias); + return CreateSuccessfulUpload(request); + }) + .Execute(spec, new PowerForgeReleaseRequest { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.False(result.Success); + Assert.NotNull(privateArchive); + Assert.Contains("private Apple upload archive snapshot", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested" && + Assert.Single(receipt.Targets).UploadPerformed); + } finally { + if (!string.IsNullOrWhiteSpace(aliasRoot) && Directory.Exists(aliasRoot)) + Directory.Delete(aliasRoot, recursive: true); + TryDelete(root); + } + } + + [Fact] + public void AppleArchiveUploadSnapshot_rejects_restored_bytes_changed_through_a_removed_hard_link_alias() { + var root = CreateSandbox(); + string? aliasRoot = null; + try { + var archive = Directory.CreateDirectory(Path.Combine(root, "approved.xcarchive")); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "approved bytes"); + var expectedSha256 = AppleNotarizationService.ComputeArtifactSha256(archive.FullName); + + using var snapshot = AppleArchiveUploadSnapshot.Create(archive.FullName, expectedSha256); + aliasRoot = Path.Combine(Directory.GetParent(snapshot.RootPath)!.FullName, $"alias-{Guid.NewGuid():N}"); + Directory.CreateDirectory(aliasRoot); + var alias = Path.Combine(aliasRoot, "payload-alias"); + TestFileLink.CreateHardLink(alias, Path.Combine(snapshot.ArchivePath, "payload")); + File.WriteAllText(alias, "transient unapproved bytes"); + File.WriteAllText(alias, "approved bytes"); + File.Delete(alias); + + var exception = Assert.Throws( + () => snapshot.ValidateUnchanged(expectedSha256)); + Assert.Contains("hard-link alias", exception.Message, StringComparison.OrdinalIgnoreCase); + } finally { + if (!string.IsNullOrWhiteSpace(aliasRoot) && Directory.Exists(aliasRoot)) + Directory.Delete(aliasRoot, recursive: true); + TryDelete(root); + } + } + + [Fact] + public void AppleArchiveUploadSnapshot_disposes_read_only_nested_directories_without_failing() { + var root = CreateSandbox(); + try { + var archive = Directory.CreateDirectory(Path.Combine(root, "approved.xcarchive")); + var nested = Directory.CreateDirectory(Path.Combine(archive.FullName, "Products", "ReadOnly.app")); + File.WriteAllText(Path.Combine(nested.FullName, "payload"), "approved bytes"); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(nested.FullName, UnixFileMode.UserRead | UnixFileMode.UserExecute); +#endif + var expectedSha256 = AppleNotarizationService.ComputeArtifactSha256(archive.FullName); + var snapshot = AppleArchiveUploadSnapshot.Create(archive.FullName, expectedSha256); + var snapshotRoot = snapshot.RootPath; + + var exception = Record.Exception(snapshot.Dispose); + + Assert.Null(exception); + Assert.False(Directory.Exists(snapshotRoot)); + } finally { +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows() && Directory.Exists(root)) + File.SetUnixFileMode(Path.Combine(root, "approved.xcarchive", "Products", "ReadOnly.app"), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + TryDelete(root); + } + } + + [Theory] + [InlineData("team")] + [InlineData("signing")] + [InlineData("xcode")] + [InlineData("symbols")] + [InlineData("archive-root")] + [InlineData("export-root")] + public void Execute_AppleUploadResume_rejects_changed_execution_policy(string changedControl) { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + spec.AppleApps.Automation.WaitForProcessing = false; + var seeded = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: request => { + var archive = Directory.CreateDirectory(request.ArchivePath!); + File.WriteAllText(Path.Combine(archive.FullName, "payload"), "attested archive"); + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: CreateSuccessfulUpload) + .Execute(spec, new PowerForgeReleaseRequest { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + Assert.True(seeded.Success, seeded.ErrorMessage); + var seededTarget = Assert.Single( + new AppleReleaseReceiptStore().ReadAll(seeded.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested").Targets.Single(); + Assert.Matches("^[0-9A-Fa-f]{64}$", seededTarget.UploadExecutionSha256!); + + switch (changedControl) { + case "team": spec.AppleApps.TeamId = "DIFFERENTTEAM"; break; + case "signing": spec.AppleApps.SigningStyle = "manual"; break; + case "xcode": spec.AppleApps.XcodeBuildExecutable = "/reviewed/Xcode.app/xcodebuild"; break; + case "symbols": spec.AppleApps.UploadSymbols = false; break; + case "archive-root": spec.AppleApps.ArchiveRoot = "build/changed-archives"; break; + case "export-root": spec.AppleApps.ExportRoot = "build/changed-exports"; break; + } + + var resumed = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: "VALID"), + archiveAppleApp: _ => throw new InvalidOperationException("Changed policy must not reuse the prior upload."), + uploadAppleApp: _ => throw new InvalidOperationException("Changed policy must not reuse the prior upload.")) + .Execute(spec, new PowerForgeReleaseRequest { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.False(resumed.Success); + Assert.Contains("no immutable local upload receipt", resumed.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } finally { + TryDelete(root); + } + } + +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs new file mode 100644 index 000000000..4682162de --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs @@ -0,0 +1,93 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Execute_AppleDirectNotarizationAmbiguityBlocksAutomaticResubmission(bool pinSource) + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var app = Assert.Single(spec.AppleApps.Apps); + app.Name = "CasaRay Mac"; + app.Platform = ApplePlatform.macOS; + app.DistributionRoute = AppleDistributionRoute.DirectNotarized; + app.AppStoreConnectAppId = null; + + var initial = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: request => + { + var artifact = Directory.CreateDirectory(Path.Combine(request.ExportPath!, "CasaRay.app")); + File.WriteAllText(Path.Combine(artifact.FullName, "payload"), "ambiguous submission bytes"); + return CreateSuccessfulUpload(request); + }, + notarizeAppleArtifact: request => + { + request.AmbiguousCheckpoint!(new AppleNotarizationAmbiguousCheckpoint + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), + SubmissionPath = request.ArtifactPath + ".notarization.zip", + SubmissionSha256 = new string('a', 64) + }); + throw new InvalidOperationException("simulated ambiguous successful notarytool response"); + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = pinSource ? sourceCommit : null + }); + + Assert.False(initial.Success); + Assert.Contains( + new AppleReleaseReceiptStore().ReadAll(initial.AppleAppPlan!), + receipt => receipt.OperationPhase == "NotarizationAmbiguous"); + + var mutationCalls = 0; + var resumed = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: request => + { + mutationCalls++; + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: request => + { + mutationCalls++; + return CreateSuccessfulUpload(request); + }, + notarizeAppleArtifact: _ => + { + mutationCalls++; + throw new InvalidOperationException("Ambiguous notarization must block a second submission."); + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = pinSource ? sourceCommit : null + }); + + Assert.False(resumed.Success); + Assert.Contains("ambiguous", resumed.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("must not be submitted again", resumed.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, mutationCalls); + } + finally + { + TryDelete(root); + } + } +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 73ca3405d..c0e76884c 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -4,6 +4,516 @@ namespace PowerForge.Tests; public sealed partial class PowerForgeReleaseServiceTests { + [Theory] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + [InlineData(true, true, true)] + public void DirectNotarizationResume_ReusesStapleOnlyAfterDurableValidation( + bool stapled, + bool stapleValidated, + bool expected) + { + var receipt = new PowerForgeAppleReleaseTargetReceipt + { + Stapled = stapled, + StapleValidated = stapleValidated + }; + + Assert.Equal(expected, PowerForgeReleaseService.HasDurablePublishedStaple(receipt)); + } + + [Fact] + public void DirectNotarizationResume_RequiresCheckpointArchiveIdentity() + { + var app = new PowerForgeAppleAppReleaseTargetPlan + { + Name = "EasyControlX Agent", + BundleId = "com.evotecit.easycontrolx.agent", + Platform = ApplePlatform.macOS, + DistributionRoute = AppleDistributionRoute.DirectNotarized, + MarketingVersion = "1.0.0", + BuildNumber = "4", + ExpectedArchiveSha256 = new string('b', 64) + }; + var receipt = new PowerForgeAppleReleaseTargetReceipt + { + Name = app.Name, + BundleId = app.BundleId, + Platform = app.Platform, + DistributionRoute = app.DistributionRoute, + Version = app.MarketingVersion, + Build = app.BuildNumber, + ArchiveSha256 = new string('a', 64) + }; + + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = Directory.GetCurrentDirectory(), + SourceCommit = "0123456789abcdef0123456789abcdef01234567" + }; + app.ProjectPath = Path.Combine(plan.ProjectRoot, "EasyControlXAgent.xcodeproj"); + app.ArchivePath = Path.Combine(plan.ProjectRoot, "EasyControlXAgent.xcarchive"); + app.ExportPath = Path.Combine(plan.ProjectRoot, "export"); + receipt.ProjectPath = "EasyControlXAgent.xcodeproj"; + receipt.Scheme = app.Scheme; + receipt.Configuration = app.Configuration; + receipt.Destination = app.Destination; + receipt.DirectExecutionSha256 = PowerForgeReleaseService.ComputeDirectExecutionSha256(plan, app); + Assert.False(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app)); + receipt.ArchiveSha256 = app.ExpectedArchiveSha256; + Assert.True(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app)); + } + + [Fact] + public void DirectNotarizationResume_UsesOwningVolumeProjectPathIdentity() + { + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = Directory.GetCurrentDirectory(), + SourceCommit = "0123456789abcdef0123456789abcdef01234567" + }; + var app = new PowerForgeAppleAppReleaseTargetPlan + { + Name = "CasaRay", + BundleId = "com.evotecit.casaray", + Platform = ApplePlatform.macOS, + ProjectPath = Path.Combine(plan.ProjectRoot, "CasaRay.xcodeproj"), + ArchivePath = Path.Combine(plan.ProjectRoot, "CasaRay.xcarchive"), + ExportPath = Path.Combine(plan.ProjectRoot, "export"), + DistributionRoute = AppleDistributionRoute.DirectNotarized, + MarketingVersion = "1.0.0", + BuildNumber = "1" + }; + var receipt = new PowerForgeAppleReleaseTargetReceipt + { + Name = app.Name, + BundleId = app.BundleId, + Platform = app.Platform, + ProjectPath = "casaray.xcodeproj", + Scheme = app.Scheme, + Configuration = app.Configuration, + Destination = app.Destination, + DistributionRoute = app.DistributionRoute, + Version = app.MarketingVersion, + Build = app.BuildNumber + }; + receipt.DirectExecutionSha256 = PowerForgeReleaseService.ComputeDirectExecutionSha256(plan, app); + + var matches = PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app); + + Assert.Equal( + FrameworkCompatibility.GetPathStringComparisonForPath(plan.ProjectRoot) == StringComparison.OrdinalIgnoreCase, + matches); + } + + [Theory] + [InlineData("team")] + [InlineData("signing")] + [InlineData("export")] + [InlineData("staple")] + [InlineData("generate")] + [InlineData("regenerate")] + [InlineData("xcodegen")] + [InlineData("generation-timeout")] + public void DirectNotarizationResume_RejectsChangedExecutionPolicy(string changedControl) + { + var root = Directory.GetCurrentDirectory(); + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = root, + SourceCommit = "0123456789abcdef0123456789abcdef01234567", + SigningStyle = "automatic", + DirectDistribution = new PowerForgeAppleDirectDistributionOptions + { + ExportMethod = "developer-id", + Staple = true, + Assess = true + } + }; + var app = new PowerForgeAppleAppReleaseTargetPlan + { + Name = "CasaRay", + BundleId = "com.evotecit.casaray", + Platform = ApplePlatform.macOS, + DistributionRoute = AppleDistributionRoute.DirectNotarized, + ProjectPath = Path.Combine(root, "CasaRay.xcodeproj"), + ArchivePath = Path.Combine(root, "CasaRay.xcarchive"), + ExportPath = Path.Combine(root, "export"), + TeamId = "TEAMONE", + MarketingVersion = "1.0.0", + BuildNumber = "1" + }; + var receipt = new PowerForgeAppleReleaseTargetReceipt + { + Name = app.Name, + BundleId = app.BundleId, + Platform = app.Platform, + DistributionRoute = app.DistributionRoute, + ProjectPath = "CasaRay.xcodeproj", + Scheme = app.Scheme, + Configuration = app.Configuration, + Destination = app.Destination, + Version = app.MarketingVersion, + Build = app.BuildNumber, + DirectExecutionSha256 = PowerForgeReleaseService.ComputeDirectExecutionSha256(plan, app) + }; + + switch (changedControl) + { + case "team": + app.TeamId = "TEAMTWO"; + break; + case "signing": + plan.SigningStyle = "manual"; + break; + case "export": + plan.DirectDistribution.ExportMethod = "release-testing"; + break; + case "staple": + plan.DirectDistribution.Staple = false; + break; + case "generate": + app.GenerateProjectIfMissing = true; + break; + case "regenerate": + app.RegenerateProject = true; + break; + case "xcodegen": + app.XcodeGenExecutable = "/reviewed/tools/xcodegen"; + break; + case "generation-timeout": + app.ProjectGenerationTimeoutSeconds++; + break; + } + + Assert.False(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app)); + } + + [Fact] + public void UploadAttestationResume_UsesOwningVolumeProjectPathIdentity() + { + var root = Directory.GetCurrentDirectory(); + var matches = PowerForgeReleaseService.AppleReleasePathsEqual( + "CasaRay.xcodeproj", + "casaray.xcodeproj", + root); + + Assert.Equal( + FrameworkCompatibility.GetPathStringComparisonForPath(root) == StringComparison.OrdinalIgnoreCase, + matches); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterCheckoutRelocation(bool pinSource) + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var originalRoot = CreateSandbox(); + var relocatedRoot = originalRoot + "-relocated"; + try + { + CreateXcodeProject(originalRoot, "EasyControlXAgent.xcodeproj", "1.0.0", "4"); + var keyPath = Path.Combine(originalRoot, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(originalRoot, keyPath); + spec.AppleApps!.TeamId = "8ZPGZ79T7J"; + spec.AppleApps.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var app = Assert.Single(spec.AppleApps.Apps); + app.Name = "EasyControlX Agent"; + app.ProjectPath = "EasyControlXAgent.xcodeproj"; + app.Scheme = "EasyControlXAgent"; + app.Platform = ApplePlatform.macOS; + app.DistributionRoute = AppleDistributionRoute.DirectNotarized; + app.AppStoreConnectAppId = null; + + var failed = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: request => + { + var artifact = Directory.CreateDirectory(Path.Combine(request.ExportPath!, "EasyControlX Agent.app")); + File.WriteAllText(Path.Combine(artifact.FullName, "payload"), "portable accepted bytes"); + return CreateSuccessfulUpload(request); + }, + notarizeAppleArtifact: request => + { + request.AcceptedCheckpoint!(new AppleNotarizationAcceptedCheckpoint + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), + SubmissionPath = request.ArtifactPath + ".zip", + SubmissionSha256 = new string('a', 64), + SubmissionId = "portable-submission", + Status = "Accepted" + }); + throw new InvalidOperationException("simulated process loss after acceptance"); + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(originalRoot, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = pinSource ? sourceCommit : null + }); + Assert.False(failed.Success); + var accepted = Assert.Single( + new AppleReleaseReceiptStore().ReadAll(failed.AppleAppPlan!), + receipt => receipt.OperationPhase == "NotarizationAccepted"); + Assert.False(Path.IsPathRooted(Assert.Single(accepted.Targets).DirectArtifactPath)); + + Directory.Move(originalRoot, relocatedRoot); + spec.AppleApps.ProjectRoot = relocatedRoot; + spec.AppleApps.AppStoreConnectApiKeyPath = Path.Combine(relocatedRoot, "AuthKey_TEST.p8"); + AppleNotarizationRequest? resumedRequest = null; + var resumeService = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: _ => throw new InvalidOperationException("Relocated accepted artifact must skip archive."), + uploadAppleApp: _ => throw new InvalidOperationException("Relocated accepted artifact must skip export."), + notarizeAppleArtifact: request => + { + resumedRequest = request; + return new AppleNotarizationResult + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), + SubmissionPath = request.ArtifactPath + ".zip", + SubmissionId = request.AcceptedSubmissionId!, + Status = "Accepted", + ResumedAcceptedSubmission = true, + Submission = new ProcessRunResult(0, "accepted", string.Empty, "xcrun", TimeSpan.Zero, false), + Staple = new ProcessRunResult(0, "stapled", string.Empty, "xcrun", TimeSpan.Zero, false), + StapleValidation = new ProcessRunResult(0, "valid", string.Empty, "xcrun", TimeSpan.Zero, false), + Assessment = new ProcessRunResult(0, "accepted", string.Empty, "spctl", TimeSpan.Zero, false) + }; + }); + var blocked = resumeService.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(relocatedRoot, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = pinSource ? sourceCommit : null + }); + Assert.False(blocked.Success); + Assert.Contains( + "cannot authorize a cross-process recovery", + Assert.Single(blocked.AppleReceipt!.Targets).ErrorMessage, + StringComparison.OrdinalIgnoreCase); + + var resumed = resumeService.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(relocatedRoot, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = pinSource ? sourceCommit : null, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true + }); + + Assert.True(resumed.Success, resumed.ErrorMessage); + Assert.Equal("portable-submission", resumedRequest!.AcceptedSubmissionId); + Assert.Equal(new string('a', 64), resumedRequest.AcceptedSubmissionSha256); + Assert.StartsWith(relocatedRoot, resumedRequest.ArtifactPath, StringComparison.Ordinal); + Assert.True(Assert.Single(resumed.AppleApps).ResumedAcceptedNotarization); + } + finally + { + TryDelete(originalRoot); + TryDelete(relocatedRoot); + } + } + + [Fact] + public void Execute_AppleDirectNotarizationResume_MissingRetainedArtifactFallsBackToFreshArchive() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "EasyControlXAgent.xcodeproj", "1.0.0", "4"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.TeamId = "8ZPGZ79T7J"; + spec.AppleApps.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var app = Assert.Single(spec.AppleApps.Apps); + app.Name = "EasyControlX Agent"; + app.ProjectPath = "EasyControlXAgent.xcodeproj"; + app.Scheme = "EasyControlXAgent"; + app.Platform = ApplePlatform.macOS; + app.DistributionRoute = AppleDistributionRoute.DirectNotarized; + app.AppStoreConnectAppId = null; + + var interrupted = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: request => + { + var artifact = Directory.CreateDirectory(Path.Combine(request.ExportPath!, "EasyControlX Agent.app")); + File.WriteAllText(Path.Combine(artifact.FullName, "payload"), "accepted bytes that will be lost"); + return CreateSuccessfulUpload(request); + }, + notarizeAppleArtifact: request => + { + request.AcceptedCheckpoint!(new AppleNotarizationAcceptedCheckpoint + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), + SubmissionPath = request.ArtifactPath + ".zip", + SubmissionSha256 = new string('a', 64), + SubmissionId = "missing-artifact-submission", + Status = "Accepted" + }); + throw new InvalidOperationException("simulated process loss after acceptance"); + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + Assert.False(interrupted.Success); + var retainedArtifact = Path.Combine( + Assert.Single(interrupted.AppleAppPlan!.Apps).ExportPath, + "EasyControlX Agent.app"); + Assert.True(Directory.Exists(retainedArtifact)); + Directory.Delete(retainedArtifact, recursive: true); + + var archiveCalls = 0; + var exportCalls = 0; + AppleNotarizationRequest? freshNotarization = null; + var resumed = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: request => + { + archiveCalls++; + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: request => + { + exportCalls++; + var artifact = Directory.CreateDirectory(Path.Combine(request.ExportPath!, "EasyControlX Agent.app")); + File.WriteAllText(Path.Combine(artifact.FullName, "payload"), "fresh exported bytes"); + return CreateSuccessfulUpload(request); + }, + notarizeAppleArtifact: request => + { + freshNotarization = request; + return new AppleNotarizationResult + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), + SubmissionPath = request.ArtifactPath + ".zip", + SubmissionId = "fresh-submission", + Status = "Accepted", + Submission = new ProcessRunResult(0, "accepted", string.Empty, "xcrun", TimeSpan.Zero, false), + Staple = new ProcessRunResult(0, "stapled", string.Empty, "xcrun", TimeSpan.Zero, false), + StapleValidation = new ProcessRunResult(0, "valid", string.Empty, "xcrun", TimeSpan.Zero, false), + Assessment = new ProcessRunResult(0, "accepted", string.Empty, "spctl", TimeSpan.Zero, false) + }; + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.True(resumed.Success, resumed.ErrorMessage); + Assert.Equal(1, archiveCalls); + Assert.Equal(1, exportCalls); + Assert.NotNull(freshNotarization); + Assert.Null(freshNotarization!.AcceptedSubmissionId); + Assert.False(Assert.Single(resumed.AppleApps).ResumedAcceptedNotarization); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleDirectNotarizationResume_RejectsChainedReceiptOutsideCurrentExportRoot() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "EasyControlXAgent.xcodeproj", "1.0.0", "4"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.TeamId = "8ZPGZ79T7J"; + var configured = Assert.Single(spec.AppleApps.Apps); + configured.Name = "EasyControlX Agent"; + configured.ProjectPath = "EasyControlXAgent.xcodeproj"; + configured.Scheme = "EasyControlXAgent"; + configured.Platform = ApplePlatform.macOS; + configured.DistributionRoute = AppleDistributionRoute.DirectNotarized; + configured.AppStoreConnectAppId = null; + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state.")); + var planned = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true, + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + var plan = Assert.IsType(planned.AppleAppPlan); + var app = Assert.Single(plan.Apps); + var outside = Directory.CreateDirectory(Path.Combine(root, "retained", "EasyControlX Agent.app")); + File.WriteAllText(Path.Combine(outside.FullName, "payload"), "accepted elsewhere"); + new AppleReleaseReceiptStore().WriteAttempt(plan, new PowerForgeAppleReleaseReceipt + { + Action = PowerForgeAppleReleaseAction.Upload, + SourceCommit = sourceCommit, + OperationPhase = "NotarizationAccepted", + Success = false, + Targets = + [ + new PowerForgeAppleReleaseTargetReceipt + { + Name = app.Name, + BundleId = app.BundleId, + Platform = app.Platform, + DistributionRoute = app.DistributionRoute, + DirectArtifactPath = outside.FullName, + DirectArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(outside.FullName), + NotarizationSubmissionId = "copied-submission", + NotarizationStatus = "Accepted" + } + ] + }); + var archiveCalls = 0; + service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), + archiveAppleApp: request => + { + archiveCalls++; + return CreateSuccessfulArchive(request); + }); + + var result = service.Execute( + spec, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.False(result.Success); + Assert.Equal(0, archiveCalls); + Assert.Contains("outside its current export root", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleDirectNotarizationResume_RejectsReceiptFromDifferentSourceCommit() { @@ -30,6 +540,7 @@ public void Execute_AppleDirectNotarizationResume_RejectsReceiptFromDifferentSou Directory.CreateDirectory(Path.GetDirectoryName(receiptPath)!); File.WriteAllText(receiptPath, JsonSerializer.Serialize(new PowerForgeAppleReleaseReceipt { + SchemaVersion = 3, Action = PowerForgeAppleReleaseAction.Upload, SourceCommit = priorSourceCommit, Success = false, diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs index 337750d66..1570c9dda 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs @@ -137,13 +137,15 @@ public void Execute_AppleApps_RequiresExplicitExternalPolicyForBetaReviewSubmiss [Fact] public void AppleReleaseDoctor_FindsControlPlaneFailuresBeforeSubmission() { + var root = CreateSandbox(); + CreateXcodeProject(root, "CasaRay.xcodeproj"); var app = new PowerForgeAppleAppReleaseTargetPlan { Name = "CasaRay", BundleId = "com.evotec.casarray", AppStoreConnectAppId = "app-1", DistributionRoute = AppleDistributionRoute.AppStore, - ProjectPath = Path.GetTempFileName() + ProjectPath = Path.Combine(root, "CasaRay.xcodeproj") }; try { @@ -160,7 +162,7 @@ public void AppleReleaseDoctor_FindsControlPlaneFailuresBeforeSubmission() } finally { - File.Delete(app.ProjectPath); + TryDelete(root); } } @@ -312,7 +314,8 @@ public void Execute_AppleDoctor_DiscoversAppIdAndWritesActionableReceipt() Assert.True(result.Success); var receipt = Assert.IsType(result.AppleReceipt); - Assert.Equal(3, receipt.SchemaVersion); + Assert.Equal(6, receipt.SchemaVersion); + Assert.Null(receipt.ReceiptAuthenticationSha256); var target = Assert.Single(receipt.Targets); Assert.Equal(AppleDistributionRoute.AppStore, target.DistributionRoute); Assert.Equal("6778025328", target.AppId); @@ -783,7 +786,7 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() notarizeAppleArtifact: request => new AppleNotarizationResult { ArtifactPath = request.ArtifactPath, - ArtifactSha256 = "failed-artifact-sha", + ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), SubmissionPath = request.ArtifactPath + ".zip", SubmissionId = "accepted-then-staple-failed", Status = "Accepted", @@ -807,8 +810,6 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() Assert.Contains("ticket stapling", target.ErrorMessage, StringComparison.OrdinalIgnoreCase); Assert.Equal(sourceCommit, result.AppleReceipt.SourceCommit); - var receiptPath = Path.Combine(root, "build", "powerforge", "apple", "release-receipt.json"); - AppleNotarizationRequest? resumedRequest = null; var resumeService = CreateAppleAutomationService( _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), @@ -837,7 +838,9 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleSourceCommit = sourceCommit, - AppleAction = PowerForgeAppleReleaseAction.Configured + AppleAction = PowerForgeAppleReleaseAction.Configured, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.True(configuredResumed.Success); @@ -853,24 +856,32 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleSourceCommit = sourceCommit, - AppleAction = PowerForgeAppleReleaseAction.Upload + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.True(resumed.Success); - Assert.NotNull(resumedRequest); - Assert.Equal("accepted-then-staple-failed", resumedRequest!.AcceptedSubmissionId); - Assert.Equal("failed-artifact-sha", resumedRequest.ExpectedArtifactSha256); + Assert.Null(resumedRequest); var resumedTarget = Assert.Single(resumed.AppleReceipt!.Targets); Assert.True(resumedTarget.ResumedAcceptedNotarization); + Assert.Contains("archive", resumedTarget.SkippedSteps); + Assert.Contains("export", resumedTarget.SkippedSteps); Assert.Contains("notarySubmission", resumedTarget.SkippedSteps); + Assert.Contains("staple", resumedTarget.SkippedSteps); + Assert.Contains("stapleValidation", resumedTarget.SkippedSteps); + Assert.Contains("gatekeeperAssessment", resumedTarget.SkippedSteps); // Simulate another target failing after this target completed. Aggregate failure // must retain and reuse the fully verified direct target. - var successfulReceipt = File.ReadAllText(receiptPath); - Assert.Contains("\"success\": true", successfulReceipt, StringComparison.Ordinal); - File.WriteAllText( - receiptPath, - successfulReceipt.Replace("\"success\": true", "\"success\": false", StringComparison.Ordinal)); + var aggregateFailureReceipt = Assert.IsType(resumed.AppleReceipt); + aggregateFailureReceipt.AttemptId = null; + aggregateFailureReceipt.CheckedAt = default; + aggregateFailureReceipt.Success = false; + aggregateFailureReceipt.ErrorMessage = "Another release target failed after notarization completed."; + aggregateFailureReceipt.ReceiptSha256 = null; + aggregateFailureReceipt.PreviousReceiptSha256 = null; + new AppleReleaseReceiptStore().WriteAttempt(resumed.AppleAppPlan!, aggregateFailureReceipt); var archiveCalls = 0; var exportCalls = 0; @@ -900,7 +911,7 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() return new AppleNotarizationResult { ArtifactPath = request.ArtifactPath, - ArtifactSha256 = "new-artifact-sha", + ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), SubmissionPath = request.ArtifactPath + ".zip", SubmissionId = "new-submission", Status = "Accepted", @@ -913,7 +924,9 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleSourceCommit = sourceCommit, - AppleAction = PowerForgeAppleReleaseAction.Upload + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.True(nextRelease.Success); @@ -927,17 +940,22 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() Assert.True(retainedTarget.GatekeeperAccepted); Assert.Contains("gatekeeperAssessment", retainedTarget.SkippedSteps); - // Disabled post-notarization checks are complete by policy even though - // their receipt flags remain null. A mixed-target retry must still reuse - // the retained artifact instead of submitting it again. + // Changing post-notarization policy creates a different approved execution. + // The prior accepted artifact must not be reused under the new controls. spec.AppleApps.DirectDistribution.Staple = false; spec.AppleApps.DirectDistribution.Assess = false; - var disabledChecksReceipt = File.ReadAllText(receiptPath) - .Replace("\"success\": true", "\"success\": false", StringComparison.Ordinal) - .Replace("\"stapled\": true", "\"stapled\": null", StringComparison.Ordinal) - .Replace("\"stapleValidated\": true", "\"stapleValidated\": null", StringComparison.Ordinal) - .Replace("\"gatekeeperAccepted\": true", "\"gatekeeperAccepted\": null", StringComparison.Ordinal); - File.WriteAllText(receiptPath, disabledChecksReceipt); + var disabledChecksReceipt = Assert.IsType(nextRelease.AppleReceipt); + disabledChecksReceipt.AttemptId = null; + disabledChecksReceipt.CheckedAt = default; + disabledChecksReceipt.Success = false; + disabledChecksReceipt.ErrorMessage = "Another release target failed with post-notarization checks disabled."; + disabledChecksReceipt.ReceiptSha256 = null; + disabledChecksReceipt.PreviousReceiptSha256 = null; + var disabledChecksTargetEvidence = Assert.Single(disabledChecksReceipt.Targets); + disabledChecksTargetEvidence.Stapled = null; + disabledChecksTargetEvidence.StapleValidated = null; + disabledChecksTargetEvidence.GatekeeperAccepted = null; + new AppleReleaseReceiptStore().WriteAttempt(nextRelease.AppleAppPlan!, disabledChecksReceipt); archiveCalls = 0; exportCalls = 0; nextReleaseRequest = null; @@ -952,26 +970,35 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() }); Assert.True(disabledChecksRetry.Success); - Assert.Equal(0, archiveCalls); - Assert.Equal(0, exportCalls); - Assert.Null(nextReleaseRequest); + Assert.Equal(1, archiveCalls); + Assert.Equal(1, exportCalls); + Assert.NotNull(nextReleaseRequest); var disabledChecksTarget = Assert.Single(disabledChecksRetry.AppleReceipt!.Targets); - Assert.True(disabledChecksTarget.ResumedAcceptedNotarization); + Assert.False(disabledChecksTarget.ResumedAcceptedNotarization); Assert.Null(disabledChecksTarget.Stapled); Assert.Null(disabledChecksTarget.StapleValidated); Assert.Null(disabledChecksTarget.GatekeeperAccepted); - var retainedReceipt = File.ReadAllText(receiptPath) - .Replace("\"success\": true", "\"success\": false", StringComparison.Ordinal); - File.WriteAllText(receiptPath, retainedReceipt); - File.WriteAllText(Path.Combine(retainedTarget.DirectArtifactPath!, "changed-after-release.txt"), "changed"); + var retainedReceipt = Assert.IsType(disabledChecksRetry.AppleReceipt); + retainedReceipt.AttemptId = null; + retainedReceipt.CheckedAt = default; + retainedReceipt.Success = false; + retainedReceipt.ErrorMessage = "Another release target failed after the artifact was retained."; + retainedReceipt.ReceiptSha256 = null; + retainedReceipt.PreviousReceiptSha256 = null; + new AppleReleaseReceiptStore().WriteAttempt(disabledChecksRetry.AppleAppPlan!, retainedReceipt); + File.WriteAllText( + Path.Combine(root, retainedTarget.DirectArtifactPath!, "changed-after-release.txt"), + "changed"); var changedArtifact = nextReleaseService.Execute( spec, new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleSourceCommit = sourceCommit, - AppleAction = PowerForgeAppleReleaseAction.Upload + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.False(changedArtifact.Success); Assert.Contains( diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePreflightAndSafety.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePreflightAndSafety.cs index 50a2f755f..6f4aa5bb9 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePreflightAndSafety.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePreflightAndSafety.cs @@ -457,7 +457,9 @@ linkCreationException is UnauthorizedAccessException || new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), - AppleAction = PowerForgeAppleReleaseAction.Upload + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.False(result.Success, System.Text.Json.JsonSerializer.Serialize(result.AppleReceipt)); @@ -478,7 +480,7 @@ linkCreationException is UnauthorizedAccessException || } [Fact] - public void Execute_AppleUpload_CleansArtifactsWhenEmbeddedTargetsAreModeled() + public void Execute_AppleUpload_RemovesOnlyExpiredArtifactsWhenEmbeddedTargetsAreModeled() { var root = CreateSandbox(); try @@ -516,15 +518,23 @@ public void Execute_AppleUpload_CleansArtifactsWhenEmbeddedTargetsAreModeled() var parentArchive = planned.AppleAppPlan!.Apps.Single(app => app.Name == parent.Name).ArchivePath; Directory.CreateDirectory(parentArchive); File.WriteAllText(Path.Combine(parentArchive, "Info.plist"), "archive"); + Directory.SetLastWriteTimeUtc(parentArchive, DateTime.UtcNow.AddDays(-10)); + var staleArchive = Path.Combine(Path.GetDirectoryName(parentArchive)!, "stale.xcarchive"); + Directory.CreateDirectory(staleArchive); + File.WriteAllText(Path.Combine(staleArchive, "Info.plist"), "stale archive"); + Directory.SetLastWriteTimeUtc(staleArchive, DateTime.UtcNow.AddDays(-10)); var result = service.Execute(spec, new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), - AppleAction = PowerForgeAppleReleaseAction.Upload + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.True(result.Success, result.ErrorMessage); - Assert.False(Directory.Exists(parentArchive)); + Assert.True(Directory.Exists(parentArchive)); + Assert.False(Directory.Exists(staleArchive)); Assert.Contains(result.AppleReceipt!.Targets, target => target.DistributionRoute == AppleDistributionRoute.EmbeddedCompanion && target.SkippedSteps.Contains("independentRelease")); @@ -536,7 +546,7 @@ public void Execute_AppleUpload_CleansArtifactsWhenEmbeddedTargetsAreModeled() } [Fact] - public void Execute_AppleUpload_RetainsDirectArtifactsWhenStoreTargetIsCleaned() + public void Execute_AppleUpload_RetainsCurrentStoreAndDirectArtifactsAfterProcessing() { var root = CreateSandbox(); try @@ -599,17 +609,31 @@ public void Execute_AppleUpload_RetainsDirectArtifactsWhenStoreTargetIsCleaned() Assessment = new ProcessRunResult(0, "accepted", string.Empty, "spctl", TimeSpan.Zero, false) }); + var planned = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + PlanOnly = true + }); + var plannedStore = planned.AppleAppPlan!.Apps.Single(app => app.Name == store.Name); + Directory.CreateDirectory(plannedStore.ArchivePath); + File.WriteAllText(Path.Combine(plannedStore.ArchivePath, "archive.txt"), "reviewed store archive"); + Directory.CreateDirectory(plannedStore.ExportPath); + File.WriteAllText(Path.Combine(plannedStore.ExportPath, "upload.txt"), "reviewed store export"); + var result = service.Execute(spec, new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), - AppleAction = PowerForgeAppleReleaseAction.Upload + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.True(result.Success, result.ErrorMessage); var storePlan = result.AppleAppPlan!.Apps.Single(app => app.Name == store.Name); var directPlan = result.AppleAppPlan.Apps.Single(app => app.Name == "EasyControlX Agent"); - Assert.False(Directory.Exists(storePlan.ArchivePath)); - Assert.False(Directory.Exists(storePlan.ExportPath)); + Assert.True(Directory.Exists(storePlan.ArchivePath)); + Assert.True(Directory.Exists(storePlan.ExportPath)); Assert.True(Directory.Exists(directPlan.ArchivePath)); Assert.True(Directory.Exists(directPlan.ExportPath)); } diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleProjectGeneration.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleProjectGeneration.cs index 92f2c0612..f7dc88b26 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleProjectGeneration.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleProjectGeneration.cs @@ -178,7 +178,9 @@ public void Execute_AppleRemoteAction_GeneratesMissingProjectBeforeResolvingIden new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), - AppleAction = action + AppleAction = action, + AppleAdoptExistingBuild = action == PowerForgeAppleReleaseAction.Upload, + AppleActionConfirmed = action == PowerForgeAppleReleaseAction.Upload }); Assert.True(result.Success); @@ -188,7 +190,10 @@ public void Execute_AppleRemoteAction_GeneratesMissingProjectBeforeResolvingIden Assert.Equal("1.2.0", target.Version); Assert.Equal("9", target.Build); if (action == PowerForgeAppleReleaseAction.Upload) + { Assert.True(target.ResumedExistingBuild); + Assert.True(target.AdoptedExistingBuild); + } } finally { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs index d3f718a58..7174402f4 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs @@ -107,6 +107,44 @@ public void Execute_AppleVersion_UsesOneBuildAboveLocalAndEveryRemotePlatform() } } + [Fact] + public void Execute_AppleVersion_UsesTheSingleApprovedRemoteObservation() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.5.0", "13"); + WriteXcodeGenVersionSource(root, "1.5.0", "13"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.VersionSourcePath = "project.yml"; + var remoteQueries = 0; + var service = CreateAppleAutomationService( + _ => throw new InvalidOperationException("Version must not query release status."), + generateAppleProject: _ => true, + getHighestAppleBuildNumber: (_, _, _) => ++remoteQueries == 1 ? 13 : 99); + + var result = service.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Version, + AppleMarketingVersion = "1.6.0", + AppleActionConfirmed = true + }); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Equal(1, remoteQueries); + Assert.Equal("14", result.AppleReceipt!.Versioning!.BuildNumber); + var version = new AppleReleaseVersionSourceService().Read(Path.Combine(root, "project.yml")); + Assert.Equal("14", version.BuildNumber); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleVersion_UsesConfiguredPatternToAdvanceRepeatedTestFlightBuild() { @@ -390,13 +428,17 @@ public void Execute_AppleAdvancePlan_EnablesSafeStepsAndStopsBeforeReview() var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); File.WriteAllText(keyPath, "private-key"); var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.TestFlightBetaGroupNames = ["Internal"]; - var result = new PowerForgeReleaseService(new NullLogger()).Execute(spec, new PowerForgeReleaseRequest - { - ConfigPath = Path.Combine(root, "powerforge.release.json"), - AppleAction = PowerForgeAppleReleaseAction.Advance, - PlanOnly = true - }); + var result = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + checkAppleReleaseReadiness: (_, request) => CreateReadyReleaseReadiness(request)) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Advance, + PlanOnly = true + }); Assert.True(result.Success, result.ErrorMessage); var plan = Assert.IsType(result.AppleAppPlan); @@ -405,9 +447,13 @@ public void Execute_AppleAdvancePlan_EnablesSafeStepsAndStopsBeforeReview() Assert.True(plan.PrepareDistribution); Assert.True(plan.SelectBuildForDistribution); Assert.True(plan.CheckReleaseReadiness); + Assert.True(plan.DistributeTestFlight); Assert.False(plan.SubmitTestFlightBetaReview); Assert.False(plan.SubmitForReview); Assert.False(plan.ReleaseApprovedVersion); + var target = Assert.Single(result.AppleReceipt!.Targets); + Assert.Null(target.BuildId); + Assert.Null(target.BuildProcessingState); } finally { @@ -454,6 +500,7 @@ public void Execute_AppleAdvance_DoesNotPrepareTestFlightOnlyTargetForPublicStor { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Advance, + AppleAdoptExistingBuild = true, AppleActionConfirmed = true }); @@ -637,6 +684,7 @@ public void Execute_AppleAdvance_PartialFailureRefreshesRemoteStateAndKeepsOrigi { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Advance, + AppleAdoptExistingBuild = true, AppleActionConfirmed = true }); @@ -653,6 +701,46 @@ public void Execute_AppleAdvance_PartialFailureRefreshesRemoteStateAndKeepsOrigi } } + [Fact] + public void Execute_AppleAdvance_SuccessRefreshesReceiptFromPostMutationRemoteState() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.6.0", "14"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var stateCalls = 0; + + var result = CreateAppleAutomationService( + request => + { + stateCalls++; + var state = CreateReleaseState(request, "VALID"); + state.Platforms.Single().Version!.Id = stateCalls == 1 ? "version-before" : "version-after"; + return state; + }, + prepareAppleDistribution: CreateSuccessfulPreparation) + .Execute( + CreateAppleAutomationSpec(root, keyPath), + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Advance, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true + }); + + Assert.True(result.Success, result.ErrorMessage); + Assert.True(stateCalls >= 2); + Assert.Equal("version-after", Assert.Single(result.AppleReceipt!.Targets).DistributionVersionId); + } + finally + { + TryDelete(root); + } + } + private static void WriteXcodeGenVersionSource(string root, string marketingVersion, string buildNumber) { File.WriteAllText( diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs new file mode 100644 index 000000000..8ea1233ed --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs @@ -0,0 +1,213 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Execute_AppleUpload_blocks_reupload_after_indeterminate_process_result( + bool throwFromUploader, + bool pinSource) + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + + var initial = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: request => + { + request.InvokeRemoteMutationStarted(); + if (throwFromUploader) + throw new IOException("response channel closed after upload handoff"); + return new AppleAppArchiveUploadResult + { + ArchivePath = request.ArchivePath, + ExportPath = request.ExportPath!, + ExportOptionsPlistPath = Path.Combine(request.ExportPath!, "ExportOptions.plist"), + ProcessResult = new ProcessRunResult( + -1, + string.Empty, + "response channel closed after upload handoff", + "xcodebuild", + TimeSpan.FromMinutes(5), + true) + }; + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = pinSource ? sourceCommit : null, + AppleWaitForProcessing = false + }); + + Assert.False(initial.Success); + Assert.Contains( + new AppleReleaseReceiptStore().ReadAll(initial.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAmbiguous"); + + var uploadCalls = 0; + var resumed = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: _ => throw new InvalidOperationException("Ambiguous upload must block archiving."), + uploadAppleApp: request => + { + uploadCalls++; + return CreateSuccessfulUpload(request); + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = pinSource ? sourceCommit : null, + AppleWaitForProcessing = false + }); + + Assert.False(resumed.Success); + Assert.Equal(0, uploadCalls); + Assert.Contains("ambiguous remote result", resumed.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("will not upload", resumed.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleUpload_does_not_checkpoint_ambiguity_before_remote_mutation_starts() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + + var initial = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: _ => throw new InvalidOperationException("privacy validation failed before xcodebuild")) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit, + AppleWaitForProcessing = false + }); + + Assert.False(initial.Success); + Assert.DoesNotContain( + new AppleReleaseReceiptStore().ReadAll(initial.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAmbiguous"); + + var uploadCalls = 0; + var retry = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: request => + { + uploadCalls++; + return CreateSuccessfulUpload(request); + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit, + AppleWaitForProcessing = false + }); + + Assert.True(retry.Success, retry.ErrorMessage); + Assert.Equal(1, uploadCalls); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_AppleUpload_blocks_reupload_when_success_attestation_has_no_delivery_id() + { + const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "CasaRay.xcodeproj", "1.2.0", "9"); + var keyPath = Path.Combine(root, "AuthKey_TEST.p8"); + File.WriteAllText(keyPath, "private-key"); + var spec = CreateAppleAutomationSpec(root, keyPath); + spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; + spec.AppleApps.Automation.CleanupBeforeArchive = false; + var stateCalls = 0; + var seeded = CreateAppleAutomationService( + request => ++stateCalls == 1 + ? CreateReleaseState(request, processingState: null) + : throw new InvalidOperationException("final readback unavailable"), + archiveAppleApp: CreateSuccessfulArchive, + uploadAppleApp: CreateSuccessfulUpload) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit, + AppleWaitForProcessing = false + }); + Assert.False(seeded.Success); + var attestation = Assert.Single( + new AppleReleaseReceiptStore().ReadAll(seeded.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested"); + Assert.Null(Assert.Single(attestation.Targets).BuildUploadId); + + var archiveCalls = 0; + var uploadCalls = 0; + var resumed = CreateAppleAutomationService( + request => CreateReleaseState(request, processingState: null), + archiveAppleApp: request => + { + archiveCalls++; + return CreateSuccessfulArchive(request); + }, + uploadAppleApp: request => + { + uploadCalls++; + return CreateSuccessfulUpload(request); + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.UploadExisting, + AppleSourceCommit = sourceCommit, + AppleWaitForProcessing = false, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true + }); + + Assert.False(resumed.Success); + Assert.Equal(0, archiveCalls); + Assert.Equal(0, uploadCalls); + Assert.Contains("without an App Store Connect Delivery UUID", resumed.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("will not upload the archive again", resumed.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs new file mode 100644 index 000000000..cf61b004b --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs @@ -0,0 +1,108 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [Fact] + public void AppleVersionSource_UpdateDoesNotOverwriteAnAtomicEditorAfterComparison() + { + var root = CreateSandbox(); + try + { + WriteXcodeGenVersionSource(root, "1.5.0", "13"); + var sourcePath = Path.Combine(root, "project.yml"); + var approvedContent = File.ReadAllText(sourcePath); + var editorContent = approvedContent.Replace( + "name: CasaRay", + "name: CasaRay-Edited", + StringComparison.Ordinal); + var service = new AppleReleaseVersionSourceService(path => + { + var editorPath = path + ".editor"; + File.WriteAllText(editorPath, editorContent); + File.Move(editorPath, path, overwrite: true); + }); + + var exception = Assert.Throws(() => + service.Update( + sourcePath, + approvedContent, + "1.6.0", + "14", + highestRemoteBuildNumber: 13, + whatIf: false)); + + Assert.Contains("changed while", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(editorContent, File.ReadAllText(sourcePath)); + var version = new AppleReleaseVersionSourceService().Read(sourcePath); + Assert.Equal("1.5.0", version.MarketingVersion); + Assert.Equal("13", version.BuildNumber); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void AppleVersionSource_UpdatePreservesApprovedSourceWhenInterruptedBeforeAtomicPublish() + { + var root = CreateSandbox(); + try + { + WriteXcodeGenVersionSource(root, "1.5.0", "13"); + var sourcePath = Path.Combine(root, "project.yml"); + var approvedContent = File.ReadAllText(sourcePath); + var service = new AppleReleaseVersionSourceService(_ => + throw new IOException("Simulated interruption before atomic publication.")); + + var exception = Assert.Throws(() => + service.Update( + sourcePath, + approvedContent, + "1.6.0", + "14", + highestRemoteBuildNumber: 13, + whatIf: false)); + + Assert.Contains("interruption", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(approvedContent, File.ReadAllText(sourcePath)); + Assert.Empty(Directory.EnumerateFiles(root, ".project.yml.*.tmp")); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void AppleVersionSource_UpdateSucceedsWhenCommittedBackupCleanupFails() + { + var root = CreateSandbox(); + try + { + WriteXcodeGenVersionSource(root, "1.5.0", "13"); + var sourcePath = Path.Combine(root, "project.yml"); + var approvedContent = File.ReadAllText(sourcePath); + var service = new AppleReleaseVersionSourceService( + deleteFile: path => throw new IOException($"Simulated cleanup failure for {path}")); + + var receipt = service.Update( + sourcePath, + approvedContent, + "1.6.0", + "14", + highestRemoteBuildNumber: 13, + whatIf: false); + + Assert.True(receipt.Changed); + var version = new AppleReleaseVersionSourceService().Read(sourcePath); + Assert.Equal("1.6.0", version.MarketingVersion); + Assert.Equal("14", version.BuildNumber); + Assert.Single(Directory.EnumerateFiles(root, ".project.yml.*.previous")); + } + finally + { + TryDelete(root); + } + } +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs index 2072cee69..a4f2ea703 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs @@ -482,12 +482,7 @@ public void Execute_AppleApps_RunsArchiveAndUploadThroughSharedService() archiveAppleApp: request => { archiveRequests.Add(request); - return new AppleAppArchiveResult - { - ArchivePath = request.ArchivePath!, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }; + return CreateSuccessfulArchive(request); }, uploadAppleApp: request => { @@ -530,7 +525,8 @@ public void Execute_AppleApps_RunsArchiveAndUploadThroughSharedService() }, new PowerForgeReleaseRequest { - ConfigPath = Path.Combine(root, "powerforge.release.json") + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleWaitForProcessing = false }); Assert.True(result.Success); @@ -597,7 +593,8 @@ public void Execute_AppleApps_PreparesDistributionVersionWithResolvedProjectVers Build = new AppStoreConnectBuildInfo { Id = "build-5", Version = request.BuildNumber }, SelectedBuild = true }; - }); + }, + getAppleReleaseState: request => CreateReleaseState(request, "VALID")); var result = service.Execute( new PowerForgeReleaseSpec @@ -796,7 +793,9 @@ public void Execute_AppleApps_AcceptsMetadataAndReadinessInUnifiedPlan() } """); - var service = new PowerForgeReleaseService(new NullLogger()); + var service = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + checkAppleReleaseReadiness: (_, request) => CreateReadyReleaseReadiness(request)); var result = service.Execute( new PowerForgeReleaseSpec { @@ -948,7 +947,8 @@ public void Execute_AppleApps_AcceptsTestFlightDistributionInUnifiedPlan() var keyPath = Path.Combine(root, "AuthKey_ABC123DEFG.p8"); File.WriteAllText(keyPath, "private-key"); - var service = new PowerForgeReleaseService(new NullLogger()); + var service = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID")); var result = service.Execute( new PowerForgeReleaseSpec { @@ -1003,7 +1003,7 @@ public void Execute_AppleApps_AcceptsTestFlightBetaReviewSubmissionInUnifiedPlan var keyPath = Path.Combine(root, "AuthKey_ABC123DEFG.p8"); File.WriteAllText(keyPath, "private-key"); - var service = new PowerForgeReleaseService(new NullLogger()); + var service = CreateAppleAutomationService(request => CreateReleaseState(request, "VALID")); var result = service.Execute( new PowerForgeReleaseSpec { @@ -1086,7 +1086,8 @@ public void Execute_AppleApps_SubmitsTestFlightBetaReviewThroughSharedService() BuildId = "build-6" } }; - }); + }, + getAppleReleaseState: request => CreateReleaseState(request, "VALID")); var result = service.Execute( new PowerForgeReleaseSpec @@ -1145,7 +1146,7 @@ public void Execute_AppleApps_AcceptsReviewSubmissionInUnifiedPlan() var keyPath = Path.Combine(root, "AuthKey_ABC123DEFG.p8"); File.WriteAllText(keyPath, "private-key"); - var service = new PowerForgeReleaseService(new NullLogger()); + var service = CreateAppleAutomationService(request => CreateReleaseState(request, "VALID")); var result = service.Execute( new PowerForgeReleaseSpec { @@ -1250,7 +1251,8 @@ public void Execute_AppleApps_PassesScreenshotSpecToReviewReadiness() State = "WAITING_FOR_REVIEW" } }; - }); + }, + getAppleReleaseState: request => CreateReleaseState(request, "VALID")); var result = service.Execute( new PowerForgeReleaseSpec @@ -1306,7 +1308,7 @@ public void Execute_AppleApps_AcceptsApprovedVersionReleaseInUnifiedPlan() var keyPath = Path.Combine(root, "AuthKey_ABC123DEFG.p8"); File.WriteAllText(keyPath, "private-key"); - var service = new PowerForgeReleaseService(new NullLogger()); + var service = CreateAppleAutomationService(request => CreateReleaseState(request, "VALID")); var result = service.Execute( new PowerForgeReleaseSpec { @@ -1413,12 +1415,7 @@ public void Execute_AppleApps_UpdatesXcodeVersionBeforeArchive() Assert.Contains("MARKETING_VERSION = 2.1.0;", content, StringComparison.Ordinal); Assert.Contains("CURRENT_PROJECT_VERSION = 8;", content, StringComparison.Ordinal); - return new AppleAppArchiveResult - { - ArchivePath = request.ArchivePath!, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }; + return CreateSuccessfulArchive(request); }, uploadAppleApp: _ => throw new InvalidOperationException("Upload should not run.")); @@ -1486,12 +1483,7 @@ public void Execute_AppleApps_NormalizesPbxprojPathForArchive() archiveAppleApp: request => { archiveRequests.Add(request); - return new AppleAppArchiveResult - { - ArchivePath = request.ArchivePath!, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }; + return CreateSuccessfulArchive(request); }, uploadAppleApp: _ => throw new InvalidOperationException("Upload should not run.")); @@ -1642,7 +1634,8 @@ public void Execute_AppleApps_StopsAfterFirstAppFailure() }, new PowerForgeReleaseRequest { - ConfigPath = Path.Combine(root, "powerforge.release.json") + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleWaitForProcessing = false }); Assert.False(result.Success); @@ -1691,12 +1684,7 @@ public void Execute_AppleApps_HonorsTargetFilter() archiveAppleApp: request => { archiveRequests.Add(request); - return new AppleAppArchiveResult - { - ArchivePath = request.ArchivePath!, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }; + return CreateSuccessfulArchive(request); }, uploadAppleApp: _ => throw new InvalidOperationException("Upload should not run.")); @@ -1851,12 +1839,7 @@ public void Execute_MixedDotNetToolsAndAppleApps_AllowsAppleOnlyTargetFilter() archiveAppleApp: request => { archiveRequests.Add(request); - return new AppleAppArchiveResult - { - ArchivePath = request.ArchivePath!, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }; + return CreateSuccessfulArchive(request); }, uploadAppleApp: _ => throw new InvalidOperationException("Upload should not run.")); @@ -1931,12 +1914,7 @@ public void Execute_MixedExternalDotNetToolsAndAppleApps_AllowsAppleOnlyTargetFi archiveAppleApp: request => { archiveRequests.Add(request); - return new AppleAppArchiveResult - { - ArchivePath = request.ArchivePath!, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }; + return CreateSuccessfulArchive(request); }, uploadAppleApp: _ => throw new InvalidOperationException("Upload should not run.")); @@ -2396,12 +2374,7 @@ public void Execute_MixedDotNetToolsAndAppleApps_RunsSharedTargetInBothSections( archiveAppleApp: request => { archiveRequests.Add(request); - return new AppleAppArchiveResult - { - ArchivePath = request.ArchivePath!, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }; + return CreateSuccessfulArchive(request); }, uploadAppleApp: _ => throw new InvalidOperationException("Upload should not run.")); @@ -2478,12 +2451,7 @@ public void Execute_MixedLegacyToolsAndAppleApps_AllowsAppleOnlyTargetFilter() archiveAppleApp: request => { archiveRequests.Add(request); - return new AppleAppArchiveResult - { - ArchivePath = request.ArchivePath!, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }; + return CreateSuccessfulArchive(request); }, uploadAppleApp: _ => throw new InvalidOperationException("Upload should not run.")); @@ -2555,12 +2523,7 @@ public void Execute_MixedModulePackagesAndAppleApps_AllowsAppleOnlyTargetFilter( archiveAppleApp: request => { archiveRequests.Add(request); - return new AppleAppArchiveResult - { - ArchivePath = request.ArchivePath!, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }; + return CreateSuccessfulArchive(request); }, uploadAppleApp: _ => throw new InvalidOperationException("Upload should not run.")); @@ -3520,6 +3483,53 @@ public void Execute_AppleApps_PlanOnly_AllowsMissingReuseArchive() } } + [Fact] + public void Execute_AppleApps_PlanOnly_BindsExistingReuseArchiveHash() + { + var root = CreateSandbox(); + try + { + CreateXcodeProject(root, "Tactra.xcodeproj"); + var archivePath = Path.Combine(root, "Artifacts", "Apple", "Archives", "iOS", "Tactra.xcarchive"); + Directory.CreateDirectory(archivePath); + File.WriteAllText(Path.Combine(archivePath, "payload"), "approved archive"); + + var result = new PowerForgeReleaseService(new NullLogger()).Execute( + new PowerForgeReleaseSpec + { + AppleApps = new PowerForgeAppleReleaseOptions + { + ProjectRoot = ".", + Archive = false, + Upload = true, + Apps = + [ + new AppleAppConfiguration + { + Name = "Tactra", + ProjectPath = "Tactra.xcodeproj", + Scheme = "Tactra", + Platform = ApplePlatform.iOS + } + ] + } + }, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + PlanOnly = true + }); + + Assert.Equal( + AppleNotarizationService.ComputeArtifactSha256(archivePath), + Assert.Single(result.AppleReceipt!.Targets).ArchiveSha256); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleApps_RejectsExistingNonProjectDirectoryBeforeArchive() { @@ -3624,12 +3634,7 @@ public void Execute_AppleApps_WritesUnifiedManifestSection() planDotNetTools: (_, _, _, _) => throw new InvalidOperationException("DotNet tools should not run."), runDotNetTools: _ => throw new InvalidOperationException("DotNet tools should not run."), publishGitHubRelease: _ => throw new InvalidOperationException("GitHub should not run."), - archiveAppleApp: request => new AppleAppArchiveResult - { - ArchivePath = archivePath, - Destination = request.Destination!, - ProcessResult = new ProcessRunResult(0, "archive-ok", string.Empty, "xcodebuild", TimeSpan.FromSeconds(1), false) - }, + archiveAppleApp: CreateSuccessfulArchive, uploadAppleApp: request => new AppleAppArchiveUploadResult { ArchivePath = request.ArchivePath, @@ -3666,7 +3671,8 @@ public void Execute_AppleApps_WritesUnifiedManifestSection() }, new PowerForgeReleaseRequest { - ConfigPath = Path.Combine(root, "powerforge.release.json") + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleWaitForProcessing = false }); Assert.True(result.Success); diff --git a/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs b/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs new file mode 100644 index 000000000..b148747e3 --- /dev/null +++ b/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs @@ -0,0 +1,67 @@ +using System.Reflection; + +namespace PowerForge.Tests; + +public sealed class ProcessRunnerEnvironmentTests +{ + [Fact] + public async Task RunAsync_invokes_completion_boundary_before_inherited_output_pipe_drain() + { + if (OperatingSystem.IsWindows()) return; + var boundary = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var request = new ProcessRunRequest( + "/bin/sh", + Path.GetTempPath(), + new[] { "-c", "sleep 2 &" }, + TimeSpan.FromSeconds(10), + captureOutput: true, + captureError: true); + request.SetCompletionBoundary(_ => boundary.TrySetResult()); + + var run = new ProcessRunner().RunAsync(request); + await boundary.Task.WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.False(run.IsCompleted); + var result = await run; + Assert.True(result.Succeeded, result.StdErr); + } + + [Fact] + public void Completion_boundary_is_available_to_external_process_runners() + { + var method = typeof(ProcessRunRequest).GetMethod( + "InvokeCompletionBoundary", + BindingFlags.Instance | BindingFlags.Public); + + Assert.NotNull(method); + } + + [Fact] + public async Task RunAsync_can_start_from_an_explicit_environment_allowlist() + { + if (OperatingSystem.IsWindows()) return; + const string variable = "POWERFORGE_TEST_UNAPPROVED_PARENT_VALUE"; + var original = Environment.GetEnvironmentVariable(variable); + Environment.SetEnvironmentVariable(variable, "must-not-leak"); + try + { + var result = await new ProcessRunner().RunAsync(new ProcessRunRequest( + "/usr/bin/env", + Path.GetTempPath(), + Array.Empty(), + TimeSpan.FromSeconds(10), + new Dictionary { ["PATH"] = "/usr/bin:/bin" }, + captureOutput: true, + captureError: true, + inheritEnvironment: false)); + + Assert.True(result.Succeeded, result.StdErr); + Assert.DoesNotContain(variable, result.StdOut, StringComparison.Ordinal); + Assert.Contains("PATH=/usr/bin:/bin", result.StdOut, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(variable, original); + } + } +} diff --git a/PowerForge/Abstractions/IProcessRunner.cs b/PowerForge/Abstractions/IProcessRunner.cs index b90a129fc..1628f8b50 100644 --- a/PowerForge/Abstractions/IProcessRunner.cs +++ b/PowerForge/Abstractions/IProcessRunner.cs @@ -8,6 +8,10 @@ namespace PowerForge; /// public sealed class ProcessRunRequest { + private int _startBoundaryInvoked; + private Action? _startBoundary; + private int _completionBoundaryInvoked; + private Action? _completionBoundary; /// /// Initializes a new instance of the class. /// @@ -35,7 +39,42 @@ public ProcessRunRequest( captureOutput, captureError, outputLineReceived: null, - errorLineReceived: null) + errorLineReceived: null, + inheritEnvironment: true) + { + } + + /// + /// Initializes a process request that can opt out of parent-environment inheritance. + /// + /// Executable name or path. + /// Working directory for the process. + /// Structured arguments passed to the process. + /// Maximum runtime before the process is terminated. + /// Environment variables applied to the child. + /// When true, capture standard output. + /// When true, capture standard error. + /// When false, start from an empty environment. + public ProcessRunRequest( + string fileName, + string workingDirectory, + IReadOnlyList arguments, + TimeSpan timeout, + IReadOnlyDictionary? environmentVariables, + bool captureOutput, + bool captureError, + bool inheritEnvironment) + : this( + fileName, + workingDirectory, + arguments, + timeout, + environmentVariables, + captureOutput, + captureError, + outputLineReceived: null, + errorLineReceived: null, + inheritEnvironment: inheritEnvironment) { } @@ -61,6 +100,44 @@ public ProcessRunRequest( bool captureError, Action? outputLineReceived, Action? errorLineReceived) + : this( + fileName, + workingDirectory, + arguments, + timeout, + environmentVariables, + captureOutput, + captureError, + outputLineReceived, + errorLineReceived, + inheritEnvironment: true) + { + } + + /// + /// Initializes a streaming process request with explicit parent-environment inheritance policy. + /// + /// Executable name or path. + /// Working directory for the process. + /// Structured arguments passed to the process. + /// Maximum runtime before the process is terminated. + /// Environment variables applied to the child. + /// When true, capture standard output. + /// When true, capture standard error. + /// Optional callback for each captured standard-output line. + /// Optional callback for each captured standard-error line. + /// When false, start from an empty environment. + public ProcessRunRequest( + string fileName, + string workingDirectory, + IReadOnlyList arguments, + TimeSpan timeout, + IReadOnlyDictionary? environmentVariables, + bool captureOutput, + bool captureError, + Action? outputLineReceived, + Action? errorLineReceived, + bool inheritEnvironment) { FileName = fileName; WorkingDirectory = workingDirectory; @@ -71,6 +148,7 @@ public ProcessRunRequest( CaptureError = captureError; OutputLineReceived = outputLineReceived; ErrorLineReceived = errorLineReceived; + InheritEnvironment = inheritEnvironment; } /// @@ -98,6 +176,11 @@ public ProcessRunRequest( /// public IReadOnlyDictionary? EnvironmentVariables { get; } + /// + /// Gets a value indicating whether the child process inherits the parent environment. + /// + public bool InheritEnvironment { get; } + /// /// Gets a value indicating whether standard output should be captured. /// @@ -113,6 +196,41 @@ public ProcessRunRequest( /// Optional callback invoked for each captured standard-error line. public Action? ErrorLineReceived { get; } + + internal void SetCompletionBoundary(Action completionBoundary) + => _completionBoundary = completionBoundary ?? throw new ArgumentNullException(nameof(completionBoundary)); + + internal void SetStartBoundary(Action startBoundary) + => _startBoundary = startBoundary ?? throw new ArgumentNullException(nameof(startBoundary)); + + /// + /// Signals that the external process was successfully started and may have begun externally + /// visible work. Custom implementations must invoke this method + /// immediately after process start succeeds. The callback is invoked at most once. + /// + public void InvokeStartBoundary() + { + if (_startBoundary is null || Interlocked.Exchange(ref _startBoundaryInvoked, 1) != 0) + return; + _startBoundary(); + } + + /// + /// Signals that the external process has completed and its final result is available. + /// Custom implementations must invoke this method immediately + /// after observing process exit and before returning or performing any post-exit mutation. + /// The exit state is final, but captured output may still be draining. The callback is invoked + /// at most once, so service-level fallback calls are safe. + /// + /// The final process result. + public void InvokeCompletionBoundary(ProcessRunResult result) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + if (_completionBoundary is null || Interlocked.Exchange(ref _completionBoundaryInvoked, 1) != 0) + return; + _completionBoundary(result); + } } /// @@ -192,6 +310,13 @@ public interface IProcessRunner /// Process execution request. /// Cancellation token. /// Structured process execution result. + /// + /// Implementations must call immediately + /// after the process starts successfully. They must also call + /// immediately + /// after the process exits and the final result is constructed, before returning from this method + /// or performing any post-exit mutation of producer outputs. + /// Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default); } @@ -218,11 +343,14 @@ public async Task RunAsync(ProcessRunRequest request, Cancella try { process.Start(); + request.InvokeStartBoundary(); } catch (Exception ex) { stopwatch.Stop(); - return new ProcessRunResult(127, string.Empty, ex.Message, request.FileName, stopwatch.Elapsed, timedOut: false); + var failedStart = new ProcessRunResult(127, string.Empty, ex.Message, request.FileName, stopwatch.Elapsed, timedOut: false); + request.InvokeCompletionBoundary(failedStart); + return failedStart; } var stdoutTask = request.CaptureOutput @@ -253,6 +381,19 @@ public async Task RunAsync(ProcessRunRequest request, Cancella // Best-effort wait only. } + // Bind producer-owned filesystem output at the first observable process-exit + // boundary. Stream drainage happens afterward so a blocked or inherited pipe + // cannot create an unmonitored post-exit replacement window. + var exitCode = timedOut ? 124 : SafeGetExitCode(process); + var boundaryResult = new ProcessRunResult( + exitCode, + string.Empty, + timedOut ? "Timeout" : string.Empty, + process.StartInfo.FileName ?? request.FileName, + stopwatch.Elapsed, + timedOut); + request.InvokeCompletionBoundary(boundaryResult); + var stdout = request.CaptureOutput ? await DrainAsync(stdoutTask).ConfigureAwait(false) : string.Empty; @@ -264,8 +405,9 @@ public async Task RunAsync(ProcessRunRequest request, Cancella if (timedOut && string.IsNullOrWhiteSpace(stderr)) stderr = "Timeout"; - var exitCode = timedOut ? 124 : SafeGetExitCode(process); - return new ProcessRunResult(exitCode, stdout, stderr, process.StartInfo.FileName ?? request.FileName, stopwatch.Elapsed, timedOut); + var result = new ProcessRunResult(exitCode, stdout, stderr, process.StartInfo.FileName ?? request.FileName, stopwatch.Elapsed, timedOut); + request.InvokeCompletionBoundary(result); + return result; } private static async Task ReadOutputAsync( @@ -299,6 +441,9 @@ private static ProcessStartInfo BuildStartInfo(ProcessRunRequest request) ProcessStartInfoEncoding.TryApplyUtf8(startInfo); + if (!request.InheritEnvironment) + startInfo.EnvironmentVariables.Clear(); + if (request.EnvironmentVariables is not null) { foreach (var variable in request.EnvironmentVariables) diff --git a/PowerForge/FrameworkCompatibility.cs b/PowerForge/FrameworkCompatibility.cs index 548573914..dec74b8c3 100644 --- a/PowerForge/FrameworkCompatibility.cs +++ b/PowerForge/FrameworkCompatibility.cs @@ -50,7 +50,26 @@ public static StringComparison GetPathStringComparison(string directory) // fall back to the platform default below } - return PathStringComparison(); + return IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : PathStringComparison(); + } + + public static StringComparison GetPathStringComparisonForPath(string path) + { + var current = Path.GetFullPath(path); + if (!Directory.Exists(current)) + current = Path.GetDirectoryName(current) ?? current; + + while (!Directory.Exists(current)) + { + var parent = Path.GetDirectoryName(current); + if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.Ordinal)) + return PathStringComparison(); + current = parent; + } + + return GetPathStringComparison(current); } public static string GetRelativePath(string relativeTo, string path) @@ -86,6 +105,15 @@ private static bool IsCaseSensitiveDirectory(string directory) } } + private static bool IsMacOS() + { +#if NET472 + return false; +#else + return OperatingSystem.IsMacOS(); +#endif + } + private static void TryDeleteFile(string path) { try diff --git a/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs b/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs index 399600433..3551c3034 100644 --- a/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs +++ b/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs @@ -52,6 +52,12 @@ public sealed class AppStoreConnectReleasePreparationRequest /// Exact release source commit expected by a reviewed screenshot manifest. public string? ExpectedSourceCommit { get; set; } + + /// Approved SHA-256 values keyed by absolute screenshot source path. + internal IReadOnlyDictionary? ExpectedScreenshotFileSha256 { get; set; } + + /// Approved SHA-256 of the exact ordered remote screenshot inventory. + internal string? ExpectedScreenshotInventorySha256 { get; set; } } /// diff --git a/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs b/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs index 87c6453a2..8f5727014 100644 --- a/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs +++ b/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs @@ -243,6 +243,12 @@ public sealed class AppStoreConnectScreenshotSyncRequest /// Exact source commit whose reviewed screenshots may be uploaded. public string? ExpectedSourceCommit { get; set; } + + /// Approved SHA-256 values keyed by absolute source path for release-orchestrated uploads. + internal IReadOnlyDictionary? ExpectedFileSha256 { get; set; } + + /// Approved SHA-256 of the exact ordered remote screenshot inventory. + internal string? ExpectedRemoteInventorySha256 { get; set; } } /// diff --git a/PowerForge/Models/AppStoreConnectScreenshotSyncValidationModels.cs b/PowerForge/Models/AppStoreConnectScreenshotSyncValidationModels.cs index 4e563f5a7..2667aed17 100644 --- a/PowerForge/Models/AppStoreConnectScreenshotSyncValidationModels.cs +++ b/PowerForge/Models/AppStoreConnectScreenshotSyncValidationModels.cs @@ -16,6 +16,12 @@ public sealed class AppStoreConnectScreenshotSyncValidationResult /// Per-set validation results. public AppStoreConnectScreenshotSetSyncValidationResult[] ScreenshotSets { get; set; } = Array.Empty(); + + /// + /// Exact manifest-approved screenshot hashes keyed by resolved source path. This internal evidence + /// binds a direct screenshot sync's immutable snapshot to the same approval read used by preflight. + /// + internal IReadOnlyDictionary? ApprovedFileSha256 { get; set; } } /// diff --git a/PowerForge/Models/AppStoreConnectVersionMetadataModels.cs b/PowerForge/Models/AppStoreConnectVersionMetadataModels.cs index 6e7d60ea3..a82a05c33 100644 --- a/PowerForge/Models/AppStoreConnectVersionMetadataModels.cs +++ b/PowerForge/Models/AppStoreConnectVersionMetadataModels.cs @@ -212,4 +212,28 @@ public sealed class AppStoreConnectReleaseScreenshotSetReadiness /// Screenshot file names currently in the set. public string[] FileNames { get; set; } = Array.Empty(); + + /// Exact ordered screenshot inventory used to bind destructive replacement plans. + public AppStoreConnectReleaseScreenshotAssetReadiness[] Screenshots { get; set; } = Array.Empty(); +} + +/// +/// Stable remote identity for one screenshot in a release-readiness inventory. +/// +public sealed class AppStoreConnectReleaseScreenshotAssetReadiness +{ + /// App Store Connect screenshot id. + public string Id { get; set; } = string.Empty; + + /// Screenshot file name when reported by App Store Connect. + public string? FileName { get; set; } + + /// Screenshot file size when reported by App Store Connect. + public long? FileSize { get; set; } + + /// Source checksum when reported by App Store Connect. + public string? SourceFileChecksum { get; set; } + + /// Asset delivery state when reported by App Store Connect. + public string? AssetDeliveryState { get; set; } } diff --git a/PowerForge/Models/AppleAppArchiveModels.cs b/PowerForge/Models/AppleAppArchiveModels.cs index 3103317dd..914883567 100644 --- a/PowerForge/Models/AppleAppArchiveModels.cs +++ b/PowerForge/Models/AppleAppArchiveModels.cs @@ -35,6 +35,9 @@ public sealed class AppleAppArchiveRequest /// xcodebuild executable name or path. public string XcodeBuildExecutable { get; set; } = "xcodebuild"; + /// Resolve, validate, and monitor the exact Swift package checkouts consumed by this archive. + public bool RequireExactPackageSnapshot { get; set; } + /// Allows Xcode to create or update signing assets during archive. public bool AllowProvisioningUpdates { get; set; } = true; @@ -65,6 +68,9 @@ public sealed class AppleAppArchiveResult /// Resolved xcodebuild destination. public string Destination { get; set; } = string.Empty; + /// SHA-256 of the exact archive bytes observed immediately after xcodebuild completed. + public string? ArchiveSha256 { get; set; } + /// xcodebuild process result. public ProcessRunResult ProcessResult { get; set; } = new(0, string.Empty, string.Empty, "xcodebuild", TimeSpan.Zero, false); @@ -77,6 +83,11 @@ public sealed class AppleAppArchiveResult /// public sealed class AppleAppArchiveUploadRequest { + internal Action? RemoteMutationStarted { get; set; } + + internal void InvokeRemoteMutationStarted() + => RemoteMutationStarted?.Invoke(); + /// Path to the .xcarchive to upload. public string ArchivePath { get; set; } = string.Empty; @@ -128,6 +139,9 @@ public sealed class AppleAppArchiveUploadRequest /// xcodebuild executable name or path. public string XcodeBuildExecutable { get; set; } = "xcodebuild"; + /// Requires the fixed system xcodebuild and an explicit environment allowlist. + public bool RequireTrustedSystemTools { get; set; } + /// Additional structured arguments appended to the export command. public string[] AdditionalArguments { get; set; } = Array.Empty(); @@ -155,6 +169,12 @@ public sealed class AppleAppArchiveUploadResult /// Build-upload id accepted by App Store Connect, when reported by Xcode delivery. public string? BuildUploadId { get; set; } + /// Developer ID artifact path observed immediately when xcodebuild export completed. + public string? ExportArtifactPath { get; set; } + + /// SHA-256 of the exact Developer ID artifact observed immediately when xcodebuild export completed. + public string? ExportArtifactSha256 { get; set; } + /// xcodebuild process result. public ProcessRunResult ProcessResult { get; set; } = new(0, string.Empty, string.Empty, "xcodebuild", TimeSpan.Zero, false); diff --git a/PowerForge/Models/AppleNotarizationModels.cs b/PowerForge/Models/AppleNotarizationModels.cs index 7d9823564..fae458dc0 100644 --- a/PowerForge/Models/AppleNotarizationModels.cs +++ b/PowerForge/Models/AppleNotarizationModels.cs @@ -6,7 +6,7 @@ public sealed class AppleNotarizationRequest /// .app, .dmg, or .pkg artifact to notarize. public string ArtifactPath { get; set; } = string.Empty; - /// Optional explicit zip path used when ArtifactPath is an .app bundle. + /// Optional retained copy path for the exact private zip submitted when ArtifactPath is an .app bundle. public string? SubmissionPath { get; set; } /// xcrun executable. @@ -18,6 +18,9 @@ public sealed class AppleNotarizationRequest /// spctl executable. public string SpctlExecutable { get; set; } = "spctl"; + /// Require fixed system notarization, packaging, and Gatekeeper executables under a sanitized PATH. + public bool RequireTrustedSystemTools { get; set; } + /// Optional notarytool keychain profile. public string? KeychainProfile { get; set; } @@ -39,6 +42,9 @@ public sealed class AppleNotarizationRequest /// Expected SHA-256 of the retained artifact bytes when resuming an accepted submission. public string? ExpectedArtifactSha256 { get; set; } + /// SHA-256 of the exact file previously accepted by Apple's notary service. + public string? AcceptedSubmissionSha256 { get; set; } + /// Whether stapling already succeeded and must not mutate the artifact again during resume. public bool StaplingCompleted { get; set; } @@ -50,6 +56,56 @@ public sealed class AppleNotarizationRequest /// Run Gatekeeper assessment. public bool Assess { get; set; } = true; + + internal Action? AcceptedCheckpoint { get; set; } + + internal Action? AmbiguousCheckpoint { get; set; } + + internal Action? StapledCheckpoint { get; set; } +} + +/// Durable evidence that notarytool may have mutated remote state without returning a terminal result. +internal sealed class AppleNotarizationAmbiguousCheckpoint +{ + internal string ArtifactPath { get; set; } = string.Empty; + + internal string ArtifactSha256 { get; set; } = string.Empty; + + internal string SubmissionPath { get; set; } = string.Empty; + + internal string SubmissionSha256 { get; set; } = string.Empty; + + internal string? SubmissionId { get; set; } + + internal string? Status { get; set; } +} + +internal sealed class AppleNotarizationAcceptedCheckpoint +{ + internal string ArtifactPath { get; set; } = string.Empty; + + internal string ArtifactSha256 { get; set; } = string.Empty; + + internal string SubmissionPath { get; set; } = string.Empty; + + internal string SubmissionSha256 { get; set; } = string.Empty; + + internal string SubmissionId { get; set; } = string.Empty; + + internal string Status { get; set; } = "Accepted"; +} + +internal sealed class AppleNotarizationStapledCheckpoint +{ + internal string ArtifactPath { get; set; } = string.Empty; + + internal string ArtifactSha256 { get; set; } = string.Empty; + + internal string SubmissionSha256 { get; set; } = string.Empty; + + internal string SubmissionId { get; set; } = string.Empty; + + internal string Status { get; set; } = "Accepted"; } /// Result of notarizing, stapling, and assessing a direct macOS artifact. @@ -64,6 +120,9 @@ public sealed class AppleNotarizationResult /// File submitted to notarytool. public string SubmissionPath { get; set; } = string.Empty; + /// SHA-256 of the exact file read and accepted by notarytool. + public string? SubmissionSha256 { get; set; } + /// Notary submission id. public string? SubmissionId { get; set; } diff --git a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index 26a33f5f6..947180425 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -62,6 +62,9 @@ internal sealed class PowerForgeAppleReleaseAutomationOptions /// Receipt path relative to the Apple project root. public string ReceiptPath { get; set; } = "build/powerforge/apple/release-receipt.json"; + /// Directory containing immutable Apple release attempt receipts. + public string ReceiptHistoryPath { get; set; } = "build/powerforge/apple/receipts"; + /// Plan receipt path relative to the Apple project root. public string PlanReceiptPath { get; set; } = "build/powerforge/apple/release-plan.json"; @@ -98,7 +101,7 @@ internal sealed class PowerForgeAppleReleaseAutomationOptions /// Remove stale release artifacts before archive creation. public bool CleanupBeforeArchive { get; set; } - /// Remove the exact local archive/export after the remote build is valid. + /// Remove expired local archive/export artifacts after the remote build is valid. public bool CleanupAfterProcessing { get; set; } /// Age threshold used by bounded stale-artifact cleanup. @@ -138,7 +141,10 @@ internal sealed class PowerForgeAppleDirectDistributionOptions /// internal sealed class PowerForgeAppleReleaseReceipt { - public int SchemaVersion { get; set; } = 3; + public int SchemaVersion { get; set; } = 6; + + /// Unique immutable attempt identity. + public string? AttemptId { get; set; } public PowerForgeAppleReleaseAction Action { get; set; } @@ -146,17 +152,41 @@ internal sealed class PowerForgeAppleReleaseReceipt public bool PlanOnly { get; set; } + /// Durability checkpoint represented by this immutable receipt. + public string? OperationPhase { get; set; } + public DateTimeOffset CheckedAt { get; set; } = DateTimeOffset.UtcNow; /// SHA-256 binding the stable action, source, target, and observed Apple state represented by a plan. public string? PlanSha256 { get; set; } + /// Canonical SHA-256 of effective mutation flags and every local payload consumed by this plan. + public string? MutationInputsSha256 { get; set; } + + /// Project-relative content hashes for configuration and asset files consumed by this plan. + public Dictionary MutationInputFiles { get; set; } = new(StringComparer.Ordinal); + public bool Success { get; set; } public string? ErrorMessage { get; set; } public string? ReceiptPath { get; set; } + /// Project-relative immutable history path for this attempt. + public string? HistoryPath { get; set; } + + /// Canonical SHA-256 of the previous immutable attempt receipt. + public string? PreviousReceiptSha256 { get; set; } + + /// Canonical SHA-256 of this receipt with this property omitted. + public string? ReceiptSha256 { get; set; } + + /// Legacy schema-5 machine-local HMAC retained only for reading historical receipts; it is not recovery authority. + public string? ReceiptAuthenticationSha256 { get; set; } + + /// True when the operator explicitly authorized recovery after independently verifying the remote Apple operation. + public bool AdoptExistingBuild { get; set; } + public PowerForgeAppleVersionReceipt? Versioning { get; set; } public PowerForgeAppleReleaseTargetReceipt[] Targets { get; set; } = Array.Empty(); @@ -219,6 +249,18 @@ internal sealed class PowerForgeAppleReleaseTargetReceipt public ApplePlatform Platform { get; set; } + public string Configuration { get; set; } = "Release"; + + public string? ProjectPath { get; set; } + + public bool IsWorkspace { get; set; } + + public string? Scheme { get; set; } + + public AppleArchiveVariant ArchiveVariant { get; set; } + + public string? Destination { get; set; } + public AppleDistributionRoute DistributionRoute { get; set; } public AppleProductRole ProductRole { get; set; } @@ -267,6 +309,8 @@ internal sealed class PowerForgeAppleReleaseTargetReceipt public string[]? ScreenshotDeliveryStates { get; set; } + public string? ScreenshotInventorySha256 { get; set; } + public AppStoreConnectReleaseReadinessCheck[]? ReadinessChecks { get; set; } public string? ReadinessSha256 { get; set; } @@ -283,12 +327,30 @@ internal sealed class PowerForgeAppleReleaseTargetReceipt public bool UploadPerformed { get; set; } + /// Project-relative archive path used for an upload attempt. + public string? ArchivePath { get; set; } + + /// SHA-256 of the exact local archive used for an upload attempt. + public string? ArchiveSha256 { get; set; } + + /// Attempt id that originally attested the uploaded archive. + public string? UploadAttestationAttemptId { get; set; } + + /// SHA-256 binding the effective archive, signing, export, and App Store upload controls. + public string? UploadExecutionSha256 { get; set; } + public string? DirectArtifactPath { get; set; } public string? DirectArtifactSha256 { get; set; } + /// SHA-256 binding the effective archive, export, signing, and notarization controls that produced the direct artifact. + public string? DirectExecutionSha256 { get; set; } + public string? NotarizationSubmissionId { get; set; } + /// SHA-256 of the exact file accepted by Apple's notary service. + public string? NotarizationSubmissionSha256 { get; set; } + public string? NotarizationStatus { get; set; } public bool? Stapled { get; set; } @@ -301,6 +363,9 @@ internal sealed class PowerForgeAppleReleaseTargetReceipt public bool ResumedExistingBuild { get; set; } + /// True when an existing remote build was adopted without a matching local upload attestation. + public bool AdoptedExistingBuild { get; set; } + public string[] SkippedSteps { get; set; } = Array.Empty(); public PowerForgeAppleReleaseDiagnostic[] Diagnostics { get; set; } = Array.Empty(); diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs index 5b700d486..251d9fac3 100644 --- a/PowerForge/Models/PowerForgeRelease.cs +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -7,6 +7,12 @@ namespace PowerForge; /// internal sealed class PowerForgeReleaseSpec { + [JsonIgnore] + internal string? LoadedConfigurationPath { get; set; } + + [JsonIgnore] + internal string? LoadedConfigurationSha256 { get; set; } + [JsonPropertyName("$schema")] public string? Schema { get; set; } @@ -37,6 +43,8 @@ internal sealed class PowerForgeReleaseRequest internal string? ResolvedReleaseVersion { get; set; } internal IPowerForgeReleaseProgressReporter? Progress { get; set; } internal CancellationToken CancellationToken { get; set; } + internal string? ExactConfigurationContent { get; set; } + internal string? LoadedConfigurationSha256 { get; set; } public string ConfigPath { get; set; } = string.Empty; @@ -59,6 +67,9 @@ internal sealed class PowerForgeReleaseRequest internal bool CheckpointAppleApps { get; set; } + /// Build Apple archives from a private detached exact-commit source worktree. + internal bool RequireImmutableAppleSourceSnapshot { get; set; } + public bool? PublishNuget { get; set; } public bool? PublishProjectGitHub { get; set; } @@ -226,8 +237,14 @@ internal sealed class PowerForgeReleaseRequest public string? AppleExpectedPlanSha256 { get; set; } + /// Checkpoint-only archive hashes keyed by stable Apple target name. + internal Dictionary AppleExpectedArchiveSha256ByTarget { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public bool AppleActionConfirmed { get; set; } + /// Explicitly authorize deliberate recovery after independently verifying the remote Apple operation. + public bool AppleAdoptExistingBuild { get; set; } + public bool? AppleResume { get; set; } public bool? AppleWaitForProcessing { get; set; } @@ -503,6 +520,8 @@ internal sealed class PowerForgeAppleReleasePlan public string ReceiptPath { get; set; } = string.Empty; + public string ReceiptHistoryPath { get; set; } = string.Empty; + public string PlanReceiptPath { get; set; } = string.Empty; public string LockPath { get; set; } = string.Empty; @@ -513,6 +532,10 @@ internal sealed class PowerForgeAppleReleasePlan public string? SourceCommit { get; set; } + public bool RequireImmutableSourceSnapshot { get; set; } + + public bool AdoptExistingBuild { get; set; } + public bool Archive { get; set; } public bool Upload { get; set; } @@ -523,6 +546,14 @@ internal sealed class PowerForgeAppleReleasePlan public string[] ScreenshotConfigPaths { get; set; } = Array.Empty(); + public Dictionary ApprovedMutationInputFilesSha256 { get; set; } = new(StringComparer.Ordinal); + + internal Dictionary ApprovedMutationInputContents { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + internal string? ExactSourceConfigPath { get; set; } + + internal string? ExactSourceConfigSha256 { get; set; } + public string? MetadataConfigPath { get; set; } public string[] MetadataConfigPaths { get; set; } = Array.Empty(); @@ -642,6 +673,12 @@ internal sealed class PowerForgeAppleAppReleaseTargetPlan public string ExportPath { get; set; } = string.Empty; + /// SHA-256 of the exact retained archive approved by a prior build checkpoint. + public string? ExpectedArchiveSha256 { get; set; } + + /// SHA-256 of the exact remote screenshot inventory approved for destructive replacement. + public string? ExpectedScreenshotInventorySha256 { get; set; } + public string? TeamId { get; set; } public bool Upload { get; set; } @@ -691,6 +728,16 @@ internal sealed class PowerForgeAppleAppReleaseResult public bool ResumedExistingBuild { get; set; } + public bool AdoptedExistingBuild { get; set; } + + public string? ArchiveSha256 { get; set; } + + public string? UploadAttestationAttemptId { get; set; } + + public string? ResumedUploadAttestationAttemptId { get; set; } + + public PowerForgeAppleReleaseTargetReceipt? ResumedUploadAttestation { get; set; } + public bool ResumedAcceptedNotarization { get; set; } public bool ProjectGenerated { get; set; } diff --git a/PowerForge/Services/AppStoreConnectClient.cs b/PowerForge/Services/AppStoreConnectClient.cs index a760b7e26..9763604c1 100644 --- a/PowerForge/Services/AppStoreConnectClient.cs +++ b/PowerForge/Services/AppStoreConnectClient.cs @@ -432,6 +432,20 @@ public async Task UploadScreenshotAsync( string screenshotSetId, string filePath, CancellationToken cancellationToken = default) + => await UploadScreenshotAsync( + screenshotSetId, + filePath, + expectedSha256: null, + cancellationToken).ConfigureAwait(false); + + /// + /// Captures one immutable byte sequence, verifies its approved SHA-256 when supplied, and uses those bytes for every upload chunk and checksum. + /// + internal async Task UploadScreenshotAsync( + string screenshotSetId, + string filePath, + string? expectedSha256, + CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException("File path is required.", nameof(filePath)); @@ -440,17 +454,26 @@ public async Task UploadScreenshotAsync( var file = new FileInfo(fullPath); if (!file.Exists) throw new FileNotFoundException("Screenshot file was not found.", fullPath); + using var captured = AppStoreConnectScreenshotUploadSnapshot.Capture(fullPath, expectedSha256); + using var mutationMonitor = new AppleReleaseSourceMutationMonitor( + captured.RootPath, + "private screenshot upload snapshot", + "App Store Connect upload operations", + "Discard the screenshot upload result and retry from approved bytes."); + captured.ValidateUnchanged(); var reservation = await CreateScreenshotReservationAsync( screenshotSetId, file.Name, - file.Length, + captured.Length, cancellationToken).ConfigureAwait(false); foreach (var operation in reservation.UploadOperations) - await ExecuteUploadOperationAsync(fullPath, operation, cancellationToken).ConfigureAwait(false); + await ExecuteUploadOperationAsync(captured, operation, cancellationToken).ConfigureAwait(false); + captured.ValidateUnchanged(); + mutationMonitor.ValidateNoChanges(); - var checksum = ComputeMd5Checksum(fullPath); + var checksum = captured.Md5; var committed = await CommitScreenshotUploadAsync(reservation.Id, checksum, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(committed.SourceFileChecksum)) reservation.SourceFileChecksum = committed.SourceFileChecksum; @@ -722,7 +745,7 @@ private async Task GetBuildArrayAsync( } private async Task ExecuteUploadOperationAsync( - string filePath, + AppStoreConnectScreenshotUploadSnapshot captured, AppStoreConnectUploadOperation operation, CancellationToken cancellationToken) { @@ -730,25 +753,11 @@ private async Task ExecuteUploadOperationAsync( throw new InvalidOperationException("Upload operation URL is missing."); if (operation.Length < 0) throw new InvalidOperationException("Upload operation length cannot be negative."); - if (operation.Length > int.MaxValue) - throw new InvalidOperationException("Upload operation is too large for the current uploader."); - - var bytes = new byte[(int)operation.Length]; - using (var stream = File.OpenRead(filePath)) - { - stream.Seek(operation.Offset, SeekOrigin.Begin); - var read = 0; - while (read < bytes.Length) - { - var count = await stream.ReadAsync(bytes, read, bytes.Length - read, cancellationToken).ConfigureAwait(false); - if (count == 0) - throw new EndOfStreamException("Screenshot file ended before upload operation bytes were read."); - read += count; - } - } + if (operation.Offset < 0) + throw new InvalidOperationException("Upload operation offset is outside the captured screenshot bytes."); using var request = new HttpRequestMessage(new HttpMethod(string.IsNullOrWhiteSpace(operation.Method) ? "PUT" : operation.Method), operation.Url); - request.Content = new ByteArrayContent(bytes); + request.Content = captured.CreateRangeContent(operation.Offset, operation.Length); foreach (var header in operation.RequestHeaders) { if (string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) @@ -1051,14 +1060,6 @@ private static int ClampLimit(int limit) return limit; } - private static string ComputeMd5Checksum(string filePath) - { - using var md5 = MD5.Create(); - using var stream = File.OpenRead(filePath); - var bytes = md5.ComputeHash(stream); - return BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant(); - } - private sealed class BuildPreReleaseVersion { public BuildPreReleaseVersion(string? version, string? platform) diff --git a/PowerForge/Services/AppStoreConnectGovernanceConfiguration.cs b/PowerForge/Services/AppStoreConnectGovernanceConfiguration.cs index ae677d37d..7a90ebc03 100644 --- a/PowerForge/Services/AppStoreConnectGovernanceConfiguration.cs +++ b/PowerForge/Services/AppStoreConnectGovernanceConfiguration.cs @@ -38,10 +38,22 @@ public AppStoreConnectGovernanceSpec Load(string path) if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Governance config path is required.", nameof(path)); var fullPath = Path.GetFullPath(path); if (!File.Exists(fullPath)) throw new FileNotFoundException("Governance config was not found.", fullPath); + try + { + return LoadContent(File.ReadAllText(fullPath), fullPath); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Governance config '{fullPath}' is not valid JSON: {ex.Message}", ex); + } + } + + internal AppStoreConnectGovernanceSpec LoadContent(string content, string sourcePath) + { try { return JsonSerializer.Deserialize( - File.ReadAllText(fullPath), + content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, @@ -53,7 +65,7 @@ public AppStoreConnectGovernanceSpec Load(string path) } catch (JsonException ex) { - throw new InvalidOperationException($"Governance config '{fullPath}' is not valid JSON: {ex.Message}", ex); + throw new InvalidOperationException($"Governance config '{sourcePath}' is not valid JSON: {ex.Message}", ex); } } diff --git a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs index 5dcf23c69..91e09c0c7 100644 --- a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs +++ b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs @@ -42,10 +42,61 @@ request.ScreenshotSpec is not null || var buildNumber = request.BuildNumber?.Trim() ?? string.Empty; var messages = new List(); var createdVersion = false; + var firstRemoteMutationAuthorized = false; AppStoreConnectVersionInfo? version = null; + var configuredVersionId = requiresVersion ? ResolveConfiguredVersionId(request) : null; + var screenshotService = request.ScreenshotSpec is null + ? null + : new AppStoreConnectScreenshotSyncService(_client); + var initialScreenshotSpec = request.ScreenshotSpec is null + ? null + : CreateScreenshotSpecForVersion( + request.ScreenshotSpec, + appId, + versionString, + request.Platform, + configuredVersionId); + using var approvedScreenshotSnapshot = initialScreenshotSpec is null + ? null + : screenshotService!.CreateSnapshot(new AppStoreConnectScreenshotSyncRequest + { + Spec = initialScreenshotSpec, + ReplaceExisting = request.ReplaceScreenshots, + BaseDirectory = request.BaseDirectory, + ExpectedSourceCommit = request.ExpectedSourceCommit, + ExpectedFileSha256 = request.ExpectedScreenshotFileSha256, + ExpectedRemoteInventorySha256 = request.ExpectedScreenshotInventorySha256 + }); + + async Task AuthorizeFirstRemoteMutationAsync() + { + if (firstRemoteMutationAuthorized) + return; + if (request.ScreenshotSpec is not null && + request.ReplaceScreenshots && + !string.IsNullOrWhiteSpace(request.ExpectedScreenshotInventorySha256)) + { + if (version is null) + { + throw new InvalidOperationException( + "The approved screenshot inventory cannot be validated because the target App Store version does not exist. Review a new release plan before creating it."); + } + var screenshotSpec = CreateScreenshotSpecForVersion( + request.ScreenshotSpec, + appId, + versionString, + request.Platform, + version.Id); + await screenshotService!.ValidateExpectedRemoteInventoryAsync( + screenshotSpec, + request.ExpectedScreenshotInventorySha256!, + cancellationToken).ConfigureAwait(false); + } + firstRemoteMutationAuthorized = true; + } + if (requiresVersion) { - var configuredVersionId = ResolveConfiguredVersionId(request); if (!request.CreateVersion && configuredVersionId is not null) { version = new AppStoreConnectVersionInfo @@ -72,6 +123,7 @@ request.ScreenshotSpec is not null || if (!request.CreateVersion) throw new InvalidOperationException($"App Store version '{versionString}' was not found for app '{appId}' and platform '{request.Platform}'."); + await AuthorizeFirstRemoteMutationAsync().ConfigureAwait(false); version = await _client.CreateVersionAsync(appId, versionString, request.Platform, cancellationToken).ConfigureAwait(false); createdVersion = true; messages.Add($"Created App Store version '{versionString}' for platform '{request.Platform}'."); @@ -107,6 +159,7 @@ request.ScreenshotSpec is not null || } else { + await AuthorizeFirstRemoteMutationAsync().ConfigureAwait(false); await _client.SetVersionBuildAsync(version.Id, build.Id, cancellationToken).ConfigureAwait(false); selectedBuild = true; messages.Add($"Selected build '{buildNumber}' for App Store version '{versionString}'."); @@ -116,6 +169,7 @@ request.ScreenshotSpec is not null || AppStoreConnectVersionMetadataSyncResult? metadata = null; if (request.MetadataSpec is not null) { + await AuthorizeFirstRemoteMutationAsync().ConfigureAwait(false); var metadataSpec = CreateMetadataSpecForVersion(request.MetadataSpec, appId, versionString, request.Platform, version!.Id); metadata = await new AppStoreConnectVersionMetadataSyncService(_client).SyncAsync( new AppStoreConnectVersionMetadataSyncRequest { Spec = metadataSpec }, @@ -126,6 +180,7 @@ request.ScreenshotSpec is not null || var appInfoMetadataResults = new List(); foreach (var sourceSpec in request.AppInfoMetadataSpecs ?? Array.Empty()) { + await AuthorizeFirstRemoteMutationAsync().ConfigureAwait(false); var appInfoMetadataSpec = CreateAppInfoMetadataSpec(sourceSpec, appId); var appInfoMetadata = await new AppStoreConnectAppInfoMetadataSyncService(_client).SyncAsync( new AppStoreConnectAppInfoMetadataSyncRequest { Spec = appInfoMetadataSpec }, @@ -137,15 +192,19 @@ request.ScreenshotSpec is not null || AppStoreConnectScreenshotSyncResult? screenshots = null; if (request.ScreenshotSpec is not null) { + await AuthorizeFirstRemoteMutationAsync().ConfigureAwait(false); var screenshotSpec = CreateScreenshotSpecForVersion(request.ScreenshotSpec, appId, versionString, request.Platform, version!.Id); - screenshots = await new AppStoreConnectScreenshotSyncService(_client).SyncAsync( + screenshots = await screenshotService!.SyncAsync( new AppStoreConnectScreenshotSyncRequest { Spec = screenshotSpec, ReplaceExisting = request.ReplaceScreenshots, BaseDirectory = request.BaseDirectory, - ExpectedSourceCommit = request.ExpectedSourceCommit + ExpectedSourceCommit = request.ExpectedSourceCommit, + ExpectedFileSha256 = request.ExpectedScreenshotFileSha256, + ExpectedRemoteInventorySha256 = request.ExpectedScreenshotInventorySha256 }, + approvedScreenshotSnapshot!, cancellationToken).ConfigureAwait(false); messages.Add("Synchronized App Store screenshots."); } @@ -218,7 +277,7 @@ private static AppStoreConnectScreenshotSyncSpec CreateScreenshotSpecForVersion( string appId, string versionString, ApplePlatform platform, - string versionId) + string? versionId) { if (!string.IsNullOrWhiteSpace(source.AppId) && !string.Equals(source.AppId.Trim(), appId, StringComparison.OrdinalIgnoreCase)) diff --git a/PowerForge/Services/AppStoreConnectReleaseReadinessService.cs b/PowerForge/Services/AppStoreConnectReleaseReadinessService.cs index e4ec22e39..ecbddfc64 100644 --- a/PowerForge/Services/AppStoreConnectReleaseReadinessService.cs +++ b/PowerForge/Services/AppStoreConnectReleaseReadinessService.cs @@ -173,7 +173,15 @@ private async Task CheckScreensh ScreenshotSetId = set.Id, Count = screenshots.Length, AssetDeliveryStates = deliveryStates, - FileNames = fileNames + FileNames = fileNames, + Screenshots = screenshots.Select(static screenshot => new AppStoreConnectReleaseScreenshotAssetReadiness + { + Id = screenshot.Id, + FileName = screenshot.FileName, + FileSize = screenshot.FileSize, + SourceFileChecksum = screenshot.SourceFileChecksum, + AssetDeliveryState = screenshot.AssetDeliveryState + }).ToArray() }); var enough = screenshots.Length >= Math.Max(1, request.MinimumScreenshotsPerSet); diff --git a/PowerForge/Services/AppStoreConnectScreenshotApprovalService.cs b/PowerForge/Services/AppStoreConnectScreenshotApprovalService.cs index 810b3474a..bc1fce998 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotApprovalService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotApprovalService.cs @@ -12,11 +12,9 @@ public AppStoreConnectScreenshotApprovalManifest Create(AppStoreConnectScreensho throw new ArgumentException("Spec is required.", nameof(request)); if (string.IsNullOrWhiteSpace(request.VersionString)) throw new ArgumentException("VersionString is required.", nameof(request)); - if (string.IsNullOrWhiteSpace(request.SourceCommit) || - request.SourceCommit.Trim().Length != 40 || - !request.SourceCommit.Trim().All(Uri.IsHexDigit)) + if (!GitObjectId.IsFull(request.SourceCommit?.Trim())) { - throw new ArgumentException("SourceCommit must be an exact 40-character Git commit SHA.", nameof(request)); + throw new ArgumentException("SourceCommit must be a full SHA-1 or SHA-256 Git commit object id.", nameof(request)); } if (string.IsNullOrWhiteSpace(request.ApprovedBy)) throw new ArgumentException("ApprovedBy is required.", nameof(request)); @@ -62,7 +60,7 @@ public AppStoreConnectScreenshotApprovalManifest Create(AppStoreConnectScreensho AppId = appId, Platform = request.Spec.Platform, VersionString = request.VersionString.Trim(), - SourceCommit = request.SourceCommit.Trim(), + SourceCommit = request.SourceCommit!.Trim(), CaptureRunId = Normalize(request.CaptureRunId), CaptureRepository = Normalize(request.CaptureRepository), CaptureWorkflowRef = Normalize(request.CaptureWorkflowRef), diff --git a/PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs b/PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs new file mode 100644 index 000000000..a4b8b350f --- /dev/null +++ b/PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs @@ -0,0 +1,59 @@ +namespace PowerForge; + +/// Applies set-relative screenshot filter semantics consistently across validation, approval, and upload. +internal static class AppStoreConnectScreenshotFileSelector +{ + internal static string[] Select(string folder, string filter, int maxCount) + { + var comparison = FrameworkCompatibility.GetPathStringComparisonForPath(folder); + var comparer = comparison == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + return Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories) + .Select(path => new + { + Path = Path.GetFullPath(path), + RelativePath = GetRelativePath(folder, path) + }) + .Where(static item => item.RelativePath is not null) + .Where(item => MatchesFilter(item.RelativePath!, filter, comparison)) + .OrderBy(static item => item.Path, comparer) + .Take(maxCount) + .Select(static item => item.Path) + .ToArray(); + } + + internal static string? GetRelativePath(string folder, string screenshotPath) + { + var root = Path.GetFullPath(folder) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + var volumeRoot = Path.GetPathRoot(root); + if (!string.Equals(root, volumeRoot, StringComparison.Ordinal)) + root = root.TrimEnd(Path.DirectorySeparatorChar); + var fullPath = Path.GetFullPath(screenshotPath) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + var comparison = FrameworkCompatibility.GetPathStringComparisonForPath(root); + var prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? root + : root + Path.DirectorySeparatorChar; + if (!fullPath.StartsWith(prefix, comparison)) + return null; + var relative = fullPath.Substring(prefix.Length); + return relative.Replace('\\', '/'); + } + + internal static bool MatchesFilter(string relativePath, string filter) + => MatchesFilter(relativePath, filter, FrameworkCompatibility.PathStringComparison()); + + internal static bool MatchesFilter(string relativePath, string filter, StringComparison comparison) + { + var normalizedFilter = filter.Replace('\\', '/'); + var expression = "^" + System.Text.RegularExpressions.Regex.Escape(normalizedFilter) + .Replace("\\*", "[^/]*") + .Replace("\\?", "[^/]") + "$"; + var options = System.Text.RegularExpressions.RegexOptions.CultureInvariant; + if (comparison == StringComparison.OrdinalIgnoreCase) + options |= System.Text.RegularExpressions.RegexOptions.IgnoreCase; + return System.Text.RegularExpressions.Regex.IsMatch(relativePath, expression, options); + } +} diff --git a/PowerForge/Services/AppStoreConnectScreenshotInventory.cs b/PowerForge/Services/AppStoreConnectScreenshotInventory.cs new file mode 100644 index 000000000..8be70a61b --- /dev/null +++ b/PowerForge/Services/AppStoreConnectScreenshotInventory.cs @@ -0,0 +1,32 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace PowerForge; + +/// Computes the stable ordered identity of remote App Store screenshot sets. +internal static class AppStoreConnectScreenshotInventory +{ + internal static string ComputeSha256( + IEnumerable screenshotSets) + { + var canonical = screenshotSets + .OrderBy(static set => set.ScreenshotDisplayType, StringComparer.Ordinal) + .Select(static set => new + { + set.ScreenshotDisplayType, + set.ScreenshotSetId, + Screenshots = (set.Screenshots ?? Array.Empty()).Select(static screenshot => new + { + screenshot.Id, + screenshot.FileName, + screenshot.FileSize, + screenshot.SourceFileChecksum, + screenshot.AssetDeliveryState + }).ToArray() + }) + .ToArray(); + var payload = JsonSerializer.SerializeToUtf8Bytes(canonical); + using var sha256 = SHA256.Create(); + return BitConverter.ToString(sha256.ComputeHash(payload)).Replace("-", string.Empty); + } +} diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs index 326d6abeb..77bf0320c 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs @@ -42,8 +42,17 @@ public AppStoreConnectScreenshotSyncValidationResult Validate( foreach (var set in spec.ScreenshotSets) setResults.Add(ValidateSet(set, spec.Quality ?? new AppStoreConnectScreenshotQualitySpec(), baseDirectory)); + var approvedFileSha256 = new Dictionary( + FrameworkCompatibility.GetPathStringComparisonForPath(baseDirectory) == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal); messages.AddRange(FindDuplicateDisplayTypes(spec.ScreenshotSets)); - messages.AddRange(ValidateApprovalManifest(spec, baseDirectory, setResults, expectedSourceCommit)); + messages.AddRange(ValidateApprovalManifest( + spec, + baseDirectory, + setResults, + expectedSourceCommit, + approvedFileSha256)); var isValid = messages.Count == 0 && setResults.All(static set => set.IsValid); return new AppStoreConnectScreenshotSyncValidationResult @@ -51,7 +60,10 @@ public AppStoreConnectScreenshotSyncValidationResult Validate( ConfigPath = configPath ?? string.Empty, IsValid = isValid, Messages = messages.ToArray(), - ScreenshotSets = setResults.ToArray() + ScreenshotSets = setResults.ToArray(), + ApprovedFileSha256 = isValid && approvedFileSha256.Count > 0 + ? approvedFileSha256 + : null }; } @@ -59,7 +71,8 @@ private static string[] ValidateApprovalManifest( AppStoreConnectScreenshotSyncSpec spec, string baseDirectory, IReadOnlyCollection sets, - string? expectedSourceCommit) + string? expectedSourceCommit, + IDictionary approvedFileSha256) { var quality = spec.Quality ?? new AppStoreConnectScreenshotQualitySpec(); if (!quality.RequireApprovalManifest) @@ -106,17 +119,14 @@ private static string[] ValidateApprovalManifest( messages.Add("Screenshot approval manifest ApprovedAt is required."); if (string.IsNullOrWhiteSpace(manifest.ApprovedBy)) messages.Add("Screenshot approval manifest ApprovedBy is required."); - if (string.IsNullOrWhiteSpace(manifest.SourceCommit) || - manifest.SourceCommit.Trim().Length != 40 || - !manifest.SourceCommit.Trim().All(Uri.IsHexDigit)) + if (!GitObjectId.IsFull(manifest.SourceCommit?.Trim())) { - messages.Add("Screenshot approval manifest SourceCommit must be an exact 40-character Git commit SHA."); + messages.Add("Screenshot approval manifest SourceCommit must be a full SHA-1 or SHA-256 Git commit object id."); } var normalizedExpectedSourceCommit = expectedSourceCommit?.Trim() ?? string.Empty; - if (normalizedExpectedSourceCommit.Length != 40 || - !normalizedExpectedSourceCommit.All(Uri.IsHexDigit)) + if (!GitObjectId.IsFull(normalizedExpectedSourceCommit)) { - messages.Add("ExpectedSourceCommit must be an exact 40-character Git commit SHA when an approval manifest is required."); + messages.Add("ExpectedSourceCommit must be a full SHA-1 or SHA-256 Git commit object id when an approval manifest is required."); } else if (!string.Equals(manifest.SourceCommit, normalizedExpectedSourceCommit, StringComparison.OrdinalIgnoreCase)) { @@ -136,23 +146,54 @@ private static string[] ValidateApprovalManifest( messages.Add($"Screenshot approval manifest locale '{manifest.Locale}' does not match config locale '{spec.Locale}'."); var entries = manifest.Screenshots ?? Array.Empty(); + var resolvedEntries = new List<(AppStoreConnectScreenshotApprovalEntry Entry, string Path)>(); + foreach (var entry in entries) + { + if (string.IsNullOrWhiteSpace(entry.ScreenshotDisplayType) || string.IsNullOrWhiteSpace(entry.File)) + { + messages.Add("Screenshot approval manifest entries require ScreenshotDisplayType and File."); + continue; + } + + try + { + resolvedEntries.Add((entry, ResolvePath(baseDirectory, entry.File))); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException) + { + messages.Add($"Screenshot approval manifest path '{entry.File}' is invalid: {exception.Message}"); + } + } + foreach (var set in sets) { foreach (var file in set.Files) { - var entry = entries.FirstOrDefault(candidate => - string.Equals(candidate.ScreenshotDisplayType, set.ScreenshotDisplayType, StringComparison.OrdinalIgnoreCase) && - (string.Equals(Path.GetFileName(candidate.File), Path.GetFileName(file), StringComparison.OrdinalIgnoreCase) || - string.Equals(ResolvePath(baseDirectory, candidate.File), Path.GetFullPath(file), StringComparison.OrdinalIgnoreCase))); - if (entry is null) + var matches = resolvedEntries.Where(candidate => + string.Equals( + candidate.Entry.ScreenshotDisplayType, + set.ScreenshotDisplayType, + StringComparison.OrdinalIgnoreCase) && + PathsEqual(candidate.Path, Path.GetFullPath(file))) + .ToArray(); + if (matches.Length == 0) { messages.Add($"Screenshot '{Path.GetFileName(file)}' in '{set.ScreenshotDisplayType}' is not present in the approval manifest."); continue; } + if (matches.Length > 1) + { + messages.Add( + $"Screenshot '{Path.GetFileName(file)}' in '{set.ScreenshotDisplayType}' appears more than once in the approval manifest."); + continue; + } + var entry = matches[0].Entry; var sha256 = ComputeSha256(file); if (!string.Equals(entry.Sha256, sha256, StringComparison.OrdinalIgnoreCase)) messages.Add($"Screenshot '{Path.GetFileName(file)}' changed after approval (SHA-256 mismatch)."); + else + approvedFileSha256[Path.GetFullPath(file)] = entry.Sha256.Trim(); if (TryReadPngDimensions(file, out var width, out var height) && (entry.Width != width || entry.Height != height)) { @@ -162,6 +203,21 @@ private static string[] ValidateApprovalManifest( } } + foreach (var approved in resolvedEntries) + { + var selected = sets.Any(set => + string.Equals( + set.ScreenshotDisplayType, + approved.Entry.ScreenshotDisplayType, + StringComparison.OrdinalIgnoreCase) && + set.Files.Any(file => PathsEqual(Path.GetFullPath(file), approved.Path))); + if (!selected) + { + messages.Add( + $"Approved screenshot '{approved.Entry.File}' in '{approved.Entry.ScreenshotDisplayType}' is not selected by the current screenshot configuration."); + } + } + return messages.ToArray(); } @@ -195,10 +251,7 @@ private static AppStoreConnectScreenshotSetSyncValidationResult ValidateSet( } else { - files = Directory.GetFiles(folder, filter) - .OrderBy(static file => file, StringComparer.OrdinalIgnoreCase) - .Take(maxCount) - .ToArray(); + files = AppStoreConnectScreenshotFileSelector.Select(folder, filter, maxCount); if (files.Length == 0) messages.Add($"No screenshots matched '{filter}' in '{folder}'."); else if (quality.Enabled) @@ -328,4 +381,15 @@ private static string ResolvePath(string baseDirectory, string path) => System.IO.Path.IsPathRooted(path) ? System.IO.Path.GetFullPath(path) : System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDirectory, path)); + + private static bool PathsEqual(string left, string right) + { + var fullLeft = Path.GetFullPath(left); + var fullRight = Path.GetFullPath(right); + var comparison = FrameworkCompatibility.GetPathStringComparisonForPath(fullLeft) == StringComparison.OrdinalIgnoreCase || + FrameworkCompatibility.GetPathStringComparisonForPath(fullRight) == StringComparison.OrdinalIgnoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals(fullLeft, fullRight, comparison); + } } diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.SnapshotIntegrity.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.SnapshotIntegrity.cs new file mode 100644 index 000000000..ce784fa9e --- /dev/null +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.SnapshotIntegrity.cs @@ -0,0 +1,102 @@ +namespace PowerForge; + +public sealed partial class AppStoreConnectScreenshotSyncService +{ + private static IReadOnlyDictionary? MergeExpectedFileSha256( + string baseDirectory, + IReadOnlyDictionary? releaseExpected, + IReadOnlyDictionary? manifestExpected) + { + if (releaseExpected is null || releaseExpected.Count == 0) + return manifestExpected; + if (manifestExpected is null || manifestExpected.Count == 0) + return releaseExpected; + + var comparer = FrameworkCompatibility.GetPathStringComparisonForPath(baseDirectory) == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var merged = new Dictionary(comparer); + foreach (var pair in releaseExpected.Concat(manifestExpected)) + { + var path = Path.GetFullPath(pair.Key); + if (merged.TryGetValue(path, out var existing) && + !existing.Equals(pair.Value, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Screenshot '{path}' has conflicting release-plan and approval-manifest SHA-256 evidence."); + } + merged[path] = pair.Value; + } + return merged; + } + + private static ScreenshotSnapshotIdentity CaptureScreenshotSnapshotIdentity( + string root, + IEnumerable sets) + { + var canonical = new System.Text.StringBuilder(); + var files = sets.SelectMany(static set => set.Files).OrderBy(static path => path, StringComparer.Ordinal).ToArray(); + var hardLinkCounts = ExistingFilePathIdentityResolver.ResolveHardLinkCounts(files); + var evidence = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < files.Length; index++) + { + var file = files[index]; + var relativePath = FrameworkCompatibility.GetRelativePath(root, file).Replace('\\', '/'); + var sha256 = ComputeSha256(file); + var md5 = ComputeSourceChecksum(file); + if (hardLinkCounts[index] != 1) + { + throw new InvalidOperationException( + $"The private approved screenshot snapshot file '{relativePath}' has {hardLinkCounts[index]} hard links. " + + "Approved screenshot snapshots require one private pathname per regular file."); + } + var mutationIdentity = ExistingFilePathIdentityResolver.ResolveStatus(file).MutationIdentity; + evidence[file] = new ScreenshotFileIdentity(sha256, md5, mutationIdentity); + canonical.Append(relativePath.Length).Append(':').Append(relativePath); + canonical.Append(sha256.Length).Append(':').Append(sha256); + canonical.Append(md5.Length).Append(':').Append(md5); + canonical.Append(mutationIdentity.Length).Append(':').Append(mutationIdentity); + } + using var hash = System.Security.Cryptography.SHA256.Create(); + var digest = BitConverter.ToString(hash.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical.ToString()))) + .Replace("-", string.Empty) + .ToLowerInvariant(); + return new ScreenshotSnapshotIdentity(digest, evidence); + } + + private sealed class ScreenshotSnapshotIdentity : IEquatable + { + internal ScreenshotSnapshotIdentity(string digest, IReadOnlyDictionary files) + { + Digest = digest; + Files = files; + } + + internal string Digest { get; } + + internal IReadOnlyDictionary Files { get; } + + public bool Equals(ScreenshotSnapshotIdentity? other) + => other is not null && Digest.Equals(other.Digest, StringComparison.Ordinal); + + public override bool Equals(object? obj) => Equals(obj as ScreenshotSnapshotIdentity); + + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Digest); + } + + private sealed class ScreenshotFileIdentity + { + internal ScreenshotFileIdentity(string sha256, string md5, string mutationIdentity) + { + Sha256 = sha256; + Md5 = md5; + MutationIdentity = mutationIdentity; + } + + internal string Sha256 { get; } + + internal string Md5 { get; } + + internal string MutationIdentity { get; } + } +} diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index 43c69e3be..c910b3121 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -3,7 +3,7 @@ namespace PowerForge; /// /// Syncs local screenshot folders to App Store Connect screenshot sets. /// -public sealed class AppStoreConnectScreenshotSyncService +public sealed partial class AppStoreConnectScreenshotSyncService { private const int AppleScreenshotSetLimit = 10; @@ -18,6 +18,63 @@ public AppStoreConnectScreenshotSyncService(AppStoreConnectClient client) _client = client ?? throw new ArgumentNullException(nameof(client)); } + /// + /// Re-reads and compares the exact ordered remote screenshot inventory without performing any remote mutation. + /// + internal async Task ValidateExpectedRemoteInventoryAsync( + AppStoreConnectScreenshotSyncSpec spec, + string expectedInventorySha256, + CancellationToken cancellationToken = default) + { + if (spec is null) + throw new ArgumentNullException(nameof(spec)); + if (string.IsNullOrWhiteSpace(expectedInventorySha256)) + throw new ArgumentException("Expected screenshot inventory SHA-256 is required.", nameof(expectedInventorySha256)); + + var version = !string.IsNullOrWhiteSpace(spec.VersionId) + ? new AppStoreConnectVersionInfo + { + Id = spec.VersionId!.Trim(), + VersionString = spec.VersionString, + Platform = spec.Platform.ToString() + } + : (await _client.GetVersionsAsync( + spec.AppId, + spec.VersionString, + spec.Platform, + limit: 10, + cancellationToken).ConfigureAwait(false)).FirstOrDefault() + ?? throw new InvalidOperationException($"App Store version '{spec.VersionString}' was not found before screenshot inventory validation."); + var localization = (await _client.GetVersionLocalizationsAsync( + version.Id, + spec.Locale, + limit: 10, + cancellationToken).ConfigureAwait(false)).FirstOrDefault() + ?? throw new InvalidOperationException($"Localization '{spec.Locale}' was not found before screenshot inventory validation."); + var existingSets = await _client.GetScreenshotSetsAsync( + localization.Id, + limit: 200, + cancellationToken).ConfigureAwait(false); + var remoteInventory = new List(); + foreach (var sourceSet in spec.ScreenshotSets) + { + var displayType = sourceSet.ScreenshotDisplayType.Trim(); + var set = existingSets.FirstOrDefault(candidate => + string.Equals(candidate.ScreenshotDisplayType, displayType, StringComparison.OrdinalIgnoreCase)); + var screenshots = set is null + ? Array.Empty() + : await _client.GetScreenshotsAsync(set.Id, limit: 200, cancellationToken).ConfigureAwait(false); + remoteInventory.Add(CreateRemoteInventorySet(displayType, set?.Id, screenshots)); + } + + var actualInventorySha256 = AppStoreConnectScreenshotInventory.ComputeSha256(remoteInventory); + if (!actualInventorySha256.Equals(expectedInventorySha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "App Store Connect screenshots changed after Apple plan approval. Review a new exact screenshot replacement plan before any remote release mutation."); + } + } + /// /// Syncs screenshots from local folders to App Store Connect. /// @@ -27,6 +84,12 @@ public AppStoreConnectScreenshotSyncService(AppStoreConnectClient client) public async Task SyncAsync( AppStoreConnectScreenshotSyncRequest request, CancellationToken cancellationToken = default) + { + using var screenshotSnapshot = CreateSnapshot(request); + return await SyncAsync(request, screenshotSnapshot, cancellationToken).ConfigureAwait(false); + } + + internal ScreenshotSnapshot CreateSnapshot(AppStoreConnectScreenshotSyncRequest request) { if (request is null) throw new ArgumentNullException(nameof(request)); @@ -64,9 +127,45 @@ public async Task SyncAsync( $"Screenshot preflight failed: {string.Join(" ", messages)}"); } - var preflightedSets = spec.ScreenshotSets + return CreateSnapshot(request, validation); + } + + internal ScreenshotSnapshot CreateSnapshot( + AppStoreConnectScreenshotSyncRequest request, + AppStoreConnectScreenshotSyncValidationResult validation) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (validation is null) + throw new ArgumentNullException(nameof(validation)); + if (!validation.IsValid) + throw new InvalidOperationException("A valid screenshot preflight is required before immutable snapshot creation."); + + var spec = request.Spec ?? throw new ArgumentException("Spec is required.", nameof(request)); + var sourceSets = spec.ScreenshotSets .Select(setSpec => PreflightScreenshotSet(request.BaseDirectory, setSpec)) .ToArray(); + return CreateScreenshotSnapshot( + sourceSets, + MergeExpectedFileSha256( + request.BaseDirectory, + request.ExpectedFileSha256, + validation.ApprovedFileSha256)); + } + + internal async Task SyncAsync( + AppStoreConnectScreenshotSyncRequest request, + ScreenshotSnapshot screenshotSnapshot, + CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (screenshotSnapshot is null) + throw new ArgumentNullException(nameof(screenshotSnapshot)); + + var spec = request.Spec ?? throw new ArgumentException("Spec is required.", nameof(request)); + screenshotSnapshot.ValidateUnchanged(); + var preflightedSets = screenshotSnapshot.Sets; var version = !string.IsNullOrWhiteSpace(spec.VersionId) ? new AppStoreConnectVersionInfo @@ -104,7 +203,7 @@ public async Task SyncAsync( if (set is not null) existingScreenshots = await _client.GetScreenshotsAsync(set.Id, limit: 200, cancellationToken).ConfigureAwait(false); - var missingFiles = FindMissingFiles(preflightedSet.Files, existingScreenshots); + var missingFiles = FindMissingFiles(preflightedSet.Files, existingScreenshots, screenshotSnapshot); if (!request.ReplaceExisting && existingScreenshots.Length + missingFiles.Length > AppleScreenshotSetLimit) { throw new InvalidOperationException( @@ -115,6 +214,20 @@ public async Task SyncAsync( plannedSets.Add(new PlannedScreenshotSet(preflightedSet, set, existingScreenshots)); } + if (request.ReplaceExisting && !string.IsNullOrWhiteSpace(request.ExpectedRemoteInventorySha256)) + { + var remoteInventory = plannedSets.Select(static plannedSet => CreateRemoteInventorySet( + plannedSet.Preflighted.ScreenshotDisplayType, + plannedSet.ScreenshotSet?.Id, + plannedSet.ExistingScreenshots)); + var actualInventorySha256 = AppStoreConnectScreenshotInventory.ComputeSha256(remoteInventory); + if (!actualInventorySha256.Equals(request.ExpectedRemoteInventorySha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "App Store Connect screenshots changed after Apple plan approval. Review a new exact screenshot replacement plan before deleting remote assets."); + } + } + var results = new List(); foreach (var plannedSet in plannedSets) { @@ -122,39 +235,39 @@ public async Task SyncAsync( var set = plannedSet.ScreenshotSet; if (set is null) { + screenshotSnapshot.ValidateUnchanged(); set = await _client.CreateScreenshotSetAsync(localization.Id, displayType, cancellationToken).ConfigureAwait(false); existingSets = existingSets.Concat(new[] { set }).ToArray(); } var deletedCount = 0; - var filesToUpload = FindMissingFiles(plannedSet.Preflighted.Files, plannedSet.ExistingScreenshots); + var filesToUpload = FindMissingFiles(plannedSet.Preflighted.Files, plannedSet.ExistingScreenshots, screenshotSnapshot); if (request.ReplaceExisting) { - var retainedIds = new HashSet(StringComparer.Ordinal); - var prefixLength = 0; - while (prefixLength < plannedSet.Preflighted.Files.Length && - prefixLength < plannedSet.ExistingScreenshots.Length) - { - var expected = ComputeSourceChecksum(plannedSet.Preflighted.Files[prefixLength]); - var existing = plannedSet.ExistingScreenshots[prefixLength]; - if (!string.Equals(expected, existing.SourceFileChecksum, StringComparison.OrdinalIgnoreCase)) - break; - retainedIds.Add(existing.Id); - prefixLength++; - } - - foreach (var screenshot in plannedSet.ExistingScreenshots.Where(screenshot => !retainedIds.Contains(screenshot.Id))) + // MD5 is an App Store Connect transport field, not approval evidence. A destructive + // replacement must create fresh asset identities for every approved immutable byte set. + foreach (var screenshot in plannedSet.ExistingScreenshots) { + screenshotSnapshot.ValidateUnchanged(); await _client.DeleteScreenshotAsync(screenshot.Id, cancellationToken).ConfigureAwait(false); deletedCount++; } - filesToUpload = plannedSet.Preflighted.Files.Skip(prefixLength).ToArray(); + filesToUpload = plannedSet.Preflighted.Files; } var uploaded = new List(); foreach (var file in filesToUpload) - uploaded.Add(await _client.UploadScreenshotAsync(set.Id, file, cancellationToken).ConfigureAwait(false)); + { + screenshotSnapshot.ValidateUnchanged(); + var upload = await _client.UploadScreenshotAsync( + set.Id, + file, + screenshotSnapshot.GetSha256(file), + cancellationToken).ConfigureAwait(false); + upload.FilePath = screenshotSnapshot.GetSourcePath(file); + uploaded.Add(upload); + } results.Add(new AppStoreConnectScreenshotSetSyncResult { @@ -164,6 +277,35 @@ public async Task SyncAsync( DeletedCount = deletedCount, Uploaded = uploaded.ToArray() }); + + if (request.ReplaceExisting) + { + var finalScreenshots = await _client.GetScreenshotsAsync( + set.Id, + limit: 200, + cancellationToken).ConfigureAwait(false); + var expectedChecksums = plannedSet.Preflighted.Files + .Select(screenshotSnapshot.GetMd5) + .ToArray(); + var finalChecksums = finalScreenshots + .Select(static screenshot => screenshot.SourceFileChecksum?.Trim() ?? string.Empty) + .ToArray(); + var expectedIds = uploaded + .Select(static upload => upload.Screenshot.Id) + .ToArray(); + var finalIds = finalScreenshots + .Select(static screenshot => screenshot.Id) + .ToArray(); + if (finalIds.Length != expectedIds.Length || + !finalIds.SequenceEqual(expectedIds, StringComparer.Ordinal) || + finalChecksums.Length != expectedChecksums.Length || + !finalChecksums.SequenceEqual(expectedChecksums, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"App Store Connect screenshot inventory for '{displayType}' changed during replacement. " + + "The final remote inventory does not exactly match the approved screenshot bytes; review and run a new plan before submission."); + } + } } return new AppStoreConnectScreenshotSyncResult @@ -174,6 +316,26 @@ public async Task SyncAsync( }; } + private static AppStoreConnectReleaseScreenshotSetReadiness CreateRemoteInventorySet( + string displayType, + string? screenshotSetId, + AppStoreConnectScreenshotInfo[] screenshots) + => new() + { + ScreenshotDisplayType = displayType, + ScreenshotSetId = screenshotSetId, + Count = screenshots.Length, + Screenshots = screenshots.Select(static screenshot => + new AppStoreConnectReleaseScreenshotAssetReadiness + { + Id = screenshot.Id, + FileName = screenshot.FileName, + FileSize = screenshot.FileSize, + SourceFileChecksum = screenshot.SourceFileChecksum, + AssetDeliveryState = screenshot.AssetDeliveryState + }).ToArray() + }; + private static string ComputeSourceChecksum(string filePath) { using var stream = File.OpenRead(filePath); @@ -181,10 +343,168 @@ private static string ComputeSourceChecksum(string filePath) return BitConverter.ToString(md5.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); } + private static ScreenshotSnapshot CreateScreenshotSnapshot( + IReadOnlyCollection sourceSets, + IReadOnlyDictionary? expectedFileSha256) + { + var comparer = sourceSets.Any(set => + FrameworkCompatibility.GetPathStringComparisonForPath(set.Folder) == StringComparison.OrdinalIgnoreCase) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var expected = expectedFileSha256 is null + ? null + : expectedFileSha256.ToDictionary( + static value => value.Key, + static value => value.Value, + comparer); + var root = Path.Combine(Path.GetTempPath(), "PowerForge", "appstore-screenshot-snapshot", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + try + { + var approvedScreenshots = expected is null + ? null + : SelectApprovedScreenshotFiles(sourceSets, expected, comparer); + var mappings = new Dictionary(comparer); + var approvedSnapshotSha256 = new Dictionary(comparer); + var consumedApprovedFiles = new HashSet(comparer); + var sets = sourceSets.Select((set, setIndex) => + { + var setRoot = Path.Combine(root, setIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Directory.CreateDirectory(setRoot); + var files = set.Files.Select(sourcePath => + { + var source = Path.GetFullPath(sourcePath); + string? expectedSha256 = null; + if (approvedScreenshots is not null && + !approvedScreenshots.TryGetValue(source, out expectedSha256)) + { + throw new InvalidOperationException( + $"Screenshot '{source}' was not part of the approved Apple release plan. Review a new exact plan before upload."); + } + consumedApprovedFiles.Add(source); + + var relativePath = AppStoreConnectScreenshotFileSelector.GetRelativePath(set.Folder, source) + ?? throw new InvalidOperationException( + $"Screenshot '{source}' escapes its configured screenshot set folder '{set.Folder}'."); + var snapshotPath = Path.GetFullPath(Path.Combine( + setRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + var snapshotDirectory = Path.GetDirectoryName(snapshotPath) + ?? throw new InvalidOperationException($"Screenshot snapshot path has no parent directory: {snapshotPath}"); + Directory.CreateDirectory(snapshotDirectory); + File.Copy(source, snapshotPath, overwrite: false); + mappings[snapshotPath] = source; + if (expectedSha256 is not null) + approvedSnapshotSha256[snapshotPath] = expectedSha256; + return snapshotPath; + }).ToArray(); + return new PreflightedScreenshotSet( + set.ScreenshotDisplayType, + set.Folder, + set.Filter, + set.MaxCount, + files); + }).ToArray(); + if (approvedScreenshots is not null) + { + var missing = approvedScreenshots.Keys + .Where(path => !consumedApprovedFiles.Contains(Path.GetFullPath(path))) + .OrderBy(static path => path, comparer) + .ToArray(); + if (missing.Length > 0) + { + throw new InvalidOperationException( + "Approved screenshots disappeared before the immutable upload snapshot was created: " + + string.Join(", ", missing)); + } + } + var mutationMonitor = new AppleReleaseSourceMutationMonitor( + root, + "private approved screenshot snapshot", + "screenshot selection and upload", + "Discard the screenshot operation and recapture the approved screenshot set.", + enableImmediately: false); + try + { + var identity = mutationMonitor.CaptureExpectedProducerOutput( + () => CaptureScreenshotSnapshotIdentity(root, sets), + "immutable screenshot snapshot creation"); + foreach (var approved in approvedSnapshotSha256) + { + var actualSha256 = identity.Files[approved.Key].Sha256; + if (!actualSha256.Equals(approved.Value, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Screenshot '{mappings[approved.Key]}' changed after Apple plan approval. Review the exact replacement bytes before upload."); + } + } + var sha256 = identity.Files.ToDictionary( + static pair => pair.Key, + static pair => pair.Value.Sha256, + comparer); + var md5 = identity.Files.ToDictionary( + static pair => pair.Key, + static pair => pair.Value.Md5, + comparer); + return new ScreenshotSnapshot(root, sets, mappings, sha256, md5, identity.Digest, mutationMonitor); + } + catch + { + mutationMonitor.Dispose(); + throw; + } + } + catch + { + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } + throw; + } + } + + private static Dictionary SelectApprovedScreenshotFiles( + IReadOnlyCollection sourceSets, + IReadOnlyDictionary expected, + StringComparer pathComparer) + { + var selected = new Dictionary(pathComparer); + foreach (var set in sourceSets) + { + var approved = expected + .Select(item => new + { + Item = item, + RelativePath = AppStoreConnectScreenshotFileSelector.GetRelativePath(set.Folder, item.Key) + }) + .Where(static item => item.RelativePath is not null) + .Where(item => AppStoreConnectScreenshotFileSelector.MatchesFilter( + item.RelativePath!, + set.Filter, + FrameworkCompatibility.GetPathStringComparisonForPath(set.Folder))) + .OrderBy(static item => item.Item.Key, StringComparer.OrdinalIgnoreCase) + .Take(set.MaxCount); + foreach (var item in approved) + selected[Path.GetFullPath(item.Item.Key)] = item.Item.Value; + } + return selected; + } + + private static string ComputeSha256(string filePath) + { + using var stream = File.OpenRead(filePath); + using var sha256 = System.Security.Cryptography.SHA256.Create(); + return BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); + } + private static string[] FindMissingFiles( IEnumerable files, - IEnumerable existingScreenshots) + IEnumerable existingScreenshots, + ScreenshotSnapshot screenshotSnapshot) { + screenshotSnapshot.ValidateUnchanged(); var available = existingScreenshots .Where(static screenshot => !string.IsNullOrWhiteSpace(screenshot.SourceFileChecksum)) .GroupBy(static screenshot => screenshot.SourceFileChecksum!, StringComparer.OrdinalIgnoreCase) @@ -195,7 +515,7 @@ private static string[] FindMissingFiles( var missing = new List(); foreach (var file in files) { - var checksum = ComputeSourceChecksum(file); + var checksum = screenshotSnapshot.GetMd5(file); if (available.TryGetValue(checksum, out var count) && count > 0) available[checksum] = count - 1; else @@ -226,23 +546,32 @@ private static PreflightedScreenshotSet PreflightScreenshotSet(string baseDirect throw new DirectoryNotFoundException($"Screenshot folder was not found: {folder}"); var filter = string.IsNullOrWhiteSpace(setSpec.Filter) ? "*.png" : setSpec.Filter; - var files = Directory.GetFiles(folder, filter) - .OrderBy(static file => file, StringComparer.OrdinalIgnoreCase) - .Take(maxCount) - .ToArray(); + var files = AppStoreConnectScreenshotFileSelector.Select(folder, filter, maxCount); if (files.Length == 0) throw new InvalidOperationException($"No screenshots matched '{filter}' in '{folder}'."); - return new PreflightedScreenshotSet(setSpec.ScreenshotDisplayType.Trim(), folder, files); + return new PreflightedScreenshotSet( + setSpec.ScreenshotDisplayType.Trim(), + folder, + filter, + maxCount, + files); } - private sealed class PreflightedScreenshotSet + internal sealed class PreflightedScreenshotSet { - public PreflightedScreenshotSet(string screenshotDisplayType, string folder, string[] files) + public PreflightedScreenshotSet( + string screenshotDisplayType, + string folder, + string filter, + int maxCount, + string[] files) { ScreenshotDisplayType = screenshotDisplayType; Folder = folder; + Filter = filter; + MaxCount = maxCount; Files = files; } @@ -250,6 +579,10 @@ public PreflightedScreenshotSet(string screenshotDisplayType, string folder, str public string Folder { get; } + public string Filter { get; } + + public int MaxCount { get; } + public string[] Files { get; } } @@ -271,4 +604,63 @@ public PlannedScreenshotSet( public AppStoreConnectScreenshotInfo[] ExistingScreenshots { get; } } + + internal sealed class ScreenshotSnapshot : IDisposable + { + private readonly string _root; + private readonly IReadOnlyDictionary _sourcePaths; + private readonly IReadOnlyDictionary _sha256; + private readonly IReadOnlyDictionary _md5; + private readonly string _identity; + private readonly AppleReleaseSourceMutationMonitor _mutationMonitor; + private bool _disposed; + + public ScreenshotSnapshot( + string root, + PreflightedScreenshotSet[] sets, + IReadOnlyDictionary sourcePaths, + IReadOnlyDictionary sha256, + IReadOnlyDictionary md5, + string identity, + AppleReleaseSourceMutationMonitor mutationMonitor) + { + _root = root; + Sets = sets; + _sourcePaths = sourcePaths; + _sha256 = sha256; + _md5 = md5; + _identity = identity; + _mutationMonitor = mutationMonitor; + } + + public PreflightedScreenshotSet[] Sets { get; } + + public string GetSourcePath(string snapshotPath) => _sourcePaths[snapshotPath]; + + public string GetSha256(string snapshotPath) => _sha256[snapshotPath]; + + public string GetMd5(string snapshotPath) => _md5[snapshotPath]; + + internal void ValidateUnchanged() + { + var currentIdentity = _mutationMonitor.CaptureExpectedProducerOutput( + () => CaptureScreenshotSnapshotIdentity(_root, Sets), + "screenshot selection or upload"); + if (!string.Equals(_identity, currentIdentity.Digest, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The private approved screenshot snapshot changed during screenshot selection or upload. " + + "A transient write or hard-link alias invalidates the approved screenshot set."); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _mutationMonitor.Dispose(); + try { AppleArtifactCopy.DeleteOwnedDirectory(_root); } catch { /* best effort after remote operation */ } + } + } } diff --git a/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs b/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs new file mode 100644 index 000000000..81d2fc46c --- /dev/null +++ b/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; + +namespace PowerForge; + +/// +/// Captures screenshot bytes into a user-only private file and exposes bounded range content for App Store upload operations. +/// +internal sealed class AppStoreConnectScreenshotUploadSnapshot : IDisposable +{ + private bool _disposed; + private readonly string _mutationIdentity; + + private AppStoreConnectScreenshotUploadSnapshot( + string rootPath, + string filePath, + long length, + string sha256, + string md5, + string mutationIdentity) + { + RootPath = rootPath; + FilePath = filePath; + Length = length; + Sha256 = sha256; + Md5 = md5; + _mutationIdentity = mutationIdentity; + } + + internal string RootPath { get; } + + internal string FilePath { get; } + + internal long Length { get; } + + internal string Sha256 { get; } + + internal string Md5 { get; } + + internal static AppStoreConnectScreenshotUploadSnapshot Capture(string sourcePath, string? expectedSha256) + { + var source = Path.GetFullPath(sourcePath); + if (!File.Exists(source)) + throw new FileNotFoundException("Screenshot file was not found.", source); + + var root = Path.Combine(Path.GetTempPath(), "PowerForge", "appstore-screenshot-upload", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + var snapshotPath = Path.Combine(root, "screenshot-bytes"); + try + { + using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.SequentialScan)) + using (var output = new FileStream(snapshotPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.SequentialScan)) + input.CopyTo(output, 81920); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(snapshotPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); +#endif + + var sha256 = ComputeHash(snapshotPath, SHA256.Create); + if (!string.IsNullOrWhiteSpace(expectedSha256) && + !sha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Screenshot '{source}' changed after its immutable upload snapshot was captured."); + } + + var md5 = ComputeHash(snapshotPath, MD5.Create); + return new AppStoreConnectScreenshotUploadSnapshot( + root, + snapshotPath, + new FileInfo(snapshotPath).Length, + sha256, + md5, + ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( + snapshotPath, + "private screenshot upload snapshot")); + } + catch + { + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } + throw; + } + } + + internal HttpContent CreateRangeContent(long offset, long length) + { + if (offset < 0 || length < 0 || offset > Length || length > Length - offset) + throw new EndOfStreamException("Captured screenshot bytes ended before the upload operation range."); + return new RangedFileContent(FilePath, offset, length); + } + + internal void ValidateUnchanged() + { + var currentSha256 = ComputeHash(FilePath, SHA256.Create); + if (!currentSha256.Equals(Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "The private screenshot upload snapshot changed while App Store Connect was reading it; discard the upload result and retry from approved bytes."); + } + var currentMutationIdentity = ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( + FilePath, + "private screenshot upload snapshot"); + if (!string.Equals(_mutationIdentity, currentMutationIdentity, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The private screenshot upload snapshot file identity changed while App Store Connect was reading it. " + + "A transient write or hard-link alias invalidates the approved screenshot bytes."); + } + } + + private static string ComputeHash(string filePath, Func createHash) + { + using var stream = File.OpenRead(filePath); + using var hash = createHash(); + return BitConverter.ToString(hash.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after remote operation */ } + } + + private sealed class RangedFileContent : HttpContent + { + private readonly string _filePath; + private readonly long _offset; + private readonly long _length; + + internal RangedFileContent(string filePath, long offset, long length) + { + _filePath = filePath; + _offset = offset; + _length = length; + Headers.ContentLength = length; + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + using var input = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + input.Seek(_offset, SeekOrigin.Begin); + var buffer = new byte[81920]; + var remaining = _length; + while (remaining > 0) + { + var read = await input.ReadAsync(buffer, 0, (int)Math.Min(buffer.Length, remaining)).ConfigureAwait(false); + if (read == 0) + throw new EndOfStreamException("Captured screenshot bytes ended during the upload operation range."); + await stream.WriteAsync(buffer, 0, read).ConfigureAwait(false); + remaining -= read; + } + } + + protected override bool TryComputeLength(out long length) + { + length = _length; + return true; + } + } +} diff --git a/PowerForge/Services/AppleAppArchiveService.Privacy.cs b/PowerForge/Services/AppleAppArchiveService.Privacy.cs index 6df0d0c32..f00e3a351 100644 --- a/PowerForge/Services/AppleAppArchiveService.Privacy.cs +++ b/PowerForge/Services/AppleAppArchiveService.Privacy.cs @@ -49,7 +49,11 @@ private async Task ValidatePrivacyUsageDescriptionsAsync( break; } - var archivedBundleId = await ReadPlistStringAsync(infoPlist, "CFBundleIdentifier", cancellationToken).ConfigureAwait(false); + var archivedBundleId = await ReadPlistStringAsync( + infoPlist, + "CFBundleIdentifier", + request.RequireTrustedSystemTools, + cancellationToken).ConfigureAwait(false); if (string.Equals(archivedBundleId, expectedBundleId, StringComparison.Ordinal)) { selectedInfoPlist = infoPlist; @@ -68,7 +72,11 @@ private async Task ValidatePrivacyUsageDescriptionsAsync( foreach (var key in requiredKeys) { - var value = await ReadPlistStringAsync(selectedInfoPlist, key, cancellationToken).ConfigureAwait(false); + var value = await ReadPlistStringAsync( + selectedInfoPlist, + key, + request.RequireTrustedSystemTools, + cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(value)) { throw new InvalidOperationException( @@ -80,18 +88,26 @@ private async Task ValidatePrivacyUsageDescriptionsAsync( private async Task ReadPlistStringAsync( string infoPlist, string key, + bool requireTrustedSystemTools, CancellationToken cancellationToken) { var executable = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( System.Runtime.InteropServices.OSPlatform.OSX) ? "/usr/bin/plutil" : "plutil"; + var toolEnvironment = requireTrustedSystemTools + ? AppleTrustedExecutionEnvironment.Create() + : null; var result = await _processRunner.RunAsync( new ProcessRunRequest( executable, Path.GetDirectoryName(infoPlist)!, new[] { "-extract", key, "raw", "-o", "-", infoPlist }, - TimeSpan.FromSeconds(30)), + TimeSpan.FromSeconds(30), + toolEnvironment, + captureOutput: true, + captureError: true, + inheritEnvironment: toolEnvironment is null), cancellationToken).ConfigureAwait(false); return result.Succeeded ? result.StdOut.Trim() : null; } diff --git a/PowerForge/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index 450395207..22309940d 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -42,6 +42,13 @@ public async Task CreateArchiveAsync( throw new FileNotFoundException("Xcode project or workspace was not found.", projectPath); var archivePath = ResolveArchivePath(request); + var xcodeBuildExecutable = NormalizeExecutable(request.XcodeBuildExecutable); + if (request.RequireExactPackageSnapshot && + !xcodeBuildExecutable.Equals("/usr/bin/xcodebuild", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Exact-source Apple archives require the system Xcode build tool '/usr/bin/xcodebuild'; received '{xcodeBuildExecutable}'."); + } var destination = string.IsNullOrWhiteSpace(request.Destination) ? GetGenericDestination(request.Platform, request.ArchiveVariant) : request.Destination!.Trim(); @@ -71,20 +78,96 @@ public async Task CreateArchiveAsync( request.AllowProvisioningUpdates, args); args.Add("archive"); - args.AddRange(request.AdditionalArguments ?? Array.Empty()); - - var result = await _processRunner.RunAsync( - new ProcessRunRequest( - NormalizeExecutable(request.XcodeBuildExecutable), - Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), - args, - request.Timeout <= TimeSpan.Zero ? TimeSpan.FromHours(1) : request.Timeout), - cancellationToken).ConfigureAwait(false); + var additionalArguments = request.AdditionalArguments ?? Array.Empty(); + args.AddRange(additionalArguments); + + AppleSwiftPackageBuildSnapshot? packageSnapshot = null; + AppleReleaseSourceMutationMonitor? archiveOutputMonitor = null; + ProcessRunResult result; + string? archiveSha256 = null; + AppleArchiveUploadSnapshot.SnapshotIdentity? archiveIdentity = null; + try + { + var timeout = request.Timeout <= TimeSpan.Zero ? TimeSpan.FromHours(1) : request.Timeout; + if (request.RequireExactPackageSnapshot) + { + AppleSwiftPackageBuildSnapshot.RejectConflictingArguments(additionalArguments); + packageSnapshot = await AppleSwiftPackageBuildSnapshot.CreateAsync( + _processRunner, + xcodeBuildExecutable, + projectPath, + request.IsWorkspace, + request.Scheme.Trim(), + timeout, + cancellationToken) + .ConfigureAwait(false); + packageSnapshot.AppendArchiveArguments(args); + } + + var archiveParent = Path.GetDirectoryName(archivePath) + ?? throw new InvalidOperationException($"Apple archive path has no parent: {archivePath}"); + Directory.CreateDirectory(archiveParent); + archiveOutputMonitor = new AppleReleaseSourceMutationMonitor( + archiveParent, + "private Apple archive output", + "xcodebuild archive", + "Discard the archive and rebuild it from the approved exact source.", + enableImmediately: false, + exactPath: archivePath, + includeExactPathDescendants: true); + + var processRequest = new ProcessRunRequest( + xcodeBuildExecutable, + Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), + args, + timeout, + packageSnapshot?.EnvironmentVariables, + captureOutput: true, + captureError: true, + inheritEnvironment: packageSnapshot is null); + processRequest.SetCompletionBoundary(completionResult => + { + if (completionResult.Succeeded && Directory.Exists(archivePath)) + { + archiveIdentity = archiveOutputMonitor!.CaptureExpectedProducerOutput( + () => AppleArchiveUploadSnapshot.CaptureCompleteIdentity(archivePath), + "xcodebuild archive"); + archiveSha256 = archiveIdentity.Sha256; + } + }); + result = await _processRunner.RunAsync(processRequest, cancellationToken).ConfigureAwait(false); + processRequest.InvokeCompletionBoundary(result); + packageSnapshot?.ValidateUnchanged(); + archiveOutputMonitor.ValidateNoChanges(); + if (result.Succeeded && + (archiveIdentity is null || + string.IsNullOrWhiteSpace(archiveSha256) || + !Directory.Exists(archivePath))) + { + throw new InvalidOperationException( + $"xcodebuild reported a successful archive but no exact private archive output was bound at process completion: {archivePath}"); + } + if (!string.IsNullOrWhiteSpace(archiveSha256) && Directory.Exists(archivePath)) + { + var currentArchiveIdentity = AppleArchiveUploadSnapshot.CaptureCompleteIdentity(archivePath); + if (archiveIdentity is null || !archiveIdentity.Equals(currentArchiveIdentity)) + { + throw new InvalidOperationException( + $"The private Apple archive changed after xcodebuild completed. Expected '{archiveSha256}', received '{currentArchiveIdentity.Sha256}'."); + } + } + } + finally + { + archiveOutputMonitor?.Dispose(); + packageSnapshot?.Dispose(); + } return new AppleAppArchiveResult { ArchivePath = archivePath, Destination = destination, + ArchiveSha256 = archiveSha256, ProcessResult = result }; } @@ -137,14 +220,61 @@ public async Task UploadArchiveAsync( args); args.AddRange(request.AdditionalArguments ?? Array.Empty()); - var result = await _processRunner.RunAsync( - new ProcessRunRequest( - NormalizeExecutable(request.XcodeBuildExecutable), + var xcodeBuildExecutable = NormalizeExecutable(request.XcodeBuildExecutable); + if (request.RequireTrustedSystemTools && + !xcodeBuildExecutable.Equals("/usr/bin/xcodebuild", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Exact-source Apple export and upload require the system Xcode build tool '/usr/bin/xcodebuild'; received '{xcodeBuildExecutable}'."); + } + var toolEnvironment = request.RequireTrustedSystemTools + ? AppleTrustedExecutionEnvironment.Create() + : null; + var directExport = request.Destination.Equals("export", StringComparison.OrdinalIgnoreCase); + using var exportMonitor = directExport + ? new AppleReleaseSourceMutationMonitor( + exportPath, + "private Developer ID export", + "xcodebuild exportArchive", + "Discard the export and run xcodebuild exportArchive again.", + enableImmediately: false) + : null; + string? exportArtifactPath = null; + string? exportArtifactSha256 = null; + var processRequest = new ProcessRunRequest( + xcodeBuildExecutable, Path.GetDirectoryName(archivePath) ?? Directory.GetCurrentDirectory(), args, - request.Timeout <= TimeSpan.Zero ? TimeSpan.FromHours(1) : request.Timeout), - cancellationToken).ConfigureAwait(false); - + request.Timeout <= TimeSpan.Zero ? TimeSpan.FromHours(1) : request.Timeout, + toolEnvironment, + captureOutput: true, + captureError: true, + inheritEnvironment: toolEnvironment is null); + processRequest.SetStartBoundary(request.InvokeRemoteMutationStarted); + if (directExport) + { + processRequest.SetCompletionBoundary(completionResult => + { + if (!completionResult.Succeeded) + return; + exportMonitor!.CaptureExpectedProducerOutput( + () => + { + exportArtifactPath = PowerForgeReleaseService.ResolveDirectAppleArtifactPath(exportPath); + exportArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(exportArtifactPath); + return exportArtifactPath + "\n" + exportArtifactSha256; + }, + "xcodebuild exportArchive"); + }); + } + var result = await _processRunner.RunAsync(processRequest, cancellationToken).ConfigureAwait(false); + processRequest.InvokeCompletionBoundary(result); + if (result.Succeeded && directExport) + { + exportMonitor!.ValidateNoChanges(); + if (string.IsNullOrWhiteSpace(exportArtifactPath) || string.IsNullOrWhiteSpace(exportArtifactSha256)) + throw new InvalidOperationException("xcodebuild completed without binding the exact Developer ID export at its process completion boundary."); + } var diagnostics = ResolveUploadDiagnostics(result); return new AppleAppArchiveUploadResult @@ -154,6 +284,8 @@ public async Task UploadArchiveAsync( ExportOptionsPlistPath = plistPath, DistributionLogPath = diagnostics.DistributionLogPath, BuildUploadId = diagnostics.BuildUploadId, + ExportArtifactPath = exportArtifactPath, + ExportArtifactSha256 = exportArtifactSha256, ProcessResult = result }; } diff --git a/PowerForge/Services/AppleArchiveBuildSnapshot.cs b/PowerForge/Services/AppleArchiveBuildSnapshot.cs new file mode 100644 index 000000000..bc4308c28 --- /dev/null +++ b/PowerForge/Services/AppleArchiveBuildSnapshot.cs @@ -0,0 +1,142 @@ +namespace PowerForge; + +/// Owns a private xcodebuild archive destination and atomically publishes its verified bytes. +internal sealed class AppleArchiveBuildSnapshot : IDisposable { + private bool _disposed; + + private AppleArchiveBuildSnapshot(string rootPath, string archivePath) { + RootPath = rootPath; + ArchivePath = archivePath; + } + + internal string RootPath { get; } + + internal string ArchivePath { get; } + + internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) { + var archiveName = Path.GetFileName(Path.GetFullPath(destinationArchivePath)); + if (string.IsNullOrWhiteSpace(archiveName)) + throw new InvalidOperationException($"Apple archive path has no file name: {destinationArchivePath}"); + + var root = Path.Combine(Path.GetTempPath(), "PowerForge", "apple-archive-builds", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + return new AppleArchiveBuildSnapshot(root, Path.Combine(root, archiveName)); + } + + /// + /// Copies the private archive into a user-only same-volume staging directory, verifies it, and + /// atomically replaces the configured public archive. + /// + internal string Publish(string destinationArchivePath, string? expectedSourceSha256 = null) { + if (!Directory.Exists(ArchivePath)) + throw new DirectoryNotFoundException( + $"The successful Apple archive operation did not produce its private archive output: {ArchivePath}"); + + var sourceSha256 = AppleNotarizationService.ComputeArtifactSha256(ArchivePath); + if (!string.IsNullOrWhiteSpace(expectedSourceSha256) && + !sourceSha256.Equals(expectedSourceSha256, StringComparison.OrdinalIgnoreCase)) { + throw new InvalidOperationException( + $"The private Apple archive changed after xcodebuild completed. Expected '{expectedSourceSha256}', received '{sourceSha256}'."); + } + var destination = Path.GetFullPath(destinationArchivePath); + var parent = Path.GetDirectoryName(destination) + ?? throw new InvalidOperationException($"Apple archive path has no parent: {destination}"); + Directory.CreateDirectory(parent); + var existingDestination = AppleArtifactCopy.CaptureRegularPathIdentity( + destination, + "Apple archive path", + requireDirectory: true); + + var name = Path.GetFileName(destination); + var stageRoot = Path.Combine(parent, $".{name}.powerforge-stage-{Guid.NewGuid():N}"); + var stage = Path.Combine(stageRoot, name); + var backupRoot = Path.Combine(parent, $".{name}.powerforge-backup-{Guid.NewGuid():N}"); + var backup = Path.Combine(backupRoot, name); + var backupDeletionCandidate = Path.Combine(parent, $".{name}.powerforge-backup-deletion-{Guid.NewGuid():N}"); + var rollbackCandidate = Path.Combine(parent, $".{name}.powerforge-failed-publication-{Guid.NewGuid():N}"); + var movedExisting = false; + var published = false; + try { + Directory.CreateDirectory(stageRoot); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(stageRoot, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + AppleArtifactCopy.CopyDirectory(ArchivePath, stage); + var stagedSha256 = AppleNotarizationService.ComputeArtifactSha256(stage); + if (!stagedSha256.Equals(sourceSha256, StringComparison.OrdinalIgnoreCase)) { + throw new InvalidOperationException( + $"The staged Apple archive changed during publication. Expected '{sourceSha256}', received '{stagedSha256}'."); + } + + movedExisting = AppleArtifactCopy.MoveExistingPathToBackupIfUnchanged( + destination, + backup, + existingDestination, + "Apple archive"); + Directory.Move(stage, destination); + published = true; + + var publishedSha256 = AppleNotarizationService.ComputeArtifactSha256(destination); + if (!publishedSha256.Equals(sourceSha256, StringComparison.OrdinalIgnoreCase)) { + throw new InvalidOperationException( + $"The published Apple archive changed before release processing. Expected '{sourceSha256}', received '{publishedSha256}'."); + } + if (movedExisting) + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup, + backupDeletionCandidate, + existingDestination!, + "Previous Apple archive"); + return publishedSha256; + } catch (Exception publicationException) { + try { + RollbackPublication(destination, backup, rollbackCandidate, sourceSha256, published, movedExisting); + } catch (Exception rollbackException) { + throw new AggregateException( + $"Apple archive publication failed and rollback could not complete. Recovery bytes are retained at '{backup}'.", + publicationException, + rollbackException); + } + throw; + } finally { + try { AppleArtifactCopy.DeleteOwnedDirectory(stageRoot); } catch { /* best effort private cleanup */ } + } + } + + internal static void RollbackPublication( + string destination, + string backup, + string rollbackCandidate, + string publishedSha256, + bool published, + bool movedExisting) + { + if (published && Directory.Exists(destination)) { + try { + AppleArtifactCopy.RemovePublishedDirectoryIfUnchanged( + destination, + rollbackCandidate, + publishedSha256, + "Apple archive"); + } catch { + throw new InvalidOperationException( + $"Apple archive rollback found a concurrently replaced destination at '{destination}'. " + + $"The previous artifact remains at '{backup}' and no unrecognized archive bytes were deleted."); + } + } + if (movedExisting) + AppleArtifactCopy.RestoreDirectoryBackup(destination, backup); + } + + public void Dispose() { + if (_disposed) + return; + _disposed = true; + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after publication */ } + } +} diff --git a/PowerForge/Services/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs new file mode 100644 index 000000000..006080bda --- /dev/null +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -0,0 +1,198 @@ +namespace PowerForge; + +/// Copies one approved archive into a private upload input so exporters cannot observe transient source changes. +internal sealed class AppleArchiveUploadSnapshot : IDisposable +{ + private readonly SnapshotIdentity _identity; + private bool _disposed; + + private AppleArchiveUploadSnapshot( + string rootPath, + string archivePath, + SnapshotIdentity identity) + { + RootPath = rootPath; + ArchivePath = archivePath; + _identity = identity; + } + + internal string RootPath { get; } + + internal string ArchivePath { get; } + + internal static AppleArchiveUploadSnapshot Create(string archivePath, string expectedSha256) + { + var source = Path.GetFullPath(archivePath); + if (!Directory.Exists(source)) + throw new DirectoryNotFoundException($"Approved Apple archive was not found: {source}"); + + var root = Path.Combine(Path.GetTempPath(), "PowerForge", "apple-upload-snapshots", Guid.NewGuid().ToString("N")); + var snapshotPath = Path.Combine(root, Path.GetFileName(source)); + Directory.CreateDirectory(root); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + try + { + AppleArtifactCopy.CopyDirectory(source, snapshotPath); + var identity = CaptureCompleteIdentity(snapshotPath); + if (!identity.Sha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple upload snapshot does not match the approved archive. Expected '{expectedSha256}', received '{identity.Sha256}'."); + } + return new AppleArchiveUploadSnapshot( + root, + snapshotPath, + identity); + } + catch + { + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } + throw; + } + } + + internal void ValidateUnchanged(string expectedSha256) + { + var current = CaptureCompleteIdentity(ArchivePath); + if (!current.Sha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple upload snapshot changed while xcodebuild was reading it. Expected '{expectedSha256}', received '{current.Sha256}'. Discard the upload/export result and inspect remote state before retrying."); + } + + if (!_identity.MutationDigest.Equals(current.MutationDigest, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The private Apple upload archive snapshot file identity changed while xcodebuild was reading it. " + + "A transient write or hard-link alias invalidates the approved archive. Discard the upload/export result and inspect remote state before retrying."); + } + } + + internal static SnapshotIdentity CaptureCompleteIdentity( + string archivePath, + string description = "private Apple upload archive snapshot") + { + var sha256 = AppleNotarizationService.ComputeArtifactSha256(archivePath); + if (File.Exists(archivePath)) + { + return new SnapshotIdentity( + sha256, + ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity(archivePath, description)); + } + var identities = CaptureFileMutationIdentities(archivePath, description); + var canonical = new System.Text.StringBuilder(); + foreach (var pair in identities.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + canonical.Append(pair.Key.Length).Append(':').Append(pair.Key); + canonical.Append(pair.Value.Length).Append(':').Append(pair.Value); + } + using var hash = System.Security.Cryptography.SHA256.Create(); + var digest = BitConverter.ToString(hash.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical.ToString()))) + .Replace("-", string.Empty) + .ToLowerInvariant(); + return new SnapshotIdentity(sha256, digest); + } + + private static IReadOnlyDictionary CaptureFileMutationIdentities( + string archivePath, + string description) + { + var result = new Dictionary(GetPathComparer(archivePath)); + var files = new List<(string RelativePath, string FullPath)>(); + var pending = new Stack(); + pending.Push(archivePath); + while (pending.Count > 0) + { + var directory = pending.Pop(); + foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + continue; + if ((attributes & FileAttributes.Directory) != 0) + { + pending.Push(entry); + continue; + } + + var relativePath = FrameworkCompatibility.GetRelativePath(archivePath, entry).Replace('\\', '/'); + files.Add((relativePath, entry)); + } + } + + var hardLinkCounts = ExistingFilePathIdentityResolver.ResolveHardLinkCounts( + files.Select(static file => file.FullPath).ToArray()); + for (var index = 0; index < files.Count; index++) + { + if (hardLinkCounts[index] != 1) + { + throw new InvalidOperationException( + $"The {description} file '{files[index].RelativePath}' has {hardLinkCounts[index]} hard links. " + + "Private release snapshots require one pathname per regular file."); + } + var status = ExistingFilePathIdentityResolver.ResolveStatus(files[index].FullPath); + try + { + result.Add(files[index].RelativePath, status.MutationIdentity); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException( + $"The {description} contains duplicate platform-equivalent file paths at '{files[index].RelativePath}'.", + exception); + } + } + return result; + } + + private static StringComparer GetPathComparer(string path) + { + // Probe the containing volume outside the monitored artifact. The case-semantics probe creates + // and removes a temporary file; doing that inside a private archive/app would itself invalidate + // the physical-identity snapshot and produce a false mutation event. + var fullPath = Path.GetFullPath(path); + var containingDirectory = Path.GetDirectoryName(fullPath); + var probePath = containingDirectory is null + ? fullPath + : Path.GetDirectoryName(containingDirectory) ?? containingDirectory; + return FrameworkCompatibility.GetPathStringComparisonForPath(probePath) == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + } + + internal sealed class SnapshotIdentity : IEquatable + { + internal SnapshotIdentity(string sha256, string mutationDigest) + { + Sha256 = sha256; + MutationDigest = mutationDigest; + } + + internal string Sha256 { get; } + + internal string MutationDigest { get; } + + public bool Equals(SnapshotIdentity? other) + => other is not null && + Sha256.Equals(other.Sha256, StringComparison.OrdinalIgnoreCase) && + MutationDigest.Equals(other.MutationDigest, StringComparison.Ordinal); + + public override bool Equals(object? obj) => Equals(obj as SnapshotIdentity); + + public override int GetHashCode() + => StringComparer.OrdinalIgnoreCase.GetHashCode(Sha256) ^ + StringComparer.Ordinal.GetHashCode(MutationDigest); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after remote operation */ } + } + +} diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs new file mode 100644 index 000000000..995510ae3 --- /dev/null +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -0,0 +1,435 @@ +namespace PowerForge; + +/// Copies Apple artifacts without following symbolic links outside their owning tree. +internal static class AppleArtifactCopy +{ + internal sealed class PathIdentity + { + internal PathIdentity(bool isDirectory, string sha256) + { + IsDirectory = isDirectory; + Sha256 = sha256; + } + + internal bool IsDirectory { get; } + + internal string Sha256 { get; } + } + + internal static void CopyDirectory(string sourceRoot, string destinationRoot) + { + Directory.CreateDirectory(destinationRoot); + var directoryMetadata = new List<(string Source, string Destination)> + { + (sourceRoot, destinationRoot) + }; + var pending = new Stack<(string Source, string Destination)>(); + pending.Push((sourceRoot, destinationRoot)); + while (pending.Count > 0) + { + var current = pending.Pop(); + foreach (var sourcePath in Directory.EnumerateFileSystemEntries(current.Source)) + { + var destinationPath = Path.Combine(current.Destination, Path.GetFileName(sourcePath)); + var attributes = File.GetAttributes(sourcePath); + var isDirectory = (attributes & FileAttributes.Directory) != 0; + var isLink = (attributes & FileAttributes.ReparsePoint) != 0; + if (isLink) + { +#if NET8_0_OR_GREATER + var linkTarget = isDirectory + ? new DirectoryInfo(sourcePath).LinkTarget + : new FileInfo(sourcePath).LinkTarget; + if (string.IsNullOrWhiteSpace(linkTarget)) + throw new InvalidOperationException($"Unable to preserve Apple artifact symbolic link: {sourcePath}"); + if (Path.IsPathRooted(linkTarget)) + throw new InvalidOperationException($"Apple artifact symbolic links must remain inside the archive or artifact: {sourcePath}"); + var resolvedTarget = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourcePath)!, linkTarget)); + var relativeTarget = FrameworkCompatibility.GetRelativePath(sourceRoot, resolvedTarget); + if (Path.IsPathRooted(relativeTarget) || + relativeTarget.Equals("..", StringComparison.Ordinal) || + relativeTarget.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Apple artifact symbolic links must remain inside the archive or artifact: {sourcePath}"); + } + if (isDirectory) + Directory.CreateSymbolicLink(destinationPath, linkTarget!); + else + File.CreateSymbolicLink(destinationPath, linkTarget!); +#else + throw new PlatformNotSupportedException("Apple artifact symbolic-link copies require .NET 8 or newer."); +#endif + } + else if (isDirectory) + { + Directory.CreateDirectory(destinationPath); + directoryMetadata.Add((sourcePath, destinationPath)); + pending.Push((sourcePath, destinationPath)); + } + else + { + File.Copy(sourcePath, destinationPath, overwrite: false); + } + + if (!isLink && !isDirectory) + ApplyMetadata(sourcePath, destinationPath, attributes); + } + } + + // Directory permissions must be restored only after every descendant has been copied. + // Applying a source mode such as 0555 at creation time makes the destination unwritable + // and prevents ordinary release users from materializing the remaining bundle contents. + for (var index = directoryMetadata.Count - 1; index >= 0; index--) + { + var directory = directoryMetadata[index]; + ApplyMetadata( + directory.Source, + directory.Destination, + File.GetAttributes(directory.Source)); + } + } + + private static void ApplyMetadata(string sourcePath, string destinationPath, FileAttributes attributes) + { +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(destinationPath, File.GetUnixFileMode(sourcePath)); +#endif + File.SetAttributes(destinationPath, attributes); + } + + /// + /// Captures the stable content identity of an existing regular artifact path. + /// Missing paths return ; linked paths are never accepted. + /// + internal static PathIdentity? CaptureRegularPathIdentity( + string path, + string artifactDescription, + bool? requireDirectory = null) + { + if (!Directory.Exists(path) && !File.Exists(path)) + return null; + + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException($"{artifactDescription} must not be a linked path: {path}"); + var isDirectory = (attributes & FileAttributes.Directory) != 0; + if (requireDirectory.HasValue && isDirectory != requireDirectory.Value) + { + var expected = requireDirectory.Value ? "directory" : "file"; + throw new InvalidOperationException($"{artifactDescription} must be a regular {expected}: {path}"); + } + + return new PathIdentity(isDirectory, AppleNotarizationService.ComputeArtifactSha256(path)); + } + + /// + /// Atomically stages the destination as a backup only when it still matches the + /// identity observed before publication. A concurrently created or replaced path + /// is left at the destination and causes publication to fail closed. + /// + internal static bool MoveExistingPathToBackupIfUnchanged( + string destinationPath, + string backupPath, + PathIdentity? expectedIdentity, + string artifactDescription) + { + var currentExists = Directory.Exists(destinationPath) || File.Exists(destinationPath); + if (expectedIdentity is null) + { + if (currentExists) + { + throw new InvalidOperationException( + $"{artifactDescription} destination was created concurrently before publication: {destinationPath}"); + } + return false; + } + if (!currentExists) + { + throw new InvalidOperationException( + $"{artifactDescription} destination disappeared concurrently before publication: {destinationPath}"); + } + + var currentAttributes = File.GetAttributes(destinationPath); + var currentIsDirectory = (currentAttributes & FileAttributes.Directory) != 0; + if ((currentAttributes & FileAttributes.ReparsePoint) != 0 || currentIsDirectory != expectedIdentity.IsDirectory) + { + throw new InvalidOperationException( + $"{artifactDescription} destination was replaced concurrently before publication: {destinationPath}"); + } + + CreatePrivateBackupParent(backupPath); + try + { + if (currentIsDirectory) + Directory.Move(destinationPath, backupPath); + else + File.Move(destinationPath, backupPath); + } + catch + { + TryDeleteOwnedBackupParent(backupPath); + throw; + } + + var backupIdentity = CaptureRegularPathIdentity(backupPath, artifactDescription); + if (backupIdentity is null || + backupIdentity.IsDirectory != expectedIdentity.IsDirectory || + !backupIdentity.Sha256.Equals(expectedIdentity.Sha256, StringComparison.OrdinalIgnoreCase)) + { + RestorePathBackup(destinationPath, backupPath); + throw new InvalidOperationException( + $"{artifactDescription} destination changed while it was being staged for publication: {destinationPath}"); + } + return true; + } + + /// Deletes a retained backup only while it still matches the pre-publication identity. + internal static void RemoveBackupIfUnchanged( + string backupPath, + string quarantinePath, + PathIdentity expectedIdentity, + string artifactDescription) + { + var current = CaptureRegularPathIdentity(backupPath, artifactDescription); + if (current is null || + current.IsDirectory != expectedIdentity.IsDirectory || + !current.Sha256.Equals(expectedIdentity.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{artifactDescription} backup was replaced concurrently and has been retained: {backupPath}"); + } + RemovePublishedPathIfUnchanged( + backupPath, + quarantinePath, + expectedIdentity.Sha256, + artifactDescription); + TryDeleteOwnedBackupParent(backupPath); + } + + /// + /// Restores a retained directory backup only when the destination is still vacant. + /// A concurrently recreated destination wins and the backup remains available for recovery. + /// + internal static void RestoreDirectoryBackup(string destinationPath, string backupPath) + { + if (!Directory.Exists(backupPath)) + return; + if (Directory.Exists(destinationPath) || File.Exists(destinationPath)) + { + throw new InvalidOperationException( + $"Apple artifact rollback could not restore '{destinationPath}' because the destination was recreated. " + + $"The previous artifact is retained at '{backupPath}'."); + } + Directory.Move(backupPath, destinationPath); + TryDeleteOwnedBackupParent(backupPath); + } + + /// + /// Quarantines a published directory and deletes it only when its complete artifact hash still + /// matches the bytes owned by the current publication. Unknown, unreadable, or linked replacement + /// bytes are restored to the destination when possible and are never recursively traversed or deleted. + /// + internal static void RemovePublishedDirectoryIfUnchanged( + string destinationPath, + string quarantinePath, + string expectedSha256, + string artifactDescription) + => RemovePublishedPathIfUnchanged(destinationPath, quarantinePath, expectedSha256, artifactDescription); + + /// + /// Quarantines a published file or directory and deletes it only when its exact artifact hash + /// still matches the bytes owned by the current publication. Concurrent or linked replacements + /// are restored to their observed pathname when possible and are never deleted. + /// + internal static void RemovePublishedPathIfUnchanged( + string destinationPath, + string quarantinePath, + string expectedSha256, + string artifactDescription) + { + var attributes = File.GetAttributes(destinationPath); + var isDirectory = (attributes & FileAttributes.Directory) != 0; + var quarantinedArtifactPath = Path.Combine(quarantinePath, Path.GetFileName(destinationPath)); + try + { + Directory.CreateDirectory(quarantinePath); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(quarantinePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + if (isDirectory) + Directory.Move(destinationPath, quarantinedArtifactPath); + else + File.Move(destinationPath, quarantinedArtifactPath); + } + catch + { + TryDeleteEmptyDirectory(quarantinePath); + throw; + } + try + { + var quarantinedAttributes = File.GetAttributes(quarantinedArtifactPath); + if ((quarantinedAttributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"{artifactDescription} rollback found a linked replacement at '{destinationPath}'."); + } + + var observedSha256 = AppleNotarizationService.ComputeArtifactSha256(quarantinedArtifactPath); + if (!observedSha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{artifactDescription} rollback found replacement bytes at '{destinationPath}'."); + } + + if (isDirectory) + { + PrepareOwnedDirectoryForDeletion(quarantinedArtifactPath); + Directory.Delete(quarantinedArtifactPath, recursive: true); + TryDeleteEmptyDirectory(quarantinePath); + } + else + { + File.Delete(quarantinedArtifactPath); + TryDeleteEmptyDirectory(quarantinePath); + } + } + catch + { + if (!Directory.Exists(destinationPath) && !File.Exists(destinationPath)) + { + if (isDirectory) + { + Directory.Move(quarantinedArtifactPath, destinationPath); + TryDeleteEmptyDirectory(quarantinePath); + } + else + { + File.Move(quarantinedArtifactPath, destinationPath); + TryDeleteEmptyDirectory(quarantinePath); + } + } + throw; + } + } + + /// + /// Makes a verified private directory tree deletable without traversing symbolic links. + /// This completes before recursive deletion starts so read-only bundle metadata cannot leave + /// a partially deleted backup that a publication rollback could mistake for the original. + /// + internal static void DeleteOwnedDirectory(string directoryPath) + { + if (!Directory.Exists(directoryPath)) + return; + PrepareOwnedDirectoryForDeletion(directoryPath); + Directory.Delete(directoryPath, recursive: true); + } + + private static void PrepareOwnedDirectoryForDeletion(string directoryPath) + { + var pending = new Stack(); + pending.Push(directoryPath); + while (pending.Count > 0) + { + var current = pending.Pop(); + var attributes = File.GetAttributes(current); + if ((attributes & FileAttributes.ReparsePoint) != 0) + continue; + + var isDirectory = (attributes & FileAttributes.Directory) != 0; +#if NET8_0_OR_GREATER + if (isDirectory && !OperatingSystem.IsWindows()) + { + var mode = File.GetUnixFileMode(current); + File.SetUnixFileMode( + current, + mode | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + else +#endif + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(current, attributes & ~FileAttributes.ReadOnly); + } + + if (!isDirectory) + continue; + foreach (var child in Directory.EnumerateFileSystemEntries(current)) + pending.Push(child); + } + } + + /// Restores a retained file or directory backup only when the destination is vacant. + internal static void RestorePathBackup(string destinationPath, string backupPath) + { + if (!Directory.Exists(backupPath) && !File.Exists(backupPath)) + return; + if (Directory.Exists(destinationPath) || File.Exists(destinationPath)) + { + throw new InvalidOperationException( + $"Apple artifact rollback could not restore '{destinationPath}' because the destination was recreated. " + + $"The previous artifact is retained at '{backupPath}'."); + } + if (Directory.Exists(backupPath)) + Directory.Move(backupPath, destinationPath); + else + File.Move(backupPath, destinationPath); + TryDeleteOwnedBackupParent(backupPath); + } + + private static void CreatePrivateBackupParent(string backupPath) + { + var parent = Path.GetDirectoryName(backupPath) + ?? throw new InvalidOperationException($"Apple artifact backup path has no parent: {backupPath}"); + Directory.CreateDirectory(parent); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(parent, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + } + + private static void TryDeleteOwnedBackupParent(string backupPath) + { + var parent = Path.GetDirectoryName(backupPath); + if (string.IsNullOrWhiteSpace(parent) || + !Path.GetFileName(parent).Contains(".powerforge-backup-", StringComparison.Ordinal)) + { + return; + } + try + { + if (Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) + Directory.Delete(parent); + } + catch (IOException) + { + // The verified backup is already gone. Empty-parent cleanup is best effort and must + // not turn a successful publication into a destructive rollback after this boundary. + } + catch (UnauthorizedAccessException) + { + // See above. A retained empty private parent is safer than rolling back published bytes. + } + } + + private static void TryDeleteEmptyDirectory(string path) + { + try + { + if (Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) + Directory.Delete(path); + } + catch (IOException) + { + // Empty private-container cleanup is best effort after its owned artifact has moved or + // been irreversibly deleted. It must not trigger rollback of successfully published bytes. + } + catch (UnauthorizedAccessException) + { + // See above. Leaving an empty private quarantine is safer than destructive rollback. + } + } +} diff --git a/PowerForge/Services/AppleDirectExportSnapshot.cs b/PowerForge/Services/AppleDirectExportSnapshot.cs new file mode 100644 index 000000000..058eeff18 --- /dev/null +++ b/PowerForge/Services/AppleDirectExportSnapshot.cs @@ -0,0 +1,258 @@ +namespace PowerForge; + +/// Owns a private Developer ID export and publishes its verified bytes atomically. +internal sealed class AppleDirectExportSnapshot : IDisposable +{ + private bool _disposed; + private string? _approvedArtifactPath; + private string? _approvedArtifactSha256; + private string? _publishedBackup; + private string? _publishedBackupDeletionCandidate; + private AppleArtifactCopy.PathIdentity? _publishedDestinationIdentity; + + private AppleDirectExportSnapshot(string rootPath, string exportPath) + { + RootPath = rootPath; + ExportPath = exportPath; + } + + internal string RootPath { get; } + + internal string ExportPath { get; } + + internal static AppleDirectExportSnapshot Create() + { + var root = Path.Combine(Path.GetTempPath(), "PowerForge", "apple-direct-exports", Guid.NewGuid().ToString("N")); + var exportPath = Path.Combine(root, "export"); + Directory.CreateDirectory(exportPath); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + return new AppleDirectExportSnapshot(root, exportPath); + } + + internal void BindProducedArtifact(string? producerArtifactPath, string? producerArtifactSha256) + { + var artifactPath = PowerForgeReleaseService.ResolveDirectAppleArtifactPath(ExportPath); + EnsureArtifactWithinExportRoot(artifactPath); + var artifactSha256 = AppleNotarizationService.ComputeArtifactSha256(artifactPath); + if (!string.IsNullOrWhiteSpace(producerArtifactPath) && + !Path.GetFullPath(producerArtifactPath).Equals( + artifactPath, + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"xcodebuild reported Developer ID artifact '{producerArtifactPath}', but the private export contains '{artifactPath}'."); + } + if (!string.IsNullOrWhiteSpace(producerArtifactSha256) && + !string.Equals(producerArtifactSha256, artifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Developer ID export changed after xcodebuild completed. Expected '{producerArtifactSha256}', received '{artifactSha256}'."); + } + + _approvedArtifactPath = artifactPath; + _approvedArtifactSha256 = artifactSha256; + } + + internal ApplePublishedDirectExport Publish(string destinationExportPath) + { + if (string.IsNullOrWhiteSpace(_approvedArtifactPath) || string.IsNullOrWhiteSpace(_approvedArtifactSha256)) + throw new InvalidOperationException("The Developer ID export must be bound immediately after xcodebuild completes before it can be published."); + var sourceArtifact = PowerForgeReleaseService.ResolveDirectAppleArtifactPath(ExportPath); + EnsureArtifactWithinExportRoot(sourceArtifact); + var sourceArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(sourceArtifact); + if (!sourceArtifact.Equals( + _approvedArtifactPath, + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) || + !sourceArtifactSha256.Equals(_approvedArtifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Developer ID export changed after xcodebuild completed. Expected '{_approvedArtifactSha256}' at '{_approvedArtifactPath}', " + + $"received '{sourceArtifactSha256}' at '{sourceArtifact}'."); + } + var relativeArtifactPath = FrameworkCompatibility.GetRelativePath(ExportPath, sourceArtifact); + var sourceExportSha256 = AppleNotarizationService.ComputeArtifactSha256(ExportPath); + + var destination = Path.GetFullPath(destinationExportPath); + var parent = Path.GetDirectoryName(destination) + ?? throw new InvalidOperationException($"Developer ID export path has no parent: {destination}"); + Directory.CreateDirectory(parent); + var existingDestination = AppleArtifactCopy.CaptureRegularPathIdentity( + destination, + "Developer ID export path", + requireDirectory: true); + + var name = Path.GetFileName(destination); + var stage = Path.Combine(parent, $".{name}.powerforge-stage-{Guid.NewGuid():N}"); + var backupRoot = Path.Combine(parent, $".{name}.powerforge-backup-{Guid.NewGuid():N}"); + var backup = Path.Combine(backupRoot, name); + var backupDeletionCandidate = Path.Combine(parent, $".{name}.powerforge-backup-deletion-{Guid.NewGuid():N}"); + var rollbackCandidate = Path.Combine(parent, $".{name}.powerforge-failed-publication-{Guid.NewGuid():N}"); + var movedExisting = false; + var published = false; + try + { + AppleArtifactCopy.CopyDirectory(ExportPath, stage); + var observedSourceExportSha256 = AppleNotarizationService.ComputeArtifactSha256(ExportPath); + if (!observedSourceExportSha256.Equals(sourceExportSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Developer ID export tree changed during publication. Expected '{sourceExportSha256}', received '{observedSourceExportSha256}'."); + } + var stagedArtifact = Path.Combine(stage, relativeArtifactPath); + var stagedSha256 = AppleNotarizationService.ComputeArtifactSha256(stagedArtifact); + if (!stagedSha256.Equals(sourceArtifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The staged Developer ID export changed during publication. Expected '{sourceArtifactSha256}', received '{stagedSha256}'."); + } + movedExisting = AppleArtifactCopy.MoveExistingPathToBackupIfUnchanged( + destination, + backup, + existingDestination, + "Developer ID export"); + Directory.Move(stage, destination); + published = true; + + var publishedArtifact = Path.Combine(destination, relativeArtifactPath); + var publishedSha256 = AppleNotarizationService.ComputeArtifactSha256(publishedArtifact); + if (!publishedSha256.Equals(sourceArtifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The published Developer ID export changed before notarization. Expected '{sourceArtifactSha256}', received '{publishedSha256}'."); + } + var observedPublishedExportSha256 = AppleNotarizationService.ComputeArtifactSha256(destination); + if (!observedPublishedExportSha256.Equals(sourceExportSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The published Developer ID export tree changed before notarization. Expected '{sourceExportSha256}', received '{observedPublishedExportSha256}'."); + } + if (movedExisting) + { + _publishedBackup = backup; + _publishedBackupDeletionCandidate = backupDeletionCandidate; + _publishedDestinationIdentity = existingDestination; + } + return new ApplePublishedDirectExport(destination, publishedArtifact, publishedSha256); + } + catch (Exception publicationException) + { + try + { + RollbackPublication(destination, backup, rollbackCandidate, sourceExportSha256, published, movedExisting); + } + catch (Exception rollbackException) + { + throw new AggregateException( + $"Developer ID export publication failed and rollback could not complete. Recovery bytes are retained at '{backup}'.", + publicationException, + rollbackException); + } + throw; + } + finally + { + try { AppleArtifactCopy.DeleteOwnedDirectory(stage); } catch { /* best effort private cleanup */ } + } + } + + internal void CommitPublication() + { + var backup = _publishedBackup; + var backupDeletionCandidate = _publishedBackupDeletionCandidate; + var destinationIdentity = _publishedDestinationIdentity; + if (string.IsNullOrWhiteSpace(backup) || + string.IsNullOrWhiteSpace(backupDeletionCandidate) || + destinationIdentity is null) + { + return; + } + + try + { + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup!, + backupDeletionCandidate!, + destinationIdentity, + "Previous Developer ID export"); + } + catch + { + // The configured notarization workflow completed. Retain any previous + // or concurrently replaced backup instead of converting cleanup into + // a retryable release failure. + } + finally + { + _publishedBackup = null; + _publishedBackupDeletionCandidate = null; + _publishedDestinationIdentity = null; + } + } + + internal static void RollbackPublication( + string destination, + string backup, + string rollbackCandidate, + string publishedExportSha256, + bool published, + bool movedExisting) + { + if (published && Directory.Exists(destination)) + { + try + { + AppleArtifactCopy.RemovePublishedDirectoryIfUnchanged( + destination, + rollbackCandidate, + publishedExportSha256, + "Developer ID export"); + } + catch + { + throw new InvalidOperationException( + $"Developer ID export rollback found a concurrently replaced destination at '{destination}'. " + + $"The previous export remains at '{backup}' and no unrecognized export bytes were deleted."); + } + } + if (movedExisting) + AppleArtifactCopy.RestoreDirectoryBackup(destination, backup); + } + + private void EnsureArtifactWithinExportRoot(string artifactPath) + { + var relativeArtifactPath = FrameworkCompatibility.GetRelativePath(ExportPath, artifactPath); + if (Path.IsPathRooted(relativeArtifactPath) || + relativeArtifactPath.Equals("..", StringComparison.Ordinal) || + relativeArtifactPath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Direct Apple export artifact escaped its private export root: {artifactPath}"); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after publication */ } + } +} + +internal sealed class ApplePublishedDirectExport +{ + internal ApplePublishedDirectExport(string exportPath, string artifactPath, string artifactSha256) + { + ExportPath = exportPath; + ArtifactPath = artifactPath; + ArtifactSha256 = artifactSha256; + } + + internal string ExportPath { get; } + + internal string ArtifactPath { get; } + + internal string ArtifactSha256 { get; } +} diff --git a/PowerForge/Services/AppleNotarizationInputSnapshot.cs b/PowerForge/Services/AppleNotarizationInputSnapshot.cs new file mode 100644 index 000000000..02a352ee2 --- /dev/null +++ b/PowerForge/Services/AppleNotarizationInputSnapshot.cs @@ -0,0 +1,272 @@ +namespace PowerForge; + +/// Provides a private, hash-verified notarization submission input. +internal sealed class AppleNotarizationInputSnapshot : IDisposable +{ + private readonly AppleArchiveUploadSnapshot? _directorySnapshot; + private readonly string? _fileMutationIdentity; + private AppleReleaseSourceMutationMonitor? _fileSnapshotMonitor; + private bool _disposed; + + private AppleNotarizationInputSnapshot( + string rootPath, + string artifactPath, + AppleArchiveUploadSnapshot? directorySnapshot, + AppleReleaseSourceMutationMonitor? fileSnapshotMonitor = null, + string? fileMutationIdentity = null) + { + RootPath = rootPath; + ArtifactPath = artifactPath; + _directorySnapshot = directorySnapshot; + _fileSnapshotMonitor = fileSnapshotMonitor; + _fileMutationIdentity = fileMutationIdentity; + } + + internal string RootPath { get; } + + internal string ArtifactPath { get; } + + internal void CompleteSubmissionCapture(string expectedSha256) + { + if (_directorySnapshot is not null) + { + _directorySnapshot.ValidateUnchanged(expectedSha256); + return; + } + if (_fileSnapshotMonitor is null) + return; + try + { + _fileSnapshotMonitor.ValidateNoChanges(); + var actual = AppleNotarizationService.ComputeArtifactSha256(ArtifactPath); + if (!actual.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple notarization file changed while its submission bytes were captured. Expected '{expectedSha256}', received '{actual}'."); + } + var currentMutationIdentity = ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( + ArtifactPath, + "private Apple notarization file snapshot"); + if (!string.Equals(_fileMutationIdentity, currentMutationIdentity, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The private Apple notarization file snapshot identity changed while its submission bytes were captured. " + + "A transient write or hard-link alias invalidates the approved artifact."); + } + } + finally + { + _fileSnapshotMonitor.Dispose(); + _fileSnapshotMonitor = null; + } + } + + internal string PublishTo(string destinationPath, string expectedSha256) + { + var destination = Path.GetFullPath(destinationPath); + var sourceSha256 = AppleNotarizationService.ComputeArtifactSha256(ArtifactPath); + if (!sourceSha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private notarized Apple artifact changed before publication. Expected '{expectedSha256}', received '{sourceSha256}'."); + } + + var parent = Path.GetDirectoryName(destination) + ?? throw new InvalidOperationException($"Apple notarization artifact path has no parent: {destination}"); + Directory.CreateDirectory(parent); + var existingDestination = AppleArtifactCopy.CaptureRegularPathIdentity( + destination, + "Apple notarization artifact path"); + var name = Path.GetFileName(destination); + var stageRoot = Path.Combine(parent, $".powerforge-stage-{Guid.NewGuid():N}"); + var stage = Path.Combine(stageRoot, name); + var backupRoot = Path.Combine(parent, $".{name}.powerforge-backup-{Guid.NewGuid():N}"); + var backup = Path.Combine(backupRoot, name); + var backupDeletionCandidate = Path.Combine(parent, $".{name}.powerforge-backup-deletion-{Guid.NewGuid():N}"); + var rollbackCandidate = Path.Combine(parent, $".{name}.powerforge-failed-publication-{Guid.NewGuid():N}"); + var sourceIsDirectory = Directory.Exists(ArtifactPath); + var movedExisting = false; + var published = false; + try + { + Directory.CreateDirectory(stageRoot); + if (sourceIsDirectory) + { + AppleArtifactCopy.CopyDirectory(ArtifactPath, stage); + } + else + { + File.Copy(ArtifactPath, stage, overwrite: false); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(stage, File.GetUnixFileMode(ArtifactPath)); +#endif + File.SetAttributes(stage, File.GetAttributes(ArtifactPath)); + } + + var stagedSha256 = AppleNotarizationService.ComputeArtifactSha256(stage); + if (!stagedSha256.Equals(sourceSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The staged notarized Apple artifact changed during publication. Expected '{sourceSha256}', received '{stagedSha256}'."); + } + + movedExisting = AppleArtifactCopy.MoveExistingPathToBackupIfUnchanged( + destination, + backup, + existingDestination, + "Apple notarization artifact"); + + if (sourceIsDirectory) + Directory.Move(stage, destination); + else + File.Move(stage, destination); + published = true; + + var publishedSha256 = AppleNotarizationService.ComputeArtifactSha256(destination); + if (!publishedSha256.Equals(sourceSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The published notarized Apple artifact changed during publication. Expected '{sourceSha256}', received '{publishedSha256}'."); + } + + if (movedExisting) + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup, + backupDeletionCandidate, + existingDestination!, + "Previous Apple notarization artifact"); + return publishedSha256; + } + catch (Exception publicationException) + { + try + { + RollbackPublication( + destination, + backup, + rollbackCandidate, + sourceSha256, + published, + movedExisting); + } + catch (Exception rollbackException) + { + throw new AggregateException( + $"Apple notarization artifact publication failed and rollback could not complete. Recovery bytes are retained at '{backup}'.", + publicationException, + rollbackException); + } + throw; + } + finally + { + TryDeletePath(stageRoot); + } + } + + internal static void RollbackPublication( + string destination, + string backup, + string rollbackCandidate, + string publishedSha256, + bool published, + bool movedExisting) + { + if (published && (Directory.Exists(destination) || File.Exists(destination))) + { + AppleArtifactCopy.RemovePublishedPathIfUnchanged( + destination, + rollbackCandidate, + publishedSha256, + "Apple notarization artifact"); + } + if (movedExisting) + AppleArtifactCopy.RestorePathBackup(destination, backup); + } + + internal static AppleNotarizationInputSnapshot Create(string artifactPath, string expectedSha256) + { + var source = Path.GetFullPath(artifactPath); + if (Directory.Exists(source)) + { + var directorySnapshot = AppleArchiveUploadSnapshot.Create(source, expectedSha256); + return new AppleNotarizationInputSnapshot( + directorySnapshot.RootPath, + directorySnapshot.ArchivePath, + directorySnapshot); + } + if (!File.Exists(source)) + throw new FileNotFoundException("Apple notarization artifact was not found.", source); + + var root = Path.Combine(Path.GetTempPath(), "PowerForge", "apple-notarization-inputs", Guid.NewGuid().ToString("N")); + var snapshotPath = Path.Combine(root, Path.GetFileName(source)); + Directory.CreateDirectory(root); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + try + { + File.Copy(source, snapshotPath, overwrite: false); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(snapshotPath, File.GetUnixFileMode(source)); +#endif + File.SetAttributes(snapshotPath, File.GetAttributes(source)); + var actual = AppleNotarizationService.ComputeArtifactSha256(snapshotPath); + if (!actual.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple notarization input does not match the approved artifact. Expected '{expectedSha256}', received '{actual}'."); + } + var monitor = new AppleReleaseSourceMutationMonitor( + root, + "private Apple notarization file snapshot", + "submission hashing", + "Discard the snapshot and recreate it from the approved artifact."); + return new AppleNotarizationInputSnapshot( + root, + snapshotPath, + directorySnapshot: null, + fileSnapshotMonitor: monitor, + fileMutationIdentity: ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( + snapshotPath, + "private Apple notarization file snapshot")); + } + catch + { + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } + throw; + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _fileSnapshotMonitor?.Dispose(); + _fileSnapshotMonitor = null; + if (_directorySnapshot is not null) + { + _directorySnapshot.Dispose(); + return; + } + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after notarization */ } + } + + private static void DeletePath(string path) + { + if (Directory.Exists(path)) + AppleArtifactCopy.DeleteOwnedDirectory(path); + else if (File.Exists(path)) + File.Delete(path); + } + + private static void TryDeletePath(string path) + { + try { DeletePath(path); } + catch { /* retain recovery bytes rather than masking publication or rollback */ } + } +} diff --git a/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs b/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs new file mode 100644 index 000000000..efaecf151 --- /dev/null +++ b/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs @@ -0,0 +1,79 @@ +using System.Text.Json; + +namespace PowerForge; + +public sealed partial class AppleNotarizationService +{ + private static InvalidOperationException CreateAmbiguousSubmissionException( + AppleNotarizationRequest request, + string artifactPath, + string artifactSha256, + string submissionPath, + string submissionSha256, + string? submissionId, + string? status, + Exception? processException = null) + { + try + { + request.AmbiguousCheckpoint?.Invoke(new AppleNotarizationAmbiguousCheckpoint + { + ArtifactPath = artifactPath, + ArtifactSha256 = artifactSha256, + SubmissionPath = submissionPath, + SubmissionSha256 = submissionSha256, + SubmissionId = submissionId, + Status = status + }); + } + catch (Exception checkpointException) + { + return new InvalidOperationException( + "The notarytool submission attempt ended without definitive terminal evidence, and the ambiguous remote mutation checkpoint could not be persisted. " + + "Do not resubmit until Apple notary history has been reconciled.", + new AggregateException( + new[] { processException, checkpointException }.Where(static value => value is not null).Cast())); + } + + return new InvalidOperationException( + "The notarytool submission attempt ended without a complete terminal submission id and status. The remote mutation is ambiguous; " + + "do not resubmit until Apple notary history has been reconciled.", + processException); + } + + private static AppleReleaseSourceMutationMonitor CreateArtifactMutationMonitor( + string artifactPath, + string scopeDescription, + string readerDescription, + string failureInstruction, + bool enableImmediately = true) + { + var fullPath = Path.GetFullPath(artifactPath); + var isDirectory = Directory.Exists(fullPath); + return new AppleReleaseSourceMutationMonitor( + Path.GetDirectoryName(fullPath)!, + scopeDescription, + readerDescription, + failureInstruction, + enableImmediately, + exactPath: fullPath, + includeExactPathDescendants: isDirectory); + } + + private static (string? Id, string? Status) ParseSubmission(ProcessRunResult result) + { + var payload = string.IsNullOrWhiteSpace(result.StdOut) ? result.StdErr : result.StdOut; + try + { + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + var id = root.TryGetProperty("id", out var idElement) ? idElement.GetString() : null; + var status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null; + return (id, status); + } + catch (JsonException) + { + return (null, null); + } + } +} diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 0640c9ef4..166c412e0 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -1,11 +1,10 @@ -using System.Text.Json; using System.Security.Cryptography; using System.Text; namespace PowerForge; /// Submits direct macOS artifacts for notarization and verifies the accepted result locally. -public sealed class AppleNotarizationService +public sealed partial class AppleNotarizationService { private readonly IProcessRunner _processRunner; @@ -40,28 +39,95 @@ public async Task NotarizeAsync( nameof(request)); } + var xcrunExecutable = ResolveAppleToolExecutable( + request.XcrunExecutable, + "xcrun", + "/usr/bin/xcrun", + request.RequireTrustedSystemTools); + var dittoExecutable = ResolveAppleToolExecutable( + request.DittoExecutable, + "ditto", + "/usr/bin/ditto", + request.RequireTrustedSystemTools); + var spctlExecutable = ResolveAppleToolExecutable( + request.SpctlExecutable, + "spctl", + "/usr/sbin/spctl", + request.RequireTrustedSystemTools); + var toolEnvironment = request.RequireTrustedSystemTools + ? AppleTrustedExecutionEnvironment.Create() + : null; + var artifactSha256 = ComputeArtifactSha256(artifactPath); var expectedArtifactSha256 = string.IsNullOrWhiteSpace(request.ExpectedArtifactSha256) ? null : request.ExpectedArtifactSha256!.Trim(); - if (expectedArtifactSha256 is not null && - !artifactSha256.Equals(expectedArtifactSha256, StringComparison.OrdinalIgnoreCase)) + var artifactChangedSinceCheckpoint = expectedArtifactSha256 is not null && + !artifactSha256.Equals(expectedArtifactSha256, StringComparison.OrdinalIgnoreCase); + if (artifactChangedSinceCheckpoint) { throw new InvalidOperationException( - $"The direct Apple artifact changed after notarization acceptance. Expected SHA-256 " + - $"'{expectedArtifactSha256}', received '{artifactSha256}'. Archive, export, and submit the changed artifact as a new release attempt."); + $"The direct Apple artifact changed after its last trusted notarization checkpoint. Expected SHA-256 " + + $"'{expectedArtifactSha256}', received '{artifactSha256}'. A stapler validation alone cannot prove artifact identity; " + + "re-export and reconcile the accepted submission before retrying."); } var timeout = request.Timeout <= TimeSpan.Zero ? TimeSpan.FromMinutes(30) : request.Timeout; var resumed = !string.IsNullOrWhiteSpace(request.AcceptedSubmissionId); if (request.StaplingCompleted && !resumed) throw new ArgumentException("StaplingCompleted requires AcceptedSubmissionId.", nameof(request)); - var submissionPath = resumed - ? artifactPath - : await PrepareSubmissionAsync(request, artifactPath, timeout, cancellationToken).ConfigureAwait(false); + var staplingCompleted = request.StaplingCompleted; + using var submissionSnapshot = AppleNotarizationInputSnapshot.Create(artifactPath, artifactSha256); + var submissionArtifactPath = submissionSnapshot.ArtifactPath; + using var packagingMonitor = !resumed && + extension.Equals(".app", StringComparison.OrdinalIgnoreCase) + ? new AppleReleaseSourceMutationMonitor( + submissionArtifactPath, + "private Apple notarization app snapshot", + "ditto", + "Discard the package and create a new notarization snapshot.") + : null; + var preparedSubmission = resumed + ? new PreparedSubmission(artifactPath, request.AcceptedSubmissionSha256?.Trim()) + : await PrepareSubmissionAsync( + request, + dittoExecutable, + toolEnvironment, + submissionArtifactPath, + submissionSnapshot.RootPath, + packagingMonitor, + timeout, + cancellationToken) + .ConfigureAwait(false); + var submittedPath = preparedSubmission.Path; + if (packagingMonitor is not null) + { + packagingMonitor.ValidateNoChanges(); + var observedPackagedArtifactSha256 = ComputeArtifactSha256(submissionArtifactPath); + if (!observedPackagedArtifactSha256.Equals(artifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple notarization app snapshot changed while ditto was packaging it. Expected SHA-256 " + + $"'{artifactSha256}', received '{observedPackagedArtifactSha256}'. Discard the package and create a new notarization snapshot."); + } + } + var submissionSha256 = resumed + ? request.AcceptedSubmissionSha256?.Trim() + : preparedSubmission.Sha256; ProcessRunResult submission; string? submissionId; string? status; + string? submittedFileMutationIdentity = null; + using var submissionMonitor = resumed + ? null + : new AppleReleaseSourceMutationMonitor( + submissionSnapshot.RootPath, + "private Apple notarization submission", + "notarytool", + "Do not resubmit until the accepted submission has been reconciled."); + var submissionPath = resumed + ? artifactPath + : ResolveRetainedSubmissionPath(request, artifactPath); if (resumed) { submissionId = request.AcceptedSubmissionId!.Trim(); @@ -79,53 +145,305 @@ public async Task NotarizeAsync( var authentication = BuildAuthenticationArguments(request); var submitArguments = new List { - "notarytool", "submit", submissionPath, "--wait", "--output-format", "json" + "notarytool", "submit", submittedPath, "--wait", "--output-format", "json" }; submitArguments.AddRange(authentication); - submission = await RunAsync(request.XcrunExecutable, artifactPath, submitArguments, timeout, cancellationToken).ConfigureAwait(false); - (submissionId, status) = ParseSubmission(submission); + submissionSnapshot.CompleteSubmissionCapture(artifactSha256); + submittedFileMutationIdentity = ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( + submittedPath, + "private Apple notarization submitted file"); + try + { + submission = await RunAsync(xcrunExecutable, submissionArtifactPath, submitArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); + (submissionId, status) = ParseSubmission(submission); + } + catch (Exception ex) + { + throw CreateAmbiguousSubmissionException( + request, + artifactPath, + artifactSha256, + submissionPath, + submissionSha256!, + submissionId: null, + status: null, + ex); + } + } + if (!resumed && + (string.IsNullOrWhiteSpace(submissionId) || + (!string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && + !string.Equals(status, "Invalid", StringComparison.OrdinalIgnoreCase)))) + { + throw CreateAmbiguousSubmissionException( + request, + artifactPath, + artifactSha256, + submissionPath, + submissionSha256!, + submissionId, + status); + } + using var acceptedArtifactMonitor = !resumed && + string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) + ? CreateArtifactMutationMonitor( + submissionArtifactPath, + "accepted private Apple notarization artifact", + "accepted checkpoint and retained submission capture", + "Do not staple or publish until the accepted submission has been reconciled.") + : null; + if (!resumed && + string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(submissionId)) + { + try + { + request.AcceptedCheckpoint?.Invoke(new AppleNotarizationAcceptedCheckpoint + { + ArtifactPath = artifactPath, + ArtifactSha256 = artifactSha256, + SubmissionPath = submissionPath, + SubmissionSha256 = submissionSha256!, + SubmissionId = submissionId!, + Status = status! + }); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Apple accepted notarization submission '{submissionId}', but its local recovery checkpoint could not be persisted. " + + "Do not resubmit the artifact until the accepted submission has been reconciled.", + ex); + } + } + if (!resumed) + { + try + { + submissionMonitor!.ValidateNoChanges(); + var observedSubmittedFileMutationIdentity = ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( + submittedPath, + "private Apple notarization submitted file"); + if (!string.Equals( + submittedFileMutationIdentity, + observedSubmittedFileMutationIdentity, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The private Apple notarization submitted file identity changed during notarytool execution. " + + "A transient write or hard-link alias invalidates the submitted bytes."); + } + var observedSubmissionSha256 = ComputeFileSha256(submittedPath); + if (!observedSubmissionSha256.Equals(submissionSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple notarization submission changed during notarytool execution. Expected SHA-256 " + + $"'{submissionSha256}', received '{observedSubmissionSha256}'."); + } + } + catch (Exception ex) when ( + submission.Succeeded && + string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(submissionId)) + { + throw new InvalidOperationException( + $"Apple accepted notarization submission '{submissionId}', but the exact submitted file changed while notarytool was reading it. " + + "Do not resubmit until the accepted submission has been reconciled.", + ex); + } } + if (!resumed) + PreserveSubmissionPath(artifactPath, submittedPath, submissionPath, submissionSha256!); + ProcessRunResult? staple = null; ProcessRunResult? validation = null; ProcessRunResult? assessment = null; + var stapledThisInvocation = false; + string? stapledArtifactSha256 = staplingCompleted ? artifactSha256 : null; + string? validatedStapledArtifactSha256 = null; + using var postStapleMonitor = submission.Succeeded && + string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && + request.Staple + ? CreateArtifactMutationMonitor( + submissionArtifactPath, + "validated private Apple notarization artifact", + "stapler production, validation, Gatekeeper assessment, and final publication", + "Discard the private artifact and resume from the last durable notarization checkpoint.", + enableImmediately: staplingCompleted) + : null; + if (!resumed && acceptedArtifactMonitor is not null) + { + try + { + acceptedArtifactMonitor!.ValidateNoChanges(); + var observedSubmissionSha256 = ComputeFileSha256(submittedPath); + if (!observedSubmissionSha256.Equals(submissionSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple notarization submission changed during notarytool execution. Expected SHA-256 " + + $"'{submissionSha256}', received '{observedSubmissionSha256}'."); + } + var observedArtifactSha256 = ComputeArtifactSha256(submissionArtifactPath); + if (!observedArtifactSha256.Equals(artifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple notarization artifact changed after Apple accepted it. Expected SHA-256 " + + $"'{artifactSha256}', received '{observedArtifactSha256}'."); + } + } + catch (Exception ex) when ( + submission.Succeeded && + string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(submissionId)) + { + throw new InvalidOperationException( + $"Apple accepted notarization submission '{submissionId}', but its exact submission or artifact changed before stapling. " + + "Do not resubmit until the accepted submission has been reconciled.", + ex); + } + } if (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Staple) { - if (request.StaplingCompleted) + if (staplingCompleted) { staple = new ProcessRunResult( 0, - "Skipped stapling because the retained receipt records that it already succeeded.", + "Skipped stapling because a retained exact post-staple checkpoint proves that it already succeeded.", string.Empty, - request.XcrunExecutable, + xcrunExecutable, TimeSpan.Zero, false); } else { - staple = await RunAsync(request.XcrunExecutable, artifactPath, new[] { "stapler", "staple", artifactPath }, timeout, cancellationToken).ConfigureAwait(false); + var preStapleArtifactSha256 = ComputeArtifactSha256(submissionArtifactPath); + if (!preStapleArtifactSha256.Equals(artifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple notarization artifact changed before stapling. Expected SHA-256 '{artifactSha256}', " + + $"received '{preStapleArtifactSha256}'. Discard the private artifact and resume from the last durable notarization checkpoint."); + } + staple = await RunAsync( + xcrunExecutable, + submissionArtifactPath, + new[] { "stapler", "staple", submissionArtifactPath }, + timeout, + toolEnvironment, + cancellationToken, + result => + { + if (result.Succeeded) + { + stapledArtifactSha256 = postStapleMonitor!.CaptureExpectedProducerOutput( + () => ComputeArtifactSha256(submissionArtifactPath), + "stapler"); + } + }) + .ConfigureAwait(false); + stapledThisInvocation = staple.Succeeded; + } + } + if (staple?.Succeeded == true) + { + validation = await RunAsync( + xcrunExecutable, + submissionArtifactPath, + new[] { "stapler", "validate", submissionArtifactPath }, + timeout, + toolEnvironment, + cancellationToken) + .ConfigureAwait(false); + if (validation.Succeeded) + { + validatedStapledArtifactSha256 = ComputeArtifactSha256(submissionArtifactPath); + if (!string.IsNullOrWhiteSpace(stapledArtifactSha256) && + !validatedStapledArtifactSha256.Equals(stapledArtifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple notarization artifact changed after stapler completed. Expected SHA-256 '{stapledArtifactSha256}', " + + $"received '{validatedStapledArtifactSha256}'. Discard the private artifact and resume from the last durable notarization checkpoint."); + } } - if (staple?.Succeeded == true) - validation = await RunAsync(request.XcrunExecutable, artifactPath, new[] { "stapler", "validate", artifactPath }, timeout, cancellationToken).ConfigureAwait(false); } if (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Assess) { + var assessmentIdentity = AppleArchiveUploadSnapshot.CaptureCompleteIdentity( + submissionArtifactPath, + "private Apple Gatekeeper assessment artifact"); + using var assessmentMonitor = CreateArtifactMutationMonitor( + submissionArtifactPath, + "private Apple Gatekeeper assessment artifact", + "spctl", + "Discard the private artifact and resume from the last durable notarization checkpoint."); var assessmentArguments = extension.Equals(".dmg", StringComparison.OrdinalIgnoreCase) - ? new[] { "--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=4", artifactPath } + ? new[] { "--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=4", submissionArtifactPath } : new[] { "--assess", "--type", extension.Equals(".app", StringComparison.OrdinalIgnoreCase) ? "execute" : "install", - "--verbose=4", artifactPath + "--verbose=4", submissionArtifactPath }; - assessment = await RunAsync(request.SpctlExecutable, artifactPath, assessmentArguments, timeout, cancellationToken).ConfigureAwait(false); + assessment = await RunAsync(spctlExecutable, submissionArtifactPath, assessmentArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); + assessmentMonitor.ValidateNoChanges(); + var observedAssessmentIdentity = AppleArchiveUploadSnapshot.CaptureCompleteIdentity( + submissionArtifactPath, + "private Apple Gatekeeper assessment artifact"); + if (!assessmentIdentity.Equals(observedAssessmentIdentity)) + { + throw new InvalidOperationException( + "The private Apple Gatekeeper assessment artifact changed while spctl was reading it. " + + "A transient write or hard-link alias invalidates the assessment result. " + + "Discard the private artifact and resume from the last durable notarization checkpoint."); + } + } + + string finalArtifactSha256; + if (request.Staple && + staple?.Succeeded == true && + validation?.Succeeded == true && + validatedStapledArtifactSha256 is not null) + { + postStapleMonitor?.ValidateNoChanges(); + finalArtifactSha256 = submissionSnapshot.PublishTo(artifactPath, validatedStapledArtifactSha256); + if (stapledThisInvocation && !string.IsNullOrWhiteSpace(submissionId)) + { + try + { + request.StapledCheckpoint?.Invoke(new AppleNotarizationStapledCheckpoint + { + ArtifactPath = artifactPath, + ArtifactSha256 = finalArtifactSha256, + SubmissionSha256 = submissionSha256 ?? string.Empty, + SubmissionId = submissionId!, + Status = status ?? "Accepted" + }); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Apple notarization submission '{submissionId}' was stapled and validated, but its local recovery checkpoint could not be persisted. " + + "Do not replace or resubmit the artifact until the stapled submission has been reconciled.", + ex); + } + } + } + else + { + finalArtifactSha256 = ComputeArtifactSha256(artifactPath); + if (!finalArtifactSha256.Equals(artifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The public Apple notarization artifact changed while its private snapshot was being processed. Expected '{artifactSha256}', received '{finalArtifactSha256}'."); + } } return new AppleNotarizationResult { ArtifactPath = artifactPath, - ArtifactSha256 = ComputeArtifactSha256(artifactPath), + ArtifactSha256 = finalArtifactSha256, SubmissionPath = submissionPath, + SubmissionSha256 = submissionSha256, SubmissionId = submissionId, Status = status, ResumedAcceptedSubmission = resumed, @@ -136,28 +454,141 @@ public async Task NotarizeAsync( }; } - private async Task PrepareSubmissionAsync( + private async Task PrepareSubmissionAsync( AppleNotarizationRequest request, + string dittoExecutable, + IReadOnlyDictionary? toolEnvironment, string artifactPath, + string privateRoot, + AppleReleaseSourceMutationMonitor? inputMonitor, TimeSpan timeout, CancellationToken cancellationToken) { if (!Path.GetExtension(artifactPath).Equals(".app", StringComparison.OrdinalIgnoreCase)) - return artifactPath; + return new PreparedSubmission(artifactPath, ComputeFileSha256(artifactPath)); - var submissionPath = string.IsNullOrWhiteSpace(request.SubmissionPath) - ? Path.Combine(Path.GetDirectoryName(artifactPath)!, Path.GetFileNameWithoutExtension(artifactPath) + ".notarization.zip") - : Path.GetFullPath(request.SubmissionPath!); + var submissionPath = Path.Combine( + privateRoot, + Path.GetFileNameWithoutExtension(artifactPath) + ".notarization.zip"); Directory.CreateDirectory(Path.GetDirectoryName(submissionPath)!); + using var producerMonitor = new AppleReleaseSourceMutationMonitor( + privateRoot, + "private Apple notarization packaging root", + "ditto", + "Discard the package and create a new notarization snapshot.", + enableImmediately: false); + string? packagedSha256 = null; var package = await RunAsync( - request.DittoExecutable, + dittoExecutable, artifactPath, new[] { "-c", "-k", "--keepParent", artifactPath, submissionPath }, timeout, - cancellationToken).ConfigureAwait(false); + toolEnvironment, + cancellationToken, + completionResult => + { + if (!completionResult.Succeeded) + return; + packagedSha256 = producerMonitor.CaptureExpectedProducerOutput( + () => ComputeFileSha256(submissionPath), + "ditto"); + inputMonitor?.ValidateNoChanges(); + }).ConfigureAwait(false); if (!package.Succeeded) throw new InvalidOperationException($"ditto failed to package '{artifactPath}' for notarization with exit code {package.ExitCode}: {package.StdErr}"); - return submissionPath; + producerMonitor.ValidateNoChanges(); + if (string.IsNullOrWhiteSpace(packagedSha256)) + throw new InvalidOperationException("ditto completed without binding the exact notarization ZIP at its process completion boundary."); + return new PreparedSubmission(submissionPath, packagedSha256); + } + + private sealed class PreparedSubmission + { + internal PreparedSubmission(string path, string? sha256) + { + Path = path; + Sha256 = sha256; + } + + internal string Path { get; } + + internal string? Sha256 { get; } + } + + private static string ResolveRetainedSubmissionPath( + AppleNotarizationRequest request, + string originalArtifactPath) + { + if (!Path.GetExtension(originalArtifactPath).Equals(".app", StringComparison.OrdinalIgnoreCase)) + return originalArtifactPath; + + return string.IsNullOrWhiteSpace(request.SubmissionPath) + ? Path.Combine( + Path.GetDirectoryName(originalArtifactPath)!, + Path.GetFileNameWithoutExtension(originalArtifactPath) + ".notarization.zip") + : Path.GetFullPath(request.SubmissionPath!); + } + + private static void PreserveSubmissionPath( + string originalArtifactPath, + string submittedPath, + string retainedPath, + string expectedSubmissionSha256) + { + if (!Path.GetExtension(originalArtifactPath).Equals(".app", StringComparison.OrdinalIgnoreCase)) + return; + + var directory = Path.GetDirectoryName(retainedPath)!; + Directory.CreateDirectory(directory); + var temporaryPath = Path.Combine(directory, $".{Path.GetFileName(retainedPath)}.{Guid.NewGuid():N}.tmp"); + try + { + using (var source = new FileStream(submittedPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var destination = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 16 * 1024, + options: FileOptions.WriteThrough)) + { + source.CopyTo(destination); + destination.Flush(flushToDisk: true); + } + + var temporarySha256 = ComputeFileSha256(temporaryPath); + if (!temporarySha256.Equals(expectedSubmissionSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The staged retained Apple notarization submission does not match the exact accepted file. Expected SHA-256 " + + $"'{expectedSubmissionSha256}', received '{temporarySha256}'."); + } + + if (Directory.Exists(retainedPath)) + throw new IOException($"The retained Apple notarization submission path is a directory: {retainedPath}"); + if (File.Exists(retainedPath)) + { + if ((File.GetAttributes(retainedPath) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException($"The retained Apple notarization submission path must not be linked: {retainedPath}"); + File.Replace(temporaryPath, retainedPath, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + else + { + File.Move(temporaryPath, retainedPath); + } + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + var retainedSha256 = ComputeFileSha256(retainedPath); + if (!retainedSha256.Equals(expectedSubmissionSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The retained Apple notarization submission does not match the exact accepted file. Expected SHA-256 " + + $"'{expectedSubmissionSha256}', received '{retainedSha256}'."); + } } private static string[] BuildAuthenticationArguments(AppleNotarizationRequest request) @@ -174,40 +605,56 @@ private static string[] BuildAuthenticationArguments(AppleNotarizationRequest re return new[] { "--key", keyPath, "--key-id", request.ApiKeyId!.Trim(), "--issuer", request.ApiIssuerId!.Trim() }; } - private Task RunAsync( + private async Task RunAsync( string executable, string artifactPath, IReadOnlyList arguments, TimeSpan timeout, - CancellationToken cancellationToken) - => _processRunner.RunAsync( - new ProcessRunRequest( - string.IsNullOrWhiteSpace(executable) ? "xcrun" : executable.Trim(), + IReadOnlyDictionary? environmentVariables, + CancellationToken cancellationToken, + Action? completionBoundary = null) + { + var processRequest = new ProcessRunRequest( + executable, Path.GetDirectoryName(artifactPath) ?? Directory.GetCurrentDirectory(), arguments, - timeout), - cancellationToken); + timeout, + environmentVariables, + captureOutput: true, + captureError: true, + inheritEnvironment: environmentVariables is null); + if (completionBoundary is not null) + processRequest.SetCompletionBoundary(completionBoundary); + var result = await _processRunner.RunAsync(processRequest, cancellationToken).ConfigureAwait(false); + processRequest.InvokeCompletionBoundary(result); + return result; + } - private static (string? Id, string? Status) ParseSubmission(ProcessRunResult result) + private static string ResolveAppleToolExecutable( + string? executable, + string defaultName, + string trustedPath, + bool requireTrustedSystemTool) { - var payload = string.IsNullOrWhiteSpace(result.StdOut) ? result.StdErr : result.StdOut; - try - { - using var document = JsonDocument.Parse(payload); - var root = document.RootElement; - var id = root.TryGetProperty("id", out var idElement) ? idElement.GetString() : null; - var status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null; - return (id, status); - } - catch (JsonException) + var value = string.IsNullOrWhiteSpace(executable) + ? defaultName + : executable!.Trim(); + if (!requireTrustedSystemTool) + return value; + if (value.Equals(defaultName, StringComparison.Ordinal) || + value.Equals(trustedPath, StringComparison.Ordinal)) { - return (null, null); + return trustedPath; } + + throw new InvalidOperationException( + $"Exact-source Apple notarization requires the trusted system tool '{trustedPath}'; received '{value}'."); } internal static string ComputeArtifactSha256(string artifactPath) { using var sha256 = SHA256.Create(); + AppendValue(sha256, "PowerForge.ArtifactSha256.v2"); if (File.Exists(artifactPath)) { AppendFileSystemEntry( @@ -255,15 +702,22 @@ internal static string ComputeArtifactSha256(string artifactPath) return BitConverter.ToString(sha256.Hash!).Replace("-", string.Empty).ToLowerInvariant(); } + internal static string ComputeFileSha256(string path) + { + using var sha256 = SHA256.Create(); + using var stream = File.OpenRead(path); + return BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); + } + private static void AppendFileSystemEntry( HashAlgorithm hash, FileSystemInfo entry, string relativePath, bool includeContents) { + AppendValue(hash, "entry"); AppendValue(hash, relativePath); AppendValue(hash, ((int)entry.Attributes).ToString(System.Globalization.CultureInfo.InvariantCulture)); - AppendValue(hash, entry.LastWriteTimeUtc.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture)); #if NET8_0_OR_GREATER if (OperatingSystem.IsWindows()) { @@ -282,26 +736,42 @@ private static void AppendFileSystemEntry( AppendValue(hash, string.Empty); AppendValue(hash, string.Empty); #endif + AppendValue(hash, includeContents ? "file" : "metadata"); if (includeContents) AppendFile(hash, entry.FullName); - AppendBytes(hash, new byte[] { 0xff }); } private static void AppendValue(HashAlgorithm hash, string value) { - AppendBytes(hash, Encoding.UTF8.GetBytes(value)); - AppendBytes(hash, new byte[] { 0 }); + var bytes = Encoding.UTF8.GetBytes(value); + AppendLength(hash, bytes.LongLength); + AppendBytes(hash, bytes); } private static void AppendFile(HashAlgorithm hash, string path) { using var stream = File.OpenRead(path); + AppendLength(hash, stream.Length); var buffer = new byte[81920]; int read; while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) hash.TransformBlock(buffer, 0, read, buffer, 0); } + private static void AppendLength(HashAlgorithm hash, long value) + { + var bytes = new byte[sizeof(long)]; + for (var index = bytes.Length - 1; index >= 0; index--) + { + bytes[index] = (byte)(value & 0xff); + value >>= 8; + } + AppendBytes(hash, bytes); + } + private static void AppendBytes(HashAlgorithm hash, byte[] bytes) - => hash.TransformBlock(bytes, 0, bytes.Length, bytes, 0); + { + if (bytes.Length > 0) + hash.TransformBlock(bytes, 0, bytes.Length, bytes, 0); + } } diff --git a/PowerForge/Services/AppleReleaseArtifactService.cs b/PowerForge/Services/AppleReleaseArtifactService.cs index c01647411..2f592092b 100644 --- a/PowerForge/Services/AppleReleaseArtifactService.cs +++ b/PowerForge/Services/AppleReleaseArtifactService.cs @@ -5,15 +5,6 @@ namespace PowerForge; /// internal sealed class AppleReleaseArtifactService { - private static readonly StringComparison PathComparison = - Path.DirectorySeparatorChar == '\\' - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - private static readonly StringComparer PathComparer = - Path.DirectorySeparatorChar == '\\' - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - private readonly Func _getAvailableBytes; private readonly Func _utcNow; @@ -25,12 +16,14 @@ internal AppleReleaseArtifactService( _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); } - internal PowerForgeAppleReleaseCleanupReceipt Preflight(PowerForgeAppleReleasePlan plan) + internal PowerForgeAppleReleaseCleanupReceipt Preflight( + PowerForgeAppleReleasePlan plan, + IEnumerable? protectedPaths = null) { if (plan is null) throw new ArgumentNullException(nameof(plan)); var cleanup = plan.Automation.CleanupBeforeArchive - ? RemoveStaleArtifacts(plan) + ? RemoveStaleArtifacts(plan, protectedPaths) : new PowerForgeAppleReleaseCleanupReceipt(); var availableBytes = _getAvailableBytes(plan.ProjectRoot); @@ -46,21 +39,38 @@ internal PowerForgeAppleReleaseCleanupReceipt Preflight(PowerForgeAppleReleasePl return cleanup; } - internal PowerForgeAppleReleaseCleanupReceipt RemoveStaleArtifacts(PowerForgeAppleReleasePlan plan) + internal PowerForgeAppleReleaseCleanupReceipt RemoveStaleArtifacts( + PowerForgeAppleReleasePlan plan, + IEnumerable? protectedPaths = null) { if (plan is null) throw new ArgumentNullException(nameof(plan)); var roots = GetConfiguredRoots(plan); + var protectedFullPaths = (protectedPaths ?? Array.Empty()) + .Where(static path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .ToArray(); var cutoff = _utcNow().UtcDateTime.AddDays(-Math.Max(0, plan.Automation.ArtifactRetentionDays)); var candidates = roots .Where(Directory.Exists) .SelectMany(static root => Directory.EnumerateFileSystemEntries(root)) + .Where(path => !protectedFullPaths.Any(protectedPath => PathsOverlap(path, protectedPath))) .Where(path => GetLastWriteTimeUtc(path) <= cutoff) .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) .ToArray(); return RemovePaths(plan, candidates); } + private static bool PathsOverlap(string first, string second) + { + var left = Path.GetFullPath(first).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var right = Path.GetFullPath(second).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var comparison = GetPathComparison(left, right); + return left.Equals(right, comparison) || + left.StartsWith(right + Path.DirectorySeparatorChar, comparison) || + right.StartsWith(left + Path.DirectorySeparatorChar, comparison); + } + internal PowerForgeAppleReleaseCleanupReceipt RemoveCurrentArtifacts( PowerForgeAppleReleasePlan plan, IEnumerable? apps = null) @@ -71,8 +81,8 @@ internal PowerForgeAppleReleaseCleanupReceipt RemoveCurrentArtifacts( var paths = selected .SelectMany(static app => new[] { app.ArchivePath, app.ExportPath }) .Where(static path => !string.IsNullOrWhiteSpace(path)) - .Distinct(PathComparer) .ToArray(); + paths = DistinctPaths(paths); return RemovePaths(plan, paths); } @@ -119,8 +129,8 @@ private static string[] GetConfiguredRoots(PowerForgeAppleReleasePlan plan) }) .Where(static path => !string.IsNullOrWhiteSpace(path)) .Select(static path => Path.GetFullPath(path!)) - .Distinct(PathComparer) .ToArray(); + roots = DistinctPaths(roots); if (roots.Length == 0) throw new InvalidOperationException("Apple release artifact roots could not be resolved."); if (roots.Any(root => !IsWithinRoot(root, plan.ProjectRoot))) @@ -130,12 +140,14 @@ private static string[] GetConfiguredRoots(PowerForgeAppleReleasePlan plan) return roots; } - private static bool IsWithinRoot(string path, string root) + /// Checks path containment using the owning volumes' actual case semantics. + internal static bool IsWithinRoot(string path, string root) { var fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return fullPath.Equals(fullRoot, PathComparison) || - fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, PathComparison); + var comparison = GetPathComparison(fullPath, fullRoot); + return fullPath.Equals(fullRoot, comparison) || + fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison); } private static void EnsureNoReparsePoints(string projectRoot, string path) @@ -156,7 +168,7 @@ private static void EnsureNoReparsePoints(string projectRoot, string path) $"Refusing to remove Apple release artifact through a symbolic link or reparse point: {current}"); } - if (current.Equals(root, PathComparison)) + if (PathsEqual(current, root)) break; current = Path.GetDirectoryName(current) ?? throw new InvalidOperationException($"Unable to inspect Apple release artifact path: {path}"); @@ -267,7 +279,7 @@ exception is UnauthorizedAccessException || return current; } - if (current.Equals(root, PathComparison)) + if (PathsEqual(current, root)) break; current = Path.GetDirectoryName(current); } @@ -275,6 +287,32 @@ exception is UnauthorizedAccessException || return null; } + private static string[] DistinctPaths(IEnumerable paths) + { + var distinct = new List(); + foreach (var path in paths) + { + if (!distinct.Any(existing => PathsEqual(existing, path))) + distinct.Add(path); + } + return distinct.ToArray(); + } + + private static bool PathsEqual(string first, string second) + => Path.GetFullPath(first).Equals(Path.GetFullPath(second), GetPathComparison(first, second)); + + private static StringComparison GetPathComparison(string first, string second) + => FrameworkCompatibility.GetPathStringComparisonForPath(GetComparisonProbePath(first)) == StringComparison.OrdinalIgnoreCase || + FrameworkCompatibility.GetPathStringComparisonForPath(GetComparisonProbePath(second)) == StringComparison.OrdinalIgnoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + private static string GetComparisonProbePath(string path) + { + var fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return Path.GetDirectoryName(fullPath) ?? fullPath; + } + private static DateTime GetLastWriteTimeUtc(string path) => File.Exists(path) ? File.GetLastWriteTimeUtc(path) diff --git a/PowerForge/Services/AppleReleaseReceiptJournalLease.cs b/PowerForge/Services/AppleReleaseReceiptJournalLease.cs new file mode 100644 index 000000000..b89a02aae --- /dev/null +++ b/PowerForge/Services/AppleReleaseReceiptJournalLease.cs @@ -0,0 +1,152 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace PowerForge; + +/// +/// Serializes receipt-chain transactions that share either the latest receipt or immutable history path. +/// +internal sealed class AppleReleaseReceiptJournalLease : IDisposable +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + private readonly FileStream[] _streams; + + private AppleReleaseReceiptJournalLease(FileStream[] streams) + { + _streams = streams; + } + + internal static AppleReleaseReceiptJournalLease Acquire(PowerForgeAppleReleasePlan plan) + { + if (plan is null) + throw new ArgumentNullException(nameof(plan)); + + var lockPaths = new[] + { + CreateLockPath(plan.ReceiptPath), + CreateLockPath(plan.ReceiptHistoryPath) + } + .Distinct(Path.DirectorySeparatorChar == '\\' + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + var streams = new List(lockPaths.Length); + try + { + foreach (var lockPath in lockPaths) + streams.Add(AcquireOne(lockPath)); + return new AppleReleaseReceiptJournalLease(streams.ToArray()); + } + catch + { + for (var index = streams.Count - 1; index >= 0; index--) + streams[index].Dispose(); + throw; + } + } + + private static FileStream AcquireOne(string lockPath) + { + var directory = Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException($"Apple receipt journal lock path has no parent: {lockPath}"); + Directory.CreateDirectory(directory); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + { + if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException($"Apple receipt journal lock root must not be a symbolic link: {directory}"); + File.SetUnixFileMode( + directory, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } +#endif + var stopwatch = Stopwatch.StartNew(); + while (true) + { + try + { + if (File.Exists(lockPath) && + (File.GetAttributes(lockPath) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"Apple receipt journal lock must not be a symbolic link or reparse point: {lockPath}"); + } + +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + { + return new FileStream( + lockPath, + new FileStreamOptions + { + Mode = FileMode.OpenOrCreate, + Access = FileAccess.ReadWrite, + Share = FileShare.None, + BufferSize = 1, + Options = FileOptions.None, + UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite + }); + } +#endif + return new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + } + catch (IOException exception) + { + if (stopwatch.Elapsed < DefaultTimeout) + { + Thread.Sleep(25); + continue; + } + + throw new TimeoutException( + $"Timed out waiting for another process to finish updating the Apple receipt journal protected by '{lockPath}'.", + exception); + } + catch (UnauthorizedAccessException exception) + { + if (stopwatch.Elapsed < DefaultTimeout) + { + Thread.Sleep(25); + continue; + } + + throw new TimeoutException( + $"Timed out waiting for another process to finish updating the Apple receipt journal protected by '{lockPath}'.", + exception); + } + } + } + + internal static string CreateLockPath(string resourcePath) + { + var fullPath = Path.GetFullPath(resourcePath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var hashPath = FrameworkCompatibility.GetPathStringComparisonForPath(fullPath) == StringComparison.OrdinalIgnoreCase + ? fullPath.ToUpperInvariant() + : fullPath; + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(hashPath)); + var key = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + var resourceDirectory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException( + $"Apple receipt journal resource path has no parent: {resourcePath}"); + return Path.Combine( + resourceDirectory, + ".powerforge-receipt-journal-locks", + $"{key}.lock"); + } + + public void Dispose() + { + for (var index = _streams.Length - 1; index >= 0; index--) + _streams[index].Dispose(); + } +} diff --git a/PowerForge/Services/AppleReleaseReceiptStore.cs b/PowerForge/Services/AppleReleaseReceiptStore.cs new file mode 100644 index 000000000..d9a2eda37 --- /dev/null +++ b/PowerForge/Services/AppleReleaseReceiptStore.cs @@ -0,0 +1,592 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PowerForge; + +/// +/// Persists immutable Apple release attempts while maintaining an atomic latest-state receipt. +/// +internal sealed class AppleReleaseReceiptStore +{ + private const long MaximumReceiptBytes = 2L * 1024L * 1024L; + private const int LegacyAuthenticatedReceiptSchemaVersion = 5; + private const int CurrentReceiptSchemaVersion = 6; + private const string AuthenticationKeyEnvironmentVariable = "POWERFORGE_APPLE_RECEIPT_AUTH_KEY_PATH"; + private static readonly StringComparison PathComparison = + Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + private readonly Func _utcNow; + + internal AppleReleaseReceiptStore(Func? utcNow = null) + { + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); + } + + /// + /// Reads the atomic latest receipt and every immutable history entry, rejecting malformed evidence. + /// + internal PowerForgeAppleReleaseReceipt[] ReadAll(PowerForgeAppleReleasePlan plan) + { + if (plan is null) + throw new ArgumentNullException(nameof(plan)); + + var receipts = new List(); + var identities = new HashSet(StringComparer.OrdinalIgnoreCase); + if (File.Exists(plan.ReceiptPath)) + AddReceipt(plan, plan.ReceiptPath, receipts, identities); + + if (Directory.Exists(plan.ReceiptHistoryPath)) + { + EnsureUnlinkedPath(plan.ProjectRoot, plan.ReceiptHistoryPath, "Apple receipt history"); + foreach (var entry in Directory.EnumerateFileSystemEntries(plan.ReceiptHistoryPath) + .OrderBy(static path => path, StringComparer.Ordinal)) + { + if (!File.Exists(entry) || + !Path.GetExtension(entry).Equals(".json", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Apple receipt history contains an unsupported entry: {entry}"); + } + + AddReceipt(plan, entry, receipts, identities); + } + } + + ValidateReceiptChain(plan, receipts); + return OrderReceipts(receipts); + } + + /// Validates every configured receipt path and the complete evidence chain without writing. + internal void Validate(PowerForgeAppleReleasePlan plan) + { + if (plan is null) + throw new ArgumentNullException(nameof(plan)); + + EnsureSafeOutputPath(plan.ProjectRoot, plan.ReceiptPath, "AppleApps.Automation.ReceiptPath"); + EnsureSafeOutputPath(plan.ProjectRoot, plan.ReceiptHistoryPath, "AppleApps.Automation.ReceiptHistoryPath"); + _ = ReadAll(plan); + } + + /// + /// Writes one immutable attempt receipt and atomically updates the configured latest receipt. + /// + internal void WriteAttempt(PowerForgeAppleReleasePlan plan, PowerForgeAppleReleaseReceipt receipt) + { + if (plan is null) + throw new ArgumentNullException(nameof(plan)); + if (receipt is null) + throw new ArgumentNullException(nameof(receipt)); + + EnsureSafeOutputPath(plan.ProjectRoot, plan.ReceiptPath, "AppleApps.Automation.ReceiptPath"); + EnsureSafeOutputPath(plan.ProjectRoot, plan.ReceiptHistoryPath, "AppleApps.Automation.ReceiptHistoryPath"); + + var suppliedAttemptId = receipt.AttemptId; + var normalizedAttemptId = string.IsNullOrWhiteSpace(suppliedAttemptId) + ? Guid.NewGuid().ToString("N") + : suppliedAttemptId!.Trim().ToLowerInvariant(); + if (normalizedAttemptId.Length != 32 || normalizedAttemptId.Any(static value => !Uri.IsHexDigit(value))) + throw new InvalidOperationException("Apple receipt attempt id must contain exactly 32 hexadecimal characters."); + receipt.AttemptId = normalizedAttemptId; + + if (receipt.CheckedAt == default) + receipt.CheckedAt = _utcNow(); + + using var journalLease = AppleReleaseReceiptJournalLease.Acquire(plan); + var previousReceipt = ReadAll(plan).FirstOrDefault(); + var historyDirectory = Path.GetFullPath(plan.ReceiptHistoryPath); + Directory.CreateDirectory(historyDirectory); + EnsureUnlinkedPath(plan.ProjectRoot, historyDirectory, "Apple receipt history"); + PreserveLegacyLatest(plan, previousReceipt, historyDirectory); + + var safeAction = receipt.Action.ToString().ToLowerInvariant(); + var timestamp = receipt.CheckedAt.UtcDateTime.ToString( + "yyyyMMdd'T'HHmmss.fffffff'Z'", + System.Globalization.CultureInfo.InvariantCulture); + var historyPath = Path.Combine(historyDirectory, $"{timestamp}-{safeAction}-{receipt.AttemptId}.json"); + receipt.ReceiptPath = ToRelativePath(plan.ProjectRoot, plan.ReceiptPath); + receipt.HistoryPath = ToRelativePath(plan.ProjectRoot, historyPath); + receipt.PreviousReceiptSha256 = previousReceipt?.ReceiptSha256; + receipt.SchemaVersion = CurrentReceiptSchemaVersion; + receipt.ReceiptAuthenticationSha256 = null; + receipt.ReceiptSha256 = ComputeReceiptSha256(receipt); + + var payload = Serialize(receipt); + WriteImmutableHistoryEntry(historyDirectory, historyPath, payload); + + WriteLatest(plan.ProjectRoot, plan.ReceiptPath, payload, "AppleApps.Automation.ReceiptPath"); + } + + /// Atomically writes a non-journaled Apple plan receipt. + internal void WritePlan(string projectRoot, string path, PowerForgeAppleReleaseReceipt receipt) + { + if (receipt is null) + throw new ArgumentNullException(nameof(receipt)); + WriteLatest(projectRoot, path, Serialize(receipt), "AppleApps.Automation.PlanReceiptPath"); + } + + /// Computes the canonical SHA-256 stored inside an immutable receipt. + internal static string ComputeReceiptSha256(PowerForgeAppleReleaseReceipt receipt) + { + if (receipt is null) + throw new ArgumentNullException(nameof(receipt)); + + using var document = JsonDocument.Parse(JsonSerializer.SerializeToUtf8Bytes(receipt, CreateOptions(writeIndented: false))); + return ComputeReceiptSha256(document.RootElement); + } + + /// + /// Computes the receipt hash from the represented JSON rather than the current CLR model. This keeps + /// historical hashes valid when a future PowerForge version adds optional receipt properties. + /// + internal static string ComputeReceiptSha256Json(string json) + { + using var document = JsonDocument.Parse(json); + return ComputeReceiptSha256(document.RootElement); + } + + private static void AddReceipt( + PowerForgeAppleReleasePlan plan, + string path, + ICollection receipts, + ISet identities) + { + var receipt = Read(plan.ProjectRoot, path); + var identity = !string.IsNullOrWhiteSpace(receipt.ReceiptSha256) + ? receipt.ReceiptSha256! + : $"legacy:{receipt.CheckedAt:O}:{receipt.Action}:{receipt.SourceCommit}"; + if (identities.Add(identity)) + receipts.Add(receipt); + } + + private static void ValidateReceiptChain( + PowerForgeAppleReleasePlan plan, + IReadOnlyCollection receipts) + { + var hashes = receipts + .Where(static receipt => !string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + .Select(static receipt => receipt.ReceiptSha256!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var attemptHashes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var previousHashes = new HashSet(StringComparer.OrdinalIgnoreCase); + var rootCount = 0; + foreach (var receipt in receipts) + { + if (string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + continue; + if (string.IsNullOrWhiteSpace(receipt.AttemptId) || + receipt.AttemptId!.Length != 32 || + receipt.AttemptId.Any(static value => !Uri.IsHexDigit(value))) + { + throw new InvalidOperationException("Apple release receipt has an invalid attempt id."); + } + if (attemptHashes.TryGetValue(receipt.AttemptId, out var existingHash) && + !existingHash.Equals(receipt.ReceiptSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Apple release receipt attempt '{receipt.AttemptId}' has conflicting evidence."); + } + + attemptHashes[receipt.AttemptId] = receipt.ReceiptSha256!; + ValidateHistoryBinding(plan, receipt); + if (string.IsNullOrWhiteSpace(receipt.PreviousReceiptSha256)) + { + rootCount++; + continue; + } + if (!IsSha256(receipt.PreviousReceiptSha256) || + receipt.PreviousReceiptSha256!.Equals(receipt.ReceiptSha256, StringComparison.OrdinalIgnoreCase) || + !hashes.Contains(receipt.PreviousReceiptSha256) || + !previousHashes.Add(receipt.PreviousReceiptSha256)) + { + throw new InvalidOperationException( + $"Apple release receipt attempt '{receipt.AttemptId}' has a broken previous-receipt chain."); + } + } + + if (attemptHashes.Count > 0 && rootCount != 1) + { + throw new InvalidOperationException( + "Apple release receipt history must contain exactly one complete evidence chain."); + } + } + + private static void ValidateHistoryBinding( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleReleaseReceipt receipt) + { + if (string.IsNullOrWhiteSpace(receipt.HistoryPath)) + throw new InvalidOperationException("Immutable Apple release receipt is missing its history path."); + + var historyRoot = Path.GetFullPath(plan.ReceiptHistoryPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var historyPath = Path.GetFullPath(Path.Combine(plan.ProjectRoot, receipt.HistoryPath!)); + if (!IsWithinRoot(historyPath, historyRoot) || !File.Exists(historyPath)) + { + throw new InvalidOperationException( + $"Immutable Apple release receipt history entry is missing or outside the configured history directory: {receipt.HistoryPath}"); + } + + EnsureUnlinkedPath(plan.ProjectRoot, historyPath, "Apple release receipt history entry"); + var historyReceipt = Read(plan.ProjectRoot, historyPath); + if (!string.Equals(historyReceipt.ReceiptSha256, receipt.ReceiptSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Immutable Apple release receipt history entry does not contain its declared receipt: {receipt.HistoryPath}"); + } + } + + private static PowerForgeAppleReleaseReceipt[] OrderReceipts( + IReadOnlyCollection receipts) + { + var hashed = receipts + .Where(static receipt => !string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + .ToDictionary(static receipt => receipt.ReceiptSha256!, StringComparer.OrdinalIgnoreCase); + var referenced = hashed.Values + .Where(static receipt => !string.IsNullOrWhiteSpace(receipt.PreviousReceiptSha256)) + .Select(static receipt => receipt.PreviousReceiptSha256!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var ordered = new List(); + if (hashed.Count > 0) + { + var tail = hashed.Values.Single(receipt => !referenced.Contains(receipt.ReceiptSha256!)); + var current = tail; + while (true) + { + ordered.Add(current); + if (string.IsNullOrWhiteSpace(current.PreviousReceiptSha256)) + break; + current = hashed[current.PreviousReceiptSha256!]; + } + + if (ordered.Count != hashed.Count) + throw new InvalidOperationException("Apple release receipt history contains a disconnected evidence chain."); + } + + ordered.AddRange(receipts + .Where(static receipt => string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + .OrderByDescending(static receipt => receipt.CheckedAt)); + return ordered.ToArray(); + } + + private static PowerForgeAppleReleaseReceipt Read(string projectRoot, string path) + { + EnsureUnlinkedPath(projectRoot, path, "Apple release receipt"); + var file = new FileInfo(path); + if (file.Length > MaximumReceiptBytes) + throw new InvalidOperationException($"Apple release receipt exceeds {MaximumReceiptBytes} bytes: {path}"); + + PowerForgeAppleReleaseReceipt receipt; + string payload; + try + { + payload = File.ReadAllText(path); + receipt = JsonSerializer.Deserialize( + payload, + CreateOptions(writeIndented: false)) + ?? throw new InvalidOperationException($"Apple release receipt is empty: {path}"); + } + catch (JsonException exception) + { + throw new InvalidOperationException($"Apple release receipt is not valid JSON: {path}", exception); + } + + if (receipt.SchemaVersion >= 4 && string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + { + throw new InvalidOperationException( + $"Apple release receipt schema {receipt.SchemaVersion} is missing its required integrity SHA-256: {path}"); + } + + if (!string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + { + var expected = receipt.ReceiptSha256!.Trim(); + if (expected.Length != 64 || expected.Any(static value => !Uri.IsHexDigit(value))) + throw new InvalidOperationException($"Apple release receipt has an invalid SHA-256: {path}"); + var actual = ComputeReceiptSha256Json(payload); + if (!actual.Equals(expected, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Apple release receipt integrity validation failed: {path}"); + } + + if (receipt.SchemaVersion > CurrentReceiptSchemaVersion) + throw new InvalidOperationException($"Apple release receipt schema {receipt.SchemaVersion} is not supported: {path}"); + + // Schema 5 used a machine-local HMAC as a tamper check. It remains readable for compatibility, + // but release recovery never treats that same-account-readable key as operator authority. + if (receipt.SchemaVersion == LegacyAuthenticatedReceiptSchemaVersion) + { + var authentication = receipt.ReceiptAuthenticationSha256?.Trim(); + if (!IsSha256(authentication) || string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + throw new InvalidOperationException($"Apple release receipt is missing its required recovery authentication: {path}"); + var actualAuthentication = ComputeReceiptAuthenticationSha256(receipt.ReceiptSha256!); + if (!FixedTimeHexEquals(authentication!, actualAuthentication)) + throw new InvalidOperationException($"Apple release receipt recovery authentication failed: {path}"); + } + + return receipt; + } + + private static string ComputeReceiptSha256(JsonElement receipt) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false })) + WriteCanonicalJson(writer, receipt, omitReceiptHash: true); + using var sha256 = SHA256.Create(); + return ToLowerHex(sha256.ComputeHash(stream.ToArray())); + } + + private static void WriteCanonicalJson(Utf8JsonWriter writer, JsonElement element, bool omitReceiptHash) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (var property in element.EnumerateObject().OrderBy(static value => value.Name, StringComparer.Ordinal)) + { + if (omitReceiptHash && + (property.Name.Equals("receiptSha256", StringComparison.OrdinalIgnoreCase) || + property.Name.Equals("receiptAuthenticationSha256", StringComparison.OrdinalIgnoreCase))) + continue; + writer.WritePropertyName(property.Name); + WriteCanonicalJson(writer, property.Value, omitReceiptHash: false); + } + writer.WriteEndObject(); + break; + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (var item in element.EnumerateArray()) + WriteCanonicalJson(writer, item, omitReceiptHash: false); + writer.WriteEndArray(); + break; + case JsonValueKind.String: + writer.WriteStringValue(element.GetString()); + break; + case JsonValueKind.Number: + writer.WriteRawValue(element.GetRawText(), skipInputValidation: false); + break; + case JsonValueKind.True: + writer.WriteBooleanValue(true); + break; + case JsonValueKind.False: + writer.WriteBooleanValue(false); + break; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + writer.WriteNullValue(); + break; + default: + throw new InvalidOperationException($"Unsupported Apple receipt JSON value kind: {element.ValueKind}."); + } + } + + private static void PreserveLegacyLatest( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleReleaseReceipt? previousReceipt, + string historyDirectory) + { + if (previousReceipt is null || + !string.IsNullOrWhiteSpace(previousReceipt.ReceiptSha256) || + !File.Exists(plan.ReceiptPath)) + { + return; + } + + var timestamp = previousReceipt.CheckedAt.UtcDateTime.ToString( + "yyyyMMdd'T'HHmmss.fffffff'Z'", + System.Globalization.CultureInfo.InvariantCulture); + var legacyPath = Path.Combine(historyDirectory, $"{timestamp}-legacy-{Guid.NewGuid():N}.json"); + WriteImmutableHistoryEntry(historyDirectory, legacyPath, File.ReadAllBytes(plan.ReceiptPath)); + } + + private static void WriteImmutableHistoryEntry(string historyDirectory, string destinationPath, string payload) + => WriteImmutableHistoryEntry( + historyDirectory, + destinationPath, + new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(payload)); + + private static void WriteImmutableHistoryEntry(string historyDirectory, string destinationPath, byte[] payload) + { + var parent = Path.GetDirectoryName(historyDirectory) ?? historyDirectory; + var temporaryPath = Path.Combine( + parent, + $".{Path.GetFileName(historyDirectory)}.{Guid.NewGuid():N}.receipt.tmp"); + try + { + WriteDurableBytes(temporaryPath, payload); + File.Move(temporaryPath, destinationPath); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private static void WriteLatest(string projectRoot, string path, string payload, string settingName) + { + EnsureSafeOutputPath(projectRoot, path, settingName); + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + EnsureUnlinkedPath(projectRoot, directory, settingName); + } + + var temporaryPath = Path.Combine( + directory ?? projectRoot, + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + WriteDurableText(temporaryPath, payload); + if (File.Exists(path)) + File.Replace(temporaryPath, path, destinationBackupFileName: null, ignoreMetadataErrors: true); + else + File.Move(temporaryPath, path); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private static void WriteDurableText(string path, string payload) + => WriteDurableBytes( + path, + new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(payload)); + + private static void WriteDurableBytes(string path, byte[] bytes) + { + using var stream = new FileStream( + path, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 16 * 1024, + options: FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(flushToDisk: true); + } + + private static string Serialize(PowerForgeAppleReleaseReceipt receipt) + => JsonSerializer.Serialize(receipt, CreateOptions(writeIndented: true)); + + private static JsonSerializerOptions CreateOptions(bool writeIndented) + => new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = writeIndented, + Converters = { new JsonStringEnumConverter() } + }; + + private static string ToRelativePath(string projectRoot, string path) + => FrameworkCompatibility.GetRelativePath(projectRoot, path).Replace('\\', '/'); + + private static string ToLowerHex(byte[] value) + => BitConverter.ToString(value).Replace("-", string.Empty).ToLowerInvariant(); + + private static bool IsSha256(string? value) + => !string.IsNullOrWhiteSpace(value) && + value!.Length == 64 && + value.All(static character => Uri.IsHexDigit(character)); + + private static string ComputeReceiptAuthenticationSha256(string receiptSha256) + { + var key = ReadAuthenticationKey(); + using var hmac = new HMACSHA256(key); + return ToLowerHex(hmac.ComputeHash(System.Text.Encoding.ASCII.GetBytes(receiptSha256.ToLowerInvariant()))); + } + + private static byte[] ReadAuthenticationKey() + { + var configured = Environment.GetEnvironmentVariable(AuthenticationKeyEnvironmentVariable); + var keyPath = Path.GetFullPath(string.IsNullOrWhiteSpace(configured) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".powerforge", "apple-receipt-auth.key") + : configured!); + EnsureUnlinkedAuthenticationKeyPath(keyPath); + if (!File.Exists(keyPath)) + throw new InvalidOperationException( + $"Legacy schema-5 Apple release evidence requires its original machine-local integrity key '{keyPath}', but it was not found."); + + var key = File.ReadAllBytes(keyPath); + if (key.Length != 32) + throw new InvalidOperationException($"Apple release receipt authentication key must contain exactly 32 bytes: {keyPath}"); + return key; + } + + private static void EnsureUnlinkedAuthenticationKeyPath(string keyPath) + { + var current = File.Exists(keyPath) ? keyPath : Path.GetDirectoryName(keyPath); + while (!string.IsNullOrWhiteSpace(current)) + { + if ((File.Exists(current) || Directory.Exists(current)) && + (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"Apple release receipt authentication key must not traverse a symbolic link or reparse point: {current}"); + } + var parent = Path.GetDirectoryName(current); + if (string.IsNullOrWhiteSpace(parent) || parent.Equals(current, PathComparison)) + break; + current = parent; + } + } + + private static bool FixedTimeHexEquals(string left, string right) + { + if (left.Length != right.Length) + return false; + var difference = 0; + for (var index = 0; index < left.Length; index++) + difference |= char.ToLowerInvariant(left[index]) ^ char.ToLowerInvariant(right[index]); + return difference == 0; + } + + private static void EnsureSafeOutputPath(string projectRoot, string path, string settingName) + { + var root = Path.GetFullPath(projectRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullPath = Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!IsWithinRoot(fullPath, root)) + throw new InvalidOperationException($"{settingName} must remain inside AppleApps.ProjectRoot."); + + var current = fullPath; + while (!File.Exists(current) && !Directory.Exists(current)) + { + current = Path.GetDirectoryName(current) + ?? throw new InvalidOperationException($"{settingName} could not be validated."); + } + EnsureUnlinkedPath(root, current, settingName); + } + + private static void EnsureUnlinkedPath(string projectRoot, string path, string description) + { + var root = Path.GetFullPath(projectRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var current = Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!IsWithinRoot(current, root)) + throw new InvalidOperationException($"{description} must remain inside AppleApps.ProjectRoot: {current}"); + + while (true) + { + if ((File.Exists(current) || Directory.Exists(current)) && + (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException($"{description} must not traverse a symbolic link or reparse point: {current}"); + } + + if (current.Equals(root, PathComparison)) + return; + current = Path.GetDirectoryName(current) + ?? throw new InvalidOperationException($"{description} could not be validated inside AppleApps.ProjectRoot."); + } + } + + private static bool IsWithinRoot(string path, string root) + => path.Equals(root, PathComparison) || + path.StartsWith(root + Path.DirectorySeparatorChar, PathComparison); +} diff --git a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs new file mode 100644 index 000000000..325ffb6eb --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs @@ -0,0 +1,206 @@ +namespace PowerForge; + +/// +/// Detects transient writes, renames, creations, deletions, or metadata changes inside an exact-source +/// build snapshot while xcodebuild is allowed to read it. +/// +internal sealed class AppleReleaseSourceMutationMonitor : IDisposable { + private readonly FileSystemWatcher _watcher; + private readonly string _scopeDescription; + private readonly string _readerDescription; + private readonly string _failureInstruction; + private readonly string? _exactPath; + private readonly bool _includeExactPathDescendants; + private int _enforceMutations; + private long _mutationSequence; + private string? _firstMutation; + private Exception? _watcherError; + private bool _disposed; + + internal AppleReleaseSourceMutationMonitor( + string rootPath, + string scopeDescription = "exact-source Apple build snapshot", + string readerDescription = "xcodebuild", + string failureInstruction = "Discard the archive and rebuild from a new snapshot.", + bool enableImmediately = true, + string? exactPath = null, + bool includeExactPathDescendants = false) { + _scopeDescription = scopeDescription; + _readerDescription = readerDescription; + _failureInstruction = failureInstruction; + _exactPath = string.IsNullOrWhiteSpace(exactPath) ? null : Path.GetFullPath(exactPath); + _includeExactPathDescendants = includeExactPathDescendants; + _watcher = new FileSystemWatcher(rootPath) { + IncludeSubdirectories = true, + InternalBufferSize = 64 * 1024, + NotifyFilter = NotifyFilters.FileName | + NotifyFilters.DirectoryName | + NotifyFilters.Attributes | + NotifyFilters.Size | + NotifyFilters.LastWrite | + NotifyFilters.CreationTime | + NotifyFilters.Security + }; + _watcher.Changed += OnMutation; + _watcher.Created += OnMutation; + _watcher.Deleted += OnMutation; + _watcher.Renamed += OnMutation; + _watcher.Error += OnError; + // Keep the watcher subscribed for the complete producer lifetime. Producer-owned writes are + // tolerated until the process completion boundary, but the watcher itself is never started + // late: the boundary transition therefore cannot create an unobserved activation window. + _enforceMutations = enableImmediately ? 1 : 0; + _watcher.EnableRaisingEvents = true; + } + + internal void ValidateNoChanges() { + // macOS FSEvents delivery is asynchronous. Give the already-completed archive operation's + // notifications a short drain window before closing the monitor and accepting its output. + Thread.Sleep(250); + _watcher.EnableRaisingEvents = false; + if (_watcherError is not null) { + throw new InvalidOperationException( + $"The {_scopeDescription} mutation monitor failed; its output cannot be trusted. {_failureInstruction}", + _watcherError); + } + if (!string.IsNullOrWhiteSpace(_firstMutation)) { + throw new InvalidOperationException( + $"The {_scopeDescription} changed while {_readerDescription} was reading it: {_firstMutation}. " + + _failureInstruction); + } + } + + internal T CaptureExpectedProducerOutput(Func capture, string producerDescription) { + if (capture is null) + throw new ArgumentNullException(nameof(capture)); + if (_watcherError is not null) { + throw new InvalidOperationException( + $"The {_scopeDescription} mutation monitor failed at the {producerDescription} completion boundary. {_failureInstruction}", + _watcherError); + } + + // Producer-output monitors are armed only at the process completion boundary. + // No producer events are cleared: the first identity is captured while every + // later write, rename, or metadata change remains observable. + T output; + if (Volatile.Read(ref _enforceMutations) == 0) + { + output = capture(); + // FileSystemWatcher delivery is asynchronous. The producer's own final writes may still + // be queued when the process-exit callback runs, so let the already-active observer drain + // to a quiet sequence before changing those events from producer activity to tampering. + // The output identity is bound before this drain and must remain identical afterward, so + // a persistent replacement during the drain cannot become the accepted producer output. + var sequence = Interlocked.Read(ref _mutationSequence); + var stablePasses = 0; + for (var pass = 0; pass < 10 && stablePasses < 5; pass++) + { + Thread.Sleep(50); + var current = Interlocked.Read(ref _mutationSequence); + if (current == sequence) + { + stablePasses++; + } + else + { + sequence = current; + stablePasses = 0; + } + } + if (stablePasses < 5) + { + throw new InvalidOperationException( + $"The {_scopeDescription} did not become quiet at the producer completion boundary. {_failureInstruction}"); + } + // Close the producer-to-consumer transition before the final comparison. A watcher + // event that lands after the quiet drain but before arming increments the sequence + // while enforcement is still disabled; comparing the sequence after the atomic arm + // catches that window. Events that land after the arm are retained in _firstMutation. + Interlocked.Exchange(ref _enforceMutations, 1); + if (Interlocked.Read(ref _mutationSequence) != sequence) + { + throw new InvalidOperationException( + $"The {_scopeDescription} changed while its {producerDescription} output was being bound. {_failureInstruction}"); + } + var drainedOutput = capture(); + if (!EqualityComparer.Default.Equals(output, drainedOutput)) + { + throw new InvalidOperationException( + $"The {_scopeDescription} changed while its {producerDescription} output was being bound. {_failureInstruction}"); + } + } + else + { + output = capture(); + } + if (!string.IsNullOrWhiteSpace(_firstMutation)) { + throw new InvalidOperationException( + $"The {_scopeDescription} changed before its {producerDescription} output could be bound. {_failureInstruction}"); + } + Thread.Sleep(250); + if (_watcherError is not null) { + throw new InvalidOperationException( + $"The {_scopeDescription} changed while its {producerDescription} output was being bound. {_failureInstruction}", + _watcherError); + } + var currentOutput = capture(); + if (!EqualityComparer.Default.Equals(output, currentOutput)) { + throw new InvalidOperationException( + $"The {_scopeDescription} changed after {producerDescription} completed. {_failureInstruction}"); + } + + if (!string.IsNullOrWhiteSpace(_firstMutation)) { + throw new InvalidOperationException( + $"The {_scopeDescription} changed after {producerDescription} completed. {_failureInstruction}"); + } + return output; + } + + private void OnMutation(object sender, FileSystemEventArgs args) + { + if (_exactPath is not null && !MutationTouchesExactPath(args, _exactPath, _includeExactPathDescendants)) + return; + Interlocked.Increment(ref _mutationSequence); + if (Volatile.Read(ref _enforceMutations) != 0) + Interlocked.CompareExchange(ref _firstMutation, args.FullPath, null); + } + + private static bool MutationTouchesExactPath( + FileSystemEventArgs args, + string exactPath, + bool includeDescendants) + { + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (PathMatches(Path.GetFullPath(args.FullPath), exactPath, includeDescendants, comparison)) + return true; + return args is RenamedEventArgs renamed && + PathMatches(Path.GetFullPath(renamed.OldFullPath), exactPath, includeDescendants, comparison); + } + + private static bool PathMatches( + string candidate, + string exactPath, + bool includeDescendants, + StringComparison comparison) + { + if (candidate.Equals(exactPath, comparison)) + return true; + if (!includeDescendants) + return false; + var prefix = exactPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return candidate.StartsWith(prefix, comparison); + } + + private void OnError(object sender, ErrorEventArgs args) + => Interlocked.CompareExchange(ref _watcherError, args.GetException(), null); + + public void Dispose() { + if (_disposed) + return; + _disposed = true; + _watcher.Dispose(); + } +} diff --git a/PowerForge/Services/AppleReleaseSourceSnapshot.cs b/PowerForge/Services/AppleReleaseSourceSnapshot.cs new file mode 100644 index 000000000..6ee0cd2b2 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceSnapshot.cs @@ -0,0 +1,413 @@ +namespace PowerForge; + +/// +/// Provides a private detached Git worktree for an exact-source Apple archive build. +/// Generated archives remain at their configured paths in the caller worktree; only +/// Xcode's source/project input path is rebound to this snapshot. +/// +internal sealed class AppleReleaseSourceSnapshot : IDisposable +{ + private readonly GitClient _git = GitClient.CreateTrustedSystemClient(defaultTimeout: TimeSpan.FromMinutes(2)); + private readonly string _repositoryRoot; + private readonly string _sourceRepositoryRoot; + private readonly string _sourceProjectRoot; + private readonly string _snapshotProjectRoot; + private readonly string _sourceCommit; + private IReadOnlyDictionary _trackedFileMutationIdentities = + new Dictionary(StringComparer.Ordinal); + private string? _snapshotConfigPath; + private string? _expectedConfigSha256; + private bool _disposed; + + private AppleReleaseSourceSnapshot( + string repositoryRoot, + string sourceRepositoryRoot, + string sourceProjectRoot, + string snapshotRoot, + string snapshotProjectRoot, + string sourceCommit) + { + _repositoryRoot = repositoryRoot; + _sourceRepositoryRoot = sourceRepositoryRoot; + _sourceProjectRoot = sourceProjectRoot; + _snapshotProjectRoot = snapshotProjectRoot; + RootPath = snapshotRoot; + _sourceCommit = sourceCommit; + } + + internal string RootPath { get; } + + internal static AppleReleaseSourceSnapshot? CreateIfRequired(PowerForgeAppleReleasePlan plan) + { + if (!plan.RequireImmutableSourceSnapshot || string.IsNullOrWhiteSpace(plan.SourceCommit)) + return null; + + if (!plan.Archive) + { + ValidateCurrentSource(plan); + return null; + } + + var sourceCommit = plan.SourceCommit!.Trim(); + var git = GitClient.CreateTrustedSystemClient(defaultTimeout: TimeSpan.FromMinutes(2)); + var topLevel = Run(git, plan.ProjectRoot, new[] { "rev-parse", "--show-toplevel" }, "resolve the source repository"); + var repositoryRoot = Path.GetFullPath(topLevel.StdOut.Trim()); + var projectPrefix = Run(git, plan.ProjectRoot, new[] { "rev-parse", "--show-prefix" }, "resolve the Apple project root") + .StdOut.Trim().Replace('/', Path.DirectorySeparatorChar); + var sourceRepositoryRoot = Path.GetFullPath(plan.ProjectRoot); + foreach (var _ in projectPrefix.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)) + sourceRepositoryRoot = Path.GetDirectoryName(sourceRepositoryRoot)!; + + var snapshotParent = Path.Combine(Path.GetTempPath(), "PowerForge", "apple-source-snapshots"); + Directory.CreateDirectory(snapshotParent); + var snapshotRoot = Path.Combine(snapshotParent, Guid.NewGuid().ToString("N")); + try + { + Run( + git, + repositoryRoot, + new[] { "worktree", "add", "--detach", snapshotRoot, sourceCommit }, + "create the exact-source Apple build snapshot"); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(snapshotRoot, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + var snapshotProjectRoot = Path.GetFullPath(Path.Combine(snapshotRoot, projectPrefix)); + var snapshot = new AppleReleaseSourceSnapshot( + repositoryRoot, + sourceRepositoryRoot, + Path.GetFullPath(plan.ProjectRoot), + snapshotRoot, + snapshotProjectRoot, + sourceCommit); + snapshot._trackedFileMutationIdentities = snapshot.CaptureTrackedFileMutationIdentities(); + snapshot.ValidateUnchanged(); + if (!string.IsNullOrWhiteSpace(plan.ExactSourceConfigPath)) + snapshot.ValidateExactSourceInputs(plan.ExactSourceConfigPath!, plan.ExactSourceConfigSha256); + return snapshot; + } + catch + { + if (Directory.Exists(snapshotRoot)) + { + try + { + Run(git, repositoryRoot, new[] { "worktree", "remove", "--force", snapshotRoot }, "remove the failed Apple build snapshot"); + } + catch + { + // Preserve the primary failure. Git can prune this unregistered temporary path later. + } + } + throw; + } + } + + private static void ValidateCurrentSource(PowerForgeAppleReleasePlan plan) + { + var sourceCommit = plan.SourceCommit!.Trim(); + var git = GitClient.CreateTrustedSystemClient(defaultTimeout: TimeSpan.FromMinutes(2)); + var topLevel = Run(git, plan.ProjectRoot, new[] { "rev-parse", "--show-toplevel" }, "resolve the source repository"); + var repositoryRoot = Path.GetFullPath(topLevel.StdOut.Trim()); + + string observedCommit; + if (!string.IsNullOrWhiteSpace(plan.ExactSourceConfigPath) && File.Exists(plan.ExactSourceConfigPath)) + { + var configPath = Path.GetFullPath(plan.ExactSourceConfigPath!); + EnsureContained(repositoryRoot, configPath, "Apple exact-source config path"); + var trust = new AppleReleaseSourceTrustService().Capture(repositoryRoot, configPath); + ValidateExpectedConfigurationSha256(trust.ExactConfigurationSha256, plan.ExactSourceConfigSha256); + observedCommit = trust.SourceCommit; + } + else + { + new HomeAssistantReleaseGitService(git).EnsureClean(repositoryRoot); + observedCommit = Run(git, repositoryRoot, new[] { "rev-parse", "HEAD" }, "verify the Apple source commit") + .StdOut.Trim(); + } + + if (!observedCommit.Equals(sourceCommit, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The current Apple release source resolved commit '{observedCommit}' instead of the approved commit '{sourceCommit}'. " + + "Run the action from the exact clean source commit or omit the source binding."); + } + } + + internal string MapPath(string sourcePath) + { + var fullPath = Path.GetFullPath(sourcePath); + EnsureContained(_sourceProjectRoot, fullPath, "Apple Xcode project path"); + var relative = FrameworkCompatibility.GetRelativePath(_sourceProjectRoot, fullPath); + var mapped = Path.GetFullPath(Path.Combine(_snapshotProjectRoot, relative)); + EnsureContained(RootPath, mapped, "Apple snapshot project path"); + if (!File.Exists(mapped) && !Directory.Exists(mapped)) + throw new FileNotFoundException($"Apple snapshot project input was not found: {mapped}", mapped); + return mapped; + } + + /// Begins monitoring the detached source tree for transient changes during xcodebuild. + internal AppleReleaseSourceMutationMonitor MonitorChanges() + => new(RootPath); + + private string MapRepositoryPath(string sourcePath) + { + var fullPath = Path.GetFullPath(sourcePath); + EnsureContained(_sourceRepositoryRoot, fullPath, "Apple exact-source config path"); + var relative = FrameworkCompatibility.GetRelativePath(_sourceRepositoryRoot, fullPath); + var mapped = Path.GetFullPath(Path.Combine(RootPath, relative)); + EnsureContained(RootPath, mapped, "Apple snapshot config path"); + return mapped; + } + + private void ValidateExactSourceInputs(string configPath, string? expectedSha256) + { + _snapshotConfigPath = MapRepositoryPath(configPath); + _expectedConfigSha256 = expectedSha256; + ValidateMappedExactSourceInputs(); + } + + private void ValidateMappedExactSourceInputs() + { + var trust = new AppleReleaseSourceTrustService().Capture(RootPath, _snapshotConfigPath!); + ValidateExpectedConfigurationSha256(trust.ExactConfigurationSha256, _expectedConfigSha256); + if (!trust.SourceCommit.Equals(_sourceCommit, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The isolated Apple build snapshot resolved commit '{trust.SourceCommit}' instead of '{_sourceCommit}'."); + } + } + + private static void ValidateExpectedConfigurationSha256(string? actualSha256, string? expectedSha256) + { + if (string.IsNullOrWhiteSpace(expectedSha256)) + return; + if (string.IsNullOrWhiteSpace(actualSha256) || + !string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The parsed Apple release configuration does not match the exact source configuration bytes. " + + $"Expected SHA-256 '{expectedSha256}', received '{actualSha256}'. Reload the configuration from the approved source snapshot."); + } + } + + internal void ValidateUnchanged() + { + var head = Run(_git, RootPath, new[] { "rev-parse", "HEAD" }, "verify the Apple build snapshot commit") + .StdOut.Trim(); + if (!head.Equals(_sourceCommit, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The isolated Apple build snapshot changed commits. Expected '{_sourceCommit}', received '{head}'."); + } + + var currentMutationIdentities = CaptureTrackedFileMutationIdentities(); + if (_trackedFileMutationIdentities.Count != currentMutationIdentities.Count || + _trackedFileMutationIdentities.Any(pair => + !currentMutationIdentities.TryGetValue(pair.Key, out var current) || + !pair.Value.Equals(current, StringComparison.Ordinal))) + { + throw new InvalidOperationException( + "The isolated Apple build snapshot file identity changed while xcodebuild was running. " + + "A transient write or hard-link alias invalidates exact-source evidence. Discard the archive and rebuild from a new snapshot."); + } + + var status = Run( + _git, + RootPath, + new[] { "status", "--porcelain", "--untracked-files=all" }, + "verify the Apple build snapshot contents"); + if (!string.IsNullOrWhiteSpace(status.StdOut)) + { + throw new InvalidOperationException( + "The isolated Apple build snapshot changed while xcodebuild was running. Discard the archive and rebuild from a new snapshot."); + } + if (!string.IsNullOrWhiteSpace(_snapshotConfigPath)) + ValidateMappedExactSourceInputs(); + } + + private IReadOnlyDictionary CaptureTrackedFileMutationIdentities() + { + var tracked = Run( + _git, + RootPath, + new[] { "ls-files", "--stage", "-z" }, + "enumerate the Apple build snapshot files") + .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries); + var result = new Dictionary(GetPathComparer()); + var trackedFiles = new List<(string RelativePath, string FullPath)>(); + foreach (var entry in tracked) + { + var separator = entry.IndexOf('\t'); + if (separator < 0 || !entry.StartsWith("100", StringComparison.Ordinal)) + continue; + var relativePath = entry.Substring(separator + 1); + var fullPath = Path.GetFullPath(Path.Combine( + RootPath, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + EnsureContained(RootPath, fullPath, "Apple snapshot tracked file"); + trackedFiles.Add((relativePath, fullPath)); + var status = ExistingFilePathIdentityResolver.ResolveStatus(fullPath); + result.Add(relativePath, status.MutationIdentity); + } + + var hardLinkCounts = ReadHardLinkCounts(trackedFiles.Select(static file => file.FullPath).ToArray()); + for (var index = 0; index < trackedFiles.Count; index++) + { + if (hardLinkCounts[index] != 1) + { + throw new InvalidOperationException( + $"The isolated Apple build snapshot tracked file '{trackedFiles[index].RelativePath}' has {hardLinkCounts[index]} hard links. " + + "Exact-source builds require one private pathname per tracked file."); + } + } + return result; + } + + private IReadOnlyList ReadHardLinkCounts(IReadOnlyList paths) + { + if (Path.DirectorySeparatorChar == '\\') + { + return paths.Select(path => + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + return ReadWindowsHardLinkCount(stream.SafeFileHandle); + }).ToArray(); + } + + const int batchSize = 64; + var executable = "/usr/bin/stat"; +#if NET8_0_OR_GREATER + var isMacOs = OperatingSystem.IsMacOS(); +#else + var isMacOs = true; +#endif + var counts = new List(paths.Count); + for (var offset = 0; offset < paths.Count; offset += batchSize) + { + var batch = paths.Skip(offset).Take(batchSize).ToArray(); + var arguments = new List + { + isMacOs ? "-f" : "-c", + isMacOs ? "%l" : "%h" + }; + arguments.AddRange(batch); + var result = new ProcessRunner().RunAsync(new ProcessRunRequest( + executable, + RootPath, + arguments, + TimeSpan.FromMinutes(1), + AppleTrustedExecutionEnvironment.Create(), + captureOutput: true, + captureError: true, + inheritEnvironment: false)) + .GetAwaiter() + .GetResult(); + if (!result.Succeeded) + throw new InvalidOperationException($"Failed to inspect Apple snapshot hard-link counts: {result.StdErr}".Trim()); + var batchCounts = result.StdOut + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(value => int.TryParse(value.Trim(), out var count) ? count : -1) + .ToArray(); + if (batchCounts.Length != batch.Length || batchCounts.Any(static count => count < 0)) + throw new InvalidOperationException("The Apple snapshot hard-link inspection returned an incomplete result."); + counts.AddRange(batchCounts); + } + return counts; + } + + private static int ReadWindowsHardLinkCount(Microsoft.Win32.SafeHandles.SafeFileHandle handle) + { + if (!GetFileInformationByHandle(handle, out var information)) + throw new System.ComponentModel.Win32Exception(System.Runtime.InteropServices.Marshal.GetLastWin32Error()); + return checked((int)information.NumberOfLinks); + } + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct WindowsFileTime + { + internal uint LowDateTime; + internal uint HighDateTime; + } + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct WindowsFileInformation + { + internal uint FileAttributes; + internal WindowsFileTime CreationTime; + internal WindowsFileTime LastAccessTime; + internal WindowsFileTime LastWriteTime; + internal uint VolumeSerialNumber; + internal uint FileSizeHigh; + internal uint FileSizeLow; + internal uint NumberOfLinks; + internal uint FileIndexHigh; + internal uint FileIndexLow; + } + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle( + Microsoft.Win32.SafeHandles.SafeFileHandle file, + out WindowsFileInformation information); + + private static StringComparer GetPathComparer() + => Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + RemoveWorktreeBestEffort(_git, _repositoryRoot, RootPath); + } + + internal static void RemoveWorktreeBestEffort(GitClient git, string repositoryRoot, string snapshotRoot) + { + try + { + _ = git.RunRawAsync( + repositoryRoot, + new[] { "worktree", "remove", "--force", snapshotRoot }, + TimeSpan.FromMinutes(2)) + .GetAwaiter() + .GetResult(); + } + catch + { + // Snapshot cleanup is private, post-operation hygiene. A Git cleanup failure must not + // turn an already completed remote release into a retryable release failure. + } + } + + private static ProcessRunResult Run( + GitClient git, + string workingDirectory, + IReadOnlyList arguments, + string operation) + { + var result = git.RunRawAsync(workingDirectory, arguments, TimeSpan.FromMinutes(2)) + .GetAwaiter() + .GetResult(); + if (!result.Succeeded) + { + throw new InvalidOperationException( + $"Failed to {operation}: {result.StdErr}".Trim()); + } + return result; + } + + private static void EnsureContained(string root, string candidate, string name) + { + var normalizedRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + var normalizedCandidate = Path.GetFullPath(candidate); + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!normalizedCandidate.StartsWith(normalizedRoot, comparison) && + !normalizedCandidate.Equals(normalizedRoot.TrimEnd(Path.DirectorySeparatorChar), comparison)) + { + throw new InvalidOperationException($"{name} must be inside the exact Git repository: {normalizedCandidate}"); + } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs new file mode 100644 index 000000000..613817979 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs @@ -0,0 +1,175 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void ValidateAssemblerInputs( + string repositoryRoot, + string sourcePath, + string source, + string assemblerWorkingDirectory) + { + var fullSourcePath = Path.GetFullPath(sourcePath); + var fullWorkingDirectory = Path.GetFullPath(assemblerWorkingDirectory); + if (!_validatedAssemblerInputFiles.Add(fullSourcePath + "|" + fullWorkingDirectory)) + return; + + RejectUnboundAssemblerPreprocessorMacros(fullSourcePath, source); + ValidateAssemblerDirectives(repositoryRoot, fullSourcePath, source, fullWorkingDirectory); + } + + private static void RejectUnboundAssemblerPreprocessorMacros(string sourcePath, string source) + { + var syntax = MaskCStringAndCharacterLiterals(source); + foreach (Match definition in Regex.Matches( + syntax, + "(?m)^[ \\t]*(?:#|%:)[ \\t]*define[ \\t]+[A-Za-z_][A-Za-z0-9_]*(?[ \\t]*\\([^\\r\\n)]*\\))?[ \\t]*(?[^\\r\\n]*)", + RegexOptions.CultureInvariant)) + { + if (!CanConstructAssemblerFileDirective( + definition.Groups["body"].Value, + definition.Groups["parameters"].Success + ? definition.Groups["parameters"].Value + : null)) + continue; + + throw new InvalidOperationException( + $"Preprocessed assembler source input '{sourcePath}' defines a macro that can construct a file-consuming .include or .incbin directive, whose expanded input cannot be bound safely to the exact source commit."); + } + + var indirectDirective = Regex.Match( + syntax, + "(? parameter.Trim()) + .Where(static parameter => Regex.IsMatch(parameter, "^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant)) + .Any(parameter => Regex.IsMatch( + body, + $"(?include|incbin)(?![A-Za-z0-9_])[ \\t]+(?[^\\r\\n]+)", + RegexOptions.CultureInvariant)) + { + var operand = directive.Groups["operand"].Value.Trim(); + var literal = Regex.Match(operand, "^\\\"(?[^\\\"\\\\]*)\\\"", RegexOptions.CultureInvariant); + if (!literal.Success) + { + throw new InvalidOperationException( + $"Assembler source input '{sourcePath}' uses computed .{directive.Groups["kind"].Value} input '{operand}', which cannot be bound to the exact source commit."); + } + + var input = literal.Groups["path"].Value; + if (Path.IsPathRooted(input)) + { + throw new InvalidOperationException( + $"Assembler source input '{sourcePath}' references absolute .{directive.Groups["kind"].Value} input '{input}', which is outside the exact-source graph."); + } + + var candidate = ResolveAssemblerInput( + repositoryRoot, + sourcePath, + input, + assemblerWorkingDirectory, + directive.Groups["kind"].Value); + EnsureTrackedFile(repositoryRoot, candidate, $"assembler .{directive.Groups["kind"].Value} input from {sourcePath}"); + if (directive.Groups["kind"].Value.Equals("include", StringComparison.OrdinalIgnoreCase)) + { + var nestedPhysicalSource = File.ReadAllText(candidate); + RejectCTrigraphs(nestedPhysicalSource, candidate); + var nestedSource = RemoveCComments(SpliceCPreprocessingLines(nestedPhysicalSource)); + ValidateAssemblerInputs(repositoryRoot, candidate, nestedSource, assemblerWorkingDirectory); + } + } + } + + private string ResolveAssemblerInput( + string repositoryRoot, + string sourcePath, + string input, + string assemblerWorkingDirectory, + string directiveKind) + { + var fullWorkingDirectory = Path.GetFullPath(assemblerWorkingDirectory); + var roots = new[] { fullWorkingDirectory } + .Concat(_approvedAssemblerSearchRoots.TryGetValue(fullWorkingDirectory, out var approvedRoots) + ? approvedRoots + : Array.Empty()) + .Distinct(GetPathComparer()); + var candidates = roots + .Select(root => Path.GetFullPath(Path.Combine(root, input))) + .Where(candidate => IsPathAtOrWithin(candidate, repositoryRoot) && File.Exists(candidate)) + .Distinct(GetPathComparer()) + .ToArray(); + if (candidates.Length == 0) + { + var expected = Path.GetFullPath(Path.Combine(assemblerWorkingDirectory, input)); + throw new FileNotFoundException( + $"Assembler .{directiveKind} input '{input}' from '{sourcePath}' was not found in the compiler working directory or a validated -I root.", + expected); + } + return candidates[0]; + } + + private static string NormalizeAssemblerStatementBoundaries(string source) + { + var normalized = source.ToCharArray(); + var insideString = false; + var escaped = false; + for (var index = 0; index < normalized.Length; index++) + { + var value = normalized[index]; + if (insideString && value == '\\' && !escaped) + { + escaped = true; + continue; + } + if (value == '"' && !escaped) + insideString = !insideString; + if (value == ';' && !insideString) + normalized[index] = '\n'; + escaped = false; + } + return new string(normalized); + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs new file mode 100644 index 000000000..7bbc6bf92 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs @@ -0,0 +1,103 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static readonly string[] HeaderSearchPathOptions = + { + "-I", "-F", "-iquote", "-isystem", "-isystem-after", "-idirafter", + "-cxx-isystem", "-stdlib++-isystem", "-iframework", "-iframeworkwithsysroot" + }; + + private static readonly string[] LinkerSingleFileInputOptions = + { + "-order_file", "-exported_symbols_list", "-unexported_symbols_list", + "-reexported_symbols_list", "-interposable_list", "-alias_list", + "-force_load", "-weak_library", "-reexport_library", "-needed_library", "-bundle_loader" + }; + + private static void ValidateHeaderSearchPathInputs(string projectDirectory, string[] tokens, string key) + { + for (var index = 0; index < tokens.Length; index++) + { + var token = tokens[index]; + string? value = null; + var option = HeaderSearchPathOptions.FirstOrDefault(candidate => token.Equals(candidate, StringComparison.Ordinal)); + if (option is not null) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with header search option '{option}' and no search root."); + value = tokens[index]; + } + else + { + option = HeaderSearchPathOptions + .OrderByDescending(static candidate => candidate.Length) + .FirstOrDefault(candidate => token.StartsWith(candidate, StringComparison.Ordinal) && token.Length > candidate.Length); + if (option is not null) + value = token.Substring(option.Length).TrimStart('='); + } + if (string.IsNullOrWhiteSpace(value) || + IsValidatedToolchainOrBuildProductPath(value!, key, "header search option")) + continue; + var candidate = ResolveBuildSettingPath(projectDirectory, value!, key); + RejectHeaderMapInput(candidate, key); + if (File.Exists(candidate)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} uses file '{candidate}' as a header search root. Header-map and other file-backed search graphs are unsupported; use a tracked directory root instead."); + } + } + } + + private static void RejectHeaderMapInput(string candidate, string key) + { + if (!Path.GetExtension(candidate).Equals(".hmap", StringComparison.OrdinalIgnoreCase)) + return; + throw new InvalidOperationException( + $"Xcode build setting {key} references header map '{candidate}', whose selected header paths cannot be bound to the exact source commit. Use tracked directory search roots instead."); + } + + private static bool TryReadLinkerFileInputPaths( + string[] tokens, + ref int index, + string token, + string key, + out string[] paths) + { + paths = Array.Empty(); + var option = LinkerSingleFileInputOptions.FirstOrDefault(candidate => token.Equals(candidate, StringComparison.Ordinal)); + if (option is not null) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with linker option '{option}' and no file input."); + paths = new[] { tokens[index] }; + return true; + } + + option = LinkerSingleFileInputOptions.FirstOrDefault(candidate => token.StartsWith(candidate + "=", StringComparison.Ordinal)); + if (option is not null) + { + var path = token.Substring(option.Length + 1); + if (string.IsNullOrWhiteSpace(path)) + throw new InvalidOperationException($"Xcode build setting {key} contains linker option '{option}' with an empty file input."); + paths = new[] { path }; + return true; + } + + if (!token.Equals("-sectorder", StringComparison.Ordinal) && + !token.Equals("-sectcreate", StringComparison.Ordinal) && + !token.Equals("-segcreate", StringComparison.Ordinal)) + return false; + if (index + 3 >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends before linker option '{token}' receives its segment, section, and file input."); + var segment = tokens[++index]; + var section = tokens[++index]; + if (IsPathLikeBuildFlagToken(segment) || IsPathLikeBuildFlagToken(section)) + { + throw new InvalidOperationException( + $"Linker option '{token}' in Xcode build setting {key} contains a path-like segment or section name."); + } + paths = new[] { tokens[++index] }; + return true; + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs new file mode 100644 index 000000000..27cea8091 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -0,0 +1,89 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + /// Compiler, assembler, and linker options whose following token names filesystem input or state. + private static readonly HashSet BuildFlagPathOptions = new(StringComparer.Ordinal) + { + "--amdgpu-arch-tool", "--cuda-path", "--gpu-instrument-lib", "--hip-device-lib", + "--hip-device-lib-path", "--hip-path", "--hipspv-pass-plugin", "--hipstdpar-path", + "--hipstdpar-prim-path", "--hipstdpar-thrust-path", "--libomptarget-amdgcn-bc-path", + "--libomptarget-amdgpu-bc-path", "--libomptarget-nvptx-bc-path", "--libomptarget-spirv-bc-path", + "--nvptx-arch-tool", "--offload-arch-tool", "--ptxas-path", "--rocm-device-lib-path", + "--rocm-path", "--source-metadata-list", + "--gcc-install-dir", "--gcc-toolchain", "--sysroot", "--warning-suppression-mappings", + "-access-notes-path", "-api-diff-data-dir", "-api-diff-data-file", "-backup-module-interface-path", + "-blocklist-file", "-candidate-module-file", "-cas-plugin-path", "-clang-build-session-file", + "-clang-scanner-module-cache-path", "-const-gather-protocols-file", "-explicit-swift-module-map-file", + "-external-pass-pipeline-filename", "-import-bridging-header", "-import-pch", + "-in-process-plugin-server-path", "-internal-import-bridging-header", "-internal-import-pch", + "-module-cache-path", "-new-driver-path", "-platform-availability-inheritance-map-path", + "-prebuilt-module-cache-path", "-previous-module-installname-map-file", "-read-legacy-type-info-path", + "-sdk-module-cache-path", "-tools-directory", "-verify-additional-file", + "-B", "-F", "-I", "-L", "-MF", "-ccc-gcc-name", "-cxx-isystem", "-dependency-dot", + "-dependency-file", "-dsym-dir", "-dumpdir", "-fapinotes-cache-path", "-fbuild-session-file", + "-fcodegen-data-use", "-fcuda-include-gpubinary", "-fdepscan-daemon", "-fembed-offload-object", + "-fexperimental-sanitize-metadata-ignorelist", "-fmemory-profile-use", + "-fmodule-file", "-fmodule-map-file", "-fmodules-cache-path", "-fmodules-user-build-path", + "-fms-secure-hotpatch-functions-file", "-fms-secure-hotpatch-functions-list", "-force_load", "-fpass-plugin", + "-fplugin", "-fprebuilt-module-path", "-fprofile-instr-use", "-fprofile-instrument-use-path", "-fprofile-list", + "-fopenmp-host-ir-file-path", "-fprofile-remapping-file", "-fprofile-sample-use", "-fprofile-use", + "-foverride-record-layout", "-frandomize-layout-seed-file", "-fsanitize-blacklist", "-fsanitize-coverage-allowlist", + "-fsanitize-coverage-blacklist", "-fsanitize-coverage-ignorelist", "-fsanitize-coverage-whitelist", + "-fsanitize-ignorelist", "-fsanitize-system-blacklist", "-fsanitize-system-ignorelist", + "-fthinlto-distributor", "-fthinlto-index", "-fxray-always-instrument", "-fxray-attr-list", "-fxray-never-instrument", + "-gcc-toolchain", "-gen-cdb-fragment-path", + "-iapinotes-modules", "-iapinotes-path", "-idirafter", "-iframework", "-iframeworkwithsysroot", + "-imacros", "-include", "-include-pch", "-include-pth", "-index-store-path", + "-index-unit-output-path", "-install_name", "-iprefix", "-iquote", "-isysroot", "-isystem", + "-isystem-after", "-ivfsstatcache", "-iwithprefix", "-iwithprefixbefore", + "-iwithsysroot", "-ld-path", "-load", "-load-pass-plugin", "-load-plugin-library", "-module-file-info", "-module-map-file", + "-multi-lib-config", "-plugin", "-plugin-path", "-profile-sample-use", "-profile-use", + "-resource-dir", "-rpath", "-sdk", "-stdlib++-isystem", "-working-directory" + }; + + /// Joined forms of path-bearing compiler, assembler, and linker options, longest prefixes first. + private static readonly string[] BuildFlagPathPrefixes = + { + "--libomptarget-amdgpu-bc-path=", "--libomptarget-amdgcn-bc-path=", + "--libomptarget-nvptx-bc-path=", "--libomptarget-spirv-bc-path=", + "--warning-suppression-mappings=", "--rocm-device-lib-path=", "--hip-device-lib-path=", + "--hipstdpar-thrust-path=", + "--hipstdpar-prim-path=", "--hipspv-pass-plugin=", "--source-metadata-list=", + "--offload-arch-tool=", "--amdgpu-arch-tool=", "--nvptx-arch-tool=", "--gpu-instrument-lib=", + "--hipstdpar-path=", "--hip-device-lib=", "--cuda-path=", "--hip-path=", "--ptxas-path=", + "--gcc-install-dir=", "--gcc-toolchain=", "--rocm-path=", "--sysroot=", + "-platform-availability-inheritance-map-path=", "-previous-module-installname-map-file=", + "-explicit-swift-module-map-file=", "-external-pass-pipeline-filename=", + "-in-process-plugin-server-path=", "-clang-scanner-module-cache-path=", + "-internal-import-bridging-header=", "-internal-import-pch=", "-clang-build-session-file=", + "-import-bridging-header=", "-read-legacy-type-info-path=", "-const-gather-protocols-file=", + "-verify-additional-file=", "-api-diff-data-dir=", "-api-diff-data-file=", "-cas-plugin-path=", + "-tools-directory=", "-blocklist-file=", "-new-driver-path=", "-import-pch=", "-access-notes-path=", + "-backup-module-interface-path=", "-prebuilt-module-cache-path=", "-candidate-module-file=", + "-sdk-module-cache-path=", "-module-cache-path=", + "-fdepscan-daemon=", "-fembed-offload-object=", "-fexperimental-sanitize-metadata-ignorelist=", + "-fms-secure-hotpatch-functions-file=", + "-fms-secure-hotpatch-functions-list=", "-fsanitize-coverage-blacklist=", + "-fsanitize-coverage-ignorelist=", "-fsanitize-coverage-whitelist=", + "-fsanitize-coverage-allowlist=", "-foverride-record-layout=", "-frandomize-layout-seed-file=", + "-fsanitize-system-blacklist=", "-fsanitize-system-ignorelist=", + "-fprofile-remapping-file=", "-fmodules-user-build-path=", "-fprofile-instrument-use-path=", "-fprofile-instr-use=", + "-fprofile-sample-use=", "-fapinotes-cache-path=", "-fbuild-session-file=", + "-fcodegen-data-use=", "-fmemory-profile-use=", "-fmodules-cache-path=", + "-fprebuilt-module-path=", "-fsanitize-ignorelist=", "-fsanitize-blacklist=", + "-fthinlto-distributor=", "-fthinlto-index=", "-gen-cdb-fragment-path=", "-index-unit-output-path=", + "-load-plugin-library=", "-load-pass-plugin=", "-fprofile-list=", "-fmodule-map-file=", "-fmodule-file=", + "-fpass-plugin=", "-fprofile-use=", "-module-map-file=", "-profile-sample-use=", + "-working-directory=", "-ccc-gcc-name=", "-dependency-file=", "-dependency-dot=", + "-iapinotes-modules=", "-iapinotes-path=", "-index-store-path=", "-multi-lib-config=", + "-object-file-name=", "-profile-use=", "-fxray-always-instrument=", "-fxray-attr-list=", + "-fxray-never-instrument=", "-include-pch=", + "-include-pth=", "-gcc-toolchain=", "-resource-dir=", "-ivfsstatcache=", + "-plugin-path=", "-fplugin=", "-force_load=", "-ld-path=", + "-stdlib++-isystem", "-iframeworkwithsysroot", "-iwithprefixbefore", "-cxx-isystem", + "-iwithsysroot", "-iwithprefix", "-isystem-after", "-iframework", "-idirafter", + "-imacros=", "-include=", "-imacros", "-include", "-isystem", "-iquote", "-iprefix", "-isysroot=", + "-load=", "-plugin=", "-sdk=", "-MF", "-I", "-F", "-L", "-B" + }; +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs new file mode 100644 index 000000000..554132b81 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -0,0 +1,714 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void ValidateBuildFlagInputPaths( + string repositoryRoot, + string projectDirectory, + string value, + string key, + IReadOnlyCollection generatedOutputPaths, + string source, + ISet responseFiles) + { + var expandedTokens = ExpandCompilerResponseFileTokens( + repositoryRoot, + projectDirectory, + ExpandForwardedBuildFlagTokens(SplitBuildSettingPaths(value).ToArray(), key), + key, + generatedOutputPaths, + source, + responseFiles) + .ToArray(); + ValidateHeaderSearchPathInputs(projectDirectory, expandedTokens, key); + if (!key.Split('[')[0].Trim().Equals("COMPILER_FLAGS", StringComparison.OrdinalIgnoreCase) && + TryReadCompilerLanguageOverride(expandedTokens, out _)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} uses a compiler language override outside a source-owned PBXBuildFile and cannot be bound safely."); + } + ValidateLinkerFileLists( + repositoryRoot, + projectDirectory, + expandedTokens, + key, + generatedOutputPaths, + source); + foreach (var rawValue in ExtractBuildFlagInputPaths(expandedTokens, key)) + { + var normalizedValue = rawValue.Trim().TrimEnd('/'); + if (string.IsNullOrWhiteSpace(normalizedValue)) + continue; + if (IsValidatedToolchainOrBuildProductPath(normalizedValue, key, source)) + continue; + + var candidate = ResolveBuildSettingPath(projectDirectory, normalizedValue, key); + EnsurePathWithinRepository(repositoryRoot, candidate, $"Xcode build setting {key} from {source}"); + EnsureNoGeneratedOutputOverlap(candidate, generatedOutputPaths, $"Xcode build setting {key}"); + RejectHeaderMapInput(candidate, key); + if (File.Exists(candidate)) + EnsureTrackedFile(repositoryRoot, candidate, $"Xcode build setting {key}"); + else if (Directory.Exists(candidate)) + EnsureTrackedDirectoryTree(repositoryRoot, candidate, $"Xcode build setting {key}"); + else + throw new FileNotFoundException( + $"Xcode build setting {key} references a missing exact-source input: {candidate}", + candidate); + } + foreach (var searchPath in ReadAssemblerSearchPaths(expandedTokens, key)) + { + var candidate = ResolveBuildSettingPath(projectDirectory, searchPath, key); + if (Directory.Exists(candidate)) + { + var workingDirectory = Path.GetFullPath(projectDirectory); + if (!_approvedAssemblerSearchRoots.TryGetValue(workingDirectory, out var roots)) + { + roots = new List(); + _approvedAssemblerSearchRoots.Add(workingDirectory, roots); + } + var fullCandidate = Path.GetFullPath(candidate); + if (!roots.Contains(fullCandidate, GetPathComparer())) + roots.Add(fullCandidate); + } + } + } + + private static IEnumerable ReadAssemblerSearchPaths(string[] tokens, string key) + { + for (var index = 0; index < tokens.Length; index++) + { + var token = tokens[index]; + if (token.Equals("-I", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with '-I' and no search path."); + yield return tokens[index]; + continue; + } + if (token.StartsWith("-I", StringComparison.Ordinal) && token.Length > 2) + yield return token.Substring(2); + } + } + + private static bool TryReadCompilerLanguageOverride(string[] tokens, out string? language) + { + language = null; + for (var index = 0; index < tokens.Length; index++) + { + var token = tokens[index]; + string? candidate = null; + if (token.Equals("-x", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException("Compiler option '-x' is missing its language argument."); + candidate = tokens[index]; + } + else if (token.StartsWith("-x", StringComparison.Ordinal) && + token.Length > 2 && + IsCompilerLanguageName(token.Substring(2))) + { + candidate = token.Substring(2); + } + if (candidate is null) + continue; + if (language is not null && !language.Equals(candidate, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("PBX per-file compiler flags contain conflicting '-x' language overrides."); + language = candidate; + } + return language is not null; + } + + private static bool IsCompilerLanguageName(string value) + => value.Equals("none", StringComparison.OrdinalIgnoreCase) || + value.Equals("c", StringComparison.OrdinalIgnoreCase) || + value.Equals("c-header", StringComparison.OrdinalIgnoreCase) || + value.Equals("cpp-output", StringComparison.OrdinalIgnoreCase) || + value.Equals("c-cpp-output", StringComparison.OrdinalIgnoreCase) || + value.Equals("objective-c", StringComparison.OrdinalIgnoreCase) || + value.Equals("objective-c-header", StringComparison.OrdinalIgnoreCase) || + value.Equals("objective-c-cpp-output", StringComparison.OrdinalIgnoreCase) || + value.Equals("c++", StringComparison.OrdinalIgnoreCase) || + value.Equals("c++-header", StringComparison.OrdinalIgnoreCase) || + value.Equals("c++-cpp-output", StringComparison.OrdinalIgnoreCase) || + value.Equals("objective-c++", StringComparison.OrdinalIgnoreCase) || + value.Equals("objective-c++-header", StringComparison.OrdinalIgnoreCase) || + value.Equals("objective-c++-cpp-output", StringComparison.OrdinalIgnoreCase) || + value.Equals("assembler", StringComparison.OrdinalIgnoreCase) || + value.Equals("assembler-with-cpp", StringComparison.OrdinalIgnoreCase) || + value.Equals("cuda", StringComparison.OrdinalIgnoreCase) || + value.Equals("hip", StringComparison.OrdinalIgnoreCase) || + value.Equals("ir", StringComparison.OrdinalIgnoreCase) || + value.Equals("cl", StringComparison.OrdinalIgnoreCase) || + value.Equals("clcpp", StringComparison.OrdinalIgnoreCase) || + value.Equals("renderscript", StringComparison.OrdinalIgnoreCase); + + private void ValidateLinkerFileLists( + string repositoryRoot, + string projectDirectory, + string[] inputTokens, + string key, + IReadOnlyCollection generatedOutputPaths, + string source) + { + var tokens = ExpandForwardedBuildFlagTokens(inputTokens, key); + for (var index = 0; index < tokens.Length; index++) + { + if (!tokens[index].Equals("-filelist", StringComparison.Ordinal)) + continue; + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with linker option '-filelist' and no input."); + + var parts = tokens[index].Split(new[] { ',' }, 2, StringSplitOptions.None); + var listPath = ResolveBuildSettingPath(projectDirectory, parts[0], key); + EnsurePathWithinRepository(repositoryRoot, listPath, $"linker file list from {source}"); + EnsureNoGeneratedOutputOverlap(listPath, generatedOutputPaths, $"Xcode build setting {key} linker file list"); + if (!File.Exists(listPath)) + throw new FileNotFoundException($"Xcode build setting {key} references a missing linker file list: {listPath}", listPath); + EnsureTrackedFile(repositoryRoot, listPath, $"Xcode build setting {key} linker file list"); + + var inputRoot = parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[1]) + ? ResolveBuildSettingPath(projectDirectory, parts[1], key) + : projectDirectory; + EnsurePathWithinRepository(repositoryRoot, inputRoot, $"linker file list base directory from {source}"); + foreach (var line in File.ReadAllLines(listPath)) + { + var entry = line.Trim(); + if (entry.Length >= 2 && entry[0] == '"' && entry[entry.Length - 1] == '"') + entry = entry.Substring(1, entry.Length - 2).Replace("\\\"", "\""); + if (string.IsNullOrWhiteSpace(entry)) + continue; + var candidate = ResolveBuildSettingPath(inputRoot, entry, key); + EnsurePathWithinRepository(repositoryRoot, candidate, $"linker file list entry from {source}"); + EnsureNoGeneratedOutputOverlap(candidate, generatedOutputPaths, $"Xcode build setting {key} linker file list entry"); + if (!File.Exists(candidate)) + throw new FileNotFoundException($"Xcode build setting {key} linker file list references a missing exact-source input: {candidate}", candidate); + EnsureTrackedFile(repositoryRoot, candidate, $"Xcode build setting {key} linker file list entry"); + } + } + } + + private IEnumerable ExpandCompilerResponseFileTokens( + string repositoryRoot, + string projectDirectory, + IEnumerable tokens, + string key, + IReadOnlyCollection generatedOutputPaths, + string source, + ISet responseFiles) + { + foreach (var token in ExpandForwardedBuildFlagTokens(tokens.ToArray(), key)) + { + if (token.Length <= 1 || + token[0] != '@' || + IsAppleRuntimeRelativePath(token)) + { + yield return token; + continue; + } + + var responseValue = token.Substring(1).Trim(); + var candidate = ResolveBuildSettingPath(projectDirectory, responseValue, key); + EnsurePathWithinRepository(repositoryRoot, candidate, $"compiler response file from {source}"); + EnsureNoGeneratedOutputOverlap(candidate, generatedOutputPaths, $"Xcode build setting {key} response file"); + if (!File.Exists(candidate)) + throw new FileNotFoundException( + $"Xcode build setting {key} references a missing compiler response file: {candidate}", + candidate); + EnsureTrackedFile(repositoryRoot, candidate, $"Xcode build setting {key} compiler response file"); + if (!responseFiles.Add(candidate)) + throw new InvalidOperationException( + $"Xcode build setting {key} contains a recursive compiler response-file cycle at '{candidate}'."); + try + { + foreach (var nested in ExpandCompilerResponseFileTokens( + repositoryRoot, + projectDirectory, + SplitBuildSettingPaths(File.ReadAllText(candidate)), + key, + generatedOutputPaths, + $"response file {candidate}", + responseFiles)) + { + yield return nested; + } + } + finally + { + responseFiles.Remove(candidate); + } + } + } + + private static IEnumerable ExtractBuildFlagInputPaths(string[] inputTokens, string key) + { + var tokens = ExpandForwardedBuildFlagTokens(inputTokens, key); + var baseKey = key.Split('[')[0].Trim(); + var linkerFlags = baseKey.Equals("OTHER_LDFLAGS", StringComparison.OrdinalIgnoreCase); + var libtoolFlags = baseKey.Equals("OTHER_LIBTOOLFLAGS", StringComparison.OrdinalIgnoreCase); + var consumeNext = false; + for (var index = 0; index < tokens.Length; index++) + { + var token = tokens[index]; + if (consumeNext) + { + consumeNext = false; + yield return token; + continue; + } + + if (libtoolFlags) + { + if (token.Equals("-filelist", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with libtool option '-filelist' and no input."); + continue; + } + if (token.Equals("-arch_only", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with libtool option '-arch_only' and no architecture."); + if (IsPathLikeBuildFlagToken(tokens[index])) + { + throw new InvalidOperationException( + $"Libtool architecture in Xcode build setting {key} contains a path-like value: {tokens[index]}"); + } + continue; + } + if (token is "-static" or "-no_warning_for_no_symbols" or "-toc64" or "-c" or "-s" or "-a" or "-D" or "-V" or "-encode_sdk_libraries_as_references") + continue; + if (token.Equals("-o", StringComparison.Ordinal) || + token.Equals("-dependency_info", StringComparison.Ordinal) || + token.Equals("-ref-framework", StringComparison.Ordinal) || + token.StartsWith("-ref-l", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Libtool option '{token}' in Xcode build setting {key} cannot be bound safely as an exact-source input. Let Xcode own output and auto-link controls."); + } + if (token.StartsWith("-", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Libtool option '{token}' in Xcode build setting {key} cannot be classified safely for an exact-source Apple build."); + } + yield return token; + continue; + } + + if (token.Equals("--config", StringComparison.Ordinal) || + token.Equals("--config-user-dir", StringComparison.Ordinal) || + token.Equals("--config-system-dir", StringComparison.Ordinal) || + token.StartsWith("--config=", StringComparison.Ordinal) || + token.StartsWith("--config-user-dir=", StringComparison.Ordinal) || + token.StartsWith("--config-system-dir=", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Clang configuration-file option '{token}' in Xcode build setting {key} cannot be used by an exact-source Apple build."); + } + + if (token.Equals("-ivfsoverlay", StringComparison.Ordinal) || + token.Equals("-vfsoverlay", StringComparison.Ordinal) || + token.StartsWith("-ivfsoverlay=", StringComparison.Ordinal) || + token.StartsWith("-vfsoverlay=", StringComparison.Ordinal) || + (token.StartsWith("-ivfsoverlay", StringComparison.Ordinal) && token.Length > "-ivfsoverlay".Length) || + (token.StartsWith("-vfsoverlay", StringComparison.Ordinal) && token.Length > "-vfsoverlay".Length)) + { + throw new InvalidOperationException( + $"VFS overlay option '{token}' in Xcode build setting {key} cannot be used by an exact-source Apple build because its backing-file graph is not independently attested."); + } + + if (IsUnsupportedOffloadToolForwardingOption(token)) + { + throw new InvalidOperationException( + $"Offload tool forwarding option '{token}' in Xcode build setting {key} cannot be classified safely for an exact-source Apple build."); + } + + if (token.Equals("-load-plugin-executable", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with Swift compiler option '-load-plugin-executable' and no argument."); + yield return ReadSwiftPluginExecutablePath(tokens[index], key); + continue; + } + if (token.StartsWith("-load-plugin-executable=", StringComparison.Ordinal)) + { + yield return ReadSwiftPluginExecutablePath(token.Substring("-load-plugin-executable=".Length), key); + continue; + } + if (token.Equals("-external-plugin-path", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with Swift compiler option '-external-plugin-path' and no argument."); + foreach (var path in ReadSwiftExternalPluginPaths(tokens[index], key)) + yield return path; + continue; + } + if (token.StartsWith("-external-plugin-path=", StringComparison.Ordinal)) + { + foreach (var path in ReadSwiftExternalPluginPaths(token.Substring("-external-plugin-path=".Length), key)) + yield return path; + continue; + } + if (token.Equals("-load-resolved-plugin", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with Swift compiler option '-load-resolved-plugin' and no argument."); + foreach (var path in ReadSwiftResolvedPluginPaths(tokens[index], key)) + yield return path; + continue; + } + if (token.StartsWith("-load-resolved-plugin=", StringComparison.Ordinal)) + { + foreach (var path in ReadSwiftResolvedPluginPaths(token.Substring("-load-resolved-plugin=".Length), key)) + yield return path; + continue; + } + if (token.StartsWith("-swift-module-file=", StringComparison.Ordinal)) + { + yield return ReadSwiftModuleFilePath(token.Substring("-swift-module-file=".Length), key); + continue; + } + if (token.Equals("-swift-module-cross-import", StringComparison.Ordinal)) + { + if (index + 2 >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends before Swift option '-swift-module-cross-import' receives its module and overlay path."); + var moduleName = tokens[++index]; + yield return ReadSwiftCrossImportPath(moduleName, tokens[++index], key); + continue; + } + if (token.Equals("-remap-file", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with Clang option '-remap-file' and no argument."); + foreach (var path in ReadClangRemapFilePaths(tokens[index], key)) + yield return path; + continue; + } + if (token.StartsWith("-remap-file=", StringComparison.Ordinal)) + { + foreach (var path in ReadClangRemapFilePaths(token.Substring("-remap-file=".Length), key)) + yield return path; + continue; + } + if (linkerFlags && token.Equals("-dylib_file", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with linker option '-dylib_file' and no argument."); + yield return ReadDylibOverrideCurrentPath(tokens[index], key); + continue; + } + if (linkerFlags && token.StartsWith("-dylib_file=", StringComparison.Ordinal)) + { + yield return ReadDylibOverrideCurrentPath(token.Substring("-dylib_file=".Length), key); + continue; + } + if (linkerFlags && TryReadLinkerFileInputPaths(tokens, ref index, token, key, out var linkerInputPaths)) + { + foreach (var linkerInputPath in linkerInputPaths) + yield return linkerInputPath; + continue; + } + + if (BuildFlagPathOptions.Contains(token)) + { + consumeNext = true; + continue; + } + + + if (token.Equals("-filelist", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with linker option '-filelist' and no input."); + continue; + } + + var prefix = BuildFlagPathPrefixes.FirstOrDefault(candidate => + token.StartsWith(candidate, StringComparison.Ordinal) && token.Length > candidate.Length); + if (prefix is not null) + { + var pathValue = token.Substring(prefix.Length); + if (prefix.Equals("-fmodule-file=", StringComparison.Ordinal)) + { + var moduleSeparator = pathValue.IndexOf('='); + if (moduleSeparator >= 0) + pathValue = pathValue.Substring(moduleSeparator + 1); + } + yield return pathValue; + continue; + } + + if (token.Equals("-D", StringComparison.Ordinal) || + token.Equals("-U", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with preprocessor option '{token}' and no argument."); + ValidatePreprocessorFlagPayload(tokens[index], key); + continue; + } + + if (token.StartsWith("-D", StringComparison.Ordinal) || + token.StartsWith("-U", StringComparison.Ordinal)) + { + ValidatePreprocessorFlagPayload(token.Substring(2), key); + continue; + } + + if (token.StartsWith("-Werror=", StringComparison.Ordinal) || + token.StartsWith("-Wno-", StringComparison.Ordinal)) + { + continue; + } + + if (IsAppleRuntimeRelativePath(token)) + continue; + + if (linkerFlags && TrySkipNonInputLinkerOption(tokens, ref index, token, key)) + continue; + + if (!linkerFlags && TrySkipNonInputCompilerOption(tokens, ref index, token, key)) + continue; + + if (token.StartsWith("-", StringComparison.Ordinal) && IsPathLikeBuildFlagToken(token)) + { + throw new InvalidOperationException( + $"Path-bearing option in Xcode build setting {key} cannot be classified safely: {token}"); + } + + if (!token.StartsWith("-", StringComparison.Ordinal) && + IsPathLikeBuildFlagToken(token)) + { + throw new InvalidOperationException( + $"Path-like token in Xcode build setting {key} cannot be classified safely: {token}"); + } + + if (!token.StartsWith("-", StringComparison.Ordinal)) + yield return token; + } + if (consumeNext) + throw new InvalidOperationException($"Xcode build setting {key} ends with a path-consuming flag and no input."); + } + + private static bool TrySkipNonInputCompilerOption(string[] tokens, ref int index, string token, string key) + { + var consumesOneValue = token.Equals("-arch", StringComparison.Ordinal) || + token.Equals("-target", StringComparison.Ordinal) || + token.Equals("--target", StringComparison.Ordinal) || + token.Equals("-std", StringComparison.Ordinal) || + token.Equals("-x", StringComparison.Ordinal) || + token.Equals("-stdlib", StringComparison.Ordinal) || + token.Equals("-module-name", StringComparison.Ordinal) || + token.Equals("-swift-version", StringComparison.Ordinal) || + token.Equals("-enforce-exclusivity", StringComparison.Ordinal) || + token.Equals("-enable-experimental-feature", StringComparison.Ordinal) || + token.Equals("-enable-upcoming-feature", StringComparison.Ordinal) || + token.Equals("-strict-concurrency", StringComparison.Ordinal); + if (!consumesOneValue) + return false; + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with compiler option '{token}' and no argument."); + var value = tokens[index]; + if (IsPathLikeBuildFlagToken(value)) + { + throw new InvalidOperationException( + $"Non-path compiler option '{token}' in Xcode build setting {key} contains a path-like argument: {value}"); + } + return true; + } + + private static bool IsUnsupportedOffloadToolForwardingOption(string token) + => token.Equals("-Xcuda-fatbinary", StringComparison.Ordinal) || + token.Equals("-Xcuda-ptxas", StringComparison.Ordinal) || + token.StartsWith("-Xoffload-linker", StringComparison.Ordinal) || + token.StartsWith("-Xopenmp-target", StringComparison.Ordinal) || + token.StartsWith("-Xsycl-target-", StringComparison.Ordinal); + + private static bool TrySkipNonInputLinkerOption(string[] tokens, ref int index, string token, string key) + { + if (token.Equals("-framework", StringComparison.Ordinal) || + token.Equals("-weak_framework", StringComparison.Ordinal) || + token.Equals("-reexport_framework", StringComparison.Ordinal) || + token.Equals("-lazy_framework", StringComparison.Ordinal) || + token.Equals("-needed_framework", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with linker option '{token}' and no framework name."); + throw new InvalidOperationException( + $"Named framework '{tokens[index]}' in Xcode build setting {key} cannot be bound to an exact SDK, toolchain, or tracked framework input. " + + "Use a validated PBX framework reference or an explicit approved-root framework path."); + } + + var argumentCount = token switch + { + "-compatibility_version" => 1, + "-current_version" => 1, + "-arch" => 1, + "-e" => 1, + "-macos_version_min" => 1, + "-ios_version_min" => 1, + "-iphoneos_version_min" => 1, + "-tvos_version_min" => 1, + "-watchos_version_min" => 1, + "-platform_version" => 3, + _ => 0 + }; + if (argumentCount == 0) + return false; + if (index + argumentCount >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends before linker option '{token}' receives all arguments."); + index += argumentCount; + return true; + } + + private static void ValidatePreprocessorFlagPayload(string payload, string key) + { + if (string.IsNullOrWhiteSpace(payload)) + throw new InvalidOperationException($"Xcode build setting {key} contains an empty preprocessor definition or undefinition."); + if (payload.Contains("$(", StringComparison.Ordinal) || + payload.Contains("${", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains a preprocessor definition or undefinition with an unresolved build-setting reference: {payload}"); + } + var nondeterministicIdentifier = FindNondeterministicCompilerMacro(payload); + if (nondeterministicIdentifier is not null) + { + throw new InvalidOperationException( + $"Xcode build setting {key} supplies nondeterministic compiler identifier '{nondeterministicIdentifier}' through a preprocessor definition or undefinition."); + } + var valueSeparator = payload.IndexOf('='); + var macroValue = valueSeparator >= 0 ? payload.Substring(valueSeparator + 1) : string.Empty; + if (CanConstructAssemblerFileDirective(macroValue, parameters: null)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} supplies a preprocessor definition that can construct a file-consuming assembler directive: {payload}"); + } + } + + private static string[] ExpandForwardedBuildFlagTokens(string[] tokens, string key) + { + var expanded = new List(tokens.Length); + for (var index = 0; index < tokens.Length; index++) + { + var token = tokens[index]; + if (token.Equals("-Xcc", StringComparison.Ordinal) || + token.Equals("-Xlinker", StringComparison.Ordinal) || + token.Equals("-Xfrontend", StringComparison.Ordinal) || + token.Equals("-Xswiftc", StringComparison.Ordinal) || + token.Equals("-Xassembler", StringComparison.Ordinal) || + token.Equals("-Xpreprocessor", StringComparison.Ordinal) || + token.Equals("-Xclang", StringComparison.Ordinal)) + { + if (++index >= tokens.Length) + throw new InvalidOperationException($"Xcode build setting {key} ends with forwarding option '{token}' and no argument."); + expanded.Add(tokens[index]); + continue; + } + + if (token.StartsWith("-Wl,", StringComparison.Ordinal) || + token.StartsWith("-Wp,", StringComparison.Ordinal) || + token.StartsWith("-Wa,", StringComparison.Ordinal)) + { + expanded.AddRange(token.Substring(4).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); + continue; + } + + foreach (var wrapper in new[] + { + "-Xcc=", "-Xlinker=", "-Xfrontend=", "-Xswiftc=", + "-Xassembler=", "-Xpreprocessor=", "-Xclang=" + }) + { + if (!token.StartsWith(wrapper, StringComparison.Ordinal)) + continue; + expanded.Add(token.Substring(wrapper.Length)); + token = string.Empty; + break; + } + if (!string.IsNullOrEmpty(token)) + expanded.Add(token); + } + return expanded.ToArray(); + } + + private static bool IsPathLikeBuildFlagToken(string token) + => Path.IsPathRooted(token) || + token.Contains('/') || + token.Contains('\\') || + token.Contains("$(", StringComparison.Ordinal) || + token.Contains("${", StringComparison.Ordinal); + + private static bool IsAppleRuntimeRelativePath(string token) + => IsAppleRuntimeRelativePath(token, "@executable_path") || + IsAppleRuntimeRelativePath(token, "@loader_path") || + IsAppleRuntimeRelativePath(token, "@rpath"); + + private static bool IsAppleRuntimeRelativePath(string token, string marker) + => token.Equals(marker, StringComparison.Ordinal) || + (token.StartsWith(marker, StringComparison.Ordinal) && + token.Length > marker.Length && + token[marker.Length] == '/'); + + private static bool IsValidatedToolchainOrBuildProductPath( + string value, + string key, + string source) + { + var unownedBuildRoots = new[] + { + "$(BUILT_PRODUCTS_DIR)", "${BUILT_PRODUCTS_DIR}", + "$(CONFIGURATION_BUILD_DIR)", "${CONFIGURATION_BUILD_DIR}", + "$(TARGET_BUILD_DIR)", "${TARGET_BUILD_DIR}" + }; + var unownedBuildRoot = unownedBuildRoots.FirstOrDefault(candidate => + value.Equals(candidate, StringComparison.Ordinal) || + (value.StartsWith(candidate, StringComparison.Ordinal) && + value.Length > candidate.Length && + (value[candidate.Length] == '/' || value[candidate.Length] == '\\'))); + if (unownedBuildRoot is not null) + { + throw new InvalidOperationException( + $"Xcode build setting {key} consumes unowned build output '{unownedBuildRoot}', whose producing target and bytes cannot be proven at the exact source commit: {value} ({source})"); + } + + var known = new[] + { + "$(SDKROOT)", "${SDKROOT}", + "$(DEVELOPER_DIR)", "${DEVELOPER_DIR}", + "$(TOOLCHAIN_DIR)", "${TOOLCHAIN_DIR}" + }; + var prefix = known.FirstOrDefault(candidate => + value.Equals(candidate, StringComparison.Ordinal) || + (value.StartsWith(candidate, StringComparison.Ordinal) && + value.Length > candidate.Length && + (value[candidate.Length] == '/' || value[candidate.Length] == '\\'))); + if (prefix is null) + return false; + + var suffix = value.Substring(prefix.Length).Replace('\\', '/'); + if (suffix.Contains("$(", StringComparison.Ordinal) || + suffix.Contains("${", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} composes multiple build roots and cannot be proven safely: {value} ({source})"); + } + + var depth = 0; + foreach (var segment in suffix.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (segment == ".") + continue; + if (segment == "..") + { + if (depth == 0) + { + throw new InvalidOperationException( + $"Xcode build setting {key} escapes approved toolchain or build-product root '{prefix}': {value} ({source})"); + } + depth--; + continue; + } + depth++; + } + return true; + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs new file mode 100644 index 000000000..adb27d43c --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs @@ -0,0 +1,183 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static string MaskCStringAndCharacterLiterals(string source) + { + var result = new System.Text.StringBuilder(source.Length); + var quote = '\0'; + var escaped = false; + for (var index = 0; index < source.Length; index++) + { + var current = source[index]; + if (quote == '\0') + { + if (TryReadCppRawStringLiteral(source, index, out var rawEnd)) + { + for (; index <= rawEnd; index++) + result.Append(source[index] is '\r' or '\n' ? source[index] : ' '); + index--; + continue; + } + if (current is '\"' or '\'') + { + quote = current; + result.Append(' '); + } + else + { + result.Append(current); + } + continue; + } + + result.Append(current is '\r' or '\n' ? current : ' '); + if (escaped) + escaped = false; + else if (current == '\\') + escaped = true; + else if (current == quote) + quote = '\0'; + } + return result.ToString(); + } + + private static string SpliceCPreprocessingLines(string source) + { + var result = new System.Text.StringBuilder(source.Length); + for (var index = 0; index < source.Length; index++) + { + if (source[index] != '\\' || index + 1 >= source.Length) + { + result.Append(source[index]); + continue; + } + + if (source[index + 1] == '\n') + { + index++; + continue; + } + if (source[index + 1] == '\r') + { + index++; + if (index + 1 < source.Length && source[index + 1] == '\n') + index++; + continue; + } + + result.Append(source[index]); + } + return result.ToString(); + } + + private static string RemoveCComments(string source) + { + var result = new System.Text.StringBuilder(source.Length); + var inBlockComment = false; + var inLineComment = false; + var quote = '\0'; + var escaped = false; + for (var index = 0; index < source.Length; index++) + { + var current = source[index]; + var next = index + 1 < source.Length ? source[index + 1] : '\0'; + if (inLineComment) + { + if (current == '\r' || current == '\n') + { + inLineComment = false; + result.Append(current); + } + else + { + result.Append(' '); + } + continue; + } + if (inBlockComment) + { + if (current == '*' && next == '/') + { + index++; + inBlockComment = false; + } + continue; + } + if (quote != '\0') + { + result.Append(current); + if (escaped) + escaped = false; + else if (current == '\\') + escaped = true; + else if (current == quote) + quote = '\0'; + continue; + } + if (TryReadCppRawStringLiteral(source, index, out var rawEnd)) + { + for (; index <= rawEnd; index++) + result.Append(source[index] is '\r' or '\n' ? source[index] : ' '); + index--; + continue; + } + if (current == '/' && next == '/') + { + result.Append(" "); + index++; + inLineComment = true; + continue; + } + if (current == '/' && next == '*') + { + // Translation phase 3 replaces one complete block comment with one space. + // In particular, newlines inside the comment do not terminate a directive. + result.Append(' '); + index++; + inBlockComment = true; + continue; + } + if (current == '"' || current == '\'') + quote = current; + result.Append(current); + } + return result.ToString(); + } + + private static bool TryReadCppRawStringLiteral(string source, int start, out int end) + { + end = -1; + if (start > 0 && (source[start - 1] == '_' || char.IsLetterOrDigit(source[start - 1]))) + return false; + + var prefixes = new[] { "u8R\"", "uR\"", "UR\"", "LR\"", "R\"" }; + var prefix = prefixes.FirstOrDefault(candidate => + start + candidate.Length <= source.Length && + source.Substring(start, candidate.Length).Equals(candidate, StringComparison.Ordinal)); + if (prefix is null) + return false; + + var delimiterStart = start + prefix.Length; + var opening = delimiterStart; + while (opening < source.Length && + opening - delimiterStart <= 16 && + source[opening] != '(') + { + var value = source[opening]; + if (value <= ' ' || value is ')' or '\\') + return false; + opening++; + } + if (opening >= source.Length || source[opening] != '(' || opening - delimiterStart > 16) + return false; + + var delimiter = source.Substring(delimiterStart, opening - delimiterStart); + var terminator = ")" + delimiter + "\""; + var closing = source.IndexOf(terminator, opening + 1, StringComparison.Ordinal); + if (closing < 0) + return false; + end = closing + terminator.Length - 1; + return true; + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs new file mode 100644 index 000000000..fd53bcd08 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs @@ -0,0 +1,89 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static string ReadSwiftPluginExecutablePath(string value, string key) + { + var separator = value.IndexOf('#'); + if (separator <= 0 || separator == value.Length - 1) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains malformed Swift compiler plugin executable '{value}'. Expected #."); + } + return value.Substring(0, separator); + } + + private static string[] ReadSwiftExternalPluginPaths(string value, string key) + { + var separator = value.IndexOf('#'); + if (separator <= 0 || separator == value.Length - 1 || value.IndexOf('#', separator + 1) >= 0) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains malformed Swift external plugin path '{value}'. Expected #."); + } + return new[] { value.Substring(0, separator), value.Substring(separator + 1) }; + } + + private static string[] ReadSwiftResolvedPluginPaths(string value, string key) + { + var parts = value.Split(new[] { '#' }, 3, StringSplitOptions.None); + if (parts.Length != 3 || parts.Any(string.IsNullOrWhiteSpace)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains malformed Swift resolved plugin '{value}'. Expected ##."); + } + return new[] { parts[0], parts[1] }; + } + + private static string ReadSwiftModuleFilePath(string value, string key) + { + var separator = value.IndexOf('='); + if (separator <= 0 || separator == value.Length - 1) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains malformed Swift module input '{value}'. Expected =."); + } + var moduleName = value.Substring(0, separator); + if (moduleName.Any(char.IsWhiteSpace) || IsPathLikeBuildFlagToken(moduleName)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains an invalid Swift module name in '{value}'."); + } + return value.Substring(separator + 1); + } + + private static string ReadSwiftCrossImportPath(string moduleName, string value, string key) + { + if (string.IsNullOrWhiteSpace(moduleName) || + moduleName.Any(char.IsWhiteSpace) || + IsPathLikeBuildFlagToken(moduleName) || + string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains malformed Swift cross-import input. Expected ."); + } + return value; + } + + private static string ReadDylibOverrideCurrentPath(string value, string key) + { + var separator = value.IndexOf(':'); + if (separator <= 0 || separator == value.Length - 1) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains malformed linker dylib override '{value}'. Expected :."); + } + return value.Substring(separator + 1); + } + + private static string[] ReadClangRemapFilePaths(string value, string key) + { + var parts = value.Split(new[] { ';' }, StringSplitOptions.None); + if (parts.Length != 2 || parts.Any(string.IsNullOrWhiteSpace)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains malformed Clang remap input '{value}'. Expected ;."); + } + return parts; + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs b/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs new file mode 100644 index 000000000..b39f7beae --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs @@ -0,0 +1,82 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void EnsureNoCustomGitFilter(string repositoryRoot, string relativePath, string name) + { + var attributes = RunGit(repositoryRoot, "check-attr", "-z", "filter", "--", relativePath) + .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries); + var value = attributes.Length >= 3 ? attributes[2] : "unspecified"; + if (!value.Equals("unspecified", StringComparison.Ordinal) && + !value.Equals("unset", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"{name} uses custom Git filter '{value}' and cannot be attested to the exact source commit: {relativePath}. " + + "Exact Apple source inputs may use Git text/EOL normalization but not repository-configuration-dependent clean or smudge filters."); + } + } + + private string ComputeRawGitBlobId(string repositoryRoot, string filePath) + { + var objectFormat = ReadGitObjectFormat(repositoryRoot); + using System.Security.Cryptography.HashAlgorithm hash = objectFormat.Equals("sha256", StringComparison.OrdinalIgnoreCase) + ? System.Security.Cryptography.SHA256.Create() + : objectFormat.Equals("sha1", StringComparison.OrdinalIgnoreCase) + ? System.Security.Cryptography.SHA1.Create() + : throw new InvalidOperationException($"Unsupported Git object format '{objectFormat}'."); + var length = new FileInfo(filePath).Length; + var prefix = System.Text.Encoding.ASCII.GetBytes($"blob {length}\0"); + hash.TransformBlock(prefix, 0, prefix.Length, prefix, 0); + using var stream = File.OpenRead(filePath); + var buffer = new byte[81920]; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + hash.TransformBlock(buffer, 0, read, buffer, 0); + hash.TransformFinalBlock(Array.Empty(), 0, 0); + return BitConverter.ToString(hash.Hash!).Replace("-", string.Empty).ToLowerInvariant(); + } + + private string ComputeRawGitBlobId(string repositoryRoot, byte[] content) + { + var objectFormat = ReadGitObjectFormat(repositoryRoot); + using System.Security.Cryptography.HashAlgorithm hash = objectFormat.Equals("sha256", StringComparison.OrdinalIgnoreCase) + ? System.Security.Cryptography.SHA256.Create() + : objectFormat.Equals("sha1", StringComparison.OrdinalIgnoreCase) + ? System.Security.Cryptography.SHA1.Create() + : throw new InvalidOperationException($"Unsupported Git object format '{objectFormat}'."); + var prefix = System.Text.Encoding.ASCII.GetBytes($"blob {content.LongLength}\0"); + hash.TransformBlock(prefix, 0, prefix.Length, prefix, 0); + hash.TransformFinalBlock(content, 0, content.Length); + return BitConverter.ToString(hash.Hash!).Replace("-", string.Empty).ToLowerInvariant(); + } + + private string ComputePathAwareGitBlobId(string repositoryRoot, string filePath, string relativePath) + => RunGit(repositoryRoot, "hash-object", $"--path={relativePath}", "--", filePath).StdOut.Trim(); + + private string ComputePathAwareGitBlobId(string repositoryRoot, byte[] content, string relativePath) + { + var temporaryRoot = Path.Combine(Path.GetTempPath(), ".powerforge-git-filter-" + Guid.NewGuid().ToString("N")); + var temporaryPath = Path.Combine(temporaryRoot, "captured-input"); + Directory.CreateDirectory(temporaryRoot); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(temporaryRoot, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + try + { + File.WriteAllBytes(temporaryPath, content); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(temporaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); +#endif + return RunGit(repositoryRoot, "hash-object", $"--path={relativePath}", "--", temporaryPath).StdOut.Trim(); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + if (Directory.Exists(temporaryRoot)) + Directory.Delete(temporaryRoot); + } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs b/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs new file mode 100644 index 000000000..ddd1ec826 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs @@ -0,0 +1,202 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void ValidateInlineAssemblerInputs( + string repositoryRoot, + string sourcePath, + string source, + string assemblerWorkingDirectory) + { + var syntax = MaskCStringAndCharacterLiterals(source); + foreach (Match invocation in Regex.Matches( + syntax, + "(? extension.Equals(".c", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".cc", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".cpp", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".cxx", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".m", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".mm", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".h", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".hh", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".hpp", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".hxx", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".pch", StringComparison.OrdinalIgnoreCase); + + private static int FindMatchingCDelimiter(string source, int openingIndex, char opening, char closing) + { + var depth = 0; + var quote = '\0'; + var escaped = false; + for (var index = openingIndex; index < source.Length; index++) + { + var current = source[index]; + if (quote != '\0') + { + if (escaped) + escaped = false; + else if (current == '\\') + escaped = true; + else if (current == quote) + quote = '\0'; + continue; + } + if (current is '\"' or '\'') + { + quote = current; + continue; + } + if (current == opening) + depth++; + else if (current == closing && --depth == 0) + return index; + } + throw new InvalidOperationException("Inline assembler declaration contains an unterminated parenthesized expression."); + } + + private static string ReadCInlineAssemblyTemplate(string body) + { + var quote = '\0'; + var escaped = false; + var parentheses = 0; + var brackets = 0; + var braces = 0; + for (var index = 0; index < body.Length; index++) + { + var current = body[index]; + if (quote != '\0') + { + if (escaped) + escaped = false; + else if (current == '\\') + escaped = true; + else if (current == quote) + quote = '\0'; + continue; + } + if (current is '\"' or '\'') + { + quote = current; + continue; + } + switch (current) + { + case '(': + parentheses++; + break; + case ')': + parentheses--; + break; + case '[': + brackets++; + break; + case ']': + brackets--; + break; + case '{': + braces++; + break; + case '}': + braces--; + break; + case ':' when parentheses == 0 && brackets == 0 && braces == 0: + return body.Substring(0, index); + } + } + return body; + } + + private static bool TryDecodeConcatenatedCStringLiterals(string expression, out string value) + { + var result = new System.Text.StringBuilder(); + var index = 0; + var literals = 0; + while (index < expression.Length) + { + while (index < expression.Length && char.IsWhiteSpace(expression[index])) + index++; + if (index >= expression.Length) + break; + + if (index + 1 < expression.Length && expression[index] == 'u' && expression[index + 1] == '8') + index += 2; + else if (expression[index] is 'u' or 'U' or 'L') + index++; + if (index >= expression.Length || expression[index] != '\"') + { + value = string.Empty; + return false; + } + index++; + literals++; + var closed = false; + while (index < expression.Length) + { + var current = expression[index++]; + if (current == '\"') + { + closed = true; + break; + } + if (current != '\\') + { + result.Append(current); + continue; + } + if (index >= expression.Length) + { + value = string.Empty; + return false; + } + var escaped = expression[index++]; + result.Append(escaped switch + { + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'a' => '\a', + 'b' => '\b', + 'f' => '\f', + 'v' => '\v', + '\\' => '\\', + '\"' => '\"', + '\'' => '\'', + '?' => '?', + _ => '\0' + }); + if (result[result.Length - 1] == '\0') + { + value = string.Empty; + return false; + } + } + if (!closed) + { + value = string.Empty; + return false; + } + } + value = result.ToString(); + return literals > 0; + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs new file mode 100644 index 000000000..31d59f12e --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -0,0 +1,573 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void ValidateLocalPackageReference( + string repositoryRoot, + string projectDirectory, + IReadOnlyCollection packageLockPaths, + PbxObject item, + ISet validatedPackageRoots) + { + var relativePath = ReadPbxScalar(item.Body, "relativePath"); + if (string.IsNullOrWhiteSpace(relativePath)) + throw new InvalidOperationException("Local Swift package reference is missing relativePath."); + var packageRoot = ResolvePbxPath(projectDirectory, relativePath!, "local Swift package"); + EnsureDirectoryWithinRepository(repositoryRoot, packageRoot, "Xcode local Swift package"); + ValidateLocalPackageRoot(repositoryRoot, packageRoot, packageLockPaths, validatedPackageRoots); + } + + private void ValidateLocalPackageRoot( + string repositoryRoot, + string packageRoot, + IReadOnlyCollection packageLockPaths, + ISet validatedPackageRoots) + { + packageRoot = Path.GetFullPath(packageRoot); + EnsureDirectoryWithinRepository(repositoryRoot, packageRoot, "Xcode local Swift package"); + if (!validatedPackageRoots.Add(packageRoot)) + return; + + var manifestPaths = Directory.EnumerateFiles(packageRoot, "Package*.swift", SearchOption.TopDirectoryOnly) + .Where(path => Path.GetFileName(path).Equals("Package.swift", StringComparison.Ordinal) || + Regex.IsMatch( + Path.GetFileName(path), + "^Package@swift-[0-9]+(?:\\.[0-9]+)*\\.swift$", + RegexOptions.CultureInvariant)) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + if (!manifestPaths.Any(path => Path.GetFileName(path).Equals("Package.swift", StringComparison.Ordinal))) + throw new FileNotFoundException($"Local Swift package manifest was not found: {Path.Combine(packageRoot, "Package.swift")}"); + foreach (var manifestPath in manifestPaths) + EnsureTrackedFile(repositoryRoot, manifestPath, "Xcode local Swift package manifest"); + foreach (var conventionalInput in new[] + { + Path.Combine(packageRoot, "Package.resolved"), + Path.Combine(packageRoot, "Sources"), + Path.Combine(packageRoot, "Plugins") + }) + { + if (File.Exists(conventionalInput)) + EnsureTrackedFile(repositoryRoot, conventionalInput, "Xcode local Swift package input"); + else if (Directory.Exists(conventionalInput)) + EnsureTrackedDirectoryTree( + repositoryRoot, + conventionalInput, + "Xcode local Swift package input", + assemblerWorkingDirectory: packageRoot); + } + + foreach (var manifestPath in manifestPaths) + ValidateLocalPackageManifest( + repositoryRoot, + packageRoot, + packageLockPaths, + validatedPackageRoots, + manifestPath); + } + + private void ValidateLocalPackageManifest( + string repositoryRoot, + string packageRoot, + IReadOnlyCollection packageLockPaths, + ISet validatedPackageRoots, + string manifestPath) + { + var manifestWithoutComments = RemoveSwiftComments(File.ReadAllText(manifestPath)); + EnsureNoExecutableSwiftStringInterpolation(packageRoot, manifestWithoutComments); + var manifestSyntax = MaskSwiftStringLiterals(manifestWithoutComments); + ValidateLocalPackageExecutableSafety(packageRoot, manifestWithoutComments, manifestSyntax); + ValidateDirectSwiftPackageDependencyFactories(packageRoot, manifestSyntax); + ValidatePackageDescriptionCalls(packageRoot, manifestSyntax); + ValidateSwiftPackageLinkedDependencies(packageRoot, manifestWithoutComments, manifestSyntax); + ValidateSwiftPackageResources(repositoryRoot, packageRoot, manifestWithoutComments, manifestSyntax); + var dependencyCalls = ParseDirectSwiftPackageDependencyCalls(manifestWithoutComments, manifestSyntax); + ValidateRemotePackageDependencies(repositoryRoot, packageRoot, packageLockPaths, dependencyCalls); + ValidateNestedLocalPackageDependencies( + repositoryRoot, + packageRoot, + packageLockPaths, + validatedPackageRoots, + dependencyCalls); + ValidateLiteralSwiftPackagePaths(repositoryRoot, packageRoot, manifestWithoutComments, manifestSyntax); + } + + private static void ValidateLocalPackageExecutableSafety( + string packageRoot, + string manifestSource, + string manifestSyntax, + bool allowInactiveNonAppleSystemLibraries = false) + { + if (ContainsSwiftIdentifier(manifestSyntax, "unsafeFlags")) + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses unsafeFlags, whose compiler and linker inputs cannot be proven at the exact source commit. " + + "Replace unsafe flags with tracked package settings before creating an Apple checkpoint."); + if (ContainsSwiftIdentifier(manifestSyntax, "systemLibrary") && + (!allowInactiveNonAppleSystemLibraries || + !AllSystemLibrariesAreExcludedFromAppleTargets(manifestSource, manifestSyntax))) + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' declares a systemLibrary target, whose pkg-config and host library inputs cannot be proven at the exact source commit. " + + "Replace the system library dependency with tracked package sources before creating an Apple checkpoint."); + if (ContainsSwiftIdentifier(manifestSyntax, "plugin") || ContainsSwiftMemberReference(manifestSyntax, "macro")) + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' declares or invokes a SwiftPM plugin or macro, whose executable runtime inputs cannot be proven at the exact source commit. " + + "Replace build-tool plugins and macros with tracked deterministic build inputs before creating an Apple checkpoint."); + ValidateDeclarativeSwiftPackageManifest(packageRoot, manifestSyntax); + } + + private static bool AllSystemLibrariesAreExcludedFromAppleTargets(string source, string syntax) + { + var systemLibraries = new HashSet(StringComparer.Ordinal); + foreach (Match reference in Regex.Matches(syntax, "\\.\\s*(?:systemLibrary|`systemLibrary`)\\s*\\(", RegexOptions.CultureInvariant)) + { + var opening = reference.Index + reference.Length - 1; + var closing = FindMatchingSwiftDelimiter(syntax, opening, '(', ')'); + var arguments = ParseTopLevelSwiftArguments( + source.Substring(opening + 1, closing - opening - 1), + syntax.Substring(opening + 1, closing - opening - 1)); + if (!arguments.TryGetValue("name", out var nameArgument) || + !TryReadLiteralSwiftString(nameArgument, out var name)) + return false; + systemLibraries.Add(name); + } + if (systemLibraries.Count == 0) + return false; + + foreach (var systemLibrary in systemLibraries) + { + var conditionedReferences = 0; + foreach (Match reference in Regex.Matches(syntax, "\\.\\s*(?:target|`target`)\\s*\\(", RegexOptions.CultureInvariant)) + { + var opening = reference.Index + reference.Length - 1; + var closing = FindMatchingSwiftDelimiter(syntax, opening, '(', ')'); + var arguments = ParseTopLevelSwiftArguments( + source.Substring(opening + 1, closing - opening - 1), + syntax.Substring(opening + 1, closing - opening - 1)); + if (!arguments.TryGetValue("name", out var nameArgument) || + !TryReadLiteralSwiftString(nameArgument, out var name) || + !name.Equals(systemLibrary, StringComparison.Ordinal)) + continue; + if (!arguments.TryGetValue("condition", out var condition) || + !Regex.IsMatch(condition, "\\.\\s*when\\s*\\(", RegexOptions.CultureInvariant) || + !condition.Contains("platforms", StringComparison.Ordinal) || + Regex.IsMatch(condition, "\\.\\s*(?:iOS|macOS|macCatalyst|watchOS|tvOS|visionOS)\\b", RegexOptions.CultureInvariant) || + !Regex.IsMatch(condition, "\\.\\s*(?:linux|android|windows|openbsd|wasi)\\b", RegexOptions.CultureInvariant)) + { + return false; + } + conditionedReferences++; + } + + var literalOccurrences = Regex.Matches( + source, + "\"" + Regex.Escape(systemLibrary) + "\"", + RegexOptions.CultureInvariant).Count; + if (conditionedReferences == 0 || literalOccurrences != conditionedReferences + 1) + return false; + } + + return true; + } + + private static HashSet ReadInactiveNonAppleSystemLibraryRoots( + string packageRoot, + string source, + string syntax) + { + var roots = new HashSet(GetPathComparer()); + if (!AllSystemLibrariesAreExcludedFromAppleTargets(source, syntax)) + return roots; + + foreach (Match reference in Regex.Matches(syntax, "\\.\\s*(?:systemLibrary|`systemLibrary`)\\s*\\(", RegexOptions.CultureInvariant)) + { + var opening = reference.Index + reference.Length - 1; + var closing = FindMatchingSwiftDelimiter(syntax, opening, '(', ')'); + var arguments = ParseTopLevelSwiftArguments( + source.Substring(opening + 1, closing - opening - 1), + syntax.Substring(opening + 1, closing - opening - 1)); + if (!arguments.TryGetValue("name", out var nameArgument) || + !TryReadLiteralSwiftString(nameArgument, out var name)) + continue; + + var relative = Path.Combine("Sources", name); + if (arguments.TryGetValue("path", out var pathArgument)) + { + if (!TryReadLiteralSwiftString(pathArgument, out relative)) + continue; + } + roots.Add(Path.GetFullPath(Path.Combine(packageRoot, relative))); + } + return roots; + } + + private static void ValidateDeclarativeSwiftPackageManifest(string packageRoot, string manifestSyntax) + { + var compilerLiteral = Regex.Match( + manifestSyntax, + "(?[A-Za-z_][A-Za-z0-9_]*)", + RegexOptions.CultureInvariant); + if (compilerLiteral.Success) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses compiler-provided manifest literal '#{compilerLiteral.Groups["literal"].Value}', " + + "which can expose checkout or host state. Use literal PackageDescription declarations before creating an exact-source Apple checkpoint."); + } + + foreach (Match import in Regex.Matches( + manifestSyntax, + "(?[A-Za-z_][A-Za-z0-9_]*)(?:\\.[A-Za-z_][A-Za-z0-9_]*)*", + RegexOptions.CultureInvariant)) + { + var module = import.Groups["module"].Value; + if (module.Equals("PackageDescription", StringComparison.Ordinal)) + continue; + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' imports '{module}', which permits host-dependent manifest execution. " + + "Use a declarative PackageDescription-only manifest before creating an exact-source Apple checkpoint."); + } + + var hostStateIdentifiers = new[] + { + "ProcessInfo", "NSProcessInfo", "FileManager", "CommandLine", "UserDefaults", + "Date", "Calendar", "TimeZone", "Locale", "Bundle", "Process", "UUID", + "Context", "System", "Clock", "ContinuousClock", "SuspendingClock", "DispatchTime", + "getenv", "readLine", "arc4random", "SecRandomCopyBytes", "CFAbsoluteTimeGetCurrent" + }; + var hostState = hostStateIdentifiers.FirstOrDefault(identifier => + ContainsSwiftIdentifier(manifestSyntax, identifier)); + if (hostState is not null) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' reads host state through '{hostState}', so its executed manifest cannot be bound to the exact source commit. " + + "Use literal PackageDescription declarations before creating an exact-source Apple checkpoint."); + } + + var executableControlFlow = Regex.Match( + manifestSyntax, + "(?if|else|switch|case|for|while|repeat|guard|do|try|catch|throw|defer|return)(?![A-Za-z0-9_])", + RegexOptions.CultureInvariant); + if (executableControlFlow.Success) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses executable manifest control flow '{executableControlFlow.Groups["keyword"].Value}', which cannot be proven independent of host state. " + + "Use declarative PackageDescription declarations before creating an exact-source Apple checkpoint."); + } + + var executableDeclaration = Regex.Match( + manifestSyntax, + "(?func|class|struct|enum|protocol|extension|subscript|init|deinit|operator|precedencegroup|var)(?![A-Za-z0-9_])|@[A-Za-z_][A-Za-z0-9_]*", + RegexOptions.CultureInvariant); + var ternaryExpression = Regex.IsMatch( + manifestSyntax, + "\\?(?=[^;\\r\\n]*:)", + RegexOptions.CultureInvariant); + if (executableDeclaration.Success || ternaryExpression) + { + var construct = executableDeclaration.Success + ? executableDeclaration.Value + : "ternary expression"; + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses executable manifest construct '{construct}', which cannot be proven independent of host state. " + + "Use declarative PackageDescription declarations before creating an exact-source Apple checkpoint."); + } + + } + + private static void ValidatePackageDescriptionCalls(string packageRoot, string manifestSyntax) + { + var directFactories = new HashSet(StringComparer.Ordinal) + { + "Package", "Version", "SupportedPlatform", "SystemPackageProvider", "LanguageTag", "BuildSettingCondition" + }; + var memberFactories = new HashSet(StringComparer.Ordinal) + { + "package", "product", "target", "executableTarget", "testTarget", "systemLibrary", + "binaryTarget", "plugin", "macro", "library", "executable", "pluginCommandIntent", + "pluginPermission", "define", "linkedLibrary", "linkedFramework", "headerSearchPath", + "unsafeFlags", "when", "exact", "revision", "branch", "upToNextMajor", "upToNextMinor", + "range", "Dependency", "Product", "Target", "SupportedPlatform", "SystemPackageProvider", "LanguageTag", "BuildSettingCondition", + "process", "copy", + "iOS", "macOS", "macCatalyst", "watchOS", "tvOS", "visionOS", "driverKit", + "apt", "brew", "yum" + }; + foreach (Match call in Regex.Matches( + manifestSyntax, + "(?(?:[A-Za-z_][A-Za-z0-9_]*|`[A-Za-z_][A-Za-z0-9_]*`)?(?:\\s*\\.\\s*(?:[A-Za-z_][A-Za-z0-9_]*|`[A-Za-z_][A-Za-z0-9_]*`))*)\\s*\\(", + RegexOptions.CultureInvariant)) + { + var target = call.Groups["target"].Value.Replace("`", string.Empty).Replace(" ", string.Empty); + if (string.IsNullOrWhiteSpace(target)) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses a parenthesized executable manifest expression, " + + "which can hide an indirect or host-dependent call. Use direct PackageDescription construction only before creating an exact-source Apple checkpoint."); + } + var segments = target.Split('.'); + var accepted = segments.Length == 1 + ? directFactories.Contains(segments[0]) + : memberFactories.Contains(segments[segments.Length - 1]); + if (accepted) + continue; + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' executes non-declarative manifest call '{target}', " + + "which cannot be proven independent of host state. Use PackageDescription construction only before creating an exact-source Apple checkpoint."); + } + } + + private static readonly HashSet ApprovedSwiftPackageLinkedLibraries = new(StringComparer.Ordinal) + { + "c", "c++", "c++abi", "compression", "iconv", "m", "network", "resolv", "sqlite3", "xml2", "z" + }; + + private static readonly HashSet ApprovedSwiftPackageLinkedFrameworks = new(StringComparer.Ordinal) + { + "Accelerate", "AppKit", "AuthenticationServices", "AudioToolbox", "AVFoundation", "CFNetwork", "CloudKit", + "Contacts", "CoreAudio", "CoreBluetooth", "CoreData", "CoreFoundation", "CoreGraphics", "CoreImage", + "CoreLocation", "CoreMedia", "CoreMotion", "CoreServices", "CoreText", "CoreVideo", "CryptoKit", "DeviceCheck", + "EventKit", "Foundation", "GameController", "HealthKit", "HomeKit", "ImageIO", "IOKit", "LocalAuthentication", + "MapKit", "Metal", "MetalKit", "Network", "NetworkExtension", "OSLog", "PassKit", "Photos", "QuartzCore", + "SafariServices", "Security", "StoreKit", "SystemConfiguration", "UIKit", "UniformTypeIdentifiers", + "UserNotifications", "VideoToolbox", "WatchKit", "WebKit" + }; + + private static void ValidateSwiftPackageLinkedDependencies( + string packageRoot, + string manifestSource, + string manifestSyntax) + { + foreach (Match reference in Regex.Matches( + manifestSyntax, + "\\.\\s*(?linkedLibrary|linkedFramework)\\s*\\(", + RegexOptions.CultureInvariant)) + { + var opening = reference.Index + reference.Length - 1; + var closing = FindMatchingSwiftDelimiter(manifestSyntax, opening, '(', ')'); + var argumentSource = manifestSource.Substring(opening + 1, closing - opening - 1); + var argumentSyntax = manifestSyntax.Substring(opening + 1, closing - opening - 1); + var nameArgument = ReadFirstTopLevelSwiftArgument(argumentSource, argumentSyntax); + if (!TryReadLiteralSwiftString(nameArgument, out var name)) + { + throw new InvalidOperationException( + $"Swift package '{packageRoot}' uses a computed {reference.Groups["factory"].Value} name, which cannot be bound to an approved Apple SDK or toolchain input."); + } + + var factory = reference.Groups["factory"].Value; + var approved = factory.Equals("linkedFramework", StringComparison.Ordinal) + ? ApprovedSwiftPackageLinkedFrameworks.Contains(name) + : ApprovedSwiftPackageLinkedLibraries.Contains(name); + if (approved) + continue; + throw new InvalidOperationException( + $"Swift package '{packageRoot}' declares {factory}('{name}'), whose selected linker bytes are not bound to an approved Apple SDK or toolchain input."); + } + } + + private void ValidateRemotePackageDependencies( + string repositoryRoot, + string packageRoot, + IReadOnlyCollection packageLockPaths, + IEnumerable dependencyCalls) + { + foreach (var dependency in dependencyCalls.Where(static call => + call.Arguments.ContainsKey("url") || call.Arguments.ContainsKey("id"))) + { + var identityArgument = dependency.Arguments.TryGetValue("url", out var url) + ? url + : dependency.Arguments["id"]; + if (!TryReadLiteralSwiftString(identityArgument, out var identity)) + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' declares a dynamic external dependency that cannot be bound to exact source. " + + "Use a literal package URL or registry identity and commit its Package.resolved lock."); + + var effectiveLocks = packageLockPaths + .Concat(new[] { Path.Combine(packageRoot, "Package.resolved") }) + .Distinct(GetPathComparer()) + .ToArray(); + var locks = FindTrackedPackageLocks(effectiveLocks, identity); + if (locks.Length == 0) + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' declares external dependency '{identity}' without a tracked Package.resolved lock. " + + "Commit the effective lock containing that dependency so preflight and exact archive materialization consume the same approved graph."); + foreach (var packageLock in locks) + EnsureTrackedFile(repositoryRoot, packageLock, "Xcode local Swift package resolution lock"); + var resolvedRevision = ResolvePackageRevision( + effectiveLocks, + identity); + ValidateRemotePackageSource(identity, resolvedRevision, effectiveLocks); + } + } + + private void ValidateNestedLocalPackageDependencies( + string repositoryRoot, + string packageRoot, + IReadOnlyCollection packageLockPaths, + ISet validatedPackageRoots, + IEnumerable dependencyCalls) + { + foreach (var dependency in dependencyCalls.Where(static call => call.Arguments.ContainsKey("path"))) + { + if (!TryReadLiteralSwiftString(dependency.Arguments["path"], out var nestedPath)) + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses a computed, interpolated, or escaped package dependency path that cannot be bound to exact source. " + + "Use a simple literal path inside the tracked repository."); + var nestedPackageRoot = ResolvePbxPath(packageRoot, nestedPath, "nested local Swift package"); + ValidateLocalPackageRoot(repositoryRoot, nestedPackageRoot, packageLockPaths, validatedPackageRoots); + } + } + + private void ValidateLiteralSwiftPackagePaths( + string repositoryRoot, + string packageRoot, + string manifest, + string manifestSyntax) + { + var pathBearingFactories = new HashSet(StringComparer.Ordinal) + { + "package", "target", "executableTarget", "testTarget", "binaryTarget", + "systemLibrary", "plugin", "macro" + }; + foreach (Match reference in Regex.Matches( + manifestSyntax, + "\\.\\s*(?[A-Za-z_][A-Za-z0-9_]*|`[A-Za-z_][A-Za-z0-9_]*`)\\s*\\(", + RegexOptions.CultureInvariant)) + { + var factory = reference.Groups["name"].Value.Trim('`'); + if (!pathBearingFactories.Contains(factory)) + continue; + var openingParenthesis = reference.Index + reference.Length - 1; + var closingParenthesis = FindMatchingSwiftDelimiter(manifestSyntax, openingParenthesis, '(', ')'); + var arguments = ParseTopLevelSwiftArguments( + manifest.Substring(openingParenthesis + 1, closingParenthesis - openingParenthesis - 1), + manifestSyntax.Substring(openingParenthesis + 1, closingParenthesis - openingParenthesis - 1)); + if (factory.Equals("binaryTarget", StringComparison.Ordinal) && !arguments.ContainsKey("path")) + { + if (!arguments.TryGetValue("url", out var urlArgument) || + !arguments.TryGetValue("checksum", out var checksumArgument) || + !TryReadLiteralSwiftString(urlArgument, out _) || + !TryReadLiteralSwiftString(checksumArgument, out var checksum) || + !Regex.IsMatch(checksum, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant)) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' declares a remote binary target without a literal URL and literal 64-character SHA-256 checksum. " + + "Bind every remote binary target to immutable, integrity-checked bytes before creating an exact-source Apple checkpoint."); + } + } + if (!arguments.TryGetValue("path", out var pathArgument)) + continue; + if (!TryReadLiteralSwiftString(pathArgument, out var literalPath)) + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses a computed, interpolated, or escaped path argument that cannot be bound to exact source. " + + "Use a simple literal path inside the tracked repository."); + + var explicitPath = ResolvePbxPath(packageRoot, literalPath, "Swift package manifest input"); + EnsurePathWithinRepository(repositoryRoot, explicitPath, "Swift package manifest input"); + if (File.Exists(explicitPath)) + EnsureTrackedFile(repositoryRoot, explicitPath, "Swift package manifest input"); + else if (Directory.Exists(explicitPath)) + EnsureTrackedDirectoryTree(repositoryRoot, explicitPath, "Swift package manifest input"); + else + throw new FileNotFoundException($"Swift package manifest input was not found: {explicitPath}", explicitPath); + } + } + + private void ValidateSwiftPackageResources( + string repositoryRoot, + string packageRoot, + string manifest, + string manifestSyntax) + { + foreach (Match target in Regex.Matches( + manifestSyntax, + "\\.\\s*(?target|executableTarget|testTarget|`target`|`executableTarget`|`testTarget`)\\s*\\(", + RegexOptions.CultureInvariant)) + { + var opening = target.Index + target.Length - 1; + var closing = FindMatchingSwiftDelimiter(manifestSyntax, opening, '(', ')'); + var argumentSource = manifest.Substring(opening + 1, closing - opening - 1); + var argumentSyntax = manifestSyntax.Substring(opening + 1, closing - opening - 1); + var arguments = ParseTopLevelSwiftArguments(argumentSource, argumentSyntax); + if (!arguments.TryGetValue("resources", out var resources)) + continue; + if (!arguments.TryGetValue("name", out var nameArgument) || + !TryReadLiteralSwiftString(nameArgument, out var targetName)) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' declares resources for a target without a literal name, so their selected paths cannot be proven."); + } + + var factory = target.Groups["factory"].Value.Trim('`'); + var relativeTargetRoot = factory.Equals("testTarget", StringComparison.Ordinal) + ? Path.Combine("Tests", targetName) + : Path.Combine("Sources", targetName); + if (arguments.TryGetValue("path", out var pathArgument)) + { + if (!TryReadLiteralSwiftString(pathArgument, out relativeTargetRoot)) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses a computed target path for resources, which cannot be bound to exact source."); + } + } + var targetRoot = Path.GetFullPath(Path.Combine(packageRoot, relativeTargetRoot)); + if (!IsPathAtOrWithin(targetRoot, packageRoot)) + throw new InvalidOperationException($"Swift package resource target path escapes the tracked package root: {targetRoot}"); + + var resourceSyntax = MaskSwiftStringLiterals(resources); + var first = 0; + while (first < resourceSyntax.Length && char.IsWhiteSpace(resourceSyntax[first])) + first++; + var last = resourceSyntax.Length - 1; + while (last >= first && char.IsWhiteSpace(resourceSyntax[last])) + last--; + if (first > last || resourceSyntax[first] != '[' || resourceSyntax[last] != ']') + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses an indirect resource declaration, whose selected paths cannot be bound to exact source."); + } + var resourceSourceBody = resources.Substring(first + 1, last - first - 1); + var resourceSyntaxBody = resourceSyntax.Substring(first + 1, last - first - 1); + foreach (var resourceExpression in SplitTopLevelSwiftExpressions(resourceSourceBody, resourceSyntaxBody)) + { + var resourceExpressionSyntax = MaskSwiftStringLiterals(resourceExpression); + var resource = Regex.Match( + resourceExpressionSyntax, + "^\\s*\\.\\s*(?process|copy|`process`|`copy`)\\s*\\(", + RegexOptions.CultureInvariant); + if (!resource.Success) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses an indirect resource declaration, whose selected paths cannot be bound to exact source."); + } + var resourceOpening = resource.Index + resource.Length - 1; + var resourceClosing = FindMatchingSwiftDelimiter(resourceExpressionSyntax, resourceOpening, '(', ')'); + if (!string.IsNullOrWhiteSpace(resourceExpressionSyntax.Substring(resourceClosing + 1))) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' composes a resource declaration with executable syntax, which cannot be bound to exact source."); + } + var resourceArguments = resourceExpression.Substring(resourceOpening + 1, resourceClosing - resourceOpening - 1); + var resourceArgumentSyntax = resourceExpressionSyntax.Substring(resourceOpening + 1, resourceClosing - resourceOpening - 1); + var resourcePathArgument = ReadFirstTopLevelSwiftArgument(resourceArguments, resourceArgumentSyntax); + if (!TryReadLiteralSwiftString(resourcePathArgument, out var resourcePath)) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses a computed resource path, which cannot be bound to exact source."); + } + + var candidate = Path.GetFullPath(Path.Combine(targetRoot, resourcePath)); + if (!IsPathAtOrWithin(candidate, packageRoot)) + throw new InvalidOperationException($"Swift package resource path escapes the tracked package root: {candidate}"); + EnsurePathWithinRepository(repositoryRoot, candidate, "Swift package resource input"); + if (File.Exists(candidate)) + EnsureTrackedFile(repositoryRoot, candidate, "Swift package resource input"); + else if (Directory.Exists(candidate)) + EnsureTrackedDirectoryTree(repositoryRoot, candidate, "Swift package resource input"); + else + throw new FileNotFoundException($"Swift package resource input was not found: {candidate}", candidate); + } + } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs b/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs new file mode 100644 index 000000000..3ddea71ff --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + /// + /// Reads the exact remote package map only after every effective lock is proven to match the current Git commit. + /// + internal IReadOnlyDictionary ReadApprovedTrackedPackageRevisions( + string repositoryRoot, + IEnumerable lockPaths) + { + var locks = lockPaths.Select(Path.GetFullPath).Distinct(GetPathComparer()).Where(File.Exists).ToArray(); + foreach (var path in locks) + EnsureTrackedFile(repositoryRoot, path, "Swift package resolution lock consumed by xcodebuild"); + return ReadApprovedPackageRevisions(locks); + } + + /// + /// Parses a normalized remote URL to exact-revision map from supported Package.resolved schemas. + /// + internal static IReadOnlyDictionary ReadApprovedPackageRevisions( + IEnumerable lockPaths) + { + var approved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var path in lockPaths.Select(Path.GetFullPath).Distinct(GetPathComparer()).Where(File.Exists)) + { + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement; + JsonElement pins; + if (!(root.TryGetProperty("pins", out pins) || + (root.TryGetProperty("object", out var legacyObject) && + legacyObject.TryGetProperty("pins", out pins))) || + pins.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var pin in pins.EnumerateArray()) + { + var location = ReadJsonString(pin, "location") ?? ReadJsonString(pin, "repositoryURL"); + if (string.IsNullOrWhiteSpace(location) || !pin.TryGetProperty("state", out var state)) + continue; + var revision = ReadJsonString(state, "revision"); + if (string.IsNullOrWhiteSpace(revision) || + !Regex.IsMatch(revision, "^(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant)) + { + throw new InvalidOperationException( + $"Swift package '{location}' in '{path}' is not bound to an exact Git revision."); + } + + var normalized = NormalizePackageLocation(location!); + if (approved.TryGetValue(normalized, out var existing) && + !existing.Equals(revision, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Swift package '{location}' resolves to conflicting exact revisions across the approved Package.resolved graph."); + } + approved[normalized] = revision!.ToLowerInvariant(); + } + } + + return approved; + } + + private static string[] FindTrackedPackageLocks( + IReadOnlyCollection effectiveLockPaths, + string dependencyIdentity) + { + return effectiveLockPaths + .Select(Path.GetFullPath) + .Distinct(GetPathComparer()) + .Where(File.Exists) + .Where(path => PackageLockBindsDependency(path, dependencyIdentity)) + .ToArray(); + } + + private static bool PackageLockBindsDependency(string path, string dependencyIdentity) + => ReadPackagePinRevisions(path, dependencyIdentity).Length > 0; + + private static string[] ReadPackagePinRevisions(string path, string dependencyIdentity) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(File.ReadAllText(path)); + } + catch (JsonException exception) + { + throw new InvalidOperationException($"Swift package resolution lock is not valid JSON: {path}", exception); + } + + var revisions = new List(); + using (document) + { + var root = document.RootElement; + JsonElement pins; + if (root.TryGetProperty("pins", out pins) || + (root.TryGetProperty("object", out var legacyObject) && + legacyObject.TryGetProperty("pins", out pins))) + { + if (pins.ValueKind != JsonValueKind.Array) + return Array.Empty(); + foreach (var pin in pins.EnumerateArray()) + { + if (!PackagePinMatchesIdentity(pin, dependencyIdentity) || + !pin.TryGetProperty("state", out var state)) + { + continue; + } + + var revision = ReadJsonString(state, "revision"); + if (!string.IsNullOrWhiteSpace(revision) && + (revision!.Length == 40 || revision.Length == 64) && + revision.All(Uri.IsHexDigit)) + { + revisions.Add(revision.ToLowerInvariant()); + } + + if (!LooksLikeRepositoryLocation(dependencyIdentity) && + !string.IsNullOrWhiteSpace(ReadJsonString(state, "version"))) + { + throw new InvalidOperationException( + $"Swift registry package '{dependencyIdentity}' is version-bound but cannot be source-inspected as a Git revision."); + } + } + } + } + + return revisions.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private static string ResolvePackageRevision( + IReadOnlyCollection effectiveLockPaths, + string dependencyIdentity, + string? exactRevision = null) + { + if (!string.IsNullOrWhiteSpace(exactRevision)) + return exactRevision!.ToLowerInvariant(); + var revisions = effectiveLockPaths + .Where(File.Exists) + .SelectMany(path => ReadPackagePinRevisions(path, dependencyIdentity)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (revisions.Length != 1) + { + throw new InvalidOperationException( + $"Remote Swift package '{dependencyIdentity}' must resolve to one exact Git revision across the effective Package.resolved graph."); + } + return revisions[0]; + } + + private static bool PackagePinMatchesIdentity(JsonElement pin, string dependencyIdentity) + { + var location = ReadJsonString(pin, "location") ?? ReadJsonString(pin, "repositoryURL"); + if (LooksLikeRepositoryLocation(dependencyIdentity)) + { + return !string.IsNullOrWhiteSpace(location) && + NormalizePackageLocation(location!).Equals( + NormalizePackageLocation(dependencyIdentity), + StringComparison.OrdinalIgnoreCase); + } + + var identity = ReadJsonString(pin, "identity") ?? ReadJsonString(pin, "package") ?? location; + return !string.IsNullOrWhiteSpace(identity) && + identity!.Trim().Equals(dependencyIdentity.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private static string? ReadJsonString(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static bool LooksLikeRepositoryLocation(string value) + => value.Contains("://", StringComparison.Ordinal) || + value.StartsWith("git@", StringComparison.OrdinalIgnoreCase) || + value.IndexOf('/') >= 0 || + value.EndsWith(".git", StringComparison.OrdinalIgnoreCase); + + private static string NormalizePackageLocation(string value) + { + var normalized = value.Trim().TrimEnd('/'); + return normalized.EndsWith(".git", StringComparison.OrdinalIgnoreCase) + ? normalized.Substring(0, normalized.Length - 4) + : normalized; + } + + private static string[] ResolveEffectivePackageLockPaths( + string projectMetadataPath, + IReadOnlyCollection metadataPaths) + { + var projectContainer = Path.GetDirectoryName(projectMetadataPath)!; + var paths = new HashSet(GetPathComparer()) + { + Path.Combine(projectContainer, "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved") + }; + var knownMetadata = new HashSet(metadataPaths.Select(Path.GetFullPath), GetPathComparer()); + foreach (var workspaceMetadata in knownMetadata.Where(path => + path.EndsWith("contents.xcworkspacedata", StringComparison.OrdinalIgnoreCase) && + File.Exists(path))) + { + if (WorkspaceReferencesContainer( + workspaceMetadata, + projectContainer, + knownMetadata, + new HashSet(GetPathComparer()))) + { + paths.Add(Path.Combine( + Path.GetDirectoryName(workspaceMetadata)!, + "xcshareddata", + "swiftpm", + "Package.resolved")); + } + } + + return paths.ToArray(); + } + + private static bool WorkspaceReferencesContainer( + string workspaceMetadata, + string targetContainer, + ISet knownMetadata, + ISet visited) + { + var normalizedMetadata = Path.GetFullPath(workspaceMetadata); + if (!visited.Add(normalizedMetadata)) + return false; + + var workspaceContainer = Path.GetDirectoryName(normalizedMetadata)!; + var workspaceRoot = Path.GetDirectoryName(workspaceContainer)!; + var document = XDocument.Load(normalizedMetadata, LoadOptions.None); + foreach (var candidate in EnumerateWorkspaceReferences(document.Root, workspaceRoot, workspaceRoot)) + { + var normalizedCandidate = Path.GetFullPath(candidate); + if (GetPathComparer().Equals(normalizedCandidate, Path.GetFullPath(targetContainer))) + return true; + if (!normalizedCandidate.EndsWith(".xcworkspace", StringComparison.OrdinalIgnoreCase)) + continue; + + var nestedMetadata = Path.Combine(normalizedCandidate, "contents.xcworkspacedata"); + if (knownMetadata.Contains(nestedMetadata) && + WorkspaceReferencesContainer(nestedMetadata, targetContainer, knownMetadata, visited)) + { + return true; + } + } + + return false; + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs new file mode 100644 index 000000000..ac7dfeb60 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -0,0 +1,502 @@ +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void ValidateRemotePackageSource( + string repositoryUrl, + string revision, + IReadOnlyCollection packageLockPaths) + { + if (!LooksLikeRepositoryLocation(repositoryUrl) || + !(repositoryUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + repositoryUrl.StartsWith("ssh://", StringComparison.OrdinalIgnoreCase) || + repositoryUrl.StartsWith("git@", StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + $"Remote Swift package '{repositoryUrl}' must use an inspectable HTTPS or SSH Git repository."); + } + if (!Regex.IsMatch(revision, "^(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant)) + throw new InvalidOperationException($"Remote Swift package '{repositoryUrl}' is not bound to an exact Git revision."); + + var identity = NormalizePackageLocation(repositoryUrl) + "@" + revision.ToLowerInvariant(); + if (_validatedRemotePackages.Contains(identity)) + return; + if (!_remotePackagesUnderValidation.Add(identity)) + return; + + try + { + if (_remotePackageCheckoutResolver is not null) + { + var resolvedCheckout = Path.GetFullPath(_remotePackageCheckoutResolver(repositoryUrl, revision)); + ValidateRemotePackageCheckout(resolvedCheckout, repositoryUrl, revision, packageLockPaths, validateRemoteDependencies: true); + _validatedRemotePackages.Add(identity); + return; + } + + var cacheRoot = ResolveRemotePackageCacheRoot(); + Directory.CreateDirectory(cacheRoot); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(cacheRoot, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + var mirrorPath = Path.Combine(cacheRoot, ComputeStablePathToken(repositoryUrl) + ".git"); + EnsureRemotePackageMirror(mirrorPath, repositoryUrl, revision); + + var checkoutParent = Path.Combine(Path.GetTempPath(), "PowerForge", "apple-swiftpm-source-trust"); + Directory.CreateDirectory(checkoutParent); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(checkoutParent, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + var checkoutPath = Path.Combine(checkoutParent, Guid.NewGuid().ToString("N")); + try + { + RunGit(mirrorPath, "-c", "core.hooksPath=/dev/null", "worktree", "add", "--detach", checkoutPath, revision); + ValidateRemotePackageCheckout(checkoutPath, repositoryUrl, revision, packageLockPaths, validateRemoteDependencies: true); + _validatedRemotePackages.Add(identity); + } + finally + { + if (Directory.Exists(checkoutPath)) + { + var removed = RunGitAllowFailure(mirrorPath, "worktree", "remove", "--force", checkoutPath); + if (!removed.Succeeded && Directory.Exists(checkoutPath)) + Directory.Delete(checkoutPath, recursive: true); + } + } + } + finally + { + _remotePackagesUnderValidation.Remove(identity); + } + } + + private void ValidateRemotePackageCheckout( + string checkoutPath, + string repositoryUrl, + string revision, + IReadOnlyCollection packageLockPaths, + bool validateRemoteDependencies) + { + var head = RunGit(checkoutPath, "rev-parse", "HEAD").StdOut.Trim(); + if (!head.Equals(revision, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Remote Swift package '{repositoryUrl}' did not materialize the approved revision '{revision}'."); + EnsureNoGitReplacementRefs(checkoutPath); + EnsureRemotePackageHasNoGitLinks(checkoutPath, repositoryUrl); + _git.EnsureClean(checkoutPath); + var locks = packageLockPaths.Where(File.Exists).Select(Path.GetFullPath).ToList(); + ValidateCheckedOutPackageRoot( + checkoutPath, + checkoutPath, + locks, + new HashSet(GetPathComparer()), + validateRemoteDependencies); + _git.EnsureClean(checkoutPath); + var headAfter = RunGit(checkoutPath, "rev-parse", "HEAD").StdOut.Trim(); + if (!headAfter.Equals(revision, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Remote Swift package '{repositoryUrl}' changed during source inspection."); + } + + /// + /// Validates the exact Swift package checkouts that Xcode will consume for an archive. + /// + internal void ValidateMaterializedPackageCheckouts( + string sourcePackagesRoot, + IReadOnlyDictionary approvedRevisions) + { + var root = Path.GetFullPath(sourcePackagesRoot); + var checkouts = Path.Combine(root, "checkouts"); + if (!Directory.Exists(checkouts)) + { + if (approvedRevisions.Count > 0) + throw new InvalidOperationException("Xcode did not materialize the complete approved Swift package graph."); + return; + } + EnsureNoLinkedTraversal(checkouts, checkouts, "Xcode materialized Swift package checkout root"); + + var observed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in Directory.EnumerateFileSystemEntries(checkouts).OrderBy(static path => path, GetPathComparer())) + { + if (!Directory.Exists(entry)) + throw new InvalidOperationException($"Xcode materialized Swift package checkout root contains an unsupported entry: {entry}"); + EnsureNoLinkedTraversal(checkouts, entry, "Xcode materialized Swift package checkout"); + var originResult = RunGitAllowFailure(entry, "remote", "get-url", "origin"); + if (!originResult.Succeeded || string.IsNullOrWhiteSpace(originResult.StdOut)) + throw new InvalidOperationException($"Xcode materialized Swift package checkout has no approved origin: {entry}"); + var origin = originResult.StdOut.Trim(); + var normalizedOrigin = NormalizePackageLocation(origin); + if (!approvedRevisions.TryGetValue(normalizedOrigin, out var approvedRevision)) + throw new InvalidOperationException($"Xcode materialized an additional Swift package checkout outside the approved graph: {origin}"); + if (!observed.Add(normalizedOrigin)) + throw new InvalidOperationException($"Xcode materialized duplicate Swift package checkouts for approved origin '{origin}'."); + ValidateRemotePackageCheckout( + entry, + origin, + approvedRevision, + Array.Empty(), + validateRemoteDependencies: false); + } + + var missing = approvedRevisions.Keys.FirstOrDefault(key => !observed.Contains(key)); + if (missing is not null) + throw new InvalidOperationException($"Xcode did not materialize approved Swift package checkout '{missing}'."); + } + + private void EnsureRemotePackageHasNoGitLinks(string checkoutPath, string repositoryUrl) + { + var gitLinks = RunGit(checkoutPath, "ls-files", "--stage", "-z").StdOut + .Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) + .Where(static entry => entry.StartsWith("160000 ", StringComparison.Ordinal)) + .Select(static entry => + { + var separator = entry.IndexOf('\t'); + return separator >= 0 ? entry.Substring(separator + 1) : entry; + }) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + if (gitLinks.Length == 0) + return; + + throw new InvalidOperationException( + $"Remote Swift package '{repositoryUrl}' contains Git submodule input '{gitLinks[0]}'. " + + "Exact-source Apple checkpoints reject remote-package gitlinks because SwiftPM materializes their bytes outside the attested parent revision."); + } + + private void ValidateCheckedOutPackageRoot( + string checkoutRoot, + string packageRoot, + IReadOnlyCollection effectiveLockPaths, + ISet validatedRoots, + bool validateRemoteDependencies) + { + packageRoot = Path.GetFullPath(packageRoot); + EnsureDirectoryWithinRepository(checkoutRoot, packageRoot, "remote Swift package root"); + if (!validatedRoots.Add(packageRoot)) + return; + + var manifests = Directory.EnumerateFiles(packageRoot, "Package*.swift", SearchOption.TopDirectoryOnly) + .Where(path => Path.GetFileName(path).Equals("Package.swift", StringComparison.Ordinal) || + Regex.IsMatch(Path.GetFileName(path), "^Package@swift-[0-9]+(?:\\.[0-9]+)*\\.swift$", RegexOptions.CultureInvariant)) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + if (!manifests.Any(path => Path.GetFileName(path).Equals("Package.swift", StringComparison.Ordinal))) + throw new FileNotFoundException($"Remote Swift package manifest was not found at the approved revision: {packageRoot}"); + + var locks = effectiveLockPaths.ToList(); + var localLock = Path.Combine(packageRoot, "Package.resolved"); + if (File.Exists(localLock)) + { + EnsureTrackedFile(checkoutRoot, localLock, "remote Swift package resolution lock"); + locks.Add(localLock); + } + + HashSet? inactiveSystemLibraryRoots = null; + foreach (var manifestPath in manifests) + { + EnsureTrackedFile(checkoutRoot, manifestPath, "remote Swift package manifest"); + var source = RemoveSwiftComments(File.ReadAllText(manifestPath)); + EnsureNoExecutableSwiftStringInterpolation(packageRoot, source); + var syntax = MaskSwiftStringLiterals(source); + ValidateLocalPackageExecutableSafety( + packageRoot, + source, + syntax, + allowInactiveNonAppleSystemLibraries: true); + var manifestInactiveRoots = ReadInactiveNonAppleSystemLibraryRoots(packageRoot, source, syntax); + if (inactiveSystemLibraryRoots is null) + inactiveSystemLibraryRoots = manifestInactiveRoots; + else + inactiveSystemLibraryRoots.IntersectWith(manifestInactiveRoots); + ValidateDirectSwiftPackageDependencyFactories(packageRoot, syntax); + ValidatePackageDescriptionCalls(packageRoot, syntax); + ValidateSwiftPackageLinkedDependencies(packageRoot, source, syntax); + ValidateLiteralSwiftPackagePaths(checkoutRoot, packageRoot, source, syntax); + foreach (var dependency in ParseDirectSwiftPackageDependencyCalls(source, syntax)) + { + if (dependency.Arguments.TryGetValue("path", out var pathArgument)) + { + if (!TryReadLiteralSwiftString(pathArgument, out var nestedPath)) + throw new InvalidOperationException($"Remote Swift package '{packageRoot}' uses a computed local dependency path."); + var nestedRoot = ResolvePbxPath(packageRoot, nestedPath, "remote Swift package local dependency"); + ValidateCheckedOutPackageRoot(checkoutRoot, nestedRoot, locks, validatedRoots, validateRemoteDependencies); + continue; + } + + if (!dependency.Arguments.TryGetValue("url", out var urlArgument) && + !dependency.Arguments.TryGetValue("id", out urlArgument)) + continue; + if (!TryReadLiteralSwiftString(urlArgument, out var dependencyUrl) || + !LooksLikeRepositoryLocation(dependencyUrl)) + { + throw new InvalidOperationException($"Remote Swift package '{packageRoot}' declares an uninspectable external dependency."); + } + if (!validateRemoteDependencies) + continue; + var dependencyRevision = string.Empty; + var hasRevision = dependency.Arguments.TryGetValue("revision", out var revisionArgument) && + TryReadLiteralSwiftString(revisionArgument, out dependencyRevision) && + Regex.IsMatch(dependencyRevision, "^(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant); + var resolved = ResolvePackageRevision(locks, dependencyUrl, hasRevision ? dependencyRevision : null); + ValidateRemotePackageSource(dependencyUrl, resolved, locks); + } + } + + if (inactiveSystemLibraryRoots is not null) + { + foreach (var root in inactiveSystemLibraryRoots) + _inactiveRemoteSystemLibraryRoots.Add(root); + } + foreach (var conventionalInput in new[] + { + Path.Combine(packageRoot, "Sources"), + Path.Combine(packageRoot, "Plugins") + }) + { + if (Directory.Exists(conventionalInput)) + EnsureTrackedDirectoryTree( + checkoutRoot, + conventionalInput, + "remote Swift package source input", + assemblerWorkingDirectory: packageRoot); + } + } + + internal void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, string revision) + { + using var mirrorLease = AcquireRemotePackageMirrorLease(mirrorPath); + var createdMirror = false; + if (!Directory.Exists(mirrorPath)) + { + var temporary = mirrorPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + Directory.CreateDirectory(temporary); + try + { + InitializeRemotePackageMirror(temporary, revision); + try + { + Directory.Move(temporary, mirrorPath); + createdMirror = true; + } + catch (IOException) when (Directory.Exists(mirrorPath)) + { + Directory.Delete(temporary, recursive: true); + } + } + finally + { + if (Directory.Exists(temporary)) + Directory.Delete(temporary, recursive: true); + } + } + + var expectedObjectFormat = GetObjectFormatForRevision(revision); + var observedObjectFormat = RunGit(mirrorPath, "rev-parse", "--show-object-format").StdOut.Trim(); + if (!observedObjectFormat.Equals(expectedObjectFormat, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Remote Swift package mirror '{mirrorPath}' uses Git object format '{observedObjectFormat}', " + + $"but revision '{revision}' requires '{expectedObjectFormat}'. Remove the incompatible private mirror and retry."); + } + + var exists = RunGitAllowFailure(mirrorPath, "cat-file", "-e", revision + "^{commit}"); + if (!exists.Succeeded) + { + try + { + RunGit( + mirrorPath, + "-c", "core.hooksPath=/dev/null", + "-c", "protocol.file.allow=never", + "fetch", "--force", "--no-tags", "--depth=1", repositoryUrl, revision); + } + catch + { + var concurrentlyAvailable = RunGitAllowFailure(mirrorPath, "cat-file", "-e", revision + "^{commit}"); + if (concurrentlyAvailable.Succeeded) + return; + if (createdMirror && Directory.Exists(mirrorPath)) + Directory.Delete(mirrorPath, recursive: true); + throw; + } + } + RunGit(mirrorPath, "cat-file", "-e", revision + "^{commit}"); + } + + internal void InitializeRemotePackageMirror(string mirrorPath, string revision) + { + var objectFormat = GetObjectFormatForRevision(revision); + RunGit(mirrorPath, "init", "--bare", $"--object-format={objectFormat}"); + } + + private static string GetObjectFormatForRevision(string revision) + => revision.Trim().Length == 64 ? "sha256" : "sha1"; + + internal static FileStream AcquireRemotePackageMirrorLease(string mirrorPath) + { + var lockPath = mirrorPath + ".lock"; + var deadline = DateTime.UtcNow.AddMinutes(2); + while (true) + { + try + { +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + return OpenUnixRemotePackageMirrorLease(lockPath); +#endif + RejectLinkedRemotePackageMirrorLock(lockPath); + var lease = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + try + { + ValidateRemotePackageMirrorLockIdentity(lockPath, lease); + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + catch (IOException) when (DateTime.UtcNow < deadline) + { + Thread.Sleep(50); + } + } + } + + private static void RejectLinkedRemotePackageMirrorLock(string lockPath) + { + if ((File.Exists(lockPath) || Directory.Exists(lockPath)) && + (File.GetAttributes(lockPath) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"Remote Swift package mirror lock must not be a symbolic link or reparse point: {lockPath}"); + } + } + + private static void ValidateRemotePackageMirrorLockIdentity(string lockPath, FileStream lease) + { + RejectLinkedRemotePackageMirrorLock(lockPath); + var opened = ExistingFilePathIdentityResolver.ResolveStatus(lease.SafeFileHandle); + var replaced = false; +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + { + using var currentHandle = OpenExistingUnixRemotePackageMirrorLock(lockPath); + var current = ExistingFilePathIdentityResolver.ResolveStatus(currentHandle); + replaced = !opened.Identity.Equals(current.Identity, StringComparison.Ordinal); + } +#endif + int hardLinkCount; +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + hardLinkCount = ExistingFilePathIdentityResolver.ResolveHardLinkCounts(new[] { lockPath })[0]; + else +#endif + hardLinkCount = ExistingFilePathIdentityResolver.ResolveHardLinkCount(lease.SafeFileHandle); + if (replaced || hardLinkCount != 1) + { + throw new InvalidOperationException( + $"Remote Swift package mirror lock was linked or replaced while it was being acquired " + + $"(replaced={replaced}, hardLinks={hardLinkCount}): {lockPath}"); + } + } + +#if NET8_0_OR_GREATER + private static SafeFileHandle OpenExistingUnixRemotePackageMirrorLock(string lockPath) + { + var noFollow = OperatingSystem.IsMacOS() ? 0x0100 : 0x20000; + var descriptor = OpenUnix(lockPath, noFollow, 0); + if (descriptor >= 0) + return new SafeFileHandle(new IntPtr(descriptor), ownsHandle: true); + var error = Marshal.GetLastWin32Error(); + if ((OperatingSystem.IsMacOS() && error == 62) || (!OperatingSystem.IsMacOS() && error == 40)) + { + throw new InvalidOperationException( + $"Remote Swift package mirror lock must not be a symbolic link: {lockPath}"); + } + throw new IOException($"Unable to verify remote Swift package mirror lock '{lockPath}'.", new Win32Exception(error)); + } + + private static FileStream OpenUnixRemotePackageMirrorLease(string lockPath) + { + var create = OperatingSystem.IsMacOS() ? 0x0200 : 0x0040; + var noFollow = OperatingSystem.IsMacOS() ? 0x0100 : 0x20000; + const int readWrite = 0x0002; + const uint userReadWrite = 0x0180; + var descriptor = OpenUnix(lockPath, readWrite | create | noFollow, userReadWrite); + if (descriptor < 0) + { + var error = Marshal.GetLastWin32Error(); + if ((OperatingSystem.IsMacOS() && error == 62) || (!OperatingSystem.IsMacOS() && error == 40)) + { + throw new InvalidOperationException( + $"Remote Swift package mirror lock must not be a symbolic link: {lockPath}"); + } + throw new IOException($"Unable to open remote Swift package mirror lock '{lockPath}'.", new Win32Exception(error)); + } + + var handle = new SafeFileHandle(new IntPtr(descriptor), ownsHandle: true); + FileStream? lease = null; + try + { + const int exclusiveNonBlocking = 0x0002 | 0x0004; + if (FlockUnix(descriptor, exclusiveNonBlocking) != 0) + throw new IOException($"Remote Swift package mirror lock is already leased: {lockPath}"); + lease = new FileStream(handle, FileAccess.ReadWrite, bufferSize: 1, isAsync: false); + handle = null!; + ValidateRemotePackageMirrorLockIdentity(lockPath, lease); + if (FchmodUnix(descriptor, userReadWrite) != 0) + throw new IOException( + $"Unable to restrict remote Swift package mirror lock permissions: {lockPath}", + new Win32Exception(Marshal.GetLastWin32Error())); + ValidateRemotePackageMirrorLockIdentity(lockPath, lease); + return lease; + } + catch + { + lease?.Dispose(); + handle?.Dispose(); + throw; + } + } + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int OpenUnix(string path, int flags, uint mode); + + [DllImport("libc", EntryPoint = "flock", SetLastError = true)] + private static extern int FlockUnix(int descriptor, int operation); + + [DllImport("libc", EntryPoint = "fchmod", SetLastError = true)] + private static extern int FchmodUnix(int descriptor, uint mode); +#endif + + private static string ResolveRemotePackageCacheRoot() + { + var configured = Environment.GetEnvironmentVariable("POWERFORGE_APPLE_SWIFTPM_TRUST_CACHE"); + return Path.GetFullPath(string.IsNullOrWhiteSpace(configured) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".powerforge", "apple-swiftpm-trust-cache") + : configured!); + } + + private static string ComputeStablePathToken(string value) + { + using var sha256 = SHA256.Create(); + return BitConverter.ToString(sha256.ComputeHash(Encoding.UTF8.GetBytes(value))) + .Replace("-", string.Empty) + .ToLowerInvariant(); + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs new file mode 100644 index 000000000..8ea80050b --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -0,0 +1,610 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static readonly HashSet SourceIncludeExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".c", ".cc", ".cpp", ".cxx", ".m", ".mm", ".metal", ".s", + ".h", ".hh", ".hpp", ".hxx", ".inc", ".pch", ".modulemap", ".swift" + }; + + private void ValidateSourceLevelIncludes( + string repositoryRoot, + string sourcePath, + bool validateSwiftDeterminism = false, + string? sourceBlob = null, + string? effectiveSourceExtension = null, + string? assemblerWorkingDirectory = null) + { + var sourceExtension = string.IsNullOrWhiteSpace(effectiveSourceExtension) + ? Path.GetExtension(sourcePath) + : effectiveSourceExtension!; + if (!SourceIncludeExtensions.Contains(sourceExtension)) + return; + var fullSourcePath = Path.GetFullPath(sourcePath); + var extension = sourceExtension; + if (extension.Equals(".swift", StringComparison.OrdinalIgnoreCase) && !validateSwiftDeterminism) + return; + var effectiveAssemblerWorkingDirectory = Path.GetFullPath( + assemblerWorkingDirectory ?? Path.GetDirectoryName(fullSourcePath)!); + var validationKey = fullSourcePath + "|" + extension + "|" + validateSwiftDeterminism + "|" + effectiveAssemblerWorkingDirectory; + if (!_validatedSourceIncludeFiles.Add(validationKey)) + return; + if (!string.IsNullOrWhiteSpace(sourceBlob)) + { + var semanticPath = ResolveSourceSemanticPath(fullSourcePath); + var semanticKey = sourceBlob + "|" + semanticPath + "|" + extension + "|" + validateSwiftDeterminism + "|" + effectiveAssemblerWorkingDirectory; + if (!_validatedSourceSemanticInputs.Add(semanticKey)) + return; + } + + if (extension.Equals(".modulemap", StringComparison.OrdinalIgnoreCase)) + { + ValidateClangModuleMapInputs(repositoryRoot, fullSourcePath); + return; + } + if (extension.Equals(".swift", StringComparison.OrdinalIgnoreCase)) + { + ValidateSwiftSourceDeterminism(fullSourcePath); + return; + } + + // C and Objective-C splice escaped physical lines before comments and + // preprocessing directives are interpreted. Scan that logical source so + // an include keyword cannot be split across lines to evade attestation. + var physicalSource = File.ReadAllText(fullSourcePath); + RejectCTrigraphs(physicalSource, fullSourcePath); + var source = RemoveCComments(SpliceCPreprocessingLines(physicalSource)); + var embedDirective = Regex.Match( + source, + "(?m)^[ \\t\\v\\f]*(?:#|%:)[ \\t\\v\\f]*embed(?![A-Za-z0-9_])", + RegexOptions.CultureInvariant); + if (embedDirective.Success) + { + throw new InvalidOperationException( + $"Source input '{fullSourcePath}' uses a C23 embed directive, whose payload selection cannot be bound safely to the exact source commit."); + } + var nondeterministicMacro = FindNondeterministicCompilerMacro(source); + if (nondeterministicMacro is not null) + { + throw new InvalidOperationException( + $"Source input '{fullSourcePath}' uses nondeterministic compiler macro '{nondeterministicMacro}', which cannot be bound to one reproducible source commit."); + } + ValidateLanguageModuleImports(fullSourcePath, source, extension); + RejectPragmaLinkedLibraries(fullSourcePath, source); + RejectPreprocessorIncludeAliases(fullSourcePath, source); + foreach (Match directive in Regex.Matches( + source, + "(?m)^[ \\t\\v\\f]*(?:#|%:)[ \\t\\v\\f]*(?:include|include_next|import)[ \\t\\v\\f]+(?[^\\r\\n]+)", + RegexOptions.CultureInvariant)) + { + var operand = Regex.Replace(directive.Groups["operand"].Value, "[ \\t\\v\\f]*(?://.*)?$", string.Empty).Trim(); + var quoted = operand.Length >= 2 && operand[0] == '"' && operand[operand.Length - 1] == '"'; + var angled = operand.Length >= 2 && operand[0] == '<' && operand[operand.Length - 1] == '>'; + if (!quoted && !angled) + { + throw new InvalidOperationException( + $"Source input '{fullSourcePath}' uses computed preprocessor include '{operand}', which cannot be bound to the exact source commit."); + } + + var include = operand.Substring(1, operand.Length - 2).Trim(); + if (Path.IsPathRooted(include)) + { + throw new InvalidOperationException( + $"Source input '{fullSourcePath}' references absolute preprocessor include '{include}', which is outside the exact-source graph."); + } + + var segments = include.Split('/', '\\'); + if (angled) + { + if (segments.Any(static segment => segment == "..")) + throw new InvalidOperationException($"Source input '{fullSourcePath}' uses escaping system include '{include}'."); + if (IsApprovedAngledInclude(repositoryRoot, fullSourcePath, include)) + continue; + throw new InvalidOperationException( + $"Source input '{fullSourcePath}' uses angled preprocessor include '{include}', whose selected bytes depend on unbound compiler search roots. " + + "Use a tracked quoted include or a validated Xcode module/framework reference instead."); + } + + ResolveQuotedSourceInclude(repositoryRoot, fullSourcePath, include, "preprocessor include"); + } + + ValidatePreprocessorFileExistenceProbes(repositoryRoot, fullSourcePath, source); + + if (IsCInlineAssemblySource(extension)) + ValidateInlineAssemblerInputs(repositoryRoot, fullSourcePath, source, effectiveAssemblerWorkingDirectory); + + if (extension.Equals(".s", StringComparison.OrdinalIgnoreCase)) + ValidateAssemblerInputs(repositoryRoot, fullSourcePath, source, effectiveAssemblerWorkingDirectory); + } + + private static string ResolveSourceSemanticPath(string sourcePath) + { + var normalized = sourcePath.Replace('\\', '/'); + var framework = normalized.IndexOf(".xcframework/", StringComparison.OrdinalIgnoreCase); + if (framework < 0) + return normalized; + var headers = normalized.IndexOf("/Headers/", framework, StringComparison.OrdinalIgnoreCase); + return headers < 0 ? normalized : normalized.Substring(headers + "/Headers/".Length); + } + + private bool IsApprovedAngledInclude(string repositoryRoot, string sourcePath, string include) + { + var normalized = include.Replace('\\', '/').TrimStart('/'); + if (_inactiveRemoteSystemLibraryRoots.Any(root => IsPathAtOrWithin(sourcePath, root))) + return true; + var slash = normalized.IndexOf('/'); + if (slash < 0 && ApprovedToolchainHeaders.Contains(normalized)) + return true; + if (slash >= 0 && ApprovedAppleSdkHeaderRoots.Contains(normalized.Substring(0, slash))) + return true; + + var roots = new HashSet(_approvedHeaderSearchRoots, GetPathComparer()); + for (var directory = Path.GetDirectoryName(sourcePath); + !string.IsNullOrWhiteSpace(directory) && IsPathAtOrWithin(directory, repositoryRoot); + directory = Path.GetDirectoryName(directory)) + { + roots.Add(Path.Combine(directory, "include")); + roots.Add(Path.Combine(directory, "Headers")); + if (PathsEqual(new[] { directory }, new[] { repositoryRoot })) + break; + } + + var matches = roots + .Where(Directory.Exists) + .Select(root => Path.GetFullPath(Path.Combine(root, normalized))) + .Where(File.Exists) + .Distinct(GetPathComparer()) + .ToArray(); + if (matches.Length != 1) + return false; + EnsurePathWithinRepository(repositoryRoot, matches[0], $"angled preprocessor include from {sourcePath}"); + EnsureTrackedFile(repositoryRoot, matches[0], $"angled preprocessor include from {sourcePath}"); + return true; + } + + private string ResolveQuotedSourceInclude( + string repositoryRoot, + string sourcePath, + string include, + string inputDescription) + { + var roots = new List { Path.GetDirectoryName(sourcePath)! }; + roots.AddRange(_approvedHeaderSearchRoots); + var candidates = new List(); + foreach (var root in roots.Distinct(GetPathComparer())) + { + var candidate = Path.GetFullPath(Path.Combine(root, include)); + EnsurePathWithinRepository(repositoryRoot, candidate, $"{inputDescription} from {sourcePath}"); + if (File.Exists(candidate) && !candidates.Contains(candidate, GetPathComparer())) + candidates.Add(candidate); + } + + if (candidates.Count == 0) + { + throw new FileNotFoundException( + $"Quoted {inputDescription} '{include}' from '{sourcePath}' was not found beside the including source or in an approved tracked header search root."); + } + if (candidates.Count > 1) + { + throw new InvalidOperationException( + $"Quoted {inputDescription} '{include}' from '{sourcePath}' resolves to multiple approved tracked inputs and cannot be bound unambiguously: {string.Join(", ", candidates)}"); + } + + EnsureTrackedFile(repositoryRoot, candidates[0], $"{inputDescription} from {sourcePath}"); + return candidates[0]; + } + + private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, string sourcePath, string source) + { + var syntax = MaskCStringAndCharacterLiterals(source); + RejectPreprocessorFileSelectionAliases(sourcePath, syntax); + var tokenPastedOperator = FindTokenPastedPreprocessorFileSelectionOperator(syntax); + if (tokenPastedOperator is not null) + { + throw new InvalidOperationException( + $"Source input '{sourcePath}' constructs preprocessor file-selection probe '{tokenPastedOperator}' through token pasting and cannot be bound to exact source."); + } + var embedProbe = Regex.Match( + syntax, + "(?= 2 && operand[0] == '\"' && operand[operand.Length - 1] == '\"'; + var angled = operand.Length >= 2 && operand[0] == '<' && operand[operand.Length - 1] == '>'; + if (!quoted && !angled) + { + throw new InvalidOperationException( + $"Source input '{sourcePath}' uses computed preprocessor file-existence probe '{operand}', which cannot be bound to exact source."); + } + + var include = operand.Substring(1, operand.Length - 2).Trim(); + if (Path.IsPathRooted(include)) + { + throw new InvalidOperationException( + $"Source input '{sourcePath}' probes absolute preprocessor input '{include}', which is outside the exact-source graph."); + } + if (angled) + { + if (!IsApprovedAngledInclude(repositoryRoot, sourcePath, include)) + throw new InvalidOperationException($"Source input '{sourcePath}' probes unbound angled preprocessor input '{include}'."); + continue; + } + + ResolveQuotedSourceInclude(repositoryRoot, sourcePath, include, "preprocessor file-existence probe"); + } + } + + private static string? FindTokenPastedPreprocessorFileSelectionOperator(string syntax) + { + var tokenPastedSyntax = Regex.Replace(syntax, "[ \\t\\v\\f\\r\\n]*(?:##|%:%:)[ \\t\\v\\f\\r\\n]*", string.Empty); + foreach (var operatorName in new[] { "__has_include", "__has_include_next", "__has_embed" }) + { + if (!syntax.Contains(operatorName, StringComparison.Ordinal) && + tokenPastedSyntax.Contains(operatorName, StringComparison.Ordinal)) + { + return operatorName; + } + } + return null; + } + + private static void RejectPreprocessorFileSelectionAliases(string sourcePath, string syntax) + { + var objectMacros = Regex.Matches( + syntax, + "(?m)^[ \\t\\v\\f]*(?:#|%:)[ \\t\\v\\f]*define[ \\t\\v\\f]+(?[A-Za-z_][A-Za-z0-9_]*)(?!\\()[ \\t\\v\\f]+(?[^\\r\\n]*)", + RegexOptions.CultureInvariant) + .Cast() + .GroupBy(match => match.Groups["name"].Value, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => string.Join("\n", group.Select(match => match.Groups["body"].Value)), + StringComparer.Ordinal); + if (objectMacros.Count == 0) + return; + + var fileSelectionAliases = new HashSet(StringComparer.Ordinal); + var changed = true; + while (changed) + { + changed = false; + foreach (var macro in objectMacros) + { + if (fileSelectionAliases.Contains(macro.Key)) + continue; + var referencesFileSelection = Regex.IsMatch( + macro.Value, + "(? + Regex.IsMatch( + macro.Value, + $"(? 0) + { + throw new InvalidOperationException( + $"Source input '{sourcePath}' aliases a preprocessor file-selection operator through object-like macro '{fileSelectionAliases.OrderBy(static alias => alias, StringComparer.Ordinal).First()}', which cannot be bound to exact source."); + } + } + + private static readonly HashSet ApprovedToolchainHeaders = new(StringComparer.Ordinal) + { + "assert.h", "complex.h", "ctype.h", "errno.h", "fenv.h", "float.h", "inttypes.h", "iso646.h", + "limits.h", "locale.h", "math.h", "setjmp.h", "signal.h", "stdalign.h", "stdarg.h", "stdatomic.h", + "stdbool.h", "stddef.h", "stdint.h", "stdio.h", "stdlib.h", "stdnoreturn.h", "string.h", "tgmath.h", + "threads.h", "time.h", "uchar.h", "wchar.h", "wctype.h", + "metal_stdlib", + "algorithm", "array", "atomic", "bit", "bitset", "cassert", "cctype", "cerrno", "cfenv", "cfloat", + "charconv", "chrono", "cinttypes", "climits", "cmath", "complex", "concepts", "condition_variable", + "coroutine", "cstddef", "cstdint", "cstdio", "cstdlib", "cstring", "deque", "exception", "filesystem", + "format", "forward_list", "fstream", "functional", "future", "initializer_list", "iomanip", "ios", "iosfwd", + "iostream", "istream", "iterator", "latch", "limits", "list", "map", "memory", "memory_resource", "mutex", + "new", "numbers", "numeric", "optional", "ostream", "queue", "random", "ranges", "ratio", "regex", + "scoped_allocator", "semaphore", "set", "shared_mutex", "source_location", "span", "sstream", "stack", + "stdexcept", "stop_token", "streambuf", "string", "string_view", "syncstream", "system_error", "thread", + "tuple", "type_traits", "typeindex", "typeinfo", "unordered_map", "unordered_set", "utility", "valarray", + "variant", "vector", "version" + }; + + private static readonly HashSet ApprovedAppleSdkHeaderRoots = new(StringComparer.Ordinal) + { + "Accelerate", "AppKit", "AuthenticationServices", "AudioToolbox", "AVFoundation", "CFNetwork", "CloudKit", "CommonCrypto", + "Compression", "Contacts", "CoreAudio", "CoreBluetooth", "CoreData", "CoreFoundation", "CoreGraphics", + "CoreImage", "CoreLocation", "CoreMedia", "CoreMotion", "CoreServices", "CoreText", "CoreVideo", + "CryptoKit", "Darwin", "DeviceCheck", "Dispatch", "EventKit", "Foundation", "GameController", + "HealthKit", "HomeKit", "ImageIO", "IOKit", "LocalAuthentication", "MapKit", "Metal", "MetalKit", + "Network", "NetworkExtension", "OSLog", "PassKit", "Photos", "QuartzCore", "SafariServices", "Security", + "StoreKit", "SystemConfiguration", "UIKit", "UniformTypeIdentifiers", "UserNotifications", "VideoToolbox", + "WatchKit", "WebKit", "ObjectiveC", "arpa", "dispatch", "libkern", "mach", "mach-o", "net", "netinet", "os", "simd", + "sys", "xpc" + }; + + private static void ValidateLanguageModuleImports(string sourcePath, string source, string effectiveSourceExtension) + { + var syntax = MaskCStringAndCharacterLiterals(source); + foreach (Match pragma in Regex.Matches( + source, + "(?(?:\\\\.|[^\\\"\\\\])*)\\\"\\s*\\)", + RegexOptions.CultureInvariant)) + { + if (syntax.IndexOf("_Pragma", pragma.Index, StringComparison.Ordinal) != pragma.Index) + continue; + var payload = Regex.Unescape(pragma.Groups["payload"].Value); + var moduleImport = Regex.Match( + payload, + "^\\s*clang\\s+module\\s+import\\s+(?[A-Za-z_][A-Za-z0-9_.]*)\\s*$", + RegexOptions.CultureInvariant); + if (moduleImport.Success) + RejectUnapprovedLanguageModule(sourcePath, moduleImport.Groups["module"].Value, "Clang _Pragma"); + } + foreach (Match computedPragma in Regex.Matches( + source, + "(?[A-Za-z_][A-Za-z0-9_.]*)\\s*;", + RegexOptions.CultureInvariant)) + { + var moduleName = import.Groups["module"].Value; + var rootModule = moduleName.Split('.')[0]; + if (ApprovedAppleSdkHeaderRoots.Contains(rootModule)) + continue; + throw new InvalidOperationException( + $"Source input '{sourcePath}' imports Objective-C module '{moduleName}', whose module map and selected headers are not bound to an approved SDK, toolchain, or unique tracked module root."); + } + + foreach (Match import in Regex.Matches( + syntax, + "(?m)^[ \\t\\v\\f]*(?:#|%:)[ \\t\\v\\f]*pragma[ \\t\\v\\f]+clang[ \\t\\v\\f]+module[ \\t\\v\\f]+import[ \\t\\v\\f]+(?[A-Za-z_][A-Za-z0-9_.]*)", + RegexOptions.CultureInvariant)) + { + RejectUnapprovedLanguageModule(sourcePath, import.Groups["module"].Value, "Clang pragma"); + } + + var extension = effectiveSourceExtension; + if (!extension.Equals(".cc", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".cpp", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".cxx", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".mm", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".hh", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".hpp", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".hxx", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + foreach (Match import in Regex.Matches( + syntax, + "(?m)^[ \\t]*(?:export\\s+)?import\\s+(?[^;]+?)\\s*;", + RegexOptions.CultureInvariant)) + { + var moduleName = import.Groups["module"].Value.Trim(); + if (!Regex.IsMatch( + moduleName, + "^[A-Za-z_][A-Za-z0-9_.]*(?::[A-Za-z_][A-Za-z0-9_.]*)?$", + RegexOptions.CultureInvariant)) + { + throw new InvalidOperationException( + $"Source input '{sourcePath}' uses C++ module or header-unit import '{moduleName}', whose selected bytes cannot be bound safely to the exact source commit."); + } + RejectUnapprovedLanguageModule(sourcePath, moduleName, "C++"); + } + } + + private static void RejectPragmaLinkedLibraries(string sourcePath, string source) + { + var syntax = MaskCStringAndCharacterLiterals(source); + if (Regex.IsMatch( + syntax, + "(?m)^[ \\t\\v\\f]*(?:#|%:)[ \\t\\v\\f]*pragma[ \\t\\v\\f]+comment[ \\t\\v\\f]*\\([ \\t\\v\\f]*lib[ \\t\\v\\f]*,", + RegexOptions.CultureInvariant)) + { + throw new InvalidOperationException( + $"Source input '{sourcePath}' uses pragma comment(lib), whose named library can resolve through an unbound linker search root."); + } + + foreach (Match pragma in Regex.Matches( + source, + "(?(?:\\\\.|[^\\\"\\\\])*)\\\"\\s*\\)", + RegexOptions.CultureInvariant)) + { + if (syntax.IndexOf("_Pragma", pragma.Index, StringComparison.Ordinal) != pragma.Index) + continue; + var payload = Regex.Unescape(pragma.Groups["payload"].Value); + if (!Regex.IsMatch(payload, "^\\s*comment\\s*\\(\\s*lib\\s*,", RegexOptions.CultureInvariant)) + continue; + throw new InvalidOperationException( + $"Source input '{sourcePath}' uses _Pragma comment(lib), whose named library can resolve through an unbound linker search root."); + } + + if (Regex.IsMatch( + syntax, + "(?(?:\\\\.|[^\\\"\\\\])*)\\\"\\s*\\)", + RegexOptions.CultureInvariant)) + { + var payload = Regex.Unescape(pragma.Groups["payload"].Value); + if (!Regex.IsMatch(payload, "^\\s*include_alias(?![A-Za-z0-9_])", RegexOptions.CultureInvariant)) + continue; + throw new InvalidOperationException( + $"Source input '{sourcePath}' uses _Pragma include_alias, whose replacement header can bypass the exact-source include graph."); + } + } + + private static void RejectUnapprovedLanguageModule(string sourcePath, string moduleName, string syntax) + { + var rootModule = moduleName.Split('.', ':')[0]; + if (rootModule.Equals("std", StringComparison.Ordinal) || + ApprovedAppleSdkHeaderRoots.Contains(rootModule)) + { + return; + } + throw new InvalidOperationException( + $"Source input '{sourcePath}' imports {syntax} module '{moduleName}', whose module map and selected headers are not bound to an approved SDK, toolchain, or unique tracked module root."); + } + + private static void RejectCTrigraphs(string source, string sourcePath) + { + var trigraph = Regex.Match(source, "\\?\\?[=/'()!<>-]", RegexOptions.CultureInvariant); + if (!trigraph.Success) + return; + throw new InvalidOperationException( + $"Source input '{sourcePath}' uses C trigraph '{trigraph.Value}', whose translation can change preprocessing semantics before exact-source validation."); + } + + private static void ValidateSwiftSourceDeterminism(string sourcePath) + { + var contents = File.ReadAllText(sourcePath); + if (contents.IndexOf("#file", StringComparison.Ordinal) < 0) + return; + var syntax = MaskSwiftStringLiterals(RemoveSwiftComments(contents)); + var locationLiteral = Regex.Match( + syntax, + "(?file|filePath)(?![A-Za-z0-9_])", + RegexOptions.CultureInvariant); + if (!locationLiteral.Success) + return; + throw new InvalidOperationException( + $"Swift source input '{sourcePath}' uses snapshot-path compiler literal '#{locationLiteral.Groups["literal"].Value}', " + + "which exposes changing checkout or host state and cannot be bound to one reproducible detached source location. " + + "Use #fileID or an explicit stable identifier instead."); + } + + private void ValidateClangModuleMapInputs(string repositoryRoot, string moduleMapPath) + { + var source = RemoveCComments(File.ReadAllText(moduleMapPath)); + var unboundLink = Regex.Match( + source, + "(?(?:\\\\.|[^\\\"\\\\])*)\\\"", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + if (unboundLink.Success && + !_inactiveRemoteSystemLibraryRoots.Any(root => IsPathAtOrWithin(moduleMapPath, root))) + { + throw new InvalidOperationException( + $"Clang module map '{moduleMapPath}' declares unbound autolink '{unboundLink.Value.Trim()}', whose SDK or library bytes cannot be proven at the exact source commit."); + } + const string declaration = + "(?(?:\\\\.|[^\"\\\\])*)\""; + foreach (Match match in Regex.Matches( + source, + declaration, + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) + { + var declaredPath = match.Groups["path"].Value; + if (declaredPath.Contains('\\')) + { + throw new InvalidOperationException( + $"Clang module map '{moduleMapPath}' uses an escaped or platform-dependent input path '{declaredPath}', which cannot be attested safely."); + } + if (Path.IsPathRooted(declaredPath)) + { + throw new InvalidOperationException( + $"Clang module map '{moduleMapPath}' references absolute input '{declaredPath}', which is outside the exact-source graph."); + } + + var candidate = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(moduleMapPath)!, declaredPath)); + EnsurePathWithinRepository(repositoryRoot, candidate, $"Clang module map input from {moduleMapPath}"); + if (File.Exists(candidate)) + { + EnsureTrackedFile(repositoryRoot, candidate, $"Clang module map input from {moduleMapPath}"); + } + else if (Directory.Exists(candidate)) + { + EnsureTrackedDirectoryTree(repositoryRoot, candidate, $"Clang module map input from {moduleMapPath}"); + } + else + { + throw new FileNotFoundException( + $"Clang module map input was not found inside the exact checked-out source: {candidate}", + candidate); + } + } + } + + private static string? FindNondeterministicCompilerMacro(string source) + { + var masked = MaskCStringAndCharacterLiterals(source); + var found = FindNondeterministicCompilerIdentifier(masked); + if (found is not null) + return found; + + var tokenPasted = Regex.Replace(masked, "[ \\t\\v\\f\\r\\n]*(?:##|%:%:)[ \\t\\v\\f\\r\\n]*", string.Empty); + return FindNondeterministicCompilerIdentifier(tokenPasted); + } + + private static string? FindNondeterministicCompilerIdentifier(string source) + { + for (var index = 0; index < source.Length; index++) + { + if (source[index] != '_' && !char.IsLetter(source[index])) + continue; + var start = index; + while (index + 1 < source.Length && + (source[index + 1] == '_' || char.IsLetterOrDigit(source[index + 1]))) + index++; + var identifier = source.Substring(start, index - start + 1); + if (identifier is "__DATE__" or "__TIME__" or "__TIMESTAMP__" or + "__FILE__" or "__BASE_FILE__" or "__builtin_FILE" or + "__builtin_source_location" or "source_location") + return identifier; + } + return null; + } + +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs new file mode 100644 index 000000000..4d8068f03 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs @@ -0,0 +1,449 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static void EnsureNoExecutableSwiftStringInterpolation(string packageRoot, string source) + { + for (var index = 0; index < source.Length; index++) + { + if (!TryFindSwiftStringBounds(source, index, out var contentStart, out var endExclusive, out var hashCount)) + continue; + + for (var cursor = contentStart; cursor < endExclusive; cursor++) + { + if (source[cursor] != '\\') + continue; + var marker = cursor + 1; + var hashes = 0; + while (marker < endExclusive && source[marker] == '#') + { + hashes++; + marker++; + } + if (hashes == hashCount && marker < endExclusive && source[marker] == '(' && + (hashCount > 0 || !IsEscapedOrdinarySwiftBackslash(source, cursor))) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' uses executable string interpolation, whose manifest expression cannot be proven safely. " + + "Use literal manifest declarations before creating an exact-source Apple checkpoint."); + } + } + index = endExclusive - 1; + } + } + + private static bool IsEscapedOrdinarySwiftBackslash(string source, int slashIndex) + { + var backslashes = 0; + for (var index = slashIndex - 1; index >= 0 && source[index] == '\\'; index--) + backslashes++; + return backslashes % 2 == 1; + } + + private static void ValidateDirectSwiftPackageDependencyFactories(string packageRoot, string manifestSyntax) + { + foreach (Match reference in Regex.Matches( + manifestSyntax, + "\\.\\s*(?:package\\b|`package`|binaryTarget\\b|`binaryTarget`)", + RegexOptions.CultureInvariant)) + { + var next = reference.Index + reference.Length; + while (next < manifestSyntax.Length && char.IsWhiteSpace(manifestSyntax[next])) + next++; + if (next < manifestSyntax.Length && manifestSyntax[next] == '(') + continue; + + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' references a package or binary target factory indirectly, so its external source cannot be proven. " + + "Invoke each factory directly with literal source identity and integrity arguments."); + } + } + + private static IReadOnlyList ParseDirectSwiftPackageDependencyCalls( + string source, + string syntax) + { + var calls = new List(); + foreach (Match reference in Regex.Matches( + syntax, + "\\.\\s*(?:package\\b|`package`)", + RegexOptions.CultureInvariant)) + { + var openingParenthesis = reference.Index + reference.Length; + while (openingParenthesis < syntax.Length && char.IsWhiteSpace(syntax[openingParenthesis])) + openingParenthesis++; + if (openingParenthesis >= syntax.Length || syntax[openingParenthesis] != '(') + continue; + + var closingParenthesis = FindMatchingSwiftDelimiter(syntax, openingParenthesis, '(', ')'); + var arguments = ParseTopLevelSwiftArguments( + source.Substring(openingParenthesis + 1, closingParenthesis - openingParenthesis - 1), + syntax.Substring(openingParenthesis + 1, closingParenthesis - openingParenthesis - 1)); + calls.Add(new SwiftPackageDependencyCall(arguments)); + } + return calls; + } + + private static IReadOnlyDictionary ParseTopLevelSwiftArguments(string source, string syntax) + { + var arguments = new Dictionary(StringComparer.Ordinal); + var start = 0; + var parentheses = 0; + var brackets = 0; + var braces = 0; + for (var index = 0; index <= syntax.Length; index++) + { + var character = index < syntax.Length ? syntax[index] : ','; + switch (character) + { + case '(': + parentheses++; + break; + case ')': + parentheses--; + break; + case '[': + brackets++; + break; + case ']': + brackets--; + break; + case '{': + braces++; + break; + case '}': + braces--; + break; + } + if (character != ',' || parentheses != 0 || brackets != 0 || braces != 0) + continue; + + AddTopLevelSwiftArgument( + arguments, + source.Substring(start, index - start), + syntax.Substring(start, index - start)); + start = index + 1; + } + return arguments; + } + + private static string ReadFirstTopLevelSwiftArgument(string source, string syntax) + { + var parentheses = 0; + var brackets = 0; + var braces = 0; + for (var index = 0; index < syntax.Length; index++) + { + switch (syntax[index]) + { + case '(': + parentheses++; + break; + case ')': + parentheses--; + break; + case '[': + brackets++; + break; + case ']': + brackets--; + break; + case '{': + braces++; + break; + case '}': + braces--; + break; + case ',' when parentheses == 0 && brackets == 0 && braces == 0: + return source.Substring(0, index).Trim(); + } + } + return source.Trim(); + } + + private static IReadOnlyList SplitTopLevelSwiftExpressions(string source, string syntax) + { + var expressions = new List(); + var start = 0; + var parentheses = 0; + var brackets = 0; + var braces = 0; + for (var index = 0; index <= syntax.Length; index++) + { + var current = index < syntax.Length ? syntax[index] : ','; + switch (current) + { + case '(': + parentheses++; + break; + case ')': + parentheses--; + break; + case '[': + brackets++; + break; + case ']': + brackets--; + break; + case '{': + braces++; + break; + case '}': + braces--; + break; + } + if (current != ',' || parentheses != 0 || brackets != 0 || braces != 0) + continue; + var expression = source.Substring(start, index - start).Trim(); + if (!string.IsNullOrWhiteSpace(expression)) + expressions.Add(expression); + start = index + 1; + } + return expressions; + } + + private static void AddTopLevelSwiftArgument( + IDictionary arguments, + string source, + string syntax) + { + var parentheses = 0; + var brackets = 0; + var braces = 0; + for (var index = 0; index < syntax.Length; index++) + { + switch (syntax[index]) + { + case '(': + parentheses++; + break; + case ')': + parentheses--; + break; + case '[': + brackets++; + break; + case ']': + brackets--; + break; + case '{': + braces++; + break; + case '}': + braces--; + break; + case ':' when parentheses == 0 && brackets == 0 && braces == 0: + var label = syntax.Substring(0, index).Trim(); + if (label.Length >= 2 && label[0] == '`' && label[label.Length - 1] == '`') + label = label.Substring(1, label.Length - 2); + if (!Regex.IsMatch(label, "^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant)) + return; + if (arguments.ContainsKey(label)) + throw new InvalidOperationException($"Swift package dependency repeats top-level argument '{label}'."); + arguments.Add(label, source.Substring(index + 1).Trim()); + return; + } + } + } + + private static int FindMatchingSwiftDelimiter(string syntax, int openingIndex, char opening, char closing) + { + var depth = 0; + for (var index = openingIndex; index < syntax.Length; index++) + { + if (syntax[index] == opening) + depth++; + else if (syntax[index] == closing && --depth == 0) + return index; + } + throw new InvalidOperationException("Swift package manifest contains an unterminated dependency declaration."); + } + + private static bool TryReadLiteralSwiftString(string value, out string literal) + { + var match = Regex.Match( + value, + "^\\s*\"(?[^\"\\\\\\r\\n]+)\"\\s*$", + RegexOptions.CultureInvariant); + literal = match.Success ? match.Groups["literal"].Value : string.Empty; + return match.Success; + } + + private static bool ContainsSwiftIdentifier(string syntax, string identifier) + => Regex.IsMatch( + syntax, + $"(? Regex.IsMatch( + syntax, + $"\\.\\s*(?:{Regex.Escape(identifier)}\\b|`{Regex.Escape(identifier)}`)", + RegexOptions.CultureInvariant); + + private static string RemoveSwiftComments(string source) + { + var result = source.ToCharArray(); + for (var index = 0; index < source.Length; index++) + { + if (TryFindSwiftStringEnd(source, index, out var stringEnd)) + { + index = stringEnd - 1; + continue; + } + if (source[index] != '/' || index + 1 >= source.Length) + continue; + if (source[index + 1] == '/') + { + while (index < source.Length && source[index] != '\r' && source[index] != '\n') + result[index++] = ' '; + index--; + continue; + } + if (source[index + 1] != '*') + continue; + + var depth = 1; + MaskSwiftTrivia(result, index, 2); + index += 2; + while (index < source.Length && depth > 0) + { + if (index + 1 < source.Length && source[index] == '/' && source[index + 1] == '*') + { + MaskSwiftTrivia(result, index, 2); + depth++; + index += 2; + continue; + } + if (index + 1 < source.Length && source[index] == '*' && source[index + 1] == '/') + { + MaskSwiftTrivia(result, index, 2); + depth--; + index += 2; + continue; + } + MaskSwiftTrivia(result, index, 1); + index++; + } + index--; + } + return new string(result); + } + + private static string MaskSwiftStringLiterals(string source) + { + var result = source.ToCharArray(); + for (var index = 0; index < source.Length; index++) + { + if (!TryFindSwiftStringEnd(source, index, out var stringEnd)) + continue; + + MaskSwiftTrivia(result, index, stringEnd - index); + index = stringEnd - 1; + } + return new string(result); + } + + private static bool TryFindSwiftStringEnd(string source, int start, out int endExclusive) + { + return TryFindSwiftStringBounds(source, start, out _, out endExclusive, out _); + } + + private static bool TryFindSwiftStringBounds( + string source, + int start, + out int contentStart, + out int endExclusive, + out int hashCount) + { + contentStart = start; + endExclusive = start; + var quoteIndex = start; + while (quoteIndex < source.Length && source[quoteIndex] == '#') + quoteIndex++; + hashCount = quoteIndex - start; + if (quoteIndex >= source.Length || source[quoteIndex] != '"') + return false; + + var quoteCount = quoteIndex + 2 < source.Length && + source[quoteIndex + 1] == '"' && + source[quoteIndex + 2] == '"' + ? 3 + : 1; + var cursor = quoteIndex + quoteCount; + contentStart = cursor; + while (cursor < source.Length) + { + if (MatchesSwiftStringDelimiter(source, cursor, quoteCount, hashCount) && + !IsEscapedSwiftStringDelimiter(source, cursor, hashCount)) + { + endExclusive = cursor + quoteCount + hashCount; + return true; + } + cursor++; + } + + endExclusive = source.Length; + return true; + } + + private static bool MatchesSwiftStringDelimiter(string source, int start, int quoteCount, int hashCount) + { + if (start + quoteCount + hashCount > source.Length) + return false; + for (var offset = 0; offset < quoteCount; offset++) + { + if (source[start + offset] != '"') + return false; + } + for (var offset = 0; offset < hashCount; offset++) + { + if (source[start + quoteCount + offset] != '#') + return false; + } + return true; + } + + private static bool IsEscapedSwiftStringDelimiter(string source, int quoteIndex, int hashCount) + { + if (hashCount > 0) + { + var escapeStart = quoteIndex - hashCount - 1; + if (escapeStart < 0 || source[escapeStart] != '\\') + return false; + for (var offset = 0; offset < hashCount; offset++) + { + if (source[escapeStart + 1 + offset] != '#') + return false; + } + return true; + } + + var backslashes = 0; + for (var index = quoteIndex - 1; index >= 0 && source[index] == '\\'; index--) + backslashes++; + return backslashes % 2 == 1; + } + + private static void MaskSwiftTrivia(char[] value, int start, int length) + { + var end = Math.Min(value.Length, start + length); + for (var index = start; index < end; index++) + { + if (value[index] != '\r' && value[index] != '\n') + value[index] = ' '; + } + } + + private sealed class SwiftPackageDependencyCall + { + internal SwiftPackageDependencyCall(IReadOnlyDictionary arguments) + { + Arguments = arguments; + } + + internal IReadOnlyDictionary Arguments { get; } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs new file mode 100644 index 000000000..cf11c0e16 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -0,0 +1,774 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static readonly HashSet ExternalXcodeSourceTrees = new(StringComparer.OrdinalIgnoreCase) + { + "BUILT_PRODUCTS_DIR", "SDKROOT", "DEVELOPER_DIR" + }; + + private static readonly HashSet FileValuedBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "INFOPLIST_FILE", + "INFOPLIST_PREFIX_HEADER", + "CODE_SIGN_ENTITLEMENTS", + "SWIFT_OBJC_BRIDGING_HEADER", + "GCC_PREFIX_HEADER", + "CLANG_PREFIX_HEADER", + "MODULEMAP_FILE", + "MODULEMAP_PRIVATE_FILE", + "DEVELOPMENT_ASSET_PATHS", + "EXPORTED_SYMBOLS_FILE", + "UNEXPORTED_SYMBOLS_FILE", + "ORDER_FILE" + }; + + private static readonly HashSet SearchPathBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "HEADER_SEARCH_PATHS", + "USER_HEADER_SEARCH_PATHS", + "SYSTEM_HEADER_SEARCH_PATHS", + "MTL_HEADER_SEARCH_PATHS", + "FRAMEWORK_SEARCH_PATHS", + "IBC_PLUGIN_SEARCH_PATHS", + "SYSTEM_FRAMEWORK_SEARCH_PATHS", + "LIBRARY_SEARCH_PATHS", + "SWIFT_INCLUDE_PATHS", + "SWIFT_SYSTEM_INCLUDE_PATHS" + }; + + private static readonly HashSet FlagBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "OTHER_CFLAGS", + "OTHER_CPLUSPLUSFLAGS", + "OTHER_LDFLAGS", + "OTHER_LIBTOOLFLAGS", + "MTL_COMPILER_FLAGS", + "OTHER_SWIFT_FLAGS", + "INFOPLIST_OTHER_PREPROCESSOR_FLAGS" + }; + + private static readonly HashSet DefinitionBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "GCC_PREPROCESSOR_DEFINITIONS", + "GCC_PREPROCESSOR_DEFINITIONS_NOT_USED_IN_PRECOMPS", + "INFOPLIST_PREPROCESSOR_DEFINITIONS", + "SWIFT_ACTIVE_COMPILATION_CONDITIONS" + }; + + private static readonly HashSet SourceSelectionBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "EXCLUDED_SOURCE_FILE_NAMES", + "INCLUDED_SOURCE_FILE_NAMES", + "EXCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES", + "INCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES" + }; + + private static readonly HashSet ExecutableBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "ACTOOL", "AR", "AS", "BITCODE_STRIP", "CC", "CHMOD", "CHOWN", "CODE_SIGN", "CODESIGN_ALLOCATE", + "COPYSTRINGS", "COREML_COMPILER", "CPLUSPLUS", "DITTO", "DSYMUTIL", "IBTOOL", "INSTALL_NAME_TOOL", + "INTENTS_COMPILER", "LD", "LDPLUSPLUS", "LEX", "LIBTOOL", "LIPO", "MAPC", "MIG", "MOMC", + "MTL_COMPILER", "NM", "OTOOL", "PLUTIL", "PRODUCT_PACKAGING_UTILITY", "RANLIB", "RESMERGER", "REZ", + "SEGEDIT", "STRIP", "SWIFT_DRIVER_SWIFT_EXEC", "SWIFT_EXEC", "TAPI", "TOUCH", "UNZIP", "YACC" + }; + + private static readonly HashSet SdkSelectionBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "SDKROOT" + }; + + private void ValidateXcodeBuildGraph( + string repositoryRoot, + string projectRoot, + IReadOnlyCollection apps, + IReadOnlyCollection metadataPaths, + IReadOnlyCollection generatedOutputPaths) + { + foreach (var app in apps.Where(static value => value.Enabled && !string.IsNullOrWhiteSpace(value.ProjectPath))) + EnsureTrackedSharedScheme(repositoryRoot, projectRoot, app, metadataPaths); + + foreach (var metadataPath in metadataPaths.Where(path => + path.EndsWith("project.pbxproj", StringComparison.OrdinalIgnoreCase) && File.Exists(path))) + { + ValidateProjectGraph(repositoryRoot, metadataPath, metadataPaths, generatedOutputPaths); + } + } + + private void AddReferencedXcodeProjects( + string repositoryRoot, + HashSet metadataPaths, + IReadOnlyCollection generatedOutputPaths) + { + var pending = new Queue(metadataPaths.Where(path => + path.EndsWith("project.pbxproj", StringComparison.OrdinalIgnoreCase))); + var inspected = new HashSet(GetPathComparer()); + while (pending.Count > 0) + { + var metadataPath = Path.GetFullPath(pending.Dequeue()); + if (!inspected.Add(metadataPath)) + continue; + + var projectDirectory = Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!; + var objects = ParsePbxObjects(File.ReadAllText(metadataPath)); + var parents = BuildPbxParentMap(objects); + var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in objects.Values.Where(static value => + value.Isa.Equals("PBXFileReference", StringComparison.OrdinalIgnoreCase) && + (value.Path ?? string.Empty).EndsWith(".xcodeproj", StringComparison.OrdinalIgnoreCase))) + { + var projectPath = ResolvePbxObjectPath( + projectDirectory, + item.Id, + objects, + parents, + cache, + new HashSet(StringComparer.OrdinalIgnoreCase)); + if (projectPath is null) + { + throw new InvalidOperationException( + $"Referenced Xcode subproject uses an external source tree and cannot be attested: {metadataPath}"); + } + + EnsurePathWithinRepository(repositoryRoot, projectPath, "Xcode referenced subproject"); + EnsureNoGeneratedOutputOverlap(projectPath, generatedOutputPaths, "Xcode referenced subproject"); + EnsureNoLinkedTraversal(repositoryRoot, projectPath, "Xcode referenced subproject"); + var referencedMetadata = Path.Combine(projectPath, "project.pbxproj"); + EnsureTrackedFile(repositoryRoot, referencedMetadata, "Xcode referenced subproject metadata"); + if (metadataPaths.Add(referencedMetadata)) + pending.Enqueue(referencedMetadata); + } + } + } + + private void EnsureTrackedSharedScheme( + string repositoryRoot, + string projectRoot, + AppleAppConfiguration app, + IReadOnlyCollection metadataPaths) + { + if (string.IsNullOrWhiteSpace(app.Scheme)) + throw new InvalidOperationException($"Apple app '{app.Name}' requires a shared Xcode scheme for an exact-source checkpoint."); + + var scheme = app.Scheme!.Trim(); + if (!Path.GetFileName(scheme).Equals(scheme, StringComparison.Ordinal) || + scheme.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }) >= 0) + throw new InvalidOperationException($"Apple app '{app.Name}' scheme must be a simple shared scheme name: {scheme}"); + + var configuredContainer = ResolvePath(projectRoot, app.ProjectPath!); + var containers = new HashSet(GetPathComparer()) { configuredContainer }; + if (configuredContainer.EndsWith(".xcworkspace", StringComparison.OrdinalIgnoreCase)) + { + foreach (var metadataPath in metadataPaths) + { + if (metadataPath.EndsWith("project.pbxproj", StringComparison.OrdinalIgnoreCase)) + containers.Add(Path.GetDirectoryName(metadataPath)!); + } + } + + var candidates = containers + .Select(container => Path.Combine(container, "xcshareddata", "xcschemes", scheme + ".xcscheme")) + .Where(File.Exists) + .Distinct(GetPathComparer()) + .ToArray(); + if (candidates.Length == 0) + { + throw new InvalidOperationException( + $"Apple app '{app.Name}' scheme '{scheme}' must exist as tracked shared Xcode metadata. " + + "User schemes under xcuserdata are not exact-source release inputs."); + } + if (candidates.Length > 1) + { + throw new InvalidOperationException( + $"Apple app '{app.Name}' scheme '{scheme}' is ambiguous across {candidates.Length} shared Xcode containers."); + } + + EnsureTrackedFile(repositoryRoot, candidates[0], $"Apple app '{app.Name}' shared scheme"); + ValidateScheme(repositoryRoot, candidates[0], metadataPaths); + } + + private void ValidateScheme( + string repositoryRoot, + string schemePath, + IReadOnlyCollection metadataPaths) + { + var document = XDocument.Load(schemePath, LoadOptions.None); + if (document.Descendants().Any(element => + element.Name.LocalName.Equals("ExecutionAction", StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"Shared Xcode scheme actions are not accepted for exact-source checkpoints because their runtime inputs cannot be proven: {schemePath}"); + } + + var schemeContainer = FindXcodeContainer(schemePath) + ?? throw new InvalidOperationException($"Shared Xcode scheme is not inside an Xcode project or workspace: {schemePath}"); + var containerRoot = Path.GetDirectoryName(schemeContainer)!; + var knownMetadata = new HashSet(metadataPaths.Select(Path.GetFullPath), GetPathComparer()); + foreach (var reference in document.Descendants() + .Select(element => element.Attribute("ReferencedContainer")?.Value) + .Where(static value => !string.IsNullOrWhiteSpace(value))) + { + var referencedContainer = ResolveSchemeContainer(reference!, containerRoot); + EnsurePathWithinRepository(repositoryRoot, referencedContainer, "Xcode scheme referenced container"); + if (!Directory.Exists(referencedContainer)) + throw new DirectoryNotFoundException($"Xcode scheme referenced container was not found: {referencedContainer}"); + EnsureNoLinkedTraversal(repositoryRoot, referencedContainer, "Xcode scheme referenced container"); + + var metadataPath = referencedContainer.EndsWith(".xcworkspace", StringComparison.OrdinalIgnoreCase) + ? Path.Combine(referencedContainer, "contents.xcworkspacedata") + : referencedContainer.EndsWith(".xcodeproj", StringComparison.OrdinalIgnoreCase) + ? Path.Combine(referencedContainer, "project.pbxproj") + : throw new InvalidOperationException( + $"Xcode scheme referenced container is not a project or workspace: {referencedContainer}"); + EnsureTrackedFile(repositoryRoot, metadataPath, "Xcode scheme referenced container metadata"); + if (!knownMetadata.Contains(Path.GetFullPath(metadataPath))) + { + throw new InvalidOperationException( + $"Xcode scheme references a container outside the validated project/workspace graph: {referencedContainer}"); + } + } + } + + private static string? FindXcodeContainer(string schemePath) + { + var current = Path.GetDirectoryName(schemePath); + while (!string.IsNullOrWhiteSpace(current)) + { + if (current.EndsWith(".xcodeproj", StringComparison.OrdinalIgnoreCase) || + current.EndsWith(".xcworkspace", StringComparison.OrdinalIgnoreCase)) + return current; + current = Path.GetDirectoryName(current); + } + return null; + } + + private static string ResolveSchemeContainer(string reference, string containerRoot) + { + var separator = reference.IndexOf(':'); + var kind = separator < 0 ? "container" : reference.Substring(0, separator); + var value = separator < 0 ? reference : reference.Substring(separator + 1); + return kind.ToLowerInvariant() switch + { + "container" or "group" => ResolvePath(containerRoot, value), + "absolute" => throw new InvalidOperationException( + $"Absolute Xcode scheme references are not accepted for exact-source snapshot builds: {reference}"), + _ => throw new InvalidOperationException($"Unsupported Xcode scheme container kind '{kind}'.") + }; + } + + private void ValidateProjectGraph( + string repositoryRoot, + string metadataPath, + IReadOnlyCollection metadataPaths, + IReadOnlyCollection generatedOutputPaths) + { + var projectDirectory = Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!; + var packageLockPaths = ResolveEffectivePackageLockPaths(metadataPath, metadataPaths); + var objects = ParsePbxObjects(File.ReadAllText(metadataPath)); + var parents = BuildPbxParentMap(objects); + var buildFileReferences = objects.Values + .Where(static value => value.Isa.Equals("PBXBuildFile", StringComparison.OrdinalIgnoreCase)) + .Select(value => ReadPbxScalar(value.Body, "fileRef")) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries)[0]) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var nativeTargetProductReferences = objects.Values + .Where(static value => value.Isa.Equals("PBXNativeTarget", StringComparison.OrdinalIgnoreCase)) + .Select(value => ReadPbxScalar(value.Body, "productReference")) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries)[0]) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var shippingSources = ResolveShippingSourceOwnership( + repositoryRoot, + projectDirectory, + objects, + metadataPath, + generatedOutputPaths); + var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var validatedLocalPackageRoots = new HashSet(GetPathComparer()); + + foreach (var buildConfiguration in objects.Values.Where(static value => + value.Isa.Equals("XCBuildConfiguration", StringComparison.OrdinalIgnoreCase))) + { + ValidateBuildConfiguration( + repositoryRoot, + projectDirectory, + buildConfiguration, + objects, + parents, + cache, + metadataPath, + generatedOutputPaths); + } + + foreach (var buildFile in objects.Values.Where(static value => + value.Isa.Equals("PBXBuildFile", StringComparison.OrdinalIgnoreCase))) + { + ValidateBuildFileSettings( + repositoryRoot, + projectDirectory, + buildFile, + generatedOutputPaths, + metadataPath); + } + + foreach (var item in objects.Values) + { + if (item.Isa.Equals("PBXShellScriptBuildPhase", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"PBX shell-script build phases are not accepted for exact-source checkpoints because arbitrary runtime inputs cannot be proven: {metadataPath}"); + } + + if (item.Isa.Equals("PBXBuildRule", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"PBX custom build rules are not accepted for exact-source checkpoints because their runtime inputs cannot be proven: {metadataPath}"); + } + + if (item.Isa.Equals("PBXLegacyTarget", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"PBX legacy targets are not accepted for exact-source checkpoints because their external build tool cannot be proven: {metadataPath}"); + } + + if (item.Isa.Equals("PBXBuildFile", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (item.Isa.Equals("XCLocalSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) + { + ValidateLocalPackageReference( + repositoryRoot, + projectDirectory, + packageLockPaths, + item, + validatedLocalPackageRoots); + continue; + } + + if (item.Isa.Equals("PBXFileSystemSynchronizedBuildFileExceptionSet", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"PBX file-system synchronized build-file exception sets are not accepted for exact-source checkpoints because their per-file compiler overrides cannot be proven: {metadataPath}"); + } + + if (item.Isa.Equals("XCRemoteSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) + { + ValidateRemotePackageReference(repositoryRoot, packageLockPaths, item); + continue; + } + + if (item.Isa.Equals("XCBuildConfiguration", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!IsPathBearingPbxObject(item.Isa)) + continue; + + if (Path.IsPathRooted(item.Path ?? string.Empty)) + { + throw new InvalidOperationException( + $"Absolute Xcode project inputs are not accepted for exact-source snapshot builds: {item.Path} ({metadataPath})"); + } + + var candidate = ResolvePbxObjectPath(projectDirectory, item.Id, objects, parents, cache, new HashSet(StringComparer.OrdinalIgnoreCase)); + if (candidate is null) + { + ValidateExternalXcodeBuildInput(item, metadataPath, buildFileReferences, nativeTargetProductReferences); + continue; + } + ValidateResolvedProjectInput( + repositoryRoot, + candidate, + item, + metadataPath, + generatedOutputPaths, + buildFileReferences, + shippingSources); + } + } + + private void ValidateBuildFileSettings( + string repositoryRoot, + string projectDirectory, + PbxObject item, + IReadOnlyCollection generatedOutputPaths, + string metadataPath) + { + var settings = ReadPbxDictionary(item.Body, "settings"); + if (settings is null) + return; + + foreach (var assignment in ReadPbxAssignments(settings)) + { + if (assignment.Key.Equals("ATTRIBUTES", StringComparison.OrdinalIgnoreCase)) + continue; + if (assignment.Key.Equals("COMPILER_FLAGS", StringComparison.OrdinalIgnoreCase)) + { + ValidateBuildFlagInputPaths( + repositoryRoot, + projectDirectory, + assignment.Value, + "COMPILER_FLAGS", + generatedOutputPaths, + $"PBXBuildFile '{item.Id}' in {metadataPath}", + new HashSet(GetPathComparer())); + continue; + } + + throw new InvalidOperationException( + $"PBXBuildFile '{item.Id}' uses unsupported per-file setting '{assignment.Key}', whose build behavior cannot be proven: {metadataPath}"); + } + } + + private static void ValidateExternalXcodeBuildInput( + PbxObject item, + string metadataPath, + ISet buildFileReferences, + ISet nativeTargetProductReferences) + { + if (!buildFileReferences.Contains(item.Id)) + return; + + var sourceTree = item.SourceTree ?? string.Empty; + var path = item.Path?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(path) || + Path.IsPathRooted(path) || + path.Contains("$(", StringComparison.Ordinal) || + path.Contains("${", StringComparison.Ordinal) || + path.Split('/', '\\').Any(static segment => segment == "..")) + { + throw new InvalidOperationException( + $"Xcode build input '{path}' uses external source tree '{sourceTree}' and cannot be proven at the exact source commit: {metadataPath}"); + } + + if (sourceTree.Equals("BUILT_PRODUCTS_DIR", StringComparison.OrdinalIgnoreCase)) + { + if (nativeTargetProductReferences.Contains(item.Id)) + return; + throw new InvalidOperationException( + $"Xcode build input '{path}' uses BUILT_PRODUCTS_DIR without a validated PBXNativeTarget product owner: {metadataPath}"); + } + + var normalized = path.Replace('\\', '/'); + var extension = Path.GetExtension(normalized); + var approvedSystemArtifact = extension.Equals(".framework", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".tbd", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".dylib", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".a", StringComparison.OrdinalIgnoreCase); + var approvedRoot = sourceTree.Equals("SDKROOT", StringComparison.OrdinalIgnoreCase) + ? normalized.StartsWith("System/Library/", StringComparison.Ordinal) || + normalized.StartsWith("usr/lib/", StringComparison.Ordinal) + : sourceTree.Equals("DEVELOPER_DIR", StringComparison.OrdinalIgnoreCase) && + (normalized.StartsWith("Platforms/", StringComparison.Ordinal) || + normalized.StartsWith("Toolchains/", StringComparison.Ordinal) || + normalized.StartsWith("Library/", StringComparison.Ordinal)); + if (approvedSystemArtifact && approvedRoot) + return; + + throw new InvalidOperationException( + $"Xcode build input '{path}' from external source tree '{sourceTree}' is not a validated SDK, toolchain, or owned target product: {metadataPath}"); + } + + private void ValidateRemotePackageReference( + string repositoryRoot, + IReadOnlyCollection packageLockPaths, + PbxObject item) + { + var repositoryUrl = ReadPbxScalar(item.Body, "repositoryURL")?.Trim(); + if (string.IsNullOrWhiteSpace(repositoryUrl)) + throw new InvalidOperationException("Remote Swift package reference is missing repositoryURL."); + + var locks = FindTrackedPackageLocks(packageLockPaths, repositoryUrl!); + if (locks.Length == 0) + { + throw new InvalidOperationException( + $"Remote Swift package '{repositoryUrl}' must be bound by a tracked Package.resolved lock so preflight and exact archive materialization consume the same approved graph."); + } + foreach (var packageLock in locks) + EnsureTrackedFile(repositoryRoot, packageLock, "Swift package resolution lock"); + var resolvedRevision = ResolvePackageRevision(packageLockPaths, repositoryUrl!); + ValidateRemotePackageSource(repositoryUrl!, resolvedRevision, packageLockPaths); + } + + private void ValidateResolvedProjectInput( + string repositoryRoot, + string candidate, + PbxObject item, + string metadataPath, + IReadOnlyCollection generatedOutputPaths, + ISet buildFileReferences, + ShippingSourceOwnership shippingSources) + { + EnsurePathWithinRepository(repositoryRoot, candidate, $"Xcode {item.Isa} input"); + var directoryFileReferenceIsBuilt = + item.Isa.Equals("PBXFileReference", StringComparison.OrdinalIgnoreCase) && + Directory.Exists(candidate) && + buildFileReferences.Contains(item.Id); + var concreteFileReference = !item.Isa.Equals("PBXFileReference", StringComparison.OrdinalIgnoreCase) || + File.Exists(candidate) || + directoryFileReferenceIsBuilt; + if (concreteFileReference && + !item.Isa.Equals("PBXGroup", StringComparison.OrdinalIgnoreCase) && + !item.Isa.Equals("PBXVariantGroup", StringComparison.OrdinalIgnoreCase)) + { + EnsureNoGeneratedOutputOverlap(candidate, generatedOutputPaths, $"Xcode {item.Isa} input"); + } + var isShippingSource = shippingSources.FileReferences.ContainsKey(item.Id) || + shippingSources.SynchronizedRoots.Contains(item.Id); + if (File.Exists(candidate)) + { + var effectiveSourceExtension = isShippingSource + ? shippingSources.ResolveEffectiveExtension(item.Id, candidate, item, metadataPath) + : null; + EnsureTrackedFile( + repositoryRoot, + candidate, + $"Xcode {item.Isa} input", + validateSwiftDeterminism: effectiveSourceExtension?.Equals(".swift", StringComparison.OrdinalIgnoreCase) == true, + effectiveSourceExtension: effectiveSourceExtension, + assemblerWorkingDirectory: Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!); + } + else if (Directory.Exists(candidate)) + { + if (directoryFileReferenceIsBuilt || + item.Isa.Equals("XCVersionGroup", StringComparison.OrdinalIgnoreCase) || + item.Isa.Equals("PBXFileSystemSynchronizedRootGroup", StringComparison.OrdinalIgnoreCase)) + { + EnsureTrackedDirectoryTree( + repositoryRoot, + candidate, + $"Xcode {item.Isa} input", + validateSwiftDeterminism: isShippingSource, + assemblerWorkingDirectory: Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!); + } + else + { + EnsureNoLinkedTraversal(repositoryRoot, candidate, $"Xcode {item.Isa} input"); + } + } + else if (buildFileReferences.Contains(item.Id) || + Path.IsPathRooted(item.Path ?? string.Empty) || + (item.Path ?? string.Empty).Split('/', '\\').Any(segment => segment == "..")) + { + throw new FileNotFoundException( + $"Xcode project references a missing explicit path that cannot be proven: {candidate} ({metadataPath})", + candidate); + } + } + + private void EnsureTrackedDirectoryTree( + string repositoryRoot, + string path, + string name, + bool validateSwiftDeterminism = false, + string? assemblerWorkingDirectory = null) + { + EnsureDirectoryWithinRepository(repositoryRoot, path, name); + var relativeRoot = FrameworkCompatibility.GetRelativePath(repositoryRoot, path).Replace('\\', '/'); + var indexEntries = RunGit(repositoryRoot, "ls-files", "-v", "-z", "--", relativeRoot) + .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries); + var hiddenEntry = indexEntries.FirstOrDefault(HasHiddenGitIndexState); + if (hiddenEntry is not null) + { + throw new InvalidOperationException( + $"{name} contains a skip-worktree or assume-unchanged Git index entry and cannot be attested: {hiddenEntry.Substring(2)}"); + } + var tracked = indexEntries + .Where(static entry => entry.Length > 2 && entry[1] == ' ') + .Select(entry => Path.GetFullPath(Path.Combine(repositoryRoot, entry.Substring(2)))) + .ToHashSet(GetPathComparer()); + var headBlobs = ReadHeadTreeBlobIds(repositoryRoot, relativeRoot); + foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories)) + { + if ((File.GetAttributes(entry) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException($"{name} must not contain a symbolic link or reparse point: {entry}"); + if (File.Exists(entry) && !tracked.Contains(Path.GetFullPath(entry))) + { + throw new InvalidOperationException( + $"{name} must be tracked at the exact source commit: " + + FrameworkCompatibility.GetRelativePath(repositoryRoot, entry).Replace('\\', '/')); + } + if (File.Exists(entry)) + { + var fullPath = Path.GetFullPath(entry); + var relativePath = FrameworkCompatibility.GetRelativePath(repositoryRoot, fullPath).Replace('\\', '/'); + EnsureNoCustomGitFilter(repositoryRoot, relativePath, name); + var worktreeBlob = ComputeRawGitBlobId(repositoryRoot, fullPath); + if (!headBlobs.TryGetValue(fullPath, out var expectedBlob)) + { + throw new InvalidOperationException( + $"{name} differs from the exact source commit: " + + relativePath); + } + if (!expectedBlob.Equals(worktreeBlob, StringComparison.OrdinalIgnoreCase) && + !expectedBlob.Equals( + ComputePathAwareGitBlobId(repositoryRoot, fullPath, relativePath), + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{name} differs from the exact source commit: " + + relativePath); + } + ValidateSourceLevelIncludes( + repositoryRoot, + fullPath, + validateSwiftDeterminism, + worktreeBlob, + assemblerWorkingDirectory: assemblerWorkingDirectory); + } + } + } + + private static string ResolveBuildSettingPath(string projectDirectory, string value, string key) + { + var expanded = value.Trim(); + if (Path.IsPathRooted(expanded)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} must resolve inside the repository for exact-source snapshot builds; absolute paths are not accepted: {value}"); + } + foreach (var variable in new[] { "$(SRCROOT)", "$(PROJECT_DIR)", "$(SOURCE_ROOT)", "${SRCROOT}", "${PROJECT_DIR}", "${SOURCE_ROOT}" }) + expanded = expanded.Replace(variable, projectDirectory); + expanded = expanded.Replace("$(inherited)", string.Empty).Replace("$(INHERITED)", string.Empty).Trim(); + if (expanded.Contains("$(", StringComparison.Ordinal) || expanded.Contains("${", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Variable-based Xcode build setting {key} cannot be proven for an exact-source checkpoint: {value}"); + } + return ResolvePath(projectDirectory, expanded); + } + + private static string[] SplitBuildSettingPaths(string value) + { + var normalized = value.Trim(); + if (normalized.StartsWith("(", StringComparison.Ordinal) && normalized.EndsWith(")", StringComparison.Ordinal)) + normalized = normalized.Substring(1, normalized.Length - 2); + return Regex.Matches(normalized, "\"(?(?:\\\\.|[^\"])*)\"|(?[^,\\s]+)", RegexOptions.CultureInvariant) + .Cast() + .Select(match => match.Groups["quoted"].Success + ? UnescapePbxString(match.Groups["quoted"].Value) + : match.Groups["bare"].Value.Trim()) + .Where(static value => !string.IsNullOrWhiteSpace(value) && + !value.Equals("$(inherited)", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + private static bool IsPathBearingPbxObject(string isa) + => isa.Equals("PBXGroup", StringComparison.OrdinalIgnoreCase) || + isa.Equals("PBXVariantGroup", StringComparison.OrdinalIgnoreCase) || + isa.Equals("XCVersionGroup", StringComparison.OrdinalIgnoreCase) || + isa.Equals("PBXFileReference", StringComparison.OrdinalIgnoreCase) || + isa.Equals("PBXFileSystemSynchronizedRootGroup", StringComparison.OrdinalIgnoreCase); + + private static Dictionary BuildPbxParentMap(IReadOnlyDictionary objects) + { + var parents = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var group in objects.Values.Where(item => + item.Isa.Equals("PBXGroup", StringComparison.OrdinalIgnoreCase) || + item.Isa.Equals("PBXVariantGroup", StringComparison.OrdinalIgnoreCase) || + item.Isa.Equals("XCVersionGroup", StringComparison.OrdinalIgnoreCase) || + item.Isa.Equals("PBXFileSystemSynchronizedRootGroup", StringComparison.OrdinalIgnoreCase))) + { + foreach (var child in ReadPbxReferences(group.Body, "children")) + { + if (parents.TryGetValue(child, out var existing) && !existing.Equals(group.Id, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Xcode object '{child}' has ambiguous PBX group ancestry."); + parents[child] = group.Id; + } + } + return parents; + } + + private static string? ResolvePbxObjectPath( + string projectDirectory, + string objectId, + IReadOnlyDictionary objects, + IReadOnlyDictionary parents, + IDictionary cache, + ISet resolving) + { + if (cache.TryGetValue(objectId, out var cached)) + return cached; + if (!objects.TryGetValue(objectId, out var item)) + throw new InvalidOperationException($"Xcode project references unknown PBX object '{objectId}'."); + if (!resolving.Add(objectId)) + throw new InvalidOperationException($"Xcode PBX group ancestry contains a cycle at '{objectId}'."); + + var sourceTree = string.IsNullOrWhiteSpace(item.SourceTree) ? "" : item.SourceTree!; + if (ExternalXcodeSourceTrees.Contains(sourceTree)) + { + cache[objectId] = null; + resolving.Remove(objectId); + return null; + } + + string basePath; + if (sourceTree.Equals("SOURCE_ROOT", StringComparison.OrdinalIgnoreCase)) + { + basePath = projectDirectory; + } + else if (sourceTree.Equals("", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(item.Path) || !Path.IsPathRooted(item.Path)) + throw new InvalidOperationException($"Absolute Xcode input '{item.Id}' does not contain an absolute path."); + basePath = Path.GetPathRoot(item.Path!)!; + } + else if (sourceTree.Equals("", StringComparison.OrdinalIgnoreCase)) + { + basePath = parents.TryGetValue(objectId, out var parentId) + ? ResolvePbxObjectPath(projectDirectory, parentId, objects, parents, cache, resolving) ?? projectDirectory + : projectDirectory; + } + else + { + throw new InvalidOperationException($"Unsupported Xcode sourceTree '{sourceTree}' in exact-source project metadata."); + } + + var resolved = string.IsNullOrWhiteSpace(item.Path) + ? Path.GetFullPath(basePath) + : ResolvePbxPath(basePath, item.Path!, item.Isa); + cache[objectId] = resolved; + resolving.Remove(objectId); + return resolved; + } + + private static string ResolvePbxPath(string basePath, string value, string context) + { + if (value.Contains("$(", StringComparison.Ordinal) || value.Contains("${", StringComparison.Ordinal)) + throw new InvalidOperationException($"Variable-based Xcode {context} path cannot be proven for an exact-source checkpoint: {value}"); + return ResolvePath(basePath, value); + } + + private static string[] ResolveObjectAwareSynchronizedRoots(IEnumerable metadataPaths) + { + var roots = new HashSet(GetPathComparer()); + foreach (var metadataPath in metadataPaths.Where(path => + path.EndsWith("project.pbxproj", StringComparison.OrdinalIgnoreCase) && File.Exists(path))) + { + var projectDirectory = Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!; + var objects = ParsePbxObjects(File.ReadAllText(metadataPath)); + var parents = BuildPbxParentMap(objects); + var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in objects.Values.Where(value => + value.Isa.Equals("PBXFileSystemSynchronizedRootGroup", StringComparison.OrdinalIgnoreCase))) + { + var path = ResolvePbxObjectPath( + projectDirectory, + item.Id, + objects, + parents, + cache, + new HashSet(StringComparer.OrdinalIgnoreCase)); + if (path is not null) + roots.Add(path); + } + } + return roots.ToArray(); + } + +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeConfigurations.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeConfigurations.cs new file mode 100644 index 000000000..5c0bffafa --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeConfigurations.cs @@ -0,0 +1,197 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void ValidateBuildConfiguration( + string repositoryRoot, + string projectDirectory, + PbxObject item, + IReadOnlyDictionary objects, + IReadOnlyDictionary parents, + IDictionary cache, + string metadataPath, + IReadOnlyCollection generatedOutputPaths) + { + var buildSettings = ReadPbxDictionary(item.Body, "buildSettings"); + var buildSettingAssignments = buildSettings is null + ? Array.Empty>() + : ReadPbxAssignments(buildSettings).ToArray(); + var baseConfigurationReference = ReadPbxScalar(item.Body, "baseConfigurationReference")? + .Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(baseConfigurationReference)) + { + var baseConfigurationPath = ResolvePbxObjectPath( + projectDirectory, + baseConfigurationReference!, + objects, + parents, + cache, + new HashSet(StringComparer.OrdinalIgnoreCase)); + if (baseConfigurationPath is null) + throw new InvalidOperationException($"Xcode base configuration uses an external source tree: {metadataPath}"); + EnsureNoGeneratedOutputOverlap(baseConfigurationPath, generatedOutputPaths, "Xcode base configuration"); + EnsureTrackedFile(repositoryRoot, baseConfigurationPath, "Xcode base configuration"); + var basePreprocess = ResolveInfoPlistPreprocessFromXcconfigGraph( + repositoryRoot, + baseConfigurationPath, + new HashSet(GetPathComparer())); + var projectPreprocess = ResolveInfoPlistPreprocessSetting(buildSettingAssignments); + var effectivePreprocess = projectPreprocess ?? basePreprocess ?? false; + EnsureTrackedXcconfigGraph( + repositoryRoot, + projectDirectory, + baseConfigurationPath, + generatedOutputPaths, + new HashSet(GetPathComparer()), + effectivePreprocess); + if (buildSettings is not null) + { + ValidateBuildSettingAssignments( + repositoryRoot, + projectDirectory, + buildSettingAssignments, + generatedOutputPaths, + "PBX build settings", + effectivePreprocess); + } + return; + } + + if (buildSettings is null) + return; + ValidateBuildSettingAssignments( + repositoryRoot, + projectDirectory, + buildSettingAssignments, + generatedOutputPaths, + "PBX build settings"); + } + + private void EnsureTrackedXcconfigGraph( + string repositoryRoot, + string projectDirectory, + string configPath, + IReadOnlyCollection generatedOutputPaths, + ISet visited, + bool? effectiveInfoPlistPreprocess = null) + { + var fullPath = Path.GetFullPath(configPath); + if (!visited.Add(fullPath)) + return; + var contents = File.ReadAllText(fullPath); + ValidateBuildSettingAssignments( + repositoryRoot, + projectDirectory, + ReadXcconfigAssignments(contents), + generatedOutputPaths, + $"xcconfig '{fullPath}'", + effectiveInfoPlistPreprocess); + foreach (Match include in Regex.Matches( + contents, + "(?m)^[ \\t]*#include(?\\?)?[ \\t]+[\\\"<](?[^\\\">]+)[\\\">]", + RegexOptions.CultureInvariant)) + { + var value = include.Groups["path"].Value.Trim(); + var includedPath = ResolvePbxPath( + Path.GetDirectoryName(fullPath)!, + value, + "xcconfig include"); + EnsurePathWithinRepository(repositoryRoot, includedPath, "Xcode xcconfig include"); + EnsureNoGeneratedOutputOverlap(includedPath, generatedOutputPaths, "Xcode xcconfig include"); + if (!File.Exists(includedPath)) + { + if (include.Groups["optional"].Success) + continue; + throw new FileNotFoundException( + $"Xcode xcconfig include cannot be proven at the exact source commit: {includedPath}", + includedPath); + } + EnsureTrackedFile(repositoryRoot, includedPath, "Xcode xcconfig include"); + EnsureTrackedXcconfigGraph( + repositoryRoot, + projectDirectory, + includedPath, + generatedOutputPaths, + visited, + effectiveInfoPlistPreprocess); + } + } + + private static bool? ResolveInfoPlistPreprocessFromXcconfigGraph( + string repositoryRoot, + string configPath, + ISet visited, + bool? inherited = null) + { + var fullPath = Path.GetFullPath(configPath); + if (!visited.Add(fullPath)) + throw new InvalidOperationException($"Xcode xcconfig include graph contains a cycle at '{fullPath}'."); + try + { + var contents = Regex.Replace(File.ReadAllText(fullPath), "\\\\[ \\t]*\\r?\\n", " "); + var enabled = inherited; + var conditionedEnabled = false; + foreach (var line in contents.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) + { + var include = Regex.Match( + line, + "^[ \\t]*#include(?\\?)?[ \\t]+[\\\"<](?[^\\\">]+)[\\\">]", + RegexOptions.CultureInvariant); + if (include.Success) + { + var includedPath = ResolvePbxPath( + Path.GetDirectoryName(fullPath)!, + include.Groups["path"].Value.Trim(), + "xcconfig include"); + EnsurePathWithinRepository(repositoryRoot, includedPath, "Xcode xcconfig include"); + if (File.Exists(includedPath)) + { + var included = ResolveInfoPlistPreprocessFromXcconfigGraph( + repositoryRoot, + includedPath, + visited, + enabled); + if (included is not null) + enabled = included; + } + continue; + } + + var assignment = Regex.Match( + line, + "^[ \\t]*(?INFOPLIST_PREPROCESS(?:\\[[^\\]]+\\])*)[ \\t]*(?\\?=|\\+=|=)[ \\t]*(?.*?)[ \\t]*(?://.*)?$", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + if (!assignment.Success) + continue; + var key = assignment.Groups["key"].Value; + var value = assignment.Groups["value"].Value.Trim(); + if (!value.Equals("YES", StringComparison.OrdinalIgnoreCase) && + !value.Equals("NO", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} must be YES or NO for an exact-source Apple build; received '{value}'."); + } + if (assignment.Groups["op"].Value.Equals("+=", StringComparison.Ordinal)) + throw new InvalidOperationException($"Xcode build setting {key} cannot use '+=' for a boolean exact-source setting."); + if (key.IndexOf('[') >= 0) + { + conditionedEnabled |= value.Equals("YES", StringComparison.OrdinalIgnoreCase); + continue; + } + if (assignment.Groups["op"].Value.Equals("?=", StringComparison.Ordinal) && enabled is not null) + continue; + enabled = value.Equals("YES", StringComparison.OrdinalIgnoreCase); + } + return conditionedEnabled || enabled == true + ? true + : enabled; + } + finally + { + visited.Remove(fullPath); + } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs new file mode 100644 index 000000000..e642a3b53 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs @@ -0,0 +1,41 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static void ValidateTrustedAppleToolExecutables(PowerForgeAppleReleaseOptions options) + { + ValidateTrustedAppleToolExecutable( + options.XcodeBuildExecutable, + "xcodebuild", + "/usr/bin/xcodebuild"); + ValidateTrustedAppleToolExecutable( + options.DirectDistribution.XcrunExecutable, + "xcrun", + "/usr/bin/xcrun"); + ValidateTrustedAppleToolExecutable( + options.DirectDistribution.DittoExecutable, + "ditto", + "/usr/bin/ditto"); + ValidateTrustedAppleToolExecutable( + options.DirectDistribution.SpctlExecutable, + "spctl", + "/usr/sbin/spctl"); + } + + private static void ValidateTrustedAppleToolExecutable( + string? executable, + string defaultName, + string trustedPath) + { + var value = executable?.Trim(); + if (string.IsNullOrWhiteSpace(value) || + string.Equals(value, defaultName, StringComparison.Ordinal) || + string.Equals(value, trustedPath, StringComparison.Ordinal)) + { + return; + } + + throw new InvalidOperationException( + $"Exact-source Apple checkpoints require the trusted system tool '{trustedPath}'; configured executable '{value}' is not trusted."); + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs new file mode 100644 index 000000000..ba986f894 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -0,0 +1,684 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void ValidateBuildSettingAssignments( + string repositoryRoot, + string projectDirectory, + IEnumerable> assignments, + IReadOnlyCollection generatedOutputPaths, + string source, + bool? effectiveInfoPlistPreprocess = null) + { + var assignmentArray = assignments.ToArray(); + var preprocessInfoPlist = effectiveInfoPlistPreprocess ?? + ResolveInfoPlistPreprocessSetting(assignmentArray) == true; + foreach (var assignment in assignmentArray) + { + var key = assignment.Key.Trim(); + var baseKey = key.Split('[')[0].Trim(); + if (ExecutableBuildSettings.Contains(baseKey) && + !string.IsNullOrWhiteSpace(assignment.Value) && + !assignment.Value.Trim().Equals("$(inherited)", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} overrides a compiler or build executable and cannot be proven at the exact source commit: {source}"); + } + if (SdkSelectionBuildSettings.Contains(baseKey)) + { + ValidateSdkSelectionBuildSetting(key, assignment.Value, source); + continue; + } + if (DefinitionBuildSettings.Contains(baseKey)) + { + foreach (var definition in SplitBuildSettingPaths(assignment.Value)) + ValidatePreprocessorFlagPayload(definition, key); + continue; + } + if (SourceSelectionBuildSettings.Contains(baseKey)) + { + ValidateSourceSelectionBuildSetting(key, assignment.Value, source); + continue; + } + IEnumerable values; + if (FileValuedBuildSettings.Contains(baseKey) || + SearchPathBuildSettings.Contains(baseKey) || + baseKey.StartsWith("SCRIPT_INPUT_FILE_", StringComparison.OrdinalIgnoreCase) || + baseKey.StartsWith("SCRIPT_INPUT_FILE_LIST_", StringComparison.OrdinalIgnoreCase)) + { + values = SplitBuildSettingPaths(assignment.Value); + } + else if (FlagBuildSettings.Contains(baseKey)) + { + if (baseKey.Equals("INFOPLIST_OTHER_PREPROCESSOR_FLAGS", StringComparison.OrdinalIgnoreCase) && + !preprocessInfoPlist) + { + ValidateUnclassifiedBuildSettingReferences(key, assignment.Value, source); + continue; + } + ValidateBuildFlagInputPaths( + repositoryRoot, + projectDirectory, + assignment.Value, + key, + generatedOutputPaths, + source, + new HashSet(GetPathComparer())); + continue; + } + else + { + ValidateUnclassifiedBuildSettingReferences(key, assignment.Value, source); + continue; + } + + foreach (var rawValue in values) + { + var value = rawValue.Trim().TrimEnd('/'); + while (value.EndsWith("/**", StringComparison.Ordinal)) + value = value.Substring(0, value.Length - 3).TrimEnd('/'); + if (string.IsNullOrWhiteSpace(value) || + IsValidatedToolchainOrBuildProductPath(value, key, source)) + continue; + var candidate = ResolveBuildSettingPath(projectDirectory, value, key); + EnsurePathWithinRepository(repositoryRoot, candidate, $"Xcode build setting {key} from {source}"); + EnsureNoGeneratedOutputOverlap(candidate, generatedOutputPaths, $"Xcode build setting {key}"); + RejectHeaderMapInput(candidate, key); + if (File.Exists(candidate)) + { + EnsureTrackedFile(repositoryRoot, candidate, $"Xcode build setting {key}"); + if (baseKey.Equals("INFOPLIST_FILE", StringComparison.OrdinalIgnoreCase)) + ValidateInfoPlistBuildSettingReferences(repositoryRoot, candidate, source, preprocessInfoPlist); + else if (baseKey.Equals("CODE_SIGN_ENTITLEMENTS", StringComparison.OrdinalIgnoreCase)) + ValidateEntitlementsBuildSettingReferences(candidate, source); + } + else if (Directory.Exists(candidate)) + { + EnsureTrackedDirectoryTree(repositoryRoot, candidate, $"Xcode build setting {key}"); + if (baseKey.Equals("HEADER_SEARCH_PATHS", StringComparison.OrdinalIgnoreCase) || + baseKey.Equals("USER_HEADER_SEARCH_PATHS", StringComparison.OrdinalIgnoreCase) || + baseKey.Equals("SYSTEM_HEADER_SEARCH_PATHS", StringComparison.OrdinalIgnoreCase) || + baseKey.Equals("MTL_HEADER_SEARCH_PATHS", StringComparison.OrdinalIgnoreCase)) + _approvedHeaderSearchRoots.Add(candidate); + } + else + throw new FileNotFoundException( + $"Xcode build setting {key} references a missing exact-source input: {candidate}", + candidate); + } + } + } + + private static bool? ResolveInfoPlistPreprocessSetting( + IEnumerable> assignments) + { + bool? unconditional = null; + var conditionedEnabled = false; + var foundUnconditional = false; + foreach (var assignment in assignments) + { + var key = assignment.Key.Trim(); + if (!key.Split('[')[0].Trim().Equals("INFOPLIST_PREPROCESS", StringComparison.OrdinalIgnoreCase)) + continue; + var value = assignment.Value.Trim(); + if (!value.Equals("YES", StringComparison.OrdinalIgnoreCase) && + !value.Equals("NO", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} must be YES or NO for an exact-source Apple build; received '{assignment.Value}'."); + } + if (key.IndexOf('[') >= 0) + conditionedEnabled |= value.Equals("YES", StringComparison.OrdinalIgnoreCase); + else + { + foundUnconditional = true; + unconditional = value.Equals("YES", StringComparison.OrdinalIgnoreCase); + } + } + if (!foundUnconditional && !conditionedEnabled) + return null; + return conditionedEnabled || unconditional == true; + } + + private static void ValidateUnclassifiedBuildSettingReferences( + string key, + string value, + string source, + ISet? additionalApprovedReferences = null) + { + var approvedReferences = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "inherited", "TARGET_NAME", "PRODUCT_NAME", "EXECUTABLE_NAME", "WRAPPER_NAME", + "FULL_PRODUCT_NAME", "CONTENTS_FOLDER_PATH", "INFOPLIST_PATH", "TEST_HOST" + }; + foreach (var reference in ReadBuildSettingReferences(value, key, source)) + { + if (approvedReferences.Contains(reference.Key) || + additionalApprovedReferences?.Contains(reference.Key) == true) + continue; + throw new InvalidOperationException( + $"Xcode build setting {key} contains unapproved host or environment reference '{reference.Value}' " + + $"and cannot be bound to the exact source commit: {source}"); + } + } + + private static IEnumerable> ReadBuildSettingReferences( + string value, + string key, + string source) + { + for (var index = 0; index + 1 < value.Length; index++) + { + if (value[index] != '$' || (value[index + 1] != '(' && value[index + 1] != '{')) + continue; + var close = value[index + 1] == '(' ? ')' : '}'; + var end = value.IndexOf(close, index + 2); + if (end < 0) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains an unterminated build-setting reference and cannot be proven: {source}"); + } + + var reference = value.Substring(index, end - index + 1); + var payload = value.Substring(index + 2, end - index - 2); + var modifier = payload.IndexOf(':'); + var name = (modifier < 0 ? payload : payload.Substring(0, modifier)).Trim(); + if (!Regex.IsMatch(name, "^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant)) + { + throw new InvalidOperationException( + $"Xcode build setting {key} contains malformed build-setting reference '{reference}' and cannot be proven: {source}"); + } + yield return new KeyValuePair(name, reference); + index = end; + } + } + + private void ValidateInfoPlistBuildSettingReferences( + string repositoryRoot, + string plistPath, + string source, + bool preprocess) + { + var plistReferences = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "DEVELOPMENT_LANGUAGE", "PRODUCT_BUNDLE_IDENTIFIER", "MARKETING_VERSION", + "CURRENT_PROJECT_VERSION", "PRODUCT_MODULE_NAME" + }; + var bytes = File.ReadAllBytes(plistPath); + if (bytes.Length >= 8 && + bytes[0] == (byte)'b' && bytes[1] == (byte)'p' && bytes[2] == (byte)'l' && bytes[3] == (byte)'i' && + bytes[4] == (byte)'s' && bytes[5] == (byte)'t' && bytes[6] == (byte)'0' && bytes[7] == (byte)'0') + { + throw new InvalidOperationException( + $"INFOPLIST_FILE '{plistPath}' uses the binary property-list format, whose semantic string values cannot be inspected " + + $"for exact-source build-setting substitutions. Commit a text property list before creating an Apple checkpoint: {source}"); + } + + var contents = DecodeTrackedText(bytes); + if (preprocess) + { + var logical = RemoveCComments(SpliceCPreprocessingLines(contents)); + RejectPreprocessorFileSelectionAliases( + $"preprocessed INFOPLIST_FILE '{plistPath}'", + MaskCStringAndCharacterLiterals(logical)); + if (Regex.IsMatch( + logical, + "(?m)^[ \\t\\v\\f]*(?:#|%:)[ \\t\\v\\f]*(?:include|include_next|import|embed)(?![A-Za-z0-9_])|(?(StringComparer.OrdinalIgnoreCase) + { + "AppIdentifierPrefix", "TeamIdentifierPrefix", "PRODUCT_BUNDLE_IDENTIFIER" + }; + var bytes = File.ReadAllBytes(entitlementsPath); + if (bytes.Length >= 8 && + bytes[0] == (byte)'b' && bytes[1] == (byte)'p' && bytes[2] == (byte)'l' && bytes[3] == (byte)'i' && + bytes[4] == (byte)'s' && bytes[5] == (byte)'t' && bytes[6] == (byte)'0' && bytes[7] == (byte)'0') + { + throw new InvalidOperationException( + $"CODE_SIGN_ENTITLEMENTS '{entitlementsPath}' uses the binary property-list format, whose semantic string values cannot be inspected " + + $"for exact-source build-setting substitutions. Commit a text property list before creating an Apple checkpoint: {source}"); + } + + ValidateUnclassifiedBuildSettingReferences( + "CODE_SIGN_ENTITLEMENTS contents", + DecodeTrackedText(bytes), + $"{source}; entitlements '{entitlementsPath}'", + approvedReferences); + } + + private static void ValidateSourceSelectionBuildSetting(string key, string value, string source) + { + foreach (var token in SplitBuildSettingPaths(value)) + { + if (token.Equals("$(inherited)", StringComparison.OrdinalIgnoreCase)) + continue; + if (token.Contains("$(", StringComparison.Ordinal) || + token.Contains("${", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Xcode source-selection setting {key} contains an unresolved build-setting or environment reference '{token}' " + + $"and can select different tracked sources on another host: {source}"); + } + } + } + + + private static IEnumerable> ReadXcconfigAssignments(string contents) + { + var logical = Regex.Replace(contents, "\\\\[ \\t]*\\r?\\n", " "); + foreach (Match match in Regex.Matches( + logical, + "(?m)^[ \\t]*(?!#)(?[A-Za-z_][A-Za-z0-9_]*(?:\\[[^\\]]+\\])*)[ \\t]*(?:\\?=|\\+=|=)[ \\t]*(?.*?)[ \\t]*(?://.*)?$", + RegexOptions.CultureInvariant)) + { + yield return new KeyValuePair( + match.Groups["key"].Value, + match.Groups["value"].Value.Trim()); + } + } + + private static void ValidateSdkSelectionBuildSetting(string key, string value, string source) + { + var selector = value.Trim(); + if (string.IsNullOrWhiteSpace(selector) || + selector.Equals("$(inherited)", StringComparison.OrdinalIgnoreCase)) + return; + if (Regex.IsMatch(selector, "^[A-Za-z0-9._*-]+$", RegexOptions.CultureInvariant)) + return; + + throw new InvalidOperationException( + $"Xcode build setting {key} selects a custom SDK path or expression that cannot be proven at the exact source commit: {source}"); + } + + + private static Dictionary ParsePbxObjects(string text) + { + var objects = new Dictionary(StringComparer.OrdinalIgnoreCase); + var syntax = RemovePbxComments(text); + var objectDictionary = ReadRootPbxObjectDictionary(syntax); + foreach (var assignment in ReadPbxAssignments(objectDictionary)) + { + var objectValue = assignment.Value.Trim(); + if (objectValue.Length < 2 || objectValue[0] != '{' || objectValue[objectValue.Length - 1] != '}') + continue; + var id = ParsePbxObjectIdentifier(assignment.Key, "object dictionary key"); + var body = objectValue.Substring(1, objectValue.Length - 2); + var isa = ReadPbxScalar(body, "isa"); + if (!string.IsNullOrWhiteSpace(isa)) + { + if (objects.ContainsKey(id)) + throw new InvalidOperationException($"Xcode project repeats PBX object identifier '{id}'."); + objects.Add(id, new PbxObject + { + Id = id, + Isa = isa!, + Path = ReadPbxScalar(body, "path"), + SourceTree = ReadPbxScalar(body, "sourceTree"), + Body = body + }); + } + } + return objects; + } + + private static string ReadRootPbxObjectDictionary(string syntax) + { + var rootStart = SkipPbxTrivia(syntax, 0); + if (rootStart >= syntax.Length || syntax[rootStart] != '{') + return syntax; + + var rootEnd = FindMatchingPbxBrace(syntax, rootStart); + var rootBody = syntax.Substring(rootStart + 1, rootEnd - rootStart - 1); + var objectAssignments = ReadPbxAssignments(rootBody) + .Where(static assignment => assignment.Key.Equals("objects", StringComparison.Ordinal)) + .Select(static assignment => assignment.Value) + .ToArray(); + if (objectAssignments.Length != 1 || + objectAssignments[0].Length < 2 || + objectAssignments[0][0] != '{' || + objectAssignments[0][objectAssignments[0].Length - 1] != '}') + { + throw new InvalidOperationException( + "Xcode project root does not contain one unambiguous top-level objects dictionary."); + } + var objects = objectAssignments[0]; + return objects.Substring(1, objects.Length - 2); + } + + /// + /// Removes OpenStep comments while retaining string contents and character positions closely enough + /// for the PBX scanners to parse only syntax that Xcode itself observes. + /// + private static string RemovePbxComments(string text) + { + var result = text.ToCharArray(); + var inString = false; + var escaped = false; + for (var index = 0; index < result.Length; index++) + { + var current = result[index]; + var next = index + 1 < result.Length ? result[index + 1] : '\0'; + if (inString) + { + if (escaped) + escaped = false; + else if (current == '\\') + escaped = true; + else if (current == '"') + inString = false; + continue; + } + if (current == '"') + { + inString = true; + continue; + } + if (current == '/' && next == '*') + { + result[index] = result[index + 1] = ' '; + index += 2; + while (index < result.Length) + { + if (index + 1 < result.Length && result[index] == '*' && result[index + 1] == '/') + { + result[index] = result[index + 1] = ' '; + index++; + break; + } + if (result[index] != '\r' && result[index] != '\n') + result[index] = ' '; + index++; + } + if (index >= result.Length) + throw new InvalidOperationException("Xcode project contains an unterminated PBX comment."); + continue; + } + if (current == '/' && next == '/') + { + result[index] = result[index + 1] = ' '; + index += 2; + while (index < result.Length && result[index] != '\r' && result[index] != '\n') + { + result[index] = ' '; + index++; + } + } + } + return new string(result); + } + + private static bool IsHexCharacter(char value) + => value is >= '0' and <= '9' or >= 'A' and <= 'F' or >= 'a' and <= 'f'; + + private static string ParsePbxObjectIdentifier(string value, string context) + { + var identifier = value.Trim(); + if (identifier.Length >= 2 && identifier[0] == '"' && identifier[identifier.Length - 1] == '"') + identifier = UnescapePbxString(identifier.Substring(1, identifier.Length - 2)); + if (identifier.Length < 8 || identifier.Length > 32 || identifier.Any(character => !IsHexCharacter(character))) + throw new InvalidOperationException($"Xcode project contains an invalid PBX {context}: '{value}'."); + return identifier; + } + + private static int SkipPbxTrivia(string text, int index) + { + while (index < text.Length) + { + if (char.IsWhiteSpace(text[index]) || text[index] == ';' || text[index] == ',') + { + index++; + continue; + } + if (index + 1 < text.Length && text[index] == '/' && text[index + 1] == '*') + { + var end = text.IndexOf("*/", index + 2, StringComparison.Ordinal); + if (end < 0) + throw new InvalidOperationException("Xcode project contains an unterminated PBX comment."); + index = end + 2; + continue; + } + if (index + 1 < text.Length && text[index] == '/' && text[index + 1] == '/') + { + var end = text.IndexOf('\n', index + 2); + index = end < 0 ? text.Length : end + 1; + continue; + } + break; + } + return index; + } + + private static int FindMatchingPbxBrace(string text, int openingBrace) + { + var depth = 0; + var inString = false; + var escaped = false; + var inLineComment = false; + var inBlockComment = false; + for (var index = openingBrace; index < text.Length; index++) + { + var current = text[index]; + var next = index + 1 < text.Length ? text[index + 1] : '\0'; + if (inLineComment) + { + if (current == '\n') inLineComment = false; + continue; + } + if (inBlockComment) + { + if (current == '*' && next == '/') + { + inBlockComment = false; + index++; + } + continue; + } + if (inString) + { + if (escaped) escaped = false; + else if (current == '\\') escaped = true; + else if (current == '"') inString = false; + continue; + } + if (current == '/' && next == '/') + { + inLineComment = true; + index++; + } + else if (current == '/' && next == '*') + { + inBlockComment = true; + index++; + } + else if (current == '"') inString = true; + else if (current == '{') depth++; + else if (current == '}' && --depth == 0) return index; + } + throw new InvalidOperationException("Xcode project contains an unterminated PBX object."); + } + + private static string? ReadPbxScalar(string body, string name) + { + return ReadPbxAssignmentValue(body, name); + } + + private static string? ReadPbxDictionary(string body, string name) + { + var value = ReadPbxAssignmentValue(body, name); + if (value is null) + return null; + if (value.Length < 2 || value[0] != '{' || value[value.Length - 1] != '}') + throw new InvalidOperationException($"Xcode PBX property '{name}' must be a dictionary."); + return value.Substring(1, value.Length - 2); + } + + private static string? ReadPbxAssignmentValue(string body, string name) + { + var values = ReadPbxAssignments(body) + .Where(assignment => assignment.Key.Equals(name, StringComparison.Ordinal)) + .Select(static assignment => assignment.Value) + .ToArray(); + if (values.Length > 1) + throw new InvalidOperationException($"Xcode PBX property '{name}' is declared more than once."); + return values.Length == 0 ? null : values[0]; + } + + private static IEnumerable> ReadPbxAssignments(string body) + { + for (var index = 0; index < body.Length; index++) + { + index = SkipPbxTrivia(body, index); + if (index >= body.Length) + yield break; + + var keyStart = index; + var inString = false; + var escaped = false; + while (index < body.Length) + { + var current = body[index]; + if (inString) + { + if (escaped) escaped = false; + else if (current == '\\') escaped = true; + else if (current == '"') inString = false; + } + else if (current == '"') inString = true; + else if (current == '=') break; + else if (current == ';') break; + index++; + } + if (index >= body.Length || body[index] != '=') + continue; + + var key = body.Substring(keyStart, index - keyStart).Trim(); + if (key.Length >= 2 && key[0] == '"' && key[key.Length - 1] == '"') + key = UnescapePbxString(key.Substring(1, key.Length - 2)); + var valueStart = ++index; + var parentheses = 0; + var braces = 0; + inString = false; + escaped = false; + var inLineComment = false; + var inBlockComment = false; + while (index < body.Length) + { + var current = body[index]; + var next = index + 1 < body.Length ? body[index + 1] : '\0'; + if (inLineComment) + { + if (current == '\n') inLineComment = false; + } + else if (inBlockComment) + { + if (current == '*' && next == '/') + { + inBlockComment = false; + index++; + } + } + else if (inString) + { + if (escaped) escaped = false; + else if (current == '\\') escaped = true; + else if (current == '"') inString = false; + } + else if (current == '/' && next == '/') + { + inLineComment = true; + index++; + } + else if (current == '/' && next == '*') + { + inBlockComment = true; + index++; + } + else if (current == '"') inString = true; + else if (current == '(') parentheses++; + else if (current == ')') parentheses--; + else if (current == '{') braces++; + else if (current == '}') braces--; + else if (current == ';' && parentheses == 0 && braces == 0) break; + index++; + } + if (index >= body.Length) + throw new InvalidOperationException($"Xcode PBX assignment '{key}' is not terminated."); + + var value = body.Substring(valueStart, index - valueStart).Trim(); + if (value.Length >= 2 && value[0] == '"' && value[value.Length - 1] == '"') + value = UnescapePbxString(value.Substring(1, value.Length - 2)); + yield return new KeyValuePair(key, value); + } + } + + private static string[] ReadPbxReferences(string body, string name) + { + var value = ReadPbxAssignmentValue(body, name); + if (value is null) + return Array.Empty(); + if (value.Length < 2 || value[0] != '(' || value[value.Length - 1] != ')') + throw new InvalidOperationException($"Xcode PBX property '{name}' must be a reference list."); + return value.Substring(1, value.Length - 2) + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(value => ParsePbxObjectIdentifier(value, $"reference in {name}")) + .ToArray(); + } + + private static string UnescapePbxString(string value) + { + var builder = new StringBuilder(value.Length); + var escaped = false; + foreach (var character in value) + { + if (escaped) + { + builder.Append(character); + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else + { + builder.Append(character); + } + } + if (escaped) builder.Append('\\'); + return builder.ToString(); + } + + private sealed class PbxObject + { + internal string Id { get; set; } = string.Empty; + + internal string Isa { get; set; } = string.Empty; + + internal string? Path { get; set; } + + internal string? SourceTree { get; set; } + + internal string Body { get; set; } = string.Empty; + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs new file mode 100644 index 000000000..57d4f53cf --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs @@ -0,0 +1,188 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private ShippingSourceOwnership ResolveShippingSourceOwnership( + string repositoryRoot, + string projectDirectory, + IReadOnlyDictionary objects, + string metadataPath, + IReadOnlyCollection generatedOutputPaths) + { + var sourcePhaseIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var synchronizedRoots = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var target in objects.Values.Where(static value => + value.Isa.Equals("PBXNativeTarget", StringComparison.OrdinalIgnoreCase))) + { + var productType = ReadPbxScalar(target.Body, "productType")?.Trim(); + if (string.IsNullOrWhiteSpace(productType)) + throw new InvalidOperationException($"PBX native target '{target.Id}' is missing productType: {metadataPath}"); + if (IsTestProductType(productType!)) + continue; + + foreach (var phaseId in ReadPbxReferences(target.Body, "buildPhases")) + { + if (!objects.TryGetValue(phaseId, out var phase)) + throw new InvalidOperationException($"PBX native target '{target.Id}' references unknown build phase '{phaseId}': {metadataPath}"); + if (phase.Isa.Equals("PBXSourcesBuildPhase", StringComparison.OrdinalIgnoreCase)) + sourcePhaseIds.Add(phaseId); + } + foreach (var rootId in ReadPbxReferences(target.Body, "fileSystemSynchronizedGroups")) + { + if (!objects.TryGetValue(rootId, out var root) || + !root.Isa.Equals("PBXFileSystemSynchronizedRootGroup", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"PBX native target '{target.Id}' references invalid synchronized source root '{rootId}': {metadataPath}"); + } + synchronizedRoots.Add(rootId); + } + } + + var fileReferences = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var phaseId in sourcePhaseIds) + { + var phase = objects[phaseId]; + foreach (var buildFileId in ReadPbxReferences(phase.Body, "files")) + { + if (!objects.TryGetValue(buildFileId, out var buildFile) || + !buildFile.Isa.Equals("PBXBuildFile", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"PBX sources phase '{phaseId}' references invalid build file '{buildFileId}': {metadataPath}"); + } + + var fileReferenceValue = ReadPbxScalar(buildFile.Body, "fileRef")?.Trim(); + if (string.IsNullOrWhiteSpace(fileReferenceValue)) + { + throw new InvalidOperationException($"PBX sources build file '{buildFileId}' is missing fileRef: {metadataPath}"); + } + var fileReference = ParsePbxObjectIdentifier( + fileReferenceValue!, + $"source file reference in build file {buildFileId}"); + if (!objects.TryGetValue(fileReference, out var source) || + !source.Isa.Equals("PBXFileReference", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"PBX sources build file '{buildFileId}' references invalid source file '{fileReference}': {metadataPath}"); + } + var effectiveExtension = ResolveShippingSourceExtension( + repositoryRoot, + projectDirectory, + source, + buildFile, + metadataPath, + generatedOutputPaths); + if (fileReferences.TryGetValue(fileReference, out var priorExtension) && + !string.Equals(priorExtension, effectiveExtension, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Shipping source '{fileReference}' is compiled with conflicting effective languages in {metadataPath}."); + } + fileReferences[fileReference] = effectiveExtension; + } + } + + return new ShippingSourceOwnership(fileReferences, synchronizedRoots); + } + + private string? ResolveShippingSourceExtension( + string repositoryRoot, + string projectDirectory, + PbxObject source, + PbxObject buildFile, + string metadataPath, + IReadOnlyCollection generatedOutputPaths) + { + string? language = null; + var settings = ReadPbxDictionary(buildFile.Body, "settings"); + if (settings is not null) + { + var compilerFlags = ReadPbxAssignments(settings) + .Where(static assignment => assignment.Key.Equals("COMPILER_FLAGS", StringComparison.OrdinalIgnoreCase)) + .Select(static assignment => assignment.Value) + .ToArray(); + if (compilerFlags.Length > 1) + throw new InvalidOperationException($"PBXBuildFile '{buildFile.Id}' repeats COMPILER_FLAGS: {metadataPath}"); + if (compilerFlags.Length == 1) + { + var tokens = ExpandCompilerResponseFileTokens( + repositoryRoot, + projectDirectory, + ExpandForwardedBuildFlagTokens(SplitBuildSettingPaths(compilerFlags[0]).ToArray(), "COMPILER_FLAGS"), + "COMPILER_FLAGS", + generatedOutputPaths, + $"PBXBuildFile '{buildFile.Id}' in {metadataPath}", + new HashSet(GetPathComparer())) + .ToArray(); + TryReadCompilerLanguageOverride(tokens, out language); + } + } + + if (string.Equals(language, "none", StringComparison.OrdinalIgnoreCase)) + language = null; + var mapped = language is null + ? MapPbxSourceType(ReadPbxScalar(source.Body, "explicitFileType") ?? ReadPbxScalar(source.Body, "lastKnownFileType")) + : MapCompilerLanguage(language); + return mapped; + } + + private static string? MapPbxSourceType(string? fileType) + => fileType?.Trim() switch + { + "sourcecode.c.c" => ".c", + "sourcecode.c.objc" => ".m", + "sourcecode.cpp.cpp" => ".cpp", + "sourcecode.cpp.objcpp" => ".mm", + "sourcecode.asm" => ".s", + "sourcecode.metal" => ".metal", + "sourcecode.swift" => ".swift", + _ => null + }; + + private static string MapCompilerLanguage(string language) + => language.Trim().ToLowerInvariant() switch + { + "c" or "c-header" => ".c", + "objective-c" or "objective-c-header" => ".m", + "c++" or "c++-header" => ".cpp", + "objective-c++" or "objective-c++-header" => ".mm", + "assembler" or "assembler-with-cpp" => ".s", + "metal" => ".metal", + _ => throw new InvalidOperationException( + $"PBX per-file compiler language '{language}' is not supported by exact-source Apple validation.") + }; + + private static bool IsTestProductType(string productType) + => productType.Equals("com.apple.product-type.bundle.unit-test", StringComparison.OrdinalIgnoreCase) || + productType.Equals("com.apple.product-type.bundle.ui-testing", StringComparison.OrdinalIgnoreCase); + + private sealed class ShippingSourceOwnership + { + internal ShippingSourceOwnership( + IReadOnlyDictionary fileReferences, + ISet synchronizedRoots) + { + FileReferences = fileReferences; + SynchronizedRoots = synchronizedRoots; + } + + internal IReadOnlyDictionary FileReferences { get; } + + internal ISet SynchronizedRoots { get; } + + internal string? ResolveEffectiveExtension( + string fileReference, + string sourcePath, + PbxObject source, + string metadataPath) + { + if (!FileReferences.TryGetValue(fileReference, out var configuredExtension)) + return null; + if (!string.IsNullOrWhiteSpace(configuredExtension)) + return configuredExtension; + var extension = Path.GetExtension(sourcePath); + if (SourceIncludeExtensions.Contains(extension)) + return extension; + throw new InvalidOperationException( + $"Shipping source '{source.Path ?? sourcePath}' has no exact compiler language in PBX metadata or per-file flags: {metadataPath}"); + } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs new file mode 100644 index 000000000..74375644c --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -0,0 +1,742 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace PowerForge; + +/// +/// Binds an Apple release checkpoint to source inputs represented by one exact Git commit. +/// +internal sealed partial class AppleReleaseSourceTrustService +{ + private static readonly HashSet AlwaysRejectedIgnoredExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".swift", ".m", ".mm", ".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", + ".metal", ".xcconfig", ".entitlements", ".xcprivacy", ".plist", ".storyboard", ".xib", + ".strings", ".stringsdict", ".xcstrings", ".intentdefinition", ".mlmodel", ".xcscheme", + ".xcfilelist", ".modulemap", ".s", ".a", ".dylib" + }; + + private readonly HomeAssistantReleaseGitService _git; + private readonly GitClient _gitClient; + private readonly Func? _remotePackageCheckoutResolver; + private readonly object _validationGate = new(); + private readonly Dictionary _gitObjectFormats = new( + Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private readonly HashSet _remotePackagesUnderValidation = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _validatedRemotePackages = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _validatedSourceIncludeFiles = new(GetPathComparer()); + private readonly HashSet _validatedAssemblerInputFiles = new(GetPathComparer()); + private readonly HashSet _validatedSourceSemanticInputs = new(StringComparer.Ordinal); + private readonly HashSet _inactiveRemoteSystemLibraryRoots = new(GetPathComparer()); + private readonly HashSet _approvedHeaderSearchRoots = new(GetPathComparer()); + private readonly Dictionary> _approvedAssemblerSearchRoots = new(GetPathComparer()); + + internal AppleReleaseSourceTrustService( + HomeAssistantReleaseGitService? git = null, + GitClient? gitClient = null, + Func? remotePackageCheckoutResolver = null) + { + _gitClient = gitClient ?? GitClient.CreateTrustedSystemClient(defaultTimeout: TimeSpan.FromMinutes(2)); + _git = git ?? new HomeAssistantReleaseGitService(_gitClient); + _remotePackageCheckoutResolver = remotePackageCheckoutResolver; + } + + internal string ResolveExactCommit(string repositoryRoot, string configPath) + => Capture(repositoryRoot, configPath).SourceCommit; + + internal AppleReleaseSourceTrustSnapshot Capture(string repositoryRoot, string configPath) + { + lock (_validationGate) + { + ResetValidationState(); + try + { + return CaptureCore(repositoryRoot, configPath); + } + finally + { + ResetValidationState(); + } + } + } + + private AppleReleaseSourceTrustSnapshot CaptureCore(string repositoryRoot, string configPath) + { + var root = Path.GetFullPath(repositoryRoot); + var releaseConfigPath = Path.GetFullPath(configPath); + EnsureNoGitReplacementRefs(root); + _git.EnsureClean(root); + var sourceCommitBeforeValidation = ReadExactHead(root); + var releaseConfigBytes = File.ReadAllBytes(releaseConfigPath); + EnsureTrackedFile( + root, + releaseConfigPath, + "Apple release configuration", + ComputeRawGitBlobId(root, releaseConfigBytes), + releaseConfigBytes); + var releaseConfigContent = DecodeTrackedText(releaseConfigBytes); + var spec = PowerForgeReleaseService.LoadConfigurationContent(releaseConfigContent, releaseConfigPath); + var options = spec.AppleApps + ?? throw new InvalidOperationException("The release configuration does not contain an AppleApps contract."); + var generatedOutputs = ResolveGeneratedOutputPaths(releaseConfigPath, options); + ValidateAppleInputs(root, releaseConfigPath, options, generatedOutputs); + + EnsureNoGitReplacementRefs(root); + _git.EnsureClean(root); + var sourceCommitAfterValidation = ReadExactHead(root); + if (!sourceCommitAfterValidation.Equals(sourceCommitBeforeValidation, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Repository HEAD changed while Apple release inputs were being validated. Rebuild from the new exact source commit."); + } + return new AppleReleaseSourceTrustSnapshot( + sourceCommitAfterValidation, + generatedOutputs, + releaseConfigContent, + ComputeSha256(releaseConfigBytes)); + } + + internal void ValidateAfterBuild( + string repositoryRoot, + string configPath, + AppleReleaseSourceTrustSnapshot snapshot) + { + if (snapshot is null) + throw new ArgumentNullException(nameof(snapshot)); + + lock (_validationGate) + { + ResetValidationState(); + try + { + ValidateAfterBuildCore(repositoryRoot, configPath, snapshot); + } + finally + { + ResetValidationState(); + } + } + } + + private void ValidateAfterBuildCore( + string repositoryRoot, + string configPath, + AppleReleaseSourceTrustSnapshot snapshot) + { + + var root = Path.GetFullPath(repositoryRoot); + var releaseConfigPath = Path.GetFullPath(configPath); + EnsureNoGitReplacementRefs(root); + EnsureNoUnexpectedWorktreeChanges(root, snapshot.GeneratedOutputPaths); + if (!ReadExactHead(root).Equals(snapshot.SourceCommit, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Repository HEAD changed while the Apple release checkpoint was being built. Rebuild from the new exact source commit."); + } + + EnsureTrackedFile(root, releaseConfigPath, "Apple release configuration"); + var options = PowerForgeReleaseService.LoadConfigurationContent( + snapshot.ExactConfigurationContent ?? File.ReadAllText(releaseConfigPath), + releaseConfigPath).AppleApps + ?? throw new InvalidOperationException("The release configuration does not contain an AppleApps contract."); + var generatedOutputs = ResolveGeneratedOutputPaths(releaseConfigPath, options); + if (!PathsEqual(snapshot.GeneratedOutputPaths, generatedOutputs)) + { + throw new InvalidOperationException( + "Apple generated output paths changed while the release checkpoint was being built. Rebuild from the updated release contract."); + } + + ValidateAppleInputs(root, releaseConfigPath, options, generatedOutputs); + EnsureNoGitReplacementRefs(root); + EnsureNoUnexpectedWorktreeChanges(root, generatedOutputs); + if (!ReadExactHead(root).Equals(snapshot.SourceCommit, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Repository HEAD changed while the Apple release checkpoint was being built. Rebuild from the new exact source commit."); + } + } + + private void ResetValidationState() + { + _remotePackagesUnderValidation.Clear(); + _validatedRemotePackages.Clear(); + _validatedSourceIncludeFiles.Clear(); + _validatedAssemblerInputFiles.Clear(); + _validatedSourceSemanticInputs.Clear(); + _inactiveRemoteSystemLibraryRoots.Clear(); + _approvedHeaderSearchRoots.Clear(); + _approvedAssemblerSearchRoots.Clear(); + } + + private void EnsureNoGitReplacementRefs(string repositoryRoot) + { + var replacements = RunGit(repositoryRoot, "replace", "-l").StdOut + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + if (replacements.Length > 0) + { + throw new InvalidOperationException( + "Git replacement refs are not accepted for exact-source Apple checkpoints because HEAD would not identify the effective source tree."); + } + } + + private void ValidateAppleInputs( + string repositoryRoot, + string configPath, + PowerForgeAppleReleaseOptions options, + IReadOnlyCollection generatedOutputPaths) + { + ValidateTrustedAppleToolExecutables(options); + var configDirectory = Path.GetDirectoryName(configPath) ?? repositoryRoot; + var projectRoot = ResolvePath( + configDirectory, + string.IsNullOrWhiteSpace(options.ProjectRoot) ? "." : options.ProjectRoot!); + EnsureDirectoryWithinRepository(repositoryRoot, projectRoot, "AppleApps.ProjectRoot"); + + foreach (var input in EnumerateConfiguredInputs(options)) + { + var inputPath = ResolvePath(projectRoot, input.Path); + EnsureNoGeneratedOutputOverlap(inputPath, generatedOutputPaths, input.Name); + EnsureTrackedFile(repositoryRoot, inputPath, input.Name); + } + + var metadataPaths = new HashSet(GetPathComparer()); + foreach (var app in options.Apps ?? Array.Empty()) + { + if (!app.Enabled || string.IsNullOrWhiteSpace(app.ProjectPath)) + continue; + + if (app.GenerateProjectIfMissing || app.RegenerateProject) + { + throw new InvalidOperationException( + $"Studio exact-source Apple checkpoints do not accept generated Xcode project metadata for '{app.Name}'. " + + "Generate the project first, review it, and commit the shared project and scheme metadata before building the checkpoint."); + } + + var configuredProjectPath = ResolvePath(projectRoot, app.ProjectPath); + EnsureNoGeneratedOutputOverlap(configuredProjectPath, generatedOutputPaths, "AppleApps.Apps.ProjectPath"); + EnsurePathWithinRepository(repositoryRoot, configuredProjectPath, "AppleApps.Apps.ProjectPath"); + if (File.Exists(configuredProjectPath)) + { + EnsureTrackedFile(repositoryRoot, configuredProjectPath, "AppleApps.Apps.ProjectPath"); + metadataPaths.Add(configuredProjectPath); + } + else if (Directory.Exists(configuredProjectPath)) + { + var metadataName = configuredProjectPath.EndsWith(".xcworkspace", StringComparison.OrdinalIgnoreCase) + ? "contents.xcworkspacedata" + : "project.pbxproj"; + var metadataPath = Path.Combine(configuredProjectPath, metadataName); + EnsureTrackedFile(repositoryRoot, metadataPath, $"AppleApps.Apps.ProjectPath/{metadataName}"); + metadataPaths.Add(metadataPath); + } + else + { + throw new FileNotFoundException( + $"AppleApps.Apps.ProjectPath was not found inside the exact checked-out source: {configuredProjectPath}", + configuredProjectPath); + } + } + + AddReferencedWorkspaceProjects(repositoryRoot, metadataPaths); + AddReferencedXcodeProjects(repositoryRoot, metadataPaths, generatedOutputPaths); + ValidateXcodeBuildGraph( + repositoryRoot, + projectRoot, + options.Apps ?? Array.Empty(), + metadataPaths, + generatedOutputPaths); + RejectIgnoredAppleInputs(repositoryRoot, projectRoot, metadataPaths, generatedOutputPaths); + } + + private static string[] ResolveGeneratedOutputPaths( + string configPath, + PowerForgeAppleReleaseOptions options) + { + var configDirectory = Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory(); + var projectRoot = ResolvePath( + configDirectory, + string.IsNullOrWhiteSpace(options.ProjectRoot) ? "." : options.ProjectRoot!); + var automation = options.Automation ?? new PowerForgeAppleReleaseAutomationOptions(); + var archiveRoot = ResolvePath(projectRoot, string.IsNullOrWhiteSpace(options.ArchiveRoot) + ? Path.Combine("Artifacts", "Apple", "Archives") + : options.ArchiveRoot!); + var exportRoot = ResolvePath(projectRoot, string.IsNullOrWhiteSpace(options.ExportRoot) + ? Path.Combine("Artifacts", "Apple", "Exports") + : options.ExportRoot!); + var artifactPaths = (options.Apps ?? Array.Empty()) + .Where(static app => app.Enabled && !string.IsNullOrWhiteSpace(app.Scheme)) + .SelectMany(app => + { + var name = string.IsNullOrWhiteSpace(app.Name) ? app.Scheme!.Trim() : app.Name!.Trim(); + var safeName = PowerForgeReleaseService.SanitizeStageEntryName(name).Replace(' ', '-'); + if (string.IsNullOrWhiteSpace(safeName)) + safeName = "AppleApp"; + return new[] + { + Path.Combine(archiveRoot, app.Platform.ToString(), $"{safeName}.xcarchive"), + Path.Combine(exportRoot, app.Platform.ToString(), safeName) + }; + }); + return artifactPaths + .Concat(new[] + { + ResolvePath(projectRoot, automation.ReceiptPath), + ResolvePath(projectRoot, automation.ReceiptHistoryPath), + ResolvePath(projectRoot, automation.PlanReceiptPath), + ResolvePath(projectRoot, automation.LockPath) + }) + .Distinct(GetPathComparer()) + .OrderBy(static path => path, GetPathComparer()) + .ToArray(); + } + + private void EnsureNoUnexpectedWorktreeChanges( + string repositoryRoot, + IReadOnlyCollection generatedOutputPaths) + { + var status = RunGit( + repositoryRoot, + "status", + "--porcelain=v1", + "--untracked-files=all", + "-z") + .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var entry in status) + { + if (entry.Length < 4) + throw new InvalidOperationException("Git returned an invalid worktree status while validating Apple release source trust."); + var statusCode = entry.Substring(0, 2); + var pathText = entry.Substring(3); + var candidate = Path.GetFullPath(Path.Combine(repositoryRoot, pathText.Replace('/', Path.DirectorySeparatorChar))); + if (statusCode == "??" && + (generatedOutputPaths.Any(output => IsPathAtOrWithin(candidate, output)) || + IsBenignIgnoredXcodeUserState(pathText.Replace('\\', '/')))) + { + continue; + } + + throw new InvalidOperationException( + $"Apple release source changed while the checkpoint was being built: {pathText}. " + + "Only declared generated Apple artifact and receipt outputs may change during the build."); + } + } + + private static void EnsureNoGeneratedOutputOverlap( + string sourcePath, + IReadOnlyCollection generatedOutputPaths, + string name) + { + var overlap = generatedOutputPaths.FirstOrDefault(output => + IsPathAtOrWithin(sourcePath, output) || IsPathAtOrWithin(output, sourcePath)); + if (overlap is not null) + { + throw new InvalidOperationException( + $"{name} overlaps a declared generated Apple output and cannot be proven as exact source: {sourcePath} ({overlap})"); + } + } + + private static bool PathsEqual( + IReadOnlyCollection left, + IReadOnlyCollection right) + => left.Count == right.Count && + new HashSet(left, GetPathComparer()).SetEquals(right); + + private static IEnumerable<(string Name, string Path)> EnumerateConfiguredInputs( + PowerForgeAppleReleaseOptions options) + { + foreach (var entry in EnumeratePathValues("AppleApps.ScreenshotConfigPath", options.ScreenshotConfigPath, options.ScreenshotConfigPaths)) + yield return entry; + foreach (var entry in EnumeratePathValues("AppleApps.MetadataConfigPath", options.MetadataConfigPath, options.MetadataConfigPaths)) + yield return entry; + foreach (var entry in EnumeratePathValues("AppleApps.AppInfoConfigPath", options.AppInfoConfigPath, options.AppInfoConfigPaths)) + yield return entry; + foreach (var entry in EnumeratePathValues("AppleApps.GovernanceConfigPath", options.GovernanceConfigPath, options.GovernanceConfigPaths)) + yield return entry; + var versionSourcePath = options.Automation?.VersionSourcePath; + if (!string.IsNullOrWhiteSpace(versionSourcePath)) + yield return ("AppleApps.Automation.VersionSourcePath", versionSourcePath!); + } + + private static IEnumerable<(string Name, string Path)> EnumeratePathValues( + string name, + string? single, + string[]? many) + { + if (!string.IsNullOrWhiteSpace(single)) + yield return (name, single!); + foreach (var path in many ?? Array.Empty()) + { + if (!string.IsNullOrWhiteSpace(path)) + yield return (name, path); + } + } + + private void AddReferencedWorkspaceProjects( + string repositoryRoot, + HashSet metadataPaths) + { + var pending = new Queue(metadataPaths.Where(path => + path.EndsWith("contents.xcworkspacedata", StringComparison.OrdinalIgnoreCase))); + while (pending.Count > 0) + { + var workspaceMetadata = pending.Dequeue(); + var workspaceContainer = Path.GetDirectoryName(workspaceMetadata)!; + var workspaceRoot = Path.GetDirectoryName(workspaceContainer)!; + var document = XDocument.Load(workspaceMetadata, LoadOptions.None); + foreach (var candidate in EnumerateWorkspaceReferences(document.Root, workspaceRoot, workspaceRoot)) + { + EnsurePathWithinRepository(repositoryRoot, candidate, "Apple workspace referenced input"); + + string? referencedMetadata = null; + if (candidate.EndsWith(".xcodeproj", StringComparison.OrdinalIgnoreCase)) + referencedMetadata = Path.Combine(candidate, "project.pbxproj"); + else if (candidate.EndsWith(".xcworkspace", StringComparison.OrdinalIgnoreCase)) + referencedMetadata = Path.Combine(candidate, "contents.xcworkspacedata"); + if (referencedMetadata is null) + continue; + + EnsureTrackedFile(repositoryRoot, referencedMetadata, "Apple workspace referenced project"); + if (!metadataPaths.Add(referencedMetadata)) + continue; + if (referencedMetadata.EndsWith("contents.xcworkspacedata", StringComparison.OrdinalIgnoreCase)) + pending.Enqueue(referencedMetadata); + } + } + } + + private static IEnumerable EnumerateWorkspaceReferences( + XElement? element, + string workspaceRoot, + string groupRoot) + { + if (element is null) + yield break; + + var currentGroupRoot = groupRoot; + if (element.Name.LocalName.Equals("Group", StringComparison.Ordinal)) + { + var location = element.Attribute("location")?.Value; + if (!string.IsNullOrWhiteSpace(location)) + currentGroupRoot = ResolveWorkspaceLocation(location!, workspaceRoot, groupRoot); + } + else if (element.Name.LocalName.Equals("FileRef", StringComparison.Ordinal)) + { + var location = element.Attribute("location")?.Value; + if (!string.IsNullOrWhiteSpace(location)) + yield return ResolveWorkspaceLocation(location!, workspaceRoot, groupRoot); + } + + foreach (var child in element.Elements()) + { + foreach (var reference in EnumerateWorkspaceReferences(child, workspaceRoot, currentGroupRoot)) + yield return reference; + } + } + + private static string ResolveWorkspaceLocation(string location, string workspaceRoot, string groupRoot) + { + var separator = location.IndexOf(':'); + var kind = separator < 0 ? "group" : location.Substring(0, separator); + var value = separator < 0 ? location : location.Substring(separator + 1); + return kind.ToLowerInvariant() switch + { + "absolute" => throw new InvalidOperationException( + $"Absolute Xcode workspace references are not accepted for exact-source snapshot builds: {location}"), + "container" => ResolvePath(workspaceRoot, value), + "group" => ResolvePath(groupRoot, value), + _ => throw new InvalidOperationException($"Unsupported Xcode workspace location kind '{kind}'.") + }; + } + + private void RejectIgnoredAppleInputs( + string repositoryRoot, + string projectRoot, + IReadOnlyCollection metadataPaths, + IReadOnlyCollection generatedOutputPaths) + { + var metadata = metadataPaths + .Where(File.Exists) + .Select(File.ReadAllText) + .ToArray(); + var synchronizedRoots = ResolveSynchronizedRoots(metadataPaths); + var relativeProjectRoot = FrameworkCompatibility.GetRelativePath(repositoryRoot, projectRoot).Replace('\\', '/'); + var arguments = new List { "ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--" }; + arguments.Add(string.IsNullOrWhiteSpace(relativeProjectRoot) || relativeProjectRoot == "." + ? "." + : relativeProjectRoot); + var ignored = RunGit(repositoryRoot, arguments.ToArray()).StdOut + .Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var gitPath in ignored) + { + var normalized = gitPath.Replace('\\', '/'); + if (IsBenignIgnoredXcodeUserState(normalized)) + continue; + + var fullPath = Path.GetFullPath(Path.Combine(repositoryRoot, normalized)); + if (generatedOutputPaths.Any(output => IsPathAtOrWithin(fullPath, output))) + continue; + if (!File.Exists(fullPath) || + !IsPotentialAppleBuildInput(projectRoot, fullPath, metadata, synchronizedRoots)) + continue; + + throw new InvalidOperationException( + $"Ignored Apple build input is not represented by the exact source commit: {normalized}. " + + "Track the input, remove it from the Xcode build, or generate it only after the source-bound checkpoint begins."); + } + } + + private static bool IsBenignIgnoredXcodeUserState(string path) + { + if (!path.Contains("/xcuserdata/", StringComparison.OrdinalIgnoreCase) && + !path.StartsWith("xcuserdata/", StringComparison.OrdinalIgnoreCase)) + return false; + + var fileName = Path.GetFileName(path); + return fileName.Equals("xcschememanagement.plist", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("UserInterfaceState.xcuserstate", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("Breakpoints_v2.xcbkptlist", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPotentialAppleBuildInput( + string projectRoot, + string fullPath, + IReadOnlyCollection metadata, + IReadOnlyCollection synchronizedRoots) + { + var relative = FrameworkCompatibility.GetRelativePath(projectRoot, fullPath).Replace('\\', '/'); + var fileName = Path.GetFileName(fullPath); + var extension = Path.GetExtension(fullPath); + if (synchronizedRoots.Any(root => IsPathAtOrWithin(fullPath, root))) + return true; + if (AlwaysRejectedIgnoredExtensions.Contains(extension)) + return true; + if (relative.Contains(".xcassets/", StringComparison.OrdinalIgnoreCase) || + relative.Contains(".xcdatamodel", StringComparison.OrdinalIgnoreCase) || + relative.Contains(".framework/", StringComparison.OrdinalIgnoreCase) || + relative.Contains(".xcframework/", StringComparison.OrdinalIgnoreCase) || + relative.StartsWith("Sources/", StringComparison.OrdinalIgnoreCase) || + relative.Contains("/Sources/", StringComparison.OrdinalIgnoreCase) || + relative.StartsWith("Plugins/", StringComparison.OrdinalIgnoreCase) || + relative.Contains("/Plugins/", StringComparison.OrdinalIgnoreCase)) + return true; + + return metadata.Any(text => + text.Contains(relative, StringComparison.Ordinal) || + text.Contains(fileName, StringComparison.Ordinal)); + } + + private static string[] ResolveSynchronizedRoots(IEnumerable metadataPaths) + => ResolveObjectAwareSynchronizedRoots(metadataPaths); + + private void EnsureTrackedFile( + string repositoryRoot, + string path, + string name, + string? capturedWorktreeBlob = null, + byte[]? capturedWorktreeBytes = null, + bool validateSwiftDeterminism = false, + string? effectiveSourceExtension = null, + string? assemblerWorkingDirectory = null) + { + var candidate = Path.GetFullPath(path); + EnsurePathWithinRepository(repositoryRoot, candidate, name); + if (!File.Exists(candidate)) + throw new FileNotFoundException($"{name} was not found: {candidate}", candidate); + EnsureNoLinkedTraversal(repositoryRoot, candidate, name); + + var relative = FrameworkCompatibility.GetRelativePath(repositoryRoot, candidate).Replace('\\', '/'); + var tracked = RunGitAllowFailure(repositoryRoot, "ls-files", "-v", "--error-unmatch", "--", relative); + if (!tracked.Succeeded) + throw new InvalidOperationException($"{name} must be tracked at the exact source commit: {relative}"); + var indexEntry = tracked.StdOut + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + if (HasHiddenGitIndexState(indexEntry)) + { + throw new InvalidOperationException( + $"{name} uses a skip-worktree or assume-unchanged Git index flag and cannot be attested to the exact source commit: {relative}"); + } + var headBlob = RunGitAllowFailure(repositoryRoot, "rev-parse", "--verify", $"HEAD:{relative}"); + if (!headBlob.Succeeded || string.IsNullOrWhiteSpace(headBlob.StdOut)) + throw new InvalidOperationException($"{name} is not present in the exact source commit: {relative}"); + EnsureNoCustomGitFilter(repositoryRoot, relative, name); + var worktreeBlob = capturedWorktreeBlob ?? ComputeRawGitBlobId(repositoryRoot, candidate); + var expectedBlob = headBlob.StdOut.Trim(); + if (!expectedBlob.Equals(worktreeBlob, StringComparison.OrdinalIgnoreCase)) + { + var filteredWorktreeBlob = capturedWorktreeBytes is null + ? ComputePathAwareGitBlobId(repositoryRoot, candidate, relative) + : ComputePathAwareGitBlobId(repositoryRoot, capturedWorktreeBytes, relative); + if (!expectedBlob.Equals(filteredWorktreeBlob, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); + } + ValidateSourceLevelIncludes( + repositoryRoot, + candidate, + validateSwiftDeterminism, + worktreeBlob, + effectiveSourceExtension, + assemblerWorkingDirectory); + } + + private static string DecodeTrackedText(byte[] content) + { + using var stream = new MemoryStream(content, writable: false); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + + private Dictionary ReadHeadTreeBlobIds(string repositoryRoot, string relativeRoot) + { + var comparer = GetPathComparer(); + var result = new Dictionary(comparer); + var entries = RunGit(repositoryRoot, "ls-tree", "-r", "-z", "HEAD", "--", relativeRoot) + .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var entry in entries) + { + var tab = entry.IndexOf('\t'); + if (tab < 0) + continue; + var header = entry.Substring(0, tab).Split(' '); + if (header.Length != 3 || !header[1].Equals("blob", StringComparison.Ordinal)) + continue; + var fullPath = Path.GetFullPath(Path.Combine(repositoryRoot, entry.Substring(tab + 1))); + result[fullPath] = header[2]; + } + return result; + } + + private static bool HasHiddenGitIndexState(string? entry) + => !string.IsNullOrWhiteSpace(entry) && + entry!.Length > 2 && + entry[1] == ' ' && + (entry[0] == 'S' || char.IsLower(entry[0])); + + private static void EnsureDirectoryWithinRepository(string repositoryRoot, string path, string name) + { + EnsurePathWithinRepository(repositoryRoot, path, name); + if (!Directory.Exists(path)) + throw new DirectoryNotFoundException($"{name} was not found inside the exact checked-out source: {path}"); + EnsureNoLinkedTraversal(repositoryRoot, path, name); + } + + private static void EnsurePathWithinRepository(string repositoryRoot, string path, string name) + { + var root = Path.GetFullPath(repositoryRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var candidate = Path.GetFullPath(path); + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!candidate.Equals(root, comparison) && + !candidate.StartsWith(root + Path.DirectorySeparatorChar, comparison)) + throw new InvalidOperationException($"{name} must resolve inside the exact checked-out source: {candidate}"); + } + + private static void EnsureNoLinkedTraversal(string repositoryRoot, string path, string name) + { + var root = Path.GetFullPath(repositoryRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var current = Path.GetFullPath(path); + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + while (true) + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException($"{name} must not traverse a symbolic link or reparse point: {current}"); + if (current.Equals(root, comparison)) + return; + current = Path.GetDirectoryName(current) + ?? throw new InvalidOperationException($"{name} escaped the checked-out source while validating path traversal."); + } + } + + private static string ResolvePath(string basePath, string path) + => Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(basePath, path)); + + private static bool IsPathAtOrWithin(string path, string root) + { + var candidate = Path.GetFullPath(path); + var normalizedRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return candidate.Equals(normalizedRoot, comparison) || + candidate.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, comparison); + } + + private string ReadExactHead(string repositoryRoot) + { + var sourceCommit = _git.GetHeadSha(repositoryRoot).Trim(); + var objectFormat = ReadGitObjectFormat(repositoryRoot); + if (!GitObjectId.IsFullForObjectFormat(sourceCommit, objectFormat)) + throw new InvalidOperationException($"Apple release checkpoints require an exact repository HEAD for Git object format '{objectFormat}'."); + return sourceCommit.ToLowerInvariant(); + } + + private string ReadGitObjectFormat(string repositoryRoot) + { + var root = Path.GetFullPath(repositoryRoot); + if (_gitObjectFormats.TryGetValue(root, out var objectFormat)) + return objectFormat; + objectFormat = RunGit(root, "rev-parse", "--show-object-format").StdOut.Trim(); + _gitObjectFormats[root] = objectFormat; + return objectFormat; + } + + private ProcessRunResult RunGit(string repositoryRoot, params string[] arguments) + { + var result = RunGitAllowFailure(repositoryRoot, arguments); + if (!result.Succeeded) + { + var detail = string.IsNullOrWhiteSpace(result.StdErr) ? result.StdOut : result.StdErr; + throw new InvalidOperationException( + $"git {string.Join(" ", arguments)} failed with exit code {result.ExitCode}. {detail.Trim()}"); + } + return result; + } + + private ProcessRunResult RunGitAllowFailure(string repositoryRoot, params string[] arguments) + => _gitClient.RunRawAsync(repositoryRoot, arguments, TimeSpan.FromMinutes(2)) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + private static string ComputeSha256(byte[] bytes) + { + using var sha256 = System.Security.Cryptography.SHA256.Create(); + return BitConverter.ToString(sha256.ComputeHash(bytes)).Replace("-", string.Empty).ToLowerInvariant(); + } + + private static StringComparer GetPathComparer() + => Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; +} + +internal sealed class AppleReleaseSourceTrustSnapshot +{ + internal AppleReleaseSourceTrustSnapshot( + string sourceCommit, + string[] generatedOutputPaths, + string? exactConfigurationContent = null, + string? exactConfigurationSha256 = null) + { + SourceCommit = sourceCommit; + GeneratedOutputPaths = generatedOutputPaths; + ExactConfigurationContent = exactConfigurationContent; + ExactConfigurationSha256 = exactConfigurationSha256; + } + + internal string SourceCommit { get; } + + internal string[] GeneratedOutputPaths { get; } + + internal string? ExactConfigurationContent { get; } + + internal string? ExactConfigurationSha256 { get; } +} diff --git a/PowerForge/Services/AppleReleaseVersionSourceService.cs b/PowerForge/Services/AppleReleaseVersionSourceService.cs index edb7e6efa..1cbe07bee 100644 --- a/PowerForge/Services/AppleReleaseVersionSourceService.cs +++ b/PowerForge/Services/AppleReleaseVersionSourceService.cs @@ -4,7 +4,7 @@ namespace PowerForge; /// -/// Reads and atomically updates the shared version values in an XcodeGen project specification. +/// Reads and compare-and-write updates the shared version values in an XcodeGen project specification. /// internal sealed class AppleReleaseVersionSourceService { @@ -13,11 +13,26 @@ internal sealed class AppleReleaseVersionSourceService private static readonly Regex MarketingVersionValuePattern = new( "^\\d+\\.\\d+(?:\\.\\d+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private readonly Action? _onComparedVersionSource; + private readonly Action _deleteFile; + + internal AppleReleaseVersionSourceService( + Action? onComparedVersionSource = null, + Action? deleteFile = null) + { + _onComparedVersionSource = onComparedVersionSource; + _deleteFile = deleteFile ?? File.Delete; + } internal PowerForgeAppleVersionReceipt Read(string sourcePath) { var fullPath = ResolveSourcePath(sourcePath); - var content = File.ReadAllText(fullPath); + return Read(fullPath, File.ReadAllText(fullPath)); + } + + internal PowerForgeAppleVersionReceipt Read(string sourcePath, string content) + { + var fullPath = ResolveSourcePath(sourcePath); return new PowerForgeAppleVersionReceipt { SourcePath = fullPath, @@ -28,6 +43,7 @@ internal PowerForgeAppleVersionReceipt Read(string sourcePath) internal PowerForgeAppleVersionReceipt Update( string sourcePath, + string approvedContent, string marketingVersion, string buildNumber, long highestRemoteBuildNumber, @@ -42,7 +58,7 @@ internal PowerForgeAppleVersionReceipt Update( throw new ArgumentException("Apple build number must be a positive integer.", nameof(buildNumber)); var fullPath = ResolveSourcePath(sourcePath); - var content = File.ReadAllText(fullPath); + var content = approvedContent ?? throw new ArgumentNullException(nameof(approvedContent)); var previousMarketingVersion = ReadSingleValue(content, MarketingVersionPattern, "MARKETING_VERSION", fullPath); var previousBuildNumber = ReadSingleValue(content, BuildNumberPattern, "CURRENT_PROJECT_VERSION", fullPath); var updated = ReplaceSingleValue(content, MarketingVersionPattern, marketingVersion.Trim()); @@ -50,7 +66,9 @@ internal PowerForgeAppleVersionReceipt Update( var changed = !string.Equals(content, updated, StringComparison.Ordinal); if (changed && !whatIf) - WriteAtomic(fullPath, updated); + { + WriteIfUnchanged(fullPath, approvedContent, updated); + } return new PowerForgeAppleVersionReceipt { @@ -99,22 +117,86 @@ private static string ResolveSourcePath(string sourcePath) return fullPath; } - private static void WriteAtomic(string path, string content) + private void WriteIfUnchanged(string path, string expectedContent, string content) { + var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content); var directory = Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory(); var temporaryPath = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + var backupPath = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.previous"); try { - File.WriteAllText(temporaryPath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - if (File.Exists(path)) - File.Replace(temporaryPath, path, destinationBackupFileName: null, ignoreMetadataErrors: true); - else - File.Move(temporaryPath, path); + using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 16 * 1024, + options: FileOptions.WriteThrough)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(flushToDisk: true); + } +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(temporaryPath, File.GetUnixFileMode(path)); +#endif + + if (!string.Equals(File.ReadAllText(path), expectedContent, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Apple version source changed after plan approval: {path}"); + } + + _onComparedVersionSource?.Invoke(path); + + File.Replace(temporaryPath, path, backupPath, ignoreMetadataErrors: true); + var replacedContent = File.ReadAllText(backupPath); + if (!string.Equals(replacedContent, expectedContent, StringComparison.Ordinal)) + { + RestoreConcurrentVersionSource(path, backupPath, content); + throw new InvalidOperationException( + $"Apple version source changed while applying the approved update and was restored instead of being overwritten: {path}"); + } + TryDeleteCommittedBackup(backupPath); + + if (!string.Equals(File.ReadAllText(path), content, StringComparison.Ordinal)) + throw new InvalidOperationException($"Apple version source changed while the approved update was being published: {path}"); } finally { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); + if (File.Exists(backupPath) && string.Equals(File.ReadAllText(backupPath), expectedContent, StringComparison.Ordinal)) + TryDeleteCommittedBackup(backupPath); + } + } + + private void TryDeleteCommittedBackup(string backupPath) + { + try { _deleteFile(backupPath); } + catch { /* approved replacement is already committed; retain the old bytes */ } + } + + private static void RestoreConcurrentVersionSource(string path, string candidatePath, string expectedNamedContent) + { + var directory = Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory(); + while (true) + { + var installedCandidateContent = File.ReadAllText(candidatePath); + var displacedPath = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.displaced"); + File.Replace(candidatePath, path, displacedPath, ignoreMetadataErrors: true); + var displacedContent = File.ReadAllText(displacedPath); + if (string.Equals(displacedContent, expectedNamedContent, StringComparison.Ordinal)) + { + File.Delete(displacedPath); + return; + } + + // A newer pathname replacement won while the prior concurrent bytes + // were being restored. Promote those newer bytes on the next atomic + // exchange instead of silently overwriting or deleting them. + candidatePath = displacedPath; + expectedNamedContent = installedCandidateContent; } } } diff --git a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs new file mode 100644 index 000000000..36e8b60bf --- /dev/null +++ b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs @@ -0,0 +1,281 @@ +namespace PowerForge; + +/// +/// Owns the private Swift package materialization consumed by one exact-source Xcode archive. +/// +internal sealed class AppleSwiftPackageBuildSnapshot : IDisposable +{ + private readonly AppleReleaseSourceTrustService _sourceTrust = new(); + private readonly IReadOnlyDictionary _approvedPackageRevisions; + private readonly IReadOnlyDictionary _environmentVariables; + private readonly AppleReleaseSourceMutationMonitor _monitor; + private readonly AppleArchiveUploadSnapshot.SnapshotIdentity _materializedPackagesIdentity; + private bool _disposed; + + private AppleSwiftPackageBuildSnapshot( + string rootPath, + IReadOnlyDictionary approvedPackageRevisions, + IReadOnlyDictionary environmentVariables, + AppleReleaseSourceMutationMonitor monitor, + AppleArchiveUploadSnapshot.SnapshotIdentity materializedPackagesIdentity) + { + RootPath = rootPath; + _approvedPackageRevisions = approvedPackageRevisions; + _environmentVariables = environmentVariables; + _monitor = monitor; + _materializedPackagesIdentity = materializedPackagesIdentity; + } + + internal string RootPath { get; } + + internal string SourcePackagesPath => Path.Combine(RootPath, "SourcePackages"); + + internal string ResolverDerivedDataPath => Path.Combine(RootPath, "ResolverDerivedData"); + + internal string ArchiveDerivedDataPath => Path.Combine(RootPath, "ArchiveDerivedData"); + + internal IReadOnlyDictionary EnvironmentVariables => _environmentVariables; + + internal static async Task CreateAsync( + IProcessRunner processRunner, + string xcodeBuildExecutable, + string projectPath, + bool isWorkspace, + string scheme, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var parent = Path.Combine(Path.GetTempPath(), "PowerForge", "apple-swiftpm-build-snapshots"); + Directory.CreateDirectory(parent); + var root = Path.Combine(parent, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + AppleReleaseSourceMutationMonitor? monitor = null; +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + try + { + var sourcePackagesPath = Path.Combine(root, "SourcePackages"); + var derivedDataPath = Path.Combine(root, "ResolverDerivedData"); + Directory.CreateDirectory(sourcePackagesPath); + Directory.CreateDirectory(derivedDataPath); + var repositoryRoot = FindRepositoryRoot(projectPath); + var approvedPackageRevisions = new AppleReleaseSourceTrustService().ReadApprovedTrackedPackageRevisions( + repositoryRoot, + DiscoverApprovedPackageLocks(repositoryRoot, projectPath)); + var environmentVariables = AppleTrustedExecutionEnvironment.Create(isolateGitConfiguration: true); + var arguments = new[] + { + isWorkspace ? "-workspace" : "-project", + projectPath, + "-scheme", + scheme, + "-resolvePackageDependencies", + "-clonedSourcePackagesDirPath", + sourcePackagesPath, + "-derivedDataPath", + derivedDataPath, + "-onlyUsePackageVersionsFromResolvedFile", + "-disableAutomaticPackageResolution", + "-skipPackageUpdates" + }; + var sourceTrust = new AppleReleaseSourceTrustService(); + monitor = new AppleReleaseSourceMutationMonitor( + sourcePackagesPath, + "materialized Swift package root", + "xcodebuild archive", + "Discard the archive and resolve the exact package graph again.", + enableImmediately: false); + AppleArchiveUploadSnapshot.SnapshotIdentity? materializedPackagesIdentity = null; + var processRequest = new ProcessRunRequest( + xcodeBuildExecutable, + Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), + arguments, + timeout, + environmentVariables, + captureOutput: true, + captureError: true, + inheritEnvironment: false); + processRequest.SetCompletionBoundary(completionResult => + { + if (!completionResult.Succeeded) + return; + materializedPackagesIdentity = monitor.CaptureExpectedProducerOutput( + () => CaptureMaterializedPackageIdentity( + sourceTrust, + sourcePackagesPath, + approvedPackageRevisions), + "xcodebuild -resolvePackageDependencies"); + }); + var result = await processRunner.RunAsync(processRequest, cancellationToken).ConfigureAwait(false); + processRequest.InvokeCompletionBoundary(result); + if (!result.Succeeded) + { + monitor.Dispose(); + throw new InvalidOperationException( + $"xcodebuild failed to resolve the exact Swift package graph with exit code {result.ExitCode}: " + + (string.IsNullOrWhiteSpace(result.StdErr) ? result.StdOut : result.StdErr)); + } + if (materializedPackagesIdentity is null) + { + monitor.Dispose(); + throw new InvalidOperationException( + "xcodebuild completed without binding the exact materialized Swift package graph at its process completion boundary."); + } + + var snapshot = new AppleSwiftPackageBuildSnapshot( + root, + approvedPackageRevisions, + environmentVariables, + monitor, + materializedPackagesIdentity); + monitor = null; + return snapshot; + } + catch + { + monitor?.Dispose(); + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } + throw; + } + } + + internal void AppendArchiveArguments(ICollection arguments) + { + arguments.Add("-clonedSourcePackagesDirPath"); + arguments.Add(SourcePackagesPath); + arguments.Add("-derivedDataPath"); + arguments.Add(ArchiveDerivedDataPath); + arguments.Add("-onlyUsePackageVersionsFromResolvedFile"); + arguments.Add("-disableAutomaticPackageResolution"); + arguments.Add("-skipPackageUpdates"); + } + + internal void ValidateUnchanged() + { + var actual = CaptureMaterializedPackageIdentity( + _sourceTrust, + SourcePackagesPath, + _approvedPackageRevisions); + if (!actual.Equals(_materializedPackagesIdentity)) + { + throw new InvalidOperationException( + "The materialized Swift package root changed before xcodebuild archive. " + + "A transient write or hard-link alias invalidates the exact package graph."); + } + _monitor.ValidateNoChanges(); + } + + private static AppleArchiveUploadSnapshot.SnapshotIdentity CaptureMaterializedPackageIdentity( + AppleReleaseSourceTrustService sourceTrust, + string sourcePackagesPath, + IReadOnlyDictionary approvedPackageRevisions) + { + sourceTrust.ValidateMaterializedPackageCheckouts(sourcePackagesPath, approvedPackageRevisions); + var artifactsPath = Path.Combine(sourcePackagesPath, "artifacts"); + if (Directory.Exists(artifactsPath)) + ValidateNoEscapingArtifactLinks(artifactsPath); + return AppleArchiveUploadSnapshot.CaptureCompleteIdentity( + sourcePackagesPath, + "materialized Swift package snapshot"); + } + + private static void ValidateNoEscapingArtifactLinks(string artifactsRoot) + { + var root = Path.GetFullPath(artifactsRoot); + var pending = new Stack(); + pending.Push(root); + while (pending.Count > 0) + { + var directory = pending.Pop(); + foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) + { + var attributes = File.GetAttributes(entry); + var isDirectory = (attributes & FileAttributes.Directory) != 0; + if ((attributes & FileAttributes.ReparsePoint) == 0) + { + if (isDirectory) + pending.Push(entry); + continue; + } +#if NET8_0_OR_GREATER + var target = isDirectory ? new DirectoryInfo(entry).LinkTarget : new FileInfo(entry).LinkTarget; + if (string.IsNullOrWhiteSpace(target) || Path.IsPathRooted(target)) + throw new InvalidOperationException($"Materialized Swift binary artifact contains an unbound symbolic link: {entry}"); + var resolved = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(entry)!, target)); + var relative = FrameworkCompatibility.GetRelativePath(root, resolved); + if (Path.IsPathRooted(relative) || + relative.Equals("..", StringComparison.Ordinal) || + relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Materialized Swift binary artifact link escapes its approved root: {entry}"); + } +#else + throw new PlatformNotSupportedException("Swift binary-artifact link validation requires .NET 8 or newer."); +#endif + } + } + } + + internal static void RejectConflictingArguments(IEnumerable arguments) + { + var forbidden = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "-clonedSourcePackagesDirPath", + "-derivedDataPath", + "-packageCachePath", + "-resolvePackageDependencies", + "-disableAutomaticPackageResolution", + "-onlyUsePackageVersionsFromResolvedFile", + "-skipPackageUpdates", + "-skipPackagePluginValidation", + "-skipPackageSignatureValidation" + }; + var conflict = arguments.FirstOrDefault(argument => forbidden.Contains(argument)); + if (conflict is not null) + { + throw new InvalidOperationException( + $"Exact-source Apple archives own Swift package materialization; additional xcodebuild argument '{conflict}' is not allowed."); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _monitor.Dispose(); + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after archive */ } + } + + private static IEnumerable DiscoverApprovedPackageLocks(string repositoryRoot, string projectPath) + { + var project = Path.GetFullPath(projectPath); + var projectDirectory = Path.GetDirectoryName(project) + ?? throw new InvalidOperationException($"Xcode project path has no parent: {project}"); + return new[] + { + Path.Combine(repositoryRoot, "Package.resolved"), + Path.Combine(projectDirectory, "Package.resolved"), + Path.Combine(project, "xcshareddata", "swiftpm", "Package.resolved"), + Path.Combine(project, "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved") + } + .Distinct(Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal) + .Where(File.Exists); + } + + private static string FindRepositoryRoot(string startPath) + { + var fullStartPath = Path.GetFullPath(startPath); + var current = new DirectoryInfo(Directory.Exists(fullStartPath) ? fullStartPath : Path.GetDirectoryName(fullStartPath)!); + while (current is not null) + { + var marker = Path.Combine(current.FullName, ".git"); + if (File.Exists(marker) || Directory.Exists(marker)) + return current.FullName; + current = current.Parent; + } + throw new InvalidOperationException($"Exact-source Xcode project is not inside a Git worktree: {startPath}"); + } +} diff --git a/PowerForge/Services/AppleTrustedExecutionEnvironment.cs b/PowerForge/Services/AppleTrustedExecutionEnvironment.cs new file mode 100644 index 000000000..4de5a2cb5 --- /dev/null +++ b/PowerForge/Services/AppleTrustedExecutionEnvironment.cs @@ -0,0 +1,32 @@ +namespace PowerForge; + +/// Builds the explicit environment allowed for exact-source Apple system tools. +internal static class AppleTrustedExecutionEnvironment +{ + private static readonly string[] ForwardedOperatorVariables = + { + "HOME", "TMPDIR", "USER", "LOGNAME", "LANG", "LC_ALL", "SSH_AUTH_SOCK" + }; + + internal static IReadOnlyDictionary Create(bool isolateGitConfiguration = false) + { + var environment = new Dictionary(StringComparer.Ordinal) + { + ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + }; + if (isolateGitConfiguration) + { + environment["GIT_CONFIG_NOSYSTEM"] = "1"; + environment["GIT_CONFIG_SYSTEM"] = "/dev/null"; + environment["GIT_CONFIG_GLOBAL"] = "/dev/null"; + } + + foreach (var name in ForwardedOperatorVariables) + { + var value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + environment[name] = value; + } + return environment; + } +} diff --git a/PowerForge/Services/ExistingFilePathIdentityResolver.cs b/PowerForge/Services/ExistingFilePathIdentityResolver.cs index ca1b46e8f..61c357416 100644 --- a/PowerForge/Services/ExistingFilePathIdentityResolver.cs +++ b/PowerForge/Services/ExistingFilePathIdentityResolver.cs @@ -16,6 +16,12 @@ internal static class ExistingFilePathIdentityResolver /// Existing file whose identity should be resolved. /// A volume/device-qualified file identifier suitable for in-process equality checks. internal static string Resolve(string path) + => ResolveStatus(path).Identity; + + /// + /// Returns the physical identity and metadata-change token for an existing file. + /// + internal static ExistingFilePhysicalStatus ResolveStatus(string path) { var fullPath = System.IO.Path.GetFullPath(path); using var stream = new FileStream( @@ -25,15 +31,149 @@ internal static string Resolve(string path) FileShare.ReadWrite | FileShare.Delete); if (System.IO.Path.DirectorySeparatorChar == '\\') - return ReadWindowsFileIdentity(stream.SafeFileHandle); + return ReadWindowsFileStatus(stream.SafeFileHandle); #if NET8_0_OR_GREATER - return ReadUnixFileIdentity(stream.SafeFileHandle); + return ReadUnixFileStatus(stream.SafeFileHandle); #else throw new PlatformNotSupportedException("Physical file identity is not available for this runtime and operating system."); #endif } + /// Returns physical status for an already-open file without resolving its pathname again. + internal static ExistingFilePhysicalStatus ResolveStatus(SafeFileHandle handle) + { + if (handle is null || handle.IsInvalid || handle.IsClosed) + throw new ArgumentException("An open file handle is required.", nameof(handle)); + if (System.IO.Path.DirectorySeparatorChar == '\\') + return ReadWindowsFileStatus(handle); +#if NET8_0_OR_GREATER + return ReadUnixFileStatus(handle); +#else + throw new PlatformNotSupportedException("Physical file identity is not available for this runtime and operating system."); +#endif + } + + /// Returns the hard-link count for an already-open regular file. + internal static int ResolveHardLinkCount(SafeFileHandle handle) + { + if (handle is null || handle.IsInvalid || handle.IsClosed) + throw new ArgumentException("An open file handle is required.", nameof(handle)); + if (System.IO.Path.DirectorySeparatorChar == '\\') + { + if (!GetFileInformationByHandle(handle, out var information)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return checked((int)information.NumberOfLinks); + } + throw new PlatformNotSupportedException( + "Open-handle hard-link inspection is available only on Windows; use the path batch inspector on Unix."); + } + + internal readonly struct ExistingFilePhysicalStatus + { + internal ExistingFilePhysicalStatus(string identity, string changeToken) + { + Identity = identity; + ChangeToken = changeToken; + } + + internal string Identity { get; } + + internal string ChangeToken { get; } + + internal string MutationIdentity => $"{Identity}:{ChangeToken}"; + } + + /// + /// Returns the number of physical path aliases for each regular file without following repository or archive metadata. + /// + internal static IReadOnlyList ResolveHardLinkCounts(IReadOnlyList paths) + { + if (paths.Count == 0) + return Array.Empty(); + if (System.IO.Path.DirectorySeparatorChar == '\\') + { + return paths.Select(path => + { + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + if (!GetFileInformationByHandle(stream.SafeFileHandle, out var information)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return checked((int)information.NumberOfLinks); + }).ToArray(); + } + +#if NET8_0_OR_GREATER + const int batchSize = 64; + var executable = "/usr/bin/stat"; + var counts = new List(paths.Count); + for (var offset = 0; offset < paths.Count; offset += batchSize) + { + var batch = paths.Skip(offset).Take(batchSize).ToArray(); + var arguments = new List + { + OperatingSystem.IsMacOS() ? "-f" : "-c", + OperatingSystem.IsMacOS() ? "%l" : "%h" + }; + arguments.AddRange(batch); + var result = new ProcessRunner().RunAsync(new ProcessRunRequest( + executable, + Directory.GetCurrentDirectory(), + arguments, + TimeSpan.FromMinutes(1), + AppleTrustedExecutionEnvironment.Create(), + captureOutput: true, + captureError: true, + inheritEnvironment: false)) + .GetAwaiter() + .GetResult(); + if (!result.Succeeded) + throw new InvalidOperationException($"Failed to inspect private artifact hard-link counts: {result.StdErr}".Trim()); + var batchCounts = result.StdOut + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(value => int.TryParse(value.Trim(), out var count) ? count : -1) + .ToArray(); + if (batchCounts.Length != batch.Length || batchCounts.Any(static count => count < 0)) + throw new InvalidOperationException("The private artifact hard-link inspection returned an incomplete result."); + counts.AddRange(batchCounts); + } + return counts; +#else + throw new PlatformNotSupportedException("Hard-link inspection is not available for this runtime and operating system."); +#endif + } + + /// + /// Captures one private regular file's physical identity and rejects any second pathname to the same bytes. + /// + internal static string CapturePrivateFileMutationIdentity(string path, string description) + { + var fullPath = System.IO.Path.GetFullPath(path); + var hardLinkCount = ResolveHardLinkCounts(new[] { fullPath })[0]; + if (hardLinkCount != 1) + { + throw new InvalidOperationException( + $"The {description} has {hardLinkCount} hard links. Private release snapshots require one pathname per regular file."); + } + return ResolveStatus(fullPath).MutationIdentity; + } + + private static ExistingFilePhysicalStatus ReadWindowsFileStatus(SafeFileHandle handle) + { + var identity = ReadWindowsFileIdentity(handle); + if (!GetFileBasicInformationByHandleEx( + handle, + WindowsFileInfoByHandleClass.FileBasicInfo, + out var information, + checked((uint)Marshal.SizeOf()))) + throw new Win32Exception(Marshal.GetLastWin32Error()); + var changeToken = $"{information.ChangeTime:X16}"; + return new ExistingFilePhysicalStatus(identity, changeToken); + } + private static string ReadWindowsFileIdentity(SafeFileHandle handle) { if (GetFileInformationByHandleEx( @@ -100,12 +240,14 @@ internal static bool IsLegacyWindowsFileIdentitySafe(string? fileSystemName, boo !string.Equals(fileSystemName, "ReFS", StringComparison.OrdinalIgnoreCase)); #if NET8_0_OR_GREATER - private static string ReadUnixFileIdentity(SafeFileHandle handle) + private static ExistingFilePhysicalStatus ReadUnixFileStatus(SafeFileHandle handle) { if (SystemNativeFStat(handle, out var status) != 0) throw new Win32Exception(Marshal.GetLastWin32Error()); - return $"unix:{unchecked((ulong)status.Device):X16}:{unchecked((ulong)status.Inode):X16}"; + var identity = $"unix:{unchecked((ulong)status.Device):X16}:{unchecked((ulong)status.Inode):X16}"; + var changeToken = $"{status.ChangeTime:X16}:{status.ChangeTimeNanoseconds:X16}"; + return new ExistingFilePhysicalStatus(identity, changeToken); } #endif @@ -145,8 +287,19 @@ private struct WindowsFileInformation internal uint FileIndexLow; } + [StructLayout(LayoutKind.Sequential)] + private struct WindowsFileBasicInfo + { + internal long CreationTime; + internal long LastAccessTime; + internal long LastWriteTime; + internal long ChangeTime; + internal uint FileAttributes; + } + private enum WindowsFileInfoByHandleClass { + FileBasicInfo = 0, FileIdInfo = 18 } @@ -184,6 +337,14 @@ private static extern bool GetFileInformationByHandleEx( out WindowsFileIdInfo information, uint bufferSize); + [DllImport("kernel32.dll", EntryPoint = "GetFileInformationByHandleEx", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileBasicInformationByHandleEx( + SafeFileHandle file, + WindowsFileInfoByHandleClass fileInformationClass, + out WindowsFileBasicInfo information, + uint bufferSize); + [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetFileInformationByHandle( diff --git a/PowerForge/Services/GitClient.cs b/PowerForge/Services/GitClient.cs index 6ef0fcd37..d796fbfc2 100644 --- a/PowerForge/Services/GitClient.cs +++ b/PowerForge/Services/GitClient.cs @@ -8,6 +8,8 @@ public sealed class GitClient private readonly IProcessRunner _processRunner; private readonly string _gitExecutable; private readonly TimeSpan _defaultTimeout; + private readonly IReadOnlyDictionary? _environmentVariables; + private readonly bool _inheritEnvironment; /// /// Initializes a new instance of the class. @@ -15,14 +17,60 @@ public sealed class GitClient /// Optional process runner implementation. /// Optional git executable name or path. /// Optional default timeout. + /// Optional environment supplied to Git. + /// When false, Git starts from only the supplied environment. public GitClient( IProcessRunner? processRunner = null, string gitExecutable = "git", - TimeSpan? defaultTimeout = null) + TimeSpan? defaultTimeout = null, + IReadOnlyDictionary? environmentVariables = null, + bool inheritEnvironment = true) { _processRunner = processRunner ?? new ProcessRunner(); _gitExecutable = string.IsNullOrWhiteSpace(gitExecutable) ? "git" : gitExecutable; _defaultTimeout = defaultTimeout ?? TimeSpan.FromSeconds(10); + _environmentVariables = environmentVariables; + _inheritEnvironment = inheritEnvironment; + } + + /// + /// Creates a Git client pinned to the operating-system installation with an isolated, + /// non-interactive environment suitable for exact-source release attestation. + /// + /// Optional process runner implementation. + /// Optional default timeout. + /// A trusted-system Git client. + internal static GitClient CreateTrustedSystemClient( + IProcessRunner? processRunner = null, + TimeSpan? defaultTimeout = null) + { + var executable = ResolveTrustedSystemExecutable(); + var nullDevice = Path.DirectorySeparatorChar == '\\' ? "NUL" : "/dev/null"; + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GIT_CONFIG_NOSYSTEM"] = "1", + ["GIT_CONFIG_GLOBAL"] = nullDevice, + ["GIT_TERMINAL_PROMPT"] = "0", + ["GCM_INTERACTIVE"] = "Never", + ["GIT_OPTIONAL_LOCKS"] = "0", + ["GIT_CONFIG_COUNT"] = "2", + ["GIT_CONFIG_KEY_0"] = "core.hooksPath", + ["GIT_CONFIG_VALUE_0"] = nullDevice, + ["GIT_CONFIG_KEY_1"] = "core.fsmonitor", + ["GIT_CONFIG_VALUE_1"] = "false", + ["HOME"] = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ["TMPDIR"] = Path.GetTempPath(), + ["TMP"] = Path.GetTempPath(), + ["TEMP"] = Path.GetTempPath(), + ["LC_ALL"] = "C", + ["LANG"] = "C" + }; + if (Path.DirectorySeparatorChar == '\\') + environment["SystemRoot"] = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var sshAuthSocket = Environment.GetEnvironmentVariable("SSH_AUTH_SOCK"); + if (!string.IsNullOrWhiteSpace(sshAuthSocket)) + environment["SSH_AUTH_SOCK"] = sshAuthSocket; + return new GitClient(processRunner, executable, defaultTimeout, environment, inheritEnvironment: false); } /// @@ -144,7 +192,11 @@ public async Task RunRawAsync( _gitExecutable, repositoryRoot, arguments, - timeout ?? _defaultTimeout), + timeout ?? _defaultTimeout, + _environmentVariables, + captureOutput: true, + captureError: true, + inheritEnvironment: _inheritEnvironment), cancellationToken).ConfigureAwait(false); } @@ -167,7 +219,11 @@ public async Task RunAsync(GitCommandRequest request, Cancella _gitExecutable, request.WorkingDirectory, arguments, - request.Timeout ?? _defaultTimeout), + request.Timeout ?? _defaultTimeout, + _environmentVariables, + captureOutput: true, + captureError: true, + inheritEnvironment: _inheritEnvironment), cancellationToken).ConfigureAwait(false); return new GitCommandResult( @@ -182,6 +238,36 @@ public async Task RunAsync(GitCommandRequest request, Cancella result.TimedOut); } + private static string ResolveTrustedSystemExecutable() + { + string[] candidates; + if (Path.DirectorySeparatorChar == '\\') + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + candidates = new[] + { + Path.Combine(programFiles, "Git", "cmd", "git.exe"), + Path.Combine(programFiles, "Git", "bin", "git.exe"), + Path.Combine(programFilesX86, "Git", "cmd", "git.exe"), + Path.Combine(programFilesX86, "Git", "bin", "git.exe") + }; + } + else + { + candidates = new[] { "/usr/bin/git" }; + } + + var executable = candidates.FirstOrDefault(File.Exists); + if (executable is null) + { + throw new FileNotFoundException( + "Trusted exact-source Git was not found in the operating-system installation. " + + "Install Git in its standard system location before creating an Apple release checkpoint."); + } + return executable; + } + private static GitStatusSnapshot ParseStatus(GitCommandResult result) { string? branchName = null; diff --git a/PowerForge/Services/GitObjectId.cs b/PowerForge/Services/GitObjectId.cs new file mode 100644 index 000000000..71a57a5b4 --- /dev/null +++ b/PowerForge/Services/GitObjectId.cs @@ -0,0 +1,22 @@ +namespace PowerForge; + +/// Validates full Git object identifiers for SHA-1 and SHA-256 repositories. +internal static class GitObjectId +{ + internal static bool IsFull(string? value) + => !string.IsNullOrWhiteSpace(value) && + (value!.Length == 40 || value.Length == 64) && + value.All(Uri.IsHexDigit); + + internal static bool IsFullForObjectFormat(string? value, string objectFormat) + { + var expectedLength = objectFormat.Equals("sha1", StringComparison.OrdinalIgnoreCase) + ? 40 + : objectFormat.Equals("sha256", StringComparison.OrdinalIgnoreCase) + ? 64 + : throw new InvalidOperationException($"Unsupported Git object format '{objectFormat}'."); + return !string.IsNullOrWhiteSpace(value) && + value!.Length == expectedLength && + value.All(Uri.IsHexDigit); + } +} diff --git a/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs b/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs index 684c9d6cd..f90332958 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs @@ -4,6 +4,49 @@ namespace PowerForge; internal sealed partial class PowerForgeReleaseService { + private static void ValidateAppleAppInfoMutationResults( + IReadOnlyCollection requested, + IReadOnlyCollection observed) + { + if (observed.Count != requested.Count) + { + throw new InvalidOperationException( + $"App Store Connect returned {observed.Count} App Information mutation result(s) for {requested.Count} requested localization(s)."); + } + + foreach (var spec in requested) + { + var result = observed.SingleOrDefault(value => + string.Equals(value.After.Locale, spec.Locale, StringComparison.OrdinalIgnoreCase)); + if (result is null || + string.IsNullOrWhiteSpace(result.AppInfo.Id) || + string.IsNullOrWhiteSpace(result.After.Id)) + { + throw new InvalidOperationException( + $"App Store Connect did not return authoritative App Information state for locale '{spec.Locale}'."); + } + + ValidateAppleAppInfoField(spec.Locale, "name", spec.Metadata.Name, result.After.Name); + ValidateAppleAppInfoField(spec.Locale, "subtitle", spec.Metadata.Subtitle, result.After.Subtitle); + ValidateAppleAppInfoField(spec.Locale, "privacyPolicyUrl", spec.Metadata.PrivacyPolicyUrl, result.After.PrivacyPolicyUrl); + ValidateAppleAppInfoField(spec.Locale, "privacyChoicesUrl", spec.Metadata.PrivacyChoicesUrl, result.After.PrivacyChoicesUrl); + ValidateAppleAppInfoField(spec.Locale, "privacyPolicyText", spec.Metadata.PrivacyPolicyText, result.After.PrivacyPolicyText); + } + } + + private static void ValidateAppleAppInfoField( + string locale, + string field, + string? expected, + string? observed) + { + if (expected is null || string.Equals(expected, observed, StringComparison.Ordinal)) + return; + + throw new InvalidOperationException( + $"App Store Connect did not confirm App Information field '{field}' for locale '{locale}'."); + } + private static (AppStoreConnectAppInfoMetadataSpec Spec, string ConfigPath)[] LoadAppleAppInfoSpecs( PowerForgeAppleReleasePlan plan) { @@ -16,7 +59,7 @@ private static (AppStoreConnectAppInfoMetadataSpec Spec, string ConfigPath)[] Lo .Distinct(StringComparer.OrdinalIgnoreCase) .Select(path => { - var json = File.ReadAllText(path); + var json = ReadApprovedMutationInputText(plan, path); var spec = JsonSerializer.Deserialize(json, CreateJsonOptions()) ?? throw new InvalidOperationException($"Unable to deserialize App Information metadata config: {path}"); if (string.IsNullOrWhiteSpace(spec.AppId)) diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs index deef02d68..40f311d0e 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs @@ -1,6 +1,3 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - namespace PowerForge; internal sealed partial class PowerForgeReleaseService @@ -15,7 +12,7 @@ private static void ApplyAppleAction( throw new InvalidOperationException("The selected Apple action requires an AppleApps release configuration."); if (!request.PlanOnly && !request.ValidateOnly && - RequiresExplicitConfirmation(request.AppleAction, options) && + (RequiresExplicitConfirmation(request.AppleAction, options) || request.AppleAdoptExistingBuild) && !request.AppleActionConfirmed) { throw new InvalidOperationException( @@ -186,10 +183,25 @@ PowerForgeAppleReleaseAction.SubmitTestFlightReview or PowerForgeAppleReleaseAction.SubmitAppReview or PowerForgeAppleReleaseAction.Release; + private static bool RequiresAppleReleaseIdentity(PowerForgeAppleReleasePlan plan) + => plan.Action == PowerForgeAppleReleaseAction.Status || + plan.Action == PowerForgeAppleReleaseAction.Doctor || + IsUploadExecution(plan) || + plan.PrepareDistribution || + plan.SyncScreenshots || + plan.SyncMetadata || + plan.CheckReleaseReadiness || + plan.DistributeTestFlight || + plan.SubmitTestFlightBetaReview || + plan.SubmitForReview || + plan.ReleaseApprovedVersion; + private static void ValidateAppleAutomation(PowerForgeAppleReleaseAutomationOptions automation) { if (string.IsNullOrWhiteSpace(automation.ReceiptPath)) throw new InvalidOperationException("AppleApps.Automation.ReceiptPath is required."); + if (string.IsNullOrWhiteSpace(automation.ReceiptHistoryPath)) + throw new InvalidOperationException("AppleApps.Automation.ReceiptHistoryPath is required."); if (string.IsNullOrWhiteSpace(automation.PlanReceiptPath)) throw new InvalidOperationException("AppleApps.Automation.PlanReceiptPath is required."); if (string.IsNullOrWhiteSpace(automation.LockPath)) @@ -266,149 +278,6 @@ private static void EnsureNoReparsePointsInExistingPath( } } - private bool TryResumeAppleUpload( - PowerForgeAppleReleasePlan plan, - PowerForgeAppleAppReleaseTargetPlan app, - PowerForgeAppleAppReleaseResult result) - { - if (!IsUploadAction(plan.Action) || !plan.Automation.Resume) - return false; - - var state = ReadAppleReleaseState(plan, app); - var platform = AssertSinglePlatformState(state, app); - if (platform.MatchedBuild is null) - return false; - if (IsTerminalAppleBuildFailure(platform.MatchedBuild.ProcessingState)) - { - throw new AppleBuildProcessingException( - $"App Store Connect already contains build {state.VersionString} ({state.BuildNumber}) " + - $"in terminal processing state '{platform.MatchedBuild.ProcessingState}' for '{app.Name}'. " + - "Diagnose the processing failure and increment the build number before uploading again.", - state); - } - - if (plan.Automation.WaitForProcessing && - !string.Equals(platform.MatchedBuild.ProcessingState, "VALID", StringComparison.OrdinalIgnoreCase)) - { - state = WaitForAppleBuild(plan, app, state); - } - - result.RemoteState = state; - result.ResumedExistingBuild = true; - result.SkippedSteps = new[] { "archive", "upload" }; - return true; - } - - private bool TryResumeDirectAppleNotarization( - PowerForgeAppleReleasePlan plan, - PowerForgeAppleAppReleaseTargetPlan app, - PowerForgeAppleAppReleaseResult result) - { - if (!IsUploadExecution(plan) || !plan.Automation.Resume || !File.Exists(plan.ReceiptPath)) - return false; - - PowerForgeAppleReleaseReceipt? priorReceipt; - try - { - priorReceipt = JsonSerializer.Deserialize( - File.ReadAllText(plan.ReceiptPath), - CreateJsonOptions()); - } - catch (JsonException) - { - return false; - } - - if (priorReceipt is null || priorReceipt.Success) - return false; - if (string.IsNullOrWhiteSpace(plan.SourceCommit) || - !string.Equals(priorReceipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) - return false; - - var prior = priorReceipt.Targets.SingleOrDefault(target => - target.Name.Equals(app.Name, StringComparison.OrdinalIgnoreCase) && - string.Equals(target.BundleId, app.BundleId, StringComparison.OrdinalIgnoreCase) && - target.Platform == app.Platform && - target.DistributionRoute == AppleDistributionRoute.DirectNotarized && - string.Equals(target.Version, app.MarketingVersion, StringComparison.OrdinalIgnoreCase) && - string.Equals(target.Build, app.BuildNumber, StringComparison.OrdinalIgnoreCase)); - if (prior is null || - !string.Equals(prior.NotarizationStatus, "Accepted", StringComparison.OrdinalIgnoreCase) || - string.IsNullOrWhiteSpace(prior.NotarizationSubmissionId) || - string.IsNullOrWhiteSpace(prior.DirectArtifactPath) || - string.IsNullOrWhiteSpace(prior.DirectArtifactSha256) || - (!File.Exists(prior.DirectArtifactPath) && !Directory.Exists(prior.DirectArtifactPath))) - { - return false; - } - - var artifactPath = Path.GetFullPath(prior.DirectArtifactPath); - var stapleCompleted = !plan.DirectDistribution.Staple || - (prior.Stapled == true && prior.StapleValidated == true); - var assessmentCompleted = !plan.DirectDistribution.Assess || - prior.GatekeeperAccepted == true; - var completed = stapleCompleted && assessmentCompleted; - if (completed) - { - var artifactSha256 = AppleNotarizationService.ComputeArtifactSha256(artifactPath); - if (!string.Equals(artifactSha256, prior.DirectArtifactSha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"The completed direct Apple artifact changed after release. Expected SHA-256 " + - $"'{prior.DirectArtifactSha256}', received '{artifactSha256}'. Archive, export, and notarize the changed artifact as a new release attempt."); - } - - static ProcessRunResult CompletedStep(string message, string executable) - => new(0, message, string.Empty, executable, TimeSpan.Zero, false); - - result.Notarization = new AppleNotarizationResult - { - ArtifactPath = artifactPath, - ArtifactSha256 = artifactSha256, - SubmissionPath = artifactPath, - SubmissionId = prior.NotarizationSubmissionId, - Status = "Accepted", - ResumedAcceptedSubmission = true, - Submission = CompletedStep("Reused the retained accepted notarization submission.", "xcrun"), - Staple = plan.DirectDistribution.Staple - ? CompletedStep("Reused completed ticket stapling.", "xcrun") - : null, - StapleValidation = plan.DirectDistribution.Staple - ? CompletedStep("Reused completed staple validation.", "xcrun") - : null, - Assessment = plan.DirectDistribution.Assess - ? CompletedStep("Reused completed Gatekeeper assessment.", "spctl") - : null - }; - result.ResumedAcceptedNotarization = true; - result.SkippedSteps = MergeAppleSkippedSteps( - result.SkippedSteps, - new[] { "archive", "export", "notarySubmission", "staple", "stapleValidation", "gatekeeperAssessment" }); - return true; - } - - if (string.IsNullOrWhiteSpace(prior.ErrorMessage)) - return false; - - result.Notarization = NotarizeDirectAppleExport( - plan, - app, - artifactPath, - prior.NotarizationSubmissionId, - prior.DirectArtifactSha256, - prior.Stapled == true); - result.ResumedAcceptedNotarization = true; - result.SkippedSteps = MergeAppleSkippedSteps( - result.SkippedSteps, - new[] { "archive", "export", "notarySubmission" }); - if (!result.Notarization.Succeeded) - { - throw CreateAppleNotarizationFailure(app, result.Notarization); - } - - return true; - } - private AppStoreConnectReleaseStateResult ReadAppleReleaseState( PowerForgeAppleReleasePlan plan, PowerForgeAppleAppReleaseTargetPlan app) @@ -515,17 +384,22 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( PowerForgeAppleReleaseCleanupReceipt cleanup, PowerForgeAppleVersionReceipt? versioning = null) { + var attemptId = Guid.NewGuid().ToString("N"); var resultByName = results.ToDictionary(static result => result.Plan.Name, StringComparer.OrdinalIgnoreCase); var remoteAction = plan.Action != PowerForgeAppleReleaseAction.Archive && plan.Action != PowerForgeAppleReleaseAction.Version && plan.Action != PowerForgeAppleReleaseAction.Cleanup; + var refreshSuccessfulMutation = HasAppleReleaseStateMutation(plan); foreach (var app in plan.Apps.Where(UsesAppStoreConnect)) { if (!resultByName.TryGetValue(app.Name, out var result)) continue; if (string.IsNullOrWhiteSpace(app.AppStoreConnectAppId)) continue; - if (remoteAction && (!result.Success || result.RemoteState is null)) + var requiresFinalReadback = plan.Action == PowerForgeAppleReleaseAction.Configured + ? refreshSuccessfulMutation + : remoteAction && (refreshSuccessfulMutation || !result.Success || result.RemoteState is null); + if (requiresFinalReadback) { try { @@ -560,9 +434,14 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( { cleanup = MergeCleanup( cleanup, - _appleArtifactService.RemoveCurrentArtifacts( + _appleArtifactService.RemoveStaleArtifacts( plan, - appStoreConnectResults.Select(static result => result.Plan))); + results.SelectMany(static result => new[] + { + result.Plan.ArchivePath, + result.Plan.ExportPath + }) + .Concat(GetProtectedAppleRecoveryArtifactPaths(plan)))); } catch (Exception exception) { @@ -603,7 +482,11 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( result?.VersionUpdate?.After.BuildNumber ?? app.BuildNumber); } } - else if (!skippedIndependentRelease && plan.Action != PowerForgeAppleReleaseAction.Cleanup) + else if (!skippedIndependentRelease && + plan.Action != PowerForgeAppleReleaseAction.Cleanup && + (RequiresAppleReleaseIdentity(plan) || + !string.IsNullOrWhiteSpace(app.MarketingVersion) || + !string.IsNullOrWhiteSpace(app.BuildNumber))) { try { @@ -635,6 +518,7 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( ? AppleReleaseDoctor.Evaluate(plan, app, result?.RemoteState?.ControlPlane) : Array.Empty()) .Concat(CreateGovernanceDiagnostics(result?.Governance)) + .Concat(CreateAdoptedBuildDiagnostics(result)) .GroupBy(static diagnostic => diagnostic.Code, StringComparer.OrdinalIgnoreCase) .Select(static group => group.First()) .ToArray(); @@ -658,6 +542,12 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( ErrorMessage = result?.ErrorMessage, BundleId = app.BundleId, Platform = app.Platform, + Configuration = app.Configuration, + ProjectPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), + IsWorkspace = app.IsWorkspace, + Scheme = app.Scheme, + ArchiveVariant = app.ArchiveVariant, + Destination = app.Destination, DistributionRoute = app.DistributionRoute, ProductRole = app.ProductRole, ParentTarget = app.ParentTarget, @@ -669,7 +559,7 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( Build = state?.BuildNumber ?? values.BuildNumber, BuildId = build?.Id, BuildProcessingState = build?.ProcessingState, - BuildUploadId = result?.Upload?.BuildUploadId, + BuildUploadId = result?.Upload?.BuildUploadId ?? result?.ResumedUploadAttestation?.BuildUploadId, DistributionVersionId = receiptVersion?.Id, DistributionState = receiptVersion?.AppStoreState ?? receiptVersion?.AppVersionState, BuildSelected = platform?.MatchedBuildSelected ?? @@ -692,15 +582,33 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( ArchiveCreated = result?.Archive?.Succeeded == true, ProjectGenerated = result?.ProjectGenerated == true, UploadPerformed = result?.Upload?.Succeeded == true, - DirectArtifactPath = result?.Notarization?.ArtifactPath, + ArchivePath = !string.IsNullOrWhiteSpace(result?.ArchiveSha256) || + !string.IsNullOrWhiteSpace(result?.ResumedUploadAttestation?.ArchiveSha256) + ? FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/') + : null, + ArchiveSha256 = result?.ArchiveSha256 ?? result?.ResumedUploadAttestation?.ArchiveSha256, + UploadAttestationAttemptId = result?.Upload?.Succeeded == true + ? result.UploadAttestationAttemptId + : result?.ResumedUploadAttestationAttemptId, + UploadExecutionSha256 = result?.Upload?.Succeeded == true || result?.ResumedUploadAttestation is not null + ? ComputeAppleUploadExecutionSha256(plan, app) + : null, + DirectArtifactPath = string.IsNullOrWhiteSpace(result?.Notarization?.ArtifactPath) + ? null + : CreatePortableDirectArtifactPath(plan, app, result!.Notarization!.ArtifactPath), DirectArtifactSha256 = result?.Notarization?.ArtifactSha256, + DirectExecutionSha256 = result?.Notarization is null + ? null + : ComputeDirectExecutionSha256(plan, app), NotarizationSubmissionId = result?.Notarization?.SubmissionId, + NotarizationSubmissionSha256 = result?.Notarization?.SubmissionSha256, NotarizationStatus = result?.Notarization?.Status, Stapled = result?.Notarization?.Staple?.Succeeded, StapleValidated = result?.Notarization?.StapleValidation?.Succeeded, GatekeeperAccepted = result?.Notarization?.Assessment?.Succeeded, ResumedAcceptedNotarization = result?.ResumedAcceptedNotarization == true, ResumedExistingBuild = result?.ResumedExistingBuild == true, + AdoptedExistingBuild = result?.AdoptedExistingBuild == true, SkippedSteps = result?.SkippedSteps ?? Array.Empty(), Diagnostics = diagnostics, NextActions = nextActions @@ -718,9 +626,12 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( : Array.Empty(); var receipt = new PowerForgeAppleReleaseReceipt { + AttemptId = attemptId, Action = plan.Action, SourceCommit = plan.SourceCommit, + AdoptExistingBuild = plan.AdoptExistingBuild, PlanOnly = false, + OperationPhase = "Completed", CheckedAt = DateTimeOffset.UtcNow, Success = results.Length == plan.Apps.Length && results.All(static result => result.Success) && @@ -742,10 +653,47 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( }; if (plan.Automation.WriteReceipt) - WriteAppleReceipt(plan.ProjectRoot, plan.ReceiptPath, receipt); + _appleReceiptStore.WriteAttempt(plan, receipt); return receipt; } + private static bool HasAppleRemoteMutation(PowerForgeAppleReleasePlan plan) + => plan.PrepareDistribution || + plan.SyncScreenshots || + plan.SyncMetadata || + plan.SyncAppInfo || + plan.DistributeTestFlight || + plan.SubmitTestFlightBetaReview || + plan.SubmitForReview || + plan.ReleaseApprovedVersion; + + private static bool HasAppleReleaseStateMutation(PowerForgeAppleReleasePlan plan) + => plan.PrepareDistribution || + plan.SyncScreenshots || + plan.SyncMetadata || + plan.DistributeTestFlight || + plan.SubmitTestFlightBetaReview || + plan.SubmitForReview || + plan.ReleaseApprovedVersion; + + private static PowerForgeAppleReleaseDiagnostic[] CreateAdoptedBuildDiagnostics( + PowerForgeAppleAppReleaseResult? result) + => result?.AdoptedExistingBuild == true + ? new[] + { + new PowerForgeAppleReleaseDiagnostic + { + Severity = "warning", + Category = "provenance", + Code = "APPLE_BUILD_ADOPTED_WITHOUT_UPLOAD_ATTESTATION", + Summary = "The existing App Store Connect build was deliberately adopted without a matching local upload attestation.", + Evidence = "The operator supplied --apple-adopt-existing-build and explicit Apple-action confirmation.", + Action = "Retain independent evidence for the adopted binary and use a new build number for future source changes.", + Retryable = false + } + } + : Array.Empty(); + private static PowerForgeAppleReleaseDiagnostic[] CreateGovernanceDiagnostics(AppStoreConnectGovernancePlan? plan) { if (plan is null || plan.IsConverged) return Array.Empty(); @@ -817,38 +765,6 @@ private static PowerForgeAppleReleaseCleanupReceipt MergeCleanup( FreeSpaceGB = second.FreeSpaceGB ?? first.FreeSpaceGB }; - private static void WriteAppleReceipt( - string projectRoot, - string path, - PowerForgeAppleReleaseReceipt receipt) - { - EnsurePathWithinProjectRoot(projectRoot, path, "AppleApps.Automation.ReceiptPath"); - var directory = Path.GetDirectoryName(path); - if (!string.IsNullOrWhiteSpace(directory)) - Directory.CreateDirectory(directory); - var options = CreateJsonOptions(); - options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; - options.WriteIndented = true; - var payload = JsonSerializer.Serialize(receipt, options); - var temporaryPath = Path.Combine( - directory ?? projectRoot, - $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); - try - { - File.WriteAllText(temporaryPath, payload); - if (File.Exists(path)) - File.Replace(temporaryPath, path, destinationBackupFileName: null, ignoreMetadataErrors: true); - else - File.Move(temporaryPath, path); - } - finally - { - if (File.Exists(temporaryPath)) - File.Delete(temporaryPath); - } - } - private static AppStoreConnectReleaseStateResult GetAppleReleaseState(AppStoreConnectReleaseStateRequest request) { if (request is null) diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs index 293a06259..f51b9c543 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs @@ -8,6 +8,7 @@ private AppleNotarizationResult NotarizeDirectAppleExport( string? artifactPath = null, string? acceptedSubmissionId = null, string? expectedArtifactSha256 = null, + string? acceptedSubmissionSha256 = null, bool staplingCompleted = false) { var result = _notarizeAppleArtifact(new AppleNotarizationRequest @@ -16,13 +17,18 @@ private AppleNotarizationResult NotarizeDirectAppleExport( XcrunExecutable = plan.DirectDistribution.XcrunExecutable, DittoExecutable = plan.DirectDistribution.DittoExecutable, SpctlExecutable = plan.DirectDistribution.SpctlExecutable, + RequireTrustedSystemTools = !string.IsNullOrWhiteSpace(plan.SourceCommit), KeychainProfile = plan.DirectDistribution.KeychainProfile, ApiKeyPath = plan.AppStoreConnectApiKeyPath, ApiKeyId = plan.AppStoreConnectApiKeyId, ApiIssuerId = plan.AppStoreConnectApiIssuerId, AcceptedSubmissionId = acceptedSubmissionId, ExpectedArtifactSha256 = expectedArtifactSha256, + AcceptedSubmissionSha256 = acceptedSubmissionSha256, StaplingCompleted = staplingCompleted, + AmbiguousCheckpoint = checkpoint => WriteAppleNotarizationAmbiguity(plan, app, checkpoint), + AcceptedCheckpoint = checkpoint => WriteAppleNotarizationAcceptance(plan, app, checkpoint), + StapledCheckpoint = checkpoint => WriteAppleNotarizationStapled(plan, app, checkpoint), Timeout = TimeSpan.FromSeconds(plan.DirectDistribution.TimeoutSeconds), Staple = plan.DirectDistribution.Staple, Assess = plan.DirectDistribution.Assess @@ -30,7 +36,7 @@ private AppleNotarizationResult NotarizeDirectAppleExport( return result; } - private static string ResolveDirectAppleArtifactPath(string exportPath) + internal static string ResolveDirectAppleArtifactPath(string exportPath) { if (!Directory.Exists(exportPath)) throw new DirectoryNotFoundException($"Developer ID export path was not found: {exportPath}"); @@ -51,6 +57,30 @@ private static string ResolveDirectAppleArtifactPath(string exportPath) return Path.GetFullPath(artifacts[0]); } + private static string? MapDirectExportOutputPath( + string privateExportPath, + string publishedExportPath, + string? outputPath) + { + if (string.IsNullOrWhiteSpace(outputPath)) + return outputPath; + + var fullPrivateRoot = Path.GetFullPath(privateExportPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullOutputPath = Path.GetFullPath(outputPath!); + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!fullOutputPath.Equals(fullPrivateRoot, comparison) && + !fullOutputPath.StartsWith(fullPrivateRoot + Path.DirectorySeparatorChar, comparison)) + { + return outputPath; + } + + var relative = FrameworkCompatibility.GetRelativePath(fullPrivateRoot, fullOutputPath); + return Path.GetFullPath(Path.Combine(publishedExportPath, relative)); + } + private static InvalidOperationException CreateAppleNotarizationFailure( PowerForgeAppleAppReleaseTargetPlan app, AppleNotarizationResult result) diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs new file mode 100644 index 000000000..002ee850a --- /dev/null +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -0,0 +1,368 @@ +namespace PowerForge; + +internal sealed partial class PowerForgeReleaseService +{ + private void PrepareAppleReceiptJournalForMutation( + PowerForgeAppleReleasePlan plan, + string? expectedPlanSha256) + { + if (!plan.Automation.WriteReceipt) + return; + + _appleReceiptStore.Validate(plan); + if (!HasAppleExecutionMutation(plan)) + return; + + _appleReceiptStore.WriteAttempt(plan, new PowerForgeAppleReleaseReceipt + { + Action = plan.Action, + SourceCommit = plan.SourceCommit, + PlanSha256 = expectedPlanSha256, + OperationPhase = "Started", + Success = false, + ErrorMessage = "Apple release operation started; inspect later receipts and remote state before retrying.", + Targets = plan.Apps.Select(app => CreateAppleCheckpointTarget(plan, app)).ToArray() + }); + } + + private void WriteAppleUploadAttestation( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + PowerForgeAppleAppReleaseResult result) + { + if (!plan.Automation.WriteReceipt || result.Upload?.Succeeded != true) + return; + + var attemptId = Guid.NewGuid().ToString("N"); + result.UploadAttestationAttemptId = attemptId; + var target = CreateAppleCheckpointTarget(plan, app); + target.UploadPerformed = true; + target.ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'); + target.ArchiveSha256 = result.ArchiveSha256; + target.BuildUploadId = result.Upload.BuildUploadId; + target.UploadAttestationAttemptId = attemptId; + target.UploadExecutionSha256 = ComputeAppleUploadExecutionSha256(plan, app); + _appleReceiptStore.WriteAttempt(plan, new PowerForgeAppleReleaseReceipt + { + AttemptId = attemptId, + Action = plan.Action, + SourceCommit = plan.SourceCommit, + OperationPhase = "UploadAttested", + Success = true, + Targets = new[] { target } + }); + } + + private void WriteAppleUploadAmbiguity( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + PowerForgeAppleAppReleaseResult result, + string detail) + { + if (!plan.Automation.WriteReceipt) + return; + + var attemptId = Guid.NewGuid().ToString("N"); + var target = CreateAppleCheckpointTarget(plan, app); + target.UploadPerformed = true; + target.ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'); + target.ArchiveSha256 = result.ArchiveSha256 ?? app.ExpectedArchiveSha256; + target.BuildUploadId = result.Upload?.BuildUploadId; + target.UploadAttestationAttemptId = attemptId; + target.UploadExecutionSha256 = ComputeAppleUploadExecutionSha256(plan, app); + target.ErrorMessage = $"App Store upload state is ambiguous for '{app.Name}'; reconcile App Store Connect before retrying. {detail}"; + _appleReceiptStore.WriteAttempt(plan, new PowerForgeAppleReleaseReceipt + { + AttemptId = attemptId, + Action = plan.Action, + SourceCommit = plan.SourceCommit, + OperationPhase = "UploadAmbiguous", + Success = false, + ErrorMessage = target.ErrorMessage, + Targets = new[] { target } + }); + } + + private void WriteAppleNotarizationAttestation( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + PowerForgeAppleAppReleaseResult result) + { + if (!plan.Automation.WriteReceipt || result.Notarization is null) + return; + + var target = CreateAppleCheckpointTarget(plan, app); + target.ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'); + target.ArchiveSha256 = result.ArchiveSha256 ?? app.ExpectedArchiveSha256; + target.DirectArtifactPath = CreatePortableDirectArtifactPath(plan, app, result.Notarization.ArtifactPath); + target.DirectArtifactSha256 = result.Notarization.ArtifactSha256; + target.DirectExecutionSha256 = ComputeDirectExecutionSha256(plan, app); + target.NotarizationSubmissionId = result.Notarization.SubmissionId; + target.NotarizationSubmissionSha256 = result.Notarization.SubmissionSha256; + target.NotarizationStatus = result.Notarization.Status; + target.Stapled = result.Notarization.Staple?.Succeeded; + target.StapleValidated = result.Notarization.StapleValidation?.Succeeded; + target.GatekeeperAccepted = result.Notarization.Assessment?.Succeeded; + target.ErrorMessage = result.Notarization.Succeeded + ? null + : $"Direct notarization post-processing did not complete for '{app.Name}'."; + _appleReceiptStore.WriteAttempt(plan, new PowerForgeAppleReleaseReceipt + { + Action = plan.Action, + SourceCommit = plan.SourceCommit, + OperationPhase = "NotarizationAttested", + Success = result.Notarization.Succeeded, + ErrorMessage = result.Notarization.Succeeded + ? null + : $"Direct notarization post-processing did not complete for '{app.Name}'.", + Targets = new[] { target } + }); + } + + private void WriteAppleNotarizationAcceptance( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + AppleNotarizationAcceptedCheckpoint checkpoint) + { + if (!plan.Automation.WriteReceipt) + return; + + var target = CreateAppleCheckpointTarget(plan, app); + target.ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'); + target.ArchiveSha256 = app.ExpectedArchiveSha256; + target.DirectArtifactPath = CreatePortableDirectArtifactPath(plan, app, checkpoint.ArtifactPath); + target.DirectArtifactSha256 = checkpoint.ArtifactSha256; + target.DirectExecutionSha256 = ComputeDirectExecutionSha256(plan, app); + target.NotarizationSubmissionId = checkpoint.SubmissionId; + target.NotarizationSubmissionSha256 = checkpoint.SubmissionSha256; + target.NotarizationStatus = checkpoint.Status; + target.ErrorMessage = $"Apple notarization accepted for '{app.Name}', but local post-processing is incomplete."; + _appleReceiptStore.WriteAttempt(plan, new PowerForgeAppleReleaseReceipt + { + Action = plan.Action, + SourceCommit = plan.SourceCommit, + OperationPhase = "NotarizationAccepted", + Success = false, + ErrorMessage = target.ErrorMessage, + Targets = new[] { target } + }); + } + + private void WriteAppleNotarizationAmbiguity( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + AppleNotarizationAmbiguousCheckpoint checkpoint) + { + if (!plan.Automation.WriteReceipt) + return; + + var target = CreateAppleCheckpointTarget(plan, app); + target.ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'); + target.ArchiveSha256 = app.ExpectedArchiveSha256; + target.DirectArtifactPath = CreatePortableDirectArtifactPath(plan, app, checkpoint.ArtifactPath); + target.DirectArtifactSha256 = checkpoint.ArtifactSha256; + target.DirectExecutionSha256 = ComputeDirectExecutionSha256(plan, app); + target.NotarizationSubmissionId = checkpoint.SubmissionId; + target.NotarizationSubmissionSha256 = checkpoint.SubmissionSha256; + target.NotarizationStatus = checkpoint.Status; + target.ErrorMessage = $"Apple notarization submission state is ambiguous for '{app.Name}'; reconcile Apple notary history before retrying."; + _appleReceiptStore.WriteAttempt(plan, new PowerForgeAppleReleaseReceipt + { + Action = plan.Action, + SourceCommit = plan.SourceCommit, + OperationPhase = "NotarizationAmbiguous", + Success = false, + ErrorMessage = target.ErrorMessage, + Targets = new[] { target } + }); + } + + private void WriteAppleNotarizationStapled( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + AppleNotarizationStapledCheckpoint checkpoint) + { + if (!plan.Automation.WriteReceipt) + return; + + var target = CreateAppleCheckpointTarget(plan, app); + target.ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'); + target.ArchiveSha256 = app.ExpectedArchiveSha256; + target.DirectArtifactPath = CreatePortableDirectArtifactPath(plan, app, checkpoint.ArtifactPath); + target.DirectArtifactSha256 = checkpoint.ArtifactSha256; + target.DirectExecutionSha256 = ComputeDirectExecutionSha256(plan, app); + target.NotarizationSubmissionId = checkpoint.SubmissionId; + target.NotarizationSubmissionSha256 = checkpoint.SubmissionSha256; + target.NotarizationStatus = checkpoint.Status; + target.Stapled = true; + target.StapleValidated = true; + target.ErrorMessage = $"Apple notarization was stapled and validated for '{app.Name}', but Gatekeeper assessment is incomplete."; + _appleReceiptStore.WriteAttempt(plan, new PowerForgeAppleReleaseReceipt + { + Action = plan.Action, + SourceCommit = plan.SourceCommit, + OperationPhase = "NotarizationStapled", + Success = false, + ErrorMessage = target.ErrorMessage, + Targets = new[] { target } + }); + } + + private static PowerForgeAppleReleaseTargetReceipt CreateAppleCheckpointTarget( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app) + => new() + { + Name = app.Name, + BundleId = app.BundleId, + Platform = app.Platform, + Configuration = app.Configuration, + ProjectPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), + IsWorkspace = app.IsWorkspace, + Scheme = app.Scheme, + ArchiveVariant = app.ArchiveVariant, + Destination = app.Destination, + DistributionRoute = app.DistributionRoute, + ProductRole = app.ProductRole, + ParentTarget = app.ParentTarget, + Capabilities = app.Capabilities, + TestFlightPolicy = app.TestFlightPolicy, + AppId = app.AppStoreConnectAppId, + Version = app.MarketingVersion, + Build = app.BuildNumber + }; + + internal static string ComputeDirectExecutionSha256( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app) + { + if (plan is null) + throw new ArgumentNullException(nameof(plan)); + if (app is null) + throw new ArgumentNullException(nameof(app)); + + return ComputeStableSha256(new + { + plan.SourceCommit, + ReleaseConfiguration = plan.Configuration, + plan.XcodeBuildExecutable, + plan.AllowProvisioningUpdates, + plan.ManageAppVersionAndBuildNumber, + plan.UploadSymbols, + plan.GenerateAppStoreInformation, + plan.SigningStyle, + app.Name, + app.BundleId, + app.Platform, + app.ArchiveVariant, + app.DistributionRoute, + TargetConfiguration = app.Configuration, + ProjectPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), + app.IsWorkspace, + app.Scheme, + app.Destination, + ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'), + ExportPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ExportPath).Replace('\\', '/'), + app.TeamId, + app.MarketingVersion, + app.BuildNumber, + app.GenerateProjectIfMissing, + app.RegenerateProject, + app.XcodeGenExecutable, + app.ProjectGenerationTimeoutSeconds, + RequiredEmbeddedBundleIds = app.RequiredEmbeddedBundleIds.OrderBy(static value => value, StringComparer.Ordinal).ToArray(), + RequiredPrivacyUsageDescriptionKeys = app.RequiredPrivacyUsageDescriptionKeys.OrderBy(static value => value, StringComparer.Ordinal).ToArray(), + DirectDistribution = new + { + plan.DirectDistribution.ExportMethod, + plan.DirectDistribution.XcrunExecutable, + plan.DirectDistribution.DittoExecutable, + plan.DirectDistribution.SpctlExecutable, + plan.DirectDistribution.KeychainProfile, + plan.DirectDistribution.TimeoutSeconds, + plan.DirectDistribution.Staple, + plan.DirectDistribution.Assess + }, + plan.AppStoreConnectApiKeyId, + plan.AppStoreConnectApiIssuerId + }); + } + + /// Computes the exact execution-policy identity required to reuse an App Store upload. + internal static string ComputeAppleUploadExecutionSha256( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app) + { + if (plan is null) + throw new ArgumentNullException(nameof(plan)); + if (app is null) + throw new ArgumentNullException(nameof(app)); + + return ComputeStableSha256(new + { + plan.SourceCommit, + ReleaseConfiguration = plan.Configuration, + plan.XcodeBuildExecutable, + plan.AllowProvisioningUpdates, + plan.ManageAppVersionAndBuildNumber, + plan.UploadSymbols, + plan.GenerateAppStoreInformation, + plan.SigningStyle, + app.Name, + app.BundleId, + app.Platform, + app.ArchiveVariant, + app.DistributionRoute, + TargetConfiguration = app.Configuration, + ProjectPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), + app.IsWorkspace, + app.Scheme, + app.Destination, + ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'), + ExportPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ExportPath).Replace('\\', '/'), + app.TeamId, + app.GenerateProjectIfMissing, + app.RegenerateProject, + app.XcodeGenExecutable, + app.ProjectGenerationTimeoutSeconds, + RequiredEmbeddedBundleIds = app.RequiredEmbeddedBundleIds.OrderBy(static value => value, StringComparer.Ordinal).ToArray(), + RequiredPrivacyUsageDescriptionKeys = app.RequiredPrivacyUsageDescriptionKeys.OrderBy(static value => value, StringComparer.Ordinal).ToArray() + }); + } + + private static string CreatePortableDirectArtifactPath( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + string artifactPath) + { + var validated = ValidateDirectRecoveryArtifactPath(plan, app, artifactPath); + return FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, validated).Replace('\\', '/'); + } + + private static bool HasAppleExecutionMutation(PowerForgeAppleReleasePlan plan) + => plan.Action == PowerForgeAppleReleaseAction.Version || + plan.Action == PowerForgeAppleReleaseAction.Cleanup || + plan.Archive || + plan.Upload || + HasAppleRemoteMutation(plan); + + private static void VerifyAppleArchiveUnchangedAfterUpload( + PowerForgeAppleAppReleaseTargetPlan app, + PowerForgeAppleAppReleaseResult result) + { + // A successful real xcodebuild archive always leaves the archive on disk. A null value is + // retained only for injected test/process adapters that do not materialize their artifact. + if (string.IsNullOrWhiteSpace(result.ArchiveSha256)) + return; + if (!File.Exists(app.ArchivePath) && !Directory.Exists(app.ArchivePath)) + throw new InvalidOperationException($"The archive disappeared while uploading '{app.Name}': {app.ArchivePath}"); + + var afterUpload = AppleNotarizationService.ComputeArtifactSha256(app.ArchivePath); + if (!afterUpload.Equals(result.ArchiveSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The archive for '{app.Name}' changed during upload. Expected SHA-256 " + + $"'{result.ArchiveSha256}', received '{afterUpload}'. The upload cannot be used as release evidence."); + } + } +} diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs new file mode 100644 index 000000000..4499ce0d5 --- /dev/null +++ b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs @@ -0,0 +1,244 @@ +namespace PowerForge; + +internal sealed partial class PowerForgeReleaseService +{ + private static void AddAppleScreenshotProtectedPaths( + string projectRoot, + IEnumerable configPaths, + ICollection<(string Name, string Path, bool IsDirectory)> protectedPaths) + { + var comparer = FrameworkCompatibility.GetPathStringComparisonForPath(projectRoot) == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + foreach (var configPath in configPaths.Distinct(comparer)) + { + var bytes = File.ReadAllBytes(configPath); + string json; + using (var stream = new MemoryStream(bytes, writable: false)) + using (var reader = new StreamReader(stream, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + json = reader.ReadToEnd(); + var spec = System.Text.Json.JsonSerializer.Deserialize(json, CreateJsonOptions()) + ?? throw new InvalidOperationException($"Unable to deserialize screenshot sync config: {configPath}"); + var baseDirectory = Path.GetDirectoryName(configPath) ?? projectRoot; + foreach (var set in (spec.ScreenshotSets ?? Array.Empty()) + .Where(static set => !string.IsNullOrWhiteSpace(set.Path))) + { + var setPath = ResolveOutputPath(baseDirectory, set.Path); + if (!Directory.Exists(setPath)) + continue; + var filter = string.IsNullOrWhiteSpace(set.Filter) ? "*.png" : set.Filter.Trim(); + var maxCount = set.MaxCount <= 0 ? 10 : set.MaxCount; + foreach (var screenshotPath in AppStoreConnectScreenshotFileSelector.Select(setPath, filter, maxCount)) + { + protectedPaths.Add(( + $"screenshot input {set.ScreenshotDisplayType}", + screenshotPath, + false)); + } + } + + if (spec.Quality?.RequireApprovalManifest != true || + string.IsNullOrWhiteSpace(spec.Quality.ApprovalManifestPath)) + continue; + var approvalPath = ResolveOutputPath(baseDirectory, spec.Quality.ApprovalManifestPath!); + protectedPaths.Add(("screenshot approval manifest", approvalPath, false)); + } + } + + private static void ValidateAppleAutomationOutputPaths( + string receiptPath, + string receiptHistoryPath, + string planReceiptPath, + string lockPath, + IEnumerable<(string Name, string Path, bool IsDirectory)> protectedPaths) + { + var protectedEntries = protectedPaths + .Where(static entry => !string.IsNullOrWhiteSpace(entry.Path)) + .Select(static entry => (entry.Name, Path: Path.GetFullPath(entry.Path), entry.IsDirectory)) + .ToArray(); + ValidateAppleReleasePathIsolation(protectedEntries); + + var history = Path.GetFullPath(receiptHistoryPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var files = new[] + { + (Name: "ReceiptPath", Path: Path.GetFullPath(receiptPath)), + (Name: "PlanReceiptPath", Path: Path.GetFullPath(planReceiptPath)), + (Name: "LockPath", Path: Path.GetFullPath(lockPath)), + (Name: "ReceiptJournalLockPath", Path: AppleReleaseReceiptJournalLease.CreateLockPath(receiptPath)), + (Name: "ReceiptHistoryJournalLockPath", Path: AppleReleaseReceiptJournalLease.CreateLockPath(receiptHistoryPath)) + }; + + for (var index = 0; index < files.Length; index++) + { + for (var siblingIndex = index + 1; siblingIndex < files.Length; siblingIndex++) + { + if (!PathsOverlap(files[index].Path, files[siblingIndex].Path)) + continue; + throw new InvalidOperationException( + $"Apple automation output files must not equal, contain, or be contained by each other: " + + $"{files[index].Name}, {files[siblingIndex].Name}."); + } + } + + foreach (var file in files) + { + var comparison = GetAppleOutputPathComparison(file.Path, history); + var candidate = file.Path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (candidate.Equals(history, comparison) || + candidate.StartsWith(history + Path.DirectorySeparatorChar, comparison) || + history.StartsWith(candidate + Path.DirectorySeparatorChar, comparison)) + { + throw new InvalidOperationException( + $"AppleApps.Automation.ReceiptHistoryPath must not equal, contain, or be contained by {file.Name}."); + } + } + + var outputs = files + .Select(static file => (file.Name, file.Path, IsDirectory: false)) + .Append((Name: "ReceiptHistoryPath", Path: history, IsDirectory: true)) + .ToArray(); + foreach (var output in outputs) + { + foreach (var protectedPath in protectedEntries) + { + if (!PathsOverlap(output.Path, protectedPath.Path)) + continue; + throw new InvalidOperationException( + $"Apple automation output {output.Name} must not equal, contain, or be contained by " + + $"release input/artifact path {protectedPath.Name}: {Path.GetFullPath(protectedPath.Path)}"); + } + } + } + + private static void ValidateAppleReleasePathIsolation( + IReadOnlyList<(string Name, string Path, bool IsDirectory)> protectedPaths) + { + var archiveRoot = protectedPaths.Single(static entry => entry.Name == "archive root"); + var exportRoot = protectedPaths.Single(static entry => entry.Name == "export root"); + if (PathsOverlap(archiveRoot.Path, exportRoot.Path)) + { + throw new InvalidOperationException( + "Apple archive and export roots must not equal, contain, or be contained by each other."); + } + + var artifacts = protectedPaths + .Where(static entry => entry.Name.EndsWith(" archive", StringComparison.Ordinal) || + entry.Name.EndsWith(" export", StringComparison.Ordinal)) + .ToArray(); + var sources = protectedPaths + .Where(static entry => entry.Name != "archive root" && + entry.Name != "export root" && + !entry.Name.EndsWith(" archive", StringComparison.Ordinal) && + !entry.Name.EndsWith(" export", StringComparison.Ordinal)) + .ToArray(); + + for (var index = 0; index < artifacts.Length; index++) + { + for (var siblingIndex = index + 1; siblingIndex < artifacts.Length; siblingIndex++) + { + if (!PathsOverlap(artifacts[index].Path, artifacts[siblingIndex].Path)) + continue; + throw new InvalidOperationException( + $"Apple release artifact paths must not equal, contain, or be contained by each other: " + + $"{artifacts[index].Name}, {artifacts[siblingIndex].Name}."); + } + + foreach (var source in sources) + { + if (!PathsOverlap(artifacts[index].Path, source.Path)) + continue; + throw new InvalidOperationException( + $"Apple release artifact path {artifacts[index].Name} must not equal, contain, or be contained by " + + $"release input path {source.Name}: {source.Path}"); + } + } + + foreach (var source in sources) + { + if (PathsOverlap(archiveRoot.Path, source.Path) || PathsOverlap(exportRoot.Path, source.Path)) + { + throw new InvalidOperationException( + $"Apple archive/export roots must not equal, contain, or be contained by release input path " + + $"{source.Name}: {source.Path}"); + } + } + } + + private static bool PathsOverlap(string first, string second) + { + var comparison = GetAppleOutputPathComparison(first, second); + var left = Path.GetFullPath(first).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var right = Path.GetFullPath(second).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return left.Equals(right, comparison) || + left.StartsWith(right + Path.DirectorySeparatorChar, comparison) || + right.StartsWith(left + Path.DirectorySeparatorChar, comparison); + } + + private static StringComparison GetAppleOutputPathComparison(string first, string second) + => FrameworkCompatibility.GetPathStringComparisonForPath(first) == StringComparison.OrdinalIgnoreCase || + FrameworkCompatibility.GetPathStringComparisonForPath(second) == StringComparison.OrdinalIgnoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + private static void VerifyExpectedAppleCheckpointArchives(PowerForgeAppleReleasePlan plan) + { + foreach (var app in plan.Apps.Where(static candidate => !string.IsNullOrWhiteSpace(candidate.ExpectedArchiveSha256))) + { + if (!File.Exists(app.ArchivePath) && !Directory.Exists(app.ArchivePath)) + { + throw new FileNotFoundException( + $"The checkpointed Apple archive for '{app.Name}' was not found: {app.ArchivePath}", + app.ArchivePath); + } + + var actual = AppleNotarizationService.ComputeArtifactSha256(app.ArchivePath); + if (!actual.Equals(app.ExpectedArchiveSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The checkpointed Apple archive for '{app.Name}' changed before publish. Expected SHA-256 " + + $"'{app.ExpectedArchiveSha256}', received '{actual}'. Rebuild and approve a new exact checkpoint."); + } + } + } + + private static string ValidateDirectRecoveryArtifactPath( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + string storedPath) + { + var artifactPath = Path.GetFullPath(Path.IsPathRooted(storedPath) + ? storedPath + : Path.Combine(plan.ProjectRoot, storedPath)); + var exportRoot = Path.GetFullPath(app.ExportPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!AppleReleaseArtifactService.IsWithinRoot(artifactPath, exportRoot)) + { + throw new InvalidOperationException( + $"Direct Apple recovery artifact for '{app.Name}' is outside its current export root: {artifactPath}"); + } + + EnsurePathHasNoLinkedTraversal(plan.ProjectRoot, artifactPath, $"Direct Apple recovery artifact for '{app.Name}'"); + return artifactPath; + } + + private static void EnsurePathHasNoLinkedTraversal(string projectRoot, string path, string name) + { + var root = Path.GetFullPath(projectRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var current = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var comparison = GetAppleOutputPathComparison(current, root); + if (!AppleReleaseArtifactService.IsWithinRoot(current, root)) + throw new InvalidOperationException($"{name} is outside AppleApps.ProjectRoot: {current}"); + + while (true) + { + if ((File.Exists(current) || Directory.Exists(current)) && + (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException($"{name} traverses a symbolic link or reparse point: {current}"); + if (current.Equals(root, comparison)) + break; + current = Path.GetDirectoryName(current) + ?? throw new InvalidOperationException($"Unable to validate {name}: {path}"); + } + } +} diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs new file mode 100644 index 000000000..44c2b9778 --- /dev/null +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -0,0 +1,536 @@ +namespace PowerForge; + +internal sealed partial class PowerForgeReleaseService +{ + private string[] GetProtectedAppleRecoveryArtifactPaths(PowerForgeAppleReleasePlan plan) + { + if (!plan.Automation.WriteReceipt) + return Array.Empty(); + + var protectedPaths = new List(); + var seenSubmissions = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var receipt in _appleReceiptStore.ReadAll(plan)) + { + foreach (var target in receipt.Targets) + { + if (target.DistributionRoute != AppleDistributionRoute.DirectNotarized || + string.IsNullOrWhiteSpace(target.NotarizationSubmissionId)) + { + continue; + } + + var key = string.Join( + "|", + target.Name, + target.BundleId, + target.Platform, + target.NotarizationSubmissionId); + if (!seenSubmissions.Add(key)) + continue; + + var accepted = string.Equals(target.NotarizationStatus, "Accepted", StringComparison.OrdinalIgnoreCase); + var stapleCompleted = !plan.DirectDistribution.Staple || + HasDurablePublishedStaple(target); + var assessmentCompleted = !plan.DirectDistribution.Assess || + target.GatekeeperAccepted == true; + if (!accepted || (stapleCompleted && assessmentCompleted) || + string.IsNullOrWhiteSpace(target.DirectArtifactPath)) + { + continue; + } + + var app = plan.Apps.SingleOrDefault(candidate => + candidate.Name.Equals(target.Name, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.BundleId, target.BundleId, StringComparison.OrdinalIgnoreCase) && + candidate.Platform == target.Platform && + candidate.DistributionRoute == target.DistributionRoute); + if (app is null || receipt.SchemaVersion < 4 || string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + continue; + var artifactPath = ValidateDirectRecoveryArtifactPath(plan, app, target.DirectArtifactPath!); + if (File.Exists(artifactPath) || Directory.Exists(artifactPath)) + protectedPaths.Add(artifactPath); + } + } + + return protectedPaths.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private bool TryResumeAppleUpload( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + PowerForgeAppleAppReleaseResult result) + { + if (!IsUploadExecution(plan) || !plan.Automation.Resume) + return false; + if (string.IsNullOrWhiteSpace(app.AppStoreConnectAppId)) + return false; + + var state = ReadAppleReleaseState(plan, app); + var platform = AssertSinglePlatformState(state, app); + var attestation = FindVerifiedAppleUploadAttestation(plan, app, state, platform.MatchedBuild); + if (platform.MatchedBuild is null) + { + if (attestation is not null) + { + if (string.IsNullOrWhiteSpace(attestation.Target.BuildUploadId)) + { + throw new InvalidOperationException( + $"Apple recovery for '{app.Name}' found a durable successful upload attestation without an App Store Connect Delivery UUID. " + + "The remote mutation is ambiguous and PowerForge will not upload the archive again. Inspect App Store Connect, wait for the " + + "uniquely matching build to become visible, then deliberately adopt it with --apple-adopt-existing-build " + + "--confirm-apple-action."); + } + EnsureExplicitAppleRecoveryAdoption(plan, app, "an attested upload that is not yet visible as a build"); + if (plan.Automation.WaitForProcessing) + state = WaitForAppleBuild(plan, app, state, attestation.Target.BuildUploadId); + else + EnsureAppleBuildUploadIsNotTerminal(plan, app, state, attestation.Target.BuildUploadId!); + PopulateResumedAppleUpload(result, state, attestation, adopted: false); + return true; + } + if (plan.AdoptExistingBuild) + { + throw new InvalidOperationException( + $"No exact App Store Connect build exists to adopt for '{app.Name}' " + + $"at {state.VersionString} ({state.BuildNumber}). Remove --apple-adopt-existing-build and upload a new archive."); + } + + return false; + } + if (IsTerminalAppleBuildFailure(platform.MatchedBuild.ProcessingState)) + { + throw new AppleBuildProcessingException( + $"App Store Connect already contains build {state.VersionString} ({state.BuildNumber}) " + + $"in terminal processing state '{platform.MatchedBuild.ProcessingState}' for '{app.Name}'. " + + "Diagnose the processing failure and increment the build number before uploading again.", + state); + } + + if (!plan.AdoptExistingBuild) + { + var evidence = attestation is null + ? "no immutable local upload receipt can independently authorize it" + : "the local upload receipt is continuity evidence, not authority against another process running as this account"; + throw new InvalidOperationException( + $"App Store Connect already contains build {state.VersionString} ({state.BuildNumber}) for '{app.Name}', " + + $"but {evidence}. Increment the build number and upload a new archive, or deliberately adopt the existing build with " + + "--apple-adopt-existing-build --confirm-apple-action after verifying it outside PowerForge."); + } + + if (plan.Automation.WaitForProcessing && + !string.Equals(platform.MatchedBuild.ProcessingState, "VALID", StringComparison.OrdinalIgnoreCase)) + { + state = WaitForAppleBuild(plan, app, state); + } + + PopulateResumedAppleUpload(result, state, attestation, adopted: attestation is null); + return true; + } + + private static void PopulateResumedAppleUpload( + PowerForgeAppleAppReleaseResult result, + AppStoreConnectReleaseStateResult state, + AppleUploadAttestation? attestation, + bool adopted) + { + result.RemoteState = state; + result.ResumedExistingBuild = true; + result.AdoptedExistingBuild = adopted; + result.ResumedUploadAttestation = attestation?.Target; + result.ResumedUploadAttestationAttemptId = attestation?.Target.UploadAttestationAttemptId ?? attestation?.Receipt.AttemptId; + result.ArchiveSha256 = attestation?.Target.ArchiveSha256; + result.SkippedSteps = new[] { "archive", "upload" }; + } + + private static void EnsureExplicitAppleRecoveryAdoption( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + string recoveredOperation) + { + if (plan.AdoptExistingBuild) + return; + + throw new InvalidOperationException( + $"Apple recovery for '{app.Name}' found {recoveredOperation}, but local receipt files cannot authorize a cross-process recovery. " + + "Verify the remote operation and exact source/archive evidence, then rerun with --apple-adopt-existing-build " + + "--confirm-apple-action, or disable resume and start a new version/build."); + } + + private void EnsureAppleBuildUploadIsNotTerminal( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + AppStoreConnectReleaseStateResult state, + string buildUploadId) + { + var upload = _getAppleBuildUpload(CreateAppStoreConnectCredential(plan), buildUploadId); + if (upload is null || !IsTerminalAppleBuildFailure(upload.State)) + return; + + var issues = upload.Errors + .Select(static issue => FormatAppleBuildUploadIssue(issue)) + .Where(static issue => !string.IsNullOrWhiteSpace(issue)) + .ToArray(); + var issueDetail = issues.Length == 0 ? string.Empty : $" {string.Join(" ", issues)}"; + throw new AppleBuildProcessingException( + $"App Store Connect rejected uploaded build {state.VersionString} ({state.BuildNumber}) " + + $"for '{app.Name}' in build-upload state '{upload.State}'.{issueDetail}", + state); + } + + private AppleUploadAttestation? FindVerifiedAppleUploadAttestation( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + AppStoreConnectReleaseStateResult state, + AppStoreConnectBuildInfo? remoteBuild) + { + var receipts = _appleReceiptStore.ReadAll(plan); + foreach (var receipt in receipts) + { + var target = FindMatchingAppleUploadAttestationTarget( + receipts, + receipt, + plan, + app, + state.VersionString, + state.BuildNumber); + if (target is null) + continue; + + if (string.Equals(receipt.OperationPhase, "UploadAmbiguous", StringComparison.Ordinal)) + { + if (remoteBuild is null) + { + throw new InvalidOperationException( + $"The prior App Store upload attempt for '{app.Name}' has an ambiguous remote result. " + + "PowerForge will not upload the archive again until App Store Connect state is reconciled and a uniquely matching build is deliberately adopted."); + } + continue; + } + + if (remoteBuild is null) + { + return new AppleUploadAttestation(receipt, target); + } + + if (!string.IsNullOrWhiteSpace(target.BuildId) && + !string.Equals(target.BuildId, remoteBuild.Id, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.IsNullOrWhiteSpace(target.BuildId)) + { + if (!string.IsNullOrWhiteSpace(target.BuildUploadId)) + { + var upload = _getAppleBuildUpload(CreateAppStoreConnectCredential(plan), target.BuildUploadId!); + if (upload is null || + !string.Equals(upload.MarketingVersion, state.VersionString, StringComparison.OrdinalIgnoreCase) || + !string.Equals(upload.BuildNumber, state.BuildNumber, StringComparison.OrdinalIgnoreCase) || + !string.Equals( + upload.Platform, + AppStoreConnectClient.ToAppStoreConnectPlatform(app.Platform), + StringComparison.OrdinalIgnoreCase) || + IsTerminalAppleBuildFailure(upload.State)) + { + continue; + } + } + } + + return new AppleUploadAttestation(receipt, target); + } + + return null; + } + + private bool HasPotentialVerifiedAppleUploadAttestation( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app) + { + var receipts = _appleReceiptStore.ReadAll(plan); + return receipts.Any(receipt => FindMatchingAppleUploadAttestationTarget( + receipts, + receipt, + plan, + app, + app.MarketingVersion, + app.BuildNumber) is not null); + } + + private static PowerForgeAppleReleaseTargetReceipt? FindMatchingAppleUploadAttestationTarget( + IReadOnlyCollection receipts, + PowerForgeAppleReleaseReceipt receipt, + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + string? version, + string? build) + { + if (receipt.PlanOnly || + (!string.Equals(receipt.OperationPhase, "UploadAttested", StringComparison.Ordinal) && + !string.Equals(receipt.OperationPhase, "UploadAmbiguous", StringComparison.Ordinal)) || + receipt.SchemaVersion < 4 || + string.IsNullOrWhiteSpace(receipt.ReceiptSha256) || + !AppleSourceCommitEvidenceMatches(receipt.SourceCommit, plan.SourceCommit)) + { + return null; + } + + return receipt.Targets.SingleOrDefault(candidate => + candidate.Name.Equals(app.Name, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.BundleId, app.BundleId, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.AppId, app.AppStoreConnectAppId, StringComparison.OrdinalIgnoreCase) && + candidate.Platform == app.Platform && + string.Equals(candidate.Configuration, app.Configuration, StringComparison.OrdinalIgnoreCase) && + AppleReleasePathsEqual( + candidate.ProjectPath, + FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), + plan.ProjectRoot) && + candidate.IsWorkspace == app.IsWorkspace && + string.Equals(candidate.Scheme, app.Scheme, StringComparison.Ordinal) && + candidate.ArchiveVariant == app.ArchiveVariant && + string.Equals(candidate.Destination, app.Destination, StringComparison.Ordinal) && + candidate.DistributionRoute == app.DistributionRoute && + (string.IsNullOrWhiteSpace(version) || + string.Equals(candidate.Version, version, StringComparison.OrdinalIgnoreCase)) && + (string.IsNullOrWhiteSpace(build) || + string.Equals(candidate.Build, build, StringComparison.OrdinalIgnoreCase)) && + !candidate.AdoptedExistingBuild && + candidate.UploadPerformed && + IsSha256(candidate.ArchiveSha256) && + IsSha256(candidate.UploadExecutionSha256) && + string.Equals( + candidate.UploadExecutionSha256, + ComputeAppleUploadExecutionSha256(plan, app), + StringComparison.OrdinalIgnoreCase) && + (string.IsNullOrWhiteSpace(app.ExpectedArchiveSha256) || + string.Equals(candidate.ArchiveSha256, app.ExpectedArchiveSha256, StringComparison.OrdinalIgnoreCase)) && + !string.IsNullOrWhiteSpace(candidate.ArchivePath) && + IsVerifiedUploadCheckpoint(receipts, receipt, candidate, plan.ProjectRoot)); + } + + private static bool IsVerifiedUploadCheckpoint( + IReadOnlyCollection receipts, + PowerForgeAppleReleaseReceipt receipt, + PowerForgeAppleReleaseTargetReceipt target, + string comparisonPath) + { + if (string.Equals(target.UploadAttestationAttemptId, receipt.AttemptId, StringComparison.OrdinalIgnoreCase)) + return true; + if (string.IsNullOrWhiteSpace(target.UploadAttestationAttemptId)) + return false; + + var checkpoint = receipts.SingleOrDefault(candidate => + string.Equals(candidate.AttemptId, target.UploadAttestationAttemptId, StringComparison.OrdinalIgnoreCase)); + if (checkpoint is null || + !string.Equals(checkpoint.OperationPhase, "UploadAttested", StringComparison.Ordinal) || + !string.Equals(checkpoint.SourceCommit, receipt.SourceCommit, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return checkpoint.Targets.Any(candidate => + candidate.UploadPerformed && + string.Equals(candidate.UploadAttestationAttemptId, checkpoint.AttemptId, StringComparison.OrdinalIgnoreCase) && + candidate.Name.Equals(target.Name, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.BundleId, target.BundleId, StringComparison.OrdinalIgnoreCase) && + candidate.Platform == target.Platform && + string.Equals(candidate.Configuration, target.Configuration, StringComparison.OrdinalIgnoreCase) && + AppleReleasePathsEqual(candidate.ProjectPath, target.ProjectPath, comparisonPath) && + candidate.IsWorkspace == target.IsWorkspace && + string.Equals(candidate.Scheme, target.Scheme, StringComparison.Ordinal) && + candidate.ArchiveVariant == target.ArchiveVariant && + string.Equals(candidate.Destination, target.Destination, StringComparison.Ordinal) && + candidate.DistributionRoute == target.DistributionRoute && + AppleReleasePathsEqual(candidate.ArchivePath, target.ArchivePath, comparisonPath) && + string.Equals(candidate.ArchiveSha256, target.ArchiveSha256, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.UploadExecutionSha256, target.UploadExecutionSha256, StringComparison.OrdinalIgnoreCase)); + } + + private bool TryResumeDirectAppleNotarization( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + PowerForgeAppleAppReleaseResult result) + { + if (!IsUploadExecution(plan) || !plan.Automation.Resume) + return false; + + var latestNotarizationEvidence = _appleReceiptStore.ReadAll(plan) + .Where(receipt => + !receipt.PlanOnly && + receipt.SchemaVersion >= 4 && + !string.IsNullOrWhiteSpace(receipt.ReceiptSha256) && + AppleSourceCommitEvidenceMatches(receipt.SourceCommit, plan.SourceCommit)) + .SelectMany(receipt => receipt.Targets + .Where(target => IsMatchingDirectReceiptTarget(plan, target, app)) + .Select(target => new { Receipt = receipt, Target = target })) + .FirstOrDefault(evidence => + string.Equals(evidence.Receipt.OperationPhase, "NotarizationAmbiguous", StringComparison.Ordinal) || + (string.Equals(evidence.Target.NotarizationStatus, "Accepted", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(evidence.Target.NotarizationSubmissionId) && + !string.IsNullOrWhiteSpace(evidence.Target.DirectArtifactPath) && + IsSha256(evidence.Target.DirectArtifactSha256))); + if (string.Equals( + latestNotarizationEvidence?.Receipt.OperationPhase, + "NotarizationAmbiguous", + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The prior Apple notarization submission state for '{app.Name}' is ambiguous. " + + "Reconcile Apple notary history and record a definitive result before retrying; the artifact must not be submitted again automatically."); + } + + var prior = latestNotarizationEvidence?.Target; + if (prior is null) + return false; + + var artifactPath = ValidateDirectRecoveryArtifactPath(plan, app, prior.DirectArtifactPath!); + if (!File.Exists(artifactPath) && !Directory.Exists(artifactPath)) + return false; + var stapleCompleted = !plan.DirectDistribution.Staple || + HasDurablePublishedStaple(prior); + var assessmentCompleted = !plan.DirectDistribution.Assess || + prior.GatekeeperAccepted == true; + var completed = stapleCompleted && assessmentCompleted; + if (completed) + { + EnsureExplicitAppleRecoveryAdoption(plan, app, "an accepted direct notarization submission"); + var artifactSha256 = AppleNotarizationService.ComputeArtifactSha256(artifactPath); + if (!string.Equals(artifactSha256, prior.DirectArtifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The completed direct Apple artifact changed after release. Expected SHA-256 " + + $"'{prior.DirectArtifactSha256}', received '{artifactSha256}'. Archive, export, and notarize the changed artifact as a new release attempt."); + } + + static ProcessRunResult CompletedStep(string message, string executable) + => new(0, message, string.Empty, executable, TimeSpan.Zero, false); + + result.Notarization = new AppleNotarizationResult + { + ArtifactPath = artifactPath, + ArtifactSha256 = artifactSha256, + SubmissionPath = artifactPath, + SubmissionSha256 = prior.NotarizationSubmissionSha256, + SubmissionId = prior.NotarizationSubmissionId, + Status = "Accepted", + ResumedAcceptedSubmission = true, + Submission = CompletedStep("Reused the retained accepted notarization submission.", "xcrun"), + Staple = plan.DirectDistribution.Staple + ? CompletedStep("Reused completed ticket stapling.", "xcrun") + : null, + StapleValidation = plan.DirectDistribution.Staple + ? CompletedStep("Reused completed staple validation.", "xcrun") + : null, + Assessment = plan.DirectDistribution.Assess + ? CompletedStep("Reused completed Gatekeeper assessment.", "spctl") + : null + }; + result.ResumedAcceptedNotarization = true; + result.SkippedSteps = MergeAppleSkippedSteps( + result.SkippedSteps, + new[] { "archive", "export", "notarySubmission", "staple", "stapleValidation", "gatekeeperAssessment" }); + return true; + } + + if (string.IsNullOrWhiteSpace(prior.ErrorMessage)) + return false; + + EnsureExplicitAppleRecoveryAdoption(plan, app, "an accepted direct notarization submission"); + + result.Notarization = NotarizeDirectAppleExport( + plan, + app, + artifactPath, + prior.NotarizationSubmissionId, + prior.DirectArtifactSha256, + prior.NotarizationSubmissionSha256, + HasDurablePublishedStaple(prior)); + result.ResumedAcceptedNotarization = true; + result.SkippedSteps = MergeAppleSkippedSteps( + result.SkippedSteps, + new[] { "archive", "export", "notarySubmission" }); + if (!result.Notarization.Succeeded) + throw CreateAppleNotarizationFailure(app, result.Notarization); + + return true; + } + + internal static bool HasDurablePublishedStaple(PowerForgeAppleReleaseTargetReceipt target) + => target.Stapled == true && target.StapleValidated == true; + + internal static bool IsMatchingDirectReceiptTarget( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleReleaseTargetReceipt target, + PowerForgeAppleAppReleaseTargetPlan app) + => target.Name.Equals(app.Name, StringComparison.OrdinalIgnoreCase) && + string.Equals(target.BundleId, app.BundleId, StringComparison.OrdinalIgnoreCase) && + target.Platform == app.Platform && + string.Equals(target.Configuration, app.Configuration, StringComparison.OrdinalIgnoreCase) && + AppleReleasePathsEqual( + target.ProjectPath, + FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), + plan.ProjectRoot) && + target.IsWorkspace == app.IsWorkspace && + string.Equals(target.Scheme, app.Scheme, StringComparison.Ordinal) && + target.ArchiveVariant == app.ArchiveVariant && + string.Equals(target.Destination, app.Destination, StringComparison.Ordinal) && + target.DistributionRoute == AppleDistributionRoute.DirectNotarized && + string.Equals(target.Version, app.MarketingVersion, StringComparison.OrdinalIgnoreCase) && + string.Equals(target.Build, app.BuildNumber, StringComparison.OrdinalIgnoreCase) && + IsSha256(target.DirectExecutionSha256) && + string.Equals( + target.DirectExecutionSha256, + ComputeDirectExecutionSha256(plan, app), + StringComparison.OrdinalIgnoreCase) && + (!IsSha256(app.ExpectedArchiveSha256) || + string.Equals(target.ArchiveSha256, app.ExpectedArchiveSha256, StringComparison.OrdinalIgnoreCase)); + + private static bool IsSha256(string? value) + => !string.IsNullOrWhiteSpace(value) && + value!.Length == 64 && + value.All(static character => Uri.IsHexDigit(character)); + + private static bool AppleSourceCommitEvidenceMatches(string? receiptSourceCommit, string? planSourceCommit) + { + var receiptValue = string.IsNullOrWhiteSpace(receiptSourceCommit) + ? null + : receiptSourceCommit!.Trim(); + var planValue = string.IsNullOrWhiteSpace(planSourceCommit) + ? null + : planSourceCommit!.Trim(); + return string.Equals(receiptValue, planValue, StringComparison.OrdinalIgnoreCase); + } + + internal static bool AppleReleasePathsEqual( + string? left, + string? right, + string? comparisonPath = null) + { + var probePath = !string.IsNullOrWhiteSpace(comparisonPath) + ? comparisonPath! + : !string.IsNullOrWhiteSpace(left) && Path.IsPathRooted(left) + ? left! + : !string.IsNullOrWhiteSpace(right) && Path.IsPathRooted(right) + ? right! + : Directory.GetCurrentDirectory(); + return string.Equals( + left, + right, + FrameworkCompatibility.GetPathStringComparisonForPath(probePath)); + } + + private sealed class AppleUploadAttestation + { + internal AppleUploadAttestation( + PowerForgeAppleReleaseReceipt receipt, + PowerForgeAppleReleaseTargetReceipt target) + { + Receipt = receipt; + Target = target; + } + + internal PowerForgeAppleReleaseReceipt Receipt { get; } + + internal PowerForgeAppleReleaseTargetReceipt Target { get; } + } +} diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index cf8128506..66a44f924 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -6,38 +6,130 @@ namespace PowerForge; internal sealed partial class PowerForgeReleaseService { - private void AssertApplePlanStillApproved( + private PowerForgeAppleReleaseReceipt? AssertApplePlanStillApproved( PowerForgeAppleReleasePlan plan, string? expectedPlanSha256) { - if (string.IsNullOrWhiteSpace(expectedPlanSha256)) - return; + if (plan.SyncScreenshots && plan.ReplaceScreenshots && string.IsNullOrWhiteSpace(expectedPlanSha256)) + { + throw new InvalidOperationException( + "Destructive App Store screenshot replacement requires the SHA-256 from a reviewed exact Apple plan."); + } + if (string.IsNullOrWhiteSpace(expectedPlanSha256) && + plan.Action != PowerForgeAppleReleaseAction.Version) + { + if (!string.IsNullOrWhiteSpace(plan.SourceCommit) && HasAppleExecutionMutation(plan)) + { + _ = CreateAppleMutationInputEvidence(plan); + CaptureApprovedMutationInputContents(plan); + } + return null; + } - var expected = expectedPlanSha256!.Trim(); - if (expected.Length != 64 || expected.Any(static value => !Uri.IsHexDigit(value))) + var expected = expectedPlanSha256?.Trim(); + if (expected is not null && + (expected.Length != 64 || expected.Any(static value => !Uri.IsHexDigit(value)))) + { throw new InvalidOperationException("The expected Apple plan SHA-256 must contain exactly 64 hexadecimal characters."); + } - var current = CreateApplePlanReceipt(plan).PlanSha256; - if (!string.Equals(expected, current, StringComparison.OrdinalIgnoreCase)) + var current = CreateApplePlanReceipt(plan); + if (expected is not null && + !string.Equals(expected, current.PlanSha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( "Apple state or release inputs changed after plan approval. Review a new exact plan before allowing mutation."); } + + CaptureApprovedMutationInputContents(plan); + return current; + } + + internal static void CaptureApprovedMutationInputContents(PowerForgeAppleReleasePlan plan) + { + var paths = new List(); + if (plan.SyncScreenshots || plan.CheckReleaseReadiness || + (plan.SubmitForReview && !plan.SkipReviewReadinessCheck)) + { + if (!string.IsNullOrWhiteSpace(plan.ScreenshotConfigPath)) paths.Add(plan.ScreenshotConfigPath!); + paths.AddRange(plan.ScreenshotConfigPaths); + } + if (plan.SyncMetadata) + { + if (!string.IsNullOrWhiteSpace(plan.MetadataConfigPath)) paths.Add(plan.MetadataConfigPath!); + paths.AddRange(plan.MetadataConfigPaths); + } + if (plan.SyncAppInfo) + { + if (!string.IsNullOrWhiteSpace(plan.AppInfoConfigPath)) paths.Add(plan.AppInfoConfigPath!); + paths.AddRange(plan.AppInfoConfigPaths); + } + if (plan.CheckGovernance) + { + if (!string.IsNullOrWhiteSpace(plan.GovernanceConfigPath)) paths.Add(plan.GovernanceConfigPath!); + paths.AddRange(plan.GovernanceConfigPaths); + } + if (plan.Action == PowerForgeAppleReleaseAction.Version && !string.IsNullOrWhiteSpace(plan.VersionSourcePath)) + paths.Add(plan.VersionSourcePath!); + + var pathComparer = Path.DirectorySeparatorChar == '\\' + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var captured = new Dictionary(pathComparer); + foreach (var path in paths.Where(static value => !string.IsNullOrWhiteSpace(value)).Distinct(pathComparer)) + { + var fullPath = Path.GetFullPath(path); + var bytes = File.ReadAllBytes(fullPath); + using var sha256 = SHA256.Create(); + var actual = BitConverter.ToString(sha256.ComputeHash(bytes)).Replace("-", string.Empty).ToLowerInvariant(); + var relative = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, fullPath).Replace('\\', '/'); + if (!plan.ApprovedMutationInputFilesSha256.TryGetValue(relative, out var expected) || + !actual.Equals(expected, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Approved Apple mutation input changed before execution: {relative}"); + } + using var stream = new MemoryStream(bytes, writable: false); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + captured[fullPath] = reader.ReadToEnd(); + } + plan.ApprovedMutationInputContents = captured; } - private PowerForgeAppleReleaseReceipt CreateApplePlanReceipt(PowerForgeAppleReleasePlan plan) + internal static string ReadApprovedMutationInputText(PowerForgeAppleReleasePlan plan, string path) { + var fullPath = Path.GetFullPath(path); + return plan.ApprovedMutationInputContents.TryGetValue(fullPath, out var content) + ? content + : File.ReadAllText(fullPath); + } + + private PowerForgeAppleReleaseReceipt CreateApplePlanReceipt( + PowerForgeAppleReleasePlan plan, + IReadOnlyCollection? checkpointResults = null) + { + if (checkpointResults is not null) + { + foreach (var app in plan.Apps) + { + var result = checkpointResults.SingleOrDefault(candidate => + candidate.Plan.Name.Equals(app.Name, StringComparison.OrdinalIgnoreCase)); + app.ExpectedArchiveSha256 = result?.ArchiveSha256; + } + } PowerForgeAppleVersionReceipt? versioning = null; if (plan.Action == PowerForgeAppleReleaseAction.Version) versioning = PlanAppleVersion(plan, whatIf: true); - var screenshotSpecs = plan.Action == PowerForgeAppleReleaseAction.SubmitAppReview && - !plan.SkipReviewReadinessCheck + var screenshotSpecs = ((plan.SyncScreenshots && plan.ReplaceScreenshots) || + plan.CheckReleaseReadiness || + (plan.SubmitForReview && !plan.SkipReviewReadinessCheck)) ? LoadAppleScreenshotSpecs(plan) : Array.Empty<(AppStoreConnectScreenshotSyncSpec Spec, string ConfigPath)>(); var targets = plan.Apps .Select(app => CreateApplePlanTarget(plan, app, versioning, screenshotSpecs)) .ToArray(); + var mutationInputs = CreateAppleMutationInputEvidence(plan); var receipt = new PowerForgeAppleReleaseReceipt { Action = plan.Action, @@ -46,6 +138,9 @@ private PowerForgeAppleReleaseReceipt CreateApplePlanReceipt(PowerForgeAppleRele CheckedAt = DateTimeOffset.UtcNow, Success = true, ReceiptPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, plan.PlanReceiptPath).Replace('\\', '/'), + AdoptExistingBuild = plan.AdoptExistingBuild, + MutationInputsSha256 = mutationInputs.Sha256, + MutationInputFiles = mutationInputs.Files, Versioning = versioning, Targets = targets, NextActions = new[] { $"Run Apple action '{plan.Action}' without --plan after reviewing this plan receipt." } @@ -53,7 +148,7 @@ private PowerForgeAppleReleaseReceipt CreateApplePlanReceipt(PowerForgeAppleRele receipt.PlanSha256 = ComputeApplePlanSha256(receipt); if (plan.Automation.WriteReceipt) - WriteAppleReceipt(plan.ProjectRoot, plan.PlanReceiptPath, receipt); + _appleReceiptStore.WritePlan(plan.ProjectRoot, plan.PlanReceiptPath, receipt); return receipt; } @@ -68,6 +163,12 @@ private PowerForgeAppleReleaseTargetReceipt CreateApplePlanTarget( Name = app.Name, BundleId = app.BundleId, Platform = app.Platform, + Configuration = app.Configuration, + ProjectPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), + IsWorkspace = app.IsWorkspace, + Scheme = app.Scheme, + ArchiveVariant = app.ArchiveVariant, + Destination = app.Destination, DistributionRoute = app.DistributionRoute, ProductRole = app.ProductRole, ParentTarget = app.ParentTarget, @@ -77,35 +178,52 @@ private PowerForgeAppleReleaseTargetReceipt CreateApplePlanTarget( AppIdDiscovered = app.AppStoreConnectAppIdDiscovered, Version = versioning?.MarketingVersion ?? app.MarketingVersion, Build = versioning?.BuildNumber ?? app.BuildNumber, + ArchivePath = string.IsNullOrWhiteSpace(app.ExpectedArchiveSha256) + ? null + : FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'), + ArchiveSha256 = app.ExpectedArchiveSha256, + DirectExecutionSha256 = app.DistributionRoute == AppleDistributionRoute.DirectNotarized + ? ComputeDirectExecutionSha256(plan, app) + : null, SkippedSteps = new[] { "plan-only" } }; - if (plan.Action is not (PowerForgeAppleReleaseAction.SubmitTestFlightReview or - PowerForgeAppleReleaseAction.SubmitAppReview or - PowerForgeAppleReleaseAction.Release) || - !ShouldExecuteAppleTarget(plan.Action, app)) + if (!RequiresObservedApplePlanState(plan, app)) { return target; } - var state = ReadAppleReleaseState(plan, app); - var platform = AssertSinglePlatformState(state, app); - var reviewSubmission = platform.ReviewSubmissions.FirstOrDefault(static value => value.IsSubmitted == true) ?? - platform.ReviewSubmissions.FirstOrDefault(); - target.Version = state.VersionString ?? target.Version; - target.Build = state.BuildNumber ?? target.Build; - target.BuildId = platform.MatchedBuild?.Id; - target.BuildProcessingState = platform.MatchedBuild?.ProcessingState; - target.DistributionVersionId = platform.Version?.Id; - target.DistributionState = platform.Version?.AppStoreState ?? platform.Version?.AppVersionState; - target.BuildSelected = platform.MatchedBuildSelected; - target.TestFlightInternalState = platform.BetaDetail?.InternalBuildState; - target.TestFlightExternalState = platform.BetaDetail?.ExternalBuildState; - target.TestFlightReviewState = platform.BetaReviewSubmission?.BetaReviewState; - target.AppReviewSubmissionId = reviewSubmission?.Id; - target.AppReviewState = reviewSubmission?.State; - target.NextActions = platform.NextActions; - if (plan.Action == PowerForgeAppleReleaseAction.SubmitAppReview && - !plan.SkipReviewReadinessCheck) + if (RequiresObservedAppleReleaseState(plan)) + { + var state = ReadAppleReleaseState(plan, app); + var platform = AssertSinglePlatformState(state, app); + if (RequiresSelectedApplePlanBuild(plan) && + (platform.MatchedBuildSelected != true || string.IsNullOrWhiteSpace(platform.MatchedBuild?.Id))) + { + throw new InvalidOperationException( + $"Apple action '{plan.Action}' requires one uniquely selected App Store Connect build for '{app.Name}'. " + + "Upload and finish processing the intended exact build, then review a new plan."); + } + var reviewSubmission = platform.ReviewSubmissions.FirstOrDefault(static value => value.IsSubmitted == true) ?? + platform.ReviewSubmissions.FirstOrDefault(); + target.Version = state.VersionString ?? target.Version; + target.Build = state.BuildNumber ?? target.Build; + target.BuildId = platform.MatchedBuild?.Id; + target.BuildProcessingState = platform.MatchedBuild?.ProcessingState; + target.DistributionVersionId = platform.Version?.Id; + target.DistributionState = platform.Version?.AppStoreState ?? platform.Version?.AppVersionState; + target.BuildSelected = platform.MatchedBuildSelected; + target.TestFlightInternalState = platform.BetaDetail?.InternalBuildState; + target.TestFlightExternalState = platform.BetaDetail?.ExternalBuildState; + target.TestFlightReviewState = platform.BetaReviewSubmission?.BetaReviewState; + target.AppReviewSubmissionId = reviewSubmission?.Id; + target.AppReviewState = reviewSubmission?.State; + target.NextActions = platform.NextActions; + } + + var bindScreenshotInventory = plan.SyncScreenshots && plan.ReplaceScreenshots; + var checkReadiness = plan.CheckReleaseReadiness || + (plan.SubmitForReview && !plan.SkipReviewReadinessCheck); + if (bindScreenshotInventory || checkReadiness) { var values = ResolveAppleDistributionValues(app, versionUpdate: null); var matchingScreenshotSpec = ResolveMatchingScreenshotSpec( @@ -122,27 +240,83 @@ PowerForgeAppleReleaseAction.SubmitAppReview or { AppId = app.AppStoreConnectAppId!, VersionString = values.MarketingVersion, - BuildNumber = values.BuildNumber, + BuildNumber = checkReadiness ? values.BuildNumber : null, Platform = app.Platform, - ScreenshotSpec = boundScreenshotSpec + ScreenshotSpec = boundScreenshotSpec, + RequireSelectedBuild = checkReadiness, + RequireValidBuild = checkReadiness, + RequireDescription = checkReadiness, + RequireKeywords = checkReadiness, + RequireSupportUrl = checkReadiness }); - target.ReadinessChecked = true; - target.ReadyForSubmission = readiness.IsReady; target.ScreenshotCount = readiness.ScreenshotSets.Sum(static set => set.Count); target.ScreenshotDeliveryStates = readiness.ScreenshotSets .SelectMany(static set => set.AssetDeliveryStates) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(static stateValue => stateValue, StringComparer.OrdinalIgnoreCase) .ToArray(); - target.ReadinessChecks = readiness.Checks - .OrderBy(static check => check.Name, StringComparer.Ordinal) - .ThenBy(static check => check.Message, StringComparer.Ordinal) - .ToArray(); - target.ReadinessSha256 = ComputeReadinessSha256(readiness); + if (bindScreenshotInventory) + { + target.ScreenshotInventorySha256 = AppStoreConnectScreenshotInventory.ComputeSha256(readiness.ScreenshotSets); + app.ExpectedScreenshotInventorySha256 = target.ScreenshotInventorySha256; + } + if (checkReadiness) + { + target.ReadinessChecked = true; + target.ReadyForSubmission = readiness.IsReady; + target.ReadinessChecks = readiness.Checks + .OrderBy(static check => check.Name, StringComparer.Ordinal) + .ThenBy(static check => check.Message, StringComparer.Ordinal) + .ToArray(); + target.ReadinessSha256 = ComputeReadinessSha256(readiness); + } } return target; } + internal static bool RequiresObservedApplePlanState( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app) + { + if (!UsesAppStoreConnect(app) || !ShouldExecuteAppleTarget(plan.Action, app)) + return false; + return RequiresObservedAppleReleaseState(plan) || + (plan.SyncScreenshots && plan.ReplaceScreenshots) || + plan.CheckReleaseReadiness || + (plan.SubmitForReview && !plan.SkipReviewReadinessCheck); + } + + private static bool RequiresObservedAppleReleaseState(PowerForgeAppleReleasePlan plan) + { + if (plan.Action is PowerForgeAppleReleaseAction.SubmitTestFlightReview or + PowerForgeAppleReleaseAction.SubmitAppReview or + PowerForgeAppleReleaseAction.Release or + PowerForgeAppleReleaseAction.Prepare or + PowerForgeAppleReleaseAction.TestFlight or + PowerForgeAppleReleaseAction.Advance) + { + return true; + } + + return plan.AdoptExistingBuild || + plan.PrepareDistribution || + plan.DistributeTestFlight || + plan.SubmitTestFlightBetaReview || + plan.SubmitForReview || + plan.ReleaseApprovedVersion; + } + + private static bool RequiresSelectedApplePlanBuild(PowerForgeAppleReleasePlan plan) + => plan.Action is PowerForgeAppleReleaseAction.SubmitTestFlightReview or + PowerForgeAppleReleaseAction.SubmitAppReview or + PowerForgeAppleReleaseAction.TestFlight or + PowerForgeAppleReleaseAction.Release || + plan.AdoptExistingBuild || + (plan.DistributeTestFlight && !IsUploadExecution(plan)) || + plan.SubmitTestFlightBetaReview || + plan.SubmitForReview || + plan.ReleaseApprovedVersion; + private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt receipt) { var canonical = new @@ -151,6 +325,11 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei receipt.Action, receipt.SourceCommit, receipt.PlanOnly, + receipt.AdoptExistingBuild, + receipt.MutationInputsSha256, + MutationInputFiles = receipt.MutationInputFiles + .OrderBy(static value => value.Key, StringComparer.Ordinal) + .ToArray(), receipt.Success, receipt.ErrorMessage, receipt.Versioning, @@ -162,6 +341,192 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei return ComputeStableSha256(canonical); } + private (Dictionary Files, string Sha256) CreateAppleMutationInputEvidence( + PowerForgeAppleReleasePlan plan) + { + var files = new Dictionary(StringComparer.Ordinal); + var configuredInputs = new List(); + if (plan.SyncScreenshots || plan.CheckReleaseReadiness || + (plan.SubmitForReview && !plan.SkipReviewReadinessCheck)) + { + if (!string.IsNullOrWhiteSpace(plan.ScreenshotConfigPath)) + configuredInputs.Add(plan.ScreenshotConfigPath!); + configuredInputs.AddRange(plan.ScreenshotConfigPaths); + } + if (plan.SyncMetadata) + { + if (!string.IsNullOrWhiteSpace(plan.MetadataConfigPath)) + configuredInputs.Add(plan.MetadataConfigPath!); + configuredInputs.AddRange(plan.MetadataConfigPaths); + } + if (plan.SyncAppInfo) + { + if (!string.IsNullOrWhiteSpace(plan.AppInfoConfigPath)) + configuredInputs.Add(plan.AppInfoConfigPath!); + configuredInputs.AddRange(plan.AppInfoConfigPaths); + } + if (plan.CheckGovernance) + { + if (!string.IsNullOrWhiteSpace(plan.GovernanceConfigPath)) + configuredInputs.Add(plan.GovernanceConfigPath!); + configuredInputs.AddRange(plan.GovernanceConfigPaths); + } + if (plan.Action == PowerForgeAppleReleaseAction.Version && !string.IsNullOrWhiteSpace(plan.VersionSourcePath)) + configuredInputs.Add(plan.VersionSourcePath!); + var effectiveInputs = configuredInputs + .Distinct(Path.DirectorySeparatorChar == '\\' + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal) + .ToArray(); + foreach (var path in effectiveInputs) + AddApplePlanInputFile(plan.ProjectRoot, path!, files); + + if (plan.SyncScreenshots || plan.CheckReleaseReadiness || + (plan.SubmitForReview && !plan.SkipReviewReadinessCheck)) + { + foreach (var configured in LoadAppleScreenshotSpecs(plan)) + { + var baseDirectory = Path.GetDirectoryName(configured.ConfigPath) ?? plan.ProjectRoot; + if (configured.Spec.Quality?.RequireApprovalManifest == true && + !string.IsNullOrWhiteSpace(configured.Spec.Quality.ApprovalManifestPath)) + { + var approvalManifestPath = ResolveOutputPath( + baseDirectory, + configured.Spec.Quality.ApprovalManifestPath!); + EnsurePathWithinProjectRoot( + plan.ProjectRoot, + approvalManifestPath, + "Apple screenshot approval manifest plan input"); + AddApplePlanInputFile(plan.ProjectRoot, approvalManifestPath, files); + } + foreach (var set in configured.Spec.ScreenshotSets) + { + if (string.IsNullOrWhiteSpace(set.Path)) + continue; + var assetPath = ResolveOutputPath(baseDirectory, set.Path); + EnsurePathWithinProjectRoot(plan.ProjectRoot, assetPath, "Apple screenshot plan input"); + if (File.Exists(assetPath)) + { + AddApplePlanInputFile(plan.ProjectRoot, assetPath, files); + } + else if (Directory.Exists(assetPath)) + { + foreach (var file in Directory.EnumerateFiles(assetPath, "*", SearchOption.AllDirectories) + .OrderBy(static value => value, StringComparer.Ordinal)) + AddApplePlanInputFile(plan.ProjectRoot, file, files); + } + else + { + throw new FileNotFoundException($"Apple screenshot plan input was not found: {assetPath}", assetPath); + } + } + } + } + + var options = new + { + plan.Configuration, + plan.Archive, + plan.Upload, + plan.XcodeBuildExecutable, + plan.AllowProvisioningUpdates, + plan.ManageAppVersionAndBuildNumber, + plan.UploadSymbols, + plan.GenerateAppStoreInformation, + plan.SigningStyle, + XcodeTargets = plan.Apps + .OrderBy(static app => app.Name, StringComparer.Ordinal) + .Select(app => new + { + app.Name, + app.TeamId, + app.Upload, + app.VersionUpdateRequested, + app.BuildNumberPolicy, + app.GenerateProjectIfMissing, + app.RegenerateProject, + app.XcodeGenExecutable, + app.ProjectGenerationTimeoutSeconds, + ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'), + ExportPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ExportPath).Replace('\\', '/'), + RequiredEmbeddedBundleIds = app.RequiredEmbeddedBundleIds.OrderBy(static value => value, StringComparer.Ordinal).ToArray(), + RequiredPrivacyUsageDescriptionKeys = app.RequiredPrivacyUsageDescriptionKeys.OrderBy(static value => value, StringComparer.Ordinal).ToArray() + }) + .ToArray(), + DirectDistribution = new + { + plan.DirectDistribution.ExportMethod, + plan.DirectDistribution.XcrunExecutable, + plan.DirectDistribution.DittoExecutable, + plan.DirectDistribution.SpctlExecutable, + plan.DirectDistribution.KeychainProfile, + plan.DirectDistribution.TimeoutSeconds, + plan.DirectDistribution.Staple, + plan.DirectDistribution.Assess + }, + Automation = new + { + plan.Automation.WriteReceipt, + plan.Automation.ReceiptPath, + plan.Automation.ReceiptHistoryPath, + plan.Automation.PlanReceiptPath, + plan.Automation.LockPath, + plan.Automation.VersionSourcePath, + plan.Automation.MarketingVersionPattern, + plan.Automation.Resume, + plan.Automation.WaitForProcessing, + plan.Automation.ProcessingTimeoutSeconds, + plan.Automation.PollIntervalSeconds, + plan.Automation.MinimumFreeSpaceGB, + plan.Automation.CleanupBeforeArchive, + plan.Automation.CleanupAfterProcessing, + plan.Automation.ArtifactRetentionDays + }, + plan.PrepareDistribution, + plan.SelectBuildForDistribution, + plan.AllowUnprocessedDistributionBuild, + plan.SyncMetadata, + plan.SyncAppInfo, + plan.SyncScreenshots, + plan.ReplaceScreenshots, + plan.CheckGovernance, + plan.CheckReleaseReadiness, + plan.DistributeTestFlight, + TestFlightBetaGroupIds = plan.TestFlightBetaGroupIds.OrderBy(static value => value, StringComparer.Ordinal).ToArray(), + TestFlightBetaGroupNames = plan.TestFlightBetaGroupNames.OrderBy(static value => value, StringComparer.Ordinal).ToArray(), + TestFlightTesterEmails = plan.TestFlightTesterEmails.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToArray(), + plan.CreateMissingTestFlightTesters, + plan.AllowUnprocessedTestFlightBuild, + plan.SubmitTestFlightBetaReview, + plan.SubmitForReview, + plan.AllowUnselectedReviewBuild, + plan.AllowUnprocessedReviewBuild, + plan.SkipReviewReadinessCheck, + plan.AllowReviewSubmissionWhenNotReady, + plan.ReleaseApprovedVersion, + plan.AllowNonPendingDeveloperRelease, + Files = files.OrderBy(static value => value.Key, StringComparer.Ordinal).ToArray() + }; + plan.ApprovedMutationInputFilesSha256 = new Dictionary(files, StringComparer.Ordinal); + return (files, ComputeStableSha256(options)); + } + + private static void AddApplePlanInputFile( + string projectRoot, + string path, + IDictionary files) + { + var fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) + throw new FileNotFoundException($"Apple plan input was not found: {fullPath}", fullPath); + EnsurePathWithinProjectRoot(projectRoot, fullPath, "Apple plan input"); + using var stream = File.OpenRead(fullPath); + using var sha256 = SHA256.Create(); + var hash = BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); + var relative = FrameworkCompatibility.GetRelativePath(projectRoot, fullPath).Replace('\\', '/'); + files[relative] = hash; + } + private static string ComputeStableSha256(T value) { var options = CreateJsonOptions(); @@ -193,7 +558,15 @@ private static string ComputeReadinessSha256(AppStoreConnectReleaseReadinessResu set.ScreenshotSetId, set.Count, AssetDeliveryStates = set.AssetDeliveryStates.OrderBy(static value => value, StringComparer.Ordinal).ToArray(), - FileNames = set.FileNames.OrderBy(static value => value, StringComparer.Ordinal).ToArray() + FileNames = set.FileNames.OrderBy(static value => value, StringComparer.Ordinal).ToArray(), + Screenshots = (set.Screenshots ?? Array.Empty()).Select(static screenshot => new + { + screenshot.Id, + screenshot.FileName, + screenshot.FileSize, + screenshot.SourceFileChecksum, + screenshot.AssetDeliveryState + }).ToArray() }) .ToArray(), Checks = readiness.Checks @@ -205,9 +578,26 @@ private static string ComputeReadinessSha256(AppStoreConnectReleaseReadinessResu return ComputeStableSha256(canonical); } - private PowerForgeAppleVersionReceipt SelectAppleVersion(PowerForgeAppleReleasePlan plan) + private static PowerForgeAppleVersionReceipt SelectAppleVersion( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleVersionReceipt approved) { - var versioning = PlanAppleVersion(plan, whatIf: false); + if (string.IsNullOrWhiteSpace(plan.VersionSourcePath)) + throw new InvalidOperationException("Apple version source path is required for Version."); + var source = new AppleReleaseVersionSourceService(); + var approvedContent = ReadApprovedMutationInputText(plan, plan.VersionSourcePath!); + var versioning = source.Update( + plan.VersionSourcePath!, + approvedContent, + approved.MarketingVersion, + approved.BuildNumber, + approved.HighestRemoteBuildNumber, + whatIf: false); + versioning.RequestedMarketingVersion = approved.RequestedMarketingVersion; + versioning.MarketingVersionPattern = approved.MarketingVersionPattern; + versioning.HighestRemoteMarketingVersion = approved.HighestRemoteMarketingVersion; + versioning.ReusedUnreleasedMarketingVersion = approved.ReusedUnreleasedMarketingVersion; + versioning.SourcePath = approved.SourcePath; foreach (var app in plan.Apps) { app.MarketingVersion = versioning.MarketingVersion; @@ -256,7 +646,8 @@ private PowerForgeAppleVersionReceipt PlanAppleVersion(PowerForgeAppleReleasePla throw new InvalidOperationException("Requested Apple marketing version is required for Version."); var source = new AppleReleaseVersionSourceService(); - var current = source.Read(plan.VersionSourcePath!); + var approvedContent = ReadApprovedMutationInputText(plan, plan.VersionSourcePath!); + var current = source.Read(plan.VersionSourcePath!, approvedContent); if (!long.TryParse(current.BuildNumber, out var currentBuild) || currentBuild < 0) throw new InvalidOperationException($"Apple version source build number '{current.BuildNumber}' is not a non-negative integer."); @@ -305,6 +696,7 @@ private PowerForgeAppleVersionReceipt PlanAppleVersion(PowerForgeAppleReleasePla : checked(Math.Max(currentBuild, highestRemote) + 1); var receipt = source.Update( plan.VersionSourcePath!, + approvedContent, requestedVersion, nextBuild.ToString(System.Globalization.CultureInfo.InvariantCulture), highestRemote, diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 784d20298..a3f2b1a1e 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -80,6 +80,7 @@ internal sealed partial class PowerForgeReleaseService private readonly Func _generateAppleProject; private readonly Action _delay; private readonly AppleReleaseArtifactService _appleArtifactService; + private readonly AppleReleaseReceiptStore _appleReceiptStore; /// /// Creates a new unified release service. @@ -168,6 +169,7 @@ internal PowerForgeReleaseService( Func? generateAppleProject = null, Action? delay = null, AppleReleaseArtifactService? appleArtifactService = null, + AppleReleaseReceiptStore? appleReceiptStore = null, Func? runToolsWithProgress = null, Func? runDotNetToolsWithProgress = null, Func? runDotNetToolsWithProgressAndCancellation = null, @@ -229,6 +231,7 @@ internal PowerForgeReleaseService( _generateAppleProject = generateAppleProject ?? (app => new AppleProjectGenerationService().Generate(app)); _delay = delay ?? Thread.Sleep; _appleArtifactService = appleArtifactService ?? new AppleReleaseArtifactService(); + _appleReceiptStore = appleReceiptStore ?? new AppleReleaseReceiptStore(); _executeModuleBuild = executeModuleBuild ?? ((moduleRequest, cancellationToken) => new ModuleBuildHostService() .ExecuteBuildAsync(moduleRequest, cancellationToken) @@ -253,6 +256,12 @@ public PowerForgeReleaseResult Execute(PowerForgeReleaseSpec spec, PowerForgeRel throw new ArgumentException("ConfigPath is required.", nameof(request)); var configPath = Path.GetFullPath(request.ConfigPath.Trim().Trim('"')); + request.LoadedConfigurationSha256 = null; + if (!string.IsNullOrWhiteSpace(spec.LoadedConfigurationPath) && + AppleReleasePathsEqual(Path.GetFullPath(spec.LoadedConfigurationPath!), configPath)) + { + request.LoadedConfigurationSha256 = spec.LoadedConfigurationSha256; + } if (spec.GitHub is { Publish: true } configuredGitHub && request.PublishProjectGitHub != false && IsVerifiedGitHubRecoveryRequested(configuredGitHub)) @@ -525,10 +534,7 @@ spec.WorkspaceValidation is not null && earlyAppleReleaseVersion, request.SkipBuild, selectedAppleTargets, - allowUnresolvedResolvedVersion: true, - validateReusableArchives: - (!request.PlanOnly && !request.ValidateOnly) || - request.CheckpointAppleApps); + allowUnresolvedResolvedVersion: true); } if (runPackages && result.Packages is null) @@ -573,10 +579,7 @@ spec.WorkspaceValidation is not null && appleConfigurationOverride, appleReleaseVersion, request.SkipBuild, - selectedAppleTargets, - validateReusableArchives: - (!request.PlanOnly && !request.ValidateOnly) || - request.CheckpointAppleApps); + selectedAppleTargets); result.AppleAppPlan = applePlan; if (request.PlanOnly) result.AppleReceipt = CreateApplePlanReceipt(applePlan); @@ -746,12 +749,19 @@ dotNetSourcePathForTools is not null && var cleanup = new PowerForgeAppleReleaseCleanupReceipt(); PowerForgeAppleAppReleaseResult[] appleResults; PowerForgeAppleVersionReceipt? appleVersioning = null; + var receiptJournalReady = !applePlan.Automation.WriteReceipt; try { - AssertApplePlanStillApproved(applePlan, request.AppleExpectedPlanSha256); + VerifyExpectedAppleCheckpointArchives(applePlan); + var approvedPlan = AssertApplePlanStillApproved(applePlan, request.AppleExpectedPlanSha256); + PrepareAppleReceiptJournalForMutation(applePlan, request.AppleExpectedPlanSha256); + receiptJournalReady = true; if (applePlan.Action == PowerForgeAppleReleaseAction.Version) { - appleVersioning = SelectAppleVersion(applePlan); + appleVersioning = SelectAppleVersion( + applePlan, + approvedPlan?.Versioning ?? throw new InvalidOperationException( + "Apple Version execution requires one approved remote version observation.")); appleResults = RunAppleVersion(applePlan); } else if (request.CheckpointAppleApps) @@ -760,7 +770,9 @@ dotNetSourcePathForTools is not null && } else if (applePlan.Action == PowerForgeAppleReleaseAction.Cleanup) { - cleanup = _appleArtifactService.RemoveStaleArtifacts(applePlan); + cleanup = _appleArtifactService.RemoveStaleArtifacts( + applePlan, + GetProtectedAppleRecoveryArtifactPaths(applePlan)); appleResults = applePlan.Apps .Select(app => new PowerForgeAppleAppReleaseResult { @@ -789,8 +801,12 @@ dotNetSourcePathForTools is not null && .ToArray(); } result.AppleApps = appleResults; - if (!request.CheckpointAppleApps && + if (request.CheckpointAppleApps && appleResults.All(static app => app.Success)) + result.AppleReceipt = CreateApplePlanReceipt(applePlan, appleResults); + if (receiptJournalReady && + !request.CheckpointAppleApps && (applePlan.Action != PowerForgeAppleReleaseAction.Configured || + HasAppleExecutionMutation(applePlan) || appleResults.Any(static app => !app.Success))) result.AppleReceipt ??= CompleteAppleReleaseReceipt(applePlan, appleResults, cleanup, appleVersioning); @@ -919,10 +935,30 @@ internal static PowerForgeReleaseSpec LoadConfiguration(string configPath) if (!File.Exists(fullPath)) throw new FileNotFoundException($"Unified release config was not found: {fullPath}", fullPath); - var spec = JsonSerializer.Deserialize( - File.ReadAllText(fullPath), - CreateJsonOptions()); - return spec ?? throw new InvalidOperationException($"Unable to deserialize unified release config: {fullPath}"); + var bytes = File.ReadAllBytes(fullPath); + string content; + using (var stream = new MemoryStream(bytes, writable: false)) + using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + content = reader.ReadToEnd(); + + var spec = LoadConfigurationContent(content, fullPath); + spec.LoadedConfigurationPath = fullPath; + spec.LoadedConfigurationSha256 = ComputeConfigurationSha256(bytes); + return spec; + } + + internal static PowerForgeReleaseSpec LoadConfigurationContent(string content, string sourcePath) + { + if (content is null) + throw new ArgumentNullException(nameof(content)); + var spec = JsonSerializer.Deserialize(content, CreateJsonOptions()); + return spec ?? throw new InvalidOperationException($"Unable to deserialize unified release config: {sourcePath}"); + } + + private static string ComputeConfigurationSha256(byte[] bytes) + { + using var algorithm = SHA256.Create(); + return BitConverter.ToString(algorithm.ComputeHash(bytes)).Replace("-", string.Empty).ToLowerInvariant(); } internal PowerForgeReleaseResult PublishBuiltReleaseOutputs( @@ -954,7 +990,17 @@ internal PowerForgeReleaseResult PublishBuiltReleaseOutputs( ConfigPath = configPath, AppleOnly = true, AppleAction = request.AppleAction, + AppleMarketingVersion = request.AppleMarketingVersion, + AppleSourceCommit = request.AppleSourceCommit, + RequireImmutableAppleSourceSnapshot = + request.RequireImmutableAppleSourceSnapshot || + !string.IsNullOrWhiteSpace(request.AppleSourceCommit), + AppleExpectedPlanSha256 = request.AppleExpectedPlanSha256, + AppleExpectedArchiveSha256ByTarget = new Dictionary( + request.AppleExpectedArchiveSha256ByTarget, + StringComparer.OrdinalIgnoreCase), AppleActionConfirmed = request.AppleActionConfirmed, + AppleAdoptExistingBuild = request.AppleAdoptExistingBuild, AppleResume = request.AppleResume, AppleWaitForProcessing = request.AppleWaitForProcessing, AppleProcessingTimeoutSeconds = request.AppleProcessingTimeoutSeconds, @@ -1417,8 +1463,7 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( string? sharedReleaseVersion, bool skipBuild, string[]? selectedTargetNames, - bool allowUnresolvedResolvedVersion = false, - bool validateReusableArchives = true) + bool allowUnresolvedResolvedVersion = false) { if (skipBuild && options.Archive) throw new InvalidOperationException("PowerForge release SkipBuild is not supported when AppleApps.Archive is enabled. Set AppleApps.Archive=false to reuse an existing Apple archive explicitly."); @@ -1479,6 +1524,8 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( throw new InvalidOperationException("AppleApps.DirectDistribution.TimeoutSeconds must be greater than zero."); var receiptPath = ResolveOutputPath(projectRoot, automation.ReceiptPath); EnsurePathWithinProjectRoot(projectRoot, receiptPath, "AppleApps.Automation.ReceiptPath"); + var receiptHistoryPath = ResolveOutputPath(projectRoot, automation.ReceiptHistoryPath); + EnsurePathWithinProjectRoot(projectRoot, receiptHistoryPath, "AppleApps.Automation.ReceiptHistoryPath"); var planReceiptPath = ResolveOutputPath(projectRoot, automation.PlanReceiptPath); EnsurePathWithinProjectRoot(projectRoot, planReceiptPath, "AppleApps.Automation.PlanReceiptPath"); var lockPath = ResolveOutputPath(projectRoot, automation.LockPath); @@ -1521,6 +1568,32 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( allowMissingProject: request.AppleAction == PowerForgeAppleReleaseAction.Cleanup)) .ToArray(); + foreach (var app in apps) + { + if (!options.Archive && + options.Upload && + ShouldExecuteAppleTarget(request.AppleAction, app) && + (File.Exists(app.ArchivePath) || Directory.Exists(app.ArchivePath))) + { + app.ExpectedArchiveSha256 = AppleNotarizationService.ComputeArtifactSha256(app.ArchivePath); + } + if (request.AppleExpectedArchiveSha256ByTarget.TryGetValue(app.Name, out var expectedArchiveSha256)) + { + var expected = expectedArchiveSha256.Trim(); + if (expected.Length != 64 || expected.Any(static value => !Uri.IsHexDigit(value))) + throw new InvalidOperationException($"The expected Apple archive SHA-256 for '{app.Name}' is invalid."); + app.ExpectedArchiveSha256 = expected.ToLowerInvariant(); + } + } + var unknownExpectedArchiveTargets = request.AppleExpectedArchiveSha256ByTarget.Keys + .Where(name => apps.All(app => !app.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + if (unknownExpectedArchiveTargets.Length > 0) + { + throw new InvalidOperationException( + $"Expected Apple archive evidence references unknown target(s): {string.Join(", ", unknownExpectedArchiveTargets)}"); + } + if (apps.Length == 0) throw new InvalidOperationException("AppleApps.Apps must contain at least one enabled app entry."); var duplicateName = apps @@ -1541,6 +1614,41 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( apps, static app => app.ExportPath, "export"); + var protectedApplePaths = new List<(string Name, string Path, bool IsDirectory)> + { + ("release configuration", releaseConfigPath, false), + ("archive root", archiveRoot, true), + ("export root", exportRoot, true) + }; + if (versionSourcePath is not null) + protectedApplePaths.Add(("version source", versionSourcePath, false)); + protectedApplePaths.AddRange( + new[] { screenshotConfigPath, metadataConfigPath, appInfoConfigPath, governanceConfigPath } + .Where(static path => path is not null) + .Select(static path => ("Apple mutation input", path!, false))); + protectedApplePaths.AddRange(screenshotConfigPaths.Select(static path => ("screenshot config", path, false))); + AddAppleScreenshotProtectedPaths( + projectRoot, + new[] { screenshotConfigPath } + .Where(static path => path is not null) + .Select(static path => path!) + .Concat(screenshotConfigPaths), + protectedApplePaths); + protectedApplePaths.AddRange(metadataConfigPaths.Select(static path => ("metadata config", path, false))); + protectedApplePaths.AddRange(appInfoConfigPaths.Select(static path => ("App Information config", path, false))); + protectedApplePaths.AddRange(governanceConfigPaths.Select(static path => ("governance config", path, false))); + foreach (var app in apps) + { + protectedApplePaths.Add(($"{app.Name} Xcode container", app.ProjectPath, Directory.Exists(app.ProjectPath))); + protectedApplePaths.Add(($"{app.Name} archive", app.ArchivePath, true)); + protectedApplePaths.Add(($"{app.Name} export", app.ExportPath, true)); + } + ValidateAppleAutomationOutputPaths( + receiptPath, + receiptHistoryPath, + planReceiptPath, + lockPath, + protectedApplePaths); var appStoreConnectAction = request.AppleAction == PowerForgeAppleReleaseAction.Status || request.AppleAction == PowerForgeAppleReleaseAction.Doctor || request.AppleAction == PowerForgeAppleReleaseAction.Version || @@ -1582,10 +1690,21 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( if (options.SyncScreenshots && screenshotConfigPath is null && screenshotConfigPaths.Length == 0) throw new InvalidOperationException("AppleApps SyncScreenshots requires ScreenshotConfigPath or ScreenshotConfigPaths."); var appleSourceCommit = request.AppleSourceCommit?.Trim() ?? string.Empty; - if (appleSourceCommit.Length > 0 && - (appleSourceCommit.Length != 40 || !appleSourceCommit.All(Uri.IsHexDigit))) + if (appleSourceCommit.Length > 0 && !GitObjectId.IsFull(appleSourceCommit)) { - throw new InvalidOperationException("Apple source commit must be an exact 40-character Git commit SHA."); + throw new InvalidOperationException("Apple source commit must be a full SHA-1 or SHA-256 Git commit object id."); + } + if (request.AppleAdoptExistingBuild && + !IsUploadAction(request.AppleAction) && + !(request.AppleAction == PowerForgeAppleReleaseAction.Configured && options.Upload)) + { + throw new InvalidOperationException( + "Adopting an existing Apple build is supported only for an upload execution."); + } + if (request.AppleAdoptExistingBuild && !automation.Resume) + { + throw new InvalidOperationException( + "Adopting an existing Apple build requires AppleApps.Automation.Resume=true and cannot be combined with --no-apple-resume."); } if (options.SyncMetadata && metadataConfigPath is null && metadataConfigPaths.Length == 0) throw new InvalidOperationException("AppleApps SyncMetadata requires MetadataConfigPath or MetadataConfigPaths."); @@ -1601,15 +1720,6 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( "Apple app version updates require AppleApps.Archive=true for the configured legacy workflow. " + "Use an explicit Apple action such as Status or Prepare to select a configured release identity without mutating the project."); } - if (validateReusableArchives && - request.AppleAction == PowerForgeAppleReleaseAction.Configured && - !options.Archive && - options.Upload) - { - var missingArchive = apps.FirstOrDefault(app => !Directory.Exists(app.ArchivePath)); - if (missingArchive is not null) - throw new FileNotFoundException($"Apple app archive was not found for upload-only release: {missingArchive.ArchivePath}", missingArchive.ArchivePath); - } var explicitAppStoreConnectApiConfiguredCount = (string.IsNullOrWhiteSpace(options.AppStoreConnectApiKeyPath) ? 0 : 1) + (string.IsNullOrWhiteSpace(options.AppStoreConnectApiKeyId) ? 0 : 1) + @@ -1666,7 +1776,7 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( DiscoverAppStoreConnectAppId(app, credential, request.AppleAction); } - return new PowerForgeAppleReleasePlan + var plan = new PowerForgeAppleReleasePlan { ProjectRoot = projectRoot, Configuration = configuration, @@ -1674,11 +1784,22 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( Automation = automation, DirectDistribution = directDistribution, ReceiptPath = receiptPath, + ReceiptHistoryPath = receiptHistoryPath, PlanReceiptPath = planReceiptPath, LockPath = lockPath, VersionSourcePath = versionSourcePath, RequestedMarketingVersion = requestedMarketingVersion, SourceCommit = appleSourceCommit.Length == 0 ? null : appleSourceCommit, + RequireImmutableSourceSnapshot = request.RequireImmutableAppleSourceSnapshot, + ExactSourceConfigPath = request.RequireImmutableAppleSourceSnapshot && File.Exists(request.ConfigPath) + ? request.ConfigPath + : null, + ExactSourceConfigSha256 = request.RequireImmutableAppleSourceSnapshot && + !string.IsNullOrWhiteSpace(request.ConfigPath) && + !string.IsNullOrWhiteSpace(request.LoadedConfigurationSha256) + ? request.LoadedConfigurationSha256 + : null, + AdoptExistingBuild = request.AppleAdoptExistingBuild, Archive = options.Archive, Upload = options.Upload, SyncScreenshots = options.SyncScreenshots, @@ -1723,6 +1844,37 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( AppStoreConnectApiIssuerId = appStoreConnectApiIssuerId, Apps = apps }; + var validateReusableArchives = + (!request.PlanOnly && !request.ValidateOnly) || request.CheckpointAppleApps; + if (validateReusableArchives && + request.AppleAction == PowerForgeAppleReleaseAction.Configured && + !options.Archive && + options.Upload) + { + var missingArchive = apps.FirstOrDefault(app => + !Directory.Exists(app.ArchivePath) && + (!automation.Resume || + !plan.AdoptExistingBuild || + !HasPotentialVerifiedAppleUploadAttestation(plan, app))); + if (missingArchive is not null) + { + throw new FileNotFoundException( + $"Apple app archive was not found for upload-only release and no exact upload attestation can resume it: {missingArchive.ArchivePath}", + missingArchive.ArchivePath); + } + } + if (!request.PlanOnly && + !request.ValidateOnly && + !request.CheckpointAppleApps && + options.Upload && + automation.WaitForProcessing && + apps.Any(app => UsesAppStoreConnect(app) && string.IsNullOrWhiteSpace(app.AppStoreConnectAppId))) + { + throw new InvalidOperationException( + "Apple upload processing waits require AppStoreConnectAppId for every App Store Connect target. " + + "Configure the app id or explicitly disable WaitForProcessing for an upload-only handoff."); + } + return plan; } private static PowerForgeAppleAppReleaseTargetPlan PrepareAppleAppPlan( @@ -1855,6 +2007,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( PowerForgeAppleReleasePlan plan, out PowerForgeAppleReleaseCleanupReceipt cleanup) { + using var sourceSnapshot = AppleReleaseSourceSnapshot.CreateIfRequired(plan); cleanup = new PowerForgeAppleReleaseCleanupReceipt(); var preflightCompleted = false; var releaseApps = plan.Apps @@ -1931,18 +2084,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( new XcodeProjectVersionEditor()); } - var needsReleaseIdentity = - plan.Action == PowerForgeAppleReleaseAction.Status || - plan.Action == PowerForgeAppleReleaseAction.Doctor || - IsUploadExecution(plan) || - plan.PrepareDistribution || - plan.SyncScreenshots || - plan.SyncMetadata || - plan.CheckReleaseReadiness || - plan.DistributeTestFlight || - plan.SubmitTestFlightBetaReview || - plan.SubmitForReview || - plan.ReleaseApprovedVersion; + var needsReleaseIdentity = RequiresAppleReleaseIdentity(plan); if (needsReleaseIdentity) { var values = ResolveAppleDistributionValues(app, versionUpdate: null); @@ -2049,88 +2191,208 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( try { var resumedUpload = resumedByApp[app]; + using var archiveBuildSnapshot = plan.Archive && !resumedUpload + ? AppleArchiveBuildSnapshot.Create(app.ArchivePath) + : null; + var approvedArchiveInputPath = app.ArchivePath; if (plan.Archive && !resumedUpload) { if (!preflightCompleted) { - cleanup = _appleArtifactService.Preflight(plan); + cleanup = _appleArtifactService.Preflight( + plan, + GetProtectedAppleRecoveryArtifactPaths(plan)); preflightCompleted = true; } } if (plan.Archive && app.VersionUpdateRequested && !resumedUpload) { + if (sourceSnapshot is not null) + { + throw new InvalidOperationException( + $"Apple app '{app.Name}' cannot mutate project versions while building an exact-source archive. " + + "Commit the requested version first, then create the checkpoint from that commit."); + } result.VersionUpdate = new XcodeProjectVersionEditor().Update(app.ProjectPath, app.MarketingVersion!, app.BuildNumber); } if (plan.Archive && !resumedUpload) { var directArchive = app.DistributionRoute == AppleDistributionRoute.DirectNotarized; + using var sourceMutationMonitor = sourceSnapshot?.MonitorChanges(); var archive = _archiveAppleApp(new AppleAppArchiveRequest - { - ProjectPath = app.ProjectPath, - IsWorkspace = app.IsWorkspace, - Scheme = app.Scheme, - Configuration = app.Configuration, - Platform = app.Platform, - ArchiveVariant = app.ArchiveVariant, - Destination = app.Destination, - ArchivePath = app.ArchivePath, - XcodeBuildExecutable = plan.XcodeBuildExecutable, - AllowProvisioningUpdates = plan.AllowProvisioningUpdates, - AppStoreConnectApiKeyPath = directArchive ? null : plan.AppStoreConnectApiKeyPath, - AppStoreConnectApiKeyId = directArchive ? null : plan.AppStoreConnectApiKeyId, - AppStoreConnectApiIssuerId = directArchive ? null : plan.AppStoreConnectApiIssuerId - }); - result.Archive = archive; - if (!archive.Succeeded) - { - result.Success = false; - result.ErrorMessage = $"xcodebuild archive failed for '{app.Name}' with exit code {archive.ProcessResult.ExitCode}."; - return CompleteAppleExecutionFailure(plan, resultsByApp, app); + { + ProjectPath = sourceSnapshot?.MapPath(app.ProjectPath) ?? app.ProjectPath, + IsWorkspace = app.IsWorkspace, + Scheme = app.Scheme, + Configuration = app.Configuration, + Platform = app.Platform, + ArchiveVariant = app.ArchiveVariant, + Destination = app.Destination, + ArchivePath = archiveBuildSnapshot?.ArchivePath ?? app.ArchivePath, + XcodeBuildExecutable = plan.XcodeBuildExecutable, + RequireExactPackageSnapshot = sourceSnapshot is not null, + AllowProvisioningUpdates = plan.AllowProvisioningUpdates, + AppStoreConnectApiKeyPath = directArchive ? null : plan.AppStoreConnectApiKeyPath, + AppStoreConnectApiKeyId = directArchive ? null : plan.AppStoreConnectApiKeyId, + AppStoreConnectApiIssuerId = directArchive ? null : plan.AppStoreConnectApiIssuerId + }); + result.Archive = archive; + sourceMutationMonitor?.ValidateNoChanges(); + sourceSnapshot?.ValidateUnchanged(); + if (!archive.Succeeded) + { + result.Success = false; + result.ErrorMessage = $"xcodebuild archive failed for '{app.Name}' with exit code {archive.ProcessResult.ExitCode}."; + return CompleteAppleExecutionFailure(plan, resultsByApp, app); + } + + var publishedArchiveSha256 = archiveBuildSnapshot!.Publish(app.ArchivePath, archive.ArchiveSha256); + approvedArchiveInputPath = archiveBuildSnapshot.ArchivePath; + result.ArchiveSha256 = publishedArchiveSha256; + app.ExpectedArchiveSha256 = publishedArchiveSha256; + archive.ArchivePath = app.ArchivePath; } - } if (plan.Upload && result.Success && !resumedUpload) { + CaptureAppleArchiveSha256(result, app); + var approvedArchiveSha256 = result.ArchiveSha256; var direct = app.DistributionRoute == AppleDistributionRoute.DirectNotarized; - var upload = _uploadAppleApp(new AppleAppArchiveUploadRequest + using var uploadSnapshot = string.IsNullOrWhiteSpace(approvedArchiveSha256) + ? null + : AppleArchiveUploadSnapshot.Create(approvedArchiveInputPath, approvedArchiveSha256!); + using var directExportSnapshot = direct ? AppleDirectExportSnapshot.Create() : null; + using var uploadMutationMonitor = uploadSnapshot is null + ? null + : new AppleReleaseSourceMutationMonitor( + uploadSnapshot.RootPath, + "private Apple upload archive snapshot", + "xcodebuild exportArchive", + "Discard the upload/export result and inspect remote state before retrying."); + AppleAppArchiveUploadResult upload; + var uploadRemoteMutationStarted = false; + try { - ArchivePath = app.ArchivePath, - BundleId = app.BundleId, - RequiredPrivacyUsageDescriptionKeys = app.RequiredPrivacyUsageDescriptionKeys, - ExportPath = app.ExportPath, - TeamId = app.TeamId, - XcodeBuildExecutable = plan.XcodeBuildExecutable, - SigningStyle = plan.SigningStyle, - Destination = direct ? "export" : "upload", - Method = direct ? plan.DirectDistribution.ExportMethod : "app-store-connect", - ManageAppVersionAndBuildNumber = plan.ManageAppVersionAndBuildNumber, - UploadSymbols = plan.UploadSymbols, - GenerateAppStoreInformation = !direct && plan.GenerateAppStoreInformation, - AppStoreConnectApiKeyPath = direct ? null : plan.AppStoreConnectApiKeyPath, - AppStoreConnectApiKeyId = direct ? null : plan.AppStoreConnectApiKeyId, - AppStoreConnectApiIssuerId = direct ? null : plan.AppStoreConnectApiIssuerId, - AllowProvisioningUpdates = plan.AllowProvisioningUpdates - }); + upload = _uploadAppleApp(new AppleAppArchiveUploadRequest + { + ArchivePath = uploadSnapshot?.ArchivePath ?? app.ArchivePath, + BundleId = app.BundleId, + RequiredPrivacyUsageDescriptionKeys = app.RequiredPrivacyUsageDescriptionKeys, + ExportPath = directExportSnapshot?.ExportPath ?? app.ExportPath, + TeamId = app.TeamId, + XcodeBuildExecutable = plan.XcodeBuildExecutable, + RequireTrustedSystemTools = !string.IsNullOrWhiteSpace(plan.SourceCommit), + SigningStyle = plan.SigningStyle, + Destination = direct ? "export" : "upload", + Method = direct ? plan.DirectDistribution.ExportMethod : "app-store-connect", + ManageAppVersionAndBuildNumber = plan.ManageAppVersionAndBuildNumber, + UploadSymbols = plan.UploadSymbols, + GenerateAppStoreInformation = !direct && plan.GenerateAppStoreInformation, + AppStoreConnectApiKeyPath = direct ? null : plan.AppStoreConnectApiKeyPath, + AppStoreConnectApiKeyId = direct ? null : plan.AppStoreConnectApiKeyId, + AppStoreConnectApiIssuerId = direct ? null : plan.AppStoreConnectApiIssuerId, + AllowProvisioningUpdates = plan.AllowProvisioningUpdates, + RemoteMutationStarted = () => uploadRemoteMutationStarted = true + }); + } + catch (Exception ex) when (!direct && uploadRemoteMutationStarted) + { + try + { + WriteAppleUploadAmbiguity(plan, app, result, $"The upload process did not return a definitive result: {ex.Message}"); + } + catch (Exception checkpointException) + { + throw new AggregateException( + $"The App Store upload attempt for '{app.Name}' was indeterminate and its ambiguity checkpoint could not be persisted. Do not upload again until remote state is reconciled.", + ex, + checkpointException); + } + throw new InvalidOperationException( + $"The App Store upload attempt for '{app.Name}' ended without a definitive result. Do not upload again until App Store Connect state is reconciled.", + ex); + } + upload.ArchivePath = app.ArchivePath; result.Upload = upload; + if (!direct && upload.Succeeded) + { + try + { + WriteAppleUploadAttestation(plan, app, result); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"The App Store upload completed for '{app.Name}', but its durable upload attestation could not be persisted. " + + "Do not upload the archive again until App Store Connect state has been reconciled.", + ex); + } + } + else if (!direct && uploadRemoteMutationStarted) + { + try + { + WriteAppleUploadAmbiguity( + plan, + app, + result, + $"xcodebuild exited with code {upload.ProcessResult.ExitCode} (timed out: {upload.ProcessResult.TimedOut})."); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"The App Store upload attempt for '{app.Name}' was indeterminate and its ambiguity checkpoint could not be persisted. " + + "Do not upload again until remote state is reconciled.", + ex); + } + } + if (direct && upload.Succeeded) + directExportSnapshot!.BindProducedArtifact(upload.ExportArtifactPath, upload.ExportArtifactSha256); + uploadMutationMonitor?.ValidateNoChanges(); + if (uploadSnapshot is not null) + uploadSnapshot.ValidateUnchanged(approvedArchiveSha256!); if (!upload.Succeeded) { result.Success = false; result.ErrorMessage = $"xcodebuild exportArchive {(direct ? "Developer ID export" : "upload")} failed for '{app.Name}' with exit code {upload.ProcessResult.ExitCode}."; return CompleteAppleExecutionFailure(plan, resultsByApp, app); } + VerifyAppleArchiveUnchangedAfterUpload(app, result); if (direct) { - result.Notarization = NotarizeDirectAppleExport(plan, app); + var published = directExportSnapshot!.Publish(app.ExportPath); + upload.ExportPath = published.ExportPath; + upload.ExportArtifactPath = published.ArtifactPath; + upload.ExportArtifactSha256 = published.ArtifactSha256; + upload.ExportOptionsPlistPath = MapDirectExportOutputPath( + directExportSnapshot.ExportPath, + published.ExportPath, + upload.ExportOptionsPlistPath) ?? upload.ExportOptionsPlistPath; + upload.DistributionLogPath = MapDirectExportOutputPath( + directExportSnapshot.ExportPath, + published.ExportPath, + upload.DistributionLogPath); + result.Notarization = NotarizeDirectAppleExport( + plan, + app, + published.ArtifactPath, + expectedArtifactSha256: published.ArtifactSha256); + WriteAppleNotarizationAttestation(plan, app, result); if (!result.Notarization.Succeeded) throw CreateAppleNotarizationFailure(app, result.Notarization); + directExportSnapshot.CommitPublication(); + } + else + { + if (IsUploadExecution(plan) && + plan.Automation.WaitForProcessing && + UsesAppStoreConnect(app)) + result.RemoteState = WaitForAppleBuild(plan, app, buildUploadId: upload.BuildUploadId); } - else if (IsUploadAction(plan.Action) && - plan.Automation.WaitForProcessing) - result.RemoteState = WaitForAppleBuild(plan, app, buildUploadId: upload.BuildUploadId); } var appInfoMetadataSpecs = app.DistributionRoute == AppleDistributionRoute.AppStore && @@ -2166,6 +2428,15 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( AppInfoMetadataSpecs = appInfoMetadataSpecs, ReplaceScreenshots = plan.ReplaceScreenshots, ExpectedSourceCommit = plan.SourceCommit, + ExpectedScreenshotFileSha256 = plan.ApprovedMutationInputFilesSha256.Count == 0 + ? null + : plan.ApprovedMutationInputFilesSha256.ToDictionary( + value => Path.GetFullPath(Path.Combine(plan.ProjectRoot, value.Key)), + static value => value.Value, + Path.DirectorySeparatorChar == '\\' + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal), + ExpectedScreenshotInventorySha256 = app.ExpectedScreenshotInventorySha256, CheckReadiness = plan.CheckReleaseReadiness, ReadinessRequest = plan.CheckReleaseReadiness && matchingScreenshotSpec is not null ? new AppStoreConnectReleaseReadinessRequest @@ -2179,6 +2450,8 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( : Path.GetDirectoryName(matchingMetadataSpec.Value.ConfigPath) ?? plan.ProjectRoot : Path.GetDirectoryName(matchingScreenshotSpec.Value.ConfigPath) ?? plan.ProjectRoot }); + if (appInfoMetadataSpecs.Length > 0) + ValidateAppleAppInfoMutationResults(appInfoMetadataSpecs, result.Distribution.AppInfoMetadataResults); } if (plan.DistributeTestFlight && result.Success && UsesTestFlight(app)) @@ -2273,6 +2546,26 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( return plan.Apps.Select(app => resultsByApp[app]).ToArray(); } + private static void CaptureAppleArchiveSha256( + PowerForgeAppleAppReleaseResult result, + PowerForgeAppleAppReleaseTargetPlan app, + string? requiredSha256 = null) + { + if (!File.Exists(app.ArchivePath) && !Directory.Exists(app.ArchivePath)) + return; + var actual = AppleNotarizationService.ComputeArtifactSha256(app.ArchivePath); + var expected = requiredSha256 ?? app.ExpectedArchiveSha256 ?? result.ArchiveSha256; + if (!string.IsNullOrWhiteSpace(expected) && + !actual.Equals(expected, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The Apple archive for '{app.Name}' changed during upload or between approval and validation. " + + $"Expected SHA-256 '{expected}', received '{actual}'. Rebuild and approve a new exact archive."); + } + result.ArchiveSha256 = actual; + app.ExpectedArchiveSha256 = actual; + } + private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( PowerForgeAppleReleasePlan plan, out PowerForgeAppleReleaseCleanupReceipt cleanup) @@ -2284,6 +2577,11 @@ private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( Action = PowerForgeAppleReleaseAction.Archive, Automation = new PowerForgeAppleReleaseAutomationOptions(), ReceiptPath = plan.ReceiptPath, + ReceiptHistoryPath = plan.ReceiptHistoryPath, + SourceCommit = plan.SourceCommit, + RequireImmutableSourceSnapshot = plan.RequireImmutableSourceSnapshot, + ExactSourceConfigPath = plan.ExactSourceConfigPath, + ExactSourceConfigSha256 = plan.ExactSourceConfigSha256, Archive = true, Upload = false, XcodeBuildExecutable = plan.XcodeBuildExecutable, @@ -2299,7 +2597,17 @@ private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( }; var results = RunAppleRelease(checkpointPlan, out cleanup); if (results.All(static app => app.Success)) + { + var missingEvidence = results.FirstOrDefault(result => + ShouldExecuteAppleTarget(PowerForgeAppleReleaseAction.Archive, result.Plan) && + string.IsNullOrWhiteSpace(result.ArchiveSha256)); + if (missingEvidence is not null) + { + throw new InvalidOperationException( + $"Apple archive checkpoint for '{missingEvidence.Plan.Name}' did not produce exact archive SHA-256 evidence."); + } plan.Archive = false; + } return results; } @@ -2465,7 +2773,7 @@ private static (AppStoreConnectScreenshotSyncSpec Spec, string ConfigPath)[] Loa .Distinct(StringComparer.OrdinalIgnoreCase) .Select(path => { - var json = File.ReadAllText(path); + var json = ReadApprovedMutationInputText(plan, path); var spec = JsonSerializer.Deserialize(json, CreateJsonOptions()) ?? throw new InvalidOperationException($"Unable to deserialize screenshot sync config: {path}"); return (spec, path); @@ -2484,7 +2792,7 @@ private static (AppStoreConnectVersionMetadataSpec Spec, string ConfigPath)[] Lo .Distinct(StringComparer.OrdinalIgnoreCase) .Select(path => { - var json = File.ReadAllText(path); + var json = ReadApprovedMutationInputText(plan, path); var spec = JsonSerializer.Deserialize(json, CreateJsonOptions()) ?? throw new InvalidOperationException($"Unable to deserialize App Store version metadata config: {path}"); return (spec, path); @@ -2501,7 +2809,7 @@ private static Dictionary LoadAppleGovern var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var path in paths.Distinct(StringComparer.OrdinalIgnoreCase)) { - var spec = configuration.Load(path); + var spec = configuration.LoadContent(ReadApprovedMutationInputText(plan, path), path); var findings = configuration.Validate(spec); var errors = findings.Where(static finding => finding.IsError).ToArray(); if (errors.Length > 0) @@ -5034,7 +5342,7 @@ private static string GetStageEntryName( }; } - private static string SanitizeStageEntryName(string value) + internal static string SanitizeStageEntryName(string value) { var normalized = value.Trim(); if (string.IsNullOrWhiteSpace(normalized)) diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs index 5b15ceb11..08bef429c 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs @@ -13,6 +13,8 @@ public sealed class ReleaseBuildExecutionService : IReleaseBuildExecutionService private readonly ProjectBuildCommandHostService _projectBuildCommandHostService; private readonly ModuleBuildHostService _moduleBuildHostService; private readonly Func _executeUnifiedReleaseBuild; + private readonly Func _captureAppleSourceTrust; + private readonly Action _validateAppleSourceTrustAfterBuild; public ReleaseBuildExecutionService() : this(new RepositoryCatalogScanner(), new ProjectBuildHostService(), new ProjectBuildCommandHostService(), new ModuleBuildHostService()) @@ -24,13 +26,34 @@ internal ReleaseBuildExecutionService( ProjectBuildHostService projectBuildHostService, ProjectBuildCommandHostService projectBuildCommandHostService, ModuleBuildHostService moduleBuildHostService, - Func? executeUnifiedReleaseBuild = null) + Func? executeUnifiedReleaseBuild = null, + Func? resolveAppleSourceCommit = null, + Func? captureAppleSourceTrust = null, + Action? validateAppleSourceTrustAfterBuild = null) { _catalogScanner = catalogScanner; _projectBuildHostService = projectBuildHostService; _projectBuildCommandHostService = projectBuildCommandHostService; _moduleBuildHostService = moduleBuildHostService; _executeUnifiedReleaseBuild = executeUnifiedReleaseBuild ?? ExecuteUnifiedReleaseBuild; + if (resolveAppleSourceCommit is not null) + { + _captureAppleSourceTrust = (root, config) => + new AppleReleaseSourceTrustSnapshot(resolveAppleSourceCommit(root, config), Array.Empty()); + _validateAppleSourceTrustAfterBuild = (root, config, snapshot) => + { + var completed = resolveAppleSourceCommit(root, config); + if (!completed.Equals(snapshot.SourceCommit, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + "Repository HEAD changed while the Apple release checkpoint was being built. Rebuild from the new exact source commit."); + }; + } + else + { + var sourceTrust = new AppleReleaseSourceTrustService(); + _captureAppleSourceTrust = captureAppleSourceTrust ?? sourceTrust.Capture; + _validateAppleSourceTrustAfterBuild = validateAppleSourceTrustAfterBuild ?? sourceTrust.ValidateAfterBuild; + } } public async Task ExecuteAsync(string repositoryRoot, CancellationToken cancellationToken = default) @@ -67,10 +90,26 @@ public async Task ExecuteAsync(string repositoryRoo configPath, PowerForgeStudioHostPaths.ResolvePSPublishModulePath(), moduleStagingPath); + AppleReleaseSourceTrustSnapshot? appleSourceTrust = null; + if (!unifiedRequest.SkipAppleApps) + { + appleSourceTrust = _captureAppleSourceTrust(repositoryRoot, configPath); + if (appleSourceTrust.ExactConfigurationContent is not null) + { + unifiedRequest = CreateUnifiedReleaseBuildRequest( + configPath, + PowerForgeStudioHostPaths.ResolvePSPublishModulePath(), + moduleStagingPath, + appleSourceTrust.ExactConfigurationContent); + } + unifiedRequest.AppleSourceCommit = appleSourceTrust.SourceCommit; + } unifiedRequest.CancellationToken = cancellationToken; var unified = await Task.Run( () => _executeUnifiedReleaseBuild(configPath, unifiedRequest), cancellationToken).ConfigureAwait(false); + if (appleSourceTrust is not null) + _validateAppleSourceTrustAfterBuild(repositoryRoot, configPath, appleSourceTrust); var moduleExportCheckpoint = await CaptureScriptModuleExportedConfigFingerprintAsync( repository, @@ -137,7 +176,8 @@ await CaptureScriptModuleExportedConfigFingerprintAsync( internal static PowerForgeReleaseRequest CreateUnifiedReleaseBuildRequest( string configPath, string moduleHostPath, - string moduleStagingPath) + string moduleStagingPath, + string? exactConfigurationContent = null) { var request = new PowerForgeReleaseRequest { ConfigPath = configPath, @@ -153,12 +193,16 @@ internal static PowerForgeReleaseRequest CreateUnifiedReleaseBuildRequest( SkipAppleApps = true, SubmitWinget = false }; + request.ExactConfigurationContent = exactConfigurationContent; - var spec = PowerForgeReleaseService.LoadConfiguration(configPath); + var spec = exactConfigurationContent is null + ? PowerForgeReleaseService.LoadConfiguration(configPath) + : PowerForgeReleaseService.LoadConfigurationContent(exactConfigurationContent, configPath); if (spec.AppleApps is not null) { request.SkipAppleApps = false; request.CheckpointAppleApps = true; + request.RequireImmutableAppleSourceSnapshot = true; request.PlanOnly = spec.Module is null && spec.Packages is null && @@ -171,10 +215,25 @@ spec.Tools is null && private static PowerForgeReleaseResult ExecuteUnifiedReleaseBuild(string configPath, PowerForgeReleaseRequest request) { - var spec = PowerForgeReleaseService.LoadConfiguration(configPath); + var spec = request.ExactConfigurationContent is null + ? PowerForgeReleaseService.LoadConfiguration(configPath) + : PowerForgeReleaseService.LoadConfigurationContent(request.ExactConfigurationContent, configPath); return new PowerForgeReleaseService(new NullLogger()).Execute(spec, request); } + internal static string ResolveExactGitHead(string repositoryRoot) + { + var git = new HomeAssistantReleaseGitService(); + git.EnsureClean(repositoryRoot); + var sourceCommit = git.GetHeadSha(repositoryRoot).Trim(); + if (!GitObjectId.IsFull(sourceCommit)) + throw new InvalidOperationException("Apple release checkpoints require a full SHA-1 or SHA-256 repository HEAD."); + return sourceCommit.ToLowerInvariant(); + } + + internal static string ResolveExactAppleSourceCommit(string repositoryRoot, string configPath) + => new AppleReleaseSourceTrustService().ResolveExactCommit(repositoryRoot, configPath); + private static IReadOnlyList CreateUnifiedAdapterResults( PowerForgeStudio.Domain.Catalog.RepositoryCatalogEntry repository, PowerForgeReleaseResult unified, diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs b/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs index ae2450c0e..b21c15950 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs @@ -290,16 +290,44 @@ private static PowerForgeReleaseResult PublishUnifiedRelease( PrepareApplePublishFromCheckpoint(spec, builtResult); return new PowerForgeReleaseService(new NullLogger()).PublishBuiltReleaseOutputs( spec, - new PowerForgeReleaseRequest { - ConfigPath = configPath, - ModuleHostPath = PowerForgeStudioHostPaths.ResolvePSPublishModulePath(), - ModuleRunMode = ConfigurationGateMode.Publish, - AppleActionConfirmed = true, - CancellationToken = cancellationToken - }, + CreateUnifiedPublishRequest(configPath, builtResult, cancellationToken), builtResult); } + internal static PowerForgeReleaseRequest CreateUnifiedPublishRequest( + string configPath, + PowerForgeReleaseResult builtResult, + CancellationToken cancellationToken = default) + { + var applePlan = builtResult.AppleAppPlan; + return new PowerForgeReleaseRequest + { + ConfigPath = configPath, + ModuleHostPath = PowerForgeStudioHostPaths.ResolvePSPublishModulePath(), + ModuleRunMode = ConfigurationGateMode.Publish, + AppleMarketingVersion = applePlan?.RequestedMarketingVersion, + AppleSourceCommit = applePlan?.SourceCommit, + RequireImmutableAppleSourceSnapshot = + applePlan?.RequireImmutableSourceSnapshot == true || + !string.IsNullOrWhiteSpace(applePlan?.SourceCommit), + AppleExpectedPlanSha256 = builtResult.AppleReceipt?.PlanSha256, + AppleExpectedArchiveSha256ByTarget = applePlan?.Apps + .Where(static app => !string.IsNullOrWhiteSpace(app.ExpectedArchiveSha256)) + .ToDictionary( + static app => app.Name, + static app => app.ExpectedArchiveSha256!, + StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + AppleAdoptExistingBuild = applePlan?.AdoptExistingBuild == true, + AppleResume = applePlan?.Automation.Resume, + AppleWaitForProcessing = applePlan?.Automation.WaitForProcessing, + AppleProcessingTimeoutSeconds = applePlan?.Automation.ProcessingTimeoutSeconds, + ApplePollIntervalSeconds = applePlan?.Automation.PollIntervalSeconds, + AppleActionConfirmed = true, + CancellationToken = cancellationToken + }; + } + internal static void PrepareApplePublishFromCheckpoint( PowerForgeReleaseSpec spec, PowerForgeReleaseResult builtResult) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs new file mode 100644 index 000000000..2b2ec5588 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs @@ -0,0 +1,591 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("--skip-worktree")] + [InlineData("--assume-unchanged")] + public void ResolveExactAppleSourceCommit_rejects_hidden_index_state_on_xcode_input(string indexFlag) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("HiddenIndexAppleInputRepo"); + var project = scope.CreateDirectory(Path.Combine("HiddenIndexAppleInputRepo", "Sample.xcodeproj")); + var projectFile = Path.Combine(project, "project.pbxproj"); + File.WriteAllText(projectFile, "// committed project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + RunGit(repositoryRoot, "update-index", indexFlag, "Sample.xcodeproj/project.pbxproj"); + File.WriteAllText(projectFile, "// hidden replacement project"); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("skip-worktree or assume-unchanged", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_unlocked_remote_swift_package() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("RemotePackageInputRepo"); + var project = scope.CreateDirectory(Path.Combine("RemotePackageInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://example.invalid/MutablePackage.git"; + requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Package.resolved", exception.Message, StringComparison.Ordinal); + Assert.Contains("exact", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_lock_for_substring_package_identity() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SubstringPackageLockRepo"); + var project = scope.CreateDirectory(Path.Combine("SubstringPackageLockRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://example.invalid/foo.git"; + requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; + }; + """); + var lockDirectory = scope.CreateDirectory(Path.Combine( + "SubstringPackageLockRepo", "Sample.xcodeproj", "project.xcworkspace", "xcshareddata", "swiftpm")); + File.WriteAllText( + Path.Combine(lockDirectory, "Package.resolved"), + """{ "pins": [ { "identity": "foo-tools", "location": "https://example.invalid/foo-tools.git", "state": { "revision": "0123456789abcdef0123456789abcdef01234567" } } ] }"""); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("https://example.invalid/foo.git", exception.Message, StringComparison.Ordinal); + Assert.Contains("Package.resolved", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_unlocked_remote_dependency_in_local_swift_package() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LocalRemotePackageInputRepo"); + var project = scope.CreateDirectory(Path.Combine("LocalRemotePackageInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/Shared; + }; + """); + var package = scope.CreateDirectory(Path.Combine("LocalRemotePackageInputRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "Shared", + dependencies: [ + .package(url: "https://example.invalid/MutablePackage.git", from: "1.0.0") + ] + ) + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Package.resolved", exception.Message, StringComparison.Ordinal); + Assert.Contains("exact", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_unsafe_flags_in_local_swift_package() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("UnsafeFlagsPackageInputRepo"); + var project = scope.CreateDirectory(Path.Combine("UnsafeFlagsPackageInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/Shared; + }; + """); + var package = scope.CreateDirectory(Path.Combine("UnsafeFlagsPackageInputRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "Shared", + targets: [ + .target( + name: "Shared", + cSettings: [.unsafeFlags(["-include", "/tmp/injected.h"])] + ) + ] + ) + """); + var sources = scope.CreateDirectory(Path.Combine("UnsafeFlagsPackageInputRepo", "Packages", "Shared", "Sources", "Shared")); + File.WriteAllText(Path.Combine(sources, "shared.c"), "int shared(void) { return 1; }"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("unsafeFlags", exception.Message, StringComparison.Ordinal); + Assert.Contains("cannot be proven", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("let rejected = CSetting.`unsafeFlags`([\"-include\", \"/tmp/injected.h\"])", "unsafeFlags")] + [InlineData("let rejected = CSetting.unsafeFlags", "unsafeFlags")] + [InlineData("let rejected = Target.`systemLibrary`", "systemLibrary")] + public void ResolveExactAppleSourceCommit_rejects_any_executable_unsafe_manifest_identifier( + string manifestSyntax, + string expectedIdentifier) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("UnsafeManifestIdentifierRepo"); + var project = scope.CreateDirectory(Path.Combine("UnsafeManifestIdentifierRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("UnsafeManifestIdentifierRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + $"// swift-tools-version: 6.0\nimport PackageDescription\n{manifestSyntax}\nlet package = Package(name: \"Shared\")"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(expectedIdentifier, exception.Message, StringComparison.Ordinal); + Assert.Contains("cannot be proven", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_ignores_disallowed_manifest_tokens_in_comments_and_strings() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CommentedPackageSyntaxRepo"); + var project = scope.CreateDirectory(Path.Combine("CommentedPackageSyntaxRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("CommentedPackageSyntaxRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + """" + // swift-tools-version: 6.0 + import PackageDescription + // Documentation example: .unsafeFlags(["-I/tmp"]) and .systemLibrary(name: "Host") + let documentation = ".unsafeFlags( and .systemLibrary( are rejected when used as syntax" + let rawDocumentation = #".plugin( and .macro( are rejected when used as syntax"# + let escapedInterpolationDocumentation = "\\(literal documentation)" + let rawInterpolationDocumentation = #"\(literal raw documentation)"# + let multilineDocumentation = """ + Nested /* comment markers */ and .macro( remain inert inside a multiline string. + """ + let package = Package(name: "Shared") + """"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var sourceCommit = CommitRepository(repositoryRoot); + + var resolved = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(sourceCommit, resolved); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_local_system_library_package() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SystemLibraryPackageRepo"); + var project = scope.CreateDirectory(Path.Combine("SystemLibraryPackageRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("SystemLibraryPackageRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "Shared", + targets: [.systemLibrary(name: "CLib", pkgConfig: "libfoo")] + ) + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("systemLibrary", exception.Message, StringComparison.Ordinal); + Assert.Contains("pkg-config", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("targets: [.plugin(name: \"Generator\", capability: .buildTool())]")] + [InlineData("targets: [.target(name: \"App\", plugins: [.plugin(name: \"Generator\")])]")] + public void ResolveExactAppleSourceCommit_rejects_local_swift_build_tool_plugins(string targets) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("BuildToolPluginPackageRepo"); + var project = scope.CreateDirectory(Path.Combine("BuildToolPluginPackageRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("BuildToolPluginPackageRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + $"// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Shared\", {targets})"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("plugin", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("runtime inputs", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_computed_local_package_path() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ComputedPackagePathRepo"); + var project = scope.CreateDirectory(Path.Combine("ComputedPackagePathRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("ComputedPackagePathRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet custom = \"Generated\"\nlet package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", path: custom)])"); + var generated = scope.CreateDirectory(Path.Combine("ComputedPackagePathRepo", "Packages", "Shared", "Generated")); + File.WriteAllText(Path.Combine(generated, "Injected.swift"), "struct Injected {}"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Packages/Shared/Generated/\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("computed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("path", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_custom_smudge_filtered_worktree_bytes() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("FilteredAppleInputRepo"); + var project = scope.CreateDirectory(Path.Combine("FilteredAppleInputRepo", "Sample.xcodeproj")); + var projectFile = Path.Combine(project, "project.pbxproj"); + File.WriteAllText(projectFile, "// committed project"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitattributes"), "*.pbxproj filter=attested\n"); + RunGit(repositoryRoot, "init", "--quiet"); + RunGit(repositoryRoot, "config", "filter.attested.clean", "sed 's/worktree/committed/g'"); + RunGit(repositoryRoot, "config", "filter.attested.smudge", "sed 's/committed/worktree/g'"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + File.Delete(projectFile); + RunGit(repositoryRoot, "checkout", "--", "Sample.xcodeproj/project.pbxproj"); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("custom Git filter", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("attested", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void AppleSourceSnapshot_rejects_custom_filters_in_detached_checkout() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("FilteredSnapshotAppleRepo"); + var project = scope.CreateDirectory(Path.Combine("FilteredSnapshotAppleRepo", "Sample.xcodeproj")); + var projectFile = Path.Combine(project, "project.pbxproj"); + File.WriteAllText(projectFile, "// committed project"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitattributes"), "*.pbxproj filter=attested\n"); + RunGit(repositoryRoot, "init", "--quiet"); + RunGit(repositoryRoot, "config", "filter.attested.clean", "sed 's/worktree/committed/g'"); + RunGit(repositoryRoot, "config", "filter.attested.smudge", "sed 's/committed/worktree/g'"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var sourceCommit = CommitRepository(repositoryRoot); + Assert.Equal("// committed project", File.ReadAllText(projectFile)); + + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = repositoryRoot, + Archive = true, + SourceCommit = sourceCommit, + RequireImmutableSourceSnapshot = true, + ExactSourceConfigPath = configPath + }; + var exception = Assert.Throws(() => + AppleReleaseSourceSnapshot.CreateIfRequired(plan)); + + Assert.Contains("custom Git filter", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_custom_filters_in_synchronized_tree() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("FilteredSynchronizedTreeRepo"); + var project = scope.CreateDirectory(Path.Combine("FilteredSynchronizedTreeRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Sources; + sourceTree = SOURCE_ROOT; + }; + """); + var sources = scope.CreateDirectory(Path.Combine("FilteredSynchronizedTreeRepo", "Sources")); + var sourceFile = Path.Combine(sources, "Filtered.swift"); + File.WriteAllText(sourceFile, "struct committed {}"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitattributes"), "Sources/*.swift filter=attested\n"); + RunGit(repositoryRoot, "init", "--quiet"); + RunGit(repositoryRoot, "config", "filter.attested.clean", "sed 's/worktree/committed/g'"); + RunGit(repositoryRoot, "config", "filter.attested.smudge", "sed 's/committed/worktree/g'"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + File.Delete(sourceFile); + RunGit(repositoryRoot, "checkout", "--", "Sources/Filtered.swift"); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("custom Git filter", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_locked_remote_package_when_source_cannot_be_inspected() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LockedLocalRemotePackageInputRepo"); + var project = scope.CreateDirectory(Path.Combine("LockedLocalRemotePackageInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/Shared; + }; + """); + var package = scope.CreateDirectory(Path.Combine("LockedLocalRemotePackageInputRepo", "Packages", "Shared")); + const string dependencyUrl = "https://example.invalid/MutablePackage.git"; + File.WriteAllText( + Path.Combine(package, "Package.swift"), + $$""" + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "Shared", + dependencies: [ + .package(url: "{{dependencyUrl}}", from: "1.0.0") + ] + ) + """); + var lockDirectory = scope.CreateDirectory(Path.Combine( + "LockedLocalRemotePackageInputRepo", + "Sample.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm")); + File.WriteAllText( + Path.Combine(lockDirectory, "Package.resolved"), + $$"""{ "pins": [ { "location": "{{dependencyUrl}}", "state": { "revision": "0123456789abcdef0123456789abcdef01234567" } } ] }"""); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("fetch", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains(dependencyUrl, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_workspace_locked_remote_package_when_source_cannot_be_inspected() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("WorkspacePackageLockRepo"); + var project = scope.CreateDirectory(Path.Combine("WorkspacePackageLockRepo", "Apps", "iOS", "App.xcodeproj")); + const string dependencyUrl = "https://example.invalid/WorkspacePackage.git"; + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $$""" + 000000000000000000000001 = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "{{dependencyUrl}}"; + requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; + }; + """); + var workspace = scope.CreateDirectory(Path.Combine("WorkspacePackageLockRepo", "Main.xcworkspace")); + File.WriteAllText( + Path.Combine(workspace, "contents.xcworkspacedata"), + """ + + + """); + var schemeDirectory = scope.CreateDirectory(Path.Combine( + "WorkspacePackageLockRepo", "Main.xcworkspace", "xcshareddata", "xcschemes")); + File.WriteAllText(Path.Combine(schemeDirectory, "App.xcscheme"), ""); + var lockDirectory = scope.CreateDirectory(Path.Combine( + "WorkspacePackageLockRepo", "Main.xcworkspace", "xcshareddata", "swiftpm")); + File.WriteAllText( + Path.Combine(lockDirectory, "Package.resolved"), + $$"""{ "pins": [ { "location": "{{dependencyUrl}}", "state": { "revision": "0123456789abcdef0123456789abcdef01234567" } } ] }"""); + var configPath = Path.Combine(repositoryRoot, "powerforge.release.json"); + File.WriteAllText( + configPath, + """ + { + "AppleApps": { + "ProjectRoot": ".", + "Apps": [ + { + "Name": "App", + "ProjectPath": "Main.xcworkspace", + "Scheme": "App" + } + ] + } + } + """); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("fetch", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains(dependencyUrl, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_git_replacement_refs() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ReplacementRefRepo"); + var project = scope.CreateDirectory(Path.Combine("ReplacementRefRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var originalHead = CommitRepository(repositoryRoot); + File.WriteAllText(Path.Combine(repositoryRoot, "replacement.txt"), "alternate source"); + RunGit(repositoryRoot, "add", "replacement.txt"); + RunGit(repositoryRoot, "commit", "--quiet", "-m", "Replacement source"); + RunGit(repositoryRoot, "replace", originalHead, "HEAD"); + RunGit(repositoryRoot, "reset", "--hard", originalHead); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("replacement refs", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_sha256_repository_head() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("Sha256AppleSourceRepo"); + RunGit(repositoryRoot, "init", "--quiet", "--object-format=sha256"); + var project = scope.CreateDirectory(Path.Combine("Sha256AppleSourceRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// SHA-256 project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var sourceCommit = CommitRepository(repositoryRoot); + + var resolved = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(64, sourceCommit.Length); + Assert.Equal(sourceCommit, resolved); + } + + private static string WriteAppleReleaseConfig( + string repositoryRoot, + string projectRoot, + bool createSharedScheme = true) + { + var projectPath = Path.Combine(repositoryRoot, "Sample.xcodeproj"); + if (createSharedScheme && Directory.Exists(projectPath)) + { + var schemes = Directory.CreateDirectory(Path.Combine(projectPath, "xcshareddata", "xcschemes")); + File.WriteAllText(Path.Combine(schemes.FullName, "Sample.xcscheme"), ""); + } + var configPath = Path.Combine(repositoryRoot, "powerforge.release.json"); + File.WriteAllText( + configPath, + $$""" + { + "AppleApps": { + "ProjectRoot": "{{projectRoot}}", + "Apps": [ + { + "Name": "Sample", + "ProjectPath": "Sample.xcodeproj", + "Scheme": "Sample" + } + ] + } + } + """); + return configPath; + } + + private static string CommitRepository(string repositoryRoot) + { + RunGit(repositoryRoot, "init", "--quiet"); + RunGit(repositoryRoot, "config", "user.name", "PowerForge Tests"); + RunGit(repositoryRoot, "config", "user.email", "powerforge-tests@example.invalid"); + RunGit(repositoryRoot, "add", "."); + RunGit(repositoryRoot, "commit", "--quiet", "-m", "Apple source fixture"); + var startInfo = new System.Diagnostics.ProcessStartInfo("git") + { + WorkingDirectory = repositoryRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("rev-parse"); + startInfo.ArgumentList.Add("HEAD"); + using var process = System.Diagnostics.Process.Start(startInfo) + ?? throw new InvalidOperationException("Unable to read fixture HEAD."); + var sha = process.StandardOutput.ReadToEnd().Trim(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"git rev-parse HEAD failed: {error}"); + return sha; + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.RemoteMirrors.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.RemoteMirrors.cs new file mode 100644 index 000000000..812a719c4 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.RemoteMirrors.cs @@ -0,0 +1,35 @@ +using PowerForge; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Fact] + public void InitializeRemotePackageMirror_uses_revision_object_format() + { + using var scope = new TemporaryDirectoryScope(); + var mirror = scope.CreateDirectory("Sha256RemoteMirror"); + + new AppleReleaseSourceTrustService().InitializeRemotePackageMirror(mirror, new string('a', 64)); + + var config = File.ReadAllText(Path.Combine(mirror, "config")); + Assert.Contains("objectformat = sha256", config, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EnsureRemotePackageMirror_rejects_existing_mismatched_object_format_before_fetch() + { + using var scope = new TemporaryDirectoryScope(); + var mirror = scope.CreateDirectory("Sha1RemoteMirror"); + RunGit(mirror, "init", "--quiet", "--bare", "--object-format=sha1"); + + var exception = Assert.Throws(() => + new AppleReleaseSourceTrustService().EnsureRemotePackageMirror( + mirror, + "https://example.invalid/Package.git", + new string('b', 64))); + + Assert.Contains("object format", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sha256", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs new file mode 100644 index 000000000..5dd8ef658 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -0,0 +1,723 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Fact] + public void AppleCheckpointRequest_UsesCapturedExactReleaseConfiguration() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CapturedConfigRepo"); + var project = scope.CreateDirectory(Path.Combine("CapturedConfigRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// tracked project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + var snapshot = new AppleReleaseSourceTrustService().Capture(repositoryRoot, configPath); + File.WriteAllText(configPath, "{}"); + + var request = ReleaseBuildExecutionService.CreateUnifiedReleaseBuildRequest( + configPath, + "PSPublishModule.dll", + Path.Combine(repositoryRoot, "staging"), + snapshot.ExactConfigurationContent); + + Assert.False(request.SkipAppleApps); + Assert.True(request.CheckpointAppleApps); + Assert.Equal(snapshot.ExactConfigurationContent, request.ExactConfigurationContent); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_tracked_project_inputs_and_ignored_user_state() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("TrackedAppleRepo"); + var project = scope.CreateDirectory(Path.Combine("TrackedAppleRepo", "Sample.xcodeproj")); + var userState = scope.CreateDirectory(Path.Combine( + "TrackedAppleRepo", + "Sample.xcodeproj", + "xcuserdata", + "developer.xcuserdatad")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "path = Sample.swift; sourceTree = SOURCE_ROOT;"); + File.WriteAllText(Path.Combine(repositoryRoot, "Sample.swift"), "struct Sample {}"); + File.WriteAllText(Path.Combine(userState, "xcschememanagement.plist"), "local user state"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "**/xcuserdata/\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_project_root_outside_repository() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ContainedAppleRepo"); + scope.CreateDirectory("OutsideAppleSource"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "../OutsideAppleSource"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("ProjectRoot", exception.Message, StringComparison.Ordinal); + Assert.Contains("inside", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_ignored_file_referenced_by_Xcode_project() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("IgnoredAppleInputRepo"); + var project = scope.CreateDirectory(Path.Combine("IgnoredAppleInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "baseConfigurationReference = Sample.xcconfig; path = Sample.swift;"); + File.WriteAllText(Path.Combine(repositoryRoot, "Sample.swift"), "struct Sample {}"); + File.WriteAllText(Path.Combine(repositoryRoot, "Sample.xcconfig"), "SWIFT_ACTIVE_COMPILATION_CONDITIONS = UNREVIEWED"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "*.xcconfig\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Ignored Apple build input", exception.Message, StringComparison.Ordinal); + Assert.Contains("Sample.xcconfig", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_ignored_compiled_source_even_without_explicit_reference() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("IgnoredSwiftRepo"); + var project = scope.CreateDirectory(Path.Combine("IgnoredSwiftRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// synchronized project fixture"); + File.WriteAllText(Path.Combine(repositoryRoot, "Generated.swift"), "struct Generated {}"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Generated.swift\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Generated.swift", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_ignored_resource_in_synchronized_Xcode_group() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SynchronizedAppleInputRepo"); + var project = scope.CreateDirectory(Path.Combine("SynchronizedAppleInputRepo", "Sample.xcodeproj")); + var synchronizedSources = scope.CreateDirectory(Path.Combine("SynchronizedAppleInputRepo", "Parent", "AppSources")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = PBXGroup; + children = ( + 000000000000000000000002, + ); + path = Parent; + sourceTree = ""; + }; + /* Begin PBXFileSystemSynchronizedRootGroup section */ + 000000000000000000000002 = { + isa = PBXFileSystemSynchronizedRootGroup; + path = AppSources; + sourceTree = ""; + }; + /* End PBXFileSystemSynchronizedRootGroup section */ + """); + File.WriteAllText(Path.Combine(synchronizedSources, "RuntimeConfig.json"), "{ \"mode\": \"unreviewed\" }"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Parent/AppSources/RuntimeConfig.json\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("RuntimeConfig.json", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_ignored_user_scheme_selected_for_release() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("UserSchemeAppleRepo"); + var project = scope.CreateDirectory(Path.Combine("UserSchemeAppleRepo", "Sample.xcodeproj")); + var userSchemes = scope.CreateDirectory(Path.Combine( + "UserSchemeAppleRepo", + "Sample.xcodeproj", + "xcuserdata", + "developer.xcuserdatad", + "xcschemes")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// exact project"); + File.WriteAllText(Path.Combine(userSchemes, "Sample.xcscheme"), ""); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "**/xcuserdata/\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Ignored Apple build input", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Sample.xcscheme", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_project_reference_outside_repository() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ExternalReferenceAppleRepo"); + var project = scope.CreateDirectory(Path.Combine("ExternalReferenceAppleRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000000 = { + isa = PBXBuildFile; + fileRef = 000000000000000000000001; + }; + 000000000000000000000001 = { + isa = PBXFileReference; + path = ../../OutsideSecrets/Injected.swift; + sourceTree = SOURCE_ROOT; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("inside", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Xcode PBXFileReference input", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_external_source_tree_build_input() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ExternalTreeBuildInputRepo"); + var project = scope.CreateDirectory(Path.Combine("ExternalTreeBuildInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000000 = { + isa = PBXBuildFile; + fileRef = 000000000000000000000001; + }; + 000000000000000000000001 = { + isa = PBXFileReference; + path = ../../../../tmp/Injected.swift; + sourceTree = DEVELOPER_DIR; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("external source tree", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Injected.swift", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_owned_built_product_reference() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("OwnedBuiltProductRepo"); + var project = scope.CreateDirectory(Path.Combine("OwnedBuiltProductRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000000 = { isa = PBXBuildFile; fileRef = 000000000000000000000001; }; + 000000000000000000000001 = { isa = PBXFileReference; path = SampleExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 000000000000000000000002 = { + isa = PBXNativeTarget; + productReference = 000000000000000000000001; + productType = "com.apple.product-type.app-extension"; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var sourceCommit = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(sourceCommit, actual); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_sdk_framework_build_input() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SdkFrameworkInputRepo"); + var project = scope.CreateDirectory(Path.Combine("SdkFrameworkInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000000 = { isa = PBXBuildFile; fileRef = 000000000000000000000001; }; + 000000000000000000000001 = { + isa = PBXFileReference; + path = System/Library/Frameworks/Foundation.framework; + sourceTree = SDKROOT; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var sourceCommit = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(sourceCommit, actual); + } + + [Fact] + public void ResolveExactAppleSourceCommit_recursively_validates_referenced_subprojects() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("NestedProjectAppleRepo"); + var outer = scope.CreateDirectory(Path.Combine("NestedProjectAppleRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(outer, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = PBXFileReference; + path = Nested.xcodeproj; + sourceTree = SOURCE_ROOT; + }; + """); + var nested = scope.CreateDirectory(Path.Combine("NestedProjectAppleRepo", "Nested.xcodeproj")); + File.WriteAllText( + Path.Combine(nested, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCBuildConfiguration; + buildSettings = { + INFOPLIST_FILE = Secret/Nested-Info.plist; + }; + }; + """); + var secret = scope.CreateDirectory(Path.Combine("NestedProjectAppleRepo", "Secret")); + File.WriteAllText(Path.Combine(secret, "Nested-Info.plist"), "unreviewed nested input"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Secret/\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("INFOPLIST_FILE", exception.Message, StringComparison.Ordinal); + Assert.Contains("tracked", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_generated_project_metadata() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("GeneratedProjectAppleRepo"); + var project = scope.CreateDirectory(Path.Combine("GeneratedProjectAppleRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// generated project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + File.WriteAllText( + configPath, + File.ReadAllText(configPath).Replace( + "\"Scheme\": \"Sample\"", + "\"Scheme\": \"Sample\", \"RegenerateProject\": true", + StringComparison.Ordinal)); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Generate the project first", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_scheme_container_outside_repository() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ExternalSchemeContainerRepo"); + var project = scope.CreateDirectory(Path.Combine("ExternalSchemeContainerRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// exact project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + File.WriteAllText( + Path.Combine(project, "xcshareddata", "xcschemes", "Sample.xcscheme"), + """ + + + + + + + + + + """); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("scheme referenced container", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("inside", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_scheme_execution_actions() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SchemeActionAppleRepo"); + var project = scope.CreateDirectory(Path.Combine("SchemeActionAppleRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// exact project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + File.WriteAllText( + Path.Combine(project, "xcshareddata", "xcschemes", "Sample.xcscheme"), + ""); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("scheme actions", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("cannot be proven", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_variable_based_project_input() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("VariableInputAppleRepo"); + var project = scope.CreateDirectory(Path.Combine("VariableInputAppleRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = PBXFileReference; + path = "$(SRCROOT)/Injected.swift"; + sourceTree = SOURCE_ROOT; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Variable-based", exception.Message, StringComparison.Ordinal); + Assert.Contains("cannot be proven", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_resolves_nested_workspace_groups() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("NestedWorkspaceAppleRepo"); + var workspace = scope.CreateDirectory(Path.Combine("NestedWorkspaceAppleRepo", "Sample.xcworkspace")); + var project = scope.CreateDirectory(Path.Combine("NestedWorkspaceAppleRepo", "Projects", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// exact nested project"); + File.WriteAllText( + Path.Combine(workspace, "contents.xcworkspacedata"), + ""); + var schemes = Directory.CreateDirectory(Path.Combine(workspace, "xcshareddata", "xcschemes")); + File.WriteAllText( + Path.Combine(schemes.FullName, "Sample.xcscheme"), + ""); + var configPath = Path.Combine(repositoryRoot, "powerforge.release.json"); + File.WriteAllText( + configPath, + """ + { + "AppleApps": { + "ProjectRoot": ".", + "Apps": [ + { + "Name": "Sample", + "ProjectPath": "Sample.xcworkspace", + "Scheme": "Sample" + } + ] + } + } + """); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Fact] + public void Apple_source_snapshot_allows_only_declared_generated_outputs_after_build() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("GeneratedArchiveOutputRepo"); + var project = scope.CreateDirectory(Path.Combine("GeneratedArchiveOutputRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// exact project"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Artifacts/\nbuild/\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + var service = new AppleReleaseSourceTrustService(); + + var snapshot = service.Capture(repositoryRoot, configPath); + var archive = Directory.CreateDirectory(Path.Combine( + repositoryRoot, + "Artifacts", + "Apple", + "Archives", + "iOS", + "Sample.xcarchive")); + File.WriteAllText(Path.Combine(archive.FullName, "Info.plist"), "generated archive"); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "build", "powerforge", "apple")); + File.WriteAllText( + Path.Combine(repositoryRoot, "build", "powerforge", "apple", "release-plan.json"), + "generated receipt"); + + service.ValidateAfterBuild(repositoryRoot, configPath, snapshot); + + Assert.Equal(expected, snapshot.SourceCommit); + } + + [Fact] + public void Apple_source_snapshot_rejects_new_source_outside_declared_outputs() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ChangedAppleSourceRepo"); + var project = scope.CreateDirectory(Path.Combine("ChangedAppleSourceRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// exact project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + var service = new AppleReleaseSourceTrustService(); + var snapshot = service.Capture(repositoryRoot, configPath); + File.WriteAllText(Path.Combine(repositoryRoot, "Injected.swift"), "struct Injected {}"); + + var exception = Assert.Throws(() => + service.ValidateAfterBuild(repositoryRoot, configPath, snapshot)); + + Assert.Contains("Injected.swift", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_ignored_file_valued_build_setting() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("BuildSettingAppleInputRepo"); + var project = scope.CreateDirectory(Path.Combine("BuildSettingAppleInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCBuildConfiguration; + buildSettings = { + INFOPLIST_FILE = Secret/Info.plist; + }; + }; + """); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "Secret")); + File.WriteAllText(Path.Combine(repositoryRoot, "Secret", "Info.plist"), "unreviewed plist"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Secret/\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("INFOPLIST_FILE", exception.Message, StringComparison.Ordinal); + Assert.Contains("tracked", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_untracked_descendant_of_folder_reference() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("FolderReferenceAppleInputRepo"); + var project = scope.CreateDirectory(Path.Combine("FolderReferenceAppleInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000000 = { + isa = PBXBuildFile; + fileRef = 000000000000000000000001; + }; + 000000000000000000000001 = { + isa = PBXFileReference; + lastKnownFileType = folder; + path = Resources; + sourceTree = SOURCE_ROOT; + }; + """); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "Resources")); + File.WriteAllText(Path.Combine(repositoryRoot, "Resources", "config.json"), "unreviewed resource"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Resources/config.json\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("config.json", exception.Message, StringComparison.Ordinal); + Assert.Contains("tracked", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_ignored_xcconfig_include() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("XcconfigIncludeAppleInputRepo"); + var project = scope.CreateDirectory(Path.Combine("XcconfigIncludeAppleInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = PBXFileReference; + path = Config/Base.xcconfig; + sourceTree = SOURCE_ROOT; + }; + 000000000000000000000002 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 000000000000000000000001; + buildSettings = {}; + }; + """); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "Config")); + File.WriteAllText(Path.Combine(repositoryRoot, "Config", "Base.xcconfig"), "#include \"Secret.settings\"\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Config", "Secret.settings"), "SWIFT_ACTIVE_COMPILATION_CONDITIONS = UNREVIEWED\n"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Config/Secret.settings\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("xcconfig include", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("tracked", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_external_file_setting_from_xcconfig() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("XcconfigExternalInputRepo"); + var project = scope.CreateDirectory(Path.Combine("XcconfigExternalInputRepo", "Sample.xcodeproj")); + var outside = scope.CreateDirectory("ExternalXcodePayload"); + var outsidePlist = Path.Combine(outside, "Info.plist"); + File.WriteAllText(outsidePlist, "external payload"); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = PBXFileReference; + path = Config.xcconfig; + sourceTree = SOURCE_ROOT; + }; + 000000000000000000000002 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 000000000000000000000001; + buildSettings = {}; + }; + """); + File.WriteAllText(Path.Combine(repositoryRoot, "Config.xcconfig"), $"INFOPLIST_FILE = {outsidePlist}\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("INFOPLIST_FILE", exception.Message, StringComparison.Ordinal); + Assert.Contains("inside", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("OTHER_CFLAGS", "-fplugin=/tmp/injected.dylib")] + [InlineData("OTHER_CFLAGS", "-B/tmp/injected-tools")] + [InlineData("OTHER_LDFLAGS", "-Wl,-force_load,/tmp/libInjected.a")] + [InlineData("OTHER_SWIFT_FLAGS", "-Xcc -fplugin=/tmp/injected.dylib")] + public void ResolveExactAppleSourceCommit_rejects_paths_hidden_in_compiler_option_tokens( + string setting, + string value) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("FlagOptionPathRepo"); + var project = scope.CreateDirectory(Path.Combine("FlagOptionPathRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $$""" + 000000000000000000000001 = { + isa = XCBuildConfiguration; + buildSettings = { + {{setting}} = "{{value}}"; + }; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(setting, exception.Message, StringComparison.Ordinal); + Assert.Contains("/tmp", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_ignored_local_swift_package_source() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LocalPackageInputRepo"); + var project = scope.CreateDirectory(Path.Combine("LocalPackageInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/Shared; + }; + """); + var package = scope.CreateDirectory(Path.Combine("LocalPackageInputRepo", "Packages", "Shared")); + File.WriteAllText(Path.Combine(package, "Package.swift"), "// swift-tools-version: 6.0"); + var sources = Directory.CreateDirectory(Path.Combine(package, "Sources", "Shared")); + File.WriteAllText(Path.Combine(sources.FullName, "Generated.swift"), "struct Injected {}"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Packages/Shared/Sources/Shared/Generated.swift\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Generated.swift", exception.Message, StringComparison.Ordinal); + Assert.Contains("tracked", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_ignored_source_inside_archive_root_outside_exact_artifact() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ArchiveRootSourceRepo"); + var project = scope.CreateDirectory(Path.Combine("ArchiveRootSourceRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// project"); + var generatedRoot = scope.CreateDirectory(Path.Combine("ArchiveRootSourceRepo", "Generated")); + File.WriteAllText(Path.Combine(generatedRoot, "Secret.h"), "#define INJECTED 1"); + File.WriteAllText(Path.Combine(repositoryRoot, ".gitignore"), "Generated/\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + File.WriteAllText( + configPath, + File.ReadAllText(configPath).Replace( + "\"ProjectRoot\": \".\",", + "\"ProjectRoot\": \".\",\n \"ArchiveRoot\": \"Generated\",", + StringComparison.Ordinal)); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Generated/Secret.h", exception.Message, StringComparison.Ordinal); + Assert.Contains("Ignored Apple build input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs new file mode 100644 index 000000000..5655d1723 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs @@ -0,0 +1,542 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Fact] + public void ResolveExactAppleSourceCommit_rejects_backticked_unpinned_swift_package_url_label() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "BacktickedPackageUrlRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let package = Package(name: \"Shared\", dependencies: [.package(`url`: \"https://example.invalid/Mutable.git\", branch: \"main\")])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Mutable.git", exception.Message, StringComparison.Ordinal); + Assert.Contains("Package.resolved", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_does_not_treat_nested_revision_argument_as_exact_dependency_revision() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "NestedRevisionArgumentRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let package = Package(name: \"Shared\", dependencies: [.package(url: \"https://example.invalid/Mutable.git\", branch: .branch(revision: \"0123456789abcdef0123456789abcdef01234567\"))])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Mutable.git", exception.Message, StringComparison.Ordinal); + Assert.Contains("Package.resolved", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_executable_override_with_multiple_xcconfig_conditions() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ConditionalXcconfigOverrideRepo", + "SWIFT_EXEC[sdk=macosx*][arch=arm64] = /tmp/custom-swiftc\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("SWIFT_EXEC", exception.Message, StringComparison.Ordinal); + Assert.Contains("executable", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_validates_version_specific_swift_package_manifests() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "VersionSpecificManifestRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 5.9\nimport PackageDescription\nlet package = Package(name: \"Shared\")"); + File.WriteAllText( + Path.Combine(packageRoot, "Package@swift-6.0.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", swiftSettings: [.unsafeFlags([\"-load-plugin-executable\", \"/tmp/injected\"])])])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("unsafeFlags", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_synchronized_build_file_exception_overrides() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SynchronizedBuildFileExceptionRepo"); + var project = scope.CreateDirectory(Path.Combine("SynchronizedBuildFileExceptionRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; additionalCompilerFlagsByRelativePath = { App.swift = \"-fplugin=/tmp/injected.dylib\"; }; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("exception set", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("compiler", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_custom_sdkroot_path() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CustomSdkRootRepo"); + var project = scope.CreateDirectory(Path.Combine("CustomSdkRootRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { SDKROOT = /tmp/Fake.sdk; }; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("SDKROOT", exception.Message, StringComparison.Ordinal); + Assert.Contains("custom SDK", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("$(BUILT_PRODUCTS_DIR)/Injected.a")] + [InlineData("$(CONFIGURATION_BUILD_DIR)/Injected.a")] + [InlineData("$(TARGET_BUILD_DIR)/Injected.a")] + public void ResolveExactAppleSourceCommit_rejects_unowned_build_output_inputs(string input) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("UnownedBuildOutputRepo"); + var project = scope.CreateDirectory(Path.Combine("UnownedBuildOutputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = XCBuildConfiguration; buildSettings = {{ OTHER_LDFLAGS = -force_load {input}; }}; }};"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("unowned build output", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_recursively_validates_nested_local_package_manifest() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "NestedLocalPackageRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Shared\", dependencies: [.package(path: \"../Nested\")])"); + var nestedRoot = scope.CreateDirectory(Path.Combine("NestedLocalPackageRepo", "Packages", "Nested")); + File.WriteAllText( + Path.Combine(nestedRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Nested\", targets: [.target(name: \"Nested\", linkerSettings: [.unsafeFlags([\"-L/tmp\"])])])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("unsafeFlags", exception.Message, StringComparison.Ordinal); + Assert.Contains("Nested", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_allows_missing_optional_xcconfig_include() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "OptionalXcconfigIncludeRepo", + "#include? \"LocalOverrides.xcconfig\"\nSDKROOT = macosx\n"); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_quoted_pbx_shell_phase_identifier() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("QuotedPbxIdentifierRepo"); + var project = scope.CreateDirectory(Path.Combine("QuotedPbxIdentifierRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "\"000000000000000000000001\" = { isa = PBXShellScriptBuildPhase; shellScript = \"date > generated.txt\"; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("shell-script", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_quoted_pbx_shell_phase_property() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("QuotedPbxPropertyRepo"); + var project = scope.CreateDirectory(Path.Combine("QuotedPbxPropertyRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { \"isa\" = PBXShellScriptBuildPhase; shellScript = \"date > generated.txt\"; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("shell-script", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_quoted_pbx_build_settings_property() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("QuotedBuildSettingsRepo"); + var project = scope.CreateDirectory(Path.Combine("QuotedBuildSettingsRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; \"buildSettings\" = { SWIFT_EXEC = /tmp/custom-swiftc; }; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("SWIFT_EXEC", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_dynamic_remote_binary_target() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "DynamicBinaryTargetRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let binaryUrl = \"https://example.invalid/Tool.zip\"\n" + + "let package = Package(name: \"Shared\", targets: [.binaryTarget(name: \"Tool\", url: binaryUrl, checksum: \"" + new string('a', 64) + "\")])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("binary target", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("literal URL", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_literal_checksum_bound_remote_binary_target() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "ChecksumBoundBinaryTargetRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let package = Package(name: \"Shared\", targets: [.binaryTarget(name: \"Tool\", url: \"https://example.invalid/Tool.zip\", checksum: \"" + new string('a', 64) + "\")])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_external_input_hidden_in_nested_response_file() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("NestedResponseFileRepo"); + var project = scope.CreateDirectory(Path.Combine("NestedResponseFileRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { OTHER_CFLAGS = @Flags.rsp; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Flags.rsp"), "@Nested.rsp"); + File.WriteAllText(Path.Combine(repositoryRoot, "Nested.rsp"), "-fplugin=/tmp/injected.dylib"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("OTHER_CFLAGS", exception.Message, StringComparison.Ordinal); + Assert.Contains("/tmp/injected.dylib", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_tracked_safe_compiler_response_file() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SafeResponseFileRepo"); + var project = scope.CreateDirectory(Path.Combine("SafeResponseFileRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { OTHER_CFLAGS = @Flags.rsp; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Flags.rsp"), "-DRELEASE_BUILD=1"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("-DRELEASE_SEED=$(CI_PIPELINE_ID)")] + [InlineData("-UFEATURE_$(CONFIGURATION)")] + [InlineData("-Xcc=-DHOST=${BUILD_NUMBER}")] + public void ResolveExactAppleSourceCommit_rejects_build_setting_references_in_preprocessor_flags(string flags) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("DynamicPreprocessorFlagRepo"); + var project = scope.CreateDirectory(Path.Combine("DynamicPreprocessorFlagRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = XCBuildConfiguration; buildSettings = {{ OTHER_CFLAGS = \"{flags}\"; }}; }};"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("preprocessor", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("build-setting reference", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("import Foundation\nlet enabled = ProcessInfo.processInfo.environment[\"CI\"] != nil")] + [InlineData("@preconcurrency import Darwin\nlet enabled = time(nil) > 0")] + [InlineData("let enabled = CommandLine.arguments.contains(\"--ci\")")] + [InlineData("let enabled = true\nif enabled { print(\"host branch\") }")] + [InlineData("#if os(macOS)\nlet enabled = true\n#else\nlet enabled = false\n#endif")] + public void ResolveExactAppleSourceCommit_rejects_host_dependent_or_imperative_package_manifests(string executableSyntax) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "HostDependentManifestRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + executableSyntax + + "\nlet package = Package(name: \"Shared\")"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("manifest", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact-source", exception.Message.Replace("exact source", "exact-source", StringComparison.OrdinalIgnoreCase), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_ignores_nonargument_path_labels_in_swift_text() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "SwiftPathTextRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let help = \"use path: foo\"\n" + + "let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\")])"); + var sources = scope.CreateDirectory(Path.Combine("SwiftPathTextRepo", "Packages", "Shared", "Sources", "Shared")); + File.WriteAllText(Path.Combine(sources, "Shared.swift"), "public struct Shared {}"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_per_file_compiler_flags() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("PerFileCompilerFlagsRepo"); + var project = scope.CreateDirectory(Path.Combine("PerFileCompilerFlagsRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; settings = { COMPILER_FLAGS = \"-fplugin=/tmp/injected.dylib\"; }; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = App.swift; sourceTree = SOURCE_ROOT; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "App.swift"), "struct App {}"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("COMPILER_FLAGS", exception.Message, StringComparison.Ordinal); + Assert.Contains("/tmp/injected.dylib", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("GCC_PREPROCESSOR_DEFINITIONS", "RELEASE_SEED=$(HOME)")] + [InlineData("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "$(CI_FEATURE)")] + public void ResolveExactAppleSourceCommit_rejects_dynamic_dedicated_definition_settings(string key, string value) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("DedicatedDefinitionsRepo" + Guid.NewGuid().ToString("N")); + var project = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Sample.xcodeproj")).FullName; + File.WriteAllText(Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = XCBuildConfiguration; buildSettings = {{ {key} = \"{value}\"; }}; }};"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(key, exception.Message, StringComparison.Ordinal); + Assert.Contains("build-setting reference", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_external_entry_inside_linker_file_list() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LinkerFileListRepo"); + var project = scope.CreateDirectory(Path.Combine("LinkerFileListRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { OTHER_LDFLAGS = \"-filelist Inputs.xcfilelist\"; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Inputs.xcfilelist"), "/tmp/injected.a\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("OTHER_LDFLAGS", exception.Message, StringComparison.Ordinal); + Assert.Contains("/tmp/injected.a", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("@_silgen_name(\"getpid\") func hostValue() -> Int32")] + [InlineData("let enabled = true ? [] : [.define(\"HOST\")]")] + public void ResolveExactAppleSourceCommit_rejects_native_or_expression_manifest_execution(string executableSyntax) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "ExecutableManifestRepo" + Guid.NewGuid().ToString("N")); + File.WriteAllText(Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + executableSyntax + + "\nlet package = Package(name: \"Shared\")"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("executable manifest", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Capture_rejects_executable_behavior_in_exact_remote_package_revision() + { + using var scope = new TemporaryDirectoryScope(); + var remoteRoot = scope.CreateDirectory("RemoteUnsafePackage"); + RunGit(remoteRoot, "init", "--quiet"); + File.WriteAllText(Path.Combine(remoteRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Remote\", targets: [.target(name: \"Remote\", swiftSettings: [.unsafeFlags([\"-I/tmp\"])])])"); + var remoteRevision = CommitRepository(remoteRoot); + + var repositoryRoot = scope.CreateDirectory("RemotePackageConsumer"); + var project = scope.CreateDirectory(Path.Combine("RemotePackageConsumer", "Sample.xcodeproj")); + const string remoteUrl = "https://example.invalid/RemoteUnsafePackage.git"; + File.WriteAllText(Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = XCRemoteSwiftPackageReference; repositoryURL = \"{remoteUrl}\"; requirement = {{ kind = revision; revision = {remoteRevision}; }}; }};"); + WriteTrackedPackageResolutionLock(repositoryRoot, remoteUrl, remoteRevision); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + var service = new AppleReleaseSourceTrustService( + remotePackageCheckoutResolver: (_, _) => remoteRoot); + + var exception = Assert.Throws(() => service.Capture(repositoryRoot, configPath)); + + Assert.Contains("unsafeFlags", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Capture_accepts_declarative_exact_remote_package_revision() + { + using var scope = new TemporaryDirectoryScope(); + var remoteRoot = scope.CreateDirectory("RemoteSafePackage"); + RunGit(remoteRoot, "init", "--quiet"); + File.WriteAllText(Path.Combine(remoteRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let package = Package(name: \"Remote\", targets: [" + + ".target(name: \"Remote\", dependencies: [.target(name: \"HostFallback\", condition: .when(platforms: [.linux, .windows]))]), " + + ".systemLibrary(name: \"HostFallback\", pkgConfig: \"host-fallback\", providers: [" + + ".apt([\"host-fallback-dev\"]), .brew([\"host-fallback\"]), .yum([\"host-fallback-devel\"])])])"); + var sources = scope.CreateDirectory(Path.Combine("RemoteSafePackage", "Sources", "Remote")); + File.WriteAllText(Path.Combine(sources, "Remote.swift"), "public struct Remote {}"); + var inactiveSystemLibrary = scope.CreateDirectory(Path.Combine("RemoteSafePackage", "Sources", "HostFallback")); + File.WriteAllText(Path.Combine(inactiveSystemLibrary, "module.modulemap"), "module HostFallback [system] { link \"host-fallback\" export * }"); + File.WriteAllText(Path.Combine(inactiveSystemLibrary, "shim.h"), "#include \n"); + var remoteRevision = CommitRepository(remoteRoot); + + var repositoryRoot = scope.CreateDirectory("SafeRemotePackageConsumer"); + var project = scope.CreateDirectory(Path.Combine("SafeRemotePackageConsumer", "Sample.xcodeproj")); + const string remoteUrl = "https://example.invalid/RemoteSafePackage.git"; + File.WriteAllText(Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = XCRemoteSwiftPackageReference; repositoryURL = \"{remoteUrl}\"; requirement = {{ kind = revision; revision = {remoteRevision}; }}; }};"); + WriteTrackedPackageResolutionLock(repositoryRoot, remoteUrl, remoteRevision); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + var service = new AppleReleaseSourceTrustService( + remotePackageCheckoutResolver: (_, _) => remoteRoot); + + var actual = service.ResolveExactCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + private static (string RepositoryRoot, string ProjectRoot, string PackageRoot) CreateLocalPackageFixture( + TemporaryDirectoryScope scope, + string repositoryName) + { + var repositoryRoot = scope.CreateDirectory(repositoryName); + var projectRoot = scope.CreateDirectory(Path.Combine(repositoryName, "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(projectRoot, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var packageRoot = scope.CreateDirectory(Path.Combine(repositoryName, "Packages", "Shared")); + return (repositoryRoot, projectRoot, packageRoot); + } + + private static (string RepositoryRoot, string ConfigPath) CreateXcconfigFixture( + TemporaryDirectoryScope scope, + string repositoryName, + string xcconfig) + { + var repositoryRoot = scope.CreateDirectory(repositoryName); + var project = scope.CreateDirectory(Path.Combine(repositoryName, "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXFileReference; path = Config.xcconfig; sourceTree = SOURCE_ROOT; }; " + + "000000000000000000000002 = { isa = XCBuildConfiguration; baseConfigurationReference = 000000000000000000000001; buildSettings = {}; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Config.xcconfig"), xcconfig); + return (repositoryRoot, WriteAppleReleaseConfig(repositoryRoot, projectRoot: ".")); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs new file mode 100644 index 000000000..c577350aa --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs @@ -0,0 +1,146 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("-load-resolved-plugin Lib#Exec#Macros", false)] + [InlineData("-load-resolved-plugin=Lib#Exec#Macros", false)] + [InlineData("-Xfrontend -load-resolved-plugin -Xfrontend Lib#Exec#Macros", false)] + [InlineData("@Plugin.rsp", true)] + public void ResolveExactAppleSourceCommit_attests_both_resolved_swift_plugin_paths( + string option, + bool responseFile) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ResolvedSwiftPluginRepo" + option.Length, + $"OTHER_SWIFT_FLAGS = {option}\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Lib"), "tracked plugin library"); + if (responseFile) + File.WriteAllText(Path.Combine(repositoryRoot, "Plugin.rsp"), "-load-resolved-plugin Lib#Exec#Macros\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Equal(Path.Combine(repositoryRoot, "Exec"), exception.FileName); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-dylib_file Foo:Current")] + [InlineData("-dylib_file=Foo:Current")] + [InlineData("-Wl,-dylib_file,Foo:Current")] + public void ResolveExactAppleSourceCommit_attests_current_dylib_override_path(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "DylibOverrideRepo" + option.Length, + $"OTHER_LDFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Equal(Path.Combine(repositoryRoot, "Current"), exception.FileName); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-remap-file Source.c;Alias.c")] + [InlineData("-remap-file=Source.c;Alias.c")] + [InlineData("-Xclang -remap-file -Xclang Source.c;Alias.c")] + [InlineData("@Remap.rsp")] + public void ResolveExactAppleSourceCommit_attests_both_clang_remap_paths(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ClangRemapRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.c"), "int source;\n"); + if (option.StartsWith("@", StringComparison.Ordinal)) + File.WriteAllText(Path.Combine(repositoryRoot, "Remap.rsp"), "-remap-file Source.c;Alias.c\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Equal(Path.Combine(repositoryRoot, "Alias.c"), exception.FileName); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("#define PAYLOAD .incbin \"/tmp/payload.bin\"\nPAYLOAD\n")] + [InlineData("#define JOIN(a, b) a ## b\nJOIN(.inc, bin) \"/tmp/payload.bin\"\n")] + [InlineData("#define DIRECTIVE(op, path) . op path\nDIRECTIVE(incbin, \"/tmp/payload.bin\")\n")] + public void ResolveExactAppleSourceCommit_rejects_preprocessed_assembler_file_directive_macros(string source) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "PreprocessedAssemblerMacroRepo" + source.Length, + "Source.S", + source); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Preprocessed assembler", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("cannot be bound", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("GCC_PREPROCESSOR_DEFINITIONS = PAYLOAD=.incbin")] + [InlineData("OTHER_CFLAGS = -DPAYLOAD=.incbin")] + public void ResolveExactAppleSourceCommit_rejects_build_setting_assembler_file_directive_macros(string assignment) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "AssemblerBuildSettingMacroRepo" + assignment.Length, + assignment + "\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("file-consuming assembler directive", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_non_file_preprocessed_assembler_macros() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "SafePreprocessedAssemblerMacroRepo", + "Source.S", + "#define VALUE 1\n.long VALUE\n"); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_non_file_include_named_preprocessor_definition() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SafeIncludeNamedDefinitionRepo", + "GCC_PREPROCESSOR_DEFINITIONS = INCLUDE=1\n"); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs new file mode 100644 index 000000000..ef7082f77 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs @@ -0,0 +1,649 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("-cas-plugin-path -Rules.dylib", "-Rules.dylib")] + [InlineData("-cas-plugin-path=-Rules.dylib", "-Rules.dylib")] + [InlineData("-external-pass-pipeline-filename Rules.json", "Rules.json")] + [InlineData("-in-process-plugin-server-path PluginServer", "PluginServer")] + public void ResolveExactAppleSourceCommit_classifies_swift_tool_and_plugin_inputs( + string option, + string expectedPath) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftToolInputRepo" + option.Length, + $"OTHER_SWIFT_FLAGS = -cache-compile-job {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(expectedPath, exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-ld-path Rules")] + [InlineData("-ld-path=Rules")] + [InlineData("-Xfrontend -ld-path -Xfrontend Rules")] + [InlineData("-Xfrontend=-ld-path=Rules")] + public void ResolveExactAppleSourceCommit_classifies_swift_linker_executable_paths(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftLinkerExecutableRepo" + option.Length, + $"OTHER_SWIFT_FLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_classifies_swift_linker_executable_path_from_response_file() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftLinkerExecutableResponseRepo", + "OTHER_SWIFT_FLAGS = @Swift.rsp\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Swift.rsp"), "-ld-path=Rules\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("__has_embed(\"/tmp/payload.bin\")", "__has_embed")] + [InlineData("__has_ ## embed(\"/tmp/payload.bin\")", "__has_embed")] + [InlineData("__has_ ## include(\"/tmp/payload.bin\")", "__has_include")] + public void ResolveExactAppleSourceCommit_rejects_unbound_preprocessor_file_probe( + string probe, + string expectedOperator) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("C23HasEmbedRepo"); + var project = scope.CreateDirectory(Path.Combine("C23HasEmbedRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.c; sourceTree = \"\"; };"); + File.WriteAllText( + Path.Combine(repositoryRoot, "Source.c"), + $"#if {probe}\nint payload = 1;\n#endif\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(expectedOperator, exception.Message, StringComparison.Ordinal); + Assert.Contains("cannot be bound", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_token_pasted_preprocessed_plist_probe() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("TokenPastedPlistProbeRepo"); + var project = scope.CreateDirectory(Path.Combine("TokenPastedPlistProbeRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { INFOPLIST_PREPROCESS = YES; INFOPLIST_FILE = Info.plist; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Info.plist"), "#if __has_ ## embed(\"payload.bin\")\n\n#endif\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("preprocessed INFOPLIST_FILE", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("file-selecting", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("GCC_PREPROCESSOR_DEFINITIONS = SEED=__TIME__")] + [InlineData("OTHER_CFLAGS = -DSEED=__TIME__")] + public void ResolveExactAppleSourceCommit_rejects_nondeterministic_macro_from_build_settings(string assignment) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "BuildSettingTimeMacroRepo" + assignment.Length, + assignment + "\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("__TIME__", exception.Message, StringComparison.Ordinal); + Assert.Contains("nondeterministic", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-load-plugin-executable Plugin#Macros", "Plugin")] + [InlineData("-load-plugin-executable=Plugin#Macros", "Plugin")] + [InlineData("-external-plugin-path Plugins#Server", "Plugins")] + [InlineData("-external-plugin-path=Plugins#Server", "Plugins")] + [InlineData("-load-plugin-library Plugin.dylib", "Plugin.dylib")] + [InlineData("-load-plugin-library=Plugin.dylib", "Plugin.dylib")] + public void ResolveExactAppleSourceCommit_classifies_swift_compiler_plugin_paths(string option, string expectedPath) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftCompilerPluginPathRepo" + option.Length, + $"OTHER_SWIFT_FLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(expectedPath, exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_classifies_swift_external_plugin_server_path() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftExternalPluginServerRepo", + "OTHER_SWIFT_FLAGS = -external-plugin-path Plugins#Server\n"); + var plugins = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Plugins")); + File.WriteAllText(Path.Combine(plugins.FullName, "marker"), "tracked plugin search root"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Server", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("--config Rules")] + [InlineData("--config=Rules")] + [InlineData("--config-user-dir Config")] + [InlineData("--config-user-dir=Config")] + [InlineData("--config-system-dir Config")] + [InlineData("--config-system-dir=Config")] + [InlineData("--config-user-dir=Config --config=Rules.cfg")] + public void ResolveExactAppleSourceCommit_rejects_clang_configuration_file_controls(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ClangConfigurationControlRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Clang configuration-file option", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(".byte 0; .incbin \"/tmp/payload.bin\"")] + [InlineData(".byte 0; label: .include \"/tmp/payload.inc\"")] + public void ResolveExactAppleSourceCommit_rejects_assembler_inputs_after_statement_separator(string source) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("AssemblerStatementSeparatorRepo" + source.Length); + var project = scope.CreateDirectory(Path.Combine(Path.GetFileName(repositoryRoot), "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.s; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.s"), source + "\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("outside the exact-source graph", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_collapses_multiline_block_comment_inside_preprocessor_directive() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "MultilineBlockCommentDirectiveRepo", + "Source.c", + "# /* comment\ncontinued */ include \"/tmp/Injected.h\"\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Injected.h", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("\v")] + [InlineData("\f")] + public void ResolveExactAppleSourceCommit_recognizes_all_non_newline_preprocessor_whitespace(string whitespace) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "PreprocessorWhitespaceRepo" + ((int)whitespace[0]), + "Source.c", + $"#{whitespace}include \"/tmp/Injected.h\"\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("absolute preprocessor include", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("#define PROBE __has_include\n#if PROBE(\"/tmp/Injected.h\")\n#endif\n", "PROBE")] + [InlineData("#define FILE_PROBE __has_include\n#define PROBE FILE_PROBE\n#if PROBE(\"/tmp/Injected.h\")\n#endif\n", "FILE_PROBE")] + public void ResolveExactAppleSourceCommit_rejects_object_like_file_probe_aliases( + string source, + string expectedAlias) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "PreprocessorProbeAliasRepo" + source.Length, + "Source.c", + source); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(expectedAlias, exception.Message, StringComparison.Ordinal); + Assert.Contains("aliases", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_validates_effective_info_plist_preprocessor_flags() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("InfoPlistPreprocessorFlagsRepo"); + var project = scope.CreateDirectory(Path.Combine("InfoPlistPreprocessorFlagsRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { " + + "INFOPLIST_PREPROCESS = YES; INFOPLIST_FILE = Info.plist; " + + "INFOPLIST_OTHER_PREPROCESSOR_FLAGS = \"-include /tmp/Injected.h\"; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Info.plist"), "\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("INFOPLIST_OTHER_PREPROCESSOR_FLAGS", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absolute path", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_ignores_inactive_info_plist_preprocessor_flags() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("InactiveInfoPlistPreprocessorFlagsRepo"); + var project = scope.CreateDirectory(Path.Combine("InactiveInfoPlistPreprocessorFlagsRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { " + + "INFOPLIST_PREPROCESS = NO; INFOPLIST_FILE = Info.plist; " + + "INFOPLIST_OTHER_PREPROCESSOR_FLAGS = \"-include /tmp/Inactive.h\"; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Info.plist"), "\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("-Wp,-I,External,-include,Injected.h")] + [InlineData("-Wa,-I,External")] + [InlineData("-Xpreprocessor -include -Xpreprocessor Injected.h")] + [InlineData("-Xassembler -I -Xassembler External")] + [InlineData("-Xclang -include -Xclang Injected.h")] + public void ResolveExactAppleSourceCommit_classifies_forwarded_preprocessor_and_assembler_inputs(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ForwardedCompilerInputRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-frandomize-layout-seed-file Rules")] + [InlineData("-frandomize-layout-seed-file=Rules")] + public void ResolveExactAppleSourceCommit_classifies_randomized_layout_seed_file(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "RandomizedLayoutSeedRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-fthinlto-index Rules")] + [InlineData("-fthinlto-index=Rules")] + [InlineData("-Xclang -fthinlto-index -Xclang Rules")] + [InlineData("-Xclang -fthinlto-index=Rules")] + public void ResolveExactAppleSourceCommit_classifies_thin_lto_index_files(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ThinLtoIndexRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_classifies_thin_lto_index_from_response_file() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ThinLtoResponseFileRepo", + "OTHER_CFLAGS = @Compiler.rsp\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Compiler.rsp"), "-fthinlto-index=Rules\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-fxray-always-instrument Rules", "Rules")] + [InlineData("-fxray-always-instrument=Rules", "Rules")] + [InlineData("-fxray-never-instrument Rules", "Rules")] + [InlineData("-fxray-never-instrument=Rules", "Rules")] + public void ResolveExactAppleSourceCommit_classifies_xray_instrumentation_lists( + string option, + string expectedPath) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "XRayInstrumentationListRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(expectedPath, exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-fprofile-instrument-use-path Rules")] + [InlineData("-fprofile-instrument-use-path=Rules")] + [InlineData("-Xclang -fprofile-instrument-use-path=Rules")] + public void ResolveExactAppleSourceCommit_classifies_clang_instrumentation_profile_inputs(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ClangInstrumentationProfileRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-fbuild-session-file=Session", "Session")] + [InlineData("-fcodegen-data-use Codegen.cgdata", "Codegen.cgdata")] + [InlineData("-fmemory-profile-use=Memory.profdata", "Memory.profdata")] + [InlineData("-iapinotes-path Notes.apinotes", "Notes.apinotes")] + [InlineData("-ivfsstatcache Stats.cache", "Stats.cache")] + [InlineData("--warning-suppression-mappings=Warnings.txt", "Warnings.txt")] + [InlineData("-multi-lib-config Config.yaml", "Config.yaml")] + [InlineData("--cuda-path Toolchain", "Toolchain")] + public void ResolveExactAppleSourceCommit_classifies_other_clang_filesystem_controls( + string option, + string expectedPath) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ClangFilesystemControlRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(expectedPath, exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_preserves_real_include_after_cpp_raw_string() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "CppRawStringIncludeRepo", + "Source.cpp", + "auto text = R\"tag(\" /*)tag\";\n#include \"/tmp/Injected.h\"\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("absolute preprocessor include", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_ignores_include_text_inside_cpp_raw_string() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "CppRawStringTextRepo", + "Source.cpp", + "auto text = R\"tag(\n#include \"/tmp/NotAnInclude.h\"\n)tag\";\n"); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_unbound_objective_c_module_import() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "ObjectiveCModuleImportRepo", + "Source.m", + "@import Injected;\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Objective-C module 'Injected'", exception.Message, StringComparison.Ordinal); + Assert.Contains("not bound", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_approved_apple_objective_c_module_import() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "AppleObjectiveCModuleImportRepo", + "Source.m", + "@import Foundation;\n"); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("Source.m", "#pragma clang module import Injected\n")] + [InlineData("Source.cpp", "import Injected;\n")] + [InlineData("Source.cpp", "import \"Injected.h\";\n")] + public void ResolveExactAppleSourceCommit_rejects_other_unbound_language_module_imports( + string sourceName, + string source) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "LanguageModuleImportRepo" + sourceName.Length + source.Length, + sourceName, + source); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("module", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("bound", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_classifies_dash_prefixed_linker_order_file() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "DashPrefixedLinkerOrderFileRepo", + "OTHER_LDFLAGS = -Wl,-order_file,-Rules\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("-Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_file_backed_header_search_map() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "HeaderMapSearchRootRepo", + "OTHER_CFLAGS = -I Rules.hmap\n"); + File.WriteAllBytes(Path.Combine(repositoryRoot, "Rules.hmap"), new byte[] { 0x68, 0x6d, 0x61, 0x70 }); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("header map", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact source", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AppleSourceSnapshot_rejects_configuration_bytes_different_from_the_parsed_plan() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ParsedConfigurationBindingRepo"); + var project = scope.CreateDirectory(Path.Combine("ParsedConfigurationBindingRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// exact project\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var committedContent = File.ReadAllText(configPath); + var sourceCommit = CommitRepository(repositoryRoot); + File.WriteAllText( + configPath, + committedContent.Replace( + "\"ProjectRoot\": \".\",", + "\"ProjectRoot\": \".\",\n \"Configuration\": \"Debug\",")); + var parsed = PowerForgeReleaseService.LoadConfiguration(configPath); + File.WriteAllText(configPath, committedContent); + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = repositoryRoot, + Archive = true, + SourceCommit = sourceCommit, + RequireImmutableSourceSnapshot = true, + ExactSourceConfigPath = configPath, + ExactSourceConfigSha256 = parsed.LoadedConfigurationSha256 + }; + + var exception = Assert.Throws(() => + { + using var _ = AppleReleaseSourceSnapshot.CreateIfRequired(plan); + }); + + Assert.Contains("parsed Apple release configuration", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact source configuration bytes", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + private static (string RepositoryRoot, string ConfigPath) CreateTrackedSourceFixture( + TemporaryDirectoryScope scope, + string name, + string sourceName, + string source) + { + var repositoryRoot = scope.CreateDirectory(name); + var project = scope.CreateDirectory(Path.Combine(name, "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + $"000000000000000000000002 = {{ isa = PBXFileReference; path = {sourceName}; sourceTree = \"\"; }};"); + File.WriteAllText(Path.Combine(repositoryRoot, sourceName), source); + return (repositoryRoot, WriteAppleReleaseConfig(repositoryRoot, projectRoot: ".")); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustDependencyScannerInputs.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustDependencyScannerInputs.cs new file mode 100644 index 000000000..c0ab90b2a --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustDependencyScannerInputs.cs @@ -0,0 +1,45 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("-fdepscan-daemon Rules")] + [InlineData("-fdepscan-daemon=Rules")] + [InlineData("-fdepscan -fdepscan-daemon=Rules")] + [InlineData("-Xclang -fdepscan-daemon=Rules")] + public void ResolveExactAppleSourceCommit_classifies_dependency_scanner_daemon_paths(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "DependencyScannerDaemonRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_classifies_dependency_scanner_daemon_from_response_file() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "DependencyScannerDaemonResponseRepo", + "OTHER_CFLAGS = @Compiler.rsp\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Compiler.rsp"), "-fdepscan-daemon=Rules\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs new file mode 100644 index 000000000..7baf3b999 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs @@ -0,0 +1,505 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Fact] + public void Capture_scopes_remote_package_validation_cache_to_each_repository_lock_graph() + { + using var scope = new TemporaryDirectoryScope(); + const string parentUrl = "https://example.invalid/ParentPackage.git"; + const string childUrl = "https://example.invalid/ChildPackage.git"; + + var childRoot = scope.CreateDirectory("CacheScopedChildPackage"); + RunGit(childRoot, "init", "--quiet"); + File.WriteAllText( + Path.Combine(childRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Child\")"); + var childRevision = CommitRepository(childRoot); + + var parentRoot = scope.CreateDirectory("CacheScopedParentPackage"); + RunGit(parentRoot, "init", "--quiet"); + File.WriteAllText( + Path.Combine(parentRoot, "Package.swift"), + $"// swift-tools-version: 6.0\nimport PackageDescription\n" + + $"let package = Package(name: \"Parent\", dependencies: [.package(url: \"{childUrl}\", exact: \"1.0.0\")])"); + var parentRevision = CommitRepository(parentRoot); + + string CreateConsumer(string name, bool includeChildLock, out string configPath) + { + var repositoryRoot = scope.CreateDirectory(name); + var project = scope.CreateDirectory(Path.Combine(name, "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = XCRemoteSwiftPackageReference; repositoryURL = \"{parentUrl}\"; requirement = {{ kind = revision; revision = {parentRevision}; }}; }};"); + var lockDirectory = Path.Combine(project, "project.xcworkspace", "xcshareddata", "swiftpm"); + Directory.CreateDirectory(lockDirectory); + var pins = new List + { + new + { + identity = "parent-package", + kind = "remoteSourceControl", + location = parentUrl, + state = new { revision = parentRevision, version = "1.0.0" } + } + }; + if (includeChildLock) + { + pins.Add(new + { + identity = "child-package", + kind = "remoteSourceControl", + location = childUrl, + state = new { revision = childRevision, version = "1.0.0" } + }); + } + File.WriteAllText( + Path.Combine(lockDirectory, "Package.resolved"), + System.Text.Json.JsonSerializer.Serialize(new { pins, version = 3 })); + configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + return repositoryRoot; + } + + var firstRepository = CreateConsumer("CacheScopedFirstConsumer", includeChildLock: true, out var firstConfig); + var secondRepository = CreateConsumer("CacheScopedSecondConsumer", includeChildLock: false, out var secondConfig); + var service = new AppleReleaseSourceTrustService( + remotePackageCheckoutResolver: (url, _) => + url.Equals(parentUrl, StringComparison.OrdinalIgnoreCase) ? parentRoot : childRoot); + + _ = service.Capture(firstRepository, firstConfig); + var exception = Assert.Throws(() => service.Capture(secondRepository, secondConfig)); + + Assert.Contains(childUrl, exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Package.resolved", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Capture_revalidates_remote_package_after_failed_inspection() + { + using var scope = new TemporaryDirectoryScope(); + var remoteRoot = scope.CreateDirectory("RetryRemotePackage"); + RunGit(remoteRoot, "init", "--quiet"); + File.WriteAllText( + Path.Combine(remoteRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Remote\")"); + var revision = CommitRepository(remoteRoot); + + var repositoryRoot = scope.CreateDirectory("RetryRemoteConsumer"); + var project = scope.CreateDirectory(Path.Combine("RetryRemoteConsumer", "Sample.xcodeproj")); + const string remoteUrl = "https://example.invalid/RetryRemotePackage.git"; + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = XCRemoteSwiftPackageReference; repositoryURL = \"{remoteUrl}\"; requirement = {{ kind = revision; revision = {revision}; }}; }};"); + WriteTrackedPackageResolutionLock(repositoryRoot, remoteUrl, revision); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + var resolverCalls = 0; + var service = new AppleReleaseSourceTrustService( + remotePackageCheckoutResolver: (_, _) => + { + if (++resolverCalls == 1) + throw new InvalidOperationException("transient remote inspection failure"); + return remoteRoot; + }); + + Assert.Throws(() => service.Capture(repositoryRoot, configPath)); + + Assert.Equal(expected, service.ResolveExactCommit(repositoryRoot, configPath)); + Assert.Equal(2, resolverCalls); + } + + [Fact] + public void Capture_rejects_gitlinks_in_exact_remote_package_revision() + { + using var scope = new TemporaryDirectoryScope(); + var childRoot = scope.CreateDirectory("RemotePackageChild"); + RunGit(childRoot, "init", "--quiet"); + File.WriteAllText(Path.Combine(childRoot, "Payload.swift"), "public let payload = 42\n"); + var childRevision = CommitRepository(childRoot); + + var remoteRoot = scope.CreateDirectory("RemotePackageWithGitlink"); + RunGit(remoteRoot, "init", "--quiet"); + File.WriteAllText( + Path.Combine(remoteRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Remote\")"); + _ = CommitRepository(remoteRoot); + RunGit(remoteRoot, "update-index", "--add", "--cacheinfo", $"160000,{childRevision},Dependencies/Child"); + RunGit(remoteRoot, "commit", "-m", "Add remote package gitlink", "--quiet"); + Directory.CreateDirectory(Path.Combine(remoteRoot, "Dependencies", "Child")); + var remoteRevision = ReadFixtureHead(remoteRoot); + + var repositoryRoot = scope.CreateDirectory("RemoteGitlinkConsumer"); + var project = scope.CreateDirectory(Path.Combine("RemoteGitlinkConsumer", "Sample.xcodeproj")); + const string remoteUrl = "https://example.invalid/RemotePackageWithGitlink.git"; + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = XCRemoteSwiftPackageReference; repositoryURL = \"{remoteUrl}\"; requirement = {{ kind = revision; revision = {remoteRevision}; }}; }};"); + WriteTrackedPackageResolutionLock(repositoryRoot, remoteUrl, remoteRevision); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + var service = new AppleReleaseSourceTrustService( + remotePackageCheckoutResolver: (_, _) => remoteRoot); + + var exception = Assert.Throws(() => service.Capture(repositoryRoot, configPath)); + + Assert.Contains("Git submodule", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Dependencies/Child", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_checkout_path_literals_in_swift_manifests() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "ManifestFilePathRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let here = #filePath\n" + + "let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", swiftSettings: [.define(\"MANIFEST_PATH\", to: here)])])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("#filePath", exception.Message, StringComparison.Ordinal); + Assert.Contains("checkout or host state", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_absolute_linker_input_containing_rpath_text() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "EmbeddedRpathRepo", + "OTHER_LDFLAGS = /tmp/@rpath/libInjected.a\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("/tmp/@rpath/libInjected.a", exception.Message, StringComparison.Ordinal); + Assert.Contains("Path-like token", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_expands_forwarded_linker_response_files() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ForwardedResponseFileRepo"); + var project = scope.CreateDirectory(Path.Combine("ForwardedResponseFileRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { OTHER_LDFLAGS = -Wl,@Injected.rsp; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Injected.rsp"), "/tmp/Injected.a"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("/tmp/Injected.a", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_validates_bare_positional_linker_inputs() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "BareLinkerInputRepo", + "OTHER_LDFLAGS = Injected.a\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Injected.a", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_validates_forwarded_linker_runtime_paths() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ForwardedRuntimePathRepo", + "OTHER_LDFLAGS = -Wl,-rpath,/tmp/InjectedFrameworks\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("/tmp/InjectedFrameworks", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_validates_imacros_compiler_input() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "CompilerMacrosInputRepo", + "OTHER_CFLAGS = -imacros Injected.h\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Injected.h", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_host_dependent_source_selection() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "HostSourceSelectionRepo", + "EXCLUDED_SOURCE_FILE_NAMES = $(USER).swift\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("EXCLUDED_SOURCE_FILE_NAMES", exception.Message, StringComparison.Ordinal); + Assert.Contains("different tracked sources", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_parses_semicolon_terminated_swift_imports() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "SemicolonImportRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport Foundation; import PackageDescription\n" + + "let seed = NSData(contentsOfFile: \"/tmp/seed\")!.base64EncodedString()\n" + + "let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", swiftSettings: [.define(\"SEED\", to: seed)])])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("imports 'Foundation'", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_custom_xcodebuild_executable() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CustomXcodeBuildRepo"); + var project = scope.CreateDirectory(Path.Combine("CustomXcodeBuildRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + File.WriteAllText( + configPath, + File.ReadAllText(configPath).Replace( + "\"AppleApps\": {", + "\"AppleApps\": { \"XcodeBuildExecutable\": \"/tmp/fake-xcodebuild\",")); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("/usr/bin/xcodebuild", exception.Message, StringComparison.Ordinal); + Assert.Contains("not trusted", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("XcrunExecutable", "/usr/bin/xcrun")] + [InlineData("DittoExecutable", "/usr/bin/ditto")] + [InlineData("SpctlExecutable", "/usr/sbin/spctl")] + public void ResolveExactAppleSourceCommit_rejects_custom_notarization_tool_executable( + string propertyName, + string trustedPath) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CustomAppleToolRepo"); + var project = scope.CreateDirectory(Path.Combine("CustomAppleToolRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// project"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + File.WriteAllText( + configPath, + File.ReadAllText(configPath).Replace( + "\"AppleApps\": {", + $"\"AppleApps\": {{ \"DirectDistribution\": {{ \"{propertyName}\": \"/tmp/hostile-tool\" }},")); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(trustedPath, exception.Message, StringComparison.Ordinal); + Assert.Contains("not trusted", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_standard_library_execution_in_swift_manifest() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "RandomManifestRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let seed = String(Int.random(in: 0...999999))\n" + + "let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", swiftSettings: [.define(\"SEED\", to: seed)])])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("non-declarative manifest call", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("String", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("let seed = ((Int.random)(in: 0...999999)).description")] + [InlineData("let seed = (Int.random)(in: 0...999999).description")] + [InlineData("let seed = ({ Int.random(in: 0...999999) })().description")] + public void ResolveExactAppleSourceCommit_rejects_parenthesized_swift_manifest_execution(string declaration) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "ParenthesizedManifestRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + declaration + "\n" + + "let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", swiftSettings: [.define(\"SEED\", to: seed)])])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("parenthesized executable manifest expression", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_host_reference_in_unclassified_build_setting() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("HostBundleIdentifierRepo"); + var project = scope.CreateDirectory(Path.Combine("HostBundleIdentifierRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.$(USER); }; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("PRODUCT_BUNDLE_IDENTIFIER", exception.Message, StringComparison.Ordinal); + Assert.Contains("$(USER)", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_escaping_source_include() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "EscapingIncludeRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Shared\")\n"); + var sources = scope.CreateDirectory(Path.Combine("EscapingIncludeRepo", "Packages", "Shared", "Sources", "Shared")); + File.WriteAllText(Path.Combine(sources, "Injected.c"), "#include \"../../../../../../tmp/injected.h\"\nint value = 1;\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("preprocessor include", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("injected.h", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void AppleSourceSnapshot_rejects_transient_external_hard_link_mutation() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("HardLinkedSnapshotRepo"); + var project = scope.CreateDirectory(Path.Combine("HardLinkedSnapshotRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), "// exact project\n"); + var sourcePath = Path.Combine(repositoryRoot, "Input.swift"); + const string sourceBytes = "let value = 1\n"; + File.WriteAllText(sourcePath, sourceBytes); + RunGit(repositoryRoot, "init", "--quiet"); + var sourceCommit = CommitRepository(repositoryRoot); + var plan = new PowerForgeAppleReleasePlan + { + ProjectRoot = repositoryRoot, + Archive = true, + SourceCommit = sourceCommit, + RequireImmutableSourceSnapshot = true + }; + + using var snapshot = AppleReleaseSourceSnapshot.CreateIfRequired(plan)!; + var snapshotSource = snapshot.MapPath(sourcePath); + var aliasPath = Path.Combine(scope.RootPath, "outside-snapshot-alias.swift"); + CreateSnapshotHardLink(aliasPath, snapshotSource); + File.WriteAllText(aliasPath, "let value = 2\n"); + File.WriteAllText(aliasPath, sourceBytes); + File.Delete(aliasPath); + + var exception = Assert.Throws(snapshot.ValidateUnchanged); + + Assert.Contains("hard-link alias", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + private static void CreateSnapshotHardLink(string linkPath, string existingPath) + { + var succeeded = Path.DirectorySeparatorChar == '\\' + ? CreateSnapshotHardLinkWindows(linkPath, existingPath, IntPtr.Zero) + : CreateSnapshotHardLinkUnix(existingPath, linkPath) == 0; + if (!succeeded) + { + var error = System.Runtime.InteropServices.Marshal.GetLastWin32Error(); + throw new IOException( + $"Unable to create hard-link test artifact: {new System.ComponentModel.Win32Exception(error).Message}"); + } + } + + [System.Runtime.InteropServices.DllImport( + "kernel32.dll", + EntryPoint = "CreateHardLinkW", + CharSet = System.Runtime.InteropServices.CharSet.Unicode, + SetLastError = true)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool CreateSnapshotHardLinkWindows( + string fileName, + string existingFileName, + IntPtr securityAttributes); + + [System.Runtime.InteropServices.DllImport("libc", EntryPoint = "link", SetLastError = true)] + private static extern int CreateSnapshotHardLinkUnix(string existingPath, string newPath); + + private static string ReadFixtureHead(string repositoryRoot) + { + var startInfo = new System.Diagnostics.ProcessStartInfo("git") + { + WorkingDirectory = repositoryRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("rev-parse"); + startInfo.ArgumentList.Add("HEAD"); + using var process = System.Diagnostics.Process.Start(startInfo) + ?? throw new InvalidOperationException("Unable to read fixture HEAD."); + var sha = process.StandardOutput.ReadToEnd().Trim(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"git rev-parse HEAD failed: {error}"); + return sha; + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustGccAndLinkerInputs.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustGccAndLinkerInputs.cs new file mode 100644 index 000000000..d1376bd3b --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustGccAndLinkerInputs.cs @@ -0,0 +1,45 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("--gcc-toolchain Rules", "Rules")] + [InlineData("--gcc-toolchain=Rules", "Rules")] + [InlineData("--gcc-install-dir Rules", "Rules")] + [InlineData("--gcc-install-dir=Rules", "Rules")] + public void ResolveExactAppleSourceCommit_attests_double_hyphen_gcc_search_roots( + string option, + string expectedPath) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "GccSearchRootRepo" + option.Length, + $"OTHER_CPLUSPLUSFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Equal(Path.Combine(repositoryRoot, expectedPath), exception.FileName); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_tracked_segcreate_file_input() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SegcreateInputRepo", + "OTHER_LDFLAGS = -segcreate __DATA __rules Rules\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Rules"), "approved section bytes"); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustInterfaceBuilderInputs.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustInterfaceBuilderInputs.cs new file mode 100644 index 000000000..097f86635 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustInterfaceBuilderInputs.cs @@ -0,0 +1,51 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Fact] + public void ResolveExactAppleSourceCommit_rejects_host_interface_builder_plugin_search_roots() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "InterfaceBuilderHostPluginRepo", + "IBC_PLUGIN_SEARCH_PATHS = /tmp/InjectedPlugin\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("IBC_PLUGIN_SEARCH_PATHS", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absolute", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_linked_interface_builder_plugins() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "InterfaceBuilderLinkedPluginRepo", + "IBC_PLUGIN_SEARCH_PATHS = Plugins\n"); + var plugins = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Plugins")); + var outside = Path.Combine(scope.CreateDirectory("InterfaceBuilderPluginExternal"), "Injected.ibplugin"); + File.WriteAllText(outside, "mutable plugin"); + try + { + File.CreateSymbolicLink(Path.Combine(plugins.FullName, "Injected.ibplugin"), outside); + } + catch (Exception linkError) when (linkError is PlatformNotSupportedException or UnauthorizedAccessException) + { + return; + } + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("IBC_PLUGIN_SEARCH_PATHS", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("symbolic link", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs new file mode 100644 index 000000000..02660123f --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs @@ -0,0 +1,185 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("INFOPLIST_FILE = Info.plist;", "#include \"/tmp/Injected.h\"\n\n")] + [InlineData("INFOPLIST_FILE = Info.plist; INFOPLIST_OTHER_PREPROCESSOR_FLAGS = \"-include /tmp/Injected.h\";", "\n")] + public void ResolveExactAppleSourceCommit_tracks_info_plist_preprocessing_across_setting_layers( + string projectSettings, + string plistContents) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LayeredInfoPlistPreprocessRepo" + projectSettings.Length); + var project = scope.CreateDirectory(Path.Combine(Path.GetFileName(repositoryRoot), "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(repositoryRoot, "Base.xcconfig"), "INFOPLIST_PREPROCESS = YES\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Info.plist"), plistContents); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXFileReference; path = Base.xcconfig; sourceTree = \"\"; }; " + + "000000000000000000000002 = { isa = XCBuildConfiguration; baseConfigurationReference = 000000000000000000000001; " + + $"buildSettings = {{ {projectSettings} }}; }};"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.ThrowsAny(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("INFOPLIST", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True( + exception.Message.Contains("preprocess", StringComparison.OrdinalIgnoreCase) || + exception.Message.Contains("absolute", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ResolveExactAppleSourceCommit_honors_project_override_that_disables_base_plist_preprocessing() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("DisabledLayeredInfoPlistPreprocessRepo"); + var project = scope.CreateDirectory(Path.Combine("DisabledLayeredInfoPlistPreprocessRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(repositoryRoot, "Base.xcconfig"), "INFOPLIST_PREPROCESS = YES\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Info.plist"), "#include \"/tmp/Inactive.h\"\n\n"); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXFileReference; path = Base.xcconfig; sourceTree = \"\"; }; " + + "000000000000000000000002 = { isa = XCBuildConfiguration; baseConfigurationReference = 000000000000000000000001; " + + "buildSettings = { INFOPLIST_PREPROCESS = NO; INFOPLIST_FILE = Info.plist; }; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("_Pragma(\"clang module import Injected\")")] + [InlineData("_Pragma(\"\\x63lang module import Injected\")")] + [InlineData("_Pragma(PRAGMA_PAYLOAD)")] + public void ResolveExactAppleSourceCommit_rejects_unbound_or_computed_pragma_module_imports(string source) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "PragmaModuleImportRepo" + source.Length, + "Source.m", + source + "\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Pragma", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("bound", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_approved_apple_pragma_module_import() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "ApprovedPragmaModuleImportRepo", + "Source.m", + "_Pragma(\"clang module import Foundation\")\n"); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("COMPILER_FLAGS = \"-x c\";")] + [InlineData("", "sourcecode.c.c")] + public void ResolveExactAppleSourceCommit_scans_shipping_sources_by_effective_compiler_language( + string perFileSettings, + string? explicitFileType = null) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("EffectiveSourceLanguageRepo" + perFileSettings.Length); + var project = scope.CreateDirectory(Path.Combine(Path.GetFileName(repositoryRoot), "Sample.xcodeproj")); + var settings = string.IsNullOrWhiteSpace(perFileSettings) ? string.Empty : $"settings = {{ {perFileSettings} }};"; + var fileType = string.IsNullOrWhiteSpace(explicitFileType) ? string.Empty : $"explicitFileType = {explicitFileType};"; + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = PBXBuildFile; fileRef = 000000000000000000000002; {settings} }}; " + + $"000000000000000000000002 = {{ isa = PBXFileReference; path = Payload.data; {fileType} sourceTree = \"\"; }}; " + + "000000000000000000000003 = { isa = PBXSourcesBuildPhase; files = (000000000000000000000001,); }; " + + "000000000000000000000004 = { isa = PBXNativeTarget; buildPhases = (000000000000000000000003,); productType = \"com.apple.product-type.application\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Payload.data"), "#include \"/tmp/Injected.h\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("absolute preprocessor include", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_global_compiler_language_override() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "GlobalCompilerLanguageOverrideRepo", + "OTHER_CFLAGS = -x c\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("language override", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("source-owned", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_shipping_source_with_unclassified_language() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("UnclassifiedShippingLanguageRepo"); + var project = scope.CreateDirectory(Path.Combine("UnclassifiedShippingLanguageRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Payload.data; sourceTree = \"\"; }; " + + "000000000000000000000003 = { isa = PBXSourcesBuildPhase; files = (000000000000000000000001,); }; " + + "000000000000000000000004 = { isa = PBXNativeTarget; buildPhases = (000000000000000000000003,); productType = \"com.apple.product-type.application\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Payload.data"), "opaque source\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("compiler language", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-swift-module-file=Rules=Injected.swiftmodule", "Injected.swiftmodule")] + [InlineData("-Xfrontend -swift-module-file=Rules=Injected.swiftmodule", "Injected.swiftmodule")] + [InlineData("-swift-module-cross-import Rules Injected.swiftoverlay", "Injected.swiftoverlay")] + [InlineData("-candidate-module-file Injected.swiftmodule", "Injected.swiftmodule")] + [InlineData("-explicit-swift-module-map-file Injected.json", "Injected.json")] + [InlineData("@Module.rsp", "Injected.swiftmodule")] + public void ResolveExactAppleSourceCommit_attests_swift_module_injection_inputs(string option, string expectedPath) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftModuleInjectionRepo" + option.Length, + $"OTHER_SWIFT_FLAGS = {option}\n"); + if (option.StartsWith("@", StringComparison.Ordinal)) + File.WriteAllText(Path.Combine(repositoryRoot, "Module.rsp"), "-swift-module-file=Rules=Injected.swiftmodule\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Equal(Path.Combine(repositoryRoot, expectedPath), exception.FileName); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.CompilerInputs.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.CompilerInputs.cs new file mode 100644 index 000000000..bbd952a72 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.CompilerInputs.cs @@ -0,0 +1,445 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Fact] + public void ResolveExactAppleSourceCommit_rejects_c23_embed_payloads() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("C23EmbedRepo"); + var project = scope.CreateDirectory(Path.Combine("C23EmbedRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.c; sourceTree = \"\"; };"); + File.WriteAllText( + Path.Combine(repositoryRoot, "Source.c"), + "const unsigned char payload[] = {\n#embed \"/tmp/payload.bin\"\n};\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("C23 embed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(".include")] + [InlineData(".incbin")] + public void ResolveExactAppleSourceCommit_rejects_absolute_assembler_inputs(string directive) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryName = "AssemblerInputRepo" + directive.Length; + var repositoryRoot = scope.CreateDirectory(repositoryName); + var project = scope.CreateDirectory(Path.Combine(repositoryName, "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Startup.S; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Startup.S"), $"{directive} \"/tmp/injected.bin\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Assembler source input", exception.Message, StringComparison.Ordinal); + Assert.Contains("/tmp/injected.bin", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_absolute_input_in_nested_assembler_include() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("NestedAssemblerInputRepo"); + var project = scope.CreateDirectory(Path.Combine("NestedAssemblerInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Startup.S; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Startup.S"), ".include \"Nested.inc\"\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Nested.inc"), ".incbin \"/tmp/injected.bin\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Assembler source input", exception.Message, StringComparison.Ordinal); + Assert.Contains("/tmp/injected.bin", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_host_reference_in_entitlements() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("EntitlementsHostReferenceRepo"); + var project = scope.CreateDirectory(Path.Combine("EntitlementsHostReferenceRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = App.entitlements; }; };"); + File.WriteAllText( + Path.Combine(repositoryRoot, "App.entitlements"), + "application-identifier$(USER).app"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("CODE_SIGN_ENTITLEMENTS contents", exception.Message, StringComparison.Ordinal); + Assert.Contains("$(USER)", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_classifies_joined_prebuilt_module_path() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "PrebuiltModulePathRepo", + "OTHER_CFLAGS = -fprebuilt-module-path=Modules\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Modules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-fsanitize-ignorelist=Ignorelist")] + [InlineData("-fsanitize-blacklist=Ignorelist")] + [InlineData("-fsanitize-system-ignorelist Ignorelist")] + [InlineData("-fsanitize-coverage-allowlist=Ignorelist")] + public void ResolveExactAppleSourceCommit_classifies_sanitizer_list_inputs(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SanitizerListRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Ignorelist", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_angled_source_include_with_unbound_search_roots() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("AngledIncludeRepo"); + var project = scope.CreateDirectory(Path.Combine("AngledIncludeRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.m; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.m"), "#include \n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("angled preprocessor include", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unbound compiler search roots", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_missing_quoted_source_include() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("MissingQuotedIncludeRepo"); + var project = scope.CreateDirectory(Path.Combine("MissingQuotedIncludeRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.m; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.m"), "#include \"Injected.h\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Quoted preprocessor include", exception.Message, StringComparison.Ordinal); + Assert.Contains("Injected.h", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("link framework \"Injected\"")] + [InlineData("link \"Injected\"")] + public void ResolveExactAppleSourceCommit_rejects_unbound_module_map_autolinks(string declaration) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryName = "ModuleMapAutolinkRepo" + declaration.Length; + var repositoryRoot = scope.CreateDirectory(repositoryName); + var project = scope.CreateDirectory(Path.Combine(repositoryName, "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { MODULEMAP_FILE = Config.modulemap; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Config.modulemap"), $"module Sample {{ {declaration} export * }}"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("unbound autolink", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Injected", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_external_inline_assembler_file_input() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("InlineAssemblerInputRepo"); + var project = scope.CreateDirectory(Path.Combine("InlineAssemblerInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.m; sourceTree = \"\"; };"); + File.WriteAllText( + Path.Combine(repositoryRoot, "Source.m"), + "void load(void) { __asm__(\".incbin \\\"/tmp/payload.bin\\\"\"); }\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("inline", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absolute .incbin", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_computed_inline_assembler_text() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ComputedInlineAssemblerRepo"); + var project = scope.CreateDirectory(Path.Combine("ComputedInlineAssemblerRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.c; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.c"), "void load(const char *text) { asm(text); }\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("computed inline assembler", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-fprofile-list=Rules")] + [InlineData("-fprofile-list Rules")] + [InlineData("-fprofile-remapping-file=Mappings")] + [InlineData("-fprofile-remapping-file Mappings")] + public void ResolveExactAppleSourceCommit_classifies_profile_configuration_files(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ProfileInputRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("process")] + [InlineData("copy")] + public void ResolveExactAppleSourceCommit_accepts_tracked_swift_package_resources(string factory) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture( + scope, + "TrackedSwiftResourceRepo" + factory); + var resources = Directory.CreateDirectory(Path.Combine(packageRoot, "Sources", "Shared", "Resources")); + File.WriteAllText(Path.Combine(resources.FullName, "message.txt"), "approved"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + $"let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", resources: [.{factory}(\"Resources\")])])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var commit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.NotEmpty(commit); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_missing_swift_package_resource() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "MissingSwiftResourceRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", resources: [.process(\"Resources\")])])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("resource input was not found", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_indirect_swift_package_resources() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "IndirectSwiftResourceRepo"); + var resources = Directory.CreateDirectory(Path.Combine(packageRoot, "Sources", "Shared", "Resources")); + File.WriteAllText(Path.Combine(resources.FullName, "message.txt"), "approved"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let assets: [Resource] = [.process(\"Resources\")]\n" + + "let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", resources: assets)])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("indirect resource declaration", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_unreachable_tracked_angled_header() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("UnreachableAngledHeaderRepo"); + var project = scope.CreateDirectory(Path.Combine("UnreachableAngledHeaderRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.c; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.c"), "#include \n"); + var docs = Directory.CreateDirectory(Path.Combine(repositoryRoot, "docs")); + File.WriteAllText(Path.Combine(docs.FullName, "Injected.h"), "#define VALUE 1\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("unbound compiler search roots", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_assembler_file_input_after_label() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LabeledAssemblerInputRepo"); + var project = scope.CreateDirectory(Path.Combine("LabeledAssemblerInputRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Payload.s; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Payload.s"), "payload: .incbin \"/tmp/payload.bin\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("absolute .incbin", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-profile-use=Rules.profdata")] + [InlineData("-profile-use Rules.profdata")] + [InlineData("-profile-sample-use=Rules.profdata")] + [InlineData("-profile-sample-use Rules.profdata")] + public void ResolveExactAppleSourceCommit_classifies_swift_profile_inputs(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftProfileInputRepo" + option.Length, + $"OTHER_SWIFT_FLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_absolute_has_include_probe() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("HasIncludeProbeRepo"); + var project = scope.CreateDirectory(Path.Combine("HasIncludeProbeRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.c; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.c"), "#if __has_include(\"/tmp/Injected.h\")\nint injected;\n#endif\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("probes absolute preprocessor input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_preprocessed_plist_file_directive() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("PreprocessedPlistRepo"); + var project = scope.CreateDirectory(Path.Combine("PreprocessedPlistRepo", "Sample.xcodeproj")); + File.WriteAllText(Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { INFOPLIST_PREPROCESS = YES; INFOPLIST_FILE = Info.plist; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Info.plist"), "#include \"/tmp/Injected.inc\"\n\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("preprocessed INFOPLIST_FILE", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("file-selecting", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + private static void WriteTrackedPackageResolutionLock(string repositoryRoot, string url, string revision) + { + var project = Directory.EnumerateDirectories(repositoryRoot, "*.xcodeproj", SearchOption.AllDirectories).Single(); + var lockDirectory = Path.Combine(project, "project.xcworkspace", "xcshareddata", "swiftpm"); + Directory.CreateDirectory(lockDirectory); + File.WriteAllText( + Path.Combine(lockDirectory, "Package.resolved"), + System.Text.Json.JsonSerializer.Serialize(new + { + pins = new[] + { + new + { + identity = "remote-package", + kind = "remoteSourceControl", + location = url, + state = new { revision, version = "1.0.0" } + } + }, + version = 3 + })); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs new file mode 100644 index 000000000..9f8ddfbbe --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -0,0 +1,412 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Fact] + public void ResolveExactAppleSourceCommit_rejects_remote_project_package_without_tracked_lock() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("RemotePackageWithoutLockRepo"); + var project = scope.CreateDirectory(Path.Combine("RemotePackageWithoutLockRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://example.invalid/Shared.git"; + requirement = { kind = revision; revision = 0123456789abcdef0123456789abcdef01234567; }; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("tracked Package.resolved", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("same approved graph", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_host_reference_in_expanded_info_plist() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ExpandedInfoPlistRepo"); + var project = scope.CreateDirectory(Path.Combine("ExpandedInfoPlistRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { INFOPLIST_FILE = Info.plist; }; };"); + File.WriteAllText( + Path.Combine(repositoryRoot, "Info.plist"), + "BuildHost$(USER)", + System.Text.Encoding.Unicode); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("INFOPLIST_FILE contents", exception.Message, StringComparison.Ordinal); + Assert.Contains("$(USER)", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_deterministic_xcode_info_plist_references() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("DeterministicInfoPlistRepo"); + var project = scope.CreateDirectory(Path.Combine("DeterministicInfoPlistRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { INFOPLIST_FILE = Info.plist; }; };"); + File.WriteAllText( + Path.Combine(repositoryRoot, "Info.plist"), + "" + + "DevelopmentRegion$(DEVELOPMENT_LANGUAGE)" + + "Executable$(EXECUTABLE_NAME)" + + "Identifier$(PRODUCT_BUNDLE_IDENTIFIER)" + + "Name$(PRODUCT_NAME)" + + "Version$(MARKETING_VERSION)" + + "Build$(CURRENT_PROJECT_VERSION)" + + "Class$(PRODUCT_MODULE_NAME).SceneDelegate" + + ""); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var commit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.NotEmpty(commit); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_binary_info_plist_before_substitution_validation() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("BinaryInfoPlistRepo"); + var project = scope.CreateDirectory(Path.Combine("BinaryInfoPlistRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { INFOPLIST_FILE = Info.plist; }; };"); + File.WriteAllBytes( + Path.Combine(repositoryRoot, "Info.plist"), + Convert.FromBase64String("YnBsaXN0MDDRAQJZQnVpbGRIb3N0aADpACQAKABVAFMARQBSACkICxUAAAAAAAABAQAAAAAAAAADAAAAAAAAAAAAAAAAAAAAJg==")); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("binary property-list", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("text property list", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_local_remote_dependency_without_tracked_lock_even_with_literal_revision() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture(scope, "LocalRemotePackageWithoutLockRepo"); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\n" + + "let package = Package(name: \"Shared\", dependencies: [" + + ".package(url: \"https://example.invalid/Remote.git\", revision: \"0123456789abcdef0123456789abcdef01234567\")])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("tracked Package.resolved", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("same approved graph", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-framework Injected")] + [InlineData("-Wl,-weak_framework,Injected")] + public void ResolveExactAppleSourceCommit_rejects_unbound_named_framework_linker_flags(string flags) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "NamedFrameworkFlagRepo" + Guid.NewGuid().ToString("N"), + $"OTHER_LDFLAGS = {flags}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Named framework 'Injected'", exception.Message, StringComparison.Ordinal); + Assert.Contains("cannot be bound", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_external_info_plist_prefix_header() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("InfoPlistPrefixRepo"); + var project = scope.CreateDirectory(Path.Combine("InfoPlistPrefixRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { INFOPLIST_PREPROCESS = YES; INFOPLIST_PREFIX_HEADER = /tmp/Injected.h; }; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("INFOPLIST_PREFIX_HEADER", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("Shader.metal")] + [InlineData("Startup.S")] + public void ResolveExactAppleSourceCommit_rejects_external_includes_in_all_preprocessed_sources(string sourceName) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryName = "PreprocessedSourceRepo" + Path.GetExtension(sourceName).Replace(".", string.Empty); + var repositoryRoot = scope.CreateDirectory(repositoryName); + var project = scope.CreateDirectory(Path.Combine(repositoryName, "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $"000000000000000000000001 = {{ isa = PBXBuildFile; fileRef = 000000000000000000000002; }}; " + + $"000000000000000000000002 = {{ isa = PBXFileReference; path = {sourceName}; sourceTree = \"\"; }};"); + File.WriteAllText(Path.Combine(repositoryRoot, sourceName), "#include \"/tmp/injected.h\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(sourceName, exception.Message, StringComparison.Ordinal); + Assert.Contains("absolute preprocessor include", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_line_spliced_external_source_include() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LineSplicedIncludeRepo"); + var project = scope.CreateDirectory(Path.Combine("LineSplicedIncludeRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.m; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.m"), "#inc\\\nlude \"/tmp/injected.h\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("absolute preprocessor include", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("/tmp/injected.h", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_absolute_clang_module_map_input() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("ModuleMapInputRepo"); + var project = scope.CreateDirectory(Path.Combine("ModuleMapInputRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { MODULEMAP_FILE = Config.modulemap; }; };"); + File.WriteAllText( + Path.Combine(repositoryRoot, "Config.modulemap"), + "module Sample { private textual header \"/tmp/injected.h\" export * }"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("module map", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("/tmp/injected.h", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_validates_c_preprocessor_digraph_includes() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("DigraphIncludeRepo"); + var project = scope.CreateDirectory(Path.Combine("DigraphIncludeRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.m; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.m"), "%:include \"/tmp/injected.h\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("/tmp/injected.h", exception.Message, StringComparison.Ordinal); + Assert.Contains("absolute preprocessor include", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("Source.m", "const char *path = __FILE__;", "__FILE__")] + [InlineData("Source.m", "const char *path = __BASE_FILE__;", "__BASE_FILE__")] + [InlineData("Source.m", "const char *path = __builtin_FILE();", "__builtin_FILE")] + [InlineData("Source.cpp", "auto location = std::source_location::current();", "source_location")] + [InlineData("Source.m", "#define PATH_TOKEN __FI %:%: LE__", "__FILE__")] + [InlineData("Source.swift", "let path = #filePath", "#filePath")] + [InlineData("Source.swift", "let path = #file", "#file")] + public void ResolveExactAppleSourceCommit_rejects_snapshot_path_compiler_identifiers( + string sourceName, + string source, + string expectedIdentifier) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SnapshotPathIdentifierRepo" + sourceName.Length + expectedIdentifier.Length); + var project = scope.CreateDirectory(Path.Combine( + "SnapshotPathIdentifierRepo" + sourceName.Length + expectedIdentifier.Length, + "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + $"000000000000000000000002 = {{ isa = PBXFileReference; path = {sourceName}; sourceTree = \"\"; }}; " + + "000000000000000000000003 = { isa = PBXSourcesBuildPhase; files = (000000000000000000000001,); }; " + + "000000000000000000000004 = { isa = PBXNativeTarget; buildPhases = (000000000000000000000003,); productType = \"com.apple.product-type.application\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, sourceName), source + "\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(expectedIdentifier, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_allows_snapshot_path_literal_in_ui_test_only_source() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("UiTestSnapshotPathLiteralRepo"); + var project = scope.CreateDirectory(Path.Combine("UiTestSnapshotPathLiteralRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = UiTests.swift; sourceTree = \"\"; }; " + + "000000000000000000000003 = { isa = PBXSourcesBuildPhase; files = (000000000000000000000001,); }; " + + "000000000000000000000004 = { isa = PBXNativeTarget; buildPhases = (000000000000000000000003,); productType = \"com.apple.product-type.bundle.ui-testing\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "UiTests.swift"), "let source = #filePath\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var sourceCommit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(ReadFixtureHead(repositoryRoot), sourceCommit); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_snapshot_path_literal_in_shipping_synchronized_source_root() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SynchronizedSnapshotPathLiteralRepo"); + var sourceRoot = scope.CreateDirectory(Path.Combine("SynchronizedSnapshotPathLiteralRepo", "Sources")); + var project = scope.CreateDirectory(Path.Combine("SynchronizedSnapshotPathLiteralRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000002 = { isa = PBXFileReference; path = Source.swift; sourceTree = \"\"; }; " + + "000000000000000000000003 = { isa = PBXFileSystemSynchronizedRootGroup; path = Sources; sourceTree = \"\"; children = (000000000000000000000002,); }; " + + "000000000000000000000004 = { isa = PBXNativeTarget; buildPhases = (); fileSystemSynchronizedGroups = (000000000000000000000003,); productType = \"com.apple.product-type.application\"; };"); + File.WriteAllText(Path.Combine(sourceRoot, "Source.swift"), "let source = #filePath\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("#filePath", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("__DATE__")] + [InlineData("__TIME__")] + [InlineData("__TIMESTAMP__")] + [InlineData("__TI ## ME__")] + public void ResolveExactAppleSourceCommit_rejects_nondeterministic_compiler_time_macros(string macro) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CompilerTimeMacroRepo" + macro.Length); + var project = scope.CreateDirectory(Path.Combine("CompilerTimeMacroRepo" + macro.Length, "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.m; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.m"), $"const char *buildTime = {macro};\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("nondeterministic compiler macro", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_allows_time_macro_names_in_comments_and_literals() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CompilerTimeMacroLiteralRepo"); + var project = scope.CreateDirectory(Path.Combine("CompilerTimeMacroLiteralRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.m; sourceTree = \"\"; };"); + File.WriteAllText( + Path.Combine(repositoryRoot, "Source.m"), + "// __DATE__ and __FILE__\nconst char *documentation = \"__TIME__, __TIMESTAMP__, __BASE_FILE__, and source_location\";\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var sourceCommit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(ReadFixtureHead(repositoryRoot), sourceCommit); + } + + [Fact] + public void ResolveExactAppleSourceCommit_parses_xcode_reference_modifiers_before_host_classification() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("BuildSettingModifierRepo"); + var project = scope.CreateDirectory(Path.Combine("BuildSettingModifierRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.$(USER:rfc1034identifier); }; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("$(USER:rfc1034identifier)", exception.Message, StringComparison.Ordinal); + Assert.Contains("unapproved host", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_c_trigraph_preprocessor_directives() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("TrigraphIncludeRepo"); + var project = scope.CreateDirectory(Path.Combine("TrigraphIncludeRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Source.m; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Source.m"), "??=include \"/tmp/injected.h\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("trigraph", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("??=", exception.Message, StringComparison.Ordinal); + } + +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMetalInputs.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMetalInputs.cs new file mode 100644 index 000000000..711d732fe --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMetalInputs.cs @@ -0,0 +1,28 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("-include Rules")] + [InlineData("-include=Rules")] + [InlineData("@Metal.rsp")] + public void ResolveExactAppleSourceCommit_classifies_metal_compiler_file_inputs(string flags) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "MetalCompilerInputRepo" + flags.Length, + $"MTL_COMPILER_FLAGS = {flags}\n"); + if (flags == "@Metal.rsp") + File.WriteAllText(Path.Combine(repositoryRoot, "Metal.rsp"), "-include Rules\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustOffloadInputs.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustOffloadInputs.cs new file mode 100644 index 000000000..fc12de52a --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustOffloadInputs.cs @@ -0,0 +1,120 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("-fembed-offload-object=Rules")] + [InlineData("-fcuda-include-gpubinary Rules")] + [InlineData("-fopenmp-host-ir-file-path Rules")] + [InlineData("--gpu-instrument-lib=Rules")] + [InlineData("--hip-device-lib=Rules")] + [InlineData("--hip-device-lib-path=Rules")] + [InlineData("--offload-arch-tool=Rules")] + [InlineData("--amdgpu-arch-tool=Rules")] + [InlineData("--nvptx-arch-tool=Rules")] + public void ResolveExactAppleSourceCommit_classifies_offload_file_and_tool_inputs(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "OffloadInputRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-Xclang -fembed-offload-object=Rules")] + [InlineData("-Xclang -fcuda-include-gpubinary -Xclang Rules")] + public void ResolveExactAppleSourceCommit_classifies_forwarded_offload_inputs(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "ForwardedOffloadInputRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_classifies_offload_input_from_response_file() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "OffloadResponseInputRepo", + "OTHER_CFLAGS = @Compiler.rsp\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Compiler.rsp"), "-fembed-offload-object=Rules\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_tracked_offload_object_symlink() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "OffloadSymlinkRepo", + "OTHER_CFLAGS = -fembed-offload-object=Rules\n"); + var outside = Path.Combine(scope.CreateDirectory("OffloadSymlinkExternal"), "payload.o"); + File.WriteAllText(outside, "mutable"); + try + { + File.CreateSymbolicLink(Path.Combine(repositoryRoot, "Rules"), outside); + } + catch (Exception linkError) when (linkError is PlatformNotSupportedException or UnauthorizedAccessException) + { + return; + } + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("OTHER_CFLAGS", exception.Message, StringComparison.Ordinal); + Assert.Contains("symlink", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-Xcuda-fatbinary --image=Rules")] + [InlineData("-Xcuda-ptxas --options-file=Rules")] + [InlineData("-Xoffload-linker --override-image=openmp=Rules")] + [InlineData("-Xopenmp-target --image=Rules")] + [InlineData("-Xopenmp-target=amdgcn --image=Rules")] + [InlineData("-Xsycl-target-linker --image=Rules")] + public void ResolveExactAppleSourceCommit_rejects_unclassified_offload_tool_forwarding(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "OffloadForwardingRepo" + option.Length, + $"OTHER_CFLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("offload tool forwarding", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("cannot be classified safely", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs new file mode 100644 index 000000000..efb9b5b67 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs @@ -0,0 +1,391 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Fact] + public void ResolveExactAppleSourceCommit_rejects_commented_decoy_isa_before_shell_phase() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CommentedPbxIsaRepo"); + var project = scope.CreateDirectory(Path.Combine("CommentedPbxIsaRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { /* isa = PBXGroup; */ isa = PBXShellScriptBuildPhase; shellScript = \"date > generated.txt\"; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("shell-script", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_same_line_shell_phase_object() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CompactPbxObjectRepo"); + var project = scope.CreateDirectory(Path.Combine("CompactPbxObjectRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """000000000000000000000001 = { isa = PBXGroup; }; 000000000000000000000002 = { isa = PBXShellScriptBuildPhase; shellScript = "date > generated.txt"; };"""); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("shell-script", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("SWIFT_EXEC", "/tmp/custom-swiftc")] + [InlineData("CC", "/tmp/custom-clang")] + [InlineData("LD", "custom-linker")] + public void ResolveExactAppleSourceCommit_rejects_compiler_and_build_tool_overrides( + string setting, + string value) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("BuildToolOverrideRepo"); + var project = scope.CreateDirectory(Path.Combine("BuildToolOverrideRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $$""" + 000000000000000000000001 = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_NAME = Sample; {{setting}} = {{value}}; }; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(setting, exception.Message, StringComparison.Ordinal); + Assert.Contains("executable", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_legacy_external_build_target() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LegacyTargetRepo"); + var project = scope.CreateDirectory(Path.Combine("LegacyTargetRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXLegacyTarget; buildToolPath = /tmp/custom-build; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("legacy target", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_missing_relative_build_file_input() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("MissingBuildFileRepo"); + var project = scope.CreateDirectory(Path.Combine("MissingBuildFileRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + """000000000000000000000002 = { isa = PBXFileReference; path = Injected.swift; sourceTree = ""; };"""); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Injected.swift", exception.Message, StringComparison.Ordinal); + Assert.Contains("cannot be proven", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_escaped_unpinned_swift_package_dependency() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("EscapedPackageDependencyRepo"); + var project = scope.CreateDirectory(Path.Combine("EscapedPackageDependencyRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("EscapedPackageDependencyRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "Shared", + dependencies: [Package.Dependency.`package`(url: "https://example.invalid/Shared.git", branch: "main")], + targets: [.target(name: "Shared")]) + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Shared.git", exception.Message, StringComparison.Ordinal); + Assert.Contains("Package.resolved", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_indirect_swift_package_dependency_factory() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("IndirectPackageDependencyRepo"); + var project = scope.CreateDirectory(Path.Combine("IndirectPackageDependencyRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("IndirectPackageDependencyRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + """ + // swift-tools-version: 6.0 + import PackageDescription + let factory: (String, Package.Dependency.Requirement) -> Package.Dependency = Package.Dependency.package + let dependency = factory("https://example.invalid/Shared.git", .branch("main")) + let package = Package( + name: "Shared", + dependencies: [dependency], + targets: [.target(name: "Shared")]) + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("indirectly", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_ignores_commented_fake_remote_package_revision() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("CommentedPackageRevisionRepo"); + var project = scope.CreateDirectory(Path.Combine("CommentedPackageRevisionRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + 000000000000000000000001 = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://example.invalid/Shared.git"; + requirement = { + /* kind = revision; revision = 0123456789abcdef0123456789abcdef01234567; */ + kind = branch; + branch = main; + }; + }; + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("tracked Package.resolved", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_ignores_fake_revision_inside_nested_swift_comment() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("NestedSwiftCommentRepo"); + var project = scope.CreateDirectory(Path.Combine("NestedSwiftCommentRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("NestedSwiftCommentRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "Shared", + dependencies: [ + .package( + url: "https://example.invalid/Shared.git", + /* outer /* inner */ revision: "0123456789abcdef0123456789abcdef01234567" */ + branch: "main") + ], + targets: [.target(name: "Shared")]) + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Shared.git", exception.Message, StringComparison.Ordinal); + Assert.Contains("Package.resolved", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_executable_swift_macro_target() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SwiftMacroTargetRepo"); + var project = scope.CreateDirectory(Path.Combine("SwiftMacroTargetRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("SwiftMacroTargetRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + """ + // swift-tools-version: 6.0 + import PackageDescription + import CompilerPluginSupport + let package = Package( + name: "Shared", + targets: [ + .macro(name: "GeneratedFeature", dependencies: []) + ]) + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("macro", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("executable", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_build_setting_that_escapes_sdk_root() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("EscapedSdkRootRepo"); + var project = scope.CreateDirectory(Path.Combine("EscapedSdkRootRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCBuildConfiguration; buildSettings = { OTHER_LDFLAGS = -force_load $(SDKROOT)/../../../../tmp/libInjected.a; }; };"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("escapes approved", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SDKROOT", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_reads_only_root_pbx_objects_dictionary() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("RootPbxObjectsRepo"); + var project = scope.CreateDirectory(Path.Combine("RootPbxObjectsRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + """ + { + classes = { objects = { 000000000000000000000001 = { isa = PBXGroup; }; }; }; + objects = { + 000000000000000000000002 = { isa = PBXShellScriptBuildPhase; shellScript = "date > generated.txt"; }; + }; + rootObject = 000000000000000000000003; + } + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("shell-script", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_executable_swift_string_interpolation() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("SwiftInterpolationRepo"); + var project = scope.CreateDirectory(Path.Combine("SwiftInterpolationRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("SwiftInterpolationRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + """ + // swift-tools-version: 6.0 + import PackageDescription + let hidden = "\(CSetting.unsafeFlags(["-include", "/tmp/injected.h"]))" + let package = Package(name: "Shared", targets: [.target(name: "Shared")]) + """); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("string interpolation", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("cannot be proven", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_decoy_package_lock_outside_effective_xcode_location() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("DecoyPackageLockRepo"); + var project = scope.CreateDirectory(Path.Combine("DecoyPackageLockRepo", "Sample.xcodeproj")); + const string dependencyUrl = "https://example.invalid/MutablePackage.git"; + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + $$""" + 000000000000000000000001 = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "{{dependencyUrl}}"; + requirement = { kind = branch; branch = main; }; + }; + """); + var docs = scope.CreateDirectory(Path.Combine("DecoyPackageLockRepo", "docs")); + File.WriteAllText( + Path.Combine(docs, "Package.resolved"), + $$"""{ "pins": [ { "location": "{{dependencyUrl}}", "state": { "revision": "0123456789abcdef0123456789abcdef01234567" } } ] }"""); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Package.resolved", exception.Message, StringComparison.Ordinal); + Assert.Contains("tracked Package.resolved", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_backticked_swift_path_argument() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("EscapedSwiftPathRepo"); + var project = scope.CreateDirectory(Path.Combine("EscapedSwiftPathRepo", "Sample.xcodeproj")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared; };"); + var package = scope.CreateDirectory(Path.Combine("EscapedSwiftPathRepo", "Packages", "Shared")); + File.WriteAllText( + Path.Combine(package, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", `path`: \"/tmp/injected\")])"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("must resolve", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("inside", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs new file mode 100644 index 000000000..3eb5a17df --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs @@ -0,0 +1,372 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("Payload.mm", null)] + [InlineData("Payload.data", "sourcecode.cpp.cpp")] + public void ResolveExactAppleSourceCommit_scans_cpp_imports_by_effective_language( + string sourceName, + string? explicitFileType) + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("EffectiveCppImportRepo" + sourceName.Length); + var project = scope.CreateDirectory(Path.Combine(Path.GetFileName(repositoryRoot), "Sample.xcodeproj")); + var fileType = string.IsNullOrWhiteSpace(explicitFileType) ? string.Empty : $"explicitFileType = {explicitFileType};"; + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + $"000000000000000000000002 = {{ isa = PBXFileReference; path = {sourceName}; {fileType} sourceTree = \"\"; }}; " + + "000000000000000000000003 = { isa = PBXSourcesBuildPhase; files = (000000000000000000000001,); }; " + + "000000000000000000000004 = { isa = PBXNativeTarget; buildPhases = (000000000000000000000003,); productType = \"com.apple.product-type.application\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, sourceName), "import Injected;\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("C++", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("module", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("#pragma include_alias(\"Owned.h\", \"/tmp/Injected.h\")")] + [InlineData("_Pragma(\"include_alias(\\\"Owned.h\\\", \\\"/tmp/Injected.h\\\")\")")] + [InlineData("_Pragma\n(\"include_alias(\\\"Owned.h\\\", \\\"/tmp/Injected.h\\\")\")")] + public void ResolveExactAppleSourceCommit_rejects_preprocessor_include_aliases(string source) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "IncludeAliasRepo" + source.Length, + "Source.m", + source + "\n#include \"Owned.h\"\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Owned.h"), "// tracked\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("include_alias", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_scans_cpp_imports_across_newlines() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "MultilineCppImportRepo", + "Source.cpp", + "export\nimport\nInjected;\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("C++", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("module", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_scans_objective_c_imports_across_newlines() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "MultilineObjectiveCImportRepo", + "Source.m", + "@import\nInjected\n;\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Objective-C module", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_accepts_standard_metal_library_header() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "MetalStandardLibraryRepo", + "Shader.metal", + "#include \nusing namespace metal;\n"); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("EXPORTED_SYMBOLS_FILE")] + [InlineData("UNEXPORTED_SYMBOLS_FILE")] + [InlineData("ORDER_FILE")] + public void ResolveExactAppleSourceCommit_attests_linker_input_file_build_settings(string setting) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "LinkerInputSettingRepo" + setting, + $"{setting} = /tmp/Injected.list\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(setting, exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absolute", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("linkedLibrary", "Injected")] + [InlineData("linkedFramework", "Injected")] + public void ResolveExactAppleSourceCommit_rejects_unapproved_swift_package_link_inputs( + string factory, + string name) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture( + scope, + "SwiftPackageLinkInputRepo" + factory); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + $"// swift-tools-version: 6.0\nimport PackageDescription\n" + + $"let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", linkerSettings: [.{factory}(\"{name}\")])])\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(factory, exception.Message, StringComparison.Ordinal); + Assert.Contains(name, exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("linkedLibrary", "z")] + [InlineData("linkedFramework", "Foundation")] + [InlineData("linkedFramework", "AuthenticationServices")] + public void ResolveExactAppleSourceCommit_accepts_approved_swift_package_link_inputs( + string factory, + string name) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, _, packageRoot) = CreateLocalPackageFixture( + scope, + "ApprovedSwiftPackageLinkInputRepo" + factory); + File.WriteAllText( + Path.Combine(packageRoot, "Package.swift"), + $"// swift-tools-version: 6.0\nimport PackageDescription\n" + + $"let package = Package(name: \"Shared\", targets: [.target(name: \"Shared\", linkerSettings: [.{factory}(\"{name}\")])])\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("-includeMissingRules")] + [InlineData("-imacrosMissingRules")] + public void ResolveExactAppleSourceCommit_classifies_joined_forced_include_inputs(string flag) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "JoinedForcedIncludeRepo" + flag.Length, + $"OTHER_CFLAGS = {flag}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("-ivfsoverlay Overlay.yaml")] + [InlineData("-vfsoverlay=Overlay.yaml")] + public void ResolveExactAppleSourceCommit_rejects_vfs_overlays(string flag) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "VfsOverlayRepo" + flag.Length, + $"OTHER_CFLAGS = {flag}\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Overlay.yaml"), "{ 'version': 0, 'roots': [] }\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("VFS overlay", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact-source", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_resolves_quoted_headers_through_tracked_search_roots() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("QuotedSearchRootRepo"); + var project = scope.CreateDirectory(Path.Combine("QuotedSearchRootRepo", "Sample.xcodeproj")); + var sources = scope.CreateDirectory(Path.Combine("QuotedSearchRootRepo", "Sources")); + var headers = scope.CreateDirectory(Path.Combine("QuotedSearchRootRepo", "Headers")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Sources/Source.m; sourceTree = SOURCE_ROOT; }; " + + "000000000000000000000003 = { isa = XCBuildConfiguration; buildSettings = { HEADER_SEARCH_PATHS = Headers; }; };"); + File.WriteAllText( + Path.Combine(sources, "Source.m"), + "#include \"Foo.h\"\n#if __has_include(\"Foo.h\")\nint found;\n#endif\n"); + File.WriteAllText(Path.Combine(headers, "Foo.h"), "// tracked header\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("-access-notes-path MissingRules")] + [InlineData("-access-notes-path=MissingRules")] + public void ResolveExactAppleSourceCommit_attests_swift_access_note_inputs(string flag) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftAccessNotesRepo" + flag.Length, + $"OTHER_SWIFT_FLAGS = {flag}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_attests_private_module_map_build_setting() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "PrivateModuleMapRepo", + "MODULEMAP_PRIVATE_FILE = /tmp/Injected.modulemap\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("MODULEMAP_PRIVATE_FILE", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absolute", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_resolves_metal_headers_through_metal_search_roots() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("MetalSearchRootRepo"); + var project = scope.CreateDirectory(Path.Combine("MetalSearchRootRepo", "Sample.xcodeproj")); + var sources = scope.CreateDirectory(Path.Combine("MetalSearchRootRepo", "Sources")); + var headers = scope.CreateDirectory(Path.Combine("MetalSearchRootRepo", "MetalHeaders")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Sources/Shader.metal; sourceTree = SOURCE_ROOT; }; " + + "000000000000000000000003 = { isa = XCBuildConfiguration; buildSettings = { MTL_HEADER_SEARCH_PATHS = MetalHeaders; }; };"); + File.WriteAllText(Path.Combine(sources, "Shader.metal"), "#include \"Shared.metal\"\n"); + File.WriteAllText(Path.Combine(headers, "Shared.metal"), "// tracked Metal header\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("-foverride-record-layout=MissingLayout")] + [InlineData("-foverride-record-layout MissingLayout")] + public void ResolveExactAppleSourceCommit_attests_clang_record_layout_override_inputs(string flag) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "RecordLayoutOverrideRepo" + flag.Length, + $"OTHER_CFLAGS = -Xclang {flag}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("MissingLayout", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("#pragma comment(lib, \"Injected\")")] + [InlineData("_Pragma(\"comment(lib, \\\"Injected\\\")\")")] + [InlineData("__pragma(comment(lib, \"Injected\"))")] + public void ResolveExactAppleSourceCommit_rejects_pragma_linked_libraries(string source) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "PragmaLinkedLibraryRepo" + source.Length, + "Source.m", + source + "\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("comment(lib)", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unbound linker search root", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("const char *value = \"#pragma comment(lib, \\\"NotExecutable\\\")\";")] + [InlineData("const char *value = \"_Pragma(\\\"comment(lib, \\\\\\\"NotExecutable\\\\\\\")\\\")\";")] + [InlineData("const char *value = \"__pragma(comment(lib, \\\"NotExecutable\\\"))\";")] + public void ResolveExactAppleSourceCommit_ignores_pragma_link_text_inside_literals(string source) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateTrackedSourceFixture( + scope, + "PragmaLinkedLibraryLiteralRepo" + source.Length, + "Source.m", + source + "\n"); + var expected = CommitRepository(repositoryRoot); + + var actual = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(expected, actual); + } + + [Fact] + public void ResolveExactAppleSourceCommit_attests_static_libtool_file_lists() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "StaticLibtoolFileListRepo", + "OTHER_LIBTOOLFLAGS = -D -filelist /tmp/InjectedInputs\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("OTHER_LIBTOOLFLAGS", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absolute", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustSystemSearchPaths.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustSystemSearchPaths.cs new file mode 100644 index 000000000..2203fc942 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustSystemSearchPaths.cs @@ -0,0 +1,26 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("SYSTEM_FRAMEWORK_SEARCH_PATHS")] + [InlineData("SWIFT_SYSTEM_INCLUDE_PATHS")] + public void ResolveExactAppleSourceCommit_rejects_host_system_search_paths(string setting) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SystemSearchPathRepo" + setting.Length, + $"{setting} = /tmp/InjectedRules\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains(setting, exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absolute", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustWorkingDirectoryRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustWorkingDirectoryRegressions.cs new file mode 100644 index 000000000..951523292 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustWorkingDirectoryRegressions.cs @@ -0,0 +1,137 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Theory] + [InlineData("-load-pass-plugin Rules.dylib")] + [InlineData("-load-pass-plugin=Rules.dylib")] + [InlineData("-Xfrontend -load-pass-plugin -Xfrontend Rules.dylib")] + public void ResolveExactAppleSourceCommit_classifies_swift_pass_plugin_paths(string option) + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftPassPluginRepo" + option.Length, + $"OTHER_SWIFT_FLAGS = {option}\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules.dylib", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_classifies_swift_pass_plugin_path_from_response_file() + { + using var scope = new TemporaryDirectoryScope(); + var (repositoryRoot, configPath) = CreateXcconfigFixture( + scope, + "SwiftPassPluginResponseRepo", + "OTHER_SWIFT_FLAGS = @Swift.rsp\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Swift.rsp"), "-load-pass-plugin=Rules.dylib\n"); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("Rules.dylib", exception.Message, StringComparison.Ordinal); + Assert.Contains("missing exact-source input", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_resolves_assembler_include_from_project_working_directory() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("AssemblerWorkingDirectoryRepo"); + var project = scope.CreateDirectory(Path.Combine("AssemblerWorkingDirectoryRepo", "Sample.xcodeproj")); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "Sources")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Sources/Payload.s; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Sources", "Payload.s"), ".include \"Rules.inc\"\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Sources", "Rules.inc"), ".byte 1\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Rules.inc"), ".incbin \"/tmp/untrusted.bin\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("/tmp/untrusted.bin", exception.Message, StringComparison.Ordinal); + Assert.Contains("outside the exact-source graph", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveExactAppleSourceCommit_resolves_assembler_include_from_validated_search_root() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("AssemblerSearchRootRepo"); + var project = scope.CreateDirectory(Path.Combine("AssemblerSearchRootRepo", "Sample.xcodeproj")); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "Sources")); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "AssemblerIncludes")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Sources/Payload.s; sourceTree = \"\"; }; " + + "000000000000000000000003 = { isa = XCBuildConfiguration; buildSettings = { OTHER_CFLAGS = \"-I AssemblerIncludes\"; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Sources", "Payload.s"), ".include \"Rules.inc\"\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "AssemblerIncludes", "Rules.inc"), ".byte 1\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var commit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Matches("^[A-Fa-f0-9]{40}$", commit); + } + + [Fact] + public void ResolveExactAppleSourceCommit_resolves_assembler_include_from_per_file_search_root_regardless_of_object_order() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("AssemblerPerFileSearchRootRepo"); + var project = scope.CreateDirectory(Path.Combine("AssemblerPerFileSearchRootRepo", "Sample.xcodeproj")); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "Sources")); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "AssemblerIncludes")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000002 = { isa = PBXFileReference; path = Sources/Payload.s; sourceTree = \"\"; }; " + + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; settings = { COMPILER_FLAGS = \"-I AssemblerIncludes\"; }; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Sources", "Payload.s"), ".include \"Rules.inc\"\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "AssemblerIncludes", "Rules.inc"), ".byte 1\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var commit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Matches("^[A-Fa-f0-9]{40}$", commit); + } + + [Fact] + public void ResolveExactAppleSourceCommit_resolves_inline_assembler_include_from_project_working_directory() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("InlineAssemblerWorkingDirectoryRepo"); + var project = scope.CreateDirectory(Path.Combine("InlineAssemblerWorkingDirectoryRepo", "Sample.xcodeproj")); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "Sources")); + File.WriteAllText( + Path.Combine(project, "project.pbxproj"), + "000000000000000000000001 = { isa = PBXBuildFile; fileRef = 000000000000000000000002; }; " + + "000000000000000000000002 = { isa = PBXFileReference; path = Sources/Payload.c; sourceTree = \"\"; };"); + File.WriteAllText(Path.Combine(repositoryRoot, "Sources", "Payload.c"), "void f(void) { __asm__(\".include \\\"Rules.inc\\\"\"); }\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Sources", "Rules.inc"), ".byte 1\n"); + File.WriteAllText(Path.Combine(repositoryRoot, "Rules.inc"), ".incbin \"/tmp/untrusted.bin\"\n"); + var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); + CommitRepository(repositoryRoot); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + + Assert.Contains("/tmp/untrusted.bin", exception.Message, StringComparison.Ordinal); + Assert.Contains("outside the exact-source graph", exception.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs index 9b51899cd..26736f2d8 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using System.Text.Json; using PowerForge; using PowerForgeStudio.Orchestrator.Catalog; using PowerForgeStudio.Orchestrator.Portfolio; @@ -7,6 +9,20 @@ namespace PowerForgeStudio.Tests; public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests { + [Fact] + public void ResolveExactGitHead_rejects_dirty_Apple_checkpoint_source() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("DirtyAppleRepo"); + RunGit(repositoryRoot, "init", "--quiet"); + File.WriteAllText(Path.Combine(repositoryRoot, "uncommitted.txt"), "not represented by HEAD"); + + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactGitHead(repositoryRoot)); + + Assert.Contains("must be clean", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ExecuteAsync_UsesSharedProjectBuildHostServiceForProjectBuilds() { @@ -74,6 +90,26 @@ public async Task ExecuteAsync_UsesSharedProjectBuildHostServiceForProjectBuilds Assert.Contains(adapter.ArtifactFiles, path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)); } + private static void RunGit(string workingDirectory, params string[] arguments) + { + var startInfo = new ProcessStartInfo("git") + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Unable to start git test process."); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"git {string.Join(' ', arguments)} failed: {output} {error}"); + } + [Fact] public async Task ExecuteAsync_UsesDiscoveredJsonModuleConfigWithoutLegacyScript() { @@ -469,10 +505,12 @@ public async Task ExecuteAsync_AppleOnlyRelease_CheckpointsPlanWithoutExecutingA Assert.Equal(releaseConfig, configPath); Assert.True(request.PlanOnly); Assert.False(request.SkipAppleApps); + Assert.Equal("0123456789abcdef0123456789abcdef01234567", request.AppleSourceCommit); return new PowerForgeReleaseResult { Success = true, ConfigPath = configPath, AppleAppPlan = new PowerForgeAppleReleasePlan { + SourceCommit = request.AppleSourceCommit, Archive = false, Upload = true, Apps = [ @@ -484,7 +522,8 @@ public async Task ExecuteAsync_AppleOnlyRelease_CheckpointsPlanWithoutExecutingA ] } }; - }); + }, + resolveAppleSourceCommit: (_, _) => "0123456789abcdef0123456789abcdef01234567"); var result = await service.ExecuteAsync(repositoryRoot); @@ -493,6 +532,15 @@ public async Task ExecuteAsync_AppleOnlyRelease_CheckpointsPlanWithoutExecutingA var adapter = Assert.Single(result.AdapterResults); Assert.Equal(ReleaseBuildAdapterKind.AppleBuild, adapter.AdapterKind); Assert.Equal([archivePath], adapter.ArtifactDirectories); + var checkpoint = Assert.IsType( + JsonSerializer.Deserialize(result.UnifiedReleaseStateJson!)); + Assert.Equal( + "0123456789abcdef0123456789abcdef01234567", + checkpoint.AppleAppPlan!.SourceCommit); + var publishRequest = ReleasePublishExecutionService.CreateUnifiedPublishRequest( + releaseConfig, + checkpoint); + Assert.Equal(checkpoint.AppleAppPlan.SourceCommit, publishRequest.AppleSourceCommit); } [Fact] @@ -538,6 +586,8 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre Assert.False(request.PlanOnly); Assert.False(request.SkipAppleApps); Assert.True(request.CheckpointAppleApps); + Assert.True(request.RequireImmutableAppleSourceSnapshot); + Assert.Equal("0123456789abcdef0123456789abcdef01234567", request.AppleSourceCommit); return new PowerForgeReleaseResult { Success = true, @@ -558,6 +608,7 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre }, AppleAppPlan = new PowerForgeAppleReleasePlan { + SourceCommit = request.AppleSourceCommit, Archive = false, Upload = true, Apps = @@ -566,12 +617,20 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre { Name = "Sample iOS", Upload = true, - ArchivePath = archivePath + ArchivePath = archivePath, + ExpectedArchiveSha256 = "1111111111111111111111111111111111111111111111111111111111111111" } ] + }, + AppleReceipt = new PowerForgeAppleReleaseReceipt + { + PlanOnly = true, + SourceCommit = request.AppleSourceCommit, + PlanSha256 = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" } }; - }); + }, + resolveAppleSourceCommit: (_, _) => "0123456789abcdef0123456789abcdef01234567"); var result = await service.ExecuteAsync(repositoryRoot); @@ -582,6 +641,19 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre Assert.Contains(result.AdapterResults, adapter => adapter.AdapterKind == ReleaseBuildAdapterKind.AppleBuild && adapter.ArtifactDirectories.Contains(archivePath)); + var checkpoint = Assert.IsType( + JsonSerializer.Deserialize(result.UnifiedReleaseStateJson!)); + Assert.Equal( + "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + checkpoint.AppleReceipt!.PlanSha256); + var publishRequest = ReleasePublishExecutionService.CreateUnifiedPublishRequest( + releaseConfig, + checkpoint); + Assert.Equal(checkpoint.AppleReceipt.PlanSha256, publishRequest.AppleExpectedPlanSha256); + Assert.Equal(checkpoint.AppleAppPlan!.SourceCommit, publishRequest.AppleSourceCommit); + Assert.Equal( + Assert.Single(checkpoint.AppleAppPlan.Apps).ExpectedArchiveSha256, + Assert.Single(publishRequest.AppleExpectedArchiveSha256ByTarget).Value); } [Fact] diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs index af83e9aa5..ceebf76df 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs @@ -305,6 +305,58 @@ public void PrepareApplePublishFromCheckpoint_reuses_the_checkpointed_archive() Assert.False(spec.AppleApps.Archive); } + [Fact] + public void CreateUnifiedPublishRequest_preserves_checkpointed_Apple_provenance_and_recovery_options() + { + var built = new PowerForgeReleaseResult + { + AppleAppPlan = new PowerForgeAppleReleasePlan + { + SourceCommit = "0123456789abcdef0123456789abcdef01234567", + RequestedMarketingVersion = "1.6", + AdoptExistingBuild = true, + Automation = new PowerForgeAppleReleaseAutomationOptions + { + Resume = false, + WaitForProcessing = true, + ProcessingTimeoutSeconds = 1200, + PollIntervalSeconds = 30 + }, + Apps = + [ + new PowerForgeAppleAppReleaseTargetPlan + { + Name = "CasaRay iOS", + ExpectedArchiveSha256 = "1111111111111111111111111111111111111111111111111111111111111111" + } + ] + }, + AppleReceipt = new PowerForgeAppleReleaseReceipt + { + PlanOnly = true, + PlanSha256 = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" + } + }; + + var request = ReleasePublishExecutionService.CreateUnifiedPublishRequest( + "/repo/powerforge.release.json", + built); + + Assert.Equal(PowerForgeAppleReleaseAction.Configured, request.AppleAction); + Assert.Equal("1.6", request.AppleMarketingVersion); + Assert.Equal("0123456789abcdef0123456789abcdef01234567", request.AppleSourceCommit); + Assert.True(request.RequireImmutableAppleSourceSnapshot); + Assert.Equal("abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", request.AppleExpectedPlanSha256); + Assert.Equal( + "1111111111111111111111111111111111111111111111111111111111111111", + Assert.Single(request.AppleExpectedArchiveSha256ByTarget).Value); + Assert.True(request.AppleAdoptExistingBuild); + Assert.False(request.AppleResume); + Assert.True(request.AppleWaitForProcessing); + Assert.Equal(1200, request.AppleProcessingTimeoutSeconds); + Assert.Equal(30, request.ApplePollIntervalSeconds); + } + [Fact] public async Task ExecuteAsync_rethrows_cancellation_from_module_repository_publication() { diff --git a/Schemas/powerforge.release.schema.json b/Schemas/powerforge.release.schema.json index be4dad215..bf8de35a9 100644 --- a/Schemas/powerforge.release.schema.json +++ b/Schemas/powerforge.release.schema.json @@ -404,6 +404,7 @@ "properties": { "WriteReceipt": { "type": "boolean" }, "ReceiptPath": { "type": "string" }, + "ReceiptHistoryPath": { "type": "string" }, "PlanReceiptPath": { "type": "string" }, "LockPath": { "type": "string" }, "VersionSourcePath": { "type": [ "string", "null" ] }, diff --git a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 index 5f48d45fc..d05fab2b4 100644 --- a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 +++ b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 @@ -10,11 +10,76 @@ function Add-AllowedConsumerEvidencePath { $null = $script:allowedConsumerEvidencePaths.Add($full) } +function Register-AppleReceiptEvidenceFile { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][string] $SourceCommit, + [switch] $HistoryEntry, + [switch] $AllowLegacy + ) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $item = Get-Item -LiteralPath $Path -Force + if ($item.Length -gt 2MB) { throw "Apple release receipt exceeds the 2 MB evidence limit: $Path" } + Assert-UnlinkedPath -Path $Path -Name 'Apple release receipt' + try { + $receipt = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } catch { + throw "Apple release receipt is not valid JSON: $Path" + } + $receiptSource = [string]$receipt.sourceCommit + $schemaVersion = [int]$receipt.schemaVersion + if ($schemaVersion -gt 6) { + throw "Apple release receipt schema $schemaVersion is not supported: $Path" + } + $supportedReceipt = $schemaVersion -in @(5, 6) + if ($supportedReceipt) { + if ( + ([string]$receipt.attemptId) -notmatch '^[0-9A-Fa-f]{32}$' -or + ([string]$receipt.receiptSha256) -notmatch '^[0-9A-Fa-f]{64}$' -or + (-not [string]::IsNullOrWhiteSpace($receiptSource) -and + $receiptSource -notmatch '^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$')) { + throw "Apple release evidence does not satisfy the supported receipt contract: $Path" + } + } + if ($schemaVersion -eq 5) { + if (([string]$receipt.receiptAuthenticationSha256) -notmatch '^[0-9A-Fa-f]{64}$') { + throw "Legacy schema-5 Apple release evidence does not satisfy its integrity-key contract: $Path" + } + $keyPath = if (-not [string]::IsNullOrWhiteSpace($env:POWERFORGE_APPLE_RECEIPT_AUTH_KEY_PATH)) { + [IO.Path]::GetFullPath($env:POWERFORGE_APPLE_RECEIPT_AUTH_KEY_PATH) + } else { + Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) '.powerforge/apple-receipt-auth.key' + } + if (-not (Test-Path -LiteralPath $keyPath -PathType Leaf)) { + throw "Authenticated Apple release evidence requires the machine-local key '$keyPath'." + } + Assert-UnlinkedPath -Path $keyPath -Name 'Apple release receipt authentication key' + $key = [IO.File]::ReadAllBytes($keyPath) + if ($key.Length -ne 32) { throw "Apple release receipt authentication key must contain exactly 32 bytes: $keyPath" } + $hmac = [Security.Cryptography.HMACSHA256]::new($key) + try { + $expected = $hmac.ComputeHash([Text.Encoding]::ASCII.GetBytes(([string]$receipt.receiptSha256).ToLowerInvariant())) + } finally { + $hmac.Dispose() + } + $actual = [Convert]::FromHexString([string]$receipt.receiptAuthenticationSha256) + if (-not [Security.Cryptography.CryptographicOperations]::FixedTimeEquals($expected, $actual)) { + throw "Apple release receipt recovery authentication failed: $Path" + } + } elseif ($schemaVersion -ne 6) { + if (-not $AllowLegacy) { + throw "Apple release receipt is legacy evidence and cannot be admitted without a supported current receipt chain: $Path" + } + } + Add-AllowedConsumerEvidencePath -Path $Path -Name 'Apple release receipt' + return $supportedReceipt +} + function Get-ForwardedArgumentList { param([Parameter(Mandatory)][string] $SourceCommit) if ($ArgumentList[0] -ne 'apple-release') { return @($ArgumentList) } - if ($SourceCommit -notmatch '^[0-9A-Fa-f]{40}$') { - throw 'Verified consumer source commit must be an exact 40-character Git commit SHA.' + if ($SourceCommit -notmatch '^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$') { + throw 'Verified consumer source commit must be a full SHA-1 or SHA-256 Git commit object id.' } $withoutLocalEvidence = [Collections.Generic.List[string]]::new() @@ -47,8 +112,8 @@ function Get-ForwardedArgumentList { if ($sourceCommitFound) { throw '--apple-source-commit must be specified at most once.' } $sourceCommitFound = $true if ([string]::IsNullOrWhiteSpace($configuredSourceCommit) -or - $configuredSourceCommit -notmatch '^[0-9A-Fa-f]{40}$') { - throw '--apple-source-commit must contain an exact 40-character Git commit SHA.' + $configuredSourceCommit -notmatch '^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$') { + throw '--apple-source-commit must contain a full SHA-1 or SHA-256 Git commit object id.' } if (-not $configuredSourceCommit.Equals($SourceCommit, [StringComparison]::OrdinalIgnoreCase)) { throw "--apple-source-commit must match the exact consumer HEAD '$SourceCommit'." @@ -108,16 +173,36 @@ function Register-AppleAutomationEvidence { 'build/powerforge/apple/release-receipt.json' } else { [string]$automation.ReceiptPath } $receiptPath = Resolve-PathFromBase -BasePath $projectRoot -Value $receiptValue + + $historyValue = if ([string]::IsNullOrWhiteSpace([string]$automation.ReceiptHistoryPath)) { + 'build/powerforge/apple/receipts' + } else { [string]$automation.ReceiptHistoryPath } + $historyPath = Resolve-PathFromBase -BasePath $projectRoot -Value $historyValue + $receiptEvidenceFiles = [Collections.Generic.List[object]]::new() if (Test-Path -LiteralPath $receiptPath) { - $directTargets = @($apple.Apps | Where-Object { $_.Enabled -ne $false -and [string]$_.DistributionRoute -eq 'DirectNotarized' }) - if ($directTargets.Count -eq 0) { - $receipt = Get-Content -LiteralPath $receiptPath -Raw | ConvertFrom-Json - if (-not ([string]$receipt.sourceCommit).Equals($SourceCommit, [StringComparison]::OrdinalIgnoreCase)) { - throw "Apple release receipt source commit does not match the exact consumer HEAD '$SourceCommit'." + $receiptEvidenceFiles.Add([pscustomobject]@{ Path = $receiptPath; History = $false }) + } + if (Test-Path -LiteralPath $historyPath) { + Assert-UnlinkedDirectory -Path $historyPath -Name 'Apple release receipt history' + foreach ($entry in @(Get-ChildItem -LiteralPath $historyPath -Force)) { + if ($entry.PSIsContainer -or -not $entry.Name.EndsWith('.json', [StringComparison]::OrdinalIgnoreCase)) { + throw "Apple release receipt history contains an unsupported entry: $($entry.FullName)" } - Add-AllowedConsumerEvidencePath -Path $receiptPath -Name 'Apple release receipt' + $receiptEvidenceFiles.Add([pscustomobject]@{ Path = $entry.FullName; History = $true }) } } + $classifiedReceipts = foreach ($entry in $receiptEvidenceFiles) { + try { $value = Get-Content -LiteralPath $entry.Path -Raw | ConvertFrom-Json } + catch { throw "Apple release receipt is not valid JSON: $($entry.Path)" } + [pscustomobject]@{ Path = $entry.Path; History = $entry.History; Supported = ([int]$value.schemaVersion -in @(5, 6)) } + } + $supportedReceipts = @($classifiedReceipts | Where-Object Supported) + foreach ($entry in $supportedReceipts) { + $null = Register-AppleReceiptEvidenceFile -Path $entry.Path -SourceCommit $SourceCommit -HistoryEntry:$entry.History + } + foreach ($entry in @($classifiedReceipts | Where-Object { -not $_.Supported })) { + $null = Register-AppleReceiptEvidenceFile -Path $entry.Path -SourceCommit $SourceCommit -HistoryEntry:$entry.History -AllowLegacy:($supportedReceipts.Count -gt 0) + } $expectedPlanSha256 = Get-OptionValue -Option '--apple-expected-plan-sha256' if (-not [string]::IsNullOrWhiteSpace($expectedPlanSha256) -and $expectedPlanSha256 -notmatch '^[0-9A-Fa-f]{64}$') { diff --git a/scripts/Invoke-PinnedPowerForge.ps1 b/scripts/Invoke-PinnedPowerForge.ps1 index 7ae608118..56d2e47a6 100644 --- a/scripts/Invoke-PinnedPowerForge.ps1 +++ b/scripts/Invoke-PinnedPowerForge.ps1 @@ -1,6 +1,6 @@ param( [Parameter(Mandatory)] - [ValidatePattern('^[0-9A-Fa-f]{40}$')] + [ValidatePattern('^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$')] [string] $ExpectedCommit, [Parameter(Mandatory)] @@ -418,7 +418,7 @@ function Assert-AuthoritativeCaptureProvenance { $workflowPattern = '^' + [regex]::Escape($ExpectedConsumerRepository) + '/(?\.github/workflows/[A-Za-z0-9._/-]+\.ya?ml)@refs/heads/' + [regex]::Escape($requiredBranch) + '$' $workflowMatch = [regex]::Match($workflowRef, $workflowPattern, [Text.RegularExpressions.RegexOptions]::IgnoreCase) if (-not $repository.Equals($ExpectedConsumerRepository, [StringComparison]::OrdinalIgnoreCase) -or - $runId -notmatch '^\d+$' -or $sourceCommit -notmatch '^[0-9a-f]{40}$' -or -not $workflowMatch.Success) { + $runId -notmatch '^\d+$' -or $sourceCommit -notmatch '^(?:[0-9a-f]{40}|[0-9a-f]{64})$' -or -not $workflowMatch.Success) { throw 'Capture provenance repository, run id, source commit, or workflow identity is invalid.' } if ($sourceCommit -ne $SourceCommit.ToLowerInvariant()) {