From 64c9fc3985056b91c7ee3a635464232ebe492376 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 15 Jul 2026 12:02:57 +0900 Subject: [PATCH 1/5] fix: Restrict release publishing to approved commits (#1790) --- .github/workflows/dispatcher-publish.yml | 421 +++++++++++------- .github/workflows/native-cli-publish.yml | 453 ++++++++++++-------- scripts/test-dispatcher-publish-workflow.sh | 286 +++++++----- scripts/test-native-cli-publish-workflow.sh | 252 +++++++---- 4 files changed, 911 insertions(+), 501 deletions(-) diff --git a/.github/workflows/dispatcher-publish.yml b/.github/workflows/dispatcher-publish.yml index f32ddc68c7..d9d212e11b 100644 --- a/.github/workflows/dispatcher-publish.yml +++ b/.github/workflows/dispatcher-publish.yml @@ -7,54 +7,32 @@ on: - v3-beta workflow_dispatch: inputs: - ref: - description: "Git ref to build from (tag, branch, or SHA). Defaults to default branch." - required: false - default: "" dry-run: description: "Dry run (skip release upload)" type: boolean default: false -permissions: - contents: write - id-token: write - attestations: write - artifact-metadata: write - concurrency: group: dispatcher-publish-${{ github.ref }} cancel-in-progress: false jobs: - publish: + build: runs-on: ubuntu-latest - if: github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v3-beta' + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v3-beta' + permissions: + contents: read + outputs: + should_publish: ${{ steps.release.outputs.publish }} + should_release: ${{ steps.release.outputs.release }} + dry_run: ${{ steps.release.outputs.dry_run }} steps: - - name: Resolve checkout ref - id: checkout - env: - EVENT_NAME: ${{ github.event_name }} - EVENT_REF: ${{ github.ref }} - EVENT_SHA: ${{ github.sha }} - INPUT_REF: ${{ inputs.ref }} - run: | - set -eu - - if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then - CHECKOUT_REF="${INPUT_REF:-${EVENT_REF}}" - else - CHECKOUT_REF="${EVENT_SHA}" - fi - - echo "ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT" - echo "Checkout ref: ${CHECKOUT_REF}" - - - name: Checkout repository + - name: Checkout approved event commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: - ref: ${{ steps.checkout.outputs.ref }} + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false - name: Resolve dispatcher release target id: release @@ -63,8 +41,18 @@ jobs: EVENT_REF_NAME: ${{ github.ref_name }} INPUT_DRY_RUN: ${{ inputs.dry-run }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: scripts/resolve-dispatcher-release-target.sh >> "$GITHUB_OUTPUT" + + - name: Verify release target matches build commit + if: steps.release.outputs.publish == 'true' || steps.release.outputs.release == 'true' + env: + TARGET_SHA: ${{ steps.release.outputs.sha }} run: | - scripts/resolve-dispatcher-release-target.sh >> "$GITHUB_OUTPUT" + set -eu + if [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then + echo "Release target ${TARGET_SHA} does not match approved build commit ${GITHUB_SHA}." >&2 + exit 1 + fi - name: Setup Go if: steps.release.outputs.publish == 'true' || steps.release.outputs.release == 'true' @@ -91,181 +79,314 @@ jobs: if: steps.release.outputs.publish == 'true' run: scripts/verify-dispatcher-release-assets.sh - - name: Attest dispatcher release assets - id: attest - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 - with: - subject-path: dist/dispatcher-release/* - - - name: Verify dispatcher asset attestations - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' + - name: Write release metadata + if: steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - SIGNER_WORKFLOW: ${{ github.repository }}/.github/workflows/dispatcher-publish.yml + TARGET_SHA: ${{ steps.release.outputs.sha }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + PRERELEASE: ${{ steps.release.outputs.prerelease }} + SHOULD_PUBLISH: ${{ steps.release.outputs.publish }} + SHOULD_RELEASE: ${{ steps.release.outputs.release }} + DRY_RUN: ${{ steps.release.outputs.dry_run }} run: | set -eu + mkdir -p release-input + { + printf 'build_sha=%s\n' "${GITHUB_SHA}" + printf 'target_sha=%s\n' "${TARGET_SHA}" + printf 'release_tag=%s\n' "${RELEASE_TAG}" + printf 'version=%s\n' "${RELEASE_VERSION}" + printf 'prerelease=%s\n' "${PRERELEASE}" + printf 'publish=%s\n' "${SHOULD_PUBLISH}" + printf 'release=%s\n' "${SHOULD_RELEASE}" + printf 'dry_run=%s\n' "${DRY_RUN}" + printf 'event_ref=%s\n' "${GITHUB_REF}" + } > release-input/release-metadata.env + if [ "${SHOULD_PUBLISH}" = "true" ]; then + shasum -a 256 dist/dispatcher-release/* > release-input/release-assets.manifest + else + : > release-input/release-assets.manifest + fi - for asset_path in dist/dispatcher-release/*; do - gh attestation verify "${asset_path}" \ - --repo "${GITHUB_REPOSITORY}" \ - --signer-workflow "${SIGNER_WORKFLOW}" - done - - - name: Distribute attestation bundles per asset - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' - env: - BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} - run: | - scripts/distribute-attestation-bundles.sh \ - --bundle "${BUNDLE_PATH}" \ - --release-dir dist/dispatcher-release - - - name: Upload dispatcher release assets artifact - if: steps.release.outputs.publish == 'true' + - name: Upload packaged dispatcher assets + if: steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: dispatcher-release-assets-${{ steps.release.outputs.tag }} - path: dist/dispatcher-release/* + name: dispatcher-release-input + path: | + dist/dispatcher-release + release-input if-no-files-found: error + - name: Dry run summary + if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run == 'true' + run: ls -lh dist/dispatcher-release + + publish: + needs: build + if: (needs.build.outputs.should_publish == 'true' || needs.build.outputs.should_release == 'true') && needs.build.outputs.dry_run != 'true' + runs-on: ubuntu-latest + environment: cli-release + permissions: + actions: read + contents: write + id-token: write + attestations: write + artifact-metadata: write + steps: + - name: Download packaged dispatcher assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dispatcher-release-input + path: . + + - name: Verify release metadata + run: | + set -eu + metadata_value() { + key=$1 + value=$(grep "^${key}=" release-input/release-metadata.env | cut -d= -f2-) + if [ -z "${value}" ]; then + echo "Missing release metadata: ${key}." >&2 + exit 1 + fi + printf '%s\n' "${value}" + } + + build_sha=$(metadata_value build_sha) + target_sha=$(metadata_value target_sha) + release_tag=$(metadata_value release_tag) + version=$(metadata_value version) + prerelease=$(metadata_value prerelease) + should_publish=$(metadata_value publish) + should_release=$(metadata_value release) + dry_run=$(metadata_value dry_run) + event_ref=$(metadata_value event_ref) + BUILD_SHA="${build_sha}" + TARGET_SHA="${target_sha}" + if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then + echo "Build SHA and release target must match the approved event commit." >&2 + exit 1 + fi + if [ "${event_ref}" != "refs/heads/main" ] && [ "${event_ref}" != "refs/heads/v3-beta" ]; then + echo "Unsupported release ref ${event_ref}." >&2 + exit 1 + fi + if [ "${release_tag}" != "dispatcher-v${version}" ]; then + echo "Release tag does not match the resolved version." >&2 + exit 1 + fi + validate_boolean() { + field_name=$1 + field_value=$2 + if [ "${field_value}" != "true" ] && [ "${field_value}" != "false" ]; then + echo "Invalid boolean metadata value for ${field_name}: ${field_value}." >&2 + exit 1 + fi + } + validate_boolean prerelease "${prerelease}" + validate_boolean publish "${should_publish}" + validate_boolean release "${should_release}" + validate_boolean dry_run "${dry_run}" + printf 'RELEASE_TAG=%s\n' "${release_tag}" >> "$GITHUB_ENV" + printf 'RELEASE_PRERELEASE=%s\n' "${prerelease}" >> "$GITHUB_ENV" + printf 'SHOULD_PUBLISH=%s\n' "${should_publish}" >> "$GITHUB_ENV" + printf 'DRY_RUN=%s\n' "${dry_run}" >> "$GITHUB_ENV" + + - name: Verify release asset manifest + if: env.SHOULD_PUBLISH == 'true' + run: | + set -eu + manifest_paths="${RUNNER_TEMP}/dispatcher-manifest-paths" + actual_paths="${RUNNER_TEMP}/dispatcher-actual-paths" + awk '{ print $2 }' release-input/release-assets.manifest | sed 's#^dist/dispatcher-release/##' | sort > "${manifest_paths}" + find dist/dispatcher-release -maxdepth 1 -type f -printf '%f\n' | sort > "${actual_paths}" + manifest_count=$(wc -l < "${manifest_paths}" | tr -d ' ') + unique_manifest_count=$(uniq "${manifest_paths}" | wc -l | tr -d ' ') + if [ "${manifest_count}" -ne "${unique_manifest_count}" ]; then + echo "Release manifest is missing files or has duplicate entries." >&2 + exit 1 + fi + if ! cmp -s "${manifest_paths}" "${actual_paths}"; then + echo "Unexpected release files are not listed in the manifest." >&2 + echo "Release manifest is missing files or has duplicate entries." >&2 + exit 1 + fi + asset_count=0 + while IFS=' ' read -r checksum asset_path; do + case "${checksum}" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]* ) ;; *) exit 1 ;; esac + case "${asset_path}" in dist/dispatcher-release/[A-Za-z0-9._+-]* ) ;; *) exit 1 ;; esac + full_path="${asset_path}" + [ -s "${full_path}" ] + actual_checksum=$(shasum -a 256 "${full_path}" | awk '{print $1}') + [ "${actual_checksum}" = "${checksum}" ] + asset_count=$((asset_count + 1)) + done < release-input/release-assets.manifest + [ "${asset_count}" -gt 0 ] + - name: Create or reuse draft dispatcher release id: draft - if: (steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true') && steps.release.outputs.dry_run != 'true' + if: env.DRY_RUN != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} - RELEASE_PRERELEASE: ${{ steps.release.outputs.prerelease }} - TARGET_SHA: ${{ steps.release.outputs.sha }} run: | set -eu - git fetch --force --tags origin - - EXISTING_TAG_SHA=$(git rev-list -n 1 "${RELEASE_TAG}" 2>/dev/null || true) - if [ -n "${EXISTING_TAG_SHA}" ] && [ "${EXISTING_TAG_SHA}" != "${TARGET_SHA}" ]; then - echo "Dispatcher release tag ${RELEASE_TAG} points at ${EXISTING_TAG_SHA}, expected ${TARGET_SHA}." >&2 - exit 1 + tag_error_path="${RUNNER_TEMP}/dispatcher-release-tag-error" + set +e + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' 2> "${tag_error_path}") + tag_status=$? + set -e + if [ "${tag_status}" -ne 0 ]; then + tag_error=$(cat "${tag_error_path}") + case "${tag_error}" in + *"HTTP 404"*) tag_sha="" ;; + *"HTTP 422"*"No commit found for SHA"*|*"No commit found for SHA"*"HTTP 422"*) tag_sha="" ;; + *) printf '%s\n' "${tag_error}" >&2; exit "${tag_status}" ;; + esac fi - - if [ -z "${EXISTING_TAG_SHA}" ]; then - git tag "${RELEASE_TAG}" "${TARGET_SHA}" - git push origin "refs/tags/${RELEASE_TAG}" + if [ -n "${tag_sha}" ] && [ "${tag_sha}" != "${GITHUB_SHA}" ]; then + echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2 + exit 1 fi - - DRAFT_STATE_PATH="${RUNNER_TEMP}/dispatcher-release-draft-${GITHUB_RUN_ID}" - if gh release view "${RELEASE_TAG}" --json isDraft --jq '.isDraft' >"${DRAFT_STATE_PATH}" 2>/dev/null; then - IS_DRAFT=$(cat "${DRAFT_STATE_PATH}") - if [ "${IS_DRAFT}" != "true" ]; then + if gh release view "${RELEASE_TAG}" --json isDraft,targetCommitish > release-input/release-state.json 2>/dev/null; then + is_draft=$(jq -r '.isDraft' release-input/release-state.json) + if [ "${is_draft}" = "false" ]; then if [ "${RELEASE_PRERELEASE}" = "true" ]; then - RELEASE_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.id') - gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" -F prerelease=true -F make_latest=false >/dev/null + release_id=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.id') + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" -F prerelease=true -F make_latest=false >/dev/null fi - echo "release_published=true" >> "$GITHUB_OUTPUT" - echo "Dispatcher release ${RELEASE_TAG} is already published; verifying existing assets." exit 0 fi - echo "release_published=false" >> "$GITHUB_OUTPUT" - echo "Reusing draft dispatcher release ${RELEASE_TAG}." exit 0 fi + prerelease_flag="" + latest_flag="" + if [ "${RELEASE_PRERELEASE}" = "true" ]; then prerelease_flag="--prerelease"; latest_flag="--latest=false"; fi + gh release create "${RELEASE_TAG}" --draft --title "${RELEASE_TAG}" --notes "Dispatcher release ${RELEASE_TAG}." --target "${GITHUB_SHA}" ${prerelease_flag} ${latest_flag} + echo "release_published=false" >> "$GITHUB_OUTPUT" - PRERELEASE_FLAG="" - LATEST_FLAG="" - if [ "${RELEASE_PRERELEASE}" = "true" ]; then - PRERELEASE_FLAG="--prerelease" - LATEST_FLAG="--latest=false" - fi + - name: Verify tagged dispatcher installer scripts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -eu + for installer_path in scripts/install.ps1 scripts/install.sh; do + installer_size=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${installer_path}?ref=${RELEASE_TAG}" --jq '.size') + [ "${installer_size}" -gt 0 ] + done - gh release create "${RELEASE_TAG}" \ - --draft \ - --title "${RELEASE_TAG}" \ - --notes "Dispatcher release ${RELEASE_TAG}." \ - --target "${TARGET_SHA}" \ - ${PRERELEASE_FLAG} \ - ${LATEST_FLAG} + - name: Attest dispatcher release assets + id: attest + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 + with: + subject-path: dist/dispatcher-release/* - echo "release_published=false" >> "$GITHUB_OUTPUT" + - name: Verify dispatcher asset attestations + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + SIGNER_WORKFLOW: ${{ github.repository }}/.github/workflows/dispatcher-publish.yml + run: | + set -eu + while IFS=' ' read -r checksum asset_path; do + gh attestation verify "${asset_path}" --repo "${GITHUB_REPOSITORY}" --signer-workflow "${SIGNER_WORKFLOW}" + done < release-input/release-assets.manifest - - name: Verify release-tagged installer scripts - if: (steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true') && steps.release.outputs.dry_run != 'true' - shell: pwsh + - name: Attach attestation bundles to dispatcher assets + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' env: - RELEASE_TAG: ${{ steps.release.outputs.tag }} + BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} run: | - ./scripts/check-release-installer.ps1 -Version $env:RELEASE_TAG + set -eu + [ -s "${BUNDLE_PATH}" ] + while IFS=' ' read -r checksum asset_path; do + cp "${BUNDLE_PATH}" "${asset_path}.sigstore.json" + done < release-input/release-assets.manifest - name: Upload dispatcher assets - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | - gh release upload "${RELEASE_TAG}" dist/dispatcher-release/* --clobber + set -eu + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha') + if [ "${tag_sha}" != "${GITHUB_SHA}" ]; then + echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2 + exit 1 + fi + while IFS=' ' read -r checksum asset_path; do + gh release upload "${RELEASE_TAG}" "${asset_path}" "${asset_path}.sigstore.json" --clobber + done < release-input/release-assets.manifest - name: Verify remote dispatcher release assets - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | set -eu - ASSETS_JSON=$(gh release view "${RELEASE_TAG}" --json assets) - - for asset_name in $(scripts/verify-dispatcher-release-assets.sh --list); do - ASSET_SIZE=$(printf '%s' "${ASSETS_JSON}" | jq -r --arg name "${asset_name}" '.assets[] | select(.name == $name) | .size') - if [ -z "${ASSET_SIZE}" ] || [ "${ASSET_SIZE}" = "null" ]; then - echo "Missing remote dispatcher release asset: ${asset_name}" >&2 + release_assets=$(gh release view "${RELEASE_TAG}" --json assets) + while IFS=' ' read -r checksum asset_path; do + asset_name=$(basename "${asset_path}") + bundle_name="${asset_name}.sigstore.json" + asset_count=$(printf '%s' "${release_assets}" | jq --arg name "${asset_name}" '[.assets[] | select(.name == $name)] | length') + bundle_count=$(printf '%s' "${release_assets}" | jq --arg name "${bundle_name}" '[.assets[] | select(.name == $name)] | length') + asset_size=$(printf '%s' "${release_assets}" | jq -r --arg name "${asset_name}" '[.assets[] | select(.name == $name) | .size] | .[0]') + bundle_size=$(printf '%s' "${release_assets}" | jq -r --arg name "${bundle_name}" '[.assets[] | select(.name == $name) | .size] | .[0]') + if [ "${asset_count}" -ne 1 ] || [ "${bundle_count}" -ne 1 ] || [ "${asset_size}" -le 0 ] || [ "${bundle_size}" -le 0 ]; then + echo "Dispatcher release asset or attestation bundle is missing or empty: ${asset_name}." >&2 exit 1 fi - - if [ "${ASSET_SIZE}" -le 0 ]; then - echo "Remote dispatcher release asset is empty: ${asset_name}" >&2 - exit 1 - fi - - BUNDLE_NAME="${asset_name}.sigstore.json" - BUNDLE_SIZE=$(printf '%s' "${ASSETS_JSON}" | jq -r --arg name "${BUNDLE_NAME}" '.assets[] | select(.name == $name) | .size') - if [ -z "${BUNDLE_SIZE}" ] || [ "${BUNDLE_SIZE}" = "null" ]; then - echo "Missing remote dispatcher attestation bundle: ${BUNDLE_NAME}" >&2 - exit 1 - fi - - if [ "${BUNDLE_SIZE}" -le 0 ]; then - echo "Remote dispatcher attestation bundle is empty: ${BUNDLE_NAME}" >&2 - exit 1 - fi - done + done < release-input/release-assets.manifest - name: Publish draft dispatcher release - if: (steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true') && steps.release.outputs.dry_run != 'true' && steps.draft.outputs.release_published != 'true' + if: env.DRY_RUN != 'true' && steps.draft.outputs.release_published != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} - RELEASE_PRERELEASE: ${{ steps.release.outputs.prerelease }} run: | set -eu + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha') + if [ "${tag_sha}" != "${GITHUB_SHA}" ]; then + echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2 + exit 1 + fi if [ "${RELEASE_PRERELEASE}" = "true" ]; then gh release edit "${RELEASE_TAG}" --draft=false --prerelease - RELEASE_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.id') - gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" -F prerelease=true -F make_latest=false >/dev/null + release_id=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.id') + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" -F prerelease=true -F make_latest=false >/dev/null else gh release edit "${RELEASE_TAG}" --draft=false fi + post-publish: + needs: [build, publish] + if: needs.build.outputs.dry_run != 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout approved event commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c + with: + go-version-file: cli/.go-version + cache-dependency-path: '**/go.sum' + - name: Sync release-please package releases - id: package_release_sync - if: (steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true') && steps.release.outputs.dry_run != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} run: scripts/sync-release-please-package-releases.sh - - - name: Dry run summary - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run == 'true' - run: | - ls -lh dist/dispatcher-release diff --git a/.github/workflows/native-cli-publish.yml b/.github/workflows/native-cli-publish.yml index d9413b26b2..401f6d2885 100644 --- a/.github/workflows/native-cli-publish.yml +++ b/.github/workflows/native-cli-publish.yml @@ -7,57 +7,29 @@ on: - v3-beta workflow_dispatch: inputs: - ref: - description: "Git ref to build from (tag, branch, or SHA). Defaults to default branch." - required: false - default: "" - release-tag: - description: "Project runner release tag to upload assets to. Defaults to uloop-project-runner-v{version}." - required: false - default: "" dry-run: description: "Dry run (skip release upload)" type: boolean default: false -permissions: - actions: write - contents: write - id-token: write - attestations: write - artifact-metadata: write - issues: write - pull-requests: write - jobs: - publish: + build: runs-on: ubuntu-latest - if: github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v3-beta' + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v3-beta' + permissions: + contents: read + outputs: + should_publish: ${{ steps.release.outputs.publish }} + should_release: ${{ steps.release.outputs.release }} + dry_run: ${{ steps.release.outputs.dry_run }} + release_tag: ${{ steps.release.outputs.tag }} steps: - - name: Resolve checkout ref - id: checkout - env: - EVENT_NAME: ${{ github.event_name }} - EVENT_REF: ${{ github.ref }} - EVENT_SHA: ${{ github.sha }} - INPUT_REF: ${{ inputs.ref }} - run: | - set -eu - - if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then - CHECKOUT_REF="${INPUT_REF:-${EVENT_REF}}" - else - CHECKOUT_REF="${EVENT_SHA}" - fi - - echo "ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT" - echo "Checkout ref: ${CHECKOUT_REF}" - - - name: Checkout repository + - name: Checkout approved event commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: - ref: ${{ steps.checkout.outputs.ref }} + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false - name: Resolve release target id: release @@ -65,11 +37,20 @@ jobs: EVENT_NAME: ${{ github.event_name }} EVENT_REF_NAME: ${{ github.ref_name }} BEFORE_SHA: ${{ github.event.before }} - INPUT_RELEASE_TAG: ${{ inputs.release-tag }} INPUT_DRY_RUN: ${{ inputs.dry-run }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: scripts/resolve-native-cli-release-target.sh >> "$GITHUB_OUTPUT" + + - name: Verify release target matches build commit + if: steps.release.outputs.publish == 'true' || steps.release.outputs.release == 'true' + env: + TARGET_SHA: ${{ steps.release.outputs.sha }} run: | - scripts/resolve-native-cli-release-target.sh >> "$GITHUB_OUTPUT" + set -eu + if [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then + echo "Release target ${TARGET_SHA} does not match approved build commit ${GITHUB_SHA}." >&2 + exit 1 + fi - name: Setup Go if: steps.release.outputs.publish == 'true' || steps.release.outputs.release == 'true' @@ -96,211 +77,323 @@ jobs: if: steps.release.outputs.publish == 'true' run: scripts/verify-native-cli-release-assets.sh - - name: Attest native CLI release assets - id: attest - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 - with: - subject-path: dist/release/* - - - name: Verify native CLI asset attestations - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' + - name: Write release notes + if: steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - SIGNER_WORKFLOW: ${{ github.repository }}/.github/workflows/native-cli-publish.yml + RELEASE_VERSION: ${{ steps.release.outputs.version }} run: | set -eu + mkdir -p release-input + awk -v version="${RELEASE_VERSION}" ' + $0 ~ "^## \\[" version "\\]" { found = 1; printing = 1; print; next } + printing && /^## \[/ { exit } + printing { print } + END { if (!found) exit 1 } + ' cli/project-runner/CHANGELOG.md > release-input/release-notes.md - for asset_path in dist/release/*; do - gh attestation verify "${asset_path}" \ - --repo "${GITHUB_REPOSITORY}" \ - --signer-workflow "${SIGNER_WORKFLOW}" - done - - - name: Distribute attestation bundles per asset - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' + - name: Write release metadata + if: steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true' env: - BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} + TARGET_SHA: ${{ steps.release.outputs.sha }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + SHOULD_PUBLISH: ${{ steps.release.outputs.publish }} + SHOULD_RELEASE: ${{ steps.release.outputs.release }} + DRY_RUN: ${{ steps.release.outputs.dry_run }} run: | - scripts/distribute-attestation-bundles.sh \ - --bundle "${BUNDLE_PATH}" \ - --release-dir dist/release + set -eu + mkdir -p release-input + { + printf 'build_sha=%s\n' "${GITHUB_SHA}" + printf 'target_sha=%s\n' "${TARGET_SHA}" + printf 'release_tag=%s\n' "${RELEASE_TAG}" + printf 'version=%s\n' "${RELEASE_VERSION}" + printf 'publish=%s\n' "${SHOULD_PUBLISH}" + printf 'release=%s\n' "${SHOULD_RELEASE}" + printf 'dry_run=%s\n' "${DRY_RUN}" + printf 'event_ref=%s\n' "${GITHUB_REF}" + } > release-input/release-metadata.env + if [ "${SHOULD_PUBLISH}" = "true" ]; then + shasum -a 256 dist/release/* > release-input/release-assets.manifest + else + : > release-input/release-assets.manifest + fi - - name: Upload packaged release assets artifact - if: steps.release.outputs.publish == 'true' + - name: Upload packaged release assets + if: steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: native-cli-release-assets-${{ steps.release.outputs.tag }} - path: dist/release/* + name: native-cli-release-input + path: | + dist/release + release-input if-no-files-found: error - - name: Write release notes - if: steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true' + - name: Dry run summary + if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run == 'true' + run: ls -lh dist/release + + publish: + needs: build + if: (needs.build.outputs.should_publish == 'true' || needs.build.outputs.should_release == 'true') && needs.build.outputs.dry_run != 'true' + runs-on: ubuntu-latest + environment: cli-release + permissions: + actions: read + contents: write + id-token: write + attestations: write + artifact-metadata: write + steps: + - name: Download packaged release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: native-cli-release-input + path: . + + - name: Verify release metadata env: - RELEASE_VERSION: ${{ steps.release.outputs.version }} + GITHUB_REPOSITORY: ${{ github.repository }} run: | set -eu - NOTES_FILE="$RUNNER_TEMP/native-cli-release-notes.md" - - awk -v version="${RELEASE_VERSION}" ' - $0 ~ "^## \\[" version "\\]" { - found = 1 - printing = 1 - print - next - } - printing && /^## \[/ { - exit - } - printing { - print - } - END { - if (!found) { - exit 1 - } - } - ' cli/project-runner/CHANGELOG.md > "${NOTES_FILE}" - - echo "RELEASE_NOTES_FILE=${NOTES_FILE}" >> "$GITHUB_ENV" + metadata_value() { + key=$1 + value=$(grep "^${key}=" release-input/release-metadata.env | cut -d= -f2-) + if [ -z "${value}" ]; then + echo "Missing release metadata: ${key}." >&2 + exit 1 + fi + printf '%s\n' "${value}" + } + + build_sha=$(metadata_value build_sha) + target_sha=$(metadata_value target_sha) + release_tag=$(metadata_value release_tag) + version=$(metadata_value version) + should_publish=$(metadata_value publish) + should_release=$(metadata_value release) + dry_run=$(metadata_value dry_run) + event_ref=$(metadata_value event_ref) + BUILD_SHA="${build_sha}" + TARGET_SHA="${target_sha}" + if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then + echo "Build SHA and release target must match the approved event commit." >&2 + exit 1 + fi + if [ "${event_ref}" != "refs/heads/main" ] && [ "${event_ref}" != "refs/heads/v3-beta" ]; then + echo "Unsupported release ref ${event_ref}." >&2 + exit 1 + fi + if [ "${release_tag}" != "uloop-project-runner-v${version}" ]; then + echo "Release tag does not match the resolved version." >&2 + exit 1 + fi + validate_boolean() { + field_name=$1 + field_value=$2 + if [ "${field_value}" != "true" ] && [ "${field_value}" != "false" ]; then + echo "Invalid boolean metadata value for ${field_name}: ${field_value}." >&2 + exit 1 + fi + } + validate_boolean publish "${should_publish}" + validate_boolean release "${should_release}" + validate_boolean dry_run "${dry_run}" + printf 'RELEASE_TAG=%s\n' "${release_tag}" >> "$GITHUB_ENV" + printf 'SHOULD_PUBLISH=%s\n' "${should_publish}" >> "$GITHUB_ENV" + printf 'SHOULD_RELEASE=%s\n' "${should_release}" >> "$GITHUB_ENV" + printf 'DRY_RUN=%s\n' "${dry_run}" >> "$GITHUB_ENV" + + - name: Verify release asset manifest + if: env.SHOULD_PUBLISH == 'true' + run: | + set -eu + manifest_paths="${RUNNER_TEMP}/native-cli-manifest-paths" + actual_paths="${RUNNER_TEMP}/native-cli-actual-paths" + awk '{ print $2 }' release-input/release-assets.manifest | sed 's#^dist/release/##' | sort > "${manifest_paths}" + find dist/release -maxdepth 1 -type f -printf '%f\n' | sort > "${actual_paths}" + manifest_count=$(wc -l < "${manifest_paths}" | tr -d ' ') + unique_manifest_count=$(uniq "${manifest_paths}" | wc -l | tr -d ' ') + if [ "${manifest_count}" -ne "${unique_manifest_count}" ]; then + echo "Release manifest is missing files or has duplicate entries." >&2 + exit 1 + fi + if ! cmp -s "${manifest_paths}" "${actual_paths}"; then + echo "Unexpected release files are not listed in the manifest." >&2 + echo "Release manifest is missing files or has duplicate entries." >&2 + exit 1 + fi + asset_count=0 + while IFS=' ' read -r checksum asset_path; do + case "${checksum}" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]* ) ;; *) exit 1 ;; esac + case "${asset_path}" in dist/release/[A-Za-z0-9._+-]* ) ;; *) exit 1 ;; esac + full_path="${asset_path}" + [ -s "${full_path}" ] + actual_checksum=$(shasum -a 256 "${full_path}" | awk '{print $1}') + [ "${actual_checksum}" = "${checksum}" ] + asset_count=$((asset_count + 1)) + done < release-input/release-assets.manifest + [ "${asset_count}" -gt 0 ] - name: Create or reuse draft release id: draft - if: (steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true') && steps.release.outputs.dry_run != 'true' + if: env.DRY_RUN != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} - TARGET_SHA: ${{ steps.release.outputs.sha }} + GITHUB_REPOSITORY: ${{ github.repository }} run: | set -eu - git fetch --force --tags origin - - EXISTING_TAG_SHA=$(git rev-list -n 1 "${RELEASE_TAG}" 2>/dev/null || true) - if [ -n "${EXISTING_TAG_SHA}" ] && [ "${EXISTING_TAG_SHA}" != "${TARGET_SHA}" ]; then - echo "Release tag ${RELEASE_TAG} points at ${EXISTING_TAG_SHA}, expected ${TARGET_SHA}." >&2 - exit 1 + tag_error_path="${RUNNER_TEMP}/native-cli-release-tag-error" + set +e + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' 2> "${tag_error_path}") + tag_status=$? + set -e + if [ "${tag_status}" -ne 0 ]; then + tag_error=$(cat "${tag_error_path}") + case "${tag_error}" in + *"HTTP 404"*) tag_sha="" ;; + *"HTTP 422"*"No commit found for SHA"*|*"No commit found for SHA"*"HTTP 422"*) tag_sha="" ;; + *) printf '%s\n' "${tag_error}" >&2; exit "${tag_status}" ;; + esac fi - - if [ -z "${EXISTING_TAG_SHA}" ]; then - git tag "${RELEASE_TAG}" "${TARGET_SHA}" - git push origin "refs/tags/${RELEASE_TAG}" + if [ -n "${tag_sha}" ] && [ "${tag_sha}" != "${GITHUB_SHA}" ]; then + echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2 + exit 1 fi - - if gh release view "${RELEASE_TAG}" --json isDraft --jq '.isDraft' >/tmp/native-cli-release-draft 2>/dev/null; then - IS_DRAFT=$(cat /tmp/native-cli-release-draft) - if [ "${IS_DRAFT}" != "true" ]; then + if gh release view "${RELEASE_TAG}" --json isDraft,targetCommitish > release-input/release-state.json 2>/dev/null; then + is_draft=$(jq -r '.isDraft' release-input/release-state.json) + if [ "${is_draft}" = "false" ]; then echo "release_published=true" >> "$GITHUB_OUTPUT" - echo "Release ${RELEASE_TAG} is already published; verifying existing assets." exit 0 fi - echo "release_published=false" >> "$GITHUB_OUTPUT" - echo "Reusing draft release ${RELEASE_TAG}." exit 0 fi + prerelease_flag="" + case "${RELEASE_TAG}" in *-beta.*) prerelease_flag="--prerelease" ;; esac + gh release create "${RELEASE_TAG}" --draft --title "${RELEASE_TAG}" --notes-file release-input/release-notes.md --target "${GITHUB_SHA}" ${prerelease_flag} + echo "release_published=false" >> "$GITHUB_OUTPUT" - PRERELEASE_FLAG="" - case "${RELEASE_TAG}" in - *-beta.*) - PRERELEASE_FLAG="--prerelease" - ;; - esac + - name: Attest native CLI release assets + id: attest + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 + with: + subject-path: dist/release/* - gh release create "${RELEASE_TAG}" \ - --draft \ - --title "${RELEASE_TAG}" \ - --notes-file "${RELEASE_NOTES_FILE}" \ - --target "${TARGET_SHA}" \ - ${PRERELEASE_FLAG} + - name: Verify native CLI asset attestations + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SIGNER_WORKFLOW: ${{ github.repository }}/.github/workflows/native-cli-publish.yml + run: | + set -eu + while IFS=' ' read -r checksum asset_path; do + gh attestation verify "${asset_path}" --repo "${GITHUB_REPOSITORY}" --signer-workflow "${SIGNER_WORKFLOW}" + done < release-input/release-assets.manifest - echo "release_published=false" >> "$GITHUB_OUTPUT" + - name: Attach attestation bundles to release assets + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' + env: + BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} + run: | + set -eu + [ -s "${BUNDLE_PATH}" ] + while IFS=' ' read -r checksum asset_path; do + cp "${BUNDLE_PATH}" "${asset_path}.sigstore.json" + done < release-input/release-assets.manifest - name: Upload native CLI assets - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | - gh release upload "${RELEASE_TAG}" dist/release/* --clobber + set -eu + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha') + if [ "${tag_sha}" != "${GITHUB_SHA}" ]; then + echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2 + exit 1 + fi + while IFS=' ' read -r checksum asset_path; do + gh release upload "${RELEASE_TAG}" "${asset_path}" "${asset_path}.sigstore.json" --clobber + done < release-input/release-assets.manifest - name: Verify remote release assets - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | set -eu - ASSETS_JSON=$(gh release view "${RELEASE_TAG}" --json assets) - - for asset_name in $(scripts/verify-native-cli-release-assets.sh --list); do - ASSET_SIZE=$(printf '%s' "${ASSETS_JSON}" | jq -r --arg name "${asset_name}" '.assets[] | select(.name == $name) | .size') - if [ -z "${ASSET_SIZE}" ] || [ "${ASSET_SIZE}" = "null" ]; then - echo "Missing remote release asset: ${asset_name}" >&2 - exit 1 - fi - - if [ "${ASSET_SIZE}" -le 0 ]; then - echo "Remote release asset is empty: ${asset_name}" >&2 + release_assets=$(gh release view "${RELEASE_TAG}" --json assets) + while IFS=' ' read -r checksum asset_path; do + asset_name=$(basename "${asset_path}") + bundle_name="${asset_name}.sigstore.json" + asset_count=$(printf '%s' "${release_assets}" | jq --arg name "${asset_name}" '[.assets[] | select(.name == $name)] | length') + bundle_count=$(printf '%s' "${release_assets}" | jq --arg name "${bundle_name}" '[.assets[] | select(.name == $name)] | length') + asset_size=$(printf '%s' "${release_assets}" | jq -r --arg name "${asset_name}" '[.assets[] | select(.name == $name) | .size] | .[0]') + bundle_size=$(printf '%s' "${release_assets}" | jq -r --arg name "${bundle_name}" '[.assets[] | select(.name == $name) | .size] | .[0]') + if [ "${asset_count}" -ne 1 ] || [ "${bundle_count}" -ne 1 ] || [ "${asset_size}" -le 0 ] || [ "${bundle_size}" -le 0 ]; then + echo "Release asset or attestation bundle is missing or empty: ${asset_name}." >&2 exit 1 fi - - BUNDLE_NAME="${asset_name}.sigstore.json" - BUNDLE_SIZE=$(printf '%s' "${ASSETS_JSON}" | jq -r --arg name "${BUNDLE_NAME}" '.assets[] | select(.name == $name) | .size') - if [ -z "${BUNDLE_SIZE}" ] || [ "${BUNDLE_SIZE}" = "null" ]; then - echo "Missing remote native CLI attestation bundle: ${BUNDLE_NAME}" >&2 - exit 1 - fi - - if [ "${BUNDLE_SIZE}" -le 0 ]; then - echo "Remote native CLI attestation bundle is empty: ${BUNDLE_NAME}" >&2 - exit 1 - fi - done + done < release-input/release-assets.manifest - name: Publish draft release - if: (steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true') && steps.release.outputs.dry_run != 'true' && steps.draft.outputs.release_published != 'true' + if: env.DRY_RUN != 'true' && steps.draft.outputs.release_published != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} + GITHUB_REPOSITORY: ${{ github.repository }} run: | set -eu - case "${RELEASE_TAG}" in - *-beta.*) - gh release edit "${RELEASE_TAG}" --draft=false --prerelease - ;; - *) - gh release edit "${RELEASE_TAG}" --draft=false - ;; - esac + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha') + if [ "${tag_sha}" != "${GITHUB_SHA}" ]; then + echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2 + exit 1 + fi + case "${RELEASE_TAG}" in *-beta.*) gh release edit "${RELEASE_TAG}" --draft=false --prerelease ;; *) gh release edit "${RELEASE_TAG}" --draft=false ;; esac + + post-publish: + needs: [build, publish] + if: needs.build.outputs.dry_run != 'true' + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + pull-requests: write + steps: + - name: Checkout approved event commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c + with: + go-version-file: cli/.go-version + cache-dependency-path: '**/go.sum' - name: Dispatch release-please after native CLI publish - if: github.event_name == 'push' && steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true' + if: needs.build.outputs.should_publish == 'true' && github.event_name == 'push' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TARGET_BRANCH: ${{ github.ref_name }} - run: | - gh workflow run release-please.yml --ref "${TARGET_BRANCH}" -f branch="${TARGET_BRANCH}" + run: gh workflow run release-please.yml --ref "${TARGET_BRANCH}" -f branch="${TARGET_BRANCH}" - name: Sync release-please package releases id: package_release_sync - if: (steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true') && steps.release.outputs.dry_run != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} run: scripts/sync-release-please-package-releases.sh - name: Mark release PR as tagged - if: github.event_name == 'push' && (steps.release.outputs.release == 'true' || steps.release.outputs.publish == 'true') && steps.release.outputs.dry_run != 'true' && steps.package_release_sync.outputs.ready != 'false' + if: github.event_name == 'push' && steps.package_release_sync.outputs.ready != 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} - TARGET_SHA: ${{ steps.release.outputs.sha }} + RELEASE_TAG: ${{ needs.build.outputs.release_tag }} + TARGET_SHA: ${{ github.sha }} TARGET_BRANCH: ${{ github.ref_name }} GITHUB_REPOSITORY: ${{ github.repository }} run: scripts/mark-release-pr-tagged.sh - - - name: Dry run summary - if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run == 'true' - run: | - ls -lh dist/release diff --git a/scripts/test-dispatcher-publish-workflow.sh b/scripts/test-dispatcher-publish-workflow.sh index 3bfc17c658..841f8cd513 100755 --- a/scripts/test-dispatcher-publish-workflow.sh +++ b/scripts/test-dispatcher-publish-workflow.sh @@ -5,120 +5,208 @@ ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) WORKFLOW="$ROOT_DIR/.github/workflows/dispatcher-publish.yml" assert_contains() { - file=$1 - expected=$2 - if ! grep -F -- "$expected" "$file" >/dev/null 2>&1; then - echo "Expected $file to contain: $expected" >&2 + expected=$1 + if ! grep -F -- "$expected" "$WORKFLOW" >/dev/null 2>&1; then + echo "Expected workflow to contain: $expected" >&2 exit 1 fi } assert_not_contains() { - file=$1 - unexpected=$2 - if grep -F -- "$unexpected" "$file" >/dev/null 2>&1; then - echo "Expected $file not to contain: $unexpected" >&2 + unexpected=$1 + if grep -F -- "$unexpected" "$WORKFLOW" >/dev/null 2>&1; then + echo "Expected workflow not to contain: $unexpected" >&2 exit 1 fi } line_number() { - file=$1 - text=$2 - grep -nF -- "$text" "$file" | head -n 1 | cut -d: -f 1 + text=$1 + grep -nF -- "$text" "$WORKFLOW" | head -n 1 | cut -d: -f 1 } assert_before() { - file=$1 - earlier=$2 - later=$3 - earlier_line=$(line_number "$file" "$earlier") - later_line=$(line_number "$file" "$later") + earlier=$1 + later=$2 + earlier_line=$(line_number "$earlier") + later_line=$(line_number "$later") + if [ -z "$earlier_line" ] || [ -z "$later_line" ] || [ "$earlier_line" -ge "$later_line" ]; then + echo "Expected '$earlier' to appear before '$later'." >&2 + exit 1 + fi +} + +assert_count() { + expected_count=$1 + text=$2 + actual_count=$(grep -Fc -- "$text" "$WORKFLOW") + if [ "$actual_count" -ne "$expected_count" ]; then + echo "Expected '$text' to appear $expected_count times, found $actual_count." >&2 + exit 1 + fi +} + +publish_section() { + awk ' + /^ publish:/ { printing = 1 } + /^ post-publish:/ { exit } + printing { print } + ' "$WORKFLOW" +} + +publish_draft_section() { + awk ' + /^ - name: Publish draft dispatcher release$/ { printing = 1; next } + printing && /^ - name:/ { exit } + printing { print } + ' "$WORKFLOW" +} + +post_publish_section() { + awk ' + /^ post-publish:/ { printing = 1 } + printing { print } + ' "$WORKFLOW" +} + +assert_post_publish_before() { + earlier=$1 + later=$2 + earlier_line=$(post_publish_section | grep -nF -- "$earlier" | head -n 1 | cut -d: -f 1) + later_line=$(post_publish_section | grep -nF -- "$later" | head -n 1 | cut -d: -f 1) if [ -z "$earlier_line" ] || [ -z "$later_line" ] || [ "$earlier_line" -ge "$later_line" ]; then - echo "Expected '$earlier' to appear before '$later' in $file." >&2 + echo "Expected post-publish '$earlier' to appear before '$later'." >&2 + exit 1 + fi +} + +test_build_and_publish_jobs_have_separate_trust_boundaries() { + assert_contains " build:" + assert_contains " publish:" + assert_contains " needs: build" + assert_contains " environment: cli-release" + assert_contains " contents: read" + assert_contains " contents: write" + assert_contains " id-token: write" + assert_contains " attestations: write" + assert_contains " artifact-metadata: write" +} + +test_unprivileged_build_uses_only_the_approved_event_commit() { + assert_not_contains "inputs.ref" + assert_not_contains "INPUT_REF" + assert_contains ' ref: ${{ github.sha }}' + assert_count 2 " persist-credentials: false" + assert_contains "github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v3-beta'" +} + +test_publish_validates_metadata_without_checking_out_source() { + assert_contains " - name: Write release metadata" + assert_contains "release-metadata.env" + assert_contains "release-assets.manifest" + assert_contains " - name: Verify release metadata" + assert_contains 'BUILD_SHA="${build_sha}"' + assert_contains 'if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then' + assert_contains " - name: Download packaged dispatcher assets" + assert_contains " path: ." + assert_contains " dist/dispatcher-release" + assert_contains " release-input" + if publish_section | grep -F "actions/checkout" >/dev/null 2>&1; then + echo "Publish job must not check out repository source." >&2 + exit 1 + fi + installer_path_lines=$(publish_section | grep -F "scripts/" || true) + if [ "${installer_path_lines}" != " for installer_path in scripts/install.ps1 scripts/install.sh; do" ]; then + echo "Publish job must not execute repository scripts." >&2 + exit 1 + fi +} + +test_dispatcher_assets_are_attested_after_the_manifest_is_verified() { + assert_contains " - name: Verify release asset manifest" + assert_contains " - name: Attest dispatcher release assets" + assert_contains " subject-path: dist/dispatcher-release/*" + assert_contains " - name: Attach attestation bundles to dispatcher assets" + assert_contains " - name: Upload dispatcher assets" + assert_contains " - name: Verify remote dispatcher release assets" + assert_contains 'gh release view "${RELEASE_TAG}" --json assets' + assert_contains 'bundle_name="${asset_name}.sigstore.json"' + assert_contains '[ "${asset_count}" -ne 1 ] || [ "${bundle_count}" -ne 1 ] || [ "${asset_size}" -le 0 ] || [ "${bundle_size}" -le 0 ]' + assert_contains " - name: Verify dispatcher asset attestations" + assert_before " - name: Verify release asset manifest" " - name: Attest dispatcher release assets" + assert_before " - name: Attest dispatcher release assets" " - name: Verify dispatcher asset attestations" + assert_before " - name: Verify dispatcher asset attestations" " - name: Attach attestation bundles to dispatcher assets" + assert_before " - name: Attach attestation bundles to dispatcher assets" " - name: Upload dispatcher assets" + assert_before " - name: Upload dispatcher assets" " - name: Verify remote dispatcher release assets" + assert_before " - name: Verify remote dispatcher release assets" " - name: Publish draft dispatcher release" +} + +test_dispatcher_verifies_tagged_installers_after_draft_creation() { + assert_contains " - name: Verify tagged dispatcher installer scripts" + assert_contains 'for installer_path in scripts/install.ps1 scripts/install.sh; do' + assert_contains 'gh api "repos/${GITHUB_REPOSITORY}/contents/${installer_path}?ref=${RELEASE_TAG}" --jq '\''.size'\''' + assert_contains '[ "${installer_size}" -gt 0 ]' + assert_before " - name: Create or reuse draft dispatcher release" " - name: Verify tagged dispatcher installer scripts" + assert_before " - name: Verify tagged dispatcher installer scripts" " - name: Upload dispatcher assets" + assert_before " - name: Verify tagged dispatcher installer scripts" " - name: Publish draft dispatcher release" +} + +test_publish_rejects_manifest_and_existing_tag_mismatches() { + assert_contains "Unexpected release files are not listed in the manifest." + assert_contains "Release manifest is missing files or has duplicate entries." + assert_contains 'gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '\''.sha'\''' + assert_contains 'Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}.' + assert_not_contains "release-input/dist/dispatcher-release" +} + +test_draft_creation_accepts_only_the_known_missing_tag_responses() { + assert_contains '*"HTTP 404"*) tag_sha="" ;;' + assert_contains '*"HTTP 422"*"No commit found for SHA"*|*"No commit found for SHA"*"HTTP 422"*) tag_sha="" ;;' + assert_count 2 'tag_sha="" ;;' + assert_contains '*) printf '\''%s\n'\'' "${tag_error}" >&2; exit "${tag_status}" ;;' +} + +test_dispatcher_publish_rechecks_the_tag_before_publishing() { + assert_contains " - name: Publish draft dispatcher release" + if ! publish_draft_section | grep -F 'tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '\''.sha'\'')' >/dev/null 2>&1; then + echo "Publish draft dispatcher release must recheck the release tag SHA." >&2 + exit 1 + fi +} + +test_dispatcher_build_preserves_release_checks() { + assert_contains "concurrency:" + assert_contains ' group: dispatcher-publish-${{ github.ref }}' + assert_contains " - name: Package dispatcher release assets" + assert_contains " - name: Verify packaged dispatcher release assets" + assert_before " - name: Package dispatcher release assets" " - name: Verify packaged dispatcher release assets" + assert_before " - name: Verify packaged dispatcher release assets" " - name: Write release metadata" +} + +test_dispatcher_release_target_and_prerelease_state_remain_verified() { + assert_contains 'gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '\''.sha'\''' + assert_contains 'Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}.' + assert_contains 'gh release edit "${RELEASE_TAG}" --draft=false --prerelease' + assert_contains " - name: Sync release-please package releases" + assert_count 2 " contents: write" + if ! post_publish_section | grep -F " - name: Setup Go" >/dev/null 2>&1; then + echo "Post-publish job must set up the pinned Go toolchain." >&2 + exit 1 + fi + if ! post_publish_section | grep -F " go-version-file: cli/.go-version" >/dev/null 2>&1 || ! post_publish_section | grep -F " cache-dependency-path: '**/go.sum'" >/dev/null 2>&1; then + echo "Post-publish Go setup must use the repository pin and cache dependency path." >&2 exit 1 fi + assert_post_publish_before " - name: Setup Go" " - name: Sync release-please package releases" } -test_dispatcher_resolver_is_used() { - assert_contains "$WORKFLOW" " - name: Resolve dispatcher release target" - assert_contains "$WORKFLOW" ' scripts/resolve-dispatcher-release-target.sh >> "$GITHUB_OUTPUT"' -} - -test_dispatcher_publish_is_serialized_by_branch() { - assert_contains "$WORKFLOW" "concurrency:" - assert_contains "$WORKFLOW" ' group: dispatcher-publish-${{ github.ref }}' - assert_contains "$WORKFLOW" " cancel-in-progress: false" -} - -test_dispatcher_assets_are_packaged_and_verified() { - assert_contains "$WORKFLOW" " - name: Package dispatcher release assets" - assert_contains "$WORKFLOW" " run: scripts/package-dispatcher.sh" - assert_contains "$WORKFLOW" " - name: Verify packaged dispatcher release assets" - assert_contains "$WORKFLOW" " run: scripts/verify-dispatcher-release-assets.sh" - assert_before "$WORKFLOW" " - name: Package dispatcher release assets" " - name: Verify packaged dispatcher release assets" -} - -test_installer_scripts_are_verified_before_upload() { - assert_contains "$WORKFLOW" " - name: Verify release-tagged installer scripts" - assert_contains "$WORKFLOW" " ./scripts/check-release-installer.ps1 -Version \$env:RELEASE_TAG" - assert_before "$WORKFLOW" " - name: Create or reuse draft dispatcher release" " - name: Verify release-tagged installer scripts" - assert_before "$WORKFLOW" " - name: Verify release-tagged installer scripts" " - name: Upload dispatcher assets" -} - -test_existing_dispatcher_tag_target_is_checked() { - assert_contains "$WORKFLOW" ' EXISTING_TAG_SHA=$(git rev-list -n 1 "${RELEASE_TAG}" 2>/dev/null || true)' - assert_contains "$WORKFLOW" ' if [ -n "${EXISTING_TAG_SHA}" ] && [ "${EXISTING_TAG_SHA}" != "${TARGET_SHA}" ]; then' - assert_contains "$WORKFLOW" ' echo "Dispatcher release tag ${RELEASE_TAG} points at ${EXISTING_TAG_SHA}, expected ${TARGET_SHA}." >&2' - assert_before "$WORKFLOW" ' EXISTING_TAG_SHA=$(git rev-list -n 1 "${RELEASE_TAG}" 2>/dev/null || true)' ' - name: Verify release-tagged installer scripts' -} - -test_dispatcher_draft_state_uses_runner_temp() { - assert_contains "$WORKFLOW" ' DRAFT_STATE_PATH="${RUNNER_TEMP}/dispatcher-release-draft-${GITHUB_RUN_ID}"' - assert_contains "$WORKFLOW" ' if gh release view "${RELEASE_TAG}" --json isDraft --jq '"'"'.isDraft'"'"' >"${DRAFT_STATE_PATH}" 2>/dev/null; then' - assert_contains "$WORKFLOW" ' IS_DRAFT=$(cat "${DRAFT_STATE_PATH}")' - assert_not_contains "$WORKFLOW" "/tmp/dispatcher-release-draft" -} - -test_dispatcher_beta_releases_are_marked_prerelease() { - assert_contains "$WORKFLOW" ' RELEASE_PRERELEASE: ${{ steps.release.outputs.prerelease }}' - assert_contains "$WORKFLOW" ' GITHUB_REPOSITORY: ${{ github.repository }}' - assert_contains "$WORKFLOW" ' PRERELEASE_FLAG=""' - assert_contains "$WORKFLOW" ' LATEST_FLAG=""' - assert_contains "$WORKFLOW" ' if [ "${RELEASE_PRERELEASE}" = "true" ]; then' - assert_contains "$WORKFLOW" ' PRERELEASE_FLAG="--prerelease"' - assert_contains "$WORKFLOW" ' LATEST_FLAG="--latest=false"' - assert_contains "$WORKFLOW" ' ${PRERELEASE_FLAG} \' - assert_contains "$WORKFLOW" ' ${LATEST_FLAG}' - assert_contains "$WORKFLOW" ' gh release edit "${RELEASE_TAG}" --draft=false --prerelease' - assert_contains "$WORKFLOW" ' RELEASE_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '"'"'.id'"'"')' - assert_contains "$WORKFLOW" ' gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" -F prerelease=true -F make_latest=false >/dev/null' - assert_before "$WORKFLOW" ' PRERELEASE_FLAG="--prerelease"' ' gh release create "${RELEASE_TAG}" \' - assert_before "$WORKFLOW" ' gh release edit "${RELEASE_TAG}" --draft=false --prerelease' ' - name: Sync release-please package releases' -} - -test_package_release_sync_runs_after_dispatcher_publish() { - assert_contains "$WORKFLOW" " - name: Sync release-please package releases" - assert_before "$WORKFLOW" " - name: Publish draft dispatcher release" " - name: Sync release-please package releases" -} - -test_dispatcher_attestation_bundles_are_distributed_per_asset() { - assert_contains "$WORKFLOW" " - name: Distribute attestation bundles per asset" - assert_contains "$WORKFLOW" " scripts/distribute-attestation-bundles.sh \\" - assert_contains "$WORKFLOW" " --bundle \"\${BUNDLE_PATH}\" \\" - assert_contains "$WORKFLOW" " --release-dir dist/dispatcher-release" - assert_before "$WORKFLOW" " - name: Verify dispatcher asset attestations" " - name: Distribute attestation bundles per asset" - assert_before "$WORKFLOW" " - name: Distribute attestation bundles per asset" " - name: Upload dispatcher assets" - assert_contains "$WORKFLOW" " BUNDLE_NAME=\"\${asset_name}.sigstore.json\"" - assert_contains "$WORKFLOW" " echo \"Missing remote dispatcher attestation bundle: \${BUNDLE_NAME}\" >&2" -} - -test_dispatcher_resolver_is_used -test_dispatcher_publish_is_serialized_by_branch -test_dispatcher_assets_are_packaged_and_verified -test_installer_scripts_are_verified_before_upload -test_existing_dispatcher_tag_target_is_checked -test_dispatcher_draft_state_uses_runner_temp -test_dispatcher_beta_releases_are_marked_prerelease -test_package_release_sync_runs_after_dispatcher_publish -test_dispatcher_attestation_bundles_are_distributed_per_asset +test_build_and_publish_jobs_have_separate_trust_boundaries +test_unprivileged_build_uses_only_the_approved_event_commit +test_publish_validates_metadata_without_checking_out_source +test_dispatcher_assets_are_attested_after_the_manifest_is_verified +test_dispatcher_verifies_tagged_installers_after_draft_creation +test_publish_rejects_manifest_and_existing_tag_mismatches +test_draft_creation_accepts_only_the_known_missing_tag_responses +test_dispatcher_publish_rechecks_the_tag_before_publishing +test_dispatcher_build_preserves_release_checks +test_dispatcher_release_target_and_prerelease_state_remain_verified diff --git a/scripts/test-native-cli-publish-workflow.sh b/scripts/test-native-cli-publish-workflow.sh index c7a0233ed0..df3626776e 100755 --- a/scripts/test-native-cli-publish-workflow.sh +++ b/scripts/test-native-cli-publish-workflow.sh @@ -5,88 +5,196 @@ ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) WORKFLOW="$ROOT_DIR/.github/workflows/native-cli-publish.yml" assert_contains() { - file=$1 - expected=$2 - if ! grep -F -- "$expected" "$file" >/dev/null 2>&1; then - echo "Expected $file to contain: $expected" >&2 + expected=$1 + if ! grep -F -- "$expected" "$WORKFLOW" >/dev/null 2>&1; then + echo "Expected workflow to contain: $expected" >&2 + exit 1 + fi +} + +assert_not_contains() { + unexpected=$1 + if grep -F -- "$unexpected" "$WORKFLOW" >/dev/null 2>&1; then + echo "Expected workflow not to contain: $unexpected" >&2 exit 1 fi } line_number() { - file=$1 - text=$2 - grep -nF -- "$text" "$file" | head -n 1 | cut -d: -f 1 + text=$1 + grep -nF -- "$text" "$WORKFLOW" | head -n 1 | cut -d: -f 1 } assert_before() { - file=$1 - earlier=$2 - later=$3 - earlier_line=$(line_number "$file" "$earlier") - later_line=$(line_number "$file" "$later") + earlier=$1 + later=$2 + earlier_line=$(line_number "$earlier") + later_line=$(line_number "$later") if [ -z "$earlier_line" ] || [ -z "$later_line" ] || [ "$earlier_line" -ge "$later_line" ]; then - echo "Expected '$earlier' to appear before '$later' in $file." >&2 + echo "Expected '$earlier' to appear before '$later'." >&2 + exit 1 + fi +} + +assert_count() { + expected_count=$1 + text=$2 + actual_count=$(grep -Fc -- "$text" "$WORKFLOW") + if [ "$actual_count" -ne "$expected_count" ]; then + echo "Expected '$text' to appear $expected_count times, found $actual_count." >&2 + exit 1 + fi +} + +publish_section() { + awk ' + /^ publish:/ { printing = 1 } + /^ post-publish:/ { exit } + printing { print } + ' "$WORKFLOW" +} + +publish_draft_section() { + awk ' + /^ - name: Publish draft release$/ { printing = 1; next } + printing && /^ - name:/ { exit } + printing { print } + ' "$WORKFLOW" +} + +post_publish_section() { + awk ' + /^ post-publish:/ { printing = 1 } + printing { print } + ' "$WORKFLOW" +} + +assert_post_publish_before() { + earlier=$1 + later=$2 + earlier_line=$(post_publish_section | grep -nF -- "$earlier" | head -n 1 | cut -d: -f 1) + later_line=$(post_publish_section | grep -nF -- "$later" | head -n 1 | cut -d: -f 1) + if [ -z "$earlier_line" ] || [ -z "$later_line" ] || [ "$earlier_line" -ge "$later_line" ]; then + echo "Expected post-publish '$earlier' to appear before '$later'." >&2 + exit 1 + fi +} + +test_build_and_publish_jobs_have_separate_trust_boundaries() { + assert_contains " build:" + assert_contains " publish:" + assert_contains " needs: build" + assert_contains " environment: cli-release" + assert_contains " contents: read" + assert_contains " contents: write" + assert_contains " id-token: write" + assert_contains " attestations: write" + assert_contains " artifact-metadata: write" +} + +test_unprivileged_build_uses_only_the_approved_event_commit() { + assert_not_contains "inputs.ref" + assert_not_contains "inputs.release-tag" + assert_not_contains "INPUT_REF" + assert_contains ' ref: ${{ github.sha }}' + assert_count 2 " persist-credentials: false" + assert_contains "github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v3-beta'" +} + +test_publish_validates_metadata_without_checking_out_source() { + assert_contains " - name: Write release metadata" + assert_contains "release-metadata.env" + assert_contains "release-assets.manifest" + assert_contains " - name: Verify release metadata" + assert_contains 'BUILD_SHA="${build_sha}"' + assert_contains 'if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then' + assert_contains " - name: Download packaged release assets" + assert_contains " path: ." + assert_contains " dist/release" + assert_contains " release-input" + if publish_section | grep -F "actions/checkout" >/dev/null 2>&1; then + echo "Publish job must not check out repository source." >&2 + exit 1 + fi + if publish_section | grep -F "scripts/" >/dev/null 2>&1; then + echo "Publish job must not execute repository scripts." >&2 + exit 1 + fi +} + +test_assets_are_attested_after_the_manifest_is_verified() { + assert_contains " - name: Verify release asset manifest" + assert_contains " - name: Attest native CLI release assets" + assert_contains " subject-path: dist/release/*" + assert_contains " - name: Attach attestation bundles to release assets" + assert_contains " - name: Upload native CLI assets" + assert_contains " - name: Verify remote release assets" + assert_contains 'gh release view "${RELEASE_TAG}" --json assets' + assert_contains 'bundle_name="${asset_name}.sigstore.json"' + assert_contains '[ "${asset_count}" -ne 1 ] || [ "${bundle_count}" -ne 1 ] || [ "${asset_size}" -le 0 ] || [ "${bundle_size}" -le 0 ]' + assert_contains " - name: Verify native CLI asset attestations" + assert_before " - name: Verify release asset manifest" " - name: Attest native CLI release assets" + assert_before " - name: Attest native CLI release assets" " - name: Verify native CLI asset attestations" + assert_before " - name: Verify native CLI asset attestations" " - name: Attach attestation bundles to release assets" + assert_before " - name: Attach attestation bundles to release assets" " - name: Upload native CLI assets" + assert_before " - name: Upload native CLI assets" " - name: Verify remote release assets" + assert_before " - name: Verify remote release assets" " - name: Publish draft release" +} + +test_publish_rejects_manifest_and_existing_tag_mismatches() { + assert_contains "Unexpected release files are not listed in the manifest." + assert_contains "Release manifest is missing files or has duplicate entries." + assert_contains 'gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '\''.sha'\''' + assert_contains 'Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}.' + assert_not_contains "release-input/dist/release" +} + +test_draft_creation_accepts_only_the_known_missing_tag_responses() { + assert_contains '*"HTTP 404"*) tag_sha="" ;;' + assert_contains '*"HTTP 422"*"No commit found for SHA"*|*"No commit found for SHA"*"HTTP 422"*) tag_sha="" ;;' + assert_count 2 'tag_sha="" ;;' + assert_contains '*) printf '\''%s\n'\'' "${tag_error}" >&2; exit "${tag_status}" ;;' +} + +test_publish_rechecks_the_tag_and_uses_least_privilege_post_publish_permissions() { + assert_contains " - name: Publish draft release" + if ! publish_draft_section | grep -F 'tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '\''.sha'\'')' >/dev/null 2>&1; then + echo "Publish draft release must recheck the release tag SHA." >&2 + exit 1 + fi + assert_not_contains " issues: write" +} + +test_build_verifies_assets_before_writing_the_publish_input() { + assert_contains " - name: Package native CLI release assets" + assert_contains " - name: Verify packaged release assets" + assert_before " - name: Package native CLI release assets" " - name: Verify packaged release assets" + assert_before " - name: Verify packaged release assets" " - name: Write release metadata" +} + +test_post_publish_automation_remains_outside_the_privileged_job() { + assert_contains " post-publish:" + assert_contains " - name: Dispatch release-please after native CLI publish" + assert_contains " - name: Sync release-please package releases" + assert_contains " - name: Mark release PR as tagged" + assert_count 2 " contents: write" + if ! post_publish_section | grep -F " - name: Setup Go" >/dev/null 2>&1; then + echo "Post-publish job must set up the pinned Go toolchain." >&2 + exit 1 + fi + if ! post_publish_section | grep -F " go-version-file: cli/.go-version" >/dev/null 2>&1 || ! post_publish_section | grep -F " cache-dependency-path: '**/go.sum'" >/dev/null 2>&1; then + echo "Post-publish Go setup must use the repository pin and cache dependency path." >&2 exit 1 fi + assert_post_publish_before " - name: Setup Go" " - name: Sync release-please package releases" } -test_attestation_permissions() { - assert_contains "$WORKFLOW" " actions: write" - assert_contains "$WORKFLOW" " id-token: write" - assert_contains "$WORKFLOW" " attestations: write" - assert_contains "$WORKFLOW" " artifact-metadata: write" -} - -test_go_is_available_for_package_release_sync() { - assert_contains "$WORKFLOW" " - name: Setup Go" - assert_contains "$WORKFLOW" " if: steps.release.outputs.publish == 'true' || steps.release.outputs.release == 'true'" - assert_before "$WORKFLOW" " - name: Setup Go" " - name: Sync release-please package releases" -} - -test_release_assets_are_attested() { - assert_contains "$WORKFLOW" " - name: Attest native CLI release assets" - assert_contains "$WORKFLOW" " if: steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true'" - assert_contains "$WORKFLOW" " uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26" - assert_contains "$WORKFLOW" " subject-path: dist/release/*" - assert_before "$WORKFLOW" " - name: Verify packaged release assets" " - name: Attest native CLI release assets" - assert_before "$WORKFLOW" " - name: Attest native CLI release assets" " - name: Upload native CLI assets" -} - -test_release_asset_attestations_are_verified() { - assert_contains "$WORKFLOW" " - name: Verify native CLI asset attestations" - assert_contains "$WORKFLOW" ' SIGNER_WORKFLOW: ${{ github.repository }}/.github/workflows/native-cli-publish.yml' - assert_contains "$WORKFLOW" ' for asset_path in dist/release/*; do' - assert_contains "$WORKFLOW" ' gh attestation verify "${asset_path}" \' - assert_contains "$WORKFLOW" ' --repo "${GITHUB_REPOSITORY}" \' - assert_contains "$WORKFLOW" ' --signer-workflow "${SIGNER_WORKFLOW}"' - assert_before "$WORKFLOW" " - name: Attest native CLI release assets" " - name: Verify native CLI asset attestations" - assert_before "$WORKFLOW" " - name: Verify native CLI asset attestations" " - name: Upload native CLI assets" -} - -test_release_please_is_dispatched_after_publish() { - assert_contains "$WORKFLOW" " - name: Dispatch release-please after native CLI publish" - assert_contains "$WORKFLOW" " if: github.event_name == 'push' && steps.release.outputs.publish == 'true' && steps.release.outputs.dry_run != 'true'" - assert_contains "$WORKFLOW" ' TARGET_BRANCH: ${{ github.ref_name }}' - assert_contains "$WORKFLOW" ' gh workflow run release-please.yml --ref "${TARGET_BRANCH}" -f branch="${TARGET_BRANCH}"' - assert_before "$WORKFLOW" " - name: Publish draft release" " - name: Dispatch release-please after native CLI publish" - assert_before "$WORKFLOW" " - name: Dispatch release-please after native CLI publish" " - name: Sync release-please package releases" -} - -test_native_cli_attestation_bundles_are_distributed_per_asset() { - assert_contains "$WORKFLOW" " - name: Distribute attestation bundles per asset" - assert_contains "$WORKFLOW" " scripts/distribute-attestation-bundles.sh \\" - assert_contains "$WORKFLOW" " --bundle \"\${BUNDLE_PATH}\" \\" - assert_contains "$WORKFLOW" " --release-dir dist/release" - assert_before "$WORKFLOW" " - name: Verify native CLI asset attestations" " - name: Distribute attestation bundles per asset" - assert_before "$WORKFLOW" " - name: Distribute attestation bundles per asset" " - name: Upload native CLI assets" - assert_contains "$WORKFLOW" " BUNDLE_NAME=\"\${asset_name}.sigstore.json\"" - assert_contains "$WORKFLOW" " echo \"Missing remote native CLI attestation bundle: \${BUNDLE_NAME}\" >&2" -} - -test_attestation_permissions -test_go_is_available_for_package_release_sync -test_release_assets_are_attested -test_release_asset_attestations_are_verified -test_release_please_is_dispatched_after_publish -test_native_cli_attestation_bundles_are_distributed_per_asset +test_build_and_publish_jobs_have_separate_trust_boundaries +test_unprivileged_build_uses_only_the_approved_event_commit +test_publish_validates_metadata_without_checking_out_source +test_assets_are_attested_after_the_manifest_is_verified +test_publish_rejects_manifest_and_existing_tag_mismatches +test_draft_creation_accepts_only_the_known_missing_tag_responses +test_publish_rechecks_the_tag_and_uses_least_privilege_post_publish_permissions +test_build_verifies_assets_before_writing_the_publish_input +test_post_publish_automation_remains_outside_the_privileged_job From 7ee46c7b253aa06f455954305ccbf283a5e6db24 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 15 Jul 2026 17:23:26 +0900 Subject: [PATCH 2/5] fix: Verify dispatcher provenance before first install (#1791) --- .github/workflows/build-and-test.yml | 20 + .uloop/project-runner-pin.json | 6 +- Assets/Tests/Editor/CliPathSetupFlowTests.cs | 11 +- .../Tests/Editor/CliPinReaderServiceTests.cs | 189 +++++++++ .../Editor/CliSetupApplicationServiceTests.cs | 74 ++-- .../Tests/Editor/NativeCliInstallerTests.cs | 64 +-- .../src/Editor/Application/CliPinReader.cs | 61 ++- .../Application/CliSetupApplicationService.cs | 65 ++- Packages/src/Editor/Domain/CliConstants.cs | 3 +- .../Infrastructure/CLI/CliPinReaderService.cs | 163 +++++++- .../CLI/NativeCliCommandBuilder.cs | 143 ++++--- .../Infrastructure/CLI/NativeCliInstaller.cs | 35 +- .../Setup/SetupWizardCliWorkflowController.cs | 7 +- .../UnityCliLoopSettingsCliSetupPresenter.cs | 7 +- Packages/src/project-runner-pin.json | 6 +- README.md | 31 +- README_ja.md | 29 +- SECURITY.md | 51 +++ .../{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 +- .../internal/dispatcher/dispatcher_test.go | 24 ++ cli/dispatcher/shared-inputs-stamp.json | 2 +- .../cmd/check-dispatcher-pin/main.go | 100 +++++ .../cmd/check-dispatcher-pin/main_test.go | 28 ++ .../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_guard.go | 167 ++++++++ .../automation/dispatcher_pin_guard_test.go | 143 +++++++ .../automation/dispatcher_pin_stamp.go | 240 ++++++++++++ .../automation/dispatcher_pin_stamp_test.go | 118 ++++++ docs/dispatcher-pin-release-order.md | 16 + scripts/install.ps1 | 33 +- scripts/install.sh | 10 +- .../test-bootstrap-manifest-from-bundle.sh | 19 + scripts/test-install-archive-manifest.sh | 7 +- scripts/test-install-release-filter.sh | 3 + 52 files changed, 2286 insertions(+), 220 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%) 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/cmd/stamp-dispatcher-pin/main.go create mode 100644 cli/release-automation/go.sum 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 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 create mode 100644 docs/dispatcher-pin-release-order.md 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 e5cbab37ef..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' @@ -68,6 +69,25 @@ 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: 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/.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/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 7616c43b10..66abfadce9 100644 --- a/Assets/Tests/Editor/CliPinReaderServiceTests.cs +++ b/Assets/Tests/Editor/CliPinReaderServiceTests.cs @@ -176,6 +176,195 @@ 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_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() + { + // 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 install.ps1\n" + + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc 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/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 5f260a9312..7a658384fb 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,18 +98,8 @@ public static CliPinLoadResult FromFailure(string errorMessage) public interface ICliPinReader { CliPinLoadResult LoadPackagePin(); + DispatcherBootstrapPinLoadResult LoadDispatcherBootstrapPin(); 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 5db10cfa6c..c0370d1e64 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,150 @@ 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."); + } + 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, + pin.DispatcherArchiveManifest); + } + + private static bool IsValidDispatcherReleaseTag(string dispatcherReleaseTag) + { + 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) + { + 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; + } + + 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 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' + || value >= 'a' && value <= 'f' + || value >= 'A' && value <= 'F'; } } } 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; } 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/README.md b/README.md index d7fe4e76af..a7eac2b59e 100644 --- a/README.md +++ b/README.md @@ -82,16 +82,43 @@ 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` 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. + 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" && \ +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: ```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 +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') ``` 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..c9825fb3aa 100644 --- a/README_ja.md +++ b/README_ja.md @@ -81,16 +81,41 @@ v2系に戻したい場合は、Settings で **Uninstall CLI** を押し、Unity Unity Package の setup を開かず、standalone の global CLI だけを入れたい場合に使ってください。 +最初に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 の場合: ```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" && \ +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 の場合: ```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 +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') ``` native CLI のインストール後、installer は古い npm package を `npm uninstall -g uloop-cli` で自動削除しようとします。 diff --git a/SECURITY.md b/SECURITY.md index f7a9f92da9..35cf3f7144 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,6 +15,57 @@ 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. + +### 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 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) { diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_test.go index 2e79b77579..f9ae168f14 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_test.go @@ -870,6 +870,30 @@ 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) 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/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..97587e22c5 --- /dev/null +++ b/cli/release-automation/cmd/check-dispatcher-pin/main.go @@ -0,0 +1,100 @@ +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) + } + warnIfDispatcherInstallerScriptsDrift(packagePin) + 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 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() + 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/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_guard.go b/cli/release-automation/internal/automation/dispatcher_pin_guard.go new file mode 100644 index 0000000000..aa50fd1569 --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_guard.go @@ -0,0 +1,167 @@ +package automation + +import ( + "bytes" + "context" + "crypto/sha256" + "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 { + 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, + deps dispatcherPinStampDeps, +) error { + values := dispatcherPinGuardValues{} + if err := json.Unmarshal(packagePin, &values); err != nil { + return fmt.Errorf("%s is invalid JSON: %w", unityPackageCliPinFile, err) + } + 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..cb685bfed0 --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go @@ -0,0 +1,143 @@ +package automation + +import ( + "context" + "errors" + "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 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 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) { + 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 new file mode 100644 index 0000000000..ae0e617c7c --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_stamp.go @@ -0,0 +1,240 @@ +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") + } + 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 +} + +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..40a5bab190 --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go @@ -0,0 +1,118 @@ +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", + }, 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 +} 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. 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-bootstrap-manifest-from-bundle.sh b/scripts/test-bootstrap-manifest-from-bundle.sh new file mode 100755 index 0000000000..8e02cd930a --- /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 | 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) +expected='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb uloop-dispatcher-darwin-arm64.tar.gz' + +if [ "$manifest" != "$expected" ]; then + echo "bootstrap manifest derivation mismatch" >&2 + exit 1 +fi 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 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 bc8be01b4bc456a1ae1985140a2eab5a128b04b8 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 15 Jul 2026 19:08:05 +0900 Subject: [PATCH 3/5] fix: Fail closed when C# security scanning fails (#1792) --- .github/workflows/build-and-test.yml | 1 + .github/workflows/security-scan.yml | 110 +++---- .../cmd/check-codeql-sarif/main.go | 31 ++ .../cmd/check-codeql-sarif/main_test.go | 15 + .../internal/automation/codeql_sarif_guard.go | 187 +++++++++++ .../automation/codeql_sarif_guard_test.go | 290 ++++++++++++++++++ .../testdata/codeql-cli-2.26.0-sarif.json | 1 + ...odeql-cli-2.26.0-security-probe-sarif.json | 35 +++ docs/security-scan-engine-decision.md | 39 +++ scripts/test-security-scan-workflow.sh | 57 ++++ 10 files changed, 705 insertions(+), 61 deletions(-) create mode 100644 cli/release-automation/cmd/check-codeql-sarif/main.go create mode 100644 cli/release-automation/cmd/check-codeql-sarif/main_test.go create mode 100644 cli/release-automation/internal/automation/codeql_sarif_guard.go create mode 100644 cli/release-automation/internal/automation/codeql_sarif_guard_test.go create mode 100644 cli/release-automation/internal/automation/testdata/codeql-cli-2.26.0-sarif.json create mode 100644 cli/release-automation/internal/automation/testdata/codeql-cli-2.26.0-security-probe-sarif.json create mode 100644 docs/security-scan-engine-decision.md create mode 100755 scripts/test-security-scan-workflow.sh diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 99a373e2ad..ff026b0225 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -40,6 +40,7 @@ jobs: scripts/test-is-release-please-release-commit.sh scripts/test-release-please-config.sh scripts/test-protocol-minimum-version-workflow.sh + scripts/test-security-scan-workflow.sh scripts/test-verify-native-cli-release-assets.sh scripts/test-verify-dispatcher-release-assets.sh scripts/test-mark-release-pr-tagged.sh diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 2dd6dc15a0..9096eff9ff 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -11,6 +11,12 @@ on: - 'cli/.go-version' - '**/go.mod' - '**/go.sum' + - 'cli/release-automation/**' + - '.github/workflows/**' + - 'scripts/**' + - 'cli/dispatcher/attestation/trusted_root.json' + - 'Packages/src/project-runner-pin.json' + - '.uloop/project-runner-pin.json' pull_request: branches: [ main, v3-beta ] paths: @@ -21,6 +27,12 @@ on: - 'cli/.go-version' - '**/go.mod' - '**/go.sum' + - 'cli/release-automation/**' + - '.github/workflows/**' + - 'scripts/**' + - 'cli/dispatcher/attestation/trusted_root.json' + - 'Packages/src/project-runner-pin.json' + - '.uloop/project-runner-pin.json' workflow_dispatch: jobs: @@ -29,7 +41,6 @@ jobs: runs-on: ubuntu-latest permissions: - actions: read contents: read security-events: write @@ -37,76 +48,53 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: - dotnet-version: '6.0.x' + go-version-file: cli/.go-version + cache: false - - name: Run SecurityCodeScan on Unity CLI Loop Package - run: | - # Create a temporary project file for scanning - cat > temp-unity-cli-loop.csproj << 'EOF' - - - net6.0 - enable - false - - - - - - - - - EOF - - # Run dotnet build to trigger SecurityCodeScan analysis - dotnet restore temp-unity-cli-loop.csproj - - # Build with continue on error and create a basic SARIF file if scanning fails - if ! dotnet build temp-unity-cli-loop.csproj --configuration Release --verbosity normal --no-restore; then - echo "SecurityCodeScan completed with warnings/errors - creating placeholder SARIF" - cat > security-results.sarif << 'SARIF_EOF' - { - "version": "2.1.0", - "$schema": "http://json.schemastore.org/sarif-2.1.0-rtm.5", - "runs": [ - { - "tool": { - "driver": { - "name": "SecurityCodeScan", - "informationUri": "https://security-code-scan.github.io/", - "version": "5.6.7" - } - }, - "results": [] - } - ] - } - SARIF_EOF - else - echo "SecurityCodeScan completed successfully" - fi - - - name: Upload SARIF results to GitHub - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa - if: always() && hashFiles('security-results.sarif') != '' + - name: Initialize CodeQL C# analysis + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: csharp + build-mode: none + tools: linked + queries: +security-extended + config: | + paths: + - Packages/src + - Assets/Tests + + - name: Analyze C# security code with CodeQL + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: - sarif_file: security-results.sarif - category: "SecurityCodeScan" + category: codeql-csharp-security + output: ${{ runner.temp }}/codeql-results + upload: never + + - name: Validate CodeQL SARIF + working-directory: cli/release-automation + run: go run ./cmd/check-codeql-sarif --sarif "${{ runner.temp }}/codeql-results/csharp.sarif" - - name: Upload security scan results as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - if: always() + - name: Upload verified CodeQL SARIF + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: - name: security-scan-results - path: security-results.sarif + sarif_file: ${{ runner.temp }}/codeql-results/csharp.sarif + category: codeql-csharp-security + + - name: Explain fork pull request SARIF upload policy + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork + run: echo "CodeQL SARIF upload is skipped for fork pull requests because their token has no security-events write permission." go-security: name: Go CLI Security Analysis runs-on: ubuntu-latest + permissions: + contents: read + steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd diff --git a/cli/release-automation/cmd/check-codeql-sarif/main.go b/cli/release-automation/cmd/check-codeql-sarif/main.go new file mode 100644 index 0000000000..5f6c74fc7d --- /dev/null +++ b/cli/release-automation/cmd/check-codeql-sarif/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +func main() { + sarifPath := flag.String("sarif", "", "path to the CodeQL SARIF report") + flag.Parse() + if *sarifPath == "" { + fmt.Fprintln(os.Stderr, "check-codeql-sarif: --sarif is required") + os.Exit(1) + } + result, err := automation.ValidateCodeQLSARIFFile(*sarifPath) + if err != nil { + fmt.Fprintln(os.Stderr, "check-codeql-sarif:", err) + os.Exit(1) + } + for _, warning := range result.Warnings { + fmt.Fprintln(os.Stderr, formatCodeQLWarning(warning)) + } + fmt.Println("CodeQL SARIF guard passed.") +} + +func formatCodeQLWarning(message string) string { + return "::warning title=CodeQL database quality::" + message +} diff --git a/cli/release-automation/cmd/check-codeql-sarif/main_test.go b/cli/release-automation/cmd/check-codeql-sarif/main_test.go new file mode 100644 index 0000000000..b52a418510 --- /dev/null +++ b/cli/release-automation/cmd/check-codeql-sarif/main_test.go @@ -0,0 +1,15 @@ +package main + +import ( + "strings" + "testing" +) + +func TestFormatCodeQLWarningUsesGitHubActionsAnnotation(t *testing.T) { + // Verifies nonblocking quality drift is visible as a GitHub Actions warning annotation. + warning := formatCodeQLWarning("quality drift") + + if !strings.HasPrefix(warning, "::warning title=CodeQL database quality::") { + t.Fatalf("expected GitHub Actions warning annotation, got %q", warning) + } +} diff --git a/cli/release-automation/internal/automation/codeql_sarif_guard.go b/cli/release-automation/internal/automation/codeql_sarif_guard.go new file mode 100644 index 0000000000..e0cda947bb --- /dev/null +++ b/cli/release-automation/internal/automation/codeql_sarif_guard.go @@ -0,0 +1,187 @@ +package automation + +import ( + "encoding/json" + "fmt" + "os" + "regexp" +) + +const ( + expectedCodeQLSARIFSchema = "https://json.schemastore.org/sarif-2.1.0.json" + expectedCodeQLSARIFVersion = "2.1.0" + expectedCodeQLToolName = "CodeQL" + expectedCodeQLToolSemanticVersion = "2.26.0" + // These hard floors detect extractor collapse without rejecting normal Unity-source composition changes. + minimumCodeQLCallTargetPercentage = 55 + minimumCodeQLKnownTypePercentage = 70 + // These warning baselines record the 2026-07-15 no-build PoC so quality drift remains visible without blocking valid source changes. + baselineCodeQLCallTargetPercentage = 68 + baselineCodeQLKnownTypePercentage = 82 + // This floor detects a hollowed database while allowing expected source movement from the 119122-line PoC. + minimumCodeQLLinesOfCode = 75000 + codeQLBuildlessCompletionMessage = "C# analysis with build-mode 'none' completed." + codeQLBuildlessCompletionDiagnosticID = "csharp/autobuilder/buildless/complete" + codeQLQualityDiagnosticID = "csharp/diagnostic/database-quality" + codeQLExtractedFilesDiagnosticID = "cs/diagnostics/successfully-extracted-files" + codeQLLinesOfCodeMetricID = "cs/summary/lines-of-code" +) + +var codeQLQualityMetricPattern = regexp.MustCompile(`Percentage of calls with call target: ([0-9]+) % .*Percentage of expressions with known type: ([0-9]+) %`) + +type codeQLSARIFReport struct { + Schema string `json:"$schema"` + Version string `json:"version"` + Runs []codeQLSARIFRun `json:"runs"` +} + +type codeQLSARIFRun struct { + Tool codeQLSARIFTool `json:"tool"` + Invocations []codeQLSARIFInvocation `json:"invocations"` + Properties codeQLSARIFProperties `json:"properties"` + Results []json.RawMessage `json:"results"` +} + +type codeQLSARIFTool struct { + Driver codeQLSARIFToolDriver `json:"driver"` +} + +type codeQLSARIFToolDriver struct { + Name string `json:"name"` + SemanticVersion string `json:"semanticVersion"` +} + +type codeQLSARIFInvocation struct { + ExecutionSuccessful bool `json:"executionSuccessful"` + ToolExecutionNotifications []codeQLSARIFNotification `json:"toolExecutionNotifications"` +} + +type codeQLSARIFNotification struct { + Descriptor codeQLSARIFDescriptor `json:"descriptor"` + Message codeQLSARIFMessage `json:"message"` +} + +type codeQLSARIFDescriptor struct { + ID string `json:"id"` +} + +type codeQLSARIFMessage struct { + Text string `json:"text"` +} + +type codeQLSARIFProperties struct { + MetricResults []codeQLSARIFMetricResult `json:"metricResults"` +} + +type codeQLSARIFMetricResult struct { + RuleID string `json:"ruleId"` + Value float64 `json:"value"` +} + +// CodeQLSARIFValidationResult carries nonblocking database-quality drift warnings. +type CodeQLSARIFValidationResult struct { + Warnings []string +} + +// ValidateCodeQLSARIFFile reads and validates a CodeQL SARIF report before upload. +func ValidateCodeQLSARIFFile(path string) (CodeQLSARIFValidationResult, error) { + data, err := os.ReadFile(path) + if err != nil { + return CodeQLSARIFValidationResult{}, fmt.Errorf("read CodeQL SARIF: %w", err) + } + return validateCodeQLSARIF(data) +} + +// ValidateCodeQLSARIF validates a completed, sufficiently complete CodeQL C# no-build analysis before upload. +func ValidateCodeQLSARIF(data []byte) error { + _, err := validateCodeQLSARIF(data) + return err +} + +func validateCodeQLSARIF(data []byte) (CodeQLSARIFValidationResult, error) { + report := codeQLSARIFReport{} + if err := json.Unmarshal(data, &report); err != nil { + return CodeQLSARIFValidationResult{}, fmt.Errorf("invalid SARIF JSON: %w", err) + } + if report.Schema != expectedCodeQLSARIFSchema || report.Version != expectedCodeQLSARIFVersion { + return CodeQLSARIFValidationResult{}, fmt.Errorf("expected SARIF schema %q and version %q", expectedCodeQLSARIFSchema, expectedCodeQLSARIFVersion) + } + if len(report.Runs) != 1 { + return CodeQLSARIFValidationResult{}, fmt.Errorf("expected exactly one SARIF run, got %d", len(report.Runs)) + } + run := report.Runs[0] + if run.Tool.Driver.Name != expectedCodeQLToolName { + return CodeQLSARIFValidationResult{}, fmt.Errorf("expected SARIF tool %q, got %q", expectedCodeQLToolName, run.Tool.Driver.Name) + } + if run.Tool.Driver.SemanticVersion != expectedCodeQLToolSemanticVersion { + return CodeQLSARIFValidationResult{}, fmt.Errorf("expected CodeQL version %q, got %q", expectedCodeQLToolSemanticVersion, run.Tool.Driver.SemanticVersion) + } + if len(run.Invocations) != 1 || !run.Invocations[0].ExecutionSuccessful { + return CodeQLSARIFValidationResult{}, fmt.Errorf("CodeQL invocation did not complete successfully") + } + return validateCodeQLSARIFQuality(run) +} + +func validateCodeQLSARIFQuality(run codeQLSARIFRun) (CodeQLSARIFValidationResult, error) { + invocation := run.Invocations[0] + hasBuildlessCompletion := false + extractedFileCount := 0 + // CodeQL 2.26.0 emits the database-quality diagnostic only below its own 85% thresholds. The strict semantic-version check above freezes that behavior, so absence means both metrics are at least 85%, not that evidence was silently lost. + callTargets := 100 + knownTypes := 100 + for _, notification := range invocation.ToolExecutionNotifications { + if notification.Descriptor.ID == codeQLBuildlessCompletionDiagnosticID && notification.Message.Text == codeQLBuildlessCompletionMessage { + hasBuildlessCompletion = true + } + if notification.Descriptor.ID == codeQLExtractedFilesDiagnosticID { + extractedFileCount++ + } + if notification.Descriptor.ID == codeQLQualityDiagnosticID { + parsedCallTargets, parsedKnownTypes, err := parseCodeQLQualityMetrics(notification.Message.Text) + if err != nil { + return CodeQLSARIFValidationResult{}, err + } + callTargets = parsedCallTargets + knownTypes = parsedKnownTypes + } + } + if !hasBuildlessCompletion { + return CodeQLSARIFValidationResult{}, fmt.Errorf("CodeQL SARIF is missing build-mode none completion diagnostic") + } + if extractedFileCount == 0 { + return CodeQLSARIFValidationResult{}, fmt.Errorf("CodeQL SARIF has no successfully extracted C# files") + } + if callTargets < minimumCodeQLCallTargetPercentage || knownTypes < minimumCodeQLKnownTypePercentage { + return CodeQLSARIFValidationResult{}, fmt.Errorf("CodeQL database quality is below the approved floor: call targets %d%%, known types %d%%", callTargets, knownTypes) + } + linesOfCode := float64(0) + for _, metric := range run.Properties.MetricResults { + if metric.RuleID == codeQLLinesOfCodeMetricID { + linesOfCode = metric.Value + } + } + if linesOfCode < float64(minimumCodeQLLinesOfCode) { + return CodeQLSARIFValidationResult{}, fmt.Errorf("CodeQL extracted lines of code %.0f is below the approved floor %d", linesOfCode, minimumCodeQLLinesOfCode) + } + warnings := []string{} + if callTargets < baselineCodeQLCallTargetPercentage || knownTypes < baselineCodeQLKnownTypePercentage { + warnings = append(warnings, fmt.Sprintf("CodeQL database quality is below the 2026-07-15 PoC baseline: call targets %d%% (baseline %d%%), known types %d%% (baseline %d%%)", callTargets, baselineCodeQLCallTargetPercentage, knownTypes, baselineCodeQLKnownTypePercentage)) + } + return CodeQLSARIFValidationResult{Warnings: warnings}, nil +} + +func parseCodeQLQualityMetrics(message string) (int, int, error) { + matches := codeQLQualityMetricPattern.FindStringSubmatch(message) + if len(matches) != 3 { + return 0, 0, fmt.Errorf("CodeQL database quality diagnostic has an unsupported format") + } + callTargets := 0 + knownTypes := 0 + if _, err := fmt.Sscanf(matches[1], "%d", &callTargets); err != nil { + return 0, 0, fmt.Errorf("parse CodeQL call target quality: %w", err) + } + if _, err := fmt.Sscanf(matches[2], "%d", &knownTypes); err != nil { + return 0, 0, fmt.Errorf("parse CodeQL known type quality: %w", err) + } + return callTargets, knownTypes, nil +} diff --git a/cli/release-automation/internal/automation/codeql_sarif_guard_test.go b/cli/release-automation/internal/automation/codeql_sarif_guard_test.go new file mode 100644 index 0000000000..63824b329f --- /dev/null +++ b/cli/release-automation/internal/automation/codeql_sarif_guard_test.go @@ -0,0 +1,290 @@ +package automation + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestValidateCodeQLSARIFAcceptsNonEmptyFindingAtQualityBaseline(t *testing.T) { + // Verifies a completed CodeQL security scan with a finding and the approved quality baseline is accepted. + sarif := []byte(`{ + "$schema":"https://json.schemastore.org/sarif-2.1.0.json", + "version":"2.1.0", + "runs":[{ + "tool":{"driver":{"name":"CodeQL","semanticVersion":"2.26.0"}}, + "properties":{"metricResults":[{"ruleId":"cs/summary/lines-of-code","value":119122}]}, + "invocations":[{"executionSuccessful":true,"toolExecutionNotifications":[ + {"descriptor":{"id":"csharp/autobuilder/buildless/complete"},"message":{"text":"C# analysis with build-mode 'none' completed."}}, + {"descriptor":{"id":"csharp/diagnostic/database-quality"},"message":{"text":"Some metrics of the database quality are: Percentage of calls with call target: 68 % (threshold 85 %). Percentage of expressions with known type: 82 % (threshold 85 %)."}}, + {"descriptor":{"id":"cs/diagnostics/successfully-extracted-files"},"message":{"text":""}} + ]}], + "results":[{"ruleId":"cs/security/test","message":{"text":"probe finding"}}] + }] + }`) + + result, err := validateCodeQLSARIF(sarif) + if err != nil { + t.Fatalf("expected valid CodeQL SARIF to pass: %v", err) + } + if len(result.Warnings) != 0 { + t.Fatalf("expected the PoC baseline not to warn, got %d warnings", len(result.Warnings)) + } +} + +func TestValidateCodeQLSARIFAcceptsCodeQLCLIProofFixture(t *testing.T) { + // Verifies parsing stays compatible with the SARIF shape emitted by the approved CodeQL 2.26.0 proof scan. + sarifPath := filepath.Join("testdata", "codeql-cli-2.26.0-sarif.json") + sarif, err := os.ReadFile(sarifPath) + if err != nil { + t.Fatalf("read CodeQL proof fixture: %v", err) + } + + if err := ValidateCodeQLSARIF(sarif); err != nil { + t.Fatalf("expected CodeQL proof fixture to pass: %v", err) + } +} + +func TestCodeQLCLIProofFixtureContainsSecurityFinding(t *testing.T) { + // Verifies the pinned CodeQL security-extended probe produced a real command-injection finding. + sarifPath := filepath.Join("testdata", "codeql-cli-2.26.0-security-probe-sarif.json") + sarif, err := os.ReadFile(sarifPath) + if err != nil { + t.Fatalf("read CodeQL security probe fixture: %v", err) + } + report := codeQLSARIFReport{} + if err := json.Unmarshal(sarif, &report); err != nil { + t.Fatalf("parse CodeQL security probe fixture: %v", err) + } + if len(report.Runs) != 1 || len(report.Runs[0].Results) == 0 { + t.Fatal("expected CodeQL security probe fixture to contain a finding") + } + finding := struct { + RuleID string `json:"ruleId"` + }{} + if err := json.Unmarshal(report.Runs[0].Results[0], &finding); err != nil { + t.Fatalf("parse CodeQL security probe finding: %v", err) + } + if finding.RuleID != "cs/command-line-injection" { + t.Fatalf("expected command-injection probe finding, got %q", finding.RuleID) + } +} + +func TestValidateCodeQLSARIFAcceptsQualityAboveCodeQLWarningThreshold(t *testing.T) { + // Verifies an improved database without a low-quality diagnostic is accepted. + sarif := validCodeQLSARIFWithoutQualityDiagnostic() + + if err := ValidateCodeQLSARIF(sarif); err != nil { + t.Fatalf("expected improved CodeQL quality to pass: %v", err) + } +} + +func TestValidateCodeQLSARIFRejectsMissingRun(t *testing.T) { + // Verifies a SARIF document without a CodeQL run cannot be mistaken for a successful zero-finding scan. + sarif := []byte(`{"version":"2.1.0","runs":[]}`) + + if err := ValidateCodeQLSARIF(sarif); err == nil { + t.Fatal("expected SARIF without a run to fail") + } +} + +func TestValidateCodeQLSARIFRejectsUnexpectedSchema(t *testing.T) { + // Verifies an incompatible SARIF schema is rejected before an upload can report misleading results. + sarif := validCodeQLSARIF(`"CodeQL"`, true, 55, 70) + sarif = []byte(strings.Replace(string(sarif), "sarif-2.1.0.json", "sarif-2.0.0.json", 1)) + + if err := ValidateCodeQLSARIF(sarif); err == nil { + t.Fatal("expected unexpected SARIF schema to fail") + } +} + +func TestValidateCodeQLSARIFRejectsUnexpectedCodeQLVersion(t *testing.T) { + // Verifies a CodeQL tool replacement is surfaced rather than silently changing the approved scanner. + sarif := validCodeQLSARIF(`"CodeQL"`, true, 55, 70) + sarif = []byte(strings.Replace(string(sarif), `"semanticVersion":"2.26.0"`, `"semanticVersion":"2.25.0"`, 1)) + + if err := ValidateCodeQLSARIF(sarif); err == nil { + t.Fatal("expected unexpected CodeQL version to fail") + } +} + +func TestValidateCodeQLSARIFFileRejectsMissingFile(t *testing.T) { + // Verifies a missing scanner output file fails instead of being treated as an empty successful scan. + missingPath := filepath.Join(t.TempDir(), "missing.sarif") + + if _, err := ValidateCodeQLSARIFFile(missingPath); err == nil { + t.Fatal("expected missing SARIF file to fail") + } +} + +func TestValidateCodeQLSARIFReportsNonblockingQualityDrift(t *testing.T) { + // Verifies quality below the PoC baseline but above the collapse floor produces a warning without failure. + sarif := validCodeQLSARIF(`"CodeQL"`, true, 67, 81) + + result, err := validateCodeQLSARIF(sarif) + if err != nil { + t.Fatalf("expected quality drift above the hard floor to pass: %v", err) + } + if len(result.Warnings) != 1 { + t.Fatalf("expected one quality drift warning, got %d", len(result.Warnings)) + } +} + +func TestValidateCodeQLSARIFRejectsUnexpectedTool(t *testing.T) { + // Verifies results from another scanner cannot satisfy the CodeQL scan gate. + sarif := validCodeQLSARIF(`"OtherScanner"`, true, 55, 70) + + if err := ValidateCodeQLSARIF(sarif); err == nil { + t.Fatal("expected unexpected tool identity to fail") + } +} + +func TestValidateCodeQLSARIFRejectsUnsuccessfulInvocation(t *testing.T) { + // Verifies a scanner failure is not accepted merely because it emitted SARIF. + sarif := validCodeQLSARIF(`"CodeQL"`, false, 55, 70) + + if err := ValidateCodeQLSARIF(sarif); err == nil { + t.Fatal("expected unsuccessful invocation to fail") + } +} + +func TestValidateCodeQLSARIFRejectsMissingBuildlessCompletion(t *testing.T) { + // Verifies a SARIF report from an unexpected analysis mode cannot pass the no-build scan gate. + sarif := validCodeQLSARIFWithoutCompletion() + + if err := ValidateCodeQLSARIF(sarif); err == nil { + t.Fatal("expected missing build-mode completion diagnostic to fail") + } +} + +func TestValidateCodeQLSARIFRejectsQualityRegression(t *testing.T) { + // Verifies reduced call-target or type-resolution quality fails before results are uploaded. + for _, quality := range []struct { + name string + callTargets int + knownTypes int + }{ + {name: "call targets", callTargets: 54, knownTypes: 70}, + {name: "known types", callTargets: 55, knownTypes: 69}, + } { + t.Run(quality.name, func(t *testing.T) { + sarif := validCodeQLSARIF(`"CodeQL"`, true, quality.callTargets, quality.knownTypes) + + if err := ValidateCodeQLSARIF(sarif); err == nil { + t.Fatal("expected quality regression to fail") + } + }) + } +} + +func TestValidateCodeQLSARIFRejectsNoSuccessfullyExtractedFiles(t *testing.T) { + // Verifies high percentage metrics cannot hide a database with no extracted C# source files. + sarif := validCodeQLSARIFWithExtractedFiles(0) + + if err := ValidateCodeQLSARIF(sarif); err == nil { + t.Fatal("expected empty extracted-file diagnostics to fail") + } +} + +func TestValidateCodeQLSARIFRejectsCompilationCollapse(t *testing.T) { + // Verifies compilation diagnostics without extracted source cannot pass as a successful no-build analysis. + sarif := []byte(`{ + "$schema":"https://json.schemastore.org/sarif-2.1.0.json", + "version":"2.1.0", + "runs":[{ + "tool":{"driver":{"name":"CodeQL","semanticVersion":"2.26.0"}}, + "properties":{"metricResults":[{"ruleId":"cs/summary/lines-of-code","value":75000}]}, + "invocations":[{"executionSuccessful":true,"toolExecutionNotifications":[ + {"descriptor":{"id":"csharp/autobuilder/buildless/complete"},"message":{"text":"C# analysis with build-mode 'none' completed."}}, + {"descriptor":{"id":"csharp/diagnostic/database-quality"},"message":{"text":"Some metrics of the database quality are: Percentage of calls with call target: 55 % (threshold 85 %). Percentage of expressions with known type: 70 % (threshold 85 %)."}}, + {"descriptor":{"id":"cs/compilation-error"},"message":{"text":"Error CS0246 compilation failed"}} + ]}], + "results":[] + }] + }`) + + if err := ValidateCodeQLSARIF(sarif); err == nil { + t.Fatal("expected compilation collapse fixture to fail") + } +} + +func validCodeQLSARIF(toolName string, executionSuccessful bool, callTargets int, knownTypes int) []byte { + return validCodeQLSARIFWithExtractedFilesAndValues(toolName, executionSuccessful, callTargets, knownTypes, 1) +} + +func validCodeQLSARIFWithExtractedFiles(extractedFiles int) []byte { + return validCodeQLSARIFWithExtractedFilesAndValues(`"CodeQL"`, true, 55, 70, extractedFiles) +} + +func validCodeQLSARIFWithExtractedFilesAndValues(toolName string, executionSuccessful bool, callTargets int, knownTypes int, extractedFiles int) []byte { + extractedFileNotifications := "" + for index := 0; index < extractedFiles; index++ { + extractedFileNotifications += `,{"descriptor":{"id":"cs/diagnostics/successfully-extracted-files"},"message":{"text":""}}` + } + return []byte(`{ + "$schema":"https://json.schemastore.org/sarif-2.1.0.json", + "version":"2.1.0", + "runs":[{ + "tool":{"driver":{"name":` + toolName + `,"semanticVersion":"2.26.0"}}, + "properties":{"metricResults":[{"ruleId":"cs/summary/lines-of-code","value":75000}]}, + "invocations":[{"executionSuccessful":` + boolJSON(executionSuccessful) + `,"toolExecutionNotifications":[ + {"descriptor":{"id":"csharp/autobuilder/buildless/complete"},"message":{"text":"C# analysis with build-mode 'none' completed."}}, + {"descriptor":{"id":"csharp/diagnostic/database-quality"},"message":{"text":"Some metrics of the database quality are: Percentage of calls with call target: ` + intJSON(callTargets) + ` % (threshold 85 %). Percentage of expressions with known type: ` + intJSON(knownTypes) + ` % (threshold 85 %)."}}` + extractedFileNotifications + ` + ]}], + "results":[{"ruleId":"cs/security/test","message":{"text":"probe finding"}}] + }] + }`) +} + +func validCodeQLSARIFWithoutCompletion() []byte { + return []byte(`{ + "$schema":"https://json.schemastore.org/sarif-2.1.0.json", + "version":"2.1.0", + "runs":[{ + "tool":{"driver":{"name":"CodeQL","semanticVersion":"2.26.0"}}, + "properties":{"metricResults":[{"ruleId":"cs/summary/lines-of-code","value":75000}]}, + "invocations":[{"executionSuccessful":true,"toolExecutionNotifications":[ + {"descriptor":{"id":"csharp/diagnostic/database-quality"},"message":{"text":"Some metrics of the database quality are: Percentage of calls with call target: 55 % (threshold 85 %). Percentage of expressions with known type: 70 % (threshold 85 %)."}}, + {"descriptor":{"id":"cs/diagnostics/successfully-extracted-files"},"message":{"text":""}} + ]}], + "results":[{"ruleId":"cs/security/test","message":{"text":"probe finding"}}] + }] + }`) +} + +func validCodeQLSARIFWithoutQualityDiagnostic() []byte { + return []byte(`{ + "$schema":"https://json.schemastore.org/sarif-2.1.0.json", + "version":"2.1.0", + "runs":[{ + "tool":{"driver":{"name":"CodeQL","semanticVersion":"2.26.0"}}, + "properties":{"metricResults":[{"ruleId":"cs/summary/lines-of-code","value":75000}]}, + "invocations":[{"executionSuccessful":true,"toolExecutionNotifications":[ + {"descriptor":{"id":"csharp/autobuilder/buildless/complete"},"message":{"text":"C# analysis with build-mode 'none' completed."}}, + {"descriptor":{"id":"cs/diagnostics/successfully-extracted-files"},"message":{"text":""}} + ]}], + "results":[] + }] + }`) +} + +func boolJSON(value bool) string { + if value { + return "true" + } + return "false" +} + +func TestIntJSONFormatsThreeDigitValues(t *testing.T) { + // Verifies test fixtures remain valid when a CodeQL quality percentage reaches 100. + if actual := intJSON(100); actual != "100" { + t.Fatalf("expected 100, got %q", actual) + } +} + +func intJSON(value int) string { + return strconv.Itoa(value) +} diff --git a/cli/release-automation/internal/automation/testdata/codeql-cli-2.26.0-sarif.json b/cli/release-automation/internal/automation/testdata/codeql-cli-2.26.0-sarif.json new file mode 100644 index 0000000000..bd43c2d1b4 --- /dev/null +++ b/cli/release-automation/internal/automation/testdata/codeql-cli-2.26.0-sarif.json @@ -0,0 +1 @@ +{"$schema":"https://json.schemastore.org/sarif-2.1.0.json","version":"2.1.0","runs":[{"tool":{"driver":{"name":"CodeQL","semanticVersion":"2.26.0"}},"invocations":[{"executionSuccessful":true,"toolExecutionNotifications":[{"descriptor":{"id":"csharp/autobuilder/buildless/complete","index":16},"message":{"text":"C# analysis with build-mode 'none' completed.","markdown":"C# analysis with build-mode 'none' completed."}},{"descriptor":{"id":"csharp/diagnostic/database-quality","index":6},"message":{"text":"Scanning C# code completed successfully, but the scan encountered issues. This may be caused by problems identifying dependencies or use of generated source code. Some metrics of the database quality are: Percentage of calls with call target: 68 % (threshold 85 %). Percentage of expressions with known type: 82 % (threshold 85 %). Ideally these metrics should be above their thresholds. Addressing these issues is advisable to avoid false-positives or missing results. If they cannot be addressed, consider scanning C# using either the `autobuild` or `manual` [build modes](https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages#comparison-of-the-build-modes)."}},{"descriptor":{"id":"cs/diagnostics/successfully-extracted-files","index":3},"message":{"text":""}}]}],"properties":{"metricResults":[{"ruleId":"cs/summary/lines-of-code","value":119122}]},"results":[]}]} diff --git a/cli/release-automation/internal/automation/testdata/codeql-cli-2.26.0-security-probe-sarif.json b/cli/release-automation/internal/automation/testdata/codeql-cli-2.26.0-security-probe-sarif.json new file mode 100644 index 0000000000..dd7b1e6408 --- /dev/null +++ b/cli/release-automation/internal/automation/testdata/codeql-cli-2.26.0-security-probe-sarif.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "CodeQL", + "semanticVersion": "2.26.0" + } + }, + "results": [ + { + "ruleId": "cs/command-line-injection", + "message": { + "text": "This command line depends on a user-provided value." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Packages/src/CodeQLSecurityProbe.cs" + } + } + } + ] + } + ], + "properties": { + "probeQueryPack": "codeql/csharp-queries@1.7.5", + "probeQuerySuite": "security-extended" + } + } + ] +} diff --git a/docs/security-scan-engine-decision.md b/docs/security-scan-engine-decision.md new file mode 100644 index 0000000000..9c78559b38 --- /dev/null +++ b/docs/security-scan-engine-decision.md @@ -0,0 +1,39 @@ +# C# Security Scan Engine Decision + +Date: 2026-07-15 + +## Decision + +The repository uses GitHub CodeQL for C# security analysis with `build-mode: none` and the `security-extended` query suite. The workflow pins `github/codeql-action` to the full commit SHA for v4.37.0 and selects its linked CodeQL 2.26.0 toolchain. A repository-owned Go guard validates the scanner identity, tool version, SARIF structure, successful no-build completion, extracted-source evidence, and database-quality floor before upload. + +The approved local proof analyzed all 975 C# files under `Packages/src` and `Assets/Tests`, generated SARIF 2.1.0, and reported call-target resolution of 68% and known-type resolution of 82%. A second proof added a temporary command-injection source outside the repository checkout. CodeQL 2.26.0 with `codeql/csharp-queries@1.7.5` and `security-extended` reported `cs/command-line-injection`. Only the resulting minimal SARIF evidence is retained under release-automation test data; the vulnerable source is not part of the repository. + +The probe is recorded rather than rerun as a separate CodeQL job on every pull request because a second database extraction and 70-query analysis would approximately double C# scan time. The production workflow fixes both the action commit and reported CodeQL semantic version, while tests reject scanner-version drift and require the recorded probe finding to remain non-empty. + +## Alternatives + +### SecurityCodeScan.VS2019 + +Rejected. Its latest repository release is 5.6.7 from September 5, 2022, which does not satisfy the 2026 maintenance requirement. The previous workflow also converted build failure into a zero-result placeholder SARIF, so it could not distinguish scanner failure from a clean scan. + +Primary source: + +### Semgrep OSS + +Not selected. Semgrep produces SARIF and its current language documentation lists C# support, but the OSS CLI identifies advanced C# analysis as a Pro-language capability. The local OSS proof scanned the repository and produced SARIF, but its C# security coverage was materially narrower than the selected CodeQL security suite. + +Primary sources: and + +### Microsoft/Roslyn analyzers + +Rejected for this workflow. .NET analyzers run as part of a C# project build. The repository proof against the Unity-generated project failed with `NETSDK1004` because the required Unity-generated restore assets were unavailable, which correctly demonstrates that an independent analyzer job cannot treat build failure as zero findings. + +Primary sources: and + +## Future accuracy improvement + +CodeQL no-build analysis cannot resolve every Unity API reference, which lowers interprocedural accuracy and can cause false negatives. A future, separately reviewed change may generate Unity project files in CI and evaluate CodeQL `autobuild` or `manual` mode with resolved Unity assemblies. That work is intentionally outside PR-3 because it adds Unity startup, dependency, and CI-time complexity; it must preserve the fail-closed SARIF and database-quality guards established here. + +Expanding the scan corpus to repository-local C# outside the shipped Unity package and its tests, including `Assets/Editor`, `Assets/Scenes`, `tools`, and `tests`, is also deferred to a separately reviewed change. Those sources were not part of the approved 975-file proof, so expansion requires remeasuring and reapproving the 68%/82% quality baseline and the 75,000-line extraction floor instead of silently applying package-derived thresholds to a different corpus. + +GitHub documents the supported C# build modes and their trade-offs at . diff --git a/scripts/test-security-scan-workflow.sh b/scripts/test-security-scan-workflow.sh new file mode 100755 index 0000000000..6ede4e5e36 --- /dev/null +++ b/scripts/test-security-scan-workflow.sh @@ -0,0 +1,57 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) +WORKFLOW="$ROOT_DIR/.github/workflows/security-scan.yml" + +assert_contains() { + expected=$1 + if ! grep -F -- "$expected" "$WORKFLOW" >/dev/null 2>&1; then + echo "Expected security workflow to contain: $expected" >&2 + exit 1 + fi +} + +assert_absent() { + unexpected=$1 + if grep -F -- "$unexpected" "$WORKFLOW" >/dev/null 2>&1; then + echo "Expected security workflow not to contain: $unexpected" >&2 + exit 1 + fi +} + +assert_step_order() { + first=$1 + second=$2 + first_line=$(grep -n -F -- "$first" "$WORKFLOW" | head -1 | cut -d: -f1) + second_line=$(grep -n -F -- "$second" "$WORKFLOW" | head -1 | cut -d: -f1) + if [ -z "$first_line" ] || [ -z "$second_line" ] || [ "$first_line" -ge "$second_line" ]; then + echo "Expected $first to precede $second" >&2 + exit 1 + fi +} + +assert_contains " contents: read" +assert_contains " security-events: write" +assert_absent " actions: read" +assert_contains "uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0" +assert_contains "uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0" +assert_contains "uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0" +assert_contains " build-mode: none" +assert_contains " tools: linked" +assert_contains " queries: +security-extended" +assert_contains " run: go run ./cmd/check-codeql-sarif --sarif \"\${{ runner.temp }}/codeql-results/csharp.sarif\"" +assert_contains " if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false" +assert_contains " if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork" +assert_contains " - '.github/workflows/**'" +assert_contains " - 'cli/release-automation/**'" +assert_contains " - 'scripts/**'" +assert_contains " - 'cli/dispatcher/attestation/trusted_root.json'" +assert_contains " - 'Packages/src/project-runner-pin.json'" +assert_contains " - '.uloop/project-runner-pin.json'" +assert_absent "SecurityCodeScan" +assert_absent "placeholder SARIF" +assert_absent "continue-on-error" +assert_absent "if: always()" +assert_step_order " - name: Analyze C# security code with CodeQL" " - name: Validate CodeQL SARIF" +assert_step_order " - name: Validate CodeQL SARIF" " - name: Upload verified CodeQL SARIF" From e278c5aaa697f42784d1593f79e83aa02eb2d45e Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 15 Jul 2026 21:23:12 +0900 Subject: [PATCH 4/5] fix: Limit Unix IPC endpoints to the current OS user (#1793) --- .../Editor/BridgeTransportEndpointTests.cs | 38 +- .../Editor/BridgeTransportListenerTests.cs | 61 +++ .../Tests/Editor/UnixEndpointSecurityTests.cs | 379 ++++++++++++++++++ .../Editor/UnixEndpointSecurityTests.cs.meta | 11 + Packages/src/Editor/Domain/CliConstants.cs | 4 +- .../Infrastructure/BridgeTransportEndpoint.cs | 8 +- .../Infrastructure/BridgeTransportListener.cs | 26 +- .../Infrastructure/UnixEndpointSecurity.cs | 284 +++++++++++++ .../UnixEndpointSecurity.cs.meta | 11 + .../Infrastructure/UnixNativeFileSystem.cs | 271 +++++++++++++ .../UnixNativeFileSystem.cs.meta | 11 + cli/common/clicontract/contract.json | 2 +- .../errors/error_envelope_classification.go | 5 + .../errors/unity_endpoint_not_created.go | 25 ++ .../errors/unity_endpoint_not_created_test.go | 16 + .../ipcendpoint/unity_endpoint_not_created.go | 12 + .../ipcendpoint/unix_endpoint_security.go | 128 ++++++ .../unix_endpoint_security_test.go | 93 +++++ .../unix_endpoint_security_unix_test.go | 53 +++ cli/common/project/endpoint_contract_test.go | 49 ++- cli/common/project/project.go | 17 +- cli/common/project/project_test.go | 10 +- cli/common/unityipc/dial.go | 24 ++ cli/common/unityipc/dial_test.go | 29 ++ cli/common/unityipc/dial_unix.go | 2 +- cli/common/unityipc/dial_windows.go | 2 +- .../unityipc/unix_endpoint_security_unix.go | 11 + .../unix_endpoint_security_windows.go | 9 + .../internal/dispatcher/dispatcher_test.go | 13 + cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- .../automation/release_trigger_guard.go | 1 + scripts/stamp-release-inputs.sh | 1 + tests/contracts/endpoint_contract.json | 18 +- 34 files changed, 1601 insertions(+), 27 deletions(-) create mode 100644 Assets/Tests/Editor/UnixEndpointSecurityTests.cs create mode 100644 Assets/Tests/Editor/UnixEndpointSecurityTests.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs create mode 100644 Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs create mode 100644 Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs.meta create mode 100644 cli/common/errors/unity_endpoint_not_created.go create mode 100644 cli/common/errors/unity_endpoint_not_created_test.go create mode 100644 cli/common/ipcendpoint/unity_endpoint_not_created.go create mode 100644 cli/common/ipcendpoint/unix_endpoint_security.go create mode 100644 cli/common/ipcendpoint/unix_endpoint_security_test.go create mode 100644 cli/common/ipcendpoint/unix_endpoint_security_unix_test.go create mode 100644 cli/common/unityipc/dial.go create mode 100644 cli/common/unityipc/dial_test.go create mode 100644 cli/common/unityipc/unix_endpoint_security_unix.go create mode 100644 cli/common/unityipc/unix_endpoint_security_windows.go diff --git a/Assets/Tests/Editor/BridgeTransportEndpointTests.cs b/Assets/Tests/Editor/BridgeTransportEndpointTests.cs index bb81625bcf..6d457e9089 100644 --- a/Assets/Tests/Editor/BridgeTransportEndpointTests.cs +++ b/Assets/Tests/Editor/BridgeTransportEndpointTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Runtime.InteropServices; using Newtonsoft.Json.Linq; @@ -59,11 +60,31 @@ public void CreateProjectIpc_WhenCanonicalRootsFromSharedContract_MatchExpectedE } } + /// + /// Verifies the contract permits a symlinked parent but never a symlinked endpoint directory. + /// + [Test] + public void UnixSecurityPolicy_WhenRead_DefinesParentAndEndpointSymlinkBoundary() + { + JObject contract = ReadEndpointContract(); + JObject policy = contract.Value("unixSecurityPolicy"); + + Assert.That(policy, Is.Not.Null); + Assert.That(policy.Value("parentPath"), Is.EqualTo("/tmp")); + Assert.That(policy.Value("parentMayBeSymbolicLink"), Is.True); + Assert.That(policy.Value("resolvedParentOwnerUid"), Is.EqualTo(0)); + Assert.That(policy.Value("resolvedParentRequiresStickyBit"), Is.True); + Assert.That(policy.Value("endpointDirectoryMayBeSymbolicLink"), Is.False); + Assert.That(policy.Value("endpointDirectoryMode"), Is.EqualTo("0700")); + Assert.That( + policy.Value("endpointDirectoryRejectedSpecialModes")?.Values(), + Is.EqualTo(new[] { "04700", "02700" })); + Assert.That(policy.Value("socketMode"), Is.EqualTo("0600")); + } + private static IEnumerable ReadEndpointContractCases() { - string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); - string json = File.ReadAllText(Path.Combine(projectRoot, SharedEndpointContractPath)); - JObject contract = JObject.Parse(json); + JObject contract = ReadEndpointContract(); JArray cases = contract.Value("cases"); Assert.That(cases, Is.Not.Null.And.Not.Empty); foreach (JToken caseToken in cases) @@ -72,6 +93,13 @@ private static IEnumerable ReadEndpointContractCases() } } + private static JObject ReadEndpointContract() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + string json = File.ReadAllText(Path.Combine(projectRoot, SharedEndpointContractPath)); + return JObject.Parse(json); + } + private static bool CanExerciseProjectRootOnCurrentPlatform(string projectRoot) { bool looksLikeWindowsPath = projectRoot.Length >= 2 && projectRoot[1] == ':'; @@ -90,7 +118,9 @@ private static string ExpectedEndpointPathForCurrentPlatform(JObject contractCas return contractCase.Value("windowsPipePath"); } - return contractCase.Value("unixSocketPath"); + string template = contractCase.Value("unixSocketPathTemplate"); + uint effectiveUserId = new UnixNativeFileSystem().GetEffectiveUserId(); + return template.Replace("", effectiveUserId.ToString(CultureInfo.InvariantCulture)); } } } diff --git a/Assets/Tests/Editor/BridgeTransportListenerTests.cs b/Assets/Tests/Editor/BridgeTransportListenerTests.cs index 426dcfe26e..ac92ace36b 100644 --- a/Assets/Tests/Editor/BridgeTransportListenerTests.cs +++ b/Assets/Tests/Editor/BridgeTransportListenerTests.cs @@ -2,6 +2,8 @@ using System.IO; using System.Threading; using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; using io.github.hatayama.UnityCliLoop.Infrastructure; @@ -68,6 +70,65 @@ public void AcceptClient_AfterStop_ThrowsObjectDisposedException() Assert.Throws( () => listener.AcceptClient(CancellationToken.None)); } + + /// + /// Verifies listener startup leaves an untrusted regular file untouched instead of deleting it. + /// + [Test] + public void Start_WhenEndpointPathIsRegularFile_FailsWithoutDeletingFile() + { + string endpointDirectory = Path.GetDirectoryName(_endpoint.Path); + UnixEndpointSecurityResult directoryResult = new UnixEndpointSecurityPolicy(new UnixNativeFileSystem()) + .EnsureEndpointDirectory(endpointDirectory); + Assert.That(directoryResult.Success, Is.True, directoryResult.ErrorMessage); + File.WriteAllText(_endpoint.Path, "untrusted"); + UnixDomainSocketBridgeTransportListener listener = new(_endpoint); + + Assert.Throws(() => listener.Start()); + Assert.That(File.Exists(_endpoint.Path), Is.True); + } + + /// + /// Verifies listener startup uses a 0700 directory and creates a 0600 Unix socket. + /// + [Test] + public void Start_WhenSuccessful_UsesOwnerOnlyDirectoryAndSocket() + { + UnixDomainSocketBridgeTransportListener listener = new(_endpoint); + listener.Start(); + UnixNativeFileSystem fileSystem = new(); + + UnixFileMetadata directoryMetadata = fileSystem.ReadMetadata( + Path.GetDirectoryName(_endpoint.Path), + followSymbolicLinks: false); + UnixFileMetadata socketMetadata = fileSystem.ReadMetadata( + _endpoint.Path, + followSymbolicLinks: false); + + Assert.That(directoryMetadata.Kind, Is.EqualTo(UnixFileKind.Directory)); + Assert.That(directoryMetadata.Mode, Is.EqualTo(0x1C0)); + Assert.That(socketMetadata.Kind, Is.EqualTo(UnixFileKind.Socket)); + Assert.That(socketMetadata.Mode, Is.EqualTo(0x180)); + listener.Stop(); + } + + /// + /// Verifies teardown warns and preserves an untrusted replacement instead of throwing or deleting it. + /// + [Test] + public void Stop_WhenSocketWasReplacedWithRegularFile_WarnsAndPreservesFile() + { + UnixDomainSocketBridgeTransportListener listener = new(_endpoint); + listener.Start(); + File.Delete(_endpoint.Path); + File.WriteAllText(_endpoint.Path, "replacement"); + LogAssert.Expect( + LogType.Warning, + new System.Text.RegularExpressions.Regex("Refusing to remove untrusted existing Unix endpoint")); + + Assert.DoesNotThrow(() => listener.Stop()); + Assert.That(File.Exists(_endpoint.Path), Is.True); + } } /// diff --git a/Assets/Tests/Editor/UnixEndpointSecurityTests.cs b/Assets/Tests/Editor/UnixEndpointSecurityTests.cs new file mode 100644 index 0000000000..b60d59be17 --- /dev/null +++ b/Assets/Tests/Editor/UnixEndpointSecurityTests.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies Unix endpoint directory and socket policy without depending on host ownership. + /// + public class UnixEndpointSecurityTests + { + private const string ParentPath = "/tmp"; + private const string EndpointDirectoryPath = "/tmp/uloop-501"; + private const string SocketPath = EndpointDirectoryPath + "/UnityCliLoop-test.sock"; + private const uint EffectiveUserId = 501; + + /// + /// Verifies a symlinked /tmp spelling is accepted when its followed target is root-owned and sticky. + /// + [Test] + public void EnsureEndpointDirectory_WhenParentIsSymlinkToSecureDirectory_Succeeds() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(ParentPath, false, Metadata(UnixFileKind.SymbolicLink, 0, 0x1FF)); + + UnixEndpointSecurityPolicy policy = new(fileSystem); + UnixEndpointSecurityResult result = policy.EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + } + + /// + /// Verifies the followed /tmp target must remain a root-owned sticky directory. + /// + [Test] + public void EnsureEndpointDirectory_WhenResolvedParentIsNotSticky_FailsClosed() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(ParentPath, true, Metadata(UnixFileKind.Directory, 0, 0x1FF)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + } + + /// + /// Verifies an endpoint-directory symlink is rejected even when its target would be secure. + /// + [Test] + public void EnsureEndpointDirectory_WhenEndpointDirectoryIsSymlink_FailsClosed() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata( + EndpointDirectoryPath, + false, + Metadata(UnixFileKind.SymbolicLink, EffectiveUserId, 0x1C0)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + } + + /// + /// Verifies an endpoint directory owned by another OS user is rejected without repair. + /// + [Test] + public void EnsureEndpointDirectory_WhenOwnerDoesNotMatchEffectiveUser_FailsWithoutChmod() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata( + EndpointDirectoryPath, + false, + Metadata(UnixFileKind.Directory, EffectiveUserId + 1, 0x1C0)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + Assert.That(fileSystem.ChangedModes, Is.Empty); + } + + /// + /// Verifies non-0700 permissions and special mode bits are rejected instead of auto-repaired. + /// + [TestCase(0x1F8u)] + [TestCase(0x1C7u)] + [TestCase(0x9C0u)] + [TestCase(0x5C0u)] + public void EnsureEndpointDirectory_WhenExistingModeIsNot0700_FailsWithoutChmod(uint mode) + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata( + EndpointDirectoryPath, + false, + Metadata(UnixFileKind.Directory, EffectiveUserId, mode)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + Assert.That(fileSystem.ChangedModes, Is.Empty); + } + + /// + /// Verifies a missing endpoint directory is created atomically with a non-widening 0700 mode. + /// + [Test] + public void EnsureEndpointDirectory_WhenMissing_CreatesWith0700() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(EndpointDirectoryPath, false, UnixFileMetadata.Missing()); + fileSystem.MetadataAfterCreate = Metadata(UnixFileKind.Directory, EffectiveUserId, 0x1C0); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(fileSystem.CreatedModes, Is.EqualTo(new[] { 0x1C0u })); + } + + /// + /// Verifies mkdir EEXIST joins the existing-directory validation path without changing permissions. + /// + [Test] + public void EnsureEndpointDirectory_WhenCreateReportsAlreadyExists_RevalidatesWithoutRepair() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(EndpointDirectoryPath, false, UnixFileMetadata.Missing()); + fileSystem.CreateResult = UnixNativeOperationResult.Failure(UnixNativeError.AlreadyExists); + fileSystem.MetadataAfterCreate = Metadata(UnixFileKind.Directory, EffectiveUserId, 0x1F8); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .EnsureEndpointDirectory(EndpointDirectoryPath); + + Assert.That(result.Success, Is.False); + Assert.That(fileSystem.ChangedModes, Is.Empty); + } + + /// + /// Verifies only an owner-only socket may be removed as a stale endpoint. + /// + [Test] + public void ValidateStaleSocket_WhenPathIsRegularFile_FailsClosed() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(SocketPath, false, Metadata(UnixFileKind.RegularFile, EffectiveUserId, 0x180)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .ValidateStaleSocket(SocketPath); + + Assert.That(result.Success, Is.False); + } + + /// + /// Verifies an owner-owned socket left before chmod may be removed after a crash. + /// + [Test] + public void ValidateStaleSocket_WhenOwnerSocketHasPermissiveMode_Succeeds() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(SocketPath, false, Metadata(UnixFileKind.Socket, EffectiveUserId, 0x1ED)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .ValidateStaleSocket(SocketPath); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + } + + /// + /// Verifies a freshly bound socket is restricted to 0600 before listener startup succeeds. + /// + [Test] + public void RestrictSocket_WhenCalled_ChangesModeTo0600() + { + FakeUnixNativeFileSystem fileSystem = CreateSecureFileSystem(); + fileSystem.SetMetadata(SocketPath, false, Metadata(UnixFileKind.Socket, EffectiveUserId, 0x1C0)); + + UnixEndpointSecurityResult result = new UnixEndpointSecurityPolicy(fileSystem) + .RestrictSocket(SocketPath); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(fileSystem.ChangedModes, Is.EqualTo(new[] { 0x180u })); + } + + /// + /// Verifies the native stat ABI reports real uid and mode values from the host filesystem. + /// + [Test] + public void UnixNativeFileSystem_WhenReadingRealDirectory_ReportsCurrentOwnerAnd0700() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Ignore("POSIX metadata is unavailable on Windows."); + } + + UnixNativeFileSystem fileSystem = new(); + string directoryPath = Path.Combine( + Path.GetTempPath(), + "uloop-native-stat-test-" + Guid.NewGuid().ToString("N")); + UnixNativeOperationResult createResult = fileSystem.CreateDirectory(directoryPath, 0x1C0); + Assert.That(createResult.Success, Is.True, $"mkdir errno {createResult.ErrorCode}"); + + try + { + UnixFileMetadata metadata = fileSystem.ReadMetadata( + directoryPath, + followSymbolicLinks: false); + Assert.That(metadata.ReadSuccess, Is.True, $"lstat errno {metadata.ErrorCode}"); + Assert.That(metadata.Kind, Is.EqualTo(UnixFileKind.Directory)); + Assert.That(metadata.OwnerUserId, Is.EqualTo(fileSystem.GetEffectiveUserId())); + Assert.That(metadata.Mode, Is.EqualTo(0x1C0)); + } + finally + { + Directory.Delete(directoryPath); + } + } + + /// + /// Verifies lstat may observe /tmp as a symlink while stat follows it to a root-owned sticky directory. + /// + [Test] + public void UnixNativeFileSystem_WhenReadingTmp_FollowsOnlyTheParentStatPath() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Ignore("POSIX metadata is unavailable on Windows."); + } + + UnixNativeFileSystem fileSystem = new(); + UnixFileMetadata noFollow = fileSystem.ReadMetadata(ParentPath, followSymbolicLinks: false); + UnixFileMetadata followed = fileSystem.ReadMetadata(ParentPath, followSymbolicLinks: true); + + Assert.That(noFollow.ReadSuccess, Is.True, $"lstat errno {noFollow.ErrorCode}"); + Assert.That( + noFollow.Kind, + Is.EqualTo(UnixFileKind.Directory).Or.EqualTo(UnixFileKind.SymbolicLink)); + Assert.That(followed.ReadSuccess, Is.True, $"stat errno {followed.ErrorCode}"); + Assert.That(followed.Kind, Is.EqualTo(UnixFileKind.Directory)); + Assert.That(followed.OwnerUserId, Is.EqualTo(0)); + Assert.That(followed.Mode & 0x200, Is.Not.EqualTo(0)); + } + + /// + /// Verifies each supported Editor OS and architecture selects the reviewed stat ABI offsets. + /// + [TestCase(true, false, Architecture.X64, 4, 2, 16, true)] + [TestCase(true, false, Architecture.Arm64, 4, 2, 16, false)] + [TestCase(false, true, Architecture.X64, 24, 4, 28, false)] + [TestCase(false, true, Architecture.Arm64, 16, 4, 24, false)] + public void UnixStatLayoutResolver_WhenPlatformIsSupported_ReturnsReviewedOffsets( + bool isMacOS, + bool isLinux, + Architecture architecture, + int expectedModeOffset, + int expectedModeSize, + int expectedOwnerUserIdOffset, + bool expectedDarwinInode64Symbols) + { + UnixStatLayoutResult result = UnixStatLayoutResolver.Resolve( + isMacOS, + isLinux, + architecture); + + Assert.That(result.Success, Is.True); + Assert.That(result.ModeOffset, Is.EqualTo(expectedModeOffset)); + Assert.That(result.ModeSize, Is.EqualTo(expectedModeSize)); + Assert.That(result.OwnerUserIdOffset, Is.EqualTo(expectedOwnerUserIdOffset)); + Assert.That(result.UseDarwinInode64Symbols, Is.EqualTo(expectedDarwinInode64Symbols)); + } + + /// + /// Verifies an unreviewed OS or architecture cannot fall through to a nearby stat ABI layout. + /// + [TestCase(false, true, Architecture.X86)] + [TestCase(false, false, Architecture.X64)] + public void UnixStatLayoutResolver_WhenPlatformIsUnsupported_FailsClosed( + bool isMacOS, + bool isLinux, + Architecture architecture) + { + UnixStatLayoutResult result = UnixStatLayoutResolver.Resolve( + isMacOS, + isLinux, + architecture); + + Assert.That(result.Success, Is.False); + } + + private static FakeUnixNativeFileSystem CreateSecureFileSystem() + { + FakeUnixNativeFileSystem fileSystem = new(EffectiveUserId); + fileSystem.SetMetadata(ParentPath, false, Metadata(UnixFileKind.Directory, 0, 0x3FF)); + fileSystem.SetMetadata(ParentPath, true, Metadata(UnixFileKind.Directory, 0, 0x3FF)); + fileSystem.SetMetadata( + EndpointDirectoryPath, + false, + Metadata(UnixFileKind.Directory, EffectiveUserId, 0x1C0)); + return fileSystem; + } + + private static UnixFileMetadata Metadata(UnixFileKind kind, uint ownerUserId, uint mode) + { + return UnixFileMetadata.Existing(kind, ownerUserId, mode); + } + + private sealed class FakeUnixNativeFileSystem : IUnixNativeFileSystem + { + private readonly Dictionary _noFollowMetadata = new(); + private readonly Dictionary _followMetadata = new(); + + public uint EffectiveUserId { get; } + public List CreatedModes { get; } = new(); + public List ChangedModes { get; } = new(); + public UnixFileMetadata MetadataAfterCreate { get; set; } + public UnixNativeOperationResult CreateResult { get; set; } = UnixNativeOperationResult.Successful(); + + public FakeUnixNativeFileSystem(uint effectiveUserId) + { + EffectiveUserId = effectiveUserId; + MetadataAfterCreate = UnixFileMetadata.Missing(); + } + + public uint GetEffectiveUserId() + { + return EffectiveUserId; + } + + public UnixFileMetadata ReadMetadata(string path, bool followSymbolicLinks) + { + Dictionary metadata = + followSymbolicLinks ? _followMetadata : _noFollowMetadata; + if (metadata.ContainsKey(path)) + { + return metadata[path]; + } + + return UnixFileMetadata.Missing(); + } + + public UnixNativeOperationResult CreateDirectory(string path, uint mode) + { + CreatedModes.Add(mode); + SetMetadata(path, false, MetadataAfterCreate); + return CreateResult; + } + + public UnixNativeOperationResult ChangeMode(string path, uint mode) + { + ChangedModes.Add(mode); + if (_noFollowMetadata.ContainsKey(path)) + { + UnixFileMetadata current = _noFollowMetadata[path]; + _noFollowMetadata[path] = UnixFileMetadata.Existing( + current.Kind, + current.OwnerUserId, + mode); + } + return UnixNativeOperationResult.Successful(); + } + + public void SetMetadata( + string path, + bool followSymbolicLinks, + UnixFileMetadata metadata) + { + Dictionary target = + followSymbolicLinks ? _followMetadata : _noFollowMetadata; + target[path] = metadata; + } + } + } +} diff --git a/Assets/Tests/Editor/UnixEndpointSecurityTests.cs.meta b/Assets/Tests/Editor/UnixEndpointSecurityTests.cs.meta new file mode 100644 index 0000000000..ce6781de91 --- /dev/null +++ b/Assets/Tests/Editor/UnixEndpointSecurityTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3e08821a99a1b4605ae82b5988c761e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 786fb76377..90289497ba 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -7,9 +7,9 @@ public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; // Why: the runtime IPC gate compares this contract generation, not release numbers. - // Bump it together with cli/contract.json protocolVersion only when this package can + // Bump it together with cli/common/clicontract/contract.json protocolVersion only when this package can // no longer interoperate with a different CLI protocol generation. - public const int REQUIRED_CLI_PROTOCOL_VERSION = 3; + public const int REQUIRED_CLI_PROTOCOL_VERSION = 4; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; public const string JSON_FLAG = "--json"; diff --git a/Packages/src/Editor/Infrastructure/BridgeTransportEndpoint.cs b/Packages/src/Editor/Infrastructure/BridgeTransportEndpoint.cs index bff5fddedf..9ed053324c 100644 --- a/Packages/src/Editor/Infrastructure/BridgeTransportEndpoint.cs +++ b/Packages/src/Editor/Infrastructure/BridgeTransportEndpoint.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using System.Text; using UnityEngine; @@ -41,9 +42,14 @@ public static BridgeTransportEndpoint CreateProjectIpc(string projectRoot) pipeName); } + uint effectiveUserId = new UnixNativeFileSystem().GetEffectiveUserId(); + string endpointDirectory = System.IO.Path.Combine( + UnixEndpointSecurityConstants.ParentPath, + UnixEndpointSecurityConstants.EndpointDirectoryPrefix + + effectiveUserId.ToString(CultureInfo.InvariantCulture)); return new BridgeTransportEndpoint( BridgeTransportKind.UnixDomainSocket, - System.IO.Path.Combine("/tmp/uloop", endpointName + ".sock"), + System.IO.Path.Combine(endpointDirectory, endpointName + ".sock"), string.Empty); } diff --git a/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs b/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs index 43896488c9..6f4b78a45d 100644 --- a/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs +++ b/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs @@ -85,6 +85,7 @@ public static IBridgeTransportListener Create(BridgeTransportEndpoint endpoint) /// internal sealed class UnixDomainSocketBridgeTransportListener : IBridgeTransportListener { + private readonly UnixEndpointSecurityPolicy _securityPolicy = new(new UnixNativeFileSystem()); private Socket _listener; private long _nextClientId; @@ -98,11 +99,17 @@ public UnixDomainSocketBridgeTransportListener(BridgeTransportEndpoint endpoint) public void Start() { string directory = Path.GetDirectoryName(Endpoint.Path); - if (!Directory.Exists(directory)) + UnixEndpointSecurityResult directoryResult = _securityPolicy.EnsureEndpointDirectory(directory); + if (!directoryResult.Success) { - Directory.CreateDirectory(directory); + throw new IOException(directoryResult.ErrorMessage); } + UnixEndpointSecurityResult staleSocketResult = _securityPolicy.ValidateStaleSocket(Endpoint.Path); + if (!staleSocketResult.Success) + { + throw new IOException(staleSocketResult.ErrorMessage); + } if (File.Exists(Endpoint.Path)) { File.Delete(Endpoint.Path); @@ -110,6 +117,13 @@ public void Start() _listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); _listener.Bind(new UnixDomainSocketEndPoint(Endpoint.Path)); + UnixEndpointSecurityResult socketModeResult = _securityPolicy.RestrictSocket(Endpoint.Path); + if (!socketModeResult.Success) + { + _listener.Close(); + _listener = null; + throw new IOException(socketModeResult.ErrorMessage); + } _listener.Listen(100); } @@ -160,6 +174,14 @@ public void Stop() listener.Close(); if (File.Exists(Endpoint.Path)) { + UnixEndpointSecurityResult staleSocketResult = _securityPolicy.ValidateStaleSocket(Endpoint.Path); + if (!staleSocketResult.Success) + { + // Why cleanup does not throw: refusing to delete an untrusted replacement is + // already the safe outcome, while domain-reload teardown must remain reliable. + UnityEngine.Debug.LogWarning(staleSocketResult.ErrorMessage); + return; + } File.Delete(Endpoint.Path); } } diff --git a/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs b/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs new file mode 100644 index 0000000000..84ef5fcf35 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs @@ -0,0 +1,284 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + internal static class UnixEndpointSecurityConstants + { + public const string ParentPath = "/tmp"; + public const string EndpointDirectoryPrefix = "uloop-"; + public const uint RootUserId = 0; + public const uint StickyBit = 0x200; + public const uint EndpointDirectoryMode = 0x1C0; + public const uint SocketMode = 0x180; + } + + internal enum UnixFileKind + { + Missing, + Directory, + SymbolicLink, + Socket, + RegularFile, + Other + } + + internal readonly struct UnixFileMetadata + { + public bool ReadSuccess { get; } + public int ErrorCode { get; } + public UnixFileKind Kind { get; } + public uint OwnerUserId { get; } + public uint Mode { get; } + + private UnixFileMetadata( + bool readSuccess, + int errorCode, + UnixFileKind kind, + uint ownerUserId, + uint mode) + { + ReadSuccess = readSuccess; + ErrorCode = errorCode; + Kind = kind; + OwnerUserId = ownerUserId; + Mode = mode; + } + + public static UnixFileMetadata Existing(UnixFileKind kind, uint ownerUserId, uint mode) + { + return new UnixFileMetadata(true, 0, kind, ownerUserId, mode); + } + + public static UnixFileMetadata Missing() + { + return new UnixFileMetadata(true, 0, UnixFileKind.Missing, 0, 0); + } + + public static UnixFileMetadata Failure(int errorCode) + { + return new UnixFileMetadata(false, errorCode, UnixFileKind.Other, 0, 0); + } + } + + internal readonly struct UnixNativeOperationResult + { + public bool Success { get; } + public int ErrorCode { get; } + + private UnixNativeOperationResult(bool success, int errorCode) + { + Success = success; + ErrorCode = errorCode; + } + + public static UnixNativeOperationResult Successful() + { + return new UnixNativeOperationResult(true, 0); + } + + public static UnixNativeOperationResult Failure(int errorCode) + { + return new UnixNativeOperationResult(false, errorCode); + } + } + + internal static class UnixNativeError + { + public const int NoEntry = 2; + public const int AlreadyExists = 17; + } + + internal interface IUnixNativeFileSystem + { + uint GetEffectiveUserId(); + UnixFileMetadata ReadMetadata(string path, bool followSymbolicLinks); + UnixNativeOperationResult CreateDirectory(string path, uint mode); + UnixNativeOperationResult ChangeMode(string path, uint mode); + } + + internal readonly struct UnixEndpointSecurityResult + { + public bool Success { get; } + public string ErrorMessage { get; } + + private UnixEndpointSecurityResult(bool success, string errorMessage) + { + Success = success; + ErrorMessage = errorMessage; + } + + public static UnixEndpointSecurityResult Successful() + { + return new UnixEndpointSecurityResult(true, string.Empty); + } + + public static UnixEndpointSecurityResult Failure(string errorMessage) + { + return new UnixEndpointSecurityResult(false, errorMessage); + } + } + + /// + /// Validates the Unix parent, per-user endpoint directory, and socket before filesystem mutation. + /// + internal sealed class UnixEndpointSecurityPolicy + { + private readonly IUnixNativeFileSystem _fileSystem; + + public UnixEndpointSecurityPolicy(IUnixNativeFileSystem fileSystem) + { + System.Diagnostics.Debug.Assert(fileSystem != null, "fileSystem must not be null"); + _fileSystem = fileSystem; + } + + public UnixEndpointSecurityResult EnsureEndpointDirectory(string endpointDirectoryPath) + { + System.Diagnostics.Debug.Assert( + !string.IsNullOrWhiteSpace(endpointDirectoryPath), + "endpointDirectoryPath must not be empty"); + + UnixEndpointSecurityResult parentResult = ValidateParent(); + if (!parentResult.Success) + { + return parentResult; + } + + UnixFileMetadata endpointMetadata = _fileSystem.ReadMetadata( + endpointDirectoryPath, + followSymbolicLinks: false); + if (!endpointMetadata.ReadSuccess) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to inspect Unix endpoint directory {endpointDirectoryPath}: errno {endpointMetadata.ErrorCode}"); + } + + if (endpointMetadata.Kind == UnixFileKind.Missing) + { + UnixNativeOperationResult createResult = _fileSystem.CreateDirectory( + endpointDirectoryPath, + UnixEndpointSecurityConstants.EndpointDirectoryMode); + if (!createResult.Success && createResult.ErrorCode != UnixNativeError.AlreadyExists) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to create Unix endpoint directory {endpointDirectoryPath}: errno {createResult.ErrorCode}"); + } + + // Why: EEXIST can mean another process won the creation race. Both successful + // creation and EEXIST must converge on the same no-follow owner/mode validation. + endpointMetadata = _fileSystem.ReadMetadata( + endpointDirectoryPath, + followSymbolicLinks: false); + if (!endpointMetadata.ReadSuccess) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to reinspect Unix endpoint directory {endpointDirectoryPath}: errno {endpointMetadata.ErrorCode}"); + } + } + + return ValidateEndpointDirectoryMetadata(endpointDirectoryPath, endpointMetadata); + } + + public UnixEndpointSecurityResult ValidateStaleSocket(string socketPath) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(socketPath), "socketPath must not be empty"); + + UnixFileMetadata metadata = _fileSystem.ReadMetadata(socketPath, followSymbolicLinks: false); + if (!metadata.ReadSuccess) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to inspect Unix socket {socketPath}: errno {metadata.ErrorCode}"); + } + if (metadata.Kind == UnixFileKind.Missing) + { + return UnixEndpointSecurityResult.Successful(); + } + if (metadata.Kind != UnixFileKind.Socket || + metadata.OwnerUserId != _fileSystem.GetEffectiveUserId()) + { + return UnixEndpointSecurityResult.Failure( + $"Refusing to remove untrusted existing Unix endpoint {socketPath}"); + } + + return UnixEndpointSecurityResult.Successful(); + } + + public UnixEndpointSecurityResult RestrictSocket(string socketPath) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(socketPath), "socketPath must not be empty"); + + // Why: bind may honor a permissive process umask, but the containing directory is + // already 0700, so other users cannot reach the socket before this chmod to 0600. + UnixNativeOperationResult result = _fileSystem.ChangeMode( + socketPath, + UnixEndpointSecurityConstants.SocketMode); + if (!result.Success) + { + return UnixEndpointSecurityResult.Failure( + $"Failed to restrict Unix socket {socketPath} to mode 0600: errno {result.ErrorCode}"); + } + + UnixFileMetadata metadata = _fileSystem.ReadMetadata(socketPath, followSymbolicLinks: false); + if (!metadata.ReadSuccess || + metadata.Kind != UnixFileKind.Socket || + metadata.OwnerUserId != _fileSystem.GetEffectiveUserId() || + metadata.Mode != UnixEndpointSecurityConstants.SocketMode) + { + return UnixEndpointSecurityResult.Failure( + $"Unix socket {socketPath} did not retain owner-only mode 0600"); + } + + return UnixEndpointSecurityResult.Successful(); + } + + private UnixEndpointSecurityResult ValidateParent() + { + UnixFileMetadata parentNoFollow = _fileSystem.ReadMetadata( + UnixEndpointSecurityConstants.ParentPath, + followSymbolicLinks: false); + if (!parentNoFollow.ReadSuccess || + (parentNoFollow.Kind != UnixFileKind.Directory && + parentNoFollow.Kind != UnixFileKind.SymbolicLink)) + { + return UnixEndpointSecurityResult.Failure( + "Unix endpoint parent /tmp must be a directory or a symbolic link to one"); + } + + UnixFileMetadata parentFollowed = _fileSystem.ReadMetadata( + UnixEndpointSecurityConstants.ParentPath, + followSymbolicLinks: true); + if (!parentFollowed.ReadSuccess || + parentFollowed.Kind != UnixFileKind.Directory || + parentFollowed.OwnerUserId != UnixEndpointSecurityConstants.RootUserId || + (parentFollowed.Mode & UnixEndpointSecurityConstants.StickyBit) == 0) + { + return UnixEndpointSecurityResult.Failure( + "Resolved Unix endpoint parent /tmp must be a root-owned sticky directory"); + } + + return UnixEndpointSecurityResult.Successful(); + } + + private UnixEndpointSecurityResult ValidateEndpointDirectoryMetadata( + string endpointDirectoryPath, + UnixFileMetadata metadata) + { + if (metadata.Kind != UnixFileKind.Directory) + { + return UnixEndpointSecurityResult.Failure( + $"Unix endpoint directory {endpointDirectoryPath} must be a real directory"); + } + if (metadata.OwnerUserId != _fileSystem.GetEffectiveUserId()) + { + return UnixEndpointSecurityResult.Failure( + $"Unix endpoint directory {endpointDirectoryPath} is not owned by the current user"); + } + if (metadata.Mode != UnixEndpointSecurityConstants.EndpointDirectoryMode) + { + // Why not chmod an existing directory: an attacker-controlled or accidentally + // shared path must be rejected, not transformed into a path the process trusts. + return UnixEndpointSecurityResult.Failure( + $"Unix endpoint directory {endpointDirectoryPath} must already have mode 0700"); + } + + return UnixEndpointSecurityResult.Successful(); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs.meta b/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs.meta new file mode 100644 index 0000000000..48ed1d9243 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnixEndpointSecurity.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d171f320293b4624baea80f9714ad7c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs b/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs new file mode 100644 index 0000000000..89c3d8f388 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs @@ -0,0 +1,271 @@ +using System.Runtime.InteropServices; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Provides the minimal POSIX metadata operations needed to secure the Unix IPC endpoint. + /// + internal sealed class UnixNativeFileSystem : IUnixNativeFileSystem + { + private const int UnsupportedPlatformError = 38; + private const int UnixNativeStatBufferSize = 144; + private const uint FileTypeMask = 0xF000; + private const uint DirectoryType = 0x4000; + private const uint RegularFileType = 0x8000; + private const uint SymbolicLinkType = 0xA000; + private const uint SocketType = 0xC000; + private const uint PermissionMask = 0x0FFF; + + public uint GetEffectiveUserId() + { +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return GetEffectiveUserIdNative(); +#else + return uint.MaxValue; +#endif + } + + public UnixFileMetadata ReadMetadata(string path, bool followSymbolicLinks) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(path), "path must not be empty"); + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + if (!System.BitConverter.IsLittleEndian) + { + return UnixFileMetadata.Failure(UnsupportedPlatformError); + } + + UnixStatLayoutResult layoutResult = UnixStatLayoutResolver.Resolve( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + RuntimeInformation.ProcessArchitecture); + if (!layoutResult.Success) + { + return UnixFileMetadata.Failure(UnsupportedPlatformError); + } + + // Why one shared buffer: libc stat and lstat write the same platform struct stat. + // A single maximum-sized buffer keeps their follow/no-follow ABI paths identical; + // Linux arm64 writes its 128-byte structure into this 144-byte allocation safely. + byte[] buffer = new byte[UnixNativeStatBufferSize]; + int result = ReadNativeStat( + path, + followSymbolicLinks, + layoutResult.UseDarwinInode64Symbols, + buffer); + if (result != 0) + { + int errorCode = Marshal.GetLastWin32Error(); + return errorCode == UnixNativeError.NoEntry + ? UnixFileMetadata.Missing() + : UnixFileMetadata.Failure(errorCode); + } + + uint mode = ReadUnsignedInteger(buffer, layoutResult.ModeOffset, layoutResult.ModeSize); + uint ownerUserId = ReadUnsignedInteger( + buffer, + layoutResult.OwnerUserIdOffset, + sizeof(uint)); + return UnixFileMetadata.Existing( + ToFileKind(mode), + ownerUserId, + mode & PermissionMask); +#else + return UnixFileMetadata.Failure(UnsupportedPlatformError); +#endif + } + + public UnixNativeOperationResult CreateDirectory(string path, uint mode) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(path), "path must not be empty"); + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + // Why this is atomic: mkdir applies the requested 0700 before publishing the + // directory. A process umask can remove permission bits, but can never widen 0700. + int result = MakeDirectoryNative(path, mode); + return result == 0 + ? UnixNativeOperationResult.Successful() + : UnixNativeOperationResult.Failure(Marshal.GetLastWin32Error()); +#else + return UnixNativeOperationResult.Failure(UnsupportedPlatformError); +#endif + } + + public UnixNativeOperationResult ChangeMode(string path, uint mode) + { + System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(path), "path must not be empty"); + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + int result = ChangeModeNative(path, mode); + return result == 0 + ? UnixNativeOperationResult.Successful() + : UnixNativeOperationResult.Failure(Marshal.GetLastWin32Error()); +#else + return UnixNativeOperationResult.Failure(UnsupportedPlatformError); +#endif + } + + private static UnixFileKind ToFileKind(uint mode) + { + uint fileType = mode & FileTypeMask; + switch (fileType) + { + case DirectoryType: + return UnixFileKind.Directory; + case RegularFileType: + return UnixFileKind.RegularFile; + case SymbolicLinkType: + return UnixFileKind.SymbolicLink; + case SocketType: + return UnixFileKind.Socket; + default: + return UnixFileKind.Other; + } + } + + private static uint ReadUnsignedInteger(byte[] buffer, int offset, int size) + { + System.Diagnostics.Debug.Assert(buffer != null, "buffer must not be null"); + System.Diagnostics.Debug.Assert(offset >= 0, "offset must not be negative"); + System.Diagnostics.Debug.Assert(size == sizeof(ushort) || size == sizeof(uint), "size must be 2 or 4"); + System.Diagnostics.Debug.Assert(offset + size <= buffer.Length, "field must fit in buffer"); + + return size == sizeof(ushort) + ? System.BitConverter.ToUInt16(buffer, offset) + : System.BitConverter.ToUInt32(buffer, offset); + } + + private static int ReadNativeStat( + string path, + bool followSymbolicLinks, + bool useDarwinInode64Symbols, + byte[] buffer) + { +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + if (useDarwinInode64Symbols) + { + // Why Intel macOS needs decorated symbols: Darwin's headers redirect the + // 64-bit-inode struct stat ABI to stat$INODE64/lstat$INODE64, while the plain + // legacy symbols use a different layout on x86_64. + // Source: https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/cdefs.h + return followSymbolicLinks + ? StatInode64Native(path, buffer) + : LStatInode64Native(path, buffer); + } + + return followSymbolicLinks + ? StatNative(path, buffer) + : LStatNative(path, buffer); +#else + return -1; +#endif + } + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + [DllImport("libc", EntryPoint = "geteuid", SetLastError = true)] + private static extern uint GetEffectiveUserIdNative(); + + [DllImport("libc", EntryPoint = "lstat", SetLastError = true)] + private static extern int LStatNative(string path, [Out] byte[] buffer); + + [DllImport("libc", EntryPoint = "stat", SetLastError = true)] + private static extern int StatNative(string path, [Out] byte[] buffer); + + [DllImport("libc", EntryPoint = "lstat$INODE64", SetLastError = true)] + private static extern int LStatInode64Native(string path, [Out] byte[] buffer); + + [DllImport("libc", EntryPoint = "stat$INODE64", SetLastError = true)] + private static extern int StatInode64Native(string path, [Out] byte[] buffer); + + [DllImport("libc", EntryPoint = "chmod", SetLastError = true)] + private static extern int ChangeModeNative(string path, uint mode); + + [DllImport("libc", EntryPoint = "mkdir", SetLastError = true)] + private static extern int MakeDirectoryNative(string path, uint mode); + +#endif + } + + internal readonly struct UnixStatLayoutResult + { + public bool Success { get; } + public int ModeOffset { get; } + public int ModeSize { get; } + public int OwnerUserIdOffset { get; } + public bool UseDarwinInode64Symbols { get; } + + private UnixStatLayoutResult( + bool success, + int modeOffset, + int modeSize, + int ownerUserIdOffset, + bool useDarwinInode64Symbols) + { + Success = success; + ModeOffset = modeOffset; + ModeSize = modeSize; + OwnerUserIdOffset = ownerUserIdOffset; + UseDarwinInode64Symbols = useDarwinInode64Symbols; + } + + public static UnixStatLayoutResult Supported( + int modeOffset, + int modeSize, + int ownerUserIdOffset, + bool useDarwinInode64Symbols) + { + return new UnixStatLayoutResult( + true, + modeOffset, + modeSize, + ownerUserIdOffset, + useDarwinInode64Symbols); + } + + public static UnixStatLayoutResult Unsupported() + { + return new UnixStatLayoutResult(false, 0, 0, 0, false); + } + } + + internal static class UnixStatLayoutResolver + { + public static UnixStatLayoutResult Resolve( + bool isMacOS, + bool isLinux, + Architecture architecture) + { + if (isMacOS && (architecture == Architecture.X64 || architecture == Architecture.Arm64)) + { + // Why these offsets: Darwin stat64 defines st_mode after st_dev at byte 4 and + // st_uid at byte 16 for both supported 64-bit Editor architectures. + // Source: https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/stat.h + return UnixStatLayoutResult.Supported( + 4, + sizeof(ushort), + 16, + useDarwinInode64Symbols: architecture == Architecture.X64); + } + + if (isLinux && architecture == Architecture.X64) + { + // Why these offsets: glibc x86_64 struct stat places st_mode at byte 24 and + // st_uid at byte 28 in its 144-byte layout. + // Source: https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/x86/bits/struct_stat.h + return UnixStatLayoutResult.Supported(24, sizeof(uint), 28, useDarwinInode64Symbols: false); + } + + if (isLinux && architecture == Architecture.Arm64) + { + // Why these offsets: glibc AArch64 struct stat places st_mode at byte 16 and + // st_uid at byte 24 in its 128-byte layout. + // Source: https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/bits/struct_stat.h + return UnixStatLayoutResult.Supported(16, sizeof(uint), 24, useDarwinInode64Symbols: false); + } + + // Why fail closed: selecting a nearby ABI layout can silently misread owner/mode + // metadata and turn a validation error into acceptance of an untrusted endpoint. + return UnixStatLayoutResult.Unsupported(); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs.meta b/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs.meta new file mode 100644 index 0000000000..a49ce08fdf --- /dev/null +++ b/Packages/src/Editor/Infrastructure/UnixNativeFileSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc5a65fd6a8814b51a1578abb7d3ac51 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/cli/common/clicontract/contract.json b/cli/common/clicontract/contract.json index 1aba4491cb..71c8d499ee 100644 --- a/cli/common/clicontract/contract.json +++ b/cli/common/clicontract/contract.json @@ -1,4 +1,4 @@ { - "protocolVersion": 3, + "protocolVersion": 4, "projectRunnerVersion": "3.0.0-beta.47" } diff --git a/cli/common/errors/error_envelope_classification.go b/cli/common/errors/error_envelope_classification.go index de3156204e..9942c37841 100644 --- a/cli/common/errors/error_envelope_classification.go +++ b/cli/common/errors/error_envelope_classification.go @@ -44,6 +44,11 @@ func classifyTypedError(err error, context ErrorContext) (CLIError, bool) { } func classifyUnityConnectionError(err error, context ErrorContext) (CLIError, bool) { + var endpointNotCreatedErr UnityEndpointNotCreatedError + if errors.As(err, &endpointNotCreatedErr) { + return unityEndpointNotCreatedCLIError(endpointNotCreatedErr, context), true + } + var notRespondingErr UnityServerNotRespondingError if errors.As(err, ¬RespondingErr) { return unityServerNotRespondingCLIError(notRespondingErr, context), true diff --git a/cli/common/errors/unity_endpoint_not_created.go b/cli/common/errors/unity_endpoint_not_created.go new file mode 100644 index 0000000000..a1401cba8c --- /dev/null +++ b/cli/common/errors/unity_endpoint_not_created.go @@ -0,0 +1,25 @@ +package clierrors + +import "github.com/hatayama/unity-cli-loop/common/ipcendpoint" + +type UnityEndpointNotCreatedError = ipcendpoint.UnityEndpointNotCreatedError + +func unityEndpointNotCreatedCLIError(err UnityEndpointNotCreatedError, context ErrorContext) CLIError { + return CLIError{ + ErrorCode: ErrorCodeUnityNotReachable, + Phase: ErrorPhaseConnection, + Message: "The Unity CLI Loop server is not reachable for this project.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: context.ProjectRoot, + Command: context.Command, + NextActions: []string{ + "If Unity is closed, run `uloop launch`.", + "If Unity is starting, compiling, or reloading scripts, wait and retry.", + "Confirm that the command targets the intended Unity project.", + }, + Details: map[string]any{ + "EndpointDirectory": err.EndpointDirectory, + }, + } +} diff --git a/cli/common/errors/unity_endpoint_not_created_test.go b/cli/common/errors/unity_endpoint_not_created_test.go new file mode 100644 index 0000000000..d667e2b975 --- /dev/null +++ b/cli/common/errors/unity_endpoint_not_created_test.go @@ -0,0 +1,16 @@ +package clierrors + +import "testing" + +// Verifies a missing private endpoint directory uses the existing not-running recovery guidance. +func TestUnityEndpointNotCreatedErrorClassifiesAsUnityNotReachable(t *testing.T) { + err := UnityEndpointNotCreatedError{EndpointDirectory: "/tmp/uloop-501"} + cliErr := ClassifyError(err, ErrorContext{Command: "status"}) + + if cliErr.ErrorCode != ErrorCodeUnityNotReachable || cliErr.Phase != ErrorPhaseConnection { + t.Fatalf("unexpected classification: %#v", cliErr) + } + if len(cliErr.NextActions) == 0 || cliErr.NextActions[0] != "If Unity is closed, run `uloop launch`." { + t.Fatalf("missing launch guidance: %#v", cliErr.NextActions) + } +} diff --git a/cli/common/ipcendpoint/unity_endpoint_not_created.go b/cli/common/ipcendpoint/unity_endpoint_not_created.go new file mode 100644 index 0000000000..b1befb0790 --- /dev/null +++ b/cli/common/ipcendpoint/unity_endpoint_not_created.go @@ -0,0 +1,12 @@ +package ipcendpoint + +import "fmt" + +// UnityEndpointNotCreatedError reports that Unity has not created its private IPC directory yet. +type UnityEndpointNotCreatedError struct { + EndpointDirectory string +} + +func (err UnityEndpointNotCreatedError) Error() string { + return fmt.Sprintf("Unity IPC endpoint directory has not been created: %s", err.EndpointDirectory) +} diff --git a/cli/common/ipcendpoint/unix_endpoint_security.go b/cli/common/ipcendpoint/unix_endpoint_security.go new file mode 100644 index 0000000000..f0abac26b3 --- /dev/null +++ b/cli/common/ipcendpoint/unix_endpoint_security.go @@ -0,0 +1,128 @@ +//go:build !windows + +package ipcendpoint + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" +) + +const ( + unixSocketParent = "/tmp" + unixPrivateDirectoryPermissions = uint32(0o700) + unixStickyBit = uint32(0o1000) +) + +type unixFileKind int + +const ( + unixFileKindOther unixFileKind = iota + unixFileKindDirectory + unixFileKindSymbolicLink + unixFileKindSocket +) + +type unixFileMetadata struct { + Kind unixFileKind + OwnerUserID uint32 + Permissions uint32 +} +type unixMetadataReader interface { + Lstat(string) (unixFileMetadata, error) + Stat(string) (unixFileMetadata, error) +} + +// Validate preserves the private Unix endpoint contract immediately before a client dials. +func Validate(network string, address string) error { + if network != "unix" { + return nil + } + return validateUnixEndpointPaths(unixSocketParent, filepath.Dir(address), uint32(os.Geteuid()), osUnixMetadataReader{}) +} + +func validateUnixEndpointPaths(parentPath string, endpointDirectoryPath string, effectiveUserID uint32, reader unixMetadataReader) error { + parentNoFollow, err := reader.Lstat(parentPath) + if err != nil { + return fmt.Errorf("inspect Unix endpoint parent without following links: %w", err) + } + if parentNoFollow.Kind != unixFileKindDirectory && parentNoFollow.Kind != unixFileKindSymbolicLink { + return fmt.Errorf("unix endpoint parent %s is neither a directory nor a symbolic link", parentPath) + } + parentFollowed, err := reader.Stat(parentPath) + if err != nil { + return fmt.Errorf("inspect resolved Unix endpoint parent: %w", err) + } + if parentFollowed.Kind != unixFileKindDirectory || parentFollowed.OwnerUserID != 0 || parentFollowed.Permissions&unixStickyBit == 0 { + return fmt.Errorf("resolved Unix endpoint parent %s must be a root-owned sticky directory", parentPath) + } + endpointDirectory, err := reader.Lstat(endpointDirectoryPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return UnityEndpointNotCreatedError{EndpointDirectory: endpointDirectoryPath} + } + return fmt.Errorf("inspect Unix endpoint directory: %w", err) + } + if endpointDirectory.Kind != unixFileKindDirectory { + return fmt.Errorf("unix endpoint directory %s must be a real directory", endpointDirectoryPath) + } + if endpointDirectory.OwnerUserID != effectiveUserID { + return fmt.Errorf("unix endpoint directory %s is not owned by the current user", endpointDirectoryPath) + } + if endpointDirectory.Permissions != unixPrivateDirectoryPermissions { + return fmt.Errorf("unix endpoint directory %s must have mode 0700", endpointDirectoryPath) + } + return nil +} + +type osUnixMetadataReader struct{} + +func (osUnixMetadataReader) Lstat(path string) (unixFileMetadata, error) { + info, err := os.Lstat(path) + if err != nil { + return unixFileMetadata{}, err + } + return unixMetadataFromFileInfo(info) +} + +func (osUnixMetadataReader) Stat(path string) (unixFileMetadata, error) { + info, err := os.Stat(path) + if err != nil { + return unixFileMetadata{}, err + } + return unixMetadataFromFileInfo(info) +} + +func unixMetadataFromFileInfo(info os.FileInfo) (unixFileMetadata, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return unixFileMetadata{}, fmt.Errorf("unix metadata for %s has unexpected type %T", info.Name(), info.Sys()) + } + permissions := uint32(info.Mode().Perm()) + if info.Mode()&os.ModeSetuid != 0 { + permissions |= 0o4000 + } + if info.Mode()&os.ModeSetgid != 0 { + permissions |= 0o2000 + } + if info.Mode()&os.ModeSticky != 0 { + permissions |= unixStickyBit + } + return unixFileMetadata{Kind: unixFileKindFromMode(info.Mode()), OwnerUserID: stat.Uid, Permissions: permissions}, nil +} + +func unixFileKindFromMode(mode os.FileMode) unixFileKind { + if mode&os.ModeSymlink != 0 { + return unixFileKindSymbolicLink + } + if mode.IsDir() { + return unixFileKindDirectory + } + if mode&os.ModeSocket != 0 { + return unixFileKindSocket + } + return unixFileKindOther +} diff --git a/cli/common/ipcendpoint/unix_endpoint_security_test.go b/cli/common/ipcendpoint/unix_endpoint_security_test.go new file mode 100644 index 0000000000..04ff7b9143 --- /dev/null +++ b/cli/common/ipcendpoint/unix_endpoint_security_test.go @@ -0,0 +1,93 @@ +//go:build !windows + +package ipcendpoint + +import ( + "errors" + "io/fs" + "testing" +) + +const ( + testUnixParentPath = "/tmp" + testUnixEndpointPath = "/tmp/uloop-501" + testEffectiveUserID = uint32(501) +) + +// Verifies the contract permits a symlinked /tmp parent only when stat resolves it to root-owned sticky storage. +func TestValidateUnixEndpointPathsAllowsSecureSymlinkedParent(t *testing.T) { + reader := secureUnixMetadataReader() + reader.noFollow[testUnixParentPath] = unixFileMetadata{Kind: unixFileKindSymbolicLink, OwnerUserID: 0, Permissions: 0o777} + if err := validateUnixEndpointPaths(testUnixParentPath, testUnixEndpointPath, testEffectiveUserID, reader); err != nil { + t.Fatalf("expected secure parent: %v", err) + } +} + +// Verifies a missing private endpoint directory stays a typed not-running state. +func TestValidateUnixEndpointPathsReturnsTypedMissingEndpointError(t *testing.T) { + reader := secureUnixMetadataReader() + delete(reader.noFollow, testUnixEndpointPath) + err := validateUnixEndpointPaths(testUnixParentPath, testUnixEndpointPath, testEffectiveUserID, reader) + var missing UnityEndpointNotCreatedError + if !errors.As(err, &missing) { + t.Fatalf("expected typed missing endpoint, got %T: %v", err, err) + } +} + +// Verifies insecure parent and endpoint metadata fail closed. +func TestValidateUnixEndpointPathsRejectsInsecureFilesystemMetadata(t *testing.T) { + tests := []struct { + name string + configure func(*fakeUnixMetadataReader) + }{ + {"non sticky parent", func(r *fakeUnixMetadataReader) { + r.follow[testUnixParentPath] = unixFileMetadata{Kind: unixFileKindDirectory, OwnerUserID: 0, Permissions: 0o777} + }}, + {"endpoint symlink", func(r *fakeUnixMetadataReader) { + r.noFollow[testUnixEndpointPath] = unixFileMetadata{Kind: unixFileKindSymbolicLink, OwnerUserID: testEffectiveUserID, Permissions: 0o700} + }}, + {"wrong endpoint owner", func(r *fakeUnixMetadataReader) { + r.noFollow[testUnixEndpointPath] = unixFileMetadata{Kind: unixFileKindDirectory, OwnerUserID: testEffectiveUserID + 1, Permissions: 0o700} + }}, + {"permissive endpoint mode", func(r *fakeUnixMetadataReader) { + r.noFollow[testUnixEndpointPath] = unixFileMetadata{Kind: unixFileKindDirectory, OwnerUserID: testEffectiveUserID, Permissions: 0o770} + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reader := secureUnixMetadataReader() + test.configure(reader) + if err := validateUnixEndpointPaths(testUnixParentPath, testUnixEndpointPath, testEffectiveUserID, reader); err == nil { + t.Fatal("expected security rejection") + } + }) + } +} + +type fakeUnixMetadataReader struct { + noFollow map[string]unixFileMetadata + follow map[string]unixFileMetadata +} + +func secureUnixMetadataReader() *fakeUnixMetadataReader { + return &fakeUnixMetadataReader{ + noFollow: map[string]unixFileMetadata{testUnixParentPath: {Kind: unixFileKindDirectory, OwnerUserID: 0, Permissions: 0o1777}, testUnixEndpointPath: {Kind: unixFileKindDirectory, OwnerUserID: testEffectiveUserID, Permissions: 0o700}}, + follow: map[string]unixFileMetadata{testUnixParentPath: {Kind: unixFileKindDirectory, OwnerUserID: 0, Permissions: 0o1777}}, + } +} + +func (r *fakeUnixMetadataReader) Lstat(path string) (unixFileMetadata, error) { + v, ok := r.noFollow[path] + if !ok { + return unixFileMetadata{}, fs.ErrNotExist + } + return v, nil +} + +func (r *fakeUnixMetadataReader) Stat(path string) (unixFileMetadata, error) { + v, ok := r.follow[path] + if !ok { + return unixFileMetadata{}, errors.New("missing followed metadata") + } + return v, nil +} diff --git a/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go b/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go new file mode 100644 index 0000000000..4b16596d59 --- /dev/null +++ b/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go @@ -0,0 +1,53 @@ +//go:build !windows + +package ipcendpoint + +import ( + "os" + "syscall" + "testing" + "time" +) + +// Verifies the live filesystem adapter reads the current user and exact private mode. +func TestOSUnixMetadataReaderReportsRealOwnerAndMode(t *testing.T) { + directory, err := os.MkdirTemp(unixSocketParent, "uloop-security-test-") + if err != nil { + t.Fatalf("create directory: %v", err) + } + t.Cleanup(func() { + if err := os.Remove(directory); err != nil { + t.Errorf("remove directory: %v", err) + } + }) + metadata, err := (osUnixMetadataReader{}).Lstat(directory) + if err != nil { + t.Fatalf("lstat directory: %v", err) + } + if metadata.Kind != unixFileKindDirectory || metadata.OwnerUserID != uint32(os.Geteuid()) || metadata.Permissions != unixPrivateDirectoryPermissions { + t.Fatalf("unexpected metadata: %#v", metadata) + } +} + +// Verifies set-ID bits remain visible to the exact 0700 policy comparison. +func TestUnixMetadataFromFileInfoPreservesSetIDBits(t *testing.T) { + metadata, err := unixMetadataFromFileInfo(fakeUnixFileInfo{mode: os.FileMode(0o700) | os.ModeDir | os.ModeSetuid | os.ModeSetgid, uid: uint32(os.Geteuid())}) + if err != nil { + t.Fatalf("convert metadata: %v", err) + } + if metadata.Permissions != 0o6700 { + t.Fatalf("expected set-ID bits, got %#o", metadata.Permissions) + } +} + +type fakeUnixFileInfo struct { + mode os.FileMode + uid uint32 +} + +func (f fakeUnixFileInfo) Name() string { return "endpoint" } +func (f fakeUnixFileInfo) Size() int64 { return 0 } +func (f fakeUnixFileInfo) Mode() os.FileMode { return f.mode } +func (f fakeUnixFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeUnixFileInfo) IsDir() bool { return f.mode.IsDir() } +func (f fakeUnixFileInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } diff --git a/cli/common/project/endpoint_contract_test.go b/cli/common/project/endpoint_contract_test.go index e5ecf28131..fa17e84fc7 100644 --- a/cli/common/project/endpoint_contract_test.go +++ b/cli/common/project/endpoint_contract_test.go @@ -4,22 +4,37 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "runtime" + "strconv" + "strings" "testing" ) const endpointContractPath = "tests/contracts/endpoint_contract.json" type endpointContractFile struct { - Comment string `json:"comment"` - Cases []endpointContractCase `json:"cases"` + Comment string `json:"comment"` + UnixSecurityPolicy endpointUnixSecurityPolicy `json:"unixSecurityPolicy"` + Cases []endpointContractCase `json:"cases"` +} + +type endpointUnixSecurityPolicy struct { + ParentPath string `json:"parentPath"` + ParentMayBeSymbolicLink bool `json:"parentMayBeSymbolicLink"` + ResolvedParentOwnerUID uint32 `json:"resolvedParentOwnerUid"` + ResolvedParentRequiresStickyBit bool `json:"resolvedParentRequiresStickyBit"` + EndpointDirectoryMayBeSymlink bool `json:"endpointDirectoryMayBeSymbolicLink"` + EndpointDirectoryMode string `json:"endpointDirectoryMode"` + EndpointDirectoryRejectedSpecialModes []string `json:"endpointDirectoryRejectedSpecialModes"` + SocketMode string `json:"socketMode"` } type endpointContractCase struct { ID string `json:"id"` CanonicalProjectRoot string `json:"canonicalProjectRoot"` TrimOnlyEquivalentRoots []string `json:"trimOnlyEquivalentRoots"` - UnixSocketPath string `json:"unixSocketPath"` + UnixSocketPathTemplate string `json:"unixSocketPathTemplate"` WindowsPipePath string `json:"windowsPipePath"` } @@ -49,7 +64,11 @@ func assertEndpointMatchesContract(t *testing.T, projectRoot string, contractCas t.Helper() endpoint := CreateEndpoint(projectRoot) - expected := contractCase.UnixSocketPath + expected := strings.ReplaceAll( + contractCase.UnixSocketPathTemplate, + "", + strconv.Itoa(os.Geteuid()), + ) if runtime.GOOS == "windows" { expected = contractCase.WindowsPipePath } @@ -58,6 +77,28 @@ func assertEndpointMatchesContract(t *testing.T, projectRoot string, contractCas } } +// Verifies the shared contract allows a symlinked parent while forbidding a symlinked endpoint directory. +func TestEndpointContractDefinesUnixSymlinkBoundary(t *testing.T) { + contract := readEndpointContract(t) + policy := contract.UnixSecurityPolicy + + if policy.ParentPath != "/tmp" || !policy.ParentMayBeSymbolicLink { + t.Fatalf("expected /tmp parent symlink allowance, got %#v", policy) + } + if policy.ResolvedParentOwnerUID != 0 || !policy.ResolvedParentRequiresStickyBit { + t.Fatalf("expected root-owned sticky resolved parent, got %#v", policy) + } + if policy.EndpointDirectoryMayBeSymlink { + t.Fatal("endpoint directory must reject symbolic links") + } + if policy.EndpointDirectoryMode != "0700" || policy.SocketMode != "0600" { + t.Fatalf("unexpected Unix endpoint modes: %#v", policy) + } + if !reflect.DeepEqual(policy.EndpointDirectoryRejectedSpecialModes, []string{"04700", "02700"}) { + t.Fatalf("expected set-ID modes to be rejected: %#v", policy) + } +} + func readEndpointContract(t *testing.T) endpointContractFile { t.Helper() diff --git a/cli/common/project/project.go b/cli/common/project/project.go index 870324a5d5..579e5ff43b 100644 --- a/cli/common/project/project.go +++ b/cli/common/project/project.go @@ -8,6 +8,7 @@ import ( "path/filepath" "runtime" "sort" + "strconv" "strings" clierrors "github.com/hatayama/unity-cli-loop/common/errors" @@ -17,7 +18,8 @@ import ( const ( ipcEndpointPrefix = "UnityCliLoop" ipcHashLength = 16 - unixSocketDir = "/tmp/uloop" + unixSocketParent = "/tmp" + unixSocketPrefix = "uloop-" windowsPipePrefix = `\\.\pipe\uloop` ) @@ -33,6 +35,13 @@ var excludedProjectSearchDirs = map[string]bool{ } func ResolveConnection(startPath string, explicitProjectPath string) (unityipc.Connection, error) { + return resolveConnection(startPath, explicitProjectPath) +} + +func resolveConnection( + startPath string, + explicitProjectPath string, +) (unityipc.Connection, error) { projectRoot, err := resolveProjectRoot(startPath, explicitProjectPath) if err != nil { return unityipc.Connection{}, err @@ -61,7 +70,11 @@ func CreateEndpoint(canonicalProjectRoot string) unityipc.Endpoint { return unityipc.Endpoint{ Network: "unix", - Address: filepath.Join(unixSocketDir, endpointName+".sock"), + Address: filepath.Join( + unixSocketParent, + unixSocketPrefix+strconv.Itoa(os.Geteuid()), + endpointName+".sock", + ), } } diff --git a/cli/common/project/project_test.go b/cli/common/project/project_test.go index cf28b742e2..0b0da1ade7 100644 --- a/cli/common/project/project_test.go +++ b/cli/common/project/project_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "testing" @@ -22,7 +23,10 @@ func TestCreateEndpointUsesStableProjectHash(t *testing.T) { return } - expectedPrefix := filepath.Join("/tmp/uloop", "UnityCliLoop-") + expectedPrefix := filepath.Join( + "/tmp/uloop-"+strconv.Itoa(os.Geteuid()), + "UnityCliLoop-", + ) if !strings.HasPrefix(endpoint.Address, expectedPrefix) { t.Fatalf("unexpected unix endpoint: %s", endpoint.Address) } @@ -106,7 +110,7 @@ func TestResolveConnection_WhenSettingsFileIsMissing_ShouldUseProjectPathEndpoin projectRoot := t.TempDir() createUnityProject(t, projectRoot) - connection, err := ResolveConnection(projectRoot, "") + connection, err := resolveConnection(projectRoot, "") if err != nil { t.Fatalf("ResolveConnection failed: %v", err) } @@ -127,7 +131,7 @@ func TestResolveConnection_WhenSettingsFileContainsStaleRuntimeState_ShouldIgnor t.Fatalf("failed to write stale settings: %v", err) } - connection, err := ResolveConnection(projectRoot, "") + connection, err := resolveConnection(projectRoot, "") if err != nil { t.Fatalf("ResolveConnection failed: %v", err) } diff --git a/cli/common/unityipc/dial.go b/cli/common/unityipc/dial.go new file mode 100644 index 0000000000..9207ab942c --- /dev/null +++ b/cli/common/unityipc/dial.go @@ -0,0 +1,24 @@ +package unityipc + +import ( + "context" + "net" +) + +type ( + endpointDialer func(context.Context, Endpoint) (net.Conn, error) + endpointValidator func(Endpoint) error +) + +// dialEndpoint is the only client transport boundary. Why: project resolution is also +// used by commands that never connect, so security validation belongs immediately before dial. +func dialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { + return dialEndpointWithValidatorAndDialer(ctx, endpoint, validateEndpointSecurity, platformDialEndpoint) +} + +func dialEndpointWithValidatorAndDialer(ctx context.Context, endpoint Endpoint, validator endpointValidator, dialer endpointDialer) (net.Conn, error) { + if err := validator(endpoint); err != nil { + return nil, err + } + return dialer(ctx, endpoint) +} diff --git a/cli/common/unityipc/dial_test.go b/cli/common/unityipc/dial_test.go new file mode 100644 index 0000000000..c8a7fad34f --- /dev/null +++ b/cli/common/unityipc/dial_test.go @@ -0,0 +1,29 @@ +package unityipc + +import ( + "context" + "errors" + "net" + "testing" +) + +// Verifies failed endpoint validation prevents every network dial attempt. +func TestDialEndpointWithValidatorAndDialerSkipsDialWhenValidationFails(t *testing.T) { + validationErr := errors.New("endpoint is insecure") + dialAttempts := 0 + _, err := dialEndpointWithValidatorAndDialer( + context.Background(), + Endpoint{Network: "unix", Address: "/tmp/uloop-501/test.sock"}, + func(Endpoint) error { return validationErr }, + func(context.Context, Endpoint) (net.Conn, error) { + dialAttempts++ + return nil, errors.New("must not dial") + }, + ) + if !errors.Is(err, validationErr) { + t.Fatalf("expected validation error, got %v", err) + } + if dialAttempts != 0 { + t.Fatalf("expected no dial attempts, got %d", dialAttempts) + } +} diff --git a/cli/common/unityipc/dial_unix.go b/cli/common/unityipc/dial_unix.go index 257ba27d9b..eea57d2a37 100644 --- a/cli/common/unityipc/dial_unix.go +++ b/cli/common/unityipc/dial_unix.go @@ -7,7 +7,7 @@ import ( "net" ) -func dialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { +func platformDialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { dialer := net.Dialer{} return dialer.DialContext(ctx, endpoint.Network, endpoint.Address) } diff --git a/cli/common/unityipc/dial_windows.go b/cli/common/unityipc/dial_windows.go index 1660af1b72..07db3ef4e9 100644 --- a/cli/common/unityipc/dial_windows.go +++ b/cli/common/unityipc/dial_windows.go @@ -9,6 +9,6 @@ import ( "github.com/Microsoft/go-winio" ) -func dialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { +func platformDialEndpoint(ctx context.Context, endpoint Endpoint) (net.Conn, error) { return winio.DialPipeContext(ctx, endpoint.Address) } diff --git a/cli/common/unityipc/unix_endpoint_security_unix.go b/cli/common/unityipc/unix_endpoint_security_unix.go new file mode 100644 index 0000000000..4fb8a27781 --- /dev/null +++ b/cli/common/unityipc/unix_endpoint_security_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package unityipc + +import ( + "github.com/hatayama/unity-cli-loop/common/ipcendpoint" +) + +func validateEndpointSecurity(endpoint Endpoint) error { + return ipcendpoint.Validate(endpoint.Network, endpoint.Address) +} diff --git a/cli/common/unityipc/unix_endpoint_security_windows.go b/cli/common/unityipc/unix_endpoint_security_windows.go new file mode 100644 index 0000000000..37bbd832e5 --- /dev/null +++ b/cli/common/unityipc/unix_endpoint_security_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package unityipc + +// Why: named pipes do not use the Unix filesystem endpoint boundary; the shared dial +// choke point deliberately keeps Windows transport behavior unchanged. +func validateEndpointSecurity(endpoint Endpoint) error { + return nil +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_test.go index f9ae168f14..8db78cd3c4 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_test.go @@ -1008,6 +1008,19 @@ func TestDispatcherReleaseAssetNameRejectsUnsupportedPlatform(t *testing.T) { } } +func TestResolveDispatcherProjectRootLaunchDoesNotRequireExistingEndpointDirectory(t *testing.T) { + // Verifies first launch resolves the Unity project before its private IPC directory exists. + projectRoot := createDispatcherUnityProject(t) + + resolved, err := resolveDispatcherProjectRoot(projectRoot, "", []string{"launch"}) + if err != nil { + t.Fatalf("first launch project resolution failed: %v", err) + } + if resolved != projectRoot { + t.Fatalf("unexpected launch project root: got %s want %s", resolved, projectRoot) + } +} + func createDispatcherUnityProject(t *testing.T) string { t.Helper() projectRoot := t.TempDir() diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 1c7bb15dbc..d9887930bd 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "9015856915596b7c6e58992664ccf8996e8d827e" + "sharedInputsHash": "d59d3b093b55893ccd07b5906f02658f23030bc5" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 38681b53cd..0f27802f88 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "c25f44bebdff747f69e1e6c653a4d8d9a5fc7c4f" + "sharedInputsHash": "1d0b11deca98e51fd880037a967a21e881619ed7" } diff --git a/cli/release-automation/internal/automation/release_trigger_guard.go b/cli/release-automation/internal/automation/release_trigger_guard.go index 8e58e06d78..3e60059b79 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard.go +++ b/cli/release-automation/internal/automation/release_trigger_guard.go @@ -153,6 +153,7 @@ var sharedCommonPackageRoots = []string{ "cli/common/clicontract/", "cli/common/clicore/", "cli/common/errors/", + "cli/common/ipcendpoint/", "cli/common/progress/", "cli/common/project/", "cli/common/skillscan/", diff --git a/scripts/stamp-release-inputs.sh b/scripts/stamp-release-inputs.sh index 1c79d9386c..4795ef7706 100755 --- a/scripts/stamp-release-inputs.sh +++ b/scripts/stamp-release-inputs.sh @@ -20,6 +20,7 @@ list_shared_common_inputs() { 'cli/common/clicontract/' \ 'cli/common/clicore/' \ 'cli/common/errors/' \ + 'cli/common/ipcendpoint/' \ 'cli/common/progress/' \ 'cli/common/project/' \ 'cli/common/skillscan/' \ diff --git a/tests/contracts/endpoint_contract.json b/tests/contracts/endpoint_contract.json index ef91b80ac7..2ab37e6b4f 100644 --- a/tests/contracts/endpoint_contract.json +++ b/tests/contracts/endpoint_contract.json @@ -1,5 +1,15 @@ { - "comment": "Maps already-canonicalized project roots to IPC endpoint names. Symlink/realpath resolution (Go filepath.EvalSymlinks / C# realpath) is intentionally out of scope because it depends on the live filesystem. Each canonicalProjectRoot is the post-resolution string that both sides hash. trimOnlyEquivalentRoots are trailing-separator variants that must hash identically after separator trimming only.", + "comment": "Maps already-canonicalized project roots to IPC endpoint names. Symlink/realpath resolution of the project root is intentionally out of scope because it depends on the live filesystem. Unix socket strings keep the unresolved /tmp spelling and replace with the effective user ID. The /tmp parent may itself be a symlink, but its followed target must be a root-owned sticky directory; the endpoint directory must never be a symlink.", + "unixSecurityPolicy": { + "parentPath": "/tmp", + "parentMayBeSymbolicLink": true, + "resolvedParentOwnerUid": 0, + "resolvedParentRequiresStickyBit": true, + "endpointDirectoryMayBeSymbolicLink": false, + "endpointDirectoryMode": "0700", + "endpointDirectoryRejectedSpecialModes": ["04700", "02700"], + "socketMode": "0600" + }, "cases": [ { "id": "unix-stable", @@ -7,14 +17,14 @@ "trimOnlyEquivalentRoots": [ "/Users/example/MyProject/" ], - "unixSocketPath": "/tmp/uloop/UnityCliLoop-bebe6bce5923715b.sock", + "unixSocketPathTemplate": "/tmp/uloop-/UnityCliLoop-bebe6bce5923715b.sock", "windowsPipePath": "\\\\.\\pipe\\uloop-UnityCliLoop-bebe6bce5923715b" }, { "id": "unix-case-sensitive", "canonicalProjectRoot": "/Users/example/myproject", "trimOnlyEquivalentRoots": [], - "unixSocketPath": "/tmp/uloop/UnityCliLoop-6b4ae0bfeab42b19.sock", + "unixSocketPathTemplate": "/tmp/uloop-/UnityCliLoop-6b4ae0bfeab42b19.sock", "windowsPipePath": "\\\\.\\pipe\\uloop-UnityCliLoop-6b4ae0bfeab42b19" }, { @@ -23,7 +33,7 @@ "trimOnlyEquivalentRoots": [ "C:\\Users\\example\\MyProject\\" ], - "unixSocketPath": "/tmp/uloop/UnityCliLoop-4abce6d9d9e51244.sock", + "unixSocketPathTemplate": "/tmp/uloop-/UnityCliLoop-4abce6d9d9e51244.sock", "windowsPipePath": "\\\\.\\pipe\\uloop-UnityCliLoop-4abce6d9d9e51244" } ] From 06fcced00e5038d7dcf09588b08bdfc453c02f19 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 15 Jul 2026 21:37:27 +0900 Subject: [PATCH 5/5] fix: remove bound socket after restriction failure --- .../Editor/BridgeTransportListenerTests.cs | 39 +++++++++++++++++++ .../Infrastructure/BridgeTransportListener.cs | 21 ++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Assets/Tests/Editor/BridgeTransportListenerTests.cs b/Assets/Tests/Editor/BridgeTransportListenerTests.cs index ac92ace36b..5fe991f9b8 100644 --- a/Assets/Tests/Editor/BridgeTransportListenerTests.cs +++ b/Assets/Tests/Editor/BridgeTransportListenerTests.cs @@ -112,6 +112,20 @@ public void Start_WhenSuccessful_UsesOwnerOnlyDirectoryAndSocket() listener.Stop(); } + /// + /// Verifies a failed socket restriction closes the listener and removes its bound socket file. + /// + [Test] + public void Start_WhenSocketRestrictionFails_RemovesBoundSocketFileBeforeThrowing() + { + UnixEndpointSecurityPolicy securityPolicy = new(new FailingSocketModeFileSystem()); + UnixDomainSocketBridgeTransportListener listener = + UnixDomainSocketBridgeTransportListener.CreateForTesting(_endpoint, securityPolicy); + + Assert.Throws(() => listener.Start()); + Assert.That(File.Exists(_endpoint.Path), Is.False); + } + /// /// Verifies teardown warns and preserves an untrusted replacement instead of throwing or deleting it. /// @@ -129,6 +143,31 @@ public void Stop_WhenSocketWasReplacedWithRegularFile_WarnsAndPreservesFile() Assert.DoesNotThrow(() => listener.Stop()); Assert.That(File.Exists(_endpoint.Path), Is.True); } + + private sealed class FailingSocketModeFileSystem : IUnixNativeFileSystem + { + private readonly IUnixNativeFileSystem _inner = new UnixNativeFileSystem(); + + public uint GetEffectiveUserId() + { + return _inner.GetEffectiveUserId(); + } + + public UnixFileMetadata ReadMetadata(string path, bool followSymbolicLinks) + { + return _inner.ReadMetadata(path, followSymbolicLinks); + } + + public UnixNativeOperationResult CreateDirectory(string path, uint mode) + { + return _inner.CreateDirectory(path, mode); + } + + public UnixNativeOperationResult ChangeMode(string path, uint mode) + { + return UnixNativeOperationResult.Failure(5); + } + } } /// diff --git a/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs b/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs index 6f4b78a45d..ac0c2763d9 100644 --- a/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs +++ b/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs @@ -85,15 +85,31 @@ public static IBridgeTransportListener Create(BridgeTransportEndpoint endpoint) /// internal sealed class UnixDomainSocketBridgeTransportListener : IBridgeTransportListener { - private readonly UnixEndpointSecurityPolicy _securityPolicy = new(new UnixNativeFileSystem()); + private readonly UnixEndpointSecurityPolicy _securityPolicy; private Socket _listener; private long _nextClientId; public BridgeTransportEndpoint Endpoint { get; } public UnixDomainSocketBridgeTransportListener(BridgeTransportEndpoint endpoint) + : this(endpoint, new UnixEndpointSecurityPolicy(new UnixNativeFileSystem())) + { + } + + internal static UnixDomainSocketBridgeTransportListener CreateForTesting( + BridgeTransportEndpoint endpoint, + UnixEndpointSecurityPolicy securityPolicy) + { + System.Diagnostics.Debug.Assert(securityPolicy != null, "securityPolicy must not be null"); + return new UnixDomainSocketBridgeTransportListener(endpoint, securityPolicy); + } + + private UnixDomainSocketBridgeTransportListener( + BridgeTransportEndpoint endpoint, + UnixEndpointSecurityPolicy securityPolicy) { Endpoint = endpoint; + _securityPolicy = securityPolicy; } public void Start() @@ -120,8 +136,7 @@ public void Start() UnixEndpointSecurityResult socketModeResult = _securityPolicy.RestrictSocket(Endpoint.Path); if (!socketModeResult.Success) { - _listener.Close(); - _listener = null; + Stop(); throw new IOException(socketModeResult.ErrorMessage); } _listener.Listen(100);