From 6c7f0e45c0e11cb6f8cb94e68a4deb53bcd0b143 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 15:10:32 +0900 Subject: [PATCH 01/13] Add provenance-pinned dispatcher bootstrap reader Keep legacy pin readers compatible with additive fields while making bootstrap-specific tag and manifest inputs fail closed when absent or malformed. --- .../Tests/Editor/CliPinReaderServiceTests.cs | 155 ++++++++++++++++++ .../src/Editor/Application/CliPinReader.cs | 50 +++++- .../Infrastructure/CLI/CliPinReaderService.cs | 123 +++++++++++++- .../internal/dispatcher/dispatcher_test.go | 25 +++ 4 files changed, 351 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/CliPinReaderServiceTests.cs b/Assets/Tests/Editor/CliPinReaderServiceTests.cs index 7616c43b10..ac33a8154d 100644 --- a/Assets/Tests/Editor/CliPinReaderServiceTests.cs +++ b/Assets/Tests/Editor/CliPinReaderServiceTests.cs @@ -176,6 +176,161 @@ public void LoadPinFromPath_WhenFileIsInvalidJson_ReturnsFailureWithInvalidJsonM } } + [Test] + public void LoadDispatcherBootstrapPinFromPath_WhenBootstrapFieldsAreMissing_RejectsOnlyBootstrapLoading() + { + // Tests that an old pin keeps existing readers working while bootstrap loading fails closed. + string root = CreateTestRoot(); + string pinPath = Path.Combine(root, "project-runner-pin.json"); + + try + { + Directory.CreateDirectory(root); + File.WriteAllText( + pinPath, + "{\"projectRunnerVersion\":\"3.0.0\",\"minimumDispatcherVersion\":\"3.0.1\"}"); + + CliPinLoadResult regularResult = CliPinReaderService.LoadPinFromPath(pinPath); + DispatcherBootstrapPinLoadResult bootstrapResult = + CliPinReaderService.LoadDispatcherBootstrapPinFromPath(pinPath); + + Assert.That(regularResult.Success, Is.True); + Assert.That(bootstrapResult.Success, Is.False); + Assert.That(bootstrapResult.ErrorMessage, Does.Contain("dispatcherReleaseTag")); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Test] + public void LoadDispatcherBootstrapPinFromPath_WhenOnlyOneBootstrapFieldExists_ReturnsFailure() + { + // Tests that a partially stamped pin is malformed rather than treated as a legacy pin. + string root = CreateTestRoot(); + string pinPath = Path.Combine(root, "project-runner-pin.json"); + + try + { + Directory.CreateDirectory(root); + File.WriteAllText( + pinPath, + "{\"projectRunnerVersion\":\"3.0.0\",\"minimumDispatcherVersion\":\"3.0.1\",\"dispatcherReleaseTag\":\"dispatcher-v3.0.1\"}"); + + DispatcherBootstrapPinLoadResult result = + CliPinReaderService.LoadDispatcherBootstrapPinFromPath(pinPath); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorMessage, Does.Contain("both")); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Test] + public void LoadDispatcherBootstrapPinFromPath_WhenBootstrapFieldsAreEmpty_ReturnsFailure() + { + // Tests that empty bootstrap values cannot fall back to an unauthenticated install path. + string root = CreateTestRoot(); + string pinPath = Path.Combine(root, "project-runner-pin.json"); + + try + { + Directory.CreateDirectory(root); + File.WriteAllText( + pinPath, + "{\"projectRunnerVersion\":\"3.0.0\",\"minimumDispatcherVersion\":\"3.0.1\",\"dispatcherReleaseTag\":\"\",\"dispatcherArchiveManifest\":\"\"}"); + + DispatcherBootstrapPinLoadResult result = + CliPinReaderService.LoadDispatcherBootstrapPinFromPath(pinPath); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorMessage, Does.Contain("empty")); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Test] + public void LoadDispatcherBootstrapPinFromPath_WhenManifestIsMalformed_ReturnsFailure() + { + // Tests that a manifest entry without an exact SHA-256 digest format fails closed. + string root = CreateTestRoot(); + string pinPath = Path.Combine(root, "project-runner-pin.json"); + + try + { + Directory.CreateDirectory(root); + File.WriteAllText( + pinPath, + "{\"projectRunnerVersion\":\"3.0.0\",\"minimumDispatcherVersion\":\"3.0.1\",\"dispatcherReleaseTag\":\"dispatcher-v3.0.1\",\"dispatcherArchiveManifest\":\"not-a-digest uloop-dispatcher-darwin-arm64.zip\"}"); + + DispatcherBootstrapPinLoadResult result = + CliPinReaderService.LoadDispatcherBootstrapPinFromPath(pinPath); + + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorMessage, Does.Contain("invalid dispatcherArchiveManifest entry")); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Test] + public void LoadDispatcherBootstrapPinFromPath_WhenBootstrapFieldsAreValid_ReturnsPinnedReleaseInputs() + { + // Tests that a valid bootstrap pin returns the immutable release tag and complete manifest text. + string root = CreateTestRoot(); + string pinPath = Path.Combine(root, "project-runner-pin.json"); + string manifest = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\n" + + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb uloop-dispatcher-darwin-arm64.zip"; + + try + { + Directory.CreateDirectory(root); + File.WriteAllText( + pinPath, + "{\"projectRunnerVersion\":\"3.0.0\",\"minimumDispatcherVersion\":\"3.0.1\",\"dispatcherReleaseTag\":\"dispatcher-v3.0.1\",\"dispatcherArchiveManifest\":\"" + + manifest.Replace("\n", "\\n") + + "\"}"); + + CliPinLoadResult regularResult = CliPinReaderService.LoadPinFromPath(pinPath); + DispatcherBootstrapPinLoadResult bootstrapResult = + CliPinReaderService.LoadDispatcherBootstrapPinFromPath(pinPath); + + Assert.That(regularResult.Success, Is.True); + Assert.That(bootstrapResult.Success, Is.True, bootstrapResult.ErrorMessage); + Assert.That(bootstrapResult.DispatcherReleaseTag, Is.EqualTo("dispatcher-v3.0.1")); + Assert.That(bootstrapResult.ArchiveManifest, Is.EqualTo(manifest)); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + private static string CreateTestRoot() { return Path.Combine( diff --git a/Packages/src/Editor/Application/CliPinReader.cs b/Packages/src/Editor/Application/CliPinReader.cs index 5f260a9312..81bf12d67d 100644 --- a/Packages/src/Editor/Application/CliPinReader.cs +++ b/Packages/src/Editor/Application/CliPinReader.cs @@ -7,14 +7,61 @@ namespace io.github.hatayama.UnityCliLoop.Application /// public readonly struct CliPin { - public CliPin(string projectRunnerVersion, string minimumDispatcherVersion) + public CliPin( + string projectRunnerVersion, + string minimumDispatcherVersion, + string dispatcherReleaseTag, + string dispatcherArchiveManifest) { ProjectRunnerVersion = projectRunnerVersion; MinimumDispatcherVersion = minimumDispatcherVersion; + DispatcherReleaseTag = dispatcherReleaseTag; + DispatcherArchiveManifest = dispatcherArchiveManifest; } public string ProjectRunnerVersion { get; } public string MinimumDispatcherVersion { get; } + public string DispatcherReleaseTag { get; } + public string DispatcherArchiveManifest { get; } + } + + /// + /// Result of reading the release inputs required for a provenance-pinned dispatcher bootstrap. + /// + public readonly struct DispatcherBootstrapPinLoadResult + { + private DispatcherBootstrapPinLoadResult( + bool success, + string dispatcherReleaseTag, + string archiveManifest, + string errorMessage) + { + Success = success; + DispatcherReleaseTag = dispatcherReleaseTag; + ArchiveManifest = archiveManifest; + ErrorMessage = errorMessage; + } + + public bool Success { get; } + public string DispatcherReleaseTag { get; } + public string ArchiveManifest { get; } + public string ErrorMessage { get; } + + public static DispatcherBootstrapPinLoadResult FromSuccess( + string dispatcherReleaseTag, + string archiveManifest) + { + return new DispatcherBootstrapPinLoadResult( + true, + dispatcherReleaseTag, + archiveManifest, + string.Empty); + } + + public static DispatcherBootstrapPinLoadResult FromFailure(string errorMessage) + { + return new DispatcherBootstrapPinLoadResult(false, string.Empty, string.Empty, errorMessage); + } } /// @@ -51,6 +98,7 @@ public static CliPinLoadResult FromFailure(string errorMessage) public interface ICliPinReader { CliPinLoadResult LoadPackagePin(); + DispatcherBootstrapPinLoadResult LoadDispatcherBootstrapPin(); string LoadMinimumDispatcherVersionOrThrow(); } diff --git a/Packages/src/Editor/Infrastructure/CLI/CliPinReaderService.cs b/Packages/src/Editor/Infrastructure/CLI/CliPinReaderService.cs index 5db10cfa6c..7d88d4467c 100644 --- a/Packages/src/Editor/Infrastructure/CLI/CliPinReaderService.cs +++ b/Packages/src/Editor/Infrastructure/CLI/CliPinReaderService.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json.Linq; using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Infrastructure @@ -17,6 +18,10 @@ public sealed class CliPinReaderService : ICliPinReader // Why: keep pin JSON keys named in one place so callers do not spread string literals. private const string PIN_JSON_PROJECT_RUNNER_VERSION_KEY = "projectRunnerVersion"; private const string PIN_JSON_MINIMUM_DISPATCHER_VERSION_KEY = "minimumDispatcherVersion"; + private const string PIN_JSON_DISPATCHER_RELEASE_TAG_KEY = "dispatcherReleaseTag"; + private const string PIN_JSON_DISPATCHER_ARCHIVE_MANIFEST_KEY = "dispatcherArchiveManifest"; + private const int SHA256_HEX_LENGTH = 64; + private const int MANIFEST_ENTRY_PREFIX_LENGTH = SHA256_HEX_LENGTH + 2; public CliPinLoadResult LoadPackagePin() { @@ -26,6 +31,17 @@ public CliPinLoadResult LoadPackagePin() return LoadPinFromPath(pinPath); } + /// + /// Reads the provenance-pinned dispatcher release inputs that are required before bootstrap execution. + /// + public DispatcherBootstrapPinLoadResult LoadDispatcherBootstrapPin() + { + string pinPath = Path.Combine( + UnityCliLoopConstants.PackageResolvedPath, + UnityCliLoopConstants.ULOOP_PROJECT_RUNNER_PIN_FILE_NAME); + return LoadDispatcherBootstrapPinFromPath(pinPath); + } + // Why: setup/detection paths must fail closed when the pin is unreadable rather than defaulting // to "compatible", so both call sites share one Fail-Fast helper instead of duplicating it. public string LoadMinimumDispatcherVersionOrThrow() @@ -78,6 +94,8 @@ internal static CliPinLoadResult LoadPinFromPath(string pinPath) string projectRunnerVersion = parsed[PIN_JSON_PROJECT_RUNNER_VERSION_KEY]?.ToString(); string minimumDispatcherVersion = parsed[PIN_JSON_MINIMUM_DISPATCHER_VERSION_KEY]?.ToString(); + string dispatcherReleaseTag = parsed[PIN_JSON_DISPATCHER_RELEASE_TAG_KEY]?.ToString(); + string dispatcherArchiveManifest = parsed[PIN_JSON_DISPATCHER_ARCHIVE_MANIFEST_KEY]?.ToString(); if (string.IsNullOrWhiteSpace(projectRunnerVersion)) { return CliPinLoadResult.FromFailure( @@ -90,7 +108,110 @@ internal static CliPinLoadResult LoadPinFromPath(string pinPath) } return CliPinLoadResult.FromSuccess( - new CliPin(projectRunnerVersion, minimumDispatcherVersion)); + new CliPin( + projectRunnerVersion, + minimumDispatcherVersion, + dispatcherReleaseTag, + dispatcherArchiveManifest)); + } + + /// + /// Loads and validates the additional pin fields needed to bootstrap a dispatcher release safely. + /// + internal static DispatcherBootstrapPinLoadResult LoadDispatcherBootstrapPinFromPath(string pinPath) + { + CliPinLoadResult pinResult = LoadPinFromPath(pinPath); + if (!pinResult.Success) + { + return DispatcherBootstrapPinLoadResult.FromFailure(pinResult.ErrorMessage); + } + + return ValidateDispatcherBootstrapPin(pinResult.Pin, pinPath); + } + + private static DispatcherBootstrapPinLoadResult ValidateDispatcherBootstrapPin(CliPin pin, string pinPath) + { + bool hasReleaseTag = pin.DispatcherReleaseTag != null; + bool hasArchiveManifest = pin.DispatcherArchiveManifest != null; + if (!hasReleaseTag && !hasArchiveManifest) + { + return DispatcherBootstrapPinLoadResult.FromFailure( + $"Unity CLI Loop pin file at {pinPath} is missing dispatcherReleaseTag and dispatcherArchiveManifest."); + } + if (!hasReleaseTag || !hasArchiveManifest) + { + return DispatcherBootstrapPinLoadResult.FromFailure( + $"Unity CLI Loop pin file at {pinPath} must define both dispatcherReleaseTag and dispatcherArchiveManifest."); + } + if (string.IsNullOrWhiteSpace(pin.DispatcherReleaseTag) + || string.IsNullOrWhiteSpace(pin.DispatcherArchiveManifest)) + { + return DispatcherBootstrapPinLoadResult.FromFailure( + $"Unity CLI Loop pin file at {pinPath} defines empty dispatcher bootstrap fields."); + } + if (!IsValidDispatcherReleaseTag(pin.DispatcherReleaseTag)) + { + return DispatcherBootstrapPinLoadResult.FromFailure( + $"Unity CLI Loop pin file at {pinPath} defines an invalid dispatcherReleaseTag."); + } + if (!IsValidArchiveManifest(pin.DispatcherArchiveManifest)) + { + return DispatcherBootstrapPinLoadResult.FromFailure( + $"Unity CLI Loop pin file at {pinPath} contains an invalid dispatcherArchiveManifest entry."); + } + + return DispatcherBootstrapPinLoadResult.FromSuccess( + pin.DispatcherReleaseTag, + pin.DispatcherArchiveManifest); + } + + private static bool IsValidDispatcherReleaseTag(string dispatcherReleaseTag) + { + return dispatcherReleaseTag == dispatcherReleaseTag.Trim() + && dispatcherReleaseTag.StartsWith(CliConstants.DISPATCHER_RELEASE_TAG_PREFIX, StringComparison.Ordinal) + && dispatcherReleaseTag.IndexOfAny(new char[] { '\r', '\n', '\t', ' ' }) < 0; + } + + private static bool IsValidArchiveManifest(string archiveManifest) + { + string[] entries = archiveManifest.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None); + foreach (string entry in entries) + { + if (!IsValidArchiveManifestEntry(entry)) + { + return false; + } + } + return entries.Length > 0; + } + + private static bool IsValidArchiveManifestEntry(string entry) + { + if (entry.Length <= MANIFEST_ENTRY_PREFIX_LENGTH + || entry[SHA256_HEX_LENGTH] != ' ' + || entry[SHA256_HEX_LENGTH + 1] != ' ') + { + return false; + } + + for (int index = 0; index < SHA256_HEX_LENGTH; index++) + { + if (!IsHexCharacter(entry[index])) + { + return false; + } + } + + string assetName = entry.Substring(MANIFEST_ENTRY_PREFIX_LENGTH); + return assetName == assetName.Trim() + && assetName.IndexOfAny(new char[] { '\r', '\n', '\t', ' ' }) < 0; + } + + private static bool IsHexCharacter(char value) + { + return value >= '0' && value <= '9' + || value >= 'a' && value <= 'f' + || value >= 'A' && value <= 'F'; } } } diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_test.go index 2e79b77579..782e6e86e9 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_test.go @@ -870,6 +870,31 @@ func TestLoadDispatcherPinNormalizesVersionPrefixes(t *testing.T) { } } +func TestLoadDispatcherPinIgnoresBootstrapFields(t *testing.T) { + // Verifies old dispatchers ignore additive bootstrap fields and retain their existing pin behavior. + projectRoot := createDispatcherUnityProject(t) + pinPath := filepath.Join(projectRoot, dispatcherProjectPinRelativePath) + if err := os.MkdirAll(filepath.Dir(pinPath), 0o755); err != nil { + t.Fatalf("failed to create pin directory: %v", err) + } + content := `{"projectRunnerVersion":"3.0.0-beta.58","minimumDispatcherVersion":"3.0.0-beta.39","dispatcherReleaseTag":"dispatcher-v3.0.1","dispatcherArchiveManifest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh"}` + if err := os.WriteFile(pinPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write pin: %v", err) + } + + pin, err := loadDispatcherPin(projectRoot) + + if err != nil { + t.Fatalf("loadDispatcherPin failed: %v", err) + } + if pin.ProjectRunnerVersion != "3.0.0-beta.58" { + t.Fatalf("projectRunnerVersion mismatch: %s", pin.ProjectRunnerVersion) + } + if pin.MinimumDispatcherVersion != "3.0.0-beta.39" { + t.Fatalf("minimumDispatcherVersion mismatch: %s", pin.MinimumDispatcherVersion) + } +} + func TestLoadDispatcherPinRejectsInvalidProjectRunnerVersion(t *testing.T) { // Verifies project pin projectRunnerVersion must be a release version, not a filesystem path. projectRoot := createDispatcherUnityProject(t) From cc6de9a6381226ab2a7b0b16ed34ac55810fdbd7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 15:16:30 +0900 Subject: [PATCH 02/13] Format dispatcher bootstrap pin regression test Keep the new additive-pin compatibility test compliant with the Go formatter. --- cli/dispatcher/internal/dispatcher/dispatcher_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_test.go index 782e6e86e9..f9ae168f14 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_test.go @@ -883,7 +883,6 @@ func TestLoadDispatcherPinIgnoresBootstrapFields(t *testing.T) { } pin, err := loadDispatcherPin(projectRoot) - if err != nil { t.Fatalf("loadDispatcherPin failed: %v", err) } From c258d6de82c5a4e3e666f60172524dc89b481c9f Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 15:17:48 +0900 Subject: [PATCH 03/13] Refactor dispatcher attestation as an exported package Make runtime verification and release automation share one implementation and one fixed release identity policy instead of duplicating Sigstore checks. --- .../{internal => }/attestation/errors.go | 0 .../{internal => }/attestation/fetcher.go | 0 cli/dispatcher/attestation/policy.go | 31 +++++++++++++++++++ cli/dispatcher/attestation/policy_test.go | 18 +++++++++++ .../testdata/happy_asset_digest.txt | 0 .../attestation/testdata/happy_bundle.json | 0 .../attestation/testdata/happy_commit_sha.txt | 0 .../attestation/trusted_root.go | 0 .../attestation/trusted_root.json | 0 .../attestation/trusted_root_test.go | 2 +- .../{internal => }/attestation/verifier.go | 0 .../attestation/verifier_test.go | 0 .../refresh-attestation-trusted-root/main.go | 6 ++-- .../dispatcher/attestation_release.go | 10 ++---- .../internal/dispatcher/attestation_verify.go | 24 ++++---------- .../dispatcher/attestation_verify_test.go | 2 +- 16 files changed, 63 insertions(+), 30 deletions(-) rename cli/dispatcher/{internal => }/attestation/errors.go (100%) rename cli/dispatcher/{internal => }/attestation/fetcher.go (100%) create mode 100644 cli/dispatcher/attestation/policy.go create mode 100644 cli/dispatcher/attestation/policy_test.go rename cli/dispatcher/{internal => }/attestation/testdata/happy_asset_digest.txt (100%) rename cli/dispatcher/{internal => }/attestation/testdata/happy_bundle.json (100%) rename cli/dispatcher/{internal => }/attestation/testdata/happy_commit_sha.txt (100%) rename cli/dispatcher/{internal => }/attestation/trusted_root.go (100%) rename cli/dispatcher/{internal => }/attestation/trusted_root.json (100%) rename cli/dispatcher/{internal => }/attestation/trusted_root_test.go (95%) rename cli/dispatcher/{internal => }/attestation/verifier.go (100%) rename cli/dispatcher/{internal => }/attestation/verifier_test.go (100%) diff --git a/cli/dispatcher/internal/attestation/errors.go b/cli/dispatcher/attestation/errors.go similarity index 100% rename from cli/dispatcher/internal/attestation/errors.go rename to cli/dispatcher/attestation/errors.go diff --git a/cli/dispatcher/internal/attestation/fetcher.go b/cli/dispatcher/attestation/fetcher.go similarity index 100% rename from cli/dispatcher/internal/attestation/fetcher.go rename to cli/dispatcher/attestation/fetcher.go diff --git a/cli/dispatcher/attestation/policy.go b/cli/dispatcher/attestation/policy.go new file mode 100644 index 0000000000..fc09611c2f --- /dev/null +++ b/cli/dispatcher/attestation/policy.go @@ -0,0 +1,31 @@ +package attestation + +// Release identity constants are exported so every component that authorizes a +// dispatcher release applies the exact policy enforced by the runtime verifier. +const ( + ReleaseRepository = "hatayama/unity-cli-loop" + DispatcherPublishWorkflowPath = ".github/workflows/dispatcher-publish.yml" + ProjectRunnerPublishWorkflowPath = ".github/workflows/native-cli-publish.yml" + MainBranchRef = "refs/heads/main" + V3BetaBranchRef = "refs/heads/v3-beta" +) + +// IdentityForWorkflow returns the closed identity policy for an approved +// release-publishing workflow. +func IdentityForWorkflow(workflowPath string) Identity { + return Identity{ + Repository: ReleaseRepository, + WorkflowPath: workflowPath, + Refs: []string{V3BetaBranchRef, MainBranchRef}, + } +} + +// DispatcherPublishIdentity returns the identity policy for dispatcher release assets. +func DispatcherPublishIdentity() Identity { + return IdentityForWorkflow(DispatcherPublishWorkflowPath) +} + +// ProjectRunnerPublishIdentity returns the identity policy for project runner release assets. +func ProjectRunnerPublishIdentity() Identity { + return IdentityForWorkflow(ProjectRunnerPublishWorkflowPath) +} diff --git a/cli/dispatcher/attestation/policy_test.go b/cli/dispatcher/attestation/policy_test.go new file mode 100644 index 0000000000..10daebcfae --- /dev/null +++ b/cli/dispatcher/attestation/policy_test.go @@ -0,0 +1,18 @@ +package attestation + +import "testing" + +func TestDispatcherPublishIdentityUsesApprovedReleasePolicy(t *testing.T) { + // Verifies every stamp consumer receives the exact dispatcher release identity enforced at runtime. + identity := DispatcherPublishIdentity() + + if identity.Repository != ReleaseRepository { + t.Fatalf("repository mismatch: %s", identity.Repository) + } + if identity.WorkflowPath != DispatcherPublishWorkflowPath { + t.Fatalf("workflow mismatch: %s", identity.WorkflowPath) + } + if len(identity.Refs) != 2 || identity.Refs[0] != V3BetaBranchRef || identity.Refs[1] != MainBranchRef { + t.Fatalf("unexpected allowed refs: %v", identity.Refs) + } +} diff --git a/cli/dispatcher/internal/attestation/testdata/happy_asset_digest.txt b/cli/dispatcher/attestation/testdata/happy_asset_digest.txt similarity index 100% rename from cli/dispatcher/internal/attestation/testdata/happy_asset_digest.txt rename to cli/dispatcher/attestation/testdata/happy_asset_digest.txt diff --git a/cli/dispatcher/internal/attestation/testdata/happy_bundle.json b/cli/dispatcher/attestation/testdata/happy_bundle.json similarity index 100% rename from cli/dispatcher/internal/attestation/testdata/happy_bundle.json rename to cli/dispatcher/attestation/testdata/happy_bundle.json diff --git a/cli/dispatcher/internal/attestation/testdata/happy_commit_sha.txt b/cli/dispatcher/attestation/testdata/happy_commit_sha.txt similarity index 100% rename from cli/dispatcher/internal/attestation/testdata/happy_commit_sha.txt rename to cli/dispatcher/attestation/testdata/happy_commit_sha.txt diff --git a/cli/dispatcher/internal/attestation/trusted_root.go b/cli/dispatcher/attestation/trusted_root.go similarity index 100% rename from cli/dispatcher/internal/attestation/trusted_root.go rename to cli/dispatcher/attestation/trusted_root.go diff --git a/cli/dispatcher/internal/attestation/trusted_root.json b/cli/dispatcher/attestation/trusted_root.json similarity index 100% rename from cli/dispatcher/internal/attestation/trusted_root.json rename to cli/dispatcher/attestation/trusted_root.json diff --git a/cli/dispatcher/internal/attestation/trusted_root_test.go b/cli/dispatcher/attestation/trusted_root_test.go similarity index 95% rename from cli/dispatcher/internal/attestation/trusted_root_test.go rename to cli/dispatcher/attestation/trusted_root_test.go index abafa593df..0e3307d6e7 100644 --- a/cli/dispatcher/internal/attestation/trusted_root_test.go +++ b/cli/dispatcher/attestation/trusted_root_test.go @@ -78,7 +78,7 @@ func TestEmbeddedTrustedRoot_HasFutureCapableAuthorityPerCategory(t *testing.T) return } } - t.Fatalf("no %s authority is usable past %s (all end before). Refresh cli/dispatcher/internal/attestation/trusted_root.json via `go run ./cmd/refresh-attestation-trusted-root` and land a fix(dispatcher) release.", category, horizon.Format(time.RFC3339)) + t.Fatalf("no %s authority is usable past %s (all end before). Refresh cli/dispatcher/attestation/trusted_root.json via `go run ./cmd/refresh-attestation-trusted-root` and land a fix(dispatcher) release.", category, horizon.Format(time.RFC3339)) } caEntries := make([]validFor, 0, len(root.CertificateAuthorities)) diff --git a/cli/dispatcher/internal/attestation/verifier.go b/cli/dispatcher/attestation/verifier.go similarity index 100% rename from cli/dispatcher/internal/attestation/verifier.go rename to cli/dispatcher/attestation/verifier.go diff --git a/cli/dispatcher/internal/attestation/verifier_test.go b/cli/dispatcher/attestation/verifier_test.go similarity index 100% rename from cli/dispatcher/internal/attestation/verifier_test.go rename to cli/dispatcher/attestation/verifier_test.go diff --git a/cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go b/cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go index 941475e16b..c67ab58829 100644 --- a/cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go +++ b/cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go @@ -1,8 +1,8 @@ -// Refreshes the Sigstore trusted_root.json embedded by cli/dispatcher/internal/attestation. +// Refreshes the Sigstore trusted_root.json embedded by cli/dispatcher/attestation. // // Run this from the dispatcher module root to fetch the current Sigstore // public-good trusted_root.json via TUF and write it as -// internal/attestation/trusted_root.json. The dispatcher then embeds that +// attestation/trusted_root.json. The dispatcher then embeds that // file so verification stays offline at runtime. Refresh cadence is // quarterly; the attestation package has a CI guard test that fails when // any embedded authority's validity window is within 90 days of expiring, @@ -40,7 +40,7 @@ func run() error { if err != nil { return fmt.Errorf("get trusted_root.json target: %w", err) } - outputPath := filepath.Join("internal", "attestation", "trusted_root.json") + outputPath := filepath.Join("attestation", "trusted_root.json") if err := os.WriteFile(outputPath, target, 0o644); err != nil { return fmt.Errorf("write %s: %w", outputPath, err) } diff --git a/cli/dispatcher/internal/dispatcher/attestation_release.go b/cli/dispatcher/internal/dispatcher/attestation_release.go index 80194d228e..644feb7079 100644 --- a/cli/dispatcher/internal/dispatcher/attestation_release.go +++ b/cli/dispatcher/internal/dispatcher/attestation_release.go @@ -9,7 +9,7 @@ import ( "os" "strings" - "github.com/hatayama/unity-cli-loop/dispatcher/internal/attestation" + "github.com/hatayama/unity-cli-loop/dispatcher/attestation" sharedupdate "github.com/hatayama/unity-cli-loop/dispatcher/internal/update" ) @@ -158,7 +158,7 @@ func fetchAttestationSubjectManifest(ctx context.Context, releaseTag string) (st if err != nil { return "", err } - commitSHA, err := attestation.FetchTagCommitSHA(ctx, dispatcherReleaseRepository, releaseTag) + commitSHA, err := attestation.FetchTagCommitSHA(ctx, attestation.ReleaseRepository, releaseTag) if err != nil { return "", err } @@ -169,11 +169,7 @@ func fetchAttestationSubjectManifest(ctx context.Context, releaseTag string) (st subjects, err := attestation.VerifySubjects(trustedMaterial, attestation.SubjectsOptions{ BundleData: bundleData, ExpectedCommitSHA: commitSHA, - Identity: attestation.Identity{ - Repository: dispatcherReleaseRepository, - WorkflowPath: attestationDispatcherPublishWorkflowPath, - Refs: attestationAllowedRefs, - }, + Identity: attestation.DispatcherPublishIdentity(), }) if err != nil { return "", err diff --git a/cli/dispatcher/internal/dispatcher/attestation_verify.go b/cli/dispatcher/internal/dispatcher/attestation_verify.go index 9a6d6313bf..4432f6ae0c 100644 --- a/cli/dispatcher/internal/dispatcher/attestation_verify.go +++ b/cli/dispatcher/internal/dispatcher/attestation_verify.go @@ -9,24 +9,16 @@ import ( "os" "path/filepath" - "github.com/hatayama/unity-cli-loop/dispatcher/internal/attestation" + "github.com/hatayama/unity-cli-loop/dispatcher/attestation" ) -// Attestation identity constants for the two workflows that publish signed -// release assets. Adding a new signed workflow requires appending the SAN here -// so a leaked OIDC token for an unrelated workflow cannot forge our releases. +// Attestation workflow aliases preserve the dispatcher package's focused +// call-site vocabulary while the exported attestation package owns policy. const ( - attestationDispatcherPublishWorkflowPath = ".github/workflows/dispatcher-publish.yml" - attestationRunnerPublishWorkflowPath = ".github/workflows/native-cli-publish.yml" + attestationDispatcherPublishWorkflowPath = attestation.DispatcherPublishWorkflowPath + attestationRunnerPublishWorkflowPath = attestation.ProjectRunnerPublishWorkflowPath ) -// attestationAllowedRefs is the closed set of refs the OIDC token may be issued -// for. Exact strings, no regex — a stolen token on a different branch fails. -var attestationAllowedRefs = []string{ - "refs/heads/v3-beta", - "refs/heads/main", -} - // verifyReleaseAssetAttestation is the hook production code calls to verify // a downloaded release asset. Tests override it to isolate checksum/extract // logic from real network + sigstore verification. @@ -65,11 +57,7 @@ func defaultVerifyReleaseAssetAttestation(ctx context.Context, releaseTag string AssetDigest: digestHex, BundleData: bundleData, ExpectedCommitSHA: commitSHA, - Identity: attestation.Identity{ - Repository: dispatcherReleaseRepository, - WorkflowPath: workflowPath, - Refs: attestationAllowedRefs, - }, + Identity: attestation.IdentityForWorkflow(workflowPath), }) } diff --git a/cli/dispatcher/internal/dispatcher/attestation_verify_test.go b/cli/dispatcher/internal/dispatcher/attestation_verify_test.go index 65ed4bd9ef..1c2fd64095 100644 --- a/cli/dispatcher/internal/dispatcher/attestation_verify_test.go +++ b/cli/dispatcher/internal/dispatcher/attestation_verify_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/hatayama/unity-cli-loop/dispatcher/internal/attestation" + "github.com/hatayama/unity-cli-loop/dispatcher/attestation" ) func TestDefaultVerifyReleaseAssetAttestationRejectsEmptyTag(t *testing.T) { From c6c97b659df92e98b5512cf1389a1389e99a46b7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 15:25:24 +0900 Subject: [PATCH 04/13] Stamp dispatcher pins from verified release subjects Verify the immutable dispatcher Release with the shared Sigstore policy before recording its asset digests, so Unity bootstrap never trusts sibling checksums. --- Packages/src/project-runner-pin.json | 6 +- .../cmd/stamp-dispatcher-pin/main.go | 54 +++ cli/release-automation/go.mod | 80 +++- cli/release-automation/go.sum | 370 ++++++++++++++++++ .../architecture/architecture_test.go | 34 +- .../automation/dispatcher_pin_stamp.go | 232 +++++++++++ .../automation/dispatcher_pin_stamp_test.go | 119 ++++++ 7 files changed, 883 insertions(+), 12 deletions(-) create mode 100644 cli/release-automation/cmd/stamp-dispatcher-pin/main.go create mode 100644 cli/release-automation/go.sum create mode 100644 cli/release-automation/internal/automation/dispatcher_pin_stamp.go create mode 100644 cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go diff --git a/Packages/src/project-runner-pin.json b/Packages/src/project-runner-pin.json index e96ac1d88b..f80ca83232 100644 --- a/Packages/src/project-runner-pin.json +++ b/Packages/src/project-runner-pin.json @@ -1,4 +1,6 @@ { - "projectRunnerVersion": "3.0.0-beta.47", - "minimumDispatcherVersion": "3.0.1-beta.6" + "dispatcherArchiveManifest": "0797991e11d66a62b757aec29276967d2c5c790641bd2c3684f5a5cb03ca9807 uloop-dispatcher-windows-amd64.zip\n5f2b897bf51e672afb2ad36d8b53db538b8e1522075f4baa2544222ab4e4f9ea uloop-dispatcher-windows-amd64.zip.sha256\n84ce1c29acb6bff71cea328417c98373e6ce8f2971ed07742581c5b1ec2620ae uloop-dispatcher-darwin-arm64.tar.gz\n88bfd28b0996efe9dc1276b74c31bb9f49016e43d4bc243a47a195a7be5d179b uloop-dispatcher-darwin-amd64.tar.gz\n957aec1660b2d0095f5989e0c7058b9fc255ff92f42a0730a8d8f3c9b35ecea6 uloop-dispatcher-darwin-amd64.tar.gz.sha256\n99a70cdb8b8b1b985cb3260ad0da6c8c5fda3a46e5272c43d566e7c4009d5ae2 install.ps1\nc65d5134647e1a522b3b40a96e8b219862348c5865c15bedc6a0a046232e5ab0 install.sh\nd3c8f08c97e1c1cefdcc8c473d05ea0c094a39ff93f5ba69c816f3b3620df924 uloop-dispatcher-darwin-arm64.tar.gz.sha256", + "dispatcherReleaseTag": "dispatcher-v3.0.1-beta.6", + "minimumDispatcherVersion": "3.0.1-beta.6", + "projectRunnerVersion": "3.0.0-beta.47" } diff --git a/cli/release-automation/cmd/stamp-dispatcher-pin/main.go b/cli/release-automation/cmd/stamp-dispatcher-pin/main.go new file mode 100644 index 0000000000..b4ce5906c2 --- /dev/null +++ b/cli/release-automation/cmd/stamp-dispatcher-pin/main.go @@ -0,0 +1,54 @@ +// stamp-dispatcher-pin verifies a published dispatcher release and records its +// attested asset digests in the Unity package pin. +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +const stampDispatcherPinTimeout = 2 * time.Minute + +func main() { + releaseTag := flag.String("tag", "", "immutable dispatcher Release tag to verify and stamp") + flag.Parse() + if *releaseTag == "" || flag.NArg() != 0 { + fmt.Fprintln(os.Stderr, "usage: stamp-dispatcher-pin --tag dispatcher-vX.Y.Z") + os.Exit(2) + } + + ctx, cancel := context.WithTimeout(context.Background(), stampDispatcherPinTimeout) + defer cancel() + repositoryRoot, err := repositoryRoot(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "stamp-dispatcher-pin:", err) + os.Exit(1) + } + pinPath := filepath.Join(repositoryRoot, "Packages", "src", "project-runner-pin.json") + if err := automation.StampDispatcherPin(ctx, pinPath, *releaseTag); err != nil { + fmt.Fprintln(os.Stderr, "stamp-dispatcher-pin:", err) + os.Exit(1) + } + fmt.Printf("stamped %s from %s\n", pinPath, *releaseTag) +} + +func repositoryRoot(ctx context.Context) (string, error) { + command := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") + output, err := command.Output() + if err != nil { + return "", fmt.Errorf("resolve repository root: %w", err) + } + root := strings.TrimSpace(string(output)) + if root == "" { + return "", fmt.Errorf("git returned an empty repository root") + } + return root, nil +} diff --git a/cli/release-automation/go.mod b/cli/release-automation/go.mod index 2eca9314c6..00147b193f 100644 --- a/cli/release-automation/go.mod +++ b/cli/release-automation/go.mod @@ -2,6 +2,84 @@ module github.com/hatayama/unity-cli-loop/tools/release-automation go 1.26 -require github.com/hatayama/unity-cli-loop/common v0.0.0-00010101000000-000000000000 +require ( + github.com/hatayama/unity-cli-loop/common v0.0.0-00010101000000-000000000000 + github.com/hatayama/unity-cli-loop/dispatcher v0.0.0-00010101000000-000000000000 +) + +require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.25.2 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/loads v0.24.0 // indirect + github.com/go-openapi/runtime v0.32.4 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect + github.com/go-openapi/spec v0.22.6 // indirect + github.com/go-openapi/strfmt v0.26.4 // indirect + github.com/go-openapi/swag v0.26.1 // indirect + github.com/go-openapi/swag/cmdutils v0.26.1 // indirect + github.com/go-openapi/swag/conv v0.27.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.26.1 // indirect + github.com/go-openapi/swag/loading v0.26.1 // indirect + github.com/go-openapi/swag/mangling v0.26.1 // indirect + github.com/go-openapi/swag/netutils v0.26.1 // indirect + github.com/go-openapi/swag/stringutils v0.26.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/go-openapi/validate v0.26.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect + github.com/google/go-containerregistry v0.21.7 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/in-toto/attestation v1.2.0 // indirect + github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/protobuf-specs v0.5.1 // indirect + github.com/sigstore/rekor v1.5.3 // indirect + github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect + github.com/sigstore/sigstore v1.10.8 // indirect + github.com/sigstore/sigstore-go v1.2.2 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect + github.com/transparency-dev/formats v0.1.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.82.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + k8s.io/klog/v2 v2.140.0 // indirect +) replace github.com/hatayama/unity-cli-loop/common => ../common + +replace github.com/hatayama/unity-cli-loop/dispatcher => ../dispatcher diff --git a/cli/release-automation/go.sum b/cli/release-automation/go.sum new file mode 100644 index 0000000000..e7b7327328 --- /dev/null +++ b/cli/release-automation/go.sum @@ -0,0 +1,370 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= +github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ= +github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= +github.com/go-openapi/runtime v0.32.4 h1:8ElGj/3goG0itt0nBPP6Cm57ehcYyuHoI3O20nxgvkw= +github.com/go-openapi/runtime v0.32.4/go.mod h1:Bz6keOZw1NX4T6f+m42OoT1MBPDt6Re13dbccHyGH/4= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= +github.com/go-openapi/strfmt v0.26.4 h1:yI6IAEfcWow459BD5UzFY430KUwXZwBHrYusPFkhWlc= +github.com/go-openapi/strfmt v0.26.4/go.mod h1:hNJi6nb5ETD6i7A1yRo03M9S6ZoTPPoWff1iUexmfUc= +github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= +github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= +github.com/go-openapi/swag/cmdutils v0.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= +github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= +github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= +github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= +github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= +github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= +github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= +github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= +github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/netutils v0.26.1 h1:BNctoc39WTAUMxyAs355fExOPzMZtPbZ0ZZ1Am2FR5M= +github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= +github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= +github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= +github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= +github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw= +github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= +github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= +github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= +github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= +github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= +github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= +github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.2.2 h1:xAJ8hxaoecC0HKBYVbrwUjkeAI+GJYu6vLqbxDlD2Q0= +github.com/sigstore/sigstore-go v1.2.2/go.mod h1:MIFwBxAHJD+/lKgZzt9n/4Zhq/3T2+EuGX8iGrIsZgU= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= +github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= +github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= +github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= +github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= +go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= +google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/cli/release-automation/internal/architecture/architecture_test.go b/cli/release-automation/internal/architecture/architecture_test.go index 1c7de0269b..96dcb814cd 100644 --- a/cli/release-automation/internal/architecture/architecture_test.go +++ b/cli/release-automation/internal/architecture/architecture_test.go @@ -81,7 +81,8 @@ type binaryNames struct { } // Tests that every module's require directives respect the repo's dependency direction: -// common depends on nothing else in the repo; the other three modules only depend on common. +// common depends on nothing else, runtime modules depend only on common, and release automation +// may additionally consume the dispatcher's exported attestation verifier. func TestModuleDependencyDirections(t *testing.T) { repositoryRoot := findRepositoryRoot(t) contract := readLayoutContract(t, filepath.Join(repositoryRoot, "layout-contract.json")) @@ -94,19 +95,34 @@ func TestModuleDependencyDirections(t *testing.T) { } } - dependentModuleDirs := []string{ - filepath.Join(repositoryRoot, contract.Layout.Modules.Dispatcher), - filepath.Join(repositoryRoot, contract.Layout.Modules.ProjectRunner), - filepath.Join(repositoryRoot, contract.Layout.Modules.ReleaseAutomation), + dependentModules := []struct { + directory string + allowed map[string]bool + }{ + { + directory: filepath.Join(repositoryRoot, contract.Layout.Modules.Dispatcher), + allowed: map[string]bool{commonModulePath: true}, + }, + { + directory: filepath.Join(repositoryRoot, contract.Layout.Modules.ProjectRunner), + allowed: map[string]bool{commonModulePath: true}, + }, + { + directory: filepath.Join(repositoryRoot, contract.Layout.Modules.ReleaseAutomation), + allowed: map[string]bool{ + commonModulePath: true, + dispatcherModulePath: true, + }, + }, } - for _, moduleDir := range dependentModuleDirs { - requires := readGoModEdit(t, moduleDir).Require + for _, module := range dependentModules { + requires := readGoModEdit(t, module.directory).Require for _, require := range requires { if !strings.HasPrefix(require.Path, repositoryModulePrefix) { continue } - if require.Path != commonModulePath { - t.Fatalf("module %s may only depend on %s among repo modules, got %s", moduleDir, commonModulePath, require.Path) + if !module.allowed[require.Path] { + t.Fatalf("module %s may only depend on %v among repo modules, got %s", module.directory, module.allowed, require.Path) } } } diff --git a/cli/release-automation/internal/automation/dispatcher_pin_stamp.go b/cli/release-automation/internal/automation/dispatcher_pin_stamp.go new file mode 100644 index 0000000000..a37b9e0024 --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_stamp.go @@ -0,0 +1,232 @@ +package automation + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "sort" + "strings" + "time" + + "github.com/hatayama/unity-cli-loop/dispatcher/attestation" +) + +const ( + dispatcherPinStampInstallerScript = "install.sh" + dispatcherPinStampPowerShellScript = "install.ps1" + dispatcherPinStampAPIBaseURL = "https://api.github.com" +) + +// dispatcherReleaseAsset describes the subset of a GitHub Release asset used +// to build the provenance-pinned dispatcher manifest. +type dispatcherReleaseAsset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} + +type dispatcherPinStampDeps struct { + fetchReleaseAssets func(context.Context, string) ([]dispatcherReleaseAsset, error) + fetchBundle func(context.Context, string) ([]byte, error) + fetchTagCommitSHA func(context.Context, string, string) (string, error) + verifySubjects func([]byte, string) (map[string]string, error) +} + +// StampDispatcherPin verifies a dispatcher release's attestation and writes +// its exact subject manifest into the package pin. +func StampDispatcherPin(ctx context.Context, pinPath string, releaseTag string) error { + return stampDispatcherPin(ctx, pinPath, releaseTag, defaultDispatcherPinStampDeps()) +} + +func defaultDispatcherPinStampDeps() dispatcherPinStampDeps { + return dispatcherPinStampDeps{ + fetchReleaseAssets: fetchDispatcherReleaseAssets, + fetchBundle: attestation.FetchBundle, + fetchTagCommitSHA: attestation.FetchTagCommitSHA, + verifySubjects: func(bundleData []byte, commitSHA string) (map[string]string, error) { + trustedMaterial, err := attestation.LoadEmbeddedTrustedMaterial() + if err != nil { + return nil, fmt.Errorf("load dispatcher attestation trusted root: %w", err) + } + return attestation.VerifySubjects(trustedMaterial, attestation.SubjectsOptions{ + BundleData: bundleData, + ExpectedCommitSHA: commitSHA, + Identity: attestation.DispatcherPublishIdentity(), + }) + }, + } +} + +func stampDispatcherPin( + ctx context.Context, + pinPath string, + releaseTag string, + deps dispatcherPinStampDeps, +) error { + if releaseTag == "" { + return fmt.Errorf("dispatcher release tag is required") + } + assets, err := deps.fetchReleaseAssets(ctx, releaseTag) + if err != nil { + return fmt.Errorf("fetch dispatcher release assets: %w", err) + } + installScriptURL, err := findDispatcherReleaseAssetURL(assets, dispatcherPinStampInstallerScript) + if err != nil { + return err + } + if _, err := findDispatcherReleaseAssetURL(assets, dispatcherPinStampPowerShellScript); err != nil { + return err + } + bundleData, err := deps.fetchBundle(ctx, installScriptURL+".sigstore.json") + if err != nil { + return fmt.Errorf("fetch dispatcher installer attestation bundle: %w", err) + } + commitSHA, err := deps.fetchTagCommitSHA(ctx, attestation.ReleaseRepository, releaseTag) + if err != nil { + return fmt.Errorf("resolve dispatcher release tag commit: %w", err) + } + subjects, err := deps.verifySubjects(bundleData, commitSHA) + if err != nil { + return fmt.Errorf("verify dispatcher release attestation: %w", err) + } + manifest, err := buildDispatcherArchiveManifest(assets, subjects) + if err != nil { + return err + } + return writeDispatcherPinStamp(pinPath, releaseTag, manifest) +} + +func findDispatcherReleaseAssetURL(assets []dispatcherReleaseAsset, name string) (string, error) { + for _, asset := range assets { + if asset.Name != name { + continue + } + if asset.URL == "" { + return "", fmt.Errorf("dispatcher release asset %q has no download URL", name) + } + return asset.URL, nil + } + return "", fmt.Errorf("dispatcher release is missing required asset %q", name) +} + +func buildDispatcherArchiveManifest(assets []dispatcherReleaseAsset, subjects map[string]string) (string, error) { + entries := make([]string, 0, len(assets)) + seenAssets := make(map[string]struct{}, len(assets)) + for _, asset := range assets { + if strings.HasSuffix(asset.Name, ".sigstore.json") { + continue + } + if asset.Name == "" || strings.ContainsAny(asset.Name, "\r\n") { + return "", fmt.Errorf("dispatcher release has an invalid asset name %q", asset.Name) + } + if _, alreadySeen := seenAssets[asset.Name]; alreadySeen { + return "", fmt.Errorf("dispatcher release lists duplicate asset %q", asset.Name) + } + seenAssets[asset.Name] = struct{}{} + digest, ok := subjects[asset.Name] + if !ok { + return "", fmt.Errorf("dispatcher release asset %q is not attested", asset.Name) + } + if !isSHA256Digest(digest) { + return "", fmt.Errorf("dispatcher release asset %q has invalid attested SHA-256 digest", asset.Name) + } + entries = append(entries, digest+" "+asset.Name) + } + if len(entries) == 0 { + return "", fmt.Errorf("dispatcher release has no attested assets") + } + sort.Strings(entries) + return strings.Join(entries, "\n"), nil +} + +func isSHA256Digest(digest string) bool { + if len(digest) != 64 { + return false + } + for _, character := range digest { + if character >= '0' && character <= '9' { + continue + } + if character >= 'a' && character <= 'f' { + continue + } + if character >= 'A' && character <= 'F' { + continue + } + return false + } + return true +} + +func writeDispatcherPinStamp(pinPath string, releaseTag string, manifest string) error { + content, err := os.ReadFile(pinPath) + if err != nil { + return fmt.Errorf("read dispatcher pin %s: %w", pinPath, err) + } + pin := map[string]json.RawMessage{} + if err := json.Unmarshal(content, &pin); err != nil { + return fmt.Errorf("parse dispatcher pin %s: %w", pinPath, err) + } + releaseTagJSON, err := json.Marshal(releaseTag) + if err != nil { + return fmt.Errorf("encode dispatcher release tag: %w", err) + } + manifestJSON, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("encode dispatcher archive manifest: %w", err) + } + pin["dispatcherReleaseTag"] = releaseTagJSON + pin["dispatcherArchiveManifest"] = manifestJSON + stampedContent, err := json.MarshalIndent(pin, "", " ") + if err != nil { + return fmt.Errorf("encode stamped dispatcher pin: %w", err) + } + stampedContent = append(stampedContent, '\n') + if err := os.WriteFile(pinPath, stampedContent, 0o644); err != nil { + return fmt.Errorf("write stamped dispatcher pin %s: %w", pinPath, err) + } + return nil +} + +func fetchDispatcherReleaseAssets(ctx context.Context, releaseTag string) ([]dispatcherReleaseAsset, error) { + requestURL := fmt.Sprintf( + "%s/repos/%s/releases/tags/%s", + dispatcherPinStampAPIBaseURL, + attestation.ReleaseRepository, + releaseTag) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, fmt.Errorf("build GitHub release request: %w", err) + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if token := dispatcherPinStampGitHubToken(); token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + client := &http.Client{Timeout: 30 * time.Second} + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer func() { + _ = response.Body.Close() + }() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("GitHub release API returned %s", response.Status) + } + payload := struct { + Assets []dispatcherReleaseAsset `json:"assets"` + }{} + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decode GitHub release assets: %w", err) + } + return payload.Assets, nil +} + +func dispatcherPinStampGitHubToken() string { + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return token + } + return os.Getenv("GH_TOKEN") +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go b/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go new file mode 100644 index 0000000000..6a9e0774ef --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go @@ -0,0 +1,119 @@ +package automation + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestStampDispatcherPinWritesOnlyVerifiedReleaseSubjects(t *testing.T) { + // Verifies a successful stamp preserves existing pin fields and writes only attestation-verified subjects. + pinPath := writeDispatcherPinForStamp(t, `{"projectRunnerVersion":"3.0.0-beta.47","minimumDispatcherVersion":"3.0.1-beta.6"}`) + deps := dispatcherPinStampDeps{ + fetchReleaseAssets: func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return []dispatcherReleaseAsset{ + {Name: "install.sh", URL: "https://example.test/install.sh"}, + {Name: "install.ps1", URL: "https://example.test/install.ps1"}, + {Name: "uloop-dispatcher-darwin-arm64.zip", URL: "https://example.test/uloop-dispatcher-darwin-arm64.zip"}, + {Name: "install.sh.sigstore.json", URL: "https://example.test/install.sh.sigstore.json"}, + }, nil + }, + fetchBundle: func(context.Context, string) ([]byte, error) { + return []byte("bundle"), nil + }, + fetchTagCommitSHA: func(context.Context, string, string) (string, error) { + return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil + }, + verifySubjects: func([]byte, string) (map[string]string, error) { + return map[string]string{ + "install.sh": "1111111111111111111111111111111111111111111111111111111111111111", + "install.ps1": "2222222222222222222222222222222222222222222222222222222222222222", + "uloop-dispatcher-darwin-arm64.zip": "3333333333333333333333333333333333333333333333333333333333333333", + "unpublished-asset.zip": "4444444444444444444444444444444444444444444444444444444444444444", + }, nil + }, + } + + err := stampDispatcherPin(context.Background(), pinPath, "dispatcher-v3.0.1-beta.6", deps) + if err != nil { + t.Fatalf("stampDispatcherPin failed: %v", err) + } + stamped := readDispatcherPinForStamp(t, pinPath) + if stamped["projectRunnerVersion"] != "3.0.0-beta.47" { + t.Fatalf("projectRunnerVersion changed: %v", stamped["projectRunnerVersion"]) + } + if stamped["minimumDispatcherVersion"] != "3.0.1-beta.6" { + t.Fatalf("minimumDispatcherVersion changed: %v", stamped["minimumDispatcherVersion"]) + } + if stamped["dispatcherReleaseTag"] != "dispatcher-v3.0.1-beta.6" { + t.Fatalf("dispatcherReleaseTag mismatch: %v", stamped["dispatcherReleaseTag"]) + } + wantManifest := "1111111111111111111111111111111111111111111111111111111111111111 install.sh\n" + wantManifest += "2222222222222222222222222222222222222222222222222222222222222222 install.ps1\n" + wantManifest += "3333333333333333333333333333333333333333333333333333333333333333 uloop-dispatcher-darwin-arm64.zip" + if stamped["dispatcherArchiveManifest"] != wantManifest { + t.Fatalf("dispatcherArchiveManifest mismatch: %v", stamped["dispatcherArchiveManifest"]) + } +} + +func TestStampDispatcherPinLeavesPinUnchangedWhenReleaseAssetIsNotAttested(t *testing.T) { + // Verifies an unsigned release asset prevents a partial or checksum-only pin stamp. + initialPin := `{"projectRunnerVersion":"3.0.0-beta.47","minimumDispatcherVersion":"3.0.1-beta.6"}` + pinPath := writeDispatcherPinForStamp(t, initialPin) + deps := dispatcherPinStampDeps{ + fetchReleaseAssets: func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return []dispatcherReleaseAsset{ + {Name: "install.sh", URL: "https://example.test/install.sh"}, + {Name: "install.ps1", URL: "https://example.test/install.ps1"}, + }, nil + }, + fetchBundle: func(context.Context, string) ([]byte, error) { + return []byte("bundle"), nil + }, + fetchTagCommitSHA: func(context.Context, string, string) (string, error) { + return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil + }, + verifySubjects: func([]byte, string) (map[string]string, error) { + return map[string]string{ + "install.sh": "1111111111111111111111111111111111111111111111111111111111111111", + }, nil + }, + } + + err := stampDispatcherPin(context.Background(), pinPath, "dispatcher-v3.0.1-beta.6", deps) + + if err == nil { + t.Fatal("expected missing attested asset to fail") + } + content, readErr := os.ReadFile(pinPath) + if readErr != nil { + t.Fatalf("read pin after failed stamp: %v", readErr) + } + if string(content) != initialPin { + t.Fatalf("failed stamp changed pin: %s", content) + } +} + +func writeDispatcherPinForStamp(t *testing.T, content string) string { + t.Helper() + pinPath := filepath.Join(t.TempDir(), "project-runner-pin.json") + if err := os.WriteFile(pinPath, []byte(content), 0o644); err != nil { + t.Fatalf("write pin: %v", err) + } + return pinPath +} + +func readDispatcherPinForStamp(t *testing.T, pinPath string) map[string]string { + t.Helper() + content, err := os.ReadFile(pinPath) + if err != nil { + t.Fatalf("read pin: %v", err) + } + values := map[string]string{} + if err := json.Unmarshal(content, &values); err != nil { + t.Fatalf("parse stamped pin: %v", err) + } + return values +} From c258840c510176de3d96c6fe764fcb8ea0d90cec Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 16:14:17 +0900 Subject: [PATCH 05/13] Add dispatcher pin verification guard Reject malformed or stale bootstrap pins before Unity installation and verify published subjects whenever a security-relevant path changes. --- .github/workflows/build-and-test.yml | 12 ++ .uloop/project-runner-pin.json | 6 +- .../cmd/check-dispatcher-pin/main.go | 78 +++++++++++ .../cmd/check-dispatcher-pin/main_test.go | 28 ++++ .../automation/dispatcher_pin_guard.go | 131 ++++++++++++++++++ .../automation/dispatcher_pin_guard_test.go | 41 ++++++ 6 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 cli/release-automation/cmd/check-dispatcher-pin/main.go create mode 100644 cli/release-automation/cmd/check-dispatcher-pin/main_test.go create mode 100644 cli/release-automation/internal/automation/dispatcher_pin_guard.go create mode 100644 cli/release-automation/internal/automation/dispatcher_pin_guard_test.go diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e5cbab37ef..b68d29eb37 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -68,6 +68,18 @@ jobs: working-directory: cli/release-automation run: go run ./cmd/check-dispatcher-minimum-version + - name: Check dispatcher pin + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + working-directory: cli/release-automation + run: go run ./cmd/check-dispatcher-pin + + - name: Verify published dispatcher pin subjects + if: github.event_name == 'pull_request' + working-directory: cli/release-automation + env: + GITHUB_TOKEN: ${{ github.token }} + run: go run ./cmd/check-dispatcher-pin --base "origin/${{ github.base_ref }}" --head HEAD + - name: Check release triggers if: github.event_name == 'pull_request' working-directory: cli/release-automation diff --git a/.uloop/project-runner-pin.json b/.uloop/project-runner-pin.json index e96ac1d88b..f80ca83232 100644 --- a/.uloop/project-runner-pin.json +++ b/.uloop/project-runner-pin.json @@ -1,4 +1,6 @@ { - "projectRunnerVersion": "3.0.0-beta.47", - "minimumDispatcherVersion": "3.0.1-beta.6" + "dispatcherArchiveManifest": "0797991e11d66a62b757aec29276967d2c5c790641bd2c3684f5a5cb03ca9807 uloop-dispatcher-windows-amd64.zip\n5f2b897bf51e672afb2ad36d8b53db538b8e1522075f4baa2544222ab4e4f9ea uloop-dispatcher-windows-amd64.zip.sha256\n84ce1c29acb6bff71cea328417c98373e6ce8f2971ed07742581c5b1ec2620ae uloop-dispatcher-darwin-arm64.tar.gz\n88bfd28b0996efe9dc1276b74c31bb9f49016e43d4bc243a47a195a7be5d179b uloop-dispatcher-darwin-amd64.tar.gz\n957aec1660b2d0095f5989e0c7058b9fc255ff92f42a0730a8d8f3c9b35ecea6 uloop-dispatcher-darwin-amd64.tar.gz.sha256\n99a70cdb8b8b1b985cb3260ad0da6c8c5fda3a46e5272c43d566e7c4009d5ae2 install.ps1\nc65d5134647e1a522b3b40a96e8b219862348c5865c15bedc6a0a046232e5ab0 install.sh\nd3c8f08c97e1c1cefdcc8c473d05ea0c094a39ff93f5ba69c816f3b3620df924 uloop-dispatcher-darwin-arm64.tar.gz.sha256", + "dispatcherReleaseTag": "dispatcher-v3.0.1-beta.6", + "minimumDispatcherVersion": "3.0.1-beta.6", + "projectRunnerVersion": "3.0.0-beta.47" } diff --git a/cli/release-automation/cmd/check-dispatcher-pin/main.go b/cli/release-automation/cmd/check-dispatcher-pin/main.go new file mode 100644 index 0000000000..d6fe39eae8 --- /dev/null +++ b/cli/release-automation/cmd/check-dispatcher-pin/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +func main() { + network := flag.Bool("network", false, "verify the published Release attestation subjects") + baseRef := flag.String("base", "", "base git ref used to select network verification") + headRef := flag.String("head", "HEAD", "head git ref used to select network verification") + flag.Parse() + packagePinPath := filepath.Join("..", "..", "Packages", "src", "project-runner-pin.json") + projectPinPath := filepath.Join("..", "..", ".uloop", "project-runner-pin.json") + packagePin, err := os.ReadFile(packagePinPath) + if err == nil { + projectPin, projectErr := os.ReadFile(projectPinPath) + if projectErr != nil { + err = projectErr + } else { + err = automation.ValidateDispatcherPinOffline(packagePin, projectPin) + } + } + if err != nil { + fmt.Fprintln(os.Stderr, "check-dispatcher-pin:", err) + os.Exit(1) + } + shouldVerifyNetwork := *network + if *baseRef != "" { + shouldVerifyNetwork = dispatcherPinNetworkVerificationRequired(*baseRef, *headRef) + } + if shouldVerifyNetwork { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + err = automation.VerifyDispatcherPinSubjects(ctx, packagePin) + cancel() + if err != nil { + fmt.Fprintln(os.Stderr, "check-dispatcher-pin:", err) + os.Exit(1) + } + } + if !shouldVerifyNetwork { + fmt.Println("Dispatcher pin network verification skipped because no watched paths changed.") + } + fmt.Println("Dispatcher pin guard passed.") +} + +func dispatcherPinNetworkVerificationRequired(baseRef string, headRef string) bool { + command := exec.Command("git", "diff", "--name-only", baseRef+".."+headRef) + output, err := command.Output() + if err != nil { + fmt.Fprintln(os.Stderr, "check-dispatcher-pin: git diff unavailable; running network verification:", err) + return true + } + for _, path := range strings.Fields(string(output)) { + if dispatcherPinPathNeedsNetworkVerification(path) { + return true + } + } + return false +} + +func dispatcherPinPathNeedsNetworkVerification(path string) bool { + return path == "Packages/src/project-runner-pin.json" || + path == ".uloop/project-runner-pin.json" || + path == "scripts/install.sh" || + path == "scripts/install.ps1" || + path == ".github/workflows/build-and-test.yml" || + strings.HasPrefix(path, "cli/dispatcher/attestation/") || + strings.HasPrefix(path, "cli/release-automation/") +} diff --git a/cli/release-automation/cmd/check-dispatcher-pin/main_test.go b/cli/release-automation/cmd/check-dispatcher-pin/main_test.go new file mode 100644 index 0000000000..7170f574df --- /dev/null +++ b/cli/release-automation/cmd/check-dispatcher-pin/main_test.go @@ -0,0 +1,28 @@ +package main + +import "testing" + +func TestDispatcherPinPathNeedsNetworkVerificationRecognizesEveryWatchedPath(t *testing.T) { + // Verifies changes to the pin, installers, verifier, guard, or its workflow revalidate published subjects. + paths := []string{ + "Packages/src/project-runner-pin.json", + ".uloop/project-runner-pin.json", + "scripts/install.sh", + "scripts/install.ps1", + "cli/dispatcher/attestation/verifier.go", + "cli/release-automation/internal/automation/dispatcher_pin_guard.go", + ".github/workflows/build-and-test.yml", + } + for _, path := range paths { + if !dispatcherPinPathNeedsNetworkVerification(path) { + t.Fatalf("expected watched path %q to require network verification", path) + } + } +} + +func TestDispatcherPinPathNeedsNetworkVerificationSkipsUnrelatedPath(t *testing.T) { + // Verifies unrelated source edits do not incur a network-dependent guard run. + if dispatcherPinPathNeedsNetworkVerification("Packages/src/Editor/Domain/CliConstants.cs") { + t.Fatal("expected unrelated path to skip network verification") + } +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_guard.go b/cli/release-automation/internal/automation/dispatcher_pin_guard.go new file mode 100644 index 0000000000..597aa7067f --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_guard.go @@ -0,0 +1,131 @@ +package automation + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + + sharedversion "github.com/hatayama/unity-cli-loop/common/version" + "github.com/hatayama/unity-cli-loop/dispatcher/attestation" +) + +const ( + dispatcherPinTagPrefix = "dispatcher-v" + sha256HexLength = 64 +) + +var requiredDispatcherPinAssets = []string{ + "install.sh", + "install.ps1", + "uloop-dispatcher-darwin-amd64.tar.gz", + "uloop-dispatcher-darwin-arm64.tar.gz", + "uloop-dispatcher-windows-amd64.zip", +} + +type dispatcherPinGuardValues struct { + MinimumDispatcherVersion string `json:"minimumDispatcherVersion"` + DispatcherReleaseTag string `json:"dispatcherReleaseTag"` + DispatcherArchiveManifest string `json:"dispatcherArchiveManifest"` +} + +// ValidateDispatcherPinOffline validates the package pin without contacting GitHub. +func ValidateDispatcherPinOffline(packagePin []byte, projectPin []byte) error { + if !bytes.Equal(packagePin, projectPin) { + return fmt.Errorf("%s must match %s byte-for-byte", unityProjectCliPinFile, unityPackageCliPinFile) + } + values := dispatcherPinGuardValues{} + if err := json.Unmarshal(packagePin, &values); err != nil { + return fmt.Errorf("%s is invalid JSON: %w", unityPackageCliPinFile, err) + } + if !strings.HasPrefix(values.DispatcherReleaseTag, dispatcherPinTagPrefix) { + return fmt.Errorf("%s dispatcherReleaseTag must start with %q", unityPackageCliPinFile, dispatcherPinTagPrefix) + } + pinnedVersion := strings.TrimPrefix(values.DispatcherReleaseTag, dispatcherPinTagPrefix) + if !sharedversion.IsValid(pinnedVersion) || !sharedversion.IsValid(values.MinimumDispatcherVersion) { + return fmt.Errorf("%s dispatcher versions must be semver", unityPackageCliPinFile) + } + if sharedversion.IsLessThan(pinnedVersion, values.MinimumDispatcherVersion) { + return fmt.Errorf("%s dispatcherReleaseTag is older than minimumDispatcherVersion", unityPackageCliPinFile) + } + return validateDispatcherArchiveManifest(values.DispatcherArchiveManifest) +} + +// VerifyDispatcherPinSubjects confirms the pinned manifest exactly matches the published verified subjects. +func VerifyDispatcherPinSubjects(ctx context.Context, packagePin []byte) error { + values := dispatcherPinGuardValues{} + if err := json.Unmarshal(packagePin, &values); err != nil { + return fmt.Errorf("%s is invalid JSON: %w", unityPackageCliPinFile, err) + } + deps := defaultDispatcherPinStampDeps() + assets, err := deps.fetchReleaseAssets(ctx, values.DispatcherReleaseTag) + if err != nil { + return fmt.Errorf("fetch dispatcher release assets: %w", err) + } + installerURL, err := findDispatcherReleaseAssetURL(assets, dispatcherPinStampInstallerScript) + if err != nil { + return err + } + bundleData, err := deps.fetchBundle(ctx, installerURL+".sigstore.json") + if err != nil { + return fmt.Errorf("fetch dispatcher installer attestation bundle: %w", err) + } + commitSHA, err := deps.fetchTagCommitSHA(ctx, attestation.ReleaseRepository, values.DispatcherReleaseTag) + if err != nil { + return fmt.Errorf("resolve dispatcher release tag commit: %w", err) + } + subjects, err := deps.verifySubjects(bundleData, commitSHA) + if err != nil { + return fmt.Errorf("verify dispatcher release attestation: %w", err) + } + manifest, err := buildDispatcherArchiveManifest(assets, subjects) + if err != nil { + return err + } + if values.DispatcherArchiveManifest != manifest { + return fmt.Errorf("%s dispatcherArchiveManifest does not exactly match verified release subjects", unityPackageCliPinFile) + } + return nil +} + +func validateDispatcherArchiveManifest(manifest string) error { + if manifest == "" { + return fmt.Errorf("%s dispatcherArchiveManifest is empty", unityPackageCliPinFile) + } + entries := strings.Split(manifest, "\n") + assetNames := make(map[string]struct{}, len(entries)) + previous := "" + for _, entry := range entries { + digest, name, ok := strings.Cut(entry, " ") + if !ok || !isDispatcherPinSHA256(digest) || name == "" || strings.ContainsAny(name, "\r\n \t") { + return fmt.Errorf("%s has an invalid dispatcherArchiveManifest entry", unityPackageCliPinFile) + } + if previous != "" && previous >= entry { + return fmt.Errorf("%s dispatcherArchiveManifest is not canonically sorted", unityPackageCliPinFile) + } + previous = entry + if _, exists := assetNames[name]; exists { + return fmt.Errorf("%s dispatcherArchiveManifest repeats asset %q", unityPackageCliPinFile, name) + } + assetNames[name] = struct{}{} + } + for _, requiredAsset := range requiredDispatcherPinAssets { + if _, exists := assetNames[requiredAsset]; !exists { + return fmt.Errorf("%s dispatcherArchiveManifest is missing required asset %q", unityPackageCliPinFile, requiredAsset) + } + } + return nil +} + +func isDispatcherPinSHA256(value string) bool { + if len(value) != sha256HexLength { + return false + } + for _, character := range value { + if (character < '0' || character > '9') && (character < 'a' || character > 'f') && (character < 'A' || character > 'F') { + return false + } + } + return true +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go b/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go new file mode 100644 index 0000000000..343e63775a --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go @@ -0,0 +1,41 @@ +package automation + +import "testing" + +func TestValidateDispatcherPinOfflineRejectsManifestWithoutRequiredArchive(t *testing.T) { + // Verifies a pin cannot authorize bootstrap when one supported platform archive is absent. + pin := []byte(`{"projectRunnerVersion":"3.0.0-beta.47","minimumDispatcherVersion":"3.0.1-beta.6","dispatcherReleaseTag":"dispatcher-v3.0.1-beta.6","dispatcherArchiveManifest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb install.ps1\ncccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc uloop-dispatcher-darwin-arm64.tar.gz\ndddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd uloop-dispatcher-darwin-amd64.tar.gz"}`) + + err := ValidateDispatcherPinOffline(pin, pin) + + if err == nil { + t.Fatal("expected required Windows archive failure") + } +} + +func TestValidateDispatcherPinOfflineRejectsUnsortedManifest(t *testing.T) { + // Verifies the guard requires canonical manifest order so a re-stamp is byte reproducible. + pin := []byte(`{"projectRunnerVersion":"3.0.0-beta.47","minimumDispatcherVersion":"3.0.1-beta.6","dispatcherReleaseTag":"dispatcher-v3.0.1-beta.6","dispatcherArchiveManifest":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb install.ps1\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\ncccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc uloop-dispatcher-darwin-amd64.tar.gz\ndddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd uloop-dispatcher-darwin-arm64.tar.gz\neeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee uloop-dispatcher-windows-amd64.zip"}`) + + err := ValidateDispatcherPinOffline(pin, pin) + + if err == nil { + t.Fatal("expected unsorted manifest failure") + } +} + +func TestValidateDispatcherPinOfflineRejectsMismatchedProjectCopy(t *testing.T) { + // Verifies the project mirror cannot silently diverge from the package trust root. + packagePin := validDispatcherPinGuardFixture() + projectPin := []byte(`{"projectRunnerVersion":"3.0.0-beta.47"}`) + + err := ValidateDispatcherPinOffline(packagePin, projectPin) + + if err == nil { + t.Fatal("expected mismatched project pin failure") + } +} + +func validDispatcherPinGuardFixture() []byte { + return []byte(`{"projectRunnerVersion":"3.0.0-beta.47","minimumDispatcherVersion":"3.0.1-beta.6","dispatcherReleaseTag":"dispatcher-v3.0.1-beta.6","dispatcherArchiveManifest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb install.ps1\ncccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc uloop-dispatcher-darwin-amd64.tar.gz\ndddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd uloop-dispatcher-darwin-arm64.tar.gz\neeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee uloop-dispatcher-windows-amd64.zip"}`) +} From 45f95eb2f68a1e5a0c381155c388f5a38593cec0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 16:16:46 +0900 Subject: [PATCH 06/13] Require authenticated archive manifests during install Fail before extraction when no verified manifest is supplied, preventing a same-origin checksum from authenticating a first installation. --- cli/dispatcher/shared-inputs-stamp.json | 2 +- scripts/install.ps1 | 33 ++++++++++++------------ scripts/install.sh | 10 +++---- scripts/test-install-archive-manifest.sh | 7 ++--- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 2a069e1d14..1c7bb15dbc 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "677ac7b5437d18efa8a09b575d0ec2619de4a1fa" + "sharedInputsHash": "9015856915596b7c6e58992664ccf8996e8d827e" } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ba7d24303f..24b4585fdf 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -511,25 +511,26 @@ try { # ULOOP_ARCHIVE_MANIFEST env whose " " lines came from a # Sigstore attestation bundle verified against the release commit SHA. # Enforcing the manifest entry stops a swapped archive from being blessed by - # a compromised same-origin .sha256. Missing env = README bootstrap; skip - # instead of hard-failing so a first-time curl|iex install still works. + # a compromised same-origin .sha256. Missing env must fail before archive + # extraction because same-origin checksums are not authentication. $Manifest = [Environment]::GetEnvironmentVariable("ULOOP_ARCHIVE_MANIFEST") - if (-not [string]::IsNullOrEmpty($Manifest)) { - $ManifestHash = $null - foreach ($Line in ($Manifest -split "`r?`n")) { - $Parts = $Line -split '\s+', 2 - if ($Parts.Length -eq 2 -and $Parts[1].Trim() -eq $AssetName) { - $ManifestHash = $Parts[0].Trim().ToLowerInvariant() - break - } - } - if ([string]::IsNullOrEmpty($ManifestHash)) { - throw "Attestation manifest has no entry for $AssetName" - } - if ($ManifestHash -ne $ActualHash) { - throw "Attestation manifest hash mismatch for $AssetName" + if ([string]::IsNullOrEmpty($Manifest)) { + throw "Attestation manifest is required" + } + $ManifestHash = $null + foreach ($Line in ($Manifest -split "`r?`n")) { + $Parts = $Line -split '\s+', 2 + if ($Parts.Length -eq 2 -and $Parts[1].Trim() -eq $AssetName) { + $ManifestHash = $Parts[0].Trim().ToLowerInvariant() + break } } + if ([string]::IsNullOrEmpty($ManifestHash)) { + throw "Attestation manifest has no entry for $AssetName" + } + if ($ManifestHash -ne $ActualHash) { + throw "Attestation manifest hash mismatch for $AssetName" + } Expand-UloopArchive -ArchivePath $ArchivePath -DestinationPath $TempDir diff --git a/scripts/install.sh b/scripts/install.sh index 834b1aed36..99b8e980f3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -488,12 +488,12 @@ verify_archive_attestation_manifest() { # extracted from a Sigstore attestation bundle already verified against the # release commit SHA. Enforce that the digest we just computed matches the # entry for our asset_name so a compromised same-origin .sha256 file alone - # cannot bless a swapped archive. When the env is unset (README curl|sh - # bootstrap), we skip: bootstrap trust rests on TLS + repo ownership, and - # promoting that missing-manifest path to a hard failure would break every - # first-time install. + # cannot bless a swapped archive. A missing manifest must fail before + # extraction because same-origin checksums alone do not authenticate a first + # installation. if [ -z "${ULOOP_ARCHIVE_MANIFEST:-}" ]; then - return + echo "Attestation manifest is required" >&2 + exit 1 fi manifest_hash=$( printf '%s\n' "$ULOOP_ARCHIVE_MANIFEST" | awk -v name="$asset_name" ' diff --git a/scripts/test-install-archive-manifest.sh b/scripts/test-install-archive-manifest.sh index ba666491f5..af3b4d104c 100755 --- a/scripts/test-install-archive-manifest.sh +++ b/scripts/test-install-archive-manifest.sh @@ -62,10 +62,11 @@ expect_fail() { fi } -# 1. Unset env: manifest check must skip silently so a first-time README -# bootstrap install (curl | sh) keeps working. +# 1. Unset env: first installation has no preverified manifest, so accepting +# it would permit the same-origin checksum-only fallback that the approved +# trust model forbids. unset ULOOP_ARCHIVE_MANIFEST || true -expect_pass "unset manifest skips silently" +expect_fail "unset manifest fails closed" # 2. Matching manifest entry: accept and continue. ULOOP_ARCHIVE_MANIFEST="$actual_hash $asset_name From 829e6cee301617dd246f1500e9e004951a83d75b Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 16:25:09 +0900 Subject: [PATCH 07/13] Harden dispatcher pin CI verification Run published subject verification whenever a pull request changes a trust input, or when no pull request diff exists. The guard now fails closed when attestation subjects and release assets are not the same complete set. --- .github/workflows/build-and-test.yml | 7 ++ .../automation/dispatcher_pin_guard.go | 9 +- .../automation/dispatcher_pin_guard_test.go | 90 ++++++++++++++++++- .../automation/dispatcher_pin_stamp.go | 8 ++ .../automation/dispatcher_pin_stamp_test.go | 1 - 5 files changed, 112 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index b68d29eb37..dfe1092614 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -80,6 +80,13 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: go run ./cmd/check-dispatcher-pin --base "origin/${{ github.base_ref }}" --head HEAD + - name: Verify published dispatcher pin subjects without a pull request diff + if: github.event_name != 'pull_request' + working-directory: cli/release-automation + env: + GITHUB_TOKEN: ${{ github.token }} + run: go run ./cmd/check-dispatcher-pin --network + - name: Check release triggers if: github.event_name == 'pull_request' working-directory: cli/release-automation diff --git a/cli/release-automation/internal/automation/dispatcher_pin_guard.go b/cli/release-automation/internal/automation/dispatcher_pin_guard.go index 597aa7067f..e1c0b3570b 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_guard.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_guard.go @@ -54,11 +54,18 @@ func ValidateDispatcherPinOffline(packagePin []byte, projectPin []byte) error { // VerifyDispatcherPinSubjects confirms the pinned manifest exactly matches the published verified subjects. func VerifyDispatcherPinSubjects(ctx context.Context, packagePin []byte) error { + return verifyDispatcherPinSubjects(ctx, packagePin, defaultDispatcherPinStampDeps()) +} + +func verifyDispatcherPinSubjects( + ctx context.Context, + packagePin []byte, + deps dispatcherPinStampDeps, +) error { values := dispatcherPinGuardValues{} if err := json.Unmarshal(packagePin, &values); err != nil { return fmt.Errorf("%s is invalid JSON: %w", unityPackageCliPinFile, err) } - deps := defaultDispatcherPinStampDeps() assets, err := deps.fetchReleaseAssets(ctx, values.DispatcherReleaseTag) if err != nil { return fmt.Errorf("fetch dispatcher release assets: %w", err) diff --git a/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go b/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go index 343e63775a..9f76af8b9a 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go @@ -1,6 +1,10 @@ package automation -import "testing" +import ( + "context" + "errors" + "testing" +) func TestValidateDispatcherPinOfflineRejectsManifestWithoutRequiredArchive(t *testing.T) { // Verifies a pin cannot authorize bootstrap when one supported platform archive is absent. @@ -36,6 +40,90 @@ func TestValidateDispatcherPinOfflineRejectsMismatchedProjectCopy(t *testing.T) } } +func TestValidateDispatcherPinOfflineRejectsMalformedManifest(t *testing.T) { + // Verifies malformed digest entries cannot become package bootstrap trust inputs. + pin := []byte(`{"projectRunnerVersion":"3.0.0-beta.47","minimumDispatcherVersion":"3.0.1-beta.6","dispatcherReleaseTag":"dispatcher-v3.0.1-beta.6","dispatcherArchiveManifest":"not-a-digest install.sh"}`) + + err := ValidateDispatcherPinOffline(pin, pin) + + if err == nil { + t.Fatal("expected malformed manifest failure") + } +} + +func TestValidateDispatcherPinOfflineRejectsPinnedVersionBelowMinimum(t *testing.T) { + // Verifies bootstrap never stamps a dispatcher release that the package immediately rejects. + pin := []byte(`{"projectRunnerVersion":"3.0.0-beta.47","minimumDispatcherVersion":"3.0.2","dispatcherReleaseTag":"dispatcher-v3.0.1-beta.6","dispatcherArchiveManifest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb install.ps1\ncccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc uloop-dispatcher-darwin-amd64.tar.gz\ndddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd uloop-dispatcher-darwin-arm64.tar.gz\neeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee uloop-dispatcher-windows-amd64.zip"}`) + + err := ValidateDispatcherPinOffline(pin, pin) + + if err == nil { + t.Fatal("expected pinned version below minimum failure") + } +} + +func TestVerifyDispatcherPinSubjectsRejectsManifestThatDoesNotExactlyMatchSubjects(t *testing.T) { + // Verifies the network guard rejects both omitted and unverified release subjects. + pin := validDispatcherPinGuardFixture() + deps := validDispatcherPinGuardDeps() + deps.verifySubjects = func([]byte, string) (map[string]string, error) { + return map[string]string{ + "install.sh": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "install.ps1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "uloop-dispatcher-darwin-amd64.tar.gz": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "uloop-dispatcher-darwin-arm64.tar.gz": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "uloop-dispatcher-windows-amd64.zip": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "uloop-dispatcher-unexpected.tar.gz": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }, nil + } + + err := verifyDispatcherPinSubjects(context.Background(), pin, deps) + + if err == nil { + t.Fatal("expected exact subject-set mismatch failure") + } +} + +func TestVerifyDispatcherPinSubjectsFailsClosedWhenReleaseLookupFails(t *testing.T) { + // Verifies GitHub API failures cannot skip published subject verification. + pin := validDispatcherPinGuardFixture() + deps := validDispatcherPinGuardDeps() + deps.fetchReleaseAssets = func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return nil, errors.New("GitHub unavailable") + } + + err := verifyDispatcherPinSubjects(context.Background(), pin, deps) + + if err == nil { + t.Fatal("expected GitHub API failure") + } +} + +func validDispatcherPinGuardDeps() dispatcherPinStampDeps { + return dispatcherPinStampDeps{ + fetchReleaseAssets: func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return []dispatcherReleaseAsset{ + {Name: "install.sh", URL: "https://example.invalid/install.sh"}, + {Name: "install.ps1", URL: "https://example.invalid/install.ps1"}, + {Name: "uloop-dispatcher-darwin-amd64.tar.gz", URL: "https://example.invalid/darwin-amd64.tar.gz"}, + {Name: "uloop-dispatcher-darwin-arm64.tar.gz", URL: "https://example.invalid/darwin-arm64.tar.gz"}, + {Name: "uloop-dispatcher-windows-amd64.zip", URL: "https://example.invalid/windows-amd64.zip"}, + }, nil + }, + fetchBundle: func(context.Context, string) ([]byte, error) { return []byte("bundle"), nil }, + fetchTagCommitSHA: func(context.Context, string, string) (string, error) { return "commit", nil }, + verifySubjects: func([]byte, string) (map[string]string, error) { + return map[string]string{ + "install.sh": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "install.ps1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "uloop-dispatcher-darwin-amd64.tar.gz": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "uloop-dispatcher-darwin-arm64.tar.gz": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "uloop-dispatcher-windows-amd64.zip": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }, nil + }, + } +} + func validDispatcherPinGuardFixture() []byte { return []byte(`{"projectRunnerVersion":"3.0.0-beta.47","minimumDispatcherVersion":"3.0.1-beta.6","dispatcherReleaseTag":"dispatcher-v3.0.1-beta.6","dispatcherArchiveManifest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb install.ps1\ncccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc uloop-dispatcher-darwin-amd64.tar.gz\ndddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd uloop-dispatcher-darwin-arm64.tar.gz\neeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee uloop-dispatcher-windows-amd64.zip"}`) } diff --git a/cli/release-automation/internal/automation/dispatcher_pin_stamp.go b/cli/release-automation/internal/automation/dispatcher_pin_stamp.go index a37b9e0024..ae0e617c7c 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_stamp.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_stamp.go @@ -136,6 +136,14 @@ func buildDispatcherArchiveManifest(assets []dispatcherReleaseAsset, subjects ma if len(entries) == 0 { return "", fmt.Errorf("dispatcher release has no attested assets") } + if len(subjects) != len(seenAssets) { + return "", fmt.Errorf("dispatcher release attestation subjects do not exactly match release assets") + } + for subjectName := range subjects { + if _, exists := seenAssets[subjectName]; !exists { + return "", fmt.Errorf("dispatcher release attestation has unexpected subject %q", subjectName) + } + } sort.Strings(entries) return strings.Join(entries, "\n"), nil } diff --git a/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go b/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go index 6a9e0774ef..40a5bab190 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go @@ -31,7 +31,6 @@ func TestStampDispatcherPinWritesOnlyVerifiedReleaseSubjects(t *testing.T) { "install.sh": "1111111111111111111111111111111111111111111111111111111111111111", "install.ps1": "2222222222222222222222222222222222222222222222222222222222222222", "uloop-dispatcher-darwin-arm64.zip": "3333333333333333333333333333333333333333333333333333333333333333", - "unpublished-asset.zip": "4444444444444444444444444444444444444444444444444444444444444444", }, nil }, } From 17f59ddaeec9ca8aee0f27e2f5566d4abf5ab16f Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 16:40:16 +0900 Subject: [PATCH 08/13] Pin Unity dispatcher installer provenance Use the package-stamped immutable dispatcher tag and manifest instead of a minimum-derived tag or release checksum sidecar. The installer command verifies the downloaded script digest before execution and propagates failures through the setup result contract. --- Assets/Tests/Editor/CliPathSetupFlowTests.cs | 11 +- .../Tests/Editor/CliPinReaderServiceTests.cs | 36 ++++- .../Editor/CliSetupApplicationServiceTests.cs | 74 +++++---- .../Tests/Editor/NativeCliInstallerTests.cs | 64 +++++--- .../src/Editor/Application/CliPinReader.cs | 11 -- .../Application/CliSetupApplicationService.cs | 65 ++++++-- Packages/src/Editor/Domain/CliConstants.cs | 3 +- .../Infrastructure/CLI/CliPinReaderService.cs | 48 +++++- .../CLI/NativeCliCommandBuilder.cs | 143 +++++++++++------- .../Infrastructure/CLI/NativeCliInstaller.cs | 35 +++-- .../Setup/SetupWizardCliWorkflowController.cs | 7 +- .../UnityCliLoopSettingsCliSetupPresenter.cs | 7 +- 12 files changed, 354 insertions(+), 150 deletions(-) diff --git a/Assets/Tests/Editor/CliPathSetupFlowTests.cs b/Assets/Tests/Editor/CliPathSetupFlowTests.cs index d37ce9549b..f7a38ec29f 100644 --- a/Assets/Tests/Editor/CliPathSetupFlowTests.cs +++ b/Assets/Tests/Editor/CliPathSetupFlowTests.cs @@ -180,7 +180,8 @@ public bool HasPackageOwnedCurrentUserInstall(RuntimePlatform platform) public Task InstallGlobalCliAsync( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, CancellationToken ct) { return Task.FromResult(new CliInstallResult(true, "")); @@ -202,12 +203,14 @@ public CliPathSetupApplyResult ApplyGlobalCliPathSetup(CliPathSetupPlan plan) return _applyResult; } - public NativeCliInstallCommand GetGlobalCliInstallCommand( + public NativeCliInstallCommandLoadResult GetGlobalCliInstallCommand( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, bool removeLegacyLaunchers) { - return new NativeCliInstallCommand("sh", "-c true", "install"); + return NativeCliInstallCommandLoadResult.FromSuccess( + new NativeCliInstallCommand("sh", "-c true", "install")); } } } diff --git a/Assets/Tests/Editor/CliPinReaderServiceTests.cs b/Assets/Tests/Editor/CliPinReaderServiceTests.cs index ac33a8154d..66abfadce9 100644 --- a/Assets/Tests/Editor/CliPinReaderServiceTests.cs +++ b/Assets/Tests/Editor/CliPinReaderServiceTests.cs @@ -294,6 +294,39 @@ public void LoadDispatcherBootstrapPinFromPath_WhenManifestIsMalformed_ReturnsFa } } + [Test] + public void LoadDispatcherBootstrapPinFromPath_WhenManifestUsesCrLfOrDuplicateAsset_ReturnsFailure() + { + // Tests that bootstrap manifests have one canonical LF-only entry per asset name. + string root = CreateTestRoot(); + string pinPath = Path.Combine(root, "project-runner-pin.json"); + + try + { + Directory.CreateDirectory(root); + string manifest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\r\n" + + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb install.sh\n" + + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc install.ps1"; + File.WriteAllText( + pinPath, + "{\"projectRunnerVersion\":\"3.0.0\",\"minimumDispatcherVersion\":\"3.0.1\",\"dispatcherReleaseTag\":\"dispatcher-v3.0.1\",\"dispatcherArchiveManifest\":\"" + + manifest.Replace("\r", "\\r").Replace("\n", "\\n") + + "\"}"); + + DispatcherBootstrapPinLoadResult result = + CliPinReaderService.LoadDispatcherBootstrapPinFromPath(pinPath); + + Assert.That(result.Success, Is.False); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + [Test] public void LoadDispatcherBootstrapPinFromPath_WhenBootstrapFieldsAreValid_ReturnsPinnedReleaseInputs() { @@ -302,7 +335,8 @@ public void LoadDispatcherBootstrapPinFromPath_WhenBootstrapFieldsAreValid_Retur string pinPath = Path.Combine(root, "project-runner-pin.json"); string manifest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\n" - + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb uloop-dispatcher-darwin-arm64.zip"; + + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb install.ps1\n" + + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc uloop-dispatcher-darwin-arm64.zip"; try { diff --git a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs index add329d6bb..b73509028e 100644 --- a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs +++ b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs @@ -16,9 +16,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor public class CliSetupApplicationServiceTests { [Test] - public async Task InstallGlobalCliAsync_UsesMinimumRequiredDispatcherReleaseTag() + public async Task InstallGlobalCliAsync_UsesPinnedDispatcherReleaseTag() { - // Verifies that manual installs target the dispatcher release derived from the package pin. + // Verifies installation uses the immutable dispatcher release tag stamped in the package pin. FakeNativeCliInstaller nativeCliInstaller = new(); CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), @@ -29,7 +29,7 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredDispatcherReleaseTag( Assert.That( nativeCliInstaller.InstalledVersion, - Is.EqualTo(ExpectedDispatcherReleaseTag())); + Is.EqualTo("dispatcher-v3.0.1-beta.6")); } [Test] @@ -45,34 +45,40 @@ public void GetMinimumRequiredCliVersion_UsesDispatcherVersion() } [Test] - public void GetMinimumRequiredCliReleaseTag_UsesDispatcherReleaseTag() + public void GetGlobalCliInstallCommand_UsesPinnedDispatcherReleaseTag() { - // Verifies setup derives the prefixed release tag from the package pin instead of a duplicated constant. + // Verifies fallback manual commands use the immutable dispatcher tag from the bootstrap pin. + FakeNativeCliInstaller nativeCliInstaller = new(); CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), - new FakeNativeCliInstaller(), + nativeCliInstaller, new CliPinReaderService()); - Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo(ExpectedDispatcherReleaseTag())); + NativeCliInstallCommandLoadResult commandResult = service.GetGlobalCliInstallCommand( + RuntimePlatform.OSXEditor, + false); + + Assert.That(commandResult.Success, Is.True, commandResult.ErrorOutput); + Assert.That( + commandResult.Command.ManualCommand, + Is.EqualTo("install dispatcher-v3.0.1-beta.6")); } [Test] - public void GetGlobalCliInstallCommand_UsesMinimumRequiredDispatcherReleaseTag() + public async Task InstallGlobalCliAsync_WhenBootstrapPinIsUnavailableFailsWithoutInvokingInstaller() { - // Verifies that fallback manual commands point at the pin-derived dispatcher release tag. + // Verifies a missing bootstrap pin cannot fall back to a derived or latest dispatcher release. FakeNativeCliInstaller nativeCliInstaller = new(); CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), nativeCliInstaller, - new CliPinReaderService()); + new FailingBootstrapPinReader()); - NativeCliInstallCommand command = service.GetGlobalCliInstallCommand( - RuntimePlatform.OSXEditor, - false); + CliInstallResult result = await service.InstallGlobalCliAsync(RuntimePlatform.OSXEditor, CancellationToken.None); - Assert.That( - command.ManualCommand, - Is.EqualTo("install " + ExpectedDispatcherReleaseTag())); + Assert.That(result.Success, Is.False); + Assert.That(result.ErrorOutput, Does.Contain("bootstrap pin")); + Assert.That(nativeCliInstaller.InstalledVersion, Is.Null); } private static string ExpectedMinimumDispatcherVersion() @@ -82,11 +88,6 @@ private static string ExpectedMinimumDispatcherVersion() return result.Pin.MinimumDispatcherVersion; } - private static string ExpectedDispatcherReleaseTag() - { - return CliPinReader.BuildDispatcherReleaseTag(ExpectedMinimumDispatcherVersion()); - } - private sealed class FakeCliInstallationDetector : ICliInstallationDetector { private readonly string[] _versions; @@ -121,6 +122,24 @@ public Task ForceRefreshCliVersionAsync(CancellationToken ct) public void InvalidateCache() { } } + private sealed class FailingBootstrapPinReader : ICliPinReader + { + public CliPinLoadResult LoadPackagePin() + { + return CliPinLoadResult.FromFailure("bootstrap pin missing"); + } + + public DispatcherBootstrapPinLoadResult LoadDispatcherBootstrapPin() + { + return DispatcherBootstrapPinLoadResult.FromFailure("bootstrap pin missing"); + } + + public string LoadMinimumDispatcherVersionOrThrow() + { + return "3.0.1"; + } + } + private sealed class FakeNativeCliInstaller : INativeCliInstaller { public string InstalledVersion { get; private set; } @@ -137,10 +156,11 @@ public bool HasPackageOwnedCurrentUserInstall(RuntimePlatform platform) public Task InstallGlobalCliAsync( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, CancellationToken ct) { - InstalledVersion = cliReleaseTag; + InstalledVersion = dispatcherReleaseTag; return Task.FromResult(new CliInstallResult(true, "")); } @@ -167,12 +187,14 @@ public CliPathSetupApplyResult ApplyGlobalCliPathSetup(CliPathSetupPlan plan) return new CliPathSetupApplyResult(true, CliPathSetupApplyStatus.Applied, ""); } - public NativeCliInstallCommand GetGlobalCliInstallCommand( + public NativeCliInstallCommandLoadResult GetGlobalCliInstallCommand( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, bool removeLegacyLaunchers) { - return new NativeCliInstallCommand("sh", "-c true", $"install {cliReleaseTag}"); + return NativeCliInstallCommandLoadResult.FromSuccess( + new NativeCliInstallCommand("sh", "-c true", $"install {dispatcherReleaseTag}")); } } } diff --git a/Assets/Tests/Editor/NativeCliInstallerTests.cs b/Assets/Tests/Editor/NativeCliInstallerTests.cs index 279f4a0a76..c92884b9e2 100644 --- a/Assets/Tests/Editor/NativeCliInstallerTests.cs +++ b/Assets/Tests/Editor/NativeCliInstallerTests.cs @@ -15,9 +15,13 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor /// public class NativeCliInstallerTests { - private const string TestBetaCliVersion = "3.0.0-beta.3"; private const string TestBetaReleaseTag = "dispatcher-v3.0.0-beta.3"; private const string TestStableReleaseTag = "dispatcher-v3.0.0"; + private const string TestArchiveManifest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\n" + + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb install.ps1\n" + + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc uloop-dispatcher-darwin-amd64.tar.gz\n" + + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd uloop-dispatcher-darwin-arm64.tar.gz\n" + + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee uloop-dispatcher-windows-amd64.zip"; [Test] public void GetInstallCommand_OnMacKeepsDispatcherCurlInstallerAvailable() @@ -25,17 +29,19 @@ public void GetInstallCommand_OnMacKeepsDispatcherCurlInstallerAvailable() // Verifies that editor installs download the dispatcher installer script and its checksum from the release, then execute it, and never fall back to npm. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.OSXEditor, - TestBetaCliVersion, + TestBetaReleaseTag, + TestArchiveManifest, false, "/bin/zsh"); Assert.That(command.FileName, Is.EqualTo("/bin/zsh")); Assert.That(command.Arguments, Does.Contain("-l -i -c")); Assert.That(command.Arguments, Does.Contain($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestBetaReleaseTag}/install.sh")); - Assert.That(command.Arguments, Does.Contain($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestBetaReleaseTag}/install.sh.sha256")); + Assert.That(command.Arguments, Does.Not.Contain(".sha256")); Assert.That(command.Arguments, Does.Contain($"{CliConstants.POSIX_SHELL_EXECUTABLE_PATH} -c")); Assert.That(command.Arguments, Does.Contain("ULOOP_VERSION")); Assert.That(command.Arguments, Does.Contain(TestBetaReleaseTag)); + Assert.That(command.Arguments, Does.Contain("ULOOP_ARCHIVE_MANIFEST")); Assert.That(command.Arguments, Does.Not.Contain("ULOOP_REMOVE_LEGACY")); Assert.That(command.ManualCommand, Does.Contain("curl -fsSL")); Assert.That(command.ManualCommand, Does.Not.Contain("npm")); @@ -48,7 +54,8 @@ public void GetInstallCommand_OnMacDelegatesPosixSnippetThroughSh() // Verifies that fish or other login shells only load environment before the POSIX script downloads, verifies, and executes. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.OSXEditor, - TestBetaCliVersion, + TestBetaReleaseTag, + TestArchiveManifest, false, "/opt/homebrew/bin/fish"); @@ -57,8 +64,8 @@ public void GetInstallCommand_OnMacDelegatesPosixSnippetThroughSh() Assert.That(command.ManualCommand, Does.StartWith($"{CliConstants.POSIX_SHELL_EXECUTABLE_PATH} -c ")); Assert.That(command.ManualCommand, Does.Contain("tmp_dir=$(mktemp -d)")); Assert.That(command.ManualCommand, Does.Contain("curl -fsSL")); - Assert.That(command.ManualCommand, Does.Contain("sha256sum -c install.sh.sha256")); - Assert.That(command.ManualCommand, Does.Contain("shasum -a 256 -c install.sh.sha256")); + Assert.That(command.ManualCommand, Does.Contain("shasum -a 256")); + Assert.That(command.ManualCommand, Does.Not.Contain(".sha256")); } [Test] @@ -67,14 +74,16 @@ public void GetInstallCommand_OnMacPropagatesInstallerDownloadFailure() // Verifies that editor installs do not report success when curl or checksum verification fails before script execution. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.OSXEditor, - TestBetaCliVersion, + TestBetaReleaseTag, + TestArchiveManifest, false, "/bin/zsh"); Assert.That(command.ManualCommand, Does.Contain("curl -fsSL")); Assert.That(command.ManualCommand, Does.Contain(" -o ")); Assert.That(command.ManualCommand, Does.Contain(" && ")); - Assert.That(command.ManualCommand, Does.Not.Contain("|")); + Assert.That(command.ManualCommand, Does.Not.Contain("curl -fsSL |")); + Assert.That(command.ManualCommand, Does.Not.Contain("| sh")); } [Test] @@ -83,14 +92,18 @@ public void GetInstallCommand_OnWindowsKeepsDispatcherPowerShellInstallerAvailab // Verifies that editor installs download the dispatcher PowerShell installer script and its checksum from the release, verify SHA-256, and run via -File, not `irm | iex`. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.WindowsEditor, - TestBetaCliVersion, + TestBetaReleaseTag, + TestArchiveManifest, false, "/bin/zsh"); Assert.That(command.FileName, Is.EqualTo("powershell")); Assert.That(command.Arguments, Does.Contain($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestBetaReleaseTag}/install.ps1")); - Assert.That(command.Arguments, Does.Contain($"https://github.com/hatayama/unity-cli-loop/releases/download/{TestBetaReleaseTag}/install.ps1.sha256")); + Assert.That(command.Arguments, Does.Not.Contain(".sha256")); Assert.That(command.Arguments, Does.Contain($"$env:ULOOP_VERSION = '{TestBetaReleaseTag}'")); + Assert.That(command.Arguments, Does.Contain("$env:ULOOP_ARCHIVE_MANIFEST")); + Assert.That(command.Arguments, Does.Contain("[char]10")); + Assert.That(command.Arguments, Does.Not.Contain("install.sh\n")); Assert.That(command.Arguments, Does.Not.Contain("ULOOP_REMOVE_LEGACY")); Assert.That(command.ManualCommand, Does.Contain("$ErrorActionPreference = 'Stop'")); Assert.That(command.ManualCommand, Does.Contain("Invoke-WebRequest")); @@ -108,7 +121,8 @@ public void GetInstallCommand_OnMacDispatcherInstallerDoesNotAdvertiseWindowsLeg // Verifies that macOS manual commands do not expose old cleanup flags. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.OSXEditor, - TestBetaCliVersion, + TestBetaReleaseTag, + TestArchiveManifest, true, "/bin/zsh"); @@ -122,7 +136,8 @@ public void GetInstallCommand_OnWindowsDoesNotNeedLegacyCleanupFlag() // Verifies that Windows installs rely on the native CLI install command for legacy cleanup. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.WindowsEditor, - TestBetaCliVersion, + TestBetaReleaseTag, + TestArchiveManifest, true, "/bin/zsh"); @@ -136,7 +151,8 @@ public void GetInstallCommand_WhenVPrefixedVersionUsesDispatcherReleaseTag() // Verifies root release tags are normalized to dispatcher releases for installer scripts. NativeCliInstallCommand command = NativeCliCommandBuilder.BuildRemoteInstallCommand( RuntimePlatform.OSXEditor, - "v3.0.0", + TestStableReleaseTag, + TestArchiveManifest, false, "/bin/zsh"); @@ -169,7 +185,8 @@ public void GetInstallCommand_WhenLocalPackageOnMacUsesPackageLocalInstaller() { NativeCliInstallCommand command = NativeCliCommandBuilder.BuildInstallCommandWithPackagePath( RuntimePlatform.OSXEditor, - TestBetaCliVersion, + TestBetaReleaseTag, + TestArchiveManifest, false, "/bin/zsh", packageResolvedPath); @@ -180,6 +197,7 @@ public void GetInstallCommand_WhenLocalPackageOnMacUsesPackageLocalInstaller() Assert.That(command.ManualCommand, Does.Contain(scriptPath)); Assert.That(command.ManualCommand, Does.Contain("ULOOP_VERSION")); Assert.That(command.ManualCommand, Does.Contain(TestBetaReleaseTag)); + Assert.That(command.ManualCommand, Does.Contain("ULOOP_ARCHIVE_MANIFEST")); Assert.That(command.ManualCommand, Does.Not.Contain("curl -fsSL")); Assert.That(command.ManualCommand, Does.Not.Contain("npm")); } @@ -215,7 +233,8 @@ public void GetInstallCommand_WhenLocalPackageOnWindowsUsesPackageLocalInstaller { NativeCliInstallCommand command = NativeCliCommandBuilder.BuildInstallCommandWithPackagePath( RuntimePlatform.WindowsEditor, - TestBetaCliVersion, + TestBetaReleaseTag, + TestArchiveManifest, false, "/bin/zsh", packageResolvedPath); @@ -223,6 +242,7 @@ public void GetInstallCommand_WhenLocalPackageOnWindowsUsesPackageLocalInstaller Assert.That(command.FileName, Is.EqualTo("powershell")); Assert.That(command.ManualCommand, Does.Contain($"& '{scriptPath}'")); Assert.That(command.ManualCommand, Does.Contain($"$env:ULOOP_VERSION='{TestBetaReleaseTag}'")); + Assert.That(command.ManualCommand, Does.Contain("$env:ULOOP_ARCHIVE_MANIFEST")); Assert.That(command.ManualCommand, Does.Not.Contain("irm")); Assert.That(command.ManualCommand, Does.Not.Contain("npm")); } @@ -258,15 +278,15 @@ public void BuildInstallerScriptUrl_WhenStableVersionUsesReleaseInstallerAsset() } [Test] - public void BuildInstallerChecksumUrl_AppendsShaSuffixToScriptUrl() + public void GetInstallCommand_WhenManifestLacksInstallerDigestFailsClosed() { - // Verifies the checksum URL points at the release-asset .sha256 sidecar next to the installer script. - string scriptUrl = NativeCliCommandBuilder.BuildInstallerScriptUrl( + // Verifies a package pin that lacks the downloaded script digest cannot build an executable command. + Assert.Throws(() => NativeCliCommandBuilder.BuildRemoteInstallCommand( + RuntimePlatform.OSXEditor, TestBetaReleaseTag, - CliConstants.POSIX_INSTALL_SCRIPT_NAME); - string checksumUrl = NativeCliCommandBuilder.BuildInstallerChecksumUrl(scriptUrl); - - Assert.That(checksumUrl, Is.EqualTo(scriptUrl + ".sha256")); + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.ps1", + false, + "/bin/zsh")); } [Test] diff --git a/Packages/src/Editor/Application/CliPinReader.cs b/Packages/src/Editor/Application/CliPinReader.cs index 81bf12d67d..7a658384fb 100644 --- a/Packages/src/Editor/Application/CliPinReader.cs +++ b/Packages/src/Editor/Application/CliPinReader.cs @@ -102,15 +102,4 @@ public interface ICliPinReader string LoadMinimumDispatcherVersionOrThrow(); } - /// - /// Builds derived values from the CLI pin JSON that ships with the Unity package. - /// - public static class CliPinReader - { - // Why: composes the dispatcher release tag from the pin so callers do not have to know the prefix. - public static string BuildDispatcherReleaseTag(string minimumDispatcherVersion) - { - return CliConstants.DISPATCHER_RELEASE_TAG_PREFIX + minimumDispatcherVersion; - } - } } diff --git a/Packages/src/Editor/Application/CliSetupApplicationService.cs b/Packages/src/Editor/Application/CliSetupApplicationService.cs index 00a1a9fd7a..80b5cfc178 100644 --- a/Packages/src/Editor/Application/CliSetupApplicationService.cs +++ b/Packages/src/Editor/Application/CliSetupApplicationService.cs @@ -26,6 +26,33 @@ public NativeCliInstallCommand(string fileName, string arguments, string manualC public string ManualCommand { get; } } + /// + /// Represents either a safe native installer command or the reason it could not be built. + /// + public readonly struct NativeCliInstallCommandLoadResult + { + private NativeCliInstallCommandLoadResult(bool success, NativeCliInstallCommand command, string errorOutput) + { + Success = success; + Command = command; + ErrorOutput = errorOutput; + } + + public bool Success { get; } + public NativeCliInstallCommand Command { get; } + public string ErrorOutput { get; } + + public static NativeCliInstallCommandLoadResult FromSuccess(NativeCliInstallCommand command) + { + return new NativeCliInstallCommandLoadResult(true, command, string.Empty); + } + + public static NativeCliInstallCommandLoadResult FromFailure(string errorOutput) + { + return new NativeCliInstallCommandLoadResult(false, default, errorOutput); + } + } + /// /// Defines how CLI installation state is detected by the owning workflow. /// @@ -49,13 +76,18 @@ public interface INativeCliInstaller { bool IsPackageOwnedCurrentUserInstallPath(string cliExecutablePath, RuntimePlatform platform); bool HasPackageOwnedCurrentUserInstall(RuntimePlatform platform); - Task InstallGlobalCliAsync(RuntimePlatform platform, string cliReleaseTag, CancellationToken ct); + Task InstallGlobalCliAsync( + RuntimePlatform platform, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, + CancellationToken ct); Task UninstallGlobalCliAsync(RuntimePlatform platform, CancellationToken ct); Task GetGlobalCliPathSetupPlanAsync(RuntimePlatform platform, CancellationToken ct); CliPathSetupApplyResult ApplyGlobalCliPathSetup(CliPathSetupPlan plan); - NativeCliInstallCommand GetGlobalCliInstallCommand( + NativeCliInstallCommandLoadResult GetGlobalCliInstallCommand( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, bool removeLegacyLaunchers); } @@ -134,13 +166,6 @@ public string GetMinimumRequiredCliVersion() return _cliPinReader.LoadMinimumDispatcherVersionOrThrow(); } - public string GetMinimumRequiredCliReleaseTag() - { - // Why: v3 setup installs the global dispatcher, and the release tag is derived from the pin so it - // cannot drift from the version reported by GetMinimumRequiredCliVersion. - return CliPinReader.BuildDispatcherReleaseTag(_cliPinReader.LoadMinimumDispatcherVersionOrThrow()); - } - public bool IsPackageOwnedCurrentUserInstallPath( string cliExecutablePath, RuntimePlatform platform) @@ -177,9 +202,16 @@ public async Task InstallGlobalCliAsync(RuntimePlatform platfo { ct.ThrowIfCancellationRequested(); + DispatcherBootstrapPinLoadResult bootstrapPin = _cliPinReader.LoadDispatcherBootstrapPin(); + if (!bootstrapPin.Success) + { + return new CliInstallResult(false, bootstrapPin.ErrorMessage); + } + CliInstallResult result = await _nativeCliInstaller.InstallGlobalCliAsync( platform, - GetMinimumRequiredCliReleaseTag(), + bootstrapPin.DispatcherReleaseTag, + bootstrapPin.ArchiveManifest, ct); _cliInstallationDetector.InvalidateCache(); return result; @@ -243,13 +275,20 @@ public async Task UninstallGlobalCliAsync(RuntimePlatform plat return result; } - public NativeCliInstallCommand GetGlobalCliInstallCommand( + public NativeCliInstallCommandLoadResult GetGlobalCliInstallCommand( RuntimePlatform platform, bool removeLegacyLaunchers) { + DispatcherBootstrapPinLoadResult bootstrapPin = _cliPinReader.LoadDispatcherBootstrapPin(); + if (!bootstrapPin.Success) + { + return NativeCliInstallCommandLoadResult.FromFailure(bootstrapPin.ErrorMessage); + } + return _nativeCliInstaller.GetGlobalCliInstallCommand( platform, - GetMinimumRequiredCliReleaseTag(), + bootstrapPin.DispatcherReleaseTag, + bootstrapPin.ArchiveManifest, removeLegacyLaunchers); } } diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index a311eb3fcb..786fb76377 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -19,6 +19,7 @@ public static class CliConstants public const string WINDOWS_INSTALL_SCRIPT_NAME = "install.ps1"; public const string INSTALL_DIR_ENVIRONMENT_VARIABLE = "ULOOP_INSTALL_DIR"; public const string INSTALL_VERSION_ENVIRONMENT_VARIABLE = "ULOOP_VERSION"; + public const string ARCHIVE_MANIFEST_ENVIRONMENT_VARIABLE = "ULOOP_ARCHIVE_MANIFEST"; public const string POSIX_HOME_ENVIRONMENT_VARIABLE = "HOME"; public const string POSIX_PATH_ENVIRONMENT_VARIABLE = "PATH"; public const string WINDOWS_LOCAL_APPDATA_ENVIRONMENT_VARIABLE = "LOCALAPPDATA"; @@ -30,8 +31,6 @@ public static class CliConstants public const string NATIVE_INSTALL_BIN_DIR_NAME = "bin"; public const string POSIX_PATH_SEPARATOR = ":"; public const string WINDOWS_PATH_SEPARATOR = ";"; - public const string RELEASE_TAG_PREFIX = "v"; - public const string PROJECT_RUNNER_RELEASE_TAG_PREFIX = "uloop-project-runner-v"; public const string DISPATCHER_RELEASE_TAG_PREFIX = "dispatcher-v"; public const string BETA_VERSION_MARKER = "-beta."; public const string SKILL_DIR_PREFIX = "uloop-"; diff --git a/Packages/src/Editor/Infrastructure/CLI/CliPinReaderService.cs b/Packages/src/Editor/Infrastructure/CLI/CliPinReaderService.cs index 7d88d4467c..c0370d1e64 100644 --- a/Packages/src/Editor/Infrastructure/CLI/CliPinReaderService.cs +++ b/Packages/src/Editor/Infrastructure/CLI/CliPinReaderService.cs @@ -159,6 +159,12 @@ private static DispatcherBootstrapPinLoadResult ValidateDispatcherBootstrapPin(C return DispatcherBootstrapPinLoadResult.FromFailure( $"Unity CLI Loop pin file at {pinPath} contains an invalid dispatcherArchiveManifest entry."); } + if (!HasArchiveManifestEntry(pin.DispatcherArchiveManifest, CliConstants.POSIX_INSTALL_SCRIPT_NAME) + || !HasArchiveManifestEntry(pin.DispatcherArchiveManifest, CliConstants.WINDOWS_INSTALL_SCRIPT_NAME)) + { + return DispatcherBootstrapPinLoadResult.FromFailure( + $"Unity CLI Loop pin file at {pinPath} is missing an installer script digest."); + } return DispatcherBootstrapPinLoadResult.FromSuccess( pin.DispatcherReleaseTag, @@ -167,20 +173,40 @@ private static DispatcherBootstrapPinLoadResult ValidateDispatcherBootstrapPin(C private static bool IsValidDispatcherReleaseTag(string dispatcherReleaseTag) { - return dispatcherReleaseTag == dispatcherReleaseTag.Trim() - && dispatcherReleaseTag.StartsWith(CliConstants.DISPATCHER_RELEASE_TAG_PREFIX, StringComparison.Ordinal) - && dispatcherReleaseTag.IndexOfAny(new char[] { '\r', '\n', '\t', ' ' }) < 0; + if (dispatcherReleaseTag != dispatcherReleaseTag.Trim() + || !dispatcherReleaseTag.StartsWith(CliConstants.DISPATCHER_RELEASE_TAG_PREFIX, StringComparison.Ordinal)) + { + return false; + } + foreach (char character in dispatcherReleaseTag) + { + if (!(char.IsLetterOrDigit(character) || character == '.' || character == '-')) + { + return false; + } + } + return true; } private static bool IsValidArchiveManifest(string archiveManifest) { - string[] entries = archiveManifest.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None); + if (archiveManifest.Contains("\r")) + { + return false; + } + string[] entries = archiveManifest.Split('\n'); + System.Collections.Generic.HashSet assetNames = new(); foreach (string entry in entries) { if (!IsValidArchiveManifestEntry(entry)) { return false; } + string assetName = entry.Substring(MANIFEST_ENTRY_PREFIX_LENGTH); + if (!assetNames.Add(assetName)) + { + return false; + } } return entries.Length > 0; } @@ -207,6 +233,20 @@ private static bool IsValidArchiveManifestEntry(string entry) && assetName.IndexOfAny(new char[] { '\r', '\n', '\t', ' ' }) < 0; } + private static bool HasArchiveManifestEntry(string archiveManifest, string assetName) + { + string suffix = " " + assetName; + string[] entries = archiveManifest.Split('\n'); + foreach (string entry in entries) + { + if (entry.EndsWith(suffix, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + private static bool IsHexCharacter(char value) { return value >= '0' && value <= '9' diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliCommandBuilder.cs b/Packages/src/Editor/Infrastructure/CLI/NativeCliCommandBuilder.cs index 45924f6194..668ab6522a 100644 --- a/Packages/src/Editor/Infrastructure/CLI/NativeCliCommandBuilder.cs +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliCommandBuilder.cs @@ -15,13 +15,15 @@ internal static class NativeCliCommandBuilder { internal static NativeCliInstallCommand BuildRemoteInstallCommand( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, bool removeLegacyLaunchers, string posixShellPath) { return BuildInstallCommandWithPackagePath( platform, - cliReleaseTag, + dispatcherReleaseTag, + dispatcherArchiveManifest, removeLegacyLaunchers, posixShellPath, null); @@ -29,16 +31,18 @@ internal static NativeCliInstallCommand BuildRemoteInstallCommand( internal static NativeCliInstallCommand BuildInstallCommandWithPackagePath( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, bool removeLegacyLaunchers, string posixShellPath, string packageResolvedPath) { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(cliReleaseTag), "cliReleaseTag must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(dispatcherReleaseTag), "dispatcherReleaseTag must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(dispatcherArchiveManifest), "dispatcherArchiveManifest must not be null or empty"); UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(posixShellPath), "posixShellPath must not be null or empty"); _ = removeLegacyLaunchers; - string releaseTag = BuildReleaseTag(cliReleaseTag); + string releaseTag = dispatcherReleaseTag; if (platform == RuntimePlatform.WindowsEditor) { string localScriptPath = ResolvePackageLocalInstallerScriptPath( @@ -48,9 +52,10 @@ internal static NativeCliInstallCommand BuildInstallCommandWithPackagePath( string command = string.IsNullOrEmpty(localScriptPath) ? BuildWindowsRemoteInstallScriptCommand( windowsScriptUrl, - BuildInstallerChecksumUrl(windowsScriptUrl), - releaseTag) - : BuildWindowsLocalInstallScriptCommand(localScriptPath, releaseTag); + GetManifestDigest(dispatcherArchiveManifest, CliConstants.WINDOWS_INSTALL_SCRIPT_NAME), + releaseTag, + dispatcherArchiveManifest) + : BuildWindowsLocalInstallScriptCommand(localScriptPath, releaseTag, dispatcherArchiveManifest); return new NativeCliInstallCommand( "powershell", $"-NoProfile -ExecutionPolicy Bypass -Command \"{command}\"", @@ -64,9 +69,10 @@ internal static NativeCliInstallCommand BuildInstallCommandWithPackagePath( string posixCommand = string.IsNullOrEmpty(posixLocalScriptPath) ? BuildPosixRemoteInstallScriptCommand( posixScriptUrl, - BuildInstallerChecksumUrl(posixScriptUrl), - releaseTag) - : BuildPosixLocalInstallScriptCommand(posixLocalScriptPath, releaseTag); + GetManifestDigest(dispatcherArchiveManifest, CliConstants.POSIX_INSTALL_SCRIPT_NAME), + releaseTag, + dispatcherArchiveManifest) + : BuildPosixLocalInstallScriptCommand(posixLocalScriptPath, releaseTag, dispatcherArchiveManifest); string loginShellCommand = BuildLoginShellPosixInstallScriptCommand(posixCommand); return new NativeCliInstallCommand( posixShellPath, @@ -87,10 +93,10 @@ internal static NativeCliInstallCommand BuildUninstallCommand( $"{QuoteProcessArgument(installPath)} uninstall"); } - private static string BuildPosixRemoteInstallScriptCommand(string scriptUrl, string checksumUrl, string releaseTag) + private static string BuildPosixRemoteInstallScriptCommand(string scriptUrl, string expectedDigest, string releaseTag, string archiveManifest) { UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(scriptUrl), "scriptUrl must not be null or empty"); - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(checksumUrl), "checksumUrl must not be null or empty"); + UnityEngine.Debug.Assert(IsSHA256Digest(expectedDigest), "expectedDigest must be a SHA-256 digest"); UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(releaseTag), "releaseTag must not be null or empty"); // Why: The whole command runs as a single /bin/sh -c string where `set -e` is not applied @@ -100,20 +106,21 @@ private static string BuildPosixRemoteInstallScriptCommand(string scriptUrl, str return "tmp_dir=$(mktemp -d) && " + "trap 'rm -rf \"$tmp_dir\"' EXIT && " + $"curl -fsSL {QuotePosixShellValue(scriptUrl)} -o \"$tmp_dir/{scriptName}\" && " - + $"curl -fsSL {QuotePosixShellValue(checksumUrl)} -o \"$tmp_dir/{scriptName}.sha256\" && " - + "( cd \"$tmp_dir\" && " - + $"if command -v sha256sum >/dev/null 2>&1; then sha256sum -c {scriptName}.sha256; " - + $"elif command -v shasum >/dev/null 2>&1; then shasum -a 256 -c {scriptName}.sha256; " - + $"else echo 'sha256sum or shasum is required to verify {scriptName}' >&2; exit 1; fi ) && " - + $"{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}={QuotePosixShellValue(releaseTag)} sh \"$tmp_dir/{scriptName}\""; + + "( if command -v sha256sum >/dev/null 2>&1; then actual_digest=$(sha256sum \"$tmp_dir/" + scriptName + "\" | awk '{print $1}'); " + + "elif command -v shasum >/dev/null 2>&1; then actual_digest=$(shasum -a 256 \"$tmp_dir/" + scriptName + "\" | awk '{print $1}'); " + + $"else echo 'sha256sum or shasum is required to verify {scriptName}' >&2; exit 1; fi && " + + $"[ \"$actual_digest\" = {QuotePosixShellValue(expectedDigest)} ] ) && " + + $"{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}={QuotePosixShellValue(releaseTag)} " + + $"{CliConstants.ARCHIVE_MANIFEST_ENVIRONMENT_VARIABLE}={QuotePosixShellValue(archiveManifest)} sh \"$tmp_dir/{scriptName}\""; } - private static string BuildPosixLocalInstallScriptCommand(string scriptPath, string releaseTag) + private static string BuildPosixLocalInstallScriptCommand(string scriptPath, string releaseTag, string archiveManifest) { UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(scriptPath), "scriptPath must not be null or empty"); UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(releaseTag), "releaseTag must not be null or empty"); - return $"{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}={QuotePosixShellValue(releaseTag)} sh {QuotePosixShellValue(scriptPath)}"; + return $"{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}={QuotePosixShellValue(releaseTag)} " + + $"{CliConstants.ARCHIVE_MANIFEST_ENVIRONMENT_VARIABLE}={QuotePosixShellValue(archiveManifest)} sh {QuotePosixShellValue(scriptPath)}"; } internal static string BuildLoginShellPosixInstallScriptCommand(string posixCommand) @@ -122,32 +129,26 @@ internal static string BuildLoginShellPosixInstallScriptCommand(string posixComm return $"{CliConstants.POSIX_SHELL_EXECUTABLE_PATH} -c {QuotePosixShellValue(posixCommand)}"; } - private static string BuildWindowsRemoteInstallScriptCommand(string scriptUrl, string checksumUrl, string releaseTag) + private static string BuildWindowsRemoteInstallScriptCommand(string scriptUrl, string expectedDigest, string releaseTag, string archiveManifest) { UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(scriptUrl), "scriptUrl must not be null or empty"); - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(checksumUrl), "checksumUrl must not be null or empty"); + UnityEngine.Debug.Assert(IsSHA256Digest(expectedDigest), "expectedDigest must be a SHA-256 digest"); UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(releaseTag), "releaseTag must not be null or empty"); - // Why: Downloading with Invoke-WebRequest to a file and re-launching PowerShell with -File - // replaces the previous `irm | iex` streaming path so the script is verified before it runs - // and so the child process owns exit-code propagation. `$ErrorActionPreference = 'Stop'` - // upgrades cmdlet non-terminating errors (e.g. missing checksum file) into throws so the - // fail-close path does not depend on incidental null dereferences downstream. The .sha256 - // file contains " ", so the leading whitespace-delimited token is - // compared as lower case against Get-FileHash's upper-case output. + // Why: Downloading to a file and running it with -File avoids the previous streaming + // execution path. The trusted package pin supplies the expected digest, so the downloaded + // script is never executed until the locally calculated SHA-256 matches that pin. string scriptName = CliConstants.WINDOWS_INSTALL_SCRIPT_NAME; return "$tmp_dir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString())) -Force; " + $"$script_path = Join-Path $tmp_dir.FullName {QuotePowerShellSingleQuotedValue(scriptName)}; " - + $"$checksum_path = Join-Path $tmp_dir.FullName {QuotePowerShellSingleQuotedValue(scriptName + ".sha256")}; " + "try { " + "$ErrorActionPreference = 'Stop'; " + "$ProgressPreference = 'SilentlyContinue'; " + $"Invoke-WebRequest -UseBasicParsing -Uri {QuotePowerShellSingleQuotedValue(scriptUrl)} -OutFile $script_path; " - + $"Invoke-WebRequest -UseBasicParsing -Uri {QuotePowerShellSingleQuotedValue(checksumUrl)} -OutFile $checksum_path; " - + "$expected_hash = ((Get-Content -Raw -Encoding UTF8 $checksum_path) -split '\\s+')[0].ToLowerInvariant(); " + "$actual_hash = (Get-FileHash -Algorithm SHA256 -Path $script_path).Hash.ToLowerInvariant(); " - + $"if ($actual_hash -ne $expected_hash) {{ throw ('Checksum mismatch for {scriptName}: expected=' + $expected_hash + ' actual=' + $actual_hash) }}; " + + $"if ($actual_hash -ne {QuotePowerShellSingleQuotedValue(expectedDigest)}) {{ throw ('Pinned digest mismatch for {scriptName}: actual=' + $actual_hash) }}; " + $"$env:{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE} = {QuotePowerShellSingleQuotedValue(releaseTag)}; " + + $"$env:{CliConstants.ARCHIVE_MANIFEST_ENVIRONMENT_VARIABLE} = {BuildPowerShellManifestExpression(archiveManifest)}; " + "& powershell -NoProfile -ExecutionPolicy Bypass -File $script_path; " + $"if ($LASTEXITCODE -ne 0) {{ throw ('{scriptName} exited with code ' + $LASTEXITCODE) }} " + "} finally { " @@ -155,12 +156,13 @@ private static string BuildWindowsRemoteInstallScriptCommand(string scriptUrl, s + "}"; } - private static string BuildWindowsLocalInstallScriptCommand(string scriptPath, string releaseTag) + private static string BuildWindowsLocalInstallScriptCommand(string scriptPath, string releaseTag, string archiveManifest) { UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(scriptPath), "scriptPath must not be null or empty"); UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(releaseTag), "releaseTag must not be null or empty"); return $"$env:{CliConstants.INSTALL_VERSION_ENVIRONMENT_VARIABLE}={QuotePowerShellSingleQuotedValue(releaseTag)}; " + + $"$env:{CliConstants.ARCHIVE_MANIFEST_ENVIRONMENT_VARIABLE}={BuildPowerShellManifestExpression(archiveManifest)}; " + $"& {QuotePowerShellSingleQuotedValue(scriptPath)}"; } @@ -206,27 +208,38 @@ private static string QuotePowerShellSingleQuotedValue(string value) return $"'{value.Replace("'", "''")}'"; } + private static string BuildPowerShellManifestExpression(string archiveManifest) + { + UnityEngine.Debug.Assert(!archiveManifest.Contains("\r"), "archiveManifest must use LF line endings"); + string[] entries = archiveManifest.Split('\n'); + return string.Join(" + [char]10 + ", System.Array.ConvertAll(entries, QuotePowerShellSingleQuotedValue)); + } + private static string QuoteProcessArgument(string value) { UnityEngine.Debug.Assert(value != null, "value must not be null"); return $"\"{value.Replace("\"", "\\\"")}\""; } - private static string BuildReleaseTag(string cliReleaseTag) + private static string GetManifestDigest(string archiveManifest, string assetName) { - if (cliReleaseTag.StartsWith(CliConstants.DISPATCHER_RELEASE_TAG_PREFIX, StringComparison.Ordinal)) - { - return cliReleaseTag; - } - if (cliReleaseTag.StartsWith(CliConstants.PROJECT_RUNNER_RELEASE_TAG_PREFIX, StringComparison.Ordinal)) + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(archiveManifest), "archiveManifest must not be null or empty"); + UnityEngine.Debug.Assert(IsSafeAssetName(assetName), "assetName must be safe for command construction"); + string expectedSuffix = " " + assetName; + string[] entries = archiveManifest.Split('\n'); + foreach (string entry in entries) { - return $"{CliConstants.DISPATCHER_RELEASE_TAG_PREFIX}{cliReleaseTag.Substring(CliConstants.PROJECT_RUNNER_RELEASE_TAG_PREFIX.Length)}"; + if (!entry.EndsWith(expectedSuffix, StringComparison.Ordinal)) + { + continue; + } + string digest = entry.Substring(0, entry.Length - expectedSuffix.Length); + if (IsSHA256Digest(digest)) + { + return digest.ToLowerInvariant(); + } } - if (cliReleaseTag.StartsWith(CliConstants.RELEASE_TAG_PREFIX, StringComparison.Ordinal)) - { - return $"{CliConstants.DISPATCHER_RELEASE_TAG_PREFIX}{cliReleaseTag.Substring(CliConstants.RELEASE_TAG_PREFIX.Length)}"; - } - return $"{CliConstants.DISPATCHER_RELEASE_TAG_PREFIX}{cliReleaseTag}"; + throw new ArgumentException($"dispatcher archive manifest is missing a valid digest for {assetName}", nameof(archiveManifest)); } internal static string BuildInstallerScriptUrl(string releaseTag, string assetName) @@ -234,17 +247,41 @@ internal static string BuildInstallerScriptUrl(string releaseTag, string assetNa UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(releaseTag), "releaseTag must not be null or empty"); UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(assetName), "assetName must not be null or empty"); - // Why: Installer scripts are shipped as dispatcher release assets alongside their .sha256 - // sidecars, so the release download URL is used instead of raw content refs to keep the - // Unity install path in sync with `uloop update`'s verified installer download. + // Why: The immutable dispatcher Release URL avoids mutable branch content while the package + // pin authenticates the downloaded asset by its SHA-256 digest. return $"{CliConstants.RELEASE_DOWNLOAD_BASE_URL}/{releaseTag}/{assetName}"; } - internal static string BuildInstallerChecksumUrl(string scriptUrl) + private static bool IsSHA256Digest(string value) { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(scriptUrl), "scriptUrl must not be null or empty"); + if (value == null || value.Length != 64) + { + return false; + } + foreach (char character in value) + { + if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F'))) + { + return false; + } + } + return true; + } - return scriptUrl + ".sha256"; + private static bool IsSafeAssetName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + foreach (char character in value) + { + if (!(char.IsLetterOrDigit(character) || character == '.' || character == '-' || character == '_')) + { + return false; + } + } + return true; } } } diff --git a/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs b/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs index 7913756b4a..55ffcba7b4 100644 --- a/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs +++ b/Packages/src/Editor/Infrastructure/CLI/NativeCliInstaller.cs @@ -19,12 +19,14 @@ public static class NativeCliInstaller public static NativeCliInstallCommand GetInstallCommand( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, bool removeLegacyLaunchers) { return NativeCliCommandBuilder.BuildInstallCommandWithPackagePath( platform, - cliReleaseTag, + dispatcherReleaseTag, + dispatcherArchiveManifest, removeLegacyLaunchers, NodeEnvironmentResolver.GetUserShell(), UnityCliLoopConstants.PackageResolvedPath); @@ -32,10 +34,12 @@ public static NativeCliInstallCommand GetInstallCommand( public static async Task InstallAsync( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, CancellationToken ct) { - UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(cliReleaseTag), "cliReleaseTag must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(dispatcherReleaseTag), "dispatcherReleaseTag must not be null or empty"); + UnityEngine.Debug.Assert(!string.IsNullOrWhiteSpace(dispatcherArchiveManifest), "dispatcherArchiveManifest must not be null or empty"); ct.ThrowIfCancellationRequested(); string installDirectory = NativeCliInstallPathResolver.GetInstallDirectoryForCurrentUser(platform); @@ -46,7 +50,11 @@ public static async Task InstallAsync( $"Could not resolve the global CLI install directory. Set {CliConstants.INSTALL_DIR_ENVIRONMENT_VARIABLE} and try again."); } - NativeCliInstallCommand command = GetInstallCommand(platform, cliReleaseTag, true); + NativeCliInstallCommand command = GetInstallCommand( + platform, + dispatcherReleaseTag, + dispatcherArchiveManifest, + true); CliInstallResult result = await Task.Run( () => NativeCliSetupCommandRunner.RunInstallCommand(command, ct, INSTALL_PROCESS_TIMEOUT_MS), ct); @@ -174,11 +182,12 @@ public bool HasPackageOwnedCurrentUserInstall(RuntimePlatform platform) public Task InstallGlobalCliAsync( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, CancellationToken ct) { ct.ThrowIfCancellationRequested(); - return NativeCliInstaller.InstallAsync(platform, cliReleaseTag, ct); + return NativeCliInstaller.InstallAsync(platform, dispatcherReleaseTag, dispatcherArchiveManifest, ct); } public Task UninstallGlobalCliAsync(RuntimePlatform platform, CancellationToken ct) @@ -198,12 +207,18 @@ public CliPathSetupApplyResult ApplyGlobalCliPathSetup(CliPathSetupPlan plan) return CliPathSetupWriter.ApplyToFileSystem(plan); } - public NativeCliInstallCommand GetGlobalCliInstallCommand( + public NativeCliInstallCommandLoadResult GetGlobalCliInstallCommand( RuntimePlatform platform, - string cliReleaseTag, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, bool removeLegacyLaunchers) { - return NativeCliInstaller.GetInstallCommand(platform, cliReleaseTag, removeLegacyLaunchers); + NativeCliInstallCommand command = NativeCliInstaller.GetInstallCommand( + platform, + dispatcherReleaseTag, + dispatcherArchiveManifest, + removeLegacyLaunchers); + return NativeCliInstallCommandLoadResult.FromSuccess(command); } } } diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardCliWorkflowController.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardCliWorkflowController.cs index d46677f1d1..b2d2caba3a 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardCliWorkflowController.cs +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardCliWorkflowController.cs @@ -119,13 +119,16 @@ private async Task HandleInstallCliAsync(CancellationToken ct) if (!result.Success) { - NativeCliInstallCommand command = _cliSetupApplicationService.GetGlobalCliInstallCommand( + NativeCliInstallCommandLoadResult commandResult = _cliSetupApplicationService.GetGlobalCliInstallCommand( UnityEngine.Application.platform, true); + string manualInstallGuidance = commandResult.Success + ? commandResult.Command.ManualCommand + : commandResult.ErrorOutput; EditorUtility.DisplayDialog( "Installation Failed", $"Failed to install uloop CLI.\n\n{result.ErrorOutput}\n\n" - + $"You can install manually:\n {command.ManualCommand}", + + manualInstallGuidance, "OK"); return; } diff --git a/Packages/src/Editor/Presentation/UnityCliLoopSettingsCliSetupPresenter.cs b/Packages/src/Editor/Presentation/UnityCliLoopSettingsCliSetupPresenter.cs index 3e4455568f..08298a8480 100644 --- a/Packages/src/Editor/Presentation/UnityCliLoopSettingsCliSetupPresenter.cs +++ b/Packages/src/Editor/Presentation/UnityCliLoopSettingsCliSetupPresenter.cs @@ -291,12 +291,15 @@ internal async Task HandleInstallCli() if (!result.Success) { - NativeCliInstallCommand command = _cliSetupApplicationService.GetGlobalCliInstallCommand( + NativeCliInstallCommandLoadResult commandResult = _cliSetupApplicationService.GetGlobalCliInstallCommand( UnityEngine.Application.platform, true); + string manualInstallGuidance = commandResult.Success + ? commandResult.Command.ManualCommand + : commandResult.ErrorOutput; EditorUtility.DisplayDialog( "Installation Failed", - $"Failed to install uLoop CLI.\n\n{result.ErrorOutput}\n\nYou can try manually:\n{command.ManualCommand}", + $"Failed to install uLoop CLI.\n\n{result.ErrorOutput}\n\n{manualInstallGuidance}", "OK"); return; } From 79cc400623f12022b138eff374adf08396f44129 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 16:42:04 +0900 Subject: [PATCH 09/13] Document pinned dispatcher release verification Report installer source drift without blocking the release required to refresh the package pin, and document the immutable CLI bootstrap and package release order. --- README.md | 24 ++++++++++- README_ja.md | 22 +++++++++- SECURITY.md | 41 +++++++++++++++++++ .../cmd/check-dispatcher-pin/main.go | 22 ++++++++++ .../automation/dispatcher_pin_guard.go | 29 +++++++++++++ .../automation/dispatcher_pin_guard_test.go | 14 +++++++ docs/dispatcher-pin-release-order.md | 16 ++++++++ 7 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 docs/dispatcher-pin-release-order.md diff --git a/README.md b/README.md index d7fe4e76af..d6b35c8a2d 100644 --- a/README.md +++ b/README.md @@ -82,16 +82,36 @@ To return to the v2 line, press **Uninstall CLI** in Settings, downgrade the U-L Use this only when you want to install the standalone global CLI without opening Unity package setup. +Install `gh` through your operating-system or package channel first. The bootstrap does not install +or fall back from `gh`. Select the immutable dispatcher Release tag and its source branch; use +`refs/heads/main` for a main release or `refs/heads/v3-beta` for a v3-beta release. + On macOS or Windows Git Bash: ```bash -curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh +REPOSITORY=hatayama/unity-cli-loop +RELEASE_TAG=dispatcher-v +SOURCE_REF=refs/heads/v3-beta +tmp_dir=$(mktemp -d) +gh release download "$RELEASE_TAG" --repo "$REPOSITORY" --pattern 'install.sh' --pattern 'install.sh.sigstore.json' --dir "$tmp_dir" && \ +tag_sha=$(gh api "repos/$REPOSITORY/commits/$RELEASE_TAG" --jq .sha) && \ +gh attestation verify "$tmp_dir/install.sh" --bundle "$tmp_dir/install.sh.sigstore.json" --repo "$REPOSITORY" --signer-workflow "$REPOSITORY/.github/workflows/dispatcher-publish.yml" --source-ref "$SOURCE_REF" --source-digest "$tag_sha" && \ +ULOOP_VERSION="$RELEASE_TAG" sh "$tmp_dir/install.sh" ``` On Windows PowerShell: ```powershell -irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex +$repository = 'hatayama/unity-cli-loop' +$releaseTag = 'dispatcher-v' +$sourceRef = 'refs/heads/v3-beta' +$temporaryDirectory = New-Item -ItemType Directory -Force -Path (Join-Path $env:TEMP ([guid]::NewGuid())) +gh release download $releaseTag --repo $repository --pattern 'install.ps1' --pattern 'install.ps1.sigstore.json' --dir $temporaryDirectory.FullName +$tagSha = gh api "repos/$repository/commits/$releaseTag" --jq .sha +gh attestation verify (Join-Path $temporaryDirectory.FullName 'install.ps1') --bundle (Join-Path $temporaryDirectory.FullName 'install.ps1.sigstore.json') --repo $repository --signer-workflow "$repository/.github/workflows/dispatcher-publish.yml" --source-ref $sourceRef --source-digest $tagSha +if ($LASTEXITCODE -ne 0) { throw 'Installer attestation verification failed.' } +$env:ULOOP_VERSION = $releaseTag +& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $temporaryDirectory.FullName 'install.ps1') ``` After installing the native CLI, the installer automatically tries to remove the old npm package with `npm uninstall -g uloop-cli`. diff --git a/README_ja.md b/README_ja.md index 95619b5455..baff216182 100644 --- a/README_ja.md +++ b/README_ja.md @@ -81,16 +81,34 @@ v2系に戻したい場合は、Settings で **Uninstall CLI** を押し、Unity Unity Package の setup を開かず、standalone の global CLI だけを入れたい場合に使ってください。 +最初にOSまたはパッケージ管理経由で`gh`を導入してください。bootstrapは`gh`を導入せず、代替手段にもフォールバックしません。immutableなdispatcher Release tagとsource branchを選択します。mainのReleaseは`refs/heads/main`、v3-betaのReleaseは`refs/heads/v3-beta`を指定してください。 + macOS、Windows Git Bash の場合: ```bash -curl -fsSL https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.sh | sh +REPOSITORY=hatayama/unity-cli-loop +RELEASE_TAG=dispatcher-v +SOURCE_REF=refs/heads/v3-beta +tmp_dir=$(mktemp -d) +gh release download "$RELEASE_TAG" --repo "$REPOSITORY" --pattern 'install.sh' --pattern 'install.sh.sigstore.json' --dir "$tmp_dir" && \ +tag_sha=$(gh api "repos/$REPOSITORY/commits/$RELEASE_TAG" --jq .sha) && \ +gh attestation verify "$tmp_dir/install.sh" --bundle "$tmp_dir/install.sh.sigstore.json" --repo "$REPOSITORY" --signer-workflow "$REPOSITORY/.github/workflows/dispatcher-publish.yml" --source-ref "$SOURCE_REF" --source-digest "$tag_sha" && \ +ULOOP_VERSION="$RELEASE_TAG" sh "$tmp_dir/install.sh" ``` Windows PowerShell の場合: ```powershell -irm https://raw.githubusercontent.com/hatayama/unity-cli-loop/main/scripts/install.ps1 | iex +$repository = 'hatayama/unity-cli-loop' +$releaseTag = 'dispatcher-v' +$sourceRef = 'refs/heads/v3-beta' +$temporaryDirectory = New-Item -ItemType Directory -Force -Path (Join-Path $env:TEMP ([guid]::NewGuid())) +gh release download $releaseTag --repo $repository --pattern 'install.ps1' --pattern 'install.ps1.sigstore.json' --dir $temporaryDirectory.FullName +$tagSha = gh api "repos/$repository/commits/$releaseTag" --jq .sha +gh attestation verify (Join-Path $temporaryDirectory.FullName 'install.ps1') --bundle (Join-Path $temporaryDirectory.FullName 'install.ps1.sigstore.json') --repo $repository --signer-workflow "$repository/.github/workflows/dispatcher-publish.yml" --source-ref $sourceRef --source-digest $tagSha +if ($LASTEXITCODE -ne 0) { throw 'Installer attestation verification failed.' } +$env:ULOOP_VERSION = $releaseTag +& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $temporaryDirectory.FullName 'install.ps1') ``` native CLI のインストール後、installer は古い npm package を `npm uninstall -g uloop-cli` で自動削除しようとします。 diff --git a/SECURITY.md b/SECURITY.md index f7a9f92da9..fc1d3f193f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,6 +15,47 @@ When reporting a vulnerability, please include: ## Trust Model and Known Risks +### First-install provenance decision + +The repository owner approved the following first-install trust model. + +- CLI-only installation requires a preinstalled GitHub CLI (`gh`) obtained + through the user's operating-system or package channel. The installer never + downloads or bootstraps `gh`. The README bootstrap downloads the installer + script and its Sigstore bundle from an immutable dispatcher Release, verifies + the script with `gh attestation verify`, and only then executes it. The + verified installer verifies the selected archive and its bundle before + extraction or execution. A missing `gh` is a hard failure. +- Unity installation treats the already-installed Unity package as its trust + root. Package-release automation verifies the dispatcher Release attestation + and stamps its verified subject digests into the package pin. The device + enforces those digests by SHA-256 pinning before it executes a downloaded + installer script, so Unity installation does not require external `gh` or an + embedded verifier. +- CLI-only verification enforces the exact repository, signer workflow + `.github/workflows/dispatcher-publish.yml`, an allowed source ref of + `refs/heads/main` or `refs/heads/v3-beta`, an attested source digest equal to + the resolved immutable Release tag commit, and a subject digest equal to the + downloaded installer or archive. Package-release automation verifies that + same policy before it stamps Unity's pinned subject digests. A same-origin + checksum provides integrity only and is never authentication. +- Offline first installation is unsupported. Network or GitHub API failure, + missing verifier, missing or malformed bundle, identity mismatch, tag + mismatch, and digest mismatch all fail closed before script or binary + execution. There is no checksum-only fallback and no mutable-branch + `curl | sh` or `irm | iex` path. + +OS-native signing remains a later defense-in-depth release improvement; it is +not a prerequisite for this bootstrap verification work. + +Dispatcher Release assets referenced by a package pin must remain available +permanently. Package releases depend on the pinned dispatcher installer in the +same way that they depend on their pinned project-runner release. Release order +is dispatcher publish, `stamp-dispatcher-pin`, then package release. If a +dispatcher release must be revoked, publish a replacement, raise the package's +`minimumDispatcherVersion`, stamp the replacement release, and publish a new +package; never silently repoint or replace an existing Release asset. + ### Untrusted Unity projects can drive the runner version At runtime `uloop` looks up the project-runner pin from the current diff --git a/cli/release-automation/cmd/check-dispatcher-pin/main.go b/cli/release-automation/cmd/check-dispatcher-pin/main.go index d6fe39eae8..97587e22c5 100644 --- a/cli/release-automation/cmd/check-dispatcher-pin/main.go +++ b/cli/release-automation/cmd/check-dispatcher-pin/main.go @@ -33,6 +33,7 @@ func main() { fmt.Fprintln(os.Stderr, "check-dispatcher-pin:", err) os.Exit(1) } + warnIfDispatcherInstallerScriptsDrift(packagePin) shouldVerifyNetwork := *network if *baseRef != "" { shouldVerifyNetwork = dispatcherPinNetworkVerificationRequired(*baseRef, *headRef) @@ -52,6 +53,27 @@ func main() { fmt.Println("Dispatcher pin guard passed.") } +func warnIfDispatcherInstallerScriptsDrift(packagePin []byte) { + scripts := map[string][]byte{} + for _, scriptName := range []string{"install.sh", "install.ps1"} { + scriptPath := filepath.Join("..", "..", "scripts", scriptName) + scriptData, err := os.ReadFile(scriptPath) + if err != nil { + fmt.Fprintln(os.Stderr, "check-dispatcher-pin: unable to compare installer source drift:", err) + return + } + scripts[scriptName] = scriptData + } + warnings, err := automation.DispatcherPinScriptDriftWarnings(packagePin, scripts) + if err != nil { + fmt.Fprintln(os.Stderr, "check-dispatcher-pin: unable to compare installer source drift:", err) + return + } + for _, warning := range warnings { + fmt.Fprintln(os.Stderr, "check-dispatcher-pin: review warning:", warning) + } +} + func dispatcherPinNetworkVerificationRequired(baseRef string, headRef string) bool { command := exec.Command("git", "diff", "--name-only", baseRef+".."+headRef) output, err := command.Output() diff --git a/cli/release-automation/internal/automation/dispatcher_pin_guard.go b/cli/release-automation/internal/automation/dispatcher_pin_guard.go index e1c0b3570b..aa50fd1569 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_guard.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_guard.go @@ -3,6 +3,7 @@ package automation import ( "bytes" "context" + "crypto/sha256" "encoding/json" "fmt" "strings" @@ -57,6 +58,34 @@ func VerifyDispatcherPinSubjects(ctx context.Context, packagePin []byte) error { return verifyDispatcherPinSubjects(ctx, packagePin, defaultDispatcherPinStampDeps()) } +// DispatcherPinScriptDriftWarnings reports source scripts that await a later dispatcher release and pin stamp. +func DispatcherPinScriptDriftWarnings(packagePin []byte, scripts map[string][]byte) ([]string, error) { + values := dispatcherPinGuardValues{} + if err := json.Unmarshal(packagePin, &values); err != nil { + return nil, fmt.Errorf("%s is invalid JSON: %w", unityPackageCliPinFile, err) + } + manifestDigests := make(map[string]string) + for _, entry := range strings.Split(values.DispatcherArchiveManifest, "\n") { + digest, name, ok := strings.Cut(entry, " ") + if !ok { + return nil, fmt.Errorf("%s has an invalid dispatcherArchiveManifest entry", unityPackageCliPinFile) + } + manifestDigests[name] = strings.ToLower(digest) + } + warnings := make([]string, 0) + for _, scriptName := range []string{dispatcherPinStampInstallerScript, dispatcherPinStampPowerShellScript} { + scriptData, exists := scripts[scriptName] + if !exists { + return nil, fmt.Errorf("source installer %q is unavailable", scriptName) + } + actualDigest := fmt.Sprintf("%x", sha256.Sum256(scriptData)) + if manifestDigests[scriptName] != actualDigest { + warnings = append(warnings, fmt.Sprintf("pin release %s has a different %s; Unity first install uses the pinned script until the next dispatcher release and pin stamp", values.DispatcherReleaseTag, scriptName)) + } + } + return warnings, nil +} + func verifyDispatcherPinSubjects( ctx context.Context, packagePin []byte, diff --git a/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go b/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go index 9f76af8b9a..cb685bfed0 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go @@ -99,6 +99,20 @@ func TestVerifyDispatcherPinSubjectsFailsClosedWhenReleaseLookupFails(t *testing } } +func TestDispatcherPinScriptDriftWarningsReportsButDoesNotFailForChangedSourceScript(t *testing.T) { + // Verifies installer source drift is review information until a subsequent release can carry the new digest. + warnings, err := DispatcherPinScriptDriftWarnings(validDispatcherPinGuardFixture(), map[string][]byte{ + "install.sh": []byte("changed"), + "install.ps1": []byte("changed"), + }) + if err != nil { + t.Fatalf("DispatcherPinScriptDriftWarnings failed: %v", err) + } + if len(warnings) != 2 { + t.Fatalf("warning count = %d, want 2", len(warnings)) + } +} + func validDispatcherPinGuardDeps() dispatcherPinStampDeps { return dispatcherPinStampDeps{ fetchReleaseAssets: func(context.Context, string) ([]dispatcherReleaseAsset, error) { diff --git a/docs/dispatcher-pin-release-order.md b/docs/dispatcher-pin-release-order.md new file mode 100644 index 0000000000..868d878b42 --- /dev/null +++ b/docs/dispatcher-pin-release-order.md @@ -0,0 +1,16 @@ +# Dispatcher pin release order + +The Unity package uses a provenance-pinned dispatcher Release for first +installation. Package release preparation must follow this order: + +1. Publish the dispatcher Release and its Sigstore attestations. +2. Run `stamp-dispatcher-pin` against that immutable Release. +3. Verify the resulting pin with `check-dispatcher-pin` and publish the Unity + package. + +Changing `scripts/install.sh` or `scripts/install.ps1` does not immediately +change the Unity first-install path. Unity downloads the script from the +Release named in its package pin. The change becomes active only after a new +dispatcher Release is published and a later package release stamps that +Release. The pin guard reports source-script drift for review but does not +block the dispatcher Release that is required to resolve it. From 3b127119640442ec6812dbe954b9544729378c85 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 16:44:16 +0900 Subject: [PATCH 10/13] Document pinned dispatcher revocation limits Clarify that digest pinning authenticates a release but cannot revoke an older published dispatcher, and record the required minimum-version remediation. --- SECURITY.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index fc1d3f193f..35cf3f7144 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,6 +56,16 @@ dispatcher release must be revoked, publish a replacement, raise the package's `minimumDispatcherVersion`, stamp the replacement release, and publish a new package; never silently repoint or replace an existing Release asset. +### Pinned dispatcher release lifetime + +A package pin can continue to authenticate an older dispatcher Release after +that dispatcher has a known vulnerability. Pinning proves provenance and +integrity; it does not revoke already published content. The mitigation is the +package's minimum dispatcher version gate: publish a fixed dispatcher, raise +the minimum, stamp its digests, and publish an updated package. Treat Unity +projects from untrusted sources as untrusted inputs because they can select +their own pin and minimum-version requirements. + ### Untrusted Unity projects can drive the runner version At runtime `uloop` looks up the project-runner pin from the current From 1d01e1cf199885c2e6025d6bd59ea2834f9e8dae Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 16:50:10 +0900 Subject: [PATCH 11/13] Fix release filter fixture for manifest enforcement Provide the authenticated archive manifest required by the installer fixture so release selection tests continue to exercise their intended behavior. --- scripts/test-install-release-filter.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/test-install-release-filter.sh b/scripts/test-install-release-filter.sh index 789ea6a50e..5331eec1a3 100755 --- a/scripts/test-install-release-filter.sh +++ b/scripts/test-install-release-filter.sh @@ -4,6 +4,9 @@ set -eu ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) TMP_DIR=$(mktemp -d) ORIGINAL_PATH=$PATH +ULOOP_ARCHIVE_MANIFEST='fakehash uloop-dispatcher-darwin-arm64.tar.gz +fakehash uloop-dispatcher-windows-amd64.zip' +export ULOOP_ARCHIVE_MANIFEST cleanup() { rm -rf "$TMP_DIR" From 088e21e591286a210cbb42c8dc71eb2161fdd218 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 17:05:38 +0900 Subject: [PATCH 12/13] Fix documented bootstrap manifest handoff Derive the archive manifest from the verified release bundle before executing the fail-closed installer, and cover that README bootstrap transformation. --- .github/workflows/build-and-test.yml | 1 + README.md | 9 ++++++++- README_ja.md | 9 ++++++++- .../test-bootstrap-manifest-from-bundle.sh | 19 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100755 scripts/test-bootstrap-manifest-from-bundle.sh diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index dfe1092614..99a373e2ad 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -50,6 +50,7 @@ jobs: scripts/test-dispatcher-publish-workflow.sh scripts/test-distribute-attestation-bundles.sh scripts/test-install-release-filter.sh + scripts/test-bootstrap-manifest-from-bundle.sh - name: Check IPC protocol reminder if: github.event_name == 'pull_request' diff --git a/README.md b/README.md index d6b35c8a2d..8fde72acbe 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ tmp_dir=$(mktemp -d) gh release download "$RELEASE_TAG" --repo "$REPOSITORY" --pattern 'install.sh' --pattern 'install.sh.sigstore.json' --dir "$tmp_dir" && \ tag_sha=$(gh api "repos/$REPOSITORY/commits/$RELEASE_TAG" --jq .sha) && \ gh attestation verify "$tmp_dir/install.sh" --bundle "$tmp_dir/install.sh.sigstore.json" --repo "$REPOSITORY" --signer-workflow "$REPOSITORY/.github/workflows/dispatcher-publish.yml" --source-ref "$SOURCE_REF" --source-digest "$tag_sha" && \ -ULOOP_VERSION="$RELEASE_TAG" sh "$tmp_dir/install.sh" +manifest=$(jq -r '.dsseEnvelope.payload | @base64d | fromjson | .subject[] | "\(.digest.sha256) \(.name)"' "$tmp_dir/install.sh.sigstore.json" | LC_ALL=C sort) && \ +ULOOP_VERSION="$RELEASE_TAG" ULOOP_ARCHIVE_MANIFEST="$manifest" sh "$tmp_dir/install.sh" ``` On Windows PowerShell: @@ -107,10 +108,16 @@ $releaseTag = 'dispatcher-v' $sourceRef = 'refs/heads/v3-beta' $temporaryDirectory = New-Item -ItemType Directory -Force -Path (Join-Path $env:TEMP ([guid]::NewGuid())) gh release download $releaseTag --repo $repository --pattern 'install.ps1' --pattern 'install.ps1.sigstore.json' --dir $temporaryDirectory.FullName +if ($LASTEXITCODE -ne 0) { throw 'Installer download failed.' } $tagSha = gh api "repos/$repository/commits/$releaseTag" --jq .sha +if ($LASTEXITCODE -ne 0) { throw 'Release tag resolution failed.' } gh attestation verify (Join-Path $temporaryDirectory.FullName 'install.ps1') --bundle (Join-Path $temporaryDirectory.FullName 'install.ps1.sigstore.json') --repo $repository --signer-workflow "$repository/.github/workflows/dispatcher-publish.yml" --source-ref $sourceRef --source-digest $tagSha if ($LASTEXITCODE -ne 0) { throw 'Installer attestation verification failed.' } +$bundle = Get-Content -Raw -Encoding UTF8 (Join-Path $temporaryDirectory.FullName 'install.ps1.sigstore.json') | ConvertFrom-Json +$statement = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($bundle.dsseEnvelope.payload)) | ConvertFrom-Json +$manifest = [string]::Join("`n", @($statement.subject | ForEach-Object { "$($_.digest.sha256) $($_.name)" } | Sort-Object)) $env:ULOOP_VERSION = $releaseTag +$env:ULOOP_ARCHIVE_MANIFEST = $manifest & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $temporaryDirectory.FullName 'install.ps1') ``` diff --git a/README_ja.md b/README_ja.md index baff216182..2b6df78751 100644 --- a/README_ja.md +++ b/README_ja.md @@ -93,7 +93,8 @@ tmp_dir=$(mktemp -d) gh release download "$RELEASE_TAG" --repo "$REPOSITORY" --pattern 'install.sh' --pattern 'install.sh.sigstore.json' --dir "$tmp_dir" && \ tag_sha=$(gh api "repos/$REPOSITORY/commits/$RELEASE_TAG" --jq .sha) && \ gh attestation verify "$tmp_dir/install.sh" --bundle "$tmp_dir/install.sh.sigstore.json" --repo "$REPOSITORY" --signer-workflow "$REPOSITORY/.github/workflows/dispatcher-publish.yml" --source-ref "$SOURCE_REF" --source-digest "$tag_sha" && \ -ULOOP_VERSION="$RELEASE_TAG" sh "$tmp_dir/install.sh" +manifest=$(jq -r '.dsseEnvelope.payload | @base64d | fromjson | .subject[] | "\(.digest.sha256) \(.name)"' "$tmp_dir/install.sh.sigstore.json" | LC_ALL=C sort) && \ +ULOOP_VERSION="$RELEASE_TAG" ULOOP_ARCHIVE_MANIFEST="$manifest" sh "$tmp_dir/install.sh" ``` Windows PowerShell の場合: @@ -104,10 +105,16 @@ $releaseTag = 'dispatcher-v' $sourceRef = 'refs/heads/v3-beta' $temporaryDirectory = New-Item -ItemType Directory -Force -Path (Join-Path $env:TEMP ([guid]::NewGuid())) gh release download $releaseTag --repo $repository --pattern 'install.ps1' --pattern 'install.ps1.sigstore.json' --dir $temporaryDirectory.FullName +if ($LASTEXITCODE -ne 0) { throw 'Installer download failed.' } $tagSha = gh api "repos/$repository/commits/$releaseTag" --jq .sha +if ($LASTEXITCODE -ne 0) { throw 'Release tag resolution failed.' } gh attestation verify (Join-Path $temporaryDirectory.FullName 'install.ps1') --bundle (Join-Path $temporaryDirectory.FullName 'install.ps1.sigstore.json') --repo $repository --signer-workflow "$repository/.github/workflows/dispatcher-publish.yml" --source-ref $sourceRef --source-digest $tagSha if ($LASTEXITCODE -ne 0) { throw 'Installer attestation verification failed.' } +$bundle = Get-Content -Raw -Encoding UTF8 (Join-Path $temporaryDirectory.FullName 'install.ps1.sigstore.json') | ConvertFrom-Json +$statement = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($bundle.dsseEnvelope.payload)) | ConvertFrom-Json +$manifest = [string]::Join("`n", @($statement.subject | ForEach-Object { "$($_.digest.sha256) $($_.name)" } | Sort-Object)) $env:ULOOP_VERSION = $releaseTag +$env:ULOOP_ARCHIVE_MANIFEST = $manifest & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $temporaryDirectory.FullName 'install.ps1') ``` diff --git a/scripts/test-bootstrap-manifest-from-bundle.sh b/scripts/test-bootstrap-manifest-from-bundle.sh new file mode 100755 index 0000000000..3002c8ea46 --- /dev/null +++ b/scripts/test-bootstrap-manifest-from-bundle.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Verifies the README bootstrap manifest derivation preserves every attested subject. +set -eu + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT INT HUP TERM + +payload='{"subject":[{"name":"install.sh","digest":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},{"name":"uloop-dispatcher-darwin-arm64.tar.gz","digest":{"sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}]}' +encoded_payload=$(printf '%s' "$payload" | base64) +printf '{"dsseEnvelope":{"payload":"%s"}}\n' "$encoded_payload" > "$TMP_DIR/bundle.json" + +manifest=$(jq -r '.dsseEnvelope.payload | @base64d | fromjson | .subject[] | "\(.digest.sha256) \(.name)"' "$TMP_DIR/bundle.json" | LC_ALL=C sort) +expected='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb uloop-dispatcher-darwin-arm64.tar.gz' + +if [ "$manifest" != "$expected" ]; then + echo "bootstrap manifest derivation mismatch" >&2 + exit 1 +fi From 53c7bb670cc0eb803b9fffcd621f77cf619bebbc Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 17:18:04 +0900 Subject: [PATCH 13/13] Fix portable bootstrap bundle fixture --- README.md | 2 +- README_ja.md | 2 +- scripts/test-bootstrap-manifest-from-bundle.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8fde72acbe..a7eac2b59e 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ To return to the v2 line, press **Uninstall CLI** in Settings, downgrade the U-L Use this only when you want to install the standalone global CLI without opening Unity package setup. -Install `gh` through your operating-system or package channel first. The bootstrap does not install +Install `gh` and `jq` through your operating-system or package channel first. The bootstrap does not install or fall back from `gh`. Select the immutable dispatcher Release tag and its source branch; use `refs/heads/main` for a main release or `refs/heads/v3-beta` for a v3-beta release. diff --git a/README_ja.md b/README_ja.md index 2b6df78751..c9825fb3aa 100644 --- a/README_ja.md +++ b/README_ja.md @@ -81,7 +81,7 @@ v2系に戻したい場合は、Settings で **Uninstall CLI** を押し、Unity Unity Package の setup を開かず、standalone の global CLI だけを入れたい場合に使ってください。 -最初にOSまたはパッケージ管理経由で`gh`を導入してください。bootstrapは`gh`を導入せず、代替手段にもフォールバックしません。immutableなdispatcher Release tagとsource branchを選択します。mainのReleaseは`refs/heads/main`、v3-betaのReleaseは`refs/heads/v3-beta`を指定してください。 +最初にOSまたはパッケージ管理経由で`gh`と`jq`を導入してください。bootstrapはこれらを導入せず、代替手段にもフォールバックしません。immutableなdispatcher Release tagとsource branchを選択します。mainのReleaseは`refs/heads/main`、v3-betaのReleaseは`refs/heads/v3-beta`を指定してください。 macOS、Windows Git Bash の場合: diff --git a/scripts/test-bootstrap-manifest-from-bundle.sh b/scripts/test-bootstrap-manifest-from-bundle.sh index 3002c8ea46..8e02cd930a 100755 --- a/scripts/test-bootstrap-manifest-from-bundle.sh +++ b/scripts/test-bootstrap-manifest-from-bundle.sh @@ -6,7 +6,7 @@ TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_DIR"' EXIT INT HUP TERM payload='{"subject":[{"name":"install.sh","digest":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},{"name":"uloop-dispatcher-darwin-arm64.tar.gz","digest":{"sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}]}' -encoded_payload=$(printf '%s' "$payload" | base64) +encoded_payload=$(printf '%s' "$payload" | base64 | tr -d '\n') printf '{"dsseEnvelope":{"payload":"%s"}}\n' "$encoded_payload" > "$TMP_DIR/bundle.json" manifest=$(jq -r '.dsseEnvelope.payload | @base64d | fromjson | .subject[] | "\(.digest.sha256) \(.name)"' "$TMP_DIR/bundle.json" | LC_ALL=C sort)