diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index e5cbab37ef..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
@@ -50,6 +51,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 +70,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/.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/.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/.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/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..5fe991f9b8 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,104 @@ 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 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.
+ ///
+ [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);
+ }
+
+ 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/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/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/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..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";
@@ -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/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..ac0c2763d9 100644
--- a/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs
+++ b/Packages/src/Editor/Infrastructure/BridgeTransportListener.cs
@@ -85,24 +85,47 @@ public static IBridgeTransportListener Create(BridgeTransportEndpoint endpoint)
///
internal sealed class UnixDomainSocketBridgeTransportListener : IBridgeTransportListener
{
+ 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()
{
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 +133,12 @@ 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)
+ {
+ Stop();
+ throw new IOException(socketModeResult.ErrorMessage);
+ }
_listener.Listen(100);
}
@@ -160,6 +189,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/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/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/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/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/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..8db78cd3c4 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)
@@ -984,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 2a069e1d14..d9887930bd 100644
--- a/cli/dispatcher/shared-inputs-stamp.json
+++ b/cli/dispatcher/shared-inputs-stamp.json
@@ -1,4 +1,4 @@
{
"schemaVersion": 1,
- "sharedInputsHash": "677ac7b5437d18efa8a09b575d0ec2619de4a1fa"
+ "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/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/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/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/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/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/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/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/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/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/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/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-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-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"
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
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"
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"
}
]