From bc6a21c0ac73eb650cc860856d27a4b85498fb9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 10:19:20 +0200 Subject: [PATCH 01/78] Harden Apple release provenance and recovery --- Docs/PSPublishModule.AppleRelease.md | 49 +- .../Cmdlets/InvokePowerForgeReleaseCommand.cs | 23 + .../PowerForgeReleaseInvocationOptions.cs | 8 + .../Cmdlets/PowerForgeReleaseRequestMapper.cs | 8 + PowerForge.Cli/AppleReleaseCliSummary.cs | 2 + .../Program.Command.AppleRelease.cs | 2 + PowerForge.Cli/Program.Command.Release.cs | 6 +- .../AppleReleaseReceiptStoreTests.cs | 188 +++++++ ...ppleReleaseWorkflowTests.PinnedEvidence.cs | 33 +- .../PowerForgeCliAppleReleaseTests.cs | 11 + .../PowerForgeReleaseRequestMapperTests.cs | 8 + ...eleaseServiceTests.AppleAtomicExecution.cs | 44 ++ ...orgeReleaseServiceTests.AppleAutomation.cs | 358 +++++++++++++- ...eleaseServiceTests.ApplePlatformControl.cs | 52 +- ...aseServiceTests.ApplePreflightAndSafety.cs | 40 +- ...easeServiceTests.AppleProjectGeneration.cs | 7 +- ...aseServiceTests.AppleReleaseAdvancement.cs | 42 ++ .../PowerForgeReleaseServiceTests.cs | 9 +- .../PowerForgeAppleReleaseAutomation.cs | 37 +- PowerForge/Models/PowerForgeRelease.cs | 17 + .../Services/AppleReleaseArtifactService.cs | 9 +- .../Services/AppleReleaseReceiptStore.cs | 467 ++++++++++++++++++ ...owerForgeReleaseService.AppleAutomation.cs | 239 +++------ ...owerForgeReleaseService.AppleDurability.cs | 130 +++++ .../PowerForgeReleaseService.AppleResume.cs | 275 +++++++++++ ...owerForgeReleaseService.AppleVersioning.cs | 4 +- .../Services/PowerForgeReleaseService.cs | 65 ++- .../Queue/ReleaseBuildExecutionService.cs | 26 +- ...ePublishExecutionService.UnifiedRelease.cs | 32 +- ...StudioReleaseBuildExecutionServiceTests.cs | 71 ++- ...ExecutionServiceTests.ProductGateReview.cs | 40 ++ Schemas/powerforge.release.schema.json | 1 + scripts/Invoke-PinnedPowerForge.Evidence.ps1 | 50 +- 33 files changed, 2099 insertions(+), 254 deletions(-) create mode 100644 PowerForge.Tests/AppleReleaseReceiptStoreTests.cs create mode 100644 PowerForge/Services/AppleReleaseReceiptStore.cs create mode 100644 PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs create mode 100644 PowerForge/Services/PowerForgeReleaseService.AppleResume.cs diff --git a/Docs/PSPublishModule.AppleRelease.md b/Docs/PSPublishModule.AppleRelease.md index 02b0bc0db..256876097 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,50 @@ 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. + +Run real publication through `scripts/Invoke-PinnedPowerForge.ps1`; it supplies the exact +consumer commit automatically. Direct CLI callers should pass the same 40-character SHA +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 never claims that PowerForge uploaded the binary. The receipt records +`adoptedExistingBuild=true` and emits +`APPLE_BUILD_ADOPTED_WITHOUT_UPLOAD_ATTESTATION`. 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..9bae0a6d8 100644 --- a/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs +++ b/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs @@ -256,12 +256,31 @@ 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 an exact 40-character 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; } + /// + /// Explicitly adopts an exact remote Apple build when no matching local upload attestation exists. + /// Requires ConfirmAppleAction and is intended only for deliberate recovery. + /// + [Parameter] + public SwitchParameter AdoptExistingAppleBuild { get; set; } + /// Forces exact remote-build reuse on this run. [Parameter] public SwitchParameter AppleResume { get; set; } @@ -811,7 +830,11 @@ private PowerForgeReleaseInvocationOptions BuildInvocationOptions(IDictionary " + "[--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.Release.cs b/PowerForge.Cli/Program.Command.Release.cs index 12e1364df..099ea07f0 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 ."; @@ -380,6 +380,7 @@ internal static PowerForgeReleaseRequest BuildReleaseRequestFromArgs( request.AppleSourceCommit = ChooseString(request.AppleSourceCommit, TryGetOptionValue(argv, "--apple-source-commit")); 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 +584,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.Tests/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs new file mode 100644 index 000000000..bd7b5819d --- /dev/null +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -0,0 +1,188 @@ +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 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_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 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); + } + } + +#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 + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + // Best-effort test cleanup. + } + } +} diff --git a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs index dda1c369b..77f50be1e 100644 --- a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs +++ b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs @@ -275,10 +275,32 @@ public void ApprovedApplePlanAndBoundAutomationOutputsDoNotRequireASecondCheckou { 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 })); + File.WriteAllText(Path.Combine(receiptHistory.FullName, "prior-upload.json"), JsonSerializer.Serialize(new + { + schemaVersion = 4, + attemptId = "11111111111111111111111111111111", + receiptSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + sourceCommit = "89abcdef0123456789abcdef0123456789abcdef" + })); + File.WriteAllText(Path.Combine(receiptHistory.FullName, "local-status.json"), JsonSerializer.Serialize(new + { + schemaVersion = 4, + attemptId = "22222222222222222222222222222222", + receiptSha256 = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + action = "Status" + })); + File.WriteAllText(Path.Combine(receiptHistory.FullName, "legacy-upload.json"), JsonSerializer.Serialize(new + { + schemaVersion = 3, + sourceCommit = commit, + action = "Upload", + success = false + })); File.WriteAllText(Path.Combine(output, "release-plan.json"), JsonSerializer.Serialize(new { planOnly = true, @@ -305,6 +327,7 @@ public void ApprovedApplePlanAndBoundAutomationOutputsDoNotRequireASecondCheckou 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 +339,14 @@ 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') + Set-Content -LiteralPath (Join-Path $consumer 'build/powerforge/apple/receipts/wrong-legacy.json') -Value '{"schemaVersion":3,"sourceCommit":"89abcdef0123456789abcdef0123456789abcdef"}' + try { Register-AppleAutomationEvidence -SourceCommit '{{commit}}'; throw 'Wrong-source legacy history was accepted.' } + catch { if ($_.Exception.Message -notlike '*source commit does not match*') { throw } } + Remove-Item -LiteralPath (Join-Path $consumer 'build/powerforge/apple/receipts/wrong-legacy.json') 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/PowerForgeCliAppleReleaseTests.cs b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs index ef7d0121e..bd91e7696 100644 --- a/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs +++ b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs @@ -113,6 +113,17 @@ public async Task AppleRelease_CliUsesDedicatedEnvelopeAndReportsLegacyConfirmat static step => step.GetString() == "stopBeforeReview"); } + var adoptedUpload = await RunCliAsync( + repoRoot, + $"\"{GetCliPath(repoRoot)}\" apple-release Upload --config \"{configPath}\" --apple-adopt-existing-build --plan --summary --output json"); + Assert.Equal(0, adoptedUpload.ExitCode); + using (var adoptedUploadDocument = JsonDocument.Parse(adoptedUpload.StdOut)) + { + var result = adoptedUploadDocument.RootElement.GetProperty("result"); + Assert.True(result.GetProperty("adoptExistingBuild").GetBoolean()); + Assert.True(result.GetProperty("requiresConfirmation").GetBoolean()); + } + var configuredDedicated = await RunCliAsync( repoRoot, $"\"{GetCliPath(repoRoot)}\" apple-release Configured --config \"{configPath}\" --plan --summary --output json"); diff --git a/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs b/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs index 1bcb8e593..8a8e853bf 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,11 @@ public void Build_MapsCompactAppleActionOverrides() }); Assert.Equal(PowerForgeAppleReleaseAction.Upload, request.AppleAction); + Assert.Equal("1.6", request.AppleMarketingVersion); + Assert.Equal("0123456789abcdef0123456789abcdef01234567", request.AppleSourceCommit); + 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); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs index 19ed9e3dd..7a5e3508c 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs @@ -2,6 +2,50 @@ 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); + Assert.True(File.Exists(result.AppleAppPlan.PlanReceiptPath)); + } + finally + { + TryDelete(root); + } + } + [Fact] public void PublishBuiltReleaseOutputs_ExecutesConfiguredAppleLane() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index a13465179..178fa70f3 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -2,6 +2,92 @@ 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_RejectsArchiveMutationAfterUploaderReturns() + { + 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 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 => + { + 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.Contains("changed during upload", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain( + new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested"); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleStatusPlan_ProducesReadOnlyExplicitPlan() { @@ -21,7 +107,7 @@ public void Execute_AppleStatusPlan_ProducesReadOnlyExplicitPlan() AppleAction = PowerForgeAppleReleaseAction.Status }); - Assert.True(result.Success); + Assert.True(result.Success, result.ErrorMessage); var plan = Assert.IsType(result.AppleAppPlan); Assert.Equal(PowerForgeAppleReleaseAction.Status, plan.Action); Assert.False(plan.Archive); @@ -479,12 +565,54 @@ public void Execute_AppleArchiveFailure_WritesActionableReceipt() public void Execute_AppleUploadAction_ResumesExactValidRemoteBuildAndWritesCompactReceipt( 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 +621,21 @@ 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( spec, new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleSourceCommit = sourceCommit, AppleAction = action }); - Assert.True(result.Success); + 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 +669,89 @@ 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_AppleStatus_PreservesBetaReviewActionWhenGroupIsConfigured() { @@ -583,11 +792,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 +935,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 +1013,120 @@ public void Execute_AppleUpload_PostUploadStatusFailureWritesReceipt() } } + [Fact] + public void Execute_AppleUpload_ResumesFromImmediateBuildUploadAttestationWhenFinalReadbackFailed() + { + 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 resumed = CreateAppleAutomationService( + request => CreateReleaseState(request, "VALID"), + archiveAppleApp: _ => throw new InvalidOperationException("Verified resume must skip archive."), + uploadAppleApp: _ => throw new InvalidOperationException("Verified resume must skip upload."), + getAppleBuildUpload: (_, id) => new AppStoreConnectBuildUploadInfo + { + Id = id, + State = "COMPLETE", + MarketingVersion = "1.2.0", + BuildNumber = "9", + Platform = "IOS" + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(root, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.UploadExisting, + AppleSourceCommit = sourceCommit + }); + + Assert.True(resumed.Success, resumed.ErrorMessage); + Assert.True(Assert.Single(resumed.AppleApps).ResumedExistingBuild); + } + 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() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs index 337750d66..188d84890 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs @@ -312,7 +312,7 @@ public void Execute_AppleDoctor_DiscoversAppIdAndWritesActionableReceipt() Assert.True(result.Success); var receipt = Assert.IsType(result.AppleReceipt); - Assert.Equal(3, receipt.SchemaVersion); + Assert.Equal(4, receipt.SchemaVersion); var target = Assert.Single(receipt.Targets); Assert.Equal(AppleDistributionRoute.AppStore, target.DistributionRoute); Assert.Equal("6778025328", target.AppId); @@ -783,7 +783,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 +807,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."), @@ -859,18 +857,23 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() Assert.True(resumed.Success); Assert.NotNull(resumedRequest); Assert.Equal("accepted-then-staple-failed", resumedRequest!.AcceptedSubmissionId); - Assert.Equal("failed-artifact-sha", resumedRequest.ExpectedArtifactSha256); + Assert.Equal( + AppleNotarizationService.ComputeArtifactSha256(resumedRequest.ArtifactPath), + resumedRequest.ExpectedArtifactSha256); var resumedTarget = Assert.Single(resumed.AppleReceipt!.Targets); Assert.True(resumedTarget.ResumedAcceptedNotarization); Assert.Contains("notarySubmission", 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; @@ -932,12 +935,18 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() // the retained artifact instead of submitting it again. 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; @@ -961,9 +970,14 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() Assert.Null(disabledChecksTarget.StapleValidated); Assert.Null(disabledChecksTarget.GatekeeperAccepted); - var retainedReceipt = File.ReadAllText(receiptPath) - .Replace("\"success\": true", "\"success\": false", StringComparison.Ordinal); - File.WriteAllText(receiptPath, retainedReceipt); + 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(retainedTarget.DirectArtifactPath!, "changed-after-release.txt"), "changed"); var changedArtifact = nextReleaseService.Execute( spec, 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..89534b09d 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs @@ -454,6 +454,7 @@ public void Execute_AppleAdvance_DoesNotPrepareTestFlightOnlyTargetForPublicStor { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Advance, + AppleAdoptExistingBuild = true, AppleActionConfirmed = true }); @@ -637,6 +638,7 @@ public void Execute_AppleAdvance_PartialFailureRefreshesRemoteStateAndKeepsOrigi { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Advance, + AppleAdoptExistingBuild = true, AppleActionConfirmed = true }); @@ -653,6 +655,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.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs index 2072cee69..4021a6790 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs @@ -530,7 +530,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); @@ -1642,7 +1643,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); @@ -3666,7 +3668,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/Models/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index 26a33f5f6..411413a0c 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; } = 4; + + /// Unique immutable attempt identity. + public string? AttemptId { get; set; } public PowerForgeAppleReleaseAction Action { get; set; } @@ -146,6 +152,9 @@ 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. @@ -157,6 +166,18 @@ internal sealed class PowerForgeAppleReleaseReceipt 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; } + + /// True when the operator explicitly requested adoption of an unattested existing build. + public bool AdoptExistingBuild { get; set; } + public PowerForgeAppleVersionReceipt? Versioning { get; set; } public PowerForgeAppleReleaseTargetReceipt[] Targets { get; set; } = Array.Empty(); @@ -283,6 +304,15 @@ 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; } + public string? DirectArtifactPath { get; set; } public string? DirectArtifactSha256 { get; set; } @@ -301,6 +331,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..066bea063 100644 --- a/PowerForge/Models/PowerForgeRelease.cs +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -228,6 +228,9 @@ internal sealed class PowerForgeReleaseRequest public bool AppleActionConfirmed { get; set; } + /// Explicitly adopt a matching remote build when no verified local upload attestation exists. + public bool AppleAdoptExistingBuild { get; set; } + public bool? AppleResume { get; set; } public bool? AppleWaitForProcessing { get; set; } @@ -503,6 +506,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 +518,8 @@ internal sealed class PowerForgeAppleReleasePlan public string? SourceCommit { get; set; } + public bool AdoptExistingBuild { get; set; } + public bool Archive { get; set; } public bool Upload { get; set; } @@ -691,6 +698,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/AppleReleaseArtifactService.cs b/PowerForge/Services/AppleReleaseArtifactService.cs index c01647411..e0810e9b9 100644 --- a/PowerForge/Services/AppleReleaseArtifactService.cs +++ b/PowerForge/Services/AppleReleaseArtifactService.cs @@ -46,15 +46,22 @@ 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) + .ToHashSet(PathComparer); 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.Contains(Path.GetFullPath(path))) .Where(path => GetLastWriteTimeUtc(path) <= cutoff) .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) .ToArray(); diff --git a/PowerForge/Services/AppleReleaseReceiptStore.cs b/PowerForge/Services/AppleReleaseReceiptStore.cs new file mode 100644 index 000000000..391cef5f8 --- /dev/null +++ b/PowerForge/Services/AppleReleaseReceiptStore.cs @@ -0,0 +1,467 @@ +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 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 previousReceipt = ReadAll(plan).FirstOrDefault(); + + 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(); + + 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.ReceiptSha256 = ComputeReceiptSha256(receipt); + + var payload = Serialize(receipt); + using (var stream = new FileStream(historyPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + using (var writer = new StreamWriter(stream, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) + writer.Write(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"); + } + + 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 (!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}"); + } + + 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)) + 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"); + using var source = new FileStream(plan.ReceiptPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var destination = new FileStream(legacyPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + source.CopyTo(destination); + } + + 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 + { + File.WriteAllText(temporaryPath, payload, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + 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 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 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/PowerForgeReleaseService.AppleAutomation.cs b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs index deef02d68..9ccf68d8b 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( @@ -190,6 +187,8 @@ private static void ValidateAppleAutomation(PowerForgeAppleReleaseAutomationOpti { 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 +265,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 +371,19 @@ 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 = HasAppleRemoteMutation(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)) + if (remoteAction && (refreshSuccessfulMutation || !result.Success || result.RemoteState is null)) { try { @@ -560,9 +418,13 @@ 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 + }))); } catch (Exception exception) { @@ -635,6 +497,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(); @@ -669,7 +532,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,6 +555,14 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( ArchiveCreated = result?.Archive?.Succeeded == true, ProjectGenerated = result?.ProjectGenerated == true, UploadPerformed = result?.Upload?.Succeeded == true, + 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, DirectArtifactPath = result?.Notarization?.ArtifactPath, DirectArtifactSha256 = result?.Notarization?.ArtifactSha256, NotarizationSubmissionId = result?.Notarization?.SubmissionId, @@ -701,6 +572,7 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( 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 +590,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 +617,38 @@ 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 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 +720,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.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs new file mode 100644 index 000000000..94214dfc4 --- /dev/null +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -0,0 +1,130 @@ +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(CreateAppleCheckpointTarget).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(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; + _appleReceiptStore.WriteAttempt(plan, new PowerForgeAppleReleaseReceipt + { + AttemptId = attemptId, + Action = plan.Action, + SourceCommit = plan.SourceCommit, + OperationPhase = "UploadAttested", + Success = true, + Targets = new[] { target } + }); + } + + private void WriteAppleNotarizationAttestation( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + PowerForgeAppleAppReleaseResult result) + { + if (!plan.Automation.WriteReceipt || result.Notarization is null) + return; + + var target = CreateAppleCheckpointTarget(app); + target.DirectArtifactPath = result.Notarization.ArtifactPath; + target.DirectArtifactSha256 = result.Notarization.ArtifactSha256; + target.NotarizationSubmissionId = result.Notarization.SubmissionId; + 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 static PowerForgeAppleReleaseTargetReceipt CreateAppleCheckpointTarget( + PowerForgeAppleAppReleaseTargetPlan app) + => new() + { + Name = app.Name, + BundleId = app.BundleId, + Platform = app.Platform, + DistributionRoute = app.DistributionRoute, + ProductRole = app.ProductRole, + ParentTarget = app.ParentTarget, + Capabilities = app.Capabilities, + TestFlightPolicy = app.TestFlightPolicy, + AppId = app.AppStoreConnectAppId, + Version = app.MarketingVersion, + Build = app.BuildNumber + }; + + 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.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs new file mode 100644 index 000000000..f8375d75b --- /dev/null +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -0,0 +1,275 @@ +namespace PowerForge; + +internal sealed partial class PowerForgeReleaseService +{ + 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); + if (platform.MatchedBuild is null) + { + 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); + } + + var attestation = FindVerifiedAppleUploadAttestation(plan, app, state, platform.MatchedBuild); + if (attestation is null && !plan.AdoptExistingBuild) + { + throw new InvalidOperationException( + $"App Store Connect already contains build {state.VersionString} ({state.BuildNumber}) for '{app.Name}', " + + "but no immutable local upload receipt binds that build to the current source commit and archive SHA-256. " + + "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); + } + + result.RemoteState = state; + result.ResumedExistingBuild = true; + result.AdoptedExistingBuild = attestation is null; + result.ResumedUploadAttestation = attestation?.Target; + result.ResumedUploadAttestationAttemptId = attestation?.Target.UploadAttestationAttemptId ?? attestation?.Receipt.AttemptId; + result.ArchiveSha256 = attestation?.Target.ArchiveSha256; + result.SkippedSteps = new[] { "archive", "upload" }; + return true; + } + + private AppleUploadAttestation? FindVerifiedAppleUploadAttestation( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + AppStoreConnectReleaseStateResult state, + AppStoreConnectBuildInfo remoteBuild) + { + if (string.IsNullOrWhiteSpace(plan.SourceCommit)) + return null; + + var receipts = _appleReceiptStore.ReadAll(plan); + foreach (var receipt in receipts) + { + if (receipt.PlanOnly || + string.IsNullOrWhiteSpace(receipt.ReceiptSha256) || + !string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var target = 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 && + candidate.DistributionRoute == app.DistributionRoute && + string.Equals(candidate.Version, state.VersionString, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.Build, state.BuildNumber, StringComparison.OrdinalIgnoreCase) && + !candidate.AdoptedExistingBuild && + candidate.UploadPerformed && + IsSha256(candidate.ArchiveSha256) && + !string.IsNullOrWhiteSpace(candidate.ArchivePath) && + IsVerifiedUploadCheckpoint(receipts, receipt, candidate)); + if (target is null) + continue; + + if (!string.IsNullOrWhiteSpace(target.BuildId) && + !string.Equals(target.BuildId, remoteBuild.Id, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.IsNullOrWhiteSpace(target.BuildId)) + { + if (string.IsNullOrWhiteSpace(target.BuildUploadId)) + continue; + 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 static bool IsVerifiedUploadCheckpoint( + IReadOnlyCollection receipts, + PowerForgeAppleReleaseReceipt receipt, + PowerForgeAppleReleaseTargetReceipt target) + { + 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 && + candidate.DistributionRoute == target.DistributionRoute && + string.Equals(candidate.ArchivePath, target.ArchivePath, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.ArchiveSha256, target.ArchiveSha256, StringComparison.OrdinalIgnoreCase)); + } + + private bool TryResumeDirectAppleNotarization( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + PowerForgeAppleAppReleaseResult result) + { + if (!IsUploadExecution(plan) || !plan.Automation.Resume) + return false; + + var prior = _appleReceiptStore.ReadAll(plan) + .Where(receipt => + !receipt.PlanOnly && + !string.IsNullOrWhiteSpace(plan.SourceCommit) && + string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) + .SelectMany(receipt => receipt.Targets.Where(target => IsMatchingDirectReceiptTarget(target, app))) + .FirstOrDefault(target => + string.Equals(target.NotarizationStatus, "Accepted", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(target.NotarizationSubmissionId) && + !string.IsNullOrWhiteSpace(target.DirectArtifactPath) && + IsSha256(target.DirectArtifactSha256) && + (File.Exists(target.DirectArtifactPath) || Directory.Exists(target.DirectArtifactPath))); + if (prior is null) + 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 static bool IsMatchingDirectReceiptTarget( + PowerForgeAppleReleaseTargetReceipt target, + PowerForgeAppleAppReleaseTargetPlan app) + => 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); + + private static bool IsSha256(string? value) + => !string.IsNullOrWhiteSpace(value) && + value!.Length == 64 && + value.All(static character => Uri.IsHexDigit(character)); + + 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..574a771fb 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -46,6 +46,7 @@ private PowerForgeAppleReleaseReceipt CreateApplePlanReceipt(PowerForgeAppleRele CheckedAt = DateTimeOffset.UtcNow, Success = true, ReceiptPath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, plan.PlanReceiptPath).Replace('\\', '/'), + AdoptExistingBuild = plan.AdoptExistingBuild, Versioning = versioning, Targets = targets, NextActions = new[] { $"Run Apple action '{plan.Action}' without --plan after reviewing this plan receipt." } @@ -53,7 +54,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; } @@ -151,6 +152,7 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei receipt.Action, receipt.SourceCommit, receipt.PlanOnly, + receipt.AdoptExistingBuild, receipt.Success, receipt.ErrorMessage, receipt.Versioning, diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 784d20298..16ecba3af 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) @@ -746,9 +749,12 @@ dotNetSourcePathForTools is not null && var cleanup = new PowerForgeAppleReleaseCleanupReceipt(); PowerForgeAppleAppReleaseResult[] appleResults; PowerForgeAppleVersionReceipt? appleVersioning = null; + var receiptJournalReady = !applePlan.Automation.WriteReceipt; try { AssertApplePlanStillApproved(applePlan, request.AppleExpectedPlanSha256); + PrepareAppleReceiptJournalForMutation(applePlan, request.AppleExpectedPlanSha256); + receiptJournalReady = true; if (applePlan.Action == PowerForgeAppleReleaseAction.Version) { appleVersioning = SelectAppleVersion(applePlan); @@ -789,7 +795,10 @@ dotNetSourcePathForTools is not null && .ToArray(); } result.AppleApps = appleResults; - if (!request.CheckpointAppleApps && + if (request.CheckpointAppleApps && appleResults.All(static app => app.Success)) + result.AppleReceipt = CreateApplePlanReceipt(applePlan); + if (receiptJournalReady && + !request.CheckpointAppleApps && (applePlan.Action != PowerForgeAppleReleaseAction.Configured || appleResults.Any(static app => !app.Success))) result.AppleReceipt ??= CompleteAppleReleaseReceipt(applePlan, appleResults, cleanup, appleVersioning); @@ -954,7 +963,11 @@ internal PowerForgeReleaseResult PublishBuiltReleaseOutputs( ConfigPath = configPath, AppleOnly = true, AppleAction = request.AppleAction, + AppleMarketingVersion = request.AppleMarketingVersion, + AppleSourceCommit = request.AppleSourceCommit, + AppleExpectedPlanSha256 = request.AppleExpectedPlanSha256, AppleActionConfirmed = request.AppleActionConfirmed, + AppleAdoptExistingBuild = request.AppleAdoptExistingBuild, AppleResume = request.AppleResume, AppleWaitForProcessing = request.AppleWaitForProcessing, AppleProcessingTimeoutSeconds = request.AppleProcessingTimeoutSeconds, @@ -1479,6 +1492,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); @@ -1587,6 +1602,13 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( { throw new InvalidOperationException("Apple source commit must be an exact 40-character Git commit SHA."); } + 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 (options.SyncMetadata && metadataConfigPath is null && metadataConfigPaths.Length == 0) throw new InvalidOperationException("AppleApps SyncMetadata requires MetadataConfigPath or MetadataConfigPaths."); if (options.SyncAppInfo && appInfoConfigPath is null && appInfoConfigPaths.Length == 0) @@ -1610,6 +1632,17 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( if (missingArchive is not null) throw new FileNotFoundException($"Apple app archive was not found for upload-only release: {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."); + } var explicitAppStoreConnectApiConfiguredCount = (string.IsNullOrWhiteSpace(options.AppStoreConnectApiKeyPath) ? 0 : 1) + (string.IsNullOrWhiteSpace(options.AppStoreConnectApiKeyId) ? 0 : 1) + @@ -1674,11 +1707,13 @@ 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, + AdoptExistingBuild = request.AppleAdoptExistingBuild, Archive = options.Archive, Upload = options.Upload, SyncScreenshots = options.SyncScreenshots, @@ -2090,10 +2125,12 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( result.ErrorMessage = $"xcodebuild archive failed for '{app.Name}' with exit code {archive.ProcessResult.ExitCode}."; return CompleteAppleExecutionFailure(plan, resultsByApp, app); } + CaptureAppleArchiveSha256(result, app); } if (plan.Upload && result.Success && !resumedUpload) { + CaptureAppleArchiveSha256(result, app); var direct = app.DistributionRoute == AppleDistributionRoute.DirectNotarized; var upload = _uploadAppleApp(new AppleAppArchiveUploadRequest { @@ -2122,15 +2159,23 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( return CompleteAppleExecutionFailure(plan, resultsByApp, app); } + VerifyAppleArchiveUnchangedAfterUpload(app, result); + if (direct) { result.Notarization = NotarizeDirectAppleExport(plan, app); + WriteAppleNotarizationAttestation(plan, app, result); if (!result.Notarization.Succeeded) throw CreateAppleNotarizationFailure(app, result.Notarization); } - else if (IsUploadAction(plan.Action) && - plan.Automation.WaitForProcessing) - result.RemoteState = WaitForAppleBuild(plan, app, buildUploadId: upload.BuildUploadId); + else + { + WriteAppleUploadAttestation(plan, app, result); + if (IsUploadExecution(plan) && + plan.Automation.WaitForProcessing && + UsesAppStoreConnect(app)) + result.RemoteState = WaitForAppleBuild(plan, app, buildUploadId: upload.BuildUploadId); + } } var appInfoMetadataSpecs = app.DistributionRoute == AppleDistributionRoute.AppStore && @@ -2273,6 +2318,17 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( return plan.Apps.Select(app => resultsByApp[app]).ToArray(); } + private static void CaptureAppleArchiveSha256( + PowerForgeAppleAppReleaseResult result, + PowerForgeAppleAppReleaseTargetPlan app) + { + if (!string.IsNullOrWhiteSpace(result.ArchiveSha256)) + return; + if (!File.Exists(app.ArchivePath) && !Directory.Exists(app.ArchivePath)) + return; + result.ArchiveSha256 = AppleNotarizationService.ComputeArtifactSha256(app.ArchivePath); + } + private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( PowerForgeAppleReleasePlan plan, out PowerForgeAppleReleaseCleanupReceipt cleanup) @@ -2284,6 +2340,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( Action = PowerForgeAppleReleaseAction.Archive, Automation = new PowerForgeAppleReleaseAutomationOptions(), ReceiptPath = plan.ReceiptPath, + ReceiptHistoryPath = plan.ReceiptHistoryPath, Archive = true, Upload = false, XcodeBuildExecutable = plan.XcodeBuildExecutable, diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs index 5b15ceb11..c4cd6e2bf 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs @@ -13,6 +13,7 @@ public sealed class ReleaseBuildExecutionService : IReleaseBuildExecutionService private readonly ProjectBuildCommandHostService _projectBuildCommandHostService; private readonly ModuleBuildHostService _moduleBuildHostService; private readonly Func _executeUnifiedReleaseBuild; + private readonly Func _resolveAppleSourceCommit; public ReleaseBuildExecutionService() : this(new RepositoryCatalogScanner(), new ProjectBuildHostService(), new ProjectBuildCommandHostService(), new ModuleBuildHostService()) @@ -24,13 +25,15 @@ internal ReleaseBuildExecutionService( ProjectBuildHostService projectBuildHostService, ProjectBuildCommandHostService projectBuildCommandHostService, ModuleBuildHostService moduleBuildHostService, - Func? executeUnifiedReleaseBuild = null) + Func? executeUnifiedReleaseBuild = null, + Func? resolveAppleSourceCommit = null) { _catalogScanner = catalogScanner; _projectBuildHostService = projectBuildHostService; _projectBuildCommandHostService = projectBuildCommandHostService; _moduleBuildHostService = moduleBuildHostService; _executeUnifiedReleaseBuild = executeUnifiedReleaseBuild ?? ExecuteUnifiedReleaseBuild; + _resolveAppleSourceCommit = resolveAppleSourceCommit ?? ResolveExactGitHead; } public async Task ExecuteAsync(string repositoryRoot, CancellationToken cancellationToken = default) @@ -67,10 +70,21 @@ public async Task ExecuteAsync(string repositoryRoo configPath, PowerForgeStudioHostPaths.ResolvePSPublishModulePath(), moduleStagingPath); + if (!unifiedRequest.SkipAppleApps) + unifiedRequest.AppleSourceCommit = _resolveAppleSourceCommit(repositoryRoot); unifiedRequest.CancellationToken = cancellationToken; var unified = await Task.Run( () => _executeUnifiedReleaseBuild(configPath, unifiedRequest), cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(unifiedRequest.AppleSourceCommit)) + { + var completedSourceCommit = _resolveAppleSourceCommit(repositoryRoot); + if (!completedSourceCommit.Equals(unifiedRequest.AppleSourceCommit, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Repository HEAD changed while the Apple release checkpoint was being built. Rebuild from the new exact source commit."); + } + } var moduleExportCheckpoint = await CaptureScriptModuleExportedConfigFingerprintAsync( repository, @@ -175,6 +189,16 @@ private static PowerForgeReleaseResult ExecuteUnifiedReleaseBuild(string configP 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 (sourceCommit.Length != 40 || !sourceCommit.All(Uri.IsHexDigit)) + throw new InvalidOperationException("Apple release checkpoints require an exact 40-character repository HEAD."); + return sourceCommit.ToLowerInvariant(); + } + 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..70667d18d 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs @@ -290,16 +290,34 @@ 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, + AppleExpectedPlanSha256 = builtResult.AppleReceipt?.PlanSha256, + 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.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs index 9b51899cd..79404572f 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,7 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre Assert.False(request.PlanOnly); Assert.False(request.SkipAppleApps); Assert.True(request.CheckpointAppleApps); + Assert.Equal("0123456789abcdef0123456789abcdef01234567", request.AppleSourceCommit); return new PowerForgeReleaseResult { Success = true, @@ -558,6 +607,7 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre }, AppleAppPlan = new PowerForgeAppleReleasePlan { + SourceCommit = request.AppleSourceCommit, Archive = false, Upload = true, Apps = @@ -569,9 +619,16 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre ArchivePath = archivePath } ] + }, + AppleReceipt = new PowerForgeAppleReleaseReceipt + { + PlanOnly = true, + SourceCommit = request.AppleSourceCommit, + PlanSha256 = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" } }; - }); + }, + resolveAppleSourceCommit: _ => "0123456789abcdef0123456789abcdef01234567"); var result = await service.ExecuteAsync(repositoryRoot); @@ -582,6 +639,16 @@ 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); } [Fact] diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs index af83e9aa5..6dd130370 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs @@ -305,6 +305,46 @@ 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 + } + }, + 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.Equal("abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", request.AppleExpectedPlanSha256); + 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..c52063ea3 100644 --- a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 +++ b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 @@ -10,6 +10,37 @@ function Add-AllowedConsumerEvidencePath { $null = $script:allowedConsumerEvidencePaths.Add($full) } +function Register-AppleReceiptEvidenceFile { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][string] $SourceCommit, + [switch] $HistoryEntry + ) + 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 + $immutableReceipt = [int]$receipt.schemaVersion -ge 4 + if ($immutableReceipt) { + 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}$')) { + throw "Apple release evidence does not satisfy the immutable receipt contract: $Path" + } + } elseif (-not $receiptSource.Equals($SourceCommit, [StringComparison]::OrdinalIgnoreCase)) { + throw "Apple release receipt source commit does not match the exact consumer HEAD '$SourceCommit'." + } + Add-AllowedConsumerEvidencePath -Path $Path -Name 'Apple release receipt' +} + function Get-ForwardedArgumentList { param([Parameter(Mandatory)][string] $SourceCommit) if ($ArgumentList[0] -ne 'apple-release') { return @($ArgumentList) } @@ -109,13 +140,20 @@ function Register-AppleAutomationEvidence { } else { [string]$automation.ReceiptPath } $receiptPath = Resolve-PathFromBase -BasePath $projectRoot -Value $receiptValue 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'." + Register-AppleReceiptEvidenceFile -Path $receiptPath -SourceCommit $SourceCommit + } + + $historyValue = if ([string]::IsNullOrWhiteSpace([string]$automation.ReceiptHistoryPath)) { + 'build/powerforge/apple/receipts' + } else { [string]$automation.ReceiptHistoryPath } + $historyPath = Resolve-PathFromBase -BasePath $projectRoot -Value $historyValue + 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' + Register-AppleReceiptEvidenceFile -Path $entry.FullName -SourceCommit $SourceCommit -HistoryEntry } } From 420b54a9d41587270e9c4afb981535857005f92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 12:15:08 +0200 Subject: [PATCH 02/78] Harden Apple release integrity and source trust --- .../AppleNotarizationServiceTests.cs | 67 ++- .../AppleReleaseReceiptStoreTests.cs | 26 + ...orgeReleaseServiceTests.AppInfoMetadata.cs | 56 ++- ...rviceTests.AppleHostedReviewRegressions.cs | 345 +++++++++++++ ...eleaseServiceTests.ApplePlatformControl.cs | 11 +- .../PowerForgeReleaseServiceTests.cs | 9 +- PowerForge/Models/AppleNotarizationModels.cs | 28 ++ .../Services/AppleNotarizationService.cs | 65 ++- .../Services/AppleReleaseReceiptStore.cs | 6 + .../AppleReleaseSourceTrustService.Xcode.cs | 462 ++++++++++++++++++ .../AppleReleaseSourceTrustService.cs | 396 +++++++++++++++ ...owerForgeReleaseService.AppInfoMetadata.cs | 43 ++ ...owerForgeReleaseService.AppleAutomation.cs | 35 +- ...eReleaseService.AppleDirectDistribution.cs | 2 + ...owerForgeReleaseService.AppleDurability.cs | 52 ++ .../Services/PowerForgeReleaseService.cs | 21 +- .../Queue/ReleaseBuildExecutionService.cs | 13 +- ...dExecutionServiceTests.AppleSourceTrust.cs | 367 ++++++++++++++ ...StudioReleaseBuildExecutionServiceTests.cs | 4 +- 19 files changed, 1962 insertions(+), 46 deletions(-) create mode 100644 PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 3e9568e5e..9c2388d40 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -18,7 +18,13 @@ public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() 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); + Assert.Equal(2, runner.Requests.Count); + } }); Assert.True(result.Succeeded); @@ -72,6 +78,34 @@ public async Task NotarizeAsync_InvalidSubmissionDoesNotStapleOrAssess() } } + [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_DiskImageUsesOpenAssessmentWithPrimarySignatureContext() { @@ -163,7 +197,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); @@ -305,6 +340,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; diff --git a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs index bd7b5819d..afae163fa 100644 --- a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -54,6 +54,32 @@ public void ReadAll_RejectsTamperedImmutableReceipt() } } + [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() { 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.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs new file mode 100644 index 000000000..dbaec3fef --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -0,0 +1,345 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [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!.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!.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); + } + } + + [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 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 => + { + request.AcceptedCheckpoint!(new AppleNotarizationAcceptedCheckpoint + { + ArtifactPath = request.ArtifactPath, + ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), + SubmissionPath = request.ArtifactPath + ".zip", + SubmissionId = "accepted-before-local-crash", + Status = "Accepted" + }); + throw new InvalidOperationException("simulated process loss after Apple acceptance"); + }) + .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("Accepted", target.NotarizationStatus); + Assert.False(accepted.Success); + } + 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.Equal( + AppleNotarizationService.ComputeArtifactSha256(target.DirectArtifactPath!), + target.DirectArtifactSha256); + } + finally + { + TryDelete(root); + } + } +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs index 188d84890..60bab799f 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs @@ -855,14 +855,15 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() }); Assert.True(resumed.Success); - Assert.NotNull(resumedRequest); - Assert.Equal("accepted-then-staple-failed", resumedRequest!.AcceptedSubmissionId); - Assert.Equal( - AppleNotarizationService.ComputeArtifactSha256(resumedRequest.ArtifactPath), - 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. diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs index 4021a6790..55f05dcd1 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs @@ -598,7 +598,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 @@ -1087,7 +1088,8 @@ public void Execute_AppleApps_SubmitsTestFlightBetaReviewThroughSharedService() BuildId = "build-6" } }; - }); + }, + getAppleReleaseState: request => CreateReleaseState(request, "VALID")); var result = service.Execute( new PowerForgeReleaseSpec @@ -1251,7 +1253,8 @@ public void Execute_AppleApps_PassesScreenshotSpecToReviewReadiness() State = "WAITING_FOR_REVIEW" } }; - }); + }, + getAppleReleaseState: request => CreateReleaseState(request, "VALID")); var result = service.Execute( new PowerForgeReleaseSpec diff --git a/PowerForge/Models/AppleNotarizationModels.cs b/PowerForge/Models/AppleNotarizationModels.cs index 7d9823564..35a583cea 100644 --- a/PowerForge/Models/AppleNotarizationModels.cs +++ b/PowerForge/Models/AppleNotarizationModels.cs @@ -50,6 +50,34 @@ public sealed class AppleNotarizationRequest /// Run Gatekeeper assessment. public bool Assess { get; set; } = true; + + internal Action? AcceptedCheckpoint { get; set; } + + internal Action? StapledCheckpoint { 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 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 SubmissionId { get; set; } = string.Empty; + + internal string Status { get; set; } = "Accepted"; } /// Result of notarizing, stapling, and assessing a direct macOS artifact. diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 0640c9ef4..3dca35aa1 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -44,18 +44,21 @@ public async Task NotarizeAsync( 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 staplingCompleted = request.StaplingCompleted; var submissionPath = resumed ? artifactPath : await PrepareSubmissionAsync(request, artifactPath, timeout, cancellationToken).ConfigureAwait(false); @@ -86,16 +89,42 @@ public async Task NotarizeAsync( (submissionId, status) = ParseSubmission(submission); } + if (!resumed && + submission.Succeeded && + string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(submissionId)) + { + try + { + request.AcceptedCheckpoint?.Invoke(new AppleNotarizationAcceptedCheckpoint + { + ArtifactPath = artifactPath, + ArtifactSha256 = artifactSha256, + SubmissionPath = submissionPath, + 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); + } + } + ProcessRunResult? staple = null; ProcessRunResult? validation = null; ProcessRunResult? assessment = null; + var stapledThisInvocation = false; 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, TimeSpan.Zero, @@ -104,9 +133,31 @@ public async Task NotarizeAsync( else { staple = await RunAsync(request.XcrunExecutable, artifactPath, new[] { "stapler", "staple", artifactPath }, timeout, cancellationToken).ConfigureAwait(false); + stapledThisInvocation = staple.Succeeded; } - if (staple?.Succeeded == true) + if (staple?.Succeeded == true && validation is null) validation = await RunAsync(request.XcrunExecutable, artifactPath, new[] { "stapler", "validate", artifactPath }, timeout, cancellationToken).ConfigureAwait(false); + if (stapledThisInvocation && validation?.Succeeded == true && !string.IsNullOrWhiteSpace(submissionId)) + { + var stapledArtifactSha256 = ComputeArtifactSha256(artifactPath); + try + { + request.StapledCheckpoint?.Invoke(new AppleNotarizationStapledCheckpoint + { + ArtifactPath = artifactPath, + ArtifactSha256 = stapledArtifactSha256, + 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); + } + } } if (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Assess) { diff --git a/PowerForge/Services/AppleReleaseReceiptStore.cs b/PowerForge/Services/AppleReleaseReceiptStore.cs index 391cef5f8..fd682400d 100644 --- a/PowerForge/Services/AppleReleaseReceiptStore.cs +++ b/PowerForge/Services/AppleReleaseReceiptStore.cs @@ -225,6 +225,12 @@ private static void ValidateHistoryBinding( } 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( diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs new file mode 100644 index 000000000..e14d15600 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -0,0 +1,462 @@ +using System.Text; +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 void ValidateXcodeBuildGraph( + string repositoryRoot, + string projectRoot, + IReadOnlyCollection apps, + IReadOnlyCollection metadataPaths) + { + 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); + } + } + + 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" => Path.GetFullPath(value), + _ => throw new InvalidOperationException($"Unsupported Xcode scheme container kind '{kind}'.") + }; + } + + private void ValidateProjectGraph(string repositoryRoot, string metadataPath) + { + 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) + { + 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("XCLocalSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) + { + ValidateLocalPackageReference(repositoryRoot, projectDirectory, item); + continue; + } + + if (!IsPathBearingPbxObject(item.Isa)) + continue; + + var candidate = ResolvePbxObjectPath(projectDirectory, item.Id, objects, parents, cache, new HashSet(StringComparer.OrdinalIgnoreCase)); + if (candidate is null) + continue; + ValidateResolvedProjectInput(repositoryRoot, candidate, item, metadataPath); + } + } + + private void ValidateLocalPackageReference( + string repositoryRoot, + string projectDirectory, + PbxObject item) + { + 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"); + EnsureTrackedFile(repositoryRoot, Path.Combine(packageRoot, "Package.swift"), "Xcode local Swift package manifest"); + } + + private void ValidateResolvedProjectInput( + string repositoryRoot, + string candidate, + PbxObject item, + string metadataPath) + { + EnsurePathWithinRepository(repositoryRoot, candidate, $"Xcode {item.Isa} input"); + if (File.Exists(candidate)) + EnsureTrackedFile(repositoryRoot, candidate, $"Xcode {item.Isa} input"); + else if (Directory.Exists(candidate)) + EnsureNoLinkedTraversal(repositoryRoot, candidate, $"Xcode {item.Isa} input"); + else if (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 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(); + } + + private static Dictionary ParsePbxObjects(string text) + { + var objects = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Match start in Regex.Matches( + text, + "(?m)^[ \\t]*(?[A-Fa-f0-9]{8,32})(?:[ \\t]+/\\*.*?\\*/)?[ \\t]*=[ \\t]*\\{", + RegexOptions.CultureInvariant)) + { + var id = start.Groups["id"].Value; + var openingBrace = text.IndexOf('{', start.Index + start.Length - 1); + var closingBrace = FindMatchingPbxBrace(text, openingBrace); + var body = text.Substring(openingBrace + 1, closingBrace - openingBrace - 1); + var isa = ReadPbxScalar(body, "isa"); + if (string.IsNullOrWhiteSpace(isa)) + continue; + objects[id] = new PbxObject + { + Id = id, + Isa = isa!, + Path = ReadPbxScalar(body, "path"), + SourceTree = ReadPbxScalar(body, "sourceTree"), + Body = body + }; + } + return objects; + } + + 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) + { + var match = Regex.Match( + body, + "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + + "[ \\t]*=[ \\t]*(?:\\\"(?(?:\\\\.|[^\\\"])*)\\\"|(?[^;\\r\\n]+))[ \\t]*;", + RegexOptions.CultureInvariant); + if (!match.Success) + return null; + var value = match.Groups["quoted"].Success + ? UnescapePbxString(match.Groups["quoted"].Value) + : match.Groups["bare"].Value.Trim(); + return value; + } + + private static string[] ReadPbxReferences(string body, string name) + { + var match = Regex.Match( + body, + "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + "[ \\t]*=[ \\t]*\\((?.*?)\\)[ \\t]*;", + RegexOptions.Singleline | RegexOptions.CultureInvariant); + if (!match.Success) + return Array.Empty(); + return Regex.Matches(match.Groups["items"].Value, "(?m)^[ \\t]*(?[A-Fa-f0-9]{8,32})", RegexOptions.CultureInvariant) + .Cast() + .Select(value => value.Groups["id"].Value) + .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.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs new file mode 100644 index 000000000..254bcf99b --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -0,0 +1,396 @@ +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; + + internal AppleReleaseSourceTrustService( + HomeAssistantReleaseGitService? git = null, + GitClient? gitClient = null) + { + _git = git ?? new HomeAssistantReleaseGitService(); + _gitClient = gitClient ?? new GitClient(defaultTimeout: TimeSpan.FromMinutes(2)); + } + + internal string ResolveExactCommit(string repositoryRoot, string configPath) + { + var root = Path.GetFullPath(repositoryRoot); + var releaseConfigPath = Path.GetFullPath(configPath); + _git.EnsureClean(root); + var sourceCommitBeforeValidation = ReadExactHead(root); + EnsureTrackedFile(root, releaseConfigPath, "Apple release configuration"); + + var spec = PowerForgeReleaseService.LoadConfiguration(releaseConfigPath); + var options = spec.AppleApps + ?? throw new InvalidOperationException("The release configuration does not contain an AppleApps contract."); + ValidateAppleInputs(root, releaseConfigPath, options); + + _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 sourceCommitAfterValidation; + } + + private void ValidateAppleInputs( + string repositoryRoot, + string configPath, + PowerForgeAppleReleaseOptions 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)) + EnsureTrackedFile(repositoryRoot, ResolvePath(projectRoot, input.Path), 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); + 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); + ValidateXcodeBuildGraph(repositoryRoot, projectRoot, options.Apps ?? Array.Empty(), metadataPaths); + RejectIgnoredAppleInputs(repositoryRoot, repositoryRoot, metadataPaths); + } + + 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" => Path.GetFullPath(value), + "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) + { + 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 (!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) + { + 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", "--error-unmatch", "--", relative); + if (!tracked.Succeeded) + throw new InvalidOperationException($"{name} must be tracked at the exact source commit: {relative}"); + var unchanged = RunGitAllowFailure(repositoryRoot, "diff", "--quiet", "HEAD", "--", relative); + if (unchanged.ExitCode != 0) + throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); + } + + 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(); + if (sourceCommit.Length != 40 || !sourceCommit.All(Uri.IsHexDigit)) + throw new InvalidOperationException("Apple release checkpoints require an exact 40-character repository HEAD."); + return sourceCommit.ToLowerInvariant(); + } + + 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 StringComparer GetPathComparer() + => Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; +} diff --git a/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs b/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs index 684c9d6cd..5ad9ec195 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) { diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs index 9ccf68d8b..35f377d5b 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs @@ -183,6 +183,19 @@ 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)) @@ -376,14 +389,17 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( var remoteAction = plan.Action != PowerForgeAppleReleaseAction.Archive && plan.Action != PowerForgeAppleReleaseAction.Version && plan.Action != PowerForgeAppleReleaseAction.Cleanup; - var refreshSuccessfulMutation = HasAppleRemoteMutation(plan); + 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 && (refreshSuccessfulMutation || !result.Success || result.RemoteState is null)) + var requiresFinalReadback = plan.Action == PowerForgeAppleReleaseAction.Configured + ? refreshSuccessfulMutation + : remoteAction && (refreshSuccessfulMutation || !result.Success || result.RemoteState is null); + if (requiresFinalReadback) { try { @@ -465,7 +481,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 { @@ -631,6 +651,15 @@ private static bool HasAppleRemoteMutation(PowerForgeAppleReleasePlan plan) 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 diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs index 293a06259..a2eea2b77 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs @@ -23,6 +23,8 @@ private AppleNotarizationResult NotarizeDirectAppleExport( AcceptedSubmissionId = acceptedSubmissionId, ExpectedArtifactSha256 = expectedArtifactSha256, StaplingCompleted = staplingCompleted, + 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 diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index 94214dfc4..4134cc131 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -84,6 +84,58 @@ private void WriteAppleNotarizationAttestation( }); } + private void WriteAppleNotarizationAcceptance( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + AppleNotarizationAcceptedCheckpoint checkpoint) + { + if (!plan.Automation.WriteReceipt) + return; + + var target = CreateAppleCheckpointTarget(app); + target.DirectArtifactPath = checkpoint.ArtifactPath; + target.DirectArtifactSha256 = checkpoint.ArtifactSha256; + target.NotarizationSubmissionId = checkpoint.SubmissionId; + 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 WriteAppleNotarizationStapled( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app, + AppleNotarizationStapledCheckpoint checkpoint) + { + if (!plan.Automation.WriteReceipt) + return; + + var target = CreateAppleCheckpointTarget(app); + target.DirectArtifactPath = checkpoint.ArtifactPath; + target.DirectArtifactSha256 = checkpoint.ArtifactSha256; + target.NotarizationSubmissionId = checkpoint.SubmissionId; + 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( PowerForgeAppleAppReleaseTargetPlan app) => new() diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 16ecba3af..b9d117b27 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -800,6 +800,7 @@ dotNetSourcePathForTools is not null && if (receiptJournalReady && !request.CheckpointAppleApps && (applePlan.Action != PowerForgeAppleReleaseAction.Configured || + HasAppleExecutionMutation(applePlan) || appleResults.Any(static app => !app.Success))) result.AppleReceipt ??= CompleteAppleReleaseReceipt(applePlan, appleResults, cleanup, appleVersioning); @@ -1609,6 +1610,11 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( 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."); if (options.SyncAppInfo && appInfoConfigPath is null && appInfoConfigPaths.Length == 0) @@ -1966,18 +1972,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); @@ -2224,6 +2219,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)) diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs index c4cd6e2bf..9bddbcdc7 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs @@ -13,7 +13,7 @@ public sealed class ReleaseBuildExecutionService : IReleaseBuildExecutionService private readonly ProjectBuildCommandHostService _projectBuildCommandHostService; private readonly ModuleBuildHostService _moduleBuildHostService; private readonly Func _executeUnifiedReleaseBuild; - private readonly Func _resolveAppleSourceCommit; + private readonly Func _resolveAppleSourceCommit; public ReleaseBuildExecutionService() : this(new RepositoryCatalogScanner(), new ProjectBuildHostService(), new ProjectBuildCommandHostService(), new ModuleBuildHostService()) @@ -26,14 +26,14 @@ internal ReleaseBuildExecutionService( ProjectBuildCommandHostService projectBuildCommandHostService, ModuleBuildHostService moduleBuildHostService, Func? executeUnifiedReleaseBuild = null, - Func? resolveAppleSourceCommit = null) + Func? resolveAppleSourceCommit = null) { _catalogScanner = catalogScanner; _projectBuildHostService = projectBuildHostService; _projectBuildCommandHostService = projectBuildCommandHostService; _moduleBuildHostService = moduleBuildHostService; _executeUnifiedReleaseBuild = executeUnifiedReleaseBuild ?? ExecuteUnifiedReleaseBuild; - _resolveAppleSourceCommit = resolveAppleSourceCommit ?? ResolveExactGitHead; + _resolveAppleSourceCommit = resolveAppleSourceCommit ?? ResolveExactAppleSourceCommit; } public async Task ExecuteAsync(string repositoryRoot, CancellationToken cancellationToken = default) @@ -71,14 +71,14 @@ public async Task ExecuteAsync(string repositoryRoo PowerForgeStudioHostPaths.ResolvePSPublishModulePath(), moduleStagingPath); if (!unifiedRequest.SkipAppleApps) - unifiedRequest.AppleSourceCommit = _resolveAppleSourceCommit(repositoryRoot); + unifiedRequest.AppleSourceCommit = _resolveAppleSourceCommit(repositoryRoot, configPath); unifiedRequest.CancellationToken = cancellationToken; var unified = await Task.Run( () => _executeUnifiedReleaseBuild(configPath, unifiedRequest), cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(unifiedRequest.AppleSourceCommit)) { - var completedSourceCommit = _resolveAppleSourceCommit(repositoryRoot); + var completedSourceCommit = _resolveAppleSourceCommit(repositoryRoot, configPath); if (!completedSourceCommit.Equals(unifiedRequest.AppleSourceCommit, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( @@ -199,6 +199,9 @@ internal static string ResolveExactGitHead(string repositoryRoot) 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.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs new file mode 100644 index 000000000..eea19bf13 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -0,0 +1,367 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [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"), + """ + 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_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); + } + + 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.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs index 79404572f..8bcf4755a 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs @@ -523,7 +523,7 @@ public async Task ExecuteAsync_AppleOnlyRelease_CheckpointsPlanWithoutExecutingA } }; }, - resolveAppleSourceCommit: _ => "0123456789abcdef0123456789abcdef01234567"); + resolveAppleSourceCommit: (_, _) => "0123456789abcdef0123456789abcdef01234567"); var result = await service.ExecuteAsync(repositoryRoot); @@ -628,7 +628,7 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre } }; }, - resolveAppleSourceCommit: _ => "0123456789abcdef0123456789abcdef01234567"); + resolveAppleSourceCommit: (_, _) => "0123456789abcdef0123456789abcdef01234567"); var result = await service.ExecuteAsync(repositoryRoot); From d7c749b5b649f592329cdc6b52f9036bb5c4f0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 14:10:54 +0200 Subject: [PATCH 03/78] Close Apple release integrity gaps --- .../PowerForgeCliAppleReleaseTests.cs | 9 +- ...eleaseServiceTests.AppleAtomicExecution.cs | 59 +++ ...orgeReleaseServiceTests.AppleAutomation.cs | 142 ++++++ ...rviceTests.AppleHostedReviewRegressions.cs | 88 +++- ...aseServiceTests.AppleNotarizationResume.cs | 83 ++++ ...eleaseServiceTests.ApplePlatformControl.cs | 6 +- .../PowerForgeReleaseServiceTests.cs | 53 +- .../PowerForgeAppleReleaseAutomation.cs | 6 + PowerForge/Models/PowerForgeRelease.cs | 6 + .../Services/AppleReleaseArtifactService.cs | 17 +- .../AppleReleaseSourceTrustService.Xcode.cs | 453 ++++++++++++------ ...eReleaseSourceTrustService.XcodeParsing.cs | 303 ++++++++++++ .../AppleReleaseSourceTrustService.cs | 159 +++++- ...owerForgeReleaseService.AppleAutomation.cs | 9 +- ...PowerForgeReleaseService.AppleIntegrity.cs | 144 ++++++ .../PowerForgeReleaseService.AppleResume.cs | 61 ++- ...owerForgeReleaseService.AppleVersioning.cs | 171 ++++++- .../Services/PowerForgeReleaseService.cs | 96 +++- .../Queue/ReleaseBuildExecutionService.cs | 43 +- ...ePublishExecutionService.UnifiedRelease.cs | 7 + ...dExecutionServiceTests.AppleSourceTrust.cs | 239 +++++++++ ...StudioReleaseBuildExecutionServiceTests.cs | 6 +- ...ExecutionServiceTests.ProductGateReview.cs | 13 +- 23 files changed, 1967 insertions(+), 206 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs create mode 100644 PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs diff --git a/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs index bd91e7696..dfe3d0f09 100644 --- a/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs +++ b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs @@ -152,7 +152,14 @@ 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"); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs index 7a5e3508c..35df90666 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAtomicExecution.cs @@ -38,6 +38,10 @@ public void Execute_AppleCheckpoint_writes_publish_shaped_plan_after_archive() 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 @@ -82,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 178fa70f3..75467b44c 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -187,6 +187,43 @@ 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_AppleProtectedMutationRejectsStateChangedAfterPlanApproval() { @@ -1349,6 +1386,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", "app-123", "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.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index dbaec3fef..3eb772845 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -196,6 +196,68 @@ public void Execute_ApplePlan_RejectsEmptyReceiptHistoryPath() } } + [Theory] + [InlineData("history-equals-receipt")] + [InlineData("history-contains-plan")] + [InlineData("history-under-lock")] + [InlineData("receipt-equals-plan")] + [InlineData("history-under-archive-root")] + [InlineData("plan-overwrites-project")] + 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 "history-under-archive-root": + automation.ReceiptHistoryPath = "build/powerforge/apple/archives/receipts"; + break; + case "plan-overwrites-project": + automation.PlanReceiptPath = "CasaRay.xcodeproj/project.pbxproj"; + 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); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_DirectNotarizationCrash_PersistsAcceptedSubmissionBeforeLocalPostProcessing() { @@ -216,7 +278,7 @@ public void Execute_DirectNotarizationCrash_PersistsAcceptedSubmissionBeforeLoca app.DistributionRoute = AppleDistributionRoute.DirectNotarized; app.AppStoreConnectAppId = null; - var result = CreateAppleAutomationService( + var service = CreateAppleAutomationService( _ => throw new InvalidOperationException("Direct distribution must not query App Store release state."), archiveAppleApp: CreateSuccessfulArchive, uploadAppleApp: request => @@ -238,8 +300,8 @@ public void Execute_DirectNotarizationCrash_PersistsAcceptedSubmissionBeforeLoca Status = "Accepted" }); throw new InvalidOperationException("simulated process loss after Apple acceptance"); - }) - .Execute( + }); + var result = service.Execute( spec, new PowerForgeReleaseRequest { @@ -257,6 +319,26 @@ public void Execute_DirectNotarizationCrash_PersistsAcceptedSubmissionBeforeLoca Assert.Equal("accepted-before-local-crash", target.NotarizationSubmissionId); Assert.Equal("Accepted", target.NotarizationStatus); Assert.False(accepted.Success); + + var protectedArtifact = Assert.IsType(target.DirectArtifactPath); + 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 { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 73ca3405d..dca7e8bcc 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -4,6 +4,89 @@ namespace PowerForge.Tests; public sealed partial class PowerForgeReleaseServiceTests { + [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() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs index 60bab799f..2c907aa90 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); } } diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs index 55f05dcd1..40e2de816 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs @@ -1005,7 +1005,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 { @@ -1148,7 +1148,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 { @@ -1310,7 +1310,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 { @@ -3525,6 +3525,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() { diff --git a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index 411413a0c..302a8c40a 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -160,6 +160,12 @@ internal sealed class PowerForgeAppleReleaseReceipt /// 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; } diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs index 066bea063..cded1c592 100644 --- a/PowerForge/Models/PowerForgeRelease.cs +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -226,6 +226,9 @@ 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 adopt a matching remote build when no verified local upload attestation exists. @@ -649,6 +652,9 @@ 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; } + public string? TeamId { get; set; } public bool Upload { get; set; } diff --git a/PowerForge/Services/AppleReleaseArtifactService.cs b/PowerForge/Services/AppleReleaseArtifactService.cs index e0810e9b9..84933c263 100644 --- a/PowerForge/Services/AppleReleaseArtifactService.cs +++ b/PowerForge/Services/AppleReleaseArtifactService.cs @@ -25,12 +25,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); @@ -61,13 +63,22 @@ internal PowerForgeAppleReleaseCleanupReceipt RemoveStaleArtifacts( var candidates = roots .Where(Directory.Exists) .SelectMany(static root => Directory.EnumerateFileSystemEntries(root)) - .Where(path => !protectedFullPaths.Contains(Path.GetFullPath(path))) + .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); + return left.Equals(right, PathComparison) || + left.StartsWith(right + Path.DirectorySeparatorChar, PathComparison) || + right.StartsWith(left + Path.DirectorySeparatorChar, PathComparison); + } + internal PowerForgeAppleReleaseCleanupReceipt RemoveCurrentArtifacts( PowerForgeAppleReleasePlan plan, IEnumerable? apps = null) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index e14d15600..3dd639e25 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -1,4 +1,3 @@ -using System.Text; using System.Text.RegularExpressions; using System.Xml.Linq; @@ -11,11 +10,41 @@ internal sealed partial class AppleReleaseSourceTrustService "BUILT_PRODUCTS_DIR", "SDKROOT", "DEVELOPER_DIR" }; + private static readonly HashSet FileValuedBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "INFOPLIST_FILE", + "CODE_SIGN_ENTITLEMENTS", + "SWIFT_OBJC_BRIDGING_HEADER", + "GCC_PREFIX_HEADER", + "CLANG_PREFIX_HEADER", + "MODULEMAP_FILE", + "DEVELOPMENT_ASSET_PATHS" + }; + + private static readonly HashSet SearchPathBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "HEADER_SEARCH_PATHS", + "USER_HEADER_SEARCH_PATHS", + "SYSTEM_HEADER_SEARCH_PATHS", + "FRAMEWORK_SEARCH_PATHS", + "LIBRARY_SEARCH_PATHS", + "SWIFT_INCLUDE_PATHS" + }; + + private static readonly HashSet FlagBuildSettings = new(StringComparer.OrdinalIgnoreCase) + { + "OTHER_CFLAGS", + "OTHER_CPLUSPLUSFLAGS", + "OTHER_LDFLAGS", + "OTHER_SWIFT_FLAGS" + }; + private void ValidateXcodeBuildGraph( string repositoryRoot, string projectRoot, IReadOnlyCollection apps, - IReadOnlyCollection metadataPaths) + IReadOnlyCollection metadataPaths, + IReadOnlyCollection generatedOutputPaths) { foreach (var app in apps.Where(static value => value.Enabled && !string.IsNullOrWhiteSpace(value.ProjectPath))) EnsureTrackedSharedScheme(repositoryRoot, projectRoot, app, metadataPaths); @@ -23,7 +52,7 @@ private void ValidateXcodeBuildGraph( foreach (var metadataPath in metadataPaths.Where(path => path.EndsWith("project.pbxproj", StringComparison.OrdinalIgnoreCase) && File.Exists(path))) { - ValidateProjectGraph(repositoryRoot, metadataPath); + ValidateProjectGraph(repositoryRoot, metadataPath, generatedOutputPaths); } } @@ -141,11 +170,20 @@ private static string ResolveSchemeContainer(string reference, string containerR }; } - private void ValidateProjectGraph(string repositoryRoot, string metadataPath) + private void ValidateProjectGraph( + string repositoryRoot, + string metadataPath, + IReadOnlyCollection generatedOutputPaths) { var projectDirectory = Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!; 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 cache = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in objects.Values) @@ -156,19 +194,137 @@ private void ValidateProjectGraph(string repositoryRoot, string metadataPath) $"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("XCLocalSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) { ValidateLocalPackageReference(repositoryRoot, projectDirectory, item); continue; } + if (item.Isa.Equals("XCRemoteSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) + { + ValidateRemotePackageReference(repositoryRoot, projectDirectory, item); + continue; + } + + if (item.Isa.Equals("XCBuildConfiguration", StringComparison.OrdinalIgnoreCase)) + { + ValidateBuildConfiguration( + repositoryRoot, + projectDirectory, + item, + objects, + parents, + cache, + metadataPath, + generatedOutputPaths); + continue; + } + if (!IsPathBearingPbxObject(item.Isa)) continue; var candidate = ResolvePbxObjectPath(projectDirectory, item.Id, objects, parents, cache, new HashSet(StringComparer.OrdinalIgnoreCase)); if (candidate is null) continue; - ValidateResolvedProjectInput(repositoryRoot, candidate, item, metadataPath); + ValidateResolvedProjectInput( + repositoryRoot, + candidate, + item, + metadataPath, + generatedOutputPaths, + buildFileReferences); + } + } + + private void ValidateBuildConfiguration( + string repositoryRoot, + string projectDirectory, + PbxObject item, + IReadOnlyDictionary objects, + IReadOnlyDictionary parents, + IDictionary cache, + string metadataPath, + IReadOnlyCollection generatedOutputPaths) + { + 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"); + EnsureTrackedXcconfigGraph( + repositoryRoot, + projectDirectory, + baseConfigurationPath, + generatedOutputPaths, + new HashSet(GetPathComparer())); + } + + var buildSettings = ReadPbxDictionary(item.Body, "buildSettings"); + if (buildSettings is null) + return; + ValidateBuildSettingAssignments( + repositoryRoot, + projectDirectory, + ReadPbxAssignments(buildSettings), + generatedOutputPaths, + "PBX build settings"); + } + + private void EnsureTrackedXcconfigGraph( + string repositoryRoot, + string projectDirectory, + string configPath, + IReadOnlyCollection generatedOutputPaths, + ISet visited) + { + var fullPath = Path.GetFullPath(configPath); + if (!visited.Add(fullPath)) + return; + var contents = File.ReadAllText(fullPath); + ValidateBuildSettingAssignments( + repositoryRoot, + projectDirectory, + ReadXcconfigAssignments(contents), + generatedOutputPaths, + $"xcconfig '{fullPath}'"); + 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)) + { + var directive = include.Groups["optional"].Success ? "optional " : string.Empty; + throw new FileNotFoundException( + $"Xcode {directive}xcconfig include cannot be proven at the exact source commit: {includedPath}", + includedPath); + } + EnsureTrackedFile(repositoryRoot, includedPath, "Xcode xcconfig include"); + EnsureTrackedXcconfigGraph(repositoryRoot, projectDirectory, includedPath, generatedOutputPaths, visited); } } @@ -182,20 +338,110 @@ private void ValidateLocalPackageReference( 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"); - EnsureTrackedFile(repositoryRoot, Path.Combine(packageRoot, "Package.swift"), "Xcode local Swift package manifest"); + var manifestPath = Path.Combine(packageRoot, "Package.swift"); + 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"); + } + + var manifest = File.ReadAllText(manifestPath); + foreach (Match match in Regex.Matches( + manifest, + "\\bpath\\s*:\\s*\"(?[^\"]+)\"", + RegexOptions.CultureInvariant)) + { + var explicitPath = ResolvePbxPath(packageRoot, match.Groups["path"].Value, "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 ValidateRemotePackageReference( + string repositoryRoot, + string projectDirectory, + PbxObject item) + { + var repositoryUrl = ReadPbxScalar(item.Body, "repositoryURL")?.Trim(); + if (string.IsNullOrWhiteSpace(repositoryUrl)) + throw new InvalidOperationException("Remote Swift package reference is missing repositoryURL."); + + var exactRevision = Regex.IsMatch( + item.Body, + "(?s)requirement\\s*=\\s*\\{.*?kind\\s*=\\s*revision\\s*;.*?revision\\s*=\\s*[\"']?[A-Fa-f0-9]{40}[\"']?\\s*;", + RegexOptions.CultureInvariant); + var identity = Path.GetFileNameWithoutExtension(repositoryUrl!.TrimEnd('/')); + var locks = RunGit(repositoryRoot, "ls-files", "-z") + .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) + .Where(static path => Path.GetFileName(path).Equals("Package.resolved", StringComparison.OrdinalIgnoreCase)) + .Select(path => Path.GetFullPath(Path.Combine(repositoryRoot, path))) + .Where(path => IsPathAtOrWithin(path, projectDirectory)) + .Where(path => + { + var text = File.ReadAllText(path); + return text.Contains(repositoryUrl, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(identity) && text.Contains(identity, StringComparison.OrdinalIgnoreCase)); + }) + .ToArray(); + if (locks.Length == 0 && !exactRevision) + { + throw new InvalidOperationException( + $"Remote Swift package '{repositoryUrl}' must be bound by a tracked Package.resolved lock or an exact 40-character revision."); + } + foreach (var packageLock in locks) + EnsureTrackedFile(repositoryRoot, packageLock, "Swift package resolution lock"); } private void ValidateResolvedProjectInput( string repositoryRoot, string candidate, PbxObject item, - string metadataPath) + string metadataPath, + IReadOnlyCollection generatedOutputPaths, + ISet buildFileReferences) { 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"); + } if (File.Exists(candidate)) EnsureTrackedFile(repositoryRoot, candidate, $"Xcode {item.Isa} input"); else if (Directory.Exists(candidate)) - EnsureNoLinkedTraversal(repositoryRoot, candidate, $"Xcode {item.Isa} input"); + { + if (directoryFileReferenceIsBuilt || + item.Isa.Equals("XCVersionGroup", StringComparison.OrdinalIgnoreCase) || + item.Isa.Equals("PBXFileSystemSynchronizedRootGroup", StringComparison.OrdinalIgnoreCase)) + { + EnsureTrackedDirectoryTree(repositoryRoot, candidate, $"Xcode {item.Isa} input"); + } + else + { + EnsureNoLinkedTraversal(repositoryRoot, candidate, $"Xcode {item.Isa} input"); + } + } else if (Path.IsPathRooted(item.Path ?? string.Empty) || (item.Path ?? string.Empty).Split('/', '\\').Any(segment => segment == "..")) { @@ -205,6 +451,56 @@ private void ValidateResolvedProjectInput( } } + private void EnsureTrackedDirectoryTree(string repositoryRoot, string path, string name) + { + EnsureDirectoryWithinRepository(repositoryRoot, path, name); + var relativeRoot = FrameworkCompatibility.GetRelativePath(repositoryRoot, path).Replace('\\', '/'); + var tracked = RunGit(repositoryRoot, "ls-files", "-z", "--", relativeRoot) + .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) + .Select(entry => Path.GetFullPath(Path.Combine(repositoryRoot, entry))) + .ToHashSet(GetPathComparer()); + 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('\\', '/')); + } + } + } + + private static string ResolveBuildSettingPath(string projectDirectory, string value, string key) + { + var expanded = value.Trim(); + 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) || @@ -318,145 +614,4 @@ private static string[] ResolveObjectAwareSynchronizedRoots(IEnumerable return roots.ToArray(); } - private static Dictionary ParsePbxObjects(string text) - { - var objects = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (Match start in Regex.Matches( - text, - "(?m)^[ \\t]*(?[A-Fa-f0-9]{8,32})(?:[ \\t]+/\\*.*?\\*/)?[ \\t]*=[ \\t]*\\{", - RegexOptions.CultureInvariant)) - { - var id = start.Groups["id"].Value; - var openingBrace = text.IndexOf('{', start.Index + start.Length - 1); - var closingBrace = FindMatchingPbxBrace(text, openingBrace); - var body = text.Substring(openingBrace + 1, closingBrace - openingBrace - 1); - var isa = ReadPbxScalar(body, "isa"); - if (string.IsNullOrWhiteSpace(isa)) - continue; - objects[id] = new PbxObject - { - Id = id, - Isa = isa!, - Path = ReadPbxScalar(body, "path"), - SourceTree = ReadPbxScalar(body, "sourceTree"), - Body = body - }; - } - return objects; - } - - 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) - { - var match = Regex.Match( - body, - "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + - "[ \\t]*=[ \\t]*(?:\\\"(?(?:\\\\.|[^\\\"])*)\\\"|(?[^;\\r\\n]+))[ \\t]*;", - RegexOptions.CultureInvariant); - if (!match.Success) - return null; - var value = match.Groups["quoted"].Success - ? UnescapePbxString(match.Groups["quoted"].Value) - : match.Groups["bare"].Value.Trim(); - return value; - } - - private static string[] ReadPbxReferences(string body, string name) - { - var match = Regex.Match( - body, - "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + "[ \\t]*=[ \\t]*\\((?.*?)\\)[ \\t]*;", - RegexOptions.Singleline | RegexOptions.CultureInvariant); - if (!match.Success) - return Array.Empty(); - return Regex.Matches(match.Groups["items"].Value, "(?m)^[ \\t]*(?[A-Fa-f0-9]{8,32})", RegexOptions.CultureInvariant) - .Cast() - .Select(value => value.Groups["id"].Value) - .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.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs new file mode 100644 index 000000000..8333e54fc --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -0,0 +1,303 @@ +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) + { + foreach (var assignment in assignments) + { + var key = assignment.Key.Trim(); + var baseKey = key.Split('[')[0].Trim(); + 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)) + { + values = ExtractBuildFlagInputPaths(assignment.Value, key); + } + else + { + 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) || IsToolchainOrBuildProductPath(value)) + continue; + var candidate = ResolveBuildSettingPath(projectDirectory, value, key); + EnsurePathWithinRepository(repositoryRoot, candidate, $"Xcode build setting {key} from {source}"); + EnsureNoGeneratedOutputOverlap(candidate, generatedOutputPaths, $"Xcode build setting {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); + } + } + } + + 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 IEnumerable ExtractBuildFlagInputPaths(string value, string key) + { + var tokens = SplitBuildSettingPaths(value); + var consumeNext = false; + for (var index = 0; index < tokens.Length; index++) + { + var token = tokens[index]; + if (consumeNext) + { + consumeNext = false; + yield return token; + continue; + } + + if (token.Equals("-I", StringComparison.Ordinal) || + token.Equals("-F", StringComparison.Ordinal) || + token.Equals("-L", StringComparison.Ordinal) || + token.Equals("-include", StringComparison.Ordinal) || + token.Equals("-force_load", StringComparison.Ordinal) || + token.Equals("-filelist", StringComparison.Ordinal) || + token.Equals("-ivfsoverlay", StringComparison.Ordinal)) + { + consumeNext = true; + continue; + } + + var prefixes = new[] { "-I", "-F", "-L", "-fmodule-map-file=" }; + var prefix = prefixes.FirstOrDefault(candidate => + token.StartsWith(candidate, StringComparison.Ordinal) && token.Length > candidate.Length); + if (prefix is not null) + { + yield return token.Substring(prefix.Length); + continue; + } + + if (!token.StartsWith("-", StringComparison.Ordinal) && + (Path.IsPathRooted(token) || token.Contains('/') || token.Contains('\\') || + token.Contains("$(", StringComparison.Ordinal) || token.Contains("${", StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"Path-like token in Xcode build setting {key} cannot be classified safely: {token}"); + } + } + if (consumeNext) + throw new InvalidOperationException($"Xcode build setting {key} ends with a path-consuming flag and no input."); + } + + private static bool IsToolchainOrBuildProductPath(string value) + { + var known = new[] + { + "$(SDKROOT)", "${SDKROOT}", + "$(DEVELOPER_DIR)", "${DEVELOPER_DIR}", + "$(TOOLCHAIN_DIR)", "${TOOLCHAIN_DIR}", + "$(BUILT_PRODUCTS_DIR)", "${BUILT_PRODUCTS_DIR}", + "$(CONFIGURATION_BUILD_DIR)", "${CONFIGURATION_BUILD_DIR}", + "$(TARGET_BUILD_DIR)", "${TARGET_BUILD_DIR}" + }; + return known.Any(prefix => value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + + private static Dictionary ParsePbxObjects(string text) + { + var objects = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Match start in Regex.Matches( + text, + "(?m)^[ \\t]*(?[A-Fa-f0-9]{8,32})(?:[ \\t]+/\\*.*?\\*/)?[ \\t]*=[ \\t]*\\{", + RegexOptions.CultureInvariant)) + { + var id = start.Groups["id"].Value; + var openingBrace = text.IndexOf('{', start.Index + start.Length - 1); + var closingBrace = FindMatchingPbxBrace(text, openingBrace); + var body = text.Substring(openingBrace + 1, closingBrace - openingBrace - 1); + var isa = ReadPbxScalar(body, "isa"); + if (string.IsNullOrWhiteSpace(isa)) + continue; + objects[id] = new PbxObject + { + Id = id, + Isa = isa!, + Path = ReadPbxScalar(body, "path"), + SourceTree = ReadPbxScalar(body, "sourceTree"), + Body = body + }; + } + return objects; + } + + 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) + { + var match = Regex.Match( + body, + "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + + "[ \\t]*=[ \\t]*(?:\\\"(?(?:\\\\.|[^\\\"])*)\\\"|(?[^;\\r\\n]+))[ \\t]*;", + RegexOptions.CultureInvariant); + if (!match.Success) + return null; + return match.Groups["quoted"].Success + ? UnescapePbxString(match.Groups["quoted"].Value) + : match.Groups["bare"].Value.Trim(); + } + + private static string? ReadPbxDictionary(string body, string name) + { + var match = Regex.Match( + body, + "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + "[ \\t]*=[ \\t]*\\{", + RegexOptions.CultureInvariant); + if (!match.Success) + return null; + var openingBrace = body.IndexOf('{', match.Index + match.Length - 1); + var closingBrace = FindMatchingPbxBrace(body, openingBrace); + return body.Substring(openingBrace + 1, closingBrace - openingBrace - 1); + } + + private static IEnumerable> ReadPbxAssignments(string body) + { + foreach (Match match in Regex.Matches( + body, + "(?m)^[ \\t]*(?:\\\"(?(?:\\\\.|[^\\\"])*)\\\"|(?[^=\\r\\n]+?))[ \\t]*=[ \\t]*(?\\([^;]*?\\)|\\\"(?:\\\\.|[^\\\"])*\\\"|[^;\\r\\n]+)[ \\t]*;", + RegexOptions.CultureInvariant)) + { + var key = match.Groups["quotedKey"].Success + ? UnescapePbxString(match.Groups["quotedKey"].Value) + : match.Groups["bareKey"].Value.Trim(); + var value = match.Groups["value"].Value.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 match = Regex.Match( + body, + "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + "[ \\t]*=[ \\t]*\\((?.*?)\\)[ \\t]*;", + RegexOptions.Singleline | RegexOptions.CultureInvariant); + if (!match.Success) + return Array.Empty(); + return Regex.Matches(match.Groups["items"].Value, "(?m)^[ \\t]*(?[A-Fa-f0-9]{8,32})", RegexOptions.CultureInvariant) + .Cast() + .Select(value => value.Groups["id"].Value) + .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.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 254bcf99b..4a2dea434 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -31,6 +31,9 @@ internal AppleReleaseSourceTrustService( } internal string ResolveExactCommit(string repositoryRoot, string configPath) + => Capture(repositoryRoot, configPath).SourceCommit; + + internal AppleReleaseSourceTrustSnapshot Capture(string repositoryRoot, string configPath) { var root = Path.GetFullPath(repositoryRoot); var releaseConfigPath = Path.GetFullPath(configPath); @@ -41,7 +44,8 @@ internal string ResolveExactCommit(string repositoryRoot, string configPath) var spec = PowerForgeReleaseService.LoadConfiguration(releaseConfigPath); var options = spec.AppleApps ?? throw new InvalidOperationException("The release configuration does not contain an AppleApps contract."); - ValidateAppleInputs(root, releaseConfigPath, options); + var generatedOutputs = ResolveGeneratedOutputPaths(releaseConfigPath, options); + ValidateAppleInputs(root, releaseConfigPath, options, generatedOutputs); _git.EnsureClean(root); var sourceCommitAfterValidation = ReadExactHead(root); @@ -50,13 +54,50 @@ internal string ResolveExactCommit(string repositoryRoot, string configPath) throw new InvalidOperationException( "Repository HEAD changed while Apple release inputs were being validated. Rebuild from the new exact source commit."); } - return sourceCommitAfterValidation; + return new AppleReleaseSourceTrustSnapshot(sourceCommitAfterValidation, generatedOutputs); + } + + internal void ValidateAfterBuild( + string repositoryRoot, + string configPath, + AppleReleaseSourceTrustSnapshot snapshot) + { + if (snapshot is null) + throw new ArgumentNullException(nameof(snapshot)); + + var root = Path.GetFullPath(repositoryRoot); + var releaseConfigPath = Path.GetFullPath(configPath); + 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.LoadConfiguration(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); + 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 ValidateAppleInputs( string repositoryRoot, string configPath, - PowerForgeAppleReleaseOptions options) + PowerForgeAppleReleaseOptions options, + IReadOnlyCollection generatedOutputPaths) { var configDirectory = Path.GetDirectoryName(configPath) ?? repositoryRoot; var projectRoot = ResolvePath( @@ -65,7 +106,11 @@ private void ValidateAppleInputs( EnsureDirectoryWithinRepository(repositoryRoot, projectRoot, "AppleApps.ProjectRoot"); foreach (var input in EnumerateConfiguredInputs(options)) - EnsureTrackedFile(repositoryRoot, ResolvePath(projectRoot, input.Path), input.Name); + { + 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()) @@ -81,6 +126,7 @@ private void ValidateAppleInputs( } var configuredProjectPath = ResolvePath(projectRoot, app.ProjectPath); + EnsureNoGeneratedOutputOverlap(configuredProjectPath, generatedOutputPaths, "AppleApps.Apps.ProjectPath"); EnsurePathWithinRepository(repositoryRoot, configuredProjectPath, "AppleApps.Apps.ProjectPath"); if (File.Exists(configuredProjectPath)) { @@ -105,10 +151,93 @@ private void ValidateAppleInputs( } AddReferencedWorkspaceProjects(repositoryRoot, metadataPaths); - ValidateXcodeBuildGraph(repositoryRoot, projectRoot, options.Apps ?? Array.Empty(), metadataPaths); - RejectIgnoredAppleInputs(repositoryRoot, repositoryRoot, metadataPaths); + 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(); + return new[] + { + ResolvePath(projectRoot, string.IsNullOrWhiteSpace(options.ArchiveRoot) + ? Path.Combine("Artifacts", "Apple", "Archives") + : options.ArchiveRoot!), + ResolvePath(projectRoot, string.IsNullOrWhiteSpace(options.ExportRoot) + ? Path.Combine("Artifacts", "Apple", "Exports") + : options.ExportRoot!), + 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) { @@ -218,7 +347,8 @@ private static string ResolveWorkspaceLocation(string location, string workspace private void RejectIgnoredAppleInputs( string repositoryRoot, string projectRoot, - IReadOnlyCollection metadataPaths) + IReadOnlyCollection metadataPaths, + IReadOnlyCollection generatedOutputPaths) { var metadata = metadataPaths .Where(File.Exists) @@ -240,6 +370,8 @@ private void RejectIgnoredAppleInputs( 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; @@ -394,3 +526,16 @@ private ProcessRunResult RunGitAllowFailure(string repositoryRoot, params string private static StringComparer GetPathComparer() => Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; } + +internal sealed class AppleReleaseSourceTrustSnapshot +{ + internal AppleReleaseSourceTrustSnapshot(string sourceCommit, string[] generatedOutputPaths) + { + SourceCommit = sourceCommit; + GeneratedOutputPaths = generatedOutputPaths; + } + + internal string SourceCommit { get; } + + internal string[] GeneratedOutputPaths { get; } +} diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs index 35f377d5b..6d4aa7416 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs @@ -437,10 +437,11 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( _appleArtifactService.RemoveStaleArtifacts( plan, results.SelectMany(static result => new[] - { - result.Plan.ArchivePath, - result.Plan.ExportPath - }))); + { + result.Plan.ArchivePath, + result.Plan.ExportPath + }) + .Concat(GetProtectedAppleRecoveryArtifactPaths(plan)))); } catch (Exception exception) { diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs new file mode 100644 index 000000000..4a40028aa --- /dev/null +++ b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs @@ -0,0 +1,144 @@ +namespace PowerForge; + +internal sealed partial class PowerForgeReleaseService +{ + private static void ValidateAppleAutomationOutputPaths( + string receiptPath, + string receiptHistoryPath, + string planReceiptPath, + string lockPath, + IEnumerable<(string Name, string Path, bool IsDirectory)> protectedPaths) + { + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + var comparer = Path.DirectorySeparatorChar == '\\' + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + 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)) + }; + + var duplicate = files + .GroupBy(static entry => entry.Path, comparer) + .FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException( + $"Apple automation output files must use distinct paths: {string.Join(", ", duplicate.Select(static entry => entry.Name))}."); + } + + foreach (var file in files) + { + 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 protectedPaths.Where(static entry => !string.IsNullOrWhiteSpace(entry.Path))) + { + 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 bool PathsOverlap(string first, string second) + { + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + 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 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); + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!artifactPath.Equals(exportRoot, comparison) && + !artifactPath.StartsWith(exportRoot + Path.DirectorySeparatorChar, comparison)) + { + 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 comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + var root = Path.GetFullPath(projectRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var current = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!current.Equals(root, comparison) && + !current.StartsWith(root + Path.DirectorySeparatorChar, comparison)) + 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 index f8375d75b..a38ba1279 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -2,6 +2,59 @@ 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 || + (target.Stapled == true && target.StapleValidated == true); + 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 || 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, @@ -166,6 +219,7 @@ private bool TryResumeDirectAppleNotarization( var prior = _appleReceiptStore.ReadAll(plan) .Where(receipt => !receipt.PlanOnly && + !string.IsNullOrWhiteSpace(receipt.ReceiptSha256) && !string.IsNullOrWhiteSpace(plan.SourceCommit) && string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) .SelectMany(receipt => receipt.Targets.Where(target => IsMatchingDirectReceiptTarget(target, app))) @@ -173,12 +227,13 @@ private bool TryResumeDirectAppleNotarization( string.Equals(target.NotarizationStatus, "Accepted", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(target.NotarizationSubmissionId) && !string.IsNullOrWhiteSpace(target.DirectArtifactPath) && - IsSha256(target.DirectArtifactSha256) && - (File.Exists(target.DirectArtifactPath) || Directory.Exists(target.DirectArtifactPath))); + IsSha256(target.DirectArtifactSha256)); if (prior is null) return false; - var artifactPath = Path.GetFullPath(prior.DirectArtifactPath!); + var artifactPath = ValidateDirectRecoveryArtifactPath(plan, app, prior.DirectArtifactPath!); + if (!File.Exists(artifactPath) && !Directory.Exists(artifactPath)) + return false; var stapleCompleted = !plan.DirectDistribution.Staple || (prior.Stapled == true && prior.StapleValidated == true); var assessmentCompleted = !plan.DirectDistribution.Assess || diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index 574a771fb..132c4d179 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -25,19 +25,31 @@ private void AssertApplePlanStillApproved( } } - private PowerForgeAppleReleaseReceipt CreateApplePlanReceipt(PowerForgeAppleReleasePlan plan) + 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.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, @@ -47,6 +59,8 @@ private PowerForgeAppleReleaseReceipt CreateApplePlanReceipt(PowerForgeAppleRele 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." } @@ -78,12 +92,13 @@ 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, 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; } @@ -105,8 +120,8 @@ PowerForgeAppleReleaseAction.SubmitAppReview or target.AppReviewSubmissionId = reviewSubmission?.Id; target.AppReviewState = reviewSubmission?.State; target.NextActions = platform.NextActions; - if (plan.Action == PowerForgeAppleReleaseAction.SubmitAppReview && - !plan.SkipReviewReadinessCheck) + if (plan.CheckReleaseReadiness || + (plan.SubmitForReview && !plan.SkipReviewReadinessCheck)) { var values = ResolveAppleDistributionValues(app, versionUpdate: null); var matchingScreenshotSpec = ResolveMatchingScreenshotSpec( @@ -144,6 +159,24 @@ PowerForgeAppleReleaseAction.SubmitAppReview or return target; } + private static bool RequiresObservedApplePlanState( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app) + { + if (!UsesAppStoreConnect(app) || !ShouldExecuteAppleTarget(plan.Action, app)) + return false; + if (plan.Action is PowerForgeAppleReleaseAction.SubmitTestFlightReview or + PowerForgeAppleReleaseAction.SubmitAppReview or + PowerForgeAppleReleaseAction.Release) + { + return true; + } + + return plan.SubmitTestFlightBetaReview || + plan.SubmitForReview || + plan.ReleaseApprovedVersion; + } + private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt receipt) { var canonical = new @@ -153,6 +186,10 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei 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, @@ -164,6 +201,122 @@ 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; + 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.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() + }; + 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(); diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index b9d117b27..98d54502c 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -752,6 +752,7 @@ dotNetSourcePathForTools is not null && var receiptJournalReady = !applePlan.Automation.WriteReceipt; try { + VerifyExpectedAppleCheckpointArchives(applePlan); AssertApplePlanStillApproved(applePlan, request.AppleExpectedPlanSha256); PrepareAppleReceiptJournalForMutation(applePlan, request.AppleExpectedPlanSha256); receiptJournalReady = true; @@ -766,7 +767,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 { @@ -796,7 +799,7 @@ dotNetSourcePathForTools is not null && } result.AppleApps = appleResults; if (request.CheckpointAppleApps && appleResults.All(static app => app.Success)) - result.AppleReceipt = CreateApplePlanReceipt(applePlan); + result.AppleReceipt = CreateApplePlanReceipt(applePlan, appleResults); if (receiptJournalReady && !request.CheckpointAppleApps && (applePlan.Action != PowerForgeAppleReleaseAction.Configured || @@ -967,6 +970,9 @@ internal PowerForgeReleaseResult PublishBuiltReleaseOutputs( AppleMarketingVersion = request.AppleMarketingVersion, AppleSourceCommit = request.AppleSourceCommit, AppleExpectedPlanSha256 = request.AppleExpectedPlanSha256, + AppleExpectedArchiveSha256ByTarget = new Dictionary( + request.AppleExpectedArchiveSha256ByTarget, + StringComparer.OrdinalIgnoreCase), AppleActionConfirmed = request.AppleActionConfirmed, AppleAdoptExistingBuild = request.AppleAdoptExistingBuild, AppleResume = request.AppleResume, @@ -1537,6 +1543,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 @@ -1557,6 +1589,34 @@ 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))); + 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 || @@ -2084,7 +2144,9 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( { if (!preflightCompleted) { - cleanup = _appleArtifactService.Preflight(plan); + cleanup = _appleArtifactService.Preflight( + plan, + GetProtectedAppleRecoveryArtifactPaths(plan)); preflightCompleted = true; } } @@ -2126,6 +2188,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( if (plan.Upload && result.Success && !resumedUpload) { CaptureAppleArchiveSha256(result, app); + var approvedArchiveSha256 = result.ArchiveSha256; var direct = app.DistributionRoute == AppleDistributionRoute.DirectNotarized; var upload = _uploadAppleApp(new AppleAppArchiveUploadRequest { @@ -2147,6 +2210,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( AllowProvisioningUpdates = plan.AllowProvisioningUpdates }); result.Upload = upload; + CaptureAppleArchiveSha256(result, app, approvedArchiveSha256); if (!upload.Succeeded) { result.Success = false; @@ -2317,13 +2381,21 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( private static void CaptureAppleArchiveSha256( PowerForgeAppleAppReleaseResult result, - PowerForgeAppleAppReleaseTargetPlan app) + PowerForgeAppleAppReleaseTargetPlan app, + string? requiredSha256 = null) { - if (!string.IsNullOrWhiteSpace(result.ArchiveSha256)) - return; if (!File.Exists(app.ArchivePath) && !Directory.Exists(app.ArchivePath)) return; - result.ArchiveSha256 = AppleNotarizationService.ComputeArtifactSha256(app.ArchivePath); + 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; } private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( @@ -2353,7 +2425,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; } diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs index 9bddbcdc7..1c654e34b 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs @@ -13,7 +13,8 @@ public sealed class ReleaseBuildExecutionService : IReleaseBuildExecutionService private readonly ProjectBuildCommandHostService _projectBuildCommandHostService; private readonly ModuleBuildHostService _moduleBuildHostService; private readonly Func _executeUnifiedReleaseBuild; - private readonly Func _resolveAppleSourceCommit; + private readonly Func _captureAppleSourceTrust; + private readonly Action _validateAppleSourceTrustAfterBuild; public ReleaseBuildExecutionService() : this(new RepositoryCatalogScanner(), new ProjectBuildHostService(), new ProjectBuildCommandHostService(), new ModuleBuildHostService()) @@ -26,14 +27,33 @@ internal ReleaseBuildExecutionService( ProjectBuildCommandHostService projectBuildCommandHostService, ModuleBuildHostService moduleBuildHostService, Func? executeUnifiedReleaseBuild = null, - Func? resolveAppleSourceCommit = null) + Func? resolveAppleSourceCommit = null, + Func? captureAppleSourceTrust = null, + Action? validateAppleSourceTrustAfterBuild = null) { _catalogScanner = catalogScanner; _projectBuildHostService = projectBuildHostService; _projectBuildCommandHostService = projectBuildCommandHostService; _moduleBuildHostService = moduleBuildHostService; _executeUnifiedReleaseBuild = executeUnifiedReleaseBuild ?? ExecuteUnifiedReleaseBuild; - _resolveAppleSourceCommit = resolveAppleSourceCommit ?? ResolveExactAppleSourceCommit; + 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) @@ -70,21 +90,18 @@ public async Task ExecuteAsync(string repositoryRoo configPath, PowerForgeStudioHostPaths.ResolvePSPublishModulePath(), moduleStagingPath); + AppleReleaseSourceTrustSnapshot? appleSourceTrust = null; if (!unifiedRequest.SkipAppleApps) - unifiedRequest.AppleSourceCommit = _resolveAppleSourceCommit(repositoryRoot, configPath); + { + appleSourceTrust = _captureAppleSourceTrust(repositoryRoot, configPath); + unifiedRequest.AppleSourceCommit = appleSourceTrust.SourceCommit; + } unifiedRequest.CancellationToken = cancellationToken; var unified = await Task.Run( () => _executeUnifiedReleaseBuild(configPath, unifiedRequest), cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(unifiedRequest.AppleSourceCommit)) - { - var completedSourceCommit = _resolveAppleSourceCommit(repositoryRoot, configPath); - if (!completedSourceCommit.Equals(unifiedRequest.AppleSourceCommit, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - "Repository HEAD changed while the Apple release checkpoint was being built. Rebuild from the new exact source commit."); - } - } + if (appleSourceTrust is not null) + _validateAppleSourceTrustAfterBuild(repositoryRoot, configPath, appleSourceTrust); var moduleExportCheckpoint = await CaptureScriptModuleExportedConfigFingerprintAsync( repository, diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs b/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs index 70667d18d..c3f6ec795 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs @@ -308,6 +308,13 @@ internal static PowerForgeReleaseRequest CreateUnifiedPublishRequest( AppleMarketingVersion = applePlan?.RequestedMarketingVersion, AppleSourceCommit = 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, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index eea19bf13..14f4171a6 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -1,3 +1,4 @@ +using PowerForge; using PowerForgeStudio.Orchestrator.Queue; namespace PowerForgeStudio.Tests; @@ -156,6 +157,10 @@ public void ResolveExactAppleSourceCommit_rejects_project_reference_outside_repo File.WriteAllText( Path.Combine(project, "project.pbxproj"), """ + 000000000000000000000000 = { + isa = PBXBuildFile; + fileRef = 000000000000000000000001; + }; 000000000000000000000001 = { isa = PBXFileReference; path = ../../OutsideSecrets/Injected.swift; @@ -308,6 +313,240 @@ public void ResolveExactAppleSourceCommit_resolves_nested_workspace_groups() 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); + } + + [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_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); + } + private static string WriteAppleReleaseConfig( string repositoryRoot, string projectRoot, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs index 8bcf4755a..2e864de26 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs @@ -616,7 +616,8 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre { Name = "Sample iOS", Upload = true, - ArchivePath = archivePath + ArchivePath = archivePath, + ExpectedArchiveSha256 = "1111111111111111111111111111111111111111111111111111111111111111" } ] }, @@ -649,6 +650,9 @@ public async Task ExecuteAsync_MixedRelease_CheckpointsAppleArchiveWithoutSuppre 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 6dd130370..4bf098ab6 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs @@ -321,7 +321,15 @@ public void CreateUnifiedPublishRequest_preserves_checkpointed_Apple_provenance_ WaitForProcessing = true, ProcessingTimeoutSeconds = 1200, PollIntervalSeconds = 30 - } + }, + Apps = + [ + new PowerForgeAppleAppReleaseTargetPlan + { + Name = "CasaRay iOS", + ExpectedArchiveSha256 = "1111111111111111111111111111111111111111111111111111111111111111" + } + ] }, AppleReceipt = new PowerForgeAppleReleaseReceipt { @@ -338,6 +346,9 @@ public void CreateUnifiedPublishRequest_preserves_checkpointed_Apple_provenance_ Assert.Equal("1.6", request.AppleMarketingVersion); Assert.Equal("0123456789abcdef0123456789abcdef01234567", request.AppleSourceCommit); 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); From ba5bd0578f084f3159302411ee3aa25500226526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 14:51:07 +0200 Subject: [PATCH 04/78] Close remaining Apple release integrity gaps --- .../AppleReleaseReceiptStoreTests.cs | 46 ++++++++ ...ppleReleaseWorkflowTests.PinnedEvidence.cs | 10 +- ...orgeReleaseServiceTests.AppleAutomation.cs | 51 +++++++-- ...aseServiceTests.AppleNotarizationResume.cs | 1 + .../Services/AppleReleaseReceiptStore.cs | 6 + .../AppleReleaseSourceTrustService.Xcode.cs | 68 ++++++++++-- .../AppleReleaseSourceTrustService.cs | 15 +++ .../PowerForgeReleaseService.AppleResume.cs | 31 +++++- ...owerForgeReleaseService.AppleVersioning.cs | 2 + ...dExecutionServiceTests.AppleSourceTrust.cs | 103 ++++++++++++++++++ scripts/Invoke-PinnedPowerForge.Evidence.ps1 | 3 +- 11 files changed, 305 insertions(+), 31 deletions(-) diff --git a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs index afae163fa..65c5cf3b9 100644 --- a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -143,6 +143,52 @@ public void WriteAttempt_PreservesLegacyLatestBeforeReplacingIt() } } + [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() diff --git a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs index 77f50be1e..a228bdaf7 100644 --- a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs +++ b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs @@ -297,7 +297,7 @@ public void ApprovedApplePlanAndBoundAutomationOutputsDoNotRequireASecondCheckou File.WriteAllText(Path.Combine(receiptHistory.FullName, "legacy-upload.json"), JsonSerializer.Serialize(new { schemaVersion = 3, - sourceCommit = commit, + sourceCommit = "89abcdef0123456789abcdef0123456789abcdef", action = "Upload", success = false })); @@ -343,10 +343,12 @@ public void ApprovedApplePlanAndBoundAutomationOutputsDoNotRequireASecondCheckou 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') - Set-Content -LiteralPath (Join-Path $consumer 'build/powerforge/apple/receipts/wrong-legacy.json') -Value '{"schemaVersion":3,"sourceCommit":"89abcdef0123456789abcdef0123456789abcdef"}' - try { Register-AppleAutomationEvidence -SourceCommit '{{commit}}'; throw 'Wrong-source legacy history was accepted.' } + $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"}' + try { Register-AppleAutomationEvidence -SourceCommit '{{commit}}'; throw 'Wrong-source latest legacy receipt was accepted.' } catch { if ($_.Exception.Message -notlike '*source commit does not match*') { throw } } - Remove-Item -LiteralPath (Join-Path $consumer 'build/powerforge/apple/receipts/wrong-legacy.json') + 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/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index 75467b44c..d1337d32b 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -224,6 +224,41 @@ public void Execute_ConfiguredProtectedPlan_BindsExactObservedAppleState() } } + [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() { @@ -1051,7 +1086,7 @@ public void Execute_AppleUpload_PostUploadStatusFailureWritesReceipt() } [Fact] - public void Execute_AppleUpload_ResumesFromImmediateBuildUploadAttestationWhenFinalReadbackFailed() + public void Execute_AppleUpload_ResumesFromImmediateAttestationBeforeRemoteBuildIsVisible() { const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; var root = CreateSandbox(); @@ -1096,22 +1131,16 @@ public void Execute_AppleUpload_ResumesFromImmediateBuildUploadAttestationWhenFi receipt => receipt.OperationPhase == "UploadAttested"); var resumed = CreateAppleAutomationService( - request => CreateReleaseState(request, "VALID"), + 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) => new AppStoreConnectBuildUploadInfo - { - Id = id, - State = "COMPLETE", - MarketingVersion = "1.2.0", - BuildNumber = "9", - Platform = "IOS" - }) + getAppleBuildUpload: (_, _) => throw new InvalidOperationException("Pre-visibility resume must not require remote build lookup.")) .Execute(spec, new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.UploadExisting, - AppleSourceCommit = sourceCommit + AppleSourceCommit = sourceCommit, + AppleWaitForProcessing = false }); Assert.True(resumed.Success, resumed.ErrorMessage); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index dca7e8bcc..27b148a3e 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -113,6 +113,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/Services/AppleReleaseReceiptStore.cs b/PowerForge/Services/AppleReleaseReceiptStore.cs index fd682400d..3942db59c 100644 --- a/PowerForge/Services/AppleReleaseReceiptStore.cs +++ b/PowerForge/Services/AppleReleaseReceiptStore.cs @@ -288,6 +288,12 @@ private static PowerForgeAppleReleaseReceipt Read(string projectRoot, string pat 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(); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 3dd639e25..bc6aa9ff6 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -354,6 +354,43 @@ private void ValidateLocalPackageReference( } var manifest = File.ReadAllText(manifestPath); + var externalDependencies = Regex.Matches( + manifest, + "\\.package\\s*\\((?.*?)\\)", + RegexOptions.Singleline | RegexOptions.CultureInvariant) + .Cast() + .Select(static match => match.Groups["body"].Value) + .Where(static body => Regex.IsMatch(body, "\\b(?:url|id)\\s*:", RegexOptions.CultureInvariant)) + .ToArray(); + foreach (var dependency in externalDependencies.Where(static body => + Regex.IsMatch(body, "\\bid\\s*:", RegexOptions.CultureInvariant) || + !Regex.IsMatch( + body, + "\\brevision\\s*:\\s*\"[A-Fa-f0-9]{40}\"", + RegexOptions.CultureInvariant))) + { + var identityMatch = Regex.Match( + dependency, + "\\b(?:url|id)\\s*:\\s*\"(?[^\"]+)\"", + RegexOptions.CultureInvariant); + if (!identityMatch.Success) + { + 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 identity = identityMatch.Groups["identity"].Value; + var locks = FindTrackedPackageLocks(repositoryRoot, projectDirectory, identity); + if (locks.Length == 0) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' declares external dependency '{identity}' without an exact 40-character revision. " + + "Commit a Package.resolved lock containing that dependency before creating an exact-source Apple checkpoint."); + } + foreach (var packageLock in locks) + EnsureTrackedFile(repositoryRoot, packageLock, "Xcode local Swift package resolution lock"); + } foreach (Match match in Regex.Matches( manifest, "\\bpath\\s*:\\s*\"(?[^\"]+)\"", @@ -383,8 +420,23 @@ private void ValidateRemotePackageReference( item.Body, "(?s)requirement\\s*=\\s*\\{.*?kind\\s*=\\s*revision\\s*;.*?revision\\s*=\\s*[\"']?[A-Fa-f0-9]{40}[\"']?\\s*;", RegexOptions.CultureInvariant); - var identity = Path.GetFileNameWithoutExtension(repositoryUrl!.TrimEnd('/')); - var locks = RunGit(repositoryRoot, "ls-files", "-z") + var locks = FindTrackedPackageLocks(repositoryRoot, projectDirectory, repositoryUrl!); + if (locks.Length == 0 && !exactRevision) + { + throw new InvalidOperationException( + $"Remote Swift package '{repositoryUrl}' must be bound by a tracked Package.resolved lock or an exact 40-character revision."); + } + foreach (var packageLock in locks) + EnsureTrackedFile(repositoryRoot, packageLock, "Swift package resolution lock"); + } + + private string[] FindTrackedPackageLocks( + string repositoryRoot, + string projectDirectory, + string dependencyIdentity) + { + var shortIdentity = Path.GetFileNameWithoutExtension(dependencyIdentity.TrimEnd('/')); + return RunGit(repositoryRoot, "ls-files", "-z") .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) .Where(static path => Path.GetFileName(path).Equals("Package.resolved", StringComparison.OrdinalIgnoreCase)) .Select(path => Path.GetFullPath(Path.Combine(repositoryRoot, path))) @@ -392,17 +444,11 @@ private void ValidateRemotePackageReference( .Where(path => { var text = File.ReadAllText(path); - return text.Contains(repositoryUrl, StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrWhiteSpace(identity) && text.Contains(identity, StringComparison.OrdinalIgnoreCase)); + return text.Contains(dependencyIdentity, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(shortIdentity) && + text.Contains(shortIdentity, StringComparison.OrdinalIgnoreCase)); }) .ToArray(); - if (locks.Length == 0 && !exactRevision) - { - throw new InvalidOperationException( - $"Remote Swift package '{repositoryUrl}' must be bound by a tracked Package.resolved lock or an exact 40-character revision."); - } - foreach (var packageLock in locks) - EnsureTrackedFile(repositoryRoot, packageLock, "Swift package resolution lock"); } private void ValidateResolvedProjectInput( diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 4a2dea434..f8920fabf 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -37,6 +37,7 @@ internal AppleReleaseSourceTrustSnapshot Capture(string repositoryRoot, string c { var root = Path.GetFullPath(repositoryRoot); var releaseConfigPath = Path.GetFullPath(configPath); + EnsureNoGitReplacementRefs(root); _git.EnsureClean(root); var sourceCommitBeforeValidation = ReadExactHead(root); EnsureTrackedFile(root, releaseConfigPath, "Apple release configuration"); @@ -47,6 +48,7 @@ internal AppleReleaseSourceTrustSnapshot Capture(string repositoryRoot, string c 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)) @@ -67,6 +69,7 @@ internal void ValidateAfterBuild( var root = Path.GetFullPath(repositoryRoot); var releaseConfigPath = Path.GetFullPath(configPath); + EnsureNoGitReplacementRefs(root); EnsureNoUnexpectedWorktreeChanges(root, snapshot.GeneratedOutputPaths); if (!ReadExactHead(root).Equals(snapshot.SourceCommit, StringComparison.OrdinalIgnoreCase)) { @@ -85,6 +88,7 @@ internal void ValidateAfterBuild( } ValidateAppleInputs(root, releaseConfigPath, options, generatedOutputs); + EnsureNoGitReplacementRefs(root); EnsureNoUnexpectedWorktreeChanges(root, generatedOutputs); if (!ReadExactHead(root).Equals(snapshot.SourceCommit, StringComparison.OrdinalIgnoreCase)) { @@ -93,6 +97,17 @@ internal void ValidateAfterBuild( } } + 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index a38ba1279..ba70d3a37 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -67,8 +67,16 @@ private bool TryResumeAppleUpload( 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 (plan.Automation.WaitForProcessing) + state = WaitForAppleBuild(plan, app, state, attestation.Target.BuildUploadId); + PopulateResumedAppleUpload(result, state, attestation, adopted: false); + return true; + } if (plan.AdoptExistingBuild) { throw new InvalidOperationException( @@ -87,7 +95,6 @@ private bool TryResumeAppleUpload( state); } - var attestation = FindVerifiedAppleUploadAttestation(plan, app, state, platform.MatchedBuild); if (attestation is null && !plan.AdoptExistingBuild) { throw new InvalidOperationException( @@ -103,21 +110,30 @@ private bool TryResumeAppleUpload( 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 = attestation is null; + 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" }; - return true; } private AppleUploadAttestation? FindVerifiedAppleUploadAttestation( PowerForgeAppleReleasePlan plan, PowerForgeAppleAppReleaseTargetPlan app, AppStoreConnectReleaseStateResult state, - AppStoreConnectBuildInfo remoteBuild) + AppStoreConnectBuildInfo? remoteBuild) { if (string.IsNullOrWhiteSpace(plan.SourceCommit)) return null; @@ -148,6 +164,13 @@ private bool TryResumeAppleUpload( if (target is null) continue; + if (remoteBuild is null) + { + if (string.IsNullOrWhiteSpace(target.BuildUploadId)) + continue; + return new AppleUploadAttestation(receipt, target); + } + if (!string.IsNullOrWhiteSpace(target.BuildId) && !string.Equals(target.BuildId, remoteBuild.Id, StringComparison.OrdinalIgnoreCase)) { diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index 132c4d179..4ce67d6e8 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -273,6 +273,8 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei var options = new { + plan.Archive, + plan.Upload, plan.PrepareDistribution, plan.SelectBuildForDistribution, plan.AllowUnprocessedDistributionBuild, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index 14f4171a6..a06741c6c 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -547,6 +547,109 @@ public void ResolveExactAppleSourceCommit_rejects_unlocked_remote_swift_package( Assert.Contains("exact", exception.Message, StringComparison.OrdinalIgnoreCase); } + [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_accepts_tracked_project_lock_for_local_package_dependency() + { + 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: "."); + var sourceCommit = CommitRepository(repositoryRoot); + + var resolved = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(sourceCommit, resolved); + } + + [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); + } + private static string WriteAppleReleaseConfig( string repositoryRoot, string projectRoot, diff --git a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 index c52063ea3..07606c390 100644 --- a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 +++ b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 @@ -35,7 +35,8 @@ function Register-AppleReceiptEvidenceFile { $receiptSource -notmatch '^[0-9A-Fa-f]{40}$')) { throw "Apple release evidence does not satisfy the immutable receipt contract: $Path" } - } elseif (-not $receiptSource.Equals($SourceCommit, [StringComparison]::OrdinalIgnoreCase)) { + } elseif (-not $HistoryEntry -and + -not $receiptSource.Equals($SourceCommit, [StringComparison]::OrdinalIgnoreCase)) { throw "Apple release receipt source commit does not match the exact consumer HEAD '$SourceCommit'." } Add-AllowedConsumerEvidencePath -Path $Path -Name 'Apple release receipt' From 0b12cda6e91bb31f13b665211aa032cc0b3a8ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 15:18:32 +0200 Subject: [PATCH 05/78] Harden Apple source and upload recovery edges --- ...orgeReleaseServiceTests.AppleAutomation.cs | 39 ++++++++- .../AppleReleaseSourceTrustService.Xcode.cs | 82 +++++++++++++++++-- .../AppleReleaseSourceTrustService.cs | 31 +++++-- .../PowerForgeReleaseService.AppleResume.cs | 23 ++++++ .../Services/PowerForgeReleaseService.cs | 2 +- ...dExecutionServiceTests.AppleSourceTrust.cs | 81 ++++++++++++++++++ 6 files changed, 240 insertions(+), 18 deletions(-) diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index d1337d32b..04cc0cb0b 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -1130,11 +1130,16 @@ public void Execute_AppleUpload_ResumesFromImmediateAttestationBeforeRemoteBuild 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: (_, _) => throw new InvalidOperationException("Pre-visibility resume must not require remote build lookup.")) + getAppleBuildUpload: (_, id) => + { + buildUploadQueries++; + return new AppStoreConnectBuildUploadInfo { Id = id, State = "PROCESSING" }; + }) .Execute(spec, new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), @@ -1144,7 +1149,39 @@ public void Execute_AppleUpload_ResumesFromImmediateAttestationBeforeRemoteBuild }); 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 + }); + + 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 { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index bc6aa9ff6..4f9e4c148 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -52,7 +52,7 @@ private void ValidateXcodeBuildGraph( foreach (var metadataPath in metadataPaths.Where(path => path.EndsWith("project.pbxproj", StringComparison.OrdinalIgnoreCase) && File.Exists(path))) { - ValidateProjectGraph(repositoryRoot, metadataPath, generatedOutputPaths); + ValidateProjectGraph(repositoryRoot, metadataPath, metadataPaths, generatedOutputPaths); } } @@ -173,9 +173,11 @@ private static string ResolveSchemeContainer(string reference, string containerR private void ValidateProjectGraph( string repositoryRoot, string metadataPath, + IReadOnlyCollection metadataPaths, IReadOnlyCollection generatedOutputPaths) { var projectDirectory = Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!; + var packageLockRoots = ResolvePackageLockSearchRoots(metadataPath, metadataPaths); var objects = ParsePbxObjects(File.ReadAllText(metadataPath)); var parents = BuildPbxParentMap(objects); var buildFileReferences = objects.Values @@ -202,13 +204,13 @@ private void ValidateProjectGraph( if (item.Isa.Equals("XCLocalSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) { - ValidateLocalPackageReference(repositoryRoot, projectDirectory, item); + ValidateLocalPackageReference(repositoryRoot, projectDirectory, packageLockRoots, item); continue; } if (item.Isa.Equals("XCRemoteSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) { - ValidateRemotePackageReference(repositoryRoot, projectDirectory, item); + ValidateRemotePackageReference(repositoryRoot, packageLockRoots, item); continue; } @@ -331,6 +333,7 @@ private void EnsureTrackedXcconfigGraph( private void ValidateLocalPackageReference( string repositoryRoot, string projectDirectory, + IReadOnlyCollection packageLockRoots, PbxObject item) { var relativePath = ReadPbxScalar(item.Body, "relativePath"); @@ -381,7 +384,11 @@ private void ValidateLocalPackageReference( } var identity = identityMatch.Groups["identity"].Value; - var locks = FindTrackedPackageLocks(repositoryRoot, projectDirectory, identity); + var lockRoots = packageLockRoots + .Concat(new[] { packageRoot }) + .Distinct(GetPathComparer()) + .ToArray(); + var locks = FindTrackedPackageLocks(repositoryRoot, lockRoots, identity); if (locks.Length == 0) { throw new InvalidOperationException( @@ -409,7 +416,7 @@ private void ValidateLocalPackageReference( private void ValidateRemotePackageReference( string repositoryRoot, - string projectDirectory, + IReadOnlyCollection packageLockRoots, PbxObject item) { var repositoryUrl = ReadPbxScalar(item.Body, "repositoryURL")?.Trim(); @@ -420,7 +427,7 @@ private void ValidateRemotePackageReference( item.Body, "(?s)requirement\\s*=\\s*\\{.*?kind\\s*=\\s*revision\\s*;.*?revision\\s*=\\s*[\"']?[A-Fa-f0-9]{40}[\"']?\\s*;", RegexOptions.CultureInvariant); - var locks = FindTrackedPackageLocks(repositoryRoot, projectDirectory, repositoryUrl!); + var locks = FindTrackedPackageLocks(repositoryRoot, packageLockRoots, repositoryUrl!); if (locks.Length == 0 && !exactRevision) { throw new InvalidOperationException( @@ -432,7 +439,7 @@ private void ValidateRemotePackageReference( private string[] FindTrackedPackageLocks( string repositoryRoot, - string projectDirectory, + IReadOnlyCollection searchRoots, string dependencyIdentity) { var shortIdentity = Path.GetFileNameWithoutExtension(dependencyIdentity.TrimEnd('/')); @@ -440,7 +447,7 @@ private string[] FindTrackedPackageLocks( .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) .Where(static path => Path.GetFileName(path).Equals("Package.resolved", StringComparison.OrdinalIgnoreCase)) .Select(path => Path.GetFullPath(Path.Combine(repositoryRoot, path))) - .Where(path => IsPathAtOrWithin(path, projectDirectory)) + .Where(path => searchRoots.Any(root => IsPathAtOrWithin(path, root))) .Where(path => { var text = File.ReadAllText(path); @@ -451,6 +458,65 @@ private string[] FindTrackedPackageLocks( .ToArray(); } + private static string[] ResolvePackageLockSearchRoots( + string projectMetadataPath, + IReadOnlyCollection metadataPaths) + { + var projectContainer = Path.GetDirectoryName(projectMetadataPath)!; + var roots = new HashSet(GetPathComparer()) + { + Path.GetDirectoryName(projectContainer)! + }; + 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()))) + { + roots.Add(Path.GetDirectoryName(workspaceMetadata)!); + } + } + + return roots.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; + } + private void ValidateResolvedProjectInput( string repositoryRoot, string candidate, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index f8920fabf..6195143c4 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -184,19 +184,34 @@ private static string[] ResolveGeneratedOutputPaths( configDirectory, string.IsNullOrWhiteSpace(options.ProjectRoot) ? "." : options.ProjectRoot!); var automation = options.Automation ?? new PowerForgeAppleReleaseAutomationOptions(); - return new[] + 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, string.IsNullOrWhiteSpace(options.ArchiveRoot) - ? Path.Combine("Artifacts", "Apple", "Archives") - : options.ArchiveRoot!), - ResolvePath(projectRoot, string.IsNullOrWhiteSpace(options.ExportRoot) - ? Path.Combine("Artifacts", "Apple", "Exports") - : options.ExportRoot!), ResolvePath(projectRoot, automation.ReceiptPath), ResolvePath(projectRoot, automation.ReceiptHistoryPath), ResolvePath(projectRoot, automation.PlanReceiptPath), ResolvePath(projectRoot, automation.LockPath) - } + }) .Distinct(GetPathComparer()) .OrderBy(static path => path, GetPathComparer()) .ToArray(); diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index ba70d3a37..e843f6287 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -74,6 +74,8 @@ private bool TryResumeAppleUpload( { 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; } @@ -129,6 +131,27 @@ private static void PopulateResumedAppleUpload( result.SkippedSteps = new[] { "archive", "upload" }; } + 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 98d54502c..43e5938c5 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -5170,7 +5170,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.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index a06741c6c..737baa70f 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -522,6 +522,32 @@ public void ResolveExactAppleSourceCommit_rejects_ignored_local_swift_package_so 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); + } + [Fact] public void ResolveExactAppleSourceCommit_rejects_unlocked_remote_swift_package() { @@ -629,6 +655,61 @@ import PackageDescription Assert.Equal(sourceCommit, resolved); } + [Fact] + public void ResolveExactAppleSourceCommit_accepts_tracked_workspace_lock_for_nested_project_dependency() + { + 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" + } + ] + } + } + """); + var sourceCommit = CommitRepository(repositoryRoot); + + var resolved = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + + Assert.Equal(sourceCommit, resolved); + } + [Fact] public void ResolveExactAppleSourceCommit_rejects_git_replacement_refs() { From d1f629ef97ceaf303ad0f5414b32068fab0fbeea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 15:52:56 +0200 Subject: [PATCH 06/78] Close final Apple source and adoption gaps --- .../PowerForgeCliAppleReleaseTests.cs | 11 -- ...orgeReleaseServiceTests.AppleAutomation.cs | 37 ++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 109 ++++++++++++++++-- .../AppleReleaseSourceTrustService.cs | 16 ++- ...owerForgeReleaseService.AppleVersioning.cs | 3 +- ...dExecutionServiceTests.AppleSourceTrust.cs | 52 +++++++++ 6 files changed, 204 insertions(+), 24 deletions(-) diff --git a/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs index dfe3d0f09..722a5c671 100644 --- a/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs +++ b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs @@ -113,17 +113,6 @@ public async Task AppleRelease_CliUsesDedicatedEnvelopeAndReportsLegacyConfirmat static step => step.GetString() == "stopBeforeReview"); } - var adoptedUpload = await RunCliAsync( - repoRoot, - $"\"{GetCliPath(repoRoot)}\" apple-release Upload --config \"{configPath}\" --apple-adopt-existing-build --plan --summary --output json"); - Assert.Equal(0, adoptedUpload.ExitCode); - using (var adoptedUploadDocument = JsonDocument.Parse(adoptedUpload.StdOut)) - { - var result = adoptedUploadDocument.RootElement.GetProperty("result"); - Assert.True(result.GetProperty("adoptExistingBuild").GetBoolean()); - Assert.True(result.GetProperty("requiresConfirmation").GetBoolean()); - } - var configuredDedicated = await RunCliAsync( repoRoot, $"\"{GetCliPath(repoRoot)}\" apple-release Configured --config \"{configPath}\" --plan --summary --output json"); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index 04cc0cb0b..be7037e2a 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -824,6 +824,43 @@ public void Execute_AppleUpload_AdoptsExistingBuildOnlyWithExplicitConfirmedOver } } + [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 = service.Execute(CreateAppleAutomationSpec(root, keyPath), request); + processingState = "VALID"; + var withBuild = service.Execute(CreateAppleAutomationSpec(root, keyPath), request); + + var absentTarget = Assert.Single(withoutBuild.AppleReceipt!.Targets); + var presentTarget = Assert.Single(withBuild.AppleReceipt!.Targets); + Assert.Null(absentTarget.BuildId); + Assert.Equal("build-id", presentTarget.BuildId); + Assert.Equal("VALID", presentTarget.BuildProcessingState); + Assert.NotEqual(withoutBuild.AppleReceipt.PlanSha256, withBuild.AppleReceipt.PlanSha256); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Execute_AppleStatus_PreservesBetaReviewActionWhenGroupIsConfigured() { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 4f9e4c148..195213f5c 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Text.Json; using System.Xml.Linq; namespace PowerForge; @@ -442,22 +443,100 @@ private string[] FindTrackedPackageLocks( IReadOnlyCollection searchRoots, string dependencyIdentity) { - var shortIdentity = Path.GetFileNameWithoutExtension(dependencyIdentity.TrimEnd('/')); return RunGit(repositoryRoot, "ls-files", "-z") .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) .Where(static path => Path.GetFileName(path).Equals("Package.resolved", StringComparison.OrdinalIgnoreCase)) .Select(path => Path.GetFullPath(Path.Combine(repositoryRoot, path))) .Where(path => searchRoots.Any(root => IsPathAtOrWithin(path, root))) - .Where(path => - { - var text = File.ReadAllText(path); - return text.Contains(dependencyIdentity, StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrWhiteSpace(shortIdentity) && - text.Contains(shortIdentity, StringComparison.OrdinalIgnoreCase)); - }) + .Where(path => PackageLockBindsDependency(path, dependencyIdentity)) .ToArray(); } + private static bool PackageLockBindsDependency(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); + } + + 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 false; + 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.All(Uri.IsHexDigit)) + { + return true; + } + + if (!LooksLikeRepositoryLocation(dependencyIdentity) && + !string.IsNullOrWhiteSpace(ReadJsonString(state, "version"))) + { + return true; + } + } + } + } + + return false; + } + + 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[] ResolvePackageLockSearchRoots( string projectMetadataPath, IReadOnlyCollection metadataPaths) @@ -567,9 +646,17 @@ private void EnsureTrackedDirectoryTree(string repositoryRoot, string path, stri { EnsureDirectoryWithinRepository(repositoryRoot, path, name); var relativeRoot = FrameworkCompatibility.GetRelativePath(repositoryRoot, path).Replace('\\', '/'); - var tracked = RunGit(repositoryRoot, "ls-files", "-z", "--", relativeRoot) - .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) - .Select(entry => Path.GetFullPath(Path.Combine(repositoryRoot, entry))) + 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()); foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories)) { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 6195143c4..6bdb7d1ff 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -464,14 +464,28 @@ private void EnsureTrackedFile(string repositoryRoot, string path, string name) EnsureNoLinkedTraversal(repositoryRoot, candidate, name); var relative = FrameworkCompatibility.GetRelativePath(repositoryRoot, candidate).Replace('\\', '/'); - var tracked = RunGitAllowFailure(repositoryRoot, "ls-files", "--error-unmatch", "--", relative); + 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 unchanged = RunGitAllowFailure(repositoryRoot, "diff", "--quiet", "HEAD", "--", relative); if (unchanged.ExitCode != 0) throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); } + 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); diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index 4ce67d6e8..c4862c284 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -172,7 +172,8 @@ PowerForgeAppleReleaseAction.SubmitAppReview or return true; } - return plan.SubmitTestFlightBetaReview || + return plan.AdoptExistingBuild || + plan.SubmitTestFlightBetaReview || plan.SubmitForReview || plan.ReleaseApprovedVersion; } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index 737baa70f..820342ff9 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -548,6 +548,28 @@ public void ResolveExactAppleSourceCommit_rejects_ignored_source_inside_archive_ Assert.Contains("Ignored Apple build input", exception.Message, StringComparison.OrdinalIgnoreCase); } + [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() { @@ -573,6 +595,36 @@ public void ResolveExactAppleSourceCommit_rejects_unlocked_remote_swift_package( 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() { From 8551d5cbdd1797ce0884104d93376fa3f81d5dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 16:33:38 +0200 Subject: [PATCH 07/78] Close Apple resume and package trust gaps --- ...rviceTests.AppleHostedReviewRegressions.cs | 164 ++++++++++++++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 9 + .../PowerForgeReleaseService.AppleResume.cs | 80 ++++++--- ...owerForgeReleaseService.AppleVersioning.cs | 18 ++ .../Services/PowerForgeReleaseService.cs | 40 +++-- ...dExecutionServiceTests.AppleSourceTrust.cs | 42 +++++ 6 files changed, 320 insertions(+), 33 deletions(-) diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index 3eb772845..c8e2f9ef9 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -2,6 +2,170 @@ 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_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 + }); + + 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() { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 195213f5c..495473688 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -358,6 +358,15 @@ private void ValidateLocalPackageReference( } var manifest = File.ReadAllText(manifestPath); + if (Regex.IsMatch( + manifest, + "\\.unsafeFlags\\s*\\(", + RegexOptions.CultureInvariant)) + { + 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."); + } var externalDependencies = Regex.Matches( manifest, "\\.package\\s*\\((?.*?)\\)", diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index e843f6287..d4fb20981 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -164,26 +164,13 @@ private void EnsureAppleBuildUploadIsNotTerminal( var receipts = _appleReceiptStore.ReadAll(plan); foreach (var receipt in receipts) { - if (receipt.PlanOnly || - string.IsNullOrWhiteSpace(receipt.ReceiptSha256) || - !string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var target = 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 && - candidate.DistributionRoute == app.DistributionRoute && - string.Equals(candidate.Version, state.VersionString, StringComparison.OrdinalIgnoreCase) && - string.Equals(candidate.Build, state.BuildNumber, StringComparison.OrdinalIgnoreCase) && - !candidate.AdoptedExistingBuild && - candidate.UploadPerformed && - IsSha256(candidate.ArchiveSha256) && - !string.IsNullOrWhiteSpace(candidate.ArchivePath) && - IsVerifiedUploadCheckpoint(receipts, receipt, candidate)); + var target = FindMatchingAppleUploadAttestationTarget( + receipts, + receipt, + plan, + app, + state.VersionString, + state.BuildNumber); if (target is null) continue; @@ -224,6 +211,59 @@ private void EnsureAppleBuildUploadIsNotTerminal( return null; } + private bool HasPotentialVerifiedAppleUploadAttestation( + PowerForgeAppleReleasePlan plan, + PowerForgeAppleAppReleaseTargetPlan app) + { + if (string.IsNullOrWhiteSpace(plan.SourceCommit)) + { + return false; + } + + 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.IsNullOrWhiteSpace(receipt.ReceiptSha256) || + !string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) + { + 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 && + 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) && + (string.IsNullOrWhiteSpace(app.ExpectedArchiveSha256) || + string.Equals(candidate.ArchiveSha256, app.ExpectedArchiveSha256, StringComparison.OrdinalIgnoreCase)) && + !string.IsNullOrWhiteSpace(candidate.ArchivePath) && + IsVerifiedUploadCheckpoint(receipts, receipt, candidate)); + } + private static bool IsVerifiedUploadCheckpoint( IReadOnlyCollection receipts, PowerForgeAppleReleaseReceipt receipt, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index c4862c284..b3fecd86a 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -276,6 +276,24 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei { plan.Archive, plan.Upload, + 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 43e5938c5..a7932b838 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -528,10 +528,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) @@ -576,10 +573,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); @@ -1437,8 +1431,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."); @@ -1689,14 +1682,34 @@ 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."); } + 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)); + var recoveryPlan = new PowerForgeAppleReleasePlan + { + ProjectRoot = projectRoot, + Action = request.AppleAction, + Automation = automation, + ReceiptPath = receiptPath, + ReceiptHistoryPath = receiptHistoryPath, + PlanReceiptPath = planReceiptPath, + LockPath = lockPath, + SourceCommit = appleSourceCommit.Length == 0 ? null : appleSourceCommit, + Apps = apps + }; + var missingArchive = apps.FirstOrDefault(app => + !Directory.Exists(app.ArchivePath) && + (!automation.Resume || !HasPotentialVerifiedAppleUploadAttestation(recoveryPlan, app))); if (missingArchive is not null) - throw new FileNotFoundException($"Apple app archive was not found for upload-only release: {missingArchive.ArchivePath}", missingArchive.ArchivePath); + { + 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 && @@ -1765,7 +1778,7 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( DiscoverAppStoreConnectAppId(app, credential, request.AppleAction); } - return new PowerForgeAppleReleasePlan + var plan = new PowerForgeAppleReleasePlan { ProjectRoot = projectRoot, Configuration = configuration, @@ -1824,6 +1837,7 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( AppStoreConnectApiIssuerId = appStoreConnectApiIssuerId, Apps = apps }; + return plan; } private static PowerForgeAppleAppReleaseTargetPlan PrepareAppleAppPlan( diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index 820342ff9..c42eedcfa 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -662,6 +662,48 @@ import PackageDescription 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); + } + [Fact] public void ResolveExactAppleSourceCommit_accepts_tracked_project_lock_for_local_package_dependency() { From f7b98095a3cd0f85429945b4c3b9a99876c930f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 17:11:52 +0200 Subject: [PATCH 08/78] Close Apple source and recovery integrity gaps --- .../AppStoreConnectClientTests.cs | 60 +++++++++++ ...rviceTests.AppleHostedReviewRegressions.cs | 7 +- ...aseServiceTests.AppleNotarizationResume.cs | 100 ++++++++++++++++++ ...eleaseServiceTests.ApplePlatformControl.cs | 4 +- ...AppStoreConnectReleasePreparationModels.cs | 3 + .../AppStoreConnectScreenshotSyncModels.cs | 3 + PowerForge/Models/PowerForgeRelease.cs | 2 + ...ppStoreConnectReleasePreparationService.cs | 3 +- .../AppStoreConnectScreenshotSyncService.cs | 99 ++++++++++++++++- .../AppleReleaseSourceTrustService.Xcode.cs | 83 ++++++++++++++- .../AppleReleaseSourceTrustService.cs | 54 +++++++++- ...owerForgeReleaseService.AppleAutomation.cs | 4 +- ...owerForgeReleaseService.AppleDurability.cs | 15 ++- ...owerForgeReleaseService.AppleVersioning.cs | 1 + .../Services/PowerForgeReleaseService.cs | 8 ++ ...dExecutionServiceTests.AppleSourceTrust.cs | 84 +++++++++++++++ 16 files changed, 514 insertions(+), 16 deletions(-) diff --git a/PowerForge.Tests/AppStoreConnectClientTests.cs b/PowerForge.Tests/AppStoreConnectClientTests.cs index 570718717..2d166173f 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.cs @@ -1029,6 +1029,11 @@ public async Task ScreenshotSyncService_CreatesMissingSetAndUploadsMappedFolder( } """)); + handler.OnRequest = count => + { + if (count == 1) + File.WriteAllBytes(screenshotPath, new byte[] { 6, 6, 6 }); + }; 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); @@ -1071,6 +1076,58 @@ public async Task ScreenshotSyncService_CreatesMissingSetAndUploadsMappedFolder( } } + [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 */ } + } + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -2423,11 +2480,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/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index c8e2f9ef9..b64440f25 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -484,7 +484,9 @@ public void Execute_DirectNotarizationCrash_PersistsAcceptedSubmissionBeforeLoca Assert.Equal("Accepted", target.NotarizationStatus); Assert.False(accepted.Success); - var protectedArtifact = Assert.IsType(target.DirectArtifactPath); + 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; @@ -579,8 +581,9 @@ public void Execute_DirectNotarizationCrash_PersistsExactPostStapleCheckpoint() 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(target.DirectArtifactPath!), + AppleNotarizationService.ComputeArtifactSha256(Path.Combine(root, target.DirectArtifactPath!)), target.DirectArtifactSha256); } finally diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 27b148a3e..b252d432c 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -4,6 +4,106 @@ namespace PowerForge.Tests; public sealed partial class PowerForgeReleaseServiceTests { + [Fact] + public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterCheckoutRelocation() + { + 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", + 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 = sourceCommit + }); + 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 resumed = 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) + }; + }) + .Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(relocatedRoot, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + + Assert.True(resumed.Success, resumed.ErrorMessage); + Assert.Equal("portable-submission", resumedRequest!.AcceptedSubmissionId); + Assert.StartsWith(relocatedRoot, resumedRequest.ArtifactPath, StringComparison.Ordinal); + Assert.True(Assert.Single(resumed.AppleApps).ResumedAcceptedNotarization); + } + finally + { + TryDelete(originalRoot); + TryDelete(relocatedRoot); + } + } + [Fact] public void Execute_AppleDirectNotarizationResume_RejectsChainedReceiptOutsideCurrentExportRoot() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs index 2c907aa90..3b19608b2 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs @@ -981,7 +981,9 @@ public void Execute_AppleUpload_PreservesAcceptedNotarizationWhenStaplingFails() retainedReceipt.ReceiptSha256 = null; retainedReceipt.PreviousReceiptSha256 = null; new AppleReleaseReceiptStore().WriteAttempt(disabledChecksRetry.AppleAppPlan!, retainedReceipt); - File.WriteAllText(Path.Combine(retainedTarget.DirectArtifactPath!, "changed-after-release.txt"), "changed"); + File.WriteAllText( + Path.Combine(root, retainedTarget.DirectArtifactPath!, "changed-after-release.txt"), + "changed"); var changedArtifact = nextReleaseService.Execute( spec, new PowerForgeReleaseRequest diff --git a/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs b/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs index 399600433..ea5d0e273 100644 --- a/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs +++ b/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs @@ -52,6 +52,9 @@ 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; } } /// diff --git a/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs b/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs index 87c6453a2..cc8911e71 100644 --- a/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs +++ b/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs @@ -243,6 +243,9 @@ 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; } } /// diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs index cded1c592..b28b6754c 100644 --- a/PowerForge/Models/PowerForgeRelease.cs +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -533,6 +533,8 @@ internal sealed class PowerForgeAppleReleasePlan public string[] ScreenshotConfigPaths { get; set; } = Array.Empty(); + public Dictionary ApprovedMutationInputFilesSha256 { get; set; } = new(StringComparer.Ordinal); + public string? MetadataConfigPath { get; set; } public string[] MetadataConfigPaths { get; set; } = Array.Empty(); diff --git a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs index 5dcf23c69..4e04da309 100644 --- a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs +++ b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs @@ -144,7 +144,8 @@ request.ScreenshotSpec is not null || Spec = screenshotSpec, ReplaceExisting = request.ReplaceScreenshots, BaseDirectory = request.BaseDirectory, - ExpectedSourceCommit = request.ExpectedSourceCommit + ExpectedSourceCommit = request.ExpectedSourceCommit, + ExpectedFileSha256 = request.ExpectedScreenshotFileSha256 }, cancellationToken).ConfigureAwait(false); messages.Add("Synchronized App Store screenshots."); diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index 43c69e3be..e4a1337e8 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -64,9 +64,11 @@ public async Task SyncAsync( $"Screenshot preflight failed: {string.Join(" ", messages)}"); } - var preflightedSets = spec.ScreenshotSets + var sourceSets = spec.ScreenshotSets .Select(setSpec => PreflightScreenshotSet(request.BaseDirectory, setSpec)) .ToArray(); + using var screenshotSnapshot = CreateScreenshotSnapshot(sourceSets, request.ExpectedFileSha256); + var preflightedSets = screenshotSnapshot.Sets; var version = !string.IsNullOrWhiteSpace(spec.VersionId) ? new AppStoreConnectVersionInfo @@ -154,7 +156,11 @@ public async Task SyncAsync( var uploaded = new List(); foreach (var file in filesToUpload) - uploaded.Add(await _client.UploadScreenshotAsync(set.Id, file, cancellationToken).ConfigureAwait(false)); + { + var upload = await _client.UploadScreenshotAsync(set.Id, file, cancellationToken).ConfigureAwait(false); + upload.FilePath = screenshotSnapshot.GetSourcePath(file); + uploaded.Add(upload); + } results.Add(new AppStoreConnectScreenshotSetSyncResult { @@ -181,6 +187,70 @@ 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 = Path.DirectorySeparatorChar == '\\' + ? 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); + try + { + var mappings = new Dictionary(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 (expected is not null && !expected.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."); + } + + var snapshotPath = Path.Combine(setRoot, Path.GetFileName(source)); + File.Copy(source, snapshotPath, overwrite: false); + if (expected is not null) + { + var actualSha256 = ComputeSha256(snapshotPath); + if (!actualSha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Screenshot '{source}' changed after Apple plan approval. Review the exact replacement bytes before upload."); + } + } + mappings[snapshotPath] = source; + return snapshotPath; + }).ToArray(); + return new PreflightedScreenshotSet(set.ScreenshotDisplayType, set.Folder, files); + }).ToArray(); + return new ScreenshotSnapshot(root, sets, mappings); + } + catch + { + try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } + throw; + } + } + + 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) @@ -271,4 +341,29 @@ public PlannedScreenshotSet( public AppStoreConnectScreenshotInfo[] ExistingScreenshots { get; } } + + private sealed class ScreenshotSnapshot : IDisposable + { + private readonly string _root; + private readonly IReadOnlyDictionary _sourcePaths; + + public ScreenshotSnapshot( + string root, + PreflightedScreenshotSet[] sets, + IReadOnlyDictionary sourcePaths) + { + _root = root; + Sets = sets; + _sourcePaths = sourcePaths; + } + + public PreflightedScreenshotSet[] Sets { get; } + + public string GetSourcePath(string snapshotPath) => _sourcePaths[snapshotPath]; + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } + } + } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 495473688..3a25b2749 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -408,10 +408,22 @@ private void ValidateLocalPackageReference( foreach (var packageLock in locks) EnsureTrackedFile(repositoryRoot, packageLock, "Xcode local Swift package resolution lock"); } - foreach (Match match in Regex.Matches( - manifest, - "\\bpath\\s*:\\s*\"(?[^\"]+)\"", - RegexOptions.CultureInvariant)) + var manifestWithoutComments = RemoveSwiftComments(manifest); + var pathArguments = Regex.Matches( + manifestWithoutComments, + "\\bpath\\s*:", + RegexOptions.CultureInvariant); + var literalPathArguments = Regex.Matches( + manifestWithoutComments, + "\\bpath\\s*:\\s*\"(?[^\"\\\\\\r\\n]+)\"\\s*(?=[,)])", + RegexOptions.CultureInvariant); + if (pathArguments.Count != literalPathArguments.Count) + { + 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."); + } + foreach (Match match in literalPathArguments) { var explicitPath = ResolvePbxPath(packageRoot, match.Groups["path"].Value, "Swift package manifest input"); EnsurePathWithinRepository(repositoryRoot, explicitPath, "Swift package manifest input"); @@ -424,6 +436,57 @@ private void ValidateLocalPackageReference( } } + private static string RemoveSwiftComments(string source) + { + var result = source.ToCharArray(); + var inString = false; + var escaped = false; + for (var index = 0; index < result.Length; index++) + { + var current = result[index]; + if (inString) + { + if (escaped) + escaped = false; + else if (current == '\\') + escaped = true; + else if (current == '"') + inString = false; + continue; + } + if (current == '"') + { + inString = true; + continue; + } + if (current != '/' || index + 1 >= result.Length) + continue; + if (result[index + 1] == '/') + { + while (index < result.Length && result[index] != '\r' && result[index] != '\n') + result[index++] = ' '; + index--; + } + else if (result[index + 1] == '*') + { + result[index++] = ' '; + result[index] = ' '; + while (++index < result.Length) + { + if (result[index] == '*' && index + 1 < result.Length && result[index + 1] == '/') + { + result[index] = ' '; + result[++index] = ' '; + break; + } + if (result[index] != '\r' && result[index] != '\n') + result[index] = ' '; + } + } + } + return new string(result); + } + private void ValidateRemotePackageReference( string repositoryRoot, IReadOnlyCollection packageLockRoots, @@ -667,6 +730,7 @@ private void EnsureTrackedDirectoryTree(string repositoryRoot, string path, stri .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) @@ -677,6 +741,17 @@ private void EnsureTrackedDirectoryTree(string repositoryRoot, string path, stri $"{name} must be tracked at the exact source commit: " + FrameworkCompatibility.GetRelativePath(repositoryRoot, entry).Replace('\\', '/')); } + if (File.Exists(entry)) + { + var fullPath = Path.GetFullPath(entry); + if (!headBlobs.TryGetValue(fullPath, out var expectedBlob) || + !expectedBlob.Equals(ComputeRawGitBlobId(repositoryRoot, fullPath), StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{name} differs from the exact source commit: " + + FrameworkCompatibility.GetRelativePath(repositoryRoot, entry).Replace('\\', '/')); + } + } } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 6bdb7d1ff..51515fb39 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -21,6 +21,8 @@ internal sealed partial class AppleReleaseSourceTrustService private readonly HomeAssistantReleaseGitService _git; private readonly GitClient _gitClient; + private readonly Dictionary _gitObjectFormats = new( + Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); internal AppleReleaseSourceTrustService( HomeAssistantReleaseGitService? git = null, @@ -475,11 +477,59 @@ private void EnsureTrackedFile(string repositoryRoot, string path, string name) 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 unchanged = RunGitAllowFailure(repositoryRoot, "diff", "--quiet", "HEAD", "--", relative); - if (unchanged.ExitCode != 0) + 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}"); + var worktreeBlob = ComputeRawGitBlobId(repositoryRoot, candidate); + if (!headBlob.StdOut.Trim().Equals(worktreeBlob, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); } + private string ComputeRawGitBlobId(string repositoryRoot, string filePath) + { + var root = Path.GetFullPath(repositoryRoot); + if (!_gitObjectFormats.TryGetValue(root, out var objectFormat)) + { + objectFormat = RunGit(root, "rev-parse", "--show-object-format").StdOut.Trim(); + _gitObjectFormats[root] = objectFormat; + } + 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 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 && diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs index 6d4aa7416..36180a175 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs @@ -584,7 +584,9 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( UploadAttestationAttemptId = result?.Upload?.Succeeded == true ? result.UploadAttestationAttemptId : result?.ResumedUploadAttestationAttemptId, - DirectArtifactPath = result?.Notarization?.ArtifactPath, + DirectArtifactPath = string.IsNullOrWhiteSpace(result?.Notarization?.ArtifactPath) + ? null + : CreatePortableDirectArtifactPath(plan, app, result!.Notarization!.ArtifactPath), DirectArtifactSha256 = result?.Notarization?.ArtifactSha256, NotarizationSubmissionId = result?.Notarization?.SubmissionId, NotarizationStatus = result?.Notarization?.Status, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index 4134cc131..5e000b7c3 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -61,7 +61,7 @@ private void WriteAppleNotarizationAttestation( return; var target = CreateAppleCheckpointTarget(app); - target.DirectArtifactPath = result.Notarization.ArtifactPath; + target.DirectArtifactPath = CreatePortableDirectArtifactPath(plan, app, result.Notarization.ArtifactPath); target.DirectArtifactSha256 = result.Notarization.ArtifactSha256; target.NotarizationSubmissionId = result.Notarization.SubmissionId; target.NotarizationStatus = result.Notarization.Status; @@ -93,7 +93,7 @@ private void WriteAppleNotarizationAcceptance( return; var target = CreateAppleCheckpointTarget(app); - target.DirectArtifactPath = checkpoint.ArtifactPath; + target.DirectArtifactPath = CreatePortableDirectArtifactPath(plan, app, checkpoint.ArtifactPath); target.DirectArtifactSha256 = checkpoint.ArtifactSha256; target.NotarizationSubmissionId = checkpoint.SubmissionId; target.NotarizationStatus = checkpoint.Status; @@ -118,7 +118,7 @@ private void WriteAppleNotarizationStapled( return; var target = CreateAppleCheckpointTarget(app); - target.DirectArtifactPath = checkpoint.ArtifactPath; + target.DirectArtifactPath = CreatePortableDirectArtifactPath(plan, app, checkpoint.ArtifactPath); target.DirectArtifactSha256 = checkpoint.ArtifactSha256; target.NotarizationSubmissionId = checkpoint.SubmissionId; target.NotarizationStatus = checkpoint.Status; @@ -153,6 +153,15 @@ private static PowerForgeAppleReleaseTargetReceipt CreateAppleCheckpointTarget( Build = app.BuildNumber }; + 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 || diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index b3fecd86a..84045567a 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -319,6 +319,7 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei plan.AllowNonPendingDeveloperRelease, Files = files.OrderBy(static value => value.Key, StringComparer.Ordinal).ToArray() }; + plan.ApprovedMutationInputFilesSha256 = new Dictionary(files, StringComparer.Ordinal); return (files, ComputeStableSha256(options)); } diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index a7932b838..228270e0e 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2284,6 +2284,14 @@ 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), CheckReadiness = plan.CheckReleaseReadiness, ReadinessRequest = plan.CheckReleaseReadiness && matchingScreenshotSpec is not null ? new AppStoreConnectReleaseReadinessRequest diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index c42eedcfa..97be2c6e0 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -704,6 +704,90 @@ import PackageDescription Assert.Contains("cannot be proven", 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_clean_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("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_filtered_bytes_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("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Filtered.swift", exception.Message, StringComparison.Ordinal); + } + [Fact] public void ResolveExactAppleSourceCommit_accepts_tracked_project_lock_for_local_package_dependency() { From f121c5885317eacac3e99d8c1aa8509bdd22720f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 18:00:11 +0200 Subject: [PATCH 09/78] Harden exact Apple archive provenance --- .../AppleNotarizationServiceTests.cs | 24 +++ ...rviceTests.AppleHostedReviewRegressions.cs | 97 +++++++++++ ...aseServiceTests.AppleNotarizationResume.cs | 29 ++++ .../PowerForgeAppleReleaseAutomation.cs | 2 + PowerForge/Models/PowerForgeRelease.cs | 5 + .../Services/AppleNotarizationService.cs | 1 - .../Services/AppleReleaseSourceSnapshot.cs | 164 ++++++++++++++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 46 +++++ .../AppleReleaseSourceTrustService.cs | 1 + ...owerForgeReleaseService.AppleAutomation.cs | 1 + ...owerForgeReleaseService.AppleDurability.cs | 7 + .../PowerForgeReleaseService.AppleResume.cs | 9 +- ...owerForgeReleaseService.AppleVersioning.cs | 2 + .../Services/PowerForgeReleaseService.cs | 14 +- .../Queue/ReleaseBuildExecutionService.cs | 1 + ...dExecutionServiceTests.AppleSourceTrust.cs | 39 +++++ ...StudioReleaseBuildExecutionServiceTests.cs | 1 + 17 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceSnapshot.cs diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 9c2388d40..0f1b6bdfb 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -296,6 +296,30 @@ 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 async Task NotarizeAsync_ResumeUsesPostStapleHashAndDoesNotStapleAgain() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index b64440f25..8f3de8fba 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -43,6 +43,103 @@ public void Execute_ApplePlan_BindsEffectiveAutomationPolicy() } } + [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_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; + } + [Fact] public void Execute_ConfiguredAppleUploadOnly_ResumesAfterAttestedArchiveWasRemoved() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index b252d432c..916ae38c6 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -4,6 +4,35 @@ namespace PowerForge.Tests; public sealed partial class PowerForgeReleaseServiceTests { + [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) + }; + + Assert.False(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(receipt, app)); + receipt.ArchiveSha256 = app.ExpectedArchiveSha256; + Assert.True(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(receipt, app)); + } + [Fact] public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterCheckoutRelocation() { diff --git a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index 302a8c40a..fce35ec07 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -246,6 +246,8 @@ internal sealed class PowerForgeAppleReleaseTargetReceipt public ApplePlatform Platform { get; set; } + public string Configuration { get; set; } = "Release"; + public AppleDistributionRoute DistributionRoute { get; set; } public AppleProductRole ProductRole { get; set; } diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs index b28b6754c..2f1eba45e 100644 --- a/PowerForge/Models/PowerForgeRelease.cs +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -59,6 +59,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; } @@ -521,6 +524,8 @@ internal sealed class PowerForgeAppleReleasePlan public string? SourceCommit { get; set; } + public bool RequireImmutableSourceSnapshot { get; set; } + public bool AdoptExistingBuild { get; set; } public bool Archive { get; set; } diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 3dca35aa1..cec82c2e4 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -314,7 +314,6 @@ private static void AppendFileSystemEntry( { 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()) { diff --git a/PowerForge/Services/AppleReleaseSourceSnapshot.cs b/PowerForge/Services/AppleReleaseSourceSnapshot.cs new file mode 100644 index 000000000..5de0ba966 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceSnapshot.cs @@ -0,0 +1,164 @@ +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 = new(defaultTimeout: TimeSpan.FromMinutes(2)); + private readonly string _repositoryRoot; + private readonly string _sourceProjectRoot; + private readonly string _snapshotProjectRoot; + private readonly string _sourceCommit; + private bool _disposed; + + private AppleReleaseSourceSnapshot( + string repositoryRoot, + string sourceProjectRoot, + string snapshotRoot, + string snapshotProjectRoot, + string sourceCommit) + { + _repositoryRoot = repositoryRoot; + _sourceProjectRoot = sourceProjectRoot; + _snapshotProjectRoot = snapshotProjectRoot; + RootPath = snapshotRoot; + _sourceCommit = sourceCommit; + } + + internal string RootPath { get; } + + internal static AppleReleaseSourceSnapshot? CreateIfRequired(PowerForgeAppleReleasePlan plan) + { + if (!plan.Archive || !plan.RequireImmutableSourceSnapshot || string.IsNullOrWhiteSpace(plan.SourceCommit)) + return null; + + var sourceCommit = plan.SourceCommit!.Trim(); + var git = new GitClient(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 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"); + var snapshotProjectRoot = Path.GetFullPath(Path.Combine(snapshotRoot, projectPrefix)); + var snapshot = new AppleReleaseSourceSnapshot( + repositoryRoot, + Path.GetFullPath(plan.ProjectRoot), + snapshotRoot, + snapshotProjectRoot, + sourceCommit); + snapshot.ValidateUnchanged(); + 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; + } + } + + 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; + } + + 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 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."); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + var result = _git.RunRawAsync( + _repositoryRoot, + new[] { "worktree", "remove", "--force", RootPath }, + TimeSpan.FromMinutes(2)) + .GetAwaiter() + .GetResult(); + if (!result.Succeeded) + { + throw new InvalidOperationException( + $"Failed to remove the isolated Apple build snapshot '{RootPath}': {result.StdErr}".Trim()); + } + } + + 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.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 3a25b2749..af427502b 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -57,6 +57,52 @@ private void ValidateXcodeBuildGraph( } } + 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, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 51515fb39..2deef8df9 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -168,6 +168,7 @@ private void ValidateAppleInputs( } AddReferencedWorkspaceProjects(repositoryRoot, metadataPaths); + AddReferencedXcodeProjects(repositoryRoot, metadataPaths, generatedOutputPaths); ValidateXcodeBuildGraph( repositoryRoot, projectRoot, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs index 36180a175..8005b4b1b 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs @@ -542,6 +542,7 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( ErrorMessage = result?.ErrorMessage, BundleId = app.BundleId, Platform = app.Platform, + Configuration = app.Configuration, DistributionRoute = app.DistributionRoute, ProductRole = app.ProductRole, ParentTarget = app.ParentTarget, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index 5e000b7c3..32cd00653 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -61,6 +61,8 @@ private void WriteAppleNotarizationAttestation( return; var target = CreateAppleCheckpointTarget(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.NotarizationSubmissionId = result.Notarization.SubmissionId; @@ -93,6 +95,8 @@ private void WriteAppleNotarizationAcceptance( return; var target = CreateAppleCheckpointTarget(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.NotarizationSubmissionId = checkpoint.SubmissionId; @@ -118,6 +122,8 @@ private void WriteAppleNotarizationStapled( return; var target = CreateAppleCheckpointTarget(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.NotarizationSubmissionId = checkpoint.SubmissionId; @@ -143,6 +149,7 @@ private static PowerForgeAppleReleaseTargetReceipt CreateAppleCheckpointTarget( Name = app.Name, BundleId = app.BundleId, Platform = app.Platform, + Configuration = app.Configuration, DistributionRoute = app.DistributionRoute, ProductRole = app.ProductRole, ParentTarget = app.ParentTarget, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index d4fb20981..e7cec785d 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -250,6 +250,7 @@ private bool HasPotentialVerifiedAppleUploadAttestation( 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) && candidate.DistributionRoute == app.DistributionRoute && (string.IsNullOrWhiteSpace(version) || string.Equals(candidate.Version, version, StringComparison.OrdinalIgnoreCase)) && @@ -289,6 +290,7 @@ private static bool IsVerifiedUploadCheckpoint( 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) && candidate.DistributionRoute == target.DistributionRoute && string.Equals(candidate.ArchivePath, target.ArchivePath, StringComparison.OrdinalIgnoreCase) && string.Equals(candidate.ArchiveSha256, target.ArchiveSha256, StringComparison.OrdinalIgnoreCase)); @@ -384,15 +386,18 @@ static ProcessRunResult CompletedStep(string message, string executable) return true; } - private static bool IsMatchingDirectReceiptTarget( + internal static bool IsMatchingDirectReceiptTarget( 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) && target.DistributionRoute == AppleDistributionRoute.DirectNotarized && string.Equals(target.Version, app.MarketingVersion, StringComparison.OrdinalIgnoreCase) && - string.Equals(target.Build, app.BuildNumber, StringComparison.OrdinalIgnoreCase); + string.Equals(target.Build, app.BuildNumber, StringComparison.OrdinalIgnoreCase) && + (!IsSha256(app.ExpectedArchiveSha256) || + string.Equals(target.ArchiveSha256, app.ExpectedArchiveSha256, StringComparison.OrdinalIgnoreCase)); private static bool IsSha256(string? value) => !string.IsNullOrWhiteSpace(value) && diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index 84045567a..59806314c 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -83,6 +83,7 @@ private PowerForgeAppleReleaseTargetReceipt CreateApplePlanTarget( Name = app.Name, BundleId = app.BundleId, Platform = app.Platform, + Configuration = app.Configuration, DistributionRoute = app.DistributionRoute, ProductRole = app.ProductRole, ParentTarget = app.ParentTarget, @@ -274,6 +275,7 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei var options = new { + plan.Configuration, plan.Archive, plan.Upload, Automation = new diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 228270e0e..fe6e15e29 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -1792,6 +1792,7 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( VersionSourcePath = versionSourcePath, RequestedMarketingVersion = requestedMarketingVersion, SourceCommit = appleSourceCommit.Length == 0 ? null : appleSourceCommit, + RequireImmutableSourceSnapshot = request.RequireImmutableAppleSourceSnapshot, AdoptExistingBuild = request.AppleAdoptExistingBuild, Archive = options.Archive, Upload = options.Upload, @@ -1970,6 +1971,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 @@ -2167,6 +2169,12 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( 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); } @@ -2175,7 +2183,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( var directArchive = app.DistributionRoute == AppleDistributionRoute.DirectNotarized; var archive = _archiveAppleApp(new AppleAppArchiveRequest { - ProjectPath = app.ProjectPath, + ProjectPath = sourceSnapshot?.MapPath(app.ProjectPath) ?? app.ProjectPath, IsWorkspace = app.IsWorkspace, Scheme = app.Scheme, Configuration = app.Configuration, @@ -2190,6 +2198,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( AppStoreConnectApiIssuerId = directArchive ? null : plan.AppStoreConnectApiIssuerId }); result.Archive = archive; + sourceSnapshot?.ValidateUnchanged(); if (!archive.Succeeded) { result.Success = false; @@ -2418,6 +2427,7 @@ private static void CaptureAppleArchiveSha256( $"Expected SHA-256 '{expected}', received '{actual}'. Rebuild and approve a new exact archive."); } result.ArchiveSha256 = actual; + app.ExpectedArchiveSha256 = actual; } private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( @@ -2432,6 +2442,8 @@ private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( Automation = new PowerForgeAppleReleaseAutomationOptions(), ReceiptPath = plan.ReceiptPath, ReceiptHistoryPath = plan.ReceiptHistoryPath, + SourceCommit = plan.SourceCommit, + RequireImmutableSourceSnapshot = plan.RequireImmutableSourceSnapshot, Archive = true, Upload = false, XcodeBuildExecutable = plan.XcodeBuildExecutable, diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs index 1c654e34b..0a23fd0c9 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs @@ -190,6 +190,7 @@ internal static PowerForgeReleaseRequest CreateUnifiedReleaseBuildRequest( { request.SkipAppleApps = false; request.CheckpointAppleApps = true; + request.RequireImmutableAppleSourceSnapshot = true; request.PlanOnly = spec.Module is null && spec.Packages is null && diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index 97be2c6e0..b8b5b96ea 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -177,6 +177,45 @@ public void ResolveExactAppleSourceCommit_rejects_project_reference_outside_repo Assert.Contains("Xcode PBXFileReference input", exception.Message, StringComparison.Ordinal); } + [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() { diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs index 2e864de26..26736f2d8 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs @@ -586,6 +586,7 @@ 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 { From 3138332bde76352d1af11a4a041a8b02dc91211a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 18:49:13 +0200 Subject: [PATCH 10/78] Complete immutable Apple release inputs --- ...orgeReleaseServiceTests.AppleAutomation.cs | 13 +- ...rviceTests.AppleHostedReviewRegressions.cs | 95 ++++++++++++++ ...aseServiceTests.AppleNotarizationResume.cs | 10 +- .../PowerForgeAppleReleaseAutomation.cs | 10 ++ PowerForge/Models/PowerForgeRelease.cs | 4 + .../AppStoreConnectGovernanceConfiguration.cs | 16 ++- .../Services/AppleArchiveUploadSnapshot.cs | 122 ++++++++++++++++++ .../Services/AppleReleaseSourceSnapshot.cs | 38 ++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 14 +- .../AppleReleaseSourceTrustService.cs | 3 +- .../AppleReleaseVersionSourceService.cs | 18 ++- ...owerForgeReleaseService.AppInfoMetadata.cs | 2 +- ...owerForgeReleaseService.AppleAutomation.cs | 5 + ...owerForgeReleaseService.AppleDurability.cs | 16 ++- .../PowerForgeReleaseService.AppleResume.cs | 18 ++- ...owerForgeReleaseService.AppleVersioning.cs | 51 +++++++- .../Services/PowerForgeReleaseService.cs | 16 ++- ...dExecutionServiceTests.AppleSourceTrust.cs | 33 +++++ 18 files changed, 460 insertions(+), 24 deletions(-) create mode 100644 PowerForge/Services/AppleArchiveUploadSnapshot.cs diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index be7037e2a..d9431ea4a 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -42,7 +42,7 @@ public void Execute_AppleArchive_RejectsMalformedJournalBeforeArchiveMutation() } [Fact] - public void Execute_AppleUpload_RejectsArchiveMutationAfterUploaderReturns() + public void Execute_AppleUpload_UsesPrivateArchiveSnapshotDuringUploaderMutation() { var root = CreateSandbox(); try @@ -53,6 +53,8 @@ public void Execute_AppleUpload_RejectsArchiveMutationAfterUploaderReturns() var spec = CreateAppleAutomationSpec(root, keyPath); spec.AppleApps!.Automation.MinimumFreeSpaceGB = 0; spec.AppleApps.Automation.CleanupBeforeArchive = false; + spec.AppleApps.Automation.WaitForProcessing = false; + string? uploaderArchivePath = null; var service = CreateAppleAutomationService( request => CreateReleaseState(request, processingState: null), archiveAppleApp: request => @@ -63,6 +65,7 @@ public void Execute_AppleUpload_RejectsArchiveMutationAfterUploaderReturns() }, uploadAppleApp: request => { + uploaderArchivePath = request.ArchivePath; File.WriteAllText(Path.Combine(request.ArchivePath!, "payload"), "after"); return CreateSuccessfulUpload(request); }); @@ -76,9 +79,11 @@ public void Execute_AppleUpload_RejectsArchiveMutationAfterUploaderReturns() AppleAction = PowerForgeAppleReleaseAction.Upload }); - Assert.False(result.Success); - Assert.Contains("changed during upload", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain( + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(uploaderArchivePath); + 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( new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), receipt => receipt.OperationPhase == "UploadAttested"); } diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index 8f3de8fba..e477a4019 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -79,6 +79,101 @@ public void Execute_ApplePlan_BindsEffectiveBuildConfiguration() } } + [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 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() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 916ae38c6..2bc7c2d75 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -28,9 +28,15 @@ public void DirectNotarizationResume_RequiresCheckpointArchiveIdentity() ArchiveSha256 = new string('a', 64) }; - Assert.False(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(receipt, app)); + var plan = new PowerForgeAppleReleasePlan { ProjectRoot = Directory.GetCurrentDirectory() }; + app.ProjectPath = Path.Combine(plan.ProjectRoot, "EasyControlXAgent.xcodeproj"); + receipt.ProjectPath = "EasyControlXAgent.xcodeproj"; + receipt.Scheme = app.Scheme; + receipt.Configuration = app.Configuration; + receipt.Destination = app.Destination; + Assert.False(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app)); receipt.ArchiveSha256 = app.ExpectedArchiveSha256; - Assert.True(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(receipt, app)); + Assert.True(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app)); } [Fact] diff --git a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index fce35ec07..054b6fc6d 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -248,6 +248,16 @@ internal sealed class PowerForgeAppleReleaseTargetReceipt 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; } diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs index 2f1eba45e..46facda82 100644 --- a/PowerForge/Models/PowerForgeRelease.cs +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -540,6 +540,10 @@ internal sealed class PowerForgeAppleReleasePlan public Dictionary ApprovedMutationInputFilesSha256 { get; set; } = new(StringComparer.Ordinal); + internal Dictionary ApprovedMutationInputContents { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + internal string? ExactSourceConfigPath { get; set; } + public string? MetadataConfigPath { get; set; } public string[] MetadataConfigPaths { get; set; } = Array.Empty(); 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/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs new file mode 100644 index 000000000..c707cccb9 --- /dev/null +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -0,0 +1,122 @@ +namespace PowerForge; + +/// Copies one approved archive into a private upload input so exporters cannot observe transient source changes. +internal sealed class AppleArchiveUploadSnapshot : IDisposable +{ + private bool _disposed; + + private AppleArchiveUploadSnapshot(string rootPath, string archivePath) + { + RootPath = rootPath; + ArchivePath = archivePath; + } + + 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); + try + { + CopyTree(source, snapshotPath); + var actual = AppleNotarizationService.ComputeArtifactSha256(snapshotPath); + if (!actual.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple upload snapshot does not match the approved archive. Expected '{expectedSha256}', received '{actual}'."); + } + return new AppleArchiveUploadSnapshot(root, snapshotPath); + } + catch + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + throw; + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (Directory.Exists(RootPath)) + Directory.Delete(RootPath, recursive: true); + } + + private static void CopyTree(string sourceRoot, string destinationRoot) + { + Directory.CreateDirectory(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 archive symbolic link: {sourcePath}"); + if (Path.IsPathRooted(linkTarget)) + throw new InvalidOperationException($"Apple archive symbolic links must remain inside the archive: {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 archive symbolic links must remain inside the archive: {sourcePath}"); + } + if (isDirectory) + Directory.CreateSymbolicLink(destinationPath, linkTarget!); + else + File.CreateSymbolicLink(destinationPath, linkTarget!); +#else + throw new PlatformNotSupportedException("Apple archive symbolic-link snapshots require .NET 8 or newer."); +#endif + } + else if (isDirectory) + { + Directory.CreateDirectory(destinationPath); + pending.Push((sourcePath, destinationPath)); + } + else + { + File.Copy(sourcePath, destinationPath, overwrite: false); + } + + if (!isLink) + { +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(destinationPath, File.GetUnixFileMode(sourcePath)); +#endif + File.SetAttributes(destinationPath, attributes); + } + } + } + +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(destinationRoot, File.GetUnixFileMode(sourceRoot)); +#endif + File.SetAttributes(destinationRoot, File.GetAttributes(sourceRoot)); + } +} diff --git a/PowerForge/Services/AppleReleaseSourceSnapshot.cs b/PowerForge/Services/AppleReleaseSourceSnapshot.cs index 5de0ba966..6232bee11 100644 --- a/PowerForge/Services/AppleReleaseSourceSnapshot.cs +++ b/PowerForge/Services/AppleReleaseSourceSnapshot.cs @@ -9,19 +9,23 @@ internal sealed class AppleReleaseSourceSnapshot : IDisposable { private readonly GitClient _git = new(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 string? _snapshotConfigPath; 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; @@ -41,6 +45,9 @@ private AppleReleaseSourceSnapshot( 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); @@ -55,11 +62,14 @@ private AppleReleaseSourceSnapshot( var snapshotProjectRoot = Path.GetFullPath(Path.Combine(snapshotRoot, projectPrefix)); var snapshot = new AppleReleaseSourceSnapshot( repositoryRoot, + sourceRepositoryRoot, Path.GetFullPath(plan.ProjectRoot), snapshotRoot, snapshotProjectRoot, sourceCommit); snapshot.ValidateUnchanged(); + if (!string.IsNullOrWhiteSpace(plan.ExactSourceConfigPath)) + snapshot.ValidateExactSourceInputs(plan.ExactSourceConfigPath!); return snapshot; } catch @@ -91,6 +101,32 @@ internal string MapPath(string sourcePath) return mapped; } + 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) + { + _snapshotConfigPath = MapRepositoryPath(configPath); + ValidateMappedExactSourceInputs(); + } + + private void ValidateMappedExactSourceInputs() + { + var trust = new AppleReleaseSourceTrustService().Capture(RootPath, _snapshotConfigPath!); + if (!trust.SourceCommit.Equals(_sourceCommit, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The isolated Apple build snapshot resolved commit '{trust.SourceCommit}' instead of '{_sourceCommit}'."); + } + } + internal void ValidateUnchanged() { var head = Run(_git, RootPath, new[] { "rev-parse", "HEAD" }, "verify the Apple build snapshot commit") @@ -111,6 +147,8 @@ internal void ValidateUnchanged() 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(); } public void Dispose() diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index af427502b..2ec56c605 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -212,7 +212,8 @@ private static string ResolveSchemeContainer(string reference, string containerR return kind.ToLowerInvariant() switch { "container" or "group" => ResolvePath(containerRoot, value), - "absolute" => Path.GetFullPath(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}'.") }; } @@ -278,6 +279,12 @@ private void ValidateProjectGraph( 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) continue; @@ -804,6 +811,11 @@ private void EnsureTrackedDirectoryTree(string repositoryRoot, string path, stri 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(); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 2deef8df9..c9b24aa16 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -370,7 +370,8 @@ private static string ResolveWorkspaceLocation(string location, string workspace var value = separator < 0 ? location : location.Substring(separator + 1); return kind.ToLowerInvariant() switch { - "absolute" => Path.GetFullPath(value), + "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}'.") diff --git a/PowerForge/Services/AppleReleaseVersionSourceService.cs b/PowerForge/Services/AppleReleaseVersionSourceService.cs index edb7e6efa..93834a86e 100644 --- a/PowerForge/Services/AppleReleaseVersionSourceService.cs +++ b/PowerForge/Services/AppleReleaseVersionSourceService.cs @@ -17,7 +17,12 @@ internal sealed class AppleReleaseVersionSourceService 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 +33,7 @@ internal PowerForgeAppleVersionReceipt Read(string sourcePath) internal PowerForgeAppleVersionReceipt Update( string sourcePath, + string approvedContent, string marketingVersion, string buildNumber, long highestRemoteBuildNumber, @@ -42,7 +48,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 +56,15 @@ internal PowerForgeAppleVersionReceipt Update( var changed = !string.Equals(content, updated, StringComparison.Ordinal); if (changed && !whatIf) + { + var currentContent = File.ReadAllText(fullPath); + if (!string.Equals(currentContent, approvedContent, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Apple version source changed after plan approval: {fullPath}"); + } WriteAtomic(fullPath, updated); + } return new PowerForgeAppleVersionReceipt { diff --git a/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs b/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs index 5ad9ec195..f90332958 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppInfoMetadata.cs @@ -59,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 8005b4b1b..763b6b9b2 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs @@ -543,6 +543,11 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index 32cd00653..47c6f7366 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -21,7 +21,7 @@ private void PrepareAppleReceiptJournalForMutation( OperationPhase = "Started", Success = false, ErrorMessage = "Apple release operation started; inspect later receipts and remote state before retrying.", - Targets = plan.Apps.Select(CreateAppleCheckpointTarget).ToArray() + Targets = plan.Apps.Select(app => CreateAppleCheckpointTarget(plan, app)).ToArray() }); } @@ -35,7 +35,7 @@ private void WriteAppleUploadAttestation( var attemptId = Guid.NewGuid().ToString("N"); result.UploadAttestationAttemptId = attemptId; - var target = CreateAppleCheckpointTarget(app); + var target = CreateAppleCheckpointTarget(plan, app); target.UploadPerformed = true; target.ArchivePath = FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ArchivePath).Replace('\\', '/'); target.ArchiveSha256 = result.ArchiveSha256; @@ -60,7 +60,7 @@ private void WriteAppleNotarizationAttestation( if (!plan.Automation.WriteReceipt || result.Notarization is null) return; - var target = CreateAppleCheckpointTarget(app); + 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); @@ -94,7 +94,7 @@ private void WriteAppleNotarizationAcceptance( if (!plan.Automation.WriteReceipt) return; - var target = CreateAppleCheckpointTarget(app); + 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); @@ -121,7 +121,7 @@ private void WriteAppleNotarizationStapled( if (!plan.Automation.WriteReceipt) return; - var target = CreateAppleCheckpointTarget(app); + 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); @@ -143,6 +143,7 @@ private void WriteAppleNotarizationStapled( } private static PowerForgeAppleReleaseTargetReceipt CreateAppleCheckpointTarget( + PowerForgeAppleReleasePlan plan, PowerForgeAppleAppReleaseTargetPlan app) => new() { @@ -150,6 +151,11 @@ private static PowerForgeAppleReleaseTargetReceipt CreateAppleCheckpointTarget( 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index e7cec785d..9c3ef0fee 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -251,6 +251,11 @@ private bool HasPotentialVerifiedAppleUploadAttestation( string.Equals(candidate.AppId, app.AppStoreConnectAppId, StringComparison.OrdinalIgnoreCase) && candidate.Platform == app.Platform && string.Equals(candidate.Configuration, app.Configuration, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.ProjectPath, FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), StringComparison.OrdinalIgnoreCase) && + 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)) && @@ -291,6 +296,11 @@ private static bool IsVerifiedUploadCheckpoint( string.Equals(candidate.BundleId, target.BundleId, StringComparison.OrdinalIgnoreCase) && candidate.Platform == target.Platform && string.Equals(candidate.Configuration, target.Configuration, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.ProjectPath, target.ProjectPath, StringComparison.OrdinalIgnoreCase) && + 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 && string.Equals(candidate.ArchivePath, target.ArchivePath, StringComparison.OrdinalIgnoreCase) && string.Equals(candidate.ArchiveSha256, target.ArchiveSha256, StringComparison.OrdinalIgnoreCase)); @@ -310,7 +320,7 @@ private bool TryResumeDirectAppleNotarization( !string.IsNullOrWhiteSpace(receipt.ReceiptSha256) && !string.IsNullOrWhiteSpace(plan.SourceCommit) && string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) - .SelectMany(receipt => receipt.Targets.Where(target => IsMatchingDirectReceiptTarget(target, app))) + .SelectMany(receipt => receipt.Targets.Where(target => IsMatchingDirectReceiptTarget(plan, target, app))) .FirstOrDefault(target => string.Equals(target.NotarizationStatus, "Accepted", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(target.NotarizationSubmissionId) && @@ -387,12 +397,18 @@ static ProcessRunResult CompletedStep(string message, string executable) } 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) && + string.Equals(target.ProjectPath, FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), StringComparison.OrdinalIgnoreCase) && + 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) && diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index 59806314c..ab4006c4a 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -23,6 +23,48 @@ private void AssertApplePlanStillApproved( throw new InvalidOperationException( "Apple state or release inputs changed after plan approval. Review a new exact plan before allowing mutation."); } + + CaptureApprovedMutationInputContents(plan); + } + + internal static void CaptureApprovedMutationInputContents(PowerForgeAppleReleasePlan plan) + { + var paths = new List(); + if (!string.IsNullOrWhiteSpace(plan.ScreenshotConfigPath)) paths.Add(plan.ScreenshotConfigPath!); + paths.AddRange(plan.ScreenshotConfigPaths); + if (!string.IsNullOrWhiteSpace(plan.MetadataConfigPath)) paths.Add(plan.MetadataConfigPath!); + paths.AddRange(plan.MetadataConfigPaths); + if (!string.IsNullOrWhiteSpace(plan.AppInfoConfigPath)) paths.Add(plan.AppInfoConfigPath!); + paths.AddRange(plan.AppInfoConfigPaths); + if (!string.IsNullOrWhiteSpace(plan.GovernanceConfigPath)) paths.Add(plan.GovernanceConfigPath!); + paths.AddRange(plan.GovernanceConfigPaths); + if (!string.IsNullOrWhiteSpace(plan.VersionSourcePath)) paths.Add(plan.VersionSourcePath!); + + var captured = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var path in paths.Where(static value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + 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}"); + } + captured[fullPath] = System.Text.Encoding.UTF8.GetString(bytes); + } + plan.ApprovedMutationInputContents = captured; + } + + 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( @@ -84,6 +126,11 @@ private PowerForgeAppleReleaseTargetReceipt CreateApplePlanTarget( 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, @@ -435,7 +482,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."); @@ -484,6 +532,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 fe6e15e29..b7f7b6e16 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -1793,6 +1793,9 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( RequestedMarketingVersion = requestedMarketingVersion, SourceCommit = appleSourceCommit.Length == 0 ? null : appleSourceCommit, RequireImmutableSourceSnapshot = request.RequireImmutableAppleSourceSnapshot, + ExactSourceConfigPath = request.RequireImmutableAppleSourceSnapshot && File.Exists(request.ConfigPath) + ? request.ConfigPath + : null, AdoptExistingBuild = request.AppleAdoptExistingBuild, Archive = options.Archive, Upload = options.Upload, @@ -2213,9 +2216,12 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( CaptureAppleArchiveSha256(result, app); var approvedArchiveSha256 = result.ArchiveSha256; var direct = app.DistributionRoute == AppleDistributionRoute.DirectNotarized; + using var uploadSnapshot = string.IsNullOrWhiteSpace(approvedArchiveSha256) + ? null + : AppleArchiveUploadSnapshot.Create(app.ArchivePath, approvedArchiveSha256!); var upload = _uploadAppleApp(new AppleAppArchiveUploadRequest { - ArchivePath = app.ArchivePath, + ArchivePath = uploadSnapshot?.ArchivePath ?? app.ArchivePath, BundleId = app.BundleId, RequiredPrivacyUsageDescriptionKeys = app.RequiredPrivacyUsageDescriptionKeys, ExportPath = app.ExportPath, @@ -2232,6 +2238,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( AppStoreConnectApiIssuerId = direct ? null : plan.AppStoreConnectApiIssuerId, AllowProvisioningUpdates = plan.AllowProvisioningUpdates }); + upload.ArchivePath = app.ArchivePath; result.Upload = upload; CaptureAppleArchiveSha256(result, app, approvedArchiveSha256); if (!upload.Succeeded) @@ -2444,6 +2451,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( ReceiptHistoryPath = plan.ReceiptHistoryPath, SourceCommit = plan.SourceCommit, RequireImmutableSourceSnapshot = plan.RequireImmutableSourceSnapshot, + ExactSourceConfigPath = plan.ExactSourceConfigPath, Archive = true, Upload = false, XcodeBuildExecutable = plan.XcodeBuildExecutable, @@ -2635,7 +2643,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); @@ -2654,7 +2662,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); @@ -2671,7 +2679,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) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index b8b5b96ea..ddcc530fb 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -793,6 +793,39 @@ public void ResolveExactAppleSourceCommit_rejects_clean_smudge_filtered_worktree Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); } + [Fact] + public void AppleSourceSnapshot_rejects_smudged_bytes_created_only_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(() => + { + using var _ = AppleReleaseSourceSnapshot.CreateIfRequired(plan); + }); + + Assert.Contains("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); + } + [Fact] public void ResolveExactAppleSourceCommit_rejects_filtered_bytes_in_synchronized_tree() { From 993cb2763642b36ba4d1d3d71ef07290632ab933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 19:22:48 +0200 Subject: [PATCH 11/78] Bind complete Apple mutation policy --- ...rviceTests.AppleHostedReviewRegressions.cs | 100 ++++++++++++++++++ ...owerForgeReleaseService.AppleVersioning.cs | 56 +++++++--- 2 files changed, 143 insertions(+), 13 deletions(-) diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index e477a4019..52142dbe5 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -148,6 +148,106 @@ public void ApprovedAppleMutationConfig_UsesCapturedBytesAfterSourceReplacement( } } + [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 AppleArchiveUploadSnapshot_RejectsEscapingSymbolicLinks() { diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index ab4006c4a..a1fe11b68 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -30,18 +30,35 @@ private void AssertApplePlanStillApproved( internal static void CaptureApprovedMutationInputContents(PowerForgeAppleReleasePlan plan) { var paths = new List(); - if (!string.IsNullOrWhiteSpace(plan.ScreenshotConfigPath)) paths.Add(plan.ScreenshotConfigPath!); - paths.AddRange(plan.ScreenshotConfigPaths); - if (!string.IsNullOrWhiteSpace(plan.MetadataConfigPath)) paths.Add(plan.MetadataConfigPath!); - paths.AddRange(plan.MetadataConfigPaths); - if (!string.IsNullOrWhiteSpace(plan.AppInfoConfigPath)) paths.Add(plan.AppInfoConfigPath!); - paths.AddRange(plan.AppInfoConfigPaths); - if (!string.IsNullOrWhiteSpace(plan.GovernanceConfigPath)) paths.Add(plan.GovernanceConfigPath!); - paths.AddRange(plan.GovernanceConfigPaths); - if (!string.IsNullOrWhiteSpace(plan.VersionSourcePath)) paths.Add(plan.VersionSourcePath!); - - var captured = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var path in paths.Where(static value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase)) + 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); @@ -54,7 +71,9 @@ internal static void CaptureApprovedMutationInputContents(PowerForgeAppleRelease throw new InvalidOperationException( $"Approved Apple mutation input changed before execution: {relative}"); } - captured[fullPath] = System.Text.Encoding.UTF8.GetString(bytes); + 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; } @@ -325,6 +344,17 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei plan.Configuration, plan.Archive, plan.Upload, + 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, From fa63c89037d9a4203e2a67748c75fc4fba245dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 20:11:04 +0200 Subject: [PATCH 12/78] Complete immutable Apple release execution inputs --- .../AppleNotarizationServiceTests.cs | 70 ++++++++++++++++ .../PowerForgeCliAppleReleaseTests.cs | 12 +-- ...orgeReleaseServiceTests.AppleAutomation.cs | 60 +++++++++----- ...rviceTests.AppleHostedReviewRegressions.cs | 83 +++++++++++++++++++ ...aseServiceTests.AppleReleaseAdvancement.cs | 18 ++-- .../PowerForgeReleaseServiceTests.cs | 3 +- PowerForge/Models/AppleNotarizationModels.cs | 2 +- PowerForge/Models/PowerForgeRelease.cs | 1 + .../Services/AppleArchiveUploadSnapshot.cs | 4 + .../AppleNotarizationInputSnapshot.cs | 81 ++++++++++++++++++ .../Services/AppleNotarizationService.cs | 53 ++++++++++-- .../AppleReleaseSourceTrustService.cs | 59 +++++++++++-- ...owerForgeReleaseService.AppleVersioning.cs | 49 ++++++++++- .../Services/PowerForgeReleaseService.cs | 13 ++- .../Queue/ReleaseBuildExecutionService.cs | 20 ++++- ...dExecutionServiceTests.AppleSourceTrust.cs | 23 +++++ 16 files changed, 491 insertions(+), 60 deletions(-) create mode 100644 PowerForge/Services/AppleNotarizationInputSnapshot.cs diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 0f1b6bdfb..ddae6c7aa 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -31,6 +31,7 @@ public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() 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.Collection( runner.Requests, request => Assert.Equal("ditto-test", request.FileName), @@ -78,6 +79,33 @@ 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_AcceptedCheckpointFailureReportsSubmissionBeforeStapling() { @@ -406,6 +434,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"; @@ -413,6 +443,46 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo } } + 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; diff --git a/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs index 722a5c671..9a379c4f9 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( diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index d9431ea4a..cd98ea5d9 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -401,19 +401,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, "VALID"), + 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); @@ -443,19 +446,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); @@ -849,16 +855,15 @@ public void Execute_AppleAdoptionPlan_BindsTheObservedRemoteBuild() PlanOnly = true }; - var withoutBuild = service.Execute(CreateAppleAutomationSpec(root, keyPath), request); + var withoutBuild = Assert.Throws(() => + service.Execute(CreateAppleAutomationSpec(root, keyPath), request)); processingState = "VALID"; var withBuild = service.Execute(CreateAppleAutomationSpec(root, keyPath), request); - var absentTarget = Assert.Single(withoutBuild.AppleReceipt!.Targets); var presentTarget = Assert.Single(withBuild.AppleReceipt!.Targets); - Assert.Null(absentTarget.BuildId); + Assert.Contains("uniquely selected", withoutBuild.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal("build-id", presentTarget.BuildId); Assert.Equal("VALID", presentTarget.BuildProcessingState); - Assert.NotEqual(withoutBuild.AppleReceipt.PlanSha256, withBuild.AppleReceipt.PlanSha256); } finally { @@ -1442,6 +1447,17 @@ private static AppStoreConnectReleaseStateResult CreateReleaseState( } }; + 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) => new() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index 52142dbe5..414730333 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -248,6 +248,89 @@ public void Execute_ApplePlan_BindsDirectNotarizationControls() } } + [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); + } + } + + [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() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs index 89534b09d..1588b1b41 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs @@ -391,12 +391,15 @@ public void Execute_AppleAdvancePlan_EnablesSafeStepsAndStopsBeforeReview() File.WriteAllText(keyPath, "private-key"); var spec = CreateAppleAutomationSpec(root, keyPath); - 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, "VALID"), + 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); @@ -408,6 +411,9 @@ public void Execute_AppleAdvancePlan_EnablesSafeStepsAndStopsBeforeReview() Assert.False(plan.SubmitTestFlightBetaReview); Assert.False(plan.SubmitForReview); Assert.False(plan.ReleaseApprovedVersion); + var target = Assert.Single(result.AppleReceipt!.Targets); + Assert.Equal("build-id", target.BuildId); + Assert.Equal("VALID", target.BuildProcessingState); } finally { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs index 40e2de816..269299f29 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs @@ -950,7 +950,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 { diff --git a/PowerForge/Models/AppleNotarizationModels.cs b/PowerForge/Models/AppleNotarizationModels.cs index 35a583cea..c0ea185b9 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. diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs index 46facda82..e1bbf6d9d 100644 --- a/PowerForge/Models/PowerForgeRelease.cs +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -37,6 +37,7 @@ internal sealed class PowerForgeReleaseRequest internal string? ResolvedReleaseVersion { get; set; } internal IPowerForgeReleaseProgressReporter? Progress { get; set; } internal CancellationToken CancellationToken { get; set; } + internal string? ExactConfigurationContent { get; set; } public string ConfigPath { get; set; } = string.Empty; diff --git a/PowerForge/Services/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs index c707cccb9..d07f12a89 100644 --- a/PowerForge/Services/AppleArchiveUploadSnapshot.cs +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -24,6 +24,10 @@ internal static AppleArchiveUploadSnapshot Create(string archivePath, string exp 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 { CopyTree(source, snapshotPath); diff --git a/PowerForge/Services/AppleNotarizationInputSnapshot.cs b/PowerForge/Services/AppleNotarizationInputSnapshot.cs new file mode 100644 index 000000000..b57ae0e41 --- /dev/null +++ b/PowerForge/Services/AppleNotarizationInputSnapshot.cs @@ -0,0 +1,81 @@ +namespace PowerForge; + +/// Provides a private, hash-verified notarization submission input. +internal sealed class AppleNotarizationInputSnapshot : IDisposable +{ + private readonly AppleArchiveUploadSnapshot? _directorySnapshot; + private bool _disposed; + + private AppleNotarizationInputSnapshot( + string rootPath, + string artifactPath, + AppleArchiveUploadSnapshot? directorySnapshot) + { + RootPath = rootPath; + ArtifactPath = artifactPath; + _directorySnapshot = directorySnapshot; + } + + internal string RootPath { get; } + + internal string ArtifactPath { get; } + + 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}'."); + } + return new AppleNotarizationInputSnapshot(root, snapshotPath, directorySnapshot: null); + } + catch + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + throw; + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_directorySnapshot is not null) + { + _directorySnapshot.Dispose(); + return; + } + if (Directory.Exists(RootPath)) + Directory.Delete(RootPath, recursive: true); + } +} diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index cec82c2e4..b05f0fb74 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -59,9 +59,19 @@ public async Task NotarizeAsync( if (request.StaplingCompleted && !resumed) throw new ArgumentException("StaplingCompleted requires AcceptedSubmissionId.", nameof(request)); var staplingCompleted = request.StaplingCompleted; - var submissionPath = resumed + using var submissionSnapshot = resumed + ? null + : AppleNotarizationInputSnapshot.Create(artifactPath, artifactSha256); + var submissionArtifactPath = submissionSnapshot?.ArtifactPath ?? artifactPath; + var submittedPath = resumed ? artifactPath - : await PrepareSubmissionAsync(request, artifactPath, timeout, cancellationToken).ConfigureAwait(false); + : await PrepareSubmissionAsync( + request, + submissionArtifactPath, + submissionSnapshot!.RootPath, + timeout, + cancellationToken) + .ConfigureAwait(false); ProcessRunResult submission; string? submissionId; string? status; @@ -82,18 +92,28 @@ 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); + submission = await RunAsync(request.XcrunExecutable, submissionArtifactPath, submitArguments, timeout, cancellationToken).ConfigureAwait(false); (submissionId, status) = ParseSubmission(submission); } + var submissionPath = resumed + ? artifactPath + : PreserveSubmissionPath(request, artifactPath, submittedPath); if (!resumed && submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(submissionId)) { + var submittedArtifactSha256 = ComputeArtifactSha256(submissionArtifactPath); + if (!submittedArtifactSha256.Equals(artifactSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Apple accepted notarization submission '{submissionId}', but the private submitted artifact changed during notarytool execution. " + + "Do not resubmit until the accepted submission has been reconciled."); + } try { request.AcceptedCheckpoint?.Invoke(new AppleNotarizationAcceptedCheckpoint @@ -190,15 +210,16 @@ public async Task NotarizeAsync( private async Task PrepareSubmissionAsync( AppleNotarizationRequest request, string artifactPath, + string privateRoot, TimeSpan timeout, CancellationToken cancellationToken) { if (!Path.GetExtension(artifactPath).Equals(".app", StringComparison.OrdinalIgnoreCase)) return 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)!); var package = await RunAsync( request.DittoExecutable, @@ -211,6 +232,24 @@ private async Task PrepareSubmissionAsync( return submissionPath; } + private static string PreserveSubmissionPath( + AppleNotarizationRequest request, + string originalArtifactPath, + string submittedPath) + { + if (!Path.GetExtension(originalArtifactPath).Equals(".app", StringComparison.OrdinalIgnoreCase)) + return originalArtifactPath; + + var retainedPath = string.IsNullOrWhiteSpace(request.SubmissionPath) + ? Path.Combine( + Path.GetDirectoryName(originalArtifactPath)!, + Path.GetFileNameWithoutExtension(originalArtifactPath) + ".notarization.zip") + : Path.GetFullPath(request.SubmissionPath!); + Directory.CreateDirectory(Path.GetDirectoryName(retainedPath)!); + File.Copy(submittedPath, retainedPath, overwrite: true); + return retainedPath; + } + private static string[] BuildAuthenticationArguments(AppleNotarizationRequest request) { if (!string.IsNullOrWhiteSpace(request.KeychainProfile)) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index c9b24aa16..26eb223b7 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -42,9 +42,14 @@ internal AppleReleaseSourceTrustSnapshot Capture(string repositoryRoot, string c EnsureNoGitReplacementRefs(root); _git.EnsureClean(root); var sourceCommitBeforeValidation = ReadExactHead(root); - EnsureTrackedFile(root, releaseConfigPath, "Apple release configuration"); - - var spec = PowerForgeReleaseService.LoadConfiguration(releaseConfigPath); + var releaseConfigBytes = File.ReadAllBytes(releaseConfigPath); + EnsureTrackedFile( + root, + releaseConfigPath, + "Apple release configuration", + ComputeRawGitBlobId(root, 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); @@ -58,7 +63,7 @@ internal AppleReleaseSourceTrustSnapshot Capture(string repositoryRoot, string c 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); + return new AppleReleaseSourceTrustSnapshot(sourceCommitAfterValidation, generatedOutputs, releaseConfigContent); } internal void ValidateAfterBuild( @@ -80,7 +85,9 @@ internal void ValidateAfterBuild( } EnsureTrackedFile(root, releaseConfigPath, "Apple release configuration"); - var options = PowerForgeReleaseService.LoadConfiguration(releaseConfigPath).AppleApps + 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)) @@ -459,7 +466,11 @@ private static bool IsPotentialAppleBuildInput( private static string[] ResolveSynchronizedRoots(IEnumerable metadataPaths) => ResolveObjectAwareSynchronizedRoots(metadataPaths); - private void EnsureTrackedFile(string repositoryRoot, string path, string name) + private void EnsureTrackedFile( + string repositoryRoot, + string path, + string name, + string? capturedWorktreeBlob = null) { var candidate = Path.GetFullPath(path); EnsurePathWithinRepository(repositoryRoot, candidate, name); @@ -482,7 +493,7 @@ private void EnsureTrackedFile(string repositoryRoot, string path, string name) 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}"); - var worktreeBlob = ComputeRawGitBlobId(repositoryRoot, candidate); + var worktreeBlob = capturedWorktreeBlob ?? ComputeRawGitBlobId(repositoryRoot, candidate); if (!headBlob.StdOut.Trim().Equals(worktreeBlob, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); } @@ -512,6 +523,32 @@ private string ComputeRawGitBlobId(string repositoryRoot, string filePath) return BitConverter.ToString(hash.Hash!).Replace("-", string.Empty).ToLowerInvariant(); } + private string ComputeRawGitBlobId(string repositoryRoot, byte[] content) + { + var root = Path.GetFullPath(repositoryRoot); + if (!_gitObjectFormats.TryGetValue(root, out var objectFormat)) + { + objectFormat = RunGit(root, "rev-parse", "--show-object-format").StdOut.Trim(); + _gitObjectFormats[root] = objectFormat; + } + 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 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(); @@ -625,13 +662,19 @@ private static StringComparer GetPathComparer() internal sealed class AppleReleaseSourceTrustSnapshot { - internal AppleReleaseSourceTrustSnapshot(string sourceCommit, string[] generatedOutputPaths) + internal AppleReleaseSourceTrustSnapshot( + string sourceCommit, + string[] generatedOutputPaths, + string? exactConfigurationContent = null) { SourceCommit = sourceCommit; GeneratedOutputPaths = generatedOutputPaths; + ExactConfigurationContent = exactConfigurationContent; } internal string SourceCommit { get; } internal string[] GeneratedOutputPaths { get; } + + internal string? ExactConfigurationContent { get; } } diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index a1fe11b68..3aa6204cc 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -172,6 +172,13 @@ private PowerForgeAppleReleaseTargetReceipt CreateApplePlanTarget( 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; @@ -226,7 +233,7 @@ private PowerForgeAppleReleaseTargetReceipt CreateApplePlanTarget( return target; } - private static bool RequiresObservedApplePlanState( + internal static bool RequiresObservedApplePlanState( PowerForgeAppleReleasePlan plan, PowerForgeAppleAppReleaseTargetPlan app) { @@ -234,17 +241,34 @@ private static bool RequiresObservedApplePlanState( return false; if (plan.Action is PowerForgeAppleReleaseAction.SubmitTestFlightReview or PowerForgeAppleReleaseAction.SubmitAppReview or - PowerForgeAppleReleaseAction.Release) + 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.Prepare or + PowerForgeAppleReleaseAction.TestFlight or + PowerForgeAppleReleaseAction.Advance || + plan.AdoptExistingBuild || + plan.PrepareDistribution || + plan.DistributeTestFlight || + plan.SubmitTestFlightBetaReview || + plan.SubmitForReview; + private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt receipt) { var canonical = new @@ -344,6 +368,27 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei 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, + 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index b7f7b6e16..87d69b109 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -926,10 +926,15 @@ 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}"); + return LoadConfigurationContent(File.ReadAllText(fullPath), fullPath); + } + + 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}"); } internal PowerForgeReleaseResult PublishBuiltReleaseOutputs( diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs index 0a23fd0c9..afefbb48c 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs @@ -94,6 +94,14 @@ public async Task ExecuteAsync(string repositoryRoo 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; @@ -168,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, @@ -184,8 +193,11 @@ 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; @@ -203,7 +215,9 @@ 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); } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index ddcc530fb..7bbab1a84 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -5,6 +5,29 @@ 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() { From 1f5d833a219bf14225d4e6e248e7ba2cbab8921c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 20:56:08 +0200 Subject: [PATCH 13/78] Harden Apple export and source provenance --- .../Assert-TrackedAppleReleaseInputs.ps1 | 2 +- .../Invoke-PowerForgeAppleRelease.ps1 | 4 +- .../Program.Command.AppleScreenshots.cs | 4 +- .../AppStoreConnectScreenshotApprovalTests.cs | 3 +- .../AppleNotarizationServiceTests.cs | 33 +++++ ...orgeReleaseServiceTests.AppleAutomation.cs | 4 +- ...rviceTests.AppleHostedReviewRegressions.cs | 77 +++++++++++ ...aseServiceTests.AppleNotarizationResume.cs | 33 +++++ ...ppStoreConnectScreenshotApprovalService.cs | 8 +- ...oreConnectScreenshotSyncConfigValidator.cs | 11 +- .../Services/AppleArchiveUploadSnapshot.cs | 69 +--------- PowerForge/Services/AppleArtifactCopy.cs | 73 ++++++++++ .../Services/AppleDirectExportSnapshot.cs | 128 ++++++++++++++++++ .../Services/AppleNotarizationService.cs | 23 +++- .../AppleReleaseSourceTrustService.Xcode.cs | 56 +++++++- .../AppleReleaseSourceTrustService.cs | 29 ++-- PowerForge/Services/GitObjectId.cs | 22 +++ ...eReleaseService.AppleDirectDistribution.cs | 26 +++- .../PowerForgeReleaseService.AppleResume.cs | 5 +- .../Services/PowerForgeReleaseService.cs | 24 +++- .../Queue/ReleaseBuildExecutionService.cs | 4 +- ...dExecutionServiceTests.AppleSourceTrust.cs | 74 ++++++++++ scripts/Invoke-PinnedPowerForge.Evidence.ps1 | 10 +- scripts/Invoke-PinnedPowerForge.ps1 | 2 +- 24 files changed, 599 insertions(+), 125 deletions(-) create mode 100644 PowerForge/Services/AppleArtifactCopy.cs create mode 100644 PowerForge/Services/AppleDirectExportSnapshot.cs create mode 100644 PowerForge/Services/GitObjectId.cs 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/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.Tests/AppStoreConnectScreenshotApprovalTests.cs b/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs index fad1b7c26..5629f1753 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); diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index ddae6c7aa..1872212e1 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -134,6 +134,39 @@ public async Task NotarizeAsync_AcceptedCheckpointFailureReportsSubmissionBefore } } + [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() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index cd98ea5d9..720f93690 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -109,12 +109,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, 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); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index 414730333..4c9f8f979 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -966,4 +966,81 @@ public void Execute_DirectNotarizationCrash_PersistsExactPostStapleCheckpoint() 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); + } + finally + { + TryDelete(root); + } + } } diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 2bc7c2d75..79e42b5d9 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -39,6 +39,39 @@ public void DirectNotarizationResume_RequiresCheckpointArchiveIdentity() Assert.True(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app)); } + [Fact] + public void DirectNotarizationResume_UsesPlatformCorrectProjectPathIdentity() + { + var plan = new PowerForgeAppleReleasePlan { ProjectRoot = Directory.GetCurrentDirectory() }; + var app = new PowerForgeAppleAppReleaseTargetPlan + { + Name = "CasaRay", + BundleId = "com.evotecit.casaray", + Platform = ApplePlatform.macOS, + ProjectPath = Path.Combine(plan.ProjectRoot, "CasaRay.xcodeproj"), + 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 + }; + + var matches = PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app); + + Assert.Equal(Path.DirectorySeparatorChar == '\\', matches); + } + [Fact] public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterCheckoutRelocation() { 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/AppStoreConnectScreenshotSyncConfigValidator.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs index 326d6abeb..c1be2ed2c 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs @@ -106,17 +106,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)) { diff --git a/PowerForge/Services/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs index d07f12a89..fc68d45e0 100644 --- a/PowerForge/Services/AppleArchiveUploadSnapshot.cs +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -30,7 +30,7 @@ internal static AppleArchiveUploadSnapshot Create(string archivePath, string exp #endif try { - CopyTree(source, snapshotPath); + AppleArtifactCopy.CopyDirectory(source, snapshotPath); var actual = AppleNotarizationService.ComputeArtifactSha256(snapshotPath); if (!actual.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) { @@ -56,71 +56,4 @@ public void Dispose() Directory.Delete(RootPath, recursive: true); } - private static void CopyTree(string sourceRoot, string destinationRoot) - { - Directory.CreateDirectory(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 archive symbolic link: {sourcePath}"); - if (Path.IsPathRooted(linkTarget)) - throw new InvalidOperationException($"Apple archive symbolic links must remain inside the archive: {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 archive symbolic links must remain inside the archive: {sourcePath}"); - } - if (isDirectory) - Directory.CreateSymbolicLink(destinationPath, linkTarget!); - else - File.CreateSymbolicLink(destinationPath, linkTarget!); -#else - throw new PlatformNotSupportedException("Apple archive symbolic-link snapshots require .NET 8 or newer."); -#endif - } - else if (isDirectory) - { - Directory.CreateDirectory(destinationPath); - pending.Push((sourcePath, destinationPath)); - } - else - { - File.Copy(sourcePath, destinationPath, overwrite: false); - } - - if (!isLink) - { -#if NET8_0_OR_GREATER - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(destinationPath, File.GetUnixFileMode(sourcePath)); -#endif - File.SetAttributes(destinationPath, attributes); - } - } - } - -#if NET8_0_OR_GREATER - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(destinationRoot, File.GetUnixFileMode(sourceRoot)); -#endif - File.SetAttributes(destinationRoot, File.GetAttributes(sourceRoot)); - } } diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs new file mode 100644 index 000000000..a93f4fd66 --- /dev/null +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -0,0 +1,73 @@ +namespace PowerForge; + +/// Copies Apple artifacts without following symbolic links outside their owning tree. +internal static class AppleArtifactCopy +{ + internal static void CopyDirectory(string sourceRoot, string destinationRoot) + { + Directory.CreateDirectory(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); + pending.Push((sourcePath, destinationPath)); + } + else + { + File.Copy(sourcePath, destinationPath, overwrite: false); + } + + if (!isLink) + { +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(destinationPath, File.GetUnixFileMode(sourcePath)); +#endif + File.SetAttributes(destinationPath, attributes); + } + } + } + +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(destinationRoot, File.GetUnixFileMode(sourceRoot)); +#endif + File.SetAttributes(destinationRoot, File.GetAttributes(sourceRoot)); + } +} diff --git a/PowerForge/Services/AppleDirectExportSnapshot.cs b/PowerForge/Services/AppleDirectExportSnapshot.cs new file mode 100644 index 000000000..245842e66 --- /dev/null +++ b/PowerForge/Services/AppleDirectExportSnapshot.cs @@ -0,0 +1,128 @@ +namespace PowerForge; + +/// Owns a private Developer ID export and publishes its verified bytes atomically. +internal sealed class AppleDirectExportSnapshot : IDisposable +{ + private bool _disposed; + + 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 ApplePublishedDirectExport Publish(string destinationExportPath) + { + var sourceArtifact = PowerForgeReleaseService.ResolveDirectAppleArtifactPath(ExportPath); + var sourceArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(sourceArtifact); + var relativeArtifactPath = FrameworkCompatibility.GetRelativePath(ExportPath, sourceArtifact); + 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: {sourceArtifact}"); + } + + var destination = Path.GetFullPath(destinationExportPath); + var parent = Path.GetDirectoryName(destination) + ?? throw new InvalidOperationException($"Developer ID export path has no parent: {destination}"); + Directory.CreateDirectory(parent); + if (File.Exists(destination) || + (Directory.Exists(destination) && (File.GetAttributes(destination) & FileAttributes.ReparsePoint) != 0)) + { + throw new InvalidOperationException($"Developer ID export path must be a regular directory: {destination}"); + } + + var name = Path.GetFileName(destination); + var stage = Path.Combine(parent, $".{name}.powerforge-stage-{Guid.NewGuid():N}"); + var backup = Path.Combine(parent, $".{name}.powerforge-backup-{Guid.NewGuid():N}"); + var movedExisting = false; + var published = false; + try + { + AppleArtifactCopy.CopyDirectory(ExportPath, stage); + 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}'."); + } + + if (Directory.Exists(destination)) + { + Directory.Move(destination, backup); + movedExisting = true; + } + 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}'."); + } + if (movedExisting && Directory.Exists(backup)) + Directory.Delete(backup, recursive: true); + return new ApplePublishedDirectExport(destination, publishedArtifact, publishedSha256); + } + catch + { + if (published && Directory.Exists(destination)) + Directory.Delete(destination, recursive: true); + if (movedExisting && Directory.Exists(backup)) + Directory.Move(backup, destination); + throw; + } + finally + { + if (Directory.Exists(stage)) + Directory.Delete(stage, recursive: true); + if (Directory.Exists(backup)) + Directory.Delete(backup, recursive: true); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (Directory.Exists(RootPath)) + Directory.Delete(RootPath, recursive: true); + } +} + +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/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index b05f0fb74..59bcdd8f7 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -100,7 +100,7 @@ public async Task NotarizeAsync( } var submissionPath = resumed ? artifactPath - : PreserveSubmissionPath(request, artifactPath, submittedPath); + : ResolveRetainedSubmissionPath(request, artifactPath); if (!resumed && submission.Succeeded && @@ -134,6 +134,9 @@ public async Task NotarizeAsync( } } + if (!resumed) + PreserveSubmissionPath(artifactPath, submittedPath, submissionPath); + ProcessRunResult? staple = null; ProcessRunResult? validation = null; ProcessRunResult? assessment = null; @@ -232,22 +235,30 @@ private async Task PrepareSubmissionAsync( return submissionPath; } - private static string PreserveSubmissionPath( + private static string ResolveRetainedSubmissionPath( AppleNotarizationRequest request, - string originalArtifactPath, - string submittedPath) + string originalArtifactPath) { if (!Path.GetExtension(originalArtifactPath).Equals(".app", StringComparison.OrdinalIgnoreCase)) return originalArtifactPath; - var retainedPath = string.IsNullOrWhiteSpace(request.SubmissionPath) + 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) + { + if (!Path.GetExtension(originalArtifactPath).Equals(".app", StringComparison.OrdinalIgnoreCase)) + return; + Directory.CreateDirectory(Path.GetDirectoryName(retainedPath)!); File.Copy(submittedPath, retainedPath, overwrite: true); - return retainedPath; } private static string[] BuildAuthenticationArguments(AppleNotarizationRequest request) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 2ec56c605..644974337 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -411,8 +411,10 @@ private void ValidateLocalPackageReference( } var manifest = File.ReadAllText(manifestPath); + var manifestWithoutComments = RemoveSwiftComments(manifest); + var manifestSyntax = MaskSwiftStringLiterals(manifestWithoutComments); if (Regex.IsMatch( - manifest, + manifestSyntax, "\\.unsafeFlags\\s*\\(", RegexOptions.CultureInvariant)) { @@ -420,8 +422,17 @@ private void ValidateLocalPackageReference( $"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 (Regex.IsMatch( + manifestSyntax, + "\\.systemLibrary\\s*\\(", + RegexOptions.CultureInvariant)) + { + 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."); + } var externalDependencies = Regex.Matches( - manifest, + manifestWithoutComments, "\\.package\\s*\\((?.*?)\\)", RegexOptions.Singleline | RegexOptions.CultureInvariant) .Cast() @@ -461,7 +472,6 @@ private void ValidateLocalPackageReference( foreach (var packageLock in locks) EnsureTrackedFile(repositoryRoot, packageLock, "Xcode local Swift package resolution lock"); } - var manifestWithoutComments = RemoveSwiftComments(manifest); var pathArguments = Regex.Matches( manifestWithoutComments, "\\bpath\\s*:", @@ -540,6 +550,46 @@ private static string RemoveSwiftComments(string source) return new string(result); } + private static string MaskSwiftStringLiterals(string source) + { + var result = source.ToCharArray(); + for (var index = 0; index < result.Length; index++) + { + if (result[index] != '"') + continue; + + var multiline = index + 2 < result.Length && result[index + 1] == '"' && result[index + 2] == '"'; + var delimiterLength = multiline ? 3 : 1; + for (var offset = 0; offset < delimiterLength; offset++) + result[index + offset] = ' '; + index += delimiterLength; + var escaped = false; + while (index < result.Length) + { + if (!multiline && !escaped && result[index] == '"') + { + result[index] = ' '; + break; + } + if (multiline && index + 2 < result.Length && + result[index] == '"' && result[index + 1] == '"' && result[index + 2] == '"') + { + result[index] = result[index + 1] = result[index + 2] = ' '; + index += 2; + break; + } + if (!multiline && !escaped && result[index] == '\\') + escaped = true; + else + escaped = false; + if (result[index] != '\r' && result[index] != '\n') + result[index] = ' '; + index++; + } + } + return new string(result); + } + private void ValidateRemotePackageReference( string repositoryRoot, IReadOnlyCollection packageLockRoots, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 26eb223b7..f045bd613 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -500,12 +500,7 @@ private void EnsureTrackedFile( private string ComputeRawGitBlobId(string repositoryRoot, string filePath) { - var root = Path.GetFullPath(repositoryRoot); - if (!_gitObjectFormats.TryGetValue(root, out var objectFormat)) - { - objectFormat = RunGit(root, "rev-parse", "--show-object-format").StdOut.Trim(); - _gitObjectFormats[root] = objectFormat; - } + 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) @@ -525,12 +520,7 @@ private string ComputeRawGitBlobId(string repositoryRoot, string filePath) private string ComputeRawGitBlobId(string repositoryRoot, byte[] content) { - var root = Path.GetFullPath(repositoryRoot); - if (!_gitObjectFormats.TryGetValue(root, out var objectFormat)) - { - objectFormat = RunGit(root, "rev-parse", "--show-object-format").StdOut.Trim(); - _gitObjectFormats[root] = objectFormat; - } + 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) @@ -633,11 +623,22 @@ private static bool IsPathAtOrWithin(string path, string root) private string ReadExactHead(string repositoryRoot) { var sourceCommit = _git.GetHeadSha(repositoryRoot).Trim(); - if (sourceCommit.Length != 40 || !sourceCommit.All(Uri.IsHexDigit)) - throw new InvalidOperationException("Apple release checkpoints require an exact 40-character repository HEAD."); + 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); 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.AppleDirectDistribution.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs index a2eea2b77..5dea19ec8 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs @@ -32,7 +32,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}"); @@ -53,6 +53,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.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 9c3ef0fee..1f6c24a6f 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -404,7 +404,10 @@ internal static bool IsMatchingDirectReceiptTarget( string.Equals(target.BundleId, app.BundleId, StringComparison.OrdinalIgnoreCase) && target.Platform == app.Platform && string.Equals(target.Configuration, app.Configuration, StringComparison.OrdinalIgnoreCase) && - string.Equals(target.ProjectPath, FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), StringComparison.OrdinalIgnoreCase) && + string.Equals( + target.ProjectPath, + FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/'), + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) && target.IsWorkspace == app.IsWorkspace && string.Equals(target.Scheme, app.Scheme, StringComparison.Ordinal) && target.ArchiveVariant == app.ArchiveVariant && diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 87d69b109..06c7a946f 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -1656,10 +1656,9 @@ 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) && @@ -2224,12 +2223,13 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( using var uploadSnapshot = string.IsNullOrWhiteSpace(approvedArchiveSha256) ? null : AppleArchiveUploadSnapshot.Create(app.ArchivePath, approvedArchiveSha256!); + using var directExportSnapshot = direct ? AppleDirectExportSnapshot.Create() : null; var upload = _uploadAppleApp(new AppleAppArchiveUploadRequest { ArchivePath = uploadSnapshot?.ArchivePath ?? app.ArchivePath, BundleId = app.BundleId, RequiredPrivacyUsageDescriptionKeys = app.RequiredPrivacyUsageDescriptionKeys, - ExportPath = app.ExportPath, + ExportPath = directExportSnapshot?.ExportPath ?? app.ExportPath, TeamId = app.TeamId, XcodeBuildExecutable = plan.XcodeBuildExecutable, SigningStyle = plan.SigningStyle, @@ -2257,7 +2257,21 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( if (direct) { - result.Notarization = NotarizeDirectAppleExport(plan, app); + var published = directExportSnapshot!.Publish(app.ExportPath); + upload.ExportPath = published.ExportPath; + 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); diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs index afefbb48c..08bef429c 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs @@ -226,8 +226,8 @@ internal static string ResolveExactGitHead(string repositoryRoot) var git = new HomeAssistantReleaseGitService(); git.EnsureClean(repositoryRoot); var sourceCommit = git.GetHeadSha(repositoryRoot).Trim(); - if (sourceCommit.Length != 40 || !sourceCommit.All(Uri.IsHexDigit)) - throw new InvalidOperationException("Apple release checkpoints require an exact 40-character repository HEAD."); + if (!GitObjectId.IsFull(sourceCommit)) + throw new InvalidOperationException("Apple release checkpoints require a full SHA-1 or SHA-256 repository HEAD."); return sourceCommit.ToLowerInvariant(); } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index 7bbab1a84..37ca48f9f 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -766,6 +766,63 @@ import PackageDescription 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 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); + } + [Fact] public void ResolveExactAppleSourceCommit_rejects_computed_local_package_path() { @@ -1004,6 +1061,23 @@ public void ResolveExactAppleSourceCommit_rejects_git_replacement_refs() 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, diff --git a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 index 07606c390..d4d8b08b4 100644 --- a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 +++ b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 @@ -32,7 +32,7 @@ function Register-AppleReceiptEvidenceFile { ([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}$')) { + $receiptSource -notmatch '^(?:[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$')) { throw "Apple release evidence does not satisfy the immutable receipt contract: $Path" } } elseif (-not $HistoryEntry -and @@ -45,8 +45,8 @@ function Register-AppleReceiptEvidenceFile { 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() @@ -79,8 +79,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'." diff --git a/scripts/Invoke-PinnedPowerForge.ps1 b/scripts/Invoke-PinnedPowerForge.ps1 index 7ae608118..a9b757150 100644 --- a/scripts/Invoke-PinnedPowerForge.ps1 +++ b/scripts/Invoke-PinnedPowerForge.ps1 @@ -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()) { From 6037e21558bb46dba092ec4e99a344f1f01b9895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 21:46:32 +0200 Subject: [PATCH 14/78] Harden Apple notarization and screenshot replacement integrity --- .../AppStoreConnectClientTests.cs | 81 ++++++++- .../AppleNotarizationServiceTests.cs | 95 ++++++++++- .../PowerForgeCliAppleReleaseTests.cs | 2 +- ...orgeReleaseServiceTests.AppleAutomation.cs | 154 ++++++++++++++++-- ...aseServiceTests.AppleNotarizationResume.cs | 10 ++ .../PowerForgeReleaseServiceTests.cs | 4 +- ...AppStoreConnectReleasePreparationModels.cs | 3 + .../AppStoreConnectScreenshotSyncModels.cs | 3 + .../AppStoreConnectVersionMetadataModels.cs | 24 +++ .../PowerForgeAppleReleaseAutomation.cs | 2 + PowerForge/Models/PowerForgeRelease.cs | 3 + ...ppStoreConnectReleasePreparationService.cs | 3 +- .../AppStoreConnectReleaseReadinessService.cs | 10 +- .../AppStoreConnectScreenshotInventory.cs | 32 ++++ .../AppStoreConnectScreenshotSyncService.cs | 26 +++ .../AppleNotarizationInputSnapshot.cs | 104 ++++++++++++ .../Services/AppleNotarizationService.cs | 39 +++-- .../AppleReleaseSourceTrustService.Xcode.cs | 16 +- .../PowerForgeReleaseService.AppleResume.cs | 17 +- ...owerForgeReleaseService.AppleVersioning.cs | 111 +++++++++---- .../Services/PowerForgeReleaseService.cs | 1 + ...dExecutionServiceTests.AppleSourceTrust.cs | 28 ++++ 22 files changed, 686 insertions(+), 82 deletions(-) create mode 100644 PowerForge/Services/AppStoreConnectScreenshotInventory.cs diff --git a/PowerForge.Tests/AppStoreConnectClientTests.cs b/PowerForge.Tests/AppStoreConnectClientTests.cs index 2d166173f..63636fb96 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] @@ -1239,6 +1248,76 @@ 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 */ } + } + } + private static SequenceResponse ScreenshotReservation(string id, string fileName, long fileSize) => new(HttpStatusCode.Created, $$"""{ "data": { "id": "{{id}}", "type": "appScreenshots", "attributes": { "fileName": "{{fileName}}", "fileSize": {{fileSize}}, "uploadOperations": [] } } }"""); diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 1872212e1..451580292 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -32,6 +32,7 @@ public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() Assert.Equal("Accepted", result.Status); Assert.EndsWith(".notarization.zip", result.SubmissionPath, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(result.SubmissionPath)); + string? privateArtifactPath = null; Assert.Collection( runner.Requests, request => Assert.Equal("ditto-test", request.FileName), @@ -41,12 +42,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 @@ -106,6 +113,35 @@ public async Task NotarizeAsync_SubmitsPrivateImmutableArtifactSnapshot() } } + [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_AcceptedCheckpointFailureReportsSubmissionBeforeStapling() { @@ -182,13 +218,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 @@ -398,6 +440,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 @@ -532,11 +579,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"; @@ -551,4 +600,38 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo return Task.FromResult(result); } } + + 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/PowerForgeCliAppleReleaseTests.cs b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs index 9a379c4f9..b3b7bce27 100644 --- a/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs +++ b/PowerForge.Tests/PowerForgeCliAppleReleaseTests.cs @@ -151,7 +151,7 @@ public async Task AppleRelease_CliUsesDedicatedEnvelopeAndReportsLegacyConfirmat 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/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index 720f93690..a2dc21298 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -338,6 +338,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() { @@ -483,19 +613,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); @@ -1575,7 +1709,7 @@ public void Execute_ApplePlan_BindsScreenshotPixelBytes() 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", "app-123", "1.2.0", "iOS", "screenshots", qualityEnabled: false); + 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"; diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 79e42b5d9..0c45f8bd9 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -72,6 +72,16 @@ public void DirectNotarizationResume_UsesPlatformCorrectProjectPathIdentity() Assert.Equal(Path.DirectorySeparatorChar == '\\', matches); } + [Fact] + public void UploadAttestationResume_UsesPlatformCorrectProjectPathIdentity() + { + var matches = PowerForgeReleaseService.AppleReleasePathsEqual( + "CasaRay.xcodeproj", + "casaray.xcodeproj"); + + Assert.Equal(Path.DirectorySeparatorChar == '\\', matches); + } + [Fact] public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterCheckoutRelocation() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs index 269299f29..7b01dfc49 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs @@ -798,7 +798,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 { diff --git a/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs b/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs index ea5d0e273..3551c3034 100644 --- a/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs +++ b/PowerForge/Models/AppStoreConnectReleasePreparationModels.cs @@ -55,6 +55,9 @@ public sealed class AppStoreConnectReleasePreparationRequest /// 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 cc8911e71..8f5727014 100644 --- a/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs +++ b/PowerForge/Models/AppStoreConnectScreenshotSyncModels.cs @@ -246,6 +246,9 @@ public sealed class AppStoreConnectScreenshotSyncRequest /// 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/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/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index 054b6fc6d..fe75126dc 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -306,6 +306,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; } diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs index e1bbf6d9d..11451fcfe 100644 --- a/PowerForge/Models/PowerForgeRelease.cs +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -667,6 +667,9 @@ internal sealed class PowerForgeAppleAppReleaseTargetPlan /// 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; } diff --git a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs index 4e04da309..b59c06900 100644 --- a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs +++ b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs @@ -145,7 +145,8 @@ request.ScreenshotSpec is not null || ReplaceExisting = request.ReplaceScreenshots, BaseDirectory = request.BaseDirectory, ExpectedSourceCommit = request.ExpectedSourceCommit, - ExpectedFileSha256 = request.ExpectedScreenshotFileSha256 + ExpectedFileSha256 = request.ExpectedScreenshotFileSha256, + ExpectedRemoteInventorySha256 = request.ExpectedScreenshotInventorySha256 }, cancellationToken).ConfigureAwait(false); messages.Add("Synchronized App Store screenshots."); 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/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/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index e4a1337e8..f2d16bdca 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -117,6 +117,32 @@ public async Task SyncAsync( plannedSets.Add(new PlannedScreenshotSet(preflightedSet, set, existingScreenshots)); } + if (request.ReplaceExisting && !string.IsNullOrWhiteSpace(request.ExpectedRemoteInventorySha256)) + { + var remoteInventory = plannedSets.Select(static plannedSet => + new AppStoreConnectReleaseScreenshotSetReadiness + { + ScreenshotDisplayType = plannedSet.Preflighted.ScreenshotDisplayType, + ScreenshotSetId = plannedSet.ScreenshotSet?.Id, + Count = plannedSet.ExistingScreenshots.Length, + Screenshots = plannedSet.ExistingScreenshots.Select(static screenshot => + new AppStoreConnectReleaseScreenshotAssetReadiness + { + Id = screenshot.Id, + FileName = screenshot.FileName, + FileSize = screenshot.FileSize, + SourceFileChecksum = screenshot.SourceFileChecksum, + AssetDeliveryState = screenshot.AssetDeliveryState + }).ToArray() + }); + 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) { diff --git a/PowerForge/Services/AppleNotarizationInputSnapshot.cs b/PowerForge/Services/AppleNotarizationInputSnapshot.cs index b57ae0e41..23981b869 100644 --- a/PowerForge/Services/AppleNotarizationInputSnapshot.cs +++ b/PowerForge/Services/AppleNotarizationInputSnapshot.cs @@ -20,6 +20,96 @@ private AppleNotarizationInputSnapshot( internal string ArtifactPath { get; } + 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 name = Path.GetFileName(destination); + var stageRoot = Path.Combine(parent, $".powerforge-stage-{Guid.NewGuid():N}"); + var stage = Path.Combine(stageRoot, name); + var backup = Path.Combine(parent, $".{name}.powerforge-backup-{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}'."); + } + + if (Directory.Exists(destination)) + { + Directory.Move(destination, backup); + movedExisting = true; + } + else if (File.Exists(destination)) + { + File.Move(destination, backup); + movedExisting = true; + } + + 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}'."); + } + + TryDeletePath(backup); + return publishedSha256; + } + catch + { + if (published) + DeletePath(destination); + if (movedExisting) + { + if (Directory.Exists(backup)) + Directory.Move(backup, destination); + else if (File.Exists(backup)) + File.Move(backup, destination); + } + throw; + } + finally + { + TryDeletePath(stageRoot); + } + } + internal static AppleNotarizationInputSnapshot Create(string artifactPath, string expectedSha256) { var source = Path.GetFullPath(artifactPath); @@ -78,4 +168,18 @@ public void Dispose() if (Directory.Exists(RootPath)) Directory.Delete(RootPath, recursive: true); } + + private static void DeletePath(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + 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.cs b/PowerForge/Services/AppleNotarizationService.cs index 59bcdd8f7..b62515ef4 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -59,16 +59,14 @@ public async Task NotarizeAsync( if (request.StaplingCompleted && !resumed) throw new ArgumentException("StaplingCompleted requires AcceptedSubmissionId.", nameof(request)); var staplingCompleted = request.StaplingCompleted; - using var submissionSnapshot = resumed - ? null - : AppleNotarizationInputSnapshot.Create(artifactPath, artifactSha256); - var submissionArtifactPath = submissionSnapshot?.ArtifactPath ?? artifactPath; + using var submissionSnapshot = AppleNotarizationInputSnapshot.Create(artifactPath, artifactSha256); + var submissionArtifactPath = submissionSnapshot.ArtifactPath; var submittedPath = resumed ? artifactPath : await PrepareSubmissionAsync( request, submissionArtifactPath, - submissionSnapshot!.RootPath, + submissionSnapshot.RootPath, timeout, cancellationToken) .ConfigureAwait(false); @@ -155,14 +153,15 @@ public async Task NotarizeAsync( } else { - staple = await RunAsync(request.XcrunExecutable, artifactPath, new[] { "stapler", "staple", artifactPath }, timeout, cancellationToken).ConfigureAwait(false); + staple = await RunAsync(request.XcrunExecutable, submissionArtifactPath, new[] { "stapler", "staple", submissionArtifactPath }, timeout, cancellationToken).ConfigureAwait(false); stapledThisInvocation = staple.Succeeded; } if (staple?.Succeeded == true && validation is null) - validation = await RunAsync(request.XcrunExecutable, artifactPath, new[] { "stapler", "validate", artifactPath }, timeout, cancellationToken).ConfigureAwait(false); + validation = await RunAsync(request.XcrunExecutable, submissionArtifactPath, new[] { "stapler", "validate", submissionArtifactPath }, timeout, cancellationToken).ConfigureAwait(false); if (stapledThisInvocation && validation?.Succeeded == true && !string.IsNullOrWhiteSpace(submissionId)) { - var stapledArtifactSha256 = ComputeArtifactSha256(artifactPath); + var privateStapledArtifactSha256 = ComputeArtifactSha256(submissionArtifactPath); + var stapledArtifactSha256 = submissionSnapshot.PublishTo(artifactPath, privateStapledArtifactSha256); try { request.StapledCheckpoint?.Invoke(new AppleNotarizationStapledCheckpoint @@ -185,20 +184,36 @@ public async Task NotarizeAsync( if (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Assess) { 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(request.SpctlExecutable, submissionArtifactPath, assessmentArguments, timeout, cancellationToken).ConfigureAwait(false); + } + + string finalArtifactSha256; + if (request.Staple && staple?.Succeeded == true && validation?.Succeeded == true) + { + var privateFinalSha256 = ComputeArtifactSha256(submissionArtifactPath); + finalArtifactSha256 = submissionSnapshot.PublishTo(artifactPath, privateFinalSha256); + } + 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, SubmissionId = submissionId, Status = status, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 644974337..99ad13bba 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -413,19 +413,13 @@ private void ValidateLocalPackageReference( var manifest = File.ReadAllText(manifestPath); var manifestWithoutComments = RemoveSwiftComments(manifest); var manifestSyntax = MaskSwiftStringLiterals(manifestWithoutComments); - if (Regex.IsMatch( - manifestSyntax, - "\\.unsafeFlags\\s*\\(", - RegexOptions.CultureInvariant)) + 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 (Regex.IsMatch( - manifestSyntax, - "\\.systemLibrary\\s*\\(", - RegexOptions.CultureInvariant)) + if (ContainsSwiftIdentifier(manifestSyntax, "systemLibrary")) { 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. " + @@ -499,6 +493,12 @@ private void ValidateLocalPackageReference( } } + private static bool ContainsSwiftIdentifier(string syntax, string identifier) + => Regex.IsMatch( + syntax, + $"(? Uri.IsHexDigit(character)); + internal static bool AppleReleasePathsEqual(string? left, string? right) + => string.Equals( + left, + right, + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + private sealed class AppleUploadAttestation { internal AppleUploadAttestation( diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index 3aa6204cc..a36454340 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -10,6 +10,11 @@ private void AssertApplePlanStillApproved( PowerForgeAppleReleasePlan plan, string? expectedPlanSha256) { + 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)) return; @@ -103,7 +108,8 @@ private PowerForgeAppleReleaseReceipt CreateApplePlanReceipt( if (plan.Action == PowerForgeAppleReleaseAction.Version) versioning = PlanAppleVersion(plan, whatIf: true); - var screenshotSpecs = (plan.CheckReleaseReadiness || + var screenshotSpecs = ((plan.SyncScreenshots && plan.ReplaceScreenshots) || + plan.CheckReleaseReadiness || (plan.SubmitForReview && !plan.SkipReviewReadinessCheck)) ? LoadAppleScreenshotSpecs(plan) : Array.Empty<(AppStoreConnectScreenshotSyncSpec Spec, string ConfigPath)>(); @@ -170,32 +176,38 @@ private PowerForgeAppleReleaseTargetReceipt CreateApplePlanTarget( return target; } - var state = ReadAppleReleaseState(plan, app); - var platform = AssertSinglePlatformState(state, app); - if (RequiresSelectedApplePlanBuild(plan) && - (platform.MatchedBuildSelected != true || string.IsNullOrWhiteSpace(platform.MatchedBuild?.Id))) + if (RequiresObservedAppleReleaseState(plan)) { - 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 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 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.CheckReleaseReadiness || - (plan.SubmitForReview && !plan.SkipReviewReadinessCheck)) + + 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( @@ -212,23 +224,36 @@ private PowerForgeAppleReleaseTargetReceipt CreateApplePlanTarget( { 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; } @@ -239,6 +264,14 @@ internal static bool RequiresObservedApplePlanState( { 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 @@ -494,7 +527,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 diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 06c7a946f..46ca2787f 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2327,6 +2327,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal), + ExpectedScreenshotInventorySha256 = app.ExpectedScreenshotInventorySha256, CheckReadiness = plan.CheckReleaseReadiness, ReadinessRequest = plan.CheckReleaseReadiness && matchingScreenshotSpec is not null ? new AppStoreConnectReleaseReadinessRequest diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index 37ca48f9f..fdbb6331f 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -766,6 +766,34 @@ import PackageDescription 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() { From c14bf20e3bda87888dd7434e2079da39593edcfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 22:34:46 +0200 Subject: [PATCH 15/78] Close remaining Apple release integrity gaps --- .../AppStoreConnectClientTests.cs | 18 +++ .../AppleReleaseReceiptStoreTests.cs | 25 ++++ ...aseServiceTests.AppleNotarizationResume.cs | 85 +++++++++++- ...eleaseServiceTests.ApplePlatformControl.cs | 15 +- .../PowerForgeAppleReleaseAutomation.cs | 3 + .../AppStoreConnectScreenshotSyncService.cs | 4 + .../Services/AppleReleaseReceiptStore.cs | 52 ++++++- .../AppleReleaseSourceTrustService.Xcode.cs | 64 +++++++++ ...eReleaseSourceTrustService.XcodeParsing.cs | 84 ++++++++++- ...owerForgeReleaseService.AppleAutomation.cs | 3 + ...owerForgeReleaseService.AppleDurability.cs | 55 ++++++++ .../PowerForgeReleaseService.AppleResume.cs | 5 + ...owerForgeReleaseService.AppleVersioning.cs | 3 + ...dExecutionServiceTests.AppleSourceTrust.cs | 130 ++++++++++++++++++ 14 files changed, 524 insertions(+), 22 deletions(-) diff --git a/PowerForge.Tests/AppStoreConnectClientTests.cs b/PowerForge.Tests/AppStoreConnectClientTests.cs index 63636fb96..1dd8defcf 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.cs @@ -958,6 +958,7 @@ 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; var handler = new SequenceHandler( new SequenceResponse(HttpStatusCode.OK, @@ -1041,7 +1042,18 @@ public async Task ScreenshotSyncService_CreatesMissingSetAndUploadsMappedFolder( handler.OnRequest = count => { if (count == 1) + { + if (!OperatingSystem.IsWindows()) + { + var snapshotBase = Path.Combine(Path.GetTempPath(), "PowerForge", "appstore-screenshot-snapshot"); + var snapshotFile = Directory.EnumerateFiles(snapshotBase, "01-home.png", SearchOption.AllDirectories) + .Where(path => File.ReadAllBytes(path).SequenceEqual(new byte[] { 9, 8, 7 })) + .OrderByDescending(File.GetLastWriteTimeUtc) + .First(); + snapshotRootMode = File.GetUnixFileMode(Directory.GetParent(Path.GetDirectoryName(snapshotFile)!)!.FullName); + } File.WriteAllBytes(screenshotPath, new byte[] { 6, 6, 6 }); + } }; using var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.appstoreconnect.apple.com/v1/") }; using var client = new AppStoreConnectClient(CreateCredential(), http); @@ -1078,6 +1090,12 @@ 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 { diff --git a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs index 65c5cf3b9..f2eed5e7c 100644 --- a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -105,6 +105,31 @@ public void WriteAttempt_ReconstructsLatestFromValidatedHistoryWhenLatestIsMissi } } + [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() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 0c45f8bd9..54cdc1bbc 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -28,12 +28,19 @@ public void DirectNotarizationResume_RequiresCheckpointArchiveIdentity() ArchiveSha256 = new string('a', 64) }; - var plan = new PowerForgeAppleReleasePlan { ProjectRoot = Directory.GetCurrentDirectory() }; + 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)); @@ -42,13 +49,19 @@ public void DirectNotarizationResume_RequiresCheckpointArchiveIdentity() [Fact] public void DirectNotarizationResume_UsesPlatformCorrectProjectPathIdentity() { - var plan = new PowerForgeAppleReleasePlan { ProjectRoot = Directory.GetCurrentDirectory() }; + 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" @@ -66,12 +79,80 @@ public void DirectNotarizationResume_UsesPlatformCorrectProjectPathIdentity() Version = app.MarketingVersion, Build = app.BuildNumber }; + receipt.DirectExecutionSha256 = PowerForgeReleaseService.ComputeDirectExecutionSha256(plan, app); var matches = PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app); Assert.Equal(Path.DirectorySeparatorChar == '\\', matches); } + [Theory] + [InlineData("team")] + [InlineData("signing")] + [InlineData("export")] + [InlineData("staple")] + 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; + } + + Assert.False(PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app)); + } + [Fact] public void UploadAttestationResume_UsesPlatformCorrectProjectPathIdentity() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs index 3b19608b2..ade4ce6be 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs @@ -906,7 +906,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", @@ -933,9 +933,8 @@ 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 = Assert.IsType(nextRelease.AppleReceipt); @@ -964,11 +963,11 @@ 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); diff --git a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index fe75126dc..b11813a43 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -337,6 +337,9 @@ internal sealed class PowerForgeAppleReleaseTargetReceipt 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; } public string? NotarizationStatus { get; set; } diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index f2d16bdca..9b6af698e 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -228,6 +228,10 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( 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 mappings = new Dictionary(comparer); diff --git a/PowerForge/Services/AppleReleaseReceiptStore.cs b/PowerForge/Services/AppleReleaseReceiptStore.cs index 3942db59c..b612b1206 100644 --- a/PowerForge/Services/AppleReleaseReceiptStore.cs +++ b/PowerForge/Services/AppleReleaseReceiptStore.cs @@ -107,9 +107,7 @@ internal void WriteAttempt(PowerForgeAppleReleasePlan plan, PowerForgeAppleRelea receipt.ReceiptSha256 = ComputeReceiptSha256(receipt); var payload = Serialize(receipt); - using (var stream = new FileStream(historyPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) - using (var writer = new StreamWriter(stream, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) - writer.Write(payload); + WriteImmutableHistoryEntry(historyDirectory, historyPath, payload); WriteLatest(plan.ProjectRoot, plan.ReceiptPath, payload, "AppleApps.Automation.ReceiptPath"); } @@ -374,9 +372,31 @@ private static void PreserveLegacyLatest( "yyyyMMdd'T'HHmmss.fffffff'Z'", System.Globalization.CultureInfo.InvariantCulture); var legacyPath = Path.Combine(historyDirectory, $"{timestamp}-legacy-{Guid.NewGuid():N}.json"); - using var source = new FileStream(plan.ReceiptPath, FileMode.Open, FileAccess.Read, FileShare.Read); - using var destination = new FileStream(legacyPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - source.CopyTo(destination); + 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) @@ -394,7 +414,7 @@ private static void WriteLatest(string projectRoot, string path, string payload, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); try { - File.WriteAllText(temporaryPath, payload, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + WriteDurableText(temporaryPath, payload); if (File.Exists(path)) File.Replace(temporaryPath, path, destinationBackupFileName: null, ignoreMetadataErrors: true); else @@ -407,6 +427,24 @@ private static void WriteLatest(string projectRoot, string path, string payload, } } + 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)); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 99ad13bba..3851b35f8 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -234,6 +234,12 @@ private void ValidateProjectGraph( .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 cache = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in objects.Values) @@ -287,7 +293,10 @@ private void ValidateProjectGraph( 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, @@ -425,6 +434,12 @@ private void ValidateLocalPackageReference( $"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")) + { + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' declares or invokes a SwiftPM plugin, whose executable runtime inputs cannot be proven at the exact source commit. " + + "Replace build-tool plugins with tracked deterministic build inputs before creating an Apple checkpoint."); + } var externalDependencies = Regex.Matches( manifestWithoutComments, "\\.package\\s*\\((?.*?)\\)", @@ -499,6 +514,55 @@ private static bool ContainsSwiftIdentifier(string syntax, string identifier) $"(? 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 static string RemoveSwiftComments(string source) { var result = source.ToCharArray(); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 8333e54fc..752f7b337 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -71,7 +71,7 @@ private static IEnumerable> ReadXcconfigAssignments private static IEnumerable ExtractBuildFlagInputPaths(string value, string key) { - var tokens = SplitBuildSettingPaths(value); + var tokens = ExpandForwardedBuildFlagTokens(SplitBuildSettingPaths(value), key); var consumeNext = false; for (var index = 0; index < tokens.Length; index++) { @@ -89,13 +89,27 @@ private static IEnumerable ExtractBuildFlagInputPaths(string value, stri token.Equals("-include", StringComparison.Ordinal) || token.Equals("-force_load", StringComparison.Ordinal) || token.Equals("-filelist", StringComparison.Ordinal) || - token.Equals("-ivfsoverlay", StringComparison.Ordinal)) + token.Equals("-ivfsoverlay", StringComparison.Ordinal) || + token.Equals("-vfsoverlay", StringComparison.Ordinal) || + token.Equals("-fplugin", StringComparison.Ordinal) || + token.Equals("-fpass-plugin", StringComparison.Ordinal) || + token.Equals("-load", StringComparison.Ordinal) || + token.Equals("-plugin", StringComparison.Ordinal) || + token.Equals("-plugin-path", StringComparison.Ordinal) || + token.Equals("-module-map-file", StringComparison.Ordinal) || + token.Equals("-isysroot", StringComparison.Ordinal) || + token.Equals("-sdk", StringComparison.Ordinal)) { consumeNext = true; continue; } - var prefixes = new[] { "-I", "-F", "-L", "-fmodule-map-file=" }; + var prefixes = new[] + { + "-I", "-F", "-L", "-fmodule-map-file=", "-fplugin=", "-fpass-plugin=", + "-ivfsoverlay=", "-vfsoverlay=", "-plugin-path=", "-module-map-file=", + "-isysroot=", "-sdk=" + }; var prefix = prefixes.FirstOrDefault(candidate => token.StartsWith(candidate, StringComparison.Ordinal) && token.Length > candidate.Length); if (prefix is not null) @@ -104,9 +118,25 @@ private static IEnumerable ExtractBuildFlagInputPaths(string value, stri continue; } + if (token.StartsWith("-D", StringComparison.Ordinal) || + token.StartsWith("-U", StringComparison.Ordinal) || + token.StartsWith("-Werror=", StringComparison.Ordinal) || + token.StartsWith("-Wno-", StringComparison.Ordinal) || + token.Contains("@executable_path", StringComparison.Ordinal) || + token.Contains("@loader_path", StringComparison.Ordinal) || + token.Contains("@rpath", StringComparison.Ordinal)) + { + 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) && - (Path.IsPathRooted(token) || token.Contains('/') || token.Contains('\\') || - token.Contains("$(", StringComparison.Ordinal) || token.Contains("${", StringComparison.Ordinal))) + IsPathLikeBuildFlagToken(token)) { throw new InvalidOperationException( $"Path-like token in Xcode build setting {key} cannot be classified safely: {token}"); @@ -116,6 +146,50 @@ private static IEnumerable ExtractBuildFlagInputPaths(string value, stri throw new InvalidOperationException($"Xcode build setting {key} ends with a path-consuming flag and no input."); } + 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)) + { + 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)) + { + expanded.AddRange(token.Substring(4).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); + continue; + } + + foreach (var wrapper in new[] { "-Xcc=", "-Xlinker=", "-Xfrontend=", "-Xswiftc=" }) + { + 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 IsToolchainOrBuildProductPath(string value) { var known = new[] diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs index 763b6b9b2..fbd43889c 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs @@ -594,6 +594,9 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( ? null : CreatePortableDirectArtifactPath(plan, app, result!.Notarization!.ArtifactPath), DirectArtifactSha256 = result?.Notarization?.ArtifactSha256, + DirectExecutionSha256 = result?.Notarization is null + ? null + : ComputeDirectExecutionSha256(plan, app), NotarizationSubmissionId = result?.Notarization?.SubmissionId, NotarizationStatus = result?.Notarization?.Status, Stapled = result?.Notarization?.Staple?.Succeeded, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index 47c6f7366..c26d2701d 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -65,6 +65,7 @@ private void WriteAppleNotarizationAttestation( 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.NotarizationStatus = result.Notarization.Status; target.Stapled = result.Notarization.Staple?.Succeeded; @@ -99,6 +100,7 @@ private void WriteAppleNotarizationAcceptance( 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.NotarizationStatus = checkpoint.Status; target.ErrorMessage = $"Apple notarization accepted for '{app.Name}', but local post-processing is incomplete."; @@ -126,6 +128,7 @@ private void WriteAppleNotarizationStapled( 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.NotarizationStatus = checkpoint.Status; target.Stapled = true; @@ -166,6 +169,58 @@ private static PowerForgeAppleReleaseTargetReceipt CreateAppleCheckpointTarget( 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, + 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 + }); + } + private static string CreatePortableDirectArtifactPath( PowerForgeAppleReleasePlan plan, PowerForgeAppleAppReleaseTargetPlan app, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 05fe8039f..d58180aab 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -414,6 +414,11 @@ internal static bool IsMatchingDirectReceiptTarget( 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)); diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index a36454340..65dbe8bee 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -169,6 +169,9 @@ private PowerForgeAppleReleaseTargetReceipt CreateApplePlanTarget( ? 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 (!RequiresObservedApplePlanState(plan, app)) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index fdbb6331f..f651e8c66 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -200,6 +200,80 @@ public void ResolveExactAppleSourceCommit_rejects_project_reference_outside_repo 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; }; + """); + 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() { @@ -555,6 +629,37 @@ public void ResolveExactAppleSourceCommit_rejects_external_file_setting_from_xcc Assert.Contains("inside", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Theory] + [InlineData("OTHER_CFLAGS", "-fplugin=/tmp/injected.dylib")] + [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() { @@ -851,6 +956,31 @@ import PackageDescription 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() { From c3e9b81205b50aff7a1157213d1e2bd8109fa17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 23:34:48 +0200 Subject: [PATCH 16/78] Close remaining Apple exact-source integrity gaps --- .../AppStoreConnectClientTests.cs | 22 ++- ...rviceTests.AppleHostedReviewRegressions.cs | 54 ++++++ ...aseServiceTests.AppleNotarizationResume.cs | 16 ++ PowerForge/Services/AppStoreConnectClient.cs | 61 ++++-- .../AppStoreConnectScreenshotSyncService.cs | 19 +- .../AppleReleaseSourceTrustService.Xcode.cs | 20 +- ...eReleaseSourceTrustService.XcodeParsing.cs | 183 +++++++++++++++--- ...owerForgeReleaseService.AppleDurability.cs | 4 + ...owerForgeReleaseService.AppleVersioning.cs | 4 + ...Tests.AppleSourceTrustReviewRegressions.cs | 121 ++++++++++++ 10 files changed, 445 insertions(+), 59 deletions(-) create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs diff --git a/PowerForge.Tests/AppStoreConnectClientTests.cs b/PowerForge.Tests/AppStoreConnectClientTests.cs index 1dd8defcf..a58238a30 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.cs @@ -924,6 +924,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); @@ -959,6 +964,7 @@ public async Task ScreenshotSyncService_CreatesMissingSetAndUploadsMappedFolder( 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, @@ -1043,17 +1049,21 @@ public async Task ScreenshotSyncService_CreatesMissingSetAndUploadsMappedFolder( { 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()) { - var snapshotBase = Path.Combine(Path.GetTempPath(), "PowerForge", "appstore-screenshot-snapshot"); - var snapshotFile = Directory.EnumerateFiles(snapshotBase, "01-home.png", SearchOption.AllDirectories) - .Where(path => File.ReadAllBytes(path).SequenceEqual(new byte[] { 9, 8, 7 })) - .OrderByDescending(File.GetLastWriteTimeUtc) - .First(); - snapshotRootMode = File.GetUnixFileMode(Directory.GetParent(Path.GetDirectoryName(snapshotFile)!)!.FullName); + 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); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index 4c9f8f979..d7efb1b64 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -284,6 +284,60 @@ public void Execute_ApplePlan_BindsCompleteXcodeExecutionControls() } } + [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)] diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 54cdc1bbc..ce8620d4a 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -91,6 +91,10 @@ public void DirectNotarizationResume_UsesPlatformCorrectProjectPathIdentity() [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(); @@ -148,6 +152,18 @@ public void DirectNotarizationResume_RejectsChangedExecutionPolicy(string change 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)); diff --git a/PowerForge/Services/AppStoreConnectClient.cs b/PowerForge/Services/AppStoreConnectClient.cs index a760b7e26..ff4653f34 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,24 @@ public async Task UploadScreenshotAsync( var file = new FileInfo(fullPath); if (!file.Exists) throw new FileNotFoundException("Screenshot file was not found.", fullPath); + var capturedBytes = File.ReadAllBytes(fullPath); + if (!string.IsNullOrWhiteSpace(expectedSha256) && + !ComputeSha256Checksum(capturedBytes).Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Screenshot '{fullPath}' changed after its immutable upload snapshot was captured."); + } var reservation = await CreateScreenshotReservationAsync( screenshotSetId, file.Name, - file.Length, + capturedBytes.LongLength, cancellationToken).ConfigureAwait(false); foreach (var operation in reservation.UploadOperations) - await ExecuteUploadOperationAsync(fullPath, operation, cancellationToken).ConfigureAwait(false); + await ExecuteUploadOperationAsync(capturedBytes, operation, cancellationToken).ConfigureAwait(false); - var checksum = ComputeMd5Checksum(fullPath); + var checksum = ComputeMd5Checksum(capturedBytes); var committed = await CommitScreenshotUploadAsync(reservation.Id, checksum, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(committed.SourceFileChecksum)) reservation.SourceFileChecksum = committed.SourceFileChecksum; @@ -722,7 +743,7 @@ private async Task GetBuildArrayAsync( } private async Task ExecuteUploadOperationAsync( - string filePath, + byte[] fileBytes, AppStoreConnectUploadOperation operation, CancellationToken cancellationToken) { @@ -732,20 +753,14 @@ private async Task ExecuteUploadOperationAsync( 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."); + if (operation.Offset < 0 || operation.Offset > int.MaxValue) + throw new InvalidOperationException("Upload operation offset is outside the captured screenshot bytes."); 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; - } - } + var offset = (int)operation.Offset; + if (offset > fileBytes.Length - bytes.Length) + throw new EndOfStreamException("Captured screenshot bytes ended before the upload operation range."); + Buffer.BlockCopy(fileBytes, offset, bytes, 0, bytes.Length); using var request = new HttpRequestMessage(new HttpMethod(string.IsNullOrWhiteSpace(operation.Method) ? "PUT" : operation.Method), operation.Url); request.Content = new ByteArrayContent(bytes); @@ -1051,12 +1066,18 @@ private static int ClampLimit(int limit) return limit; } - private static string ComputeMd5Checksum(string filePath) + private static string ComputeMd5Checksum(byte[] bytes) { using var md5 = MD5.Create(); - using var stream = File.OpenRead(filePath); - var bytes = md5.ComputeHash(stream); - return BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant(); + var hash = md5.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + + private static string ComputeSha256Checksum(byte[] bytes) + { + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); } private sealed class BuildPreReleaseVersion diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index 9b6af698e..e4d952f85 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -183,7 +183,11 @@ public async Task SyncAsync( var uploaded = new List(); foreach (var file in filesToUpload) { - var upload = await _client.UploadScreenshotAsync(set.Id, file, cancellationToken).ConfigureAwait(false); + var upload = await _client.UploadScreenshotAsync( + set.Id, + file, + screenshotSnapshot.GetSha256(file), + cancellationToken).ConfigureAwait(false); upload.FilePath = screenshotSnapshot.GetSourcePath(file); uploaded.Add(upload); } @@ -235,6 +239,7 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( try { var mappings = new Dictionary(comparer); + var sha256 = new Dictionary(comparer); var sets = sourceSets.Select((set, setIndex) => { var setRoot = Path.Combine(root, setIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)); @@ -251,9 +256,9 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( var snapshotPath = Path.Combine(setRoot, Path.GetFileName(source)); File.Copy(source, snapshotPath, overwrite: false); + var actualSha256 = ComputeSha256(snapshotPath); if (expected is not null) { - var actualSha256 = ComputeSha256(snapshotPath); if (!actualSha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( @@ -261,11 +266,12 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( } } mappings[snapshotPath] = source; + sha256[snapshotPath] = actualSha256; return snapshotPath; }).ToArray(); return new PreflightedScreenshotSet(set.ScreenshotDisplayType, set.Folder, files); }).ToArray(); - return new ScreenshotSnapshot(root, sets, mappings); + return new ScreenshotSnapshot(root, sets, mappings, sha256); } catch { @@ -376,21 +382,26 @@ private sealed class ScreenshotSnapshot : IDisposable { private readonly string _root; private readonly IReadOnlyDictionary _sourcePaths; + private readonly IReadOnlyDictionary _sha256; public ScreenshotSnapshot( string root, PreflightedScreenshotSet[] sets, - IReadOnlyDictionary sourcePaths) + IReadOnlyDictionary sourcePaths, + IReadOnlyDictionary sha256) { _root = root; Sets = sets; _sourcePaths = sourcePaths; + _sha256 = sha256; } public PreflightedScreenshotSet[] Sets { get; } public string GetSourcePath(string snapshotPath) => _sourcePaths[snapshotPath]; + public string GetSha256(string snapshotPath) => _sha256[snapshotPath]; + public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 3851b35f8..e037f2206 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -40,6 +40,15 @@ internal sealed partial class AppleReleaseSourceTrustService "OTHER_SWIFT_FLAGS" }; + 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 void ValidateXcodeBuildGraph( string repositoryRoot, string projectRoot, @@ -256,6 +265,12 @@ private void ValidateProjectGraph( $"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("XCLocalSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) { ValidateLocalPackageReference(repositoryRoot, projectDirectory, packageLockRoots, item); @@ -442,7 +457,7 @@ private void ValidateLocalPackageReference( } var externalDependencies = Regex.Matches( manifestWithoutComments, - "\\.package\\s*\\((?.*?)\\)", + "\\.\\s*(?:package|`package`)\\s*\\((?.*?)\\)", RegexOptions.Singleline | RegexOptions.CultureInvariant) .Cast() .Select(static match => match.Groups["body"].Value) @@ -872,7 +887,8 @@ private void ValidateResolvedProjectInput( EnsureNoLinkedTraversal(repositoryRoot, candidate, $"Xcode {item.Isa} input"); } } - else if (Path.IsPathRooted(item.Path ?? string.Empty) || + else if (buildFileReferences.Contains(item.Id) || + Path.IsPathRooted(item.Path ?? string.Empty) || (item.Path ?? string.Empty).Split('/', '\\').Any(segment => segment == "..")) { throw new FileNotFoundException( diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 752f7b337..c4e4088b2 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -16,6 +16,13 @@ private void ValidateBuildSettingAssignments( { 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}"); + } IEnumerable values; if (FileValuedBuildSettings.Contains(baseKey) || SearchPathBuildSettings.Contains(baseKey) || @@ -207,30 +214,81 @@ private static bool IsToolchainOrBuildProductPath(string value) private static Dictionary ParsePbxObjects(string text) { var objects = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (Match start in Regex.Matches( - text, - "(?m)^[ \\t]*(?[A-Fa-f0-9]{8,32})(?:[ \\t]+/\\*.*?\\*/)?[ \\t]*=[ \\t]*\\{", - RegexOptions.CultureInvariant)) + var objectDictionary = ReadPbxDictionary(text, "objects") ?? text; + for (var index = 0; index < objectDictionary.Length; index++) { - var id = start.Groups["id"].Value; - var openingBrace = text.IndexOf('{', start.Index + start.Length - 1); - var closingBrace = FindMatchingPbxBrace(text, openingBrace); - var body = text.Substring(openingBrace + 1, closingBrace - openingBrace - 1); - var isa = ReadPbxScalar(body, "isa"); - if (string.IsNullOrWhiteSpace(isa)) + index = SkipPbxTrivia(objectDictionary, index); + if (index >= objectDictionary.Length) + break; + if (!IsHexCharacter(objectDictionary[index])) continue; - objects[id] = new PbxObject + + var idStart = index; + while (index < objectDictionary.Length && IsHexCharacter(objectDictionary[index])) + index++; + var idLength = index - idStart; + if (idLength < 8 || idLength > 32) + continue; + + var id = objectDictionary.Substring(idStart, idLength); + index = SkipPbxTrivia(objectDictionary, index); + if (index >= objectDictionary.Length || objectDictionary[index] != '=') + continue; + index = SkipPbxTrivia(objectDictionary, index + 1); + if (index >= objectDictionary.Length || objectDictionary[index] != '{') + continue; + + var openingBrace = index; + var closingBrace = FindMatchingPbxBrace(objectDictionary, openingBrace); + var body = objectDictionary.Substring(openingBrace + 1, closingBrace - openingBrace - 1); + var isa = ReadPbxScalar(body, "isa"); + if (!string.IsNullOrWhiteSpace(isa)) { - Id = id, - Isa = isa!, - Path = ReadPbxScalar(body, "path"), - SourceTree = ReadPbxScalar(body, "sourceTree"), - Body = body - }; + objects[id] = new PbxObject + { + Id = id, + Isa = isa!, + Path = ReadPbxScalar(body, "path"), + SourceTree = ReadPbxScalar(body, "sourceTree"), + Body = body + }; + } + index = closingBrace; } return objects; } + private static bool IsHexCharacter(char value) + => value is >= '0' and <= '9' or >= 'A' and <= 'F' or >= 'a' and <= 'f'; + + 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; @@ -298,7 +356,7 @@ private static int FindMatchingPbxBrace(string text, int openingBrace) { var match = Regex.Match( body, - "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + "[ \\t]*=[ \\t]*\\{", + "(?:^|[{\\r\\n;])[ \\t]*" + Regex.Escape(name) + "[ \\t]*=[ \\t]*\\{", RegexOptions.CultureInvariant); if (!match.Success) return null; @@ -309,15 +367,86 @@ private static int FindMatchingPbxBrace(string text, int openingBrace) private static IEnumerable> ReadPbxAssignments(string body) { - foreach (Match match in Regex.Matches( - body, - "(?m)^[ \\t]*(?:\\\"(?(?:\\\\.|[^\\\"])*)\\\"|(?[^=\\r\\n]+?))[ \\t]*=[ \\t]*(?\\([^;]*?\\)|\\\"(?:\\\\.|[^\\\"])*\\\"|[^;\\r\\n]+)[ \\t]*;", - RegexOptions.CultureInvariant)) + for (var index = 0; index < body.Length; index++) { - var key = match.Groups["quotedKey"].Success - ? UnescapePbxString(match.Groups["quotedKey"].Value) - : match.Groups["bareKey"].Value.Trim(); - var value = match.Groups["value"].Value.Trim(); + 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); @@ -332,7 +461,7 @@ private static string[] ReadPbxReferences(string body, string name) RegexOptions.Singleline | RegexOptions.CultureInvariant); if (!match.Success) return Array.Empty(); - return Regex.Matches(match.Groups["items"].Value, "(?m)^[ \\t]*(?[A-Fa-f0-9]{8,32})", RegexOptions.CultureInvariant) + return Regex.Matches(match.Groups["items"].Value, "(?:^|,)[ \\t\\r\\n]*(?[A-Fa-f0-9]{8,32})", RegexOptions.CultureInvariant) .Cast() .Select(value => value.Groups["id"].Value) .ToArray(); diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index c26d2701d..2ccba19a1 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -203,6 +203,10 @@ internal static string ComputeDirectExecutionSha256( 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 diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index 65dbe8bee..ae88b54e4 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -419,6 +419,10 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei 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(), diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs new file mode 100644 index 000000000..86ff46e16 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs @@ -0,0 +1,121 @@ +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [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); + } +} From 2f9fcf457c6c982ccc94c84168d448dfa3c172b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 00:57:05 +0200 Subject: [PATCH 17/78] Harden Apple source and upload integrity --- ...eTests.AppleIntegrityClosureRegressions.cs | 194 ++++++++++++++++++ .../PowerForgeAppleReleaseAutomation.cs | 3 + .../Services/AppleArchiveBuildSnapshot.cs | 104 ++++++++++ .../AppleReleaseSourceMutationMonitor.cs | 62 ++++++ .../Services/AppleReleaseSourceSnapshot.cs | 8 + .../AppleReleaseSourceTrustService.Xcode.cs | 30 ++- ...eReleaseSourceTrustService.XcodeParsing.cs | 65 +++++- ...owerForgeReleaseService.AppleAutomation.cs | 3 + ...owerForgeReleaseService.AppleDurability.cs | 43 ++++ .../PowerForgeReleaseService.AppleResume.cs | 8 +- .../Services/PowerForgeReleaseService.cs | 140 +++++++------ ...Tests.AppleSourceTrustReviewRegressions.cs | 77 +++++++ 12 files changed, 664 insertions(+), 73 deletions(-) create mode 100644 PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs create mode 100644 PowerForge/Services/AppleArchiveBuildSnapshot.cs create mode 100644 PowerForge/Services/AppleReleaseSourceMutationMonitor.cs diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs new file mode 100644 index 000000000..abfd1d4c5 --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs @@ -0,0 +1,194 @@ +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); + } + } + + [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/Models/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index b11813a43..278922542 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -333,6 +333,9 @@ internal sealed class PowerForgeAppleReleaseTargetReceipt /// 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; } diff --git a/PowerForge/Services/AppleArchiveBuildSnapshot.cs b/PowerForge/Services/AppleArchiveBuildSnapshot.cs new file mode 100644 index 000000000..ae00c4cc9 --- /dev/null +++ b/PowerForge/Services/AppleArchiveBuildSnapshot.cs @@ -0,0 +1,104 @@ +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. A missing archive is retained only for + /// injected process adapters. + /// + internal string? Publish(string destinationArchivePath) { + if (!Directory.Exists(ArchivePath)) + return null; + + var sourceSha256 = AppleNotarizationService.ComputeArtifactSha256(ArchivePath); + var destination = Path.GetFullPath(destinationArchivePath); + var parent = Path.GetDirectoryName(destination) + ?? throw new InvalidOperationException($"Apple archive path has no parent: {destination}"); + Directory.CreateDirectory(parent); + if (File.Exists(destination) || + (Directory.Exists(destination) && (File.GetAttributes(destination) & FileAttributes.ReparsePoint) != 0)) { + throw new InvalidOperationException($"Apple archive path must be a regular directory: {destination}"); + } + + var name = Path.GetFileName(destination); + var stageRoot = Path.Combine(parent, $".{name}.powerforge-stage-{Guid.NewGuid():N}"); + var stage = Path.Combine(stageRoot, name); + var backup = Path.Combine(parent, $".{name}.powerforge-backup-{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}'."); + } + + if (Directory.Exists(destination)) { + Directory.Move(destination, backup); + movedExisting = true; + } + 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 && Directory.Exists(backup)) + Directory.Delete(backup, recursive: true); + return publishedSha256; + } catch { + if (published && Directory.Exists(destination)) + Directory.Delete(destination, recursive: true); + if (movedExisting && Directory.Exists(backup)) + Directory.Move(backup, destination); + throw; + } finally { + if (Directory.Exists(stageRoot)) + Directory.Delete(stageRoot, recursive: true); + if (Directory.Exists(backup)) + Directory.Delete(backup, recursive: true); + } + } + + public void Dispose() { + if (_disposed) + return; + _disposed = true; + if (Directory.Exists(RootPath)) + Directory.Delete(RootPath, recursive: true); + } +} \ No newline at end of file diff --git a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs new file mode 100644 index 000000000..b8ffdcac3 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs @@ -0,0 +1,62 @@ +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 string? _firstMutation; + private Exception? _watcherError; + private bool _disposed; + + internal AppleReleaseSourceMutationMonitor(string rootPath) { + _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; + _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 exact-source Apple build snapshot mutation monitor failed; the archive cannot be trusted.", + _watcherError); + } + if (!string.IsNullOrWhiteSpace(_firstMutation)) { + throw new InvalidOperationException( + $"The exact-source Apple build snapshot changed while xcodebuild was reading it: {_firstMutation}. " + + "Discard the archive and rebuild from a new snapshot."); + } + } + + private void OnMutation(object sender, FileSystemEventArgs args) + => Interlocked.CompareExchange(ref _firstMutation, args.FullPath, null); + + private void OnError(object sender, ErrorEventArgs args) + => Interlocked.CompareExchange(ref _watcherError, args.GetException(), null); + + public void Dispose() { + if (_disposed) + return; + _disposed = true; + _watcher.Dispose(); + } +} \ No newline at end of file diff --git a/PowerForge/Services/AppleReleaseSourceSnapshot.cs b/PowerForge/Services/AppleReleaseSourceSnapshot.cs index 6232bee11..da59306be 100644 --- a/PowerForge/Services/AppleReleaseSourceSnapshot.cs +++ b/PowerForge/Services/AppleReleaseSourceSnapshot.cs @@ -59,6 +59,10 @@ private AppleReleaseSourceSnapshot( 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, @@ -101,6 +105,10 @@ internal string MapPath(string sourcePath) 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); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index e037f2206..de811d6e4 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -455,6 +455,7 @@ private void ValidateLocalPackageReference( $"Local Swift package '{packageRoot}' declares or invokes a SwiftPM plugin, whose executable runtime inputs cannot be proven at the exact source commit. " + "Replace build-tool plugins with tracked deterministic build inputs before creating an Apple checkpoint."); } + ValidateDirectSwiftPackageDependencyFactories(packageRoot, manifestSyntax); var externalDependencies = Regex.Matches( manifestWithoutComments, "\\.\\s*(?:package|`package`)\\s*\\((?.*?)\\)", @@ -523,6 +524,25 @@ private void ValidateLocalPackageReference( } } + private static void ValidateDirectSwiftPackageDependencyFactories(string packageRoot, string manifestSyntax) + { + foreach (Match reference in Regex.Matches( + manifestSyntax, + "\\.\\s*(?:package\\b|`package`)", + 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 dependency factory indirectly, so its external source cannot be proven. " + + "Invoke each package dependency factory directly with a literal URL or registry identity and commit its Package.resolved lock."); + } + } + private static bool ContainsSwiftIdentifier(string syntax, string identifier) => Regex.IsMatch( syntax, @@ -678,10 +698,12 @@ private void ValidateRemotePackageReference( if (string.IsNullOrWhiteSpace(repositoryUrl)) throw new InvalidOperationException("Remote Swift package reference is missing repositoryURL."); - var exactRevision = Regex.IsMatch( - item.Body, - "(?s)requirement\\s*=\\s*\\{.*?kind\\s*=\\s*revision\\s*;.*?revision\\s*=\\s*[\"']?[A-Fa-f0-9]{40}[\"']?\\s*;", - RegexOptions.CultureInvariant); + var requirement = ReadPbxDictionary(item.Body, "requirement"); + var kind = requirement is null ? null : ReadPbxScalar(requirement, "kind"); + var revision = requirement is null ? null : ReadPbxScalar(requirement, "revision"); + var exactRevision = string.Equals(kind, "revision", StringComparison.OrdinalIgnoreCase) && + revision?.Length == 40 && + revision.All(Uri.IsHexDigit); var locks = FindTrackedPackageLocks(repositoryRoot, packageLockRoots, repositoryUrl!); if (locks.Length == 0 && !exactRevision) { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index c4e4088b2..5960ae819 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -214,7 +214,8 @@ private static bool IsToolchainOrBuildProductPath(string value) private static Dictionary ParsePbxObjects(string text) { var objects = new Dictionary(StringComparer.OrdinalIgnoreCase); - var objectDictionary = ReadPbxDictionary(text, "objects") ?? text; + var syntax = RemovePbxComments(text); + var objectDictionary = ReadPbxDictionary(syntax, "objects") ?? syntax; for (var index = 0; index < objectDictionary.Length; index++) { index = SkipPbxTrivia(objectDictionary, index); @@ -258,6 +259,68 @@ private static Dictionary ParsePbxObjects(string text) return objects; } + /// + /// 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'; diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs index fbd43889c..f82643680 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs @@ -590,6 +590,9 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( 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), diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index 2ccba19a1..913a98ba3 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -41,6 +41,7 @@ private void WriteAppleUploadAttestation( target.ArchiveSha256 = result.ArchiveSha256; target.BuildUploadId = result.Upload.BuildUploadId; target.UploadAttestationAttemptId = attemptId; + target.UploadExecutionSha256 = ComputeAppleUploadExecutionSha256(plan, app); _appleReceiptStore.WriteAttempt(plan, new PowerForgeAppleReleaseReceipt { AttemptId = attemptId, @@ -225,6 +226,48 @@ internal static string ComputeDirectExecutionSha256( }); } + /// 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index d58180aab..10b6bd9b0 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -264,6 +264,11 @@ private bool HasPotentialVerifiedAppleUploadAttestation( !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) && @@ -303,7 +308,8 @@ private static bool IsVerifiedUploadCheckpoint( string.Equals(candidate.Destination, target.Destination, StringComparison.Ordinal) && candidate.DistributionRoute == target.DistributionRoute && AppleReleasePathsEqual(candidate.ArchivePath, target.ArchivePath) && - string.Equals(candidate.ArchiveSha256, target.ArchiveSha256, StringComparison.OrdinalIgnoreCase)); + string.Equals(candidate.ArchiveSha256, target.ArchiveSha256, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.UploadExecutionSha256, target.UploadExecutionSha256, StringComparison.OrdinalIgnoreCase)); } private bool TryResumeDirectAppleNotarization( diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 46ca2787f..cb1464987 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -1686,46 +1686,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."); } - var validateReusableArchives = - (!request.PlanOnly && !request.ValidateOnly) || request.CheckpointAppleApps; - if (validateReusableArchives && - request.AppleAction == PowerForgeAppleReleaseAction.Configured && - !options.Archive && - options.Upload) - { - var recoveryPlan = new PowerForgeAppleReleasePlan - { - ProjectRoot = projectRoot, - Action = request.AppleAction, - Automation = automation, - ReceiptPath = receiptPath, - ReceiptHistoryPath = receiptHistoryPath, - PlanReceiptPath = planReceiptPath, - LockPath = lockPath, - SourceCommit = appleSourceCommit.Length == 0 ? null : appleSourceCommit, - Apps = apps - }; - var missingArchive = apps.FirstOrDefault(app => - !Directory.Exists(app.ArchivePath) && - (!automation.Resume || !HasPotentialVerifiedAppleUploadAttestation(recoveryPlan, 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."); - } var explicitAppStoreConnectApiConfiguredCount = (string.IsNullOrWhiteSpace(options.AppStoreConnectApiKeyPath) ? 0 : 1) + (string.IsNullOrWhiteSpace(options.AppStoreConnectApiKeyId) ? 0 : 1) + @@ -1845,6 +1805,34 @@ 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 || !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; } @@ -2162,6 +2150,10 @@ 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) { @@ -2188,32 +2180,46 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( if (plan.Archive && !resumedUpload) { var directArchive = app.DistributionRoute == AppleDistributionRoute.DirectNotarized; + using var sourceMutationMonitor = sourceSnapshot?.MonitorChanges(); var archive = _archiveAppleApp(new AppleAppArchiveRequest - { - 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 = 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; - 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); + { + 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, + 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); + if (!string.IsNullOrWhiteSpace(publishedArchiveSha256)) + { + approvedArchiveInputPath = archiveBuildSnapshot!.ArchivePath; + result.ArchiveSha256 = publishedArchiveSha256; + app.ExpectedArchiveSha256 = publishedArchiveSha256; + archive.ArchivePath = app.ArchivePath; + } + else + { + CaptureAppleArchiveSha256(result, app); + } } - CaptureAppleArchiveSha256(result, app); - } if (plan.Upload && result.Success && !resumedUpload) { @@ -2222,7 +2228,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( var direct = app.DistributionRoute == AppleDistributionRoute.DirectNotarized; using var uploadSnapshot = string.IsNullOrWhiteSpace(approvedArchiveSha256) ? null - : AppleArchiveUploadSnapshot.Create(app.ArchivePath, approvedArchiveSha256!); + : AppleArchiveUploadSnapshot.Create(approvedArchiveInputPath, approvedArchiveSha256!); using var directExportSnapshot = direct ? AppleDirectExportSnapshot.Create() : null; var upload = _uploadAppleApp(new AppleAppArchiveUploadRequest { @@ -2245,7 +2251,6 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( }); upload.ArchivePath = app.ArchivePath; result.Upload = upload; - CaptureAppleArchiveSha256(result, app, approvedArchiveSha256); if (!upload.Succeeded) { result.Success = false; @@ -2253,6 +2258,8 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( return CompleteAppleExecutionFailure(plan, resultsByApp, app); } + if (!direct) + WriteAppleUploadAttestation(plan, app, result); VerifyAppleArchiveUnchangedAfterUpload(app, result); if (direct) @@ -2278,7 +2285,6 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( } else { - WriteAppleUploadAttestation(plan, app, result); if (IsUploadExecution(plan) && plan.Automation.WaitForProcessing && UsesAppStoreConnect(app)) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs index 86ff46e16..6476b5569 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs @@ -4,6 +4,24 @@ 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() { @@ -118,4 +136,63 @@ import PackageDescription 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("exact 40-character revision", exception.Message, StringComparison.OrdinalIgnoreCase); + } } From d7dfa7dfb4dcc8c6de7267f84f2228152da0d3ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 01:27:43 +0200 Subject: [PATCH 18/78] Harden Swift package source validation --- ...eReleaseSourceTrustService.SwiftParsing.cs | 179 ++++++++++++++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 123 +----------- ...dExecutionServiceTests.AppleSourceTrust.cs | 8 +- ...Tests.AppleSourceTrustReviewRegressions.cs | 67 +++++++ 4 files changed, 256 insertions(+), 121 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs new file mode 100644 index 000000000..23449f10b --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs @@ -0,0 +1,179 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static void ValidateDirectSwiftPackageDependencyFactories(string packageRoot, string manifestSyntax) + { + foreach (Match reference in Regex.Matches( + manifestSyntax, + "\\.\\s*(?:package\\b|`package`)", + 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 dependency factory indirectly, so its external source cannot be proven. " + + "Invoke each package dependency factory directly with a literal URL or registry identity and commit its Package.resolved lock."); + } + } + + 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) + { + endExclusive = start; + var quoteIndex = start; + while (quoteIndex < source.Length && source[quoteIndex] == '#') + quoteIndex++; + var 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; + 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] = ' '; + } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index de811d6e4..b0d505a27 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -449,11 +449,12 @@ private void ValidateLocalPackageReference( $"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")) + if (ContainsSwiftIdentifier(manifestSyntax, "plugin") || + ContainsSwiftMemberReference(manifestSyntax, "macro")) { throw new InvalidOperationException( - $"Local Swift package '{packageRoot}' declares or invokes a SwiftPM plugin, whose executable runtime inputs cannot be proven at the exact source commit. " + - "Replace build-tool plugins with tracked deterministic build inputs before creating an Apple checkpoint."); + $"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."); } ValidateDirectSwiftPackageDependencyFactories(packageRoot, manifestSyntax); var externalDependencies = Regex.Matches( @@ -524,31 +525,6 @@ private void ValidateLocalPackageReference( } } - private static void ValidateDirectSwiftPackageDependencyFactories(string packageRoot, string manifestSyntax) - { - foreach (Match reference in Regex.Matches( - manifestSyntax, - "\\.\\s*(?:package\\b|`package`)", - 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 dependency factory indirectly, so its external source cannot be proven. " + - "Invoke each package dependency factory directly with a literal URL or registry identity and commit its Package.resolved lock."); - } - } - - private static bool ContainsSwiftIdentifier(string syntax, string identifier) - => Regex.IsMatch( - syntax, - $"(?= result.Length) - continue; - if (result[index + 1] == '/') - { - while (index < result.Length && result[index] != '\r' && result[index] != '\n') - result[index++] = ' '; - index--; - } - else if (result[index + 1] == '*') - { - result[index++] = ' '; - result[index] = ' '; - while (++index < result.Length) - { - if (result[index] == '*' && index + 1 < result.Length && result[index + 1] == '/') - { - result[index] = ' '; - result[++index] = ' '; - break; - } - if (result[index] != '\r' && result[index] != '\n') - result[index] = ' '; - } - } - } - return new string(result); - } - - private static string MaskSwiftStringLiterals(string source) - { - var result = source.ToCharArray(); - for (var index = 0; index < result.Length; index++) - { - if (result[index] != '"') - continue; - - var multiline = index + 2 < result.Length && result[index + 1] == '"' && result[index + 2] == '"'; - var delimiterLength = multiline ? 3 : 1; - for (var offset = 0; offset < delimiterLength; offset++) - result[index + offset] = ' '; - index += delimiterLength; - var escaped = false; - while (index < result.Length) - { - if (!multiline && !escaped && result[index] == '"') - { - result[index] = ' '; - break; - } - if (multiline && index + 2 < result.Length && - result[index] == '"' && result[index + 1] == '"' && result[index + 2] == '"') - { - result[index] = result[index + 1] = result[index + 2] = ' '; - index += 2; - break; - } - if (!multiline && !escaped && result[index] == '\\') - escaped = true; - else - escaped = false; - if (result[index] != '\r' && result[index] != '\n') - result[index] = ' '; - index++; - } - } - return new string(result); - } - private void ValidateRemotePackageReference( string repositoryRoot, IReadOnlyCollection packageLockRoots, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index f651e8c66..ffc374558 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -911,13 +911,17 @@ public void ResolveExactAppleSourceCommit_ignores_disallowed_manifest_tokens_in_ 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 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); diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs index 6476b5569..c2ea3a6b1 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs @@ -195,4 +195,71 @@ public void ResolveExactAppleSourceCommit_ignores_commented_fake_remote_package_ Assert.Contains("exact 40-character revision", 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); + } } From 5e38b11d8693c8925ff8b88d3550d63c2110b96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 02:15:25 +0200 Subject: [PATCH 19/78] Harden Apple release plan integrity --- .../AppStoreConnectClientTests.cs | 54 ++++++ ...aseServiceTests.AppleReleaseAdvancement.cs | 38 ++++ .../AppStoreConnectScreenshotSyncService.cs | 82 +++++++- ...eReleaseSourceTrustService.PackageLocks.cs | 167 ++++++++++++++++ ...eReleaseSourceTrustService.SwiftParsing.cs | 52 ++++- .../AppleReleaseSourceTrustService.Xcode.cs | 182 ++---------------- ...eReleaseSourceTrustService.XcodeParsing.cs | 69 ++++++- ...owerForgeReleaseService.AppleVersioning.cs | 41 +++- .../Services/PowerForgeReleaseService.cs | 7 +- ...dExecutionServiceTests.AppleSourceTrust.cs | 2 + ...Tests.AppleSourceTrustReviewRegressions.cs | 126 ++++++++++++ 11 files changed, 629 insertions(+), 191 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs diff --git a/PowerForge.Tests/AppStoreConnectClientTests.cs b/PowerForge.Tests/AppStoreConnectClientTests.cs index a58238a30..684cbdf0f 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.cs @@ -1165,6 +1165,60 @@ public async Task ScreenshotSyncService_RejectsBytesChangedAfterApprovedPlanBefo } } + [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 + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs index 1588b1b41..c68a6a6e7 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() { diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index e4d952f85..edd049371 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -238,8 +238,12 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( #endif try { + var approvedScreenshots = expected is null + ? null + : SelectApprovedScreenshotFiles(sourceSets, expected, comparer); var mappings = new Dictionary(comparer); var sha256 = 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)); @@ -248,16 +252,18 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( { var source = Path.GetFullPath(sourcePath); string? expectedSha256 = null; - if (expected is not null && !expected.TryGetValue(source, out expectedSha256)) + 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 snapshotPath = Path.Combine(setRoot, Path.GetFileName(source)); File.Copy(source, snapshotPath, overwrite: false); var actualSha256 = ComputeSha256(snapshotPath); - if (expected is not null) + if (approvedScreenshots is not null) { if (!actualSha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) { @@ -269,8 +275,26 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( sha256[snapshotPath] = actualSha256; return snapshotPath; }).ToArray(); - return new PreflightedScreenshotSet(set.ScreenshotDisplayType, set.Folder, files); + 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)); + } + } return new ScreenshotSnapshot(root, sets, mappings, sha256); } catch @@ -280,6 +304,38 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( } } + private static Dictionary SelectApprovedScreenshotFiles( + IReadOnlyCollection sourceSets, + IReadOnlyDictionary expected, + StringComparer pathComparer) + { + var selected = new Dictionary(pathComparer); + foreach (var set in sourceSets) + { + var approved = expected + .Where(item => pathComparer.Equals( + Path.GetDirectoryName(Path.GetFullPath(item.Key)), + set.Folder)) + .Where(item => MatchesScreenshotFilter(Path.GetFileName(item.Key), set.Filter)) + .OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase) + .Take(set.MaxCount); + foreach (var item in approved) + selected[Path.GetFullPath(item.Key)] = item.Value; + } + return selected; + } + + private static bool MatchesScreenshotFilter(string fileName, string filter) + { + var expression = "^" + System.Text.RegularExpressions.Regex.Escape(filter) + .Replace("\\*", ".*") + .Replace("\\?", ".") + "$"; + var options = System.Text.RegularExpressions.RegexOptions.CultureInvariant; + if (Path.DirectorySeparatorChar == '\\') + options |= System.Text.RegularExpressions.RegexOptions.IgnoreCase; + return System.Text.RegularExpressions.Regex.IsMatch(fileName, expression, options); + } + private static string ComputeSha256(string filePath) { using var stream = File.OpenRead(filePath); @@ -340,15 +396,27 @@ private static PreflightedScreenshotSet PreflightScreenshotSet(string baseDirect 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 { - 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; } @@ -356,6 +424,10 @@ public PreflightedScreenshotSet(string screenshotDisplayType, string folder, str public string Folder { get; } + public string Filter { get; } + + public int MaxCount { get; } + public string[] Files { get; } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs b/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs new file mode 100644 index 000000000..885f99c86 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using System.Xml.Linq; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + 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) + { + 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); + } + + 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 false; + 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.All(Uri.IsHexDigit)) + { + return true; + } + + if (!LooksLikeRepositoryLocation(dependencyIdentity) && + !string.IsNullOrWhiteSpace(ReadJsonString(state, "version"))) + { + return true; + } + } + } + } + + return false; + } + + 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.SwiftParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs index 23449f10b..f3f0eeda0 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs @@ -4,6 +4,44 @@ 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( @@ -100,11 +138,22 @@ private static string MaskSwiftStringLiterals(string source) 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++; - var hashCount = quoteIndex - start; + hashCount = quoteIndex - start; if (quoteIndex >= source.Length || source[quoteIndex] != '"') return false; @@ -114,6 +163,7 @@ private static bool TryFindSwiftStringEnd(string source, int start, out int endE ? 3 : 1; var cursor = quoteIndex + quoteCount; + contentStart = cursor; while (cursor < source.Length) { if (MatchesSwiftStringDelimiter(source, cursor, quoteCount, hashCount) && diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index b0d505a27..5ec78b22c 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -1,5 +1,4 @@ using System.Text.RegularExpressions; -using System.Text.Json; using System.Xml.Linq; namespace PowerForge; @@ -234,7 +233,7 @@ private void ValidateProjectGraph( IReadOnlyCollection generatedOutputPaths) { var projectDirectory = Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!; - var packageLockRoots = ResolvePackageLockSearchRoots(metadataPath, metadataPaths); + var packageLockPaths = ResolveEffectivePackageLockPaths(metadataPath, metadataPaths); var objects = ParsePbxObjects(File.ReadAllText(metadataPath)); var parents = BuildPbxParentMap(objects); var buildFileReferences = objects.Values @@ -273,13 +272,13 @@ private void ValidateProjectGraph( if (item.Isa.Equals("XCLocalSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) { - ValidateLocalPackageReference(repositoryRoot, projectDirectory, packageLockRoots, item); + ValidateLocalPackageReference(repositoryRoot, projectDirectory, packageLockPaths, item); continue; } if (item.Isa.Equals("XCRemoteSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) { - ValidateRemotePackageReference(repositoryRoot, packageLockRoots, item); + ValidateRemotePackageReference(repositoryRoot, packageLockPaths, item); continue; } @@ -411,7 +410,7 @@ private void EnsureTrackedXcconfigGraph( private void ValidateLocalPackageReference( string repositoryRoot, string projectDirectory, - IReadOnlyCollection packageLockRoots, + IReadOnlyCollection packageLockPaths, PbxObject item) { var relativePath = ReadPbxScalar(item.Body, "relativePath"); @@ -436,6 +435,7 @@ private void ValidateLocalPackageReference( var manifest = File.ReadAllText(manifestPath); var manifestWithoutComments = RemoveSwiftComments(manifest); + EnsureNoExecutableSwiftStringInterpolation(packageRoot, manifestWithoutComments); var manifestSyntax = MaskSwiftStringLiterals(manifestWithoutComments); if (ContainsSwiftIdentifier(manifestSyntax, "unsafeFlags")) { @@ -484,11 +484,11 @@ private void ValidateLocalPackageReference( } var identity = identityMatch.Groups["identity"].Value; - var lockRoots = packageLockRoots - .Concat(new[] { packageRoot }) + var locksForPackage = packageLockPaths + .Concat(new[] { Path.Combine(packageRoot, "Package.resolved") }) .Distinct(GetPathComparer()) .ToArray(); - var locks = FindTrackedPackageLocks(repositoryRoot, lockRoots, identity); + var locks = FindTrackedPackageLocks(locksForPackage, identity); if (locks.Length == 0) { throw new InvalidOperationException( @@ -500,11 +500,11 @@ private void ValidateLocalPackageReference( } var pathArguments = Regex.Matches( manifestWithoutComments, - "\\bpath\\s*:", + "(?:\\bpath\\b|`path`)\\s*:", RegexOptions.CultureInvariant); var literalPathArguments = Regex.Matches( manifestWithoutComments, - "\\bpath\\s*:\\s*\"(?[^\"\\\\\\r\\n]+)\"\\s*(?=[,)])", + "(?:\\bpath\\b|`path`)\\s*:\\s*\"(?[^\"\\\\\\r\\n]+)\"\\s*(?=[,)])", RegexOptions.CultureInvariant); if (pathArguments.Count != literalPathArguments.Count) { @@ -576,7 +576,7 @@ private static void ValidateExternalXcodeBuildInput( private void ValidateRemotePackageReference( string repositoryRoot, - IReadOnlyCollection packageLockRoots, + IReadOnlyCollection packageLockPaths, PbxObject item) { var repositoryUrl = ReadPbxScalar(item.Body, "repositoryURL")?.Trim(); @@ -589,7 +589,7 @@ private void ValidateRemotePackageReference( var exactRevision = string.Equals(kind, "revision", StringComparison.OrdinalIgnoreCase) && revision?.Length == 40 && revision.All(Uri.IsHexDigit); - var locks = FindTrackedPackageLocks(repositoryRoot, packageLockRoots, repositoryUrl!); + var locks = FindTrackedPackageLocks(packageLockPaths, repositoryUrl!); if (locks.Length == 0 && !exactRevision) { throw new InvalidOperationException( @@ -599,164 +599,6 @@ private void ValidateRemotePackageReference( EnsureTrackedFile(repositoryRoot, packageLock, "Swift package resolution lock"); } - private string[] FindTrackedPackageLocks( - string repositoryRoot, - IReadOnlyCollection searchRoots, - string dependencyIdentity) - { - return RunGit(repositoryRoot, "ls-files", "-z") - .StdOut.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) - .Where(static path => Path.GetFileName(path).Equals("Package.resolved", StringComparison.OrdinalIgnoreCase)) - .Select(path => Path.GetFullPath(Path.Combine(repositoryRoot, path))) - .Where(path => searchRoots.Any(root => IsPathAtOrWithin(path, root))) - .Where(path => PackageLockBindsDependency(path, dependencyIdentity)) - .ToArray(); - } - - private static bool PackageLockBindsDependency(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); - } - - 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 false; - 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.All(Uri.IsHexDigit)) - { - return true; - } - - if (!LooksLikeRepositoryLocation(dependencyIdentity) && - !string.IsNullOrWhiteSpace(ReadJsonString(state, "version"))) - { - return true; - } - } - } - } - - return false; - } - - 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[] ResolvePackageLockSearchRoots( - string projectMetadataPath, - IReadOnlyCollection metadataPaths) - { - var projectContainer = Path.GetDirectoryName(projectMetadataPath)!; - var roots = new HashSet(GetPathComparer()) - { - Path.GetDirectoryName(projectContainer)! - }; - 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()))) - { - roots.Add(Path.GetDirectoryName(workspaceMetadata)!); - } - } - - return roots.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; - } - private void ValidateResolvedProjectInput( string repositoryRoot, string candidate, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 5960ae819..4ed90e39c 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -45,7 +45,8 @@ private void ValidateBuildSettingAssignments( var value = rawValue.Trim().TrimEnd('/'); while (value.EndsWith("/**", StringComparison.Ordinal)) value = value.Substring(0, value.Length - 3).TrimEnd('/'); - if (string.IsNullOrWhiteSpace(value) || IsToolchainOrBuildProductPath(value)) + if (string.IsNullOrWhiteSpace(value) || + IsValidatedToolchainOrBuildProductPath(value, key, source)) continue; var candidate = ResolveBuildSettingPath(projectDirectory, value, key); EnsurePathWithinRepository(repositoryRoot, candidate, $"Xcode build setting {key} from {source}"); @@ -197,7 +198,10 @@ private static bool IsPathLikeBuildFlagToken(string token) token.Contains("$(", StringComparison.Ordinal) || token.Contains("${", StringComparison.Ordinal); - private static bool IsToolchainOrBuildProductPath(string value) + private static bool IsValidatedToolchainOrBuildProductPath( + string value, + string key, + string source) { var known = new[] { @@ -208,14 +212,47 @@ private static bool IsToolchainOrBuildProductPath(string value) "$(CONFIGURATION_BUILD_DIR)", "${CONFIGURATION_BUILD_DIR}", "$(TARGET_BUILD_DIR)", "${TARGET_BUILD_DIR}" }; - return known.Any(prefix => value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + 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; } private static Dictionary ParsePbxObjects(string text) { var objects = new Dictionary(StringComparer.OrdinalIgnoreCase); var syntax = RemovePbxComments(text); - var objectDictionary = ReadPbxDictionary(syntax, "objects") ?? syntax; + var objectDictionary = ReadRootPbxObjectDictionary(syntax); for (var index = 0; index < objectDictionary.Length; index++) { index = SkipPbxTrivia(objectDictionary, index); @@ -259,6 +296,30 @@ private static Dictionary ParsePbxObjects(string text) 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. diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index ae88b54e4..e0c4739e6 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -6,7 +6,7 @@ namespace PowerForge; internal sealed partial class PowerForgeReleaseService { - private void AssertApplePlanStillApproved( + private PowerForgeAppleReleaseReceipt? AssertApplePlanStillApproved( PowerForgeAppleReleasePlan plan, string? expectedPlanSha256) { @@ -15,21 +15,27 @@ private void AssertApplePlanStillApproved( throw new InvalidOperationException( "Destructive App Store screenshot replacement requires the SHA-256 from a reviewed exact Apple plan."); } - if (string.IsNullOrWhiteSpace(expectedPlanSha256)) - return; + if (string.IsNullOrWhiteSpace(expectedPlanSha256) && + plan.Action != PowerForgeAppleReleaseAction.Version) + 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) @@ -554,9 +560,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; diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index cb1464987..3d31e558d 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -747,12 +747,15 @@ dotNetSourcePathForTools is not null && try { VerifyExpectedAppleCheckpointArchives(applePlan); - AssertApplePlanStillApproved(applePlan, request.AppleExpectedPlanSha256); + 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) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index ffc374558..f9e4adde6 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -917,6 +917,8 @@ 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. """ diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs index c2ea3a6b1..428ede6c4 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs @@ -262,4 +262,130 @@ import CompilerPluginSupport 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("exact 40-character revision", 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); + } } From 5ffa3ed8ba04f4cfcd06014da3798abdf49eec9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 02:55:21 +0200 Subject: [PATCH 20/78] Harden Xcode and Swift package source trust --- ...rviceTests.AppleHostedReviewRegressions.cs | 2 + ...ReleaseSourceTrustService.LocalPackages.cs | 184 ++++++++++++++++ ...eReleaseSourceTrustService.SwiftParsing.cs | 145 ++++++++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 142 ++---------- ...eReleaseSourceTrustService.XcodeParsing.cs | 42 +++- ...ests.AppleSourceTrustClosureRegressions.cs | 206 ++++++++++++++++++ 6 files changed, 595 insertions(+), 126 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index d7efb1b64..035506c42 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -605,6 +605,7 @@ public void Execute_ConfiguredRemoteMutation_RefreshesAuthoritativeFinalState() 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; @@ -645,6 +646,7 @@ public void Execute_ConfiguredRemoteMutation_FailsReceiptWhenFinalStateCannotBeR 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; diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs new file mode 100644 index 000000000..9f0326164 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -0,0 +1,184 @@ +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"); + } + + 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, manifestSyntax); + ValidateDirectSwiftPackageDependencyFactories(packageRoot, manifestSyntax); + var dependencyCalls = ParseDirectSwiftPackageDependencyCalls(manifestWithoutComments, manifestSyntax); + ValidateRemotePackageDependencies(repositoryRoot, packageRoot, packageLockPaths, dependencyCalls); + ValidateNestedLocalPackageDependencies( + repositoryRoot, + packageRoot, + packageLockPaths, + validatedPackageRoots, + dependencyCalls); + ValidateLiteralSwiftPackagePaths(repositoryRoot, packageRoot, manifestWithoutComments); + } + + private static void ValidateLocalPackageExecutableSafety(string packageRoot, string manifestSyntax) + { + 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")) + 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."); + } + + 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 hasExactRevision = dependency.Arguments.TryGetValue("revision", out var revision) && + TryReadLiteralSwiftString(revision, out var revisionValue) && + Regex.IsMatch(revisionValue, "^[A-Fa-f0-9]{40}$", RegexOptions.CultureInvariant); + if (!dependency.Arguments.ContainsKey("id") && hasExactRevision) + continue; + var locks = FindTrackedPackageLocks( + packageLockPaths + .Concat(new[] { Path.Combine(packageRoot, "Package.resolved") }) + .Distinct(GetPathComparer()) + .ToArray(), + identity); + if (locks.Length == 0) + throw new InvalidOperationException( + $"Local Swift package '{packageRoot}' declares external dependency '{identity}' without an exact 40-character revision. " + + "Commit a Package.resolved lock containing that dependency before creating an exact-source Apple checkpoint."); + foreach (var packageLock in locks) + EnsureTrackedFile(repositoryRoot, packageLock, "Xcode local Swift package resolution lock"); + } + } + + 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) + { + var pathArguments = Regex.Matches(manifest, "(?:\\bpath\\b|`path`)\\s*:", RegexOptions.CultureInvariant); + var literalPathArguments = Regex.Matches( + manifest, + "(?:\\bpath\\b|`path`)\\s*:\\s*\"(?[^\"\\\\\\r\\n]+)\"\\s*(?=[,)])", + RegexOptions.CultureInvariant); + if (pathArguments.Count != literalPathArguments.Count) + 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."); + foreach (Match match in literalPathArguments) + { + var explicitPath = ResolvePbxPath(packageRoot, match.Groups["path"].Value, "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); + } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs index f3f0eeda0..80467fbd0 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs @@ -61,6 +61,141 @@ private static void ValidateDirectSwiftPackageDependencyFactories(string package } } + 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 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, @@ -226,4 +361,14 @@ private static void MaskSwiftTrivia(char[] value, int start, int length) 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 index 5ec78b22c..ea485d1d4 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -48,6 +48,11 @@ internal sealed partial class AppleReleaseSourceTrustService "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, @@ -249,6 +254,7 @@ private void ValidateProjectGraph( .Select(static value => value!.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries)[0]) .ToHashSet(StringComparer.OrdinalIgnoreCase); var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var validatedLocalPackageRoots = new HashSet(GetPathComparer()); foreach (var item in objects.Values) { @@ -272,10 +278,21 @@ private void ValidateProjectGraph( if (item.Isa.Equals("XCLocalSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) { - ValidateLocalPackageReference(repositoryRoot, projectDirectory, packageLockPaths, item); + 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); @@ -397,9 +414,10 @@ private void EnsureTrackedXcconfigGraph( EnsureNoGeneratedOutputOverlap(includedPath, generatedOutputPaths, "Xcode xcconfig include"); if (!File.Exists(includedPath)) { - var directive = include.Groups["optional"].Success ? "optional " : string.Empty; + if (include.Groups["optional"].Success) + continue; throw new FileNotFoundException( - $"Xcode {directive}xcconfig include cannot be proven at the exact source commit: {includedPath}", + $"Xcode xcconfig include cannot be proven at the exact source commit: {includedPath}", includedPath); } EnsureTrackedFile(repositoryRoot, includedPath, "Xcode xcconfig include"); @@ -407,124 +425,6 @@ private void EnsureTrackedXcconfigGraph( } } - private void ValidateLocalPackageReference( - string repositoryRoot, - string projectDirectory, - IReadOnlyCollection packageLockPaths, - PbxObject item) - { - 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"); - var manifestPath = Path.Combine(packageRoot, "Package.swift"); - 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"); - } - - var manifest = File.ReadAllText(manifestPath); - var manifestWithoutComments = RemoveSwiftComments(manifest); - EnsureNoExecutableSwiftStringInterpolation(packageRoot, manifestWithoutComments); - var manifestSyntax = MaskSwiftStringLiterals(manifestWithoutComments); - 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")) - { - 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."); - } - ValidateDirectSwiftPackageDependencyFactories(packageRoot, manifestSyntax); - var externalDependencies = Regex.Matches( - manifestWithoutComments, - "\\.\\s*(?:package|`package`)\\s*\\((?.*?)\\)", - RegexOptions.Singleline | RegexOptions.CultureInvariant) - .Cast() - .Select(static match => match.Groups["body"].Value) - .Where(static body => Regex.IsMatch(body, "\\b(?:url|id)\\s*:", RegexOptions.CultureInvariant)) - .ToArray(); - foreach (var dependency in externalDependencies.Where(static body => - Regex.IsMatch(body, "\\bid\\s*:", RegexOptions.CultureInvariant) || - !Regex.IsMatch( - body, - "\\brevision\\s*:\\s*\"[A-Fa-f0-9]{40}\"", - RegexOptions.CultureInvariant))) - { - var identityMatch = Regex.Match( - dependency, - "\\b(?:url|id)\\s*:\\s*\"(?[^\"]+)\"", - RegexOptions.CultureInvariant); - if (!identityMatch.Success) - { - 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 identity = identityMatch.Groups["identity"].Value; - var locksForPackage = packageLockPaths - .Concat(new[] { Path.Combine(packageRoot, "Package.resolved") }) - .Distinct(GetPathComparer()) - .ToArray(); - var locks = FindTrackedPackageLocks(locksForPackage, identity); - if (locks.Length == 0) - { - throw new InvalidOperationException( - $"Local Swift package '{packageRoot}' declares external dependency '{identity}' without an exact 40-character revision. " + - "Commit a Package.resolved lock containing that dependency before creating an exact-source Apple checkpoint."); - } - foreach (var packageLock in locks) - EnsureTrackedFile(repositoryRoot, packageLock, "Xcode local Swift package resolution lock"); - } - var pathArguments = Regex.Matches( - manifestWithoutComments, - "(?:\\bpath\\b|`path`)\\s*:", - RegexOptions.CultureInvariant); - var literalPathArguments = Regex.Matches( - manifestWithoutComments, - "(?:\\bpath\\b|`path`)\\s*:\\s*\"(?[^\"\\\\\\r\\n]+)\"\\s*(?=[,)])", - RegexOptions.CultureInvariant); - if (pathArguments.Count != literalPathArguments.Count) - { - 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."); - } - foreach (Match match in literalPathArguments) - { - var explicitPath = ResolvePbxPath(packageRoot, match.Groups["path"].Value, "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 static void ValidateExternalXcodeBuildInput( PbxObject item, string metadataPath, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 4ed90e39c..0842f5dc0 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -23,6 +23,11 @@ private void ValidateBuildSettingAssignments( 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; + } IEnumerable values; if (FileValuedBuildSettings.Contains(baseKey) || SearchPathBuildSettings.Contains(baseKey) || @@ -68,7 +73,7 @@ private static IEnumerable> ReadXcconfigAssignments 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]*(?://.*)?$", + "(?m)^[ \\t]*(?!#)(?[A-Za-z_][A-Za-z0-9_]*(?:\\[[^\\]]+\\])*)[ \\t]*(?:\\?=|\\+=|=)[ \\t]*(?.*?)[ \\t]*(?://.*)?$", RegexOptions.CultureInvariant)) { yield return new KeyValuePair( @@ -77,6 +82,19 @@ private static IEnumerable> ReadXcconfigAssignments } } + 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 IEnumerable ExtractBuildFlagInputPaths(string value, string key) { var tokens = ExpandForwardedBuildFlagTokens(SplitBuildSettingPaths(value), key); @@ -203,15 +221,29 @@ private static bool IsValidatedToolchainOrBuildProductPath( string key, string source) { - var known = new[] + var unownedBuildRoots = new[] { - "$(SDKROOT)", "${SDKROOT}", - "$(DEVELOPER_DIR)", "${DEVELOPER_DIR}", - "$(TOOLCHAIN_DIR)", "${TOOLCHAIN_DIR}", "$(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) && diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs new file mode 100644 index 000000000..fdd712814 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs @@ -0,0 +1,206 @@ +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" + + "func choose(revision: String) -> Package.Dependency.Requirement { .branch(\"main\") }\n" + + "let package = Package(name: \"Shared\", dependencies: [.package(url: \"https://example.invalid/Mutable.git\", branch: choose(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); + } + + 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: ".")); + } +} From 7eb947e759fffc3696873b32bdb20bd67671bb14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 03:41:21 +0200 Subject: [PATCH 21/78] Harden Apple source trust parsing --- ...pleReleaseSourceTrustService.BuildFlags.cs | 283 ++++++++++++++++++ ...ReleaseSourceTrustService.LocalPackages.cs | 44 ++- ...eReleaseSourceTrustService.XcodeParsing.cs | 248 ++------------- ...ests.AppleSourceTrustClosureRegressions.cs | 77 +++++ 4 files changed, 424 insertions(+), 228 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs new file mode 100644 index 000000000..39d5b8815 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -0,0 +1,283 @@ +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, + SplitBuildSettingPaths(value), + key, + generatedOutputPaths, + source, + responseFiles) + .ToArray(); + 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}"); + 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); + } + } + + private IEnumerable ExpandCompilerResponseFileTokens( + string repositoryRoot, + string projectDirectory, + IEnumerable tokens, + string key, + IReadOnlyCollection generatedOutputPaths, + string source, + ISet responseFiles) + { + foreach (var token in tokens) + { + if (token.Length <= 1 || + token[0] != '@' || + token.StartsWith("@executable_path", StringComparison.Ordinal) || + token.StartsWith("@loader_path", StringComparison.Ordinal) || + token.StartsWith("@rpath", StringComparison.Ordinal)) + { + 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 consumeNext = false; + for (var index = 0; index < tokens.Length; index++) + { + var token = tokens[index]; + if (consumeNext) + { + consumeNext = false; + yield return token; + continue; + } + + if (token.Equals("-I", StringComparison.Ordinal) || + token.Equals("-F", StringComparison.Ordinal) || + token.Equals("-L", StringComparison.Ordinal) || + token.Equals("-include", StringComparison.Ordinal) || + token.Equals("-force_load", StringComparison.Ordinal) || + token.Equals("-filelist", StringComparison.Ordinal) || + token.Equals("-ivfsoverlay", StringComparison.Ordinal) || + token.Equals("-vfsoverlay", StringComparison.Ordinal) || + token.Equals("-fplugin", StringComparison.Ordinal) || + token.Equals("-fpass-plugin", StringComparison.Ordinal) || + token.Equals("-load", StringComparison.Ordinal) || + token.Equals("-plugin", StringComparison.Ordinal) || + token.Equals("-plugin-path", StringComparison.Ordinal) || + token.Equals("-module-map-file", StringComparison.Ordinal) || + token.Equals("-isysroot", StringComparison.Ordinal) || + token.Equals("-sdk", StringComparison.Ordinal)) + { + consumeNext = true; + continue; + } + + var prefixes = new[] + { + "-I", "-F", "-L", "-fmodule-map-file=", "-fplugin=", "-fpass-plugin=", + "-ivfsoverlay=", "-vfsoverlay=", "-plugin-path=", "-module-map-file=", + "-isysroot=", "-sdk=" + }; + var prefix = prefixes.FirstOrDefault(candidate => + token.StartsWith(candidate, StringComparison.Ordinal) && token.Length > candidate.Length); + if (prefix is not null) + { + yield return token.Substring(prefix.Length); + continue; + } + + if (token.StartsWith("-D", StringComparison.Ordinal) || + token.StartsWith("-U", StringComparison.Ordinal) || + token.StartsWith("-Werror=", StringComparison.Ordinal) || + token.StartsWith("-Wno-", StringComparison.Ordinal) || + token.Contains("@executable_path", StringComparison.Ordinal) || + token.Contains("@loader_path", StringComparison.Ordinal) || + token.Contains("@rpath", StringComparison.Ordinal)) + { + 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 (consumeNext) + throw new InvalidOperationException($"Xcode build setting {key} ends with a path-consuming flag and no input."); + } + + 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)) + { + 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)) + { + expanded.AddRange(token.Substring(4).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); + continue; + } + + foreach (var wrapper in new[] { "-Xcc=", "-Xlinker=", "-Xfrontend=", "-Xswiftc=" }) + { + 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 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.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index 9f0326164..6375283c3 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -84,7 +84,7 @@ private void ValidateLocalPackageManifest( packageLockPaths, validatedPackageRoots, dependencyCalls); - ValidateLiteralSwiftPackagePaths(repositoryRoot, packageRoot, manifestWithoutComments); + ValidateLiteralSwiftPackagePaths(repositoryRoot, packageRoot, manifestWithoutComments, manifestSyntax); } private static void ValidateLocalPackageExecutableSafety(string packageRoot, string manifestSyntax) @@ -158,20 +158,38 @@ private void ValidateNestedLocalPackageDependencies( } } - private void ValidateLiteralSwiftPackagePaths(string repositoryRoot, string packageRoot, string manifest) + private void ValidateLiteralSwiftPackagePaths( + string repositoryRoot, + string packageRoot, + string manifest, + string manifestSyntax) { - var pathArguments = Regex.Matches(manifest, "(?:\\bpath\\b|`path`)\\s*:", RegexOptions.CultureInvariant); - var literalPathArguments = Regex.Matches( - manifest, - "(?:\\bpath\\b|`path`)\\s*:\\s*\"(?[^\"\\\\\\r\\n]+)\"\\s*(?=[,)])", - RegexOptions.CultureInvariant); - if (pathArguments.Count != literalPathArguments.Count) - 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."); - foreach (Match match in literalPathArguments) + var pathBearingFactories = new HashSet(StringComparer.Ordinal) { - var explicitPath = ResolvePbxPath(packageRoot, match.Groups["path"].Value, "Swift package manifest input"); + "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 (!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"); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 0842f5dc0..d5307dd9c 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -38,7 +38,15 @@ private void ValidateBuildSettingAssignments( } else if (FlagBuildSettings.Contains(baseKey)) { - values = ExtractBuildFlagInputPaths(assignment.Value, key); + ValidateBuildFlagInputPaths( + repositoryRoot, + projectDirectory, + assignment.Value, + key, + generatedOutputPaths, + source, + new HashSet(GetPathComparer())); + continue; } else { @@ -68,6 +76,7 @@ private void ValidateBuildSettingAssignments( } } + private static IEnumerable> ReadXcconfigAssignments(string contents) { var logical = Regex.Replace(contents, "\\\\[ \\t]*\\r?\\n", " "); @@ -95,235 +104,33 @@ private static void ValidateSdkSelectionBuildSetting(string key, string value, s $"Xcode build setting {key} selects a custom SDK path or expression that cannot be proven at the exact source commit: {source}"); } - private static IEnumerable ExtractBuildFlagInputPaths(string value, string key) - { - var tokens = ExpandForwardedBuildFlagTokens(SplitBuildSettingPaths(value), key); - var consumeNext = false; - for (var index = 0; index < tokens.Length; index++) - { - var token = tokens[index]; - if (consumeNext) - { - consumeNext = false; - yield return token; - continue; - } - - if (token.Equals("-I", StringComparison.Ordinal) || - token.Equals("-F", StringComparison.Ordinal) || - token.Equals("-L", StringComparison.Ordinal) || - token.Equals("-include", StringComparison.Ordinal) || - token.Equals("-force_load", StringComparison.Ordinal) || - token.Equals("-filelist", StringComparison.Ordinal) || - token.Equals("-ivfsoverlay", StringComparison.Ordinal) || - token.Equals("-vfsoverlay", StringComparison.Ordinal) || - token.Equals("-fplugin", StringComparison.Ordinal) || - token.Equals("-fpass-plugin", StringComparison.Ordinal) || - token.Equals("-load", StringComparison.Ordinal) || - token.Equals("-plugin", StringComparison.Ordinal) || - token.Equals("-plugin-path", StringComparison.Ordinal) || - token.Equals("-module-map-file", StringComparison.Ordinal) || - token.Equals("-isysroot", StringComparison.Ordinal) || - token.Equals("-sdk", StringComparison.Ordinal)) - { - consumeNext = true; - continue; - } - - var prefixes = new[] - { - "-I", "-F", "-L", "-fmodule-map-file=", "-fplugin=", "-fpass-plugin=", - "-ivfsoverlay=", "-vfsoverlay=", "-plugin-path=", "-module-map-file=", - "-isysroot=", "-sdk=" - }; - var prefix = prefixes.FirstOrDefault(candidate => - token.StartsWith(candidate, StringComparison.Ordinal) && token.Length > candidate.Length); - if (prefix is not null) - { - yield return token.Substring(prefix.Length); - continue; - } - - if (token.StartsWith("-D", StringComparison.Ordinal) || - token.StartsWith("-U", StringComparison.Ordinal) || - token.StartsWith("-Werror=", StringComparison.Ordinal) || - token.StartsWith("-Wno-", StringComparison.Ordinal) || - token.Contains("@executable_path", StringComparison.Ordinal) || - token.Contains("@loader_path", StringComparison.Ordinal) || - token.Contains("@rpath", StringComparison.Ordinal)) - { - 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 (consumeNext) - throw new InvalidOperationException($"Xcode build setting {key} ends with a path-consuming flag and no input."); - } - - 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)) - { - 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)) - { - expanded.AddRange(token.Substring(4).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); - continue; - } - - foreach (var wrapper in new[] { "-Xcc=", "-Xlinker=", "-Xfrontend=", "-Xswiftc=" }) - { - 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 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; - } private static Dictionary ParsePbxObjects(string text) { var objects = new Dictionary(StringComparer.OrdinalIgnoreCase); var syntax = RemovePbxComments(text); var objectDictionary = ReadRootPbxObjectDictionary(syntax); - for (var index = 0; index < objectDictionary.Length; index++) + foreach (var assignment in ReadPbxAssignments(objectDictionary)) { - index = SkipPbxTrivia(objectDictionary, index); - if (index >= objectDictionary.Length) - break; - if (!IsHexCharacter(objectDictionary[index])) + var objectValue = assignment.Value.Trim(); + if (objectValue.Length < 2 || objectValue[0] != '{' || objectValue[objectValue.Length - 1] != '}') continue; - - var idStart = index; - while (index < objectDictionary.Length && IsHexCharacter(objectDictionary[index])) - index++; - var idLength = index - idStart; - if (idLength < 8 || idLength > 32) - continue; - - var id = objectDictionary.Substring(idStart, idLength); - index = SkipPbxTrivia(objectDictionary, index); - if (index >= objectDictionary.Length || objectDictionary[index] != '=') - continue; - index = SkipPbxTrivia(objectDictionary, index + 1); - if (index >= objectDictionary.Length || objectDictionary[index] != '{') - continue; - - var openingBrace = index; - var closingBrace = FindMatchingPbxBrace(objectDictionary, openingBrace); - var body = objectDictionary.Substring(openingBrace + 1, closingBrace - openingBrace - 1); + 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)) { - objects[id] = new PbxObject + 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 - }; + }); } - index = closingBrace; } return objects; } @@ -417,6 +224,16 @@ private static string RemovePbxComments(string text) 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) @@ -617,9 +434,10 @@ private static string[] ReadPbxReferences(string body, string name) RegexOptions.Singleline | RegexOptions.CultureInvariant); if (!match.Success) return Array.Empty(); - return Regex.Matches(match.Groups["items"].Value, "(?:^|,)[ \\t\\r\\n]*(?[A-Fa-f0-9]{8,32})", RegexOptions.CultureInvariant) - .Cast() - .Select(value => value.Groups["id"].Value) + return match.Groups["items"].Value + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(value => ParsePbxObjectIdentifier(value, $"reference in {name}")) .ToArray(); } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs index fdd712814..6b97854a0 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs @@ -176,6 +176,83 @@ public void ResolveExactAppleSourceCommit_allows_missing_optional_xcconfig_inclu 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_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); + } + + [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\"\nfunc explain(path: String) -> String { path }\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); + } + private static (string RepositoryRoot, string ProjectRoot, string PackageRoot) CreateLocalPackageFixture( TemporaryDirectoryScope scope, string repositoryName) From aec8c9884aa74dbea06b612b367d30eb386d93e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 04:19:05 +0200 Subject: [PATCH 22/78] Fix Apple release integrity edge cases --- .../AppleNotarizationServiceTests.cs | 55 ++++++++++++++ ...rviceTests.AppleHostedReviewRegressions.cs | 2 + ...aseServiceTests.AppleNotarizationResume.cs | 2 + PowerForge/Models/AppleNotarizationModels.cs | 10 +++ .../PowerForgeAppleReleaseAutomation.cs | 3 + .../Services/AppleNotarizationService.cs | 58 ++++++++++++--- .../AppleReleaseSourceMutationMonitor.cs | 20 +++-- ...ReleaseSourceTrustService.LocalPackages.cs | 13 ++++ ...eReleaseSourceTrustService.SwiftParsing.cs | 6 +- ...eReleaseSourceTrustService.XcodeParsing.cs | 46 ++++++------ ...owerForgeReleaseService.AppleAutomation.cs | 1 + ...eReleaseService.AppleDirectDistribution.cs | 2 + ...owerForgeReleaseService.AppleDurability.cs | 3 + .../PowerForgeReleaseService.AppleResume.cs | 2 + ...ests.AppleSourceTrustClosureRegressions.cs | 73 +++++++++++++++++++ 15 files changed, 255 insertions(+), 41 deletions(-) diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 451580292..957ffa690 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -12,6 +12,7 @@ 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, @@ -23,6 +24,7 @@ public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() { Assert.Equal("submission-1", checkpoint.SubmissionId); Assert.Equal("Accepted", checkpoint.Status); + checkpointSubmissionSha256 = checkpoint.SubmissionSha256; Assert.Equal(2, runner.Requests.Count); } }); @@ -32,6 +34,9 @@ public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() 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, @@ -62,6 +67,34 @@ public async Task NotarizeAsync_PackagesSubmitsStaplesValidatesAndAssessesApp() } } + [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")); + var checkpointed = false; + var exception = await Assert.ThrowsAsync(() => + new AppleNotarizationService(new MutatingSubmissionRunner()).NotarizeAsync(new AppleNotarizationRequest + { + ArtifactPath = app.FullName, + KeychainProfile = "powerforge-notary", + Staple = false, + Assess = false, + AcceptedCheckpoint = _ => checkpointed = true + })); + + Assert.Contains("exact submitted file changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Do not resubmit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(checkpointed); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + [Fact] public async Task NotarizeAsync_InvalidSubmissionDoesNotStapleOrAssess() { @@ -523,6 +556,28 @@ 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; diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index 035506c42..b0f0a2885 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -891,6 +891,7 @@ public void Execute_DirectNotarizationCrash_PersistsAcceptedSubmissionBeforeLoca ArtifactPath = request.ArtifactPath, ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), SubmissionPath = request.ArtifactPath + ".zip", + SubmissionSha256 = new string('b', 64), SubmissionId = "accepted-before-local-crash", Status = "Accepted" }); @@ -912,6 +913,7 @@ public void Execute_DirectNotarizationCrash_PersistsAcceptedSubmissionBeforeLoca 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); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index ce8620d4a..ba0a3fed1 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -218,6 +218,7 @@ public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterChe ArtifactPath = request.ArtifactPath, ArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(request.ArtifactPath), SubmissionPath = request.ArtifactPath + ".zip", + SubmissionSha256 = new string('a', 64), SubmissionId = "portable-submission", Status = "Accepted" }); @@ -269,6 +270,7 @@ public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterChe 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); } diff --git a/PowerForge/Models/AppleNotarizationModels.cs b/PowerForge/Models/AppleNotarizationModels.cs index c0ea185b9..862c414e6 100644 --- a/PowerForge/Models/AppleNotarizationModels.cs +++ b/PowerForge/Models/AppleNotarizationModels.cs @@ -39,6 +39,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; } @@ -64,6 +67,8 @@ internal sealed class AppleNotarizationAcceptedCheckpoint 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"; @@ -75,6 +80,8 @@ internal sealed class AppleNotarizationStapledCheckpoint 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"; @@ -92,6 +99,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 278922542..9912bff24 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -345,6 +345,9 @@ internal sealed class PowerForgeAppleReleaseTargetReceipt 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; } diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index b62515ef4..75df40de8 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -70,6 +70,9 @@ public async Task NotarizeAsync( timeout, cancellationToken) .ConfigureAwait(false); + var submissionSha256 = resumed + ? request.AcceptedSubmissionSha256?.Trim() + : ComputeFileSha256(submittedPath); ProcessRunResult submission; string? submissionId; string? status; @@ -93,8 +96,34 @@ public async Task NotarizeAsync( "notarytool", "submit", submittedPath, "--wait", "--output-format", "json" }; submitArguments.AddRange(authentication); + using var submissionMonitor = new AppleReleaseSourceMutationMonitor( + submissionSnapshot.RootPath, + "private Apple notarization submission", + "notarytool", + "Do not resubmit until the accepted submission has been reconciled."); submission = await RunAsync(request.XcrunExecutable, submissionArtifactPath, submitArguments, timeout, cancellationToken).ConfigureAwait(false); (submissionId, status) = ParseSubmission(submission); + try + { + submissionMonitor.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}'."); + } + } + 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); + } } var submissionPath = resumed ? artifactPath @@ -105,13 +134,6 @@ public async Task NotarizeAsync( string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(submissionId)) { - var submittedArtifactSha256 = ComputeArtifactSha256(submissionArtifactPath); - if (!submittedArtifactSha256.Equals(artifactSha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Apple accepted notarization submission '{submissionId}', but the private submitted artifact changed during notarytool execution. " + - "Do not resubmit until the accepted submission has been reconciled."); - } try { request.AcceptedCheckpoint?.Invoke(new AppleNotarizationAcceptedCheckpoint @@ -119,6 +141,7 @@ public async Task NotarizeAsync( ArtifactPath = artifactPath, ArtifactSha256 = artifactSha256, SubmissionPath = submissionPath, + SubmissionSha256 = submissionSha256!, SubmissionId = submissionId!, Status = status! }); @@ -133,7 +156,7 @@ public async Task NotarizeAsync( } if (!resumed) - PreserveSubmissionPath(artifactPath, submittedPath, submissionPath); + PreserveSubmissionPath(artifactPath, submittedPath, submissionPath, submissionSha256!); ProcessRunResult? staple = null; ProcessRunResult? validation = null; @@ -168,6 +191,7 @@ public async Task NotarizeAsync( { ArtifactPath = artifactPath, ArtifactSha256 = stapledArtifactSha256, + SubmissionSha256 = submissionSha256 ?? string.Empty, SubmissionId = submissionId!, Status = status ?? "Accepted" }); @@ -215,6 +239,7 @@ public async Task NotarizeAsync( ArtifactPath = artifactPath, ArtifactSha256 = finalArtifactSha256, SubmissionPath = submissionPath, + SubmissionSha256 = submissionSha256, SubmissionId = submissionId, Status = status, ResumedAcceptedSubmission = resumed, @@ -267,13 +292,21 @@ private static string ResolveRetainedSubmissionPath( private static void PreserveSubmissionPath( string originalArtifactPath, string submittedPath, - string retainedPath) + string retainedPath, + string expectedSubmissionSha256) { if (!Path.GetExtension(originalArtifactPath).Equals(".app", StringComparison.OrdinalIgnoreCase)) return; Directory.CreateDirectory(Path.GetDirectoryName(retainedPath)!); File.Copy(submittedPath, retainedPath, overwrite: true); + 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) @@ -371,6 +404,13 @@ 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, diff --git a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs index b8ffdcac3..c447e015a 100644 --- a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs +++ b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs @@ -6,11 +6,21 @@ namespace PowerForge; /// internal sealed class AppleReleaseSourceMutationMonitor : IDisposable { private readonly FileSystemWatcher _watcher; + private readonly string _scopeDescription; + private readonly string _readerDescription; + private readonly string _failureInstruction; private string? _firstMutation; private Exception? _watcherError; private bool _disposed; - internal AppleReleaseSourceMutationMonitor(string rootPath) { + 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.") { + _scopeDescription = scopeDescription; + _readerDescription = readerDescription; + _failureInstruction = failureInstruction; _watcher = new FileSystemWatcher(rootPath) { IncludeSubdirectories = true, InternalBufferSize = 64 * 1024, @@ -37,13 +47,13 @@ internal void ValidateNoChanges() { _watcher.EnableRaisingEvents = false; if (_watcherError is not null) { throw new InvalidOperationException( - "The exact-source Apple build snapshot mutation monitor failed; the archive cannot be trusted.", + $"The {_scopeDescription} mutation monitor failed; its output cannot be trusted. {_failureInstruction}", _watcherError); } if (!string.IsNullOrWhiteSpace(_firstMutation)) { throw new InvalidOperationException( - $"The exact-source Apple build snapshot changed while xcodebuild was reading it: {_firstMutation}. " + - "Discard the archive and rebuild from a new snapshot."); + $"The {_scopeDescription} changed while {_readerDescription} was reading it: {_firstMutation}. " + + _failureInstruction); } } @@ -59,4 +69,4 @@ public void Dispose() { _disposed = true; _watcher.Dispose(); } -} \ No newline at end of file +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index 6375283c3..a32cc21f2 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -182,6 +182,19 @@ private void ValidateLiteralSwiftPackagePaths( 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)) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs index 80467fbd0..212f94dcb 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs @@ -46,7 +46,7 @@ private static void ValidateDirectSwiftPackageDependencyFactories(string package { foreach (Match reference in Regex.Matches( manifestSyntax, - "\\.\\s*(?:package\\b|`package`)", + "\\.\\s*(?:package\\b|`package`|binaryTarget\\b|`binaryTarget`)", RegexOptions.CultureInvariant)) { var next = reference.Index + reference.Length; @@ -56,8 +56,8 @@ private static void ValidateDirectSwiftPackageDependencyFactories(string package continue; throw new InvalidOperationException( - $"Local Swift package '{packageRoot}' references a package dependency factory indirectly, so its external source cannot be proven. " + - "Invoke each package dependency factory directly with a literal URL or registry identity and commit its Package.resolved lock."); + $"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."); } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index d5307dd9c..2c3180887 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -313,29 +313,28 @@ private static int FindMatchingPbxBrace(string text, int openingBrace) private static string? ReadPbxScalar(string body, string name) { - var match = Regex.Match( - body, - "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + - "[ \\t]*=[ \\t]*(?:\\\"(?(?:\\\\.|[^\\\"])*)\\\"|(?[^;\\r\\n]+))[ \\t]*;", - RegexOptions.CultureInvariant); - if (!match.Success) - return null; - return match.Groups["quoted"].Success - ? UnescapePbxString(match.Groups["quoted"].Value) - : match.Groups["bare"].Value.Trim(); + return ReadPbxAssignmentValue(body, name); } private static string? ReadPbxDictionary(string body, string name) { - var match = Regex.Match( - body, - "(?:^|[{\\r\\n;])[ \\t]*" + Regex.Escape(name) + "[ \\t]*=[ \\t]*\\{", - RegexOptions.CultureInvariant); - if (!match.Success) + var value = ReadPbxAssignmentValue(body, name); + if (value is null) return null; - var openingBrace = body.IndexOf('{', match.Index + match.Length - 1); - var closingBrace = FindMatchingPbxBrace(body, openingBrace); - return body.Substring(openingBrace + 1, closingBrace - openingBrace - 1); + 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) @@ -428,13 +427,12 @@ private static IEnumerable> ReadPbxAssignments(stri private static string[] ReadPbxReferences(string body, string name) { - var match = Regex.Match( - body, - "(?:^|[\\r\\n;])[ \\t]*" + Regex.Escape(name) + "[ \\t]*=[ \\t]*\\((?.*?)\\)[ \\t]*;", - RegexOptions.Singleline | RegexOptions.CultureInvariant); - if (!match.Success) + var value = ReadPbxAssignmentValue(body, name); + if (value is null) return Array.Empty(); - return match.Groups["items"].Value + 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}")) diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs index f82643680..40f311d0e 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleAutomation.cs @@ -601,6 +601,7 @@ private PowerForgeAppleReleaseReceipt CompleteAppleReleaseReceipt( ? 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs index 5dea19ec8..45448c2c4 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 @@ -22,6 +23,7 @@ private AppleNotarizationResult NotarizeDirectAppleExport( ApiIssuerId = plan.AppStoreConnectApiIssuerId, AcceptedSubmissionId = acceptedSubmissionId, ExpectedArtifactSha256 = expectedArtifactSha256, + AcceptedSubmissionSha256 = acceptedSubmissionSha256, StaplingCompleted = staplingCompleted, AcceptedCheckpoint = checkpoint => WriteAppleNotarizationAcceptance(plan, app, checkpoint), StapledCheckpoint = checkpoint => WriteAppleNotarizationStapled(plan, app, checkpoint), diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index 913a98ba3..d20035d66 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -68,6 +68,7 @@ private void WriteAppleNotarizationAttestation( 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; @@ -103,6 +104,7 @@ private void WriteAppleNotarizationAcceptance( 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 @@ -131,6 +133,7 @@ private void WriteAppleNotarizationStapled( 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; diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 10b6bd9b0..32f5621df 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -361,6 +361,7 @@ static ProcessRunResult CompletedStep(string message, string executable) ArtifactPath = artifactPath, ArtifactSha256 = artifactSha256, SubmissionPath = artifactPath, + SubmissionSha256 = prior.NotarizationSubmissionSha256, SubmissionId = prior.NotarizationSubmissionId, Status = "Accepted", ResumedAcceptedSubmission = true, @@ -391,6 +392,7 @@ static ProcessRunResult CompletedStep(string message, string executable) artifactPath, prior.NotarizationSubmissionId, prior.DirectArtifactSha256, + prior.NotarizationSubmissionSha256, prior.Stapled == true); result.ResumedAcceptedNotarization = true; result.SkippedSteps = MergeAppleSkippedSteps( diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs index 6b97854a0..800d23008 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs @@ -194,6 +194,79 @@ public void ResolveExactAppleSourceCommit_rejects_quoted_pbx_shell_phase_identif 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() { From d33be5d1d90b8a205cb44af1d83394b06c96185e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 05:03:39 +0200 Subject: [PATCH 23/78] Harden Apple source and upload provenance --- ...orgeReleaseServiceTests.AppleAutomation.cs | 7 +-- ...eTests.AppleIntegrityClosureRegressions.cs | 45 ++++++++++++++++++ .../Services/AppleArchiveUploadSnapshot.cs | 10 ++++ ...pleReleaseSourceTrustService.BuildFlags.cs | 30 +++++++++++- ...ReleaseSourceTrustService.LocalPackages.cs | 46 +++++++++++++++++++ .../Services/PowerForgeReleaseService.cs | 10 ++++ ...ests.AppleSourceTrustClosureRegressions.cs | 46 +++++++++++++++++++ 7 files changed, 189 insertions(+), 5 deletions(-) diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index a2dc21298..62bf8325b 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -42,7 +42,7 @@ public void Execute_AppleArchive_RejectsMalformedJournalBeforeArchiveMutation() } [Fact] - public void Execute_AppleUpload_UsesPrivateArchiveSnapshotDuringUploaderMutation() + public void Execute_AppleUpload_RejectsPrivateArchiveSnapshotMutation() { var root = CreateSandbox(); try @@ -79,11 +79,12 @@ public void Execute_AppleUpload_UsesPrivateArchiveSnapshotDuringUploaderMutation AppleAction = PowerForgeAppleReleaseAction.Upload }); - Assert.True(result.Success, result.ErrorMessage); + Assert.False(result.Success); Assert.NotNull(uploaderArchivePath); 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( + Assert.Contains("private Apple upload archive snapshot changed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain( new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), receipt => receipt.OperationPhase == "UploadAttested"); } diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs index abfd1d4c5..f716185dc 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs @@ -128,6 +128,51 @@ public void Execute_AppleUpload_persists_attestation_before_public_archive_reche } } + [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.DoesNotContain( + new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested"); + } finally { + TryDelete(root); + } + } + [Theory] [InlineData("team")] [InlineData("signing")] diff --git a/PowerForge/Services/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs index fc68d45e0..4ab0f1553 100644 --- a/PowerForge/Services/AppleArchiveUploadSnapshot.cs +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -47,6 +47,16 @@ internal static AppleArchiveUploadSnapshot Create(string archivePath, string exp } } + internal void ValidateUnchanged(string expectedSha256) + { + var actual = AppleNotarizationService.ComputeArtifactSha256(ArchivePath); + if (!actual.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple upload snapshot changed while xcodebuild was reading it. Expected '{expectedSha256}', received '{actual}'. Discard the upload/export result and inspect remote state before retrying."); + } + } + public void Dispose() { if (_disposed) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 39d5b8815..5598cefae 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -145,9 +145,23 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke 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) || - token.StartsWith("-Werror=", StringComparison.Ordinal) || + token.StartsWith("-U", StringComparison.Ordinal)) + { + ValidatePreprocessorFlagPayload(token.Substring(2), key); + continue; + } + + if (token.StartsWith("-Werror=", StringComparison.Ordinal) || token.StartsWith("-Wno-", StringComparison.Ordinal) || token.Contains("@executable_path", StringComparison.Ordinal) || token.Contains("@loader_path", StringComparison.Ordinal) || @@ -173,6 +187,18 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke throw new InvalidOperationException($"Xcode build setting {key} ends with a path-consuming flag and no input."); } + 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}"); + } + } + private static string[] ExpandForwardedBuildFlagTokens(string[] tokens, string key) { var expanded = new List(tokens.Length); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index a32cc21f2..55870c0b3 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -101,6 +101,52 @@ private static void ValidateLocalPackageExecutableSafety(string packageRoot, str 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 void ValidateDeclarativeSwiftPackageManifest(string packageRoot, string manifestSyntax) + { + foreach (Match import in Regex.Matches( + manifestSyntax, + "(?m)^\\s*(?:@[A-Za-z_][A-Za-z0-9_]*(?:\\([^\\r\\n]*\\))?\\s+)*" + + "import\\s+(?:(?:class|struct|enum|protocol|typealias|func|var|let)\\s+)?" + + "(?[A-Za-z_][A-Za-z0-9_]*)(?:\\.[^\\r\\n]+)?\\s*$", + 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."); + } } private void ValidateRemotePackageDependencies( diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 3d31e558d..de20557af 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2233,6 +2233,13 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( ? 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."); var upload = _uploadAppleApp(new AppleAppArchiveUploadRequest { ArchivePath = uploadSnapshot?.ArchivePath ?? app.ArchivePath, @@ -2252,6 +2259,9 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( AppStoreConnectApiIssuerId = direct ? null : plan.AppStoreConnectApiIssuerId, AllowProvisioningUpdates = plan.AllowProvisioningUpdates }); + uploadMutationMonitor?.ValidateNoChanges(); + if (uploadSnapshot is not null) + uploadSnapshot.ValidateUnchanged(approvedArchiveSha256!); upload.ArchivePath = app.ArchivePath; result.Upload = upload; if (!upload.Succeeded) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs index 800d23008..832a7a40a 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs @@ -306,6 +306,52 @@ public void ResolveExactAppleSourceCommit_accepts_tracked_safe_compiler_response 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() { From fb38cb7dae81393927338fea967ec356db7f29f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 06:06:29 +0200 Subject: [PATCH 24/78] Harden Apple release evidence and dependency trust --- .../AppStoreConnectClientTests.cs | 60 ++++- .../AppleReleaseReceiptStoreTests.cs | 28 +++ ...ppleReleaseWorkflowTests.PinnedEvidence.cs | 39 ++- ...eleaseServiceTests.ApplePlatformControl.cs | 3 +- .../PowerForgeAppleReleaseAutomation.cs | 3 + .../AppStoreConnectScreenshotSyncService.cs | 21 ++ .../Services/AppleReleaseReceiptStore.cs | 97 +++++++- ...pleReleaseSourceTrustService.BuildFlags.cs | 61 ++++- ...ReleaseSourceTrustService.LocalPackages.cs | 111 +++++++-- ...eReleaseSourceTrustService.PackageLocks.cs | 35 ++- ...eleaseSourceTrustService.RemotePackages.cs | 234 ++++++++++++++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 56 ++++- ...eReleaseSourceTrustService.XcodeParsing.cs | 6 + .../AppleReleaseSourceTrustService.cs | 6 +- .../PowerForgeReleaseService.AppleResume.cs | 4 +- ...dExecutionServiceTests.AppleSourceTrust.cs | 20 +- ...ests.AppleSourceTrustClosureRegressions.cs | 140 ++++++++++- scripts/Invoke-PinnedPowerForge.Evidence.ps1 | 60 ++++- 18 files changed, 922 insertions(+), 62 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs diff --git a/PowerForge.Tests/AppStoreConnectClientTests.cs b/PowerForge.Tests/AppStoreConnectClientTests.cs index 684cbdf0f..d81f85b74 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.cs @@ -1236,6 +1236,8 @@ public async Task ScreenshotSyncService_RetryRetainsMatchingChecksumWithoutReupl """{ "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-1", "type": "appScreenshots", "attributes": { "fileName": "01-home.png", "fileSize": 3, "sourceFileChecksum": "0c8e83d7bd4e4d5e9c170932482c3264", "assetDeliveryState": { "state": "UPLOAD_COMPLETE" } } }] }"""), new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "shot-1", "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/") }; @@ -1265,7 +1267,7 @@ public async Task ScreenshotSyncService_RetryRetainsMatchingChecksumWithoutReupl var set = Assert.Single(result.ScreenshotSets); Assert.Equal(0, set.DeletedCount); Assert.Empty(set.Uploaded); - Assert.Equal(4, handler.RequestUris.Count); + Assert.Equal(replaceExisting ? 5 : 4, handler.RequestUris.Count); } finally { @@ -1293,7 +1295,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); @@ -1400,6 +1404,58 @@ public async Task ScreenshotSyncService_ReplaceExistingRejectsRemoteInventoryDri } } + [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": [] } } }"""); diff --git a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs index f2eed5e7c..d950cfd16 100644 --- a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -54,6 +54,34 @@ public void ReadAll_RejectsTamperedImmutableReceipt() } } + [Fact] + public void ReadAll_RejectsForgedSchemaFiveReceiptWithOnlySelfHash() + { + var root = CreateSandbox(); + try + { + var plan = CreatePlan(root); + var store = new AppleReleaseReceiptStore(); + store.WriteAttempt(plan, CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: true)); + var historyPath = Assert.Single(Directory.GetFiles(plan.ReceiptHistoryPath, "*.json")); + var json = File.ReadAllText(historyPath); + File.WriteAllText( + historyPath, + System.Text.RegularExpressions.Regex.Replace( + json, + "\"receiptAuthenticationSha256\"\\s*:\\s*\"[A-Fa-f0-9]{64}\"", + "\"receiptAuthenticationSha256\": \"" + new string('0', 64) + "\"")); + + var exception = Assert.Throws(() => store.ReadAll(plan)); + + Assert.Contains("recovery authentication failed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + [Fact] public void ReadAll_RejectsLatestReceiptThatPointsAtDifferentValidHistoryEntry() { diff --git a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs index a228bdaf7..905eff1cd 100644 --- a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs +++ b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs @@ -273,34 +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", "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 = 5, attemptId = "00000000000000000000000000000000", receiptSha256 = latestSha, + receiptAuthenticationSha256 = Authenticate(latestSha), sourceCommit = commit })); File.WriteAllText(Path.Combine(receiptHistory.FullName, "prior-upload.json"), JsonSerializer.Serialize(new { - schemaVersion = 4, + schemaVersion = 5, attemptId = "11111111111111111111111111111111", receiptSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + receiptAuthenticationSha256 = Authenticate("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), sourceCommit = "89abcdef0123456789abcdef0123456789abcdef" })); File.WriteAllText(Path.Combine(receiptHistory.FullName, "local-status.json"), JsonSerializer.Serialize(new { - schemaVersion = 4, + schemaVersion = 5, attemptId = "22222222222222222222222222222222", receiptSha256 = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + receiptAuthenticationSha256 = Authenticate("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), action = "Status" })); - File.WriteAllText(Path.Combine(receiptHistory.FullName, "legacy-upload.json"), JsonSerializer.Serialize(new - { - schemaVersion = 3, - sourceCommit = "89abcdef0123456789abcdef0123456789abcdef", - action = "Upload", - success = false - })); File.WriteAllText(Path.Combine(output, "release-plan.json"), JsonSerializer.Serialize(new { planOnly = true, @@ -321,6 +328,7 @@ 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 } @@ -346,8 +354,15 @@ public void ApprovedApplePlanAndBoundAutomationOutputsDoNotRequireASecondCheckou $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"}' - try { Register-AppleAutomationEvidence -SourceCommit '{{commit}}'; throw 'Wrong-source latest legacy receipt was accepted.' } - catch { if ($_.Exception.Message -notlike '*source commit does not match*') { throw } } + 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 an authenticated 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.' } diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs index ade4ce6be..dc7b3eace 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs @@ -314,7 +314,8 @@ public void Execute_AppleDoctor_DiscoversAppIdAndWritesActionableReceipt() Assert.True(result.Success); var receipt = Assert.IsType(result.AppleReceipt); - Assert.Equal(4, receipt.SchemaVersion); + Assert.Equal(5, receipt.SchemaVersion); + Assert.Matches("^[0-9a-f]{64}$", receipt.ReceiptAuthenticationSha256); var target = Assert.Single(receipt.Targets); Assert.Equal(AppleDistributionRoute.AppStore, target.DistributionRoute); Assert.Equal("6778025328", target.AppId); diff --git a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index 9912bff24..901099c9f 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -181,6 +181,9 @@ internal sealed class PowerForgeAppleReleaseReceipt /// Canonical SHA-256 of this receipt with this property omitted. public string? ReceiptSha256 { get; set; } + /// Machine-local HMAC-SHA-256 authenticating for recovery. + public string? ReceiptAuthenticationSha256 { get; set; } + /// True when the operator explicitly requested adoption of an unattested existing build. public bool AdoptExistingBuild { get; set; } diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index edd049371..d128f1be9 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -200,6 +200,27 @@ 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(ComputeSourceChecksum) + .ToArray(); + var finalChecksums = finalScreenshots + .Select(static screenshot => screenshot.SourceFileChecksum?.Trim() ?? string.Empty) + .ToArray(); + if (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 diff --git a/PowerForge/Services/AppleReleaseReceiptStore.cs b/PowerForge/Services/AppleReleaseReceiptStore.cs index b612b1206..261e5e33e 100644 --- a/PowerForge/Services/AppleReleaseReceiptStore.cs +++ b/PowerForge/Services/AppleReleaseReceiptStore.cs @@ -10,6 +10,8 @@ namespace PowerForge; internal sealed class AppleReleaseReceiptStore { private const long MaximumReceiptBytes = 2L * 1024L * 1024L; + private const int AuthenticatedReceiptSchemaVersion = 5; + private const string AuthenticationKeyEnvironmentVariable = "POWERFORGE_APPLE_RECEIPT_AUTH_KEY_PATH"; private static readonly StringComparison PathComparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase @@ -104,7 +106,10 @@ internal void WriteAttempt(PowerForgeAppleReleasePlan plan, PowerForgeAppleRelea receipt.ReceiptPath = ToRelativePath(plan.ProjectRoot, plan.ReceiptPath); receipt.HistoryPath = ToRelativePath(plan.ProjectRoot, historyPath); receipt.PreviousReceiptSha256 = previousReceipt?.ReceiptSha256; + receipt.SchemaVersion = AuthenticatedReceiptSchemaVersion; + receipt.ReceiptAuthenticationSha256 = null; receipt.ReceiptSha256 = ComputeReceiptSha256(receipt); + receipt.ReceiptAuthenticationSha256 = ComputeReceiptAuthenticationSha256(receipt.ReceiptSha256, readOnly: false); var payload = Serialize(receipt); WriteImmutableHistoryEntry(historyDirectory, historyPath, payload); @@ -302,6 +307,16 @@ private static PowerForgeAppleReleaseReceipt Read(string projectRoot, string pat throw new InvalidOperationException($"Apple release receipt integrity validation failed: {path}"); } + if (receipt.SchemaVersion >= AuthenticatedReceiptSchemaVersion) + { + 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!, readOnly: true); + if (!FixedTimeHexEquals(authentication!, actualAuthentication)) + throw new InvalidOperationException($"Apple release receipt recovery authentication failed: {path}"); + } + return receipt; } @@ -322,7 +337,9 @@ private static void WriteCanonicalJson(Utf8JsonWriter writer, JsonElement elemen writer.WriteStartObject(); foreach (var property in element.EnumerateObject().OrderBy(static value => value.Name, StringComparer.Ordinal)) { - if (omitReceiptHash && property.Name.Equals("receiptSha256", StringComparison.OrdinalIgnoreCase)) + 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); @@ -469,6 +486,84 @@ private static bool IsSha256(string? value) value!.Length == 64 && value.All(static character => Uri.IsHexDigit(character)); + private static string ComputeReceiptAuthenticationSha256(string receiptSha256, bool readOnly) + { + var key = ReadAuthenticationKey(readOnly); + using var hmac = new HMACSHA256(key); + return ToLowerHex(hmac.ComputeHash(System.Text.Encoding.ASCII.GetBytes(receiptSha256.ToLowerInvariant()))); + } + + private static byte[] ReadAuthenticationKey(bool readOnly) + { + 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)) + { + if (readOnly) + throw new InvalidOperationException( + $"Authenticated Apple release evidence requires the machine-local key '{keyPath}', but it was not found."); + var directory = Path.GetDirectoryName(keyPath)!; + Directory.CreateDirectory(directory); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + var generated = new byte[32]; + using (var random = RandomNumberGenerator.Create()) + random.GetBytes(generated); + try + { + using var stream = new FileStream(keyPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + stream.Write(generated, 0, generated.Length); + stream.Flush(flushToDisk: true); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); +#endif + } + catch (IOException) when (File.Exists(keyPath)) + { + // Another release process created the same machine-local key. + } + } + + 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) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 5598cefae..555291d09 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -20,6 +20,13 @@ private void ValidateBuildFlagInputPaths( source, responseFiles) .ToArray(); + ValidateLinkerFileLists( + repositoryRoot, + projectDirectory, + expandedTokens, + key, + generatedOutputPaths, + source); foreach (var rawValue in ExtractBuildFlagInputPaths(expandedTokens, key)) { var normalizedValue = rawValue.Trim().TrimEnd('/'); @@ -42,6 +49,51 @@ private void ValidateBuildFlagInputPaths( } } + 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, @@ -115,7 +167,6 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke token.Equals("-L", StringComparison.Ordinal) || token.Equals("-include", StringComparison.Ordinal) || token.Equals("-force_load", StringComparison.Ordinal) || - token.Equals("-filelist", StringComparison.Ordinal) || token.Equals("-ivfsoverlay", StringComparison.Ordinal) || token.Equals("-vfsoverlay", StringComparison.Ordinal) || token.Equals("-fplugin", StringComparison.Ordinal) || @@ -131,6 +182,14 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke 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 prefixes = new[] { "-I", "-F", "-L", "-fmodule-map-file=", "-fplugin=", "-fpass-plugin=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index 55870c0b3..606c944d2 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -74,7 +74,7 @@ private void ValidateLocalPackageManifest( var manifestWithoutComments = RemoveSwiftComments(File.ReadAllText(manifestPath)); EnsureNoExecutableSwiftStringInterpolation(packageRoot, manifestWithoutComments); var manifestSyntax = MaskSwiftStringLiterals(manifestWithoutComments); - ValidateLocalPackageExecutableSafety(packageRoot, manifestSyntax); + ValidateLocalPackageExecutableSafety(packageRoot, manifestWithoutComments, manifestSyntax); ValidateDirectSwiftPackageDependencyFactories(packageRoot, manifestSyntax); var dependencyCalls = ParseDirectSwiftPackageDependencyCalls(manifestWithoutComments, manifestSyntax); ValidateRemotePackageDependencies(repositoryRoot, packageRoot, packageLockPaths, dependencyCalls); @@ -87,13 +87,19 @@ private void ValidateLocalPackageManifest( ValidateLiteralSwiftPackagePaths(repositoryRoot, packageRoot, manifestWithoutComments, manifestSyntax); } - private static void ValidateLocalPackageExecutableSafety(string packageRoot, string 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")) + 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."); @@ -104,6 +110,60 @@ private static void ValidateLocalPackageExecutableSafety(string packageRoot, str 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 void ValidateDeclarativeSwiftPackageManifest(string packageRoot, string manifestSyntax) { foreach (Match import in Regex.Matches( @@ -147,6 +207,24 @@ private static void ValidateDeclarativeSwiftPackageManifest(string packageRoot, $"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 void ValidateRemotePackageDependencies( @@ -166,23 +244,26 @@ private void ValidateRemotePackageDependencies( $"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 revisionValue = string.Empty; var hasExactRevision = dependency.Arguments.TryGetValue("revision", out var revision) && - TryReadLiteralSwiftString(revision, out var revisionValue) && - Regex.IsMatch(revisionValue, "^[A-Fa-f0-9]{40}$", RegexOptions.CultureInvariant); - if (!dependency.Arguments.ContainsKey("id") && hasExactRevision) - continue; - var locks = FindTrackedPackageLocks( - packageLockPaths - .Concat(new[] { Path.Combine(packageRoot, "Package.resolved") }) - .Distinct(GetPathComparer()) - .ToArray(), - identity); - if (locks.Length == 0) + TryReadLiteralSwiftString(revision, out revisionValue) && + Regex.IsMatch(revisionValue, "^(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant); + var effectiveLocks = packageLockPaths + .Concat(new[] { Path.Combine(packageRoot, "Package.resolved") }) + .Distinct(GetPathComparer()) + .ToArray(); + var locks = FindTrackedPackageLocks(effectiveLocks, identity); + if (locks.Length == 0 && !hasExactRevision) throw new InvalidOperationException( - $"Local Swift package '{packageRoot}' declares external dependency '{identity}' without an exact 40-character revision. " + + $"Local Swift package '{packageRoot}' declares external dependency '{identity}' without an exact full Git revision. " + "Commit a Package.resolved lock containing that dependency before creating an exact-source Apple checkpoint."); foreach (var packageLock in locks) EnsureTrackedFile(repositoryRoot, packageLock, "Xcode local Swift package resolution lock"); + var resolvedRevision = ResolvePackageRevision( + effectiveLocks, + identity, + !dependency.Arguments.ContainsKey("id") && hasExactRevision ? revisionValue : null); + ValidateRemotePackageSource(identity, resolvedRevision, effectiveLocks); } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs b/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs index 885f99c86..f6ece188a 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs @@ -18,6 +18,9 @@ private static string[] FindTrackedPackageLocks( } private static bool PackageLockBindsDependency(string path, string dependencyIdentity) + => ReadPackagePinRevisions(path, dependencyIdentity).Length > 0; + + private static string[] ReadPackagePinRevisions(string path, string dependencyIdentity) { JsonDocument document; try @@ -29,6 +32,7 @@ private static bool PackageLockBindsDependency(string path, string dependencyIde throw new InvalidOperationException($"Swift package resolution lock is not valid JSON: {path}", exception); } + var revisions = new List(); using (document) { var root = document.RootElement; @@ -38,7 +42,7 @@ private static bool PackageLockBindsDependency(string path, string dependencyIde legacyObject.TryGetProperty("pins", out pins))) { if (pins.ValueKind != JsonValueKind.Array) - return false; + return Array.Empty(); foreach (var pin in pins.EnumerateArray()) { if (!PackagePinMatchesIdentity(pin, dependencyIdentity) || @@ -49,22 +53,43 @@ private static bool PackageLockBindsDependency(string path, string dependencyIde var revision = ReadJsonString(state, "revision"); if (!string.IsNullOrWhiteSpace(revision) && - revision!.Length == 40 && + (revision!.Length == 40 || revision.Length == 64) && revision.All(Uri.IsHexDigit)) { - return true; + revisions.Add(revision.ToLowerInvariant()); } if (!LooksLikeRepositoryLocation(dependencyIdentity) && !string.IsNullOrWhiteSpace(ReadJsonString(state, "version"))) { - return true; + throw new InvalidOperationException( + $"Swift registry package '{dependencyIdentity}' is version-bound but cannot be source-inspected as a Git revision."); } } } } - return false; + 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) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs new file mode 100644 index 000000000..cd0cb8102 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -0,0 +1,234 @@ +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.Add(identity)) + return; + + if (_remotePackageCheckoutResolver is not null) + { + var resolvedCheckout = Path.GetFullPath(_remotePackageCheckoutResolver(repositoryUrl, revision)); + ValidateRemotePackageCheckout(resolvedCheckout, repositoryUrl, revision, packageLockPaths); + 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); + } + finally + { + if (Directory.Exists(checkoutPath)) + { + var removed = RunGitAllowFailure(mirrorPath, "worktree", "remove", "--force", checkoutPath); + if (!removed.Succeeded && Directory.Exists(checkoutPath)) + Directory.Delete(checkoutPath, recursive: true); + } + } + } + + private void ValidateRemotePackageCheckout( + string checkoutPath, + string repositoryUrl, + string revision, + IReadOnlyCollection packageLockPaths) + { + 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); + _git.EnsureClean(checkoutPath); + var locks = packageLockPaths.Where(File.Exists).Select(Path.GetFullPath).ToList(); + ValidateCheckedOutPackageRoot( + checkoutPath, + checkoutPath, + locks, + new HashSet(GetPathComparer())); + _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."); + } + + private void ValidateCheckedOutPackageRoot( + string checkoutRoot, + string packageRoot, + IReadOnlyCollection effectiveLockPaths, + ISet validatedRoots) + { + 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}"); + + 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"); + } + + 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); + } + + 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); + ValidateDirectSwiftPackageDependencyFactories(packageRoot, 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); + 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."); + } + 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); + } + } + } + + private void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, string revision) + { + var createdMirror = false; + if (!Directory.Exists(mirrorPath)) + { + var temporary = mirrorPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + Directory.CreateDirectory(temporary); + try + { + RunGit(temporary, "init", "--bare"); + 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 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 (createdMirror && !concurrentlyAvailable.Succeeded && Directory.Exists(mirrorPath)) + Directory.Delete(mirrorPath, recursive: true); + throw; + } + } + RunGit(mirrorPath, "cat-file", "-e", revision + "^{commit}"); + } + + 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.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index ea485d1d4..15d41d5dc 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -39,6 +39,14 @@ internal sealed partial class AppleReleaseSourceTrustService "OTHER_SWIFT_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 ExecutableBuildSettings = new(StringComparer.OrdinalIgnoreCase) { "ACTOOL", "AR", "AS", "BITCODE_STRIP", "CC", "CHMOD", "CHOWN", "CODE_SIGN", "CODESIGN_ALLOCATE", @@ -276,6 +284,17 @@ private void ValidateProjectGraph( $"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)) + { + ValidateBuildFileSettings( + repositoryRoot, + projectDirectory, + item, + generatedOutputPaths, + metadataPath); + continue; + } + if (item.Isa.Equals("XCLocalSwiftPackageReference", StringComparison.OrdinalIgnoreCase)) { ValidateLocalPackageReference( @@ -338,6 +357,39 @@ private void ValidateProjectGraph( } } + 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 void ValidateBuildConfiguration( string repositoryRoot, string projectDirectory, @@ -487,7 +539,7 @@ private void ValidateRemotePackageReference( var kind = requirement is null ? null : ReadPbxScalar(requirement, "kind"); var revision = requirement is null ? null : ReadPbxScalar(requirement, "revision"); var exactRevision = string.Equals(kind, "revision", StringComparison.OrdinalIgnoreCase) && - revision?.Length == 40 && + (revision?.Length == 40 || revision?.Length == 64) && revision.All(Uri.IsHexDigit); var locks = FindTrackedPackageLocks(packageLockPaths, repositoryUrl!); if (locks.Length == 0 && !exactRevision) @@ -497,6 +549,8 @@ private void ValidateRemotePackageReference( } foreach (var packageLock in locks) EnsureTrackedFile(repositoryRoot, packageLock, "Swift package resolution lock"); + var resolvedRevision = ResolvePackageRevision(packageLockPaths, repositoryUrl!, exactRevision ? revision : null); + ValidateRemotePackageSource(repositoryUrl!, resolvedRevision, packageLockPaths); } private void ValidateResolvedProjectInput( diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 2c3180887..1cea1233c 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -28,6 +28,12 @@ private void ValidateBuildSettingAssignments( ValidateSdkSelectionBuildSetting(key, assignment.Value, source); continue; } + if (DefinitionBuildSettings.Contains(baseKey)) + { + foreach (var definition in SplitBuildSettingPaths(assignment.Value)) + ValidatePreprocessorFlagPayload(definition, key); + continue; + } IEnumerable values; if (FileValuedBuildSettings.Contains(baseKey) || SearchPathBuildSettings.Contains(baseKey) || diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index f045bd613..1aef07c30 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -21,15 +21,19 @@ internal sealed partial class AppleReleaseSourceTrustService private readonly HomeAssistantReleaseGitService _git; private readonly GitClient _gitClient; + private readonly Func? _remotePackageCheckoutResolver; private readonly Dictionary _gitObjectFormats = new( Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private readonly HashSet _validatedRemotePackages = new(StringComparer.OrdinalIgnoreCase); internal AppleReleaseSourceTrustService( HomeAssistantReleaseGitService? git = null, - GitClient? gitClient = null) + GitClient? gitClient = null, + Func? remotePackageCheckoutResolver = null) { _git = git ?? new HomeAssistantReleaseGitService(); _gitClient = gitClient ?? new GitClient(defaultTimeout: TimeSpan.FromMinutes(2)); + _remotePackageCheckoutResolver = remotePackageCheckoutResolver; } internal string ResolveExactCommit(string repositoryRoot, string configPath) diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 32f5621df..16549f8e8 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -44,7 +44,7 @@ private string[] GetProtectedAppleRecoveryArtifactPaths(PowerForgeAppleReleasePl string.Equals(candidate.BundleId, target.BundleId, StringComparison.OrdinalIgnoreCase) && candidate.Platform == target.Platform && candidate.DistributionRoute == target.DistributionRoute); - if (app is null || string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + if (app is null || receipt.SchemaVersion < 5 || string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) continue; var artifactPath = ValidateDirectRecoveryArtifactPath(plan, app, target.DirectArtifactPath!); if (File.Exists(artifactPath) || Directory.Exists(artifactPath)) @@ -239,6 +239,7 @@ private bool HasPotentialVerifiedAppleUploadAttestation( string? build) { if (receipt.PlanOnly || + receipt.SchemaVersion < 5 || string.IsNullOrWhiteSpace(receipt.ReceiptSha256) || !string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) { @@ -323,6 +324,7 @@ private bool TryResumeDirectAppleNotarization( var prior = _appleReceiptStore.ReadAll(plan) .Where(receipt => !receipt.PlanOnly && + receipt.SchemaVersion >= 5 && !string.IsNullOrWhiteSpace(receipt.ReceiptSha256) && !string.IsNullOrWhiteSpace(plan.SourceCommit) && string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index f9e4adde6..33a20ea1f 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -1105,7 +1105,7 @@ public void ResolveExactAppleSourceCommit_rejects_filtered_bytes_in_synchronized } [Fact] - public void ResolveExactAppleSourceCommit_accepts_tracked_project_lock_for_local_package_dependency() + public void ResolveExactAppleSourceCommit_rejects_locked_remote_package_when_source_cannot_be_inspected() { using var scope = new TemporaryDirectoryScope(); var repositoryRoot = scope.CreateDirectory("LockedLocalRemotePackageInputRepo"); @@ -1142,15 +1142,17 @@ import PackageDescription Path.Combine(lockDirectory, "Package.resolved"), $$"""{ "pins": [ { "location": "{{dependencyUrl}}", "state": { "revision": "0123456789abcdef0123456789abcdef01234567" } } ] }"""); var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); - var sourceCommit = CommitRepository(repositoryRoot); + CommitRepository(repositoryRoot); - var resolved = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); - Assert.Equal(sourceCommit, resolved); + Assert.Contains("fetch", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains(dependencyUrl, exception.Message, StringComparison.Ordinal); } [Fact] - public void ResolveExactAppleSourceCommit_accepts_tracked_workspace_lock_for_nested_project_dependency() + public void ResolveExactAppleSourceCommit_rejects_workspace_locked_remote_package_when_source_cannot_be_inspected() { using var scope = new TemporaryDirectoryScope(); var repositoryRoot = scope.CreateDirectory("WorkspacePackageLockRepo"); @@ -1197,11 +1199,13 @@ public void ResolveExactAppleSourceCommit_accepts_tracked_workspace_lock_for_nes } } """); - var sourceCommit = CommitRepository(repositoryRoot); + CommitRepository(repositoryRoot); - var resolved = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); - Assert.Equal(sourceCommit, resolved); + Assert.Contains("fetch", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains(dependencyUrl, exception.Message, StringComparison.Ordinal); } [Fact] diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs index 832a7a40a..cad371b1f 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs @@ -1,3 +1,4 @@ +using PowerForge; using PowerForgeStudio.Orchestrator.Queue; namespace PowerForgeStudio.Tests; @@ -31,8 +32,7 @@ public void ResolveExactAppleSourceCommit_does_not_treat_nested_revision_argumen File.WriteAllText( Path.Combine(packageRoot, "Package.swift"), "// swift-tools-version: 6.0\nimport PackageDescription\n" + - "func choose(revision: String) -> Package.Dependency.Requirement { .branch(\"main\") }\n" + - "let package = Package(name: \"Shared\", dependencies: [.package(url: \"https://example.invalid/Mutable.git\", branch: choose(revision: \"0123456789abcdef0123456789abcdef01234567\"))])"); + "let package = Package(name: \"Shared\", dependencies: [.package(url: \"https://example.invalid/Mutable.git\", branch: .branch(revision: \"0123456789abcdef0123456789abcdef01234567\"))])"); var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); CommitRepository(repositoryRoot); @@ -360,7 +360,7 @@ public void ResolveExactAppleSourceCommit_ignores_nonargument_path_labels_in_swi File.WriteAllText( Path.Combine(packageRoot, "Package.swift"), "// swift-tools-version: 6.0\nimport PackageDescription\n" + - "let help = \"use path: foo\"\nfunc explain(path: String) -> String { path }\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 {}"); @@ -372,6 +372,140 @@ public void ResolveExactAppleSourceCommit_ignores_nonargument_path_labels_in_swi 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}; }}; }};"); + 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\")])"); + var sources = scope.CreateDirectory(Path.Combine("RemoteSafePackage", "Sources", "Remote")); + File.WriteAllText(Path.Combine(sources, "Remote.swift"), "public struct Remote {}"); + 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}; }}; }};"); + 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) diff --git a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 index d4d8b08b4..930934d4a 100644 --- a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 +++ b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 @@ -14,7 +14,8 @@ function Register-AppleReceiptEvidenceFile { param( [Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][string] $SourceCommit, - [switch] $HistoryEntry + [switch] $HistoryEntry, + [switch] $AllowLegacy ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } $item = Get-Item -LiteralPath $Path -Force @@ -26,20 +27,44 @@ function Register-AppleReceiptEvidenceFile { throw "Apple release receipt is not valid JSON: $Path" } $receiptSource = [string]$receipt.sourceCommit - $immutableReceipt = [int]$receipt.schemaVersion -ge 4 - if ($immutableReceipt) { + $authenticatedReceipt = [int]$receipt.schemaVersion -ge 5 + if ($authenticatedReceipt) { if ( ([string]$receipt.attemptId) -notmatch '^[0-9A-Fa-f]{32}$' -or ([string]$receipt.receiptSha256) -notmatch '^[0-9A-Fa-f]{64}$' -or + ([string]$receipt.receiptAuthenticationSha256) -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 immutable receipt contract: $Path" + throw "Apple release evidence does not satisfy the authenticated receipt 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" + } + } else { + if (-not $AllowLegacy) { + throw "Apple release receipt is legacy unauthenticated evidence and cannot be admitted without an authenticated receipt chain: $Path" } - } elseif (-not $HistoryEntry -and - -not $receiptSource.Equals($SourceCommit, [StringComparison]::OrdinalIgnoreCase)) { - throw "Apple release receipt source commit does not match the exact consumer HEAD '$SourceCommit'." } Add-AllowedConsumerEvidencePath -Path $Path -Name 'Apple release receipt' + return $authenticatedReceipt } function Get-ForwardedArgumentList { @@ -140,23 +165,36 @@ function Register-AppleAutomationEvidence { 'build/powerforge/apple/release-receipt.json' } else { [string]$automation.ReceiptPath } $receiptPath = Resolve-PathFromBase -BasePath $projectRoot -Value $receiptValue - if (Test-Path -LiteralPath $receiptPath) { - Register-AppleReceiptEvidenceFile -Path $receiptPath -SourceCommit $SourceCommit - } $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) { + $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)" } - Register-AppleReceiptEvidenceFile -Path $entry.FullName -SourceCommit $SourceCommit -HistoryEntry + $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; Authenticated = ([int]$value.schemaVersion -ge 5) } + } + $authenticatedReceipts = @($classifiedReceipts | Where-Object Authenticated) + foreach ($entry in $authenticatedReceipts) { + $null = Register-AppleReceiptEvidenceFile -Path $entry.Path -SourceCommit $SourceCommit -HistoryEntry:$entry.History + } + foreach ($entry in @($classifiedReceipts | Where-Object { -not $_.Authenticated })) { + $null = Register-AppleReceiptEvidenceFile -Path $entry.Path -SourceCommit $SourceCommit -HistoryEntry:$entry.History -AllowLegacy:($authenticatedReceipts.Count -gt 0) + } $expectedPlanSha256 = Get-OptionValue -Option '--apple-expected-plan-sha256' if (-not [string]::IsNullOrWhiteSpace($expectedPlanSha256) -and $expectedPlanSha256 -notmatch '^[0-9A-Fa-f]{64}$') { From f86683b7969bed469d5be9676d0cafac6814df23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 06:54:59 +0200 Subject: [PATCH 25/78] Harden Apple recovery authority and package trust --- Docs/PSPublishModule.AppleRelease.md | 14 +- .../Cmdlets/InvokePowerForgeReleaseCommand.cs | 7 +- ...rizationServiceTests.PackagingIntegrity.cs | 57 ++++++++ .../AppleNotarizationServiceTests.cs | 2 +- .../AppleReleaseReceiptStoreTests.cs | 17 +-- ...ppleReleaseWorkflowTests.PinnedEvidence.cs | 6 +- ...orgeReleaseServiceTests.AppleAutomation.cs | 29 +++- ...rviceTests.AppleHostedReviewRegressions.cs | 4 +- ...aseServiceTests.AppleNotarizationResume.cs | 22 ++- ...eleaseServiceTests.ApplePlatformControl.cs | 20 ++- .../PowerForgeAppleReleaseAutomation.cs | 6 +- PowerForge/Models/PowerForgeRelease.cs | 2 +- .../Services/AppleNotarizationService.cs | 19 +++ .../Services/AppleReleaseReceiptStore.cs | 51 ++----- ...pleReleaseSourceTrustService.BuildFlags.cs | 23 ++- ...ReleaseSourceTrustService.LocalPackages.cs | 11 ++ ...eleaseSourceTrustService.RemotePackages.cs | 86 +++++++---- .../AppleReleaseSourceTrustService.cs | 1 + .../PowerForgeReleaseService.AppleResume.cs | 30 +++- .../Services/PowerForgeReleaseService.cs | 4 +- ...eTests.AppleSourceTrustFinalRegressions.cs | 136 ++++++++++++++++++ scripts/Invoke-PinnedPowerForge.Evidence.ps1 | 32 +++-- 22 files changed, 448 insertions(+), 131 deletions(-) create mode 100644 PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs diff --git a/Docs/PSPublishModule.AppleRelease.md b/Docs/PSPublishModule.AppleRelease.md index 256876097..3db3452ad 100644 --- a/Docs/PSPublishModule.AppleRelease.md +++ b/Docs/PSPublishModule.AppleRelease.md @@ -336,6 +336,12 @@ version/build in App Store Connect is not enough by itself. If no retained attem 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 40-character SHA with `--apple-source-commit`. If an older binary must be recovered and independent evidence @@ -346,10 +352,10 @@ powerforge apple-release Upload --config powerforge.release.json --apple-source- powerforge apple-release Upload --config powerforge.release.json --apple-source-commit --apple-adopt-existing-build --confirm-apple-action --summary --output json ``` -Adoption never claims that PowerForge uploaded the binary. The receipt records -`adoptedExistingBuild=true` and emits -`APPLE_BUILD_ADOPTED_WITHOUT_UPLOAD_ATTESTATION`. Prefer a new build number and a fresh -upload whenever that is possible. +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 diff --git a/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs b/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs index 9bae0a6d8..59474b3df 100644 --- a/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs +++ b/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs @@ -275,13 +275,14 @@ public sealed partial class InvokePowerForgeReleaseCommand : PSCmdlet public SwitchParameter ConfirmAppleAction { get; set; } /// - /// Explicitly adopts an exact remote Apple build when no matching local upload attestation exists. - /// Requires ConfirmAppleAction and is intended only for deliberate recovery. + /// 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; } - /// Forces exact remote-build reuse on this run. + /// Enables recovery discovery; deliberate cross-process reuse also requires AdoptExistingAppleBuild and ConfirmAppleAction. [Parameter] public SwitchParameter AppleResume { get; set; } diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs new file mode 100644 index 000000000..809b7d570 --- /dev/null +++ b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs @@ -0,0 +1,57 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleNotarizationServiceTests +{ + [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 { } + } + } + + 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)); + } + } +} diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 957ffa690..1b8846221 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() diff --git a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs index d950cfd16..12fa705e7 100644 --- a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -55,7 +55,7 @@ public void ReadAll_RejectsTamperedImmutableReceipt() } [Fact] - public void ReadAll_RejectsForgedSchemaFiveReceiptWithOnlySelfHash() + public void WriteAttempt_UsesIntegrityOnlySchemaWithoutClaimingSameAccountAuthentication() { var root = CreateSandbox(); try @@ -63,18 +63,11 @@ public void ReadAll_RejectsForgedSchemaFiveReceiptWithOnlySelfHash() var plan = CreatePlan(root); var store = new AppleReleaseReceiptStore(); store.WriteAttempt(plan, CreateReceipt(PowerForgeAppleReleaseAction.Upload, success: true)); - var historyPath = Assert.Single(Directory.GetFiles(plan.ReceiptHistoryPath, "*.json")); - var json = File.ReadAllText(historyPath); - File.WriteAllText( - historyPath, - System.Text.RegularExpressions.Regex.Replace( - json, - "\"receiptAuthenticationSha256\"\\s*:\\s*\"[A-Fa-f0-9]{64}\"", - "\"receiptAuthenticationSha256\": \"" + new string('0', 64) + "\"")); - - var exception = Assert.Throws(() => store.ReadAll(plan)); + var receipt = Assert.Single(store.ReadAll(plan)); - Assert.Contains("recovery authentication failed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(6, receipt.SchemaVersion); + Assert.Null(receipt.ReceiptAuthenticationSha256); + Assert.Matches("^[0-9a-f]{64}$", receipt.ReceiptSha256); } finally { diff --git a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs index 905eff1cd..a82b8cc7d 100644 --- a/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs +++ b/PowerForge.Tests/AppleReleaseWorkflowTests.PinnedEvidence.cs @@ -290,8 +290,8 @@ string Authenticate(string receiptSha256) File.WriteAllText(Path.Combine(sandbox, ".gitignore"), "build/\n"); const string latestSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; File.WriteAllText(Path.Combine(output, "release-receipt.json"), JsonSerializer.Serialize(new { - schemaVersion = 5, attemptId = "00000000000000000000000000000000", receiptSha256 = latestSha, - receiptAuthenticationSha256 = Authenticate(latestSha), sourceCommit = commit })); + schemaVersion = 6, attemptId = "00000000000000000000000000000000", receiptSha256 = latestSha, + sourceCommit = commit })); File.WriteAllText(Path.Combine(receiptHistory.FullName, "prior-upload.json"), JsonSerializer.Serialize(new { schemaVersion = 5, @@ -361,7 +361,7 @@ string Authenticate(string receiptSha256) 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 an authenticated receipt chain*') { throw } } + 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' diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index 62bf8325b..b19103cab 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -782,7 +782,7 @@ 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"; @@ -842,7 +842,7 @@ public void Execute_AppleUploadAction_ResumesExactValidRemoteBuildAndWritesCompa }, getAvailableBytes: _ => throw new InvalidOperationException("Resumed builds must skip archive preflight.")); - var result = service.Execute( + var blocked = service.Execute( spec, new PowerForgeReleaseRequest { @@ -850,6 +850,23 @@ public void Execute_AppleUploadAction_ResumesExactValidRemoteBuildAndWritesCompa AppleSourceCommit = sourceCommit, AppleAction = action }); + Assert.False(blocked.Success); + Assert.Contains( + "continuity evidence, not authority", + Assert.Single(blocked.AppleReceipt!.Targets).ErrorMessage, + StringComparison.OrdinalIgnoreCase); + stateCalls = 0; + + 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); @@ -1329,7 +1346,9 @@ public void Execute_AppleUpload_ResumesFromImmediateAttestationBeforeRemoteBuild ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.UploadExisting, AppleSourceCommit = sourceCommit, - AppleWaitForProcessing = false + AppleWaitForProcessing = false, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.True(resumed.Success, resumed.ErrorMessage); @@ -1358,7 +1377,9 @@ public void Execute_AppleUpload_ResumesFromImmediateAttestationBeforeRemoteBuild ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.UploadExisting, AppleSourceCommit = sourceCommit, - AppleWaitForProcessing = false + AppleWaitForProcessing = false, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.False(rejected.Success); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index b0f0a2885..27885d9b7 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -519,7 +519,9 @@ public void Execute_ConfiguredAppleUploadOnly_ResumesAfterAttestedArchiveWasRemo { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Configured, - AppleSourceCommit = sourceCommit + AppleSourceCommit = sourceCommit, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.True(resumed.Success, resumed.ErrorMessage); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index ba0a3fed1..3f3b43036 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -240,7 +240,7 @@ public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterChe spec.AppleApps.ProjectRoot = relocatedRoot; spec.AppleApps.AppStoreConnectApiKeyPath = Path.Combine(relocatedRoot, "AuthKey_TEST.p8"); AppleNotarizationRequest? resumedRequest = null; - var resumed = CreateAppleAutomationService( + 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."), @@ -260,12 +260,26 @@ public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterChe 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 + }); + var blocked = resumeService.Execute(spec, new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(relocatedRoot, "powerforge.release.json"), + AppleAction = PowerForgeAppleReleaseAction.Upload, + AppleSourceCommit = sourceCommit + }); + 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 = sourceCommit + AppleSourceCommit = sourceCommit, + AppleAdoptExistingBuild = true, + AppleActionConfirmed = true }); Assert.True(resumed.Success, resumed.ErrorMessage); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs index dc7b3eace..1570c9dda 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.ApplePlatformControl.cs @@ -314,8 +314,8 @@ public void Execute_AppleDoctor_DiscoversAppIdAndWritesActionableReceipt() Assert.True(result.Success); var receipt = Assert.IsType(result.AppleReceipt); - Assert.Equal(5, receipt.SchemaVersion); - Assert.Matches("^[0-9a-f]{64}$", receipt.ReceiptAuthenticationSha256); + 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); @@ -838,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); @@ -854,7 +856,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(resumed.Success); @@ -920,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); @@ -990,7 +996,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.False(changedArtifact.Success); Assert.Contains( diff --git a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs index 901099c9f..947180425 100644 --- a/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs +++ b/PowerForge/Models/PowerForgeAppleReleaseAutomation.cs @@ -141,7 +141,7 @@ internal sealed class PowerForgeAppleDirectDistributionOptions /// internal sealed class PowerForgeAppleReleaseReceipt { - public int SchemaVersion { get; set; } = 4; + public int SchemaVersion { get; set; } = 6; /// Unique immutable attempt identity. public string? AttemptId { get; set; } @@ -181,10 +181,10 @@ internal sealed class PowerForgeAppleReleaseReceipt /// Canonical SHA-256 of this receipt with this property omitted. public string? ReceiptSha256 { get; set; } - /// Machine-local HMAC-SHA-256 authenticating for recovery. + /// 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 requested adoption of an unattested existing build. + /// True when the operator explicitly authorized recovery after independently verifying the remote Apple operation. public bool AdoptExistingBuild { get; set; } public PowerForgeAppleVersionReceipt? Versioning { get; set; } diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs index 11451fcfe..897cdf4bf 100644 --- a/PowerForge/Models/PowerForgeRelease.cs +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -235,7 +235,7 @@ internal sealed class PowerForgeReleaseRequest public bool AppleActionConfirmed { get; set; } - /// Explicitly adopt a matching remote build when no verified local upload attestation exists. + /// Explicitly authorize deliberate recovery after independently verifying the remote Apple operation. public bool AppleAdoptExistingBuild { get; set; } public bool? AppleResume { get; set; } diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 75df40de8..747393763 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -61,6 +61,14 @@ public async Task NotarizeAsync( 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 submittedPath = resumed ? artifactPath : await PrepareSubmissionAsync( @@ -70,6 +78,17 @@ public async Task NotarizeAsync( timeout, cancellationToken) .ConfigureAwait(false); + 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() : ComputeFileSha256(submittedPath); diff --git a/PowerForge/Services/AppleReleaseReceiptStore.cs b/PowerForge/Services/AppleReleaseReceiptStore.cs index 261e5e33e..4a0c47a5a 100644 --- a/PowerForge/Services/AppleReleaseReceiptStore.cs +++ b/PowerForge/Services/AppleReleaseReceiptStore.cs @@ -10,7 +10,8 @@ namespace PowerForge; internal sealed class AppleReleaseReceiptStore { private const long MaximumReceiptBytes = 2L * 1024L * 1024L; - private const int AuthenticatedReceiptSchemaVersion = 5; + 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 == '\\' @@ -106,10 +107,9 @@ internal void WriteAttempt(PowerForgeAppleReleasePlan plan, PowerForgeAppleRelea receipt.ReceiptPath = ToRelativePath(plan.ProjectRoot, plan.ReceiptPath); receipt.HistoryPath = ToRelativePath(plan.ProjectRoot, historyPath); receipt.PreviousReceiptSha256 = previousReceipt?.ReceiptSha256; - receipt.SchemaVersion = AuthenticatedReceiptSchemaVersion; + receipt.SchemaVersion = CurrentReceiptSchemaVersion; receipt.ReceiptAuthenticationSha256 = null; receipt.ReceiptSha256 = ComputeReceiptSha256(receipt); - receipt.ReceiptAuthenticationSha256 = ComputeReceiptAuthenticationSha256(receipt.ReceiptSha256, readOnly: false); var payload = Serialize(receipt); WriteImmutableHistoryEntry(historyDirectory, historyPath, payload); @@ -307,12 +307,17 @@ private static PowerForgeAppleReleaseReceipt Read(string projectRoot, string pat throw new InvalidOperationException($"Apple release receipt integrity validation failed: {path}"); } - if (receipt.SchemaVersion >= AuthenticatedReceiptSchemaVersion) + 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!, readOnly: true); + var actualAuthentication = ComputeReceiptAuthenticationSha256(receipt.ReceiptSha256!); if (!FixedTimeHexEquals(authentication!, actualAuthentication)) throw new InvalidOperationException($"Apple release receipt recovery authentication failed: {path}"); } @@ -486,14 +491,14 @@ private static bool IsSha256(string? value) value!.Length == 64 && value.All(static character => Uri.IsHexDigit(character)); - private static string ComputeReceiptAuthenticationSha256(string receiptSha256, bool readOnly) + private static string ComputeReceiptAuthenticationSha256(string receiptSha256) { - var key = ReadAuthenticationKey(readOnly); + var key = ReadAuthenticationKey(); using var hmac = new HMACSHA256(key); return ToLowerHex(hmac.ComputeHash(System.Text.Encoding.ASCII.GetBytes(receiptSha256.ToLowerInvariant()))); } - private static byte[] ReadAuthenticationKey(bool readOnly) + private static byte[] ReadAuthenticationKey() { var configured = Environment.GetEnvironmentVariable(AuthenticationKeyEnvironmentVariable); var keyPath = Path.GetFullPath(string.IsNullOrWhiteSpace(configured) @@ -501,34 +506,8 @@ private static byte[] ReadAuthenticationKey(bool readOnly) : configured!); EnsureUnlinkedAuthenticationKeyPath(keyPath); if (!File.Exists(keyPath)) - { - if (readOnly) - throw new InvalidOperationException( - $"Authenticated Apple release evidence requires the machine-local key '{keyPath}', but it was not found."); - var directory = Path.GetDirectoryName(keyPath)!; - Directory.CreateDirectory(directory); -#if NET8_0_OR_GREATER - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); -#endif - var generated = new byte[32]; - using (var random = RandomNumberGenerator.Create()) - random.GetBytes(generated); - try - { - using var stream = new FileStream(keyPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - stream.Write(generated, 0, generated.Length); - stream.Flush(flushToDisk: true); -#if NET8_0_OR_GREATER - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); -#endif - } - catch (IOException) when (File.Exists(keyPath)) - { - // Another release process created the same machine-local key. - } - } + 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) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 555291d09..92673e099 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -107,9 +107,7 @@ private IEnumerable ExpandCompilerResponseFileTokens( { if (token.Length <= 1 || token[0] != '@' || - token.StartsWith("@executable_path", StringComparison.Ordinal) || - token.StartsWith("@loader_path", StringComparison.Ordinal) || - token.StartsWith("@rpath", StringComparison.Ordinal)) + IsAppleRuntimeRelativePath(token)) { yield return token; continue; @@ -221,14 +219,14 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke } if (token.StartsWith("-Werror=", StringComparison.Ordinal) || - token.StartsWith("-Wno-", StringComparison.Ordinal) || - token.Contains("@executable_path", StringComparison.Ordinal) || - token.Contains("@loader_path", StringComparison.Ordinal) || - token.Contains("@rpath", StringComparison.Ordinal)) + token.StartsWith("-Wno-", StringComparison.Ordinal)) { continue; } + if (IsAppleRuntimeRelativePath(token)) + continue; + if (token.StartsWith("-", StringComparison.Ordinal) && IsPathLikeBuildFlagToken(token)) { throw new InvalidOperationException( @@ -302,6 +300,17 @@ private static bool IsPathLikeBuildFlagToken(string token) 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, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index 606c944d2..240214c78 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -166,6 +166,17 @@ private static bool AllSystemLibrariesAreExcludedFromAppleTargets(string source, 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, "(?m)^\\s*(?:@[A-Za-z_][A-Za-z0-9_]*(?:\\([^\\r\\n]*\\))?\\s+)*" + diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs index cd0cb8102..6400f82bf 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -23,45 +23,56 @@ private void ValidateRemotePackageSource( throw new InvalidOperationException($"Remote Swift package '{repositoryUrl}' is not bound to an exact Git revision."); var identity = NormalizePackageLocation(repositoryUrl) + "@" + revision.ToLowerInvariant(); - if (!_validatedRemotePackages.Add(identity)) + if (_validatedRemotePackages.Contains(identity)) + return; + if (!_remotePackagesUnderValidation.Add(identity)) return; - if (_remotePackageCheckoutResolver is not null) + try { - var resolvedCheckout = Path.GetFullPath(_remotePackageCheckoutResolver(repositoryUrl, revision)); - ValidateRemotePackageCheckout(resolvedCheckout, repositoryUrl, revision, packageLockPaths); - return; - } + if (_remotePackageCheckoutResolver is not null) + { + var resolvedCheckout = Path.GetFullPath(_remotePackageCheckoutResolver(repositoryUrl, revision)); + ValidateRemotePackageCheckout(resolvedCheckout, repositoryUrl, revision, packageLockPaths); + _validatedRemotePackages.Add(identity); + return; + } - var cacheRoot = ResolveRemotePackageCacheRoot(); - Directory.CreateDirectory(cacheRoot); + var cacheRoot = ResolveRemotePackageCacheRoot(); + Directory.CreateDirectory(cacheRoot); #if NET8_0_OR_GREATER - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(cacheRoot, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + 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 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); + 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); + 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); + 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); + _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 { - if (Directory.Exists(checkoutPath)) - { - var removed = RunGitAllowFailure(mirrorPath, "worktree", "remove", "--force", checkoutPath); - if (!removed.Succeeded && Directory.Exists(checkoutPath)) - Directory.Delete(checkoutPath, recursive: true); - } + _remotePackagesUnderValidation.Remove(identity); } } @@ -75,6 +86,7 @@ private void ValidateRemotePackageCheckout( 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( @@ -88,6 +100,26 @@ private void ValidateRemotePackageCheckout( throw new InvalidOperationException($"Remote Swift package '{repositoryUrl}' changed during source inspection."); } + 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, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 1aef07c30..0a3d24440 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -24,6 +24,7 @@ internal sealed partial class AppleReleaseSourceTrustService private readonly Func? _remotePackageCheckoutResolver; 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); internal AppleReleaseSourceTrustService( diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 16549f8e8..5ea095f04 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -44,7 +44,7 @@ private string[] GetProtectedAppleRecoveryArtifactPaths(PowerForgeAppleReleasePl string.Equals(candidate.BundleId, target.BundleId, StringComparison.OrdinalIgnoreCase) && candidate.Platform == target.Platform && candidate.DistributionRoute == target.DistributionRoute); - if (app is null || receipt.SchemaVersion < 5 || string.IsNullOrWhiteSpace(receipt.ReceiptSha256)) + 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)) @@ -72,6 +72,7 @@ private bool TryResumeAppleUpload( { if (attestation is not null) { + 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 @@ -97,12 +98,14 @@ private bool TryResumeAppleUpload( state); } - if (attestation is null && !plan.AdoptExistingBuild) + 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 no immutable local upload receipt binds that build to the current source commit and archive SHA-256. " + - "Increment the build number and upload a new archive, or deliberately adopt the existing build with " + + $"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."); } @@ -131,6 +134,20 @@ private static void PopulateResumedAppleUpload( 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, @@ -239,7 +256,7 @@ private bool HasPotentialVerifiedAppleUploadAttestation( string? build) { if (receipt.PlanOnly || - receipt.SchemaVersion < 5 || + receipt.SchemaVersion < 4 || string.IsNullOrWhiteSpace(receipt.ReceiptSha256) || !string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) { @@ -324,7 +341,7 @@ private bool TryResumeDirectAppleNotarization( var prior = _appleReceiptStore.ReadAll(plan) .Where(receipt => !receipt.PlanOnly && - receipt.SchemaVersion >= 5 && + receipt.SchemaVersion >= 4 && !string.IsNullOrWhiteSpace(receipt.ReceiptSha256) && !string.IsNullOrWhiteSpace(plan.SourceCommit) && string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) @@ -336,6 +353,7 @@ private bool TryResumeDirectAppleNotarization( IsSha256(target.DirectArtifactSha256)); if (prior is null) return false; + EnsureExplicitAppleRecoveryAdoption(plan, app, "an accepted direct notarization submission"); var artifactPath = ValidateDirectRecoveryArtifactPath(plan, app, prior.DirectArtifactPath!); if (!File.Exists(artifactPath) && !Directory.Exists(artifactPath)) diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index de20557af..9b6f9c7ec 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -1817,7 +1817,9 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( { var missingArchive = apps.FirstOrDefault(app => !Directory.Exists(app.ArchivePath) && - (!automation.Resume || !HasPotentialVerifiedAppleUploadAttestation(plan, app))); + (!automation.Resume || + !plan.AdoptExistingBuild || + !HasPotentialVerifiedAppleUploadAttestation(plan, app))); if (missingArchive is not null) { throw new FileNotFoundException( diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs new file mode 100644 index 000000000..c82a364b5 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs @@ -0,0 +1,136 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed partial class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [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}; }}; }};"); + 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}; }}; }};"); + 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); + } + + 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/scripts/Invoke-PinnedPowerForge.Evidence.ps1 b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 index 930934d4a..d05fab2b4 100644 --- a/scripts/Invoke-PinnedPowerForge.Evidence.ps1 +++ b/scripts/Invoke-PinnedPowerForge.Evidence.ps1 @@ -27,15 +27,23 @@ function Register-AppleReceiptEvidenceFile { throw "Apple release receipt is not valid JSON: $Path" } $receiptSource = [string]$receipt.sourceCommit - $authenticatedReceipt = [int]$receipt.schemaVersion -ge 5 - if ($authenticatedReceipt) { + $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 - ([string]$receipt.receiptAuthenticationSha256) -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 authenticated receipt contract: $Path" + 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) @@ -58,13 +66,13 @@ function Register-AppleReceiptEvidenceFile { if (-not [Security.Cryptography.CryptographicOperations]::FixedTimeEquals($expected, $actual)) { throw "Apple release receipt recovery authentication failed: $Path" } - } else { + } elseif ($schemaVersion -ne 6) { if (-not $AllowLegacy) { - throw "Apple release receipt is legacy unauthenticated evidence and cannot be admitted without an authenticated receipt chain: $Path" + 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 $authenticatedReceipt + return $supportedReceipt } function Get-ForwardedArgumentList { @@ -186,14 +194,14 @@ function Register-AppleAutomationEvidence { $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; Authenticated = ([int]$value.schemaVersion -ge 5) } + [pscustomobject]@{ Path = $entry.Path; History = $entry.History; Supported = ([int]$value.schemaVersion -in @(5, 6)) } } - $authenticatedReceipts = @($classifiedReceipts | Where-Object Authenticated) - foreach ($entry in $authenticatedReceipts) { + $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 $_.Authenticated })) { - $null = Register-AppleReceiptEvidenceFile -Path $entry.Path -SourceCommit $SourceCommit -HistoryEntry:$entry.History -AllowLegacy:($authenticatedReceipts.Count -gt 0) + 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' From 0701f658bfa523984cca224ea7a57b1f23183701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 07:47:17 +0200 Subject: [PATCH 26/78] Close Apple exact-build trust gaps --- ...ArchiveServiceTests.ExactSourcePackages.cs | 144 +++++++++++++++++ .../AppleAppArchiveServiceTests.cs | 2 +- ...aseServiceTests.AppleNotarizationResume.cs | 110 +++++++++++++ PowerForge/Models/AppleAppArchiveModels.cs | 3 + PowerForge/Services/AppleAppArchiveService.cs | 51 +++++- ...pleReleaseSourceTrustService.BuildFlags.cs | 42 ++++- ...ReleaseSourceTrustService.LocalPackages.cs | 6 +- ...eleaseSourceTrustService.RemotePackages.cs | 46 +++++- .../AppleReleaseSourceTrustService.Xcode.cs | 8 + ...eleaseSourceTrustService.XcodeExecution.cs | 18 +++ ...eReleaseSourceTrustService.XcodeParsing.cs | 21 +++ .../AppleReleaseSourceTrustService.cs | 1 + .../AppleSwiftPackageBuildSnapshot.cs | 150 ++++++++++++++++++ .../PowerForgeReleaseService.AppleResume.cs | 4 +- .../Services/PowerForgeReleaseService.cs | 1 + ...eTests.AppleSourceTrustFinalRegressions.cs | 110 +++++++++++++ 16 files changed, 696 insertions(+), 21 deletions(-) create mode 100644 PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs create mode 100644 PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs new file mode 100644 index 000000000..885a53178 --- /dev/null +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs @@ -0,0 +1,144 @@ +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); + var runner = new ExactPackageProcessRunner(); + + 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(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.Contains("-clonedSourcePackagesDirPath", archive.Arguments); + Assert.Equal("/usr/bin:/bin:/usr/sbin:/sbin", archive.EnvironmentVariables!["PATH"]); + Assert.Equal( + resolve.Arguments[Array.IndexOf(resolve.Arguments.ToArray(), "-clonedSourcePackagesDirPath") + 1], + archive.Arguments[Array.IndexOf(archive.Arguments.ToArray(), "-clonedSourcePackagesDirPath") + 1]); + Assert.False(Directory.Exists(runner.SourcePackagesRoot)); + } + 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); + var runner = new ExactPackageProcessRunner(mutateDuringArchive: true); + + 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 */ } + } + } + + private sealed class ExactPackageProcessRunner : IProcessRunner + { + private readonly bool _mutateDuringArchive; + + internal ExactPackageProcessRunner(bool mutateDuringArchive = false) + { + _mutateDuringArchive = mutateDuringArchive; + } + + internal List Requests { get; } = new(); + + internal string SourcePackagesRoot { get; private set; } = string.Empty; + + 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 checkout = Directory.CreateDirectory(Path.Combine(SourcePackagesRoot, "checkouts", "Shared")).FullName; + RunGit(checkout, "init", "--quiet"); + RunGit(checkout, "config", "user.name", "PowerForge Tests"); + RunGit(checkout, "config", "user.email", "powerforge-tests@example.invalid"); + File.WriteAllText( + Path.Combine(checkout, "Package.swift"), + "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Shared\")\n"); + RunGit(checkout, "add", "."); + RunGit(checkout, "commit", "--quiet", "-m", "Package fixture"); + } + 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); + } + + return Task.FromResult(new ProcessRunResult( + 0, + "ok", + string.Empty, + request.FileName, + TimeSpan.FromMilliseconds(1), + false)); + } + } + + 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.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.cs index 53087255f..b43aff334 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() diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 3f3b43036..62ac63842 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -295,6 +295,116 @@ public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterChe } } + [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() { diff --git a/PowerForge/Models/AppleAppArchiveModels.cs b/PowerForge/Models/AppleAppArchiveModels.cs index 3103317dd..2efb5f8d5 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; diff --git a/PowerForge/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index 450395207..ec5402477 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,15 +78,43 @@ public async Task CreateArchiveAsync( request.AllowProvisioningUpdates, args); args.Add("archive"); - args.AddRange(request.AdditionalArguments ?? Array.Empty()); + var additionalArguments = request.AdditionalArguments ?? Array.Empty(); + args.AddRange(additionalArguments); - 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); + AppleSwiftPackageBuildSnapshot? packageSnapshot = null; + ProcessRunResult result; + 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); + } + + result = await _processRunner.RunAsync( + new ProcessRunRequest( + xcodeBuildExecutable, + Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), + args, + timeout, + packageSnapshot?.EnvironmentVariables), + cancellationToken).ConfigureAwait(false); + packageSnapshot?.ValidateUnchanged(); + } + finally + { + packageSnapshot?.Dispose(); + } return new AppleAppArchiveResult { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 92673e099..f62a5837d 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -14,7 +14,7 @@ private void ValidateBuildFlagInputPaths( var expandedTokens = ExpandCompilerResponseFileTokens( repositoryRoot, projectDirectory, - SplitBuildSettingPaths(value), + ExpandForwardedBuildFlagTokens(SplitBuildSettingPaths(value).ToArray(), key), key, generatedOutputPaths, source, @@ -103,7 +103,7 @@ private IEnumerable ExpandCompilerResponseFileTokens( string source, ISet responseFiles) { - foreach (var token in tokens) + foreach (var token in ExpandForwardedBuildFlagTokens(tokens.ToArray(), key)) { if (token.Length <= 1 || token[0] != '@' || @@ -149,6 +149,7 @@ private IEnumerable ExpandCompilerResponseFileTokens( private static IEnumerable ExtractBuildFlagInputPaths(string[] inputTokens, string key) { var tokens = ExpandForwardedBuildFlagTokens(inputTokens, key); + var linkerFlags = key.Split('[')[0].Trim().Equals("OTHER_LDFLAGS", StringComparison.OrdinalIgnoreCase); var consumeNext = false; for (var index = 0; index < tokens.Length; index++) { @@ -173,6 +174,8 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke token.Equals("-plugin", StringComparison.Ordinal) || token.Equals("-plugin-path", StringComparison.Ordinal) || token.Equals("-module-map-file", StringComparison.Ordinal) || + token.Equals("-install_name", StringComparison.Ordinal) || + token.Equals("-rpath", StringComparison.Ordinal) || token.Equals("-isysroot", StringComparison.Ordinal) || token.Equals("-sdk", StringComparison.Ordinal)) { @@ -227,6 +230,9 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke if (IsAppleRuntimeRelativePath(token)) continue; + if (linkerFlags && TrySkipNonInputLinkerOption(tokens, ref index, token, key)) + continue; + if (token.StartsWith("-", StringComparison.Ordinal) && IsPathLikeBuildFlagToken(token)) { throw new InvalidOperationException( @@ -239,11 +245,43 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke throw new InvalidOperationException( $"Path-like token in Xcode build setting {key} cannot be classified safely: {token}"); } + + if (linkerFlags && !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 TrySkipNonInputLinkerOption(string[] tokens, ref int index, string token, string key) + { + var argumentCount = token switch + { + "-framework" => 1, + "-weak_framework" => 1, + "-reexport_framework" => 1, + "-lazy_framework" => 1, + "-needed_framework" => 1, + "-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)) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index 240214c78..cc6d6ad77 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -179,9 +179,9 @@ private static void ValidateDeclarativeSwiftPackageManifest(string packageRoot, foreach (Match import in Regex.Matches( manifestSyntax, - "(?m)^\\s*(?:@[A-Za-z_][A-Za-z0-9_]*(?:\\([^\\r\\n]*\\))?\\s+)*" + - "import\\s+(?:(?:class|struct|enum|protocol|typealias|func|var|let)\\s+)?" + - "(?[A-Za-z_][A-Za-z0-9_]*)(?:\\.[^\\r\\n]+)?\\s*$", + "(?[A-Za-z_][A-Za-z0-9_]*)(?:\\.[A-Za-z_][A-Za-z0-9_]*)*", RegexOptions.CultureInvariant)) { var module = import.Groups["module"].Value; diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs index 6400f82bf..ffaad42bd 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -33,7 +33,7 @@ private void ValidateRemotePackageSource( if (_remotePackageCheckoutResolver is not null) { var resolvedCheckout = Path.GetFullPath(_remotePackageCheckoutResolver(repositoryUrl, revision)); - ValidateRemotePackageCheckout(resolvedCheckout, repositoryUrl, revision, packageLockPaths); + ValidateRemotePackageCheckout(resolvedCheckout, repositoryUrl, revision, packageLockPaths, validateRemoteDependencies: true); _validatedRemotePackages.Add(identity); return; } @@ -57,7 +57,7 @@ private void ValidateRemotePackageSource( try { RunGit(mirrorPath, "-c", "core.hooksPath=/dev/null", "worktree", "add", "--detach", checkoutPath, revision); - ValidateRemotePackageCheckout(checkoutPath, repositoryUrl, revision, packageLockPaths); + ValidateRemotePackageCheckout(checkoutPath, repositoryUrl, revision, packageLockPaths, validateRemoteDependencies: true); _validatedRemotePackages.Add(identity); } finally @@ -80,7 +80,8 @@ private void ValidateRemotePackageCheckout( string checkoutPath, string repositoryUrl, string revision, - IReadOnlyCollection packageLockPaths) + IReadOnlyCollection packageLockPaths, + bool validateRemoteDependencies) { var head = RunGit(checkoutPath, "rev-parse", "HEAD").StdOut.Trim(); if (!head.Equals(revision, StringComparison.OrdinalIgnoreCase)) @@ -93,13 +94,43 @@ private void ValidateRemotePackageCheckout( checkoutPath, checkoutPath, locks, - new HashSet(GetPathComparer())); + 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) + { + var root = Path.GetFullPath(sourcePackagesRoot); + var checkouts = Path.Combine(root, "checkouts"); + if (!Directory.Exists(checkouts)) + return; + EnsureNoLinkedTraversal(checkouts, checkouts, "Xcode materialized Swift package checkout root"); + + 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 head = RunGit(entry, "rev-parse", "HEAD").StdOut.Trim(); + var objectFormat = ReadGitObjectFormat(entry); + if (!GitObjectId.IsFullForObjectFormat(head, objectFormat)) + throw new InvalidOperationException($"Xcode materialized Swift package checkout does not have an exact Git HEAD: {entry}"); + ValidateRemotePackageCheckout( + entry, + Path.GetFileName(entry), + head, + Array.Empty(), + validateRemoteDependencies: false); + } + } + private void EnsureRemotePackageHasNoGitLinks(string checkoutPath, string repositoryUrl) { var gitLinks = RunGit(checkoutPath, "ls-files", "--stage", "-z").StdOut @@ -124,7 +155,8 @@ private void ValidateCheckedOutPackageRoot( string checkoutRoot, string packageRoot, IReadOnlyCollection effectiveLockPaths, - ISet validatedRoots) + ISet validatedRoots, + bool validateRemoteDependencies) { packageRoot = Path.GetFullPath(packageRoot); EnsureDirectoryWithinRepository(checkoutRoot, packageRoot, "remote Swift package root"); @@ -177,7 +209,7 @@ private void ValidateCheckedOutPackageRoot( 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); + ValidateCheckedOutPackageRoot(checkoutRoot, nestedRoot, locks, validatedRoots, validateRemoteDependencies); continue; } @@ -189,6 +221,8 @@ private void ValidateCheckedOutPackageRoot( { 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) && diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 15d41d5dc..e6a346b86 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -47,6 +47,14 @@ internal sealed partial class AppleReleaseSourceTrustService "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", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs new file mode 100644 index 000000000..9230bb4fb --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs @@ -0,0 +1,18 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static void ValidateTrustedXcodeBuildExecutable(string? executable) + { + var value = executable?.Trim(); + if (string.IsNullOrWhiteSpace(value) || + string.Equals(value, "xcodebuild", StringComparison.Ordinal) || + string.Equals(value, "/usr/bin/xcodebuild", StringComparison.Ordinal)) + { + return; + } + + throw new InvalidOperationException( + $"Exact-source Apple checkpoints require the system Xcode build tool '/usr/bin/xcodebuild'; configured executable '{value}' is not trusted."); + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 1cea1233c..52d56d346 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -34,6 +34,11 @@ private void ValidateBuildSettingAssignments( ValidatePreprocessorFlagPayload(definition, key); continue; } + if (SourceSelectionBuildSettings.Contains(baseKey)) + { + ValidateSourceSelectionBuildSetting(key, assignment.Value, source); + continue; + } IEnumerable values; if (FileValuedBuildSettings.Contains(baseKey) || SearchPathBuildSettings.Contains(baseKey) || @@ -82,6 +87,22 @@ private void ValidateBuildSettingAssignments( } } + 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) { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 0a3d24440..fc87c3641 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -128,6 +128,7 @@ private void ValidateAppleInputs( PowerForgeAppleReleaseOptions options, IReadOnlyCollection generatedOutputPaths) { + ValidateTrustedXcodeBuildExecutable(options.XcodeBuildExecutable); var configDirectory = Path.GetDirectoryName(configPath) ?? repositoryRoot; var projectRoot = ResolvePath( configDirectory, diff --git a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs new file mode 100644 index 000000000..3e9f80b4e --- /dev/null +++ b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs @@ -0,0 +1,150 @@ +namespace PowerForge; + +/// +/// Owns the private Swift package materialization consumed by one exact-source Xcode archive. +/// +internal sealed class AppleSwiftPackageBuildSnapshot : IDisposable +{ + private static readonly IReadOnlyDictionary IsolatedGitEnvironment = + new Dictionary(StringComparer.Ordinal) + { + ["GIT_CONFIG_NOSYSTEM"] = "1", + ["GIT_CONFIG_SYSTEM"] = "/dev/null", + ["GIT_CONFIG_GLOBAL"] = "/dev/null", + ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + }; + + private readonly AppleReleaseSourceTrustService _sourceTrust = new(); + private readonly AppleReleaseSourceMutationMonitor? _monitor; + private bool _disposed; + + private AppleSwiftPackageBuildSnapshot(string rootPath) + { + RootPath = rootPath; + _monitor = Directory.Exists(rootPath) + ? new AppleReleaseSourceMutationMonitor( + rootPath, + "materialized Swift package root", + "xcodebuild archive", + "Discard the archive and resolve the exact package graph again.") + : null; + } + + internal string RootPath { get; } + + internal IReadOnlyDictionary EnvironmentVariables => IsolatedGitEnvironment; + + 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); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + try + { + var arguments = new[] + { + isWorkspace ? "-workspace" : "-project", + projectPath, + "-scheme", + scheme, + "-resolvePackageDependencies", + "-clonedSourcePackagesDirPath", + root, + "-onlyUsePackageVersionsFromResolvedFile", + "-disableAutomaticPackageResolution", + "-skipPackageUpdates" + }; + var result = await processRunner.RunAsync( + new ProcessRunRequest( + xcodeBuildExecutable, + Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), + arguments, + timeout, + IsolatedGitEnvironment), + cancellationToken) + .ConfigureAwait(false); + if (!result.Succeeded) + { + 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)); + } + + new AppleReleaseSourceTrustService().ValidateMaterializedPackageCheckouts(root); + var snapshot = new AppleSwiftPackageBuildSnapshot(root); + try + { + return snapshot; + } + catch + { + snapshot.Dispose(); + throw; + } + } + catch + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + throw; + } + } + + internal void AppendArchiveArguments(ICollection arguments) + { + arguments.Add("-clonedSourcePackagesDirPath"); + arguments.Add(RootPath); + arguments.Add("-onlyUsePackageVersionsFromResolvedFile"); + arguments.Add("-disableAutomaticPackageResolution"); + arguments.Add("-skipPackageUpdates"); + } + + internal void ValidateUnchanged() + { + _monitor?.ValidateNoChanges(); + _sourceTrust.ValidateMaterializedPackageCheckouts(RootPath); + } + + internal static void RejectConflictingArguments(IEnumerable arguments) + { + var forbidden = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "-clonedSourcePackagesDirPath", + "-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(); + if (Directory.Exists(RootPath)) + Directory.Delete(RootPath, recursive: true); + } +} diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 5ea095f04..13fdf9cd5 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -353,7 +353,6 @@ private bool TryResumeDirectAppleNotarization( IsSha256(target.DirectArtifactSha256)); if (prior is null) return false; - EnsureExplicitAppleRecoveryAdoption(plan, app, "an accepted direct notarization submission"); var artifactPath = ValidateDirectRecoveryArtifactPath(plan, app, prior.DirectArtifactPath!); if (!File.Exists(artifactPath) && !Directory.Exists(artifactPath)) @@ -365,6 +364,7 @@ private bool TryResumeDirectAppleNotarization( 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)) { @@ -406,6 +406,8 @@ static ProcessRunResult CompletedStep(string message, string executable) if (string.IsNullOrWhiteSpace(prior.ErrorMessage)) return false; + EnsureExplicitAppleRecoveryAdoption(plan, app, "an accepted direct notarization submission"); + result.Notarization = NotarizeDirectAppleExport( plan, app, diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 9b6f9c7ec..fedb71903 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2197,6 +2197,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( 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, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs index c82a364b5..2a970340c 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs @@ -113,6 +113,116 @@ public void ResolveExactAppleSourceCommit_rejects_absolute_linker_input_containi 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_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); + } + private static string ReadFixtureHead(string repositoryRoot) { var startInfo = new System.Diagnostics.ProcessStartInfo("git") From 8753c34e2681e40a8ca11049b15edf2d1f2e0c41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 08:33:51 +0200 Subject: [PATCH 27/78] Close final Apple merge blockers --- .../AppStoreConnectClientTests.cs | 70 ++++++++-- .../AppleNotarizationServiceTests.cs | 123 ++++++++++++++++++ PowerForge/Models/AppleNotarizationModels.cs | 3 + .../AppStoreConnectScreenshotSyncService.cs | 29 ++--- .../Services/AppleNotarizationService.cs | 95 ++++++++++++-- ...pleReleaseSourceTrustService.BuildFlags.cs | 73 ++++++++++- ...eleaseSourceTrustService.XcodeExecution.cs | 31 ++++- .../AppleReleaseSourceTrustService.cs | 2 +- ...eReleaseService.AppleDirectDistribution.cs | 1 + ...eTests.AppleSourceTrustFinalRegressions.cs | 44 +++++++ 10 files changed, 425 insertions(+), 46 deletions(-) diff --git a/PowerForge.Tests/AppStoreConnectClientTests.cs b/PowerForge.Tests/AppStoreConnectClientTests.cs index d81f85b74..69b690a43 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.cs @@ -1219,10 +1219,8 @@ public async Task ScreenshotSyncService_RejectsApprovedFileMissingFromImmutableS } } - [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 @@ -1236,8 +1234,6 @@ public async Task ScreenshotSyncService_RetryRetainsMatchingChecksumWithoutReupl """{ "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-1", "type": "appScreenshots", "attributes": { "fileName": "01-home.png", "fileSize": 3, "sourceFileChecksum": "0c8e83d7bd4e4d5e9c170932482c3264", "assetDeliveryState": { "state": "UPLOAD_COMPLETE" } } }] }"""), new SequenceResponse(HttpStatusCode.OK, """{ "data": [{ "id": "shot-1", "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/") }; @@ -1246,7 +1242,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", @@ -1267,7 +1263,65 @@ public async Task ScreenshotSyncService_RetryRetainsMatchingChecksumWithoutReupl var set = Assert.Single(result.ScreenshotSets); Assert.Equal(0, set.DeletedCount); Assert.Empty(set.Uploaded); - Assert.Equal(replaceExisting ? 5 : 4, handler.RequestUris.Count); + Assert.Equal(4, handler.RequestUris.Count); + } + finally + { + try { root.Delete(recursive: true); } catch { /* best effort */ } + } + } + + [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 { diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 1b8846221..1bc66f5eb 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -67,6 +67,71 @@ 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"]); + }, + request => + { + Assert.Equal("/usr/sbin/spctl", request.FileName); + Assert.Equal("/usr/bin:/bin:/usr/sbin:/sbin", request.EnvironmentVariables?["PATH"]); + }); + } + finally + { + try { File.Delete(artifact); } catch { } + } + } + [Fact] public async Task NotarizeAsync_RejectsAcceptedAppSubmissionWhenPrivateZipChangesDuringUpload() { @@ -456,6 +521,64 @@ public void ComputeArtifactSha256_IsStableAcrossTimestampOnlyCopyChanges() } } + [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() { diff --git a/PowerForge/Models/AppleNotarizationModels.cs b/PowerForge/Models/AppleNotarizationModels.cs index 862c414e6..eabad82f8 100644 --- a/PowerForge/Models/AppleNotarizationModels.cs +++ b/PowerForge/Models/AppleNotarizationModels.cs @@ -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; } diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index d128f1be9..e4810749b 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -158,26 +158,15 @@ public async Task SyncAsync( var filesToUpload = FindMissingFiles(plannedSet.Preflighted.Files, plannedSet.ExistingScreenshots); 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) { await _client.DeleteScreenshotAsync(screenshot.Id, cancellationToken).ConfigureAwait(false); deletedCount++; } - filesToUpload = plannedSet.Preflighted.Files.Skip(prefixLength).ToArray(); + filesToUpload = plannedSet.Preflighted.Files; } var uploaded = new List(); @@ -213,7 +202,15 @@ public async Task SyncAsync( var finalChecksums = finalScreenshots .Select(static screenshot => screenshot.SourceFileChecksum?.Trim() ?? string.Empty) .ToArray(); - if (finalChecksums.Length != expectedChecksums.Length || + 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( diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 747393763..5987d2e90 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -7,6 +7,12 @@ namespace PowerForge; /// Submits direct macOS artifacts for notarization and verifies the accepted result locally. public sealed class AppleNotarizationService { + private static readonly IReadOnlyDictionary TrustedSystemToolEnvironment = + new Dictionary(StringComparer.Ordinal) + { + ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + }; + private readonly IProcessRunner _processRunner; /// Creates a notarization service. @@ -40,6 +46,25 @@ 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 + ? TrustedSystemToolEnvironment + : null; + var artifactSha256 = ComputeArtifactSha256(artifactPath); var expectedArtifactSha256 = string.IsNullOrWhiteSpace(request.ExpectedArtifactSha256) ? null @@ -73,6 +98,8 @@ public async Task NotarizeAsync( ? artifactPath : await PrepareSubmissionAsync( request, + dittoExecutable, + toolEnvironment, submissionArtifactPath, submissionSnapshot.RootPath, timeout, @@ -120,7 +147,7 @@ public async Task NotarizeAsync( "private Apple notarization submission", "notarytool", "Do not resubmit until the accepted submission has been reconciled."); - submission = await RunAsync(request.XcrunExecutable, submissionArtifactPath, submitArguments, timeout, cancellationToken).ConfigureAwait(false); + submission = await RunAsync(xcrunExecutable, submissionArtifactPath, submitArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); (submissionId, status) = ParseSubmission(submission); try { @@ -189,17 +216,17 @@ public async Task NotarizeAsync( 0, "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, submissionArtifactPath, new[] { "stapler", "staple", submissionArtifactPath }, timeout, cancellationToken).ConfigureAwait(false); + staple = await RunAsync(xcrunExecutable, submissionArtifactPath, new[] { "stapler", "staple", submissionArtifactPath }, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); stapledThisInvocation = staple.Succeeded; } if (staple?.Succeeded == true && validation is null) - validation = await RunAsync(request.XcrunExecutable, submissionArtifactPath, new[] { "stapler", "validate", submissionArtifactPath }, timeout, cancellationToken).ConfigureAwait(false); + validation = await RunAsync(xcrunExecutable, submissionArtifactPath, new[] { "stapler", "validate", submissionArtifactPath }, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); if (stapledThisInvocation && validation?.Succeeded == true && !string.IsNullOrWhiteSpace(submissionId)) { var privateStapledArtifactSha256 = ComputeArtifactSha256(submissionArtifactPath); @@ -234,7 +261,7 @@ public async Task NotarizeAsync( extension.Equals(".app", StringComparison.OrdinalIgnoreCase) ? "execute" : "install", "--verbose=4", submissionArtifactPath }; - assessment = await RunAsync(request.SpctlExecutable, submissionArtifactPath, assessmentArguments, timeout, cancellationToken).ConfigureAwait(false); + assessment = await RunAsync(spctlExecutable, submissionArtifactPath, assessmentArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); } string finalArtifactSha256; @@ -271,6 +298,8 @@ public async Task NotarizeAsync( private async Task PrepareSubmissionAsync( AppleNotarizationRequest request, + string dittoExecutable, + IReadOnlyDictionary? toolEnvironment, string artifactPath, string privateRoot, TimeSpan timeout, @@ -284,10 +313,11 @@ private async Task PrepareSubmissionAsync( Path.GetFileNameWithoutExtension(artifactPath) + ".notarization.zip"); Directory.CreateDirectory(Path.GetDirectoryName(submissionPath)!); var package = await RunAsync( - request.DittoExecutable, + dittoExecutable, artifactPath, new[] { "-c", "-k", "--keepParent", artifactPath, submissionPath }, timeout, + toolEnvironment, cancellationToken).ConfigureAwait(false); if (!package.Succeeded) throw new InvalidOperationException($"ditto failed to package '{artifactPath}' for notarization with exit code {package.ExitCode}: {package.StdErr}"); @@ -347,15 +377,38 @@ private Task RunAsync( string artifactPath, IReadOnlyList arguments, TimeSpan timeout, + IReadOnlyDictionary? environmentVariables, CancellationToken cancellationToken) => _processRunner.RunAsync( new ProcessRunRequest( - string.IsNullOrWhiteSpace(executable) ? "xcrun" : executable.Trim(), + executable, Path.GetDirectoryName(artifactPath) ?? Directory.GetCurrentDirectory(), arguments, - timeout), + timeout, + environmentVariables), cancellationToken); + private static string ResolveAppleToolExecutable( + string? executable, + string defaultName, + string trustedPath, + bool requireTrustedSystemTool) + { + var value = string.IsNullOrWhiteSpace(executable) + ? defaultName + : executable!.Trim(); + if (!requireTrustedSystemTool) + return value; + if (value.Equals(defaultName, StringComparison.Ordinal) || + value.Equals(trustedPath, StringComparison.Ordinal)) + { + return trustedPath; + } + + throw new InvalidOperationException( + $"Exact-source Apple notarization requires the trusted system tool '{trustedPath}'; received '{value}'."); + } + private static (string? Id, string? Status) ParseSubmission(ProcessRunResult result) { var payload = string.IsNullOrWhiteSpace(result.StdOut) ? result.StdErr : result.StdOut; @@ -376,6 +429,7 @@ private static (string? Id, string? Status) ParseSubmission(ProcessRunResult res internal static string ComputeArtifactSha256(string artifactPath) { using var sha256 = SHA256.Create(); + AppendValue(sha256, "PowerForge.ArtifactSha256.v2"); if (File.Exists(artifactPath)) { AppendFileSystemEntry( @@ -436,6 +490,7 @@ private static void AppendFileSystemEntry( string relativePath, bool includeContents) { + AppendValue(hash, "entry"); AppendValue(hash, relativePath); AppendValue(hash, ((int)entry.Attributes).ToString(System.Globalization.CultureInfo.InvariantCulture)); #if NET8_0_OR_GREATER @@ -456,26 +511,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/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index f62a5837d..99b58f032 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -165,6 +165,18 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke token.Equals("-F", StringComparison.Ordinal) || token.Equals("-L", StringComparison.Ordinal) || token.Equals("-include", StringComparison.Ordinal) || + token.Equals("-imacros", StringComparison.Ordinal) || + token.Equals("-include-pch", StringComparison.Ordinal) || + token.Equals("-include-pth", StringComparison.Ordinal) || + token.Equals("-iquote", StringComparison.Ordinal) || + token.Equals("-isystem", StringComparison.Ordinal) || + token.Equals("-isystem-after", StringComparison.Ordinal) || + token.Equals("-idirafter", StringComparison.Ordinal) || + token.Equals("-iframework", StringComparison.Ordinal) || + token.Equals("-iframeworkwithsysroot", StringComparison.Ordinal) || + token.Equals("-iprefix", StringComparison.Ordinal) || + token.Equals("-iwithprefix", StringComparison.Ordinal) || + token.Equals("-iwithprefixbefore", StringComparison.Ordinal) || token.Equals("-force_load", StringComparison.Ordinal) || token.Equals("-ivfsoverlay", StringComparison.Ordinal) || token.Equals("-vfsoverlay", StringComparison.Ordinal) || @@ -174,6 +186,15 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke token.Equals("-plugin", StringComparison.Ordinal) || token.Equals("-plugin-path", StringComparison.Ordinal) || token.Equals("-module-map-file", StringComparison.Ordinal) || + token.Equals("-fmodule-map-file", StringComparison.Ordinal) || + token.Equals("-fmodule-file", StringComparison.Ordinal) || + token.Equals("-fprofile-use", StringComparison.Ordinal) || + token.Equals("-fprofile-instr-use", StringComparison.Ordinal) || + token.Equals("-fprofile-sample-use", StringComparison.Ordinal) || + token.Equals("-resource-dir", StringComparison.Ordinal) || + token.Equals("-working-directory", StringComparison.Ordinal) || + token.Equals("-gcc-toolchain", StringComparison.Ordinal) || + token.Equals("--sysroot", StringComparison.Ordinal) || token.Equals("-install_name", StringComparison.Ordinal) || token.Equals("-rpath", StringComparison.Ordinal) || token.Equals("-isysroot", StringComparison.Ordinal) || @@ -193,15 +214,27 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke var prefixes = new[] { - "-I", "-F", "-L", "-fmodule-map-file=", "-fplugin=", "-fpass-plugin=", - "-ivfsoverlay=", "-vfsoverlay=", "-plugin-path=", "-module-map-file=", - "-isysroot=", "-sdk=" + "-iframeworkwithsysroot", "-iwithprefixbefore", "-fprofile-instr-use=", + "-fprofile-sample-use=", "-fmodule-map-file=", "-working-directory=", + "-include-pch=", "-include-pth=", "-gcc-toolchain=", "-resource-dir=", + "-iwithprefix", "-fprofile-use=", "-fmodule-file=", "-module-map-file=", + "-ivfsoverlay=", "-vfsoverlay=", "-plugin-path=", "-fpass-plugin=", + "-iframework", "-idirafter", "-imacros=", "-include=", "--sysroot=", + "-isystem-after", "-isystem", "-iquote", "-iprefix", "-fplugin=", "-isysroot=", "-sdk=", + "-I", "-F", "-L" }; var prefix = prefixes.FirstOrDefault(candidate => token.StartsWith(candidate, StringComparison.Ordinal) && token.Length > candidate.Length); if (prefix is not null) { - yield return token.Substring(prefix.Length); + 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; } @@ -233,6 +266,9 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke 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( @@ -246,13 +282,40 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke $"Path-like token in Xcode build setting {key} cannot be classified safely: {token}"); } - if (linkerFlags && !token.StartsWith("-", StringComparison.Ordinal)) + 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 TrySkipNonInputLinkerOption(string[] tokens, ref int index, string token, string key) { var argumentCount = token switch diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs index 9230bb4fb..e642a3b53 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeExecution.cs @@ -2,17 +2,40 @@ namespace PowerForge; internal sealed partial class AppleReleaseSourceTrustService { - private static void ValidateTrustedXcodeBuildExecutable(string? executable) + 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, "xcodebuild", StringComparison.Ordinal) || - string.Equals(value, "/usr/bin/xcodebuild", StringComparison.Ordinal)) + string.Equals(value, defaultName, StringComparison.Ordinal) || + string.Equals(value, trustedPath, StringComparison.Ordinal)) { return; } throw new InvalidOperationException( - $"Exact-source Apple checkpoints require the system Xcode build tool '/usr/bin/xcodebuild'; configured executable '{value}' is not trusted."); + $"Exact-source Apple checkpoints require the trusted system tool '{trustedPath}'; configured executable '{value}' is not trusted."); } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index fc87c3641..0263b2eb9 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -128,7 +128,7 @@ private void ValidateAppleInputs( PowerForgeAppleReleaseOptions options, IReadOnlyCollection generatedOutputPaths) { - ValidateTrustedXcodeBuildExecutable(options.XcodeBuildExecutable); + ValidateTrustedAppleToolExecutables(options); var configDirectory = Path.GetDirectoryName(configPath) ?? repositoryRoot; var projectRoot = ResolvePath( configDirectory, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs index 45448c2c4..64a4308af 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs @@ -17,6 +17,7 @@ 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, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs index 2a970340c..2703ce412 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs @@ -165,6 +165,23 @@ public void ResolveExactAppleSourceCommit_validates_forwarded_linker_runtime_pat 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() { @@ -223,6 +240,33 @@ public void ResolveExactAppleSourceCommit_rejects_custom_xcodebuild_executable() 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); + } + private static string ReadFixtureHead(string repositoryRoot) { var startInfo = new System.Diagnostics.ProcessStartInfo("git") From 2af953ecc8dc0162d3b021c862bc94805c276a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 09:51:26 +0200 Subject: [PATCH 28/78] Harden exact Apple build input boundaries --- ...ArchiveServiceTests.ExactSourcePackages.cs | 145 ++++++++++++++++-- ...rizationServiceTests.PackagingIntegrity.cs | 22 +++ .../ProcessRunnerEnvironmentTests.cs | 33 ++++ PowerForge/Abstractions/IProcessRunner.cs | 84 +++++++++- PowerForge/Services/AppleAppArchiveService.cs | 5 +- .../AppleNotarizationInputSnapshot.cs | 35 ++++- .../Services/AppleNotarizationService.cs | 1 + ...ReleaseSourceTrustService.LocalPackages.cs | 37 +++++ ...eReleaseSourceTrustService.PackageLocks.cs | 61 ++++++++ ...eleaseSourceTrustService.RemotePackages.cs | 31 +++- ...eleaseSourceTrustService.SourceIncludes.cs | 126 +++++++++++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 1 + ...eReleaseSourceTrustService.XcodeParsing.cs | 22 +++ .../AppleReleaseSourceTrustService.cs | 2 + .../AppleSwiftPackageBuildSnapshot.cs | 107 ++++++++++--- ...eTests.AppleSourceTrustFinalRegressions.cs | 59 +++++++ 16 files changed, 729 insertions(+), 42 deletions(-) create mode 100644 PowerForge.Tests/ProcessRunnerEnvironmentTests.cs create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs index 885a53178..e7d04a8a3 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs @@ -13,7 +13,10 @@ public async Task CreateArchiveAsync_exact_source_builds_from_validated_private_ { var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); - var runner = new ExactPackageProcessRunner(); + 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 { @@ -32,12 +35,20 @@ public async Task CreateArchiveAsync_exact_source_builds_from_validated_private_ 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.Equal( + 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 { @@ -54,7 +65,10 @@ public async Task CreateArchiveAsync_exact_source_rejects_transient_package_chec { var project = Directory.CreateDirectory(Path.Combine(root.FullName, "App.xcodeproj")); File.WriteAllText(Path.Combine(project.FullName, "project.pbxproj"), string.Empty); - var runner = new ExactPackageProcessRunner(mutateDuringArchive: true); + 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 @@ -74,19 +88,72 @@ public async Task CreateArchiveAsync_exact_source_rejects_transient_package_chec } } + [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 { } + } + } + private sealed class ExactPackageProcessRunner : IProcessRunner { private readonly bool _mutateDuringArchive; + private readonly bool _materializeWrongRevision; + private readonly string _remoteSourceRoot; - internal ExactPackageProcessRunner(bool mutateDuringArchive = false) + internal ExactPackageProcessRunner( + string fixtureRoot, + bool mutateDuringArchive = false, + bool materializeWrongRevision = false) { _mutateDuringArchive = mutateDuringArchive; + _materializeWrongRevision = materializeWrongRevision; + _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); @@ -94,15 +161,20 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo { var index = Array.IndexOf(request.Arguments.ToArray(), "-clonedSourcePackagesDirPath"); SourcePackagesRoot = request.Arguments[index + 1]; - var checkout = Directory.CreateDirectory(Path.Combine(SourcePackagesRoot, "checkouts", "Shared")).FullName; - RunGit(checkout, "init", "--quiet"); - RunGit(checkout, "config", "user.name", "PowerForge Tests"); - RunGit(checkout, "config", "user.email", "powerforge-tests@example.invalid"); - File.WriteAllText( - Path.Combine(checkout, "Package.swift"), - "// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: \"Shared\")\n"); - RunGit(checkout, "add", "."); - RunGit(checkout, "commit", "--quiet", "-m", "Package fixture"); + 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", _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"); + } } else if (_mutateDuringArchive) { @@ -122,6 +194,55 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo } } + 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") diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs index 809b7d570..efdb26e00 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs @@ -2,6 +2,28 @@ 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 async Task NotarizeAsync_RejectsTransientAppMutationWhileDittoCreatesSubmissionZip() { diff --git a/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs b/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs new file mode 100644 index 000000000..f237663e4 --- /dev/null +++ b/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs @@ -0,0 +1,33 @@ +namespace PowerForge.Tests; + +public sealed class ProcessRunnerEnvironmentTests +{ + [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..61e06b106 100644 --- a/PowerForge/Abstractions/IProcessRunner.cs +++ b/PowerForge/Abstractions/IProcessRunner.cs @@ -35,7 +35,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 +96,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 +144,7 @@ public ProcessRunRequest( CaptureError = captureError; OutputLineReceived = outputLineReceived; ErrorLineReceived = errorLineReceived; + InheritEnvironment = inheritEnvironment; } /// @@ -98,6 +172,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. /// @@ -299,6 +378,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/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index ec5402477..b139657fb 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -107,7 +107,10 @@ public async Task CreateArchiveAsync( Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), args, timeout, - packageSnapshot?.EnvironmentVariables), + packageSnapshot?.EnvironmentVariables, + captureOutput: true, + captureError: true, + inheritEnvironment: packageSnapshot is null), cancellationToken).ConfigureAwait(false); packageSnapshot?.ValidateUnchanged(); } diff --git a/PowerForge/Services/AppleNotarizationInputSnapshot.cs b/PowerForge/Services/AppleNotarizationInputSnapshot.cs index 23981b869..d9fdb4f04 100644 --- a/PowerForge/Services/AppleNotarizationInputSnapshot.cs +++ b/PowerForge/Services/AppleNotarizationInputSnapshot.cs @@ -4,22 +4,46 @@ namespace PowerForge; internal sealed class AppleNotarizationInputSnapshot : IDisposable { private readonly AppleArchiveUploadSnapshot? _directorySnapshot; + private AppleReleaseSourceMutationMonitor? _fileSnapshotMonitor; private bool _disposed; private AppleNotarizationInputSnapshot( string rootPath, string artifactPath, - AppleArchiveUploadSnapshot? directorySnapshot) + AppleArchiveUploadSnapshot? directorySnapshot, + AppleReleaseSourceMutationMonitor? fileSnapshotMonitor = null) { RootPath = rootPath; ArtifactPath = artifactPath; _directorySnapshot = directorySnapshot; + _fileSnapshotMonitor = fileSnapshotMonitor; } internal string RootPath { get; } internal string ArtifactPath { get; } + internal void CompleteSubmissionCapture(string expectedSha256) + { + 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}'."); + } + } + finally + { + _fileSnapshotMonitor.Dispose(); + _fileSnapshotMonitor = null; + } + } + internal string PublishTo(string destinationPath, string expectedSha256) { var destination = Path.GetFullPath(destinationPath); @@ -145,7 +169,12 @@ internal static AppleNotarizationInputSnapshot Create(string artifactPath, strin throw new InvalidOperationException( $"The private Apple notarization input does not match the approved artifact. Expected '{expectedSha256}', received '{actual}'."); } - return new AppleNotarizationInputSnapshot(root, snapshotPath, directorySnapshot: null); + 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, monitor); } catch { @@ -160,6 +189,8 @@ public void Dispose() if (_disposed) return; _disposed = true; + _fileSnapshotMonitor?.Dispose(); + _fileSnapshotMonitor = null; if (_directorySnapshot is not null) { _directorySnapshot.Dispose(); diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 5987d2e90..bedba39ce 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -147,6 +147,7 @@ public async Task NotarizeAsync( "private Apple notarization submission", "notarytool", "Do not resubmit until the accepted submission has been reconciled."); + submissionSnapshot.CompleteSubmissionCapture(artifactSha256); submission = await RunAsync(xcrunExecutable, submissionArtifactPath, submitArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); (submissionId, status) = ParseSubmission(submission); try diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index cc6d6ad77..17fd2ffac 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -76,6 +76,7 @@ private void ValidateLocalPackageManifest( var manifestSyntax = MaskSwiftStringLiterals(manifestWithoutComments); ValidateLocalPackageExecutableSafety(packageRoot, manifestWithoutComments, manifestSyntax); ValidateDirectSwiftPackageDependencyFactories(packageRoot, manifestSyntax); + ValidatePackageDescriptionCalls(packageRoot, manifestSyntax); var dependencyCalls = ParseDirectSwiftPackageDependencyCalls(manifestWithoutComments, manifestSyntax); ValidateRemotePackageDependencies(repositoryRoot, packageRoot, packageLockPaths, dependencyCalls); ValidateNestedLocalPackageDependencies( @@ -236,6 +237,42 @@ private static void ValidateDeclarativeSwiftPackageManifest(string packageRoot, $"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", + "iOS", "macOS", "macCatalyst", "watchOS", "tvOS", "visionOS", "driverKit" + }; + 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)) + continue; + 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 void ValidateRemotePackageDependencies( diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs b/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs index f6ece188a..3ddea71ff 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.PackageLocks.cs @@ -1,10 +1,71 @@ 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) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs index ffaad42bd..1c3b06c02 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -105,30 +105,46 @@ private void ValidateRemotePackageCheckout( /// /// Validates the exact Swift package checkouts that Xcode will consume for an archive. /// - internal void ValidateMaterializedPackageCheckouts(string sourcePackagesRoot) + 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 head = RunGit(entry, "rev-parse", "HEAD").StdOut.Trim(); - var objectFormat = ReadGitObjectFormat(entry); - if (!GitObjectId.IsFullForObjectFormat(head, objectFormat)) - throw new InvalidOperationException($"Xcode materialized Swift package checkout does not have an exact Git HEAD: {entry}"); + 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, - Path.GetFileName(entry), - head, + 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) @@ -201,6 +217,7 @@ private void ValidateCheckedOutPackageRoot( syntax, allowInactiveNonAppleSystemLibraries: true); ValidateDirectSwiftPackageDependencyFactories(packageRoot, syntax); + ValidatePackageDescriptionCalls(packageRoot, syntax); ValidateLiteralSwiftPackagePaths(checkoutRoot, packageRoot, source, syntax); foreach (var dependency in ParseDirectSwiftPackageDependencyCalls(source, syntax)) { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs new file mode 100644 index 000000000..fcc5c7add --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -0,0 +1,126 @@ +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", ".h", ".hh", ".hpp", ".hxx", ".inc", ".pch" + }; + + private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePath) + { + if (!SourceIncludeExtensions.Contains(Path.GetExtension(sourcePath))) + return; + var fullSourcePath = Path.GetFullPath(sourcePath); + if (!_validatedSourceIncludeFiles.Add(fullSourcePath)) + return; + + var source = RemoveCComments(File.ReadAllText(fullSourcePath)); + foreach (Match directive in Regex.Matches( + source, + "(?m)^[ \\t]*#[ \\t]*(?:include|include_next|import)[ \\t]+(?[^\\r\\n]+)", + RegexOptions.CultureInvariant)) + { + var operand = Regex.Replace(directive.Groups["operand"].Value, "[ \\t]*(?://.*)?$", 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}'."); + continue; + } + + var candidate = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(fullSourcePath)!, include)); + EnsurePathWithinRepository(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); + if (File.Exists(candidate)) + EnsureTrackedFile(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); + } + } + + 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 == '/') + { + result.Append(" "); + index++; + inBlockComment = false; + } + else + { + result.Append(current == '\r' || current == '\n' ? current : ' '); + } + continue; + } + if (quote != '\0') + { + result.Append(current); + if (escaped) + escaped = false; + else if (current == '\\') + escaped = true; + else if (current == quote) + quote = '\0'; + continue; + } + if (current == '/' && next == '/') + { + result.Append(" "); + index++; + inLineComment = true; + continue; + } + if (current == '/' && next == '*') + { + result.Append(" "); + index++; + inBlockComment = true; + continue; + } + if (current == '"' || current == '\'') + quote = current; + result.Append(current); + } + return result.ToString(); + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index e6a346b86..0b7a4a904 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -645,6 +645,7 @@ private void EnsureTrackedDirectoryTree(string repositoryRoot, string path, stri $"{name} differs from the exact source commit: " + FrameworkCompatibility.GetRelativePath(repositoryRoot, entry).Replace('\\', '/')); } + ValidateSourceLevelIncludes(repositoryRoot, fullPath); } } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 52d56d346..947185239 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -61,6 +61,7 @@ private void ValidateBuildSettingAssignments( } else { + ValidateUnclassifiedBuildSettingReferences(key, assignment.Value, source); continue; } @@ -87,6 +88,27 @@ private void ValidateBuildSettingAssignments( } } + private static void ValidateUnclassifiedBuildSettingReferences(string key, string value, string source) + { + 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 (Match reference in Regex.Matches( + value, + "\\$\\((?[A-Za-z_][A-Za-z0-9_]*)\\)|\\$\\{(?[A-Za-z_][A-Za-z0-9_]*)\\}", + RegexOptions.CultureInvariant)) + { + var name = reference.Groups["name"].Value; + if (approvedReferences.Contains(name)) + 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 void ValidateSourceSelectionBuildSetting(string key, string value, string source) { foreach (var token in SplitBuildSettingPaths(value)) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 0263b2eb9..f693f0a62 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -26,6 +26,7 @@ internal sealed partial class AppleReleaseSourceTrustService 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()); internal AppleReleaseSourceTrustService( HomeAssistantReleaseGitService? git = null, @@ -502,6 +503,7 @@ private void EnsureTrackedFile( var worktreeBlob = capturedWorktreeBlob ?? ComputeRawGitBlobId(repositoryRoot, candidate); if (!headBlob.StdOut.Trim().Equals(worktreeBlob, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); + ValidateSourceLevelIncludes(repositoryRoot, candidate); } private string ComputeRawGitBlobId(string repositoryRoot, string filePath) diff --git a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs index 3e9f80b4e..9275851d3 100644 --- a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs +++ b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs @@ -5,25 +5,23 @@ namespace PowerForge; /// internal sealed class AppleSwiftPackageBuildSnapshot : IDisposable { - private static readonly IReadOnlyDictionary IsolatedGitEnvironment = - new Dictionary(StringComparer.Ordinal) - { - ["GIT_CONFIG_NOSYSTEM"] = "1", - ["GIT_CONFIG_SYSTEM"] = "/dev/null", - ["GIT_CONFIG_GLOBAL"] = "/dev/null", - ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" - }; - private readonly AppleReleaseSourceTrustService _sourceTrust = new(); + private readonly IReadOnlyDictionary _approvedPackageRevisions; + private readonly IReadOnlyDictionary _environmentVariables; private readonly AppleReleaseSourceMutationMonitor? _monitor; private bool _disposed; - private AppleSwiftPackageBuildSnapshot(string rootPath) + private AppleSwiftPackageBuildSnapshot( + string rootPath, + IReadOnlyDictionary approvedPackageRevisions, + IReadOnlyDictionary environmentVariables) { RootPath = rootPath; - _monitor = Directory.Exists(rootPath) + _approvedPackageRevisions = approvedPackageRevisions; + _environmentVariables = environmentVariables; + _monitor = Directory.Exists(SourcePackagesPath) ? new AppleReleaseSourceMutationMonitor( - rootPath, + SourcePackagesPath, "materialized Swift package root", "xcodebuild archive", "Discard the archive and resolve the exact package graph again.") @@ -32,7 +30,11 @@ private AppleSwiftPackageBuildSnapshot(string rootPath) internal string RootPath { get; } - internal IReadOnlyDictionary EnvironmentVariables => IsolatedGitEnvironment; + internal string SourcePackagesPath => Path.Combine(RootPath, "SourcePackages"); + + internal string DerivedDataPath => Path.Combine(RootPath, "DerivedData"); + + internal IReadOnlyDictionary EnvironmentVariables => _environmentVariables; internal static async Task CreateAsync( IProcessRunner processRunner, @@ -53,6 +55,15 @@ internal static async Task CreateAsync( #endif try { + var sourcePackagesPath = Path.Combine(root, "SourcePackages"); + var derivedDataPath = Path.Combine(root, "DerivedData"); + Directory.CreateDirectory(sourcePackagesPath); + Directory.CreateDirectory(derivedDataPath); + var repositoryRoot = FindRepositoryRoot(projectPath); + var approvedPackageRevisions = new AppleReleaseSourceTrustService().ReadApprovedTrackedPackageRevisions( + repositoryRoot, + DiscoverApprovedPackageLocks(repositoryRoot, projectPath)); + var environmentVariables = BuildIsolatedEnvironment(); var arguments = new[] { isWorkspace ? "-workspace" : "-project", @@ -61,7 +72,9 @@ internal static async Task CreateAsync( scheme, "-resolvePackageDependencies", "-clonedSourcePackagesDirPath", - root, + sourcePackagesPath, + "-derivedDataPath", + derivedDataPath, "-onlyUsePackageVersionsFromResolvedFile", "-disableAutomaticPackageResolution", "-skipPackageUpdates" @@ -72,7 +85,10 @@ internal static async Task CreateAsync( Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), arguments, timeout, - IsolatedGitEnvironment), + environmentVariables, + captureOutput: true, + captureError: true, + inheritEnvironment: false), cancellationToken) .ConfigureAwait(false); if (!result.Succeeded) @@ -82,8 +98,10 @@ internal static async Task CreateAsync( (string.IsNullOrWhiteSpace(result.StdErr) ? result.StdOut : result.StdErr)); } - new AppleReleaseSourceTrustService().ValidateMaterializedPackageCheckouts(root); - var snapshot = new AppleSwiftPackageBuildSnapshot(root); + new AppleReleaseSourceTrustService().ValidateMaterializedPackageCheckouts( + sourcePackagesPath, + approvedPackageRevisions); + var snapshot = new AppleSwiftPackageBuildSnapshot(root, approvedPackageRevisions, environmentVariables); try { return snapshot; @@ -105,7 +123,9 @@ internal static async Task CreateAsync( internal void AppendArchiveArguments(ICollection arguments) { arguments.Add("-clonedSourcePackagesDirPath"); - arguments.Add(RootPath); + arguments.Add(SourcePackagesPath); + arguments.Add("-derivedDataPath"); + arguments.Add(DerivedDataPath); arguments.Add("-onlyUsePackageVersionsFromResolvedFile"); arguments.Add("-disableAutomaticPackageResolution"); arguments.Add("-skipPackageUpdates"); @@ -114,7 +134,7 @@ internal void AppendArchiveArguments(ICollection arguments) internal void ValidateUnchanged() { _monitor?.ValidateNoChanges(); - _sourceTrust.ValidateMaterializedPackageCheckouts(RootPath); + _sourceTrust.ValidateMaterializedPackageCheckouts(SourcePackagesPath, _approvedPackageRevisions); } internal static void RejectConflictingArguments(IEnumerable arguments) @@ -122,6 +142,7 @@ internal static void RejectConflictingArguments(IEnumerable arguments) var forbidden = new HashSet(StringComparer.OrdinalIgnoreCase) { "-clonedSourcePackagesDirPath", + "-derivedDataPath", "-packageCachePath", "-resolvePackageDependencies", "-disableAutomaticPackageResolution", @@ -147,4 +168,52 @@ public void Dispose() if (Directory.Exists(RootPath)) Directory.Delete(RootPath, recursive: true); } + + private static IReadOnlyDictionary BuildIsolatedEnvironment() + { + var environment = new Dictionary(StringComparer.Ordinal) + { + ["GIT_CONFIG_NOSYSTEM"] = "1", + ["GIT_CONFIG_SYSTEM"] = "/dev/null", + ["GIT_CONFIG_GLOBAL"] = "/dev/null", + ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + }; + foreach (var name in new[] { "HOME", "TMPDIR", "USER", "LOGNAME", "LANG", "LC_ALL", "SSH_AUTH_SOCK" }) + { + var value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + environment[name] = value; + } + return environment; + } + + 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/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs index 2703ce412..bd13d821a 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs @@ -267,6 +267,65 @@ public void ResolveExactAppleSourceCommit_rejects_custom_notarization_tool_execu 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); + } + + [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); + } + private static string ReadFixtureHead(string repositoryRoot) { var startInfo = new System.Diagnostics.ProcessStartInfo("git") From 770513c5debddeb8b2e3531a0aec5139c2cc29a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 11:22:44 +0200 Subject: [PATCH 29/78] Seal exact Apple execution inputs --- ...ArchiveServiceTests.ExactSourcePackages.cs | 8 + ...AppArchiveServiceTests.TrustedExecution.cs | 62 +++++++ .../AppleArchiveBuildSnapshotTests.cs | 29 +++ .../AppleNotarizationServiceTests.cs | 4 + ...orgeReleaseServiceTests.AppleAutomation.cs | 3 + PowerForge/Models/AppleAppArchiveModels.cs | 6 + .../AppleAppArchiveService.Privacy.cs | 22 ++- PowerForge/Services/AppleAppArchiveService.cs | 22 ++- .../Services/AppleArchiveBuildSnapshot.cs | 9 +- .../Services/AppleNotarizationService.cs | 13 +- ...ReleaseSourceTrustService.LocalPackages.cs | 3 +- ...eleaseSourceTrustService.SourceIncludes.cs | 3 +- .../AppleReleaseSourceTrustService.Xcode.cs | 13 +- ...eReleaseSourceTrustService.XcodeParsing.cs | 65 ++++++- .../AppleSwiftPackageBuildSnapshot.cs | 20 +-- .../AppleTrustedExecutionEnvironment.cs | 32 ++++ .../Services/PowerForgeReleaseService.cs | 3 +- ...ests.AppleSourceTrustClosureRegressions.cs | 5 +- ...eTests.AppleSourceTrustFinalRegressions.cs | 2 + ...eTests.AppleSourceTrustMergeRegressions.cs | 166 ++++++++++++++++++ ...Tests.AppleSourceTrustReviewRegressions.cs | 4 +- 21 files changed, 438 insertions(+), 56 deletions(-) create mode 100644 PowerForge.Tests/AppleAppArchiveServiceTests.TrustedExecution.cs create mode 100644 PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs create mode 100644 PowerForge/Services/AppleTrustedExecutionEnvironment.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs index e7d04a8a3..a3bb45c7b 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs @@ -27,6 +27,7 @@ public async Task CreateArchiveAsync_exact_source_builds_from_validated_private_ }); 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]; @@ -184,6 +185,13 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo File.WriteAllText(manifest, original); } + 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"); + } + return Task.FromResult(new ProcessRunResult( 0, "ok", 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/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs new file mode 100644 index 000000000..cbafd7c17 --- /dev/null +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -0,0 +1,29 @@ +namespace PowerForge.Tests; + +public sealed class AppleArchiveBuildSnapshotTests +{ + [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 { } + } + } +} diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 1bc66f5eb..12f856b9e 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -119,11 +119,15 @@ public async Task NotarizeAsync_ExactSourceUsesFixedAppleToolPathAndSanitizedPat { 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 diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index b19103cab..7c8a5fa02 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -55,6 +55,7 @@ public void Execute_AppleUpload_RejectsPrivateArchiveSnapshotMutation() 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 => @@ -66,6 +67,7 @@ public void Execute_AppleUpload_RejectsPrivateArchiveSnapshotMutation() uploadAppleApp: request => { uploaderArchivePath = request.ArchivePath; + trustedUploadExecution = request.RequireTrustedSystemTools; File.WriteAllText(Path.Combine(request.ArchivePath!, "payload"), "after"); return CreateSuccessfulUpload(request); }); @@ -81,6 +83,7 @@ public void Execute_AppleUpload_RejectsPrivateArchiveSnapshotMutation() 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); diff --git a/PowerForge/Models/AppleAppArchiveModels.cs b/PowerForge/Models/AppleAppArchiveModels.cs index 2efb5f8d5..07b07660e 100644 --- a/PowerForge/Models/AppleAppArchiveModels.cs +++ b/PowerForge/Models/AppleAppArchiveModels.cs @@ -68,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); @@ -131,6 +134,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(); 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 b139657fb..9d0ebb87a 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -119,10 +119,14 @@ public async Task CreateArchiveAsync( packageSnapshot?.Dispose(); } + var archiveSha256 = result.Succeeded && Directory.Exists(archivePath) + ? AppleNotarizationService.ComputeArtifactSha256(archivePath) + : null; return new AppleAppArchiveResult { ArchivePath = archivePath, Destination = destination, + ArchiveSha256 = archiveSha256, ProcessResult = result }; } @@ -175,12 +179,26 @@ public async Task UploadArchiveAsync( args); args.AddRange(request.AdditionalArguments ?? Array.Empty()); + 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 result = await _processRunner.RunAsync( new ProcessRunRequest( - NormalizeExecutable(request.XcodeBuildExecutable), + xcodeBuildExecutable, Path.GetDirectoryName(archivePath) ?? Directory.GetCurrentDirectory(), args, - request.Timeout <= TimeSpan.Zero ? TimeSpan.FromHours(1) : request.Timeout), + request.Timeout <= TimeSpan.Zero ? TimeSpan.FromHours(1) : request.Timeout, + toolEnvironment, + captureOutput: true, + captureError: true, + inheritEnvironment: toolEnvironment is null), cancellationToken).ConfigureAwait(false); var diagnostics = ResolveUploadDiagnostics(result); diff --git a/PowerForge/Services/AppleArchiveBuildSnapshot.cs b/PowerForge/Services/AppleArchiveBuildSnapshot.cs index ae00c4cc9..a46e4e2ab 100644 --- a/PowerForge/Services/AppleArchiveBuildSnapshot.cs +++ b/PowerForge/Services/AppleArchiveBuildSnapshot.cs @@ -32,11 +32,16 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) /// atomically replaces the configured public archive. A missing archive is retained only for /// injected process adapters. /// - internal string? Publish(string destinationArchivePath) { + internal string? Publish(string destinationArchivePath, string? expectedSourceSha256 = null) { if (!Directory.Exists(ArchivePath)) return null; 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}"); @@ -101,4 +106,4 @@ public void Dispose() { if (Directory.Exists(RootPath)) Directory.Delete(RootPath, recursive: true); } -} \ No newline at end of file +} diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index bedba39ce..6c89e5780 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -7,12 +7,6 @@ namespace PowerForge; /// Submits direct macOS artifacts for notarization and verifies the accepted result locally. public sealed class AppleNotarizationService { - private static readonly IReadOnlyDictionary TrustedSystemToolEnvironment = - new Dictionary(StringComparer.Ordinal) - { - ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" - }; - private readonly IProcessRunner _processRunner; /// Creates a notarization service. @@ -62,7 +56,7 @@ public async Task NotarizeAsync( "/usr/sbin/spctl", request.RequireTrustedSystemTools); var toolEnvironment = request.RequireTrustedSystemTools - ? TrustedSystemToolEnvironment + ? AppleTrustedExecutionEnvironment.Create() : null; var artifactSha256 = ComputeArtifactSha256(artifactPath); @@ -386,7 +380,10 @@ private Task RunAsync( Path.GetDirectoryName(artifactPath) ?? Directory.GetCurrentDirectory(), arguments, timeout, - environmentVariables), + environmentVariables, + captureOutput: true, + captureError: true, + inheritEnvironment: environmentVariables is null), cancellationToken); private static string ResolveAppleToolExecutable( diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index 17fd2ffac..26b3330a6 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -253,7 +253,8 @@ private static void ValidatePackageDescriptionCalls(string packageRoot, string m "pluginPermission", "define", "linkedLibrary", "linkedFramework", "headerSearchPath", "unsafeFlags", "when", "exact", "revision", "branch", "upToNextMajor", "upToNextMinor", "range", "Dependency", "Product", "Target", "SupportedPlatform", "SystemPackageProvider", "LanguageTag", "BuildSettingCondition", - "iOS", "macOS", "macCatalyst", "watchOS", "tvOS", "visionOS", "driverKit" + "iOS", "macOS", "macCatalyst", "watchOS", "tvOS", "visionOS", "driverKit", + "apt", "brew", "yum" }; foreach (Match call in Regex.Matches( manifestSyntax, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index fcc5c7add..23eceb28b 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -6,7 +6,8 @@ internal sealed partial class AppleReleaseSourceTrustService { private static readonly HashSet SourceIncludeExtensions = new(StringComparer.OrdinalIgnoreCase) { - ".c", ".cc", ".cpp", ".cxx", ".m", ".mm", ".h", ".hh", ".hpp", ".hxx", ".inc", ".pch" + ".c", ".cc", ".cpp", ".cxx", ".m", ".mm", ".metal", ".s", + ".h", ".hh", ".hpp", ".hxx", ".inc", ".pch" }; private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePath) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 0b7a4a904..5573623cb 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -13,6 +13,7 @@ internal sealed partial class AppleReleaseSourceTrustService private static readonly HashSet FileValuedBuildSettings = new(StringComparer.OrdinalIgnoreCase) { "INFOPLIST_FILE", + "INFOPLIST_PREFIX_HEADER", "CODE_SIGN_ENTITLEMENTS", "SWIFT_OBJC_BRIDGING_HEADER", "GCC_PREFIX_HEADER", @@ -543,21 +544,15 @@ private void ValidateRemotePackageReference( if (string.IsNullOrWhiteSpace(repositoryUrl)) throw new InvalidOperationException("Remote Swift package reference is missing repositoryURL."); - var requirement = ReadPbxDictionary(item.Body, "requirement"); - var kind = requirement is null ? null : ReadPbxScalar(requirement, "kind"); - var revision = requirement is null ? null : ReadPbxScalar(requirement, "revision"); - var exactRevision = string.Equals(kind, "revision", StringComparison.OrdinalIgnoreCase) && - (revision?.Length == 40 || revision?.Length == 64) && - revision.All(Uri.IsHexDigit); var locks = FindTrackedPackageLocks(packageLockPaths, repositoryUrl!); - if (locks.Length == 0 && !exactRevision) + if (locks.Length == 0) { throw new InvalidOperationException( - $"Remote Swift package '{repositoryUrl}' must be bound by a tracked Package.resolved lock or an exact 40-character revision."); + $"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!, exactRevision ? revision : null); + var resolvedRevision = ResolvePackageRevision(packageLockPaths, repositoryUrl!); ValidateRemotePackageSource(repositoryUrl!, resolvedRevision, packageLockPaths); } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 947185239..8bd200657 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -77,7 +77,11 @@ private void ValidateBuildSettingAssignments( EnsurePathWithinRepository(repositoryRoot, candidate, $"Xcode build setting {key} from {source}"); EnsureNoGeneratedOutputOverlap(candidate, generatedOutputPaths, $"Xcode build setting {key}"); if (File.Exists(candidate)) + { EnsureTrackedFile(repositoryRoot, candidate, $"Xcode build setting {key}"); + if (baseKey.Equals("INFOPLIST_FILE", StringComparison.OrdinalIgnoreCase)) + ValidateInfoPlistBuildSettingReferences(candidate, source); + } else if (Directory.Exists(candidate)) EnsureTrackedDirectoryTree(repositoryRoot, candidate, $"Xcode build setting {key}"); else @@ -88,20 +92,21 @@ private void ValidateBuildSettingAssignments( } } - private static void ValidateUnclassifiedBuildSettingReferences(string key, string value, string source) + 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 (Match reference in Regex.Matches( - value, - "\\$\\((?[A-Za-z_][A-Za-z0-9_]*)\\)|\\$\\{(?[A-Za-z_][A-Za-z0-9_]*)\\}", - RegexOptions.CultureInvariant)) + foreach (var reference in ReadBuildSettingReferences(value, key, source)) { - var name = reference.Groups["name"].Value; - if (approvedReferences.Contains(name)) + 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}' " + @@ -109,6 +114,52 @@ private static void ValidateUnclassifiedBuildSettingReferences(string key, strin } } + 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 static void ValidateInfoPlistBuildSettingReferences(string plistPath, string source) + { + var plistReferences = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "DEVELOPMENT_LANGUAGE", "PRODUCT_BUNDLE_IDENTIFIER", "MARKETING_VERSION", + "CURRENT_PROJECT_VERSION", "PRODUCT_MODULE_NAME" + }; + var contents = DecodeTrackedText(File.ReadAllBytes(plistPath)); + ValidateUnclassifiedBuildSettingReferences( + "INFOPLIST_FILE contents", + contents, + $"{source}; plist '{plistPath}'", + plistReferences); + } + private static void ValidateSourceSelectionBuildSetting(string key, string value, string source) { foreach (var token in SplitBuildSettingPaths(value)) diff --git a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs index 9275851d3..b8a703f26 100644 --- a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs +++ b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs @@ -63,7 +63,7 @@ internal static async Task CreateAsync( var approvedPackageRevisions = new AppleReleaseSourceTrustService().ReadApprovedTrackedPackageRevisions( repositoryRoot, DiscoverApprovedPackageLocks(repositoryRoot, projectPath)); - var environmentVariables = BuildIsolatedEnvironment(); + var environmentVariables = AppleTrustedExecutionEnvironment.Create(isolateGitConfiguration: true); var arguments = new[] { isWorkspace ? "-workspace" : "-project", @@ -169,24 +169,6 @@ public void Dispose() Directory.Delete(RootPath, recursive: true); } - private static IReadOnlyDictionary BuildIsolatedEnvironment() - { - var environment = new Dictionary(StringComparer.Ordinal) - { - ["GIT_CONFIG_NOSYSTEM"] = "1", - ["GIT_CONFIG_SYSTEM"] = "/dev/null", - ["GIT_CONFIG_GLOBAL"] = "/dev/null", - ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" - }; - foreach (var name in new[] { "HOME", "TMPDIR", "USER", "LOGNAME", "LANG", "LC_ALL", "SSH_AUTH_SOCK" }) - { - var value = Environment.GetEnvironmentVariable(name); - if (!string.IsNullOrWhiteSpace(value)) - environment[name] = value; - } - return environment; - } - private static IEnumerable DiscoverApprovedPackageLocks(string repositoryRoot, string projectPath) { var project = Path.GetFullPath(projectPath); 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/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index fedb71903..9d1532965 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2213,7 +2213,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( return CompleteAppleExecutionFailure(plan, resultsByApp, app); } - var publishedArchiveSha256 = archiveBuildSnapshot?.Publish(app.ArchivePath); + var publishedArchiveSha256 = archiveBuildSnapshot?.Publish(app.ArchivePath, archive.ArchiveSha256); if (!string.IsNullOrWhiteSpace(publishedArchiveSha256)) { approvedArchiveInputPath = archiveBuildSnapshot!.ArchivePath; @@ -2251,6 +2251,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( 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", diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs index cad371b1f..b8d132fa0 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs @@ -466,6 +466,7 @@ public void Capture_rejects_executable_behavior_in_exact_remote_package_revision 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( @@ -486,7 +487,8 @@ public void Capture_accepts_declarative_exact_remote_package_revision() "// 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\")])"); + ".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 remoteRevision = CommitRepository(remoteRoot); @@ -496,6 +498,7 @@ public void Capture_accepts_declarative_exact_remote_package_revision() 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( diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs index bd13d821a..89a1cc56b 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs @@ -22,6 +22,7 @@ public void Capture_revalidates_remote_package_after_failed_inspection() 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; @@ -65,6 +66,7 @@ public void Capture_rejects_gitlinks_in_exact_remote_package_revision() 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( diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs new file mode 100644 index 000000000..111dbfaa6 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -0,0 +1,166 @@ +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_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_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); + } + + 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.AppleSourceTrustReviewRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs index 428ede6c4..efb9b5b67 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewRegressions.cs @@ -193,7 +193,7 @@ public void ResolveExactAppleSourceCommit_ignores_commented_fake_remote_package_ var exception = Assert.Throws(() => ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); - Assert.Contains("exact 40-character revision", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("tracked Package.resolved", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -363,7 +363,7 @@ public void ResolveExactAppleSourceCommit_rejects_decoy_package_lock_outside_eff ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); Assert.Contains("Package.resolved", exception.Message, StringComparison.Ordinal); - Assert.Contains("exact 40-character revision", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("tracked Package.resolved", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] From 006b374b325aa3a7ea27ec62cbbb5b8edda5532a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 12:05:47 +0200 Subject: [PATCH 30/78] Close final Apple exact-source merge gaps --- ...pleReleaseSourceTrustService.BuildFlags.cs | 18 ++++-- ...ReleaseSourceTrustService.LocalPackages.cs | 13 ++-- ...eReleaseSourceTrustService.XcodeParsing.cs | 12 +++- ...eTests.AppleSourceTrustMergeRegressions.cs | 61 +++++++++++++++++++ 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 99b58f032..165b34671 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -318,13 +318,21 @@ private static bool TrySkipNonInputCompilerOption(string[] tokens, ref int index 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 { - "-framework" => 1, - "-weak_framework" => 1, - "-reexport_framework" => 1, - "-lazy_framework" => 1, - "-needed_framework" => 1, "-compatibility_version" => 1, "-current_version" => 1, "-arch" => 1, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index 26b3330a6..d2215f1bd 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -293,25 +293,20 @@ private void ValidateRemotePackageDependencies( $"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 revisionValue = string.Empty; - var hasExactRevision = dependency.Arguments.TryGetValue("revision", out var revision) && - TryReadLiteralSwiftString(revision, out revisionValue) && - Regex.IsMatch(revisionValue, "^(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant); var effectiveLocks = packageLockPaths .Concat(new[] { Path.Combine(packageRoot, "Package.resolved") }) .Distinct(GetPathComparer()) .ToArray(); var locks = FindTrackedPackageLocks(effectiveLocks, identity); - if (locks.Length == 0 && !hasExactRevision) + if (locks.Length == 0) throw new InvalidOperationException( - $"Local Swift package '{packageRoot}' declares external dependency '{identity}' without an exact full Git revision. " + - "Commit a Package.resolved lock containing that dependency before creating an exact-source Apple checkpoint."); + $"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, - !dependency.Arguments.ContainsKey("id") && hasExactRevision ? revisionValue : null); + identity); ValidateRemotePackageSource(identity, resolvedRevision, effectiveLocks); } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 8bd200657..704c56c7d 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -152,7 +152,17 @@ private static void ValidateInfoPlistBuildSettingReferences(string plistPath, st "DEVELOPMENT_LANGUAGE", "PRODUCT_BUNDLE_IDENTIFIER", "MARKETING_VERSION", "CURRENT_PROJECT_VERSION", "PRODUCT_MODULE_NAME" }; - var contents = DecodeTrackedText(File.ReadAllBytes(plistPath)); + 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); ValidateUnclassifiedBuildSettingReferences( "INFOPLIST_FILE contents", contents, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs index 111dbfaa6..91f1b709d 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -80,6 +80,67 @@ public void ResolveExactAppleSourceCommit_accepts_deterministic_xcode_info_plist 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() { From d5ed1de4c1fd59bd39259adfe3c419c31a244f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 13:49:03 +0200 Subject: [PATCH 31/78] Harden Apple source and mutation evidence --- ...rviceTests.AppleHostedReviewRegressions.cs | 86 +++++++++++++++++++ ...aseServiceTests.AppleReleaseAdvancement.cs | 43 ++++++++++ ...pleReleaseSourceTrustService.BuildFlags.cs | 3 +- ...eleaseSourceTrustService.SourceIncludes.cs | 34 +++++++- .../AppleReleaseVersionSourceService.cs | 64 +++++++++----- ...owerForgeReleaseService.AppleVersioning.cs | 12 +++ ...dExecutionServiceTests.AppleSourceTrust.cs | 1 + ...eTests.AppleSourceTrustMergeRegressions.cs | 21 +++++ 8 files changed, 240 insertions(+), 24 deletions(-) diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index 27885d9b7..5fa537762 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -284,6 +284,92 @@ public void Execute_ApplePlan_BindsCompleteXcodeExecutionControls() } } + [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")] diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs index c68a6a6e7..33118f95a 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs @@ -379,6 +379,49 @@ public void Execute_AppleVersion_RetryKeepsUnpublishedSelectedBuild() } } + [Fact] + public void AppleVersionSource_UpdateDoesNotOverwriteAnAtomicEditorAfterComparison() + { + if (!OperatingSystem.IsMacOS()) + return; + 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 Execute_AppleVersionPlan_WritesSeparatePlanReceiptWithoutChangingSource() { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 165b34671..b4a200566 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -164,6 +164,7 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke if (token.Equals("-I", StringComparison.Ordinal) || token.Equals("-F", StringComparison.Ordinal) || token.Equals("-L", StringComparison.Ordinal) || + token.Equals("-B", StringComparison.Ordinal) || token.Equals("-include", StringComparison.Ordinal) || token.Equals("-imacros", StringComparison.Ordinal) || token.Equals("-include-pch", StringComparison.Ordinal) || @@ -221,7 +222,7 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke "-ivfsoverlay=", "-vfsoverlay=", "-plugin-path=", "-fpass-plugin=", "-iframework", "-idirafter", "-imacros=", "-include=", "--sysroot=", "-isystem-after", "-isystem", "-iquote", "-iprefix", "-fplugin=", "-isysroot=", "-sdk=", - "-I", "-F", "-L" + "-I", "-F", "-L", "-B" }; var prefix = prefixes.FirstOrDefault(candidate => token.StartsWith(candidate, StringComparison.Ordinal) && token.Length > candidate.Length); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 23eceb28b..d29c190b3 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -18,7 +18,10 @@ private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePat if (!_validatedSourceIncludeFiles.Add(fullSourcePath)) return; - var source = RemoveCComments(File.ReadAllText(fullSourcePath)); + // 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 source = RemoveCComments(SpliceCPreprocessingLines(File.ReadAllText(fullSourcePath))); foreach (Match directive in Regex.Matches( source, "(?m)^[ \\t]*#[ \\t]*(?:include|include_next|import)[ \\t]+(?[^\\r\\n]+)", @@ -55,6 +58,35 @@ private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePat } } + 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); diff --git a/PowerForge/Services/AppleReleaseVersionSourceService.cs b/PowerForge/Services/AppleReleaseVersionSourceService.cs index 93834a86e..c24ec47ba 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,6 +13,12 @@ internal sealed class AppleReleaseVersionSourceService private static readonly Regex MarketingVersionValuePattern = new( "^\\d+\\.\\d+(?:\\.\\d+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private readonly Action? _onComparedVersionSource; + + internal AppleReleaseVersionSourceService(Action? onComparedVersionSource = null) + { + _onComparedVersionSource = onComparedVersionSource; + } internal PowerForgeAppleVersionReceipt Read(string sourcePath) { @@ -57,13 +63,7 @@ internal PowerForgeAppleVersionReceipt Update( if (changed && !whatIf) { - var currentContent = File.ReadAllText(fullPath); - if (!string.Equals(currentContent, approvedContent, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Apple version source changed after plan approval: {fullPath}"); - } - WriteAtomic(fullPath, updated); + WriteIfUnchanged(fullPath, approvedContent, updated); } return new PowerForgeAppleVersionReceipt @@ -113,22 +113,42 @@ 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 directory = Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory(); - var temporaryPath = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); - 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); - } - finally + var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content); + using (var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read)) { - if (File.Exists(temporaryPath)) - File.Delete(temporaryPath); + string currentContent; + using (var reader = new StreamReader( + stream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + bufferSize: 4096, + leaveOpen: true)) + { + currentContent = reader.ReadToEnd(); + } + if (!string.Equals(currentContent, expectedContent, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Apple version source changed after plan approval: {path}"); + } + + _onComparedVersionSource?.Invoke(path); + + // Keep the same read/write handle from comparison through the durable + // write. Platforms with mandatory share modes exclude another writer; + // pathname-replacing editors are covered by the post-write check below. + stream.Position = 0; + stream.Write(bytes, 0, bytes.Length); + stream.SetLength(bytes.Length); + stream.Flush(flushToDisk: true); } + + // On platforms where a pathname can be replaced while its old inode is + // open, ensure that the bytes currently named by the source path are the + // bytes we published. An atomic editor wins and is never overwritten. + if (!string.Equals(File.ReadAllText(path), content, StringComparison.Ordinal)) + throw new InvalidOperationException($"Apple version source changed while the approved update was being written: {path}"); } } diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index e0c4739e6..50b3be99d 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -381,6 +381,18 @@ private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt recei 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)) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index 33a20ea1f..197a9ba0e 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -631,6 +631,7 @@ public void ResolveExactAppleSourceCommit_rejects_external_file_setting_from_xcc [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( diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs index 91f1b709d..4c5dc2858 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -183,6 +183,27 @@ public void ResolveExactAppleSourceCommit_rejects_external_includes_in_all_prepr 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_parses_xcode_reference_modifiers_before_host_classification() { From ed547091dede91337154f925d918168a5f277172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 14:26:18 +0200 Subject: [PATCH 32/78] Harden Apple source tools and artifact rollback --- .../AppleArchiveBuildSnapshotTests.cs | 24 ++++ PowerForge.Tests/GitClientTests.cs | 28 ++++ .../Services/AppleArchiveBuildSnapshot.cs | 19 ++- PowerForge/Services/AppleArtifactCopy.cs | 17 +++ .../Services/AppleDirectExportSnapshot.cs | 22 +++- .../Services/AppleReleaseSourceSnapshot.cs | 4 +- ...eleaseSourceTrustService.SourceIncludes.cs | 120 +++++++++++++++++- .../AppleReleaseSourceTrustService.cs | 4 +- PowerForge/Services/GitClient.cs | 89 ++++++++++++- ...eTests.AppleSourceTrustMergeRegressions.cs | 67 ++++++++++ 10 files changed, 372 insertions(+), 22 deletions(-) diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index cbafd7c17..9d9a8b15e 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -26,4 +26,28 @@ public void Publish_rejects_archive_replaced_after_xcodebuild_identity_was_obser 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 { } + } + } } diff --git a/PowerForge.Tests/GitClientTests.cs b/PowerForge.Tests/GitClientTests.cs index 28c1de84f..d85f8747f 100644 --- a/PowerForge.Tests/GitClientTests.cs +++ b/PowerForge.Tests/GitClientTests.cs @@ -73,6 +73,34 @@ 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"]); + } + private sealed class StubProcessRunner : IProcessRunner { private readonly Func _execute; diff --git a/PowerForge/Services/AppleArchiveBuildSnapshot.cs b/PowerForge/Services/AppleArchiveBuildSnapshot.cs index a46e4e2ab..323296311 100644 --- a/PowerForge/Services/AppleArchiveBuildSnapshot.cs +++ b/PowerForge/Services/AppleArchiveBuildSnapshot.cs @@ -85,17 +85,22 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) if (movedExisting && Directory.Exists(backup)) Directory.Delete(backup, recursive: true); return publishedSha256; - } catch { - if (published && Directory.Exists(destination)) - Directory.Delete(destination, recursive: true); - if (movedExisting && Directory.Exists(backup)) - Directory.Move(backup, destination); + } catch (Exception publicationException) { + try { + if (published && Directory.Exists(destination)) + Directory.Delete(destination, recursive: true); + if (movedExisting) + AppleArtifactCopy.RestoreDirectoryBackup(destination, backup); + } 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 { if (Directory.Exists(stageRoot)) Directory.Delete(stageRoot, recursive: true); - if (Directory.Exists(backup)) - Directory.Delete(backup, recursive: true); } } diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs index a93f4fd66..594b6c178 100644 --- a/PowerForge/Services/AppleArtifactCopy.cs +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -70,4 +70,21 @@ internal static void CopyDirectory(string sourceRoot, string destinationRoot) #endif File.SetAttributes(destinationRoot, File.GetAttributes(sourceRoot)); } + + /// + /// 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); + } } diff --git a/PowerForge/Services/AppleDirectExportSnapshot.cs b/PowerForge/Services/AppleDirectExportSnapshot.cs index 245842e66..d36cd0df7 100644 --- a/PowerForge/Services/AppleDirectExportSnapshot.cs +++ b/PowerForge/Services/AppleDirectExportSnapshot.cs @@ -84,20 +84,28 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) Directory.Delete(backup, recursive: true); return new ApplePublishedDirectExport(destination, publishedArtifact, publishedSha256); } - catch + catch (Exception publicationException) { - if (published && Directory.Exists(destination)) - Directory.Delete(destination, recursive: true); - if (movedExisting && Directory.Exists(backup)) - Directory.Move(backup, destination); + try + { + if (published && Directory.Exists(destination)) + Directory.Delete(destination, recursive: true); + if (movedExisting) + AppleArtifactCopy.RestoreDirectoryBackup(destination, backup); + } + 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 { if (Directory.Exists(stage)) Directory.Delete(stage, recursive: true); - if (Directory.Exists(backup)) - Directory.Delete(backup, recursive: true); } } diff --git a/PowerForge/Services/AppleReleaseSourceSnapshot.cs b/PowerForge/Services/AppleReleaseSourceSnapshot.cs index da59306be..a5098dfc7 100644 --- a/PowerForge/Services/AppleReleaseSourceSnapshot.cs +++ b/PowerForge/Services/AppleReleaseSourceSnapshot.cs @@ -7,7 +7,7 @@ namespace PowerForge; /// internal sealed class AppleReleaseSourceSnapshot : IDisposable { - private readonly GitClient _git = new(defaultTimeout: TimeSpan.FromMinutes(2)); + private readonly GitClient _git = GitClient.CreateTrustedSystemClient(defaultTimeout: TimeSpan.FromMinutes(2)); private readonly string _repositoryRoot; private readonly string _sourceRepositoryRoot; private readonly string _sourceProjectRoot; @@ -40,7 +40,7 @@ private AppleReleaseSourceSnapshot( return null; var sourceCommit = plan.SourceCommit!.Trim(); - var git = new GitClient(defaultTimeout: TimeSpan.FromMinutes(2)); + 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") diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index d29c190b3..c3346b875 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -7,7 +7,7 @@ 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" + ".h", ".hh", ".hpp", ".hxx", ".inc", ".pch", ".modulemap" }; private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePath) @@ -18,10 +18,22 @@ private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePat if (!_validatedSourceIncludeFiles.Add(fullSourcePath)) return; + if (Path.GetExtension(fullSourcePath).Equals(".modulemap", StringComparison.OrdinalIgnoreCase)) + { + ValidateClangModuleMapInputs(repositoryRoot, 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 source = RemoveCComments(SpliceCPreprocessingLines(File.ReadAllText(fullSourcePath))); + 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."); + } foreach (Match directive in Regex.Matches( source, "(?m)^[ \\t]*#[ \\t]*(?:include|include_next|import)[ \\t]+(?[^\\r\\n]+)", @@ -58,6 +70,112 @@ private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePat } } + private void ValidateClangModuleMapInputs(string repositoryRoot, string moduleMapPath) + { + var source = RemoveCComments(File.ReadAllText(moduleMapPath)); + 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\\r\\n]*##[ \\t\\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__") + return identifier; + } + return null; + } + + private static string MaskCStringAndCharacterLiterals(string source) + { + var result = new System.Text.StringBuilder(source.Length); + var quote = '\0'; + var escaped = false; + foreach (var current in source) + { + if (quote == '\0') + { + 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); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index f693f0a62..d3edd4926 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -33,8 +33,8 @@ internal AppleReleaseSourceTrustService( GitClient? gitClient = null, Func? remotePackageCheckoutResolver = null) { - _git = git ?? new HomeAssistantReleaseGitService(); - _gitClient = gitClient ?? new GitClient(defaultTimeout: TimeSpan.FromMinutes(2)); + _gitClient = gitClient ?? GitClient.CreateTrustedSystemClient(defaultTimeout: TimeSpan.FromMinutes(2)); + _git = git ?? new HomeAssistantReleaseGitService(_gitClient); _remotePackageCheckoutResolver = remotePackageCheckoutResolver; } diff --git a/PowerForge/Services/GitClient.cs b/PowerForge/Services/GitClient.cs index 6ef0fcd37..d260c02f3 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,57 @@ 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); + return new GitClient(processRunner, executable, defaultTimeout, environment, inheritEnvironment: false); } /// @@ -144,7 +189,11 @@ public async Task RunRawAsync( _gitExecutable, repositoryRoot, arguments, - timeout ?? _defaultTimeout), + timeout ?? _defaultTimeout, + _environmentVariables, + captureOutput: true, + captureError: true, + inheritEnvironment: _inheritEnvironment), cancellationToken).ConfigureAwait(false); } @@ -167,7 +216,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 +235,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/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs index 4c5dc2858..c18f2216a 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -204,6 +204,73 @@ public void ResolveExactAppleSourceCommit_rejects_line_spliced_external_source_i 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); + } + + [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__\nconst char *documentation = \"__TIME__ and __TIMESTAMP__\";\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() { From 968806f015a996a71379b2b971907b9506acd93c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 19:37:12 +0200 Subject: [PATCH 33/78] Reject indirect Swift manifest execution --- ...ReleaseSourceTrustService.LocalPackages.cs | 6 ++++- ...eTests.AppleSourceTrustFinalRegressions.cs | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index d2215f1bd..51cfacadb 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -263,7 +263,11 @@ private static void ValidatePackageDescriptionCalls(string packageRoot, string m { var target = call.Groups["target"].Value.Replace("`", string.Empty).Replace(" ", string.Empty); if (string.IsNullOrWhiteSpace(target)) - continue; + { + 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]) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs index 89a1cc56b..5722cedf5 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs @@ -289,6 +289,28 @@ public void ResolveExactAppleSourceCommit_rejects_standard_library_execution_in_ 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() { From 1d8860cf8e3da07abf8b277b49978bbd39291375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 20:47:02 +0200 Subject: [PATCH 34/78] Seal Apple source and notarization evidence --- .../AppleNotarizationServiceTests.cs | 61 +++++++++++ .../Services/AppleNotarizationService.cs | 70 +++++++----- ...eleaseSourceTrustService.SourceIncludes.cs | 43 ++++++-- .../AppleReleaseSourceTrustService.Xcode.cs | 33 ++++-- ...SourceTrustService.XcodeSourceOwnership.cs | 86 +++++++++++++++ .../AppleReleaseSourceTrustService.cs | 5 +- ...dExecutionServiceTests.AppleSourceTrust.cs | 6 +- ...eTests.AppleSourceTrustMergeRegressions.cs | 100 +++++++++++++++++- 8 files changed, 363 insertions(+), 41 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index 12f856b9e..e591b7f79 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -244,6 +244,34 @@ public async Task NotarizeAsync_StaplesPrivateBundleAndPublishesThoseExactBytes( } } + [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() { @@ -783,6 +811,39 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo } } + 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; diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 6c89e5780..65da184b0 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -203,6 +203,7 @@ public async Task NotarizeAsync( ProcessRunResult? validation = null; ProcessRunResult? assessment = null; var stapledThisInvocation = false; + string? validatedStapledArtifactSha256 = null; if (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Staple) { if (staplingCompleted) @@ -220,18 +221,56 @@ public async Task NotarizeAsync( staple = await RunAsync(xcrunExecutable, submissionArtifactPath, new[] { "stapler", "staple", submissionArtifactPath }, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); stapledThisInvocation = staple.Succeeded; } - if (staple?.Succeeded == true && validation is null) - validation = await RunAsync(xcrunExecutable, submissionArtifactPath, new[] { "stapler", "validate", submissionArtifactPath }, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); - if (stapledThisInvocation && validation?.Succeeded == true && !string.IsNullOrWhiteSpace(submissionId)) + } + using var postStapleMonitor = staple?.Succeeded == true + ? new AppleReleaseSourceMutationMonitor( + submissionSnapshot.RootPath, + "validated private Apple notarization artifact", + "stapler validation, Gatekeeper assessment, and final publication", + "Discard the private artifact and resume from the last durable notarization checkpoint.") + : null; + 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 (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Assess) + { + var assessmentArguments = extension.Equals(".dmg", StringComparison.OrdinalIgnoreCase) + ? new[] { "--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=4", submissionArtifactPath } + : new[] + { + "--assess", "--type", + extension.Equals(".app", StringComparison.OrdinalIgnoreCase) ? "execute" : "install", + "--verbose=4", submissionArtifactPath + }; + assessment = await RunAsync(spctlExecutable, submissionArtifactPath, assessmentArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); + } + + 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)) { - var privateStapledArtifactSha256 = ComputeArtifactSha256(submissionArtifactPath); - var stapledArtifactSha256 = submissionSnapshot.PublishTo(artifactPath, privateStapledArtifactSha256); try { request.StapledCheckpoint?.Invoke(new AppleNotarizationStapledCheckpoint { ArtifactPath = artifactPath, - ArtifactSha256 = stapledArtifactSha256, + ArtifactSha256 = finalArtifactSha256, SubmissionSha256 = submissionSha256 ?? string.Empty, SubmissionId = submissionId!, Status = status ?? "Accepted" @@ -246,25 +285,6 @@ public async Task NotarizeAsync( } } } - if (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Assess) - { - var assessmentArguments = extension.Equals(".dmg", StringComparison.OrdinalIgnoreCase) - ? new[] { "--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=4", submissionArtifactPath } - : new[] - { - "--assess", "--type", - extension.Equals(".app", StringComparison.OrdinalIgnoreCase) ? "execute" : "install", - "--verbose=4", submissionArtifactPath - }; - assessment = await RunAsync(spctlExecutable, submissionArtifactPath, assessmentArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); - } - - string finalArtifactSha256; - if (request.Staple && staple?.Succeeded == true && validation?.Succeeded == true) - { - var privateFinalSha256 = ComputeArtifactSha256(submissionArtifactPath); - finalArtifactSha256 = submissionSnapshot.PublishTo(artifactPath, privateFinalSha256); - } else { finalArtifactSha256 = ComputeArtifactSha256(artifactPath); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index c3346b875..d91e22aba 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -7,22 +7,33 @@ 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" + ".h", ".hh", ".hpp", ".hxx", ".inc", ".pch", ".modulemap", ".swift" }; - private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePath) + private void ValidateSourceLevelIncludes( + string repositoryRoot, + string sourcePath, + bool validateSwiftDeterminism = false) { if (!SourceIncludeExtensions.Contains(Path.GetExtension(sourcePath))) return; var fullSourcePath = Path.GetFullPath(sourcePath); + var extension = Path.GetExtension(fullSourcePath); + if (extension.Equals(".swift", StringComparison.OrdinalIgnoreCase) && !validateSwiftDeterminism) + return; if (!_validatedSourceIncludeFiles.Add(fullSourcePath)) return; - if (Path.GetExtension(fullSourcePath).Equals(".modulemap", StringComparison.OrdinalIgnoreCase)) + 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 @@ -36,7 +47,7 @@ private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePat } foreach (Match directive in Regex.Matches( source, - "(?m)^[ \\t]*#[ \\t]*(?:include|include_next|import)[ \\t]+(?[^\\r\\n]+)", + "(?m)^[ \\t]*(?:#|%:)[ \\t]*(?:include|include_next|import)[ \\t]+(?[^\\r\\n]+)", RegexOptions.CultureInvariant)) { var operand = Regex.Replace(directive.Groups["operand"].Value, "[ \\t]*(?://.*)?$", string.Empty).Trim(); @@ -70,6 +81,24 @@ private void ValidateSourceLevelIncludes(string repositoryRoot, string sourcePat } } + 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)); @@ -123,7 +152,7 @@ private void ValidateClangModuleMapInputs(string repositoryRoot, string moduleMa if (found is not null) return found; - var tokenPasted = Regex.Replace(masked, "[ \\t\\r\\n]*##[ \\t\\r\\n]*", string.Empty); + var tokenPasted = Regex.Replace(masked, "[ \\t\\r\\n]*(?:##|%:%:)[ \\t\\r\\n]*", string.Empty); return FindNondeterministicCompilerIdentifier(tokenPasted); } @@ -138,7 +167,9 @@ private void ValidateClangModuleMapInputs(string repositoryRoot, string moduleMa (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__") + 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.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 5573623cb..3983a278b 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -270,6 +270,7 @@ private void ValidateProjectGraph( .Where(static value => !string.IsNullOrWhiteSpace(value)) .Select(static value => value!.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries)[0]) .ToHashSet(StringComparer.OrdinalIgnoreCase); + var shippingSources = ResolveShippingSourceOwnership(objects, metadataPath); var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); var validatedLocalPackageRoots = new HashSet(GetPathComparer()); @@ -362,7 +363,8 @@ private void ValidateProjectGraph( item, metadataPath, generatedOutputPaths, - buildFileReferences); + buildFileReferences, + shippingSources); } } @@ -562,7 +564,8 @@ private void ValidateResolvedProjectInput( PbxObject item, string metadataPath, IReadOnlyCollection generatedOutputPaths, - ISet buildFileReferences) + ISet buildFileReferences, + ShippingSourceOwnership shippingSources) { EnsurePathWithinRepository(repositoryRoot, candidate, $"Xcode {item.Isa} input"); var directoryFileReferenceIsBuilt = @@ -578,15 +581,29 @@ private void ValidateResolvedProjectInput( { EnsureNoGeneratedOutputOverlap(candidate, generatedOutputPaths, $"Xcode {item.Isa} input"); } + var isShippingSource = shippingSources.FileReferences.Contains(item.Id) || + shippingSources.SynchronizedRoots.Contains(item.Id); if (File.Exists(candidate)) - EnsureTrackedFile(repositoryRoot, candidate, $"Xcode {item.Isa} input"); + { + EnsureTrackedFile( + repositoryRoot, + candidate, + $"Xcode {item.Isa} input", + validateSwiftDeterminism: false); + if (isShippingSource && Path.GetExtension(candidate).Equals(".swift", StringComparison.OrdinalIgnoreCase)) + ValidateSourceLevelIncludes(repositoryRoot, candidate, validateSwiftDeterminism: true); + } 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"); + EnsureTrackedDirectoryTree( + repositoryRoot, + candidate, + $"Xcode {item.Isa} input", + validateSwiftDeterminism: isShippingSource); } else { @@ -603,7 +620,11 @@ private void ValidateResolvedProjectInput( } } - private void EnsureTrackedDirectoryTree(string repositoryRoot, string path, string name) + private void EnsureTrackedDirectoryTree( + string repositoryRoot, + string path, + string name, + bool validateSwiftDeterminism = false) { EnsureDirectoryWithinRepository(repositoryRoot, path, name); var relativeRoot = FrameworkCompatibility.GetRelativePath(repositoryRoot, path).Replace('\\', '/'); @@ -640,7 +661,7 @@ private void EnsureTrackedDirectoryTree(string repositoryRoot, string path, stri $"{name} differs from the exact source commit: " + FrameworkCompatibility.GetRelativePath(repositoryRoot, entry).Replace('\\', '/')); } - ValidateSourceLevelIncludes(repositoryRoot, fullPath); + ValidateSourceLevelIncludes(repositoryRoot, fullPath, validateSwiftDeterminism); } } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs new file mode 100644 index 000000000..20ede429f --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs @@ -0,0 +1,86 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private static ShippingSourceOwnership ResolveShippingSourceOwnership( + IReadOnlyDictionary objects, + string metadataPath) + { + 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 HashSet(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}"); + } + fileReferences.Add(fileReference); + } + } + + return new ShippingSourceOwnership(fileReferences, synchronizedRoots); + } + + 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(ISet fileReferences, ISet synchronizedRoots) + { + FileReferences = fileReferences; + SynchronizedRoots = synchronizedRoots; + } + + internal ISet FileReferences { get; } + + internal ISet SynchronizedRoots { get; } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index d3edd4926..cce36b80d 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -477,7 +477,8 @@ private void EnsureTrackedFile( string repositoryRoot, string path, string name, - string? capturedWorktreeBlob = null) + string? capturedWorktreeBlob = null, + bool validateSwiftDeterminism = false) { var candidate = Path.GetFullPath(path); EnsurePathWithinRepository(repositoryRoot, candidate, name); @@ -503,7 +504,7 @@ private void EnsureTrackedFile( var worktreeBlob = capturedWorktreeBlob ?? ComputeRawGitBlobId(repositoryRoot, candidate); if (!headBlob.StdOut.Trim().Equals(worktreeBlob, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); - ValidateSourceLevelIncludes(repositoryRoot, candidate); + ValidateSourceLevelIncludes(repositoryRoot, candidate, validateSwiftDeterminism); } private string ComputeRawGitBlobId(string repositoryRoot, string filePath) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index 197a9ba0e..2e7d9a50a 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -240,7 +240,11 @@ public void ResolveExactAppleSourceCommit_accepts_owned_built_product_reference( """ 000000000000000000000000 = { isa = PBXBuildFile; fileRef = 000000000000000000000001; }; 000000000000000000000001 = { isa = PBXFileReference; path = SampleExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 000000000000000000000002 = { isa = PBXNativeTarget; productReference = 000000000000000000000001; }; + 000000000000000000000002 = { + isa = PBXNativeTarget; + productReference = 000000000000000000000001; + productType = "com.apple.product-type.app-extension"; + }; """); var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); var sourceCommit = CommitRepository(repositoryRoot); diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs index c18f2216a..976b0a4ee 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -226,6 +226,104 @@ public void ResolveExactAppleSourceCommit_rejects_absolute_clang_module_map_inpu 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__")] @@ -262,7 +360,7 @@ public void ResolveExactAppleSourceCommit_allows_time_macro_names_in_comments_an "000000000000000000000002 = { isa = PBXFileReference; path = Source.m; sourceTree = \"\"; };"); File.WriteAllText( Path.Combine(repositoryRoot, "Source.m"), - "// __DATE__\nconst char *documentation = \"__TIME__ and __TIMESTAMP__\";\n"); + "// __DATE__ and __FILE__\nconst char *documentation = \"__TIME__, __TIMESTAMP__, __BASE_FILE__, and source_location\";\n"); var configPath = WriteAppleReleaseConfig(repositoryRoot, projectRoot: "."); CommitRepository(repositoryRoot); From 26af8e8cae3d94f27018a43667fb27dfbed6e0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 21:45:29 +0200 Subject: [PATCH 35/78] Complete Apple source trust validation --- ...pleReleaseSourceTrustService.BuildFlags.cs | 2 + ...ReleaseSourceTrustService.LocalPackages.cs | 31 ++++ ...eleaseSourceTrustService.RemotePackages.cs | 31 ++-- ...eleaseSourceTrustService.SourceIncludes.cs | 140 +++++++++++++++++- .../AppleReleaseSourceTrustService.Xcode.cs | 2 +- ...eReleaseSourceTrustService.XcodeParsing.cs | 25 ++++ .../AppleReleaseSourceTrustService.cs | 5 +- ...ests.AppleSourceTrustClosureRegressions.cs | 2 + ...eTests.AppleSourceTrustMergeRegressions.cs | 128 ++++++++++++++++ 9 files changed, 351 insertions(+), 15 deletions(-) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index b4a200566..47b8b89eb 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -189,6 +189,7 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke token.Equals("-module-map-file", StringComparison.Ordinal) || token.Equals("-fmodule-map-file", StringComparison.Ordinal) || token.Equals("-fmodule-file", StringComparison.Ordinal) || + token.Equals("-fprebuilt-module-path", StringComparison.Ordinal) || token.Equals("-fprofile-use", StringComparison.Ordinal) || token.Equals("-fprofile-instr-use", StringComparison.Ordinal) || token.Equals("-fprofile-sample-use", StringComparison.Ordinal) || @@ -217,6 +218,7 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke { "-iframeworkwithsysroot", "-iwithprefixbefore", "-fprofile-instr-use=", "-fprofile-sample-use=", "-fmodule-map-file=", "-working-directory=", + "-fprebuilt-module-path=", "-include-pch=", "-include-pth=", "-gcc-toolchain=", "-resource-dir=", "-iwithprefix", "-fprofile-use=", "-fmodule-file=", "-module-map-file=", "-ivfsoverlay=", "-vfsoverlay=", "-plugin-path=", "-fpass-plugin=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index 51cfacadb..738565b35 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -165,6 +165,37 @@ private static bool AllSystemLibrariesAreExcludedFromAppleTargets(string source, 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( diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs index 1c3b06c02..322fe3cfa 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -187,16 +187,6 @@ private void ValidateCheckedOutPackageRoot( 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}"); - 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"); - } - var locks = effectiveLockPaths.ToList(); var localLock = Path.Combine(packageRoot, "Package.resolved"); if (File.Exists(localLock)) @@ -205,6 +195,7 @@ private void ValidateCheckedOutPackageRoot( locks.Add(localLock); } + HashSet? inactiveSystemLibraryRoots = null; foreach (var manifestPath in manifests) { EnsureTrackedFile(checkoutRoot, manifestPath, "remote Swift package manifest"); @@ -216,6 +207,11 @@ private void ValidateCheckedOutPackageRoot( 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); ValidateLiteralSwiftPackagePaths(checkoutRoot, packageRoot, source, syntax); @@ -248,6 +244,21 @@ private void ValidateCheckedOutPackageRoot( 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"); + } } private void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, string revision) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index d91e22aba..bf7151fa3 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -13,7 +13,8 @@ internal sealed partial class AppleReleaseSourceTrustService private void ValidateSourceLevelIncludes( string repositoryRoot, string sourcePath, - bool validateSwiftDeterminism = false) + bool validateSwiftDeterminism = false, + string? sourceBlob = null) { if (!SourceIncludeExtensions.Contains(Path.GetExtension(sourcePath))) return; @@ -23,6 +24,13 @@ private void ValidateSourceLevelIncludes( return; if (!_validatedSourceIncludeFiles.Add(fullSourcePath)) return; + if (!string.IsNullOrWhiteSpace(sourceBlob)) + { + var semanticPath = ResolveSourceSemanticPath(fullSourcePath); + var semanticKey = sourceBlob + "|" + semanticPath + "|" + validateSwiftDeterminism; + if (!_validatedSourceSemanticInputs.Add(semanticKey)) + return; + } if (extension.Equals(".modulemap", StringComparison.OrdinalIgnoreCase)) { @@ -38,7 +46,9 @@ private void ValidateSourceLevelIncludes( // 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 source = RemoveCComments(SpliceCPreprocessingLines(File.ReadAllText(fullSourcePath))); + var physicalSource = File.ReadAllText(fullSourcePath); + RejectCTrigraphs(physicalSource, fullSourcePath); + var source = RemoveCComments(SpliceCPreprocessingLines(physicalSource)); var nondeterministicMacro = FindNondeterministicCompilerMacro(source); if (nondeterministicMacro is not null) { @@ -71,7 +81,11 @@ private void ValidateSourceLevelIncludes( { if (segments.Any(static segment => segment == "..")) throw new InvalidOperationException($"Source input '{fullSourcePath}' uses escaping system include '{include}'."); - continue; + if (IsApprovedAngledInclude(repositoryRoot, 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."); } var candidate = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(fullSourcePath)!, include)); @@ -79,6 +93,116 @@ private void ValidateSourceLevelIncludes( if (File.Exists(candidate)) EnsureTrackedFile(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); } + + if (extension.Equals(".s", StringComparison.OrdinalIgnoreCase)) + ValidateAssemblerInputs(repositoryRoot, fullSourcePath, source); + } + + 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 include) + { + var normalized = include.Replace('\\', '/').TrimStart('/'); + if (!_trackedSourceSuffixes.TryGetValue(repositoryRoot, out var trackedSuffixes)) + { + trackedSuffixes = new HashSet(GetPathComparer()); + var tracked = RunGit(repositoryRoot, "ls-files", "-z").StdOut + .Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) + .Select(static path => path.Replace('\\', '/')) + .ToArray(); + foreach (var path in tracked) + { + trackedSuffixes.Add(path); + for (var index = path.IndexOf('/'); index >= 0; index = path.IndexOf('/', index + 1)) + trackedSuffixes.Add(path.Substring(index + 1)); + } + _trackedSourceSuffixes[repositoryRoot] = trackedSuffixes; + } + if (trackedSuffixes.Contains(normalized)) + return true; + + var slash = normalized.IndexOf('/'); + if (slash < 0) + return ApprovedToolchainHeaders.Contains(normalized); + return ApprovedAppleSdkHeaderRoots.Contains(normalized.Substring(0, slash)); + } + + 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", + "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", "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", "arpa", "dispatch", "libkern", "mach", "mach-o", "net", "netinet", "os", "simd", + "sys", "xpc" + }; + + 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 void ValidateAssemblerInputs(string repositoryRoot, string sourcePath, string source) + { + foreach (Match directive in Regex.Matches( + source, + "(?im)^[ \\t]*\\.(?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 = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourcePath)!, input)); + EnsurePathWithinRepository(repositoryRoot, candidate, $"assembler .{directive.Groups["kind"].Value} input from {sourcePath}"); + if (!File.Exists(candidate)) + throw new FileNotFoundException($"Assembler input was not found inside the exact checked-out source: {candidate}", candidate); + EnsureTrackedFile(repositoryRoot, candidate, $"assembler .{directive.Groups["kind"].Value} input from {sourcePath}"); + } } private static void ValidateSwiftSourceDeterminism(string sourcePath) @@ -102,6 +226,16 @@ private static void ValidateSwiftSourceDeterminism(string sourcePath) 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 = "(?(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)) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index cce36b80d..1772a7ea6 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -27,6 +27,9 @@ internal sealed partial class AppleReleaseSourceTrustService private readonly HashSet _remotePackagesUnderValidation = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _validatedRemotePackages = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _validatedSourceIncludeFiles = new(GetPathComparer()); + private readonly HashSet _validatedSourceSemanticInputs = new(StringComparer.Ordinal); + private readonly HashSet _inactiveRemoteSystemLibraryRoots = new(GetPathComparer()); + private readonly Dictionary> _trackedSourceSuffixes = new(GetPathComparer()); internal AppleReleaseSourceTrustService( HomeAssistantReleaseGitService? git = null, @@ -504,7 +507,7 @@ private void EnsureTrackedFile( var worktreeBlob = capturedWorktreeBlob ?? ComputeRawGitBlobId(repositoryRoot, candidate); if (!headBlob.StdOut.Trim().Equals(worktreeBlob, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); - ValidateSourceLevelIncludes(repositoryRoot, candidate, validateSwiftDeterminism); + ValidateSourceLevelIncludes(repositoryRoot, candidate, validateSwiftDeterminism, worktreeBlob); } private string ComputeRawGitBlobId(string repositoryRoot, string filePath) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs index b8d132fa0..0bb861b34 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs @@ -491,6 +491,8 @@ public void Capture_accepts_declarative_exact_remote_package_revision() ".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 * }"); var remoteRevision = CommitRepository(remoteRoot); var repositoryRoot = scope.CreateDirectory("SafeRemotePackageConsumer"); diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs index 976b0a4ee..22d276de6 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -388,6 +388,134 @@ public void ResolveExactAppleSourceCommit_parses_xcode_reference_modifiers_befor 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); + } + + [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_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); + } + + [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); + } + + [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); + } + private static void WriteTrackedPackageResolutionLock(string repositoryRoot, string url, string revision) { var project = Directory.EnumerateDirectories(repositoryRoot, "*.xcodeproj", SearchOption.AllDirectories).Single(); From 52a3b1917df163c41a50f53cb5167b901f88d76f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 22:36:58 +0200 Subject: [PATCH 36/78] Close final Apple source and export trust gaps --- .../AppleAppArchiveServiceTests.cs | 40 +++++++++++++- .../AppleArchiveBuildSnapshotTests.cs | 26 +++++++++ ...rviceTests.AppleHostedReviewRegressions.cs | 2 + PowerForge/Models/AppleAppArchiveModels.cs | 6 +++ PowerForge/Services/AppleAppArchiveService.cs | 9 ++++ .../Services/AppleDirectExportSnapshot.cs | 53 +++++++++++++++++-- ...eleaseSourceTrustService.SourceIncludes.cs | 21 +++++++- .../AppleReleaseSourceTrustService.cs | 1 + .../Services/PowerForgeReleaseService.cs | 4 ++ ...eTests.AppleSourceTrustMergeRegressions.cs | 43 +++++++++++++++ 10 files changed, 197 insertions(+), 8 deletions(-) diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.cs index b43aff334..21062edb2 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.cs @@ -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,7 @@ public CapturingProcessRunner(ProcessRunResult? result = null) public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) { Requests.Add(request); + _beforeResult?.Invoke(request); return Task.FromResult(_result); } } diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index 9d9a8b15e..a2c967e02 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -2,6 +2,32 @@ namespace PowerForge.Tests; public sealed class AppleArchiveBuildSnapshotTests { + [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 Publish_rejects_archive_replaced_after_xcodebuild_identity_was_observed() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs index 5fa537762..fec2d338d 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -1183,6 +1183,8 @@ public void Execute_DirectExport_UsesPrivateInputAndPublishesVerifiedArtifactBef 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 { diff --git a/PowerForge/Models/AppleAppArchiveModels.cs b/PowerForge/Models/AppleAppArchiveModels.cs index 07b07660e..7302f7318 100644 --- a/PowerForge/Models/AppleAppArchiveModels.cs +++ b/PowerForge/Models/AppleAppArchiveModels.cs @@ -164,6 +164,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/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index 9d0ebb87a..4940283bb 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -201,6 +201,13 @@ public async Task UploadArchiveAsync( inheritEnvironment: toolEnvironment is null), cancellationToken).ConfigureAwait(false); + string? exportArtifactPath = null; + string? exportArtifactSha256 = null; + if (result.Succeeded && request.Destination.Equals("export", StringComparison.OrdinalIgnoreCase)) + { + exportArtifactPath = PowerForgeReleaseService.ResolveDirectAppleArtifactPath(exportPath); + exportArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(exportArtifactPath); + } var diagnostics = ResolveUploadDiagnostics(result); return new AppleAppArchiveUploadResult @@ -210,6 +217,8 @@ public async Task UploadArchiveAsync( ExportOptionsPlistPath = plistPath, DistributionLogPath = diagnostics.DistributionLogPath, BuildUploadId = diagnostics.BuildUploadId, + ExportArtifactPath = exportArtifactPath, + ExportArtifactSha256 = exportArtifactSha256, ProcessResult = result }; } diff --git a/PowerForge/Services/AppleDirectExportSnapshot.cs b/PowerForge/Services/AppleDirectExportSnapshot.cs index d36cd0df7..01c354b95 100644 --- a/PowerForge/Services/AppleDirectExportSnapshot.cs +++ b/PowerForge/Services/AppleDirectExportSnapshot.cs @@ -4,6 +4,8 @@ namespace PowerForge; internal sealed class AppleDirectExportSnapshot : IDisposable { private bool _disposed; + private string? _approvedArtifactPath; + private string? _approvedArtifactSha256; private AppleDirectExportSnapshot(string rootPath, string exportPath) { @@ -27,17 +29,47 @@ internal static AppleDirectExportSnapshot Create() 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); - var relativeArtifactPath = FrameworkCompatibility.GetRelativePath(ExportPath, sourceArtifact); - if (Path.IsPathRooted(relativeArtifactPath) || - relativeArtifactPath.Equals("..", StringComparison.Ordinal) || - relativeArtifactPath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + if (!sourceArtifact.Equals( + _approvedArtifactPath, + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) || + !sourceArtifactSha256.Equals(_approvedArtifactSha256, StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException($"Direct Apple export artifact escaped its private export root: {sourceArtifact}"); + 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 destination = Path.GetFullPath(destinationExportPath); var parent = Path.GetDirectoryName(destination) @@ -109,6 +141,17 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) } } + 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) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index bf7151fa3..8b5cdfcad 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -90,8 +90,14 @@ private void ValidateSourceLevelIncludes( var candidate = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(fullSourcePath)!, include)); EnsurePathWithinRepository(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); - if (File.Exists(candidate)) - EnsureTrackedFile(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); + if (!File.Exists(candidate)) + { + throw new FileNotFoundException( + $"Quoted preprocessor include '{include}' from '{fullSourcePath}' was not found beside the including source. " + + "Its compiler search-path selection cannot be bound to the exact source commit; use an adjacent tracked include or an approved module/framework reference.", + candidate); + } + EnsureTrackedFile(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); } if (extension.Equals(".s", StringComparison.OrdinalIgnoreCase)) @@ -177,6 +183,10 @@ private static void RejectCTrigraphs(string source, string sourcePath) private void ValidateAssemblerInputs(string repositoryRoot, string sourcePath, string source) { + var fullSourcePath = Path.GetFullPath(sourcePath); + if (!_validatedAssemblerInputFiles.Add(fullSourcePath)) + return; + foreach (Match directive in Regex.Matches( source, "(?im)^[ \\t]*\\.(?include|incbin)(?![A-Za-z0-9_])[ \\t]+(?[^\\r\\n]+)", @@ -202,6 +212,13 @@ private void ValidateAssemblerInputs(string repositoryRoot, string sourcePath, s if (!File.Exists(candidate)) throw new FileNotFoundException($"Assembler input was not found inside the exact checked-out source: {candidate}", candidate); 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); + } } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 1772a7ea6..8c305b634 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -27,6 +27,7 @@ internal sealed partial class AppleReleaseSourceTrustService 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 Dictionary> _trackedSourceSuffixes = new(GetPathComparer()); diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 9d1532965..2584874a6 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2263,6 +2263,8 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( AppStoreConnectApiIssuerId = direct ? null : plan.AppStoreConnectApiIssuerId, AllowProvisioningUpdates = plan.AllowProvisioningUpdates }); + if (direct && upload.Succeeded) + directExportSnapshot!.BindProducedArtifact(upload.ExportArtifactPath, upload.ExportArtifactSha256); uploadMutationMonitor?.ValidateNoChanges(); if (uploadSnapshot is not null) uploadSnapshot.ValidateUnchanged(approvedArchiveSha256!); @@ -2283,6 +2285,8 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( { 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, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs index 22d276de6..66cd3ad3b 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -433,6 +433,28 @@ public void ResolveExactAppleSourceCommit_rejects_absolute_assembler_inputs(stri 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() { @@ -493,6 +515,27 @@ public void ResolveExactAppleSourceCommit_rejects_angled_source_include_with_unb 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\"")] From 20214f6cb7cceb9f6cabbd4fb4053a02ae9bacbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 10 Aug 2026 23:19:00 +0200 Subject: [PATCH 37/78] Close Apple source and screenshot mutation gaps --- ...entTests.ScreenshotIntegrityRegressions.cs | 205 ++++++++++++++++++ PowerForge/Services/AppStoreConnectClient.cs | 50 ++--- ...ppStoreConnectReleasePreparationService.cs | 34 +++ .../AppStoreConnectScreenshotSyncService.cs | 135 +++++++++--- ...AppStoreConnectScreenshotUploadSnapshot.cs | 152 +++++++++++++ ...pleReleaseSourceTrustService.BuildFlags.cs | 12 + ...eleaseSourceTrustService.SourceIncludes.cs | 9 + ...eTests.AppleSourceTrustMergeRegressions.cs | 43 ++++ 8 files changed, 578 insertions(+), 62 deletions(-) create mode 100644 PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs create mode 100644 PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs diff --git a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs new file mode 100644 index 000000000..fa76cd5bd --- /dev/null +++ b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs @@ -0,0 +1,205 @@ +using System.Net; +using System.Security.Cryptography; + +namespace PowerForge.Tests; + +public sealed partial class AppStoreConnectClientTests +{ + [Fact] + public async Task ReleasePreparationService_RejectsScreenshotInventoryDriftBeforeFirstMutation() + { + 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, + 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 = "not-read-before-inventory-compare" + } + ] + } + })); + + 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)); + } + + [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 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 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/Services/AppStoreConnectClient.cs b/PowerForge/Services/AppStoreConnectClient.cs index ff4653f34..9763604c1 100644 --- a/PowerForge/Services/AppStoreConnectClient.cs +++ b/PowerForge/Services/AppStoreConnectClient.cs @@ -454,24 +454,26 @@ internal async Task UploadScreenshotAsync var file = new FileInfo(fullPath); if (!file.Exists) throw new FileNotFoundException("Screenshot file was not found.", fullPath); - var capturedBytes = File.ReadAllBytes(fullPath); - if (!string.IsNullOrWhiteSpace(expectedSha256) && - !ComputeSha256Checksum(capturedBytes).Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Screenshot '{fullPath}' changed after its immutable upload snapshot was captured."); - } + 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, - capturedBytes.LongLength, + captured.Length, cancellationToken).ConfigureAwait(false); foreach (var operation in reservation.UploadOperations) - await ExecuteUploadOperationAsync(capturedBytes, operation, cancellationToken).ConfigureAwait(false); + await ExecuteUploadOperationAsync(captured, operation, cancellationToken).ConfigureAwait(false); + captured.ValidateUnchanged(); + mutationMonitor.ValidateNoChanges(); - var checksum = ComputeMd5Checksum(capturedBytes); + var checksum = captured.Md5; var committed = await CommitScreenshotUploadAsync(reservation.Id, checksum, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(committed.SourceFileChecksum)) reservation.SourceFileChecksum = committed.SourceFileChecksum; @@ -743,7 +745,7 @@ private async Task GetBuildArrayAsync( } private async Task ExecuteUploadOperationAsync( - byte[] fileBytes, + AppStoreConnectScreenshotUploadSnapshot captured, AppStoreConnectUploadOperation operation, CancellationToken cancellationToken) { @@ -751,19 +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."); - if (operation.Offset < 0 || operation.Offset > int.MaxValue) + if (operation.Offset < 0) throw new InvalidOperationException("Upload operation offset is outside the captured screenshot bytes."); - var bytes = new byte[(int)operation.Length]; - var offset = (int)operation.Offset; - if (offset > fileBytes.Length - bytes.Length) - throw new EndOfStreamException("Captured screenshot bytes ended before the upload operation range."); - Buffer.BlockCopy(fileBytes, offset, bytes, 0, bytes.Length); - 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)) @@ -1066,20 +1060,6 @@ private static int ClampLimit(int limit) return limit; } - private static string ComputeMd5Checksum(byte[] bytes) - { - using var md5 = MD5.Create(); - var hash = md5.ComputeHash(bytes); - return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); - } - - private static string ComputeSha256Checksum(byte[] bytes) - { - using var sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(bytes); - return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); - } - private sealed class BuildPreReleaseVersion { public BuildPreReleaseVersion(string? version, string? platform) diff --git a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs index b59c06900..b211520c7 100644 --- a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs +++ b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs @@ -42,7 +42,36 @@ 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; + + 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 new AppStoreConnectScreenshotSyncService(_client).ValidateExpectedRemoteInventoryAsync( + screenshotSpec, + request.ExpectedScreenshotInventorySha256!, + cancellationToken).ConfigureAwait(false); + } + firstRemoteMutationAuthorized = true; + } + if (requiresVersion) { var configuredVersionId = ResolveConfiguredVersionId(request); @@ -72,6 +101,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 +137,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 +147,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 +158,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,6 +170,7 @@ 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( new AppStoreConnectScreenshotSyncRequest diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index e4810749b..74f6aedc9 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -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. /// @@ -119,22 +176,10 @@ public async Task SyncAsync( if (request.ReplaceExisting && !string.IsNullOrWhiteSpace(request.ExpectedRemoteInventorySha256)) { - var remoteInventory = plannedSets.Select(static plannedSet => - new AppStoreConnectReleaseScreenshotSetReadiness - { - ScreenshotDisplayType = plannedSet.Preflighted.ScreenshotDisplayType, - ScreenshotSetId = plannedSet.ScreenshotSet?.Id, - Count = plannedSet.ExistingScreenshots.Length, - Screenshots = plannedSet.ExistingScreenshots.Select(static screenshot => - new AppStoreConnectReleaseScreenshotAssetReadiness - { - Id = screenshot.Id, - FileName = screenshot.FileName, - FileSize = screenshot.FileSize, - SourceFileChecksum = screenshot.SourceFileChecksum, - AssetDeliveryState = screenshot.AssetDeliveryState - }).ToArray() - }); + 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)) { @@ -228,6 +273,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); @@ -331,27 +396,43 @@ private static Dictionary SelectApprovedScreenshotFiles( foreach (var set in sourceSets) { var approved = expected - .Where(item => pathComparer.Equals( - Path.GetDirectoryName(Path.GetFullPath(item.Key)), - set.Folder)) - .Where(item => MatchesScreenshotFilter(Path.GetFileName(item.Key), set.Filter)) - .OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase) + .Select(item => new + { + Item = item, + RelativePath = GetRelativeScreenshotPath(set.Folder, item.Key) + }) + .Where(static item => item.RelativePath is not null) + .Where(item => MatchesScreenshotFilter(item.RelativePath!, set.Filter)) + .OrderBy(static item => item.Item.Key, StringComparer.OrdinalIgnoreCase) .Take(set.MaxCount); foreach (var item in approved) - selected[Path.GetFullPath(item.Key)] = item.Value; + selected[Path.GetFullPath(item.Item.Key)] = item.Item.Value; } return selected; } - private static bool MatchesScreenshotFilter(string fileName, string filter) + private static string? GetRelativeScreenshotPath(string folder, string screenshotPath) + { + var relative = FrameworkCompatibility.GetRelativePath(folder, Path.GetFullPath(screenshotPath)); + if (Path.IsPathRooted(relative) || + relative.Equals("..", StringComparison.Ordinal) || + relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + return null; + } + return relative.Replace('\\', '/'); + } + + private static bool MatchesScreenshotFilter(string relativePath, string filter) { - var expression = "^" + System.Text.RegularExpressions.Regex.Escape(filter) - .Replace("\\*", ".*") - .Replace("\\?", ".") + "$"; + var normalizedFilter = filter.Replace('\\', '/'); + var expression = "^" + System.Text.RegularExpressions.Regex.Escape(normalizedFilter) + .Replace("\\*", "[^/]*") + .Replace("\\?", "[^/]") + "$"; var options = System.Text.RegularExpressions.RegexOptions.CultureInvariant; if (Path.DirectorySeparatorChar == '\\') options |= System.Text.RegularExpressions.RegexOptions.IgnoreCase; - return System.Text.RegularExpressions.Regex.IsMatch(fileName, expression, options); + return System.Text.RegularExpressions.Regex.IsMatch(relativePath, expression, options); } private static string ComputeSha256(string filePath) diff --git a/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs b/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs new file mode 100644 index 000000000..d05416685 --- /dev/null +++ b/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs @@ -0,0 +1,152 @@ +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 AppStoreConnectScreenshotUploadSnapshot( + string rootPath, + string filePath, + long length, + string sha256, + string md5) + { + RootPath = rootPath; + FilePath = filePath; + Length = length; + Sha256 = sha256; + Md5 = md5; + } + + 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."); + } + + return new AppStoreConnectScreenshotUploadSnapshot( + root, + snapshotPath, + new FileInfo(snapshotPath).Length, + sha256, + ComputeHash(snapshotPath, MD5.Create)); + } + catch + { + try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } + 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."); + } + } + + 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; + if (Directory.Exists(RootPath)) + Directory.Delete(RootPath, recursive: true); + } + + 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/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 47b8b89eb..b8f029e51 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -193,6 +193,14 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke token.Equals("-fprofile-use", StringComparison.Ordinal) || token.Equals("-fprofile-instr-use", StringComparison.Ordinal) || token.Equals("-fprofile-sample-use", StringComparison.Ordinal) || + token.Equals("-fsanitize-ignorelist", StringComparison.Ordinal) || + token.Equals("-fsanitize-blacklist", StringComparison.Ordinal) || + token.Equals("-fsanitize-system-ignorelist", StringComparison.Ordinal) || + token.Equals("-fsanitize-system-blacklist", StringComparison.Ordinal) || + token.Equals("-fsanitize-coverage-allowlist", StringComparison.Ordinal) || + token.Equals("-fsanitize-coverage-ignorelist", StringComparison.Ordinal) || + token.Equals("-fsanitize-coverage-whitelist", StringComparison.Ordinal) || + token.Equals("-fsanitize-coverage-blacklist", StringComparison.Ordinal) || token.Equals("-resource-dir", StringComparison.Ordinal) || token.Equals("-working-directory", StringComparison.Ordinal) || token.Equals("-gcc-toolchain", StringComparison.Ordinal) || @@ -218,6 +226,10 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke { "-iframeworkwithsysroot", "-iwithprefixbefore", "-fprofile-instr-use=", "-fprofile-sample-use=", "-fmodule-map-file=", "-working-directory=", + "-fsanitize-system-ignorelist=", "-fsanitize-system-blacklist=", + "-fsanitize-coverage-allowlist=", "-fsanitize-coverage-ignorelist=", + "-fsanitize-coverage-whitelist=", "-fsanitize-coverage-blacklist=", + "-fsanitize-ignorelist=", "-fsanitize-blacklist=", "-fprebuilt-module-path=", "-include-pch=", "-include-pth=", "-gcc-toolchain=", "-resource-dir=", "-iwithprefix", "-fprofile-use=", "-fmodule-file=", "-module-map-file=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 8b5cdfcad..520c726d9 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -49,6 +49,15 @@ private void ValidateSourceLevelIncludes( var physicalSource = File.ReadAllText(fullSourcePath); RejectCTrigraphs(physicalSource, fullSourcePath); var source = RemoveCComments(SpliceCPreprocessingLines(physicalSource)); + var embedDirective = Regex.Match( + source, + "(?m)^[ \\t]*(?:#|%:)[ \\t]*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) { diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs index 66cd3ad3b..5a8b9c7eb 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -409,6 +409,28 @@ public void ResolveExactAppleSourceCommit_rejects_c_trigraph_preprocessor_direct Assert.Contains("??=", exception.Message, StringComparison.Ordinal); } + [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")] @@ -494,6 +516,27 @@ public void ResolveExactAppleSourceCommit_classifies_joined_prebuilt_module_path 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() { From 51527d0e3d212dbc17eca9745fe42f7052abb54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 00:01:13 +0200 Subject: [PATCH 38/78] Close Apple source parser input gaps --- ...pleReleaseSourceTrustService.BuildFlags.cs | 5 +- ...eleaseSourceTrustService.InlineAssembly.cs | 198 ++++++++++++++++++ ...ReleaseSourceTrustService.LocalPackages.cs | 99 +++++++++ ...eleaseSourceTrustService.SourceIncludes.cs | 9 + ...eReleaseSourceTrustService.SwiftParsing.cs | 75 +++++++ ...eTests.AppleSourceTrustMergeRegressions.cs | 125 +++++++++++ 6 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index b8f029e51..f66144e0e 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -193,6 +193,8 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke token.Equals("-fprofile-use", StringComparison.Ordinal) || token.Equals("-fprofile-instr-use", StringComparison.Ordinal) || token.Equals("-fprofile-sample-use", StringComparison.Ordinal) || + token.Equals("-fprofile-list", StringComparison.Ordinal) || + token.Equals("-fprofile-remapping-file", StringComparison.Ordinal) || token.Equals("-fsanitize-ignorelist", StringComparison.Ordinal) || token.Equals("-fsanitize-blacklist", StringComparison.Ordinal) || token.Equals("-fsanitize-system-ignorelist", StringComparison.Ordinal) || @@ -225,7 +227,8 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke var prefixes = new[] { "-iframeworkwithsysroot", "-iwithprefixbefore", "-fprofile-instr-use=", - "-fprofile-sample-use=", "-fmodule-map-file=", "-working-directory=", + "-fprofile-sample-use=", "-fprofile-remapping-file=", "-fprofile-list=", + "-fmodule-map-file=", "-working-directory=", "-fsanitize-system-ignorelist=", "-fsanitize-system-blacklist=", "-fsanitize-coverage-allowlist=", "-fsanitize-coverage-ignorelist=", "-fsanitize-coverage-whitelist=", "-fsanitize-coverage-blacklist=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs b/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs new file mode 100644 index 000000000..ceba0e1e4 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs @@ -0,0 +1,198 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void ValidateInlineAssemblerInputs(string repositoryRoot, string sourcePath, string source) + { + 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 index 738565b35..f9fd05958 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -77,6 +77,7 @@ private void ValidateLocalPackageManifest( ValidateLocalPackageExecutableSafety(packageRoot, manifestWithoutComments, manifestSyntax); ValidateDirectSwiftPackageDependencyFactories(packageRoot, manifestSyntax); ValidatePackageDescriptionCalls(packageRoot, manifestSyntax); + ValidateSwiftPackageResources(repositoryRoot, packageRoot, manifestWithoutComments, manifestSyntax); var dependencyCalls = ParseDirectSwiftPackageDependencyCalls(manifestWithoutComments, manifestSyntax); ValidateRemotePackageDependencies(repositoryRoot, packageRoot, packageLockPaths, dependencyCalls); ValidateNestedLocalPackageDependencies( @@ -284,6 +285,7 @@ private static void ValidatePackageDescriptionCalls(string packageRoot, string m "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" }; @@ -418,4 +420,101 @@ private void ValidateLiteralSwiftPackagePaths( 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.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 520c726d9..8944f3a81 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -109,6 +109,9 @@ private void ValidateSourceLevelIncludes( EnsureTrackedFile(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); } + if (IsCInlineAssemblySource(extension)) + ValidateInlineAssemblerInputs(repositoryRoot, fullSourcePath, source); + if (extension.Equals(".s", StringComparison.OrdinalIgnoreCase)) ValidateAssemblerInputs(repositoryRoot, fullSourcePath, source); } @@ -196,6 +199,12 @@ private void ValidateAssemblerInputs(string repositoryRoot, string sourcePath, s if (!_validatedAssemblerInputFiles.Add(fullSourcePath)) return; + ValidateAssemblerDirectives(repositoryRoot, fullSourcePath, source); + } + + private void ValidateAssemblerDirectives(string repositoryRoot, string sourcePath, string source) + { + foreach (Match directive in Regex.Matches( source, "(?im)^[ \\t]*\\.(?include|incbin)(?![A-Za-z0-9_])[ \\t]+(?[^\\r\\n]+)", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs index 212f94dcb..4d8068f03 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SwiftParsing.cs @@ -129,6 +129,81 @@ private static IReadOnlyDictionary ParseTopLevelSwiftArguments(s 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, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs index 5a8b9c7eb..103387cc9 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -602,6 +602,131 @@ public void ResolveExactAppleSourceCommit_rejects_unbound_module_map_autolinks(s 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); + } + private static void WriteTrackedPackageResolutionLock(string repositoryRoot, string url, string revision) { var project = Directory.EnumerateDirectories(repositoryRoot, "*.xcodeproj", SearchOption.AllDirectories).Single(); From cfb57a759a2490b9dc53125d0ff81df9425e4a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 00:46:03 +0200 Subject: [PATCH 39/78] Close Apple compiler input integrity gaps --- ...ArchiveServiceTests.ExactSourcePackages.cs | 52 +++++++++- .../AppleArchiveBuildSnapshotTests.cs | 36 +++++++ .../Services/AppleArchiveBuildSnapshot.cs | 31 +++++- ...pleReleaseSourceTrustService.BuildFlags.cs | 3 + ...eleaseSourceTrustService.SourceIncludes.cs | 92 +++++++++++++---- .../AppleReleaseSourceTrustService.Xcode.cs | 25 ++--- ...eReleaseSourceTrustService.XcodeParsing.cs | 34 ++++++- .../AppleReleaseSourceTrustService.cs | 2 +- .../AppleSwiftPackageBuildSnapshot.cs | 83 +++++++++++++--- ...ests.AppleSourceTrustClosureRegressions.cs | 1 + ...eTests.AppleSourceTrustMergeRegressions.cs | 98 +++++++++++++++++++ 11 files changed, 402 insertions(+), 55 deletions(-) diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs index a3bb45c7b..c91620468 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs @@ -120,19 +120,59 @@ public async Task CreateArchiveAsync_exact_source_rejects_materialized_package_r } } + [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 binary-artifact tree 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 string _remoteSourceRoot; internal ExactPackageProcessRunner( string fixtureRoot, bool mutateDuringArchive = false, - bool materializeWrongRevision = false) + bool materializeWrongRevision = false, + bool materializeBinaryArtifact = false, + bool mutateBinaryArtifactDuringArchive = false) { _mutateDuringArchive = mutateDuringArchive; _materializeWrongRevision = materializeWrongRevision; + _materializeBinaryArtifact = materializeBinaryArtifact; + _mutateBinaryArtifactDuringArchive = mutateBinaryArtifactDuringArchive; _remoteSourceRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "RemoteShared")).FullName; RunGit(_remoteSourceRoot, "init", "--quiet"); RunGit(_remoteSourceRoot, "config", "user.name", "PowerForge Tests"); @@ -176,6 +216,11 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo 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) { @@ -184,6 +229,11 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo File.WriteAllText(manifest, original + "// injected\n"); File.WriteAllText(manifest, original); } + else if (_mutateBinaryArtifactDuringArchive) + { + var payload = Path.Combine(SourcePackagesRoot, "artifacts", "Shared", "Framework.xcframework", "payload"); + File.WriteAllText(payload, "replacement binary artifact"); + } if (!request.Arguments.Contains("-resolvePackageDependencies")) { diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index a2c967e02..1a90f9825 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -76,4 +76,40 @@ public void RestoreDirectoryBackup_retains_previous_artifact_when_destination_wa 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 { } + } + } } diff --git a/PowerForge/Services/AppleArchiveBuildSnapshot.cs b/PowerForge/Services/AppleArchiveBuildSnapshot.cs index 323296311..b26330e96 100644 --- a/PowerForge/Services/AppleArchiveBuildSnapshot.cs +++ b/PowerForge/Services/AppleArchiveBuildSnapshot.cs @@ -55,6 +55,7 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) var stageRoot = Path.Combine(parent, $".{name}.powerforge-stage-{Guid.NewGuid():N}"); var stage = Path.Combine(stageRoot, name); var backup = Path.Combine(parent, $".{name}.powerforge-backup-{Guid.NewGuid():N}"); + var rollbackCandidate = Path.Combine(parent, $".{name}.powerforge-failed-publication-{Guid.NewGuid():N}"); var movedExisting = false; var published = false; try { @@ -87,10 +88,7 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) return publishedSha256; } catch (Exception publicationException) { try { - if (published && Directory.Exists(destination)) - Directory.Delete(destination, recursive: true); - if (movedExisting) - AppleArtifactCopy.RestoreDirectoryBackup(destination, backup); + 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}'.", @@ -104,6 +102,31 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) } } + internal static void RollbackPublication( + string destination, + string backup, + string rollbackCandidate, + string publishedSha256, + bool published, + bool movedExisting) + { + if (published && Directory.Exists(destination)) { + Directory.Move(destination, rollbackCandidate); + var rollbackSha256 = AppleNotarizationService.ComputeArtifactSha256(rollbackCandidate); + if (rollbackSha256.Equals(publishedSha256, StringComparison.OrdinalIgnoreCase)) { + Directory.Delete(rollbackCandidate, recursive: true); + } else { + if (!Directory.Exists(destination) && !File.Exists(destination)) + Directory.Move(rollbackCandidate, destination); + 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; diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index f66144e0e..2f3a074ed 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -195,6 +195,8 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke token.Equals("-fprofile-sample-use", StringComparison.Ordinal) || token.Equals("-fprofile-list", StringComparison.Ordinal) || token.Equals("-fprofile-remapping-file", StringComparison.Ordinal) || + token.Equals("-profile-use", StringComparison.Ordinal) || + token.Equals("-profile-sample-use", StringComparison.Ordinal) || token.Equals("-fsanitize-ignorelist", StringComparison.Ordinal) || token.Equals("-fsanitize-blacklist", StringComparison.Ordinal) || token.Equals("-fsanitize-system-ignorelist", StringComparison.Ordinal) || @@ -228,6 +230,7 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke { "-iframeworkwithsysroot", "-iwithprefixbefore", "-fprofile-instr-use=", "-fprofile-sample-use=", "-fprofile-remapping-file=", "-fprofile-list=", + "-profile-sample-use=", "-profile-use=", "-fmodule-map-file=", "-working-directory=", "-fsanitize-system-ignorelist=", "-fsanitize-system-blacklist=", "-fsanitize-coverage-allowlist=", "-fsanitize-coverage-ignorelist=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 8944f3a81..babc316f2 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -90,7 +90,7 @@ private void ValidateSourceLevelIncludes( { if (segments.Any(static segment => segment == "..")) throw new InvalidOperationException($"Source input '{fullSourcePath}' uses escaping system include '{include}'."); - if (IsApprovedAngledInclude(repositoryRoot, 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. " + @@ -109,6 +109,8 @@ private void ValidateSourceLevelIncludes( EnsureTrackedFile(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); } + ValidatePreprocessorFileExistenceProbes(repositoryRoot, fullSourcePath, source); + if (IsCInlineAssemblySource(extension)) ValidateInlineAssemblerInputs(repositoryRoot, fullSourcePath, source); @@ -126,31 +128,79 @@ private static string ResolveSourceSemanticPath(string sourcePath) return headers < 0 ? normalized : normalized.Substring(headers + "/Headers/".Length); } - private bool IsApprovedAngledInclude(string repositoryRoot, string include) + private bool IsApprovedAngledInclude(string repositoryRoot, string sourcePath, string include) { var normalized = include.Replace('\\', '/').TrimStart('/'); - if (!_trackedSourceSuffixes.TryGetValue(repositoryRoot, out var trackedSuffixes)) + 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 void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, string sourcePath, string source) + { + var syntax = MaskCStringAndCharacterLiterals(source); + foreach (Match probe in Regex.Matches( + syntax, + "(?(GetPathComparer()); - var tracked = RunGit(repositoryRoot, "ls-files", "-z").StdOut - .Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries) - .Select(static path => path.Replace('\\', '/')) - .ToArray(); - foreach (var path in tracked) + var opening = probe.Index + probe.Length - 1; + var closing = FindMatchingCDelimiter(source, opening, '(', ')'); + var operand = source.Substring(opening + 1, closing - opening - 1).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) { - trackedSuffixes.Add(path); - for (var index = path.IndexOf('/'); index >= 0; index = path.IndexOf('/', index + 1)) - trackedSuffixes.Add(path.Substring(index + 1)); + throw new InvalidOperationException( + $"Source input '{sourcePath}' uses computed preprocessor file-existence probe '{operand}', which cannot be bound to exact source."); } - _trackedSourceSuffixes[repositoryRoot] = trackedSuffixes; - } - if (trackedSuffixes.Contains(normalized)) - return true; - var slash = normalized.IndexOf('/'); - if (slash < 0) - return ApprovedToolchainHeaders.Contains(normalized); - return ApprovedAppleSdkHeaderRoots.Contains(normalized.Substring(0, slash)); + 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; + } + + var candidate = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourcePath)!, include)); + EnsurePathWithinRepository(repositoryRoot, candidate, $"preprocessor file-existence probe from {sourcePath}"); + if (!File.Exists(candidate)) + throw new FileNotFoundException($"Preprocessor file-existence probe input was not found: {candidate}", candidate); + EnsureTrackedFile(repositoryRoot, candidate, $"preprocessor file-existence probe from {sourcePath}"); + } } private static readonly HashSet ApprovedToolchainHeaders = new(StringComparer.Ordinal) @@ -207,7 +257,7 @@ private void ValidateAssemblerDirectives(string repositoryRoot, string sourcePat foreach (Match directive in Regex.Matches( source, - "(?im)^[ \\t]*\\.(?include|incbin)(?![A-Za-z0-9_])[ \\t]+(?[^\\r\\n]+)", + "(?im)^[ \\t]*(?:(?:[A-Za-z_.$][A-Za-z0-9_.$]*|[0-9]+):[ \\t]*)*\\.(?include|incbin)(?![A-Za-z0-9_])[ \\t]+(?[^\\r\\n]+)", RegexOptions.CultureInvariant)) { var operand = directive.Groups["operand"].Value.Trim(); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 7093eea09..616d5dae9 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -274,6 +274,20 @@ private void ValidateProjectGraph( 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 item in objects.Values) { if (item.Isa.Equals("PBXShellScriptBuildPhase", StringComparison.OrdinalIgnoreCase)) @@ -329,18 +343,7 @@ private void ValidateProjectGraph( } if (item.Isa.Equals("XCBuildConfiguration", StringComparison.OrdinalIgnoreCase)) - { - ValidateBuildConfiguration( - repositoryRoot, - projectDirectory, - item, - objects, - parents, - cache, - metadataPath, - generatedOutputPaths); continue; - } if (!IsPathBearingPbxObject(item.Isa)) continue; diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index cdb994e8e..473e96801 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -12,7 +12,11 @@ private void ValidateBuildSettingAssignments( IReadOnlyCollection generatedOutputPaths, string source) { - foreach (var assignment in assignments) + var assignmentArray = assignments.ToArray(); + var preprocessInfoPlist = assignmentArray.Any(static assignment => + assignment.Key.Split('[')[0].Trim().Equals("INFOPLIST_PREPROCESS", StringComparison.OrdinalIgnoreCase) && + assignment.Value.Trim().Equals("YES", StringComparison.OrdinalIgnoreCase)); + foreach (var assignment in assignmentArray) { var key = assignment.Key.Trim(); var baseKey = key.Split('[')[0].Trim(); @@ -80,12 +84,18 @@ private void ValidateBuildSettingAssignments( { EnsureTrackedFile(repositoryRoot, candidate, $"Xcode build setting {key}"); if (baseKey.Equals("INFOPLIST_FILE", StringComparison.OrdinalIgnoreCase)) - ValidateInfoPlistBuildSettingReferences(candidate, source); + 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)) + _approvedHeaderSearchRoots.Add(candidate); + } else throw new FileNotFoundException( $"Xcode build setting {key} references a missing exact-source input: {candidate}", @@ -147,7 +157,11 @@ private static IEnumerable> ReadBuildSettingReferen } } - private static void ValidateInfoPlistBuildSettingReferences(string plistPath, string source) + private void ValidateInfoPlistBuildSettingReferences( + string repositoryRoot, + string plistPath, + string source, + bool preprocess) { var plistReferences = new HashSet(StringComparer.OrdinalIgnoreCase) { @@ -165,6 +179,20 @@ private static void ValidateInfoPlistBuildSettingReferences(string plistPath, st } var contents = DecodeTrackedText(bytes); + if (preprocess) + { + var logical = RemoveCComments(SpliceCPreprocessingLines(contents)); + if (Regex.IsMatch( + logical, + "(?m)^[ \\t]*(?:#|%:)[ \\t]*(?:include|include_next|import|embed)(?![A-Za-z0-9_])|(? _validatedAssemblerInputFiles = new(GetPathComparer()); private readonly HashSet _validatedSourceSemanticInputs = new(StringComparer.Ordinal); private readonly HashSet _inactiveRemoteSystemLibraryRoots = new(GetPathComparer()); - private readonly Dictionary> _trackedSourceSuffixes = new(GetPathComparer()); + private readonly HashSet _approvedHeaderSearchRoots = new(GetPathComparer()); internal AppleReleaseSourceTrustService( HomeAssistantReleaseGitService? git = null, diff --git a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs index b8a703f26..58e308ee5 100644 --- a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs +++ b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs @@ -9,6 +9,7 @@ internal sealed class AppleSwiftPackageBuildSnapshot : IDisposable private readonly IReadOnlyDictionary _approvedPackageRevisions; private readonly IReadOnlyDictionary _environmentVariables; private readonly AppleReleaseSourceMutationMonitor? _monitor; + private readonly string? _artifactSha256; private bool _disposed; private AppleSwiftPackageBuildSnapshot( @@ -26,6 +27,21 @@ private AppleSwiftPackageBuildSnapshot( "xcodebuild archive", "Discard the archive and resolve the exact package graph again.") : null; + try + { + _sourceTrust.ValidateMaterializedPackageCheckouts(SourcePackagesPath, _approvedPackageRevisions); + var artifactsPath = Path.Combine(SourcePackagesPath, "artifacts"); + if (Directory.Exists(artifactsPath)) + { + ValidateNoEscapingArtifactLinks(artifactsPath); + _artifactSha256 = AppleNotarizationService.ComputeArtifactSha256(artifactsPath); + } + } + catch + { + _monitor?.Dispose(); + throw; + } } internal string RootPath { get; } @@ -98,19 +114,7 @@ internal static async Task CreateAsync( (string.IsNullOrWhiteSpace(result.StdErr) ? result.StdOut : result.StdErr)); } - new AppleReleaseSourceTrustService().ValidateMaterializedPackageCheckouts( - sourcePackagesPath, - approvedPackageRevisions); - var snapshot = new AppleSwiftPackageBuildSnapshot(root, approvedPackageRevisions, environmentVariables); - try - { - return snapshot; - } - catch - { - snapshot.Dispose(); - throw; - } + return new AppleSwiftPackageBuildSnapshot(root, approvedPackageRevisions, environmentVariables); } catch { @@ -133,8 +137,59 @@ internal void AppendArchiveArguments(ICollection arguments) internal void ValidateUnchanged() { - _monitor?.ValidateNoChanges(); _sourceTrust.ValidateMaterializedPackageCheckouts(SourcePackagesPath, _approvedPackageRevisions); + var artifactsPath = Path.Combine(SourcePackagesPath, "artifacts"); + if (_artifactSha256 is not null) + { + if (!Directory.Exists(artifactsPath)) + throw new InvalidOperationException("The materialized Swift binary-artifact tree disappeared before xcodebuild archive."); + ValidateNoEscapingArtifactLinks(artifactsPath); + var actual = AppleNotarizationService.ComputeArtifactSha256(artifactsPath); + if (!actual.Equals(_artifactSha256, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The materialized Swift binary-artifact tree changed before xcodebuild archive."); + } + else if (Directory.Exists(artifactsPath) && Directory.EnumerateFileSystemEntries(artifactsPath).Any()) + { + throw new InvalidOperationException("A materialized Swift binary-artifact tree appeared after package approval."); + } + _monitor?.ValidateNoChanges(); + } + + 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) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs index 0bb861b34..5655d1723 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustClosureRegressions.cs @@ -493,6 +493,7 @@ public void Capture_accepts_declarative_exact_remote_package_revision() 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"); diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs index 103387cc9..c7e3ba7ad 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -727,6 +727,104 @@ public void ResolveExactAppleSourceCommit_rejects_indirect_swift_package_resourc 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(); From ec53de719e92f01999d020c199ce099435d9aedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 01:42:29 +0200 Subject: [PATCH 40/78] Close Apple preprocessor plugin and version atomicity gaps --- ...aseServiceTests.AppleReleaseAdvancement.cs | 43 ------- ...leaseServiceTests.AppleVersionAtomicity.cs | 76 ++++++++++++ ...pleReleaseSourceTrustService.BuildFlags.cs | 57 +++++++++ ...eleaseSourceTrustService.SourceIncludes.cs | 29 +++++ ...eReleaseSourceTrustService.XcodeParsing.cs | 5 +- .../AppleReleaseVersionSourceService.cs | 82 +++++++++---- ....AppleSourceTrustCurrentHeadRegressions.cs | 115 ++++++++++++++++++ 7 files changed, 340 insertions(+), 67 deletions(-) create mode 100644 PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs index 33118f95a..c68a6a6e7 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs @@ -379,49 +379,6 @@ public void Execute_AppleVersion_RetryKeepsUnpublishedSelectedBuild() } } - [Fact] - public void AppleVersionSource_UpdateDoesNotOverwriteAnAtomicEditorAfterComparison() - { - if (!OperatingSystem.IsMacOS()) - return; - 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 Execute_AppleVersionPlan_WritesSeparatePlanReceiptWithoutChangingSource() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs new file mode 100644 index 000000000..6c92af338 --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs @@ -0,0 +1,76 @@ +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); + } + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 2f3a074ed..201fbec67 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -161,6 +161,33 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke continue; } + 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("-I", StringComparison.Ordinal) || token.Equals("-F", StringComparison.Ordinal) || token.Equals("-L", StringComparison.Ordinal) || @@ -186,6 +213,7 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke token.Equals("-load", StringComparison.Ordinal) || token.Equals("-plugin", StringComparison.Ordinal) || token.Equals("-plugin-path", StringComparison.Ordinal) || + token.Equals("-load-plugin-library", StringComparison.Ordinal) || token.Equals("-module-map-file", StringComparison.Ordinal) || token.Equals("-fmodule-map-file", StringComparison.Ordinal) || token.Equals("-fmodule-file", StringComparison.Ordinal) || @@ -240,6 +268,7 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke "-include-pch=", "-include-pth=", "-gcc-toolchain=", "-resource-dir=", "-iwithprefix", "-fprofile-use=", "-fmodule-file=", "-module-map-file=", "-ivfsoverlay=", "-vfsoverlay=", "-plugin-path=", "-fpass-plugin=", + "-load-plugin-library=", "-iframework", "-idirafter", "-imacros=", "-include=", "--sysroot=", "-isystem-after", "-isystem", "-iquote", "-iprefix", "-fplugin=", "-isysroot=", "-sdk=", "-I", "-F", "-L", "-B" @@ -384,6 +413,34 @@ private static void ValidatePreprocessorFlagPayload(string payload, string key) 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."); + } + } + + 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[] ExpandForwardedBuildFlagTokens(string[] tokens, string key) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index babc316f2..fca1e3b08 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -166,6 +166,21 @@ private bool IsApprovedAngledInclude(string repositoryRoot, string sourcePath, s private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, string sourcePath, string source) { var syntax = MaskCStringAndCharacterLiterals(source); + 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, + "(? ApprovedToolchainHeaders = new(StringComparer.Ordinal) { "assert.h", "complex.h", "ctype.h", "errno.h", "fenv.h", "float.h", "inttypes.h", "iso646.h", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 473e96801..ca8a4ac0d 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -184,8 +184,9 @@ private void ValidateInfoPlistBuildSettingReferences( var logical = RemoveCComments(SpliceCPreprocessingLines(contents)); if (Regex.IsMatch( logical, - "(?m)^[ \\t]*(?:#|%:)[ \\t]*(?:include|include_next|import|embed)(?![A-Za-z0-9_])|(?\"; };"); + 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); + } +} From 2c5df34675f1afbf01717fe526a68f0ce8a48f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 02:25:20 +0200 Subject: [PATCH 41/78] Close Apple config export and stapler integrity gaps --- .../AppleArchiveBuildSnapshotTests.cs | 78 +++++++++++++++++ ...NotarizationServiceTests.StapleBoundary.cs | 60 +++++++++++++ PowerForge/Abstractions/IProcessRunner.cs | 20 ++++- .../Services/AppleArchiveBuildSnapshot.cs | 14 +-- PowerForge/Services/AppleArtifactCopy.cs | 38 ++++++++ .../Services/AppleDirectExportSnapshot.cs | 49 +++++++++-- .../Services/AppleNotarizationService.cs | 62 ++++++++++--- .../AppleReleaseSourceMutationMonitor.cs | 24 ++++++ ...pleReleaseSourceTrustService.BuildFlags.cs | 11 +++ ...eleaseSourceTrustService.SourceIncludes.cs | 24 +++++- .../AppleReleaseSourceTrustService.Xcode.cs | 3 +- ...eReleaseSourceTrustService.XcodeParsing.cs | 6 ++ ....AppleSourceTrustCurrentHeadRegressions.cs | 86 +++++++++++++++++++ 13 files changed, 445 insertions(+), 30 deletions(-) create mode 100644 PowerForge.Tests/AppleNotarizationServiceTests.StapleBoundary.cs diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index 1a90f9825..68187a341 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -112,4 +112,82 @@ public void RollbackPublication_preserves_concurrently_replaced_archive_and_prev 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.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/Abstractions/IProcessRunner.cs b/PowerForge/Abstractions/IProcessRunner.cs index 61e06b106..b335f10b5 100644 --- a/PowerForge/Abstractions/IProcessRunner.cs +++ b/PowerForge/Abstractions/IProcessRunner.cs @@ -8,6 +8,8 @@ namespace PowerForge; /// public sealed class ProcessRunRequest { + private int _completionBoundaryInvoked; + private Action? _completionBoundary; /// /// Initializes a new instance of the class. /// @@ -192,6 +194,16 @@ 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 InvokeCompletionBoundary(ProcessRunResult result) + { + if (_completionBoundary is null || Interlocked.Exchange(ref _completionBoundaryInvoked, 1) != 0) + return; + _completionBoundary(result); + } } /// @@ -301,7 +313,9 @@ public async Task RunAsync(ProcessRunRequest request, Cancella 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 @@ -344,7 +358,9 @@ public async Task RunAsync(ProcessRunRequest request, Cancella 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( diff --git a/PowerForge/Services/AppleArchiveBuildSnapshot.cs b/PowerForge/Services/AppleArchiveBuildSnapshot.cs index b26330e96..629272fc5 100644 --- a/PowerForge/Services/AppleArchiveBuildSnapshot.cs +++ b/PowerForge/Services/AppleArchiveBuildSnapshot.cs @@ -111,13 +111,13 @@ internal static void RollbackPublication( bool movedExisting) { if (published && Directory.Exists(destination)) { - Directory.Move(destination, rollbackCandidate); - var rollbackSha256 = AppleNotarizationService.ComputeArtifactSha256(rollbackCandidate); - if (rollbackSha256.Equals(publishedSha256, StringComparison.OrdinalIgnoreCase)) { - Directory.Delete(rollbackCandidate, recursive: true); - } else { - if (!Directory.Exists(destination) && !File.Exists(destination)) - Directory.Move(rollbackCandidate, 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."); diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs index 594b6c178..67a6b1442 100644 --- a/PowerForge/Services/AppleArtifactCopy.cs +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -87,4 +87,42 @@ internal static void RestoreDirectoryBackup(string destinationPath, string backu } Directory.Move(backupPath, destinationPath); } + + /// + /// 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) + { + Directory.Move(destinationPath, quarantinePath); + try + { + var attributes = File.GetAttributes(quarantinePath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"{artifactDescription} rollback found a linked replacement at '{destinationPath}'."); + } + + var observedSha256 = AppleNotarizationService.ComputeArtifactSha256(quarantinePath); + if (!observedSha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{artifactDescription} rollback found replacement bytes at '{destinationPath}'."); + } + + Directory.Delete(quarantinePath, recursive: true); + } + catch + { + if (!Directory.Exists(destinationPath) && !File.Exists(destinationPath)) + Directory.Move(quarantinePath, destinationPath); + throw; + } + } } diff --git a/PowerForge/Services/AppleDirectExportSnapshot.cs b/PowerForge/Services/AppleDirectExportSnapshot.cs index 01c354b95..347694783 100644 --- a/PowerForge/Services/AppleDirectExportSnapshot.cs +++ b/PowerForge/Services/AppleDirectExportSnapshot.cs @@ -70,6 +70,7 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) $"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) @@ -84,11 +85,18 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) var name = Path.GetFileName(destination); var stage = Path.Combine(parent, $".{name}.powerforge-stage-{Guid.NewGuid():N}"); var backup = Path.Combine(parent, $".{name}.powerforge-backup-{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)) @@ -96,7 +104,6 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) throw new InvalidOperationException( $"The staged Developer ID export changed during publication. Expected '{sourceArtifactSha256}', received '{stagedSha256}'."); } - if (Directory.Exists(destination)) { Directory.Move(destination, backup); @@ -112,6 +119,12 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) 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 && Directory.Exists(backup)) Directory.Delete(backup, recursive: true); return new ApplePublishedDirectExport(destination, publishedArtifact, publishedSha256); @@ -120,10 +133,7 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) { try { - if (published && Directory.Exists(destination)) - Directory.Delete(destination, recursive: true); - if (movedExisting) - AppleArtifactCopy.RestoreDirectoryBackup(destination, backup); + RollbackPublication(destination, backup, rollbackCandidate, sourceExportSha256, published, movedExisting); } catch (Exception rollbackException) { @@ -141,6 +151,35 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) } } + 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); diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 65da184b0..1a2611535 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -203,7 +203,17 @@ public async Task NotarizeAsync( 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 + ? new AppleReleaseSourceMutationMonitor( + submissionSnapshot.RootPath, + "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.") + : null; if (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Staple) { if (staplingCompleted) @@ -218,17 +228,26 @@ public async Task NotarizeAsync( } else { - staple = await RunAsync(xcrunExecutable, submissionArtifactPath, new[] { "stapler", "staple", submissionArtifactPath }, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); + 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; } } - using var postStapleMonitor = staple?.Succeeded == true - ? new AppleReleaseSourceMutationMonitor( - submissionSnapshot.RootPath, - "validated private Apple notarization artifact", - "stapler validation, Gatekeeper assessment, and final publication", - "Discard the private artifact and resume from the last durable notarization checkpoint.") - : null; if (staple?.Succeeded == true) { validation = await RunAsync( @@ -240,7 +259,16 @@ public async Task NotarizeAsync( 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 (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Assess) { @@ -387,15 +415,16 @@ 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, IReadOnlyDictionary? environmentVariables, - CancellationToken cancellationToken) - => _processRunner.RunAsync( - new ProcessRunRequest( + CancellationToken cancellationToken, + Action? completionBoundary = null) + { + var processRequest = new ProcessRunRequest( executable, Path.GetDirectoryName(artifactPath) ?? Directory.GetCurrentDirectory(), arguments, @@ -403,8 +432,13 @@ private Task RunAsync( environmentVariables, captureOutput: true, captureError: true, - inheritEnvironment: environmentVariables is null), - cancellationToken); + 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 ResolveAppleToolExecutable( string? executable, diff --git a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs index c447e015a..3fe0d3de6 100644 --- a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs +++ b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs @@ -57,6 +57,30 @@ internal void ValidateNoChanges() { } } + internal T CaptureExpectedProducerOutput(Func capture, string producerDescription) { + if (capture is null) + throw new ArgumentNullException(nameof(capture)); + Thread.Sleep(250); + if (_watcherError is not null) { + throw new InvalidOperationException( + $"The {_scopeDescription} mutation monitor failed at the {producerDescription} completion boundary. {_failureInstruction}", + _watcherError); + } + + // All events observed before this boundary belong to the awaited producer. + // Keep the watcher active, reset its event baseline, and capture the output + // before the process runner returns control to the caller. + Interlocked.Exchange(ref _firstMutation, null); + var output = capture(); + Thread.Sleep(250); + if (_watcherError is not null || !string.IsNullOrWhiteSpace(_firstMutation)) { + throw new InvalidOperationException( + $"The {_scopeDescription} changed while its {producerDescription} output was being bound. {_failureInstruction}", + _watcherError); + } + return output; + } + private void OnMutation(object sender, FileSystemEventArgs args) => Interlocked.CompareExchange(ref _firstMutation, args.FullPath, null); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 201fbec67..dc5daf2e5 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -161,6 +161,17 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke 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("-load-plugin-executable", StringComparison.Ordinal)) { if (++index >= tokens.Length) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index fca1e3b08..31dfee8c5 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -283,7 +283,7 @@ private void ValidateAssemblerInputs(string repositoryRoot, string sourcePath, s private void ValidateAssemblerDirectives(string repositoryRoot, string sourcePath, string source) { - + source = NormalizeAssemblerStatementBoundaries(source); foreach (Match directive in Regex.Matches( source, "(?im)^[ \\t]*(?:(?:[A-Za-z_.$][A-Za-z0-9_.$]*|[0-9]+):[ \\t]*)*\\.(?include|incbin)(?![A-Za-z0-9_])[ \\t]+(?[^\\r\\n]+)", @@ -319,6 +319,28 @@ private void ValidateAssemblerDirectives(string repositoryRoot, string sourcePat } } + 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); + } + private static void ValidateSwiftSourceDeterminism(string sourcePath) { var contents = File.ReadAllText(sourcePath); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 616d5dae9..00fb2a476 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -37,7 +37,8 @@ internal sealed partial class AppleReleaseSourceTrustService "OTHER_CFLAGS", "OTHER_CPLUSPLUSFLAGS", "OTHER_LDFLAGS", - "OTHER_SWIFT_FLAGS" + "OTHER_SWIFT_FLAGS", + "INFOPLIST_OTHER_PREPROCESSOR_FLAGS" }; private static readonly HashSet DefinitionBuildSettings = new(StringComparer.OrdinalIgnoreCase) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index ca8a4ac0d..7a403ccbd 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -53,6 +53,12 @@ private void ValidateBuildSettingAssignments( } else if (FlagBuildSettings.Contains(baseKey)) { + if (baseKey.Equals("INFOPLIST_OTHER_PREPROCESSOR_FLAGS", StringComparison.OrdinalIgnoreCase) && + !preprocessInfoPlist) + { + ValidateUnclassifiedBuildSettingReferences(key, assignment.Value, source); + continue; + } ValidateBuildFlagInputPaths( repositoryRoot, projectDirectory, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs index beafe7c8e..32ce4527c 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs @@ -112,4 +112,90 @@ public void ResolveExactAppleSourceCommit_classifies_swift_external_plugin_serve 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")] + 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_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); + } } From 594fb61dd9d882517ee2ff7d5bcd8c7cc0f184d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 03:13:53 +0200 Subject: [PATCH 42/78] Close Apple compiler and module source-trust gaps --- ...easeSourceTrustService.BuildFlagOptions.cs | 63 ++++++ ...pleReleaseSourceTrustService.BuildFlags.cs | 90 ++------ ...ReleaseSourceTrustService.CFamilyLexing.cs | 186 ++++++++++++++++ ...eleaseSourceTrustService.SourceIncludes.cs | 198 ++++++------------ ....AppleSourceTrustCurrentHeadRegressions.cs | 173 +++++++++++++++ 5 files changed, 503 insertions(+), 207 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs new file mode 100644 index 000000000..07fe694da --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -0,0 +1,63 @@ +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) + { + "--cuda-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", + "--ptxas-path", "--rocm-device-lib-path", "--rocm-path", "--source-metadata-list", + "--sysroot", "--warning-suppression-mappings", + "-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", "-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-list", + "-fprofile-remapping-file", "-fprofile-sample-use", "-fprofile-use", + "-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", "-fxray-attr-list", "-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", "-ivfsoverlay", "-ivfsstatcache", "-iwithprefix", "-iwithprefixbefore", + "-iwithsysroot", "-load", "-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", "-vfsoverlay", "-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=", "--hipstdpar-thrust-path=", + "--hipstdpar-prim-path=", "--hipspv-pass-plugin=", "--source-metadata-list=", + "--hipstdpar-path=", "--cuda-path=", "--hip-path=", "--ptxas-path=", "--rocm-path=", "--sysroot=", + "-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=", "-frandomize-layout-seed-file=", + "-fsanitize-system-blacklist=", "-fsanitize-system-ignorelist=", + "-fprofile-remapping-file=", "-fmodules-user-build-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=", "-gen-cdb-fragment-path=", "-index-unit-output-path=", + "-load-plugin-library=", "-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-attr-list=", "-include-pch=", + "-include-pth=", "-gcc-toolchain=", "-resource-dir=", "-ivfsoverlay=", + "-ivfsstatcache=", "-vfsoverlay=", "-plugin-path=", "-fplugin=", "-force_load=", + "-stdlib++-isystem", "-iframeworkwithsysroot", "-iwithprefixbefore", "-cxx-isystem", + "-iwithsysroot", "-iwithprefix", "-isystem-after", "-iframework", "-idirafter", + "-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 index dc5daf2e5..8fcf64294 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -199,59 +199,7 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke continue; } - if (token.Equals("-I", StringComparison.Ordinal) || - token.Equals("-F", StringComparison.Ordinal) || - token.Equals("-L", StringComparison.Ordinal) || - token.Equals("-B", StringComparison.Ordinal) || - token.Equals("-include", StringComparison.Ordinal) || - token.Equals("-imacros", StringComparison.Ordinal) || - token.Equals("-include-pch", StringComparison.Ordinal) || - token.Equals("-include-pth", StringComparison.Ordinal) || - token.Equals("-iquote", StringComparison.Ordinal) || - token.Equals("-isystem", StringComparison.Ordinal) || - token.Equals("-isystem-after", StringComparison.Ordinal) || - token.Equals("-idirafter", StringComparison.Ordinal) || - token.Equals("-iframework", StringComparison.Ordinal) || - token.Equals("-iframeworkwithsysroot", StringComparison.Ordinal) || - token.Equals("-iprefix", StringComparison.Ordinal) || - token.Equals("-iwithprefix", StringComparison.Ordinal) || - token.Equals("-iwithprefixbefore", StringComparison.Ordinal) || - token.Equals("-force_load", StringComparison.Ordinal) || - token.Equals("-ivfsoverlay", StringComparison.Ordinal) || - token.Equals("-vfsoverlay", StringComparison.Ordinal) || - token.Equals("-fplugin", StringComparison.Ordinal) || - token.Equals("-fpass-plugin", StringComparison.Ordinal) || - token.Equals("-load", StringComparison.Ordinal) || - token.Equals("-plugin", StringComparison.Ordinal) || - token.Equals("-plugin-path", StringComparison.Ordinal) || - token.Equals("-load-plugin-library", StringComparison.Ordinal) || - token.Equals("-module-map-file", StringComparison.Ordinal) || - token.Equals("-fmodule-map-file", StringComparison.Ordinal) || - token.Equals("-fmodule-file", StringComparison.Ordinal) || - token.Equals("-fprebuilt-module-path", StringComparison.Ordinal) || - token.Equals("-fprofile-use", StringComparison.Ordinal) || - token.Equals("-fprofile-instr-use", StringComparison.Ordinal) || - token.Equals("-fprofile-sample-use", StringComparison.Ordinal) || - token.Equals("-fprofile-list", StringComparison.Ordinal) || - token.Equals("-fprofile-remapping-file", StringComparison.Ordinal) || - token.Equals("-profile-use", StringComparison.Ordinal) || - token.Equals("-profile-sample-use", StringComparison.Ordinal) || - token.Equals("-fsanitize-ignorelist", StringComparison.Ordinal) || - token.Equals("-fsanitize-blacklist", StringComparison.Ordinal) || - token.Equals("-fsanitize-system-ignorelist", StringComparison.Ordinal) || - token.Equals("-fsanitize-system-blacklist", StringComparison.Ordinal) || - token.Equals("-fsanitize-coverage-allowlist", StringComparison.Ordinal) || - token.Equals("-fsanitize-coverage-ignorelist", StringComparison.Ordinal) || - token.Equals("-fsanitize-coverage-whitelist", StringComparison.Ordinal) || - token.Equals("-fsanitize-coverage-blacklist", StringComparison.Ordinal) || - token.Equals("-resource-dir", StringComparison.Ordinal) || - token.Equals("-working-directory", StringComparison.Ordinal) || - token.Equals("-gcc-toolchain", StringComparison.Ordinal) || - token.Equals("--sysroot", StringComparison.Ordinal) || - token.Equals("-install_name", StringComparison.Ordinal) || - token.Equals("-rpath", StringComparison.Ordinal) || - token.Equals("-isysroot", StringComparison.Ordinal) || - token.Equals("-sdk", StringComparison.Ordinal)) + if (BuildFlagPathOptions.Contains(token)) { consumeNext = true; continue; @@ -265,26 +213,7 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke continue; } - var prefixes = new[] - { - "-iframeworkwithsysroot", "-iwithprefixbefore", "-fprofile-instr-use=", - "-fprofile-sample-use=", "-fprofile-remapping-file=", "-fprofile-list=", - "-profile-sample-use=", "-profile-use=", - "-fmodule-map-file=", "-working-directory=", - "-fsanitize-system-ignorelist=", "-fsanitize-system-blacklist=", - "-fsanitize-coverage-allowlist=", "-fsanitize-coverage-ignorelist=", - "-fsanitize-coverage-whitelist=", "-fsanitize-coverage-blacklist=", - "-fsanitize-ignorelist=", "-fsanitize-blacklist=", - "-fprebuilt-module-path=", - "-include-pch=", "-include-pth=", "-gcc-toolchain=", "-resource-dir=", - "-iwithprefix", "-fprofile-use=", "-fmodule-file=", "-module-map-file=", - "-ivfsoverlay=", "-vfsoverlay=", "-plugin-path=", "-fpass-plugin=", - "-load-plugin-library=", - "-iframework", "-idirafter", "-imacros=", "-include=", "--sysroot=", - "-isystem-after", "-isystem", "-iquote", "-iprefix", "-fplugin=", "-isysroot=", "-sdk=", - "-I", "-F", "-L", "-B" - }; - var prefix = prefixes.FirstOrDefault(candidate => + var prefix = BuildFlagPathPrefixes.FirstOrDefault(candidate => token.StartsWith(candidate, StringComparison.Ordinal) && token.Length > candidate.Length); if (prefix is not null) { @@ -463,7 +392,10 @@ private static string[] ExpandForwardedBuildFlagTokens(string[] tokens, string k if (token.Equals("-Xcc", StringComparison.Ordinal) || token.Equals("-Xlinker", StringComparison.Ordinal) || token.Equals("-Xfrontend", StringComparison.Ordinal) || - token.Equals("-Xswiftc", 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."); @@ -471,13 +403,19 @@ private static string[] ExpandForwardedBuildFlagTokens(string[] tokens, string k continue; } - if (token.StartsWith("-Wl,", StringComparison.Ordinal)) + 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=" }) + foreach (var wrapper in new[] + { + "-Xcc=", "-Xlinker=", "-Xfrontend=", "-Xswiftc=", + "-Xassembler=", "-Xpreprocessor=", "-Xclang=" + }) { if (!token.StartsWith(wrapper, StringComparison.Ordinal)) continue; diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs new file mode 100644 index 000000000..c5e8e7b00 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs @@ -0,0 +1,186 @@ +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 == '/') + { + result.Append(" "); + index++; + inBlockComment = false; + } + else + { + result.Append(current == '\r' || current == '\n' ? current : ' '); + } + 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 == '*') + { + 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.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 31dfee8c5..e629af36e 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -64,6 +64,7 @@ private void ValidateSourceLevelIncludes( throw new InvalidOperationException( $"Source input '{fullSourcePath}' uses nondeterministic compiler macro '{nondeterministicMacro}', which cannot be bound to one reproducible source commit."); } + ValidateLanguageModuleImports(fullSourcePath, source); foreach (Match directive in Regex.Matches( source, "(?m)^[ \\t]*(?:#|%:)[ \\t]*(?:include|include_next|import)[ \\t]+(?[^\\r\\n]+)", @@ -259,10 +260,75 @@ private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, stri "HealthKit", "HomeKit", "ImageIO", "IOKit", "LocalAuthentication", "MapKit", "Metal", "MetalKit", "Network", "NetworkExtension", "OSLog", "PassKit", "Photos", "QuartzCore", "SafariServices", "Security", "StoreKit", "SystemConfiguration", "UIKit", "UniformTypeIdentifiers", "UserNotifications", "VideoToolbox", - "WatchKit", "WebKit", "arpa", "dispatch", "libkern", "mach", "mach-o", "net", "netinet", "os", "simd", + "WatchKit", "WebKit", "ObjectiveC", "arpa", "dispatch", "libkern", "mach", "mach-o", "net", "netinet", "os", "simd", "sys", "xpc" }; + private static void ValidateLanguageModuleImports(string sourcePath, string source) + { + var syntax = MaskCStringAndCharacterLiterals(source); + foreach (Match import in Regex.Matches( + syntax, + "(?[A-Za-z_][A-Za-z0-9_.]*)[ \\t]*;", + 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]*(?:#|%:)[ \\t]*pragma[ \\t]+clang[ \\t]+module[ \\t]+import[ \\t]+(?[A-Za-z_][A-Za-z0-9_.]*)", + RegexOptions.CultureInvariant)) + { + RejectUnapprovedLanguageModule(sourcePath, import.Groups["module"].Value, "Clang pragma"); + } + + var extension = Path.GetExtension(sourcePath); + if (!extension.Equals(".cc", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".cpp", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".cxx", 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[ \\t]+)?import[ \\t]+(?[^;\\r\\n]+)[ \\t]*;", + 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 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); @@ -445,134 +511,4 @@ private void ValidateClangModuleMapInputs(string repositoryRoot, string moduleMa return null; } - private static string MaskCStringAndCharacterLiterals(string source) - { - var result = new System.Text.StringBuilder(source.Length); - var quote = '\0'; - var escaped = false; - foreach (var current in source) - { - if (quote == '\0') - { - 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 == '/') - { - result.Append(" "); - index++; - inBlockComment = false; - } - else - { - result.Append(current == '\r' || current == '\n' ? current : ' '); - } - continue; - } - if (quote != '\0') - { - result.Append(current); - if (escaped) - escaped = false; - else if (current == '\\') - escaped = true; - else if (current == quote) - quote = '\0'; - continue; - } - if (current == '/' && next == '/') - { - result.Append(" "); - index++; - inLineComment = true; - continue; - } - if (current == '/' && next == '*') - { - result.Append(" "); - index++; - inBlockComment = true; - continue; - } - if (current == '"' || current == '\'') - quote = current; - result.Append(current); - } - return result.ToString(); - } } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs index 32ce4527c..e08cde0d2 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs @@ -198,4 +198,177 @@ public void ResolveExactAppleSourceCommit_ignores_inactive_info_plist_preprocess 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("-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); + } + + 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: ".")); + } } From 98e96660293be3a012b6d015f3e7ec4113346370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 03:53:12 +0200 Subject: [PATCH 43/78] Bind Apple composite inputs and nested screenshots --- ...entTests.ScreenshotIntegrityRegressions.cs | 61 ++++++++ .../AppStoreConnectScreenshotFileSelector.cs | 43 ++++++ ...oreConnectScreenshotSyncConfigValidator.cs | 5 +- .../AppStoreConnectScreenshotSyncService.cs | 43 ++---- ...ppleReleaseSourceTrustService.Assembler.cs | 136 ++++++++++++++++++ ...pleReleaseSourceTrustService.BuildFlags.cs | 49 ++++--- ...eSourceTrustService.CompositeBuildFlags.cs | 48 +++++++ ...eleaseSourceTrustService.SourceIncludes.cs | 69 --------- ...ceTests.AppleSourceTrustCompositeInputs.cs | 122 ++++++++++++++++ 9 files changed, 453 insertions(+), 123 deletions(-) create mode 100644 PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs diff --git a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs index fa76cd5bd..f261b3e0a 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs @@ -128,6 +128,67 @@ public async Task ScreenshotSyncService_PreservesSubdirectoryFiltersInApprovedIn } } + [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() { diff --git a/PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs b/PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs new file mode 100644 index 000000000..05482555f --- /dev/null +++ b/PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs @@ -0,0 +1,43 @@ +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) + => 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)) + .OrderBy(static item => item.Path, StringComparer.OrdinalIgnoreCase) + .Take(maxCount) + .Select(static item => item.Path) + .ToArray(); + + internal static string? GetRelativePath(string folder, string screenshotPath) + { + var relative = FrameworkCompatibility.GetRelativePath(folder, Path.GetFullPath(screenshotPath)); + if (Path.IsPathRooted(relative) || + relative.Equals("..", StringComparison.Ordinal) || + relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + return null; + } + return relative.Replace('\\', '/'); + } + + internal static bool MatchesFilter(string relativePath, string filter) + { + var normalizedFilter = filter.Replace('\\', '/'); + var expression = "^" + System.Text.RegularExpressions.Regex.Escape(normalizedFilter) + .Replace("\\*", "[^/]*") + .Replace("\\?", "[^/]") + "$"; + var options = System.Text.RegularExpressions.RegexOptions.CultureInvariant; + if (Path.DirectorySeparatorChar == '\\') + options |= System.Text.RegularExpressions.RegexOptions.IgnoreCase; + return System.Text.RegularExpressions.Regex.IsMatch(relativePath, expression, options); + } +} diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs index c1be2ed2c..b7bf55d79 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs @@ -192,10 +192,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) diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index 74f6aedc9..8de919434 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -343,7 +343,15 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( } consumedApprovedFiles.Add(source); - var snapshotPath = Path.Combine(setRoot, Path.GetFileName(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); var actualSha256 = ComputeSha256(snapshotPath); if (approvedScreenshots is not null) @@ -399,10 +407,10 @@ private static Dictionary SelectApprovedScreenshotFiles( .Select(item => new { Item = item, - RelativePath = GetRelativeScreenshotPath(set.Folder, item.Key) + RelativePath = AppStoreConnectScreenshotFileSelector.GetRelativePath(set.Folder, item.Key) }) .Where(static item => item.RelativePath is not null) - .Where(item => MatchesScreenshotFilter(item.RelativePath!, set.Filter)) + .Where(item => AppStoreConnectScreenshotFileSelector.MatchesFilter(item.RelativePath!, set.Filter)) .OrderBy(static item => item.Item.Key, StringComparer.OrdinalIgnoreCase) .Take(set.MaxCount); foreach (var item in approved) @@ -411,30 +419,6 @@ private static Dictionary SelectApprovedScreenshotFiles( return selected; } - private static string? GetRelativeScreenshotPath(string folder, string screenshotPath) - { - var relative = FrameworkCompatibility.GetRelativePath(folder, Path.GetFullPath(screenshotPath)); - if (Path.IsPathRooted(relative) || - relative.Equals("..", StringComparison.Ordinal) || - relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) - { - return null; - } - return relative.Replace('\\', '/'); - } - - private static bool MatchesScreenshotFilter(string relativePath, string filter) - { - var normalizedFilter = filter.Replace('\\', '/'); - var expression = "^" + System.Text.RegularExpressions.Regex.Escape(normalizedFilter) - .Replace("\\*", "[^/]*") - .Replace("\\?", "[^/]") + "$"; - var options = System.Text.RegularExpressions.RegexOptions.CultureInvariant; - if (Path.DirectorySeparatorChar == '\\') - options |= System.Text.RegularExpressions.RegexOptions.IgnoreCase; - return System.Text.RegularExpressions.Regex.IsMatch(relativePath, expression, options); - } - private static string ComputeSha256(string filePath) { using var stream = File.OpenRead(filePath); @@ -487,10 +471,7 @@ 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}'."); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs new file mode 100644 index 000000000..0825017c8 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs @@ -0,0 +1,136 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + private void ValidateAssemblerInputs(string repositoryRoot, string sourcePath, string source) + { + var fullSourcePath = Path.GetFullPath(sourcePath); + if (!_validatedAssemblerInputFiles.Add(fullSourcePath)) + return; + + RejectUnboundAssemblerPreprocessorMacros(fullSourcePath, source); + ValidateAssemblerDirectives(repositoryRoot, fullSourcePath, source); + } + + 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 = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourcePath)!, input)); + EnsurePathWithinRepository(repositoryRoot, candidate, $"assembler .{directive.Groups["kind"].Value} input from {sourcePath}"); + if (!File.Exists(candidate)) + throw new FileNotFoundException($"Assembler input was not found inside the exact checked-out source: {candidate}", candidate); + 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); + } + } + } + + 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.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 8fcf64294..7285c6185 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -198,6 +198,32 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke 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 (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 (BuildFlagPathOptions.Contains(token)) { @@ -359,28 +385,13 @@ private static void ValidatePreprocessorFlagPayload(string payload, string key) throw new InvalidOperationException( $"Xcode build setting {key} supplies nondeterministic compiler identifier '{nondeterministicIdentifier}' through a preprocessor definition or undefinition."); } - } - - 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) + 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} contains malformed Swift external plugin path '{value}'. Expected #."); + $"Xcode build setting {key} supplies a preprocessor definition that can construct a file-consuming assembler directive: {payload}"); } - return new[] { value.Substring(0, separator), value.Substring(separator + 1) }; } private static string[] ExpandForwardedBuildFlagTokens(string[] tokens, string key) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs new file mode 100644 index 000000000..186912de8 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs @@ -0,0 +1,48 @@ +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 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); + } +} diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index e629af36e..2ed444295 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -338,75 +338,6 @@ private static void RejectCTrigraphs(string source, string sourcePath) $"Source input '{sourcePath}' uses C trigraph '{trigraph.Value}', whose translation can change preprocessing semantics before exact-source validation."); } - private void ValidateAssemblerInputs(string repositoryRoot, string sourcePath, string source) - { - var fullSourcePath = Path.GetFullPath(sourcePath); - if (!_validatedAssemblerInputFiles.Add(fullSourcePath)) - return; - - ValidateAssemblerDirectives(repositoryRoot, fullSourcePath, source); - } - - private void ValidateAssemblerDirectives(string repositoryRoot, string sourcePath, string source) - { - source = NormalizeAssemblerStatementBoundaries(source); - foreach (Match directive in Regex.Matches( - source, - "(?im)^[ \\t]*(?:(?:[A-Za-z_.$][A-Za-z0-9_.$]*|[0-9]+):[ \\t]*)*\\.(?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 = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourcePath)!, input)); - EnsurePathWithinRepository(repositoryRoot, candidate, $"assembler .{directive.Groups["kind"].Value} input from {sourcePath}"); - if (!File.Exists(candidate)) - throw new FileNotFoundException($"Assembler input was not found inside the exact checked-out source: {candidate}", candidate); - 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); - } - } - } - - 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); - } - private static void ValidateSwiftSourceDeterminism(string sourcePath) { var contents = File.ReadAllText(sourcePath); diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs new file mode 100644 index 000000000..af4d95d35 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs @@ -0,0 +1,122 @@ +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("#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); + } +} From 5962cb14f6365ab90708527743353efed0b8c77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 04:33:14 +0200 Subject: [PATCH 44/78] Bind Apple archive and rollback integrity --- ...eServiceTests.ArchiveCompletionBoundary.cs | 47 ++++++ ...rizationServiceTests.PackagingIntegrity.cs | 149 ++++++++++++++++++ ...aseWorkflowTests.RunnerLocalCredentials.cs | 1 + PowerForge/Services/AppleAppArchiveService.cs | 26 ++- PowerForge/Services/AppleArtifactCopy.cs | 78 ++++++++- .../AppleNotarizationInputSnapshot.cs | 45 +++++- ...pleReleaseSourceTrustService.BuildFlags.cs | 14 ++ ...eSourceTrustService.CompositeBuildFlags.cs | 11 ++ ...ceTests.AppleSourceTrustCompositeInputs.cs | 24 +++ scripts/Invoke-PinnedPowerForge.ps1 | 2 +- 10 files changed, 375 insertions(+), 22 deletions(-) create mode 100644 PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs new file mode 100644 index 000000000..31584d52d --- /dev/null +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs @@ -0,0 +1,47 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleAppArchiveServiceTests +{ + [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("changed after xcodebuild completed", 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); + } + } +} diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs index efdb26e00..4d40e27fc 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs @@ -54,6 +54,155 @@ public async Task NotarizeAsync_RejectsTransientAppMutationWhileDittoCreatesSubm } } + [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) 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/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index 4940283bb..ff3815109 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -83,6 +83,7 @@ public async Task CreateArchiveAsync( AppleSwiftPackageBuildSnapshot? packageSnapshot = null; ProcessRunResult result; + string? archiveSha256 = null; try { var timeout = request.Timeout <= TimeSpan.Zero ? TimeSpan.FromHours(1) : request.Timeout; @@ -101,8 +102,7 @@ public async Task CreateArchiveAsync( packageSnapshot.AppendArchiveArguments(args); } - result = await _processRunner.RunAsync( - new ProcessRunRequest( + var processRequest = new ProcessRunRequest( xcodeBuildExecutable, Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), args, @@ -110,18 +110,30 @@ public async Task CreateArchiveAsync( packageSnapshot?.EnvironmentVariables, captureOutput: true, captureError: true, - inheritEnvironment: packageSnapshot is null), - cancellationToken).ConfigureAwait(false); + inheritEnvironment: packageSnapshot is null); + processRequest.SetCompletionBoundary(completionResult => + { + if (completionResult.Succeeded && Directory.Exists(archivePath)) + archiveSha256 = AppleNotarizationService.ComputeArtifactSha256(archivePath); + }); + result = await _processRunner.RunAsync(processRequest, cancellationToken).ConfigureAwait(false); + processRequest.InvokeCompletionBoundary(result); packageSnapshot?.ValidateUnchanged(); + if (!string.IsNullOrWhiteSpace(archiveSha256) && Directory.Exists(archivePath)) + { + var currentArchiveSha256 = AppleNotarizationService.ComputeArtifactSha256(archivePath); + if (!currentArchiveSha256.Equals(archiveSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The private Apple archive changed after xcodebuild completed. Expected '{archiveSha256}', received '{currentArchiveSha256}'."); + } + } } finally { packageSnapshot?.Dispose(); } - var archiveSha256 = result.Succeeded && Directory.Exists(archivePath) - ? AppleNotarizationService.ComputeArtifactSha256(archivePath) - : null; return new AppleAppArchiveResult { ArchivePath = archivePath, diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs index 67a6b1442..3270c9d55 100644 --- a/PowerForge/Services/AppleArtifactCopy.cs +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -98,31 +98,97 @@ internal static void RemovePublishedDirectoryIfUnchanged( 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) { - Directory.Move(destinationPath, quarantinePath); + var attributes = File.GetAttributes(destinationPath); + var isDirectory = (attributes & FileAttributes.Directory) != 0; + var quarantinedArtifactPath = quarantinePath; + if (isDirectory) + Directory.Move(destinationPath, quarantinePath); + else + { + try + { + Directory.CreateDirectory(quarantinePath); +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(quarantinePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#endif + quarantinedArtifactPath = Path.Combine(quarantinePath, Path.GetFileName(destinationPath)); + File.Move(destinationPath, quarantinedArtifactPath); + } + catch + { + if (Directory.Exists(quarantinePath) && !Directory.EnumerateFileSystemEntries(quarantinePath).Any()) + Directory.Delete(quarantinePath); + throw; + } + } try { - var attributes = File.GetAttributes(quarantinePath); - if ((attributes & FileAttributes.ReparsePoint) != 0) + 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(quarantinePath); + var observedSha256 = AppleNotarizationService.ComputeArtifactSha256(quarantinedArtifactPath); if (!observedSha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( $"{artifactDescription} rollback found replacement bytes at '{destinationPath}'."); } - Directory.Delete(quarantinePath, recursive: true); + if (isDirectory) + Directory.Delete(quarantinePath, recursive: true); + else + { + File.Delete(quarantinedArtifactPath); + Directory.Delete(quarantinePath); + } } catch { if (!Directory.Exists(destinationPath) && !File.Exists(destinationPath)) - Directory.Move(quarantinePath, destinationPath); + { + if (isDirectory) + Directory.Move(quarantinePath, destinationPath); + else + { + File.Move(quarantinedArtifactPath, destinationPath); + Directory.Delete(quarantinePath); + } + } throw; } } + + /// 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); + } } diff --git a/PowerForge/Services/AppleNotarizationInputSnapshot.cs b/PowerForge/Services/AppleNotarizationInputSnapshot.cs index d9fdb4f04..2d5267d55 100644 --- a/PowerForge/Services/AppleNotarizationInputSnapshot.cs +++ b/PowerForge/Services/AppleNotarizationInputSnapshot.cs @@ -61,6 +61,7 @@ internal string PublishTo(string destinationPath, string expectedSha256) var stageRoot = Path.Combine(parent, $".powerforge-stage-{Guid.NewGuid():N}"); var stage = Path.Combine(stageRoot, name); var backup = Path.Combine(parent, $".{name}.powerforge-backup-{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; @@ -115,16 +116,24 @@ internal string PublishTo(string destinationPath, string expectedSha256) TryDeletePath(backup); return publishedSha256; } - catch + catch (Exception publicationException) { - if (published) - DeletePath(destination); - if (movedExisting) + try + { + RollbackPublication( + destination, + backup, + rollbackCandidate, + sourceSha256, + published, + movedExisting); + } + catch (Exception rollbackException) { - if (Directory.Exists(backup)) - Directory.Move(backup, destination); - else if (File.Exists(backup)) - File.Move(backup, destination); + throw new AggregateException( + $"Apple notarization artifact publication failed and rollback could not complete. Recovery bytes are retained at '{backup}'.", + publicationException, + rollbackException); } throw; } @@ -134,6 +143,26 @@ internal string PublishTo(string destinationPath, string expectedSha256) } } + 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); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 7285c6185..5074285ef 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -212,6 +212,20 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke yield return path; 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) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs index 186912de8..393f0a0b8 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs @@ -45,4 +45,15 @@ private static string ReadDylibOverrideCurrentPath(string value, string key) } 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/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs index af4d95d35..c577350aa 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCompositeInputs.cs @@ -50,6 +50,30 @@ public void ResolveExactAppleSourceCommit_attests_current_dylib_override_path(st 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")] diff --git a/scripts/Invoke-PinnedPowerForge.ps1 b/scripts/Invoke-PinnedPowerForge.ps1 index a9b757150..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)] From 903718e27e51aa2ca7ca82ab85d2c9fb7f8d083a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 05:22:30 +0200 Subject: [PATCH 45/78] Harden final Apple source and notarization boundaries --- ...rizationServiceTests.PackagingIntegrity.cs | 45 ++++ .../Services/AppleNotarizationService.cs | 48 ++++- ...pleReleaseSourceTrustService.BuildFlags.cs | 58 ++++++ ...eleaseSourceTrustService.SourceIncludes.cs | 36 +++- .../AppleReleaseSourceTrustService.Xcode.cs | 104 ++------- ...eSourceTrustService.XcodeConfigurations.cs | 197 ++++++++++++++++++ ...eReleaseSourceTrustService.XcodeParsing.cs | 39 +++- ...SourceTrustService.XcodeSourceOwnership.cs | 114 +++++++++- .../AppleReleaseSourceTrustService.cs | 10 +- ...AppleSourceTrustLatestReviewRegressions.cs | 160 ++++++++++++++ 10 files changed, 695 insertions(+), 116 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.XcodeConfigurations.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs index 4d40e27fc..4ee439bb0 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs @@ -54,6 +54,35 @@ public async Task NotarizeAsync_RejectsTransientAppMutationWhileDittoCreatesSubm } } + [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 { } + } + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -225,4 +254,20 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo 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); + } + } } diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 1a2611535..2a337533c 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -88,17 +88,19 @@ public async Task NotarizeAsync( "ditto", "Discard the package and create a new notarization snapshot.") : null; - var submittedPath = resumed - ? artifactPath + 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(); @@ -112,7 +114,7 @@ public async Task NotarizeAsync( } var submissionSha256 = resumed ? request.AcceptedSubmissionSha256?.Trim() - : ComputeFileSha256(submittedPath); + : preparedSubmission.Sha256; ProcessRunResult submission; string? submissionId; string? status; @@ -339,32 +341,64 @@ 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 = 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."); + string? packagedSha256 = null; var package = await RunAsync( dittoExecutable, artifactPath, new[] { "-c", "-k", "--keepParent", artifactPath, submissionPath }, timeout, toolEnvironment, - cancellationToken).ConfigureAwait(false); + cancellationToken, + completionResult => + { + if (!completionResult.Succeeded) + return; + inputMonitor?.ValidateNoChanges(); + packagedSha256 = producerMonitor.CaptureExpectedProducerOutput( + () => ComputeFileSha256(submissionPath), + "ditto"); + }).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( diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 5074285ef..88b4a9b72 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -20,6 +20,12 @@ private void ValidateBuildFlagInputPaths( source, responseFiles) .ToArray(); + 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, @@ -49,6 +55,58 @@ private void ValidateBuildFlagInputPaths( } } + 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, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 2ed444295..92ec86fe4 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -14,20 +14,25 @@ private void ValidateSourceLevelIncludes( string repositoryRoot, string sourcePath, bool validateSwiftDeterminism = false, - string? sourceBlob = null) + string? sourceBlob = null, + string? effectiveSourceExtension = null) { - if (!SourceIncludeExtensions.Contains(Path.GetExtension(sourcePath))) + var sourceExtension = string.IsNullOrWhiteSpace(effectiveSourceExtension) + ? Path.GetExtension(sourcePath) + : effectiveSourceExtension!; + if (!SourceIncludeExtensions.Contains(sourceExtension)) return; var fullSourcePath = Path.GetFullPath(sourcePath); - var extension = Path.GetExtension(fullSourcePath); + var extension = sourceExtension; if (extension.Equals(".swift", StringComparison.OrdinalIgnoreCase) && !validateSwiftDeterminism) return; - if (!_validatedSourceIncludeFiles.Add(fullSourcePath)) + var validationKey = fullSourcePath + "|" + extension + "|" + validateSwiftDeterminism; + if (!_validatedSourceIncludeFiles.Add(validationKey)) return; if (!string.IsNullOrWhiteSpace(sourceBlob)) { var semanticPath = ResolveSourceSemanticPath(fullSourcePath); - var semanticKey = sourceBlob + "|" + semanticPath + "|" + validateSwiftDeterminism; + var semanticKey = sourceBlob + "|" + semanticPath + "|" + extension + "|" + validateSwiftDeterminism; if (!_validatedSourceSemanticInputs.Add(semanticKey)) return; } @@ -266,6 +271,27 @@ private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, stri private static void ValidateLanguageModuleImports(string sourcePath, string source) { + foreach (Match pragma in Regex.Matches( + source, + "(?(?:\\\\.|[^\\\"\\\\])*)\\\"[ \\t]*\\)", + RegexOptions.CultureInvariant)) + { + var payload = Regex.Unescape(pragma.Groups["payload"].Value); + var moduleImport = Regex.Match( + payload, + "^[ \\t]*clang[ \\t]+module[ \\t]+import[ \\t]+(?[A-Za-z_][A-Za-z0-9_.]*)[ \\t]*$", + RegexOptions.CultureInvariant); + if (moduleImport.Success) + RejectUnapprovedLanguageModule(sourcePath, moduleImport.Groups["module"].Value, "Clang _Pragma"); + } + if (Regex.IsMatch( + source, + "(? !string.IsNullOrWhiteSpace(value)) .Select(static value => value!.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries)[0]) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var shippingSources = ResolveShippingSourceOwnership(objects, metadataPath); + var shippingSources = ResolveShippingSourceOwnership( + repositoryRoot, + projectDirectory, + objects, + metadataPath, + generatedOutputPaths); var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); var validatedLocalPackageRoots = new HashSet(GetPathComparer()); @@ -405,93 +410,6 @@ private void ValidateBuildFileSettings( } } - private void ValidateBuildConfiguration( - string repositoryRoot, - string projectDirectory, - PbxObject item, - IReadOnlyDictionary objects, - IReadOnlyDictionary parents, - IDictionary cache, - string metadataPath, - IReadOnlyCollection generatedOutputPaths) - { - 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"); - EnsureTrackedXcconfigGraph( - repositoryRoot, - projectDirectory, - baseConfigurationPath, - generatedOutputPaths, - new HashSet(GetPathComparer())); - } - - var buildSettings = ReadPbxDictionary(item.Body, "buildSettings"); - if (buildSettings is null) - return; - ValidateBuildSettingAssignments( - repositoryRoot, - projectDirectory, - ReadPbxAssignments(buildSettings), - generatedOutputPaths, - "PBX build settings"); - } - - private void EnsureTrackedXcconfigGraph( - string repositoryRoot, - string projectDirectory, - string configPath, - IReadOnlyCollection generatedOutputPaths, - ISet visited) - { - var fullPath = Path.GetFullPath(configPath); - if (!visited.Add(fullPath)) - return; - var contents = File.ReadAllText(fullPath); - ValidateBuildSettingAssignments( - repositoryRoot, - projectDirectory, - ReadXcconfigAssignments(contents), - generatedOutputPaths, - $"xcconfig '{fullPath}'"); - 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); - } - } - private static void ValidateExternalXcodeBuildInput( PbxObject item, string metadataPath, @@ -585,17 +503,19 @@ private void ValidateResolvedProjectInput( { EnsureNoGeneratedOutputOverlap(candidate, generatedOutputPaths, $"Xcode {item.Isa} input"); } - var isShippingSource = shippingSources.FileReferences.Contains(item.Id) || + 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: false); - if (isShippingSource && Path.GetExtension(candidate).Equals(".swift", StringComparison.OrdinalIgnoreCase)) - ValidateSourceLevelIncludes(repositoryRoot, candidate, validateSwiftDeterminism: true); + validateSwiftDeterminism: effectiveSourceExtension?.Equals(".swift", StringComparison.OrdinalIgnoreCase) == true, + effectiveSourceExtension: effectiveSourceExtension); } else if (Directory.Exists(candidate)) { 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.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 7a403ccbd..20105dbb9 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -10,12 +10,12 @@ private void ValidateBuildSettingAssignments( string projectDirectory, IEnumerable> assignments, IReadOnlyCollection generatedOutputPaths, - string source) + string source, + bool? effectiveInfoPlistPreprocess = null) { var assignmentArray = assignments.ToArray(); - var preprocessInfoPlist = assignmentArray.Any(static assignment => - assignment.Key.Split('[')[0].Trim().Equals("INFOPLIST_PREPROCESS", StringComparison.OrdinalIgnoreCase) && - assignment.Value.Trim().Equals("YES", StringComparison.OrdinalIgnoreCase)); + var preprocessInfoPlist = effectiveInfoPlistPreprocess ?? + ResolveInfoPlistPreprocessSetting(assignmentArray) == true; foreach (var assignment in assignmentArray) { var key = assignment.Key.Trim(); @@ -110,6 +110,37 @@ private void ValidateBuildSettingAssignments( } } + 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, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs index 20ede429f..57d4f53cf 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeSourceOwnership.cs @@ -2,9 +2,12 @@ namespace PowerForge; internal sealed partial class AppleReleaseSourceTrustService { - private static ShippingSourceOwnership ResolveShippingSourceOwnership( + private ShippingSourceOwnership ResolveShippingSourceOwnership( + string repositoryRoot, + string projectDirectory, IReadOnlyDictionary objects, - string metadataPath) + string metadataPath, + IReadOnlyCollection generatedOutputPaths) { var sourcePhaseIds = new HashSet(StringComparer.OrdinalIgnoreCase); var synchronizedRoots = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -35,7 +38,7 @@ private static ShippingSourceOwnership ResolveShippingSourceOwnership( } } - var fileReferences = new HashSet(StringComparer.OrdinalIgnoreCase); + var fileReferences = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var phaseId in sourcePhaseIds) { var phase = objects[phaseId]; @@ -60,27 +63,126 @@ private static ShippingSourceOwnership ResolveShippingSourceOwnership( { throw new InvalidOperationException($"PBX sources build file '{buildFileId}' references invalid source file '{fileReference}': {metadataPath}"); } - fileReferences.Add(fileReference); + 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(ISet fileReferences, ISet synchronizedRoots) + internal ShippingSourceOwnership( + IReadOnlyDictionary fileReferences, + ISet synchronizedRoots) { FileReferences = fileReferences; SynchronizedRoots = synchronizedRoots; } - internal ISet FileReferences { get; } + 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 index edd2b60fa..b438974eb 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -482,7 +482,8 @@ private void EnsureTrackedFile( string path, string name, string? capturedWorktreeBlob = null, - bool validateSwiftDeterminism = false) + bool validateSwiftDeterminism = false, + string? effectiveSourceExtension = null) { var candidate = Path.GetFullPath(path); EnsurePathWithinRepository(repositoryRoot, candidate, name); @@ -508,7 +509,12 @@ private void EnsureTrackedFile( var worktreeBlob = capturedWorktreeBlob ?? ComputeRawGitBlobId(repositoryRoot, candidate); if (!headBlob.StdOut.Trim().Equals(worktreeBlob, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); - ValidateSourceLevelIncludes(repositoryRoot, candidate, validateSwiftDeterminism, worktreeBlob); + ValidateSourceLevelIncludes( + repositoryRoot, + candidate, + validateSwiftDeterminism, + worktreeBlob, + effectiveSourceExtension); } private string ComputeRawGitBlobId(string repositoryRoot, string filePath) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs new file mode 100644 index 000000000..3bb09168f --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs @@ -0,0 +1,160 @@ +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); + } +} From 8338243087b71bbf764bd356f81e8a008ccd04f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 05:57:04 +0200 Subject: [PATCH 46/78] Bind Swift modules and direct exports at source boundaries --- ...eServiceTests.ArchiveCompletionBoundary.cs | 43 ++++++++++++++++++ PowerForge/Services/AppleAppArchiveService.cs | 44 ++++++++++++++----- ...easeSourceTrustService.BuildFlagOptions.cs | 6 +++ ...pleReleaseSourceTrustService.BuildFlags.cs | 13 ++++++ ...eSourceTrustService.CompositeBuildFlags.cs | 30 +++++++++++++ ...AppleSourceTrustLatestReviewRegressions.cs | 25 +++++++++++ 6 files changed, 151 insertions(+), 10 deletions(-) diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs index 31584d52d..92cacc3fd 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs @@ -30,6 +30,34 @@ public async Task CreateArchiveAsync_rejects_archive_replaced_after_xcodebuild_c } } + [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) @@ -44,4 +72,19 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo 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/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index ff3815109..84516a662 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -201,8 +201,17 @@ public async Task UploadArchiveAsync( var toolEnvironment = request.RequireTrustedSystemTools ? AppleTrustedExecutionEnvironment.Create() : null; - var result = await _processRunner.RunAsync( - new ProcessRunRequest( + 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.") + : null; + string? exportArtifactPath = null; + string? exportArtifactSha256 = null; + var processRequest = new ProcessRunRequest( xcodeBuildExecutable, Path.GetDirectoryName(archivePath) ?? Directory.GetCurrentDirectory(), args, @@ -210,15 +219,30 @@ public async Task UploadArchiveAsync( toolEnvironment, captureOutput: true, captureError: true, - inheritEnvironment: toolEnvironment is null), - cancellationToken).ConfigureAwait(false); - - string? exportArtifactPath = null; - string? exportArtifactSha256 = null; - if (result.Succeeded && request.Destination.Equals("export", StringComparison.OrdinalIgnoreCase)) + inheritEnvironment: toolEnvironment is null); + if (directExport) + { + processRequest.SetCompletionBoundary(completionResult => + { + if (!completionResult.Succeeded) + return; + exportMonitor!.CaptureExpectedProducerOutput( + () => + { + exportArtifactPath = PowerForgeReleaseService.ResolveDirectAppleArtifactPath(exportPath); + exportArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(exportArtifactPath); + return true; + }, + "xcodebuild exportArchive"); + }); + } + var result = await _processRunner.RunAsync(processRequest, cancellationToken).ConfigureAwait(false); + processRequest.InvokeCompletionBoundary(result); + if (result.Succeeded && directExport) { - exportArtifactPath = PowerForgeReleaseService.ResolveDirectAppleArtifactPath(exportPath); - exportArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(exportArtifactPath); + 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); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index 07fe694da..e3322b08f 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -10,6 +10,9 @@ internal sealed partial class AppleReleaseSourceTrustService "--libomptarget-amdgpu-bc-path", "--libomptarget-nvptx-bc-path", "--libomptarget-spirv-bc-path", "--ptxas-path", "--rocm-device-lib-path", "--rocm-path", "--source-metadata-list", "--sysroot", "--warning-suppression-mappings", + "-backup-module-interface-path", "-candidate-module-file", "-explicit-swift-module-map-file", + "-module-cache-path", "-prebuilt-module-cache-path", "-previous-module-installname-map-file", + "-sdk-module-cache-path", "-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", "-fexperimental-sanitize-metadata-ignorelist", "-fmemory-profile-use", @@ -38,6 +41,9 @@ internal sealed partial class AppleReleaseSourceTrustService "--warning-suppression-mappings=", "--rocm-device-lib-path=", "--hipstdpar-thrust-path=", "--hipstdpar-prim-path=", "--hipspv-pass-plugin=", "--source-metadata-list=", "--hipstdpar-path=", "--cuda-path=", "--hip-path=", "--ptxas-path=", "--rocm-path=", "--sysroot=", + "-previous-module-installname-map-file=", "-explicit-swift-module-map-file=", + "-backup-module-interface-path=", "-prebuilt-module-cache-path=", "-candidate-module-file=", + "-sdk-module-cache-path=", "-module-cache-path=", "-fexperimental-sanitize-metadata-ignorelist=", "-fms-secure-hotpatch-functions-file=", "-fms-secure-hotpatch-functions-list=", "-fsanitize-coverage-blacklist=", "-fsanitize-coverage-ignorelist=", "-fsanitize-coverage-whitelist=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 88b4a9b72..fae905973 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -270,6 +270,19 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke 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) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs index 393f0a0b8..fd53bcd08 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.CompositeBuildFlags.cs @@ -35,6 +35,36 @@ private static string[] ReadSwiftResolvedPluginPaths(string value, string key) 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(':'); diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs index 3bb09168f..02660123f 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustLatestReviewRegressions.cs @@ -157,4 +157,29 @@ public void ResolveExactAppleSourceCommit_rejects_shipping_source_with_unclassif 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); + } } From 88d769bf69609438c0663180a34c8e78aff4eaa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 06:51:51 +0200 Subject: [PATCH 47/78] Close Apple source trust and producer boundary gaps --- .../AppleReleaseSourceMutationMonitorTests.cs | 41 ++++ PowerForge/Services/AppleAppArchiveService.cs | 2 +- .../AppleReleaseSourceMutationMonitor.cs | 28 ++- ...ReleaseSourceTrustService.LocalPackages.cs | 49 +++++ ...eleaseSourceTrustService.RemotePackages.cs | 1 + ...eleaseSourceTrustService.SourceIncludes.cs | 45 ++++- .../AppleReleaseSourceTrustService.Xcode.cs | 5 +- ...ServiceTests.AppleSourceTrustReviewWave.cs | 175 ++++++++++++++++++ 8 files changed, 330 insertions(+), 16 deletions(-) create mode 100644 PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs diff --git a/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs b/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs new file mode 100644 index 000000000..977a3431b --- /dev/null +++ b/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs @@ -0,0 +1,41 @@ +namespace PowerForge.Tests; + +public sealed class AppleReleaseSourceMutationMonitorTests +{ + [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 { } + } + } +} diff --git a/PowerForge/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index 84516a662..dfcdeb35f 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -231,7 +231,7 @@ public async Task UploadArchiveAsync( { exportArtifactPath = PowerForgeReleaseService.ResolveDirectAppleArtifactPath(exportPath); exportArtifactSha256 = AppleNotarizationService.ComputeArtifactSha256(exportArtifactPath); - return true; + return exportArtifactPath + "\n" + exportArtifactSha256; }, "xcodebuild exportArchive"); }); diff --git a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs index 3fe0d3de6..edc885dcb 100644 --- a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs +++ b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs @@ -60,24 +60,42 @@ internal void ValidateNoChanges() { internal T CaptureExpectedProducerOutput(Func capture, string producerDescription) { if (capture is null) throw new ArgumentNullException(nameof(capture)); - Thread.Sleep(250); if (_watcherError is not null) { throw new InvalidOperationException( $"The {_scopeDescription} mutation monitor failed at the {producerDescription} completion boundary. {_failureInstruction}", _watcherError); } - // All events observed before this boundary belong to the awaited producer. - // Keep the watcher active, reset its event baseline, and capture the output - // before the process runner returns control to the caller. + // Events already delivered belong to the awaited producer. Establish the + // boundary immediately, then bind its output before giving another process + // a deterministic replacement window. Interlocked.Exchange(ref _firstMutation, null); var output = capture(); Thread.Sleep(250); - if (_watcherError is not null || !string.IsNullOrWhiteSpace(_firstMutation)) { + if (_watcherError is not null) { throw new InvalidOperationException( $"The {_scopeDescription} changed while its {producerDescription} output was being bound. {_failureInstruction}", _watcherError); } + + if (!string.IsNullOrWhiteSpace(_firstMutation)) { + // FileSystemWatcher can deliver producer writes after process exit. A + // delayed producer notification is harmless only when the exact bound + // identity is unchanged. Re-establish the event fence, rebind, and then + // require a quiet drain so a real post-exit replacement cannot be erased. + Interlocked.Exchange(ref _firstMutation, null); + var currentOutput = capture(); + if (!EqualityComparer.Default.Equals(output, currentOutput)) { + throw new InvalidOperationException( + $"The {_scopeDescription} changed after {producerDescription} completed. {_failureInstruction}"); + } + Thread.Sleep(250); + if (_watcherError is not null || !string.IsNullOrWhiteSpace(_firstMutation)) { + throw new InvalidOperationException( + $"The {_scopeDescription} changed while its {producerDescription} output was being bound. {_failureInstruction}", + _watcherError); + } + } return output; } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index f9fd05958..afb96d7f9 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -77,6 +77,7 @@ private void ValidateLocalPackageManifest( 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); @@ -313,6 +314,54 @@ private static void ValidatePackageDescriptionCalls(string packageRoot, string m } } + 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, diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs index 322fe3cfa..ff92cc244 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -214,6 +214,7 @@ private void ValidateCheckedOutPackageRoot( 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)) { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 92ec86fe4..4a92104fb 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -69,7 +69,8 @@ private void ValidateSourceLevelIncludes( throw new InvalidOperationException( $"Source input '{fullSourcePath}' uses nondeterministic compiler macro '{nondeterministicMacro}', which cannot be bound to one reproducible source commit."); } - ValidateLanguageModuleImports(fullSourcePath, source); + ValidateLanguageModuleImports(fullSourcePath, source, extension); + RejectPreprocessorIncludeAliases(fullSourcePath, source); foreach (Match directive in Regex.Matches( source, "(?m)^[ \\t]*(?:#|%:)[ \\t]*(?:include|include_next|import)[ \\t]+(?[^\\r\\n]+)", @@ -244,6 +245,7 @@ private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, stri "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", @@ -258,7 +260,7 @@ private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, stri private static readonly HashSet ApprovedAppleSdkHeaderRoots = new(StringComparer.Ordinal) { - "Accelerate", "AppKit", "AudioToolbox", "AVFoundation", "CFNetwork", "CloudKit", "CommonCrypto", + "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", @@ -269,24 +271,24 @@ private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, stri "sys", "xpc" }; - private static void ValidateLanguageModuleImports(string sourcePath, string source) + private static void ValidateLanguageModuleImports(string sourcePath, string source, string effectiveSourceExtension) { foreach (Match pragma in Regex.Matches( source, - "(?(?:\\\\.|[^\\\"\\\\])*)\\\"[ \\t]*\\)", + "(?(?:\\\\.|[^\\\"\\\\])*)\\\"\\s*\\)", RegexOptions.CultureInvariant)) { var payload = Regex.Unescape(pragma.Groups["payload"].Value); var moduleImport = Regex.Match( payload, - "^[ \\t]*clang[ \\t]+module[ \\t]+import[ \\t]+(?[A-Za-z_][A-Za-z0-9_.]*)[ \\t]*$", + "^\\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"); } if (Regex.IsMatch( source, - "(?[A-Za-z_][A-Za-z0-9_.]*)[ \\t]*;", + "(?[A-Za-z_][A-Za-z0-9_.]*)\\s*;", RegexOptions.CultureInvariant)) { var moduleName = import.Groups["module"].Value; @@ -314,10 +316,11 @@ private static void ValidateLanguageModuleImports(string sourcePath, string sour RejectUnapprovedLanguageModule(sourcePath, import.Groups["module"].Value, "Clang pragma"); } - var extension = Path.GetExtension(sourcePath); + 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)) @@ -327,7 +330,7 @@ private static void ValidateLanguageModuleImports(string sourcePath, string sour foreach (Match import in Regex.Matches( syntax, - "(?m)^[ \\t]*(?:export[ \\t]+)?import[ \\t]+(?[^;\\r\\n]+)[ \\t]*;", + "(?m)^[ \\t]*(?:export\\s+)?import\\s+(?[^;]+?)\\s*;", RegexOptions.CultureInvariant)) { var moduleName = import.Groups["module"].Value.Trim(); @@ -343,6 +346,30 @@ private static void ValidateLanguageModuleImports(string sourcePath, string sour } } + private static void RejectPreprocessorIncludeAliases(string sourcePath, string source) + { + if (Regex.IsMatch( + source, + "(?m)^[ \\t]*(?:#|%:)[ \\t]*pragma[ \\t]+include_alias(?![A-Za-z0-9_])", + RegexOptions.CultureInvariant)) + { + throw new InvalidOperationException( + $"Source input '{sourcePath}' uses pragma include_alias, whose replacement header can bypass the exact-source include graph."); + } + + foreach (Match pragma in Regex.Matches( + source, + "(?(?:\\\\.|[^\\\"\\\\])*)\\\"\\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]; diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 72837544f..8bc59a6ad 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -19,7 +19,10 @@ internal sealed partial class AppleReleaseSourceTrustService "GCC_PREFIX_HEADER", "CLANG_PREFIX_HEADER", "MODULEMAP_FILE", - "DEVELOPMENT_ASSET_PATHS" + "DEVELOPMENT_ASSET_PATHS", + "EXPORTED_SYMBOLS_FILE", + "UNEXPORTED_SYMBOLS_FILE", + "ORDER_FILE" }; private static readonly HashSet SearchPathBuildSettings = new(StringComparer.OrdinalIgnoreCase) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs new file mode 100644 index 000000000..1ed4bf531 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs @@ -0,0 +1,175 @@ +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); + } +} From f5b715095a2038824e8d0ff36826442eeaff2944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 07:44:04 +0200 Subject: [PATCH 48/78] Close remaining Apple input and screenshot approval gaps --- .../AppStoreConnectScreenshotApprovalTests.cs | 46 ++++++++++++++ ...oreConnectScreenshotSyncConfigValidator.cs | 9 ++- ...easeSourceTrustService.BuildFlagOptions.cs | 10 +-- ...pleReleaseSourceTrustService.BuildFlags.cs | 11 ++++ ...eleaseSourceTrustService.SourceIncludes.cs | 49 ++++++++++----- ...ServiceTests.AppleSourceTrustReviewWave.cs | 63 +++++++++++++++++++ 6 files changed, 166 insertions(+), 22 deletions(-) diff --git a/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs b/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs index 5629f1753..a0567a59b 100644 --- a/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs +++ b/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs @@ -220,6 +220,52 @@ 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 { } + } + } + private static AppStoreConnectScreenshotSyncSpec CreateSpec() => new() { diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs index b7bf55d79..434036cdd 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs @@ -139,8 +139,7 @@ private static string[] ValidateApprovalManifest( { 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))); + PathsEqual(ResolvePath(baseDirectory, candidate.File), Path.GetFullPath(file))); if (entry is null) { messages.Add($"Screenshot '{Path.GetFileName(file)}' in '{set.ScreenshotDisplayType}' is not present in the approval manifest."); @@ -322,4 +321,10 @@ 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) + => string.Equals( + left, + right, + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index e3322b08f..d677653eb 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -27,10 +27,10 @@ internal sealed partial class AppleReleaseSourceTrustService "-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", "-ivfsoverlay", "-ivfsstatcache", "-iwithprefix", "-iwithprefixbefore", + "-isystem-after", "-ivfsstatcache", "-iwithprefix", "-iwithprefixbefore", "-iwithsysroot", "-load", "-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", "-vfsoverlay", "-working-directory" + "-resource-dir", "-rpath", "-sdk", "-stdlib++-isystem", "-working-directory" }; /// Joined forms of path-bearing compiler, assembler, and linker options, longest prefixes first. @@ -59,11 +59,11 @@ internal sealed partial class AppleReleaseSourceTrustService "-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-attr-list=", "-include-pch=", - "-include-pth=", "-gcc-toolchain=", "-resource-dir=", "-ivfsoverlay=", - "-ivfsstatcache=", "-vfsoverlay=", "-plugin-path=", "-fplugin=", "-force_load=", + "-include-pth=", "-gcc-toolchain=", "-resource-dir=", "-ivfsstatcache=", + "-plugin-path=", "-fplugin=", "-force_load=", "-stdlib++-isystem", "-iframeworkwithsysroot", "-iwithprefixbefore", "-cxx-isystem", "-iwithsysroot", "-iwithprefix", "-isystem-after", "-iframework", "-idirafter", - "-imacros=", "-include=", "-isystem", "-iquote", "-iprefix", "-isysroot=", + "-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 index fae905973..2f9c8cb60 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -230,6 +230,17 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke $"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 (token.Equals("-load-plugin-executable", StringComparison.Ordinal)) { if (++index >= tokens.Length) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 4a92104fb..7de520228 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -104,16 +104,7 @@ private void ValidateSourceLevelIncludes( "Use a tracked quoted include or a validated Xcode module/framework reference instead."); } - var candidate = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(fullSourcePath)!, include)); - EnsurePathWithinRepository(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); - if (!File.Exists(candidate)) - { - throw new FileNotFoundException( - $"Quoted preprocessor include '{include}' from '{fullSourcePath}' was not found beside the including source. " + - "Its compiler search-path selection cannot be bound to the exact source commit; use an adjacent tracked include or an approved module/framework reference.", - candidate); - } - EnsureTrackedFile(repositoryRoot, candidate, $"preprocessor include from {fullSourcePath}"); + ResolveQuotedSourceInclude(repositoryRoot, fullSourcePath, include, "preprocessor include"); } ValidatePreprocessorFileExistenceProbes(repositoryRoot, fullSourcePath, source); @@ -170,6 +161,38 @@ private bool IsApprovedAngledInclude(string repositoryRoot, string sourcePath, s 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); @@ -217,11 +240,7 @@ private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, stri continue; } - var candidate = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourcePath)!, include)); - EnsurePathWithinRepository(repositoryRoot, candidate, $"preprocessor file-existence probe from {sourcePath}"); - if (!File.Exists(candidate)) - throw new FileNotFoundException($"Preprocessor file-existence probe input was not found: {candidate}", candidate); - EnsureTrackedFile(repositoryRoot, candidate, $"preprocessor file-existence probe from {sourcePath}"); + ResolveQuotedSourceInclude(repositoryRoot, sourcePath, include, "preprocessor file-existence probe"); } } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs index 1ed4bf531..3f8dd1fae 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs @@ -172,4 +172,67 @@ public void ResolveExactAppleSourceCommit_accepts_approved_swift_package_link_in 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); + } } From a24d671d7437f70e6b50a221145f01f40f79d49a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 08:45:47 +0200 Subject: [PATCH 49/78] Close Apple source and producer boundary gaps --- .../ProcessRunnerEnvironmentTests.cs | 12 ++++ PowerForge/Abstractions/IProcessRunner.cs | 16 ++++- ...easeSourceTrustService.BuildFlagOptions.cs | 3 +- .../AppleReleaseSourceTrustService.Xcode.cs | 2 + ...eReleaseSourceTrustService.XcodeParsing.cs | 3 +- ...ServiceTests.AppleSourceTrustReviewWave.cs | 58 +++++++++++++++++++ 6 files changed, 91 insertions(+), 3 deletions(-) diff --git a/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs b/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs index f237663e4..bf648734a 100644 --- a/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs +++ b/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs @@ -1,7 +1,19 @@ +using System.Reflection; + namespace PowerForge.Tests; public sealed class ProcessRunnerEnvironmentTests { + [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() { diff --git a/PowerForge/Abstractions/IProcessRunner.cs b/PowerForge/Abstractions/IProcessRunner.cs index b335f10b5..33d7bce3e 100644 --- a/PowerForge/Abstractions/IProcessRunner.cs +++ b/PowerForge/Abstractions/IProcessRunner.cs @@ -198,8 +198,17 @@ public ProcessRunRequest( internal void SetCompletionBoundary(Action completionBoundary) => _completionBoundary = completionBoundary ?? throw new ArgumentNullException(nameof(completionBoundary)); - internal void InvokeCompletionBoundary(ProcessRunResult result) + /// + /// 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 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); @@ -283,6 +292,11 @@ public interface IProcessRunner /// Process execution request. /// Cancellation token. /// Structured process execution result. + /// + /// Implementations must 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); } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index d677653eb..d02acf586 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -10,7 +10,7 @@ internal sealed partial class AppleReleaseSourceTrustService "--libomptarget-amdgpu-bc-path", "--libomptarget-nvptx-bc-path", "--libomptarget-spirv-bc-path", "--ptxas-path", "--rocm-device-lib-path", "--rocm-path", "--source-metadata-list", "--sysroot", "--warning-suppression-mappings", - "-backup-module-interface-path", "-candidate-module-file", "-explicit-swift-module-map-file", + "-access-notes-path", "-backup-module-interface-path", "-candidate-module-file", "-explicit-swift-module-map-file", "-module-cache-path", "-prebuilt-module-cache-path", "-previous-module-installname-map-file", "-sdk-module-cache-path", "-B", "-F", "-I", "-L", "-MF", "-ccc-gcc-name", "-cxx-isystem", "-dependency-dot", @@ -42,6 +42,7 @@ internal sealed partial class AppleReleaseSourceTrustService "--hipstdpar-prim-path=", "--hipspv-pass-plugin=", "--source-metadata-list=", "--hipstdpar-path=", "--cuda-path=", "--hip-path=", "--ptxas-path=", "--rocm-path=", "--sysroot=", "-previous-module-installname-map-file=", "-explicit-swift-module-map-file=", + "-access-notes-path=", "-backup-module-interface-path=", "-prebuilt-module-cache-path=", "-candidate-module-file=", "-sdk-module-cache-path=", "-module-cache-path=", "-fexperimental-sanitize-metadata-ignorelist=", "-fms-secure-hotpatch-functions-file=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 8bc59a6ad..12acf85b2 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -19,6 +19,7 @@ internal sealed partial class AppleReleaseSourceTrustService "GCC_PREFIX_HEADER", "CLANG_PREFIX_HEADER", "MODULEMAP_FILE", + "MODULEMAP_PRIVATE_FILE", "DEVELOPMENT_ASSET_PATHS", "EXPORTED_SYMBOLS_FILE", "UNEXPORTED_SYMBOLS_FILE", @@ -30,6 +31,7 @@ internal sealed partial class AppleReleaseSourceTrustService "HEADER_SEARCH_PATHS", "USER_HEADER_SEARCH_PATHS", "SYSTEM_HEADER_SEARCH_PATHS", + "MTL_HEADER_SEARCH_PATHS", "FRAMEWORK_SEARCH_PATHS", "LIBRARY_SEARCH_PATHS", "SWIFT_INCLUDE_PATHS" diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index 20105dbb9..d66cec38f 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -99,7 +99,8 @@ private void ValidateBuildSettingAssignments( 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("SYSTEM_HEADER_SEARCH_PATHS", StringComparison.OrdinalIgnoreCase) || + baseKey.Equals("MTL_HEADER_SEARCH_PATHS", StringComparison.OrdinalIgnoreCase)) _approvedHeaderSearchRoots.Add(candidate); } else diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs index 3f8dd1fae..3624a4850 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustReviewWave.cs @@ -235,4 +235,62 @@ public void ResolveExactAppleSourceCommit_resolves_quoted_headers_through_tracke 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); + } } From 0ba66e71b015ae7da0ba3d295a22e353a3d43dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 09:53:06 +0200 Subject: [PATCH 50/78] Close Apple source and screenshot mutation gaps --- ...entTests.ScreenshotIntegrityRegressions.cs | 204 +++++++++++++----- .../AppStoreConnectClientTests.cs | 3 +- ...ppStoreConnectReleasePreparationService.cs | 31 ++- .../AppStoreConnectScreenshotSyncService.cs | 25 ++- ...easeSourceTrustService.BuildFlagOptions.cs | 4 +- ...pleReleaseSourceTrustService.BuildFlags.cs | 42 +++- ...eleaseSourceTrustService.SourceIncludes.cs | 51 ++++- .../AppleReleaseSourceTrustService.Xcode.cs | 1 + ...ServiceTests.AppleSourceTrustReviewWave.cs | 76 +++++++ 9 files changed, 364 insertions(+), 73 deletions(-) diff --git a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs index f261b3e0a..b888f1746 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs @@ -8,71 +8,163 @@ public sealed partial class AppStoreConnectClientTests [Fact] public async Task ReleasePreparationService_RejectsScreenshotInventoryDriftBeforeFirstMutation() { - var approvedInventorySha256 = AppStoreConnectScreenshotInventory.ComputeSha256( - [ - new AppStoreConnectReleaseScreenshotSetReadiness - { - ScreenshotDisplayType = "APP_IPHONE_65", - ScreenshotSetId = "set-1", - Count = 1, - Screenshots = - [ - new AppStoreConnectReleaseScreenshotAssetReadiness + 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 { - Id = "shot-before", - FileName = "01-home.png", - FileSize = 3, - SourceFileChecksum = "approved-checksum", - AssetDeliveryState = "COMPLETE" + 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" + } + ] } - ] - } - ]); - 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); + })); + + 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 { } + } + } - var exception = await Assert.ThrowsAsync(() => - new AppStoreConnectReleasePreparationService(client).PrepareAsync(new AppStoreConnectReleasePreparationRequest + [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 => { - AppId = "app-1", - VersionString = "1.0.0", - BuildNumber = "5", - Platform = ApplePlatform.iOS, - CreateVersion = false, - SelectBuild = true, - ReplaceScreenshots = true, - ExpectedScreenshotInventorySha256 = approvedInventorySha256, - ScreenshotSpec = new AppStoreConnectScreenshotSyncSpec + 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", - VersionId = "version-1", + BuildNumber = "5", Platform = ApplePlatform.iOS, - Locale = "en-US", - ScreenshotSets = - [ - new AppStoreConnectScreenshotSetSyncSpec - { - ScreenshotDisplayType = "APP_IPHONE_65", - Path = "not-read-before-inventory-compare" - } - ] - } - })); + 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.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)); + 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] diff --git a/PowerForge.Tests/AppStoreConnectClientTests.cs b/PowerForge.Tests/AppStoreConnectClientTests.cs index 69b690a43..d3a64a33b 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.cs @@ -800,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 { diff --git a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs index b211520c7..91e09c0c7 100644 --- a/PowerForge/Services/AppStoreConnectReleasePreparationService.cs +++ b/PowerForge/Services/AppStoreConnectReleasePreparationService.cs @@ -44,6 +44,29 @@ request.ScreenshotSpec is not null || 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() { @@ -64,7 +87,7 @@ async Task AuthorizeFirstRemoteMutationAsync() versionString, request.Platform, version.Id); - await new AppStoreConnectScreenshotSyncService(_client).ValidateExpectedRemoteInventoryAsync( + await screenshotService!.ValidateExpectedRemoteInventoryAsync( screenshotSpec, request.ExpectedScreenshotInventorySha256!, cancellationToken).ConfigureAwait(false); @@ -74,7 +97,6 @@ async Task AuthorizeFirstRemoteMutationAsync() if (requiresVersion) { - var configuredVersionId = ResolveConfiguredVersionId(request); if (!request.CreateVersion && configuredVersionId is not null) { version = new AppStoreConnectVersionInfo @@ -172,7 +194,7 @@ async Task AuthorizeFirstRemoteMutationAsync() { 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, @@ -182,6 +204,7 @@ async Task AuthorizeFirstRemoteMutationAsync() ExpectedFileSha256 = request.ExpectedScreenshotFileSha256, ExpectedRemoteInventorySha256 = request.ExpectedScreenshotInventorySha256 }, + approvedScreenshotSnapshot!, cancellationToken).ConfigureAwait(false); messages.Add("Synchronized App Store screenshots."); } @@ -254,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/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index 8de919434..d20e8da06 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -84,6 +84,12 @@ internal async Task ValidateExpectedRemoteInventoryAsync( 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)); @@ -124,7 +130,20 @@ public async Task SyncAsync( var sourceSets = spec.ScreenshotSets .Select(setSpec => PreflightScreenshotSet(request.BaseDirectory, setSpec)) .ToArray(); - using var screenshotSnapshot = CreateScreenshotSnapshot(sourceSets, request.ExpectedFileSha256); + return CreateScreenshotSnapshot(sourceSets, request.ExpectedFileSha256); + } + + 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)); var preflightedSets = screenshotSnapshot.Sets; var version = !string.IsNullOrWhiteSpace(spec.VersionId) @@ -484,7 +503,7 @@ private static PreflightedScreenshotSet PreflightScreenshotSet(string baseDirect files); } - private sealed class PreflightedScreenshotSet + internal sealed class PreflightedScreenshotSet { public PreflightedScreenshotSet( string screenshotDisplayType, @@ -530,7 +549,7 @@ public PlannedScreenshotSet( public AppStoreConnectScreenshotInfo[] ExistingScreenshots { get; } } - private sealed class ScreenshotSnapshot : IDisposable + internal sealed class ScreenshotSnapshot : IDisposable { private readonly string _root; private readonly IReadOnlyDictionary _sourcePaths; diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index d02acf586..58cff7800 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -20,7 +20,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-fms-secure-hotpatch-functions-file", "-fms-secure-hotpatch-functions-list", "-force_load", "-fpass-plugin", "-fplugin", "-fprebuilt-module-path", "-fprofile-instr-use", "-fprofile-list", "-fprofile-remapping-file", "-fprofile-sample-use", "-fprofile-use", - "-frandomize-layout-seed-file", "-fsanitize-blacklist", "-fsanitize-coverage-allowlist", + "-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", "-fxray-attr-list", "-gcc-toolchain", "-gen-cdb-fragment-path", @@ -48,7 +48,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-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=", "-frandomize-layout-seed-file=", + "-fsanitize-coverage-allowlist=", "-foverride-record-layout=", "-frandomize-layout-seed-file=", "-fsanitize-system-blacklist=", "-fsanitize-system-ignorelist=", "-fprofile-remapping-file=", "-fmodules-user-build-path=", "-fprofile-instr-use=", "-fprofile-sample-use=", "-fapinotes-cache-path=", "-fbuild-session-file=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 2f9c8cb60..a10971d9f 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -207,7 +207,9 @@ private IEnumerable ExpandCompilerResponseFileTokens( private static IEnumerable ExtractBuildFlagInputPaths(string[] inputTokens, string key) { var tokens = ExpandForwardedBuildFlagTokens(inputTokens, key); - var linkerFlags = key.Split('[')[0].Trim().Equals("OTHER_LDFLAGS", StringComparison.OrdinalIgnoreCase); + 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++) { @@ -219,6 +221,44 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke 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) || diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 7de520228..69032ccfa 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -70,6 +70,7 @@ private void ValidateSourceLevelIncludes( $"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, @@ -292,11 +293,14 @@ private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, stri 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, @@ -305,15 +309,16 @@ private static void ValidateLanguageModuleImports(string sourcePath, string sour if (moduleImport.Success) RejectUnapprovedLanguageModule(sourcePath, moduleImport.Groups["module"].Value, "Clang _Pragma"); } - if (Regex.IsMatch( - source, - "(?[A-Za-z_][A-Za-z0-9_.]*)\\s*;", @@ -365,6 +370,42 @@ private static void ValidateLanguageModuleImports(string sourcePath, string sour } } + private static void RejectPragmaLinkedLibraries(string sourcePath, string source) + { + var syntax = MaskCStringAndCharacterLiterals(source); + if (Regex.IsMatch( + syntax, + "(?m)^[ \\t]*(?:#|%:)[ \\t]*pragma[ \\t]+comment[ \\t]*\\([ \\t]*lib[ \\t]*,", + 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, + "(?(() => + 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); + } } From 7f997be5ca074260fc802d0afd09708e3d0356ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 11:14:25 +0200 Subject: [PATCH 51/78] Close Apple plugin, assembler, and plan gaps --- ...orgeReleaseServiceTests.AppleAutomation.cs | 3 +- ...aseServiceTests.AppleReleaseAdvancement.cs | 8 +- ...ppleReleaseSourceTrustService.Assembler.cs | 57 ++++++-- ...easeSourceTrustService.BuildFlagOptions.cs | 4 +- ...pleReleaseSourceTrustService.BuildFlags.cs | 33 +++++ ...eleaseSourceTrustService.InlineAssembly.cs | 8 +- ...ReleaseSourceTrustService.LocalPackages.cs | 6 +- ...eleaseSourceTrustService.RemotePackages.cs | 6 +- ...eleaseSourceTrustService.SourceIncludes.cs | 13 +- .../AppleReleaseSourceTrustService.Xcode.cs | 33 +++-- .../AppleReleaseSourceTrustService.cs | 7 +- ...owerForgeReleaseService.AppleVersioning.cs | 9 +- ...eSourceTrustWorkingDirectoryRegressions.cs | 137 ++++++++++++++++++ 13 files changed, 283 insertions(+), 41 deletions(-) create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustWorkingDirectoryRegressions.cs diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index 7c8a5fa02..0a5bf60b1 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -543,7 +543,7 @@ public void Execute_ApplePreparePlan_EnablesConfiguredDistributionInputs() spec.AppleApps.ScreenshotConfigPath = "screenshots.json"; var result = CreateAppleAutomationService( - request => CreateReleaseState(request, "VALID"), + request => CreateReleaseState(request, processingState: null), checkAppleReleaseReadiness: (_, request) => CreateReadyReleaseReadiness(request)) .Execute( spec, @@ -562,6 +562,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 { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs index c68a6a6e7..7174402f4 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleReleaseAdvancement.cs @@ -428,9 +428,10 @@ 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 = CreateAppleAutomationService( - request => CreateReleaseState(request, "VALID"), + request => CreateReleaseState(request, processingState: null), checkAppleReleaseReadiness: (_, request) => CreateReadyReleaseReadiness(request)) .Execute(spec, new PowerForgeReleaseRequest { @@ -446,12 +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.Equal("build-id", target.BuildId); - Assert.Equal("VALID", target.BuildProcessingState); + Assert.Null(target.BuildId); + Assert.Null(target.BuildProcessingState); } finally { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs index 0825017c8..613817979 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Assembler.cs @@ -4,14 +4,19 @@ namespace PowerForge; internal sealed partial class AppleReleaseSourceTrustService { - private void ValidateAssemblerInputs(string repositoryRoot, string sourcePath, string source) + private void ValidateAssemblerInputs( + string repositoryRoot, + string sourcePath, + string source, + string assemblerWorkingDirectory) { var fullSourcePath = Path.GetFullPath(sourcePath); - if (!_validatedAssemblerInputFiles.Add(fullSourcePath)) + var fullWorkingDirectory = Path.GetFullPath(assemblerWorkingDirectory); + if (!_validatedAssemblerInputFiles.Add(fullSourcePath + "|" + fullWorkingDirectory)) return; RejectUnboundAssemblerPreprocessorMacros(fullSourcePath, source); - ValidateAssemblerDirectives(repositoryRoot, fullSourcePath, source); + ValidateAssemblerDirectives(repositoryRoot, fullSourcePath, source, fullWorkingDirectory); } private static void RejectUnboundAssemblerPreprocessorMacros(string sourcePath, string source) @@ -74,7 +79,11 @@ private static bool CanConstructAssemblerFileDirective(string body, string? para RegexOptions.CultureInvariant)); } - private void ValidateAssemblerDirectives(string repositoryRoot, string sourcePath, string source) + private void ValidateAssemblerDirectives( + string repositoryRoot, + string sourcePath, + string source, + string assemblerWorkingDirectory) { source = NormalizeAssemblerStatementBoundaries(source); foreach (Match directive in Regex.Matches( @@ -97,21 +106,51 @@ private void ValidateAssemblerDirectives(string repositoryRoot, string sourcePat $"Assembler source input '{sourcePath}' references absolute .{directive.Groups["kind"].Value} input '{input}', which is outside the exact-source graph."); } - var candidate = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourcePath)!, input)); - EnsurePathWithinRepository(repositoryRoot, candidate, $"assembler .{directive.Groups["kind"].Value} input from {sourcePath}"); - if (!File.Exists(candidate)) - throw new FileNotFoundException($"Assembler input was not found inside the exact checked-out source: {candidate}", candidate); + 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); + 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(); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index 58cff7800..5b73555fb 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -28,7 +28,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-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", "-load", "-load-plugin-library", "-module-file-info", "-module-map-file", + "-iwithsysroot", "-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" }; @@ -55,7 +55,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-fcodegen-data-use=", "-fmemory-profile-use=", "-fmodules-cache-path=", "-fprebuilt-module-path=", "-fsanitize-ignorelist=", "-fsanitize-blacklist=", "-fthinlto-distributor=", "-gen-cdb-fragment-path=", "-index-unit-output-path=", - "-load-plugin-library=", "-fprofile-list=", "-fmodule-map-file=", "-fmodule-file=", + "-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=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index a10971d9f..04fe7f6b5 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -53,6 +53,39 @@ private void ValidateBuildFlagInputPaths( $"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) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs b/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs index ceba0e1e4..ddd1ec826 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.InlineAssembly.cs @@ -4,7 +4,11 @@ namespace PowerForge; internal sealed partial class AppleReleaseSourceTrustService { - private void ValidateInlineAssemblerInputs(string repositoryRoot, string sourcePath, string source) + private void ValidateInlineAssemblerInputs( + string repositoryRoot, + string sourcePath, + string source, + string assemblerWorkingDirectory) { var syntax = MaskCStringAndCharacterLiterals(source); foreach (Match invocation in Regex.Matches( @@ -22,7 +26,7 @@ private void ValidateInlineAssemblerInputs(string repositoryRoot, string sourceP $"Source input '{sourcePath}' uses computed inline assembler text, whose file directives cannot be bound to the exact source commit. " + "Use literal inline assembler text or a tracked standalone assembler source."); } - ValidateAssemblerDirectives(repositoryRoot, sourcePath, assemblerSource); + ValidateAssemblerDirectives(repositoryRoot, sourcePath, assemblerSource, assemblerWorkingDirectory); } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs index afb96d7f9..31d59f12e 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.LocalPackages.cs @@ -52,7 +52,11 @@ private void ValidateLocalPackageRoot( 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"); + EnsureTrackedDirectoryTree( + repositoryRoot, + conventionalInput, + "Xcode local Swift package input", + assemblerWorkingDirectory: packageRoot); } foreach (var manifestPath in manifestPaths) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs index ff92cc244..bd2231825 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -258,7 +258,11 @@ private void ValidateCheckedOutPackageRoot( }) { if (Directory.Exists(conventionalInput)) - EnsureTrackedDirectoryTree(checkoutRoot, conventionalInput, "remote Swift package source input"); + EnsureTrackedDirectoryTree( + checkoutRoot, + conventionalInput, + "remote Swift package source input", + assemblerWorkingDirectory: packageRoot); } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 69032ccfa..858972655 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -15,7 +15,8 @@ private void ValidateSourceLevelIncludes( string sourcePath, bool validateSwiftDeterminism = false, string? sourceBlob = null, - string? effectiveSourceExtension = null) + string? effectiveSourceExtension = null, + string? assemblerWorkingDirectory = null) { var sourceExtension = string.IsNullOrWhiteSpace(effectiveSourceExtension) ? Path.GetExtension(sourcePath) @@ -26,13 +27,15 @@ private void ValidateSourceLevelIncludes( var extension = sourceExtension; if (extension.Equals(".swift", StringComparison.OrdinalIgnoreCase) && !validateSwiftDeterminism) return; - var validationKey = fullSourcePath + "|" + extension + "|" + validateSwiftDeterminism; + 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; + var semanticKey = sourceBlob + "|" + semanticPath + "|" + extension + "|" + validateSwiftDeterminism + "|" + effectiveAssemblerWorkingDirectory; if (!_validatedSourceSemanticInputs.Add(semanticKey)) return; } @@ -111,10 +114,10 @@ private void ValidateSourceLevelIncludes( ValidatePreprocessorFileExistenceProbes(repositoryRoot, fullSourcePath, source); if (IsCInlineAssemblySource(extension)) - ValidateInlineAssemblerInputs(repositoryRoot, fullSourcePath, source); + ValidateInlineAssemblerInputs(repositoryRoot, fullSourcePath, source, effectiveAssemblerWorkingDirectory); if (extension.Equals(".s", StringComparison.OrdinalIgnoreCase)) - ValidateAssemblerInputs(repositoryRoot, fullSourcePath, source); + ValidateAssemblerInputs(repositoryRoot, fullSourcePath, source, effectiveAssemblerWorkingDirectory); } private static string ResolveSourceSemanticPath(string sourcePath) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index b9f4c67b4..2415f99ba 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -300,6 +300,17 @@ private void ValidateProjectGraph( 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)) @@ -322,12 +333,6 @@ private void ValidateProjectGraph( if (item.Isa.Equals("PBXBuildFile", StringComparison.OrdinalIgnoreCase)) { - ValidateBuildFileSettings( - repositoryRoot, - projectDirectory, - item, - generatedOutputPaths, - metadataPath); continue; } @@ -521,7 +526,8 @@ private void ValidateResolvedProjectInput( candidate, $"Xcode {item.Isa} input", validateSwiftDeterminism: effectiveSourceExtension?.Equals(".swift", StringComparison.OrdinalIgnoreCase) == true, - effectiveSourceExtension: effectiveSourceExtension); + effectiveSourceExtension: effectiveSourceExtension, + assemblerWorkingDirectory: Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!); } else if (Directory.Exists(candidate)) { @@ -533,7 +539,8 @@ private void ValidateResolvedProjectInput( repositoryRoot, candidate, $"Xcode {item.Isa} input", - validateSwiftDeterminism: isShippingSource); + validateSwiftDeterminism: isShippingSource, + assemblerWorkingDirectory: Path.GetDirectoryName(Path.GetDirectoryName(metadataPath)!)!); } else { @@ -554,7 +561,8 @@ private void EnsureTrackedDirectoryTree( string repositoryRoot, string path, string name, - bool validateSwiftDeterminism = false) + bool validateSwiftDeterminism = false, + string? assemblerWorkingDirectory = null) { EnsureDirectoryWithinRepository(repositoryRoot, path, name); var relativeRoot = FrameworkCompatibility.GetRelativePath(repositoryRoot, path).Replace('\\', '/'); @@ -591,7 +599,12 @@ private void EnsureTrackedDirectoryTree( $"{name} differs from the exact source commit: " + FrameworkCompatibility.GetRelativePath(repositoryRoot, entry).Replace('\\', '/')); } - ValidateSourceLevelIncludes(repositoryRoot, fullPath, validateSwiftDeterminism, expectedBlob); + ValidateSourceLevelIncludes( + repositoryRoot, + fullPath, + validateSwiftDeterminism, + expectedBlob, + assemblerWorkingDirectory: assemblerWorkingDirectory); } } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index b438974eb..5d3f189b4 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -31,6 +31,7 @@ internal sealed partial class AppleReleaseSourceTrustService 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, @@ -483,7 +484,8 @@ private void EnsureTrackedFile( string name, string? capturedWorktreeBlob = null, bool validateSwiftDeterminism = false, - string? effectiveSourceExtension = null) + string? effectiveSourceExtension = null, + string? assemblerWorkingDirectory = null) { var candidate = Path.GetFullPath(path); EnsurePathWithinRepository(repositoryRoot, candidate, name); @@ -514,7 +516,8 @@ private void EnsureTrackedFile( candidate, validateSwiftDeterminism, worktreeBlob, - effectiveSourceExtension); + effectiveSourceExtension, + assemblerWorkingDirectory); } private string ComputeRawGitBlobId(string repositoryRoot, string filePath) diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index 50b3be99d..bd3e17a4c 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -302,14 +302,13 @@ PowerForgeAppleReleaseAction.TestFlight or private static bool RequiresSelectedApplePlanBuild(PowerForgeAppleReleasePlan plan) => plan.Action is PowerForgeAppleReleaseAction.SubmitTestFlightReview or PowerForgeAppleReleaseAction.SubmitAppReview or - PowerForgeAppleReleaseAction.Prepare or PowerForgeAppleReleaseAction.TestFlight or - PowerForgeAppleReleaseAction.Advance || + PowerForgeAppleReleaseAction.Release || plan.AdoptExistingBuild || - plan.PrepareDistribution || - plan.DistributeTestFlight || + (plan.DistributeTestFlight && !IsUploadExecution(plan)) || plan.SubmitTestFlightBetaReview || - plan.SubmitForReview; + plan.SubmitForReview || + plan.ReleaseApprovedVersion; private static string ComputeApplePlanSha256(PowerForgeAppleReleaseReceipt receipt) { 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); + } +} From f9dd3a3714259da85125369c9845c7d66fd4d30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 12:10:23 +0200 Subject: [PATCH 52/78] Close Apple publication and source trust races --- .../AppleArchiveBuildSnapshotTests.cs | 81 ++++++++ .../AppleReleaseSourceMutationMonitorTests.cs | 72 +++++++ .../ProcessRunnerEnvironmentTests.cs | 22 ++ PowerForge/Abstractions/IProcessRunner.cs | 17 +- PowerForge/Services/AppleAppArchiveService.cs | 3 +- .../Services/AppleArchiveBuildSnapshot.cs | 29 ++- PowerForge/Services/AppleArtifactCopy.cs | 189 ++++++++++++++++-- .../Services/AppleDirectExportSnapshot.cs | 31 +-- .../AppleNotarizationInputSnapshot.cs | 29 +-- .../Services/AppleNotarizationService.cs | 8 +- .../AppleReleaseSourceMutationMonitor.cs | 41 ++-- .../AppleReleaseSourceTrustService.cs | 49 +++++ ...eTests.AppleSourceTrustFinalRegressions.cs | 72 +++++++ 13 files changed, 560 insertions(+), 83 deletions(-) diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index 68187a341..cb88e62c9 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -2,6 +2,87 @@ namespace PowerForge.Tests; public sealed class AppleArchiveBuildSnapshotTests { + [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 DirectExport_publish_rejects_artifact_replaced_after_xcodebuild_identity_was_observed() { diff --git a/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs b/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs index 977a3431b..74b403e99 100644 --- a/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs +++ b/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs @@ -2,6 +2,78 @@ 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() { diff --git a/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs b/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs index bf648734a..b148747e3 100644 --- a/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs +++ b/PowerForge.Tests/ProcessRunnerEnvironmentTests.cs @@ -4,6 +4,28 @@ 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() { diff --git a/PowerForge/Abstractions/IProcessRunner.cs b/PowerForge/Abstractions/IProcessRunner.cs index 33d7bce3e..9aab21cf7 100644 --- a/PowerForge/Abstractions/IProcessRunner.cs +++ b/PowerForge/Abstractions/IProcessRunner.cs @@ -202,7 +202,8 @@ internal void SetCompletionBoundary(Action completionBoundary) /// 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 callback is invoked at most once, so service-level fallback calls are safe. + /// 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) @@ -360,6 +361,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; @@ -371,7 +385,6 @@ public async Task RunAsync(ProcessRunRequest request, Cancella if (timedOut && string.IsNullOrWhiteSpace(stderr)) stderr = "Timeout"; - var exitCode = timedOut ? 124 : SafeGetExitCode(process); var result = new ProcessRunResult(exitCode, stdout, stderr, process.StartInfo.FileName ?? request.FileName, stopwatch.Elapsed, timedOut); request.InvokeCompletionBoundary(result); return result; diff --git a/PowerForge/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index dfcdeb35f..1621985f3 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -207,7 +207,8 @@ public async Task UploadArchiveAsync( exportPath, "private Developer ID export", "xcodebuild exportArchive", - "Discard the export and run xcodebuild exportArchive again.") + "Discard the export and run xcodebuild exportArchive again.", + enableImmediately: false) : null; string? exportArtifactPath = null; string? exportArtifactSha256 = null; diff --git a/PowerForge/Services/AppleArchiveBuildSnapshot.cs b/PowerForge/Services/AppleArchiveBuildSnapshot.cs index 629272fc5..fea31b2cf 100644 --- a/PowerForge/Services/AppleArchiveBuildSnapshot.cs +++ b/PowerForge/Services/AppleArchiveBuildSnapshot.cs @@ -46,15 +46,17 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) var parent = Path.GetDirectoryName(destination) ?? throw new InvalidOperationException($"Apple archive path has no parent: {destination}"); Directory.CreateDirectory(parent); - if (File.Exists(destination) || - (Directory.Exists(destination) && (File.GetAttributes(destination) & FileAttributes.ReparsePoint) != 0)) { - throw new InvalidOperationException($"Apple archive path must be a regular directory: {destination}"); - } + 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 backup = Path.Combine(parent, $".{name}.powerforge-backup-{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; @@ -71,10 +73,11 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) $"The staged Apple archive changed during publication. Expected '{sourceSha256}', received '{stagedSha256}'."); } - if (Directory.Exists(destination)) { - Directory.Move(destination, backup); - movedExisting = true; - } + movedExisting = AppleArtifactCopy.MoveExistingPathToBackupIfUnchanged( + destination, + backup, + existingDestination, + "Apple archive"); Directory.Move(stage, destination); published = true; @@ -83,8 +86,12 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) throw new InvalidOperationException( $"The published Apple archive changed before release processing. Expected '{sourceSha256}', received '{publishedSha256}'."); } - if (movedExisting && Directory.Exists(backup)) - Directory.Delete(backup, recursive: true); + if (movedExisting) + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup, + backupDeletionCandidate, + existingDestination!, + "Previous Apple archive"); return publishedSha256; } catch (Exception publicationException) { try { diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs index 3270c9d55..4deae4219 100644 --- a/PowerForge/Services/AppleArtifactCopy.cs +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -3,6 +3,19 @@ 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); @@ -71,6 +84,115 @@ internal static void CopyDirectory(string sourceRoot, string destinationRoot) File.SetAttributes(destinationRoot, File.GetAttributes(sourceRoot)); } + /// + /// 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. @@ -86,6 +208,7 @@ internal static void RestoreDirectoryBackup(string destinationPath, string backu $"The previous artifact is retained at '{backupPath}'."); } Directory.Move(backupPath, destinationPath); + TryDeleteOwnedBackupParent(backupPath); } /// @@ -113,27 +236,24 @@ internal static void RemovePublishedPathIfUnchanged( { var attributes = File.GetAttributes(destinationPath); var isDirectory = (attributes & FileAttributes.Directory) != 0; - var quarantinedArtifactPath = quarantinePath; - if (isDirectory) - Directory.Move(destinationPath, quarantinePath); - else + var quarantinedArtifactPath = Path.Combine(quarantinePath, Path.GetFileName(destinationPath)); + try { - try - { - Directory.CreateDirectory(quarantinePath); + Directory.CreateDirectory(quarantinePath); #if NET8_0_OR_GREATER - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(quarantinePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(quarantinePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); #endif - quarantinedArtifactPath = Path.Combine(quarantinePath, Path.GetFileName(destinationPath)); + if (isDirectory) + Directory.Move(destinationPath, quarantinedArtifactPath); + else File.Move(destinationPath, quarantinedArtifactPath); - } - catch - { - if (Directory.Exists(quarantinePath) && !Directory.EnumerateFileSystemEntries(quarantinePath).Any()) - Directory.Delete(quarantinePath); - throw; - } + } + catch + { + if (Directory.Exists(quarantinePath) && !Directory.EnumerateFileSystemEntries(quarantinePath).Any()) + Directory.Delete(quarantinePath); + throw; } try { @@ -152,7 +272,10 @@ internal static void RemovePublishedPathIfUnchanged( } if (isDirectory) - Directory.Delete(quarantinePath, recursive: true); + { + Directory.Delete(quarantinedArtifactPath, recursive: true); + Directory.Delete(quarantinePath); + } else { File.Delete(quarantinedArtifactPath); @@ -164,7 +287,10 @@ internal static void RemovePublishedPathIfUnchanged( if (!Directory.Exists(destinationPath) && !File.Exists(destinationPath)) { if (isDirectory) - Directory.Move(quarantinePath, destinationPath); + { + Directory.Move(quarantinedArtifactPath, destinationPath); + Directory.Delete(quarantinePath); + } else { File.Move(quarantinedArtifactPath, destinationPath); @@ -190,5 +316,30 @@ internal static void RestorePathBackup(string destinationPath, string 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) || + !Directory.Exists(parent) || + Directory.EnumerateFileSystemEntries(parent).Any()) + { + return; + } + Directory.Delete(parent); } } diff --git a/PowerForge/Services/AppleDirectExportSnapshot.cs b/PowerForge/Services/AppleDirectExportSnapshot.cs index 347694783..9e8f3057c 100644 --- a/PowerForge/Services/AppleDirectExportSnapshot.cs +++ b/PowerForge/Services/AppleDirectExportSnapshot.cs @@ -76,15 +76,16 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) var parent = Path.GetDirectoryName(destination) ?? throw new InvalidOperationException($"Developer ID export path has no parent: {destination}"); Directory.CreateDirectory(parent); - if (File.Exists(destination) || - (Directory.Exists(destination) && (File.GetAttributes(destination) & FileAttributes.ReparsePoint) != 0)) - { - throw new InvalidOperationException($"Developer ID export path must be a regular directory: {destination}"); - } + 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 backup = Path.Combine(parent, $".{name}.powerforge-backup-{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; @@ -104,11 +105,11 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) throw new InvalidOperationException( $"The staged Developer ID export changed during publication. Expected '{sourceArtifactSha256}', received '{stagedSha256}'."); } - if (Directory.Exists(destination)) - { - Directory.Move(destination, backup); - movedExisting = true; - } + movedExisting = AppleArtifactCopy.MoveExistingPathToBackupIfUnchanged( + destination, + backup, + existingDestination, + "Developer ID export"); Directory.Move(stage, destination); published = true; @@ -125,8 +126,12 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) throw new InvalidOperationException( $"The published Developer ID export tree changed before notarization. Expected '{sourceExportSha256}', received '{observedPublishedExportSha256}'."); } - if (movedExisting && Directory.Exists(backup)) - Directory.Delete(backup, recursive: true); + if (movedExisting) + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup, + backupDeletionCandidate, + existingDestination!, + "Previous Developer ID export"); return new ApplePublishedDirectExport(destination, publishedArtifact, publishedSha256); } catch (Exception publicationException) diff --git a/PowerForge/Services/AppleNotarizationInputSnapshot.cs b/PowerForge/Services/AppleNotarizationInputSnapshot.cs index 2d5267d55..febb30bbc 100644 --- a/PowerForge/Services/AppleNotarizationInputSnapshot.cs +++ b/PowerForge/Services/AppleNotarizationInputSnapshot.cs @@ -57,10 +57,15 @@ internal string PublishTo(string destinationPath, string expectedSha256) 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 backup = Path.Combine(parent, $".{name}.powerforge-backup-{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 sourceIsDirectory = Directory.Exists(ArtifactPath); var movedExisting = false; @@ -89,16 +94,11 @@ internal string PublishTo(string destinationPath, string expectedSha256) $"The staged notarized Apple artifact changed during publication. Expected '{sourceSha256}', received '{stagedSha256}'."); } - if (Directory.Exists(destination)) - { - Directory.Move(destination, backup); - movedExisting = true; - } - else if (File.Exists(destination)) - { - File.Move(destination, backup); - movedExisting = true; - } + movedExisting = AppleArtifactCopy.MoveExistingPathToBackupIfUnchanged( + destination, + backup, + existingDestination, + "Apple notarization artifact"); if (sourceIsDirectory) Directory.Move(stage, destination); @@ -113,7 +113,12 @@ internal string PublishTo(string destinationPath, string expectedSha256) $"The published notarized Apple artifact changed during publication. Expected '{sourceSha256}', received '{publishedSha256}'."); } - TryDeletePath(backup); + if (movedExisting) + AppleArtifactCopy.RemoveBackupIfUnchanged( + backup, + backupDeletionCandidate, + existingDestination!, + "Previous Apple notarization artifact"); return publishedSha256; } catch (Exception publicationException) diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 2a337533c..065bf21bc 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -214,7 +214,8 @@ public async Task NotarizeAsync( submissionSnapshot.RootPath, "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.") + "Discard the private artifact and resume from the last durable notarization checkpoint.", + enableImmediately: staplingCompleted) : null; if (submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Staple) { @@ -362,7 +363,8 @@ private async Task PrepareSubmissionAsync( privateRoot, "private Apple notarization packaging root", "ditto", - "Discard the package and create a new notarization snapshot."); + "Discard the package and create a new notarization snapshot.", + enableImmediately: false); string? packagedSha256 = null; var package = await RunAsync( dittoExecutable, @@ -375,10 +377,10 @@ private async Task PrepareSubmissionAsync( { if (!completionResult.Succeeded) return; - inputMonitor?.ValidateNoChanges(); 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}"); diff --git a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs index edc885dcb..258a962b5 100644 --- a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs +++ b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs @@ -17,7 +17,8 @@ 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.") { + string failureInstruction = "Discard the archive and rebuild from a new snapshot.", + bool enableImmediately = true) { _scopeDescription = scopeDescription; _readerDescription = readerDescription; _failureInstruction = failureInstruction; @@ -37,7 +38,7 @@ internal AppleReleaseSourceMutationMonitor( _watcher.Deleted += OnMutation; _watcher.Renamed += OnMutation; _watcher.Error += OnError; - _watcher.EnableRaisingEvents = true; + _watcher.EnableRaisingEvents = enableImmediately; } internal void ValidateNoChanges() { @@ -66,10 +67,15 @@ internal T CaptureExpectedProducerOutput(Func capture, string producerDesc _watcherError); } - // Events already delivered belong to the awaited producer. Establish the - // boundary immediately, then bind its output before giving another process - // a deterministic replacement window. - Interlocked.Exchange(ref _firstMutation, null); + // 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. + if (!_watcher.EnableRaisingEvents) + _watcher.EnableRaisingEvents = true; + if (!string.IsNullOrWhiteSpace(_firstMutation)) { + throw new InvalidOperationException( + $"The {_scopeDescription} changed before its {producerDescription} output could be bound. {_failureInstruction}"); + } var output = capture(); Thread.Sleep(250); if (_watcherError is not null) { @@ -77,24 +83,15 @@ internal T CaptureExpectedProducerOutput(Func capture, string producerDesc $"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)) { - // FileSystemWatcher can deliver producer writes after process exit. A - // delayed producer notification is harmless only when the exact bound - // identity is unchanged. Re-establish the event fence, rebind, and then - // require a quiet drain so a real post-exit replacement cannot be erased. - Interlocked.Exchange(ref _firstMutation, null); - var currentOutput = capture(); - if (!EqualityComparer.Default.Equals(output, currentOutput)) { - throw new InvalidOperationException( - $"The {_scopeDescription} changed after {producerDescription} completed. {_failureInstruction}"); - } - Thread.Sleep(250); - if (_watcherError is not null || !string.IsNullOrWhiteSpace(_firstMutation)) { - throw new InvalidOperationException( - $"The {_scopeDescription} changed while its {producerDescription} output was being bound. {_failureInstruction}", - _watcherError); - } + throw new InvalidOperationException( + $"The {_scopeDescription} changed after {producerDescription} completed. {_failureInstruction}"); } return output; } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 5d3f189b4..116950610 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -22,6 +22,7 @@ internal sealed partial class AppleReleaseSourceTrustService 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); @@ -47,6 +48,22 @@ 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); @@ -85,6 +102,26 @@ internal void ValidateAfterBuild( 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); @@ -117,6 +154,18 @@ internal void ValidateAfterBuild( } } + 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 diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs index 5722cedf5..6374e3ec1 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs @@ -5,6 +5,78 @@ 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() { From 7f7575e25e297b80a20c37763c9e74ff67560db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 13:18:03 +0200 Subject: [PATCH 53/78] Close Apple source and artifact review gaps --- PowerForge.Cli/Program.Command.Release.cs | 2 + .../AppleArchiveBuildSnapshotTests.cs | 39 ++++++++++++ PowerForge/Services/AppleArtifactCopy.cs | 34 +++++++---- .../AppleReleaseSourceMutationMonitor.cs | 59 +++++++++++++++++-- ...ReleaseSourceTrustService.CFamilyLexing.cs | 9 +-- ....AppleSourceTrustCurrentHeadRegressions.cs | 18 ++++++ 6 files changed, 140 insertions(+), 21 deletions(-) diff --git a/PowerForge.Cli/Program.Command.Release.cs b/PowerForge.Cli/Program.Command.Release.cs index 099ea07f0..21a3b75c2 100644 --- a/PowerForge.Cli/Program.Command.Release.cs +++ b/PowerForge.Cli/Program.Command.Release.cs @@ -378,6 +378,8 @@ 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)); diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index cb88e62c9..3766c4e4a 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -2,6 +2,45 @@ namespace PowerForge.Tests; public sealed class AppleArchiveBuildSnapshotTests { + [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)] diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs index 4deae4219..598c227d3 100644 --- a/PowerForge/Services/AppleArtifactCopy.cs +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -19,6 +19,10 @@ internal PathIdentity(bool isDirectory, string sha256) 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) @@ -59,6 +63,7 @@ internal static void CopyDirectory(string sourceRoot, string destinationRoot) else if (isDirectory) { Directory.CreateDirectory(destinationPath); + directoryMetadata.Add((sourcePath, destinationPath)); pending.Push((sourcePath, destinationPath)); } else @@ -66,22 +71,31 @@ internal static void CopyDirectory(string sourceRoot, string destinationRoot) File.Copy(sourcePath, destinationPath, overwrite: false); } - if (!isLink) - { -#if NET8_0_OR_GREATER - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(destinationPath, File.GetUnixFileMode(sourcePath)); -#endif - File.SetAttributes(destinationPath, attributes); - } + 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(destinationRoot, File.GetUnixFileMode(sourceRoot)); + File.SetUnixFileMode(destinationPath, File.GetUnixFileMode(sourcePath)); #endif - File.SetAttributes(destinationRoot, File.GetAttributes(sourceRoot)); + File.SetAttributes(destinationPath, attributes); } /// diff --git a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs index 258a962b5..904521bc0 100644 --- a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs +++ b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs @@ -9,6 +9,8 @@ internal sealed class AppleReleaseSourceMutationMonitor : IDisposable { private readonly string _scopeDescription; private readonly string _readerDescription; private readonly string _failureInstruction; + private int _enforceMutations; + private long _mutationSequence; private string? _firstMutation; private Exception? _watcherError; private bool _disposed; @@ -38,7 +40,11 @@ internal AppleReleaseSourceMutationMonitor( _watcher.Deleted += OnMutation; _watcher.Renamed += OnMutation; _watcher.Error += OnError; - _watcher.EnableRaisingEvents = enableImmediately; + // 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() { @@ -70,13 +76,52 @@ internal T CaptureExpectedProducerOutput(Func capture, string producerDesc // 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. - if (!_watcher.EnableRaisingEvents) - _watcher.EnableRaisingEvents = true; + 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}"); + } + var drainedOutput = capture(); + if (!EqualityComparer.Default.Equals(output, drainedOutput)) + { + throw new InvalidOperationException( + $"The {_scopeDescription} changed while its {producerDescription} output was being bound. {_failureInstruction}"); + } + Interlocked.Exchange(ref _enforceMutations, 1); + } + else + { + output = capture(); + } if (!string.IsNullOrWhiteSpace(_firstMutation)) { throw new InvalidOperationException( $"The {_scopeDescription} changed before its {producerDescription} output could be bound. {_failureInstruction}"); } - var output = capture(); Thread.Sleep(250); if (_watcherError is not null) { throw new InvalidOperationException( @@ -97,7 +142,11 @@ internal T CaptureExpectedProducerOutput(Func capture, string producerDesc } private void OnMutation(object sender, FileSystemEventArgs args) - => Interlocked.CompareExchange(ref _firstMutation, args.FullPath, null); + { + Interlocked.Increment(ref _mutationSequence); + if (Volatile.Read(ref _enforceMutations) != 0) + Interlocked.CompareExchange(ref _firstMutation, args.FullPath, null); + } private void OnError(object sender, ErrorEventArgs args) => Interlocked.CompareExchange(ref _watcherError, args.GetException(), null); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs index c5e8e7b00..adb27d43c 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.CFamilyLexing.cs @@ -99,14 +99,9 @@ private static string RemoveCComments(string source) { if (current == '*' && next == '/') { - result.Append(" "); index++; inBlockComment = false; } - else - { - result.Append(current == '\r' || current == '\n' ? current : ' '); - } continue; } if (quote != '\0') @@ -136,7 +131,9 @@ private static string RemoveCComments(string source) } if (current == '/' && next == '*') { - result.Append(" "); + // 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; diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs index e08cde0d2..d05ef14b7 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs @@ -120,6 +120,7 @@ public void ResolveExactAppleSourceCommit_classifies_swift_external_plugin_serve [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(); @@ -157,6 +158,23 @@ public void ResolveExactAppleSourceCommit_rejects_assembler_inputs_after_stateme 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); + } + [Fact] public void ResolveExactAppleSourceCommit_validates_effective_info_plist_preprocessor_flags() { From c4d5bd44f1d8a8ad08381f337fb375837acf828f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 14:12:13 +0200 Subject: [PATCH 54/78] Close Apple preprocessing and source snapshot gaps --- .../Cmdlets/PowerForgeReleaseRequestMapper.cs | 3 + .../PowerForgeReleaseRequestMapperTests.cs | 15 ++++ ...easeSourceTrustService.BuildFlagOptions.cs | 6 +- ...eleaseSourceTrustService.SourceIncludes.cs | 69 ++++++++++++++++--- ...eReleaseSourceTrustService.XcodeParsing.cs | 5 +- ....AppleSourceTrustCurrentHeadRegressions.cs | 64 +++++++++++++++++ 6 files changed, 149 insertions(+), 13 deletions(-) diff --git a/PSPublishModule/Cmdlets/PowerForgeReleaseRequestMapper.cs b/PSPublishModule/Cmdlets/PowerForgeReleaseRequestMapper.cs index 49b85a65f..5bf475083 100644 --- a/PSPublishModule/Cmdlets/PowerForgeReleaseRequestMapper.cs +++ b/PSPublishModule/Cmdlets/PowerForgeReleaseRequestMapper.cs @@ -46,6 +46,8 @@ internal static PowerForgeReleaseRequest Build( request.AppleAdoptExistingBuild = request.AppleAdoptExistingBuild || options.AppleAdoptExistingBuild; request.AppleMarketingVersion = ChooseString(request.AppleMarketingVersion, options.AppleMarketingVersion); request.AppleSourceCommit = ChooseString(request.AppleSourceCommit, options.AppleSourceCommit); + request.RequireImmutableAppleSourceSnapshot = request.RequireImmutableAppleSourceSnapshot || + !string.IsNullOrWhiteSpace(request.AppleSourceCommit); request.AppleExpectedPlanSha256 = ChooseString(request.AppleExpectedPlanSha256, options.AppleExpectedPlanSha256); request.SkipWorkspaceValidation = request.SkipWorkspaceValidation || options.SkipWorkspaceValidation; @@ -214,6 +216,7 @@ private static PowerForgeReleaseRequest Clone(PowerForgeReleaseRequest? source) AppleAction = source.AppleAction, AppleMarketingVersion = source.AppleMarketingVersion, AppleSourceCommit = source.AppleSourceCommit, + RequireImmutableAppleSourceSnapshot = source.RequireImmutableAppleSourceSnapshot, AppleExpectedPlanSha256 = source.AppleExpectedPlanSha256, AppleActionConfirmed = source.AppleActionConfirmed, AppleAdoptExistingBuild = source.AppleAdoptExistingBuild, diff --git a/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs b/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs index 8a8e853bf..a471362c8 100644 --- a/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseRequestMapperTests.cs @@ -104,6 +104,7 @@ 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); @@ -114,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/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index 5b73555fb..0db20b78d 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -23,7 +23,8 @@ internal sealed partial class AppleReleaseSourceTrustService "-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", "-fxray-attr-list", "-gcc-toolchain", "-gen-cdb-fragment-path", + "-fthinlto-distributor", "-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", @@ -59,7 +60,8 @@ internal sealed partial class AppleReleaseSourceTrustService "-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-attr-list=", "-include-pch=", + "-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=", "-stdlib++-isystem", "-iframeworkwithsysroot", "-iwithprefixbefore", "-cxx-isystem", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs index 858972655..8ea80050b 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.SourceIncludes.cs @@ -59,7 +59,7 @@ private void ValidateSourceLevelIncludes( var source = RemoveCComments(SpliceCPreprocessingLines(physicalSource)); var embedDirective = Regex.Match( source, - "(?m)^[ \\t]*(?:#|%:)[ \\t]*embed(?![A-Za-z0-9_])", + "(?m)^[ \\t\\v\\f]*(?:#|%:)[ \\t\\v\\f]*embed(?![A-Za-z0-9_])", RegexOptions.CultureInvariant); if (embedDirective.Success) { @@ -77,10 +77,10 @@ private void ValidateSourceLevelIncludes( RejectPreprocessorIncludeAliases(fullSourcePath, source); foreach (Match directive in Regex.Matches( source, - "(?m)^[ \\t]*(?:#|%:)[ \\t]*(?:include|include_next|import)[ \\t]+(?[^\\r\\n]+)", + "(?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]*(?://.*)?$", string.Empty).Trim(); + 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) @@ -200,6 +200,7 @@ private string ResolveQuotedSourceInclude( 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) { @@ -208,7 +209,7 @@ private void ValidatePreprocessorFileExistenceProbes(string repositoryRoot, stri } var embedProbe = Regex.Match( syntax, - "(?[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", @@ -337,7 +386,7 @@ private static void ValidateLanguageModuleImports(string sourcePath, string sour foreach (Match import in Regex.Matches( syntax, - "(?m)^[ \\t]*(?:#|%:)[ \\t]*pragma[ \\t]+clang[ \\t]+module[ \\t]+import[ \\t]+(?[A-Za-z_][A-Za-z0-9_.]*)", + "(?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"); @@ -378,7 +427,7 @@ private static void RejectPragmaLinkedLibraries(string sourcePath, string source var syntax = MaskCStringAndCharacterLiterals(source); if (Regex.IsMatch( syntax, - "(?m)^[ \\t]*(?:#|%:)[ \\t]*pragma[ \\t]+comment[ \\t]*\\([ \\t]*lib[ \\t]*,", + "(?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( @@ -413,7 +462,7 @@ private static void RejectPreprocessorIncludeAliases(string sourcePath, string s { if (Regex.IsMatch( source, - "(?m)^[ \\t]*(?:#|%:)[ \\t]*pragma[ \\t]+include_alias(?![A-Za-z0-9_])", + "(?m)^[ \\t\\v\\f]*(?:#|%:)[ \\t\\v\\f]*pragma[ \\t\\v\\f]+include_alias(?![A-Za-z0-9_])", RegexOptions.CultureInvariant)) { throw new InvalidOperationException( @@ -535,7 +584,7 @@ private void ValidateClangModuleMapInputs(string repositoryRoot, string moduleMa if (found is not null) return found; - var tokenPasted = Regex.Replace(masked, "[ \\t\\r\\n]*(?:##|%:%:)[ \\t\\r\\n]*", string.Empty); + var tokenPasted = Regex.Replace(masked, "[ \\t\\v\\f\\r\\n]*(?:##|%:%:)[ \\t\\v\\f\\r\\n]*", string.Empty); return FindNondeterministicCompilerIdentifier(tokenPasted); } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index d66cec38f..b77d7e404 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -220,9 +220,12 @@ private void ValidateInfoPlistBuildSettingReferences( if (preprocess) { var logical = RemoveCComments(SpliceCPreprocessingLines(contents)); + RejectPreprocessorFileSelectionAliases( + $"preprocessed INFOPLIST_FILE '{plistPath}'", + MaskCStringAndCharacterLiterals(logical)); if (Regex.IsMatch( logical, - "(?m)^[ \\t]*(?:#|%:)[ \\t]*(?:include|include_next|import|embed)(?![A-Za-z0-9_])|(?(() => + 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() { @@ -257,6 +298,29 @@ public void ResolveExactAppleSourceCommit_classifies_randomized_layout_seed_file 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("-fbuild-session-file=Session", "Session")] [InlineData("-fcodegen-data-use Codegen.cgdata", "Codegen.cgdata")] From 71940ce882e6dc6a40b032daf852a0b29a87f0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 15:21:37 +0200 Subject: [PATCH 55/78] Close Apple source and package cache races --- .../SyncAppStoreConnectScreenshotsCommand.cs | 2 +- ...StoreConnectScreenshotSyncConfigCommand.cs | 2 +- ...onnectScreenshotCmdletSourceCommitTests.cs | 24 +++ .../AppleRemotePackageMirrorLeaseTests.cs | 90 +++++++++++ PowerForge.Tests/GitClientTests.cs | 45 ++++++ .../Services/AppleReleaseSourceSnapshot.cs | 142 ++++++++++++++++++ ...eleaseSourceTrustService.RemotePackages.cs | 43 +++++- .../ExistingFilePathIdentityResolver.cs | 64 +++++++- PowerForge/Services/GitClient.cs | 3 + ...eTests.AppleSourceTrustFinalRegressions.cs | 60 ++++++++ 10 files changed, 467 insertions(+), 8 deletions(-) create mode 100644 PowerForge.Tests/AppStoreConnectScreenshotCmdletSourceCommitTests.cs create mode 100644 PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs diff --git a/PSPublishModule/Cmdlets/SyncAppStoreConnectScreenshotsCommand.cs b/PSPublishModule/Cmdlets/SyncAppStoreConnectScreenshotsCommand.cs index 729c61fbe..4d1c315d1 100644 --- a/PSPublishModule/Cmdlets/SyncAppStoreConnectScreenshotsCommand.cs +++ b/PSPublishModule/Cmdlets/SyncAppStoreConnectScreenshotsCommand.cs @@ -41,7 +41,7 @@ public sealed class SyncAppStoreConnectScreenshotsCommand : AsyncPSCmdlet /// 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; } /// 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.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/AppleRemotePackageMirrorLeaseTests.cs b/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs new file mode 100644 index 000000000..757ea9d21 --- /dev/null +++ b/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs @@ -0,0 +1,90 @@ +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"); + 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 { } + } + } + + private static ProcessRunResult Success(ProcessRunRequest request) + => new(0, string.Empty, 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 d85f8747f..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() { @@ -101,6 +118,34 @@ public async Task TrustedSystemClient_UsesFixedExecutableAndIsolatedGitEnvironme 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/Services/AppleReleaseSourceSnapshot.cs b/PowerForge/Services/AppleReleaseSourceSnapshot.cs index a5098dfc7..c7cc8d219 100644 --- a/PowerForge/Services/AppleReleaseSourceSnapshot.cs +++ b/PowerForge/Services/AppleReleaseSourceSnapshot.cs @@ -13,6 +13,8 @@ internal sealed class AppleReleaseSourceSnapshot : IDisposable private readonly string _sourceProjectRoot; private readonly string _snapshotProjectRoot; private readonly string _sourceCommit; + private IReadOnlyDictionary _trackedFileMutationIdentities = + new Dictionary(StringComparer.Ordinal); private string? _snapshotConfigPath; private bool _disposed; @@ -71,6 +73,7 @@ private AppleReleaseSourceSnapshot( snapshotRoot, snapshotProjectRoot, sourceCommit); + snapshot._trackedFileMutationIdentities = snapshot.CaptureTrackedFileMutationIdentities(); snapshot.ValidateUnchanged(); if (!string.IsNullOrWhiteSpace(plan.ExactSourceConfigPath)) snapshot.ValidateExactSourceInputs(plan.ExactSourceConfigPath!); @@ -145,6 +148,17 @@ internal void ValidateUnchanged() $"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, @@ -159,6 +173,134 @@ internal void ValidateUnchanged() 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) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs index bd2231825..01df25a9d 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -266,8 +266,9 @@ private void ValidateCheckedOutPackageRoot( } } - private void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, string revision) + internal void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, string revision) { + using var mirrorLease = AcquireRemotePackageMirrorLease(mirrorPath); var createdMirror = false; if (!Directory.Exists(mirrorPath)) { @@ -307,7 +308,9 @@ private void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, catch { var concurrentlyAvailable = RunGitAllowFailure(mirrorPath, "cat-file", "-e", revision + "^{commit}"); - if (createdMirror && !concurrentlyAvailable.Succeeded && Directory.Exists(mirrorPath)) + if (concurrentlyAvailable.Succeeded) + return; + if (createdMirror && Directory.Exists(mirrorPath)) Directory.Delete(mirrorPath, recursive: true); throw; } @@ -315,6 +318,42 @@ private void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, RunGit(mirrorPath, "cat-file", "-e", revision + "^{commit}"); } + internal static FileStream AcquireRemotePackageMirrorLease(string mirrorPath) + { + var lockPath = mirrorPath + ".lock"; + var deadline = DateTime.UtcNow.AddMinutes(2); + while (true) + { + try + { + var lease = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + try + { +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(lockPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); +#endif + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + catch (IOException) when (DateTime.UtcNow < deadline) + { + Thread.Sleep(50); + } + } + } + private static string ResolveRemotePackageCacheRoot() { var configured = Environment.GetEnvironmentVariable("POWERFORGE_APPLE_SWIFTPM_TRUST_CACHE"); diff --git a/PowerForge/Services/ExistingFilePathIdentityResolver.cs b/PowerForge/Services/ExistingFilePathIdentityResolver.cs index ca1b46e8f..ddf5ba184 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,44 @@ 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 } + 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}"; + } + + 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 +135,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 +182,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 +232,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 d260c02f3..d796fbfc2 100644 --- a/PowerForge/Services/GitClient.cs +++ b/PowerForge/Services/GitClient.cs @@ -67,6 +67,9 @@ internal static GitClient CreateTrustedSystemClient( }; 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); } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs index 6374e3ec1..7baf3b999 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustFinalRegressions.cs @@ -422,6 +422,66 @@ public void ResolveExactAppleSourceCommit_rejects_escaping_source_include() 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") From 5005bfe371c69e3291a0d195252daf147567efe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 16:21:02 +0200 Subject: [PATCH 56/78] Close Apple package producer boundaries --- ...ArchiveServiceTests.ExactSourcePackages.cs | 53 +++++++- ...easeSourceTrustService.BuildFlagOptions.cs | 4 +- .../AppleSwiftPackageBuildSnapshot.cs | 127 ++++++++++-------- ....AppleSourceTrustCurrentHeadRegressions.cs | 20 +++ 4 files changed, 144 insertions(+), 60 deletions(-) diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs index c91620468..5db9e6c1f 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs @@ -146,7 +146,41 @@ public async Task CreateArchiveAsync_exact_source_rejects_materialized_binary_ar RequireExactPackageSnapshot = true })); - Assert.Contains("materialized Swift binary-artifact tree changed", exception.Message, StringComparison.OrdinalIgnoreCase); + 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 { @@ -160,6 +194,7 @@ private sealed class ExactPackageProcessRunner : IProcessRunner private readonly bool _materializeWrongRevision; private readonly bool _materializeBinaryArtifact; private readonly bool _mutateBinaryArtifactDuringArchive; + private readonly bool _replaceBinaryArtifactAfterResolveCompletion; private readonly string _remoteSourceRoot; internal ExactPackageProcessRunner( @@ -167,12 +202,14 @@ internal ExactPackageProcessRunner( bool mutateDuringArchive = false, bool materializeWrongRevision = false, bool materializeBinaryArtifact = false, - bool mutateBinaryArtifactDuringArchive = false) + bool mutateBinaryArtifactDuringArchive = false, + bool replaceBinaryArtifactAfterResolveCompletion = false) { _mutateDuringArchive = mutateDuringArchive; _materializeWrongRevision = materializeWrongRevision; _materializeBinaryArtifact = materializeBinaryArtifact; _mutateBinaryArtifactDuringArchive = mutateBinaryArtifactDuringArchive; + _replaceBinaryArtifactAfterResolveCompletion = replaceBinaryArtifactAfterResolveCompletion; _remoteSourceRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "RemoteShared")).FullName; RunGit(_remoteSourceRoot, "init", "--quiet"); RunGit(_remoteSourceRoot, "config", "user.name", "PowerForge Tests"); @@ -242,13 +279,21 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo File.WriteAllText(Path.Combine(archive.FullName, "payload"), "approved archive"); } - return Task.FromResult(new ProcessRunResult( + var result = new ProcessRunResult( 0, "ok", string.Empty, request.FileName, TimeSpan.FromMilliseconds(1), - false)); + 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); } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index 0db20b78d..e630c9062 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -18,7 +18,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-fcodegen-data-use", "-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-list", + "-fplugin", "-fprebuilt-module-path", "-fprofile-instr-use", "-fprofile-instrument-use-path", "-fprofile-list", "-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", @@ -51,7 +51,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-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-instr-use=", + "-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=", diff --git a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs index 58e308ee5..bf60ba87d 100644 --- a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs +++ b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs @@ -8,40 +8,22 @@ internal sealed class AppleSwiftPackageBuildSnapshot : IDisposable private readonly AppleReleaseSourceTrustService _sourceTrust = new(); private readonly IReadOnlyDictionary _approvedPackageRevisions; private readonly IReadOnlyDictionary _environmentVariables; - private readonly AppleReleaseSourceMutationMonitor? _monitor; - private readonly string? _artifactSha256; + private readonly AppleReleaseSourceMutationMonitor _monitor; + private readonly string _materializedPackagesSha256; private bool _disposed; private AppleSwiftPackageBuildSnapshot( string rootPath, IReadOnlyDictionary approvedPackageRevisions, - IReadOnlyDictionary environmentVariables) + IReadOnlyDictionary environmentVariables, + AppleReleaseSourceMutationMonitor monitor, + string materializedPackagesSha256) { RootPath = rootPath; _approvedPackageRevisions = approvedPackageRevisions; _environmentVariables = environmentVariables; - _monitor = Directory.Exists(SourcePackagesPath) - ? new AppleReleaseSourceMutationMonitor( - SourcePackagesPath, - "materialized Swift package root", - "xcodebuild archive", - "Discard the archive and resolve the exact package graph again.") - : null; - try - { - _sourceTrust.ValidateMaterializedPackageCheckouts(SourcePackagesPath, _approvedPackageRevisions); - var artifactsPath = Path.Combine(SourcePackagesPath, "artifacts"); - if (Directory.Exists(artifactsPath)) - { - ValidateNoEscapingArtifactLinks(artifactsPath); - _artifactSha256 = AppleNotarizationService.ComputeArtifactSha256(artifactsPath); - } - } - catch - { - _monitor?.Dispose(); - throw; - } + _monitor = monitor; + _materializedPackagesSha256 = materializedPackagesSha256; } internal string RootPath { get; } @@ -65,6 +47,7 @@ internal static async Task CreateAsync( 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); @@ -95,29 +78,62 @@ internal static async Task CreateAsync( "-disableAutomaticPackageResolution", "-skipPackageUpdates" }; - var result = await processRunner.RunAsync( - new ProcessRunRequest( - xcodeBuildExecutable, - Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), - arguments, - timeout, - environmentVariables, - captureOutput: true, - captureError: true, - inheritEnvironment: false), - cancellationToken) - .ConfigureAwait(false); + 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); + string? materializedPackagesSha256 = 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; + materializedPackagesSha256 = 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 (string.IsNullOrWhiteSpace(materializedPackagesSha256)) + { + monitor.Dispose(); + throw new InvalidOperationException( + "xcodebuild completed without binding the exact materialized Swift package graph at its process completion boundary."); + } - return new AppleSwiftPackageBuildSnapshot(root, approvedPackageRevisions, environmentVariables); + var snapshot = new AppleSwiftPackageBuildSnapshot( + root, + approvedPackageRevisions, + environmentVariables, + monitor, + materializedPackagesSha256!); + monitor = null; + return snapshot; } catch { + monitor?.Dispose(); if (Directory.Exists(root)) Directory.Delete(root, recursive: true); throw; @@ -137,22 +153,25 @@ internal void AppendArchiveArguments(ICollection arguments) internal void ValidateUnchanged() { - _sourceTrust.ValidateMaterializedPackageCheckouts(SourcePackagesPath, _approvedPackageRevisions); - var artifactsPath = Path.Combine(SourcePackagesPath, "artifacts"); - if (_artifactSha256 is not null) - { - if (!Directory.Exists(artifactsPath)) - throw new InvalidOperationException("The materialized Swift binary-artifact tree disappeared before xcodebuild archive."); + var actual = CaptureMaterializedPackageIdentity( + _sourceTrust, + SourcePackagesPath, + _approvedPackageRevisions); + if (!actual.Equals(_materializedPackagesSha256, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The materialized Swift package root changed before xcodebuild archive."); + _monitor.ValidateNoChanges(); + } + + private static string CaptureMaterializedPackageIdentity( + AppleReleaseSourceTrustService sourceTrust, + string sourcePackagesPath, + IReadOnlyDictionary approvedPackageRevisions) + { + sourceTrust.ValidateMaterializedPackageCheckouts(sourcePackagesPath, approvedPackageRevisions); + var artifactsPath = Path.Combine(sourcePackagesPath, "artifacts"); + if (Directory.Exists(artifactsPath)) ValidateNoEscapingArtifactLinks(artifactsPath); - var actual = AppleNotarizationService.ComputeArtifactSha256(artifactsPath); - if (!actual.Equals(_artifactSha256, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("The materialized Swift binary-artifact tree changed before xcodebuild archive."); - } - else if (Directory.Exists(artifactsPath) && Directory.EnumerateFileSystemEntries(artifactsPath).Any()) - { - throw new InvalidOperationException("A materialized Swift binary-artifact tree appeared after package approval."); - } - _monitor?.ValidateNoChanges(); + return AppleNotarizationService.ComputeArtifactSha256(sourcePackagesPath); } private static void ValidateNoEscapingArtifactLinks(string artifactsRoot) @@ -219,7 +238,7 @@ public void Dispose() if (_disposed) return; _disposed = true; - _monitor?.Dispose(); + _monitor.Dispose(); if (Directory.Exists(RootPath)) Directory.Delete(RootPath, recursive: true); } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs index f5189336f..61f7e889e 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs @@ -321,6 +321,26 @@ public void ResolveExactAppleSourceCommit_classifies_xray_instrumentation_lists( 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")] From 9c9180a2a06a8d71b8c6bb7468892fa76350e140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 17:21:53 +0200 Subject: [PATCH 57/78] Close Apple source verification gaps --- Docs/PSPublishModule.AppleRelease.md | 7 +- .../Cmdlets/InvokePowerForgeReleaseCommand.cs | 2 +- .../AppleReleaseSourceMutationMonitorTests.cs | 43 ++ ...ServiceTests.AppleExactSourceNonArchive.cs | 44 ++ ...ts.AppleHostedReviewRecoveryRegressions.cs | 638 ++++++++++++++++++ ...rviceTests.AppleHostedReviewRegressions.cs | 633 ----------------- .../AppleReleaseSourceMutationMonitor.cs | 11 +- .../Services/AppleReleaseSourceSnapshot.cs | 39 +- ...viceTests.AppleSourceTrust.PackageGraph.cs | 595 ++++++++++++++++ ...dExecutionServiceTests.AppleSourceTrust.cs | 587 ---------------- ...rceTrustMergeRegressions.CompilerInputs.cs | 445 ++++++++++++ ...eTests.AppleSourceTrustMergeRegressions.cs | 438 ------------ 12 files changed, 1818 insertions(+), 1664 deletions(-) create mode 100644 PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs create mode 100644 PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.CompilerInputs.cs diff --git a/Docs/PSPublishModule.AppleRelease.md b/Docs/PSPublishModule.AppleRelease.md index 3db3452ad..8145c5c4f 100644 --- a/Docs/PSPublishModule.AppleRelease.md +++ b/Docs/PSPublishModule.AppleRelease.md @@ -343,9 +343,10 @@ even when the matching receipt is present. This prevents a writable local journa 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 40-character SHA -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: +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 diff --git a/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs b/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs index 59474b3df..82931cc16 100644 --- a/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs +++ b/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs @@ -260,7 +260,7 @@ public sealed partial class InvokePowerForgeReleaseCommand : PSCmdlet [Parameter] public string? AppleVersion { get; set; } - /// Binds Apple release evidence to an exact 40-character source commit. + /// Binds Apple release evidence to a full 40-character SHA-1 or 64-character SHA-256 source commit. [Parameter] public string? AppleSourceCommit { get; set; } diff --git a/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs b/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs index 74b403e99..5ada53fc9 100644 --- a/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs +++ b/PowerForge.Tests/AppleReleaseSourceMutationMonitorTests.cs @@ -110,4 +110,47 @@ public async Task CaptureExpectedProducerOutput_rejects_replacement_during_post_ 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/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs new file mode 100644 index 000000000..10801c293 --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs @@ -0,0 +1,44 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [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); + } + } +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs new file mode 100644 index 000000000..3192c91fd --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs @@ -0,0 +1,638 @@ +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("history-under-archive-root")] + [InlineData("plan-overwrites-project")] + 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 "history-under-archive-root": + automation.ReceiptHistoryPath = "build/powerforge/apple/archives/receipts"; + break; + case "plan-overwrites-project": + automation.PlanReceiptPath = "CasaRay.xcodeproj/project.pbxproj"; + 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); + } + 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 index fec2d338d..2066c9988 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRegressions.cs @@ -558,637 +558,4 @@ private static string RunSnapshotGit(string root, params string[] arguments) return result.StdOut; } - [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("history-under-archive-root")] - [InlineData("plan-overwrites-project")] - 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 "history-under-archive-root": - automation.ReceiptHistoryPath = "build/powerforge/apple/archives/receipts"; - break; - case "plan-overwrites-project": - automation.PlanReceiptPath = "CasaRay.xcodeproj/project.pbxproj"; - 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); - } - 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/Services/AppleReleaseSourceMutationMonitor.cs b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs index 904521bc0..343800d96 100644 --- a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs +++ b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs @@ -106,13 +106,22 @@ internal T CaptureExpectedProducerOutput(Func capture, string producerDesc 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}"); } - Interlocked.Exchange(ref _enforceMutations, 1); } else { diff --git a/PowerForge/Services/AppleReleaseSourceSnapshot.cs b/PowerForge/Services/AppleReleaseSourceSnapshot.cs index c7cc8d219..a27ca9fe2 100644 --- a/PowerForge/Services/AppleReleaseSourceSnapshot.cs +++ b/PowerForge/Services/AppleReleaseSourceSnapshot.cs @@ -38,9 +38,15 @@ private AppleReleaseSourceSnapshot( internal static AppleReleaseSourceSnapshot? CreateIfRequired(PowerForgeAppleReleasePlan plan) { - if (!plan.Archive || !plan.RequireImmutableSourceSnapshot || string.IsNullOrWhiteSpace(plan.SourceCommit)) + 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"); @@ -96,6 +102,37 @@ private AppleReleaseSourceSnapshot( } } + 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"); + observedCommit = new AppleReleaseSourceTrustService() + .Capture(repositoryRoot, configPath) + .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); diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs new file mode 100644 index 000000000..7ed412b1b --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs @@ -0,0 +1,595 @@ +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_clean_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("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void AppleSourceSnapshot_rejects_smudged_bytes_created_only_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(() => + { + using var _ = AppleReleaseSourceSnapshot.CreateIfRequired(plan); + }); + + Assert.Contains("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveExactAppleSourceCommit_rejects_filtered_bytes_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("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Filtered.swift", exception.Message, StringComparison.Ordinal); + } + + [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.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs index 2e7d9a50a..5dd8ef658 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.cs @@ -720,591 +720,4 @@ public void ResolveExactAppleSourceCommit_rejects_ignored_source_inside_archive_ Assert.Contains("Ignored Apple build input", exception.Message, StringComparison.OrdinalIgnoreCase); } - [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_clean_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("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void AppleSourceSnapshot_rejects_smudged_bytes_created_only_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(() => - { - using var _ = AppleReleaseSourceSnapshot.CreateIfRequired(plan); - }); - - Assert.Contains("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void ResolveExactAppleSourceCommit_rejects_filtered_bytes_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("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Filtered.swift", exception.Message, StringComparison.Ordinal); - } - - [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.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 index c7e3ba7ad..9f8ddfbbe 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMergeRegressions.cs @@ -409,442 +409,4 @@ public void ResolveExactAppleSourceCommit_rejects_c_trigraph_preprocessor_direct Assert.Contains("??=", exception.Message, StringComparison.Ordinal); } - [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 - })); - } } From c4aa559b96a9c7d103e9ebb8dc2cba824102eda4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 18:25:53 +0200 Subject: [PATCH 58/78] Fix remaining Apple release integrity gaps --- ...tarizationServiceTests.AcceptedBoundary.cs | 102 ++++++++++++++++++ ...ServiceTests.AppleExactSourceNonArchive.cs | 46 ++++++++ ...aseServiceTests.AppleNotarizationResume.cs | 18 ++++ .../Services/AppleNotarizationService.cs | 87 +++++++++++++-- .../AppleReleaseSourceMutationMonitor.cs | 39 ++++++- ...easeSourceTrustService.BuildFlagOptions.cs | 20 +++- .../PowerForgeReleaseService.AppleResume.cs | 9 +- ...owerForgeReleaseService.AppleVersioning.cs | 7 ++ ....AppleSourceTrustCurrentHeadRegressions.cs | 23 ++++ 9 files changed, 334 insertions(+), 17 deletions(-) create mode 100644 PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs new file mode 100644 index 000000000..0afe87828 --- /dev/null +++ b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs @@ -0,0 +1,102 @@ +namespace PowerForge.Tests; + +public sealed partial class AppleNotarizationServiceTests +{ + [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("changed before stapling", 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("changed before stapling", 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); + } + } +} diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs index 10801c293..95a672913 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs @@ -2,6 +2,52 @@ 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)] diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 62ac63842..b5825dcfd 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -4,6 +4,24 @@ 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() { diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 065bf21bc..e54cfb434 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -118,6 +118,13 @@ public async Task NotarizeAsync( ProcessRunResult submission; string? submissionId; string? status; + using var submissionMonitor = resumed + ? null + : new AppleReleaseSourceMutationMonitor( + submissionSnapshot.RootPath, + "private Apple notarization submission", + "notarytool", + "Do not resubmit until the accepted submission has been reconciled."); if (resumed) { submissionId = request.AcceptedSubmissionId!.Trim(); @@ -138,17 +145,24 @@ public async Task NotarizeAsync( "notarytool", "submit", submittedPath, "--wait", "--output-format", "json" }; submitArguments.AddRange(authentication); - using var submissionMonitor = new AppleReleaseSourceMutationMonitor( - submissionSnapshot.RootPath, - "private Apple notarization submission", - "notarytool", - "Do not resubmit until the accepted submission has been reconciled."); submissionSnapshot.CompleteSubmissionCapture(artifactSha256); submission = await RunAsync(xcrunExecutable, submissionArtifactPath, submitArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); (submissionId, status) = ParseSubmission(submission); + } + using var acceptedArtifactMonitor = !resumed && + submission.Succeeded && + 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) + { try { - submissionMonitor.ValidateNoChanges(); + submissionMonitor!.ValidateNoChanges(); var observedSubmissionSha256 = ComputeFileSha256(submittedPath); if (!observedSubmissionSha256.Equals(submissionSha256, StringComparison.OrdinalIgnoreCase)) { @@ -210,13 +224,44 @@ public async Task NotarizeAsync( using var postStapleMonitor = submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && request.Staple - ? new AppleReleaseSourceMutationMonitor( - submissionSnapshot.RootPath, + ? 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 (staplingCompleted) @@ -231,6 +276,13 @@ public async Task NotarizeAsync( } else { + 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, @@ -476,6 +528,25 @@ private async Task RunAsync( return result; } + 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 ResolveAppleToolExecutable( string? executable, string defaultName, diff --git a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs index 343800d96..325ffb6eb 100644 --- a/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs +++ b/PowerForge/Services/AppleReleaseSourceMutationMonitor.cs @@ -9,6 +9,8 @@ internal sealed class AppleReleaseSourceMutationMonitor : IDisposable { 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; @@ -20,10 +22,14 @@ internal AppleReleaseSourceMutationMonitor( string scopeDescription = "exact-source Apple build snapshot", string readerDescription = "xcodebuild", string failureInstruction = "Discard the archive and rebuild from a new snapshot.", - bool enableImmediately = true) { + 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, @@ -152,11 +158,42 @@ internal T CaptureExpectedProducerOutput(Func capture, string producerDesc 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); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index e630c9062..b9c837899 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -10,9 +10,14 @@ internal sealed partial class AppleReleaseSourceTrustService "--libomptarget-amdgpu-bc-path", "--libomptarget-nvptx-bc-path", "--libomptarget-spirv-bc-path", "--ptxas-path", "--rocm-device-lib-path", "--rocm-path", "--source-metadata-list", "--sysroot", "--warning-suppression-mappings", - "-access-notes-path", "-backup-module-interface-path", "-candidate-module-file", "-explicit-swift-module-map-file", - "-module-cache-path", "-prebuilt-module-cache-path", "-previous-module-installname-map-file", - "-sdk-module-cache-path", + "-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", "-fexperimental-sanitize-metadata-ignorelist", "-fmemory-profile-use", @@ -42,8 +47,13 @@ internal sealed partial class AppleReleaseSourceTrustService "--warning-suppression-mappings=", "--rocm-device-lib-path=", "--hipstdpar-thrust-path=", "--hipstdpar-prim-path=", "--hipspv-pass-plugin=", "--source-metadata-list=", "--hipstdpar-path=", "--cuda-path=", "--hip-path=", "--ptxas-path=", "--rocm-path=", "--sysroot=", - "-previous-module-installname-map-file=", "-explicit-swift-module-map-file=", - "-access-notes-path=", + "-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=", "-fexperimental-sanitize-metadata-ignorelist=", "-fms-secure-hotpatch-functions-file=", diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 13fdf9cd5..71513fc33 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -30,7 +30,7 @@ private string[] GetProtectedAppleRecoveryArtifactPaths(PowerForgeAppleReleasePl var accepted = string.Equals(target.NotarizationStatus, "Accepted", StringComparison.OrdinalIgnoreCase); var stapleCompleted = !plan.DirectDistribution.Staple || - (target.Stapled == true && target.StapleValidated == true); + HasDurablePublishedStaple(target); var assessmentCompleted = !plan.DirectDistribution.Assess || target.GatekeeperAccepted == true; if (!accepted || (stapleCompleted && assessmentCompleted) || @@ -358,7 +358,7 @@ private bool TryResumeDirectAppleNotarization( if (!File.Exists(artifactPath) && !Directory.Exists(artifactPath)) return false; var stapleCompleted = !plan.DirectDistribution.Staple || - (prior.Stapled == true && prior.StapleValidated == true); + HasDurablePublishedStaple(prior); var assessmentCompleted = !plan.DirectDistribution.Assess || prior.GatekeeperAccepted == true; var completed = stapleCompleted && assessmentCompleted; @@ -415,7 +415,7 @@ static ProcessRunResult CompletedStep(string message, string executable) prior.NotarizationSubmissionId, prior.DirectArtifactSha256, prior.NotarizationSubmissionSha256, - prior.Stapled == true); + HasDurablePublishedStaple(prior)); result.ResumedAcceptedNotarization = true; result.SkippedSteps = MergeAppleSkippedSteps( result.SkippedSteps, @@ -426,6 +426,9 @@ static ProcessRunResult CompletedStep(string message, string executable) return true; } + internal static bool HasDurablePublishedStaple(PowerForgeAppleReleaseTargetReceipt target) + => target.Stapled == true && target.StapleValidated == true; + internal static bool IsMatchingDirectReceiptTarget( PowerForgeAppleReleasePlan plan, PowerForgeAppleReleaseTargetReceipt target, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs index bd3e17a4c..66a44f924 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleVersioning.cs @@ -17,7 +17,14 @@ internal sealed partial class PowerForgeReleaseService } 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 is not null && diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs index 61f7e889e..36be919ab 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs @@ -4,6 +4,29 @@ 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("__has_embed(\"/tmp/payload.bin\")", "__has_embed")] [InlineData("__has_ ## embed(\"/tmp/payload.bin\")", "__has_embed")] From bb55fc5350e0844d7937618a95f15f5f888c8c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 19:16:35 +0200 Subject: [PATCH 59/78] Bind ThinLTO index inputs to exact source --- ...easeSourceTrustService.BuildFlagOptions.cs | 4 +- ....AppleSourceTrustCurrentHeadRegressions.cs | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index b9c837899..4abc52292 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -28,7 +28,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-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", "-fxray-always-instrument", "-fxray-attr-list", "-fxray-never-instrument", + "-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", @@ -65,7 +65,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-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=", "-gen-cdb-fragment-path=", "-index-unit-output-path=", + "-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=", diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs index 36be919ab..e26b642ad 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs @@ -321,6 +321,45 @@ public void ResolveExactAppleSourceCommit_classifies_randomized_layout_seed_file 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")] From 054398f85e4168e713af3aae90c2f02b1c3e84d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 20:00:40 +0200 Subject: [PATCH 60/78] Preserve exact source and private snapshot identity --- ...entTests.ScreenshotIntegrityRegressions.cs | 31 +++++++ ...rizationServiceTests.PackagingIntegrity.cs | 26 ++++++ ...ServiceTests.AppleExactSourceNonArchive.cs | 38 +++++++++ ...eTests.AppleIntegrityClosureRegressions.cs | 82 +++++++++++++++++++ ...AppStoreConnectScreenshotUploadSnapshot.cs | 20 ++++- .../Services/AppleArchiveUploadSnapshot.cs | 76 ++++++++++++++++- .../AppleNotarizationInputSnapshot.cs | 23 +++++- .../ExistingFilePathIdentityResolver.cs | 78 +++++++++++++++++- .../Services/PowerForgeReleaseService.cs | 3 + ...ePublishExecutionService.UnifiedRelease.cs | 3 + ...ExecutionServiceTests.ProductGateReview.cs | 1 + 11 files changed, 374 insertions(+), 7 deletions(-) diff --git a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs index b888f1746..4481f9d9d 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs @@ -315,6 +315,37 @@ public async Task ScreenshotUploadSnapshot_UsesPrivateFileBackedRanges() } } + [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 async Task UploadScreenshotAsync_RejectsPrivateSnapshotMutationDuringReservation() { diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs index 4ee439bb0..064a20195 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs @@ -24,6 +24,32 @@ public void FileSnapshot_rejects_mutation_before_submission_monitor_takes_owners } } + [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 async Task NotarizeAsync_RejectsTransientAppMutationWhileDittoCreatesSubmissionZip() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs index 95a672913..4c63a76e7 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleExactSourceNonArchive.cs @@ -87,4 +87,42 @@ public void Execute_NonArchiveAppleAction_RejectsSourceCommitThatIsNotCurrentHea 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.AppleIntegrityClosureRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs index f716185dc..fc853c37e 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs @@ -173,6 +173,88 @@ public void Execute_AppleUpload_rejects_transient_private_archive_snapshot_mutat } } + [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.DoesNotContain( + new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), + receipt => receipt.OperationPhase == "UploadAttested"); + } 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); + } + } + [Theory] [InlineData("team")] [InlineData("signing")] diff --git a/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs b/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs index d05416685..3880e6dc9 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs @@ -10,19 +10,22 @@ namespace PowerForge; 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 md5, + string mutationIdentity) { RootPath = rootPath; FilePath = filePath; Length = length; Sha256 = sha256; Md5 = md5; + _mutationIdentity = mutationIdentity; } internal string RootPath { get; } @@ -66,12 +69,16 @@ internal static AppStoreConnectScreenshotUploadSnapshot Capture(string sourcePat $"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, - ComputeHash(snapshotPath, MD5.Create)); + md5, + ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( + snapshotPath, + "private screenshot upload snapshot")); } catch { @@ -95,6 +102,15 @@ internal void ValidateUnchanged() 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) diff --git a/PowerForge/Services/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs index 4ab0f1553..f567eb1fb 100644 --- a/PowerForge/Services/AppleArchiveUploadSnapshot.cs +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -3,12 +3,17 @@ 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 IReadOnlyDictionary _fileMutationIdentities; private bool _disposed; - private AppleArchiveUploadSnapshot(string rootPath, string archivePath) + private AppleArchiveUploadSnapshot( + string rootPath, + string archivePath, + IReadOnlyDictionary fileMutationIdentities) { RootPath = rootPath; ArchivePath = archivePath; + _fileMutationIdentities = fileMutationIdentities; } internal string RootPath { get; } @@ -37,7 +42,10 @@ internal static AppleArchiveUploadSnapshot Create(string archivePath, string exp throw new InvalidOperationException( $"The private Apple upload snapshot does not match the approved archive. Expected '{expectedSha256}', received '{actual}'."); } - return new AppleArchiveUploadSnapshot(root, snapshotPath); + return new AppleArchiveUploadSnapshot( + root, + snapshotPath, + CaptureFileMutationIdentities(snapshotPath)); } catch { @@ -55,8 +63,72 @@ internal void ValidateUnchanged(string expectedSha256) throw new InvalidOperationException( $"The private Apple upload snapshot changed while xcodebuild was reading it. Expected '{expectedSha256}', received '{actual}'. Discard the upload/export result and inspect remote state before retrying."); } + + var currentMutationIdentities = CaptureFileMutationIdentities(ArchivePath); + if (_fileMutationIdentities.Count != currentMutationIdentities.Count || + _fileMutationIdentities.Any(pair => + !currentMutationIdentities.TryGetValue(pair.Key, out var current) || + !string.Equals(pair.Value, current, 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."); + } + } + + private static IReadOnlyDictionary CaptureFileMutationIdentities(string archivePath) + { + var result = new Dictionary(GetPathComparer()); + 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 private Apple upload archive snapshot file '{files[index].RelativePath}' has {hardLinkCounts[index]} hard links. " + + "Upload snapshots require one private 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 private Apple upload archive snapshot contains duplicate platform-equivalent file paths at '{files[index].RelativePath}'.", + exception); + } + } + return result; } + private static StringComparer GetPathComparer() + => Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + public void Dispose() { if (_disposed) diff --git a/PowerForge/Services/AppleNotarizationInputSnapshot.cs b/PowerForge/Services/AppleNotarizationInputSnapshot.cs index febb30bbc..08b448775 100644 --- a/PowerForge/Services/AppleNotarizationInputSnapshot.cs +++ b/PowerForge/Services/AppleNotarizationInputSnapshot.cs @@ -4,6 +4,7 @@ namespace PowerForge; internal sealed class AppleNotarizationInputSnapshot : IDisposable { private readonly AppleArchiveUploadSnapshot? _directorySnapshot; + private readonly string? _fileMutationIdentity; private AppleReleaseSourceMutationMonitor? _fileSnapshotMonitor; private bool _disposed; @@ -11,12 +12,14 @@ private AppleNotarizationInputSnapshot( string rootPath, string artifactPath, AppleArchiveUploadSnapshot? directorySnapshot, - AppleReleaseSourceMutationMonitor? fileSnapshotMonitor = null) + AppleReleaseSourceMutationMonitor? fileSnapshotMonitor = null, + string? fileMutationIdentity = null) { RootPath = rootPath; ArtifactPath = artifactPath; _directorySnapshot = directorySnapshot; _fileSnapshotMonitor = fileSnapshotMonitor; + _fileMutationIdentity = fileMutationIdentity; } internal string RootPath { get; } @@ -36,6 +39,15 @@ internal void CompleteSubmissionCapture(string expectedSha256) 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 { @@ -208,7 +220,14 @@ internal static AppleNotarizationInputSnapshot Create(string artifactPath, strin "private Apple notarization file snapshot", "submission hashing", "Discard the snapshot and recreate it from the approved artifact."); - return new AppleNotarizationInputSnapshot(root, snapshotPath, directorySnapshot: null, monitor); + return new AppleNotarizationInputSnapshot( + root, + snapshotPath, + directorySnapshot: null, + fileSnapshotMonitor: monitor, + fileMutationIdentity: ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( + snapshotPath, + "private Apple notarization file snapshot")); } catch { diff --git a/PowerForge/Services/ExistingFilePathIdentityResolver.cs b/PowerForge/Services/ExistingFilePathIdentityResolver.cs index ddf5ba184..a0bd8062c 100644 --- a/PowerForge/Services/ExistingFilePathIdentityResolver.cs +++ b/PowerForge/Services/ExistingFilePathIdentityResolver.cs @@ -55,6 +55,83 @@ internal ExistingFilePhysicalStatus(string identity, string changeToken) 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); @@ -64,7 +141,6 @@ private static ExistingFilePhysicalStatus ReadWindowsFileStatus(SafeFileHandle h out var information, checked((uint)Marshal.SizeOf()))) throw new Win32Exception(Marshal.GetLastWin32Error()); - var changeToken = $"{information.ChangeTime:X16}"; return new ExistingFilePhysicalStatus(identity, changeToken); } diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 2584874a6..c2f9fa073 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -971,6 +971,9 @@ internal PowerForgeReleaseResult PublishBuiltReleaseOutputs( AppleAction = request.AppleAction, AppleMarketingVersion = request.AppleMarketingVersion, AppleSourceCommit = request.AppleSourceCommit, + RequireImmutableAppleSourceSnapshot = + request.RequireImmutableAppleSourceSnapshot || + !string.IsNullOrWhiteSpace(request.AppleSourceCommit), AppleExpectedPlanSha256 = request.AppleExpectedPlanSha256, AppleExpectedArchiveSha256ByTarget = new Dictionary( request.AppleExpectedArchiveSha256ByTarget, diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs b/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs index c3f6ec795..b21c15950 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.UnifiedRelease.cs @@ -307,6 +307,9 @@ internal static PowerForgeReleaseRequest CreateUnifiedPublishRequest( 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)) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs index 4bf098ab6..ceebf76df 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.ProductGateReview.cs @@ -345,6 +345,7 @@ public void CreateUnifiedPublishRequest_preserves_checkpointed_Apple_provenance_ 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", From 98131ced50cd76bbdece64e5a7e1faa8f8c25a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 21:03:38 +0200 Subject: [PATCH 61/78] Harden Apple source input binding --- .../Cmdlets/InvokePowerForgeReleaseCommand.cs | 19 +--- PowerForge.Cli/Program.Helpers.IOAndJson.cs | 3 +- PowerForge/Models/PowerForgeRelease.cs | 9 ++ .../Services/AppleReleaseSourceSnapshot.cs | 26 ++++- ...eSourceTrustService.BuildFlagFileInputs.cs | 102 ++++++++++++++++++ ...pleReleaseSourceTrustService.BuildFlags.cs | 8 ++ ...eReleaseSourceTrustService.XcodeParsing.cs | 1 + .../AppleReleaseSourceTrustService.cs | 18 +++- .../Services/PowerForgeReleaseService.cs | 29 ++++- ....AppleSourceTrustCurrentHeadRegressions.cs | 72 +++++++++++++ 10 files changed, 259 insertions(+), 28 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs diff --git a/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs b/PSPublishModule/Cmdlets/InvokePowerForgeReleaseCommand.cs index 82931cc16..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; @@ -683,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) { 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/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs index 897cdf4bf..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; } @@ -38,6 +44,7 @@ internal sealed class PowerForgeReleaseRequest 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; @@ -545,6 +552,8 @@ internal sealed class PowerForgeAppleReleasePlan internal string? ExactSourceConfigPath { get; set; } + internal string? ExactSourceConfigSha256 { get; set; } + public string? MetadataConfigPath { get; set; } public string[] MetadataConfigPaths { get; set; } = Array.Empty(); diff --git a/PowerForge/Services/AppleReleaseSourceSnapshot.cs b/PowerForge/Services/AppleReleaseSourceSnapshot.cs index a27ca9fe2..43bb47c2d 100644 --- a/PowerForge/Services/AppleReleaseSourceSnapshot.cs +++ b/PowerForge/Services/AppleReleaseSourceSnapshot.cs @@ -16,6 +16,7 @@ internal sealed class AppleReleaseSourceSnapshot : IDisposable private IReadOnlyDictionary _trackedFileMutationIdentities = new Dictionary(StringComparer.Ordinal); private string? _snapshotConfigPath; + private string? _expectedConfigSha256; private bool _disposed; private AppleReleaseSourceSnapshot( @@ -82,7 +83,7 @@ private AppleReleaseSourceSnapshot( snapshot._trackedFileMutationIdentities = snapshot.CaptureTrackedFileMutationIdentities(); snapshot.ValidateUnchanged(); if (!string.IsNullOrWhiteSpace(plan.ExactSourceConfigPath)) - snapshot.ValidateExactSourceInputs(plan.ExactSourceConfigPath!); + snapshot.ValidateExactSourceInputs(plan.ExactSourceConfigPath!, plan.ExactSourceConfigSha256); return snapshot; } catch @@ -114,9 +115,9 @@ private static void ValidateCurrentSource(PowerForgeAppleReleasePlan plan) { var configPath = Path.GetFullPath(plan.ExactSourceConfigPath!); EnsureContained(repositoryRoot, configPath, "Apple exact-source config path"); - observedCommit = new AppleReleaseSourceTrustService() - .Capture(repositoryRoot, configPath) - .SourceCommit; + var trust = new AppleReleaseSourceTrustService().Capture(repositoryRoot, configPath); + ValidateExpectedConfigurationSha256(trust.ExactConfigurationSha256, plan.ExactSourceConfigSha256); + observedCommit = trust.SourceCommit; } else { @@ -159,15 +160,17 @@ private string MapRepositoryPath(string sourcePath) return mapped; } - private void ValidateExactSourceInputs(string configPath) + 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( @@ -175,6 +178,19 @@ private void ValidateMappedExactSourceInputs() } } + 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") diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs new file mode 100644 index 000000000..959cd86b0 --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs @@ -0,0 +1,102 @@ +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)) + 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.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index 04fe7f6b5..edb56ec31 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -20,6 +20,7 @@ private void ValidateBuildFlagInputPaths( source, responseFiles) .ToArray(); + ValidateHeaderSearchPathInputs(projectDirectory, expandedTokens, key); if (!key.Split('[')[0].Trim().Equals("COMPILER_FLAGS", StringComparison.OrdinalIgnoreCase) && TryReadCompilerLanguageOverride(expandedTokens, out _)) { @@ -44,6 +45,7 @@ private void ValidateBuildFlagInputPaths( 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)) @@ -393,6 +395,12 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke 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)) { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs index b77d7e404..ba986f894 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.XcodeParsing.cs @@ -86,6 +86,7 @@ private void ValidateBuildSettingAssignments( 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}"); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 116950610..0666789a3 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -91,7 +91,11 @@ private AppleReleaseSourceTrustSnapshot CaptureCore(string repositoryRoot, strin 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); + return new AppleReleaseSourceTrustSnapshot( + sourceCommitAfterValidation, + generatedOutputs, + releaseConfigContent, + ComputeSha256(releaseConfigBytes)); } internal void ValidateAfterBuild( @@ -728,6 +732,12 @@ private ProcessRunResult RunGitAllowFailure(string repositoryRoot, params string .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; } @@ -737,11 +747,13 @@ internal sealed class AppleReleaseSourceTrustSnapshot internal AppleReleaseSourceTrustSnapshot( string sourceCommit, string[] generatedOutputPaths, - string? exactConfigurationContent = null) + string? exactConfigurationContent = null, + string? exactConfigurationSha256 = null) { SourceCommit = sourceCommit; GeneratedOutputPaths = generatedOutputPaths; ExactConfigurationContent = exactConfigurationContent; + ExactConfigurationSha256 = exactConfigurationSha256; } internal string SourceCommit { get; } @@ -749,4 +761,6 @@ internal AppleReleaseSourceTrustSnapshot( internal string[] GeneratedOutputPaths { get; } internal string? ExactConfigurationContent { get; } + + internal string? ExactConfigurationSha256 { get; } } diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index c2f9fa073..0c938a11d 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -256,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)) @@ -929,7 +935,16 @@ internal static PowerForgeReleaseSpec LoadConfiguration(string configPath) if (!File.Exists(fullPath)) throw new FileNotFoundException($"Unified release config was not found: {fullPath}", fullPath); - return LoadConfigurationContent(File.ReadAllText(fullPath), 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) @@ -940,6 +955,12 @@ internal static PowerForgeReleaseSpec LoadConfigurationContent(string content, s 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( PowerForgeReleaseSpec spec, PowerForgeReleaseRequest request, @@ -1766,6 +1787,11 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( 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, @@ -2502,6 +2528,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleArchiveCheckpoint( SourceCommit = plan.SourceCommit, RequireImmutableSourceSnapshot = plan.RequireImmutableSourceSnapshot, ExactSourceConfigPath = plan.ExactSourceConfigPath, + ExactSourceConfigSha256 = plan.ExactSourceConfigSha256, Archive = true, Upload = false, XcodeBuildExecutable = plan.XcodeBuildExecutable, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs index e26b642ad..00a6769fc 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs @@ -1,3 +1,4 @@ +using PowerForge; using PowerForgeStudio.Orchestrator.Queue; namespace PowerForgeStudio.Tests; @@ -520,6 +521,77 @@ public void ResolveExactAppleSourceCommit_rejects_other_unbound_language_module_ 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, From c35d5369d709e47442d4b4b14db4a0439cb08231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 21:35:28 +0200 Subject: [PATCH 62/78] Serialize Apple receipt journal updates --- .../AppleReleaseReceiptStoreTests.cs | 50 ++++++ .../AppleReleaseReceiptJournalLease.cs | 149 ++++++++++++++++++ .../Services/AppleReleaseReceiptStore.cs | 3 +- 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 PowerForge/Services/AppleReleaseReceiptJournalLease.cs diff --git a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs index 12fa705e7..b423e0dc6 100644 --- a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -30,6 +30,45 @@ public void WriteAttempt_PreservesEveryAttemptAndChainsTheLatestReceipt() } } + [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 ReadAll_RejectsTamperedImmutableReceipt() { @@ -295,6 +334,17 @@ 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); } diff --git a/PowerForge/Services/AppleReleaseReceiptJournalLease.cs b/PowerForge/Services/AppleReleaseReceiptJournalLease.cs new file mode 100644 index 000000000..24b551a9e --- /dev/null +++ b/PowerForge/Services/AppleReleaseReceiptJournalLease.cs @@ -0,0 +1,149 @@ +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); + if (Path.DirectorySeparatorChar == '\\') + fullPath = fullPath.ToUpperInvariant(); + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(fullPath)); + var key = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + return Path.Combine( + Path.GetTempPath(), + "PowerForge", + "apple-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 index 4a0c47a5a..d9a2eda37 100644 --- a/PowerForge/Services/AppleReleaseReceiptStore.cs +++ b/PowerForge/Services/AppleReleaseReceiptStore.cs @@ -81,7 +81,6 @@ internal void WriteAttempt(PowerForgeAppleReleasePlan plan, PowerForgeAppleRelea EnsureSafeOutputPath(plan.ProjectRoot, plan.ReceiptPath, "AppleApps.Automation.ReceiptPath"); EnsureSafeOutputPath(plan.ProjectRoot, plan.ReceiptHistoryPath, "AppleApps.Automation.ReceiptHistoryPath"); - var previousReceipt = ReadAll(plan).FirstOrDefault(); var suppliedAttemptId = receipt.AttemptId; var normalizedAttemptId = string.IsNullOrWhiteSpace(suppliedAttemptId) @@ -94,6 +93,8 @@ internal void WriteAttempt(PowerForgeAppleReleasePlan plan, PowerForgeAppleRelea 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"); From 47345a460f4c0fbe43eede0f3681583d9f81c155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 21:41:37 +0200 Subject: [PATCH 63/78] Require complete screenshot approval sets --- .../AppStoreConnectScreenshotApprovalTests.cs | 64 +++++++++++++++++++ ...oreConnectScreenshotSyncConfigValidator.cs | 53 +++++++++++++-- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs b/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs index a0567a59b..c50bc7d97 100644 --- a/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs +++ b/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs @@ -266,6 +266,70 @@ public void Validate_MatchesRecursiveApprovalEntriesBySetRelativePath() } } + [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 { } + } + } + private static AppStoreConnectScreenshotSyncSpec CreateSpec() => new() { diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs index 434036cdd..32df63a6c 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs @@ -133,19 +133,49 @@ 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) && - PathsEqual(ResolvePath(baseDirectory, candidate.File), Path.GetFullPath(file))); - 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)."); @@ -158,6 +188,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(); } From 99d3bcf7afaa51b550fac7574a30502b5a36e1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 22:06:35 +0200 Subject: [PATCH 64/78] Harden receipt leases and backup cleanup --- .../AppleArchiveBuildSnapshotTests.cs | 43 +++++++++++++++++++ .../AppleReleaseReceiptStoreTests.cs | 25 +++++++++++ PowerForge/Services/AppleArtifactCopy.cs | 40 +++++++++++++++++ .../AppleReleaseReceiptJournalLease.cs | 8 ++-- 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index 3766c4e4a..cefe70cd9 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -122,6 +122,49 @@ public void RemoveBackupIfUnchanged_preserves_concurrent_backup_replacement() } } + [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 DirectExport_publish_rejects_artifact_replaced_after_xcodebuild_identity_was_observed() { diff --git a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs index b423e0dc6..308b887fe 100644 --- a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -69,6 +69,31 @@ public async Task WriteAttempt_SerializesConcurrentWritersAcrossStoreInstances() } } + [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 ReadAll_RejectsTamperedImmutableReceipt() { diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs index 598c227d3..12dd939e4 100644 --- a/PowerForge/Services/AppleArtifactCopy.cs +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -287,6 +287,7 @@ internal static void RemovePublishedPathIfUnchanged( if (isDirectory) { + PrepareOwnedDirectoryForDeletion(quarantinedArtifactPath); Directory.Delete(quarantinedArtifactPath, recursive: true); Directory.Delete(quarantinePath); } @@ -315,6 +316,45 @@ internal static void RemovePublishedPathIfUnchanged( } } + /// + /// 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. + /// + 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) { diff --git a/PowerForge/Services/AppleReleaseReceiptJournalLease.cs b/PowerForge/Services/AppleReleaseReceiptJournalLease.cs index 24b551a9e..27b6739a0 100644 --- a/PowerForge/Services/AppleReleaseReceiptJournalLease.cs +++ b/PowerForge/Services/AppleReleaseReceiptJournalLease.cs @@ -134,10 +134,12 @@ internal static string CreateLockPath(string resourcePath) using var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(fullPath)); 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( - Path.GetTempPath(), - "PowerForge", - "apple-receipt-journal-locks", + resourceDirectory, + ".powerforge-receipt-journal-locks", $"{key}.lock"); } From e0bf8ffa45592a59a730ac6e5f0fde7140e32e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 11 Aug 2026 23:16:16 +0200 Subject: [PATCH 65/78] Fix Apple output and filtered source integrity --- .../AppleArchiveBuildSnapshotTests.cs | 45 ++++++++++++ ...ts.AppleHostedReviewRecoveryRegressions.cs | 10 +++ PowerForge/Services/AppleArtifactCopy.cs | 19 ++++-- ...AppleReleaseSourceTrustService.GitBlobs.cs | 68 +++++++++++++++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 18 +++-- .../AppleReleaseSourceTrustService.cs | 49 ++++--------- ...PowerForgeReleaseService.AppleIntegrity.cs | 18 ++--- ...viceTests.AppleSourceTrust.PackageGraph.cs | 38 +++++------ 8 files changed, 192 insertions(+), 73 deletions(-) create mode 100644 PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index cefe70cd9..c5231304d 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -165,6 +165,51 @@ public void RemoveBackupIfUnchanged_deletes_verified_backup_with_read_only_neste #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 DirectExport_publish_rejects_artifact_replaced_after_xcodebuild_identity_was_observed() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs index 3192c91fd..31b0ee0c9 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs @@ -328,6 +328,8 @@ public void Execute_ApplePlan_RejectsEmptyReceiptHistoryPath() [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("plan-overwrites-project")] public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string scenario) @@ -355,6 +357,14 @@ public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string sce 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; diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs index 12dd939e4..484ae882d 100644 --- a/PowerForge/Services/AppleArtifactCopy.cs +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -388,12 +388,23 @@ private static void TryDeleteOwnedBackupParent(string backupPath) { var parent = Path.GetDirectoryName(backupPath); if (string.IsNullOrWhiteSpace(parent) || - !Path.GetFileName(parent).Contains(".powerforge-backup-", StringComparison.Ordinal) || - !Directory.Exists(parent) || - Directory.EnumerateFileSystemEntries(parent).Any()) + !Path.GetFileName(parent).Contains(".powerforge-backup-", StringComparison.Ordinal)) { return; } - Directory.Delete(parent); + 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. + } } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs b/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs new file mode 100644 index 000000000..de36b167e --- /dev/null +++ b/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs @@ -0,0 +1,68 @@ +namespace PowerForge; + +internal sealed partial class AppleReleaseSourceTrustService +{ + 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.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 2415f99ba..69a4981a6 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -592,18 +592,28 @@ private void EnsureTrackedDirectoryTree( if (File.Exists(entry)) { var fullPath = Path.GetFullPath(entry); - if (!headBlobs.TryGetValue(fullPath, out var expectedBlob) || - !expectedBlob.Equals(ComputeRawGitBlobId(repositoryRoot, fullPath), StringComparison.OrdinalIgnoreCase)) + var relativePath = FrameworkCompatibility.GetRelativePath(repositoryRoot, fullPath).Replace('\\', '/'); + var worktreeBlob = ComputeRawGitBlobId(repositoryRoot, fullPath); + if (!headBlobs.TryGetValue(fullPath, out var expectedBlob)) { throw new InvalidOperationException( $"{name} differs from the exact source commit: " + - FrameworkCompatibility.GetRelativePath(repositoryRoot, entry).Replace('\\', '/')); + 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, - expectedBlob, + worktreeBlob, assemblerWorkingDirectory: assemblerWorkingDirectory); } } diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index 0666789a3..ceee771bb 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -75,7 +75,8 @@ private AppleReleaseSourceTrustSnapshot CaptureCore(string repositoryRoot, strin root, releaseConfigPath, "Apple release configuration", - ComputeRawGitBlobId(root, releaseConfigBytes)); + ComputeRawGitBlobId(root, releaseConfigBytes), + releaseConfigBytes); var releaseConfigContent = DecodeTrackedText(releaseConfigBytes); var spec = PowerForgeReleaseService.LoadConfigurationContent(releaseConfigContent, releaseConfigPath); var options = spec.AppleApps @@ -536,6 +537,7 @@ private void EnsureTrackedFile( string path, string name, string? capturedWorktreeBlob = null, + byte[]? capturedWorktreeBytes = null, bool validateSwiftDeterminism = false, string? effectiveSourceExtension = null, string? assemblerWorkingDirectory = null) @@ -562,8 +564,15 @@ private void EnsureTrackedFile( if (!headBlob.Succeeded || string.IsNullOrWhiteSpace(headBlob.StdOut)) throw new InvalidOperationException($"{name} is not present in the exact source commit: {relative}"); var worktreeBlob = capturedWorktreeBlob ?? ComputeRawGitBlobId(repositoryRoot, candidate); - if (!headBlob.StdOut.Trim().Equals(worktreeBlob, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"{name} differs from the exact source commit: {relative}"); + 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, @@ -573,40 +582,6 @@ private void EnsureTrackedFile( assemblerWorkingDirectory); } - 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 static string DecodeTrackedText(byte[] content) { using var stream = new MemoryStream(content, writable: false); diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs index 4a40028aa..6c85324c2 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs @@ -12,9 +12,6 @@ private static void ValidateAppleAutomationOutputPaths( var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - var comparer = Path.DirectorySeparatorChar == '\\' - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; var history = Path.GetFullPath(receiptHistoryPath) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var files = new[] @@ -24,13 +21,16 @@ private static void ValidateAppleAutomationOutputPaths( (Name: "LockPath", Path: Path.GetFullPath(lockPath)) }; - var duplicate = files - .GroupBy(static entry => entry.Path, comparer) - .FirstOrDefault(static group => group.Count() > 1); - if (duplicate is not null) + for (var index = 0; index < files.Length; index++) { - throw new InvalidOperationException( - $"Apple automation output files must use distinct paths: {string.Join(", ", duplicate.Select(static entry => entry.Name))}."); + 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) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs index 7ed412b1b..0fe13c76e 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs @@ -304,7 +304,7 @@ public void ResolveExactAppleSourceCommit_rejects_computed_local_package_path() } [Fact] - public void ResolveExactAppleSourceCommit_rejects_clean_smudge_filtered_worktree_bytes() + public void ResolveExactAppleSourceCommit_accepts_clean_smudge_filtered_worktree_bytes() { using var scope = new TemporaryDirectoryScope(); var repositoryRoot = scope.CreateDirectory("FilteredAppleInputRepo"); @@ -316,19 +316,18 @@ public void ResolveExactAppleSourceCommit_rejects_clean_smudge_filtered_worktree 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); + var sourceCommit = CommitRepository(repositoryRoot); File.Delete(projectFile); RunGit(repositoryRoot, "checkout", "--", "Sample.xcodeproj/project.pbxproj"); - var exception = Assert.Throws(() => - ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + var commit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); - Assert.Contains("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); + Assert.Equal(sourceCommit, commit); + Assert.Contains("worktree", File.ReadAllText(projectFile), StringComparison.Ordinal); } [Fact] - public void AppleSourceSnapshot_rejects_smudged_bytes_created_only_in_detached_checkout() + public void AppleSourceSnapshot_accepts_and_binds_smudged_bytes_created_in_detached_checkout() { using var scope = new TemporaryDirectoryScope(); var repositoryRoot = scope.CreateDirectory("FilteredSnapshotAppleRepo"); @@ -351,17 +350,19 @@ public void AppleSourceSnapshot_rejects_smudged_bytes_created_only_in_detached_c RequireImmutableSourceSnapshot = true, ExactSourceConfigPath = configPath }; - var exception = Assert.Throws(() => + using (var snapshot = AppleReleaseSourceSnapshot.CreateIfRequired(plan)) { - using var _ = AppleReleaseSourceSnapshot.CreateIfRequired(plan); - }); - - Assert.Contains("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("project.pbxproj", exception.Message, StringComparison.Ordinal); + Assert.NotNull(snapshot); + Assert.NotEqual(repositoryRoot, snapshot!.RootPath); + Assert.Contains( + "worktree", + File.ReadAllText(Path.Combine(snapshot.RootPath, "Sample.xcodeproj", "project.pbxproj")), + StringComparison.Ordinal); + } } [Fact] - public void ResolveExactAppleSourceCommit_rejects_filtered_bytes_in_synchronized_tree() + public void ResolveExactAppleSourceCommit_accepts_and_validates_filtered_bytes_in_synchronized_tree() { using var scope = new TemporaryDirectoryScope(); var repositoryRoot = scope.CreateDirectory("FilteredSynchronizedTreeRepo"); @@ -383,15 +384,14 @@ public void ResolveExactAppleSourceCommit_rejects_filtered_bytes_in_synchronized 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); + var sourceCommit = CommitRepository(repositoryRoot); File.Delete(sourceFile); RunGit(repositoryRoot, "checkout", "--", "Sources/Filtered.swift"); - var exception = Assert.Throws(() => - ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); + var commit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); - Assert.Contains("differs from the exact source commit", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Filtered.swift", exception.Message, StringComparison.Ordinal); + Assert.Equal(sourceCommit, commit); + Assert.Contains("worktree", File.ReadAllText(sourceFile), StringComparison.Ordinal); } [Fact] From bc6650fdbec9cd04982bd0ba4ed67bc27ccfb476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 00:18:50 +0200 Subject: [PATCH 66/78] Harden Apple filesystem and snapshot integrity --- ...AppleNotarizationServiceTests.Retention.cs | 39 ++++++++++++++++ .../AppleReleaseReceiptStoreTests.cs | 22 +++++++++ ...ts.AppleHostedReviewRecoveryRegressions.cs | 34 ++++++++++++++ ...eTests.AppleIntegrityClosureRegressions.cs | 29 ++++++++++++ PowerForge/FrameworkCompatibility.cs | 30 +++++++++++- .../Services/AppleArchiveBuildSnapshot.cs | 6 +-- .../Services/AppleArchiveUploadSnapshot.cs | 6 +-- PowerForge/Services/AppleArtifactCopy.cs | 8 ++++ .../Services/AppleDirectExportSnapshot.cs | 6 +-- .../AppleNotarizationInputSnapshot.cs | 8 ++-- .../Services/AppleNotarizationService.cs | 46 ++++++++++++++++++- .../AppleReleaseReceiptJournalLease.cs | 7 +-- ...AppleReleaseSourceTrustService.GitBlobs.cs | 14 ++++++ .../AppleReleaseSourceTrustService.Xcode.cs | 1 + .../AppleReleaseSourceTrustService.cs | 1 + .../AppleSwiftPackageBuildSnapshot.cs | 6 +-- ...PowerForgeReleaseService.AppleIntegrity.cs | 14 +++--- ...viceTests.AppleSourceTrust.PackageGraph.cs | 36 +++++++-------- 18 files changed, 260 insertions(+), 53 deletions(-) create mode 100644 PowerForge.Tests/AppleNotarizationServiceTests.Retention.cs 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/AppleReleaseReceiptStoreTests.cs b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs index 308b887fe..816212d88 100644 --- a/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs +++ b/PowerForge.Tests/AppleReleaseReceiptStoreTests.cs @@ -94,6 +94,28 @@ public void CreateLockPath_UsesTheProtectedResourceNamespace() } } + [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() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs index 31b0ee0c9..ad273d03c 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs @@ -395,6 +395,40 @@ public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string sce } } + [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() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs index fc853c37e..0b77e3f51 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs @@ -255,6 +255,35 @@ public void AppleArchiveUploadSnapshot_rejects_restored_bytes_changed_through_a_ } } + [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")] 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/Services/AppleArchiveBuildSnapshot.cs b/PowerForge/Services/AppleArchiveBuildSnapshot.cs index fea31b2cf..5c0e3b58d 100644 --- a/PowerForge/Services/AppleArchiveBuildSnapshot.cs +++ b/PowerForge/Services/AppleArchiveBuildSnapshot.cs @@ -104,8 +104,7 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) } throw; } finally { - if (Directory.Exists(stageRoot)) - Directory.Delete(stageRoot, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(stageRoot); } catch { /* best effort private cleanup */ } } } @@ -138,7 +137,6 @@ public void Dispose() { if (_disposed) return; _disposed = true; - if (Directory.Exists(RootPath)) - Directory.Delete(RootPath, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after publication */ } } } diff --git a/PowerForge/Services/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs index f567eb1fb..563826d84 100644 --- a/PowerForge/Services/AppleArchiveUploadSnapshot.cs +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -49,8 +49,7 @@ internal static AppleArchiveUploadSnapshot Create(string archivePath, string exp } catch { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } throw; } } @@ -134,8 +133,7 @@ public void Dispose() if (_disposed) return; _disposed = true; - if (Directory.Exists(RootPath)) - Directory.Delete(RootPath, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after remote operation */ } } } diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs index 484ae882d..eba97a0a2 100644 --- a/PowerForge/Services/AppleArtifactCopy.cs +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -321,6 +321,14 @@ internal static void RemovePublishedPathIfUnchanged( /// 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(); diff --git a/PowerForge/Services/AppleDirectExportSnapshot.cs b/PowerForge/Services/AppleDirectExportSnapshot.cs index 9e8f3057c..1b4d59702 100644 --- a/PowerForge/Services/AppleDirectExportSnapshot.cs +++ b/PowerForge/Services/AppleDirectExportSnapshot.cs @@ -151,8 +151,7 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) } finally { - if (Directory.Exists(stage)) - Directory.Delete(stage, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(stage); } catch { /* best effort private cleanup */ } } } @@ -201,8 +200,7 @@ public void Dispose() if (_disposed) return; _disposed = true; - if (Directory.Exists(RootPath)) - Directory.Delete(RootPath, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after publication */ } } } diff --git a/PowerForge/Services/AppleNotarizationInputSnapshot.cs b/PowerForge/Services/AppleNotarizationInputSnapshot.cs index 08b448775..47799d155 100644 --- a/PowerForge/Services/AppleNotarizationInputSnapshot.cs +++ b/PowerForge/Services/AppleNotarizationInputSnapshot.cs @@ -231,8 +231,7 @@ internal static AppleNotarizationInputSnapshot Create(string artifactPath, strin } catch { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } throw; } } @@ -249,14 +248,13 @@ public void Dispose() _directorySnapshot.Dispose(); return; } - if (Directory.Exists(RootPath)) - Directory.Delete(RootPath, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after notarization */ } } private static void DeletePath(string path) { if (Directory.Exists(path)) - Directory.Delete(path, recursive: true); + AppleArtifactCopy.DeleteOwnedDirectory(path); else if (File.Exists(path)) File.Delete(path); } diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index e54cfb434..d6109d230 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -478,8 +478,50 @@ private static void PreserveSubmissionPath( if (!Path.GetExtension(originalArtifactPath).Equals(".app", StringComparison.OrdinalIgnoreCase)) return; - Directory.CreateDirectory(Path.GetDirectoryName(retainedPath)!); - File.Copy(submittedPath, retainedPath, overwrite: true); + 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)) { diff --git a/PowerForge/Services/AppleReleaseReceiptJournalLease.cs b/PowerForge/Services/AppleReleaseReceiptJournalLease.cs index 27b6739a0..b89a02aae 100644 --- a/PowerForge/Services/AppleReleaseReceiptJournalLease.cs +++ b/PowerForge/Services/AppleReleaseReceiptJournalLease.cs @@ -129,10 +129,11 @@ internal static string CreateLockPath(string resourcePath) { var fullPath = Path.GetFullPath(resourcePath) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.DirectorySeparatorChar == '\\') - fullPath = fullPath.ToUpperInvariant(); + var hashPath = FrameworkCompatibility.GetPathStringComparisonForPath(fullPath) == StringComparison.OrdinalIgnoreCase + ? fullPath.ToUpperInvariant() + : fullPath; using var sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(fullPath)); + 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( diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs b/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs index de36b167e..b39f7beae 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.GitBlobs.cs @@ -2,6 +2,20 @@ 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); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 69a4981a6..dc86018df 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -593,6 +593,7 @@ private void EnsureTrackedDirectoryTree( { 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)) { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.cs b/PowerForge/Services/AppleReleaseSourceTrustService.cs index ceee771bb..74375644c 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.cs @@ -563,6 +563,7 @@ private void EnsureTrackedFile( 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)) diff --git a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs index bf60ba87d..f6713221e 100644 --- a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs +++ b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs @@ -134,8 +134,7 @@ internal static async Task CreateAsync( catch { monitor?.Dispose(); - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } throw; } } @@ -239,8 +238,7 @@ public void Dispose() return; _disposed = true; _monitor.Dispose(); - if (Directory.Exists(RootPath)) - Directory.Delete(RootPath, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after archive */ } } private static IEnumerable DiscoverApprovedPackageLocks(string repositoryRoot, string projectPath) diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs index 6c85324c2..3f2f36cb0 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs @@ -9,9 +9,6 @@ private static void ValidateAppleAutomationOutputPaths( string lockPath, IEnumerable<(string Name, string Path, bool IsDirectory)> protectedPaths) { - var comparison = Path.DirectorySeparatorChar == '\\' - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; var history = Path.GetFullPath(receiptHistoryPath) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var files = new[] @@ -35,6 +32,7 @@ private static void ValidateAppleAutomationOutputPaths( 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) || @@ -64,9 +62,7 @@ private static void ValidateAppleAutomationOutputPaths( private static bool PathsOverlap(string first, string second) { - var comparison = Path.DirectorySeparatorChar == '\\' - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; + 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) || @@ -74,6 +70,12 @@ private static bool PathsOverlap(string first, string second) 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))) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs index 0fe13c76e..2b2ec5588 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.PackageGraph.cs @@ -304,7 +304,7 @@ public void ResolveExactAppleSourceCommit_rejects_computed_local_package_path() } [Fact] - public void ResolveExactAppleSourceCommit_accepts_clean_smudge_filtered_worktree_bytes() + public void ResolveExactAppleSourceCommit_rejects_custom_smudge_filtered_worktree_bytes() { using var scope = new TemporaryDirectoryScope(); var repositoryRoot = scope.CreateDirectory("FilteredAppleInputRepo"); @@ -316,18 +316,19 @@ public void ResolveExactAppleSourceCommit_accepts_clean_smudge_filtered_worktree 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); + CommitRepository(repositoryRoot); File.Delete(projectFile); RunGit(repositoryRoot, "checkout", "--", "Sample.xcodeproj/project.pbxproj"); - var commit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); - Assert.Equal(sourceCommit, commit); - Assert.Contains("worktree", File.ReadAllText(projectFile), StringComparison.Ordinal); + Assert.Contains("custom Git filter", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("attested", exception.Message, StringComparison.Ordinal); } [Fact] - public void AppleSourceSnapshot_accepts_and_binds_smudged_bytes_created_in_detached_checkout() + public void AppleSourceSnapshot_rejects_custom_filters_in_detached_checkout() { using var scope = new TemporaryDirectoryScope(); var repositoryRoot = scope.CreateDirectory("FilteredSnapshotAppleRepo"); @@ -350,19 +351,14 @@ public void AppleSourceSnapshot_accepts_and_binds_smudged_bytes_created_in_detac RequireImmutableSourceSnapshot = true, ExactSourceConfigPath = configPath }; - using (var snapshot = AppleReleaseSourceSnapshot.CreateIfRequired(plan)) - { - Assert.NotNull(snapshot); - Assert.NotEqual(repositoryRoot, snapshot!.RootPath); - Assert.Contains( - "worktree", - File.ReadAllText(Path.Combine(snapshot.RootPath, "Sample.xcodeproj", "project.pbxproj")), - StringComparison.Ordinal); - } + var exception = Assert.Throws(() => + AppleReleaseSourceSnapshot.CreateIfRequired(plan)); + + Assert.Contains("custom Git filter", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] - public void ResolveExactAppleSourceCommit_accepts_and_validates_filtered_bytes_in_synchronized_tree() + public void ResolveExactAppleSourceCommit_rejects_custom_filters_in_synchronized_tree() { using var scope = new TemporaryDirectoryScope(); var repositoryRoot = scope.CreateDirectory("FilteredSynchronizedTreeRepo"); @@ -384,14 +380,14 @@ public void ResolveExactAppleSourceCommit_accepts_and_validates_filtered_bytes_i 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); + CommitRepository(repositoryRoot); File.Delete(sourceFile); RunGit(repositoryRoot, "checkout", "--", "Sources/Filtered.swift"); - var commit = ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath); + var exception = Assert.Throws(() => + ReleaseBuildExecutionService.ResolveExactAppleSourceCommit(repositoryRoot, configPath)); - Assert.Equal(sourceCommit, commit); - Assert.Contains("worktree", File.ReadAllText(sourceFile), StringComparison.Ordinal); + Assert.Contains("custom Git filter", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] From b3f25f8bbbee6201bf2bcb20a47ba96bfcb59e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 01:22:41 +0200 Subject: [PATCH 67/78] Harden Apple archive and package boundaries --- ...eServiceTests.ArchiveCompletionBoundary.cs | 51 ++++++++++- .../AppleArchiveBuildSnapshotTests.cs | 45 ++++++++++ ...rizationServiceTests.PackagingIntegrity.cs | 31 +++++++ .../AppleRemotePackageMirrorLeaseTests.cs | 5 ++ PowerForge/Services/AppleAppArchiveService.cs | 29 ++++++- .../Services/AppleArchiveUploadSnapshot.cs | 85 +++++++++++++++---- PowerForge/Services/AppleArtifactCopy.cs | 29 +++++-- .../AppleNotarizationInputSnapshot.cs | 5 ++ ...eleaseSourceTrustService.RemotePackages.cs | 20 ++++- ...iceTests.AppleSourceTrust.RemoteMirrors.cs | 35 ++++++++ 10 files changed, 305 insertions(+), 30 deletions(-) create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrust.RemoteMirrors.cs diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs index 92cacc3fd..bb299ce13 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs @@ -22,7 +22,35 @@ public async Task CreateArchiveAsync_rejects_archive_replaced_after_xcodebuild_c XcodeBuildExecutable = "xcodebuild-test" })); - Assert.Contains("changed after xcodebuild completed", exception.Message, StringComparison.OrdinalIgnoreCase); + 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 { @@ -73,6 +101,27 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo } } + 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) diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index c5231304d..2df678058 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -210,6 +210,51 @@ public void RemoveBackupIfUnchanged_does_not_fail_after_verified_backup_deletion #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() { diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs index 064a20195..370ca9fa5 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs @@ -50,6 +50,37 @@ public void FileSnapshot_rejects_a_second_hard_link_path_before_submission() } } + [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() { diff --git a/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs b/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs index 757ea9d21..ab4409534 100644 --- a/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs +++ b/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs @@ -54,6 +54,8 @@ public void EnsureRemotePackageMirror_AcceptsRevisionMaterializedByConcurrentFet } 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)); @@ -77,6 +79,9 @@ public void EnsureRemotePackageMirror_AcceptsRevisionMaterializedByConcurrentFet 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); diff --git a/PowerForge/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index 1621985f3..56218f9eb 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -82,8 +82,10 @@ public async Task CreateArchiveAsync( 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; @@ -102,6 +104,18 @@ public async Task CreateArchiveAsync( 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(), @@ -114,23 +128,30 @@ public async Task CreateArchiveAsync( processRequest.SetCompletionBoundary(completionResult => { if (completionResult.Succeeded && Directory.Exists(archivePath)) - archiveSha256 = AppleNotarizationService.ComputeArtifactSha256(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 (!string.IsNullOrWhiteSpace(archiveSha256) && Directory.Exists(archivePath)) { - var currentArchiveSha256 = AppleNotarizationService.ComputeArtifactSha256(archivePath); - if (!currentArchiveSha256.Equals(archiveSha256, StringComparison.OrdinalIgnoreCase)) + 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 '{currentArchiveSha256}'."); + $"The private Apple archive changed after xcodebuild completed. Expected '{archiveSha256}', received '{currentArchiveIdentity.Sha256}'."); } } } finally { + archiveOutputMonitor?.Dispose(); packageSnapshot?.Dispose(); } diff --git a/PowerForge/Services/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs index 563826d84..44bf2ecbf 100644 --- a/PowerForge/Services/AppleArchiveUploadSnapshot.cs +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -3,17 +3,17 @@ 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 IReadOnlyDictionary _fileMutationIdentities; + private readonly SnapshotIdentity _identity; private bool _disposed; private AppleArchiveUploadSnapshot( string rootPath, string archivePath, - IReadOnlyDictionary fileMutationIdentities) + SnapshotIdentity identity) { RootPath = rootPath; ArchivePath = archivePath; - _fileMutationIdentities = fileMutationIdentities; + _identity = identity; } internal string RootPath { get; } @@ -36,16 +36,16 @@ internal static AppleArchiveUploadSnapshot Create(string archivePath, string exp try { AppleArtifactCopy.CopyDirectory(source, snapshotPath); - var actual = AppleNotarizationService.ComputeArtifactSha256(snapshotPath); - if (!actual.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + 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 '{actual}'."); + $"The private Apple upload snapshot does not match the approved archive. Expected '{expectedSha256}', received '{identity.Sha256}'."); } return new AppleArchiveUploadSnapshot( root, snapshotPath, - CaptureFileMutationIdentities(snapshotPath)); + identity); } catch { @@ -56,18 +56,14 @@ internal static AppleArchiveUploadSnapshot Create(string archivePath, string exp internal void ValidateUnchanged(string expectedSha256) { - var actual = AppleNotarizationService.ComputeArtifactSha256(ArchivePath); - if (!actual.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) + 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 '{actual}'. Discard the upload/export result and inspect remote state before retrying."); + $"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."); } - var currentMutationIdentities = CaptureFileMutationIdentities(ArchivePath); - if (_fileMutationIdentities.Count != currentMutationIdentities.Count || - _fileMutationIdentities.Any(pair => - !currentMutationIdentities.TryGetValue(pair.Key, out var current) || - !string.Equals(pair.Value, current, StringComparison.Ordinal))) + 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. " + @@ -75,9 +71,26 @@ internal void ValidateUnchanged(string expectedSha256) } } + internal static SnapshotIdentity CaptureCompleteIdentity(string archivePath) + { + var sha256 = AppleNotarizationService.ComputeArtifactSha256(archivePath); + var identities = CaptureFileMutationIdentities(archivePath); + 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) { - var result = new Dictionary(GetPathComparer()); + var result = new Dictionary(GetPathComparer(archivePath)); var files = new List<(string RelativePath, string FullPath)>(); var pending = new Stack(); pending.Push(archivePath); @@ -125,8 +138,44 @@ private static IReadOnlyDictionary CaptureFileMutationIdentities return result; } - private static StringComparer GetPathComparer() - => Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + 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() { diff --git a/PowerForge/Services/AppleArtifactCopy.cs b/PowerForge/Services/AppleArtifactCopy.cs index eba97a0a2..995510ae3 100644 --- a/PowerForge/Services/AppleArtifactCopy.cs +++ b/PowerForge/Services/AppleArtifactCopy.cs @@ -265,8 +265,7 @@ internal static void RemovePublishedPathIfUnchanged( } catch { - if (Directory.Exists(quarantinePath) && !Directory.EnumerateFileSystemEntries(quarantinePath).Any()) - Directory.Delete(quarantinePath); + TryDeleteEmptyDirectory(quarantinePath); throw; } try @@ -289,12 +288,12 @@ internal static void RemovePublishedPathIfUnchanged( { PrepareOwnedDirectoryForDeletion(quarantinedArtifactPath); Directory.Delete(quarantinedArtifactPath, recursive: true); - Directory.Delete(quarantinePath); + TryDeleteEmptyDirectory(quarantinePath); } else { File.Delete(quarantinedArtifactPath); - Directory.Delete(quarantinePath); + TryDeleteEmptyDirectory(quarantinePath); } } catch @@ -304,12 +303,12 @@ internal static void RemovePublishedPathIfUnchanged( if (isDirectory) { Directory.Move(quarantinedArtifactPath, destinationPath); - Directory.Delete(quarantinePath); + TryDeleteEmptyDirectory(quarantinePath); } else { File.Move(quarantinedArtifactPath, destinationPath); - Directory.Delete(quarantinePath); + TryDeleteEmptyDirectory(quarantinePath); } } throw; @@ -415,4 +414,22 @@ private static void TryDeleteOwnedBackupParent(string backupPath) // 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/AppleNotarizationInputSnapshot.cs b/PowerForge/Services/AppleNotarizationInputSnapshot.cs index 47799d155..02a352ee2 100644 --- a/PowerForge/Services/AppleNotarizationInputSnapshot.cs +++ b/PowerForge/Services/AppleNotarizationInputSnapshot.cs @@ -28,6 +28,11 @@ private AppleNotarizationInputSnapshot( internal void CompleteSubmissionCapture(string expectedSha256) { + if (_directorySnapshot is not null) + { + _directorySnapshot.ValidateUnchanged(expectedSha256); + return; + } if (_fileSnapshotMonitor is null) return; try diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs index 01df25a9d..4f366e644 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -276,7 +276,7 @@ internal void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, Directory.CreateDirectory(temporary); try { - RunGit(temporary, "init", "--bare"); + InitializeRemotePackageMirror(temporary, revision); try { Directory.Move(temporary, mirrorPath); @@ -294,6 +294,15 @@ internal void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, } } + 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) { @@ -318,6 +327,15 @@ internal void EnsureRemotePackageMirror(string mirrorPath, string repositoryUrl, 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"; 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); + } +} From c49bfc149b7f8ea043ee534626494162098cccc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 02:22:58 +0200 Subject: [PATCH 68/78] Harden Apple archive recovery boundaries --- ...eServiceTests.ArchiveCompletionBoundary.cs | 33 +++++++++ .../AppleAppArchiveServiceTests.cs | 15 ++++ .../AppleArchiveBuildSnapshotTests.cs | 20 +++++ .../AppleReleaseArtifactServiceTests.cs | 50 ++++++++++++- .../AppleReleaseSourceSnapshotTests.cs | 29 ++++++++ ...orgeReleaseServiceTests.AppleAutomation.cs | 8 +- ...ts.AppleHostedReviewRecoveryRegressions.cs | 7 ++ ...eleaseServiceTests.AppleUploadAmbiguity.cs | 73 +++++++++++++++++++ .../PowerForgeReleaseServiceTests.cs | 70 +++--------------- PowerForge/Services/AppleAppArchiveService.cs | 8 ++ .../Services/AppleArchiveBuildSnapshot.cs | 8 +- .../Services/AppleReleaseArtifactService.cs | 57 ++++++++++----- .../Services/AppleReleaseSourceSnapshot.cs | 26 ++++--- ...PowerForgeReleaseService.AppleIntegrity.cs | 4 +- .../PowerForgeReleaseService.AppleResume.cs | 35 +++++---- .../Services/PowerForgeReleaseService.cs | 17 ++--- 16 files changed, 338 insertions(+), 122 deletions(-) create mode 100644 PowerForge.Tests/AppleReleaseSourceSnapshotTests.cs create mode 100644 PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs index bb299ce13..9e6668af7 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ArchiveCompletionBoundary.cs @@ -2,6 +2,33 @@ 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() { @@ -101,6 +128,12 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo } } + 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) diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.cs index 21062edb2..61ec4d0e8 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.cs @@ -581,6 +581,21 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo { Requests.Add(request); _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 index 2df678058..c223c4048 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -2,6 +2,26 @@ 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() { 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/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/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index 0a5bf60b1..98c95fcb4 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -1621,7 +1621,12 @@ private static AppStoreConnectReleaseReadinessResult CreateReadyReleaseReadiness }; private static AppleAppArchiveResult CreateSuccessfulArchive(AppleAppArchiveRequest request) - => new() + { + 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!, @@ -1633,6 +1638,7 @@ private static AppleAppArchiveResult CreateSuccessfulArchive(AppleAppArchiveRequ TimeSpan.FromSeconds(1), false) }; + } private static AppleAppArchiveUploadResult CreateSuccessfulUpload(AppleAppArchiveUploadRequest request) => new() diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs index ad273d03c..3a81cb83e 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs @@ -331,6 +331,7 @@ public void Execute_ApplePlan_RejectsEmptyReceiptHistoryPath() [InlineData("receipt-contains-plan")] [InlineData("lock-under-plan")] [InlineData("history-under-archive-root")] + [InlineData("archive-overlaps-receipt-journal-lock")] [InlineData("plan-overwrites-project")] public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string scenario) { @@ -368,6 +369,12 @@ public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string sce 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; diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs new file mode 100644 index 000000000..56da0172f --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs @@ -0,0 +1,73 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [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.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs index 7b01dfc49..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 => { @@ -1420,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.")); @@ -1493,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.")); @@ -1699,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.")); @@ -1859,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.")); @@ -1939,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.")); @@ -2404,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.")); @@ -2486,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.")); @@ -2563,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.")); @@ -3679,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, diff --git a/PowerForge/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index 56218f9eb..75613d140 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -139,6 +139,14 @@ public async Task CreateArchiveAsync( 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); diff --git a/PowerForge/Services/AppleArchiveBuildSnapshot.cs b/PowerForge/Services/AppleArchiveBuildSnapshot.cs index 5c0e3b58d..bc4308c28 100644 --- a/PowerForge/Services/AppleArchiveBuildSnapshot.cs +++ b/PowerForge/Services/AppleArchiveBuildSnapshot.cs @@ -29,12 +29,12 @@ internal static AppleArchiveBuildSnapshot Create(string destinationArchivePath) /// /// Copies the private archive into a user-only same-volume staging directory, verifies it, and - /// atomically replaces the configured public archive. A missing archive is retained only for - /// injected process adapters. + /// atomically replaces the configured public archive. /// - internal string? Publish(string destinationArchivePath, string? expectedSourceSha256 = null) { + internal string Publish(string destinationArchivePath, string? expectedSourceSha256 = null) { if (!Directory.Exists(ArchivePath)) - return null; + 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) && diff --git a/PowerForge/Services/AppleReleaseArtifactService.cs b/PowerForge/Services/AppleReleaseArtifactService.cs index 84933c263..8b6b8296c 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; @@ -58,7 +49,7 @@ internal PowerForgeAppleReleaseCleanupReceipt RemoveStaleArtifacts( var protectedFullPaths = (protectedPaths ?? Array.Empty()) .Where(static path => !string.IsNullOrWhiteSpace(path)) .Select(Path.GetFullPath) - .ToHashSet(PathComparer); + .ToArray(); var cutoff = _utcNow().UtcDateTime.AddDays(-Math.Max(0, plan.Automation.ArtifactRetentionDays)); var candidates = roots .Where(Directory.Exists) @@ -74,9 +65,10 @@ 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); - return left.Equals(right, PathComparison) || - left.StartsWith(right + Path.DirectorySeparatorChar, PathComparison) || - right.StartsWith(left + Path.DirectorySeparatorChar, PathComparison); + 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( @@ -89,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); } @@ -137,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))) @@ -152,8 +144,9 @@ private 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) @@ -174,7 +167,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}"); @@ -285,7 +278,7 @@ exception is UnauthorizedAccessException || return current; } - if (current.Equals(root, PathComparison)) + if (PathsEqual(current, root)) break; current = Path.GetDirectoryName(current); } @@ -293,6 +286,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/AppleReleaseSourceSnapshot.cs b/PowerForge/Services/AppleReleaseSourceSnapshot.cs index 43bb47c2d..6ee0cd2b2 100644 --- a/PowerForge/Services/AppleReleaseSourceSnapshot.cs +++ b/PowerForge/Services/AppleReleaseSourceSnapshot.cs @@ -359,16 +359,24 @@ public void Dispose() if (_disposed) return; _disposed = true; - var result = _git.RunRawAsync( - _repositoryRoot, - new[] { "worktree", "remove", "--force", RootPath }, - TimeSpan.FromMinutes(2)) - .GetAwaiter() - .GetResult(); - if (!result.Succeeded) + RemoveWorktreeBestEffort(_git, _repositoryRoot, RootPath); + } + + internal static void RemoveWorktreeBestEffort(GitClient git, string repositoryRoot, string snapshotRoot) + { + try { - throw new InvalidOperationException( - $"Failed to remove the isolated Apple build snapshot '{RootPath}': {result.StdErr}".Trim()); + _ = 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. } } diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs index 3f2f36cb0..b86c45850 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs @@ -15,7 +15,9 @@ private static void ValidateAppleAutomationOutputPaths( { (Name: "ReceiptPath", Path: Path.GetFullPath(receiptPath)), (Name: "PlanReceiptPath", Path: Path.GetFullPath(planReceiptPath)), - (Name: "LockPath", Path: Path.GetFullPath(lockPath)) + (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++) diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 71513fc33..35055184d 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -72,6 +72,14 @@ private bool TryResumeAppleUpload( { 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); @@ -193,8 +201,6 @@ private void EnsureAppleBuildUploadIsNotTerminal( if (remoteBuild is null) { - if (string.IsNullOrWhiteSpace(target.BuildUploadId)) - continue; return new AppleUploadAttestation(receipt, target); } @@ -206,19 +212,20 @@ private void EnsureAppleBuildUploadIsNotTerminal( if (string.IsNullOrWhiteSpace(target.BuildId)) { - if (string.IsNullOrWhiteSpace(target.BuildUploadId)) - continue; - 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)) + if (!string.IsNullOrWhiteSpace(target.BuildUploadId)) { - continue; + 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; + } } } diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 0c938a11d..a1267aca4 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2242,18 +2242,11 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( return CompleteAppleExecutionFailure(plan, resultsByApp, app); } - var publishedArchiveSha256 = archiveBuildSnapshot?.Publish(app.ArchivePath, archive.ArchiveSha256); - if (!string.IsNullOrWhiteSpace(publishedArchiveSha256)) - { - approvedArchiveInputPath = archiveBuildSnapshot!.ArchivePath; - result.ArchiveSha256 = publishedArchiveSha256; - app.ExpectedArchiveSha256 = publishedArchiveSha256; - archive.ArchivePath = app.ArchivePath; - } - else - { - CaptureAppleArchiveSha256(result, 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) From c781c5ea9ce8732ea6f1daf95f8fdce2d431b2b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 03:16:51 +0200 Subject: [PATCH 69/78] Close Swift linker and screenshot cleanup gaps --- ...entTests.ScreenshotIntegrityRegressions.cs | 39 +++++++++++++++++++ .../AppStoreConnectScreenshotSyncService.cs | 4 +- ...AppStoreConnectScreenshotUploadSnapshot.cs | 5 +-- ...easeSourceTrustService.BuildFlagOptions.cs | 4 +- ....AppleSourceTrustCurrentHeadRegressions.cs | 39 +++++++++++++++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs index 4481f9d9d..5145329bc 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotIntegrityRegressions.cs @@ -346,6 +346,45 @@ public void ScreenshotUploadSnapshot_rejects_restored_bytes_changed_through_a_re } } + [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() { diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index d20e8da06..e60c3598d 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -409,7 +409,7 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( } catch { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } throw; } } @@ -575,7 +575,7 @@ public ScreenshotSnapshot( public void Dispose() { - try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } + try { AppleArtifactCopy.DeleteOwnedDirectory(_root); } catch { /* best effort after remote operation */ } } } } diff --git a/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs b/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs index 3880e6dc9..81d2fc46c 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotUploadSnapshot.cs @@ -82,7 +82,7 @@ internal static AppStoreConnectScreenshotUploadSnapshot Capture(string sourcePat } catch { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } + try { AppleArtifactCopy.DeleteOwnedDirectory(root); } catch { /* best effort private cleanup */ } throw; } } @@ -125,8 +125,7 @@ public void Dispose() if (_disposed) return; _disposed = true; - if (Directory.Exists(RootPath)) - Directory.Delete(RootPath, recursive: true); + try { AppleArtifactCopy.DeleteOwnedDirectory(RootPath); } catch { /* best effort after remote operation */ } } private sealed class RangedFileContent : HttpContent diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index 4abc52292..8dfe9e745 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -34,7 +34,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-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", "-load", "-load-pass-plugin", "-load-plugin-library", "-module-file-info", "-module-map-file", + "-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" }; @@ -73,7 +73,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-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=", + "-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=", diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs index 00a6769fc..ef7082f77 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustCurrentHeadRegressions.cs @@ -28,6 +28,45 @@ public void ResolveExactAppleSourceCommit_classifies_swift_tool_and_plugin_input 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")] From 01bb37d9c1f75a6bce3dad6989534b298cd93b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 04:16:26 +0200 Subject: [PATCH 70/78] Bind Clang offload inputs to exact source --- ...easeSourceTrustService.BuildFlagOptions.cs | 21 ++- ...pleReleaseSourceTrustService.BuildFlags.cs | 13 ++ ...viceTests.AppleSourceTrustOffloadInputs.cs | 120 ++++++++++++++++++ 3 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustOffloadInputs.cs diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index 8dfe9e745..6a64cbfc5 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -5,10 +5,12 @@ 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) { - "--cuda-path", "--hip-path", "--hipspv-pass-plugin", "--hipstdpar-path", + "--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", - "--ptxas-path", "--rocm-device-lib-path", "--rocm-path", "--source-metadata-list", + "--nvptx-arch-tool", "--offload-arch-tool", "--ptxas-path", "--rocm-device-lib-path", + "--rocm-path", "--source-metadata-list", "--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", @@ -20,11 +22,12 @@ internal sealed partial class AppleReleaseSourceTrustService "-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", "-fexperimental-sanitize-metadata-ignorelist", "-fmemory-profile-use", + "-fcodegen-data-use", "-fcuda-include-gpubinary", "-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", - "-fprofile-remapping-file", "-fprofile-sample-use", "-fprofile-use", + "-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", @@ -44,9 +47,12 @@ internal sealed partial class AppleReleaseSourceTrustService { "--libomptarget-amdgpu-bc-path=", "--libomptarget-amdgcn-bc-path=", "--libomptarget-nvptx-bc-path=", "--libomptarget-spirv-bc-path=", - "--warning-suppression-mappings=", "--rocm-device-lib-path=", "--hipstdpar-thrust-path=", + "--warning-suppression-mappings=", "--rocm-device-lib-path=", "--hip-device-lib-path=", + "--hipstdpar-thrust-path=", "--hipstdpar-prim-path=", "--hipspv-pass-plugin=", "--source-metadata-list=", - "--hipstdpar-path=", "--cuda-path=", "--hip-path=", "--ptxas-path=", "--rocm-path=", "--sysroot=", + "--offload-arch-tool=", "--amdgpu-arch-tool=", "--nvptx-arch-tool=", "--gpu-instrument-lib=", + "--hipstdpar-path=", "--hip-device-lib=", "--cuda-path=", "--hip-path=", "--ptxas-path=", + "--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=", @@ -56,7 +62,8 @@ internal sealed partial class AppleReleaseSourceTrustService "-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=", - "-fexperimental-sanitize-metadata-ignorelist=", "-fms-secure-hotpatch-functions-file=", + "-fexperimental-sanitize-metadata-ignorelist=", "-fembed-offload-object=", + "-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=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs index edb56ec31..554132b81 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlags.cs @@ -316,6 +316,12 @@ private static IEnumerable ExtractBuildFlagInputPaths(string[] inputToke $"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) @@ -509,6 +515,13 @@ private static bool TrySkipNonInputCompilerOption(string[] tokens, ref int index 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) || 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); + } +} From 9f3c5b3ec6daef60b512c9063b55cdc5a1144c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 05:10:08 +0200 Subject: [PATCH 71/78] Harden screenshot snapshots and Metal input trust --- ...ClientTests.ScreenshotSelectionSnapshot.cs | 81 ++++++++++++++ .../AppStoreConnectScreenshotApprovalTests.cs | 52 +++++++++ .../AppStoreConnectScreenshotFileSelector.cs | 36 ++++-- ...oreConnectScreenshotSyncConfigValidator.cs | 13 ++- ...ScreenshotSyncService.SnapshotIntegrity.cs | 74 +++++++++++++ .../AppStoreConnectScreenshotSyncService.cs | 104 ++++++++++++++---- .../AppleReleaseSourceTrustService.Xcode.cs | 1 + ...erviceTests.AppleSourceTrustMetalInputs.cs | 28 +++++ 8 files changed, 355 insertions(+), 34 deletions(-) create mode 100644 PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs create mode 100644 PowerForge/Services/AppStoreConnectScreenshotSyncService.SnapshotIntegrity.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustMetalInputs.cs diff --git a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs new file mode 100644 index 000000000..3ae28e1cf --- /dev/null +++ b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs @@ -0,0 +1,81 @@ +using System.Net; + +namespace PowerForge.Tests; + +public sealed partial class AppStoreConnectClientTests +{ + [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/AppStoreConnectScreenshotApprovalTests.cs b/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs index c50bc7d97..40d4cc4ad 100644 --- a/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs +++ b/PowerForge.Tests/AppStoreConnectScreenshotApprovalTests.cs @@ -330,6 +330,58 @@ public void Validate_RequiresEveryApprovedScreenshotToRemainSelected(string muta } } + [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/Services/AppStoreConnectScreenshotFileSelector.cs b/PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs index 05482555f..a4b8b350f 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotFileSelector.cs @@ -4,39 +4,55 @@ namespace PowerForge; internal static class AppStoreConnectScreenshotFileSelector { internal static string[] Select(string folder, string filter, int maxCount) - => Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories) + { + 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)) - .OrderBy(static item => item.Path, StringComparer.OrdinalIgnoreCase) + .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 relative = FrameworkCompatibility.GetRelativePath(folder, Path.GetFullPath(screenshotPath)); - if (Path.IsPathRooted(relative) || - relative.Equals("..", StringComparison.Ordinal) || - relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) - { + 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 (Path.DirectorySeparatorChar == '\\') + if (comparison == StringComparison.OrdinalIgnoreCase) options |= System.Text.RegularExpressions.RegexOptions.IgnoreCase; return System.Text.RegularExpressions.Regex.IsMatch(relativePath, expression, options); } diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs index 32df63a6c..dfb180218 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs @@ -368,8 +368,13 @@ private static string ResolvePath(string baseDirectory, string path) : System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDirectory, path)); private static bool PathsEqual(string left, string right) - => string.Equals( - left, - right, - Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + { + 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..6cb34759b --- /dev/null +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.SnapshotIntegrity.cs @@ -0,0 +1,74 @@ +namespace PowerForge; + +public sealed partial class AppStoreConnectScreenshotSyncService +{ + 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 e60c3598d..e514a285e 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; @@ -144,6 +144,7 @@ internal async Task SyncAsync( 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) @@ -182,7 +183,7 @@ internal 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( @@ -214,18 +215,20 @@ internal 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) { // 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++; } @@ -236,6 +239,7 @@ internal async Task SyncAsync( var uploaded = new List(); foreach (var file in filesToUpload) { + screenshotSnapshot.ValidateUnchanged(); var upload = await _client.UploadScreenshotAsync( set.Id, file, @@ -261,7 +265,7 @@ internal async Task SyncAsync( limit: 200, cancellationToken).ConfigureAwait(false); var expectedChecksums = plannedSet.Preflighted.Files - .Select(ComputeSourceChecksum) + .Select(screenshotSnapshot.GetMd5) .ToArray(); var finalChecksums = finalScreenshots .Select(static screenshot => screenshot.SourceFileChecksum?.Trim() ?? string.Empty) @@ -344,7 +348,7 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( ? null : SelectApprovedScreenshotFiles(sourceSets, expected, comparer); var mappings = new Dictionary(comparer); - var sha256 = new Dictionary(comparer); + var approvedSnapshotSha256 = new Dictionary(comparer); var consumedApprovedFiles = new HashSet(comparer); var sets = sourceSets.Select((set, setIndex) => { @@ -372,17 +376,9 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( ?? throw new InvalidOperationException($"Screenshot snapshot path has no parent directory: {snapshotPath}"); Directory.CreateDirectory(snapshotDirectory); File.Copy(source, snapshotPath, overwrite: false); - var actualSha256 = ComputeSha256(snapshotPath); - if (approvedScreenshots is not null) - { - if (!actualSha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Screenshot '{source}' changed after Apple plan approval. Review the exact replacement bytes before upload."); - } - } mappings[snapshotPath] = source; - sha256[snapshotPath] = actualSha256; + if (expectedSha256 is not null) + approvedSnapshotSha256[snapshotPath] = expectedSha256; return snapshotPath; }).ToArray(); return new PreflightedScreenshotSet( @@ -405,7 +401,41 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( string.Join(", ", missing)); } } - return new ScreenshotSnapshot(root, sets, mappings, sha256); + 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 { @@ -429,7 +459,10 @@ private static Dictionary SelectApprovedScreenshotFiles( RelativePath = AppStoreConnectScreenshotFileSelector.GetRelativePath(set.Folder, item.Key) }) .Where(static item => item.RelativePath is not null) - .Where(item => AppStoreConnectScreenshotFileSelector.MatchesFilter(item.RelativePath!, set.Filter)) + .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) @@ -447,8 +480,10 @@ private static string ComputeSha256(string filePath) 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) @@ -459,7 +494,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 @@ -554,17 +589,27 @@ 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 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; } @@ -573,8 +618,27 @@ public ScreenshotSnapshot( 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/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index dc86018df..98bf8f651 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -43,6 +43,7 @@ internal sealed partial class AppleReleaseSourceTrustService "OTHER_CPLUSPLUSFLAGS", "OTHER_LDFLAGS", "OTHER_LIBTOOLFLAGS", + "MTL_COMPILER_FLAGS", "OTHER_SWIFT_FLAGS", "INFOPLIST_OTHER_PREPROCESSOR_FLAGS" }; 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); + } +} From bb68a9b7efd426b5506fd623951c44e00dd755f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 06:01:43 +0200 Subject: [PATCH 72/78] Bind approved screenshots and package identities --- ...ClientTests.ScreenshotSelectionSnapshot.cs | 74 +++++++++++++++++++ ...ArchiveServiceTests.ExactSourcePackages.cs | 57 +++++++++++++- ...reConnectScreenshotSyncValidationModels.cs | 6 ++ ...oreConnectScreenshotSyncConfigValidator.cs | 21 +++++- ...ScreenshotSyncService.SnapshotIntegrity.cs | 28 +++++++ .../AppStoreConnectScreenshotSyncService.cs | 25 ++++++- .../Services/AppleArchiveUploadSnapshot.cs | 16 ++-- .../AppleSwiftPackageBuildSnapshot.cs | 28 ++++--- 8 files changed, 231 insertions(+), 24 deletions(-) diff --git a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs index 3ae28e1cf..a3bae1d01 100644 --- a/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs +++ b/PowerForge.Tests/AppStoreConnectClientTests.ScreenshotSelectionSnapshot.cs @@ -1,9 +1,83 @@ 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() { diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs index 5db9e6c1f..9eaf43fca 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs @@ -89,6 +89,38 @@ public async Task CreateArchiveAsync_exact_source_rejects_transient_package_chec } } + [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() { @@ -195,6 +227,8 @@ private sealed class ExactPackageProcessRunner : IProcessRunner 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( @@ -203,13 +237,16 @@ internal ExactPackageProcessRunner( bool materializeWrongRevision = false, bool materializeBinaryArtifact = false, bool mutateBinaryArtifactDuringArchive = false, - bool replaceBinaryArtifactAfterResolveCompletion = 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"); @@ -243,7 +280,7 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo DerivedDataRoot = request.Arguments[derivedIndex + 1]; var checkouts = Directory.CreateDirectory(Path.Combine(SourcePackagesRoot, "checkouts")).FullName; var checkout = Path.Combine(checkouts, "Shared"); - RunGit(checkouts, "clone", "--quiet", _remoteSourceRoot, checkout); + RunGit(checkouts, "clone", "--quiet", "--no-hardlinks", _remoteSourceRoot, checkout); RunGit(checkout, "remote", "set-url", "origin", RemoteUrl); if (_materializeWrongRevision) { @@ -266,6 +303,22 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo 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"); 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/Services/AppStoreConnectScreenshotSyncConfigValidator.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncConfigValidator.cs index dfb180218..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) @@ -179,6 +192,8 @@ private static string[] ValidateApprovalManifest( 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)) { diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.SnapshotIntegrity.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.SnapshotIntegrity.cs index 6cb34759b..ce784fa9e 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.SnapshotIntegrity.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.SnapshotIntegrity.cs @@ -2,6 +2,34 @@ 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) diff --git a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs index e514a285e..c910b3121 100644 --- a/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs +++ b/PowerForge/Services/AppStoreConnectScreenshotSyncService.cs @@ -127,10 +127,30 @@ internal ScreenshotSnapshot CreateSnapshot(AppStoreConnectScreenshotSyncRequest $"Screenshot preflight failed: {string.Join(" ", messages)}"); } + 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, request.ExpectedFileSha256); + return CreateScreenshotSnapshot( + sourceSets, + MergeExpectedFileSha256( + request.BaseDirectory, + request.ExpectedFileSha256, + validation.ApprovedFileSha256)); } internal async Task SyncAsync( @@ -327,7 +347,8 @@ private static ScreenshotSnapshot CreateScreenshotSnapshot( IReadOnlyCollection sourceSets, IReadOnlyDictionary? expectedFileSha256) { - var comparer = Path.DirectorySeparatorChar == '\\' + var comparer = sourceSets.Any(set => + FrameworkCompatibility.GetPathStringComparisonForPath(set.Folder) == StringComparison.OrdinalIgnoreCase) ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; var expected = expectedFileSha256 is null diff --git a/PowerForge/Services/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs index 44bf2ecbf..e2be488f5 100644 --- a/PowerForge/Services/AppleArchiveUploadSnapshot.cs +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -71,10 +71,12 @@ internal void ValidateUnchanged(string expectedSha256) } } - internal static SnapshotIdentity CaptureCompleteIdentity(string archivePath) + internal static SnapshotIdentity CaptureCompleteIdentity( + string archivePath, + string description = "private Apple upload archive snapshot") { var sha256 = AppleNotarizationService.ComputeArtifactSha256(archivePath); - var identities = CaptureFileMutationIdentities(archivePath); + var identities = CaptureFileMutationIdentities(archivePath, description); var canonical = new System.Text.StringBuilder(); foreach (var pair in identities.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) { @@ -88,7 +90,9 @@ internal static SnapshotIdentity CaptureCompleteIdentity(string archivePath) return new SnapshotIdentity(sha256, digest); } - private static IReadOnlyDictionary CaptureFileMutationIdentities(string archivePath) + private static IReadOnlyDictionary CaptureFileMutationIdentities( + string archivePath, + string description) { var result = new Dictionary(GetPathComparer(archivePath)); var files = new List<(string RelativePath, string FullPath)>(); @@ -120,8 +124,8 @@ private static IReadOnlyDictionary CaptureFileMutationIdentities if (hardLinkCounts[index] != 1) { throw new InvalidOperationException( - $"The private Apple upload archive snapshot file '{files[index].RelativePath}' has {hardLinkCounts[index]} hard links. " + - "Upload snapshots require one private pathname per regular file."); + $"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 @@ -131,7 +135,7 @@ private static IReadOnlyDictionary CaptureFileMutationIdentities catch (ArgumentException exception) { throw new InvalidOperationException( - $"The private Apple upload archive snapshot contains duplicate platform-equivalent file paths at '{files[index].RelativePath}'.", + $"The {description} contains duplicate platform-equivalent file paths at '{files[index].RelativePath}'.", exception); } } diff --git a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs index f6713221e..238ba1513 100644 --- a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs +++ b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs @@ -9,7 +9,7 @@ internal sealed class AppleSwiftPackageBuildSnapshot : IDisposable private readonly IReadOnlyDictionary _approvedPackageRevisions; private readonly IReadOnlyDictionary _environmentVariables; private readonly AppleReleaseSourceMutationMonitor _monitor; - private readonly string _materializedPackagesSha256; + private readonly AppleArchiveUploadSnapshot.SnapshotIdentity _materializedPackagesIdentity; private bool _disposed; private AppleSwiftPackageBuildSnapshot( @@ -17,13 +17,13 @@ private AppleSwiftPackageBuildSnapshot( IReadOnlyDictionary approvedPackageRevisions, IReadOnlyDictionary environmentVariables, AppleReleaseSourceMutationMonitor monitor, - string materializedPackagesSha256) + AppleArchiveUploadSnapshot.SnapshotIdentity materializedPackagesIdentity) { RootPath = rootPath; _approvedPackageRevisions = approvedPackageRevisions; _environmentVariables = environmentVariables; _monitor = monitor; - _materializedPackagesSha256 = materializedPackagesSha256; + _materializedPackagesIdentity = materializedPackagesIdentity; } internal string RootPath { get; } @@ -85,7 +85,7 @@ internal static async Task CreateAsync( "xcodebuild archive", "Discard the archive and resolve the exact package graph again.", enableImmediately: false); - string? materializedPackagesSha256 = null; + AppleArchiveUploadSnapshot.SnapshotIdentity? materializedPackagesIdentity = null; var processRequest = new ProcessRunRequest( xcodeBuildExecutable, Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(), @@ -99,7 +99,7 @@ internal static async Task CreateAsync( { if (!completionResult.Succeeded) return; - materializedPackagesSha256 = monitor.CaptureExpectedProducerOutput( + materializedPackagesIdentity = monitor.CaptureExpectedProducerOutput( () => CaptureMaterializedPackageIdentity( sourceTrust, sourcePackagesPath, @@ -115,7 +115,7 @@ internal static async Task CreateAsync( $"xcodebuild failed to resolve the exact Swift package graph with exit code {result.ExitCode}: " + (string.IsNullOrWhiteSpace(result.StdErr) ? result.StdOut : result.StdErr)); } - if (string.IsNullOrWhiteSpace(materializedPackagesSha256)) + if (materializedPackagesIdentity is null) { monitor.Dispose(); throw new InvalidOperationException( @@ -127,7 +127,7 @@ internal static async Task CreateAsync( approvedPackageRevisions, environmentVariables, monitor, - materializedPackagesSha256!); + materializedPackagesIdentity); monitor = null; return snapshot; } @@ -156,12 +156,16 @@ internal void ValidateUnchanged() _sourceTrust, SourcePackagesPath, _approvedPackageRevisions); - if (!actual.Equals(_materializedPackagesSha256, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("The materialized Swift package root changed before xcodebuild archive."); + 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 string CaptureMaterializedPackageIdentity( + private static AppleArchiveUploadSnapshot.SnapshotIdentity CaptureMaterializedPackageIdentity( AppleReleaseSourceTrustService sourceTrust, string sourcePackagesPath, IReadOnlyDictionary approvedPackageRevisions) @@ -170,7 +174,9 @@ private static string CaptureMaterializedPackageIdentity( var artifactsPath = Path.Combine(sourcePackagesPath, "artifacts"); if (Directory.Exists(artifactsPath)) ValidateNoEscapingArtifactLinks(artifactsPath); - return AppleNotarizationService.ComputeArtifactSha256(sourcePackagesPath); + return AppleArchiveUploadSnapshot.CaptureCompleteIdentity( + sourcePackagesPath, + "materialized Swift package snapshot"); } private static void ValidateNoEscapingArtifactLinks(string artifactsRoot) From cb578e90b32daeea27228260a048e4665ea7d447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 06:42:02 +0200 Subject: [PATCH 73/78] Harden notarization submission continuity --- ...tarizationServiceTests.AcceptedBoundary.cs | 6 +- ...rizationServiceTests.PackagingIntegrity.cs | 72 +++++++++++++++++++ .../AppleNotarizationServiceTests.cs | 7 +- .../Services/AppleNotarizationService.cs | 64 ++++++++++------- 4 files changed, 120 insertions(+), 29 deletions(-) diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs index 0afe87828..ae0e80143 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs @@ -30,7 +30,8 @@ public async Task NotarizeAsync_rejects_private_app_root_replaced_after_acceptan } })); - Assert.Contains("changed before stapling", exception.Message, StringComparison.OrdinalIgnoreCase); + 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"))); } @@ -64,7 +65,8 @@ public async Task NotarizeAsync_rejects_private_artifact_changed_after_acceptanc "replacement-after-acceptance") })); - Assert.Contains("changed before stapling", exception.Message, StringComparison.OrdinalIgnoreCase); + 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)); } diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs index 370ca9fa5..97a441b57 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.PackagingIntegrity.cs @@ -140,6 +140,39 @@ public async Task NotarizeAsync_BindsDittoZipAtProcessCompletionBoundary() } } + [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)] @@ -327,4 +360,43 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo 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.cs b/PowerForge.Tests/AppleNotarizationServiceTests.cs index e591b7f79..4c0b12d2f 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.cs @@ -143,7 +143,7 @@ public async Task NotarizeAsync_RejectsAcceptedAppSubmissionWhenPrivateZipChange try { var app = Directory.CreateDirectory(Path.Combine(root.FullName, "Mutable.app")); - var checkpointed = false; + AppleNotarizationAcceptedCheckpoint? checkpoint = null; var exception = await Assert.ThrowsAsync(() => new AppleNotarizationService(new MutatingSubmissionRunner()).NotarizeAsync(new AppleNotarizationRequest { @@ -151,12 +151,13 @@ public async Task NotarizeAsync_RejectsAcceptedAppSubmissionWhenPrivateZipChange KeychainProfile = "powerforge-notary", Staple = false, Assess = false, - AcceptedCheckpoint = _ => checkpointed = true + AcceptedCheckpoint = accepted => checkpoint = accepted })); Assert.Contains("exact submitted file changed", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("Do not resubmit", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.False(checkpointed); + Assert.NotNull(checkpoint); + Assert.Equal("submission-mutated", checkpoint.SubmissionId); } finally { diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index d6109d230..6065dd5fe 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -118,6 +118,7 @@ public async Task NotarizeAsync( ProcessRunResult submission; string? submissionId; string? status; + string? submittedFileMutationIdentity = null; using var submissionMonitor = resumed ? null : new AppleReleaseSourceMutationMonitor( @@ -146,6 +147,9 @@ public async Task NotarizeAsync( }; submitArguments.AddRange(authentication); submissionSnapshot.CompleteSubmissionCapture(artifactSha256); + submittedFileMutationIdentity = ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( + submittedPath, + "private Apple notarization submitted file"); submission = await RunAsync(xcrunExecutable, submissionArtifactPath, submitArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); (submissionId, status) = ParseSubmission(submission); } @@ -158,30 +162,6 @@ public async Task NotarizeAsync( "accepted checkpoint and retained submission capture", "Do not staple or publish until the accepted submission has been reconciled.") : null; - if (!resumed) - { - try - { - submissionMonitor!.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}'."); - } - } - 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); - } - } var submissionPath = resumed ? artifactPath : ResolveRetainedSubmissionPath(request, artifactPath); @@ -211,6 +191,42 @@ public async Task NotarizeAsync( 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!); From 9584a744377a8b18700c821c77777916b4c5575a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 07:29:57 +0200 Subject: [PATCH 74/78] Harden Apple remote mutation recovery --- ...tarizationServiceTests.AcceptedBoundary.cs | 62 ++++++++++++ ...rizationServiceTests.AssessmentBoundary.cs | 70 ++++++++++++++ ...orgeReleaseServiceTests.AppleAutomation.cs | 3 +- ...eTests.AppleIntegrityClosureRegressions.cs | 10 +- ...ServiceTests.AppleNotarizationAmbiguity.cs | 91 ++++++++++++++++++ ...aseServiceTests.AppleNotarizationResume.cs | 16 +++- PowerForge/Models/AppleNotarizationModels.cs | 18 ++++ .../Services/AppleArchiveUploadSnapshot.cs | 6 ++ ...NotarizationService.SubmissionIntegrity.cs | 42 ++++++++ .../Services/AppleNotarizationService.cs | 95 +++++++++++-------- ...eReleaseService.AppleDirectDistribution.cs | 1 + ...owerForgeReleaseService.AppleDurability.cs | 29 ++++++ .../PowerForgeReleaseService.AppleResume.cs | 63 ++++++++---- .../Services/PowerForgeReleaseService.cs | 21 +++- 14 files changed, 454 insertions(+), 73 deletions(-) create mode 100644 PowerForge.Tests/AppleNotarizationServiceTests.AssessmentBoundary.cs create mode 100644 PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs create mode 100644 PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs index ae0e80143..e1a950ea3 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs @@ -2,6 +2,44 @@ namespace PowerForge.Tests; public sealed partial class AppleNotarizationServiceTests { + [Theory] + [InlineData("{}", null, null)] + [InlineData("{\"id\":\"pending-submission\",\"status\":\"In Progress\"}", "pending-submission", "In Progress")] + public async Task NotarizeAsync_checkpoints_ambiguous_success_without_terminal_submission_evidence( + string response, + string? expectedId, + string? expectedStatus) + { + 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)).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_rejects_private_app_root_replaced_after_acceptance() { @@ -101,4 +139,28 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo return Task.FromResult(result); } } + + private sealed class IncompleteSubmissionEvidenceRunner : IProcessRunner + { + private readonly string _response; + + internal IncompleteSubmissionEvidenceRunner(string response) + { + _response = response; + } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + var isSubmission = request.Arguments.Count > 0 && request.Arguments[0] == "notarytool"; + var result = new ProcessRunResult( + 0, + isSubmission ? _response : "ok", + string.Empty, + request.FileName, + TimeSpan.Zero, + false); + request.InvokeCompletionBoundary(result); + return Task.FromResult(result); + } + } } 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/PowerForgeReleaseServiceTests.AppleAutomation.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs index 98c95fcb4..9d82d4d36 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleAutomation.cs @@ -87,9 +87,10 @@ public void Execute_AppleUpload_RejectsPrivateArchiveSnapshotMutation() 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); - Assert.DoesNotContain( + var uploadAttestation = Assert.Single( new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), receipt => receipt.OperationPhase == "UploadAttested"); + Assert.True(Assert.Single(uploadAttestation.Targets).UploadPerformed); } finally { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs index 0b77e3f51..77f9b764a 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleIntegrityClosureRegressions.cs @@ -165,9 +165,10 @@ public void Execute_AppleUpload_rejects_transient_private_archive_snapshot_mutat Assert.False(result.Success); Assert.NotNull(privateArchive); Assert.Contains("private Apple upload archive snapshot changed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain( + Assert.Contains( new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), - receipt => receipt.OperationPhase == "UploadAttested"); + receipt => receipt.OperationPhase == "UploadAttested" && + Assert.Single(receipt.Targets).UploadPerformed); } finally { TryDelete(root); } @@ -217,9 +218,10 @@ public void Execute_AppleUpload_rejects_transient_private_archive_hard_link_alia Assert.False(result.Success); Assert.NotNull(privateArchive); Assert.Contains("private Apple upload archive snapshot", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain( + Assert.Contains( new AppleReleaseReceiptStore().ReadAll(result.AppleAppPlan!), - receipt => receipt.OperationPhase == "UploadAttested"); + receipt => receipt.OperationPhase == "UploadAttested" && + Assert.Single(receipt.Targets).UploadPerformed); } finally { if (!string.IsNullOrWhiteSpace(aliasRoot) && Directory.Exists(aliasRoot)) Directory.Delete(aliasRoot, recursive: true); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs new file mode 100644 index 000000000..9d023e0a1 --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs @@ -0,0 +1,91 @@ +namespace PowerForge.Tests; + +public sealed partial class PowerForgeReleaseServiceTests +{ + [Fact] + public void Execute_AppleDirectNotarizationAmbiguityBlocksAutomaticResubmission() + { + 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 = sourceCommit + }); + + 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 = sourceCommit + }); + + 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 b5825dcfd..9dd243b88 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -65,7 +65,7 @@ public void DirectNotarizationResume_RequiresCheckpointArchiveIdentity() } [Fact] - public void DirectNotarizationResume_UsesPlatformCorrectProjectPathIdentity() + public void DirectNotarizationResume_UsesOwningVolumeProjectPathIdentity() { var plan = new PowerForgeAppleReleasePlan { @@ -101,7 +101,9 @@ public void DirectNotarizationResume_UsesPlatformCorrectProjectPathIdentity() var matches = PowerForgeReleaseService.IsMatchingDirectReceiptTarget(plan, receipt, app); - Assert.Equal(Path.DirectorySeparatorChar == '\\', matches); + Assert.Equal( + FrameworkCompatibility.GetPathStringComparisonForPath(plan.ProjectRoot) == StringComparison.OrdinalIgnoreCase, + matches); } [Theory] @@ -188,13 +190,17 @@ public void DirectNotarizationResume_RejectsChangedExecutionPolicy(string change } [Fact] - public void UploadAttestationResume_UsesPlatformCorrectProjectPathIdentity() + public void UploadAttestationResume_UsesOwningVolumeProjectPathIdentity() { + var root = Directory.GetCurrentDirectory(); var matches = PowerForgeReleaseService.AppleReleasePathsEqual( "CasaRay.xcodeproj", - "casaray.xcodeproj"); + "casaray.xcodeproj", + root); - Assert.Equal(Path.DirectorySeparatorChar == '\\', matches); + Assert.Equal( + FrameworkCompatibility.GetPathStringComparisonForPath(root) == StringComparison.OrdinalIgnoreCase, + matches); } [Fact] diff --git a/PowerForge/Models/AppleNotarizationModels.cs b/PowerForge/Models/AppleNotarizationModels.cs index eabad82f8..fae458dc0 100644 --- a/PowerForge/Models/AppleNotarizationModels.cs +++ b/PowerForge/Models/AppleNotarizationModels.cs @@ -59,9 +59,27 @@ public sealed class AppleNotarizationRequest 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; diff --git a/PowerForge/Services/AppleArchiveUploadSnapshot.cs b/PowerForge/Services/AppleArchiveUploadSnapshot.cs index e2be488f5..006080bda 100644 --- a/PowerForge/Services/AppleArchiveUploadSnapshot.cs +++ b/PowerForge/Services/AppleArchiveUploadSnapshot.cs @@ -76,6 +76,12 @@ internal static SnapshotIdentity CaptureCompleteIdentity( 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)) diff --git a/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs b/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs new file mode 100644 index 000000000..25fc30e59 --- /dev/null +++ b/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace PowerForge; + +public sealed partial class AppleNotarizationService +{ + 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 6065dd5fe..7d41b1a28 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; @@ -126,6 +125,9 @@ public async Task NotarizeAsync( "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(); @@ -153,6 +155,36 @@ public async Task NotarizeAsync( submission = await RunAsync(xcrunExecutable, submissionArtifactPath, submitArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); (submissionId, status) = ParseSubmission(submission); } + if (!resumed && + submission.Succeeded && + (string.IsNullOrWhiteSpace(submissionId) || + (!string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && + !string.Equals(status, "Invalid", StringComparison.OrdinalIgnoreCase)))) + { + try + { + request.AmbiguousCheckpoint?.Invoke(new AppleNotarizationAmbiguousCheckpoint + { + ArtifactPath = artifactPath, + ArtifactSha256 = artifactSha256, + SubmissionPath = submissionPath, + SubmissionSha256 = submissionSha256!, + SubmissionId = submissionId, + Status = status + }); + } + catch (Exception ex) + { + throw new InvalidOperationException( + "notarytool exited successfully without complete submission evidence, and the ambiguous remote mutation checkpoint could not be persisted. " + + "Do not resubmit until Apple notary history has been reconciled.", + ex); + } + + throw new InvalidOperationException( + "notarytool exited successfully without a complete terminal submission id and status. The remote mutation is ambiguous; " + + "do not resubmit until Apple notary history has been reconciled."); + } using var acceptedArtifactMonitor = !resumed && submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) @@ -162,10 +194,6 @@ public async Task NotarizeAsync( "accepted checkpoint and retained submission capture", "Do not staple or publish until the accepted submission has been reconciled.") : null; - var submissionPath = resumed - ? artifactPath - : ResolveRetainedSubmissionPath(request, artifactPath); - if (!resumed && submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && @@ -343,6 +371,14 @@ public async Task NotarizeAsync( } 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", submissionArtifactPath } : new[] @@ -352,6 +388,17 @@ public async Task NotarizeAsync( "--verbose=4", submissionArtifactPath }; 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; @@ -586,25 +633,6 @@ private async Task RunAsync( return result; } - 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 ResolveAppleToolExecutable( string? executable, string defaultName, @@ -626,23 +654,6 @@ private static string ResolveAppleToolExecutable( $"Exact-source Apple notarization requires the trusted system tool '{trustedPath}'; received '{value}'."); } - 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); - } - } - internal static string ComputeArtifactSha256(string artifactPath) { using var sha256 = SHA256.Create(); diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs index 64a4308af..f51b9c543 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDirectDistribution.cs @@ -26,6 +26,7 @@ private AppleNotarizationResult NotarizeDirectAppleExport( 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), diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index d20035d66..5d8a99698 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -118,6 +118,35 @@ private void WriteAppleNotarizationAcceptance( }); } + 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 35055184d..882e45e8c 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -276,7 +276,10 @@ private bool HasPotentialVerifiedAppleUploadAttestation( 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('\\', '/')) && + 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 && @@ -297,13 +300,14 @@ private bool HasPotentialVerifiedAppleUploadAttestation( (string.IsNullOrWhiteSpace(app.ExpectedArchiveSha256) || string.Equals(candidate.ArchiveSha256, app.ExpectedArchiveSha256, StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(candidate.ArchivePath) && - IsVerifiedUploadCheckpoint(receipts, receipt, candidate)); + IsVerifiedUploadCheckpoint(receipts, receipt, candidate, plan.ProjectRoot)); } private static bool IsVerifiedUploadCheckpoint( IReadOnlyCollection receipts, PowerForgeAppleReleaseReceipt receipt, - PowerForgeAppleReleaseTargetReceipt target) + PowerForgeAppleReleaseTargetReceipt target, + string comparisonPath) { if (string.Equals(target.UploadAttestationAttemptId, receipt.AttemptId, StringComparison.OrdinalIgnoreCase)) return true; @@ -326,13 +330,13 @@ private static bool IsVerifiedUploadCheckpoint( 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) && + 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) && + AppleReleasePathsEqual(candidate.ArchivePath, target.ArchivePath, comparisonPath) && string.Equals(candidate.ArchiveSha256, target.ArchiveSha256, StringComparison.OrdinalIgnoreCase) && string.Equals(candidate.UploadExecutionSha256, target.UploadExecutionSha256, StringComparison.OrdinalIgnoreCase)); } @@ -345,19 +349,33 @@ private bool TryResumeDirectAppleNotarization( if (!IsUploadExecution(plan) || !plan.Automation.Resume) return false; - var prior = _appleReceiptStore.ReadAll(plan) + var latestNotarizationEvidence = _appleReceiptStore.ReadAll(plan) .Where(receipt => !receipt.PlanOnly && receipt.SchemaVersion >= 4 && !string.IsNullOrWhiteSpace(receipt.ReceiptSha256) && !string.IsNullOrWhiteSpace(plan.SourceCommit) && string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) - .SelectMany(receipt => receipt.Targets.Where(target => IsMatchingDirectReceiptTarget(plan, target, app))) - .FirstOrDefault(target => - string.Equals(target.NotarizationStatus, "Accepted", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(target.NotarizationSubmissionId) && - !string.IsNullOrWhiteSpace(target.DirectArtifactPath) && - IsSha256(target.DirectArtifactSha256)); + .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; @@ -446,7 +464,8 @@ internal static bool IsMatchingDirectReceiptTarget( string.Equals(target.Configuration, app.Configuration, StringComparison.OrdinalIgnoreCase) && AppleReleasePathsEqual( target.ProjectPath, - FrameworkCompatibility.GetRelativePath(plan.ProjectRoot, app.ProjectPath).Replace('\\', '/')) && + 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 && @@ -467,11 +486,23 @@ private static bool IsSha256(string? value) value!.Length == 64 && value.All(static character => Uri.IsHexDigit(character)); - internal static bool AppleReleasePathsEqual(string? left, string? right) - => string.Equals( + 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, - Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + FrameworkCompatibility.GetPathStringComparisonForPath(probePath)); + } private sealed class AppleUploadAttestation { diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index a1267aca4..f68958a2a 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2285,22 +2285,33 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( AppStoreConnectApiIssuerId = direct ? null : plan.AppStoreConnectApiIssuerId, AllowProvisioningUpdates = plan.AllowProvisioningUpdates }); + 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); + } + } if (direct && upload.Succeeded) directExportSnapshot!.BindProducedArtifact(upload.ExportArtifactPath, upload.ExportArtifactSha256); uploadMutationMonitor?.ValidateNoChanges(); if (uploadSnapshot is not null) uploadSnapshot.ValidateUnchanged(approvedArchiveSha256!); - upload.ArchivePath = app.ArchivePath; - result.Upload = upload; 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); } - - if (!direct) - WriteAppleUploadAttestation(plan, app, result); VerifyAppleArchiveUnchangedAfterUpload(app, result); if (direct) From ec93e1e7fd3e1734f98b86955ee8fe1ed1672f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 08:21:36 +0200 Subject: [PATCH 75/78] Close Apple mutation ambiguity gaps --- .../AppleArchiveBuildSnapshotTests.cs | 32 ++++++++ ...tarizationServiceTests.AcceptedBoundary.cs | 60 ++++++++++++-- ...eleaseServiceTests.AppleUploadAmbiguity.cs | 75 ++++++++++++++++++ ...leaseServiceTests.AppleVersionAtomicity.cs | 32 ++++++++ .../Services/AppleDirectExportSnapshot.cs | 47 +++++++++-- ...NotarizationService.SubmissionIntegrity.cs | 37 +++++++++ .../Services/AppleNotarizationService.cs | 51 ++++++------ .../AppleReleaseSourceTrustService.Xcode.cs | 4 +- .../AppleReleaseVersionSourceService.cs | 16 +++- ...owerForgeReleaseService.AppleDurability.cs | 30 +++++++ .../PowerForgeReleaseService.AppleResume.cs | 13 ++++ .../Services/PowerForgeReleaseService.cs | 78 ++++++++++++++----- ...Tests.AppleSourceTrustSystemSearchPaths.cs | 26 +++++++ 13 files changed, 438 insertions(+), 63 deletions(-) create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustSystemSearchPaths.cs diff --git a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs index c223c4048..99528e452 100644 --- a/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs +++ b/PowerForge.Tests/AppleArchiveBuildSnapshotTests.cs @@ -301,6 +301,38 @@ public void DirectExport_publish_rejects_artifact_replaced_after_xcodebuild_iden } } + [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() { diff --git a/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs index e1a950ea3..490a36067 100644 --- a/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs +++ b/PowerForge.Tests/AppleNotarizationServiceTests.AcceptedBoundary.cs @@ -3,12 +3,16 @@ namespace PowerForge.Tests; public sealed partial class AppleNotarizationServiceTests { [Theory] - [InlineData("{}", null, null)] - [InlineData("{\"id\":\"pending-submission\",\"status\":\"In Progress\"}", "pending-submission", "In Progress")] - public async Task NotarizeAsync_checkpoints_ambiguous_success_without_terminal_submission_evidence( + [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) + string? expectedStatus, + int exitCode, + bool timedOut) { var package = Path.GetTempFileName() + ".pkg"; await File.WriteAllTextAsync(package, "approved-package"); @@ -17,7 +21,7 @@ public async Task NotarizeAsync_checkpoints_ambiguous_success_without_terminal_s AppleNotarizationAmbiguousCheckpoint? checkpoint = null; var exception = await Assert.ThrowsAsync(() => - new AppleNotarizationService(new IncompleteSubmissionEvidenceRunner(response)).NotarizeAsync( + new AppleNotarizationService(new IncompleteSubmissionEvidenceRunner(response, exitCode, timedOut)).NotarizeAsync( new AppleNotarizationRequest { ArtifactPath = package, @@ -40,6 +44,36 @@ public async Task NotarizeAsync_checkpoints_ambiguous_success_without_terminal_s } } + [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() { @@ -143,24 +177,34 @@ public Task RunAsync(ProcessRunRequest request, CancellationTo private sealed class IncompleteSubmissionEvidenceRunner : IProcessRunner { private readonly string _response; + private readonly int _exitCode; + private readonly bool _timedOut; - internal IncompleteSubmissionEvidenceRunner(string response) + 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( - 0, + _exitCode, isSubmission ? _response : "ok", string.Empty, request.FileName, TimeSpan.Zero, - false); + _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/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs index 56da0172f..a1003de7f 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs @@ -2,6 +2,81 @@ namespace PowerForge.Tests; public sealed partial class PowerForgeReleaseServiceTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Execute_AppleUpload_blocks_reupload_after_indeterminate_process_result(bool throwFromUploader) + { + 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 => throwFromUploader + ? throw new IOException("response channel closed after upload handoff") + : 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 = sourceCommit, + 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 = sourceCommit, + 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_blocks_reupload_when_success_attestation_has_no_delivery_id() { diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs index 6c92af338..cf61b004b 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleVersionAtomicity.cs @@ -73,4 +73,36 @@ public void AppleVersionSource_UpdatePreservesApprovedSourceWhenInterruptedBefor 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/Services/AppleDirectExportSnapshot.cs b/PowerForge/Services/AppleDirectExportSnapshot.cs index 1b4d59702..058eeff18 100644 --- a/PowerForge/Services/AppleDirectExportSnapshot.cs +++ b/PowerForge/Services/AppleDirectExportSnapshot.cs @@ -6,6 +6,9 @@ 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) { @@ -127,11 +130,11 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) $"The published Developer ID export tree changed before notarization. Expected '{sourceExportSha256}', received '{observedPublishedExportSha256}'."); } if (movedExisting) - AppleArtifactCopy.RemoveBackupIfUnchanged( - backup, - backupDeletionCandidate, - existingDestination!, - "Previous Developer ID export"); + { + _publishedBackup = backup; + _publishedBackupDeletionCandidate = backupDeletionCandidate; + _publishedDestinationIdentity = existingDestination; + } return new ApplePublishedDirectExport(destination, publishedArtifact, publishedSha256); } catch (Exception publicationException) @@ -155,6 +158,40 @@ internal ApplePublishedDirectExport Publish(string destinationExportPath) } } + 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, diff --git a/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs b/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs index 25fc30e59..efaecf151 100644 --- a/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs +++ b/PowerForge/Services/AppleNotarizationService.SubmissionIntegrity.cs @@ -4,6 +4,43 @@ 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, diff --git a/PowerForge/Services/AppleNotarizationService.cs b/PowerForge/Services/AppleNotarizationService.cs index 7d41b1a28..166c412e0 100644 --- a/PowerForge/Services/AppleNotarizationService.cs +++ b/PowerForge/Services/AppleNotarizationService.cs @@ -152,41 +152,39 @@ public async Task NotarizeAsync( submittedFileMutationIdentity = ExistingFilePathIdentityResolver.CapturePrivateFileMutationIdentity( submittedPath, "private Apple notarization submitted file"); - submission = await RunAsync(xcrunExecutable, submissionArtifactPath, submitArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); - (submissionId, status) = ParseSubmission(submission); - } - if (!resumed && - submission.Succeeded && - (string.IsNullOrWhiteSpace(submissionId) || - (!string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && - !string.Equals(status, "Invalid", StringComparison.OrdinalIgnoreCase)))) - { try { - request.AmbiguousCheckpoint?.Invoke(new AppleNotarizationAmbiguousCheckpoint - { - ArtifactPath = artifactPath, - ArtifactSha256 = artifactSha256, - SubmissionPath = submissionPath, - SubmissionSha256 = submissionSha256!, - SubmissionId = submissionId, - Status = status - }); + submission = await RunAsync(xcrunExecutable, submissionArtifactPath, submitArguments, timeout, toolEnvironment, cancellationToken).ConfigureAwait(false); + (submissionId, status) = ParseSubmission(submission); } catch (Exception ex) { - throw new InvalidOperationException( - "notarytool exited successfully without complete submission evidence, and the ambiguous remote mutation checkpoint could not be persisted. " + - "Do not resubmit until Apple notary history has been reconciled.", + throw CreateAmbiguousSubmissionException( + request, + artifactPath, + artifactSha256, + submissionPath, + submissionSha256!, + submissionId: null, + status: null, ex); } - - throw new InvalidOperationException( - "notarytool exited successfully without a complete terminal submission id and status. The remote mutation is ambiguous; " + - "do not resubmit until Apple notary history has been reconciled."); + } + 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 && - submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) ? CreateArtifactMutationMonitor( submissionArtifactPath, @@ -195,7 +193,6 @@ public async Task NotarizeAsync( "Do not staple or publish until the accepted submission has been reconciled.") : null; if (!resumed && - submission.Succeeded && string.Equals(status, "Accepted", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(submissionId)) { diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index 98bf8f651..e6f3793f4 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -33,8 +33,10 @@ internal sealed partial class AppleReleaseSourceTrustService "SYSTEM_HEADER_SEARCH_PATHS", "MTL_HEADER_SEARCH_PATHS", "FRAMEWORK_SEARCH_PATHS", + "SYSTEM_FRAMEWORK_SEARCH_PATHS", "LIBRARY_SEARCH_PATHS", - "SWIFT_INCLUDE_PATHS" + "SWIFT_INCLUDE_PATHS", + "SWIFT_SYSTEM_INCLUDE_PATHS" }; private static readonly HashSet FlagBuildSettings = new(StringComparer.OrdinalIgnoreCase) diff --git a/PowerForge/Services/AppleReleaseVersionSourceService.cs b/PowerForge/Services/AppleReleaseVersionSourceService.cs index d0dda4d65..1cbe07bee 100644 --- a/PowerForge/Services/AppleReleaseVersionSourceService.cs +++ b/PowerForge/Services/AppleReleaseVersionSourceService.cs @@ -14,10 +14,14 @@ internal sealed class AppleReleaseVersionSourceService "^\\d+\\.\\d+(?:\\.\\d+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private readonly Action? _onComparedVersionSource; + private readonly Action _deleteFile; - internal AppleReleaseVersionSourceService(Action? onComparedVersionSource = null) + internal AppleReleaseVersionSourceService( + Action? onComparedVersionSource = null, + Action? deleteFile = null) { _onComparedVersionSource = onComparedVersionSource; + _deleteFile = deleteFile ?? File.Delete; } internal PowerForgeAppleVersionReceipt Read(string sourcePath) @@ -153,7 +157,7 @@ private void WriteIfUnchanged(string path, string expectedContent, string conten throw new InvalidOperationException( $"Apple version source changed while applying the approved update and was restored instead of being overwritten: {path}"); } - File.Delete(backupPath); + 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}"); @@ -163,10 +167,16 @@ private void WriteIfUnchanged(string path, string expectedContent, string conten if (File.Exists(temporaryPath)) File.Delete(temporaryPath); if (File.Exists(backupPath) && string.Equals(File.ReadAllText(backupPath), expectedContent, StringComparison.Ordinal)) - File.Delete(backupPath); + 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(); diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs index 5d8a99698..002ee850a 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleDurability.cs @@ -53,6 +53,36 @@ private void WriteAppleUploadAttestation( }); } + 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index 882e45e8c..be1903c65 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -199,6 +199,17 @@ private void EnsureAppleBuildUploadIsNotTerminal( 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); @@ -263,6 +274,8 @@ private bool HasPotentialVerifiedAppleUploadAttestation( 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) || !string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index f68958a2a..15cc0894a 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2265,26 +2265,47 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( "private Apple upload archive snapshot", "xcodebuild exportArchive", "Discard the upload/export result and inspect remote state before retrying."); - var upload = _uploadAppleApp(new AppleAppArchiveUploadRequest + AppleAppArchiveUploadResult upload; + try { - 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 - }); + 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 + }); + } + catch (Exception ex) when (!direct) + { + 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) @@ -2301,6 +2322,24 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( ex); } } + else if (!direct) + { + 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(); @@ -2336,6 +2375,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( WriteAppleNotarizationAttestation(plan, app, result); if (!result.Notarization.Succeeded) throw CreateAppleNotarizationFailure(app, result.Notarization); + directExportSnapshot.CommitPublication(); } else { 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); + } +} From 2370bc4b9192099184f1fa584d09bc022b73a5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 09:07:43 +0200 Subject: [PATCH 76/78] Close Apple recovery and path isolation gaps --- .../AppleAppArchiveServiceTests.cs | 1 + .../PowerForgeReleaseProgressAdaptersTests.cs | 17 ++++ ...ts.AppleHostedReviewRecoveryRegressions.cs | 7 +- ...ServiceTests.AppleNotarizationAmbiguity.cs | 10 ++- ...aseServiceTests.AppleNotarizationResume.cs | 12 +-- ...eleaseServiceTests.AppleUploadAmbiguity.cs | 83 +++++++++++++++++-- PowerForge/Abstractions/IProcessRunner.cs | 22 ++++- PowerForge/Models/AppleAppArchiveModels.cs | 5 ++ PowerForge/Services/AppleAppArchiveService.cs | 1 + ...PowerForgeReleaseService.AppleIntegrity.cs | 62 +++++++++++++- .../PowerForgeReleaseService.AppleResume.cs | 24 +++--- .../Services/PowerForgeReleaseService.cs | 8 +- 12 files changed, 217 insertions(+), 35 deletions(-) diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.cs index 61ec4d0e8..0b6995a78 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.cs @@ -580,6 +580,7 @@ public CapturingProcessRunner(ProcessRunResult? result = null, Action RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) { Requests.Add(request); + request.InvokeStartBoundary(); _beforeResult?.Invoke(request); if (_result.Succeeded) { 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/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs index 3a81cb83e..abe3476df 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs @@ -333,6 +333,7 @@ public void Execute_ApplePlan_RejectsEmptyReceiptHistoryPath() [InlineData("history-under-archive-root")] [InlineData("archive-overlaps-receipt-journal-lock")] [InlineData("plan-overwrites-project")] + [InlineData("archive-root-overlaps-export-root")] public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string scenario) { var root = CreateSandbox(); @@ -378,6 +379,9 @@ public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string sce case "plan-overwrites-project": automation.PlanReceiptPath = "CasaRay.xcodeproj/project.pbxproj"; break; + case "archive-root-overlaps-export-root": + spec.AppleApps.ExportRoot = spec.AppleApps.ArchiveRoot; + break; } var exception = Assert.Throws(() => @@ -393,7 +397,8 @@ public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string sce 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("automation output", StringComparison.OrdinalIgnoreCase) || + exception.Message.Contains("archive and export roots", StringComparison.OrdinalIgnoreCase), exception.Message); } finally diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs index 9d023e0a1..4682162de 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationAmbiguity.cs @@ -2,8 +2,10 @@ namespace PowerForge.Tests; public sealed partial class PowerForgeReleaseServiceTests { - [Fact] - public void Execute_AppleDirectNotarizationAmbiguityBlocksAutomaticResubmission() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Execute_AppleDirectNotarizationAmbiguityBlocksAutomaticResubmission(bool pinSource) { const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; var root = CreateSandbox(); @@ -45,7 +47,7 @@ public void Execute_AppleDirectNotarizationAmbiguityBlocksAutomaticResubmission( { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Upload, - AppleSourceCommit = sourceCommit + AppleSourceCommit = pinSource ? sourceCommit : null }); Assert.False(initial.Success); @@ -75,7 +77,7 @@ public void Execute_AppleDirectNotarizationAmbiguityBlocksAutomaticResubmission( { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Upload, - AppleSourceCommit = sourceCommit + AppleSourceCommit = pinSource ? sourceCommit : null }); Assert.False(resumed.Success); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs index 9dd243b88..c0e76884c 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleNotarizationResume.cs @@ -203,8 +203,10 @@ public void UploadAttestationResume_UsesOwningVolumeProjectPathIdentity() matches); } - [Fact] - public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterCheckoutRelocation() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterCheckoutRelocation(bool pinSource) { const string sourceCommit = "0123456789abcdef0123456789abcdef01234567"; var originalRoot = CreateSandbox(); @@ -252,7 +254,7 @@ public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterChe { ConfigPath = Path.Combine(originalRoot, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Upload, - AppleSourceCommit = sourceCommit + AppleSourceCommit = pinSource ? sourceCommit : null }); Assert.False(failed.Success); var accepted = Assert.Single( @@ -289,7 +291,7 @@ public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterChe { ConfigPath = Path.Combine(relocatedRoot, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Upload, - AppleSourceCommit = sourceCommit + AppleSourceCommit = pinSource ? sourceCommit : null }); Assert.False(blocked.Success); Assert.Contains( @@ -301,7 +303,7 @@ public void Execute_AppleDirectNotarizationResume_FollowsRelativeReceiptAfterChe { ConfigPath = Path.Combine(relocatedRoot, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Upload, - AppleSourceCommit = sourceCommit, + AppleSourceCommit = pinSource ? sourceCommit : null, AppleAdoptExistingBuild = true, AppleActionConfirmed = true }); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs index a1003de7f..8ea1233ed 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleUploadAmbiguity.cs @@ -3,9 +3,13 @@ namespace PowerForge.Tests; public sealed partial class PowerForgeReleaseServiceTests { [Theory] - [InlineData(false)] - [InlineData(true)] - public void Execute_AppleUpload_blocks_reupload_after_indeterminate_process_result(bool throwFromUploader) + [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(); @@ -21,9 +25,12 @@ public void Execute_AppleUpload_blocks_reupload_after_indeterminate_process_resu var initial = CreateAppleAutomationService( request => CreateReleaseState(request, processingState: null), archiveAppleApp: CreateSuccessfulArchive, - uploadAppleApp: request => throwFromUploader - ? throw new IOException("response channel closed after upload handoff") - : new AppleAppArchiveUploadResult + uploadAppleApp: request => + { + request.InvokeRemoteMutationStarted(); + if (throwFromUploader) + throw new IOException("response channel closed after upload handoff"); + return new AppleAppArchiveUploadResult { ArchivePath = request.ArchivePath, ExportPath = request.ExportPath!, @@ -35,12 +42,13 @@ public void Execute_AppleUpload_blocks_reupload_after_indeterminate_process_resu "xcodebuild", TimeSpan.FromMinutes(5), true) - }) + }; + }) .Execute(spec, new PowerForgeReleaseRequest { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Upload, - AppleSourceCommit = sourceCommit, + AppleSourceCommit = pinSource ? sourceCommit : null, AppleWaitForProcessing = false }); @@ -62,7 +70,7 @@ public void Execute_AppleUpload_blocks_reupload_after_indeterminate_process_resu { ConfigPath = Path.Combine(root, "powerforge.release.json"), AppleAction = PowerForgeAppleReleaseAction.Upload, - AppleSourceCommit = sourceCommit, + AppleSourceCommit = pinSource ? sourceCommit : null, AppleWaitForProcessing = false }); @@ -77,6 +85,63 @@ public void Execute_AppleUpload_blocks_reupload_after_indeterminate_process_resu } } + [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() { diff --git a/PowerForge/Abstractions/IProcessRunner.cs b/PowerForge/Abstractions/IProcessRunner.cs index 9aab21cf7..1628f8b50 100644 --- a/PowerForge/Abstractions/IProcessRunner.cs +++ b/PowerForge/Abstractions/IProcessRunner.cs @@ -8,6 +8,8 @@ namespace PowerForge; /// public sealed class ProcessRunRequest { + private int _startBoundaryInvoked; + private Action? _startBoundary; private int _completionBoundaryInvoked; private Action? _completionBoundary; /// @@ -198,6 +200,21 @@ public ProcessRunRequest( 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 @@ -294,7 +311,9 @@ public interface IProcessRunner /// Cancellation token. /// Structured process execution result. /// - /// Implementations must call immediately + /// 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. /// @@ -324,6 +343,7 @@ public async Task RunAsync(ProcessRunRequest request, Cancella try { process.Start(); + request.InvokeStartBoundary(); } catch (Exception ex) { diff --git a/PowerForge/Models/AppleAppArchiveModels.cs b/PowerForge/Models/AppleAppArchiveModels.cs index 7302f7318..914883567 100644 --- a/PowerForge/Models/AppleAppArchiveModels.cs +++ b/PowerForge/Models/AppleAppArchiveModels.cs @@ -83,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; diff --git a/PowerForge/Services/AppleAppArchiveService.cs b/PowerForge/Services/AppleAppArchiveService.cs index 75613d140..22309940d 100644 --- a/PowerForge/Services/AppleAppArchiveService.cs +++ b/PowerForge/Services/AppleAppArchiveService.cs @@ -250,6 +250,7 @@ public async Task UploadArchiveAsync( captureOutput: true, captureError: true, inheritEnvironment: toolEnvironment is null); + processRequest.SetStartBoundary(request.InvokeRemoteMutationStarted); if (directExport) { processRequest.SetCompletionBoundary(completionResult => diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs index b86c45850..c8f5d9ce9 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs @@ -9,6 +9,12 @@ private static void ValidateAppleAutomationOutputPaths( 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[] @@ -51,7 +57,7 @@ private static void ValidateAppleAutomationOutputPaths( .ToArray(); foreach (var output in outputs) { - foreach (var protectedPath in protectedPaths.Where(static entry => !string.IsNullOrWhiteSpace(entry.Path))) + foreach (var protectedPath in protectedEntries) { if (!PathsOverlap(output.Path, protectedPath.Path)) continue; @@ -62,6 +68,60 @@ private static void ValidateAppleAutomationOutputPaths( } } + 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); diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs index be1903c65..44c2b9778 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleResume.cs @@ -183,9 +183,6 @@ private void EnsureAppleBuildUploadIsNotTerminal( AppStoreConnectReleaseStateResult state, AppStoreConnectBuildInfo? remoteBuild) { - if (string.IsNullOrWhiteSpace(plan.SourceCommit)) - return null; - var receipts = _appleReceiptStore.ReadAll(plan); foreach (var receipt in receipts) { @@ -250,11 +247,6 @@ private bool HasPotentialVerifiedAppleUploadAttestation( PowerForgeAppleReleasePlan plan, PowerForgeAppleAppReleaseTargetPlan app) { - if (string.IsNullOrWhiteSpace(plan.SourceCommit)) - { - return false; - } - var receipts = _appleReceiptStore.ReadAll(plan); return receipts.Any(receipt => FindMatchingAppleUploadAttestationTarget( receipts, @@ -278,7 +270,7 @@ private bool HasPotentialVerifiedAppleUploadAttestation( !string.Equals(receipt.OperationPhase, "UploadAmbiguous", StringComparison.Ordinal)) || receipt.SchemaVersion < 4 || string.IsNullOrWhiteSpace(receipt.ReceiptSha256) || - !string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) + !AppleSourceCommitEvidenceMatches(receipt.SourceCommit, plan.SourceCommit)) { return null; } @@ -367,8 +359,7 @@ private bool TryResumeDirectAppleNotarization( !receipt.PlanOnly && receipt.SchemaVersion >= 4 && !string.IsNullOrWhiteSpace(receipt.ReceiptSha256) && - !string.IsNullOrWhiteSpace(plan.SourceCommit) && - string.Equals(receipt.SourceCommit, plan.SourceCommit, StringComparison.OrdinalIgnoreCase)) + AppleSourceCommitEvidenceMatches(receipt.SourceCommit, plan.SourceCommit)) .SelectMany(receipt => receipt.Targets .Where(target => IsMatchingDirectReceiptTarget(plan, target, app)) .Select(target => new { Receipt = receipt, Target = target })) @@ -499,6 +490,17 @@ private static bool IsSha256(string? 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 15cc0894a..051e61546 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -2266,6 +2266,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( "xcodebuild exportArchive", "Discard the upload/export result and inspect remote state before retrying."); AppleAppArchiveUploadResult upload; + var uploadRemoteMutationStarted = false; try { upload = _uploadAppleApp(new AppleAppArchiveUploadRequest @@ -2286,10 +2287,11 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( AppStoreConnectApiKeyPath = direct ? null : plan.AppStoreConnectApiKeyPath, AppStoreConnectApiKeyId = direct ? null : plan.AppStoreConnectApiKeyId, AppStoreConnectApiIssuerId = direct ? null : plan.AppStoreConnectApiIssuerId, - AllowProvisioningUpdates = plan.AllowProvisioningUpdates + AllowProvisioningUpdates = plan.AllowProvisioningUpdates, + RemoteMutationStarted = () => uploadRemoteMutationStarted = true }); } - catch (Exception ex) when (!direct) + catch (Exception ex) when (!direct && uploadRemoteMutationStarted) { try { @@ -2322,7 +2324,7 @@ private PowerForgeAppleAppReleaseResult[] RunAppleRelease( ex); } } - else if (!direct) + else if (!direct && uploadRemoteMutationStarted) { try { From ccb6c4851d64b3a78faafec8696ab3c92c4202c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 10:09:01 +0200 Subject: [PATCH 77/78] Close Apple source trust and recovery gaps --- ...eTests.AppleDirectRecoveryPathSemantics.cs | 26 ++++++++++ .../Services/AppleReleaseArtifactService.cs | 3 +- ...easeSourceTrustService.BuildFlagOptions.cs | 4 +- .../AppleReleaseSourceTrustService.Xcode.cs | 1 + ...PowerForgeReleaseService.AppleIntegrity.cs | 13 ++--- ...AppleSourceTrustDependencyScannerInputs.cs | 45 ++++++++++++++++ ....AppleSourceTrustInterfaceBuilderInputs.cs | 51 +++++++++++++++++++ 7 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 PowerForge.Tests/PowerForgeReleaseServiceTests.AppleDirectRecoveryPathSemantics.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustDependencyScannerInputs.cs create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustInterfaceBuilderInputs.cs 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/Services/AppleReleaseArtifactService.cs b/PowerForge/Services/AppleReleaseArtifactService.cs index 8b6b8296c..2f592092b 100644 --- a/PowerForge/Services/AppleReleaseArtifactService.cs +++ b/PowerForge/Services/AppleReleaseArtifactService.cs @@ -140,7 +140,8 @@ 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); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index 6a64cbfc5..7d2023fb7 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -22,7 +22,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-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", "-fembed-offload-object", + "-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", @@ -62,7 +62,7 @@ internal sealed partial class AppleReleaseSourceTrustService "-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=", - "-fexperimental-sanitize-metadata-ignorelist=", "-fembed-offload-object=", + "-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=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs index e6f3793f4..cf11c0e16 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.Xcode.cs @@ -33,6 +33,7 @@ internal sealed partial class AppleReleaseSourceTrustService "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", diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs index c8f5d9ce9..99268a1ae 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs @@ -169,11 +169,7 @@ private static string ValidateDirectRecoveryArtifactPath( : Path.Combine(plan.ProjectRoot, storedPath)); var exportRoot = Path.GetFullPath(app.ExportPath) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var comparison = Path.DirectorySeparatorChar == '\\' - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - if (!artifactPath.Equals(exportRoot, comparison) && - !artifactPath.StartsWith(exportRoot + Path.DirectorySeparatorChar, comparison)) + if (!AppleReleaseArtifactService.IsWithinRoot(artifactPath, exportRoot)) { throw new InvalidOperationException( $"Direct Apple recovery artifact for '{app.Name}' is outside its current export root: {artifactPath}"); @@ -185,13 +181,10 @@ private static string ValidateDirectRecoveryArtifactPath( private static void EnsurePathHasNoLinkedTraversal(string projectRoot, string path, string name) { - var comparison = Path.DirectorySeparatorChar == '\\' - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; var root = Path.GetFullPath(projectRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var current = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (!current.Equals(root, comparison) && - !current.StartsWith(root + Path.DirectorySeparatorChar, comparison)) + var comparison = GetAppleOutputPathComparison(current, root); + if (!AppleReleaseArtifactService.IsWithinRoot(current, root)) throw new InvalidOperationException($"{name} is outside AppleApps.ProjectRoot: {current}"); while (true) 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.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); + } +} From bb1689a829b8aec18972fe6fa04ce6652a8ecb25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Wed, 12 Aug 2026 11:09:48 +0200 Subject: [PATCH 78/78] Complete Apple release input isolation --- ...ArchiveServiceTests.ExactSourcePackages.cs | 2 +- .../AppleRemotePackageMirrorLeaseTests.cs | 33 +++++ ...ts.AppleHostedReviewRecoveryRegressions.cs | 21 ++- ...eSourceTrustService.BuildFlagFileInputs.cs | 3 +- ...easeSourceTrustService.BuildFlagOptions.cs | 4 +- ...eleaseSourceTrustService.RemotePackages.cs | 120 +++++++++++++++++- .../AppleSwiftPackageBuildSnapshot.cs | 8 +- .../ExistingFilePathIdentityResolver.cs | 29 +++++ ...PowerForgeReleaseService.AppleIntegrity.cs | 43 +++++++ .../Services/PowerForgeReleaseService.cs | 7 + ...ests.AppleSourceTrustGccAndLinkerInputs.cs | 45 +++++++ 11 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.AppleSourceTrustGccAndLinkerInputs.cs diff --git a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs index 9eaf43fca..0363dcbf3 100644 --- a/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs +++ b/PowerForge.Tests/AppleAppArchiveServiceTests.ExactSourcePackages.cs @@ -45,7 +45,7 @@ public async Task CreateArchiveAsync_exact_source_builds_from_validated_private_ Assert.Equal( resolve.Arguments[Array.IndexOf(resolve.Arguments.ToArray(), "-clonedSourcePackagesDirPath") + 1], archive.Arguments[Array.IndexOf(archive.Arguments.ToArray(), "-clonedSourcePackagesDirPath") + 1]); - Assert.Equal( + 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)); diff --git a/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs b/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs index ab4409534..2a90d99db 100644 --- a/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs +++ b/PowerForge.Tests/AppleRemotePackageMirrorLeaseTests.cs @@ -76,6 +76,39 @@ public void EnsureRemotePackageMirror_AcceptsRevisionMaterializedByConcurrentFet } } + [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); diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs index abe3476df..af66cbe49 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.AppleHostedReviewRecoveryRegressions.cs @@ -334,6 +334,8 @@ public void Execute_ApplePlan_RejectsEmptyReceiptHistoryPath() [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(); @@ -382,6 +384,22 @@ public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string sce 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(() => @@ -398,7 +416,8 @@ public void Execute_ApplePlan_RejectsOverlappingAutomationOutputPaths(string sce 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 and export roots", StringComparison.OrdinalIgnoreCase) || + exception.Message.Contains("archive/export roots", StringComparison.OrdinalIgnoreCase), exception.Message); } finally diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs index 959cd86b0..7bbc6bf92 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagFileInputs.cs @@ -85,7 +85,8 @@ private static bool TryReadLinkerFileInputPaths( } if (!token.Equals("-sectorder", StringComparison.Ordinal) && - !token.Equals("-sectcreate", 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."); diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs index 7d2023fb7..27cea8091 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.BuildFlagOptions.cs @@ -11,7 +11,7 @@ internal sealed partial class AppleReleaseSourceTrustService "--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", - "--sysroot", "--warning-suppression-mappings", + "--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", @@ -52,7 +52,7 @@ internal sealed partial class AppleReleaseSourceTrustService "--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=", - "--rocm-path=", "--sysroot=", + "--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=", diff --git a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs index 4f366e644..ac7dfeb60 100644 --- a/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs +++ b/PowerForge/Services/AppleReleaseSourceTrustService.RemotePackages.cs @@ -1,3 +1,6 @@ +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -344,6 +347,11 @@ internal static FileStream AcquireRemotePackageMirrorLease(string mirrorPath) { try { +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + return OpenUnixRemotePackageMirrorLease(lockPath); +#endif + RejectLinkedRemotePackageMirrorLock(lockPath); var lease = new FileStream( lockPath, FileMode.OpenOrCreate, @@ -353,10 +361,7 @@ internal static FileStream AcquireRemotePackageMirrorLease(string mirrorPath) FileOptions.None); try { -#if NET8_0_OR_GREATER - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(lockPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); -#endif + ValidateRemotePackageMirrorLockIdentity(lockPath, lease); return lease; } catch @@ -372,6 +377,113 @@ internal static FileStream AcquireRemotePackageMirrorLease(string mirrorPath) } } + 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"); diff --git a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs index 238ba1513..36e8b60bf 100644 --- a/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs +++ b/PowerForge/Services/AppleSwiftPackageBuildSnapshot.cs @@ -30,7 +30,9 @@ private AppleSwiftPackageBuildSnapshot( internal string SourcePackagesPath => Path.Combine(RootPath, "SourcePackages"); - internal string DerivedDataPath => Path.Combine(RootPath, "DerivedData"); + internal string ResolverDerivedDataPath => Path.Combine(RootPath, "ResolverDerivedData"); + + internal string ArchiveDerivedDataPath => Path.Combine(RootPath, "ArchiveDerivedData"); internal IReadOnlyDictionary EnvironmentVariables => _environmentVariables; @@ -55,7 +57,7 @@ internal static async Task CreateAsync( try { var sourcePackagesPath = Path.Combine(root, "SourcePackages"); - var derivedDataPath = Path.Combine(root, "DerivedData"); + var derivedDataPath = Path.Combine(root, "ResolverDerivedData"); Directory.CreateDirectory(sourcePackagesPath); Directory.CreateDirectory(derivedDataPath); var repositoryRoot = FindRepositoryRoot(projectPath); @@ -144,7 +146,7 @@ internal void AppendArchiveArguments(ICollection arguments) arguments.Add("-clonedSourcePackagesDirPath"); arguments.Add(SourcePackagesPath); arguments.Add("-derivedDataPath"); - arguments.Add(DerivedDataPath); + arguments.Add(ArchiveDerivedDataPath); arguments.Add("-onlyUsePackageVersionsFromResolvedFile"); arguments.Add("-disableAutomaticPackageResolution"); arguments.Add("-skipPackageUpdates"); diff --git a/PowerForge/Services/ExistingFilePathIdentityResolver.cs b/PowerForge/Services/ExistingFilePathIdentityResolver.cs index a0bd8062c..61c357416 100644 --- a/PowerForge/Services/ExistingFilePathIdentityResolver.cs +++ b/PowerForge/Services/ExistingFilePathIdentityResolver.cs @@ -40,6 +40,35 @@ internal static ExistingFilePhysicalStatus ResolveStatus(string path) #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) diff --git a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs index 99268a1ae..4499ce0d5 100644 --- a/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs +++ b/PowerForge/Services/PowerForgeReleaseService.AppleIntegrity.cs @@ -2,6 +2,49 @@ 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, diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs index 051e61546..a3f2b1a1e 100644 --- a/PowerForge/Services/PowerForgeReleaseService.cs +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -1627,6 +1627,13 @@ private PowerForgeAppleReleasePlan PrepareAppleRelease( .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))); 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); + } +}