diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..81c79e6 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "Codebase Combiner" + +[setup] +script = "" + +[[actions]] +name = "Run" +icon = "run" +command = "./script/build_and_run.sh --verify" diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 54ddba8..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - root: true, - env: { - node: true, - es2021: true, - mocha: true, - }, - extends: ['eslint:recommended', 'prettier'], - parserOptions: { - ecmaVersion: 'latest', - }, - ignorePatterns: ['node_modules/', 'SwiftExplorerApp/.build/', 'coverage/', '*.vsix'], -}; diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f2682e0..64be02d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,7 +7,7 @@ body: attributes: value: | Thanks for taking the time to report a bug. - Please search existing issues before filing a new one. + Please search existing issues before filing a new one. Redact source code, local paths, credentials, combined output, and other private data from every field and attachment. - type: textarea id: summary attributes: @@ -45,7 +45,7 @@ body: id: logs attributes: label: Logs or screenshots - description: Paste relevant logs or attach screenshots. + description: Paste relevant logs or attach screenshots only after redacting source code, local paths, credentials, combined output, and other private data. render: shell validations: required: false @@ -54,7 +54,7 @@ body: attributes: label: Version description: App or extension version. - placeholder: 0.0.1 + placeholder: Extension 0.0.2 or macOS 0.1.0 candidate validations: required: false - type: dropdown @@ -72,6 +72,6 @@ body: attributes: label: Environment description: OS + toolchain versions. - placeholder: macOS 14.x, Node 18.x, Swift 6.x + placeholder: macOS version, Node 20+ or 24.x, Swift 6.x, Xcode version validations: required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1328fe7..e68f2ec 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Security reports - url: https://github.com/s1korrrr/codebase-combiner/security/advisories/new - about: Please report security vulnerabilities via private advisories. + url: https://github.com/s1korrrr/codebase-combiner/security/policy + about: Read the current private-reporting status; do not disclose vulnerabilities in a public issue. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 449898f..d7b508b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ - [ ] `npm run lint` - [ ] `npm run format:check` - [ ] `cd SwiftExplorerApp && swift test` -- [ ] `swiftformat --lint .` +- [ ] SwiftFormat 0.61.1: `test "$(swiftformat --version)" = "0.61.1" && swiftformat --lint .` # Checklist diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7fa90eb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + development-tools: + dependency-type: development + update-types: + - minor + - patch + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd996d3..37ed128 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,17 +4,28 @@ on: push: pull_request: +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build-test: runs-on: macos-latest + timeout-minutes: 30 + env: + SWIFTFORMAT_VERSION: 0.61.1 + SWIFTFORMAT_SHA256: b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 18 + node-version: 24 cache: npm - name: Install JS dependencies @@ -29,11 +40,54 @@ jobs: - name: JS tests run: npm test + - name: Audit JS dependencies + run: | + npm audit --omit=dev + npm audit + npm audit signatures + + - name: Package VS Code extension + run: | + npm run package + script/tests/vsix_inventory_test.sh + - name: Install SwiftFormat - run: brew install swiftformat + run: | + archive="$RUNNER_TEMP/swiftformat.zip" + install_dir="$RUNNER_TEMP/swiftformat" + curl --fail --location --silent --show-error \ + --output "$archive" \ + "https://github.com/nicklockwood/SwiftFormat/releases/download/$SWIFTFORMAT_VERSION/swiftformat.zip" + echo "$SWIFTFORMAT_SHA256 $archive" | shasum --algorithm 256 --check + unzip -q "$archive" -d "$install_dir" + echo "$install_dir" >> "$GITHUB_PATH" - name: SwiftFormat lint - run: swiftformat --lint . + run: | + test "$(swiftformat --version)" = "$SWIFTFORMAT_VERSION" + swiftformat --lint . - name: Swift tests run: cd SwiftExplorerApp && swift test + + - name: Swift Release build with warnings as errors + run: cd SwiftExplorerApp && swift build -c release -Xswiftc -warnings-as-errors + + - name: Shell contracts + run: | + bash -n Packaging/AppStore/build_app_store_package.sh + bash -n Packaging/DeveloperID/build_release.sh + bash -n Packaging/DeveloperID/notarize_release.sh + bash Packaging/AppStore/tests/validate_provisioning_profile_test.sh + bash script/tests/build_and_run_contract_test.sh + bash script/tests/open_source_release_contract_test.sh + + - name: Developer ID release contracts + run: Packaging/DeveloperID/tests/run_tests.sh + + - name: App Store bundle validation + run: | + plutil -lint Packaging/AppStore/AppStore.entitlements + plutil -lint Packaging/AppStore/Info.plist.in + plutil -lint Packaging/AppStore/PrivacyInfo.xcprivacy + Packaging/AppStore/build_app_store_package.sh --skip-signing diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..e757f89 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '23 4 * * 1' + +permissions: + actions: read + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze ${{ matrix.language }} + runs-on: macos-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + language: [javascript-typescript, swift] + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Initialize CodeQL + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + + - name: Analyze + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..170aea8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,246 @@ +name: macOS Developer ID Release + +on: + push: + tags: + - 'macos-v*' + +permissions: + contents: read + +jobs: + verify-source: + if: startsWith(github.ref, 'refs/tags/macos-v') + runs-on: macos-15 + timeout-minutes: 10 + permissions: + checks: read + contents: read + outputs: + commit: ${{ steps.verify.outputs.commit }} + version: ${{ steps.verify.outputs.version }} + steps: + - name: Checkout exact tag + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Verify signed annotated tag, main ancestry, and required checks + id: verify + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#macos-v}" + [[ "$version" =~ ^[0-9]+([.][0-9]+){1,2}$ ]] + notes="docs/release/$version/RELEASE_NOTES.md" + test -s "$notes" || { + echo "Release notes are missing or empty: $notes" >&2 + exit 1 + } + + ref_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$GITHUB_REF_NAME")" + test "$(jq -r '.object.type' <<< "$ref_json")" = tag + tag_object="$(jq -r '.object.sha' <<< "$ref_json")" + tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_object")" + test "$(jq -r '.verification.verified' <<< "$tag_json")" = true + + commit="$(jq -r '.object.sha' <<< "$tag_json")" + test "$commit" = "$GITHUB_SHA" + git fetch --no-tags origin main + test "$commit" = "$(git rev-parse origin/main)" + + checks="$(gh api "repos/$GITHUB_REPOSITORY/commits/$commit/check-runs?per_page=100")" + required_checks=(build-test 'Analyze javascript-typescript' 'Analyze swift') + for required_check in "${required_checks[@]}"; do + passed="$(jq --arg name "$required_check" \ + '[.check_runs[] | select(.name == $name and .status == "completed" and .conclusion == "success")] | length' \ + <<< "$checks")" + test "$passed" -ge 1 || { + echo "Required successful check is missing: $required_check" >&2 + exit 1 + } + done + + echo "commit=$commit" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + sign-notarize: + needs: verify-source + runs-on: macos-15 + timeout-minutes: 45 + environment: release + permissions: + attestations: write + contents: read + id-token: write + env: + SIGNING_IDENTITY: 'Developer ID Application: Rafal Sikora (2NY8A789TN)' + NOTARY_PROFILE: codebase-combiner-notary + steps: + - name: Require explicit release signing provisioning + env: + CI_SIGNING_PROVISIONED: ${{ vars.CI_SIGNING_PROVISIONED }} + run: | + test "$CI_SIGNING_PROVISIONED" = true || { + echo "Release signing is not provisioned. Configure the protected release environment before tagging." >&2 + exit 3 + } + + - name: Checkout verified source commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.verify-source.outputs.commit }} + fetch-depth: 0 + + - name: Initialize ephemeral release paths + run: echo "RELEASE_KEYCHAIN=$RUNNER_TEMP/release.keychain-db" >> "$GITHUB_ENV" + + - name: Import Developer ID identity + env: + DEVELOPER_ID_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12_BASE64 }} + DEVELOPER_ID_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + CI_DEVELOPER_ID_CERTIFICATE_SHA256: ${{ vars.CI_DEVELOPER_ID_CERTIFICATE_SHA256 }} + LOCAL_DEVELOPER_ID_CERTIFICATE_SHA256: ${{ vars.LOCAL_DEVELOPER_ID_CERTIFICATE_SHA256 }} + run: | + set -euo pipefail + umask 077 + [[ "$CI_DEVELOPER_ID_CERTIFICATE_SHA256" =~ ^[A-Fa-f0-9]{64}$ ]] + [[ "$LOCAL_DEVELOPER_ID_CERTIFICATE_SHA256" =~ ^[A-Fa-f0-9]{64}$ ]] + ci_fingerprint="$(printf '%s' "$CI_DEVELOPER_ID_CERTIFICATE_SHA256" | tr '[:lower:]' '[:upper:]')" + local_fingerprint="$(printf '%s' "$LOCAL_DEVELOPER_ID_CERTIFICATE_SHA256" | tr '[:lower:]' '[:upper:]')" + test "$ci_fingerprint" != "$local_fingerprint" + keychain_secret="$(openssl rand -hex 32)" + certificate="$RUNNER_TEMP/developer-id.p12" + public_certificate="$RUNNER_TEMP/developer-id.pem" + printf '%s' "$DEVELOPER_ID_P12_BASE64" | base64 --decode > "$certificate" + chmod 600 "$certificate" + security create-keychain -p "$keychain_secret" "$RELEASE_KEYCHAIN" + security set-keychain-settings -lut 21600 "$RELEASE_KEYCHAIN" + security unlock-keychain -p "$keychain_secret" "$RELEASE_KEYCHAIN" + security import "$certificate" -k "$RELEASE_KEYCHAIN" -P "$DEVELOPER_ID_P12_PASSWORD" -T /usr/bin/codesign + rm -f "$certificate" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_secret" "$RELEASE_KEYCHAIN" + security list-keychains -d user -s "$RELEASE_KEYCHAIN" + security find-identity -p codesigning -v "$RELEASE_KEYCHAIN" | grep -F "$SIGNING_IDENTITY" + security find-certificate -c "$SIGNING_IDENTITY" -p "$RELEASE_KEYCHAIN" > "$public_certificate" + actual_fingerprint="$(openssl x509 -in "$public_certificate" -noout -fingerprint -sha256 | sed 's/^sha256 Fingerprint=//; s/^SHA256 Fingerprint=//; s/://g' | tr '[:lower:]' '[:upper:]')" + test "$actual_fingerprint" = "$ci_fingerprint" + test "$actual_fingerprint" != "$local_fingerprint" + rm -f "$public_certificate" + + - name: Store notary credentials in the ephemeral Keychain + env: + APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + run: | + set -euo pipefail + umask 077 + key="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$key" + chmod 600 "$key" + xcrun notarytool store-credentials "$NOTARY_PROFILE" \ + --key "$key" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + --keychain "$RELEASE_KEYCHAIN" \ + --validate + rm -f "$key" + + - name: Build and sign the DMG + env: + DEVELOPER_ID_SOURCE_TAG: ${{ github.ref_name }} + run: | + Packaging/DeveloperID/build_release.sh \ + --version "${{ needs.verify-source.outputs.version }}" \ + --signing-identity "$SIGNING_IDENTITY" + + - name: Notarize, staple, and validate + run: | + version="${{ needs.verify-source.outputs.version }}" + Packaging/DeveloperID/notarize_release.sh \ + --dmg "dist/developer-id/Codebase-Combiner-$version-arm64.dmg" \ + --keychain-profile "$NOTARY_PROFILE" \ + --keychain "$RELEASE_KEYCHAIN" \ + --timeout 30m + + - name: Remove signing credentials after notarization + run: security delete-keychain "$RELEASE_KEYCHAIN" + + - name: Attest every published release subject + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + dist/developer-id/Codebase-Combiner-${{ needs.verify-source.outputs.version }}-arm64.dmg + dist/developer-id/Codebase-Combiner-${{ needs.verify-source.outputs.version }}-arm64.cdx.json + dist/developer-id/Codebase-Combiner-${{ needs.verify-source.outputs.version }}-arm64-symbols.zip + dist/developer-id/SHA256SUMS + dist/developer-id/release-manifest.json + dist/developer-id/notarization-summary.json + dist/developer-id/notarization-submission.json + dist/developer-id/notarization-log.json + + - name: Transfer verified release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-release-${{ needs.verify-source.outputs.version }} + if-no-files-found: error + retention-days: 14 + path: | + dist/developer-id/Codebase-Combiner-${{ needs.verify-source.outputs.version }}-arm64.dmg + dist/developer-id/Codebase-Combiner-${{ needs.verify-source.outputs.version }}-arm64.cdx.json + dist/developer-id/Codebase-Combiner-${{ needs.verify-source.outputs.version }}-arm64-symbols.zip + dist/developer-id/SHA256SUMS + dist/developer-id/release-manifest.json + dist/developer-id/notarization-summary.json + dist/developer-id/notarization-submission.json + dist/developer-id/notarization-log.json + + - name: Remove ephemeral signing material + if: always() + run: | + rm -f "$RUNNER_TEMP"/AuthKey_*.p8 "$RUNNER_TEMP/developer-id.p12" + if [[ -n "${RELEASE_KEYCHAIN:-}" ]]; then + security delete-keychain "$RELEASE_KEYCHAIN" || true + fi + + draft-release: + needs: [verify-source, sign-notarize] + runs-on: macos-15 + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout verified source commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.verify-source.outputs.commit }} + + - name: Download verified release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: macos-release-${{ needs.verify-source.outputs.version }} + path: release-assets + + - name: Create draft GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="${{ needs.verify-source.outputs.version }}" + notes="docs/release/$version/RELEASE_NOTES.md" + test -f "$notes" + gh release create "$GITHUB_REF_NAME" \ + --verify-tag \ + --draft \ + --title "Codebase Combiner $version for macOS" \ + --notes-file "$notes" \ + "release-assets/Codebase-Combiner-$version-arm64.dmg" \ + "release-assets/SHA256SUMS" \ + "release-assets/Codebase-Combiner-$version-arm64.cdx.json" \ + "release-assets/Codebase-Combiner-$version-arm64-symbols.zip" \ + "release-assets/release-manifest.json" \ + "release-assets/notarization-summary.json" \ + "release-assets/notarization-submission.json" \ + "release-assets/notarization-log.json" diff --git a/.gitignore b/.gitignore index e16e641..17c3a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,31 @@ SwiftExplorerApp/.build/ *.swp *.swo coverage/ +dist/ AGENTS.md PLAN.md TODO.md MEMORY.md +.worktrees/ +.superpowers/ + +# Apple signing and notarization secrets +*.p12 +AuthKey_*.p8 +*.key +*.pem +*.keychain +*.keychain-db +*.mobileprovision +*.provisionprofile + +# Local credentials +.env +.env.* +!.env.example + +# Release products +*.dmg +*.pkg +*.xcarchive +*.dSYM/ diff --git a/.swiftformat b/.swiftformat index 452d0b3..b2b5748 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,4 +1,5 @@ --swiftversion 6.0 +--disable redundantSendable --indent 4 --exclude SwiftExplorerApp/.build --exclude node_modules diff --git a/.vscodeignore b/.vscodeignore index 429eba2..4d9d588 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,8 +1,21 @@ .github/** .vscode/** +.codex/** +.superpowers/** +.worktrees/** +Packaging/** SwiftExplorerApp/** +dist/** +docs/** +script/** +test/** node_modules/.bin/** +node_modules/**/.tap/** +node_modules/**/.github/** +node_modules/**/*.map +node_modules/**/*.d.ts assets/icon.jpg +image.png AGENTS.md PLAN.md TODO.md @@ -12,10 +25,11 @@ CODE_OF_CONDUCT.md CONTRIBUTING.md SECURITY.md .editorconfig +.gitignore .eslintrc.cjs +eslint.config.cjs .prettierignore .prettierrc .swiftformat package-lock.json -test/** *.vsix diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c62f7..1290511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,47 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### VS Code extension + +- Advanced the candidate version to `0.0.2`; Marketplace publication remains a separate release action. +- Added structured skip summaries and Restricted Mode configuration boundaries while preserving explicitly empty one-run filters. +- Hardened traversal against symbolic-link roots, special-file blocking, recursive-glob denial of service, silent traversal errors, and forged plain-text path headers. +- Excluded local Git worktrees, app bundles, E2E evidence, agent artifacts, and other non-extension files from VSIX packages. + +## [0.1.0] - Release candidate + ### Added -- JS unit tests with Mocha/Chai and Swift XCTest coverage. -- ESLint/Prettier and SwiftFormat configuration. -- CI workflow for linting, formatting, and tests. +- Swift XCTest coverage and pinned SwiftFormat validation. +- CI workflows for build, test, CodeQL, and gated Developer ID release automation. - Open-source documentation (README, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY). - GitHub issue templates and PR template. -- README preview image and badges. -- VS Code packaging rules via `.vscodeignore`. +- README status and license badges. +- Focused macOS preference, workspace, output, dependency, command, and telemetry stores with injectable boundaries and behavioral tests. +- Structured scan outcomes and skipped-file summaries. +- Typed scan and persistence retry state with visible recovery controls. +- An isolated sandboxed E2E host with synthetic fixtures, exact-PID ownership, deterministic window sizing, and scoped cleanup. +- A single current interaction, performance, security, packaging, and release audit under `docs/audit/`. + +### Changed + +- Hardened the native scanner against symbolic-link roots, special-file blocking, malformed persisted size values, locale-dependent bounded selection, and silent traversal errors. +- Declared Apple file-timestamp required-reason APIs, embedded the MIT license in App Store bundles, and added source-bound App Store manifests, checksums, and operation locking. +- Made notarization resolve SBOM and symbols from the release manifest, publish flat checksum-verifiable evidence, and fail closed when hosted signing is not provisioned. +- Prevented concurrent or orphaned E2E hosts from sharing and resetting the same sandbox state. +- Rebuilt the macOS app as an adaptive three-workarea utility that remains usable at 960×640 and independently hides the workspace sidebar and output inspector. +- Consolidated app actions into shared menu, shortcut, toolbar, and button handlers and reduced Settings to one canonical macOS scene. +- Kept macOS 13 as the deployment floor while confining macOS 26 presentation to a bounded availability-gated style boundary. +- Reworked recovery so saved payload metadata is visible on relaunch while payload content stays concealed until Reveal; Copy Last does not reveal it, and Clear requires confirmation. +- Bounded current and recovered previews to 20,000 characters while keeping Copy and Save operations full-payload. +- Replaced content-bearing logging with typed metadata-only telemetry for scan, persistence, copy, save, and recovery outcomes. + +### Fixed + +- Prevented stale scans and asynchronous output/recovery completions from overwriting newer state. +- Rejected symbolic links before file metadata or content reads so scans cannot follow in-root or escaping link targets. +- Reserved a non-overlapping preparation region for every visible pane combination at compact, regular, and wide widths. +- Avoided macOS 27 beta AppKit constraint crashes by keeping sidebar and inspector hosts structurally stable during visibility transitions. ## [0.0.1] - 2026-01-14 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 25ee090..ab9c7b0 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -20,10 +20,7 @@ Examples of unacceptable behavior include: ## Reporting and enforcement -If you experience or witness unacceptable behavior, report it to the maintainers. - -- Preferred: request a private follow-up by opening a GitHub issue titled “Code of Conduct” with minimal details. -- Maintainers will respond promptly and move the discussion to a private channel. +If you experience or witness unacceptable behavior, use the maintainer's private contact method listed on their GitHub profile and include “Code of Conduct” in the subject. Do not include unrelated personal data. The maintainer will review the report privately and coordinate any necessary follow-up. Maintainers will review and take appropriate action, which may include warnings, temporary bans, or permanent bans from the project spaces. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb1a14e..fa8be3a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,12 +5,13 @@ Thanks for contributing! This project aims to keep changes small, well-tested, a ## Getting started - Read `INSTALL.md` for setup. +- Use SwiftFormat 0.61.1; other versions are not equivalent to CI. - Run tests and lint before submitting: - `npm test` - `npm run lint` - `npm run format:check` - `cd SwiftExplorerApp && swift test` - - `swiftformat --lint .` + - `test "$(swiftformat --version)" = "0.61.1" && swiftformat --lint .` ## Pull requests diff --git a/INSTALL.md b/INSTALL.md index 9ed3b3b..f02315d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -7,13 +7,25 @@ This repo contains two deliverables: ## Prerequisites -- macOS 13 or newer. +- The candidate declares a macOS 13 deployment target. Runtime verification at that floor remains pending. - Xcode Command Line Tools (for `swift`); install with: ```sh xcode-select --install ``` - Swift 6.0+ (`swift --version` should show a 6.x toolchain). -- Node.js 18+ and npm (for the VS Code extension). +- Node.js 20+ and npm (for the VS Code extension packaging toolchain). + +## Install an official macOS release + +After an official release is published, download the DMG and `SHA256SUMS` from the same [GitHub Release](https://github.com/s1korrrr/codebase-combiner/releases). The planned 0.1.0 package is Apple silicon (`arm64`) and declares a macOS 13 deployment target. Runtime support is not considered proven until the direct-distribution checklist contains a successful macOS 13 clean-account smoke. + +Verify the download: + +```sh +shasum -a 256 -c SHA256SUMS +``` + +Open `Codebase-Combiner--arm64.dmg`, drag **Codebase Combiner** to `/Applications`, and launch it normally from Finder. The official artifact is signed with Apple Developer ID and notarized; do not remove quarantine attributes or bypass Gatekeeper. If verification fails, delete the artifact and report it through `SECURITY.md`. ## VS Code extension (Node/JavaScript) @@ -49,6 +61,14 @@ npm run package ## Build and Run (recommended) +From the repository root, build the local app bundle and verify launch: + +```sh +./script/build_and_run.sh --verify +``` + +For direct SwiftPM development: + 1. Open Terminal and `cd` to the app directory: ```sh cd SwiftExplorerApp @@ -57,7 +77,9 @@ npm run package ```sh swift run ``` - The SwiftUI window opens. Pick a folder, adjust filters, select files, then copy/save the combined prompt. + The SwiftUI window opens. Pick a folder, review the structured scan summary, adjust filters, select files, then copy or save the combined prompt. + +The app targets macOS 13. Newer presentation is availability-gated; the current verified toolchain is Xcode 26.6 with the macOS 26.5 SDK. A macOS 27 host does not add macOS 27 SDK-only symbols to that build. ## Run an existing build @@ -91,21 +113,100 @@ swift test ## Swift formatting (SwiftFormat) -Install SwiftFormat (macOS): +This repository and CI require SwiftFormat 0.61.1. Install that exact release using the same verified archive shown in `.github/workflows/ci.yml`; an unpinned Homebrew install is not equivalent. ```sh -brew install swiftformat +test "$(swiftformat --version)" = "0.61.1" ``` -Then run: +Then format or lint from the repository root: ```sh swiftformat . +swiftformat --lint . +``` + +## Create a local direct-distribution bundle + +For local structural validation without distribution credentials: + +```sh +Packaging/DeveloperID/build_release.sh --skip-signing +open "dist/developer-id/Codebase Combiner.app" +``` + +This creates a sandbox-entitled, ad-hoc signed app and DMG for local validation only. It is not a public distributable. The default package is Apple silicon (`arm64`) only and includes a UUID-matched dSYM under `dist/developer-id/symbols/`. + +The script validates the app, mounted DMG contents, entitlements, architecture, hashes, source state, and ad-hoc signature. Gatekeeper rejects an ad-hoc artifact because it has no Developer ID trust chain or notarization ticket; that is expected. + +For the production signing and notarization path, see `Packaging/DeveloperID/README.md` and `RELEASING.md`. + +## Isolated native E2E host + +Use the sandboxed E2E host for a disposable interaction sweep: + +```sh +./script/build_and_run.sh --e2e ``` -## Optional: create a distributable .app bundle +This foreground command builds a separate `com.s1korrrr.codebasecombiner.e2ehost` app, copies only the synthetic fixture into `/private/tmp`, prints the exact owned PID, and reaps only that PID when the wrapper exits. It does not use the production app's preferences or recovered output. Press Control-C in the same terminal to stop it. + +For a recovery relaunch, preserve the isolated E2E container for one subsequent run: + +```sh +CODEBASE_COMBINER_E2E_RESET=0 ./script/build_and_run.sh --e2e +``` + +Remove app-owned E2E state, runtime files, fixtures, and exports afterward: + +```sh +./script/build_and_run.sh --clean-e2e-state +``` + +## Alternate Mac App Store packaging + +The repository retains a separate optional pipeline under `Packaging/AppStore/`. It is not used for the direct GitHub download and has different identities, profiles, package format, and review requirements. + +Prerequisites for the real upload path: + +- Apple Developer Program membership. +- Bundle ID registered as `com.s1korrrr.codebasecombiner`. +- Mac App Store provisioning profile for that bundle ID. +- App signing identity such as `Apple Distribution: ()` or `3rd Party Mac Developer Application: ()`. +- Installer signing identity such as `3rd Party Mac Developer Installer: ()` or `Mac Installer Distribution: ()`. + +The 2026-07-14 local audit detected valid application-distribution and installer identities, but did not find or embed a matching provisioning profile and did not create an uploadable package. Identity availability alone is not App Store Connect readiness. + +Build and validate locally: + +```sh +Packaging/AppStore/build_app_store_package.sh --skip-signing +``` + +Build a signed package after the Apple signing assets are installed: + +```sh +Packaging/AppStore/build_app_store_package.sh \ + --signing-identity "Apple Distribution: ()" \ + --installer-identity "3rd Party Mac Developer Installer: ()" \ + --provisioning-profile "/path/to/profile.provisionprofile" +``` + +The signed path validates the profile platform, expiration, Team ID, exact bundle identifier, entitlements, and selected certificate before building. Any mismatch is a hard failure, as is a failed installer-signature check. + +The signed package, when produced, is written to: + +```sh +dist/app-store/CodebaseCombiner-AppStore.pkg +``` + +Only after the signed package, embedded profile, account/app record, metadata, privacy declarations, screenshots, and review inputs are verified should the owner upload it with Apple Transporter, Xcode, or another current Apple-supported upload path. + +## Legacy SwiftPM executable copy + +If you only need the raw executable: -1. Archive with SwiftPM: +1. Build with SwiftPM: ```sh swift build -c release ``` @@ -121,4 +222,6 @@ swiftformat . ## Troubleshooting - If `swift run` fails with missing tools, reinstall Xcode Command Line Tools (`xcode-select --install`). -- If macOS blocks execution, right-click the binary once and choose “Open” to approve it. +- If local bundle verification fails, rerun `Packaging/AppStore/build_app_store_package.sh --skip-signing` and inspect `codesign --verify --deep --strict --verbose=2 "dist/app-store/Codebase Combiner.app"`. +- For the direct-release bundle, rerun `Packaging/DeveloperID/build_release.sh --skip-signing` and inspect the generated release manifest and checksum evidence under `dist/developer-id/`. +- Do not interpret Gatekeeper rejection of the ad-hoc local bundle as a distribution-signature success or failure; verify the final distribution artifact separately after the matching profile and Apple-controlled assets are available. diff --git a/Packaging/AppStore/AppStore.entitlements b/Packaging/AppStore/AppStore.entitlements new file mode 100644 index 0000000..48da0c8 --- /dev/null +++ b/Packaging/AppStore/AppStore.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + + diff --git a/Packaging/AppStore/Info.plist.in b/Packaging/AppStore/Info.plist.in new file mode 100644 index 0000000..26c8645 --- /dev/null +++ b/Packaging/AppStore/Info.plist.in @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + @EXECUTABLE_NAME@ + CFBundleIconFile + AppIcon + CFBundleIdentifier + @BUNDLE_IDENTIFIER@ + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + @APP_NAME@ + CFBundleDisplayName + @APP_NAME@ + CFBundlePackageType + APPL + CFBundleShortVersionString + @MARKETING_VERSION@ + CFBundleVersion + @BUILD_NUMBER@ + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + @MINIMUM_SYSTEM_VERSION@ + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright © @COPYRIGHT_YEAR@ Rafal Sikora. Licensed under the MIT License. + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/Packaging/AppStore/PrivacyInfo.xcprivacy b/Packaging/AppStore/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..c09c9a2 --- /dev/null +++ b/Packaging/AppStore/PrivacyInfo.xcprivacy @@ -0,0 +1,32 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + 3B52.1 + C617.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/Packaging/AppStore/README.md b/Packaging/AppStore/README.md new file mode 100644 index 0000000..34b66f3 --- /dev/null +++ b/Packaging/AppStore/README.md @@ -0,0 +1,81 @@ +# Mac App Store Packaging + +This folder contains the Mac App Store packaging path for the SwiftPM macOS app. + +## Defaults + +- App name: `Codebase Combiner` +- Bundle ID: `com.s1korrrr.codebasecombiner` +- Version: `0.1.0` +- Build: `1` +- Minimum macOS: `13.0` +- Architecture: Apple silicon (`arm64`). Intel Macs are not included in this release artifact. +- Category: `public.app-category.developer-tools` +- Entitlements: App Sandbox plus user-selected file read/write access. +- Privacy manifest: declares no tracking or collected data, UserDefaults access for app settings, and file-timestamp access for user-selected files and app-container metadata. + +## Local validation + +Build an unsigned/ad-hoc sandboxed app bundle: + +```sh +Packaging/AppStore/build_app_store_package.sh --skip-signing +``` + +Output: + +- `dist/app-store/Codebase Combiner.app` +- `dist/app-store/CodebaseCombiner-AppStore-summary.txt` +- `dist/app-store/symbols/0.1.0-1-arm64/` with a UUID-checked dSYM and SHA-256 manifest +- `dist/app-store/release-manifest.json` with source, product, signing-mode, and artifact identities +- `dist/app-store/SHA256SUMS` covering the executable, privacy manifest, bundled license, symbols manifest, release manifest, and signed installer when present + +## App Store signing + +Install the Apple signing assets first: + +- Mac App Distribution / Apple Distribution certificate for the app binary. +- Mac Installer Distribution / 3rd Party Mac Developer Installer certificate for the `.pkg`. +- A Mac App Store provisioning profile for bundle ID `com.s1korrrr.codebasecombiner`. + +Then run: + +```sh +Packaging/AppStore/build_app_store_package.sh \ + --signing-identity "Apple Distribution: ()" \ + --installer-identity "3rd Party Mac Developer Installer: ()" \ + --provisioning-profile "/path/to/profile.provisionprofile" +``` + +Before it builds, the signed path decodes and validates the profile's CMS payload, platform, expiration, Team ID, exact bundle identifier, required entitlements, and inclusion of the selected signing certificate. It stops without producing a package if any check fails. The final package signature is also a fail-closed gate. + +Override `--architecture` only when intentionally producing and separately testing another architecture. The default and currently verified release is `arm64`. + +The script also accepts environment variables: + +```sh +APPSTORE_BUNDLE_ID=com.s1korrrr.codebasecombiner +APPSTORE_MARKETING_VERSION=0.1.0 +APPSTORE_BUILD_NUMBER=1 +APPSTORE_SIGNING_IDENTITY="Apple Distribution: ()" +APPSTORE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: ()" +APPSTORE_PROVISIONING_PROFILE="/path/to/profile.provisionprofile" +``` + +## Upload + +After a signed package is created and independently validated, upload it through Apple Transporter, Xcode Organizer, or the App Store Connect API. + +Before submission, validate sandboxed behavior by launching the packaged app and checking: + +- choose workspace folder +- scan selected folder +- change include/exclude filters +- copy combined output +- save combined output to a user-selected location +- open Settings +- open Support link + +## Current local blocker + +This repository can create and validate an ad-hoc `.app` bundle locally. Final Mac App Store packaging is blocked until a non-expired provisioning profile matching `com.s1korrrr.codebasecombiner`, the installed distribution certificate, and the required entitlements is available. diff --git a/Packaging/AppStore/build_app_store_package.sh b/Packaging/AppStore/build_app_store_package.sh new file mode 100755 index 0000000..284a521 --- /dev/null +++ b/Packaging/AppStore/build_app_store_package.sh @@ -0,0 +1,575 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/script/release_path_guard.sh" +PACKAGE_DIR="$ROOT_DIR/SwiftExplorerApp" +PACKAGING_DIR="$ROOT_DIR/Packaging/AppStore" +OUTPUT_NAME="${APPSTORE_OUTPUT_NAME:-app-store}" +DIST_DIR="$ROOT_DIR/dist/$OUTPUT_NAME" +APP_NAME="${APPSTORE_APP_NAME:-Codebase Combiner}" +EXECUTABLE_NAME="${APPSTORE_EXECUTABLE_NAME:-CodebaseExplorerApp}" +BUNDLE_IDENTIFIER="${APPSTORE_BUNDLE_ID:-com.s1korrrr.codebasecombiner}" +MARKETING_VERSION="${APPSTORE_MARKETING_VERSION:-0.1.0}" +BUILD_NUMBER="${APPSTORE_BUILD_NUMBER:-1}" +MINIMUM_SYSTEM_VERSION="${APPSTORE_MINIMUM_SYSTEM_VERSION:-13.0}" +ARCHITECTURE="${APPSTORE_ARCHITECTURE:-arm64}" +COPYRIGHT_YEAR="${APPSTORE_COPYRIGHT_YEAR:-2026}" +SIGNING_IDENTITY="${APPSTORE_SIGNING_IDENTITY:-}" +INSTALLER_IDENTITY="${APPSTORE_INSTALLER_IDENTITY:-}" +PROVISIONING_PROFILE="${APPSTORE_PROVISIONING_PROFILE:-}" +SKIP_SIGNING=0 + +usage() { + cat < Override bundle identifier. + --version Override CFBundleShortVersionString. + --build-number Override CFBundleVersion. + --architecture Build one declared architecture (default: arm64). + --signing-identity App signing identity. + --installer-identity Installer/package signing identity. + --provisioning-profile Mac App Store provisioning profile. + -h, --help Show this help. + +Environment overrides: + APPSTORE_BUNDLE_ID, APPSTORE_MARKETING_VERSION, APPSTORE_BUILD_NUMBER, + APPSTORE_ARCHITECTURE, APPSTORE_OUTPUT_NAME, + APPSTORE_SIGNING_IDENTITY, APPSTORE_INSTALLER_IDENTITY, + APPSTORE_PROVISIONING_PROFILE +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-signing) + SKIP_SIGNING=1 + shift + ;; + --bundle-id) + [[ $# -ge 2 ]] || { echo "Missing value for --bundle-id" >&2; exit 2; } + BUNDLE_IDENTIFIER="$2" + shift 2 + ;; + --version) + [[ $# -ge 2 ]] || { echo "Missing value for --version" >&2; exit 2; } + MARKETING_VERSION="$2" + shift 2 + ;; + --build-number) + [[ $# -ge 2 ]] || { echo "Missing value for --build-number" >&2; exit 2; } + BUILD_NUMBER="$2" + shift 2 + ;; + --architecture) + [[ $# -ge 2 ]] || { echo "Missing value for --architecture" >&2; exit 2; } + ARCHITECTURE="$2" + shift 2 + ;; + --signing-identity) + [[ $# -ge 2 ]] || { echo "Missing value for --signing-identity" >&2; exit 2; } + SIGNING_IDENTITY="$2" + shift 2 + ;; + --installer-identity) + [[ $# -ge 2 ]] || { echo "Missing value for --installer-identity" >&2; exit 2; } + INSTALLER_IDENTITY="$2" + shift 2 + ;; + --provisioning-profile) + [[ $# -ge 2 ]] || { echo "Missing value for --provisioning-profile" >&2; exit 2; } + PROVISIONING_PROFILE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +invalid_metadata() { + echo "Invalid release metadata: $1" >&2 + exit 2 +} + +[[ "$APP_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._\ -]*$ ]] || invalid_metadata "app name" +[[ "$EXECUTABLE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || invalid_metadata "executable name" +[[ "$BUNDLE_IDENTIFIER" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$ && "$BUNDLE_IDENTIFIER" == *.* ]] || invalid_metadata "bundle identifier" +[[ "$MARKETING_VERSION" =~ ^[0-9]+([.][0-9]+){1,2}$ ]] || invalid_metadata "marketing version" +[[ "$BUILD_NUMBER" =~ ^[0-9]+$ ]] || invalid_metadata "build number" +[[ "$MINIMUM_SYSTEM_VERSION" =~ ^[0-9]+([.][0-9]+){1,2}$ ]] || invalid_metadata "minimum system version" +[[ "$ARCHITECTURE" == "arm64" || "$ARCHITECTURE" == "x86_64" ]] || invalid_metadata "architecture" +[[ "$COPYRIGHT_YEAR" =~ ^[0-9]{4}$ ]] || invalid_metadata "copyright year" +[[ "$OUTPUT_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || invalid_metadata "output name" + +is_app_store_app_identity() { + case "$1" in + "Apple Distribution:"*|"Mac App Distribution:"*|"3rd Party Mac Developer Application:"*) return 0 ;; + *) return 1 ;; + esac +} + +is_app_store_installer_identity() { + case "$1" in + "Mac Installer Distribution:"*|"3rd Party Mac Developer Installer:"*) return 0 ;; + *) return 1 ;; + esac +} + +if [[ -n "$SIGNING_IDENTITY" ]] && ! is_app_store_app_identity "$SIGNING_IDENTITY"; then + echo "Signing identity must be a Mac App Store distribution identity." >&2 + exit 3 +fi +if [[ -n "$INSTALLER_IDENTITY" ]] && ! is_app_store_installer_identity "$INSTALLER_IDENTITY"; then + echo "Installer identity must be a Mac App Store installer distribution identity." >&2 + exit 3 +fi + +APP_PATH="$DIST_DIR/$APP_NAME.app" +PKG_PATH="$DIST_DIR/${APP_NAME// /}-AppStore.pkg" +SUMMARY_PATH="$DIST_DIR/${APP_NAME// /}-AppStore-summary.txt" +ICONSET_DIR="$DIST_DIR/AppIcon.iconset" +RELEASE_DIR="$PACKAGE_DIR/.build/${ARCHITECTURE}-apple-macosx/release" +RELEASE_BINARY="$RELEASE_DIR/$EXECUTABLE_NAME" +RELEASE_DSYM="$RELEASE_DIR/$EXECUTABLE_NAME.dSYM" +SYMBOLS_DIR="$DIST_DIR/symbols/$MARKETING_VERSION-$BUILD_NUMBER-$ARCHITECTURE" +SYMBOL_MANIFEST="$SYMBOLS_DIR/manifest.txt" +ENTITLEMENTS="$PACKAGING_DIR/AppStore.entitlements" +INFO_TEMPLATE="$PACKAGING_DIR/Info.plist.in" +PRIVACY_MANIFEST="$PACKAGING_DIR/PrivacyInfo.xcprivacy" +PROFILE_VALIDATOR="$PACKAGING_DIR/validate_provisioning_profile.py" +INFO_PLIST="$APP_PATH/Contents/Info.plist" +ICON_SOURCE="$ROOT_DIR/assets/icon.jpg" +TEMP_DIR="" +EXPECTED_TEAM_ID="" +SIGNING_ENTITLEMENTS="$ENTITLEMENTS" +SOURCE_COMMIT="$(git -C "$ROOT_DIR" rev-parse HEAD)" +SOURCE_STATE="clean" +MANIFEST_PATH="$DIST_DIR/release-manifest.json" +CHECKSUM_PATH="$DIST_DIR/SHA256SUMS" +OPERATION_LOCK="$DIST_DIR/.app-store-operation.lock" +OPERATION_LOCK_CREATED=0 + +require_tool() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required tool: $1" >&2 + exit 1 + fi +} + +validate_binary_minimum_system_version() { + local binary="$1" + local binary_minimum + binary_minimum="$(xcrun vtool -show-build "$binary" | awk '$1 == "minos" { print $2; exit }')" + [[ -n "$binary_minimum" ]] || { echo "Unable to read the Mach-O deployment target." >&2; exit 1; } + [[ "$binary_minimum" == "$MINIMUM_SYSTEM_VERSION" ]] || { + echo "Mach-O deployment target '$binary_minimum' does not match declared minimum '$MINIMUM_SYSTEM_VERSION'." >&2 + exit 1 + } +} + +escape_sed() { + printf '%s' "$1" | sed 's/[\/&]/\\&/g' +} + +render_info_plist() { + sed \ + -e "s/@APP_NAME@/$(escape_sed "$APP_NAME")/g" \ + -e "s/@EXECUTABLE_NAME@/$(escape_sed "$EXECUTABLE_NAME")/g" \ + -e "s/@BUNDLE_IDENTIFIER@/$(escape_sed "$BUNDLE_IDENTIFIER")/g" \ + -e "s/@MARKETING_VERSION@/$(escape_sed "$MARKETING_VERSION")/g" \ + -e "s/@BUILD_NUMBER@/$(escape_sed "$BUILD_NUMBER")/g" \ + -e "s/@MINIMUM_SYSTEM_VERSION@/$(escape_sed "$MINIMUM_SYSTEM_VERSION")/g" \ + -e "s/@COPYRIGHT_YEAR@/$(escape_sed "$COPYRIGHT_YEAR")/g" \ + "$INFO_TEMPLATE" > "$INFO_PLIST" +} + +make_icon() { + rm -rf "$ICONSET_DIR" + mkdir -p "$ICONSET_DIR" + + sips -s format png -z 16 16 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null + sips -s format png -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null + sips -s format png -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null + sips -s format png -z 64 64 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null + sips -s format png -z 128 128 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null + sips -s format png -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null + sips -s format png -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null + sips -s format png -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null + sips -s format png -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null + sips -s format png -z 1024 1024 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null + iconutil -c icns "$ICONSET_DIR" -o "$APP_PATH/Contents/Resources/AppIcon.icns" +} + +identity_exists() { + local identity="$1" + security find-identity -p codesigning -v | grep -F -- "$identity" >/dev/null +} + +installer_identity_exists() { + local identity="$1" + security find-identity -p basic -v 2>/dev/null | grep -F -- "$identity" >/dev/null +} + +cleanup() { + local status=$? + if [[ -n "$TEMP_DIR" ]]; then + rm -rf "$TEMP_DIR" + fi + if [[ "$OPERATION_LOCK_CREATED" -eq 1 ]]; then + rm -rf "$OPERATION_LOCK" + fi + return "$status" +} + +prepare_distribution_signing() { + if [[ -z "$PROVISIONING_PROFILE" ]]; then + echo "Missing Mac App Store provisioning profile. Pass --provisioning-profile with a profile matching $BUNDLE_IDENTIFIER." >&2 + exit 3 + fi + if [[ ! -f "$PROVISIONING_PROFILE" ]]; then + echo "Provisioning profile not found: $PROVISIONING_PROFILE" >&2 + exit 3 + fi + if [[ -z "$INSTALLER_IDENTITY" ]]; then + echo "Missing Mac App Store installer identity." >&2 + exit 3 + fi + if ! installer_identity_exists "$INSTALLER_IDENTITY"; then + echo "Installer identity not found in keychain: $INSTALLER_IDENTITY" >&2 + exit 3 + fi + + TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codebase-combiner-signing.XXXXXX")" + trap cleanup EXIT + local decoded_profile="$TEMP_DIR/profile.plist" + local certificate_pem="$TEMP_DIR/certificate.pem" + local certificate_der="$TEMP_DIR/certificate.der" + SIGNING_ENTITLEMENTS="$TEMP_DIR/signing.entitlements" + + if ! security cms -D -u 9 -i "$PROVISIONING_PROFILE" > "$decoded_profile"; then + echo "Provisioning profile could not be decoded or trusted under the protected-object signer policy." >&2 + exit 3 + fi + if ! security find-certificate -c "$SIGNING_IDENTITY" -p > "$certificate_pem"; then + echo "Signing certificate could not be exported for profile matching: $SIGNING_IDENTITY" >&2 + exit 3 + fi + openssl x509 -in "$certificate_pem" -outform der -out "$certificate_der" + EXPECTED_TEAM_ID="$(openssl x509 -in "$certificate_pem" -noout -subject -nameopt RFC2253 | tr ',' '\n' | sed -n 's/^[[:space:]]*OU=//p' | head -n 1)" + if [[ -z "$EXPECTED_TEAM_ID" ]]; then + echo "Signing certificate does not contain an Organizational Unit Team ID." >&2 + exit 3 + fi + + local installer_certificate_pem="$TEMP_DIR/installer-certificate.pem" + local installer_team_id + if ! security find-certificate -c "$INSTALLER_IDENTITY" -p > "$installer_certificate_pem"; then + echo "Installer certificate could not be exported for Team ID validation: $INSTALLER_IDENTITY" >&2 + exit 3 + fi + installer_team_id="$(openssl x509 -in "$installer_certificate_pem" -noout -subject -nameopt RFC2253 | tr ',' '\n' | sed -n 's/^[[:space:]]*OU=//p' | head -n 1)" + if [[ -z "$installer_team_id" || "$installer_team_id" != "$EXPECTED_TEAM_ID" ]]; then + echo "Installer certificate Team ID '${installer_team_id:-missing}' does not match app signing Team ID '$EXPECTED_TEAM_ID'." >&2 + exit 3 + fi + + xcrun python3 "$PROFILE_VALIDATOR" \ + --profile-plist "$decoded_profile" \ + --bundle-id "$BUNDLE_IDENTIFIER" \ + --team-id "$EXPECTED_TEAM_ID" \ + --certificate-der "$certificate_der" \ + --entitlements "$ENTITLEMENTS" \ + --output-entitlements "$SIGNING_ENTITLEMENTS" +} + +maybe_autodetect_identities() { + if [[ -z "$SIGNING_IDENTITY" ]]; then + SIGNING_IDENTITY="$(security find-identity -p codesigning -v | sed -n 's/.*"\(Apple Distribution:.*\)".*/\1/p; s/.*"\(Mac App Distribution:.*\)".*/\1/p; s/.*"\(3rd Party Mac Developer Application:.*\)".*/\1/p' | head -n 1)" + fi + if [[ -z "$INSTALLER_IDENTITY" ]]; then + INSTALLER_IDENTITY="$(security find-identity -p basic -v 2>/dev/null | sed -n 's/.*"\(3rd Party Mac Developer Installer:.*\)".*/\1/p; s/.*"\(Mac Installer Distribution:.*\)".*/\1/p' | head -n 1)" + fi +} + +validate_bundle() { + local signed_entitlements="$DIST_DIR/codesign-entitlements.plist" + local signature_details="$DIST_DIR/codesign-details.txt" + plutil -lint "$INFO_PLIST" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + codesign -dvvv "$APP_PATH" > /dev/null 2> "$signature_details" + codesign -d --entitlements - --xml "$APP_PATH" > "$signed_entitlements" + plutil -lint "$signed_entitlements" + + if [[ "$SKIP_SIGNING" -eq 0 ]]; then + local expected_application_identifier + local actual_application_identifier + local actual_team_identifier + expected_application_identifier="$(/usr/libexec/PlistBuddy -c 'Print :com.apple.application-identifier' "$SIGNING_ENTITLEMENTS")" + actual_application_identifier="$(/usr/libexec/PlistBuddy -c 'Print :com.apple.application-identifier' "$signed_entitlements")" + actual_team_identifier="$(/usr/libexec/PlistBuddy -c 'Print :com.apple.developer.team-identifier' "$signed_entitlements")" + + grep -F "TeamIdentifier=$EXPECTED_TEAM_ID" "$signature_details" >/dev/null + grep -E '^Authority=(Apple Distribution|Mac App Distribution|3rd Party Mac Developer Application):' "$signature_details" >/dev/null + [[ "$actual_application_identifier" == "$expected_application_identifier" ]] + [[ "$actual_team_identifier" == "$EXPECTED_TEAM_ID" ]] + [[ "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.app-sandbox' "$signed_entitlements")" == "true" ]] + [[ "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.files.user-selected.read-write' "$signed_entitlements")" == "true" ]] + cmp -s "$PROVISIONING_PROFILE" "$APP_PATH/Contents/embedded.provisionprofile" + fi + + spctl -a -vv "$APP_PATH" > "$DIST_DIR/spctl-app.txt" 2>&1 || true +} + +preserve_symbols() { + local bundle_binary="$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" + local copied_dsym="$SYMBOLS_DIR/$EXECUTABLE_NAME.dSYM" + local copied_dwarf="$copied_dsym/Contents/Resources/DWARF/$EXECUTABLE_NAME" + local binary_uuid + local dsym_uuid + local bundle_architectures + + bundle_architectures="$(lipo -archs "$bundle_binary")" + if [[ "$bundle_architectures" != "$ARCHITECTURE" ]]; then + echo "Packaged architectures '$bundle_architectures' do not match declared architecture '$ARCHITECTURE'." >&2 + exit 1 + fi + if [[ ! -d "$RELEASE_DSYM" ]]; then + echo "Release dSYM not found: $RELEASE_DSYM" >&2 + exit 1 + fi + + mkdir -p "$SYMBOLS_DIR" + cp -R "$RELEASE_DSYM" "$copied_dsym" + binary_uuid="$(dwarfdump --uuid "$bundle_binary" | awk '{print $2}' | sort -u)" + dsym_uuid="$(dwarfdump --uuid "$copied_dsym" | awk '{print $2}' | sort -u)" + if [[ -z "$binary_uuid" || "$binary_uuid" != "$dsym_uuid" ]]; then + echo "Release dSYM UUID does not match the packaged executable." >&2 + exit 1 + fi + + { + echo "App: $APP_NAME" + echo "Version: $MARKETING_VERSION" + echo "Build: $BUILD_NUMBER" + echo "Architecture: $bundle_architectures" + echo "Executable UUID: $binary_uuid" + shasum -a 256 "$bundle_binary" "$copied_dwarf" + } > "$SYMBOL_MANIFEST" +} + +write_release_manifest() { + local bundle_binary="$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" + local bundled_privacy="$APP_PATH/Contents/Resources/PrivacyInfo.xcprivacy" + local bundled_license="$APP_PATH/Contents/Resources/LICENSE" + local package_name="" + local package_sha256="" + + if [[ "$PKG_CREATED" -eq 1 ]]; then + package_name="$(basename "$PKG_PATH")" + package_sha256="$(shasum -a 256 "$PKG_PATH" | awk '{print $1}')" + fi + + python3 - "$MANIFEST_PATH" "$APP_NAME" "$EXECUTABLE_NAME" "$BUNDLE_IDENTIFIER" \ + "$MARKETING_VERSION" "$BUILD_NUMBER" "$ARCHITECTURE" "$SOURCE_COMMIT" "$SOURCE_STATE" \ + "$([[ "$SKIP_SIGNING" -eq 1 ]] && printf 'ad-hoc local validation' || printf '%s' "$SIGNING_IDENTITY")" \ + "$(shasum -a 256 "$bundle_binary" | awk '{print $1}')" \ + "$(shasum -a 256 "$bundled_privacy" | awk '{print $1}')" \ + "$(shasum -a 256 "$bundled_license" | awk '{print $1}')" \ + "$(shasum -a 256 "$SYMBOL_MANIFEST" | awk '{print $1}')" \ + "$package_name" "$package_sha256" <<'PY' +import json +import sys + +( + path, app_name, executable, bundle_identifier, version, build, architecture, + source_commit, source_state, signing_mode, executable_sha256, privacy_sha256, + license_sha256, symbols_sha256, package_name, package_sha256, +) = sys.argv[1:] +data = { + "schemaVersion": 1, + "product": { + "name": app_name, + "executable": executable, + "bundleIdentifier": bundle_identifier, + "marketingVersion": version, + "buildNumber": build, + "architecture": architecture, + }, + "sourceCommit": source_commit, + "sourceState": source_state, + "signingMode": signing_mode, + "artifacts": { + "appExecutableSHA256": executable_sha256, + "privacyManifestSHA256": privacy_sha256, + "licenseSHA256": license_sha256, + "symbolManifestSHA256": symbols_sha256, + "installerPackage": package_name or None, + "installerPackageSHA256": package_sha256 or None, + }, +} +with open(path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") +PY +} + +write_release_checksums() { + local assets=( + "$APP_NAME.app/Contents/MacOS/$EXECUTABLE_NAME" + "$APP_NAME.app/Contents/Resources/PrivacyInfo.xcprivacy" + "$APP_NAME.app/Contents/Resources/LICENSE" + "symbols/$MARKETING_VERSION-$BUILD_NUMBER-$ARCHITECTURE/manifest.txt" + "$(basename "$MANIFEST_PATH")" + ) + if [[ "$PKG_CREATED" -eq 1 ]]; then + assets+=("$(basename "$PKG_PATH")") + fi + ( + cd "$DIST_DIR" + shasum -a 256 "${assets[@]}" > "$(basename "$CHECKSUM_PATH")" + shasum -a 256 -c "$(basename "$CHECKSUM_PATH")" >/dev/null + ) +} + +require_tool swift +require_tool sips +require_tool iconutil +require_tool codesign +require_tool plutil +require_tool productbuild +require_tool lipo +require_tool dwarfdump +require_tool shasum +require_tool xcrun +require_tool python3 + +if [[ -n "$(git -C "$ROOT_DIR" status --porcelain --untracked-files=all)" ]]; then + SOURCE_STATE="dirty" +fi + +if [[ "$SKIP_SIGNING" -eq 0 ]]; then + [[ "$SOURCE_STATE" == clean ]] || { + echo "Production App Store signing requires a clean Git worktree so the package matches its source commit." >&2 + exit 3 + } + maybe_autodetect_identities + if [[ -z "$SIGNING_IDENTITY" ]]; then + echo "Missing app signing identity. Install/pass an Apple Distribution, Mac App Distribution, or 3rd Party Mac Developer Application identity, or use --skip-signing for local validation." >&2 + exit 3 + fi + if ! identity_exists "$SIGNING_IDENTITY"; then + echo "Signing identity not found in keychain: $SIGNING_IDENTITY" >&2 + exit 3 + fi + require_tool security + require_tool openssl + require_tool xcrun + require_tool pkgutil + prepare_distribution_signing +fi + +guard_release_output_path "$ROOT_DIR" "$DIST_DIR" +mkdir -p "$DIST_DIR" +guard_release_output_path "$ROOT_DIR" "$DIST_DIR" +if ! mkdir "$OPERATION_LOCK" 2>/dev/null; then + echo "Another App Store packaging operation is already running for $DIST_DIR." >&2 + exit 7 +fi +OPERATION_LOCK_CREATED=1 +printf '%s\n' "$$" > "$OPERATION_LOCK/pid" +trap cleanup EXIT +rm -rf "$APP_PATH" "$PKG_PATH" "$SUMMARY_PATH" "$SYMBOLS_DIR" +rm -rf "$ICONSET_DIR" +rm -f \ + "$MANIFEST_PATH" \ + "$CHECKSUM_PATH" \ + "$DIST_DIR/codesign-entitlements.plist" \ + "$DIST_DIR/codesign-details.txt" \ + "$DIST_DIR/spctl-app.txt" \ + "$DIST_DIR/pkg-signature.txt" + +echo "==> Building SwiftPM release product" +swift build -c release --arch "$ARCHITECTURE" --package-path "$PACKAGE_DIR" --product "$EXECUTABLE_NAME" + +echo "==> Assembling app bundle: $APP_PATH" +mkdir -p "$APP_PATH/Contents/MacOS" "$APP_PATH/Contents/Resources" +cp "$RELEASE_BINARY" "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +chmod 755 "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +validate_binary_minimum_system_version "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +render_info_plist +make_icon +rm -rf "$ICONSET_DIR" +cp "$PRIVACY_MANIFEST" "$APP_PATH/Contents/Resources/PrivacyInfo.xcprivacy" +cp "$ROOT_DIR/LICENSE" "$APP_PATH/Contents/Resources/LICENSE" + +if [[ "$SKIP_SIGNING" -eq 0 ]]; then + cp "$PROVISIONING_PROFILE" "$APP_PATH/Contents/embedded.provisionprofile" +fi + +if [[ "$SKIP_SIGNING" -eq 1 ]]; then + echo "==> Ad-hoc signing for local bundle validation" + codesign --force --sign - --entitlements "$ENTITLEMENTS" --timestamp=none "$APP_PATH" +else + echo "==> Signing app with: $SIGNING_IDENTITY" + codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" --entitlements "$SIGNING_ENTITLEMENTS" "$APP_PATH" +fi + +echo "==> Validating app bundle" +validate_bundle + +echo "==> Preserving matching release symbols" +preserve_symbols + +PKG_CREATED=0 +if [[ "$SKIP_SIGNING" -eq 0 ]]; then + echo "==> Building signed installer package: $PKG_PATH" + productbuild --component "$APP_PATH" /Applications --sign "$INSTALLER_IDENTITY" "$PKG_PATH" + pkgutil --check-signature "$PKG_PATH" > "$DIST_DIR/pkg-signature.txt" 2>&1 + grep -E '(Mac Installer Distribution|3rd Party Mac Developer Installer):' "$DIST_DIR/pkg-signature.txt" >/dev/null + PKG_CREATED=1 +fi + +echo "==> Writing source-bound release manifest and checksums" +write_release_manifest +write_release_checksums + +cat > "$SUMMARY_PATH" < Done" +cat "$SUMMARY_PATH" diff --git a/Packaging/AppStore/tests/validate_provisioning_profile_test.sh b/Packaging/AppStore/tests/validate_provisioning_profile_test.sh new file mode 100755 index 0000000..80c1653 --- /dev/null +++ b/Packaging/AppStore/tests/validate_provisioning_profile_test.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +VALIDATOR="$ROOT_DIR/Packaging/AppStore/validate_provisioning_profile.py" +FIXTURE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codebase-combiner-profile-tests.XXXXXX")" +trap 'rm -rf "$FIXTURE_DIR"' EXIT + +CERTIFICATE_A="$FIXTURE_DIR/certificate-a.der" +CERTIFICATE_B="$FIXTURE_DIR/certificate-b.der" +printf 'certificate-a' > "$CERTIFICATE_A" +printf 'certificate-b' > "$CERTIFICATE_B" +CERTIFICATE_A_BASE64="$(base64 < "$CERTIFICATE_A" | tr -d '[:space:]')" + +write_profile() { + local output="$1" + local platform="$2" + local expiration="$3" + local team_id="$4" + local application_identifier="$5" + local sandbox="$6" + local file_access="$7" + + cat > "$output" < + + + + Platform + $platform + ExpirationDate + $expiration + TeamIdentifier + $team_id + ApplicationIdentifierPrefix + $team_id + DeveloperCertificates + $CERTIFICATE_A_BASE64 + Entitlements + + com.apple.application-identifier + $application_identifier + com.apple.developer.team-identifier + $team_id + com.apple.security.app-sandbox + <$sandbox/> + com.apple.security.files.user-selected.read-write + <$file_access/> + + + +PLIST +} + +expect_success() { + local label="$1" + shift + if ! output="$("$@" 2>&1)"; then + echo "FAIL: $label should succeed" >&2 + echo "$output" >&2 + exit 1 + fi +} + +expect_failure() { + local label="$1" + local expected="$2" + shift 2 + if output="$("$@" 2>&1)"; then + echo "FAIL: $label should fail" >&2 + exit 1 + fi + if [[ "$output" != *"$expected"* ]]; then + echo "FAIL: $label did not report '$expected'" >&2 + echo "$output" >&2 + exit 1 + fi +} + +run_validator() { + xcrun python3 "$VALIDATOR" \ + --profile-plist "$1" \ + --bundle-id "com.s1korrrr.codebasecombiner" \ + --team-id "TEAM123456" \ + --certificate-der "$2" \ + --entitlements "$ROOT_DIR/Packaging/AppStore/AppStore.entitlements" +} + +VALID_PROFILE="$FIXTURE_DIR/valid.plist" +write_profile \ + "$VALID_PROFILE" \ + "OSX" \ + "2099-01-01T00:00:00Z" \ + "TEAM123456" \ + "TEAM123456.com.s1korrrr.codebasecombiner" \ + "true" \ + "true" + +expect_success "valid profile" run_validator "$VALID_PROFILE" "$CERTIFICATE_A" + +MALFORMED_PROFILE="$FIXTURE_DIR/malformed.plist" +printf 'not a plist' > "$MALFORMED_PROFILE" +expect_failure "malformed profile" "could not be decoded" run_validator "$MALFORMED_PROFILE" "$CERTIFICATE_A" + +EXPIRED_PROFILE="$FIXTURE_DIR/expired.plist" +write_profile "$EXPIRED_PROFILE" "OSX" "2000-01-01T00:00:00Z" "TEAM123456" "TEAM123456.com.s1korrrr.codebasecombiner" "true" "true" +expect_failure "expired profile" "expired" run_validator "$EXPIRED_PROFILE" "$CERTIFICATE_A" + +IOS_PROFILE="$FIXTURE_DIR/ios.plist" +write_profile "$IOS_PROFILE" "iOS" "2099-01-01T00:00:00Z" "TEAM123456" "TEAM123456.com.s1korrrr.codebasecombiner" "true" "true" +expect_failure "iOS profile" "Mac App Store platform" run_validator "$IOS_PROFILE" "$CERTIFICATE_A" + +WRONG_TEAM_PROFILE="$FIXTURE_DIR/wrong-team.plist" +write_profile "$WRONG_TEAM_PROFILE" "OSX" "2099-01-01T00:00:00Z" "OTHERTEAM1" "OTHERTEAM1.com.s1korrrr.codebasecombiner" "true" "true" +expect_failure "wrong team" "Team ID" run_validator "$WRONG_TEAM_PROFILE" "$CERTIFICATE_A" + +WRONG_BUNDLE_PROFILE="$FIXTURE_DIR/wrong-bundle.plist" +write_profile "$WRONG_BUNDLE_PROFILE" "OSX" "2099-01-01T00:00:00Z" "TEAM123456" "TEAM123456.com.example.other" "true" "true" +expect_failure "wrong bundle" "bundle identifier" run_validator "$WRONG_BUNDLE_PROFILE" "$CERTIFICATE_A" + +NO_SANDBOX_PROFILE="$FIXTURE_DIR/no-sandbox.plist" +write_profile "$NO_SANDBOX_PROFILE" "OSX" "2099-01-01T00:00:00Z" "TEAM123456" "TEAM123456.com.s1korrrr.codebasecombiner" "false" "true" +expect_failure "missing sandbox" "app sandbox" run_validator "$NO_SANDBOX_PROFILE" "$CERTIFICATE_A" + +NO_FILE_ACCESS_PROFILE="$FIXTURE_DIR/no-file-access.plist" +write_profile "$NO_FILE_ACCESS_PROFILE" "OSX" "2099-01-01T00:00:00Z" "TEAM123456" "TEAM123456.com.s1korrrr.codebasecombiner" "true" "false" +expect_failure "missing user-selected file access" "user-selected file" run_validator "$NO_FILE_ACCESS_PROFILE" "$CERTIFICATE_A" + +expect_failure "certificate mismatch" "signing certificate" run_validator "$VALID_PROFILE" "$CERTIFICATE_B" + +if ! grep -F 'validate_provisioning_profile.py' "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" >/dev/null; then + echo "FAIL: packaging script does not invoke the profile validator" >&2 + exit 1 +fi +if grep -E 'pkgutil --check-signature .*\|\| true' "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" >/dev/null; then + echo "FAIL: package signature failures must not be swallowed" >&2 + exit 1 +fi +if ! grep -F 'swift build -c release --arch "$ARCHITECTURE"' "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" >/dev/null; then + echo "FAIL: packaging must build the declared release architecture deterministically" >&2 + exit 1 +fi +if ! grep -F 'dwarfdump --uuid' "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" >/dev/null; then + echo "FAIL: packaging must preserve and verify matching release symbols" >&2 + exit 1 +fi +if ! grep -F 'security cms -D -u 9' "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" >/dev/null; then + echo "FAIL: provisioning profiles must use the protected-object signer trust policy" >&2 + exit 1 +fi +if ! grep -F 'Installer certificate Team ID' "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" >/dev/null; then + echo "FAIL: installer identity must be bound to the app signing Team ID" >&2 + exit 1 +fi +if ! grep -F 'Production App Store signing requires a clean Git worktree' "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" >/dev/null; then + echo "FAIL: signed App Store packages must be source-bound" >&2 + exit 1 +fi +if ! grep -F 'Source commit:' "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" >/dev/null; then + echo "FAIL: App Store package summary must record the source commit" >&2 + exit 1 +fi + +expect_invalid_package_input() { + local label="$1" + local variable="$2" + local value="$3" + local sentinel="$ROOT_DIR/dist/release-hardening-sentinel" + mkdir -p "$(dirname "$sentinel")" + printf 'preserve-me' > "$sentinel" + if output="$(env "$variable=$value" "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" --skip-signing 2>&1)"; then + echo "FAIL: $label should be rejected" >&2 + exit 1 + fi + if [[ "$output" != *"Invalid release metadata"* ]]; then + echo "FAIL: $label did not produce the release metadata error" >&2 + echo "$output" >&2 + exit 1 + fi + [[ "$(cat "$sentinel")" == "preserve-me" ]] || { + echo "FAIL: $label mutated files before validation" >&2 + exit 1 + } + rm -f "$sentinel" +} + +expect_invalid_package_input "traversal app name" APPSTORE_APP_NAME "../outside" +expect_invalid_package_input "traversal executable" APPSTORE_EXECUTABLE_NAME "../../outside" +expect_invalid_package_input "traversal version" APPSTORE_MARKETING_VERSION "../../outside" +expect_invalid_package_input "invalid build number" APPSTORE_BUILD_NUMBER "1/../../outside" +expect_invalid_package_input "invalid architecture" APPSTORE_ARCHITECTURE "../../outside" + +expect_invalid_identity_class() { + local label="$1" + local expected="$2" + shift 2 + if output="$("$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" "$@" 2>&1)"; then + echo "FAIL: $label should be rejected" >&2 + exit 1 + fi + if [[ "$output" != *"$expected"* ]]; then + echo "FAIL: $label did not report '$expected'" >&2 + echo "$output" >&2 + exit 1 + fi +} + +expect_invalid_identity_class \ + "development app identity" \ + "Mac App Store distribution identity" \ + --signing-identity "Apple Development: Example (TEAM123456)" \ + --installer-identity "3rd Party Mac Developer Installer: Example (TEAM123456)" \ + --provisioning-profile "$VALID_PROFILE" +expect_invalid_identity_class \ + "Developer ID app identity" \ + "Mac App Store distribution identity" \ + --signing-identity "Developer ID Application: Example (TEAM123456)" \ + --installer-identity "3rd Party Mac Developer Installer: Example (TEAM123456)" \ + --provisioning-profile "$VALID_PROFILE" +expect_invalid_identity_class \ + "Developer ID installer identity" \ + "Mac App Store installer distribution identity" \ + --signing-identity "Apple Distribution: Example (TEAM123456)" \ + --installer-identity "Developer ID Installer: Example (TEAM123456)" \ + --provisioning-profile "$VALID_PROFILE" + +external_output="$(mktemp -d "${TMPDIR:-/tmp}/codebase-combiner-app-store-external.XXXXXX")" +output_name="app-store-contract-$$" +output_link="$ROOT_DIR/dist/$output_name" +mkdir -p "$ROOT_DIR/dist" +printf 'preserve-external\n' > "$external_output/sentinel" +ln -s "$external_output" "$output_link" +if APPSTORE_OUTPUT_NAME="$output_name" "$ROOT_DIR/Packaging/AppStore/build_app_store_package.sh" --skip-signing >/dev/null 2>&1; then + echo "FAIL: symlinked App Store output unexpectedly succeeded" >&2 + exit 1 +fi +grep -F 'preserve-external' "$external_output/sentinel" >/dev/null +rm -f "$output_link" +rm -rf "$external_output" + +echo "provisioning-profile and signed-package contracts passed" diff --git a/Packaging/AppStore/validate_provisioning_profile.py b/Packaging/AppStore/validate_provisioning_profile.py new file mode 100755 index 0000000..231580c --- /dev/null +++ b/Packaging/AppStore/validate_provisioning_profile.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Fail-closed validation for a decoded Mac App Store provisioning profile.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import plistlib +import sys +from pathlib import Path + + +def fail(message: str) -> None: + raise ValueError(message) + + +def load_plist(path: Path, label: str) -> dict: + try: + with path.open("rb") as handle: + value = plistlib.load(handle) + except (OSError, plistlib.InvalidFileException, ValueError) as error: + fail(f"{label} could not be decoded: {error}") + if not isinstance(value, dict): + fail(f"{label} root is not a dictionary") + return value + + +def normalized_datetime(value: object) -> dt.datetime: + if not isinstance(value, dt.datetime): + fail("expiration date is missing or invalid") + if value.tzinfo is None: + return value.replace(tzinfo=dt.timezone.utc) + return value.astimezone(dt.timezone.utc) + + +def require_true(entitlements: dict, key: str, label: str) -> None: + if entitlements.get(key) is not True: + fail(f"profile does not authorize {label}") + + +def validate(args: argparse.Namespace) -> None: + profile_path = Path(args.profile_plist) + certificate_path = Path(args.certificate_der) + entitlements_path = Path(args.entitlements) + + profile = load_plist(profile_path, "provisioning profile") + requested_entitlements = load_plist(entitlements_path, "repository entitlements") + + platforms = profile.get("Platform") + if not isinstance(platforms, list) or "OSX" not in platforms: + fail("profile is not for the Mac App Store platform (OSX)") + + expiration = normalized_datetime(profile.get("ExpirationDate")) + if expiration <= dt.datetime.now(dt.timezone.utc): + fail(f"profile expired at {expiration.isoformat()}") + + team_identifiers = profile.get("TeamIdentifier") + if not isinstance(team_identifiers, list) or args.team_id not in team_identifiers: + fail(f"profile Team ID does not match {args.team_id}") + + profile_entitlements = profile.get("Entitlements") + if not isinstance(profile_entitlements, dict): + fail("profile entitlements are missing") + + entitlement_team = profile_entitlements.get("com.apple.developer.team-identifier") + if entitlement_team != args.team_id: + fail(f"profile entitlement Team ID does not match {args.team_id}") + + prefixes = profile.get("ApplicationIdentifierPrefix") + if not isinstance(prefixes, list) or not all(isinstance(prefix, str) for prefix in prefixes): + fail("profile application-identifier prefix is missing") + permitted_identifiers = {f"{prefix}.{args.bundle_id}" for prefix in prefixes} + application_identifier = profile_entitlements.get("com.apple.application-identifier") + if application_identifier is None: + application_identifier = profile_entitlements.get("application-identifier") + if application_identifier not in permitted_identifiers: + fail(f"profile bundle identifier does not match {args.bundle_id}") + + require_true(profile_entitlements, "com.apple.security.app-sandbox", "the app sandbox") + require_true( + profile_entitlements, + "com.apple.security.files.user-selected.read-write", + "user-selected file read/write access", + ) + + for entitlement_key, entitlement_value in requested_entitlements.items(): + if entitlement_value is True and profile_entitlements.get(entitlement_key) is not True: + fail(f"profile does not authorize requested entitlement {entitlement_key}") + + try: + selected_certificate = certificate_path.read_bytes() + except OSError as error: + fail(f"selected signing certificate could not be read: {error}") + if not selected_certificate: + fail("selected signing certificate is empty") + + profile_certificates = profile.get("DeveloperCertificates") + if not isinstance(profile_certificates, list) or not any( + isinstance(certificate, bytes) and certificate == selected_certificate + for certificate in profile_certificates + ): + fail("selected signing certificate is not included in the provisioning profile") + + certificate_digest = hashlib.sha256(selected_certificate).hexdigest() + if args.output_entitlements: + signing_entitlements = dict(requested_entitlements) + signing_entitlements["com.apple.application-identifier"] = application_identifier + signing_entitlements["com.apple.developer.team-identifier"] = args.team_id + try: + with Path(args.output_entitlements).open("wb") as handle: + plistlib.dump(signing_entitlements, handle, fmt=plistlib.FMT_XML, sort_keys=True) + except OSError as error: + fail(f"distribution entitlements could not be written: {error}") + + print( + "Provisioning profile valid: " + f"bundle={args.bundle_id} team={args.team_id} " + f"expires={expiration.date().isoformat()} certificate_sha256={certificate_digest}" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--profile-plist", required=True) + parser.add_argument("--bundle-id", required=True) + parser.add_argument("--team-id", required=True) + parser.add_argument("--certificate-der", required=True) + parser.add_argument("--entitlements", required=True) + parser.add_argument("--output-entitlements") + return parser.parse_args() + + +def main() -> int: + try: + validate(parse_args()) + except ValueError as error: + print(f"Invalid provisioning profile: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Packaging/DeveloperID/DeveloperID.entitlements b/Packaging/DeveloperID/DeveloperID.entitlements new file mode 100644 index 0000000..48da0c8 --- /dev/null +++ b/Packaging/DeveloperID/DeveloperID.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + + diff --git a/Packaging/DeveloperID/Info.plist.in b/Packaging/DeveloperID/Info.plist.in new file mode 100644 index 0000000..26c8645 --- /dev/null +++ b/Packaging/DeveloperID/Info.plist.in @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + @EXECUTABLE_NAME@ + CFBundleIconFile + AppIcon + CFBundleIdentifier + @BUNDLE_IDENTIFIER@ + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + @APP_NAME@ + CFBundleDisplayName + @APP_NAME@ + CFBundlePackageType + APPL + CFBundleShortVersionString + @MARKETING_VERSION@ + CFBundleVersion + @BUILD_NUMBER@ + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + @MINIMUM_SYSTEM_VERSION@ + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright © @COPYRIGHT_YEAR@ Rafal Sikora. Licensed under the MIT License. + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/Packaging/DeveloperID/PrivacyInfo.xcprivacy b/Packaging/DeveloperID/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..c09c9a2 --- /dev/null +++ b/Packaging/DeveloperID/PrivacyInfo.xcprivacy @@ -0,0 +1,32 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + 3B52.1 + C617.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/Packaging/DeveloperID/README.md b/Packaging/DeveloperID/README.md new file mode 100644 index 0000000..c6f184a --- /dev/null +++ b/Packaging/DeveloperID/README.md @@ -0,0 +1,48 @@ +# Developer ID direct distribution + +This lane builds Codebase Combiner for download outside the Mac App Store. It is intentionally separate from `Packaging/AppStore` because the identities, package format, and release gates differ. + +## Local structural validation + +```sh +Packaging/DeveloperID/build_release.sh --skip-signing +``` + +This creates an ad-hoc signed app and DMG under `dist/developer-id/`. They prove bundle structure only and are not public distributables. + +## Developer ID release candidate + +Install a `Developer ID Application` identity with its private key, then run: + +```sh +DEVELOPER_ID_SOURCE_TAG=macos-v0.1.0 \ +Packaging/DeveloperID/build_release.sh \ + --signing-identity "Developer ID Application: Rafal Sikora (2NY8A789TN)" +``` + +The script requires Hardened Runtime, a secure timestamp, the sandbox entitlements, strict signature verification, a matching dSYM, and an explicit architecture. Version 0.1.0 is deliberately Apple-silicon-only until an Intel build is separately produced and tested. + +The installed identity is local-only and must be used directly through the login Keychain. Never export its private key, create a PKCS#12 copy, import it into a temporary Keychain, or reuse it for CI. If `security find-identity` lists the certificate but `codesign` returns `errSecInternalComponent`, the owner must repair only the local `/usr/bin/codesign` authorization in Keychain Access. Never pass a login-Keychain password through a script, shell history, CI log, or support message. + +CI signing is `blocked:external` until the owner deliberately provisions a separate CI signing credential. + +## Notarization + +Store App Store Connect API-key credentials in the login Keychain without writing them to the repository: + +```sh +xcrun notarytool store-credentials "codebase-combiner-notary" \ + --key "/secure/path/AuthKey_KEYID.p8" \ + --key-id "KEYID" \ + --issuer "ISSUER_UUID" +``` + +After explicit approval for the Apple submission: + +```sh +Packaging/DeveloperID/notarize_release.sh \ + --dmg "dist/developer-id/Codebase-Combiner-0.1.0-arm64.dmg" \ + --keychain-profile "codebase-combiner-notary" +``` + +The notarization script requires Apple status `Accepted`, retrieves the log, staples and validates the ticket, runs Gatekeeper against the DMG and mounted app, and then writes the final checksum. It never publishes to GitHub. diff --git a/Packaging/DeveloperID/build_release.sh b/Packaging/DeveloperID/build_release.sh new file mode 100755 index 0000000..34b8fe3 --- /dev/null +++ b/Packaging/DeveloperID/build_release.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/script/release_path_guard.sh" +source "$ROOT_DIR/script/disk_image_tools.sh" +PACKAGE_DIR="$ROOT_DIR/SwiftExplorerApp" +PACKAGING_DIR="$ROOT_DIR/Packaging/DeveloperID" +OUTPUT_NAME="${DEVELOPER_ID_OUTPUT_NAME:-developer-id}" +DIST_DIR="$ROOT_DIR/dist/$OUTPUT_NAME" + +APP_NAME="${DEVELOPER_ID_APP_NAME:-Codebase Combiner}" +EXECUTABLE_NAME="${DEVELOPER_ID_EXECUTABLE_NAME:-CodebaseExplorerApp}" +BUNDLE_IDENTIFIER="${DEVELOPER_ID_BUNDLE_ID:-com.s1korrrr.codebasecombiner}" +MARKETING_VERSION="${DEVELOPER_ID_MARKETING_VERSION:-0.1.0}" +BUILD_NUMBER="${DEVELOPER_ID_BUILD_NUMBER:-1}" +MINIMUM_SYSTEM_VERSION="${DEVELOPER_ID_MINIMUM_SYSTEM_VERSION:-13.0}" +ARCHITECTURE="${DEVELOPER_ID_ARCHITECTURE:-arm64}" +COPYRIGHT_YEAR="${DEVELOPER_ID_COPYRIGHT_YEAR:-2026}" +SIGNING_IDENTITY="${DEVELOPER_ID_SIGNING_IDENTITY:-}" +SOURCE_TAG="${DEVELOPER_ID_SOURCE_TAG:-}" +SKIP_SIGNING=0 + +usage() { + cat <<'USAGE' +Usage: Packaging/DeveloperID/build_release.sh [options] + +Options: + --skip-signing Create an ad-hoc signed local validation DMG. + --bundle-id Override the bundle identifier. + --version Override CFBundleShortVersionString. + --build-number Override CFBundleVersion. + --architecture Build one declared architecture (default: arm64). + --signing-identity Developer ID Application identity. + -h, --help Show this help. + +Environment overrides use the DEVELOPER_ID_ prefix, including +DEVELOPER_ID_SIGNING_IDENTITY, DEVELOPER_ID_ARCHITECTURE, and the safe +DEVELOPER_ID_OUTPUT_NAME directory name under dist/. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-signing) + SKIP_SIGNING=1 + shift + ;; + --bundle-id) + [[ $# -ge 2 ]] || { echo "Missing value for --bundle-id" >&2; exit 2; } + BUNDLE_IDENTIFIER="$2" + shift 2 + ;; + --version) + [[ $# -ge 2 ]] || { echo "Missing value for --version" >&2; exit 2; } + MARKETING_VERSION="$2" + shift 2 + ;; + --build-number) + [[ $# -ge 2 ]] || { echo "Missing value for --build-number" >&2; exit 2; } + BUILD_NUMBER="$2" + shift 2 + ;; + --architecture) + [[ $# -ge 2 ]] || { echo "Missing value for --architecture" >&2; exit 2; } + ARCHITECTURE="$2" + shift 2 + ;; + --signing-identity) + [[ $# -ge 2 ]] || { echo "Missing value for --signing-identity" >&2; exit 2; } + SIGNING_IDENTITY="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +invalid_metadata() { + echo "Invalid release metadata: $1" >&2 + exit 2 +} + +[[ "$APP_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._\ -]*$ ]] || invalid_metadata "app name" +[[ "$EXECUTABLE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || invalid_metadata "executable name" +[[ "$BUNDLE_IDENTIFIER" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$ && "$BUNDLE_IDENTIFIER" == *.* ]] || invalid_metadata "bundle identifier" +[[ "$MARKETING_VERSION" =~ ^[0-9]+([.][0-9]+){1,2}$ ]] || invalid_metadata "marketing version" +[[ "$BUILD_NUMBER" =~ ^[0-9]+$ ]] || invalid_metadata "build number" +[[ "$MINIMUM_SYSTEM_VERSION" =~ ^[0-9]+([.][0-9]+){1,2}$ ]] || invalid_metadata "minimum system version" +[[ "$ARCHITECTURE" == arm64 || "$ARCHITECTURE" == x86_64 ]] || invalid_metadata "architecture" +[[ "$COPYRIGHT_YEAR" =~ ^[0-9]{4}$ ]] || invalid_metadata "copyright year" +[[ "$OUTPUT_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || invalid_metadata "output name" +if [[ -n "$SOURCE_TAG" ]]; then + [[ "$SOURCE_TAG" =~ ^macos-v[0-9]+([.][0-9]+){1,2}$ ]] || invalid_metadata "source tag" + [[ "${SOURCE_TAG#macos-v}" == "$MARKETING_VERSION" ]] || { echo "Release source tag does not match the marketing version." >&2; exit 2; } +fi + +if [[ -n "$SIGNING_IDENTITY" && "$SIGNING_IDENTITY" != "Developer ID Application:"* ]]; then + echo "Signing identity must be a Developer ID Application identity." >&2 + exit 3 +fi + +APP_PATH="$DIST_DIR/$APP_NAME.app" +DMG_BASENAME="Codebase-Combiner-$MARKETING_VERSION-$ARCHITECTURE.dmg" +DMG_PATH="$DIST_DIR/$DMG_BASENAME" +SBOM_BASENAME="Codebase-Combiner-$MARKETING_VERSION-$ARCHITECTURE.cdx.json" +SBOM_PATH="$DIST_DIR/$SBOM_BASENAME" +MANIFEST_PATH="$DIST_DIR/release-manifest.json" +CHECKSUM_PATH="$DIST_DIR/SHA256SUMS.pre-notarization" +FINAL_CHECKSUM_PATH="$DIST_DIR/SHA256SUMS" +NOTARY_DIR="$DIST_DIR/notarization" +PUBLIC_NOTARY_SUMMARY="$DIST_DIR/notarization-summary.json" +PUBLIC_NOTARY_SUBMISSION="$DIST_DIR/notarization-submission.json" +PUBLIC_NOTARY_LOG="$DIST_DIR/notarization-log.json" +SYMBOLS_DIR="$DIST_DIR/symbols/$MARKETING_VERSION-$BUILD_NUMBER-$ARCHITECTURE" +SYMBOL_MANIFEST="$SYMBOLS_DIR/manifest.txt" +SYMBOLS_ARCHIVE_BASENAME="Codebase-Combiner-$MARKETING_VERSION-$ARCHITECTURE-symbols.zip" +SYMBOLS_ARCHIVE_PATH="$DIST_DIR/$SYMBOLS_ARCHIVE_BASENAME" +ENTITLEMENTS="$PACKAGING_DIR/DeveloperID.entitlements" +INFO_TEMPLATE="$PACKAGING_DIR/Info.plist.in" +PRIVACY_MANIFEST="$PACKAGING_DIR/PrivacyInfo.xcprivacy" +ICON_SOURCE="$ROOT_DIR/assets/icon.jpg" +ICONSET_DIR="$DIST_DIR/AppIcon.iconset" +STAGING_DIR="$DIST_DIR/dmg-root" +OPERATION_LOCK="$DIST_DIR/.release-operation.lock" +EXPECTED_TEAM_ID="" +CERTIFICATE_FINGERPRINT_SHA256="" +SOURCE_STATE="clean" + +require_tool() { + command -v "$1" >/dev/null 2>&1 || { echo "Missing required tool: $1" >&2; exit 1; } +} + +validate_binary_minimum_system_version() { + local binary="$1" + local binary_minimum + binary_minimum="$(xcrun vtool -show-build "$binary" | awk '$1 == "minos" { print $2; exit }')" + [[ -n "$binary_minimum" ]] || { echo "Unable to read the Mach-O deployment target." >&2; exit 1; } + [[ "$binary_minimum" == "$MINIMUM_SYSTEM_VERSION" ]] || { + echo "Mach-O deployment target '$binary_minimum' does not match declared minimum '$MINIMUM_SYSTEM_VERSION'." >&2 + exit 1 + } +} + +escape_sed() { + printf '%s' "$1" | sed 's/[\/&]/\\&/g' +} + +render_info_plist() { + sed \ + -e "s/@APP_NAME@/$(escape_sed "$APP_NAME")/g" \ + -e "s/@EXECUTABLE_NAME@/$(escape_sed "$EXECUTABLE_NAME")/g" \ + -e "s/@BUNDLE_IDENTIFIER@/$(escape_sed "$BUNDLE_IDENTIFIER")/g" \ + -e "s/@MARKETING_VERSION@/$(escape_sed "$MARKETING_VERSION")/g" \ + -e "s/@BUILD_NUMBER@/$(escape_sed "$BUILD_NUMBER")/g" \ + -e "s/@MINIMUM_SYSTEM_VERSION@/$(escape_sed "$MINIMUM_SYSTEM_VERSION")/g" \ + -e "s/@COPYRIGHT_YEAR@/$(escape_sed "$COPYRIGHT_YEAR")/g" \ + "$INFO_TEMPLATE" > "$APP_PATH/Contents/Info.plist" +} + +make_icon() { + rm -rf "$ICONSET_DIR" + mkdir -p "$ICONSET_DIR" + sips -s format png -z 16 16 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null + sips -s format png -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null + sips -s format png -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null + sips -s format png -z 64 64 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null + sips -s format png -z 128 128 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null + sips -s format png -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null + sips -s format png -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null + sips -s format png -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null + sips -s format png -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null + sips -s format png -z 1024 1024 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null + iconutil -c icns "$ICONSET_DIR" -o "$APP_PATH/Contents/Resources/AppIcon.icns" +} + +identity_exists() { + security find-identity -p codesigning -v | grep -F -- "\"$1\"" >/dev/null +} + +read_team_id() { + local certificate_pem + certificate_pem="$(mktemp "${TMPDIR:-/tmp}/codebase-combiner-cert.XXXXXX")" + if ! security find-certificate -c "$SIGNING_IDENTITY" -p > "$certificate_pem"; then + rm -f "$certificate_pem" + echo "Unable to inspect signing certificate: $SIGNING_IDENTITY" >&2 + exit 3 + fi + EXPECTED_TEAM_ID="$(openssl x509 -in "$certificate_pem" -noout -subject -nameopt RFC2253 | tr ',' '\n' | sed -n 's/^[[:space:]]*OU=//p' | head -n 1)" + CERTIFICATE_FINGERPRINT_SHA256="$(openssl x509 -in "$certificate_pem" -noout -fingerprint -sha256 | sed 's/^sha256 Fingerprint=//; s/^SHA256 Fingerprint=//; s/://g' | tr '[:lower:]' '[:upper:]')" + rm -f "$certificate_pem" + [[ "$EXPECTED_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || { echo "Signing certificate has no valid Team ID." >&2; exit 3; } + [[ "$CERTIFICATE_FINGERPRINT_SHA256" =~ ^[A-F0-9]{64}$ ]] || { echo "Signing certificate fingerprint could not be determined." >&2; exit 3; } +} + +validate_app() { + local entitlements_output="$DIST_DIR/codesign-entitlements.plist" + local signature_output="$DIST_DIR/codesign-details.txt" + plutil -lint "$APP_PATH/Contents/Info.plist" >/dev/null + plutil -lint "$APP_PATH/Contents/Resources/PrivacyInfo.xcprivacy" >/dev/null + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + codesign -dvvv "$APP_PATH" > /dev/null 2> "$signature_output" + codesign -d --entitlements - --xml "$APP_PATH" > "$entitlements_output" + plutil -lint "$entitlements_output" >/dev/null + [[ "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.app-sandbox' "$entitlements_output")" == true ]] + [[ "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.files.user-selected.read-write' "$entitlements_output")" == true ]] + + if [[ "$SKIP_SIGNING" -eq 0 ]]; then + grep -F 'Authority=Developer ID Application:' "$signature_output" >/dev/null + grep -F "TeamIdentifier=$EXPECTED_TEAM_ID" "$signature_output" >/dev/null + grep -E '^flags=.*runtime' "$signature_output" >/dev/null + grep -F 'Timestamp=' "$signature_output" >/dev/null + fi +} + +preserve_symbols() { + local release_dsym="$1/$EXECUTABLE_NAME.dSYM" + local copied_dsym="$SYMBOLS_DIR/$EXECUTABLE_NAME.dSYM" + local copied_dwarf="$copied_dsym/Contents/Resources/DWARF/$EXECUTABLE_NAME" + local binary_uuid + local dsym_uuid + local bundle_architectures + + bundle_architectures="$(lipo -archs "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME")" + [[ "$bundle_architectures" == "$ARCHITECTURE" ]] || { echo "Packaged architecture '$bundle_architectures' does not match '$ARCHITECTURE'." >&2; exit 1; } + [[ -d "$release_dsym" ]] || { echo "Release dSYM not found: $release_dsym" >&2; exit 1; } + + mkdir -p "$SYMBOLS_DIR" + cp -R "$release_dsym" "$copied_dsym" + binary_uuid="$(dwarfdump --uuid "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" | awk '{print $2}' | sort -u)" + dsym_uuid="$(dwarfdump --uuid "$copied_dsym" | awk '{print $2}' | sort -u)" + [[ -n "$binary_uuid" && "$binary_uuid" == "$dsym_uuid" ]] || { echo "Release dSYM UUID does not match the app executable." >&2; exit 1; } + + { + echo "App: $APP_NAME" + echo "Version: $MARKETING_VERSION" + echo "Build: $BUILD_NUMBER" + echo "Architecture: $bundle_architectures" + echo "Executable UUID: $binary_uuid" + shasum -a 256 "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" "$copied_dwarf" + } > "$SYMBOL_MANIFEST" +} + +write_metadata() { + local signing_mode="$1" + local source_commit + local source_timestamp + local app_sha256 + local dmg_sha256 + local sbom_sha256 + local symbols_sha256 + source_commit="$(git -C "$ROOT_DIR" rev-parse HEAD)" + source_timestamp="$(git -C "$ROOT_DIR" show -s --format=%cI HEAD)" + + python3 - "$SBOM_PATH" "$APP_NAME" "$MARKETING_VERSION" "$BUNDLE_IDENTIFIER" "$source_commit" "$source_timestamp" "$SOURCE_TAG" <<'PY' +import json +import sys + +path, name, version, bundle_id, commit, timestamp, source_tag = sys.argv[1:] +document = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": f"urn:uuid:{commit[:8]}-{commit[8:12]}-{commit[12:16]}-{commit[16:20]}-{commit[20:32]}", + "version": 1, + "metadata": { + "timestamp": timestamp, + "component": { + "type": "application", + "name": name, + "version": version, + "bom-ref": f"pkg:generic/{bundle_id}@{version}", + "licenses": [{"license": {"id": "MIT"}}], + "properties": [ + {"name": "source.commit", "value": commit}, + *([{"name": "source.tag", "value": source_tag}] if source_tag else []), + ], + }, + }, + "components": [], +} +with open(path, "w", encoding="utf-8") as handle: + json.dump(document, handle, indent=2, sort_keys=True) + handle.write("\n") +PY + + app_sha256="$(shasum -a 256 "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" | awk '{print $1}')" + dmg_sha256="$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')" + sbom_sha256="$(shasum -a 256 "$SBOM_PATH" | awk '{print $1}')" + symbols_sha256="$(shasum -a 256 "$SYMBOLS_ARCHIVE_PATH" | awk '{print $1}')" + + python3 - "$MANIFEST_PATH" "$APP_NAME" "$EXECUTABLE_NAME" "$MARKETING_VERSION" "$BUILD_NUMBER" "$BUNDLE_IDENTIFIER" "$MINIMUM_SYSTEM_VERSION" "$ARCHITECTURE" "$signing_mode" "$EXPECTED_TEAM_ID" "$CERTIFICATE_FINGERPRINT_SHA256" "$source_commit" "$SOURCE_TAG" "$SOURCE_STATE" "$DMG_BASENAME" "$SBOM_BASENAME" "$SYMBOLS_ARCHIVE_BASENAME" "$app_sha256" "$dmg_sha256" "$sbom_sha256" "$symbols_sha256" <<'PY' +import json +import sys + +(path, name, executable, version, build, bundle_id, minimum_os, architecture, + signing_mode, team_id, certificate_fingerprint, commit, source_tag, source_state, + dmg, sbom, symbols, app_sha256, dmg_sha256, sbom_sha256, symbols_sha256) = sys.argv[1:] +document = { + "schemaVersion": 1, + "product": { + "name": name, + "executable": executable, + "bundleIdentifier": bundle_id, + "marketingVersion": version, + "buildNumber": build, + "minimumSystemVersion": minimum_os, + "architecture": architecture, + }, + "sourceCommit": commit, + "sourceTag": source_tag or None, + "sourceState": source_state, + "signingMode": signing_mode, + "signingTeamId": team_id or None, + "certificateFingerprintSHA256": certificate_fingerprint or None, + "notarization": { + "status": "not-submitted", + "submissionId": None, + "ticketStapled": False, + "gatekeeperPassed": False, + }, + "artifacts": { + "dmg": dmg, + "sbom": sbom, + "symbols": symbols, + "appExecutableSHA256": app_sha256, + "dmgSHA256": dmg_sha256, + "sbomSHA256": sbom_sha256, + "symbolsSHA256": symbols_sha256, + }, +} +with open(path, "w", encoding="utf-8") as handle: + json.dump(document, handle, indent=2, sort_keys=True) + handle.write("\n") +PY +} + +cleanup() { + rm -rf "$ICONSET_DIR" "$STAGING_DIR" "$OPERATION_LOCK" +} + +guard_release_output_path "$ROOT_DIR" "$DIST_DIR" +mkdir -p "$DIST_DIR" +guard_release_output_path "$ROOT_DIR" "$DIST_DIR" +if ! mkdir "$OPERATION_LOCK" 2>/dev/null; then + echo "Another Developer ID build is already running for $DIST_DIR." >&2 + exit 7 +fi +printf '%s\n' "$$" > "$OPERATION_LOCK/pid" +trap cleanup EXIT + +for tool in swift sips iconutil codesign plutil lipo dwarfdump shasum python3 xcrun ditto; do + require_tool "$tool" +done +require_disk_image_tool + +if [[ -n "$(git -C "$ROOT_DIR" status --porcelain --untracked-files=all)" ]]; then + SOURCE_STATE="dirty" +fi + +if [[ "$SKIP_SIGNING" -eq 0 ]]; then + require_tool security + require_tool openssl + [[ "$SOURCE_STATE" == clean ]] || { echo "Production signing requires a clean Git worktree so the manifest matches the public source commit." >&2; exit 3; } + [[ -n "$SIGNING_IDENTITY" ]] || { echo "Pass --signing-identity explicitly for production signing." >&2; exit 3; } + [[ "$SOURCE_TAG" =~ ^macos-v[0-9]+([.][0-9]+){1,2}$ ]] || { echo "Production signing requires DEVELOPER_ID_SOURCE_TAG=macos-v." >&2; exit 3; } + source_tag_commit="$(git -C "$ROOT_DIR" rev-list -n 1 "$SOURCE_TAG" 2>/dev/null || true)" + [[ -n "$source_tag_commit" && "$source_tag_commit" == "$(git -C "$ROOT_DIR" rev-parse HEAD)" ]] || { echo "Release tag does not resolve to the source commit." >&2; exit 3; } + [[ "$SIGNING_IDENTITY" == "Developer ID Application:"* ]] || { echo "Signing identity must be a Developer ID Application identity." >&2; exit 3; } + identity_exists "$SIGNING_IDENTITY" || { echo "Signing identity not found in keychain: $SIGNING_IDENTITY" >&2; exit 3; } + read_team_id +fi + +rm -rf "$APP_PATH" "$DMG_PATH" "$SBOM_PATH" "$MANIFEST_PATH" "$CHECKSUM_PATH" "$FINAL_CHECKSUM_PATH" "$NOTARY_DIR" "$SYMBOLS_DIR" "$SYMBOLS_ARCHIVE_PATH" "$STAGING_DIR" "$ICONSET_DIR" +rm -f "$PUBLIC_NOTARY_SUMMARY" "$PUBLIC_NOTARY_SUBMISSION" "$PUBLIC_NOTARY_LOG" + +echo "==> Building SwiftPM release product for $ARCHITECTURE" +swift build -c release --arch "$ARCHITECTURE" --package-path "$PACKAGE_DIR" --product "$EXECUTABLE_NAME" +RELEASE_DIR="$(swift build --show-bin-path -c release --arch "$ARCHITECTURE" --package-path "$PACKAGE_DIR")" +RELEASE_BINARY="$RELEASE_DIR/$EXECUTABLE_NAME" +[[ -x "$RELEASE_BINARY" ]] || { echo "Release executable not found: $RELEASE_BINARY" >&2; exit 1; } + +echo "==> Assembling $APP_PATH" +mkdir -p "$APP_PATH/Contents/MacOS" "$APP_PATH/Contents/Resources" +cp "$RELEASE_BINARY" "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +chmod 755 "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +validate_binary_minimum_system_version "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +render_info_plist +make_icon +cp "$PRIVACY_MANIFEST" "$APP_PATH/Contents/Resources/PrivacyInfo.xcprivacy" +cp "$ROOT_DIR/LICENSE" "$APP_PATH/Contents/Resources/LICENSE" +cp "$ROOT_DIR/THIRD_PARTY_NOTICES.md" "$APP_PATH/Contents/Resources/THIRD_PARTY_NOTICES.md" + +unexpected_executables="$(find "$APP_PATH/Contents" -type f -perm -111 ! -path "$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" -print)" +[[ -z "$unexpected_executables" ]] || { echo "Unexpected nested executable code must be reviewed and signed explicitly:" >&2; printf '%s\n' "$unexpected_executables" >&2; exit 1; } + +if [[ "$SKIP_SIGNING" -eq 1 ]]; then + echo "==> Ad-hoc signing app for local validation" + codesign --force --sign - --entitlements "$ENTITLEMENTS" --timestamp=none "$APP_PATH" + SIGNING_MODE="ad-hoc local validation" +else + echo "==> Developer ID signing app" + codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$APP_PATH" + SIGNING_MODE="$SIGNING_IDENTITY" +fi + +validate_app +preserve_symbols "$RELEASE_DIR" +ditto -c -k --sequesterRsrc --keepParent "$DIST_DIR/symbols" "$SYMBOLS_ARCHIVE_PATH" + +echo "==> Creating drag-to-Applications DMG" +mkdir -p "$STAGING_DIR" +cp -R "$APP_PATH" "$STAGING_DIR/$APP_NAME.app" +ln -s /Applications "$STAGING_DIR/Applications" +disk_image_create "$APP_NAME" "$STAGING_DIR" "$DMG_PATH" + +if [[ "$SKIP_SIGNING" -eq 1 ]]; then + codesign --force --sign - --timestamp=none "$DMG_PATH" +else + codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH" +fi +codesign --verify --verbose=2 "$DMG_PATH" + +write_metadata "$SIGNING_MODE" + +( + cd "$DIST_DIR" + shasum -a 256 \ + "$DMG_BASENAME" \ + "$APP_NAME.app/Contents/MacOS/$EXECUTABLE_NAME" \ + "symbols/$MARKETING_VERSION-$BUILD_NUMBER-$ARCHITECTURE/$EXECUTABLE_NAME.dSYM/Contents/Resources/DWARF/$EXECUTABLE_NAME" \ + "$SBOM_BASENAME" \ + "$SYMBOLS_ARCHIVE_BASENAME" \ + "release-manifest.json" > "$(basename "$CHECKSUM_PATH")" +) + +"$PACKAGING_DIR/verify_release_artifact.sh" \ + --dmg "$DMG_PATH" \ + --manifest "$MANIFEST_PATH" \ + --phase build \ + --signing-mode "$([[ "$SKIP_SIGNING" -eq 1 ]] && printf ad-hoc || printf developer-id)" + +echo "==> Developer ID release candidate assembled" +echo "App: $APP_PATH" +echo "DMG: $DMG_PATH" +echo "Signing: $SIGNING_MODE" +echo "Notarization: not submitted; use Packaging/DeveloperID/notarize_release.sh after approval" diff --git a/Packaging/DeveloperID/notarize_release.sh b/Packaging/DeveloperID/notarize_release.sh new file mode 100755 index 0000000..c39612e --- /dev/null +++ b/Packaging/DeveloperID/notarize_release.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +set -euo pipefail + +DMG_PATH="" +KEYCHAIN_PROFILE="" +KEYCHAIN_PATH="" +APP_NAME="Codebase Combiner" +RESUME_SUBMISSION_ID="" +WAIT_TIMEOUT="${DEVELOPER_ID_NOTARY_TIMEOUT:-30m}" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +VERIFIER="$ROOT_DIR/Packaging/DeveloperID/verify_release_artifact.sh" + +usage() { + cat <<'USAGE' +Usage: Packaging/DeveloperID/notarize_release.sh --dmg --keychain-profile [options] + +Options: + --dmg Existing Developer ID-signed DMG. + --keychain-profile notarytool Keychain profile name. + --keychain Keychain containing the profile (recommended in CI). + --app-name Application name inside the DMG. + --submission-id Resume a prior submission without uploading again. + --timeout Bounded notary wait (default: 30m). + -h, --help Show this help. + +This command submits to Apple. Run it only after external-action approval. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dmg) + [[ $# -ge 2 ]] || { echo "Missing value for --dmg" >&2; exit 2; } + DMG_PATH="$2" + shift 2 + ;; + --keychain-profile) + [[ $# -ge 2 ]] || { echo "Missing value for --keychain-profile" >&2; exit 2; } + KEYCHAIN_PROFILE="$2" + shift 2 + ;; + --keychain) + [[ $# -ge 2 ]] || { echo "Missing value for --keychain" >&2; exit 2; } + KEYCHAIN_PATH="$2" + shift 2 + ;; + --app-name) + [[ $# -ge 2 ]] || { echo "Missing value for --app-name" >&2; exit 2; } + APP_NAME="$2" + shift 2 + ;; + --submission-id) + [[ $# -ge 2 ]] || { echo "Missing value for --submission-id" >&2; exit 2; } + RESUME_SUBMISSION_ID="$2" + shift 2 + ;; + --timeout) + [[ $# -ge 2 ]] || { echo "Missing value for --timeout" >&2; exit 2; } + WAIT_TIMEOUT="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[[ -n "$DMG_PATH" ]] || { echo "--dmg is required" >&2; exit 2; } +[[ -f "$DMG_PATH" ]] || { echo "DMG not found: $DMG_PATH" >&2; exit 2; } +[[ "$KEYCHAIN_PROFILE" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "Invalid or missing Keychain profile name." >&2; exit 2; } +[[ -z "$KEYCHAIN_PATH" || "$KEYCHAIN_PATH" = /* ]] || { echo "--keychain must be an absolute path." >&2; exit 2; } +[[ "$APP_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._\ -]*$ ]] || { echo "Invalid app name." >&2; exit 2; } +[[ -z "$RESUME_SUBMISSION_ID" || "$RESUME_SUBMISSION_ID" =~ ^[A-Za-z0-9-]+$ ]] || { echo "Invalid submission ID." >&2; exit 2; } +[[ "$WAIT_TIMEOUT" =~ ^[1-9][0-9]*[smh]$ ]] || { echo "Invalid timeout. Use a positive value such as 30m." >&2; exit 2; } +[[ -x "$VERIFIER" ]] || { echo "Release verifier is unavailable: $VERIFIER" >&2; exit 1; } + +require_tool() { + command -v "$1" >/dev/null 2>&1 || { echo "Missing required tool: $1" >&2; exit 1; } +} + +for tool in xcrun python3 shasum; do + require_tool "$tool" +done + +DIST_DIR="$(cd "$(dirname "$DMG_PATH")" && pwd)" +DMG_PATH="$DIST_DIR/$(basename "$DMG_PATH")" +NOTARY_DIR="$DIST_DIR/notarization" +SUBMIT_JSON="$NOTARY_DIR/submission.json" +SUMMARY_JSON="$NOTARY_DIR/summary.json" +FINAL_CHECKSUMS="$DIST_DIR/SHA256SUMS" +MANIFEST_PATH="$DIST_DIR/release-manifest.json" +PUBLIC_SUMMARY="$DIST_DIR/notarization-summary.json" +PUBLIC_SUBMISSION="$DIST_DIR/notarization-submission.json" +PUBLIC_LOG="$DIST_DIR/notarization-log.json" +OPERATION_LOCK="$DIST_DIR/.release-operation.lock" +[[ -f "$MANIFEST_PATH" ]] || { echo "Release manifest not found: $MANIFEST_PATH" >&2; exit 2; } +artifact_names="$(python3 - "$MANIFEST_PATH" <<'PY' +import json +import os +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as handle: + manifest = json.load(handle) + product_name = manifest["product"]["name"] + artifacts = manifest["artifacts"] + names = [artifacts["sbom"], artifacts["symbols"]] + if not isinstance(product_name, str) or not product_name: + raise ValueError("product name must be a non-empty string") + if any(not isinstance(name, str) or not name or os.path.basename(name) != name for name in names): + raise ValueError("artifact names must be non-empty basenames") + print("\t".join([product_name, *names])) +except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR\t{error}") +PY +)" +IFS=$'\t' read -r manifest_app_name sbom_basename symbols_basename <<< "$artifact_names" +[[ "$manifest_app_name" != ERROR && -n "$manifest_app_name" && -n "$sbom_basename" && -n "$symbols_basename" ]] || { + echo "Unable to resolve release assets from manifest: ${sbom_basename:-malformed manifest}" >&2 + exit 2 +} +[[ "$APP_NAME" == "$manifest_app_name" ]] || { + echo "Requested app name '$APP_NAME' does not match release manifest product '$manifest_app_name'." >&2 + exit 2 +} +SBOM_PATH="$DIST_DIR/$sbom_basename" +SYMBOLS_PATH="$DIST_DIR/$symbols_basename" +[[ -n "$SBOM_PATH" && -f "$SBOM_PATH" ]] || { echo "Release SBOM not found." >&2; exit 2; } +[[ -n "$SYMBOLS_PATH" && -f "$SYMBOLS_PATH" ]] || { echo "Release symbols archive not found." >&2; exit 2; } + +if ! mkdir "$OPERATION_LOCK" 2>/dev/null; then + echo "Another release operation is already running for $DIST_DIR." >&2 + exit 7 +fi +printf '%s\n' "$$" > "$OPERATION_LOCK/pid" +trap 'rm -rf "$OPERATION_LOCK"' EXIT + +mkdir -p "$NOTARY_DIR" + +NOTARY_AUTH=(--keychain-profile "$KEYCHAIN_PROFILE") +if [[ -n "$KEYCHAIN_PATH" ]]; then + NOTARY_AUTH+=(--keychain "$KEYCHAIN_PATH") +fi + +write_resume_command() { + local submission_id="$1" + local resume_path="$NOTARY_DIR/resume-command.txt" + printf 'Packaging/DeveloperID/notarize_release.sh --dmg %q --keychain-profile %q' \ + "$DMG_PATH" "$KEYCHAIN_PROFILE" > "$resume_path" + printf ' --app-name %q' "$APP_NAME" >> "$resume_path" + if [[ -n "$KEYCHAIN_PATH" ]]; then + printf ' --keychain %q' "$KEYCHAIN_PATH" >> "$resume_path" + fi + printf ' --timeout %q --submission-id %q\n' "$WAIT_TIMEOUT" "$submission_id" >> "$resume_path" + echo "$resume_path" +} + +parse_response() { + python3 - "$1" <<'PY' +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +except (OSError, json.JSONDecodeError) as error: + print(f"ERROR\t{error}") + raise SystemExit(0) + +submission_id = response.get("id") +status = response.get("status") +print( + f"{submission_id if isinstance(submission_id, str) else ''}\t" + f"{status if isinstance(status, str) else ''}" +) +PY +} + +echo "==> Authenticating signed release candidate before submission" +"$VERIFIER" --dmg "$DMG_PATH" --manifest "$MANIFEST_PATH" --phase pre-submit --signing-mode developer-id + +if [[ -n "$RESUME_SUBMISSION_ID" ]]; then + echo "==> Resuming Apple notarization submission $RESUME_SUBMISSION_ID" + if ! xcrun notarytool info "$RESUME_SUBMISSION_ID" \ + "${NOTARY_AUTH[@]}" \ + --output-format json > "$SUBMIT_JSON"; then + echo "Unable to query submission $RESUME_SUBMISSION_ID" >&2 + exit 4 + fi +else + echo "==> Submitting DMG to Apple notarization" + set +e + xcrun notarytool submit "$DMG_PATH" \ + "${NOTARY_AUTH[@]}" \ + --output-format json > "$SUBMIT_JSON" + submit_status=$? + set -e +fi + +response_line="$(parse_response "$SUBMIT_JSON")" +IFS=$'\t' read -r response_id response_status <<< "$response_line" + +if [[ "${response_id:-}" == ERROR || -z "${response_id:-}" ]]; then + echo "Notarization returned malformed evidence: $SUBMIT_JSON" >&2 + exit 4 +fi + +if [[ -z "$RESUME_SUBMISSION_ID" && "${submit_status:-0}" -ne 0 ]]; then + RESUME_PATH="$(write_resume_command "$response_id")" + echo "Submission interrupted after receiving ID $response_id. Resume command: $RESUME_PATH" >&2 + exit 4 +fi + +if [[ -z "${response_status:-}" ]]; then + echo "Notarization returned no status for submission $response_id" >&2 + exit 4 +fi + +if [[ "$response_status" != Accepted && "$response_status" != Invalid && "$response_status" != Rejected ]]; then + echo "==> Waiting up to $WAIT_TIMEOUT for submission $response_id" + if ! xcrun notarytool wait "$response_id" \ + "${NOTARY_AUTH[@]}" \ + --timeout "$WAIT_TIMEOUT" \ + --output-format json > "$SUBMIT_JSON"; then + RESUME_PATH="$(write_resume_command "$response_id")" + echo "Submission is still incomplete: $response_id. Resume command: $RESUME_PATH" >&2 + exit 4 + fi + response_line="$(parse_response "$SUBMIT_JSON")" + IFS=$'\t' read -r response_id response_status <<< "$response_line" + if [[ "${response_id:-}" == ERROR || -z "${response_id:-}" || -z "${response_status:-}" ]]; then + echo "Notarization wait returned malformed evidence: $SUBMIT_JSON" >&2 + exit 4 + fi +fi + +SUBMISSION_ID="$response_id" +NOTARY_STATUS="$response_status" +NOTARY_LOG="$NOTARY_DIR/$SUBMISSION_ID-log.json" + +if ! xcrun notarytool log "$SUBMISSION_ID" \ + "${NOTARY_AUTH[@]}" \ + "$NOTARY_LOG"; then + echo "Unable to retrieve notarization log for $SUBMISSION_ID" >&2 + exit 4 +fi + +if [[ "$NOTARY_STATUS" != Accepted ]]; then + echo "Notarization status is '$NOTARY_STATUS', not Accepted. Evidence: $NOTARY_LOG" >&2 + exit 4 +fi + +echo "==> Stapling and validating notarization ticket" +xcrun stapler staple "$DMG_PATH" +xcrun stapler validate "$DMG_PATH" + +python3 - "$MANIFEST_PATH" "$SUBMISSION_ID" "$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')" <<'PY' +import json +import sys + +path, submission_id, dmg_sha256 = sys.argv[1:] +with open(path, encoding="utf-8") as handle: + data = json.load(handle) +data["artifacts"]["dmgSHA256"] = dmg_sha256 +data["notarization"] = { + "status": "Accepted", + "submissionId": submission_id, + "ticketStapled": True, + "gatekeeperPassed": False, +} +temporary = f"{path}.tmp" +with open(temporary, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") +import os +os.replace(temporary, path) +PY + +echo "==> Verifying stapled DMG and mounted application" +"$VERIFIER" --dmg "$DMG_PATH" --manifest "$MANIFEST_PATH" --phase final --signing-mode developer-id + +python3 - "$MANIFEST_PATH" <<'PY' +import json +import os +import sys + +path = sys.argv[1] +with open(path, encoding="utf-8") as handle: + data = json.load(handle) +data["notarization"]["gatekeeperPassed"] = True +temporary = f"{path}.tmp" +with open(temporary, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") +os.replace(temporary, path) +PY + +python3 - "$SUMMARY_JSON" "$SUBMISSION_ID" "$NOTARY_STATUS" "$(basename "$DMG_PATH")" <<'PY' +import json +import sys + +path, submission_id, status, artifact = sys.argv[1:] +with open(path, "w", encoding="utf-8") as handle: + json.dump( + { + "submissionId": submission_id, + "status": status, + "artifact": artifact, + "ticketStapled": True, + "gatekeeperPassed": True, + }, + handle, + indent=2, + sort_keys=True, + ) + handle.write("\n") +PY + +( + cp "$SUMMARY_JSON" "$PUBLIC_SUMMARY" + cp "$SUBMIT_JSON" "$PUBLIC_SUBMISSION" + cp "$NOTARY_LOG" "$PUBLIC_LOG" + cd "$DIST_DIR" + shasum -a 256 \ + "$(basename "$DMG_PATH")" \ + "$(basename "$SBOM_PATH")" \ + "$(basename "$SYMBOLS_PATH")" \ + "$(basename "$MANIFEST_PATH")" \ + "$(basename "$PUBLIC_SUMMARY")" \ + "$(basename "$PUBLIC_SUBMISSION")" \ + "$(basename "$PUBLIC_LOG")" > "$(basename "$FINAL_CHECKSUMS")" + shasum -a 256 -c "$(basename "$FINAL_CHECKSUMS")" >/dev/null +) + +echo "==> Notarization Accepted, ticket stapled, Gatekeeper passed" +echo "Submission: $SUBMISSION_ID" +echo "Evidence: $NOTARY_DIR" diff --git a/Packaging/DeveloperID/tests/build_release_contract_test.sh b/Packaging/DeveloperID/tests/build_release_contract_test.sh new file mode 100755 index 0000000..cfbdfd7 --- /dev/null +++ b/Packaging/DeveloperID/tests/build_release_contract_test.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +source "$ROOT_DIR/script/disk_image_tools.sh" +SCRIPT="$ROOT_DIR/Packaging/DeveloperID/build_release.sh" +VERIFIER="$ROOT_DIR/Packaging/DeveloperID/verify_release_artifact.sh" +OUTPUT_NAME="developer-id-contract-$$" +DIST_DIR="$ROOT_DIR/dist/$OUTPUT_NAME" +APP="$DIST_DIR/Codebase Combiner.app" +DMG="$DIST_DIR/Codebase-Combiner-0.1.0-arm64.dmg" +mount_point="" + +cleanup() { + if [[ -n "$mount_point" ]]; then + disk_image_eject "$mount_point" >/dev/null 2>&1 || true + fi + rm -rf "$DIST_DIR" +} +trap cleanup EXIT + +run_build() { + DEVELOPER_ID_OUTPUT_NAME="$OUTPUT_NAME" "$SCRIPT" "$@" +} + +if [[ ! -x "$SCRIPT" ]]; then + echo "Developer ID build script is missing or not executable: $SCRIPT" >&2 + exit 1 +fi +if [[ ! -x "$VERIFIER" ]]; then + echo "Developer ID verifier is missing or not executable: $VERIFIER" >&2 + exit 1 +fi + +bash -n "$SCRIPT" +bash -n "$VERIFIER" +grep -F 'verify_release_artifact.sh' "$SCRIPT" >/dev/null + +help_output="$(run_build --help)" +grep -F -- '--skip-signing' <<< "$help_output" >/dev/null +grep -F -- '--signing-identity' <<< "$help_output" >/dev/null +grep -F 'Developer ID Application' <<< "$help_output" >/dev/null + +if grep -Ei 'provisioning|productbuild|installer identity|app store' "$SCRIPT"; then + echo "Developer ID build must not depend on Mac App Store packaging concepts." >&2 + exit 1 +fi + +grep -F -- '--options runtime' "$SCRIPT" >/dev/null +grep -F -- '--timestamp' "$SCRIPT" >/dev/null +grep -F 'Production signing requires a clean Git worktree' "$SCRIPT" >/dev/null +grep -F 'Pass --signing-identity explicitly' "$SCRIPT" >/dev/null +grep -F 'Another Developer ID build is already running' "$SCRIPT" >/dev/null +grep -F 'guard_release_output_path' "$SCRIPT" >/dev/null +if grep -E 'codesign .*--deep.*--sign|codesign .*--sign.*--deep' "$SCRIPT"; then + echo "Developer ID signing must enumerate nested code rather than sign with --deep." >&2 + exit 1 +fi + +external_output="$(mktemp -d "${TMPDIR:-/tmp}/codebase-combiner-external-output.XXXXXX")" +symlink_output_name="$OUTPUT_NAME-symlink" +symlink_output="$ROOT_DIR/dist/$symlink_output_name" +mkdir -p "$ROOT_DIR/dist" +printf 'preserve-external\n' > "$external_output/sentinel" +ln -s "$external_output" "$symlink_output" +if DEVELOPER_ID_OUTPUT_NAME="$symlink_output_name" "$SCRIPT" --skip-signing >/dev/null 2>&1; then + echo "Symlinked Developer ID output unexpectedly succeeded." >&2 + exit 1 +fi +grep -F 'preserve-external' "$external_output/sentinel" >/dev/null +rm -f "$symlink_output" +rm -rf "$external_output" + +mkdir -p "$DIST_DIR" +sentinel="$DIST_DIR/path-validation-sentinel" +printf 'preserve\n' > "$sentinel" + +if run_build --skip-signing --version '../escape' >/dev/null 2>&1; then + echo "Unsafe release version unexpectedly succeeded." >&2 + exit 1 +fi +grep -F 'preserve' "$sentinel" >/dev/null + +if run_build --skip-signing --architecture universal2 >/dev/null 2>&1; then + echo "Unimplemented universal architecture unexpectedly succeeded." >&2 + exit 1 +fi +grep -F 'preserve' "$sentinel" >/dev/null + +if run_build --signing-identity 'Apple Development: Example (AAAAAAAAAA)' >/dev/null 2>&1; then + echo "Non-Developer-ID signing identity unexpectedly succeeded." >&2 + exit 1 +fi +grep -F 'preserve' "$sentinel" >/dev/null + +mkdir -p "$DIST_DIR/.release-operation.lock" +if run_build --skip-signing >/dev/null 2>&1; then + echo "Concurrent Developer ID build unexpectedly succeeded." >&2 + exit 1 +fi +grep -F 'preserve' "$sentinel" >/dev/null +rm -rf "$DIST_DIR/.release-operation.lock" + +if DEVELOPER_ID_MINIMUM_SYSTEM_VERSION=12.0 run_build --skip-signing >/dev/null 2>&1; then + echo "Mismatched Mach-O deployment target unexpectedly succeeded." >&2 + exit 1 +fi +grep -F 'preserve' "$sentinel" >/dev/null + +if DEVELOPER_ID_SOURCE_TAG=macos-v9.9.9 run_build --skip-signing >/dev/null 2>&1; then + echo "Source tag that disagrees with the marketing version unexpectedly succeeded." >&2 + exit 1 +fi +grep -F 'preserve' "$sentinel" >/dev/null + +mkdir -p "$DIST_DIR/notarization" +printf 'stale\n' > "$DIST_DIR/notarization/summary.json" +printf 'stale\n' > "$DIST_DIR/SHA256SUMS" +printf 'stale\n' > "$DIST_DIR/notarization-summary.json" +printf 'stale\n' > "$DIST_DIR/notarization-submission.json" +printf 'stale\n' > "$DIST_DIR/notarization-log.json" +GITHUB_REF_NAME=main run_build --skip-signing + +test -d "$APP" +test -f "$DMG" +test -f "$DIST_DIR/SHA256SUMS.pre-notarization" +test -f "$DIST_DIR/Codebase-Combiner-0.1.0-arm64.cdx.json" +test -f "$DIST_DIR/release-manifest.json" +test -f "$DIST_DIR/symbols/0.1.0-1-arm64/manifest.txt" +test ! -e "$APP/Contents/embedded.provisionprofile" +test ! -e "$DIST_DIR/notarization" +test ! -e "$DIST_DIR/SHA256SUMS" +test ! -e "$DIST_DIR/notarization-summary.json" +test ! -e "$DIST_DIR/notarization-submission.json" +test ! -e "$DIST_DIR/notarization-log.json" +cmp -s "$ROOT_DIR/LICENSE" "$APP/Contents/Resources/LICENSE" +cmp -s "$ROOT_DIR/THIRD_PARTY_NOTICES.md" "$APP/Contents/Resources/THIRD_PARTY_NOTICES.md" + +plutil -lint "$APP/Contents/Info.plist" >/dev/null +test "$(/usr/libexec/PlistBuddy -c 'Print :NSHumanReadableCopyright' "$APP/Contents/Info.plist")" = \ + 'Copyright © 2026 Rafal Sikora. Licensed under the MIT License.' +plutil -lint "$APP/Contents/Resources/PrivacyInfo.xcprivacy" >/dev/null +codesign --verify --deep --strict --verbose=2 "$APP" +codesign --verify --verbose=2 "$DMG" + +signed_entitlements="$DIST_DIR/test-entitlements.plist" +codesign -d --entitlements - --xml "$APP" > "$signed_entitlements" +test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.app-sandbox' "$signed_entitlements")" = true +test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.files.user-selected.read-write' "$signed_entitlements")" = true + +test "$(lipo -archs "$APP/Contents/MacOS/CodebaseExplorerApp")" = arm64 +binary_uuid="$(dwarfdump --uuid "$APP/Contents/MacOS/CodebaseExplorerApp" | awk '{print $2}' | sort -u)" +dsym_uuid="$(dwarfdump --uuid "$DIST_DIR/symbols/0.1.0-1-arm64/CodebaseExplorerApp.dSYM" | awk '{print $2}' | sort -u)" +test -n "$binary_uuid" +test "$binary_uuid" = "$dsym_uuid" + +grep -F '"bomFormat": "CycloneDX"' "$DIST_DIR/Codebase-Combiner-0.1.0-arm64.cdx.json" >/dev/null +grep -F '"name": "Codebase Combiner"' "$DIST_DIR/Codebase-Combiner-0.1.0-arm64.cdx.json" >/dev/null +grep -F '"signingMode": "ad-hoc local validation"' "$DIST_DIR/release-manifest.json" >/dev/null +grep -F '"sourceTag": null' "$DIST_DIR/release-manifest.json" >/dev/null +grep -E '"sourceState": "(clean|dirty)"' "$DIST_DIR/release-manifest.json" >/dev/null +grep -F '"appExecutableSHA256":' "$DIST_DIR/release-manifest.json" >/dev/null +grep -F '"dmgSHA256":' "$DIST_DIR/release-manifest.json" >/dev/null + +attach_output="$(disk_image_attach "$DMG")" +mount_point="$(printf '%s\n' "$attach_output" | awk -F '\t' 'END {print $NF}')" +test "$(readlink "$mount_point/Applications")" = /Applications +codesign --verify --deep --strict --verbose=2 "$mount_point/Codebase Combiner.app" +cmp -s "$ROOT_DIR/LICENSE" "$mount_point/Codebase Combiner.app/Contents/Resources/LICENSE" +cmp -s "$ROOT_DIR/THIRD_PARTY_NOTICES.md" "$mount_point/Codebase Combiner.app/Contents/Resources/THIRD_PARTY_NOTICES.md" +source_hash="$(shasum -a 256 "$APP/Contents/MacOS/CodebaseExplorerApp" | awk '{print $1}')" +mounted_hash="$(shasum -a 256 "$mount_point/Codebase Combiner.app/Contents/MacOS/CodebaseExplorerApp" | awk '{print $1}')" +test "$source_hash" = "$mounted_hash" +disk_image_eject "$mount_point" +mount_point="" + +(cd "$DIST_DIR" && shasum -a 256 -c SHA256SUMS.pre-notarization) + +rm -f "$signed_entitlements" +echo "Developer ID build contract passed" diff --git a/Packaging/DeveloperID/tests/notarize_release_contract_test.sh b/Packaging/DeveloperID/tests/notarize_release_contract_test.sh new file mode 100755 index 0000000..36b49ce --- /dev/null +++ b/Packaging/DeveloperID/tests/notarize_release_contract_test.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +SCRIPT="$ROOT_DIR/Packaging/DeveloperID/notarize_release.sh" +VERIFIER="$ROOT_DIR/Packaging/DeveloperID/verify_release_artifact.sh" + +for executable in "$SCRIPT" "$VERIFIER"; do + [[ -x "$executable" ]] || { echo "Release script is missing or not executable: $executable" >&2; exit 1; } + bash -n "$executable" +done + +help_output="$($SCRIPT --help)" +grep -F -- '--keychain-profile' <<< "$help_output" >/dev/null +grep -F -- ' --keychain ' <<< "$help_output" >/dev/null +grep -F -- '--submission-id' <<< "$help_output" >/dev/null +grep -F -- '--timeout' "$SCRIPT" >/dev/null +grep -F 'verify_release_artifact.sh' "$SCRIPT" >/dev/null +if grep -Ei 'password|apple-id' "$SCRIPT"; then + echo "Notarization script must accept only a Keychain profile, never inline account credentials." >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codebase-combiner-notary-test.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT + +BIN_DIR="$TMP_DIR/bin" +MOUNT_POINT="$TMP_DIR/mount/Codebase Combiner" +MOUNTED_APP="$MOUNT_POINT/Codebase Combiner.app" +MOUNTED_EXECUTABLE="$MOUNTED_APP/Contents/MacOS/CodebaseExplorerApp" +DMG="$TMP_DIR/Codebase-Combiner-0.1.0-arm64.dmg" +SBOM="$TMP_DIR/Codebase-Combiner-0.1.0-arm64.cdx.json" +MANIFEST="$TMP_DIR/release-manifest.json" +ENTITLEMENTS="$TMP_DIR/entitlements.plist" +INFO_PLIST="$MOUNTED_APP/Contents/Info.plist" +LOG="$TMP_DIR/calls.log" +SOURCE_COMMIT="$(git -C "$ROOT_DIR" rev-parse HEAD)" +SYMBOLS="$TMP_DIR/Codebase-Combiner-0.1.0-arm64-symbols.zip" +mkdir -p "$BIN_DIR" "$MOUNTED_APP/Contents/MacOS" + +cat > "$ENTITLEMENTS" <<'PLIST' + + + +com.apple.security.app-sandbox +com.apple.security.files.user-selected.read-write + +PLIST + +cat > "$INFO_PLIST" <<'PLIST' + + + +CFBundleIdentifiercom.s1korrrr.codebasecombiner +CFBundleShortVersionString0.1.0 +CFBundleVersion1 +LSMinimumSystemVersion13.0 + +PLIST +printf 'fixture executable\n' > "$MOUNTED_EXECUTABLE" +chmod +x "$MOUNTED_EXECUTABLE" +ln -s /Applications "$MOUNT_POINT/Applications" + +cat > "$BIN_DIR/xcrun" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf 'xcrun %s\n' "$*" >> "$CALL_LOG" +case "$1:$2" in + notarytool:submit) + if [[ "${NOTARYTOOL_MALFORMED:-0}" == 1 ]]; then + printf '{"id":"submission-123"}\n' + elif [[ "${SUBMIT_FAIL_WITH_ID:-0}" == 1 ]]; then + printf '{"id":"submission-123","status":"In Progress"}\n' + exit 1 + else + printf '{"id":"submission-123","status":"%s","message":"test"}\n' "${NOTARYTOOL_STATUS:-Accepted}" + fi + ;; + notarytool:info) + printf '{"id":"submission-123","status":"%s"}\n' "${INFO_STATUS:-Accepted}" + ;; + notarytool:wait) + printf '{"id":"submission-123","status":"%s"}\n' "${WAIT_STATUS:-Accepted}" + ;; + notarytool:log) + output="${!#}" + printf '{"id":"submission-123","issues":[]}\n' > "$output" + ;; + stapler:staple) + [[ "${FAIL_GATE:-}" != staple ]] || exit 70 + ;; + stapler:validate) + [[ "${FAIL_GATE:-}" != stapler-validate ]] || exit 71 + ;; + *) + echo "Unexpected xcrun invocation: $*" >&2 + exit 64 + ;; +esac +STUB + +cat > "$BIN_DIR/spctl" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf 'spctl %s\n' "$*" >> "$CALL_LOG" +if [[ "$*" == *"--type open"* && "${FAIL_GATE:-}" == spctl-open ]]; then exit 72; fi +if [[ "$*" == *"--type execute"* && "${FAIL_GATE:-}" == spctl-execute ]]; then exit 73; fi +STUB + +cat > "$BIN_DIR/codesign" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf 'codesign %s\n' "$*" >> "$CALL_LOG" +if [[ "$*" == *"--verify"* && "${FAIL_GATE:-}" == codesign-verify ]]; then exit 74; fi +if [[ "$*" == *"--extract-certificates"* ]]; then + while [[ $# -gt 0 ]]; do + if [[ "$1" == --extract-certificates ]]; then + printf 'public certificate fixture\n' > "$2"0 + exit 0 + fi + shift + done +elif [[ "$*" == *"-dvvv"* ]]; then + cat >&2 <<'DETAILS' +Authority=Developer ID Application: Rafal Sikora (2NY8A789TN) +TeamIdentifier=2NY8A789TN +flags=0x10000(runtime) +Timestamp=Jul 15, 2026 at 10:00:00 PM +DETAILS +elif [[ "$*" == *"--entitlements"* ]]; then + cat "$TEST_ENTITLEMENTS" +fi +STUB + +cat > "$BIN_DIR/openssl" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${FAIL_GATE:-}" == fingerprint ]]; then + printf 'SHA256 Fingerprint=' + printf '0%.0s' {1..64} + printf '\n' +else + printf 'SHA256 Fingerprint=' + printf 'A%.0s' {1..64} + printf '\n' +fi +STUB + +cat > "$BIN_DIR/hdiutil" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf 'hdiutil %s\n' "$*" >> "$CALL_LOG" +case "$1" in + attach) + [[ "${FAIL_GATE:-}" != hdiutil-attach ]] || exit 75 + if [[ "${FAIL_GATE:-}" == wrong-link ]]; then + rm -f "$TEST_MOUNT_POINT/Applications" + ln -s /tmp "$TEST_MOUNT_POINT/Applications" + fi + printf '/dev/disk42\tApple_HFS\t%s\n' "$TEST_MOUNT_POINT" + ;; + detach) + ;; + *) + exit 64 + ;; +esac +STUB + +cat > "$BIN_DIR/lipo" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf 'arm64\n' +STUB + +cat > "$BIN_DIR/git" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +case "$*" in + *"rev-parse HEAD"*|*"rev-list -n 1 macos-v0.1.0"*) printf '%s\n' "$TEST_SOURCE_COMMIT" ;; + *) exec /usr/bin/git "$@" ;; +esac +STUB +chmod +x "$BIN_DIR/xcrun" "$BIN_DIR/spctl" "$BIN_DIR/codesign" "$BIN_DIR/hdiutil" "$BIN_DIR/lipo" "$BIN_DIR/openssl" "$BIN_DIR/git" + +reset_fixture() { + rm -rf "$TMP_DIR/notarization" "$TMP_DIR/SHA256SUMS" + rm -f "$MOUNT_POINT/Applications" + ln -s /Applications "$MOUNT_POINT/Applications" + printf 'dmg fixture\n' > "$DMG" + printf '{"bomFormat":"CycloneDX","specVersion":"1.5"}\n' > "$SBOM" + printf 'symbols fixture\n' > "$SYMBOLS" + printf 'stale SBOM that is not named by the manifest\n' > "$TMP_DIR/000-stale.cdx.json" + printf 'stale symbols that are not named by the manifest\n' > "$TMP_DIR/000-stale-symbols.zip" + local app_hash + local dmg_hash + local sbom_hash + local symbols_hash + app_hash="$(shasum -a 256 "$MOUNTED_EXECUTABLE" | awk '{print $1}')" + dmg_hash="$(shasum -a 256 "$DMG" | awk '{print $1}')" + sbom_hash="$(shasum -a 256 "$SBOM" | awk '{print $1}')" + symbols_hash="$(shasum -a 256 "$SYMBOLS" | awk '{print $1}')" + python3 - "$MANIFEST" "$SOURCE_COMMIT" "$app_hash" "$dmg_hash" "$sbom_hash" "$symbols_hash" <<'PY' +import json +import sys + +path, commit, app_hash, dmg_hash, sbom_hash, symbols_hash = sys.argv[1:] +data = { + "schemaVersion": 1, + "product": { + "name": "Codebase Combiner", + "executable": "CodebaseExplorerApp", + "bundleIdentifier": "com.s1korrrr.codebasecombiner", + "marketingVersion": "0.1.0", + "buildNumber": "1", + "minimumSystemVersion": "13.0", + "architecture": "arm64", + }, + "sourceCommit": commit, + "sourceTag": "macos-v0.1.0", + "sourceState": "clean", + "signingMode": "Developer ID Application: Rafal Sikora (2NY8A789TN)", + "signingTeamId": "2NY8A789TN", + "certificateFingerprintSHA256": "A" * 64, + "notarization": { + "status": "not-submitted", + "submissionId": None, + "ticketStapled": False, + "gatekeeperPassed": False, + }, + "artifacts": { + "dmg": "Codebase-Combiner-0.1.0-arm64.dmg", + "sbom": "Codebase-Combiner-0.1.0-arm64.cdx.json", + "symbols": "Codebase-Combiner-0.1.0-arm64-symbols.zip", + "appExecutableSHA256": app_hash, + "dmgSHA256": dmg_hash, + "sbomSHA256": sbom_hash, + "symbolsSHA256": symbols_hash, + }, +} +with open(path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") +PY + ( + cd "$TMP_DIR" + shasum -a 256 \ + "$(basename "$DMG")" \ + "$(basename "$SBOM")" \ + "$(basename "$SYMBOLS")" \ + "$(basename "$MANIFEST")" > SHA256SUMS.pre-notarization + ) + : > "$LOG" +} + +run_notary() { + CALL_LOG="$LOG" \ + TEST_MOUNT_POINT="$MOUNT_POINT" \ + TEST_ENTITLEMENTS="$ENTITLEMENTS" \ + TEST_SOURCE_COMMIT="$SOURCE_COMMIT" \ + CODEBASE_COMBINER_USE_HDIUTIL=1 \ + PATH="$BIN_DIR:$PATH" \ + "$SCRIPT" --dmg "$DMG" --keychain-profile test-notary --keychain "$TMP_DIR/test.keychain-db" --app-name 'Codebase Combiner' "$@" +} + +reset_fixture +mkdir -p "$TMP_DIR/.release-operation.lock" +if NOTARYTOOL_STATUS=Accepted run_notary >/dev/null 2>&1; then + echo "Concurrent release operation unexpectedly entered notarization." >&2 + exit 1 +fi +test ! -s "$LOG" +rm -rf "$TMP_DIR/.release-operation.lock" + +reset_fixture +if NOTARYTOOL_STATUS=Accepted run_notary --app-name 'Different App' >/dev/null 2>&1; then + echo "A notarization request with an app name that differs from the manifest unexpectedly succeeded." >&2 + exit 1 +fi +test ! -s "$LOG" + +reset_fixture +NOTARYTOOL_STATUS='In Progress' WAIT_STATUS=Accepted run_notary +grep -F 'notarytool submit' "$LOG" >/dev/null +grep -F -- '--keychain-profile test-notary' "$LOG" >/dev/null +grep -F -- '--keychain '"$TMP_DIR/test.keychain-db" "$LOG" >/dev/null +grep -F 'notarytool wait submission-123' "$LOG" >/dev/null +grep -F 'notarytool log submission-123' "$LOG" >/dev/null +grep -F 'stapler staple' "$LOG" >/dev/null +grep -F 'stapler validate' "$LOG" >/dev/null +grep -F 'spctl --assess --type open' "$LOG" >/dev/null +grep -F 'spctl --assess --type execute' "$LOG" >/dev/null +grep -F 'codesign --verify --deep --strict' "$LOG" >/dev/null +test -f "$TMP_DIR/notarization/submission-123-log.json" +test -f "$TMP_DIR/SHA256SUMS" +test -f "$TMP_DIR/notarization-summary.json" +test -f "$TMP_DIR/notarization-submission.json" +test -f "$TMP_DIR/notarization-log.json" +python3 - "$MANIFEST" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as handle: + data = json.load(handle) +assert data["notarization"] == { + "status": "Accepted", + "submissionId": "submission-123", + "ticketStapled": True, + "gatekeeperPassed": True, +} +PY +(cd "$TMP_DIR" && shasum -a 256 -c SHA256SUMS) +test "$(wc -l < "$TMP_DIR/SHA256SUMS" | tr -d ' ')" = 7 +grep -F 'Codebase-Combiner-0.1.0-arm64.dmg' "$TMP_DIR/SHA256SUMS" >/dev/null +grep -F 'Codebase-Combiner-0.1.0-arm64.cdx.json' "$TMP_DIR/SHA256SUMS" >/dev/null +grep -F 'Codebase-Combiner-0.1.0-arm64-symbols.zip' "$TMP_DIR/SHA256SUMS" >/dev/null +grep -F 'release-manifest.json' "$TMP_DIR/SHA256SUMS" >/dev/null +grep -F 'notarization-summary.json' "$TMP_DIR/SHA256SUMS" >/dev/null +grep -F 'notarization-submission.json' "$TMP_DIR/SHA256SUMS" >/dev/null +grep -F 'notarization-log.json' "$TMP_DIR/SHA256SUMS" >/dev/null +if grep -F 'notarization/' "$TMP_DIR/SHA256SUMS"; then + echo "Published checksums must use the flat GitHub asset layout." >&2 + exit 1 +fi + +for protected_asset in "$SBOM" "$SYMBOLS" "$MANIFEST" "$TMP_DIR/notarization-summary.json" "$TMP_DIR/notarization-log.json"; do + cp "$protected_asset" "$protected_asset.backup" + printf 'tampered\n' >> "$protected_asset" + if (cd "$TMP_DIR" && shasum -a 256 -c SHA256SUMS >/dev/null 2>&1); then + echo "Tampered final release asset unexpectedly passed checksums: $protected_asset" >&2 + exit 1 + fi + mv "$protected_asset.backup" "$protected_asset" +done + +reset_fixture +if NOTARYTOOL_STATUS=Invalid run_notary >/dev/null 2>&1; then + echo "Invalid notarization unexpectedly succeeded." >&2 + exit 1 +fi +grep -F 'notarytool log submission-123' "$LOG" >/dev/null +if grep -F 'stapler staple' "$LOG"; then + echo "Invalid notarization must not staple the artifact." >&2 + exit 1 +fi + +reset_fixture +if NOTARYTOOL_MALFORMED=1 run_notary >/dev/null 2>&1; then + echo "Malformed notarization response unexpectedly succeeded." >&2 + exit 1 +fi +if grep -F 'stapler staple' "$LOG"; then + echo "Malformed notarization response must not staple the artifact." >&2 + exit 1 +fi + +reset_fixture +if SUBMIT_FAIL_WITH_ID=1 run_notary >/dev/null 2>&1; then + echo "Interrupted submission unexpectedly succeeded." >&2 + exit 1 +fi +test -f "$TMP_DIR/notarization/resume-command.txt" +grep -F -- '--submission-id submission-123' "$TMP_DIR/notarization/resume-command.txt" >/dev/null +grep -F -- '--app-name Codebase\ Combiner' "$TMP_DIR/notarization/resume-command.txt" >/dev/null +if grep -F 'stapler staple' "$LOG"; then + echo "Interrupted submission must not staple the artifact." >&2 + exit 1 +fi + +reset_fixture +INFO_STATUS='In Progress' WAIT_STATUS=Accepted run_notary --submission-id submission-123 +grep -F 'notarytool info submission-123' "$LOG" >/dev/null +grep -F 'notarytool wait submission-123' "$LOG" >/dev/null + +for failure_gate in staple stapler-validate spctl-open spctl-execute codesign-verify fingerprint hdiutil-attach wrong-link; do + reset_fixture + if FAIL_GATE="$failure_gate" NOTARYTOOL_STATUS=Accepted run_notary >/dev/null 2>&1; then + echo "Release gate '$failure_gate' unexpectedly succeeded." >&2 + exit 1 + fi +done + +echo "Developer ID notarization contract passed" diff --git a/Packaging/DeveloperID/tests/run_tests.sh b/Packaging/DeveloperID/tests/run_tests.sh new file mode 100755 index 0000000..b268bc0 --- /dev/null +++ b/Packaging/DeveloperID/tests/run_tests.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +bash "$TEST_DIR/build_release_contract_test.sh" +bash "$TEST_DIR/notarize_release_contract_test.sh" + +echo "Developer ID release contracts passed" diff --git a/Packaging/DeveloperID/verify_release_artifact.sh b/Packaging/DeveloperID/verify_release_artifact.sh new file mode 100755 index 0000000..2864745 --- /dev/null +++ b/Packaging/DeveloperID/verify_release_artifact.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/script/disk_image_tools.sh" +DMG_PATH="" +MANIFEST_PATH="" +PHASE="" +SIGNING_MODE="" +MOUNT_POINT="" + +usage() { + cat <<'USAGE' +Usage: Packaging/DeveloperID/verify_release_artifact.sh --dmg --manifest --phase --signing-mode +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dmg) + DMG_PATH="$2" + shift 2 + ;; + --manifest) + MANIFEST_PATH="$2" + shift 2 + ;; + --phase) + PHASE="$2" + shift 2 + ;; + --signing-mode) + SIGNING_MODE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 2 + ;; + esac +done + +[[ -f "$DMG_PATH" ]] || { echo "DMG not found: $DMG_PATH" >&2; exit 2; } +[[ -f "$MANIFEST_PATH" ]] || { echo "Release manifest not found: $MANIFEST_PATH" >&2; exit 2; } +[[ "$PHASE" == build || "$PHASE" == pre-submit || "$PHASE" == final ]] || { echo "Invalid verification phase." >&2; exit 2; } +[[ "$SIGNING_MODE" == ad-hoc || "$SIGNING_MODE" == developer-id ]] || { echo "Invalid signing mode." >&2; exit 2; } + +require_tool() { + command -v "$1" >/dev/null 2>&1 || { echo "Missing required tool: $1" >&2; exit 1; } +} +for tool in python3 shasum codesign lipo plutil readlink openssl; do + require_tool "$tool" +done +require_disk_image_tool +if [[ "$PHASE" == final ]]; then + require_tool spctl +fi + +manifest_line="$(python3 - "$MANIFEST_PATH" <<'PY' +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as handle: + data = json.load(handle) + product = data["product"] + artifacts = data["artifacts"] + notarization = data["notarization"] + values = [ + product["name"], product["executable"], product["bundleIdentifier"], + product["marketingVersion"], product["buildNumber"], + product["minimumSystemVersion"], product["architecture"], + data["sourceCommit"], data.get("sourceTag") or "", data["sourceState"], data["signingMode"], + data.get("signingTeamId") or "", data.get("certificateFingerprintSHA256") or "", + artifacts["appExecutableSHA256"], + artifacts["dmgSHA256"], artifacts["sbom"], artifacts["sbomSHA256"], + artifacts["symbols"], artifacts["symbolsSHA256"], + notarization["status"], str(notarization["ticketStapled"]).lower(), + ] + if any("\x1f" in str(value) or "\n" in str(value) for value in values): + raise ValueError("manifest values contain control separators") + print("\x1f".join(str(value) for value in values)) +except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR\t{error}") +PY +)" +IFS=$'\x1f' read -r APP_NAME EXECUTABLE_NAME BUNDLE_IDENTIFIER MARKETING_VERSION BUILD_NUMBER MINIMUM_SYSTEM_VERSION ARCHITECTURE SOURCE_COMMIT SOURCE_TAG SOURCE_STATE EFFECTIVE_SIGNING_IDENTITY TEAM_ID CERTIFICATE_FINGERPRINT_SHA256 APP_SHA256 EXPECTED_DMG_SHA256 SBOM_BASENAME EXPECTED_SBOM_SHA256 SYMBOLS_BASENAME EXPECTED_SYMBOLS_SHA256 NOTARY_STATUS TICKET_STAPLED <<< "$manifest_line" + +[[ "$APP_NAME" != ERROR && -n "$TICKET_STAPLED" ]] || { echo "Malformed release manifest: $MANIFEST_PATH" >&2; exit 6; } +[[ "$APP_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._\ -]*$ ]] || { echo "Unsafe app name in release manifest." >&2; exit 6; } +[[ "$EXECUTABLE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { echo "Unsafe executable name in release manifest." >&2; exit 6; } +[[ "$ARCHITECTURE" == arm64 || "$ARCHITECTURE" == x86_64 ]] || { echo "Unsupported manifest architecture." >&2; exit 6; } + +DIST_DIR="$(cd "$(dirname "$DMG_PATH")" && pwd)" +DMG_PATH="$DIST_DIR/$(basename "$DMG_PATH")" +SBOM_PATH="$DIST_DIR/$SBOM_BASENAME" +SYMBOLS_PATH="$DIST_DIR/$SYMBOLS_BASENAME" +[[ -f "$SBOM_PATH" ]] || { echo "SBOM not found: $SBOM_PATH" >&2; exit 6; } +[[ -f "$SYMBOLS_PATH" ]] || { echo "Symbols archive not found: $SYMBOLS_PATH" >&2; exit 6; } + +actual_dmg_sha256="$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')" +actual_sbom_sha256="$(shasum -a 256 "$SBOM_PATH" | awk '{print $1}')" +actual_symbols_sha256="$(shasum -a 256 "$SYMBOLS_PATH" | awk '{print $1}')" +[[ "$actual_dmg_sha256" == "$EXPECTED_DMG_SHA256" ]] || { echo "DMG hash does not match the release manifest." >&2; exit 6; } +[[ "$actual_sbom_sha256" == "$EXPECTED_SBOM_SHA256" ]] || { echo "SBOM hash does not match the release manifest." >&2; exit 6; } +[[ "$actual_symbols_sha256" == "$EXPECTED_SYMBOLS_SHA256" ]] || { echo "Symbols hash does not match the release manifest." >&2; exit 6; } + +if [[ "$PHASE" == build || "$PHASE" == pre-submit ]]; then + (cd "$DIST_DIR" && shasum -a 256 -c SHA256SUMS.pre-notarization >/dev/null) +fi + +signature_fingerprint() { + local artifact="$1" + local certificate_dir + local certificate_prefix + local fingerprint + certificate_dir="$(mktemp -d "${TMPDIR:-/tmp}/codebase-combiner-signature-certificates.XXXXXX")" + certificate_prefix="$certificate_dir/certificate-" + if ! codesign -d --extract-certificates "$certificate_prefix" "$artifact" >/dev/null 2>&1; then + rm -rf "$certificate_dir" + echo "Unable to extract the public signing certificate from $artifact." >&2 + return 1 + fi + [[ -f "${certificate_prefix}0" ]] || { + rm -rf "$certificate_dir" + echo "The signature did not contain a leaf certificate: $artifact" >&2 + return 1 + } + fingerprint="$(openssl x509 -inform DER -in "${certificate_prefix}0" -noout -fingerprint -sha256 | sed 's/^sha256 Fingerprint=//; s/^SHA256 Fingerprint=//; s/://g' | tr '[:lower:]' '[:upper:]')" + rm -rf "$certificate_dir" + printf '%s\n' "$fingerprint" +} + +codesign --verify --verbose=4 "$DMG_PATH" +if [[ "$SIGNING_MODE" == developer-id ]]; then + [[ "$SOURCE_STATE" == clean ]] || { echo "Developer ID artifact manifest is not source-clean." >&2; exit 6; } + [[ "$SOURCE_COMMIT" == "$(git -C "$ROOT_DIR" rev-parse HEAD)" ]] || { echo "Release manifest source commit does not match HEAD." >&2; exit 6; } + [[ "$SOURCE_TAG" =~ ^macos-v[0-9]+([.][0-9]+){1,2}$ ]] || { echo "Release manifest has no valid source tag." >&2; exit 6; } + [[ "${SOURCE_TAG#macos-v}" == "$MARKETING_VERSION" ]] || { echo "Release manifest source tag does not match its marketing version." >&2; exit 6; } + [[ "$(git -C "$ROOT_DIR" rev-list -n 1 "$SOURCE_TAG")" == "$SOURCE_COMMIT" ]] || { echo "Release manifest source tag does not resolve to its source commit." >&2; exit 6; } + [[ "$EFFECTIVE_SIGNING_IDENTITY" == "Developer ID Application:"* ]] || { echo "Release manifest does not name a Developer ID identity." >&2; exit 6; } + [[ "$TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || { echo "Release manifest has no valid signing Team ID." >&2; exit 6; } + [[ "$CERTIFICATE_FINGERPRINT_SHA256" =~ ^[A-F0-9]{64}$ ]] || { echo "Release manifest has no valid signing certificate fingerprint." >&2; exit 6; } + [[ "$(signature_fingerprint "$DMG_PATH")" == "$CERTIFICATE_FINGERPRINT_SHA256" ]] || { echo "DMG signing certificate fingerprint does not match the release manifest." >&2; exit 6; } + dmg_details="$(mktemp "${TMPDIR:-/tmp}/codebase-combiner-dmg-signature.XXXXXX")" + codesign -dvvv "$DMG_PATH" >/dev/null 2> "$dmg_details" + grep -F 'Authority=Developer ID Application:' "$dmg_details" >/dev/null + grep -F "TeamIdentifier=$TEAM_ID" "$dmg_details" >/dev/null + grep -F 'Timestamp=' "$dmg_details" >/dev/null + rm -f "$dmg_details" +fi + +cleanup() { + if [[ -n "$MOUNT_POINT" ]]; then + disk_image_eject "$MOUNT_POINT" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +attach_output="$(disk_image_attach "$DMG_PATH")" +MOUNT_POINT="$(printf '%s\n' "$attach_output" | awk -F '\t' 'END {print $NF}')" +[[ -n "$MOUNT_POINT" && -d "$MOUNT_POINT" ]] || { echo "Unable to determine mounted DMG path." >&2; exit 6; } +[[ -L "$MOUNT_POINT/Applications" ]] || { echo "DMG is missing its Applications link." >&2; exit 6; } +[[ "$(readlink "$MOUNT_POINT/Applications")" == /Applications ]] || { echo "DMG Applications link has the wrong target." >&2; exit 6; } + +MOUNTED_APP="$MOUNT_POINT/$APP_NAME.app" +MOUNTED_EXECUTABLE="$MOUNTED_APP/Contents/MacOS/$EXECUTABLE_NAME" +INFO_PLIST="$MOUNTED_APP/Contents/Info.plist" +[[ -d "$MOUNTED_APP" && -x "$MOUNTED_EXECUTABLE" ]] || { echo "Mounted app or executable is missing." >&2; exit 6; } +codesign --verify --deep --strict --verbose=4 "$MOUNTED_APP" +plutil -lint "$INFO_PLIST" >/dev/null +[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$INFO_PLIST")" == "$BUNDLE_IDENTIFIER" ]] +[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO_PLIST")" == "$MARKETING_VERSION" ]] +[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INFO_PLIST")" == "$BUILD_NUMBER" ]] +[[ "$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$INFO_PLIST")" == "$MINIMUM_SYSTEM_VERSION" ]] +[[ "$(lipo -archs "$MOUNTED_EXECUTABLE")" == "$ARCHITECTURE" ]] +[[ "$(shasum -a 256 "$MOUNTED_EXECUTABLE" | awk '{print $1}')" == "$APP_SHA256" ]] + +entitlements_output="$(mktemp "${TMPDIR:-/tmp}/codebase-combiner-entitlements.XXXXXX")" +codesign -d --entitlements - --xml "$MOUNTED_APP" > "$entitlements_output" +python3 - "$entitlements_output" <<'PY' +import plistlib +import sys + +with open(sys.argv[1], "rb") as handle: + actual = plistlib.load(handle) +expected = { + "com.apple.security.app-sandbox": True, + "com.apple.security.files.user-selected.read-write": True, +} +if actual != expected: + raise SystemExit(f"Unexpected signed entitlements: {actual}") +PY +rm -f "$entitlements_output" + +if [[ "$SIGNING_MODE" == developer-id ]]; then + [[ "$(signature_fingerprint "$MOUNTED_APP")" == "$CERTIFICATE_FINGERPRINT_SHA256" ]] || { echo "App signing certificate fingerprint does not match the release manifest." >&2; exit 6; } + app_details="$(mktemp "${TMPDIR:-/tmp}/codebase-combiner-app-signature.XXXXXX")" + codesign -dvvv "$MOUNTED_APP" >/dev/null 2> "$app_details" + grep -F 'Authority=Developer ID Application:' "$app_details" >/dev/null + grep -F "TeamIdentifier=$TEAM_ID" "$app_details" >/dev/null + grep -E '^flags=.*runtime' "$app_details" >/dev/null + grep -F 'Timestamp=' "$app_details" >/dev/null + rm -f "$app_details" +fi + +if [[ "$PHASE" == final ]]; then + [[ "$NOTARY_STATUS" == Accepted && "$TICKET_STAPLED" == true ]] || { echo "Final manifest does not record an accepted, stapled artifact." >&2; exit 6; } + spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG_PATH" + spctl --assess --type execute --verbose=4 "$MOUNTED_APP" +fi + +disk_image_eject "$MOUNT_POINT" +MOUNT_POINT="" +trap - EXIT +echo "Release artifact verification passed: $PHASE / $SIGNING_MODE" diff --git a/README.md b/README.md index 18f65c1..9c2879b 100644 --- a/README.md +++ b/README.md @@ -8,23 +8,39 @@ Codebase Combiner helps you curate files, count tokens, and generate a ready-to- This repo ships two deliverables: - VS Code extension (Node/JavaScript) -- macOS SwiftUI app (SwiftPM) +- Native macOS SwiftUI app (SwiftPM) ## Features - Combine a workspace or folder into a single Markdown or text file. - Flexible include/exclude filters by glob and extension. - Token estimation for prompt sizing. -- SwiftUI desktop app for visual selection and preview. - -## Preview - -![Codebase Combiner app preview](image.png) +- Adaptive native macOS workspace with independently collapsible workspace and output panes, including a verified 960×640 regular-width path. +- Structured scan summaries for hidden, excluded, disallowed, oversized, binary, symbolic-link, and unreadable files without exposing skipped paths or symbolic-link targets. +- A shared 20,000-character current/recovered preview limit with an honest truncation notice; Copy, Copy Last, and Save continue to use the full payload. +- Privacy-conscious “last ready output” recovery: metadata stays visible, content stays concealed until Reveal, and clearing requires confirmation. +- Actionable recovery for failed workspace scans and recoverable-output persistence, including safe Retry and Choose Another Folder paths. +- One native Settings scene for output format, filter visibility, hidden-file handling, extension filters, and validated file-size limits. +- Typed local telemetry for lifecycle, scan, export, and recovery outcomes; logs contain counts and outcomes, not paths or payloads. +- A macOS 13 semantic-material baseline with narrowly availability-gated macOS 26 presentation when the compiled SDK and runtime support it. +- Public support and privacy-policy links in the app menu and Settings. ## Getting started See `INSTALL.md` for full setup and run instructions. +### Prepare for the macOS download + +No macOS 0.1.0 release is currently published. When an official build is available, it will be distributed outside the Mac App Store through [GitHub Releases](https://github.com/s1korrrr/codebase-combiner/releases) as a Developer ID-signed, Apple-notarized DMG. The candidate is Apple-silicon-only (`arm64`) and declares a macOS 13 deployment target; runtime verification at that floor remains pending. Intel and universal builds are not provided. + +Download all assets from the release into one directory and verify them before opening the DMG: + +```sh +shasum -a 256 -c SHA256SUMS +``` + +Open the DMG and drag **Codebase Combiner** into `/Applications`. Do not use Gatekeeper-bypass commands; the official artifact must open normally. + Quick start (VS Code extension): ```sh @@ -37,7 +53,7 @@ Quick start (Swift app): ```sh cd SwiftExplorerApp -swift run +swift run CodebaseExplorerApp ``` ## Usage @@ -53,8 +69,11 @@ Output options are configurable in VS Code settings under “Codebase Combiner ### macOS SwiftUI app -- Launch with `swift run` (or run the built binary). -- Choose a folder, adjust filters, select files, and copy/save the combined prompt. +- Launch with `swift run CodebaseExplorerApp` from `SwiftExplorerApp/`. +- Choose a folder, adjust filters, select files, then copy or save the combined prompt. +- The app keeps the last ready combined payload in local Application Support storage. Relaunch shows only its metadata until you explicitly reveal the content; revealed previews are bounded, while Copy Last still uses the full payload. +- Use the standard macOS Settings command or the app menu for preferences and support. +- Use the View menu or toolbar to show or hide the workspace sidebar, filters, and output inspector. ## Development @@ -63,15 +82,42 @@ Output options are configurable in VS Code settings under “Codebase Combiner - Tests: `npm test` - Lint: `npm run lint` - Format: `npm run format` (or `npm run format:check` in CI) +- Package a local VSIX: `npm run package` ### Swift +- Required formatter: SwiftFormat 0.61.1. Other versions are not equivalent to the CI toolchain. +- Build: `cd SwiftExplorerApp && swift build` - Tests: `cd SwiftExplorerApp && swift test` -- Format (SwiftFormat): `swiftformat .` +- Run: `cd SwiftExplorerApp && swift run CodebaseExplorerApp` +- Bundle launch smoke: `./script/build_and_run.sh --verify` +- Isolated native E2E host: `./script/build_and_run.sh --e2e` +- Remove isolated E2E state: `./script/build_and_run.sh --clean-e2e-state` +- Format (SwiftFormat 0.61.1): `test "$(swiftformat --version)" = "0.61.1" && swiftformat .` +- Format check: `test "$(swiftformat --version)" = "0.61.1" && swiftformat --lint .` + +### Developer ID direct distribution + +- Local structural validation: `Packaging/DeveloperID/build_release.sh --skip-signing` +- Developer ID, DMG, and notarization flow: see `Packaging/DeveloperID/README.md` +- Public release procedure: see `RELEASING.md` +- Output directory: `dist/developer-id/` + +The local `--skip-signing` output is ad-hoc signed and is not a public distributable. Public readiness requires Developer ID signing, Hardened Runtime, a secure timestamp, Apple notarization acceptance, a stapled ticket, Gatekeeper validation, matching checksums/SBOM/source commit, and a clean-download smoke. + +### Alternate Mac App Store packaging + +- Local bundle validation: `Packaging/AppStore/build_app_store_package.sh --skip-signing` +- App Store signing/package flow: see `Packaging/AppStore/README.md` +- Output directory: `dist/app-store/` + +The App Store lane remains available as a separate alternate channel; it is not used for the direct GitHub download. + +The current implementation was built with Xcode 26.6 and the macOS 26.5 SDK. Running it on macOS 27 does not prove or include macOS 27 SDK-only features; those remain blocked until Xcode 27 is installed and the availability boundary is revalidated. ## Quality gates -- JS: ESLint + Prettier + Mocha +- JS: Node's built-in test runner with Chai assertions, ESLint, and Prettier - Swift: XCTest + SwiftFormat - CI: GitHub Actions runs all quality gates on PRs @@ -83,6 +129,11 @@ See `CONTRIBUTING.md`. See `SECURITY.md`. +## Support and privacy + +- [Support](docs/support.md) +- [Privacy policy](docs/privacy-policy.md) + ## License MIT. See `LICENSE`. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..fdd8327 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,114 @@ +# Releasing Codebase Combiner + +The intended macOS release channel is a Developer ID-signed and Apple-notarized DMG distributed outside the Mac App Store. The VS Code extension has an independent version and release cadence. + +## Version and source policy + +- macOS tags use `macos-v`, beginning with `macos-v0.1.0`. +- VS Code extension tags use `vscode-v`. +- A release artifact must be built from the exact immutable tag named by its release manifest. +- The public source, packaging scripts, entitlements, checksums, SBOM, and release evidence must correspond to that tag. +- Apple secure timestamps and notarization tickets mean final signed bytes are not reproducible byte-for-byte; the pre-signing source build remains auditable. + +## Local release gates + +From a clean release branch: + +```sh +npm ci +npm test +npm run lint +npm run format:check +npm audit --omit=dev +npm audit +npm audit signatures +npm run package +script/tests/vsix_inventory_test.sh + +cd SwiftExplorerApp +swift test +swift build -c release -Xswiftc -warnings-as-errors +cd .. + +swiftformat --lint . --disable redundantSendable +Packaging/DeveloperID/tests/run_tests.sh +``` + +This produces a local VSIX candidate only. Marketplace publication remains an +external release action and requires the configured publisher credential, +release tag/checksum/provenance agreement, green CI, and explicit owner approval. + +The checked v0.1.0 artifact is Apple silicon (`arm64`) only. Do not claim Intel or universal support until a separate x86_64 build and runtime smoke have been completed. + +## Developer ID candidate + +The release machine must contain the private-key-backed `Developer ID Application` identity. This Mac uses the existing identity directly through its login Keychain. Its private key is never exported, copied, repackaged as PKCS#12, imported into a temporary Keychain, copied into release notes, or exposed to pull-request jobs. + +```sh +DEVELOPER_ID_SOURCE_TAG=macos-v0.1.0 \ +Packaging/DeveloperID/build_release.sh \ + --version 0.1.0 \ + --signing-identity "Developer ID Application: Rafal Sikora (2NY8A789TN)" +``` + +Require all of these before Apple submission: + +- strict `codesign` verification; +- Hardened Runtime and secure timestamp; +- Team ID `2NY8A789TN`; +- reviewed sandbox entitlements; +- arm64 architecture and macOS 13 minimum metadata; +- matching dSYM UUID; +- signed DMG containing the app and `/Applications` link; +- pre-notarization manifest, SBOM, and checksums. + +## Notarization + +Notarization is an external Apple action and requires explicit owner approval. Use an App Store Connect API key stored through `notarytool` in Keychain under `codebase-combiner-notary`. + +```sh +Packaging/DeveloperID/notarize_release.sh \ + --dmg "dist/developer-id/Codebase-Combiner-0.1.0-arm64.dmg" \ + --keychain-profile "codebase-combiner-notary" +``` + +The release remains blocked unless Apple returns `Accepted`, the ticket staples and validates, and Gatekeeper accepts both the DMG and mounted app. Publish only the post-stapling `SHA256SUMS` value. + +## Draft and clean-download proof + +After the workflow creates the draft and before publishing it: + +1. As an authenticated repository owner, download the DMG from the draft release's actual GitHub asset URL, not from a workflow artifact or local build directory. +2. Verify its SHA-256 against `SHA256SUMS`. +3. Confirm macOS applied the quarantine attribute. +4. In a clean standard macOS account, mount it and drag the app into `/Applications`. +5. Launch normally through Finder without bypassing Gatekeeper. +6. Choose a representative folder, scan it, select files, copy output, save output, relaunch, and verify recovered-output privacy. +7. Record the tested macOS version and architecture. A macOS 13 machine or VM is still required to prove the advertised minimum. + +## GitHub publication + +Create `macos-v0.1.0` as a signed annotated tag at the exact current `main` +commit. Verify the signature locally and require GitHub's tag API to report +`verification.verified=true` before relying on the tag. + +The hosted path requires a separate CI signing credential in a protected +`release` environment; the local login-Keychain identity must never be exported +for CI. A verified signed annotated `macos-v*` tag triggers +`.github/workflows/release.yml`. An unprovisioned tag fails explicitly. A +provisioned run signs, notarizes, verifies, attests, and creates a draft release. + +An owner-operated local release may use the installed login-Keychain identity +directly when CI signing is unavailable. Build from a clean checkout of the +verified tag, pass `DEVELOPER_ID_SOURCE_TAG=macos-v0.1.0`, notarize with the +stored Keychain profile, and upload only the final DMG, `SHA256SUMS`, SBOM, +symbols, generated manifest, and flat notarization evidence files to a draft +GitHub Release. Never upload an ad-hoc artifact. + +Release notes must exist at `docs/release//RELEASE_NOTES.md`. Review +the notarization log, artifact hashes, SBOM, matched symbols, source commit, and +clean-download evidence before publishing the draft. Publish 0.1.0 only after +the downloaded assets pass `shasum -a 256 -c SHA256SUMS`, Gatekeeper, launch, +and representative workflow checks. + +If a release is defective, leave its tag immutable, mark the release as withdrawn, remove the affected binary from the recommended-download path, publish a security notice when appropriate, and issue a new patch version. Never replace an existing tagged artifact silently. diff --git a/SECURITY.md b/SECURITY.md index 0794968..d0cc219 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,15 +2,16 @@ ## Supported versions -This project does not currently maintain a formal support window. Security fixes will be released as needed. +Security fixes are provided for the latest published release. Older releases may be unsupported once a fix is available. ## Reporting a vulnerability -Please do not open public issues for security reports. +Do not disclose a suspected vulnerability in an issue, discussion, pull request, or other public channel. -Preferred reporting path: +GitHub private vulnerability reporting is not currently enabled, so there is no functioning private intake channel yet. Do not place vulnerability details in a public issue. The repository owner must enable private reporting before publication-ready security intake can be claimed; once enabled, use the repository's private advisory form and omit secrets, private source code, and unrelated personal data. -- If hosted on GitHub with Security Advisories enabled, submit a private advisory. -- If that is not available, open a GitHub issue titled “Security Report” with minimal details and request a private follow-up. Maintainers will respond and move the discussion to a private channel. +The maintainer will acknowledge the report, investigate it, and coordinate remediation and disclosure with the reporter. Timing depends on severity and the complexity of a safe fix. -We aim to acknowledge reports promptly and provide a timeline for remediation. +## Release security + +An artifact is official only when it is attached to a GitHub Release, signed with Apple Developer ID, notarized by Apple, and accompanied by a matching SHA-256 checksum. Signing certificates, notarization credentials, and private keys are never repository contents. diff --git a/SwiftExplorerApp/Package.swift b/SwiftExplorerApp/Package.swift index 2677c98..98b5dc7 100644 --- a/SwiftExplorerApp/Package.swift +++ b/SwiftExplorerApp/Package.swift @@ -15,8 +15,14 @@ let package = Package( targets: [ .executableTarget( name: "CodebaseExplorerApp", + dependencies: ["SecureFileAccessC"], path: "Sources/CodebaseExplorerApp" ), + .target( + name: "SecureFileAccessC", + path: "Sources/SecureFileAccessC", + publicHeadersPath: "include" + ), .testTarget( name: "CodebaseExplorerAppTests", dependencies: ["CodebaseExplorerApp"], diff --git a/SwiftExplorerApp/Sources/CodebaseExplorerApp/App/AppCommands.swift b/SwiftExplorerApp/Sources/CodebaseExplorerApp/App/AppCommands.swift new file mode 100644 index 0000000..24ba26c --- /dev/null +++ b/SwiftExplorerApp/Sources/CodebaseExplorerApp/App/AppCommands.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct AppCommands: Commands { + @ObservedObject var controller: AppController + + var body: some Commands { + CommandGroup(after: .newItem) { + Button("Choose Folder…", action: controller.chooseFolder) + .keyboardShortcut("o", modifiers: [.command]) + + Button("Refresh Workspace", action: controller.refresh) + .keyboardShortcut("r", modifiers: [.command]) + .disabled(!controller.commandState.canRefresh) + .help(controller.commandState.refreshHelp) + } + + CommandGroup(after: .pasteboard) { + Button("Copy Combined Output", action: controller.copy) + .keyboardShortcut("c", modifiers: [.command, .shift]) + .disabled(!controller.commandState.canExport) + .help(controller.commandState.copyHelp) + } + + CommandGroup(after: .saveItem) { + Button("Save Combined Output…", action: controller.save) + .keyboardShortcut("s", modifiers: [.command]) + .disabled(!controller.commandState.canExport) + .help(controller.commandState.saveHelp) + } + + CommandGroup(after: .sidebar) { + Button( + controller.sidebarCommandTitle, + action: controller.toggleSidebar + ) + Button( + controller.preferences.values.showFilters ? "Hide Filters" : "Show Filters", + action: controller.toggleFilters + ) + Button( + controller.isInspectorPresented ? "Hide Output Inspector" : "Show Output Inspector", + action: controller.toggleInspector + ) + } + + CommandMenu("Help") { + Button("Codebase Combiner Support") { + AppLinks.openSupportPage() + } + Button("Privacy Policy") { + AppLinks.openPrivacyPolicy() + } + } + } +} diff --git a/SwiftExplorerApp/Sources/CodebaseExplorerApp/App/AppController.swift b/SwiftExplorerApp/Sources/CodebaseExplorerApp/App/AppController.swift new file mode 100644 index 0000000..9f448c2 --- /dev/null +++ b/SwiftExplorerApp/Sources/CodebaseExplorerApp/App/AppController.swift @@ -0,0 +1,379 @@ +import AppKit +import Combine +import Foundation +import UniformTypeIdentifiers + +struct AppCommandState: Equatable { + let hasWorkspace: Bool + let isScanning: Bool + let hasSelection: Bool + let hasFreshOutput: Bool + let hasRecoveredOutput: Bool + + init( + hasWorkspace: Bool, + isScanning: Bool, + hasSelection: Bool, + hasFreshOutput: Bool, + hasRecoveredOutput: Bool = false + ) { + self.hasWorkspace = hasWorkspace + self.isScanning = isScanning + self.hasSelection = hasSelection + self.hasFreshOutput = hasFreshOutput + self.hasRecoveredOutput = hasRecoveredOutput + } + + var canRefresh: Bool { + hasWorkspace && !isScanning + } + + var canExport: Bool { + hasSelection && hasFreshOutput + } + + var canCopyRecovered: Bool { + hasRecoveredOutput + } + + var copyHelp: String { + if !hasSelection { + return "Select at least one file to copy the combined output." + } + if !hasFreshOutput { + return "Wait for the combined output to finish building." + } + return "Copy combined output" + } + + var saveHelp: String { + if !hasSelection { + return "Select at least one file to save the combined output." + } + if !hasFreshOutput { + return "Wait for the combined output to finish building." + } + return "Save combined output" + } + + var copyRecoveredHelp: String { + hasRecoveredOutput ? "Copy the last recoverable output" : "There is no recovered output to copy." + } + + var refreshHelp: String { + if !hasWorkspace { + return "Choose a folder before refreshing the workspace." + } + if isScanning { + return "Wait for the current workspace scan to finish." + } + return "Refresh workspace" + } +} + +@MainActor +final class AppController: ObservableObject { + typealias FolderPicker = () -> URL? + typealias SaveDestinationPicker = (CombinedOutputFormat) -> URL? + + let preferences: AppPreferences + let workspace: WorkspaceStore + let output: OutputStore + + @Published var isSidebarPresented = true + @Published var isInspectorPresented = true + @Published private(set) var displayStatus: String + + private let folderPicker: FolderPicker + private let saveDestinationPicker: SaveDestinationPicker + private let telemetry: any AppTelemetryRecording + private var cancellables: Set = [] + private var scanTask: Task? + private var saveTask: Task? + private var rebuildTask: Task? + private var preferenceRescanTask: Task? + private var persistenceRetryTask: Task? + private var hasStarted = false + + var commandState: AppCommandState { + AppCommandState( + hasWorkspace: workspace.rootURL != nil, + isScanning: workspace.isScanning, + hasSelection: !workspace.selectedFiles.isEmpty, + hasFreshOutput: output.hasFreshCurrentPayload, + hasRecoveredOutput: output.recoveredDraft != nil + ) + } + + var sidebarCommandTitle: String { + isSidebarPresented ? "Hide Workspace Sidebar" : "Show Workspace Sidebar" + } + + static func live(dependencies: AppDependencies = AppDependencies()) -> AppController { + let telemetry = LiveAppTelemetry.shared + let preferences = AppPreferences(defaults: dependencies.defaults) + return AppController( + preferences: preferences, + workspace: WorkspaceStore(), + output: OutputStore( + drafts: ClipboardDraftStore(baseDirectory: dependencies.draftBaseDirectory), + clipboard: SystemClipboardWriter(), + telemetry: telemetry + ), + folderPicker: Self.presentOpenPanel, + saveDestinationPicker: Self.presentSavePanel, + telemetry: telemetry + ) + } + + init( + preferences: AppPreferences, + workspace: WorkspaceStore, + output: OutputStore, + folderPicker: @escaping FolderPicker, + saveDestinationPicker: @escaping SaveDestinationPicker, + telemetry: any AppTelemetryRecording = LiveAppTelemetry.shared + ) { + self.preferences = preferences + self.workspace = workspace + self.output = output + self.folderPicker = folderPicker + self.saveDestinationPicker = saveDestinationPicker + self.telemetry = telemetry + displayStatus = workspace.status + output.format = preferences.values.outputMarkdown ? .markdown : .plainText + bindSharedState() + } + + func start() async { + guard !hasStarted else { return } + hasStarted = true + await output.loadRecoveredDraft() + } + + func chooseFolder() { + guard let rootURL = folderPicker() else { return } + beginScan(rootURL: rootURL) + } + + func refresh() { + guard commandState.canRefresh, let rootURL = workspace.rootURL else { return } + beginScan(rootURL: rootURL) + } + + func retryFailedScan() { + guard workspace.canRetryFailedScan else { return } + scanTask?.cancel() + scanTask = Task { [weak self] in + guard let self else { return } + telemetry.record(.scanStarted) + let outcome = await workspace.retryFailedScan() + telemetry.record(.scanFinished(outcome)) + } + } + + func copy() { + guard commandState.canExport else { return } + output.copyCurrent() + } + + func copyRecovered() { + guard commandState.canCopyRecovered else { return } + output.copyRecovered() + } + + func save() { + guard commandState.canExport, + let payload = output.exportPayload, + let destination = saveDestinationPicker(output.format) + else { return } + + saveTask?.cancel() + saveTask = Task { [weak self] in + guard let self else { return } + await output.save(payload, to: destination) + } + } + + func retryPersistence() { + guard output.canRetryPersistence else { return } + persistenceRetryTask?.cancel() + persistenceRetryTask = Task { [weak self] in + guard let self else { return } + await output.retryPersistence() + } + } + + func toggleFilters() { + preferences.values.showFilters.toggle() + } + + func toggleSidebar() { + isSidebarPresented.toggle() + } + + func toggleInspector() { + isInspectorPresented.toggle() + } + + @discardableResult + func scan(rootURL: URL) async -> WorkspaceScanOutcome { + preferenceRescanTask?.cancel() + return await performScan(rootURL: rootURL) + } + + private func performScan(rootURL: URL) async -> WorkspaceScanOutcome { + let snapshot = preferences.values + telemetry.record(.scanStarted) + let outcome = await workspace.scan(rootURL: rootURL, preferences: snapshot) + telemetry.record(.scanFinished(outcome)) + return outcome + } + + private func beginScan(rootURL: URL) { + preferenceRescanTask?.cancel() + scanTask?.cancel() + scanTask = Task { [weak self] in + guard let self else { return } + _ = await performScan(rootURL: rootURL) + } + } + + private func bindSharedState() { + preferences.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + workspace.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + output.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + + workspace.$state + .map(\.status) + .removeDuplicates() + .dropFirst() + .sink { [weak self] status in + self?.displayStatus = status + } + .store(in: &cancellables) + + output.$status + .sink { [weak self] status in + guard let self else { return } + displayStatus = status ?? workspace.status + } + .store(in: &cancellables) + + preferences.$values + .map(\.outputMarkdown) + .removeDuplicates() + .dropFirst() + .sink { [weak self] outputMarkdown in + guard let self else { return } + let format: CombinedOutputFormat = outputMarkdown ? .markdown : .plainText + if output.format.rawValue != format.rawValue { + output.format = format + } + } + .store(in: &cancellables) + + output.$format + .map(\.rawValue) + .removeDuplicates() + .dropFirst() + .sink { [weak self] rawValue in + guard let self else { return } + let outputMarkdown = rawValue == CombinedOutputFormat.markdown.rawValue + if preferences.values.outputMarkdown != outputMarkdown { + preferences.values.outputMarkdown = outputMarkdown + } + } + .store(in: &cancellables) + + preferences.$values + .map(ScanPreferences.init) + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in + self?.schedulePreferenceRescan() + } + .store(in: &cancellables) + + Publishers.CombineLatest3( + workspace.$state + .map { RebuildSource(files: $0.selectedFiles, rootPath: $0.rootURL?.path) } + .removeDuplicates(), + output.$promptPrefix.removeDuplicates(), + output.$format.map(\.rawValue).removeDuplicates() + ) + .dropFirst() + .sink { [weak self] source, _, _ in + self?.rebuildOutput(from: source) + } + .store(in: &cancellables) + } + + private func rebuildOutput(from source: RebuildSource) { + displayStatus = source.files.isEmpty ? workspace.status : "Building combined output…" + output.invalidateCurrentOutput() + rebuildTask?.cancel() + rebuildTask = Task { [weak self] in + guard let self else { return } + await output.rebuild(files: source.files, rootPath: source.rootPath) + } + } + + private func schedulePreferenceRescan() { + preferenceRescanTask?.cancel() + guard let rootURL = workspace.rootURL else { return } + + preferenceRescanTask = Task { [weak self] in + do { + try await Task.sleep(for: .milliseconds(350)) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + _ = await performScan(rootURL: rootURL) + } + } + + private static func presentOpenPanel() -> URL? { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.title = "Choose a workspace root" + return panel.runModal() == .OK ? panel.url : nil + } + + private static func presentSavePanel(format: CombinedOutputFormat) -> URL? { + let panel = NSSavePanel() + let markdownType = UTType(filenameExtension: "md") ?? .plainText + panel.allowedContentTypes = [format == .markdown ? markdownType : .plainText] + panel.nameFieldStringValue = format == .markdown ? "combined.md" : "combined.txt" + panel.canCreateDirectories = true + return panel.runModal() == .OK ? panel.url : nil + } +} + +private struct RebuildSource: Equatable { + let files: [FileNode] + let rootPath: String? +} + +private struct ScanPreferences: Equatable { + let allowList: String + let excludeList: String + let maxFileSizeKB: Double + let skipHidden: Bool + + init(_ values: AppPreferences.Values) { + allowList = values.allowList + excludeList = values.excludeList + maxFileSizeKB = values.maxFileSizeKB + skipHidden = values.skipHidden + } +} diff --git a/SwiftExplorerApp/Sources/CodebaseExplorerApp/App/CodebaseExplorerApp.swift b/SwiftExplorerApp/Sources/CodebaseExplorerApp/App/CodebaseExplorerApp.swift new file mode 100644 index 0000000..a1b92b7 --- /dev/null +++ b/SwiftExplorerApp/Sources/CodebaseExplorerApp/App/CodebaseExplorerApp.swift @@ -0,0 +1,136 @@ +import AppKit +import SwiftUI + +@main +struct CodebaseExplorerApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var controller: AppController + private let initialWindowSize: CGSize + + init() { + let dependencies = AppDependencies() + _controller = StateObject(wrappedValue: AppController.live(dependencies: dependencies)) + initialWindowSize = dependencies.initialWindowSize ?? CGSize(width: 1180, height: 760) + } + + var body: some Scene { + WindowGroup("Codebase Combiner") { + ContentView(controller: controller) + } + .defaultSize(width: initialWindowSize.width, height: initialWindowSize.height) + .commands { + AppCommands(controller: controller) + } + + Settings { + SettingsView(preferences: controller.preferences) + } + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private let defaults = AppDependencies().defaults + private let e2eWindowSize = AppDependencies().initialWindowSize + private var didConfigureE2EWindow = false + + func applicationWillFinishLaunching(_: Notification) { + defaults.set(false, forKey: "NSQuitAlwaysKeepsWindows") + } + + func applicationSupportsSecureRestorableState(_: NSApplication) -> Bool { + true + } + + func application(_: NSApplication, shouldSaveSecureApplicationState _: NSCoder) -> Bool { + false + } + + func application(_: NSApplication, shouldRestoreSecureApplicationState _: NSCoder) -> Bool { + false + } + + func application(_: NSApplication, shouldSaveApplicationState _: NSCoder) -> Bool { + false + } + + func application(_: NSApplication, shouldRestoreApplicationState _: NSCoder) -> Bool { + false + } + + func applicationDidFinishLaunching(_: Notification) { + if e2eWindowSize != nil { + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidBecomeKey(_:)), + name: NSWindow.didBecomeKeyNotification, + object: nil + ) + } + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + AppLog.lifecycle.info("Application finished launching") + DispatchQueue.main.async { self.disableWindowRestoration() } + DispatchQueue.main.async { self.recenterOffscreenWindowsIfNeeded() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { self.disableWindowRestoration() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { self.recenterOffscreenWindowsIfNeeded() } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.configureE2EWindowFrameIfNeeded() } + } + + @objc private func windowDidBecomeKey(_ notification: Notification) { + configureE2EWindowFrameIfNeeded(window: notification.object as? NSWindow) + } + + @MainActor + private func disableWindowRestoration() { + for window in NSApp.windows { + window.isRestorable = false + window.restorationClass = nil + window.disableSnapshotRestoration() + } + } + + @MainActor + private func recenterOffscreenWindowsIfNeeded() { + for window in NSApp.windows where window.isVisible { + guard !window.frame.isEmpty else { continue } + let isVisible = NSScreen.screens.contains { $0.visibleFrame.intersects(window.frame) } + if !isVisible, let screen = NSScreen.main { + window.setFrameOrigin(CGPoint( + x: screen.visibleFrame.midX - window.frame.width / 2, + y: screen.visibleFrame.midY - window.frame.height / 2 + )) + } + } + } + + @MainActor + private func configureE2EWindowFrameIfNeeded(window providedWindow: NSWindow? = nil) { + guard !didConfigureE2EWindow, + let e2eWindowSize, + let window = providedWindow ?? NSApp.keyWindow ?? NSApp.windows.first(where: { $0.isVisible && $0.canBecomeMain }), + let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame + else { return } + + window.setFrame( + E2EWindowFramePolicy.frame(size: e2eWindowSize, visibleFrame: visibleFrame), + display: true, + animate: false + ) + didConfigureE2EWindow = true + AppLog.lifecycle.info( + "Configured E2E window frame width=\(Int(window.frame.width), privacy: .public) height=\(Int(window.frame.height), privacy: .public)" + ) + } +} + +enum E2EWindowFramePolicy { + static func frame(size: CGSize, visibleFrame: CGRect) -> CGRect { + CGRect( + x: visibleFrame.midX - size.width / 2, + y: visibleFrame.midY - size.height / 2, + width: size.width, + height: size.height + ) + } +} diff --git a/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/AppPreferences.swift b/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/AppPreferences.swift new file mode 100644 index 0000000..cf3c54c --- /dev/null +++ b/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/AppPreferences.swift @@ -0,0 +1,72 @@ +import Combine +import Foundation + +@MainActor +final class AppPreferences: ObservableObject { + enum Validation: Equatable { + case valid + case invalid(String) + } + + struct Values: Equatable, Sendable { + var allowList = "swift,js,ts,tsx,jsx,md,txt,py" + var excludeList = "png,jpg,jpeg,gif,mp4,zip,bin,lock" + var maxFileSizeKB = 512.0 + var skipHidden = true + var outputMarkdown = true + var showFilters = true + } + + @Published var values: Values { + didSet { save() } + } + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + values = Values( + allowList: defaults.string(forKey: "cc_allowListString") ?? Values().allowList, + excludeList: defaults.string(forKey: "cc_excludeListString") ?? Values().excludeList, + maxFileSizeKB: Self.normalizedMaximumFileSize( + defaults.object(forKey: "cc_maxFileSizeKB") as? Double + ), + skipHidden: defaults.object(forKey: "cc_skipHidden") as? Bool ?? true, + outputMarkdown: defaults.object(forKey: "cc_outputMarkdown") as? Bool ?? true, + showFilters: defaults.object(forKey: "cc_showFilters") as? Bool ?? true + ) + } + + nonisolated static func validate(maxFileSizeKB: Double) -> Validation { + maxFileSizeKB.isFinite && (32 ... 8192).contains(maxFileSizeKB) + ? .valid + : .invalid("Enter a value from 32 to 8,192 KB.") + } + + private nonisolated static func normalizedMaximumFileSize(_ value: Double?) -> Double { + guard let value, validate(maxFileSizeKB: value) == .valid else { return 512 } + return value + } + + nonisolated static func extensionSet(from text: String) -> Set { + let delimiters = CharacterSet(charactersIn: ",;|\n\t ") + return Set( + text.lowercased() + .components(separatedBy: delimiters) + .map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + } + .filter { !$0.isEmpty } + ) + } + + private func save() { + defaults.set(values.allowList, forKey: "cc_allowListString") + defaults.set(values.excludeList, forKey: "cc_excludeListString") + defaults.set(values.maxFileSizeKB, forKey: "cc_maxFileSizeKB") + defaults.set(values.skipHidden, forKey: "cc_skipHidden") + defaults.set(values.outputMarkdown, forKey: "cc_outputMarkdown") + defaults.set(values.showFilters, forKey: "cc_showFilters") + } +} diff --git a/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/ClipboardDraft.swift b/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/ClipboardDraft.swift new file mode 100644 index 0000000..741ee37 --- /dev/null +++ b/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/ClipboardDraft.swift @@ -0,0 +1,20 @@ +import Foundation + +struct ClipboardDraft: Codable, Equatable, Sendable { + let text: String + let format: CombinedOutputFormat + let fileCount: Int + let tokenCount: Int + let byteCount: Int + let rootPath: String? + let generatedAt: Date + + var formatLabel: String { + switch format { + case .markdown: + "Markdown" + case .plainText: + "Plain Text" + } + } +} diff --git a/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/FileNode.swift b/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/FileNode.swift index 9a23a09..a0ca899 100644 --- a/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/FileNode.swift +++ b/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/FileNode.swift @@ -1,7 +1,6 @@ import Foundation -struct FileNode: Identifiable, Hashable { - let id = UUID() +struct FileNode: Identifiable, Hashable, Sendable { let name: String let relativePath: String let url: URL @@ -11,6 +10,10 @@ struct FileNode: Identifiable, Hashable { let sizeBytes: Int let content: String? + var id: String { + relativePath.isEmpty ? name : relativePath + } + var childrenOrNil: [FileNode]? { children.isEmpty ? nil : children } diff --git a/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/OutputPreviewPolicy.swift b/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/OutputPreviewPolicy.swift new file mode 100644 index 0000000..9254987 --- /dev/null +++ b/SwiftExplorerApp/Sources/CodebaseExplorerApp/Models/OutputPreviewPolicy.swift @@ -0,0 +1,30 @@ +struct OutputPreviewPresentation: Equatable { + let text: String + let isTruncated: Bool + let notice: String? +} + +enum OutputPreviewPolicy { + static let characterLimit = 20000 + private static let characterLimitLabel = "20,000" + + static func presentation(for payload: String) -> OutputPreviewPresentation { + guard let previewEnd = payload.index( + payload.startIndex, + offsetBy: characterLimit, + limitedBy: payload.endIndex + ) else { + return OutputPreviewPresentation(text: payload, isTruncated: false, notice: nil) + } + + guard previewEnd != payload.endIndex else { + return OutputPreviewPresentation(text: payload, isTruncated: false, notice: nil) + } + + return OutputPreviewPresentation( + text: String(payload[..