diff --git a/README.md b/README.md index adeb993..764d2dc 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,42 @@ Only the trusted signing workflow creates the immutable GitHub Release. See [VERIFICATION.md](VERIFICATION.md), [CONTRIBUTING.md](CONTRIBUTING.md), and [SECURITY.md](SECURITY.md) for project processes. +## GitHub Action + +`action.yml` is a composite action so any repository can build a package on a +macOS runner without hand-rolling install-and-invoke. It installs the swiftpkg +release, optionally lints, builds with `--output-format json`, and exposes the +result as step outputs. + +```yaml +jobs: + build: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - id: pkg + uses: codecarton/swiftpkg@v1 + with: + project-path: packages/my-project + version: ${{ github.ref_name }} + lint: true + verify: true + - run: echo "Built ${{ steps.pkg.outputs.pkg-path }} (${{ steps.pkg.outputs.sha256 }})" +``` + +Inputs: `project-path` (required), `version` (→ `--pkg-version`), `output-dir`, +`swiftpkg-version`, `swiftpkg-sha256`, `expected-team-id`, `lint`, `verify`, +`provenance`, `extra-args`. Outputs: `pkg-path`, `version`, `sha256`. Requires a +swiftpkg release that includes the CI flags (`--output-format`, `--output-dir`, +`--pkg-version`, `--lint`, `--verify`, `--provenance`). + +The action installs a release package as root, so it checks what it downloaded +first: the asset must match the release's `SHA256SUMS` and must be signed by the +`expected-team-id` Developer Team, and `spctl` must accept it. `swiftpkg-version` +defaults to a pinned tag rather than `latest`. GitHub release assets can be +replaced without moving the tag, so a build that must be reproducible byte for +byte should also set `swiftpkg-sha256` to the checksum it expects. + ## Marketing site The static marketing site lives in [`site/`](site/) and publishes to diff --git a/Swiftpkgr/State/ProjectEditorModel.swift b/Swiftpkgr/State/ProjectEditorModel.swift index dea68e7..ac135ac 100644 --- a/Swiftpkgr/State/ProjectEditorModel.swift +++ b/Swiftpkgr/State/ProjectEditorModel.swift @@ -283,7 +283,7 @@ final class ProjectEditorModel { private func saveDraft() throws { guard let projectURL, let document = buildInfoDocument else { - throw MunkiPkgError.message("No project is open.") + throw SwiftPkgError.message("No project is open.") } let configuration = try draft.validatedConfiguration() try BuildInfoStore.write(configuration, to: projectURL, format: document.format) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..1a0317c --- /dev/null +++ b/action.yml @@ -0,0 +1,169 @@ +name: 'swiftpkg build' +description: 'Build an Apple installer package from a swiftpkg project directory' +author: 'codecarton' + +inputs: + project-path: + description: 'Path to the swiftpkg package project directory' + required: true + version: + description: 'Override the build-info version (e.g. a git tag). Maps to --pkg-version.' + required: false + default: '' + output-dir: + description: 'Directory to write the built package into. Maps to --output-dir.' + required: false + default: 'dist' + swiftpkg-version: + description: 'Release tag of swiftpkg to install. "latest" is accepted but makes builds depend on whatever ships next.' + required: false + default: 'v0.3.1' + swiftpkg-sha256: + description: 'Expected SHA-256 of the installer asset. Set it to pin the exact bytes; release assets are mutable, so a tag alone does not.' + required: false + default: '' + expected-team-id: + description: 'Apple Developer Team ID the installer must be signed by.' + required: false + default: 'DPXY7JLK67' + lint: + description: 'If "true", run `swiftpkg --lint` before building and fail on lint errors.' + required: false + default: 'false' + verify: + description: 'If "true", pass --verify so a signed/notarized build is checked after building.' + required: false + default: 'false' + provenance: + description: 'If "true", pass --provenance to write a .provenance.json sidecar.' + required: false + default: 'false' + extra-args: + description: 'Additional arguments appended to the swiftpkg build invocation.' + required: false + default: '' + +outputs: + pkg-path: + description: 'Path to the built package' + value: ${{ steps.build.outputs.pkg-path }} + version: + description: 'Version of the built package' + value: ${{ steps.build.outputs.version }} + sha256: + description: 'SHA-256 of the built package' + value: ${{ steps.build.outputs.sha256 }} + +runs: + using: composite + steps: + - name: Install swiftpkg + shell: bash + env: + SWIFTPKG_VERSION: ${{ inputs.swiftpkg-version }} + SWIFTPKG_SHA256: ${{ inputs.swiftpkg-sha256 }} + EXPECTED_TEAM_ID: ${{ inputs.expected-team-id }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + runner_tmp="${RUNNER_TEMP:-/tmp}" + # Download into a fresh directory so a stale or unexpected package left + # in the runner temp can't be picked up. + download_dir="$(mktemp -d "$runner_tmp/swiftpkg-install.XXXXXX")" + # A release publishes swiftpkg--cli.pkg (the CLI alone), + # swiftpkg--combined.pkg (CLI + Swiftpkgr), and SHA256SUMS. CI + # wants the CLI. The version is embedded in the filename, so match it + # with a pattern rather than a fixed URL. + args=(--repo codecarton/swiftpkg --pattern 'swiftpkg-*-cli.pkg' --pattern 'SHA256SUMS' --dir "$download_dir") + if [ "$SWIFTPKG_VERSION" = "latest" ]; then + gh release download "${args[@]}" + else + gh release download "$SWIFTPKG_VERSION" "${args[@]}" + fi + # Require exactly one matching asset, so an ambiguous release can't cause + # a surprising package to be installed as root. + shopt -s nullglob + pkgs=("$download_dir"/swiftpkg-*-cli.pkg) + if [ "${#pkgs[@]}" -ne 1 ]; then + echo "Expected exactly one swiftpkg installer, found ${#pkgs[@]}: ${pkgs[*]:-none}" >&2 + exit 1 + fi + pkg="${pkgs[0]}" + + # Three checks, each covering what the others cannot. Only the caller's + # own swiftpkg-sha256 pins the bytes against a release asset being + # replaced in place; SHA256SUMS ships from the same release, so it + # catches a truncated or corrupted download but moves with the release; + # and the signature is what makes a substituted asset unusable, since + # forging it requires the publisher's Developer ID certificate. + actual="$(shasum -a 256 "$pkg" | awk '{print $1}')" + if [ -n "$SWIFTPKG_SHA256" ] && [ "$actual" != "$SWIFTPKG_SHA256" ]; then + echo "Installer SHA-256 does not match swiftpkg-sha256: expected $SWIFTPKG_SHA256, got $actual" >&2 + exit 1 + fi + published="$(awk -v name="$(basename "$pkg")" '$2 == name { print $1 }' "$download_dir/SHA256SUMS")" + if [ -z "$published" ]; then + echo "SHA256SUMS has no entry for $(basename "$pkg")" >&2 + exit 1 + fi + if [ "$actual" != "$published" ]; then + echo "Installer SHA-256 does not match SHA256SUMS: expected $published, got $actual" >&2 + exit 1 + fi + + # Assess before running the installer as root. spctl establishes that + # Apple notarized it; the Team ID establishes who signed it, which + # notarization alone does not. + signature="$(pkgutil --check-signature "$pkg")" + printf '%s\n' "$signature" + case "$signature" in + *"($EXPECTED_TEAM_ID)"*) ;; + *) echo "Installer is not signed by Team ID $EXPECTED_TEAM_ID" >&2; exit 1 ;; + esac + spctl --assess --type install -vv "$pkg" + + sudo installer -pkg "$pkg" -target / + swiftpkg --version + + - name: Lint + if: ${{ inputs.lint == 'true' }} + shell: bash + env: + PROJECT_PATH: ${{ inputs.project-path }} + run: swiftpkg --lint "$PROJECT_PATH" + + - name: Build + id: build + shell: bash + env: + PROJECT_PATH: ${{ inputs.project-path }} + PKG_VERSION: ${{ inputs.version }} + OUTPUT_DIR: ${{ inputs.output-dir }} + DO_VERIFY: ${{ inputs.verify }} + DO_PROVENANCE: ${{ inputs.provenance }} + EXTRA_ARGS: ${{ inputs.extra-args }} + run: | + set -euo pipefail + # extra-args first so the action's required flags (json output, + # output-dir) always win and can't be overridden into a shape that + # breaks jq parsing. + args=() + if [ -n "$EXTRA_ARGS" ]; then + read -ra extra <<< "$EXTRA_ARGS" + args+=("${extra[@]}") + fi + args+=(--output-format json --output-dir "$OUTPUT_DIR") + if [ -n "$PKG_VERSION" ]; then + args+=(--pkg-version "$PKG_VERSION") + fi + if [ "$DO_VERIFY" = "true" ]; then + args+=(--verify) + fi + if [ "$DO_PROVENANCE" = "true" ]; then + args+=(--provenance) + fi + result="$(swiftpkg "${args[@]}" "$PROJECT_PATH")" + echo "$result" + echo "pkg-path=$(echo "$result" | jq -r '.pkg_path')" >> "$GITHUB_OUTPUT" + echo "version=$(echo "$result" | jq -r '.version')" >> "$GITHUB_OUTPUT" + echo "sha256=$(echo "$result" | jq -r '.sha256')" >> "$GITHUB_OUTPUT" diff --git a/azure-pipelines/swiftpkg-build.yml b/azure-pipelines/swiftpkg-build.yml new file mode 100644 index 0000000..58c2bc3 --- /dev/null +++ b/azure-pipelines/swiftpkg-build.yml @@ -0,0 +1,185 @@ +# Azure DevOps steps template that builds an Apple installer package from a +# swiftpkg project directory — the ADO equivalent of this repo's GitHub +# composite action (action.yml). It installs the swiftpkg release package, +# optionally lints, builds with --output-format json, and exposes the result +# as output variables on the 'build' step. +# +# Usage from a pipeline in another repo. Reference swiftpkg as a repository +# resource, then include this template: +# +# resources: +# repositories: +# - repository: swiftpkg +# type: github +# name: codecarton/swiftpkg +# endpoint: +# +# steps: +# - template: azure-pipelines/swiftpkg-build.yml@swiftpkg +# parameters: +# projectPath: packages/my-package-project +# version: $(Build.SourceBranchName) +# lint: true +# +# Outputs (from the step named 'build'): +# $(build.pkgPath) $(build.version) $(build.sha256) +# +# Must run on a macOS agent (pool: { vmImage: 'macOS-latest' }). + +parameters: + - name: projectPath + type: string + - name: version + type: string + default: '' + - name: outputDir + type: string + default: 'dist' + - name: swiftpkgVersion + type: string + default: 'v0.3.1' + - name: swiftpkgSha256 + type: string + default: '' + - name: expectedTeamId + type: string + default: 'DPXY7JLK67' + - name: lint + type: boolean + default: false + - name: verify + type: boolean + default: false + - name: provenance + type: boolean + default: false + - name: extraArgs + type: string + default: '' + +steps: + - bash: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + echo "##vso[task.logissue type=error]jq is required but was not found. Install it (e.g. 'brew install jq')." + exit 1 + fi + repo="codecarton/swiftpkg" + if [ "$SWIFTPKG_VERSION" = "latest" ]; then + api="https://api.github.com/repos/$repo/releases/latest" + else + api="https://api.github.com/repos/$repo/releases/tags/$SWIFTPKG_VERSION" + fi + # A release publishes swiftpkg--cli.pkg (the CLI alone), + # swiftpkg--combined.pkg (CLI + Swiftpkgr), and SHA256SUMS. CI + # wants the CLI. The name embeds the version, so resolve the asset URL from + # the release metadata rather than guessing the filename. Select the first + # match inside jq — piping to `head` under pipefail can SIGPIPE jq and fail + # the step when more than one asset matches. + release="$(curl -fsSL "$api")" + url="$(printf '%s' "$release" | jq -er 'first(.assets[] | select(.name | test("swiftpkg-.*-cli\\.pkg$")) | .browser_download_url)')" || url="" + if [ -z "$url" ]; then + echo "##vso[task.logissue type=error]Could not find a swiftpkg CLI package asset for '$SWIFTPKG_VERSION'." + exit 1 + fi + sums_url="$(printf '%s' "$release" | jq -er 'first(.assets[] | select(.name == "SHA256SUMS") | .browser_download_url)')" || sums_url="" + if [ -z "$sums_url" ]; then + echo "##vso[task.logissue type=error]Release '$SWIFTPKG_VERSION' publishes no SHA256SUMS." + exit 1 + fi + asset="$(basename "$url")" + dest="$AGENT_TEMPDIRECTORY/$asset" + sums="$AGENT_TEMPDIRECTORY/SHA256SUMS" + curl -fsSL --retry 3 --retry-delay 2 -o "$dest" "$url" + curl -fsSL --retry 3 --retry-delay 2 -o "$sums" "$sums_url" + + # Three checks, each covering what the others cannot. Only the caller's own + # swiftpkgSha256 pins the bytes against a release asset being replaced in + # place; SHA256SUMS ships from the same release, so it catches a truncated + # or corrupted download but moves with the release; and the signature is + # what makes a substituted asset unusable, since forging it requires the + # publisher's Developer ID certificate. + actual="$(shasum -a 256 "$dest" | awk '{print $1}')" + if [ -n "$SWIFTPKG_SHA256" ] && [ "$actual" != "$SWIFTPKG_SHA256" ]; then + echo "##vso[task.logissue type=error]Installer SHA-256 does not match swiftpkgSha256: expected $SWIFTPKG_SHA256, got $actual" + exit 1 + fi + published="$(awk -v name="$asset" '$2 == name { print $1 }' "$sums")" + if [ -z "$published" ]; then + echo "##vso[task.logissue type=error]SHA256SUMS has no entry for $asset." + exit 1 + fi + if [ "$actual" != "$published" ]; then + echo "##vso[task.logissue type=error]Installer SHA-256 does not match SHA256SUMS: expected $published, got $actual" + exit 1 + fi + + # Assess before running the installer as root. spctl establishes that Apple + # notarized it; the Team ID establishes who signed it, which notarization + # alone does not. + signature="$(pkgutil --check-signature "$dest")" + printf '%s\n' "$signature" + case "$signature" in + *"($EXPECTED_TEAM_ID)"*) ;; + *) + echo "##vso[task.logissue type=error]Installer is not signed by Team ID $EXPECTED_TEAM_ID." + exit 1 + ;; + esac + spctl --assess --type install -vv "$dest" + + sudo installer -pkg "$dest" -target / + swiftpkg --version + displayName: 'Install swiftpkg' + env: + SWIFTPKG_VERSION: ${{ parameters.swiftpkgVersion }} + SWIFTPKG_SHA256: ${{ parameters.swiftpkgSha256 }} + EXPECTED_TEAM_ID: ${{ parameters.expectedTeamId }} + + - ${{ if eq(parameters.lint, true) }}: + - bash: swiftpkg --lint "$PROJECT_PATH" + displayName: 'Lint package project' + env: + PROJECT_PATH: ${{ parameters.projectPath }} + + - bash: | + set -euo pipefail + # extra-args first so the template's required flags always win and can't be + # overridden into a shape that breaks jq parsing. + args=() + if [ -n "$EXTRA_ARGS" ]; then + read -ra extra <<< "$EXTRA_ARGS" + args+=("${extra[@]}") + fi + args+=(--output-format json --output-dir "$OUTPUT_DIR") + if [ -n "$PKG_VERSION" ]; then + args+=(--pkg-version "$PKG_VERSION") + fi + # Azure renders booleans as True/False; accept any case. + if [ "$(echo "$DO_VERIFY" | tr '[:upper:]' '[:lower:]')" = "true" ]; then + args+=(--verify) + fi + if [ "$(echo "$DO_PROVENANCE" | tr '[:upper:]' '[:lower:]')" = "true" ]; then + args+=(--provenance) + fi + result="$(swiftpkg "${args[@]}" "$PROJECT_PATH")" + echo "$result" + # Extract with `jq -er` so a null/missing field fails the step rather than + # silently publishing an empty output variable, and strip any CR/LF so the + # value can't inject a second Azure logging command. + emit() { printf '%s' "$1" | tr -d '\r\n'; } + pkg_path="$(printf '%s' "$result" | jq -er '.pkg_path')" + version="$(printf '%s' "$result" | jq -er '.version')" + sha256="$(printf '%s' "$result" | jq -er '.sha256')" + echo "##vso[task.setvariable variable=pkgPath;isOutput=true]$(emit "$pkg_path")" + echo "##vso[task.setvariable variable=version;isOutput=true]$(emit "$version")" + echo "##vso[task.setvariable variable=sha256;isOutput=true]$(emit "$sha256")" + name: build + displayName: 'Build package' + env: + PROJECT_PATH: ${{ parameters.projectPath }} + PKG_VERSION: ${{ parameters.version }} + OUTPUT_DIR: ${{ parameters.outputDir }} + DO_VERIFY: ${{ parameters.verify }} + DO_PROVENANCE: ${{ parameters.provenance }} + EXTRA_ARGS: ${{ parameters.extraArgs }} diff --git a/scripts/verify-loop.sh b/scripts/verify-loop.sh index 7c8943c..acba4a3 100755 --- a/scripts/verify-loop.sh +++ b/scripts/verify-loop.sh @@ -58,6 +58,15 @@ run "$BIN" "$EMPTY" run /usr/sbin/pkgutil --expand "$EMPTY/build/EmptyPayload-1.0.pkg" "$WORK/expanded-empty" test -e "$WORK/expanded-empty/Payload" +RECEIPT="$WORK/ReceiptOnly" +run "$BIN" --create "$RECEIPT" +rm -rf "$RECEIPT/payload" "$RECEIPT/scripts" +run "$BIN" "$RECEIPT" +run /usr/sbin/pkgutil --expand "$RECEIPT/build/ReceiptOnly-1.0.pkg" "$WORK/expanded-receipt" +test ! -e "$WORK/expanded-receipt/Payload" +test ! -e "$WORK/expanded-receipt/Scripts" +printf 'receipt-only package OK\n' + for format in json yaml; do PROJECT_FORMAT="$WORK/Format-$format" if [ "$format" = json ]; then @@ -71,6 +80,119 @@ for format in json yaml; do test -f "$PROJECT_FORMAT/build/Format-$format-1.0.pkg" done +ENVSUB="$WORK/EnvSub" +run "$BIN" --create "$ENVSUB" +mkdir -p "$ENVSUB/payload/usr/local/bin" +printf 'x\n' > "$ENVSUB/payload/usr/local/bin/tool" +printf '%s\n' '#!/bin/sh' 'echo "server=${SERVER_URL}"' 'exit 0' > "$ENVSUB/scripts/postinstall" +printf 'SERVER_URL=https://mdm.example.edu\n' > "$ENVSUB/.env" +run "$BIN" "$ENVSUB" +run /usr/sbin/pkgutil --expand "$ENVSUB/build/EnvSub-1.0.pkg" "$WORK/expanded-envsub" +POSTINSTALL="$WORK/expanded-envsub/Scripts/postinstall" +test -f "$POSTINSTALL" +grep -q 'server=https://mdm.example.edu' "$POSTINSTALL" +if grep -q '\${SERVER_URL}' "$POSTINSTALL"; then + printf 'placeholder was not substituted\n' >&2; exit 1 +fi +printf 'env substitution OK\n' + +PROVENANCE="$WORK/Provenance" +run "$BIN" --create "$PROVENANCE" +mkdir -p "$PROVENANCE/payload/usr/local/bin" +printf '%s\n' '#!/bin/sh' 'exit 0' > "$PROVENANCE/payload/usr/local/bin/tool" +run "$BIN" --provenance "$PROVENANCE" +test -f "$PROVENANCE/build/Provenance-1.0.pkg.provenance.json" +python3 - "$PROVENANCE/build/Provenance-1.0.pkg.provenance.json" "$PROVENANCE/build/Provenance-1.0.pkg" <<'PY' +import hashlib, json, sys +prov = json.load(open(sys.argv[1])) +for key in ("tool", "tool_version", "built_at", "name", "version", "identifier", "pkg_path", "sha256", "input_digest"): + assert key in prov, f"provenance missing key: {key}" +assert prov["tool"] == "swiftpkg" +digest = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest() +assert prov["sha256"] == digest, f'sha256 mismatch: {prov["sha256"]} != {digest}' +assert len(prov["input_digest"]) == 64 +print("provenance OK") +PY + +VERIFY="$WORK/Verify" +run "$BIN" --create "$VERIFY" +mkdir -p "$VERIFY/payload/usr/local/bin" +printf '%s\n' '#!/bin/sh' 'exit 0' > "$VERIFY/payload/usr/local/bin/tool" +run "$BIN" --verify "$VERIFY" +test -f "$VERIFY/build/Verify-1.0.pkg" + +LINTGOOD="$WORK/LintGood" +run "$BIN" --create "$LINTGOOD" +mkdir -p "$LINTGOOD/payload" +printf 'x\n' > "$LINTGOOD/payload/file.txt" +run "$BIN" --lint "$LINTGOOD" +LINTBAD="$WORK/LintBad" +mkdir -p "$LINTBAD/payload" +printf 'x\n' > "$LINTBAD/payload/file.txt" +printf '%s\n' '{"name":"../evil.pkg","identifier":"com.example.bad","version":""}' > "$LINTBAD/build-info.json" +if "$BIN" --lint "$LINTBAD"; then + printf 'lint should have failed on a bad project\n' >&2 + exit 1 +fi +printf 'lint rejects bad project OK\n' + +MANIFEST="$WORK/Manifest" +run "$BIN" --create "$MANIFEST" +mkdir -p "$MANIFEST/payload/usr/local/bin" +printf '%s\n' '#!/bin/sh' 'exit 0' > "$MANIFEST/payload/usr/local/bin/tool" +chmod +x "$MANIFEST/payload/usr/local/bin/tool" +printf '+ %s\n' "$BIN --output-format json $MANIFEST" +"$BIN" --output-format json "$MANIFEST" > "$WORK/manifest.json" +python3 - "$WORK/manifest.json" "$MANIFEST/build/Manifest-1.0.pkg" <<'PY' +import hashlib, json, os.path, sys + +# Explicit checks that raise, rather than assert: `python3 -O`/PYTHONOPTIMIZE +# strips assert statements, which would let a bad manifest print "manifest OK". +def fail(message): + print("manifest check failed:", message, file=sys.stderr) + raise SystemExit(1) + +manifest = json.load(open(sys.argv[1])) +for key in ("name", "version", "identifier", "pkg_path", "sha256", "signed", "notarized", "stapled"): + if key not in manifest: + fail(f"missing key: {key}") +if manifest["name"] != "Manifest-1.0.pkg": + fail(f'name: {manifest["name"]}') +if manifest["version"] != "1.0": + fail(f'version: {manifest["version"]}') +if manifest["identifier"] != "org.swiftpkg.pkg.Manifest": + fail(f'identifier: {manifest["identifier"]}') +if not (manifest["signed"] is False and manifest["notarized"] is False and manifest["stapled"] is False): + fail("signed/notarized/stapled are not all false") +if os.path.normpath(manifest["pkg_path"]) != os.path.normpath(sys.argv[2]): + fail(f'pkg_path: {manifest["pkg_path"]}') +if not os.path.isfile(manifest["pkg_path"]): + fail(f'pkg not found: {manifest["pkg_path"]}') +digest = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest() +if manifest["sha256"] != digest: + fail(f'sha256 mismatch: {manifest["sha256"]} != {digest}') +print("manifest OK") +PY + +OVERRIDE="$WORK/Override" +run "$BIN" --create "$OVERRIDE" +mkdir -p "$OVERRIDE/payload/usr/local/bin" +printf '%s\n' '#!/bin/sh' 'exit 0' > "$OVERRIDE/payload/usr/local/bin/tool" +run "$BIN" --pkg-version 3.1.4 --output-dir "$WORK/artifacts" "$OVERRIDE" +test -f "$WORK/artifacts/Override-3.1.4.pkg" +test ! -e "$OVERRIDE/build/Override-3.1.4.pkg" + +DYNAMIC="$WORK/Dynamic" +run "$BIN" --create --json "$DYNAMIC" +printf '%s\n' '{' ' "name": "Dyn-${version}.pkg",' ' "identifier": "com.example.dynamic",' ' "version": "${DATE}"' '}' > "$DYNAMIC/build-info.json" +printf '%s\n' 'dyn' > "$DYNAMIC/payload/marker.txt" +run "$BIN" "$DYNAMIC" +DYN_PKG=$(ls "$DYNAMIC/build/") +case "$DYN_PKG" in + Dyn-[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9].pkg) printf 'dynamic version OK: %s\n' "$DYN_PKG" ;; + *) printf 'unexpected dynamic package name: %s\n' "$DYN_PKG" >&2; exit 1 ;; +esac + DISTRIBUTION="$WORK/Distribution" run "$BIN" --create --json "$DISTRIBUTION" printf '%s\n' '{' ' "name": "Distribution-${version}.pkg",' ' "identifier": "com.example.distribution",' ' "version": "2.0",' ' "title": "Distribution 2.0",' ' "ownership": "recommended",' ' "postinstall_action": "none",' ' "distribution_style": true' '}' > "$DISTRIBUTION/build-info.json" diff --git a/swiftpkg.xcodeproj/project.pbxproj b/swiftpkg.xcodeproj/project.pbxproj index 0070c91..43bf78f 100644 --- a/swiftpkg.xcodeproj/project.pbxproj +++ b/swiftpkg.xcodeproj/project.pbxproj @@ -481,7 +481,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = DPXY7JLK67; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -492,7 +492,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.3.1; + MARKETING_VERSION = 0.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.codecarton.Swiftpkgr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; @@ -506,7 +506,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = DPXY7JLK67; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -517,7 +517,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.3.1; + MARKETING_VERSION = 0.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.codecarton.Swiftpkgr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; diff --git a/swiftpkg/BuildInfo.swift b/swiftpkg/BuildInfo.swift index 48e4717..b8dfaab 100644 --- a/swiftpkg/BuildInfo.swift +++ b/swiftpkg/BuildInfo.swift @@ -49,6 +49,10 @@ public struct NotarizationConfiguration: Sendable { public enum Authentication: Sendable { case appleID(appleID: String, teamID: String, password: String) case keychainProfile(String) + /// notarization_info was present but incomplete. Loading tolerates this + /// so `--skip-notarization` builds still succeed; the error surfaces only + /// if notarization is actually attempted. + case invalid(reason: String) } public let authentication: Authentication @@ -98,7 +102,7 @@ public struct PackageConfiguration: Sendable { public static func defaults(for project: URL) -> PackageConfiguration { let baseName = project.lastPathComponent.replacingOccurrences(of: " ", with: "") return PackageConfiguration( - name: "\(baseName)-${version}.pkg", identifier: "com.github.munki.pkg.\(baseName)", version: "1.0", + name: "\(baseName)-${version}.pkg", identifier: "org.swiftpkg.pkg.\(baseName)", version: "1.0", ownership: .recommended, installLocation: "/", compression: nil, minimumOSVersion: nil, usesLargePayload: false, postInstallAction: .none, preservesExtendedAttributes: false, suppressesBundleRelocation: true, usesDistributionStyle: false, title: nil, @@ -126,13 +130,46 @@ public struct PackageConfiguration: Sendable { notarization = try Self.notarizationConfiguration(in: values) } + /// Returns a copy with the version replaced, before `${version}` substitution. + public func withVersion(_ newVersion: String) -> PackageConfiguration { + PackageConfiguration( + name: name, identifier: identifier, version: newVersion, ownership: ownership, + installLocation: installLocation, compression: compression, minimumOSVersion: minimumOSVersion, + usesLargePayload: usesLargePayload, postInstallAction: postInstallAction, + preservesExtendedAttributes: preservesExtendedAttributes, suppressesBundleRelocation: suppressesBundleRelocation, + usesDistributionStyle: usesDistributionStyle, title: title, productIdentifier: productIdentifier, + signing: signing, notarization: notarization + ) + } + + /// Returns a copy with dynamic version tokens (${TIMESTAMP}/${DATE}/${DATETIME}) + /// resolved in the version field, before `${version}` name substitution. + public func resolvingDynamicVersion(now: Date = Date()) -> PackageConfiguration { + let resolved = DynamicVersion.resolve(version, now: now) + guard resolved != version else { return self } + return PackageConfiguration( + name: name, identifier: identifier, version: resolved, ownership: ownership, + installLocation: installLocation, compression: compression, minimumOSVersion: minimumOSVersion, + usesLargePayload: usesLargePayload, postInstallAction: postInstallAction, + preservesExtendedAttributes: preservesExtendedAttributes, suppressesBundleRelocation: suppressesBundleRelocation, + usesDistributionStyle: usesDistributionStyle, title: title, productIdentifier: productIdentifier, + signing: signing, notarization: notarization + ) + } + /// Returns a copy with `${version}` substituted in user-facing name fields. + /// The resolved package name is normalized to end in `.pkg`: build-info may + /// set `name` without the extension (e.g. `MunkiBootstrap`), and munki-pkg + /// writes the artifact as `.pkg`, so swiftpkg does too — otherwise the + /// output is extensionless and `find '*.pkg'`/munkiimport miss it. public func substitutingVersion() -> PackageConfiguration { func replacingVersion(in value: String?) -> String? { value?.replacingOccurrences(of: "${version}", with: version) } + let resolvedName = replacingVersion(in: name)! + let normalizedName = resolvedName.hasSuffix(".pkg") ? resolvedName : "\(resolvedName).pkg" return PackageConfiguration( - name: replacingVersion(in: name)!, identifier: identifier, version: version, ownership: ownership, + name: normalizedName, identifier: identifier, version: version, ownership: ownership, installLocation: installLocation, compression: compression, minimumOSVersion: minimumOSVersion, usesLargePayload: usesLargePayload, postInstallAction: postInstallAction, preservesExtendedAttributes: preservesExtendedAttributes, suppressesBundleRelocation: suppressesBundleRelocation, @@ -161,38 +198,38 @@ public struct PackageConfiguration: Sendable { private static func stringValue(for key: String, in values: [String: Any]) throws -> String? { guard let value = values[key] else { return nil } - guard let string = scalarString(value) else { throw MunkiPkgError.invalidConfiguration("build-info key '\(key)' must be a string") } + guard let string = scalarString(value) else { throw SwiftPkgError.invalidConfiguration("build-info key '\(key)' must be a string") } return string } private static func boolValue(for key: String, in values: [String: Any]) throws -> Bool? { guard let value = values[key] else { return nil } - guard let bool = value as? Bool else { throw MunkiPkgError.invalidConfiguration("build-info key '\(key)' must be a Boolean") } + guard let bool = value as? Bool else { throw SwiftPkgError.invalidConfiguration("build-info key '\(key)' must be a Boolean") } return bool } private static func enumValue(_ type: T.Type, key: String, values: [String: Any]) throws -> T? where T.RawValue == String { guard let string = try stringValue(for: key, in: values) else { return nil } - guard let value = T(rawValue: string) else { throw MunkiPkgError.invalidConfiguration("build-info key '\(key)' has illegal value: \(string)") } + guard let value = T(rawValue: string) else { throw SwiftPkgError.invalidConfiguration("build-info key '\(key)' has illegal value: \(string)") } return value } private static func signingConfiguration(in values: [String: Any]) throws -> SigningConfiguration? { guard let value = values["signing_info"] else { return nil } guard let signing = value as? [String: Any], let identity = signing["identity"] as? String else { - throw MunkiPkgError.invalidConfiguration("signing_info must contain a string identity") + throw SwiftPkgError.invalidConfiguration("signing_info must contain a string identity") } let certificates: [String] if let certificate = signing["additional_cert_names"] as? String { certificates = [certificate] } else if let values = signing["additional_cert_names"] as? [String] { certificates = values } else if signing["additional_cert_names"] == nil { certificates = [] } - else { throw MunkiPkgError.invalidConfiguration("signing_info additional_cert_names must be a string or string array") } + else { throw SwiftPkgError.invalidConfiguration("signing_info additional_cert_names must be a string or string array") } return SigningConfiguration(identity: identity, keychain: signing["keychain"] as? String, additionalCertificateNames: certificates, usesTimestamp: signing["timestamp"] as? Bool) } private static func notarizationConfiguration(in values: [String: Any]) throws -> NotarizationConfiguration? { guard let value = values["notarization_info"] else { return nil } - guard let notary = value as? [String: Any] else { throw MunkiPkgError.invalidConfiguration("notarization_info must be a dictionary") } + guard let notary = value as? [String: Any] else { throw SwiftPkgError.invalidConfiguration("notarization_info must be a dictionary") } let authentication: NotarizationConfiguration.Authentication if let appleID = notary["apple_id"] as? String, let teamID = notary["team_id"] as? String { let password = notary["password"] as? String ?? "" @@ -200,7 +237,10 @@ public struct PackageConfiguration: Sendable { } else if let profile = notary["keychain_profile"] as? String { authentication = .keychainProfile(profile) } else { - throw MunkiPkgError.invalidConfiguration("notarization_info must specify apple_id + team_id or keychain_profile") + // Tolerate incomplete notarization_info at load time so + // --skip-notarization builds succeed. munki-pkg defers this check to + // the point notarization actually runs; so does swiftpkg. + authentication = .invalid(reason: "notarization_info must specify apple_id + team_id or keychain_profile") } let timeout = (notary["staple_timeout"] as? NSNumber)?.intValue ?? 300 return NotarizationConfiguration(authentication: authentication, staplingTimeout: timeout) @@ -225,6 +265,7 @@ private extension NotarizationConfiguration { case let .appleID(appleID, teamID, password): values.merge(["apple_id": appleID, "team_id": teamID, "password": password]) { _, new in new } case let .keychainProfile(profile): values["keychain_profile"] = profile + case .invalid: break } return values } @@ -232,9 +273,11 @@ private extension NotarizationConfiguration { /// Loads and saves package configuration files in plist, JSON, or YAML form. public enum BuildInfoStore { - public static func load(from project: URL, requestedFormat: BuildInfoFormat?, fileManager: FileManager = .default) throws -> PackageConfiguration { + public static func load(from project: URL, requestedFormat: BuildInfoFormat?, versionOverride: String? = nil, fileManager: FileManager = .default) throws -> PackageConfiguration { let document = try discover(in: project, requestedFormat: requestedFormat, fileManager: fileManager) - return try loadTemplate(from: document.url, defaultsFor: project).substitutingVersion() + let template = try loadTemplate(from: document.url, defaultsFor: project) + let versioned = versionOverride.map(template.withVersion) ?? template + return versioned.resolvingDynamicVersion().substitutingVersion() } public static func loadTemplate(from project: URL, requestedFormat: BuildInfoFormat? = nil, fileManager: FileManager = .default) throws -> PackageConfiguration { @@ -252,8 +295,8 @@ public enum BuildInfoStore { case .json: object = try JSONSerialization.jsonObject(with: data) case .yaml, .yml: object = try Yams.load(yaml: String(decoding: data, as: UTF8.self)) as Any } - } catch { throw MunkiPkgError.invalidConfiguration("\(url.path) is not a valid \(format.rawValue) file: \(error.localizedDescription)") } - guard let values = object as? [String: Any] else { throw MunkiPkgError.invalidConfiguration("\(url.path) must contain a dictionary") } + } catch { throw SwiftPkgError.invalidConfiguration("\(url.path) is not a valid \(format.rawValue) file: \(error.localizedDescription)") } + guard let values = object as? [String: Any] else { throw SwiftPkgError.invalidConfiguration("\(url.path) must contain a dictionary") } return try PackageConfiguration(values: values, defaults: .defaults(for: project)) } @@ -275,19 +318,19 @@ public enum BuildInfoStore { public static func discover(in project: URL, requestedFormat: BuildInfoFormat? = nil, fileManager: FileManager = .default) throws -> BuildInfoDocument { if let requestedFormat { let url = project.appendingPathComponent("build-info").appendingPathExtension(requestedFormat.rawValue) - guard fileManager.itemExists(at: url) else { throw MunkiPkgError.invalidConfiguration("No build-info file found!") } + guard fileManager.itemExists(at: url) else { throw SwiftPkgError.invalidConfiguration("No build-info file found!") } return BuildInfoDocument(url: url, format: requestedFormat) } let baseURL = project.appendingPathComponent("build-info") let formats = BuildInfoFormat.allCases.filter { fileManager.itemExists(at: baseURL.appendingPathExtension($0.rawValue)) } - guard formats.count <= 1 else { throw MunkiPkgError.invalidConfiguration("Multiple build-info files found!") } - guard let format = formats.first else { throw MunkiPkgError.invalidConfiguration("No build-info file found!") } + guard formats.count <= 1 else { throw SwiftPkgError.invalidConfiguration("Multiple build-info files found!") } + guard let format = formats.first else { throw SwiftPkgError.invalidConfiguration("No build-info file found!") } return BuildInfoDocument(url: baseURL.appendingPathExtension(format.rawValue), format: format) } private static func format(for url: URL) throws -> BuildInfoFormat { guard let format = BuildInfoFormat(rawValue: url.pathExtension.lowercased()) else { - throw MunkiPkgError.invalidConfiguration("Unsupported build-info format: \(url.pathExtension)") + throw SwiftPkgError.invalidConfiguration("Unsupported build-info format: \(url.pathExtension)") } return format } diff --git a/swiftpkg/BuildResult.swift b/swiftpkg/BuildResult.swift new file mode 100644 index 0000000..94930b2 --- /dev/null +++ b/swiftpkg/BuildResult.swift @@ -0,0 +1,54 @@ +import CryptoKit +import Foundation + +/// Machine-readable summary of one completed build. Booleans reflect what +/// actually happened, not what build-info requested. +public struct BuildResult: Sendable, Codable, Equatable { + public let name: String + public let version: String + public let identifier: String + public let pkgPath: String + public let sha256: String + public let signed: Bool + public let notarized: Bool + public let stapled: Bool + + enum CodingKeys: String, CodingKey { + case name, version, identifier + case pkgPath = "pkg_path" + case sha256, signed, notarized, stapled + } + + public init( + name: String, version: String, identifier: String, pkgPath: String, + sha256: String, signed: Bool, notarized: Bool, stapled: Bool + ) { + self.name = name + self.version = version + self.identifier = identifier + self.pkgPath = pkgPath + self.sha256 = sha256 + self.signed = signed + self.notarized = notarized + self.stapled = stapled + } + + /// Pretty-printed, stable-key JSON for the CLI's `--output-format json`. + public func jsonString() throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return String(decoding: try encoder.encode(self), as: UTF8.self) + } +} + +/// Streaming SHA-256 of a file, lowercase hex. Reads in 1 MB chunks so a large +/// package is not loaded into memory all at once. +public func sha256Hex(ofFileAt url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { + hasher.update(data: chunk) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() +} diff --git a/swiftpkg/DynamicVersion.swift b/swiftpkg/DynamicVersion.swift new file mode 100644 index 0000000..8355e0e --- /dev/null +++ b/swiftpkg/DynamicVersion.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Resolves dynamic date/time tokens in a build-info version string. +/// +/// ${TIMESTAMP} -> yyyy.MM.dd.HHmm (e.g. 2026.07.18.1405) +/// ${DATE} -> yyyy.MM.dd (e.g. 2026.07.18) +/// ${DATETIME} -> yyyy.MM.dd.HHmmss (e.g. 2026.07.18.140530) +/// +/// Tokens are distinct (none is a substring of another), so replacement order is +/// irrelevant. Matches munki-pkg's behavior. +public enum DynamicVersion { + public static func resolve(_ version: String, now: Date = Date()) -> String { + guard version.contains("${") else { return version } + func stamp(_ format: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = format + return formatter.string(from: now) + } + return version + .replacingOccurrences(of: "${TIMESTAMP}", with: stamp("yyyy.MM.dd.HHmm")) + .replacingOccurrences(of: "${DATE}", with: stamp("yyyy.MM.dd")) + .replacingOccurrences(of: "${DATETIME}", with: stamp("yyyy.MM.dd.HHmmss")) + } +} diff --git a/swiftpkg/EnvLoader.swift b/swiftpkg/EnvLoader.swift new file mode 100644 index 0000000..15bb626 --- /dev/null +++ b/swiftpkg/EnvLoader.swift @@ -0,0 +1,262 @@ +import Foundation + +// Build-time variable substitution for install scripts. +// +// IMPORTANT: substituted values are embedded verbatim in scripts, which end up +// as plain text inside the resulting .pkg (readable via `pkgutil --expand`). +// This is for build-time variables (server URLs, org identifiers, version +// metadata) — NOT for secrets. For runtime secrets, fetch from Keychain or an +// MDM-delivered profile inside the script itself. + +/// Loads and merges build-time variables from a `.env` file. +public enum EnvLoader { + /// Maximum `.env` file size (1 MB); larger files are rejected. + public static let maxFileSize = 1_048_576 + + /// Process-environment prefix whose variables are merged in by default. + public static let inheritedPrefix = "SWIFTPKG_" + + private static let keyPattern = try! NSRegularExpression(pattern: #"^[A-Za-z_][A-Za-z0-9_]*$"#) + + /// Parses a `.env` file into key/value pairs. Returns empty if the file is + /// absent. Throws on unreadable files or ones over `maxFileSize`. + public static func load(from path: String, console: Console? = nil) throws -> [String: String] { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: path) else { return [:] } + + let attributes = try fileManager.attributesOfItem(atPath: path) + if let size = attributes[.size] as? Int, size > maxFileSize { + throw SwiftPkgError.invalidConfiguration("Environment file exceeds the \(maxFileSize)-byte limit: \(path)") + } + if let permissions = attributes[.posixPermissions] as? Int, permissions & 0o044 != 0 { + console?.warning("environment file \(path) is group- or world-readable (mode 0\(String(permissions, radix: 8))). Recommend `chmod 600 \(path)`.") + } + + guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else { + throw SwiftPkgError.invalidConfiguration("Failed to read environment file: \(path)") + } + + var variables: [String: String] = [:] + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#"), let equals = trimmed.firstIndex(of: "=") else { continue } + let key = String(trimmed[..= 2, (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { + value = String(value.dropFirst().dropLast()) + } + variables[key] = value + } + return variables + } + + /// Merges `.env` values over `SWIFTPKG_*` process-environment variables. + /// `.env` values win. `environment` is injectable for testing. + public static func merge( + fileVariables: [String: String], + inheritsEnvironment: Bool = true, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [String: String] { + var merged: [String: String] = [:] + if inheritsEnvironment { + for (key, value) in environment where key.hasPrefix(inheritedPrefix) { merged[key] = value } + } + for (key, value) in fileVariables { merged[key] = value } + return merged + } +} + +/// Replaces `${VAR}` placeholders in script content in a single pass, so a +/// substituted value containing placeholder syntax is never re-expanded. +public enum PlaceholderReplacer { + public struct Result: Sendable { + public let content: String + /// Placeholder names present in the script with no matching variable. + public let unresolved: Set + /// Placeholder names actually replaced. A variable that is loaded but + /// never referenced does not appear here. + public let substituted: Set + } + + private static let pattern = try! NSRegularExpression(pattern: #"\$\{([A-Za-z_][A-Za-z0-9_]*)\}"#) + + public static func replace(in content: String, with variables: [String: String]) -> Result { + let ns = content as NSString + let matches = pattern.matches(in: content, range: NSRange(location: 0, length: ns.length)) + guard !matches.isEmpty else { return Result(content: content, unresolved: [], substituted: []) } + + let output = NSMutableString() + var cursor = 0 + var unresolved: Set = [] + var substituted: Set = [] + for match in matches { + let range = match.range + if range.location > cursor { + output.append(ns.substring(with: NSRange(location: cursor, length: range.location - cursor))) + } + let key = ns.substring(with: match.range(at: 1)) + if let value = variables[key] { + output.append(value) + substituted.insert(key) + } else { + output.append(ns.substring(with: range)) + unresolved.insert(key) + } + cursor = range.location + range.length + } + if cursor < ns.length { + output.append(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor))) + } + return Result(content: output as String, unresolved: unresolved, substituted: substituted) + } +} + +/// Applies build variables to a project's scripts, writing substituted copies to +/// a private temp directory so pkgbuild packages those instead of the originals. +public enum ScriptEnvironment { + static let scriptNames: Set = ["preinstall", "postinstall", "preupgrade", "postupgrade", "preexpansion"] + + private static func isScript(_ name: String) -> Bool { + scriptNames.contains(name) || name.hasSuffix(".sh") || name.hasSuffix(".py") + } + + /// Matches the ways a shell script introduces a name it owns: a plain or + /// keyword-prefixed assignment (`n=`, `local n=`, `export -f n=`), a bare + /// declaration (`local n`), a loop variable (`for n in`), or `read n`. + /// Deliberately line-oriented and permissive — a false positive here only + /// costs one suppressed warning, while a false negative restores the noise. + private static let assignmentPatterns: [NSRegularExpression] = { + let declarators = "local|declare|typeset|export|readonly" + return [ + // Any assignment, wherever it sits on the line — this is what catches + // the second and third names in `local key="$1" type="$2" val="$3"`. + #"(?m)(?:^|[[:blank:]])([A-Za-z_][A-Za-z0-9_]*)="#, + // Declaration with no value: `local declared;` or `local declared`. + #"(?m)(?:^|[[:blank:]])(?:\#(declarators))[[:blank:]]+(?:-[A-Za-z]+[[:blank:]]+)*([A-Za-z_][A-Za-z0-9_]*)[[:blank:]]*(?:;|$)"#, + #"(?m)\bfor[[:blank:]]+([A-Za-z_][A-Za-z0-9_]*)[[:blank:]]+in\b"#, + #"(?m)\bread[[:blank:]]+(?:-[A-Za-z]+[[:blank:]]+)*([A-Za-z_][A-Za-z0-9_]*)"#, + ].compactMap { try? NSRegularExpression(pattern: $0) } + }() + + /// Names the script assigns itself, and therefore resolves at install time. + /// `${VAR}` is ambiguous by construction: it is both the build-time + /// substitution syntax and ordinary shell expansion, so a name the script + /// declares is a shell variable, not a placeholder anyone forgot to supply. + static func shellOwnedNames(in content: String) -> Set { + let ns = content as NSString + let whole = NSRange(location: 0, length: ns.length) + var owned: Set = [] + for pattern in assignmentPatterns { + for match in pattern.matches(in: content, range: whole) where match.numberOfRanges > 1 { + let range = match.range(at: 1) + if range.location != NSNotFound { owned.insert(ns.substring(with: range)) } + } + } + return owned + } + + /// Names the install environment supplies, which no build variable is + /// expected to provide. `shellOwnedNames` only recognises names a script + /// assigns itself, so without this list `${HOME}` or `${PATH}` — expanded by + /// the shell at install time like any other environment variable — get + /// reported as forgotten build variables, and `--strict-env` fails the build + /// on a correct script. + /// + /// Deliberately limited to names the environment is guaranteed to define: + /// the POSIX/shell set, and the variables macOS `installer` exports into + /// package scripts. Anything outside it stays reportable, so a genuinely + /// missing variable is still caught. + static let environmentOwnedNames: Set = [ + // Shell and POSIX environment. + "HOME", "PATH", "PWD", "OLDPWD", "SHELL", "TMPDIR", "USER", "LOGNAME", + "TERM", "LANG", "IFS", "SHLVL", "PS1", "PS2", "PS4", "EDITOR", "VISUAL", "PAGER", + // Shell-maintained specials. + "RANDOM", "SECONDS", "LINENO", "PPID", "UID", "EUID", "GROUPS", + "HOSTNAME", "HOSTTYPE", "OSTYPE", "MACHTYPE", "BASH_VERSION", "ZSH_VERSION", + // Exported by macOS `installer` into package scripts. + "INSTALLER_TEMP", "INSTALLER_PAYLOAD_DIR", "INSTALLER_SECURE_TEMP", + "PACKAGE_PATH", "SCRIPT_NAME", "RECEIPT_PATH", "DSTVOLUME", "DSTROOT", + "COMMAND_LINE_INSTALL", "CM_BUILD", + ] + + /// Names that already resolve at install time and therefore need no build + /// variable: those the script assigns itself, plus those the environment + /// supplies. + static func installTimeOwnedNames(in content: String) -> Set { + shellOwnedNames(in: content).union(environmentOwnedNames) + } + + /// Unresolved placeholders worth reporting: referenced by the script, absent + /// from `variables`, and not a name that already resolves at install time. + static func reportableUnresolved(in content: String, with variables: [String: String]) -> Set { + PlaceholderReplacer.replace(in: content, with: variables).unresolved + .subtracting(installTimeOwnedNames(in: content)) + } + + /// Returns placeholder names referenced by any script but not in `variables`. + public static func unresolvedPlaceholders(in scriptsDir: URL, given variables: [String: String], fileManager: FileManager = .default) -> [String: Set] { + let contents = (try? fileManager.contentsOfDirectory(atPath: scriptsDir.path)) ?? [] + var byScript: [String: Set] = [:] + for name in contents where isScript(name) { + let path = scriptsDir.appendingPathComponent(name).path + guard let data = fileManager.contents(atPath: path), let text = String(data: data, encoding: .utf8) else { continue } + let unresolved = reportableUnresolved(in: text, with: variables) + if !unresolved.isEmpty { byScript[name] = unresolved } + } + return byScript + } + + /// What a substitution pass did to a project's scripts. + public struct Outcome: Sendable { + /// The directory of substituted copies, for pkgbuild to package. + public let directory: URL + /// Reportable unresolved placeholder names, keyed by script. + public let unresolved: [String: Set] + /// Placeholder names actually replaced, keyed by script. Scripts where + /// nothing was replaced are absent. + public let substituted: [String: Set] + + /// Distinct variable names replaced across every script. + public var substitutedNames: Set { substituted.values.reduce(into: []) { $0.formUnion($1) } } + } + + /// Copies scripts into `/env-scripts` (mode 0700), substituting + /// `${VAR}` placeholders. Returns what the pass replaced and left unresolved, + /// or nil when there is nothing to substitute. + public static func process(scriptsDir: URL, into tempDir: URL, with variables: [String: String], fileManager: FileManager = .default) throws -> Outcome? { + guard !variables.isEmpty else { return nil } + let contents = (try? fileManager.contentsOfDirectory(atPath: scriptsDir.path))?.filter { $0 != ".DS_Store" } ?? [] + guard !contents.isEmpty else { return nil } + + let processedDir = tempDir.appendingPathComponent("env-scripts", isDirectory: true) + try fileManager.createDirectory(at: processedDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: tempDir.path) + + var unresolvedByScript: [String: Set] = [:] + var substitutedByScript: [String: Set] = [:] + for name in contents { + let source = scriptsDir.appendingPathComponent(name) + let destination = processedDir.appendingPathComponent(name) + if isScript(name), let data = fileManager.contents(atPath: source.path), let text = String(data: data, encoding: .utf8) { + let result = PlaceholderReplacer.replace(in: text, with: variables) + try Data(result.content.utf8).write(to: destination, options: .atomic) + if !result.substituted.isEmpty { substitutedByScript[name] = result.substituted } + // Substitution is unchanged — only what gets reported narrows. + let reportable = result.unresolved.subtracting(installTimeOwnedNames(in: text)) + if !reportable.isEmpty { unresolvedByScript[name] = reportable } + // Keep values non-world-readable during the build; force owner-exec + // since pkgbuild requires runnable scripts. + let sourcePermissions = ((try? fileManager.attributesOfItem(atPath: source.path))?[.posixPermissions] as? Int) ?? 0o700 + try fileManager.setAttributes([.posixPermissions: (sourcePermissions & 0o700) | 0o100], ofItemAtPath: destination.path) + } else { + try fileManager.copyItem(at: source, to: destination) + } + } + return Outcome(directory: processedDir, unresolved: unresolvedByScript, substituted: substitutedByScript) + } +} diff --git a/swiftpkg/Linter.swift b/swiftpkg/Linter.swift new file mode 100644 index 0000000..0c5e598 --- /dev/null +++ b/swiftpkg/Linter.swift @@ -0,0 +1,108 @@ +import Foundation + +/// One problem found by `--lint`. +public struct LintFinding: Sendable, Equatable { + public enum Severity: String, Sendable { + case error + case warning + } + + public let severity: Severity + public let message: String + + public init(_ severity: Severity, _ message: String) { + self.severity = severity + self.message = message + } +} + +/// Validates a package project without building it, for fast PR/CI pre-checks. +public struct Linter { + private let fileManager: FileManager + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + /// Returns findings. A `.error` means the project should not build; a + /// `.warning` is advisory. Throws only when the project can't be read at all + /// (missing directory / undecodable build-info), which is itself a failure. + public func lint(project: URL, requestedFormat: BuildInfoFormat?) throws -> [LintFinding] { + guard fileManager.directoryExists(at: project) else { + throw SwiftPkgError.message("\(project.path): Project not found.") + } + var findings: [LintFinding] = [] + + // Decoding failures throw SwiftPkgError; surface them as a lint error + // rather than a crash so `--lint` always produces a report. + let configuration: PackageConfiguration + do { + configuration = try BuildInfoStore.load(from: project, requestedFormat: requestedFormat) + } catch let error as SwiftPkgError { + return [LintFinding(.error, error.description)] + } + + if configuration.identifier.isEmpty { + findings.append(LintFinding(.error, "identifier is empty")) + } else if !Self.isReverseDNS(configuration.identifier) { + findings.append(LintFinding(.warning, "identifier \"\(configuration.identifier)\" is not reverse-DNS style")) + } + + if configuration.version.isEmpty { + findings.append(LintFinding(.error, "version is empty")) + } + + // A missing .pkg extension is not flagged: the build normalizes the + // resolved name to end in .pkg (matching munki-pkg), so it is harmless. + if configuration.name.isEmpty || configuration.name.contains("/") || configuration.name == "." || configuration.name == ".." { + findings.append(LintFinding(.error, "name \"\(configuration.name)\" must be a single path component")) + } + + if configuration.notarization != nil, configuration.signing == nil { + findings.append(LintFinding(.warning, "notarization is configured but signing is not; notarization requires a Developer ID signature")) + } + + let payload = project.appendingPathComponent("payload", isDirectory: true) + let scripts = project.appendingPathComponent("scripts", isDirectory: true) + let hasPayload = fileManager.directoryExists(at: payload) + let hasScripts = fileManager.directoryExists(at: scripts) + && ((try? fileManager.contents(at: scripts).contains { $0 != ".DS_Store" }) ?? false) + if !hasPayload, !hasScripts { + findings.append(LintFinding(.error, "project has neither a payload directory nor a non-empty scripts directory")) + } + + if hasScripts { + findings.append(contentsOf: lintScripts(in: scripts)) + } + + return findings + } + + /// Reverse-DNS means at least two dot-separated, non-empty components, so + /// leading, repeated, and trailing dots (`.a`, `a..b`, `a.b.`) are rejected. + static func isReverseDNS(_ identifier: String) -> Bool { + let components = identifier.split(separator: ".", omittingEmptySubsequences: false) + return components.count >= 2 && components.allSatisfy { !$0.isEmpty } + } + + private func lintScripts(in scripts: URL) -> [LintFinding] { + var findings: [LintFinding] = [] + for name in ["preinstall", "postinstall"] { + let script = scripts.appendingPathComponent(name) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: script.path, isDirectory: &isDirectory) else { continue } + if isDirectory.boolValue { + findings.append(LintFinding(.error, "\(name) is a directory, but an install script must be a regular file")) + continue + } + if let data = fileManager.contents(atPath: script.path), !data.starts(with: Data("#!".utf8)) { + findings.append(LintFinding(.warning, "\(name) script does not start with a shebang (#!)")) + } + let permissions = (try? fileManager.attributesOfItem(atPath: script.path)[.posixPermissions] as? NSNumber)?.uint16Value ?? 0 + if permissions & 0o111 == 0 { + findings.append(LintFinding(.warning, "\(name) script is not executable")) + } + } + return findings + } +} diff --git a/swiftpkg/PackageBuildOptions.swift b/swiftpkg/PackageBuildOptions.swift index bf437d8..3f05ae2 100644 --- a/swiftpkg/PackageBuildOptions.swift +++ b/swiftpkg/PackageBuildOptions.swift @@ -1,3 +1,5 @@ +import Foundation + /// Frontend-neutral choices that affect one package build. public struct PackageBuildOptions: Sendable { public let requestedFormat: BuildInfoFormat? @@ -6,6 +8,24 @@ public struct PackageBuildOptions: Sendable { public let skipsSigning: Bool public let skipsNotarization: Bool public let skipsStapling: Bool + /// Path to a `.env` file of build-time variables; nil auto-detects the + /// project's `.env`. + public let envFile: String? + /// Fail the build if a script references a `${VAR}` with no matching variable. + public let strictEnvironment: Bool + /// Merge `SWIFTPKG_*` variables from the calling process environment. + public let inheritsEnvironment: Bool + /// Write a `.provenance.json` sidecar recording tool version, build + /// time, git commit/remote, an input digest, and the package hash. + public let writesProvenance: Bool + /// After building, assert the package matches what build-info declared + /// (signature present when signing was requested, Gatekeeper-accepted when + /// notarized). Fails the build on mismatch. + public let verifies: Bool + /// Overrides the build-info version (resolved before `${version}` substitution). + public let versionOverride: String? + /// Writes the package here instead of the project's `build/` directory. + public let outputDirectory: URL? public init( requestedFormat: BuildInfoFormat? = nil, @@ -13,7 +33,14 @@ public struct PackageBuildOptions: Sendable { isQuiet: Bool = false, skipsSigning: Bool = false, skipsNotarization: Bool = false, - skipsStapling: Bool = false + skipsStapling: Bool = false, + envFile: String? = nil, + strictEnvironment: Bool = false, + inheritsEnvironment: Bool = true, + writesProvenance: Bool = false, + verifies: Bool = false, + versionOverride: String? = nil, + outputDirectory: URL? = nil ) { self.requestedFormat = requestedFormat self.exportsBOM = exportsBOM @@ -21,5 +48,12 @@ public struct PackageBuildOptions: Sendable { self.skipsSigning = skipsSigning self.skipsNotarization = skipsNotarization self.skipsStapling = skipsStapling + self.envFile = envFile + self.strictEnvironment = strictEnvironment + self.inheritsEnvironment = inheritsEnvironment + self.writesProvenance = writesProvenance + self.verifies = verifies + self.versionOverride = versionOverride + self.outputDirectory = outputDirectory } } diff --git a/swiftpkg/PackageBuilder.swift b/swiftpkg/PackageBuilder.swift index 0a79280..b44b987 100644 --- a/swiftpkg/PackageBuilder.swift +++ b/swiftpkg/PackageBuilder.swift @@ -1,6 +1,12 @@ import Darwin import Foundation +/// Mutable holder used to carry a value out of a non-escaping async closure. +private final class Box { + var value: Value + init(_ value: Value) { self.value = value } +} + /// Coordinates the stages that produce, sign, and optionally notarize a package. public struct PackageBuildCoordinator: @unchecked Sendable { public let fileManager: FileManager @@ -13,17 +19,22 @@ public struct PackageBuildCoordinator: @unchecked Sendable { self.console = console } - public func buildPackage(in project: URL, configuration: PackageBuildOptions) async throws { - let packageConfiguration = try BuildInfoStore.load(from: project, requestedFormat: configuration.requestedFormat) + @discardableResult + public func buildPackage(in project: URL, configuration: PackageBuildOptions) async throws -> BuildResult { + let packageConfiguration = try BuildInfoStore.load(from: project, requestedFormat: configuration.requestedFormat, versionOverride: configuration.versionOverride) + try Self.validatePackageName(packageConfiguration.name) if packageConfiguration.ownership != .recommended, geteuid() != 0 { console.warning("build-info ownership: \(packageConfiguration.ownership.rawValue) might require using sudo to build this package.") } - let layout = try PackageProjectLayout(project: project, fileManager: fileManager) + let layout = try PackageProjectLayout(project: project, fileManager: fileManager, outputDirectory: configuration.outputDirectory) try layout.createBuildDirectoryIfNeeded() + let output = layout.buildDirectory.appendingPathComponent(packageConfiguration.name) + let notarization = Box(NotarizationService.Outcome(accepted: false, stapled: false)) try await layout.withTemporaryDirectory { temporaryDirectory in - let context = PackageBuildContext(configuration: packageConfiguration, layout: layout, temporaryDirectory: temporaryDirectory) + let scriptsOverride = try applyBuildVariables(to: layout.scripts, project: project, temporaryDirectory: temporaryDirectory, configuration: configuration) + let context = PackageBuildContext(configuration: packageConfiguration, layout: layout, temporaryDirectory: temporaryDirectory, scriptsOverride: scriptsOverride) let scriptPreparer = ScriptPreparer(fileManager: fileManager, console: console) - if let scripts = layout.scripts { try scriptPreparer.prepareScripts(in: scripts) } + if scriptsOverride == nil, let scripts = layout.scripts { try scriptPreparer.prepareScripts(in: scripts) } try ComponentPackageBuilder(fileManager: fileManager, runner: runner, console: console) .buildComponent(using: context, isQuiet: configuration.isQuiet, skipsSigning: configuration.skipsSigning) if configuration.exportsBOM { @@ -34,19 +45,117 @@ public struct PackageBuildCoordinator: @unchecked Sendable { try DistributionPackageBuilder(fileManager: fileManager, runner: runner, console: console) .buildDistribution(using: context, isQuiet: configuration.isQuiet, skipsSigning: configuration.skipsSigning) } - guard let notarization = packageConfiguration.notarization, !configuration.skipsNotarization, !configuration.skipsSigning else { return } - try await NotarizationService(runner: runner, console: console) - .notarize(package: context.output, configuration: notarization, skipsStapling: configuration.skipsStapling) + guard let notarizationConfig = packageConfiguration.notarization, !configuration.skipsNotarization, !configuration.skipsSigning else { return } + notarization.value = try await NotarizationService(runner: runner, console: console) + .notarize(package: context.output, configuration: notarizationConfig, skipsStapling: configuration.skipsStapling) + } + let signed = packageConfiguration.signing != nil && !configuration.skipsSigning + if configuration.verifies { + let notarized = packageConfiguration.notarization != nil && !configuration.skipsNotarization && !configuration.skipsSigning + try PackageVerifier(runner: runner, console: console) + .verify(package: output, expectedIdentifier: packageConfiguration.identifier, expectedVersion: packageConfiguration.version, signed: signed, notarized: notarized) + } + if configuration.writesProvenance { + let provenance = try ProvenanceBuilder(runner: runner, fileManager: fileManager) + .build(configuration: packageConfiguration, output: output, project: project) + let sidecar = URL(fileURLWithPath: output.path + ".provenance.json") + try Data(provenance.jsonString().utf8).write(to: sidecar, options: .atomic) + console.display("Wrote provenance to \(sidecar.path)") + } + return BuildResult( + name: packageConfiguration.name, + version: packageConfiguration.version, + identifier: packageConfiguration.identifier, + pkgPath: output.path, + sha256: try sha256Hex(ofFileAt: output), + signed: signed, + notarized: notarization.value.accepted, + stapled: notarization.value.stapled + ) + } + + /// Loads `.env` + inherited variables and, if any apply, writes substituted + /// script copies to a private temp dir, returning that directory for the + /// build to use in place of the originals. + private func applyBuildVariables(to scripts: URL?, project: URL, temporaryDirectory: URL, configuration: PackageBuildOptions) throws -> URL? { + guard let scripts else { return nil } + + let envPath: String + if let explicit = configuration.envFile { + guard fileManager.fileExists(atPath: explicit) else { + throw SwiftPkgError.invalidConfiguration("--env-file not found: \(explicit)") + } + envPath = explicit + } else { + envPath = project.appendingPathComponent(".env").path + } + + let fileVariables = try EnvLoader.load(from: envPath, console: console) + let variables = EnvLoader.merge(fileVariables: fileVariables, inheritsEnvironment: configuration.inheritsEnvironment) + + guard !variables.isEmpty else { + if configuration.strictEnvironment { + try failOnUnresolved(ScriptEnvironment.unresolvedPlaceholders(in: scripts, given: [:], fileManager: fileManager)) + } + return nil + } + + guard let processed = try ScriptEnvironment.process(scriptsDir: scripts, into: temporaryDirectory, with: variables, fileManager: fileManager) else { + return nil + } + if configuration.strictEnvironment { + try failOnUnresolved(processed.unresolved) + } else { + for (script, keys) in processed.unresolved { + console.warning("\(script): unresolved placeholder(s) \(keys.sorted().joined(separator: ", "))") + } } + // Report what the pass replaced, not what it loaded. A variable that no + // script references is the usual sign of a typo in either place, and a + // count of loaded variables hides it behind an encouraging number. + let applied = processed.substitutedNames + if applied.isEmpty { + console.display("Loaded \(variables.count) build variable(s); no install script referenced any of them") + } else { + console.display("Applied \(applied.count) of \(variables.count) build variable(s) to \(processed.substituted.count) install script(s)") + } + return processed.directory + } + + private func failOnUnresolved(_ unresolved: [String: Set]) throws { + guard !unresolved.isEmpty else { return } + let detail = unresolved.sorted { $0.key < $1.key } + .map { "\($0.key): \($0.value.sorted().joined(separator: ", "))" } + .joined(separator: "; ") + throw SwiftPkgError.invalidConfiguration("Unresolved script placeholders (--strict-env): \(detail)") } private func packageBOM(for package: URL) throws -> URL { let result = try runner.run(executable: ToolPaths.pkgutil, arguments: ["--bom", package.path]) guard result.status == 0, let path = result.stdoutString.split(whereSeparator: \.isNewline).first else { - throw MunkiPkgError.processFailed(tool: "pkgutil", message: "pkgutil returned no BOM path") + throw SwiftPkgError.processFailed(tool: "pkgutil", message: "pkgutil returned no BOM path") } return URL(fileURLWithPath: String(path)) } + + /// Rejects a package `name` that could escape the build directory. + /// + /// The output package path is `build/` (and `build/Dist-` for + /// distribution builds), so a `name` containing a path separator or `..` + /// would write the artifact outside `build/`. Require a single, safe path + /// component. Called with the post-`${version}`-substitution name. + static func validatePackageName(_ name: String) throws { + guard !name.isEmpty, + name != ".", name != "..", + !name.contains("/"), + !name.contains("\0"), + URL(fileURLWithPath: name).lastPathComponent == name + else { + throw SwiftPkgError.invalidConfiguration( + "Package name \"\(name)\" must be a single path component (no \"/\" or \"..\")." + ) + } + } } /// Describes the files and directories involved in one package build. @@ -57,7 +166,7 @@ private struct PackageProjectLayout { let buildDirectory: URL let fileManager: FileManager - init(project: URL, fileManager: FileManager) throws { + init(project: URL, fileManager: FileManager, outputDirectory: URL? = nil) throws { self.project = project self.fileManager = fileManager let payloadURL = project.appendingPathComponent("payload", isDirectory: true) @@ -66,13 +175,16 @@ private struct PackageProjectLayout { if fileManager.directoryExists(at: scriptsURL), try !fileManager.contents(at: scriptsURL).filter({ $0 != ".DS_Store" }).isEmpty { scripts = scriptsURL } else { scripts = nil } - guard payload != nil || scripts != nil else { throw MunkiPkgError.message("\(project.path) does not contain a payload folder or a scripts folder.") } - buildDirectory = project.appendingPathComponent("build", isDirectory: true) + // A project with neither payload nor scripts is valid: it builds a + // receipt-only package (pkgbuild --nopayload) that installs no files but + // records a receipt, which Munki conditions can key off. munki-pkg + // allows this, so swiftpkg does too. + buildDirectory = outputDirectory ?? project.appendingPathComponent("build", isDirectory: true) } func createBuildDirectoryIfNeeded() throws { - if !fileManager.itemExists(at: buildDirectory) { try fileManager.createDirectory(at: buildDirectory, withIntermediateDirectories: false) } - else if !fileManager.directoryExists(at: buildDirectory) { throw MunkiPkgError.message("\(buildDirectory.path) is not a directory.") } + if !fileManager.itemExists(at: buildDirectory) { try fileManager.createDirectory(at: buildDirectory, withIntermediateDirectories: true) } + else if !fileManager.directoryExists(at: buildDirectory) { throw SwiftPkgError.message("\(buildDirectory.path) is not a directory.") } } func withTemporaryDirectory(_ body: (URL) async throws -> Void) async throws { @@ -88,8 +200,12 @@ private struct PackageBuildContext { let configuration: PackageConfiguration let layout: PackageProjectLayout let temporaryDirectory: URL + /// Substituted scripts directory to package instead of `layout.scripts`. + var scriptsOverride: URL? = nil var output: URL { layout.buildDirectory.appendingPathComponent(configuration.name) } + /// The scripts directory pkgbuild should package. + var effectiveScripts: URL? { scriptsOverride ?? layout.scripts } } /// Normalizes installer scripts before package construction. @@ -134,7 +250,7 @@ private struct ComponentPackageBuilder { if let compression = context.configuration.compression { arguments += ["--compression", compression.rawValue] } if let minimumOSVersion = context.configuration.minimumOSVersion { arguments += ["--min-os-version", minimumOSVersion] } if context.configuration.usesLargePayload { arguments.append("--large-payload") } - if let scripts = context.layout.scripts { arguments += ["--scripts", scripts.path] } + if let scripts = context.effectiveScripts { arguments += ["--scripts", scripts.path] } if isQuiet { arguments.append("--quiet") } if !context.configuration.usesDistributionStyle, !skipsSigning { appendSigningArguments(&arguments, signing: context.configuration.signing) } arguments.append(context.output.path) @@ -154,7 +270,7 @@ private struct ComponentPackageBuilder { try runner.runSuccessfully(executable: ToolPaths.pkgbuild, arguments: arguments, failureMessage: "pkgbuild failed while analyzing payload") let data = try Data(contentsOf: destination) guard var propertyList = try PropertyListSerialization.propertyList(from: data, format: nil) as? [[String: Any]] else { - throw MunkiPkgError.message("Couldn't read \(destination.path)") + throw SwiftPkgError.message("Couldn't read \(destination.path)") } for index in propertyList.indices where propertyList[index]["BundleIsRelocatable"] as? Bool == true { propertyList[index]["BundleIsRelocatable"] = false @@ -193,20 +309,36 @@ private struct DistributionPackageBuilder { } /// Uploads a package to Apple notarization and optionally staples it. -private struct NotarizationService: Sendable { +struct NotarizationService: Sendable { let runner: any ProcessRunning let console: Console - func notarize(package: URL, configuration: NotarizationConfiguration, skipsStapling: Bool) async throws { + /// The outcome of a notarization attempt: whether Apple accepted the + /// submission, and whether the ticket was stapled to the package. + struct Outcome: Sendable { + let accepted: Bool + let stapled: Bool + } + + /// Uploads the package and always polls for acceptance, then staples when + /// accepted and stapling was not skipped. Acceptance and stapling are + /// reported independently so the build manifest reflects what actually + /// happened rather than what was merely requested. + func notarize(package: URL, configuration: NotarizationConfiguration, skipsStapling: Bool) async throws -> Outcome { + if case let .invalid(reason) = configuration.authentication { + throw SwiftPkgError.invalidConfiguration(reason) + } console.display("Uploading package to Apple notary service") let submission = try plistOutput(for: ["notarytool", "submit", "--output-format", "plist", package.path] + authenticationArguments(for: configuration), failureMessage: "Notarization upload failed.") - guard let identifier = submission["id"] as? String else { throw MunkiPkgError.message("Unexpected output from notarytool") } + guard let identifier = submission["id"] as? String else { throw SwiftPkgError.notarizationFailed("Unexpected output from notarytool") } console.display("id \(identifier)", toolName: "notarytool") if let message = submission["message"] as? String { console.display(message, toolName: "notarytool") } - guard !skipsStapling, try await waitForAcceptance(identifier, configuration: configuration) else { return } + let accepted = try await waitForAcceptance(identifier, configuration: configuration) + guard accepted, !skipsStapling else { return Outcome(accepted: accepted, stapled: false) } console.display("Stapling package") try runner.runSuccessfully(executable: ToolPaths.xcrun, arguments: ["stapler", "staple", package.path], failureMessage: "Stapling failed") console.display("The staple and validate action worked!") + return Outcome(accepted: true, stapled: true) } private func waitForAcceptance(_ identifier: String, configuration: NotarizationConfiguration) async throws -> Bool { @@ -219,23 +351,39 @@ private struct NotarizationService: Sendable { let status = output["status"] as? String ?? "Unknown" let message = output["message"] as? String ?? "" if status == "Accepted" { console.display("Notarization successful. \(message)"); return true } - if status != "In Progress" && status != "Unknown" { throw MunkiPkgError.message("Notarization failed (\(status)): \(message)") } + if status != "In Progress" && status != "Unknown" { throw SwiftPkgError.notarizationFailed("Notarization failed (\(status)): \(message)") } console.display("Notarization state: \(status). Trying again in \(delay) seconds") } - console.warning("Timeout EXCEEDED when waiting for the notarization to complete. You can manually staple the package later if notarization is successful.") - return false + throw SwiftPkgError.notarizationFailed("Timeout exceeded (\(configuration.staplingTimeout)s) waiting for notarization to complete. The package was uploaded but never confirmed Accepted, so it was not stapled. Check with 'xcrun notarytool info \(identifier)' and staple manually if it later succeeds.") } + /// Carries notarytool's own explanation through, the way every other + /// subprocess call does via `runSuccessfully`. Without it a missing keychain + /// profile — which notarytool names, along with the command that creates it — + /// surfaces only as "Notarization upload failed." private func plistOutput(for arguments: [String], failureMessage: String) throws -> [String: Any] { - let result = try runner.run(executable: ToolPaths.xcrun, arguments: arguments) - guard result.status == 0 else { throw MunkiPkgError.processFailed(tool: "notarytool", message: failureMessage) } + let result: ProcessResult + do { + result = try runner.run(executable: ToolPaths.xcrun, arguments: arguments) + } catch { + throw SwiftPkgError.notarizationFailed("\(failureMessage) \(error.localizedDescription)") + } + guard result.status == 0 else { + throw SwiftPkgError.notarizationFailed("notarytool: \(result.failureDetail(fallback: failureMessage))") + } let data: Data if result.stdoutString.hasPrefix("Generated JWT"), let newline = result.stdoutString.firstIndex(of: "\n") { data = Data(result.stdoutString[result.stdoutString.index(after: newline)...].utf8) } else { data = result.stdout } - guard let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { throw MunkiPkgError.message(failureMessage) } + let object: Any + do { + object = try PropertyListSerialization.propertyList(from: data, format: nil) + } catch { + throw SwiftPkgError.notarizationFailed("\(failureMessage) \(error.localizedDescription)") + } + guard let plist = object as? [String: Any] else { throw SwiftPkgError.notarizationFailed(failureMessage) } return plist } @@ -243,6 +391,7 @@ private struct NotarizationService: Sendable { switch configuration.authentication { case let .appleID(appleID, teamID, password): return ["--apple-id", appleID, "--team-id", teamID, "--password", password] case let .keychainProfile(profile): return ["--keychain-profile", profile] + case .invalid: return [] // notarize(package:...) rejects .invalid before reaching here } } } @@ -250,7 +399,16 @@ private struct NotarizationService: Sendable { private func appendSigningArguments(_ arguments: inout [String], signing: SigningConfiguration?) { guard let signing else { return } arguments += ["--sign", signing.identity] - if let keychain = signing.keychain { arguments += ["--keychain", keychain] } + if let keychain = signing.keychain { arguments += ["--keychain", expandKeychainPath(keychain)] } for certificate in signing.additionalCertificateNames { arguments += ["--cert", certificate] } if let usesTimestamp = signing.usesTimestamp { arguments.append(usesTimestamp ? "--timestamp" : "--timestamp=none") } } + +/// Expands `${HOME}` and a leading tilde in a build-info keychain path so that +/// projects written as `${HOME}/Library/Keychains/signing.keychain` resolve to a +/// real path before being handed to `productbuild`/`productsign`. Mirrors the +/// original munki-pkg, whose build-info files rely on this expansion. +func expandKeychainPath(_ path: String) -> String { + let withHome = path.replacingOccurrences(of: "${HOME}", with: NSHomeDirectory()) + return NSString(string: withHome).expandingTildeInPath +} diff --git a/swiftpkg/PackageImporter.swift b/swiftpkg/PackageImporter.swift index 7e49cd5..e2c25c0 100644 --- a/swiftpkg/PackageImporter.swift +++ b/swiftpkg/PackageImporter.swift @@ -28,7 +28,7 @@ public struct PackageImporter { /// Imports a package into a new project using the requested configuration format. public func importPackage(at package: URL, to project: URL, format: BuildInfoFormat) throws { guard !fileManager.itemExists(at: project) else { - throw MunkiPkgError.message("Directory \(project.path) already exists.") + throw SwiftPkgError.projectExists("Directory \(project.path) already exists.") } if fileManager.directoryExists(at: package) { try importBundlePackage(package, project: project, format: format) @@ -42,7 +42,7 @@ public struct PackageImporter { let contents = package.appendingPathComponent("Contents", isDirectory: true) let distributionFiles = try fileManager.contents(at: contents).filter { $0.hasSuffix(".dist") } guard distributionFiles.isEmpty else { - throw MunkiPkgError.message("Bundle-style distribution packages are not supported for import. Consider importing the included sub-package(s).") + throw SwiftPkgError.importFailed("Bundle-style distribution packages are not supported for import. Consider importing the included sub-package(s).") } try fileManager.createDirectory(at: project, withIntermediateDirectories: false) do { @@ -104,7 +104,7 @@ public struct PackageImporter { name.hasSuffix(".pkg") && fileManager.directoryExists(at: project.appendingPathComponent(name)) } guard packages.count == 1 else { - throw MunkiPkgError.message("Distribution packages to be imported must contain exactly one component package! Found: \(packages)") + throw SwiftPkgError.importFailed("Distribution packages to be imported must contain exactly one component package! Found: \(packages)") } let component = project.appendingPathComponent(packages[0]) for name in ["Bom", "PackageInfo", "Payload", "Scripts"] { @@ -129,10 +129,10 @@ public struct PackageImporter { private func convertPackageInfo(package: URL, project: URL, format: BuildInfoFormat) throws { let url = project.appendingPathComponent("PackageInfo") - guard let parser = XMLParser(contentsOf: url) else { throw MunkiPkgError.message("Could not parse \(url.path)") } + guard let parser = XMLParser(contentsOf: url) else { throw SwiftPkgError.importFailed("Could not parse \(url.path)") } let delegate = PackageInfoParser() parser.delegate = delegate - guard parser.parse() else { throw MunkiPkgError.message("Could not parse \(url.path): \(parser.parserError?.localizedDescription ?? "invalid XML")") } + guard parser.parse() else { throw SwiftPkgError.importFailed("Could not parse \(url.path): \(parser.parserError?.localizedDescription ?? "invalid XML")") } let attributes = delegate.attributes var values: [String: Any] = [ "identifier": attributes["identifier"] ?? "", @@ -150,8 +150,13 @@ public struct PackageImporter { private func convertInfoPlist(package: URL, project: URL, format: BuildInfoFormat) throws { let url = package.appendingPathComponent("Contents/Info.plist") - let object = try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) - guard let plist = object as? [String: Any] else { throw MunkiPkgError.message("Could not read \(url.path)") } + let object: Any + do { + object = try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) + } catch { + throw SwiftPkgError.importFailed("Could not read \(url.path): \(error.localizedDescription)") + } + guard let plist = object as? [String: Any] else { throw SwiftPkgError.importFailed("Could not read \(url.path)") } let restart = plist["IFPkgFlagRestartAction"] as? String let action: String if ["RequiredRestart", "RecommendedRestart"].contains(restart) { action = "restart" } diff --git a/swiftpkg/PackageOperationService.swift b/swiftpkg/PackageOperationService.swift index 726dc32..3e67f95 100644 --- a/swiftpkg/PackageOperationService.swift +++ b/swiftpkg/PackageOperationService.swift @@ -35,11 +35,12 @@ public actor PackageOperationService { .importPackage(at: package, to: project, format: format) } + @discardableResult public func buildPackage( in project: URL, options: PackageBuildOptions, reporter: (@Sendable (ConsoleEvent) -> Void)? = nil - ) async throws { + ) async throws -> BuildResult { try await PackageBuildCoordinator(fileManager: fileManager, runner: runner, console: console(reporter: reporter)) .buildPackage(in: project, configuration: options) } diff --git a/swiftpkg/PackageSettingsDraft.swift b/swiftpkg/PackageSettingsDraft.swift index cca82b1..bd7ddc8 100644 --- a/swiftpkg/PackageSettingsDraft.swift +++ b/swiftpkg/PackageSettingsDraft.swift @@ -66,7 +66,7 @@ public struct PackageSettingsDraft: Equatable, Sendable { notarizationTeamID = "" notarizationPassword = "" notarizationKeychainProfile = profile - case nil: + case nil, .invalid?: notarizationMode = .none notarizationAppleID = "" notarizationTeamID = "" @@ -83,14 +83,14 @@ public struct PackageSettingsDraft: Equatable, Sendable { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedIdentifier = identifier.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { throw MunkiPkgError.invalidConfiguration("Package name is required.") } - guard !trimmedIdentifier.isEmpty else { throw MunkiPkgError.invalidConfiguration("Package identifier is required.") } - guard !trimmedVersion.isEmpty else { throw MunkiPkgError.invalidConfiguration("Package version is required.") } + guard !trimmedName.isEmpty else { throw SwiftPkgError.invalidConfiguration("Package name is required.") } + guard !trimmedIdentifier.isEmpty else { throw SwiftPkgError.invalidConfiguration("Package identifier is required.") } + guard !trimmedVersion.isEmpty else { throw SwiftPkgError.invalidConfiguration("Package version is required.") } let signing: SigningConfiguration? if signingEnabled { let identity = signingIdentity.trimmingCharacters(in: .whitespacesAndNewlines) - guard !identity.isEmpty else { throw MunkiPkgError.invalidConfiguration("A signing identity is required when signing is enabled.") } + guard !identity.isEmpty else { throw SwiftPkgError.invalidConfiguration("A signing identity is required when signing is enabled.") } let usesTimestamp: Bool? = switch signingTimestampMode { case .automatic: nil case .enabled: true @@ -115,16 +115,16 @@ public struct PackageSettingsDraft: Equatable, Sendable { notarization = nil case .keychainProfile: guard let profile = optional(notarizationKeychainProfile) else { - throw MunkiPkgError.invalidConfiguration("A keychain profile is required for notarization.") + throw SwiftPkgError.invalidConfiguration("A keychain profile is required for notarization.") } - guard staplingTimeout > 0 else { throw MunkiPkgError.invalidConfiguration("Stapling timeout must be greater than zero.") } + guard staplingTimeout > 0 else { throw SwiftPkgError.invalidConfiguration("Stapling timeout must be greater than zero.") } notarization = NotarizationConfiguration(authentication: .keychainProfile(profile), staplingTimeout: staplingTimeout) case .appleID: guard let appleID = optional(notarizationAppleID), let teamID = optional(notarizationTeamID) else { - throw MunkiPkgError.invalidConfiguration("Apple ID and team ID are required for Apple ID notarization.") + throw SwiftPkgError.invalidConfiguration("Apple ID and team ID are required for Apple ID notarization.") } - guard staplingTimeout > 0 else { throw MunkiPkgError.invalidConfiguration("Stapling timeout must be greater than zero.") } + guard staplingTimeout > 0 else { throw SwiftPkgError.invalidConfiguration("Stapling timeout must be greater than zero.") } let password = optional(notarizationPassword) ?? "" notarization = NotarizationConfiguration( authentication: .appleID(appleID: appleID, teamID: teamID, password: password), diff --git a/swiftpkg/PackageVerifier.swift b/swiftpkg/PackageVerifier.swift new file mode 100644 index 0000000..c8d7fff --- /dev/null +++ b/swiftpkg/PackageVerifier.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Post-build verification: asserts the finished package matches what build-info +/// declared. A belt-and-suspenders companion to the notarization failure checks. +struct PackageVerifier { + let runner: any ProcessRunning + let console: Console + var fileManager: FileManager = .default + + /// - Parameters: + /// - expectedIdentifier: the `identifier` build-info declared. + /// - expectedVersion: the `version` build-info declared. + /// - signed: signing was requested, so a valid signature must be present. + /// - notarized: notarization was requested, so Gatekeeper must accept it. + func verify(package: URL, expectedIdentifier: String, expectedVersion: String, signed: Bool, notarized: Bool) throws { + try verifyMetadata(package: package, expectedIdentifier: expectedIdentifier, expectedVersion: expectedVersion) + if signed { + let result = try runner.run(executable: ToolPaths.pkgutil, arguments: ["--check-signature", package.path]) + guard result.status == 0 else { + throw SwiftPkgError.message("Verification failed: package is not validly signed. \(diagnostics(result))") + } + console.display("Verified package signature") + } + if notarized { + let result = try runner.run(executable: ToolPaths.spctl, arguments: ["-a", "-vvv", "-t", "install", package.path]) + guard result.status == 0 else { + throw SwiftPkgError.message("Verification failed: package does not pass Gatekeeper assessment. \(diagnostics(result))") + } + console.display("Verified Gatekeeper assessment") + } + } + + /// Confirms the built package embeds the identifier and version build-info + /// declared, so a stale or mismatched artifact can't silently pass `--verify`. + /// + /// Best-effort: component packages carry a top-level `PackageInfo`; if it + /// can't be extracted (e.g. a distribution-style package, whose metadata + /// lives elsewhere), the check is skipped rather than failing the build. + private func verifyMetadata(package: URL, expectedIdentifier: String, expectedVersion: String) throws { + let scratch = fileManager.temporaryDirectory.appendingPathComponent("swiftpkg-verify-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: scratch) } + let result = try runner.run(executable: ToolPaths.pkgutil, arguments: ["--expand", package.path, scratch.path]) + guard result.status == 0 else { + throw SwiftPkgError.message("Verification failed: could not expand \(package.lastPathComponent) to inspect its metadata. \(diagnostics(result))") + } + // A component package carries a top-level PackageInfo. If it's absent + // (e.g. a distribution-style package, whose metadata lives elsewhere) + // the metadata check is skipped rather than failing the build. + guard let data = try? Data(contentsOf: scratch.appendingPathComponent("PackageInfo")), + let xml = String(data: data, encoding: .utf8) + else { return } + if let mismatch = Self.metadataMismatch(expectedIdentifier: expectedIdentifier, expectedVersion: expectedVersion, packageInfoXML: xml) { + throw SwiftPkgError.message("Verification failed: \(mismatch)") + } + console.display("Verified package identifier and version") + } + + /// Parses a `PackageInfo` document and returns a human-readable message if + /// its `identifier`/`version` differ from what was expected, else `nil`. + /// Pure and side-effect free so it can be unit-tested without a subprocess. + static func metadataMismatch(expectedIdentifier: String, expectedVersion: String, packageInfoXML: String) -> String? { + let parser = XMLParser(data: Data(packageInfoXML.utf8)) + let delegate = PackageInfoAttributes() + parser.delegate = delegate + guard parser.parse(), let actual = delegate.pkgInfo else { return nil } + // A PackageInfo we could parse but that omits identifier/version is + // incomplete and must not silently pass. + guard let identifier = actual["identifier"] else { + return "package PackageInfo is missing an identifier." + } + if identifier != expectedIdentifier { + return "package identifier is \"\(identifier)\" but build-info declares \"\(expectedIdentifier)\"." + } + guard let version = actual["version"] else { + return "package PackageInfo is missing a version." + } + if version != expectedVersion { + return "package version is \"\(version)\" but build-info declares \"\(expectedVersion)\"." + } + return nil + } + + private func diagnostics(_ result: ProcessResult) -> String { + let text = (result.stderrString + result.stdoutString).trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "(no output)" : text + } +} + +/// Captures the attributes of a `PackageInfo`'s root `pkg-info` element. +private final class PackageInfoAttributes: NSObject, XMLParserDelegate { + private(set) var pkgInfo: [String: String]? + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { + if elementName == "pkg-info", pkgInfo == nil { pkgInfo = attributeDict } + } +} diff --git a/swiftpkg/ProjectOperations.swift b/swiftpkg/ProjectOperations.swift index 3835f6e..4c4bf46 100644 --- a/swiftpkg/ProjectOperations.swift +++ b/swiftpkg/ProjectOperations.swift @@ -18,14 +18,14 @@ public struct ProjectCreator { configuration: PackageConfiguration? = nil ) throws { if fileManager.itemExists(at: project), !force { - throw MunkiPkgError.message("\(project.path) already exists! Use --force to convert it to a project directory.") + throw SwiftPkgError.projectExists("\(project.path) already exists! Use --force to convert it to a project directory.") } if !fileManager.itemExists(at: project) { try fileManager.createDirectory(at: project, withIntermediateDirectories: false) } for directoryName in ["payload", "scripts", "build"] { let directory = project.appendingPathComponent(directoryName, isDirectory: true) - guard !fileManager.itemExists(at: directory) else { throw MunkiPkgError.message("\(directory.path) already exists") } + guard !fileManager.itemExists(at: directory) else { throw SwiftPkgError.projectExists("\(directory.path) already exists") } try fileManager.createDirectory(at: directory, withIntermediateDirectories: false) } try BuildInfoStore.write(configuration ?? .defaults(for: project), to: project, format: format) @@ -49,7 +49,7 @@ public struct BOMMetadataService { public func exportMetadata(from bom: URL, to project: URL) throws { let result = try runner.run(executable: ToolPaths.lsbom, arguments: [bom.path]) guard result.status == 0 else { - throw MunkiPkgError.processFailed(tool: "lsbom", message: result.stderrString.trimmingCharacters(in: .whitespacesAndNewlines)) + throw SwiftPkgError.processFailed(tool: "lsbom", message: result.stderrString.trimmingCharacters(in: .whitespacesAndNewlines)) } try result.stdout.write(to: project.appendingPathComponent("Bom.txt"), options: .atomic) } @@ -66,7 +66,7 @@ public struct BOMMetadataService { public func synchronizeMetadataFromBOM(in project: URL, requestedFormat: BuildInfoFormat?) throws { let bom = project.appendingPathComponent("Bom.txt") let payload = project.appendingPathComponent("payload", isDirectory: true) - guard fileManager.itemExists(at: bom) else { throw MunkiPkgError.message("Can't sync with bom info: no Bom.txt found in project directory.") } + guard fileManager.itemExists(at: bom) else { throw SwiftPkgError.message("Can't sync with bom info: no Bom.txt found in project directory.") } let packageConfiguration = (try? BuildInfoStore.load(from: project, requestedFormat: requestedFormat)) ?? .defaults(for: project) let isRoot = geteuid() == 0 if packageConfiguration.ownership != .recommended, !isRoot { @@ -97,7 +97,7 @@ public struct BOMMetadataService { changes += 1 continue } else { - throw MunkiPkgError.message("File \(target.path) is missing in payload") + throw SwiftPkgError.message("File \(target.path) is missing in payload") } if isRoot, fileStatus.st_uid != metadata.owner || fileStatus.st_gid != metadata.group { console.display("Changing user/group of \(target.path) to \(metadata.owner)/\(metadata.group)") @@ -135,12 +135,12 @@ private struct BOMEntry { init(parsing line: String, lineNumber: Int) throws { let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) - guard fields.count >= 3 else { throw MunkiPkgError.message("Malformed Bom.txt row \(lineNumber): expected path, mode, and owner/group") } + guard fields.count >= 3 else { throw SwiftPkgError.message("Malformed Bom.txt row \(lineNumber): expected path, mode, and owner/group") } var path = fields[0] if path.hasPrefix("./") { path.removeFirst(2) } let ownerGroup = fields[2].split(separator: "/", omittingEmptySubsequences: false) guard ownerGroup.count == 2, let owner = uid_t(ownerGroup[0]), let group = gid_t(ownerGroup[1]), let mode = mode_t(String(fields[1].suffix(4)), radix: 8) else { - throw MunkiPkgError.message("Malformed Bom.txt metadata on row \(lineNumber)") + throw SwiftPkgError.message("Malformed Bom.txt metadata on row \(lineNumber)") } relativePath = path self.mode = mode @@ -150,6 +150,6 @@ private struct BOMEntry { } } -private func posixError(_ action: String, path: String) -> MunkiPkgError { +private func posixError(_ action: String, path: String) -> SwiftPkgError { .message("\(action) for \(path): \(String(cString: strerror(errno)))") } diff --git a/swiftpkg/Provenance.swift b/swiftpkg/Provenance.swift new file mode 100644 index 0000000..a2313b2 --- /dev/null +++ b/swiftpkg/Provenance.swift @@ -0,0 +1,132 @@ +import CryptoKit +import Foundation + +/// Build attestation written next to the package as `.provenance.json`. +public struct Provenance: Codable, Sendable, Equatable { + public let tool: String + public let toolVersion: String + public let builtAt: String + public let name: String + public let version: String + public let identifier: String + public let pkgPath: String + public let sha256: String + public let inputDigest: String + public let gitCommit: String? + public let gitRemote: String? + + enum CodingKeys: String, CodingKey { + case tool + case toolVersion = "tool_version" + case builtAt = "built_at" + case name, version, identifier + case pkgPath = "pkg_path" + case sha256 + case inputDigest = "input_digest" + case gitCommit = "git_commit" + case gitRemote = "git_remote" + } + + public func jsonString() throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return String(decoding: try encoder.encode(self), as: UTF8.self) + } +} + +/// Assembles a `Provenance` from the project, its inputs, and git metadata. +public struct ProvenanceBuilder { + private let runner: any ProcessRunning + private let fileManager: FileManager + + public init(runner: any ProcessRunning, fileManager: FileManager = .default) { + self.runner = runner + self.fileManager = fileManager + } + + public func build(configuration: PackageConfiguration, output: URL, project: URL, now: Date = Date()) throws -> Provenance { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return Provenance( + tool: "swiftpkg", + toolVersion: swiftpkgVersion, + builtAt: formatter.string(from: now), + name: configuration.name, + version: configuration.version, + identifier: configuration.identifier, + pkgPath: output.path, + sha256: try provenanceSHA256(ofFileAt: output), + inputDigest: try inputDigest(for: project), + gitCommit: gitOutput(["-C", project.path, "rev-parse", "HEAD"], in: project), + gitRemote: gitOutput(["-C", project.path, "remote", "get-url", "origin"], in: project).map(Self.sanitizedRemote) + ) + } + + /// Deterministic digest of the build inputs (payload, scripts, build-info), + /// hashing each file's project-relative path and contents in sorted order. + private func inputDigest(for project: URL) throws -> String { + var entries: [(path: String, url: URL)] = [] + for subdirectory in ["payload", "scripts"] { + let directory = project.appendingPathComponent(subdirectory, isDirectory: true) + guard fileManager.directoryExists(at: directory) else { continue } + guard let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else { continue } + for case let fileURL as URL in enumerator { + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values?.isRegularFile == true || values?.isSymbolicLink == true else { continue } + entries.append((relativePath(of: fileURL, under: project), fileURL)) + } + } + for name in ["build-info.plist", "build-info.json", "build-info.yaml", "build-info.yml"] { + let url = project.appendingPathComponent(name) + if fileManager.fileExists(atPath: url.path) { entries.append((name, url)) } + } + entries.sort { $0.path < $1.path } + + var hasher = SHA256() + for entry in entries { + hasher.update(data: Data(entry.path.utf8)) + hasher.update(data: Data([0])) + let mode = (try fileManager.attributesOfItem(atPath: entry.url.path)[.posixPermissions] as? NSNumber)?.uint16Value ?? 0 + hasher.update(data: withUnsafeBytes(of: (mode & 0o7777).littleEndian) { Data($0) }) + if let destination = try? fileManager.destinationOfSymbolicLink(atPath: entry.url.path) { + hasher.update(data: Data(destination.utf8)) + } else { + hasher.update(data: try Data(contentsOf: entry.url)) + } + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private func relativePath(of url: URL, under project: URL) -> String { + let base = project.standardizedFileURL.path + let path = url.standardizedFileURL.path + return path.hasPrefix(base + "/") ? String(path.dropFirst(base.count + 1)) : url.lastPathComponent + } + + private func gitOutput(_ arguments: [String], in project: URL) -> String? { + guard let result = try? runner.run(executable: ToolPaths.git, arguments: arguments), result.status == 0 else { return nil } + let trimmed = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Removes `user:pass@` userinfo from a remote URL before recording it. + static func sanitizedRemote(_ remote: String) -> String { + guard let schemeRange = remote.range(of: "://") else { return remote } + let authorityAndPath = remote[schemeRange.upperBound...] + guard let at = authorityAndPath.firstIndex(of: "@") else { return remote } + let firstSlash = authorityAndPath.firstIndex(of: "/") ?? authorityAndPath.endIndex + guard at < firstSlash else { return remote } + return String(remote[.. String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { + hasher.update(data: chunk) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() +} diff --git a/swiftpkg/Support.swift b/swiftpkg/Support.swift index 847cd9c..a6c888d 100644 --- a/swiftpkg/Support.swift +++ b/swiftpkg/Support.swift @@ -1,19 +1,49 @@ import Darwin import Foundation -public enum MunkiPkgError: Error, CustomStringConvertible, LocalizedError { +public enum SwiftPkgError: Error, CustomStringConvertible, LocalizedError { case message(String) case invalidConfiguration(String) case processFailed(tool: String, message: String) + case projectExists(String) + case importFailed(String) + case notarizationFailed(String) public var description: String { switch self { - case let .message(value), let .invalidConfiguration(value): value + case let .message(value), + let .invalidConfiguration(value), + let .projectExists(value), + let .importFailed(value), + let .notarizationFailed(value): + value case let .processFailed(tool, message): "\(tool): \(message)" } } public var errorDescription: String? { description } + + /// Process exit code for this error class. `0` is reserved for success and + /// `64` (EX_USAGE) for command-line usage errors; `6` is reserved for a + /// future dedicated signing-failure class. See the README exit-code table. + public var exitCode: Int32 { + switch self { + case .message: 1 + case .projectExists: 2 + case .invalidConfiguration: 3 + case .importFailed: 4 + case .processFailed: 5 + case .notarizationFailed: 7 + } + } +} + +/// Exit code for a command-line usage/parse error (sysexits.h EX_USAGE). +public let usageErrorExitCode: Int32 = 64 + +/// Maps any thrown error to a process exit code, defaulting unknown errors to 1. +public func exitCode(for error: any Error) -> Int32 { + (error as? SwiftPkgError)?.exitCode ?? 1 } public struct ProcessResult: Equatable, Sendable { @@ -43,12 +73,26 @@ public protocol ProcessControlling: Sendable { func cancel() } +public extension ProcessResult { + /// The tool's own account of a failure, appended to `fallback`. Tools explain + /// themselves better than we can from the outside — notarytool, for one, names + /// the missing keychain profile and the command that creates it — so prefer + /// their words. stdout is a fallback for tools that report failures there; + /// when neither says anything, `fallback` stands alone. + func failureDetail(fallback: String) -> String { + let detail = [stderrString, stdoutString] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + guard let detail else { return fallback } + return "\(fallback) \(detail)" + } +} + public extension ProcessRunning { func runSuccessfully(executable: String, arguments: [String], failureMessage: String) throws { let result = try run(executable: executable, arguments: arguments) guard result.status == 0 else { - let details = result.stderrString.trimmingCharacters(in: .whitespacesAndNewlines) - throw MunkiPkgError.processFailed(tool: URL(fileURLWithPath: executable).lastPathComponent, message: details.isEmpty ? failureMessage : "\(failureMessage) \(details)") + throw SwiftPkgError.processFailed(tool: URL(fileURLWithPath: executable).lastPathComponent, message: result.failureDetail(fallback: failureMessage)) } } } @@ -76,13 +120,38 @@ public final class SystemProcessRunner: ProcessRunning, ProcessControlling, @unc do { try process.run() } catch { - throw MunkiPkgError.message("\(URL(fileURLWithPath: executable).lastPathComponent) execution failed: \(error.localizedDescription)") + throw SwiftPkgError.message("\(URL(fileURLWithPath: executable).lastPathComponent) execution failed: \(error.localizedDescription)") + } + + // Drain stdout and stderr concurrently on background queues *before* + // waiting. Reading only after waitUntilExit() (or draining the pipes + // sequentially) deadlocks: a child that writes more than the ~64KB pipe + // buffer to either stream blocks on write(), never exits, and the wait + // hangs forever. Each field is written exactly once by its own closure; + // the parent reads the box only after group.wait(), so there is no + // concurrent mutation. + final class DataBox: @unchecked Sendable { + var stdout = Data() + var stderr = Data() + } + let box = DataBox() + let group = DispatchGroup() + group.enter() + DispatchQueue.global(qos: .utility).async { + box.stdout = stdout.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + group.enter() + DispatchQueue.global(qos: .utility).async { + box.stderr = stderr.fileHandleForReading.readDataToEndOfFile() + group.leave() } process.waitUntilExit() + group.wait() return ProcessResult( status: process.terminationStatus, - stdout: stdout.fileHandleForReading.readDataToEndOfFile(), - stderr: stderr.fileHandleForReading.readDataToEndOfFile() + stdout: box.stdout, + stderr: box.stderr ) } @@ -138,6 +207,8 @@ enum ToolPaths { static let pkgutil = "/usr/sbin/pkgutil" static let productbuild = "/usr/bin/productbuild" static let xcrun = "/usr/bin/xcrun" + static let git = "/usr/bin/git" + static let spctl = "/usr/sbin/spctl" } extension FileManager { diff --git a/swiftpkgCLI/CLI.swift b/swiftpkgCLI/CLI.swift index c8e92a9..be4e375 100644 --- a/swiftpkgCLI/CLI.swift +++ b/swiftpkgCLI/CLI.swift @@ -2,6 +2,12 @@ import ArgumentParser import Foundation import SwiftPkgCore +/// Format for the machine/human result printed to stdout after a build. +public enum BuildManifestFormat: String, CaseIterable, Sendable, ExpressibleByArgument { + case text + case json +} + public struct CLIOptions: ParsableArguments { @Flag(name: .long, help: "Create a new empty project with default settings.") public var create = false @@ -21,6 +27,9 @@ public struct CLIOptions: ParsableArguments { @Flag(name: .long, help: "Apply Bom.txt metadata without building.") public var sync = false + @Flag(name: .long, help: "Validate the project (build-info, name, scripts, signing coherence) without building. Exits non-zero on any error. Fast pre-check for PR CI.") + public var lint = false + @Flag(name: .long, help: "Inhibit status messages on stdout.") public var quiet = false @@ -36,9 +45,36 @@ public struct CLIOptions: ParsableArguments { @Flag(name: .long, help: "Skip stapling after notarization.") public var skipStapling = false + @Option(name: .long, help: "Path to a .env file of build-time variables substituted into ${VAR} placeholders in scripts. Auto-detects .env in the project if omitted. Values are embedded as plain text in the .pkg — do NOT use for secrets.") + public var envFile: String? + + @Flag(name: .long, help: "Fail the build if a script references a ${VAR} with no matching variable (default: warn).") + public var strictEnv = false + + @Flag(name: .long, help: "Do not merge SWIFTPKG_* variables from the calling process environment.") + public var noInheritEnv = false + + @Flag(name: .long, help: "Write a .provenance.json sidecar (tool version, build time, git commit/remote, input digest, package hash) for supply-chain attestation.") + public var provenance = false + + @Flag(name: .long, help: "After building, verify the package matches build-info: signature present when signing was requested (pkgutil), Gatekeeper-accepted when notarized (spctl). Fails the build on mismatch.") + public var verify = false + + @Flag(name: .long, help: "Accepted for munki-pkg compatibility; ignored. swiftpkg never prompts to import into a repo, so there is nothing to skip.") + public var skipImport = false + @Flag(name: .long, help: "Show program's version number and exit.") public var version = false + @Option(name: .long, help: "Result printed to stdout after a build: 'text' (default) or 'json' (machine-readable manifest). json implies quiet human output so stdout carries only the manifest.") + public var outputFormat: BuildManifestFormat = .text + + @Option(name: .long, help: "Override the build-info version (e.g. from a git tag or CI variable). Resolved before ${version} substitution.") + public var pkgVersion: String? + + @Option(name: .long, help: "Write the built package to this directory instead of the project's build/ directory. Created if it does not exist.") + public var outputDir: String? + @Argument(help: "The package project directory.") public var projectDirectory: String? @@ -57,6 +93,7 @@ public enum CLICommand { case create(project: URL, format: BuildInfoFormat, force: Bool) case `import`(package: URL, project: URL, format: BuildInfoFormat) case synchronize(project: URL, requestedFormat: BuildInfoFormat?) + case lint(project: URL, requestedFormat: BuildInfoFormat?) case build(project: URL, configuration: PackageBuildOptions) /// Resolves a parsed option set into one executable command. @@ -76,6 +113,7 @@ public enum CLICommand { ) } if options.sync { return .synchronize(project: project, requestedFormat: requestedFormat) } + if options.lint { return .lint(project: project, requestedFormat: requestedFormat) } return .build( project: project, configuration: PackageBuildOptions( @@ -84,14 +122,21 @@ public enum CLICommand { isQuiet: options.quiet, skipsSigning: options.skipSigning, skipsNotarization: options.skipNotarization, - skipsStapling: options.skipStapling + skipsStapling: options.skipStapling, + envFile: options.envFile, + strictEnvironment: options.strictEnv, + inheritsEnvironment: !options.noInheritEnv, + writesProvenance: options.provenance, + verifies: options.verify, + versionOverride: options.pkgVersion, + outputDirectory: options.outputDir.map { URL(fileURLWithPath: $0).standardizedFileURL } ) ) } private static func requestedFormat(from options: CLIOptions) throws -> BuildInfoFormat? { guard !(options.json && options.yaml) else { - throw MunkiPkgError.invalidConfiguration("Only a single build-info file can be built at a time!") + throw SwiftPkgError.invalidConfiguration("Only a single build-info file can be built at a time!") } if options.json { return .json } if options.yaml { return .yaml } @@ -114,11 +159,21 @@ public enum CLIParser { --yaml Create build-info in YAML format. --export-bom-info Export the built package's Bom.txt. --sync Apply Bom.txt metadata without building. + --lint Validate the project without building. --quiet Inhibit status messages on stdout. -f, --force Convert an existing directory to a project. --skip-signing Skip configured package signing. --skip-notarization Skip configured notarization. --skip-stapling Skip stapling after notarization. + --env-file PATH Substitute ${VAR} from a .env file into scripts. + --strict-env Fail on unresolved ${VAR} placeholders. + --no-inherit-env Don't merge SWIFTPKG_* from the environment. + --provenance Write a .provenance.json attestation sidecar. + --verify Verify the built package matches build-info. + --output-format FORMAT Build result on stdout: text (default) or json. + --skip-import Accepted for munki-pkg compatibility; ignored. + --pkg-version VERSION Override the build-info version. + --output-dir DIR Write the package to DIR instead of build/. """ public static func parse(_ arguments: [String]) -> CLIParseResult { diff --git a/swiftpkgCLI/SwiftPkg.swift b/swiftpkgCLI/SwiftPkg.swift index dab615e..c13f8c3 100644 --- a/swiftpkgCLI/SwiftPkg.swift +++ b/swiftpkgCLI/SwiftPkg.swift @@ -16,20 +16,26 @@ public enum SwiftPkg { return 0 case .failure(let message): FileHandle.standardError.write(Data(("ERROR: \(message)\n").utf8)) - return 255 + return usageErrorExitCode case .options(let options): let command: CLICommand? do { command = try CLICommand.resolve(from: options) } catch { consoleError(String(describing: error)) - return 255 + return exitCode(for: error) } guard let command else { print(CLIParser.usage) return 0 } - let console = Console(quiet: options.quiet) + // json output reserves stdout for the manifest, so suppress human + // status — but only for builds, which are the only command that + // emits a manifest. Other commands keep their normal output. + // warnings/errors still go to stderr via Console. + let isJSONBuild: Bool + if case .build = command { isJSONBuild = options.outputFormat == .json } else { isJSONBuild = false } + let console = Console(quiet: options.quiet || isJSONBuild) do { switch command { case let .create(project, format, force): @@ -40,25 +46,40 @@ public enum SwiftPkg { .importPackage(at: package, to: project, format: format) case let .synchronize(project, requestedFormat): guard fileManager.directoryExists(at: project) else { - throw MunkiPkgError.message(fileManager.itemExists(at: project) + throw SwiftPkgError.message(fileManager.itemExists(at: project) ? "\(project.path) is not a directory." : "\(project.path): Project not found.") } try BOMMetadataService(fileManager: fileManager, runner: runner, console: console) .synchronizeMetadataFromBOM(in: project, requestedFormat: requestedFormat) + case let .lint(project, requestedFormat): + let findings = try Linter(fileManager: fileManager).lint(project: project, requestedFormat: requestedFormat) + for finding in findings { + FileHandle.standardError.write(Data("\(finding.severity.rawValue): \(finding.message)\n".utf8)) + } + let errors = findings.filter { $0.severity == .error }.count + if errors > 0 { + FileHandle.standardError.write(Data("lint failed: \(errors) error(s), \(findings.count - errors) warning(s)\n".utf8)) + return 1 + } + console.display("lint passed (\(findings.count) warning(s))") + return 0 case let .build(project, configuration): guard fileManager.directoryExists(at: project) else { - throw MunkiPkgError.message(fileManager.itemExists(at: project) + throw SwiftPkgError.message(fileManager.itemExists(at: project) ? "\(project.path) is not a directory." : "\(project.path): Project not found.") } - try await PackageBuildCoordinator(fileManager: fileManager, runner: runner, console: console) + let result = try await PackageBuildCoordinator(fileManager: fileManager, runner: runner, console: console) .buildPackage(in: project, configuration: configuration) + if options.outputFormat == .json { + print(try result.jsonString()) + } } return 0 } catch { console.error(String(describing: error)) - return 255 + return exitCode(for: error) } } } diff --git a/swiftpkgTests/BuildInfoTests.swift b/swiftpkgTests/BuildInfoTests.swift index 1496f0e..4f2d435 100644 --- a/swiftpkgTests/BuildInfoTests.swift +++ b/swiftpkgTests/BuildInfoTests.swift @@ -8,7 +8,7 @@ struct BuildInfoTests { let configuration = PackageConfiguration.defaults(for: URL(fileURLWithPath: "/tmp/My Project")) #expect(configuration.name == "MyProject-${version}.pkg") - #expect(configuration.identifier == "com.github.munki.pkg.MyProject") + #expect(configuration.identifier == "org.swiftpkg.pkg.MyProject") #expect(configuration.version == "1.0") #expect(configuration.ownership == .recommended) #expect(!configuration.usesDistributionStyle) diff --git a/swiftpkgTests/BuildResultTests.swift b/swiftpkgTests/BuildResultTests.swift new file mode 100644 index 0000000..fbb8d5b --- /dev/null +++ b/swiftpkgTests/BuildResultTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct BuildResultTests { + + @Test("sha256Hex matches the known vector for \"abc\"") + func sha256KnownVector() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let file = temp.url.appendingPathComponent("abc.bin") + try write("abc", to: file) + let digest = try sha256Hex(ofFileAt: file) + #expect(digest == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + } + + @Test("sha256Hex handles files that span multiple read chunks") + func sha256LargeFile() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let file = temp.url.appendingPathComponent("big.bin") + try Data(repeating: 0x61, count: 3 * (1 << 20) + 7).write(to: file) + let hex = try sha256Hex(ofFileAt: file) + // Known digest of 3 MiB + 7 bytes of 0x61, so a chunk-boundary bug in the + // streaming hash would change the value, not just the shape. + #expect(hex == "7d8aedf62548b6943c912ca5192d7a2c13322cd689d7a47d4b3944b4bb2e30c6") + } + + @Test("manifest JSON uses snake_case pkg_path and round-trips") + func jsonRoundTrip() throws { + let result = BuildResult( + name: "App-1.0.pkg", version: "1.0", identifier: "com.example.app", + pkgPath: "/tmp/App-1.0.pkg", sha256: "deadbeef", + signed: true, notarized: false, stapled: false + ) + let json = try result.jsonString() + #expect(json.contains("\"pkg_path\"")) + #expect(!json.contains("\"pkgPath\"")) + let decoded = try JSONDecoder().decode(BuildResult.self, from: Data(json.utf8)) + #expect(decoded == result) + } +} diff --git a/swiftpkgTests/CLITests.swift b/swiftpkgTests/CLITests.swift index feed2cd..a7b69ab 100644 --- a/swiftpkgTests/CLITests.swift +++ b/swiftpkgTests/CLITests.swift @@ -44,6 +44,20 @@ struct CLITests { #expect(options.importPackage == "existing.pkg") } + @Test("accepts --skip-import as an ignored no-op and still builds") + func acceptsSkipImport() throws { + guard case .options(let options) = CLIParser.parse(["--skip-import", "Project"]) else { + Issue.record("Expected options result") + return + } + #expect(options.skipImport) + // The flag must not divert away from a normal build. + guard case .build = try #require(try CLICommand.resolve(from: options)) else { + Issue.record("Expected a build command") + return + } + } + @Test("reports missing import argument") func reportsMissingImportArgument() { guard case .failure(let message) = CLIParser.parse(["--import"]) else { @@ -95,7 +109,7 @@ struct CLITests { } private func parsedOptions(_ arguments: [String]) throws -> CLIOptions { - guard case let .options(options) = CLIParser.parse(arguments) else { throw MunkiPkgError.message("Expected CLI options") } + guard case let .options(options) = CLIParser.parse(arguments) else { throw SwiftPkgError.message("Expected CLI options") } return options } } diff --git a/swiftpkgTests/DynamicVersionTests.swift b/swiftpkgTests/DynamicVersionTests.swift new file mode 100644 index 0000000..4eee443 --- /dev/null +++ b/swiftpkgTests/DynamicVersionTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct DynamicVersionTests { + + /// 2026-07-18 14:05:30 in the current time zone, so formatting round-trips. + private static func fixedDate() -> Date { + var components = DateComponents() + components.year = 2026; components.month = 7; components.day = 18 + components.hour = 14; components.minute = 5; components.second = 30 + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar.date(from: components)! + } + + @Test("each token resolves to its documented format") + func tokensResolve() { + let now = Self.fixedDate() + #expect(DynamicVersion.resolve("${TIMESTAMP}", now: now) == "2026.07.18.1405") + #expect(DynamicVersion.resolve("${DATE}", now: now) == "2026.07.18") + #expect(DynamicVersion.resolve("${DATETIME}", now: now) == "2026.07.18.140530") + #expect(DynamicVersion.resolve("1.2.${DATE}", now: now) == "1.2.2026.07.18") + #expect(DynamicVersion.resolve("static", now: now) == "static") + } + + @Test("resolvingDynamicVersion feeds the resolved version into ${version} in name") + func resolvedVersionFlowsIntoName() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let project = temp.url.appendingPathComponent("Dyn", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: false) + let template = try PackageConfiguration( + values: ["name": "Dyn-${version}.pkg", "identifier": "com.example.dyn", "version": "${DATE}"], + defaults: .defaults(for: project) + ) + let resolved = template.resolvingDynamicVersion(now: Self.fixedDate()).substitutingVersion() + #expect(resolved.version == "2026.07.18") + #expect(resolved.name == "Dyn-2026.07.18.pkg") + } +} diff --git a/swiftpkgTests/EnvLoaderTests.swift b/swiftpkgTests/EnvLoaderTests.swift new file mode 100644 index 0000000..0acbd39 --- /dev/null +++ b/swiftpkgTests/EnvLoaderTests.swift @@ -0,0 +1,279 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct EnvLoaderTests { + + private func writeEnv(_ contents: String) throws -> (TemporaryDirectory, String) { + let temp = try TemporaryDirectory() + let path = temp.url.appendingPathComponent(".env").path + try write(contents, to: URL(fileURLWithPath: path)) + return (temp, path) + } + + @Test("parses key=value, strips quotes, skips comments and blanks") + func parsesBasics() throws { + let (temp, path) = try writeEnv(""" + # comment + SERVER=https://example.com + + NAME="Quoted Value" + SINGLE='single' + EMPTY= + """) + defer { temp.remove() } + let vars = try EnvLoader.load(from: path) + #expect(vars["SERVER"] == "https://example.com") + #expect(vars["NAME"] == "Quoted Value") + #expect(vars["SINGLE"] == "single") + #expect(vars["EMPTY"] == "") + #expect(vars.count == 4) + } + + @Test("skips entries with invalid keys") + func skipsInvalidKeys() throws { + let (temp, path) = try writeEnv("9BAD=x\nGO OD=y\nOKAY=z\n") + defer { temp.remove() } + let vars = try EnvLoader.load(from: path) + #expect(vars == ["OKAY": "z"]) + } + + @Test("rejects a file over the size limit") + func rejectsOversized() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let path = temp.url.appendingPathComponent(".env").path + try write(String(repeating: "A=B\n", count: EnvLoader.maxFileSize), to: URL(fileURLWithPath: path)) + #expect(throws: SwiftPkgError.self) { try EnvLoader.load(from: path) } + } + + @Test("missing file yields no variables") + func missingFile() throws { + #expect(try EnvLoader.load(from: "/no/such/.env").isEmpty) + } + + @Test("merge: .env wins over inherited SWIFTPKG_*, and inherit can be disabled") + func mergePrecedence() { + let environment = ["SWIFTPKG_A": "sys", "SWIFTPKG_B": "sysB", "OTHER": "ignored"] + let merged = EnvLoader.merge(fileVariables: ["SWIFTPKG_A": "file", "C": "c"], inheritsEnvironment: true, environment: environment) + #expect(merged["SWIFTPKG_A"] == "file") // file wins + #expect(merged["SWIFTPKG_B"] == "sysB") + #expect(merged["C"] == "c") + #expect(merged["OTHER"] == nil) // only SWIFTPKG_* inherited + + let noInherit = EnvLoader.merge(fileVariables: ["C": "c"], inheritsEnvironment: false, environment: environment) + #expect(noInherit == ["C": "c"]) + } +} + +struct PlaceholderReplacerTests { + + @Test("substitutes known placeholders and reports unresolved ones") + func substitutesAndReports() { + let result = PlaceholderReplacer.replace(in: "url=${SERVER} id=${MISSING}", with: ["SERVER": "x"]) + #expect(result.content == "url=x id=${MISSING}") + #expect(result.unresolved == ["MISSING"]) + } + + @Test("single pass: a substituted value is not re-expanded") + func singlePass() { + let result = PlaceholderReplacer.replace(in: "${A}", with: ["A": "${B}", "B": "leaked"]) + #expect(result.content == "${B}") // not "leaked" + } + + @Test("values with shell metacharacters are spliced verbatim") + func verbatimSplice() { + let result = PlaceholderReplacer.replace(in: "run ${X}", with: ["X": "$(whoami)"]) + #expect(result.content == "run $(whoami)") + } + + @Test("reports the names it replaced, not the ones it was offered") + func reportsSubstitutedNames() { + let result = PlaceholderReplacer.replace(in: "url=${SERVER}", with: ["SERVER": "x", "UNUSED": "y"]) + #expect(result.substituted == ["SERVER"]) + } + + @Test("a script with no placeholders substitutes nothing") + func noPlaceholdersSubstitutesNothing() { + let result = PlaceholderReplacer.replace(in: "echo hello", with: ["SERVER": "x"]) + #expect(result.substituted.isEmpty) + } +} + +struct ScriptEnvironmentTests { + + @Test("processes scripts into a 0700 temp dir with substituted values") + func processesScripts() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let scripts = temp.url.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: false) + try write("#!/bin/sh\necho ${SERVER}\n", to: scripts.appendingPathComponent("postinstall")) + let tempDir = temp.url.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: false) + + let processed = try #require(try ScriptEnvironment.process(scriptsDir: scripts, into: tempDir, with: ["SERVER": "https://ecu.example"])) + let content = try String(contentsOf: processed.directory.appendingPathComponent("postinstall"), encoding: .utf8) + #expect(content.contains("echo https://ecu.example")) + #expect(!content.contains("${SERVER}")) + + let perms = (try FileManager.default.attributesOfItem(atPath: processed.directory.appendingPathComponent("postinstall").path)[.posixPermissions] as? NSNumber)?.intValue ?? 0 + #expect(perms & 0o077 == 0) // no group/other bits + #expect(perms & 0o100 != 0) // owner-executable + } + + @Test("returns nil when there are no variables") + func noVariables() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let scripts = temp.url.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: false) + try write("#!/bin/sh\n", to: scripts.appendingPathComponent("postinstall")) + let result = try ScriptEnvironment.process(scriptsDir: scripts, into: temp.url, with: [:]) + #expect(result == nil) + } + + @Test("accounts for what was substituted, per script and across the project") + func accountsForSubstitutions() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let scripts = temp.url.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: false) + try write("#!/bin/sh\necho ${SERVER}\n", to: scripts.appendingPathComponent("preinstall")) + try write("#!/bin/sh\necho ${SERVER} ${ORG}\n", to: scripts.appendingPathComponent("postinstall")) + let tempDir = temp.url.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: false) + + let processed = try #require(try ScriptEnvironment.process( + scriptsDir: scripts, + into: tempDir, + with: ["SERVER": "https://ecu.example", "ORG": "ecuad", "UNUSED": "never referenced"] + )) + #expect(processed.substituted["preinstall"] == ["SERVER"]) + #expect(processed.substituted["postinstall"] == ["SERVER", "ORG"]) + #expect(processed.substitutedNames == ["SERVER", "ORG"]) // UNUSED is not counted + } + + @Test("a variable no script references is not counted as applied") + func unreferencedVariableIsNotApplied() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let scripts = temp.url.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: false) + try write("#!/bin/sh\necho hello\n", to: scripts.appendingPathComponent("postinstall")) + let tempDir = temp.url.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: false) + + let processed = try #require(try ScriptEnvironment.process(scriptsDir: scripts, into: tempDir, with: ["SERVER": "x"])) + #expect(processed.substituted.isEmpty) + #expect(processed.substitutedNames.isEmpty) + } +} + +struct ShellOwnedPlaceholderTests { + + /// The shape that produced the false positive in production: a helper whose + /// parameters are shell locals expanded with `${...}` at install time. + private static let plistHelper = """ + #!/bin/zsh + plist_set() { + local key="$1" type="$2" val="$3" + if $PB -c "Set :${key} ${val}" "$PLIST" 2>/dev/null; then + return 0 + fi + $PB -c "Add :${key} ${type} ${val}" "$PLIST" + } + plist_set ApiUrl string ${REPORTMATE_API_URL} + """ + + @Test("a helper's shell locals are not reported as unresolved placeholders") + func shellLocalsAreNotPlaceholders() { + let unresolved = ScriptEnvironment.reportableUnresolved( + in: Self.plistHelper, + with: ["REPORTMATE_API_URL": "https://ecu.example"] + ) + #expect(unresolved.isEmpty) + } + + @Test("a genuinely missing build variable is still reported") + func missingBuildVariableStillWarns() { + let unresolved = ScriptEnvironment.reportableUnresolved(in: Self.plistHelper, with: [:]) + #expect(unresolved == ["REPORTMATE_API_URL"]) + } + + @Test("recognises every form that introduces a shell name") + func recognisesDeclarationForms() { + let script = """ + #!/bin/bash + plain=1 + local scoped="x" + export -f exported=2 + declare -i counted=3 + readonly frozen=4 + typeset typed=5 + bare_local() { local declared; } + for item in a b c; do echo "${item}"; done + read -r answer + """ + let owned = ScriptEnvironment.shellOwnedNames(in: script) + for name in ["plain", "scoped", "exported", "counted", "frozen", "typed", "declared", "item", "answer"] { + #expect(owned.contains(name), "expected \(name) to be recognised as shell-owned") + } + } + + @Test("environment-supplied names are not reported as unresolved placeholders") + func environmentNamesAreNotPlaceholders() { + let script = """ + #!/bin/sh + echo "${HOME}" "${PATH}" "${USER}" "${TMPDIR}" + cp "$1" "${DSTVOLUME}/opt" + """ + #expect(ScriptEnvironment.reportableUnresolved(in: script, with: [:]).isEmpty) + } + + @Test("an environment name does not mask a genuinely missing variable beside it") + func environmentNamesDoNotMaskMissingOnes() { + let script = """ + #!/bin/sh + echo "${HOME}/${API_TOKEN}" + """ + #expect(ScriptEnvironment.reportableUnresolved(in: script, with: [:]) == ["API_TOKEN"]) + } + + @Test("an environment name supplied as a build variable is still substituted") + func environmentNameStillSubstitutes() { + let result = PlaceholderReplacer.replace(in: "echo ${TMPDIR}\n", with: ["TMPDIR": "/staged"]) + #expect(result.content == "echo /staged\n") + #expect(result.substituted == ["TMPDIR"]) + } + + @Test("suppression is per-script, not global") + func suppressionIsPerScript() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let scripts = temp.url.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: false) + try write("#!/bin/sh\nlocal token=1\necho ${token}\n", to: scripts.appendingPathComponent("preinstall")) + try write("#!/bin/sh\necho ${token}\n", to: scripts.appendingPathComponent("postinstall")) + + let byScript = ScriptEnvironment.unresolvedPlaceholders(in: scripts, given: [:]) + #expect(byScript["preinstall"] == nil) + #expect(byScript["postinstall"] == ["token"]) + } + + @Test("substitution is unaffected by the reporting filter") + func substitutionUnchanged() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let scripts = temp.url.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: false) + try write("#!/bin/sh\nlocal key=1\necho ${key} ${SERVER}\n", to: scripts.appendingPathComponent("postinstall")) + let tempDir = temp.url.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: false) + + let processed = try #require(try ScriptEnvironment.process(scriptsDir: scripts, into: tempDir, with: ["SERVER": "https://ecu.example"])) + let content = try String(contentsOf: processed.directory.appendingPathComponent("postinstall"), encoding: .utf8) + #expect(content.contains("echo ${key} https://ecu.example")) + #expect(processed.unresolved.isEmpty) + } +} diff --git a/swiftpkgTests/ExitCodeTests.swift b/swiftpkgTests/ExitCodeTests.swift new file mode 100644 index 0000000..5d1ba4f --- /dev/null +++ b/swiftpkgTests/ExitCodeTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import SwiftPkgCore +@testable import swiftpkg + +struct ExitCodeTests { + + @Test("each error class maps to its documented exit code") + func errorExitCodes() { + #expect(SwiftPkgError.message("x").exitCode == 1) + #expect(SwiftPkgError.projectExists("x").exitCode == 2) + #expect(SwiftPkgError.invalidConfiguration("x").exitCode == 3) + #expect(SwiftPkgError.importFailed("x").exitCode == 4) + #expect(SwiftPkgError.processFailed(tool: "t", message: "m").exitCode == 5) + #expect(SwiftPkgError.notarizationFailed("x").exitCode == 7) + } + + @Test("unknown errors default to exit 1") + func unknownErrorDefault() { + struct Other: Error {} + #expect(exitCode(for: Other()) == 1) + #expect(exitCode(for: SwiftPkgError.notarizationFailed("x")) == 7) + } + + // End-to-end through the CLI entry point. + @Test("create on an existing directory exits 2") + func createExistingExitsTwo() async throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let project = temp.url.appendingPathComponent("Existing", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: false) + let code = await SwiftPkg.run(arguments: ["--create", project.path]) + #expect(code == 2) + } + + @Test("building a nonexistent project is a general error (1)") + func buildMissingProjectExitsOne() async { + let code = await SwiftPkg.run(arguments: ["/no/such/swiftpkg-project-xyz"]) + #expect(code == 1) + } + + @Test("conflicting format flags are a usage error (64)") + func usageErrorExit() async { + let code = await SwiftPkg.run(arguments: ["--json", "--yaml", "/tmp/whatever"]) + #expect(code == usageErrorExitCode) + } +} diff --git a/swiftpkgTests/KeychainPathTests.swift b/swiftpkgTests/KeychainPathTests.swift new file mode 100644 index 0000000..3f02462 --- /dev/null +++ b/swiftpkgTests/KeychainPathTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct KeychainPathTests { + + @Test("expands ${HOME} to the user's home directory") + func expandsHome() { + let expanded = expandKeychainPath("${HOME}/Library/Keychains/signing.keychain") + #expect(expanded == "\(NSHomeDirectory())/Library/Keychains/signing.keychain") + #expect(!expanded.contains("${HOME}")) + } + + @Test("expands a leading tilde") + func expandsTilde() { + let expanded = expandKeychainPath("~/Library/Keychains/signing.keychain") + #expect(expanded == "\(NSHomeDirectory())/Library/Keychains/signing.keychain") + } + + @Test("leaves an absolute path unchanged") + func leavesAbsolutePathUnchanged() { + let path = "/Library/Keychains/System.keychain" + #expect(expandKeychainPath(path) == path) + } + + @Test("leaves a bare keychain name unchanged") + func leavesBareNameUnchanged() { + #expect(expandKeychainPath("login.keychain") == "login.keychain") + } +} diff --git a/swiftpkgTests/LinterTests.swift b/swiftpkgTests/LinterTests.swift new file mode 100644 index 0000000..ffdb54b --- /dev/null +++ b/swiftpkgTests/LinterTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct LinterTests { + + private func makeProject(buildInfo: String, addPayload: Bool = true) throws -> (TemporaryDirectory, URL) { + let temp = try TemporaryDirectory() + let project = temp.url.appendingPathComponent("P", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: false) + if addPayload { + let payload = project.appendingPathComponent("payload", isDirectory: true) + try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: false) + try write("x", to: payload.appendingPathComponent("file.txt")) + } + try write(buildInfo, to: project.appendingPathComponent("build-info.json")) + return (temp, project) + } + + @Test("a well-formed project produces no findings") + func cleanProject() throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#) + defer { temp.remove() } + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(findings.isEmpty) + } + + @Test("errors on empty version and a traversal name") + func errorsOnBadVersionAndName() throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"../evil.pkg","identifier":"com.example.app","version":""}"#) + defer { temp.remove() } + let findings = try Linter().lint(project: project, requestedFormat: nil) + let errors = findings.filter { $0.severity == .error } + #expect(errors.contains { $0.message.contains("version is empty") }) + #expect(errors.contains { $0.message.contains("single path component") }) + } + + @Test("warns on non-reverse-DNS identifier (a non-.pkg name is auto-normalized, not flagged)") + func warnsOnStyleIssues() throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"App","identifier":"noreverse","version":"1.0"}"#) + defer { temp.remove() } + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(findings.allSatisfy { $0.severity == .warning }) + #expect(findings.contains { $0.message.contains("reverse-DNS") }) + #expect(!findings.contains { $0.message.contains(".pkg") }) + } + + @Test("flags malformed dotted identifiers as non-reverse-DNS", arguments: [ + "noreverse", ".example", "com..example", "com.example.", + ]) + func warnsOnMalformedIdentifier(_ identifier: String) throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"App-1.0.pkg","identifier":"\#(identifier)","version":"1.0"}"#) + defer { temp.remove() } + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(findings.contains { $0.message.contains("reverse-DNS") }) + } + + @Test("accepts a well-formed reverse-DNS identifier") + func acceptsReverseDNS() throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#) + defer { temp.remove() } + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(!findings.contains { $0.message.contains("reverse-DNS") }) + } + + @Test("errors when an install script is a directory") + func errorsOnScriptDirectory() throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#) + defer { temp.remove() } + let scripts = project.appendingPathComponent("scripts", isDirectory: true) + let postinstall = scripts.appendingPathComponent("postinstall", isDirectory: true) + try FileManager.default.createDirectory(at: postinstall, withIntermediateDirectories: true) + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(findings.contains { $0.severity == .error && $0.message.contains("is a directory") }) + } + + @Test("a scripts-only project (no payload) lints cleanly") + func scriptsOnlyProjectIsClean() throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, addPayload: false) + defer { temp.remove() } + let scripts = project.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: false) + let postinstall = scripts.appendingPathComponent("postinstall") + try write("#!/bin/sh\necho hi\n", to: postinstall) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: postinstall.path) + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(findings.isEmpty) + } + + @Test("warns when notarization is configured without signing") + func warnsNotarizationWithoutSigning() throws { + let buildInfo = #"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0","notarization_info":{"keychain_profile":"p"}}"# + let (temp, project) = try makeProject(buildInfo: buildInfo) + defer { temp.remove() } + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(findings.contains { $0.message.contains("notarization is configured but signing") }) + } + + @Test("errors when there is neither payload nor scripts") + func errorsOnEmptyProject() throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, addPayload: false) + defer { temp.remove() } + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(findings.contains { $0.severity == .error && $0.message.contains("neither a payload") }) + } + + /// Payload-free, since a scripts-only project is supported: the script + /// findings must be warnings, with no "neither a payload" error alongside. + @Test("warns on a non-executable script without a shebang") + func warnsOnBadScript() throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, addPayload: false) + defer { temp.remove() } + let scripts = project.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: false) + let postinstall = scripts.appendingPathComponent("postinstall") + try write("echo hi\n", to: postinstall) // no shebang + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: postinstall.path) + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(findings.contains { $0.message.contains("shebang") }) + #expect(findings.contains { $0.message.contains("not executable") }) + #expect(findings.allSatisfy { $0.severity == .warning }) + } + + @Test("a scripts directory holding only .DS_Store does not count as scripts") + func emptyScriptsDirectoryStillErrors() throws { + let (temp, project) = try makeProject(buildInfo: #"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, addPayload: false) + defer { temp.remove() } + let scripts = project.appendingPathComponent("scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: false) + try write("", to: scripts.appendingPathComponent(".DS_Store")) + let findings = try Linter().lint(project: project, requestedFormat: nil) + #expect(findings.contains { $0.severity == .error && $0.message.contains("neither a payload") }) + } +} diff --git a/swiftpkgTests/NotarizationDeferTests.swift b/swiftpkgTests/NotarizationDeferTests.swift new file mode 100644 index 0000000..e6f777f --- /dev/null +++ b/swiftpkgTests/NotarizationDeferTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct NotarizationDeferTests { + private var defaults: PackageConfiguration { .defaults(for: URL(fileURLWithPath: "/tmp/Proj")) } + + @Test("incomplete notarization_info loads as .invalid instead of throwing") + func incompleteLoadsAsInvalid() throws { + let values: [String: Any] = [ + "name": "Proj.pkg", "identifier": "com.example.proj", "version": "1.0", + "notarization_info": ["password": "abcd-efgh-ijkl-mnop"] + ] + let config = try PackageConfiguration(values: values, defaults: defaults) + guard case .invalid = try #require(config.notarization?.authentication) else { + Issue.record("Expected .invalid authentication") + return + } + } + + @Test("complete notarization_info still parses to a usable authentication") + func completeParses() throws { + let profileValues: [String: Any] = [ + "name": "Proj.pkg", "identifier": "com.example.proj", "version": "1.0", + "notarization_info": ["keychain_profile": "notary"] + ] + let profile = try PackageConfiguration(values: profileValues, defaults: defaults) + guard case .keychainProfile("notary") = try #require(profile.notarization?.authentication) else { + Issue.record("Expected .keychainProfile") + return + } + + let appleValues: [String: Any] = [ + "name": "Proj.pkg", "identifier": "com.example.proj", "version": "1.0", + "notarization_info": ["apple_id": "a@b.com", "team_id": "TEAM"] + ] + let apple = try PackageConfiguration(values: appleValues, defaults: defaults) + guard case .appleID = try #require(apple.notarization?.authentication) else { + Issue.record("Expected .appleID") + return + } + } +} diff --git a/swiftpkgTests/NotarizationDiagnosticTests.swift b/swiftpkgTests/NotarizationDiagnosticTests.swift new file mode 100644 index 0000000..7cb22dd --- /dev/null +++ b/swiftpkgTests/NotarizationDiagnosticTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct NotarizationDiagnosticTests { + + private func result(status: Int32, stdout: String = "", stderr: String = "") -> ProcessResult { + ProcessResult(status: status, stdout: Data(stdout.utf8), stderr: Data(stderr.utf8)) + } + + /// The failure that cost a CI investigation: notarytool names the missing + /// profile and the command that creates it, and that must reach the user. + @Test("a missing keychain profile keeps notarytool's explanation") + func missingKeychainProfileIsExplained() { + let stderr = """ + Error: No Keychain password item found for profile: notarization_credentials + Run 'notarytool store-credentials' to create another credential profile. + """ + let message = result(status: 1, stderr: stderr).failureDetail(fallback: "Notarization upload failed.") + #expect(message.contains("Notarization upload failed.")) + #expect(message.contains("notarization_credentials")) + #expect(message.contains("store-credentials")) + } + + @Test("falls back to stdout when the tool reports on stdout instead") + func fallsBackToStdout() { + let message = result(status: 1, stdout: "Submission not found").failureDetail(fallback: "Notarization check failed.") + #expect(message == "Notarization check failed. Submission not found") + } + + @Test("stderr wins when the tool writes to both") + func stderrWins() { + let message = result(status: 1, stdout: "noise", stderr: "the real cause").failureDetail(fallback: "failed.") + #expect(message == "failed. the real cause") + } + + @Test("a silent failure still yields the base message, with no trailing space") + func silentFailureKeepsBaseMessage() { + #expect(result(status: 1).failureDetail(fallback: "Notarization upload failed.") == "Notarization upload failed.") + #expect(result(status: 1, stderr: " \n ").failureDetail(fallback: "failed.") == "failed.") + } +} diff --git a/swiftpkgTests/NotarizationServiceTests.swift b/swiftpkgTests/NotarizationServiceTests.swift new file mode 100644 index 0000000..953f7b9 --- /dev/null +++ b/swiftpkgTests/NotarizationServiceTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +/// Returns a scripted sequence of results, one per call, recording each call. +private final class ScriptedRunner: ProcessRunning, @unchecked Sendable { + private var results: [ProcessResult] + private(set) var calls: [(executable: String, arguments: [String])] = [] + + init(_ results: [ProcessResult]) { self.results = results } + + func run(executable: String, arguments: [String]) throws -> ProcessResult { + calls.append((executable, arguments)) + return results.isEmpty ? ProcessResult(status: 0, stdout: Data(), stderr: Data()) : results.removeFirst() + } +} + +private func plistResult(_ dictionary: [String: Any]) throws -> ProcessResult { + ProcessResult( + status: 0, + stdout: try PropertyListSerialization.data(fromPropertyList: dictionary, format: .xml, options: 0), + stderr: Data() + ) +} + +struct NotarizationServiceTests { + + // The core of this change: a notarization that never resolves must fail the + // build, not warn and exit 0 with an un-stapled package. staplingTimeout: 0 + // triggers the timeout path immediately with no sleep. + @Test("timeout waiting for notarization throws instead of succeeding silently") + func timeoutThrows() async throws { + let runner = ScriptedRunner([try plistResult(["id": "submission-abc"])]) + let configuration = NotarizationConfiguration(authentication: .keychainProfile("profile"), staplingTimeout: 0) + await #expect(throws: SwiftPkgError.self) { + try await NotarizationService(runner: runner, console: Console(quiet: true)) + .notarize(package: URL(fileURLWithPath: "/tmp/does-not-matter.pkg"), configuration: configuration, skipsStapling: false) + } + } + + // Guard against over-correction: an Accepted submission must still staple. + // (Costs one ~5s poll sleep — the wait loop's minimum delay.) + @Test("accepted notarization still staples the package") + func acceptedStaples() async throws { + let runner = ScriptedRunner([ + try plistResult(["id": "submission-abc"]), + try plistResult(["status": "Accepted", "message": "ok"]), + ProcessResult(status: 0, stdout: Data(), stderr: Data()), + ]) + let configuration = NotarizationConfiguration(authentication: .keychainProfile("profile"), staplingTimeout: 60) + try await NotarizationService(runner: runner, console: Console(quiet: true)) + .notarize(package: URL(fileURLWithPath: "/tmp/does-not-matter.pkg"), configuration: configuration, skipsStapling: false) + #expect(runner.calls.count == 3) + #expect(runner.calls.last?.arguments.contains("staple") == true) + } +} diff --git a/swiftpkgTests/PackageNameExtensionTests.swift b/swiftpkgTests/PackageNameExtensionTests.swift new file mode 100644 index 0000000..9715b5b --- /dev/null +++ b/swiftpkgTests/PackageNameExtensionTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct PackageNameExtensionTests { + private var defaults: PackageConfiguration { .defaults(for: URL(fileURLWithPath: "/tmp/Proj")) } + + private func config(name: String, version: String = "1.0") throws -> PackageConfiguration { + try PackageConfiguration( + values: ["name": name, "identifier": "com.example.proj", "version": version], + defaults: defaults + ) + } + + @Test("a name without .pkg gains the extension") + func appendsExtension() throws { + let resolved = try config(name: "MunkiBootstrap").substitutingVersion() + #expect(resolved.name == "MunkiBootstrap.pkg") + } + + @Test("a name already ending in .pkg is left unchanged") + func keepsExtension() throws { + let resolved = try config(name: "AdminDock.pkg").substitutingVersion() + #expect(resolved.name == "AdminDock.pkg") + } + + @Test("the extension is appended after ${version} substitution") + func appendsAfterVersionSubstitution() throws { + let resolved = try config(name: "Tool-${version}", version: "2.3").substitutingVersion() + #expect(resolved.name == "Tool-2.3.pkg") + } +} diff --git a/swiftpkgTests/PackageNameValidationTests.swift b/swiftpkgTests/PackageNameValidationTests.swift new file mode 100644 index 0000000..c8b8185 --- /dev/null +++ b/swiftpkgTests/PackageNameValidationTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct PackageNameValidationTests { + + @Test("rejects names that escape the build directory", arguments: [ + "../evil.pkg", + "../../tmp/evil.pkg", + "sub/dir/evil.pkg", + "/etc/evil.pkg", + "..", + ".", + "", + ]) + func rejectsUnsafeNames(_ name: String) { + #expect(throws: SwiftPkgError.self) { + try PackageBuildCoordinator.validatePackageName(name) + } + } + + @Test("accepts safe single-component names", arguments: [ + "MyApp-1.0.pkg", + "com.example.thing.pkg", + "package_2026.07.18.pkg", + "no-extension", + ]) + func acceptsSafeNames(_ name: String) throws { + try PackageBuildCoordinator.validatePackageName(name) + } + + // End-to-end: a malicious build-info name must fail before pkgbuild runs. + @Test("build with a traversal name throws and never invokes pkgbuild") + func buildRejectsTraversalNameBeforeRunning() async throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let project = temp.url.appendingPathComponent("Evil", isDirectory: true) + let payload = project.appendingPathComponent("payload", isDirectory: true) + try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true) + try write("payload", to: payload.appendingPathComponent("file.txt")) + try write( + #"{"name":"../evil.pkg","identifier":"com.test.evil","version":"1.0"}"#, + to: project.appendingPathComponent("build-info.json") + ) + + let runner = RecordingRunner() + let coordinator = PackageBuildCoordinator(fileManager: .default, runner: runner, console: makeConsole()) + await #expect(throws: SwiftPkgError.self) { + try await coordinator.buildPackage(in: project, configuration: PackageBuildOptions()) + } + #expect(runner.calls.isEmpty) + } +} diff --git a/swiftpkgTests/PackageVerifierTests.swift b/swiftpkgTests/PackageVerifierTests.swift new file mode 100644 index 0000000..d26c2dd --- /dev/null +++ b/swiftpkgTests/PackageVerifierTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct PackageVerifierTests { + private func makePackage() throws -> (TemporaryDirectory, URL) { + let temp = try TemporaryDirectory() + return (temp, temp.url.appendingPathComponent("App-1.0.pkg")) + } + + /// Expansion succeeds (so metadata is skipped when no real PackageInfo is + /// produced) while the named check tool returns a failing status. + private func runnerFailing(_ failingTool: String) -> RecordingRunner { + let runner = RecordingRunner() + runner.resultProvider = { executable, arguments in + if arguments.contains("--expand") { return ProcessResult(status: 0, stdout: Data(), stderr: Data()) } + let status: Int32 = executable == failingTool ? 1 : 0 + return ProcessResult(status: status, stdout: Data(), stderr: Data("bad".utf8)) + } + return runner + } + + @Test("an unsigned, un-notarized build only introspects metadata") + func metadataOnlyWhenNothingDeclared() throws { + let (temp, package) = try makePackage(); defer { temp.remove() } + let runner = RecordingRunner() + try PackageVerifier(runner: runner, console: makeConsole()) + .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: false, notarized: false) + #expect(runner.calls.contains { $0.executable == ToolPaths.pkgutil && $0.arguments.contains("--expand") }) + #expect(!runner.calls.contains { $0.arguments.contains("--check-signature") }) + #expect(!runner.calls.contains { $0.executable == ToolPaths.spctl }) + } + + @Test("a signed build checks the signature and passes when valid") + func signedPasses() throws { + let (temp, package) = try makePackage(); defer { temp.remove() } + let runner = RecordingRunner() + try PackageVerifier(runner: runner, console: makeConsole()) + .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: true, notarized: false) + #expect(runner.calls.contains { $0.executable == ToolPaths.pkgutil && $0.arguments.contains("--expand") }) + #expect(runner.calls.contains { $0.executable == ToolPaths.pkgutil && $0.arguments.contains("--check-signature") }) + } + + @Test("a signed build fails when the signature check fails") + func signedFails() throws { + let (temp, package) = try makePackage(); defer { temp.remove() } + let runner = runnerFailing(ToolPaths.pkgutil) + #expect(throws: SwiftPkgError.self) { + try PackageVerifier(runner: runner, console: makeConsole()) + .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: true, notarized: false) + } + } + + @Test("verification fails when the package cannot be expanded") + func failsWhenExpansionFails() throws { + let (temp, package) = try makePackage(); defer { temp.remove() } + let runner = RecordingRunner() + runner.result = ProcessResult(status: 1, stdout: Data(), stderr: Data("corrupt".utf8)) + #expect(throws: SwiftPkgError.self) { + try PackageVerifier(runner: runner, console: makeConsole()) + .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: false, notarized: false) + } + } + + @Test("a notarized build runs a Gatekeeper assessment") + func notarizedRunsSpctl() throws { + let (temp, package) = try makePackage(); defer { temp.remove() } + let runner = runnerFailing(ToolPaths.spctl) + #expect(throws: SwiftPkgError.self) { + try PackageVerifier(runner: runner, console: makeConsole()) + .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: false, notarized: true) + } + #expect(runner.calls.contains { $0.executable == ToolPaths.spctl && $0.arguments.contains("install") }) + } + + private let packageInfo = #""# + + @Test("matching identifier and version produce no mismatch") + func metadataMatches() { + #expect(PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "1.0", packageInfoXML: packageInfo) == nil) + } + + @Test("a mismatched identifier is reported") + func identifierMismatch() { + let message = PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.other", expectedVersion: "1.0", packageInfoXML: packageInfo) + #expect(message?.contains("identifier") == true) + } + + @Test("a mismatched version is reported") + func versionMismatch() { + let message = PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "2.0", packageInfoXML: packageInfo) + #expect(message?.contains("version") == true) + } + + @Test("unparseable PackageInfo is treated as no mismatch (best-effort)") + func malformedPackageInfoSkips() { + #expect(PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "1.0", packageInfoXML: "not xml") == nil) + } + + @Test("a parsed PackageInfo missing identifier or version is rejected") + func incompleteMetadataRejected() { + let noIdentifier = #""# + let noVersion = #""# + #expect(PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "1.0", packageInfoXML: noIdentifier)?.contains("identifier") == true) + #expect(PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "1.0", packageInfoXML: noVersion)?.contains("version") == true) + } +} diff --git a/swiftpkgTests/ProvenanceTests.swift b/swiftpkgTests/ProvenanceTests.swift new file mode 100644 index 0000000..cb6dd55 --- /dev/null +++ b/swiftpkgTests/ProvenanceTests.swift @@ -0,0 +1,129 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +private final class GitRunner: ProcessRunning, @unchecked Sendable { + var commit = "abc123" + var remote = "https://github.com/example/repo.git" + + func run(executable: String, arguments: [String]) throws -> ProcessResult { + let out: String + if arguments.contains("rev-parse") { out = commit } + else if arguments.contains("remote") { out = remote } + else { out = "" } + return ProcessResult(status: 0, stdout: Data((out + "\n").utf8), stderr: Data()) + } +} + +struct ProvenanceTests { + + @Test("sanitizedRemote strips user:pass@ userinfo but leaves clean URLs") + func sanitizesRemote() { + #expect(ProvenanceBuilder.sanitizedRemote("https://user:pass@github.com/x/y.git") == "https://github.com/x/y.git") + #expect(ProvenanceBuilder.sanitizedRemote("https://token@github.com/x/y.git") == "https://github.com/x/y.git") + #expect(ProvenanceBuilder.sanitizedRemote("https://github.com/x/y.git") == "https://github.com/x/y.git") + #expect(ProvenanceBuilder.sanitizedRemote("git@github.com:x/y.git") == "git@github.com:x/y.git") // scp-style, no :// + } + + @Test("provenance captures git metadata and a stable input digest") + func buildsProvenance() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let project = temp.url.appendingPathComponent("P", isDirectory: true) + let payload = project.appendingPathComponent("payload", isDirectory: true) + try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true) + try write("hello", to: payload.appendingPathComponent("file.txt")) + try write(#"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, to: project.appendingPathComponent("build-info.json")) + let output = project.appendingPathComponent("build/App-1.0.pkg") + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try write("PKGDATA", to: output) + + let runner = GitRunner() + runner.remote = "https://user:secret@github.com/example/repo.git" + let builder = ProvenanceBuilder(runner: runner, fileManager: .default) + let config = try BuildInfoStore.load(from: project, requestedFormat: nil) + let now = Date(timeIntervalSince1970: 1_800_000_000) + let provenance = try builder.build(configuration: config, output: output, project: project, now: now) + + #expect(provenance.tool == "swiftpkg") + #expect(provenance.gitCommit == "abc123") + #expect(provenance.gitRemote == "https://github.com/example/repo.git") // credentials stripped + #expect(provenance.identifier == "com.example.app") + #expect(provenance.sha256.count == 64) + #expect(provenance.inputDigest.count == 64) + + // Input digest is deterministic for identical inputs. + let again = try builder.build(configuration: config, output: output, project: project, now: now) + #expect(again.inputDigest == provenance.inputDigest) + + // JSON uses snake_case keys and round-trips. + let json = try provenance.jsonString() + #expect(json.contains("\"input_digest\"")) + #expect(json.contains("\"git_commit\"")) + let decoded = try JSONDecoder().decode(Provenance.self, from: Data(json.utf8)) + #expect(decoded == provenance) + } + + @Test("input digest changes when an input file changes") + func digestChangesWithInputs() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let project = temp.url.appendingPathComponent("P", isDirectory: true) + let payload = project.appendingPathComponent("payload", isDirectory: true) + try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true) + try write(#"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, to: project.appendingPathComponent("build-info.json")) + let output = project.appendingPathComponent("build/App-1.0.pkg") + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try write("PKG", to: output) + let builder = ProvenanceBuilder(runner: GitRunner(), fileManager: .default) + let config = try BuildInfoStore.load(from: project, requestedFormat: nil) + + try write("v1", to: payload.appendingPathComponent("file.txt")) + let first = try builder.build(configuration: config, output: output, project: project).inputDigest + try write("v2", to: payload.appendingPathComponent("file.txt")) + let second = try builder.build(configuration: config, output: output, project: project).inputDigest + #expect(first != second) + } + + private func makeDigestFixture() throws -> (TemporaryDirectory, URL, URL, ProvenanceBuilder, PackageConfiguration) { + let temp = try TemporaryDirectory() + let project = temp.url.appendingPathComponent("P", isDirectory: true) + let payload = project.appendingPathComponent("payload", isDirectory: true) + try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true) + try write(#"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, to: project.appendingPathComponent("build-info.json")) + let output = project.appendingPathComponent("build/App-1.0.pkg") + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try write("PKG", to: output) + let builder = ProvenanceBuilder(runner: GitRunner(), fileManager: .default) + let config = try BuildInfoStore.load(from: project, requestedFormat: nil) + return (temp, project, payload, builder, config) + } + + @Test("input digest changes when a file's executable bit is toggled") + func digestChangesWithPermissions() throws { + let (temp, project, payload, builder, config) = try makeDigestFixture() + defer { temp.remove() } + let script = payload.appendingPathComponent("run.sh") + try write("#!/bin/sh\n", to: script) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: script.path) + let before = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + let after = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest + #expect(before != after) + } + + @Test("input digest changes when a symlink's target changes") + func digestChangesWithSymlinkTarget() throws { + let (temp, project, payload, builder, config) = try makeDigestFixture() + defer { temp.remove() } + let link = payload.appendingPathComponent("Current") + try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: "A") + let before = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest + try FileManager.default.removeItem(at: link) + try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: "B") + let after = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest + #expect(before != after) + } + + private func output(for project: URL) -> URL { project.appendingPathComponent("build/App-1.0.pkg") } +} diff --git a/swiftpkgTests/ReceiptOnlyBuildTests.swift b/swiftpkgTests/ReceiptOnlyBuildTests.swift new file mode 100644 index 0000000..5e002e9 --- /dev/null +++ b/swiftpkgTests/ReceiptOnlyBuildTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct ReceiptOnlyBuildTests { + + // A project with neither payload nor scripts is valid: it builds a + // receipt-only package. pkgbuild must be invoked with --nopayload (and no + // --root), matching munki-pkg — otherwise the build would fail looking for + // a payload that isn't there. + @Test("receipt-only project builds with pkgbuild --nopayload") + func receiptOnlyUsesNopayload() async throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let project = temp.url.appendingPathComponent("ReceiptOnly", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + try write( + #"{"name":"ReceiptOnly-1.0.pkg","identifier":"com.test.receipt","version":"1.0"}"#, + to: project.appendingPathComponent("build-info.json") + ) + let runner = RecordingRunner() + runner.onRun = { executable, arguments in + guard executable.hasSuffix("pkgbuild"), let output = arguments.last else { return } + try write("fake package", to: URL(fileURLWithPath: output)) + } + let coordinator = PackageBuildCoordinator(fileManager: .default, runner: runner, console: makeConsole()) + try await coordinator.buildPackage(in: project, configuration: PackageBuildOptions(skipsSigning: true)) + + let pkgbuild = try #require(runner.calls.first { $0.executable.hasSuffix("pkgbuild") }) + #expect(pkgbuild.arguments.contains("--nopayload")) + #expect(!pkgbuild.arguments.contains("--root")) + } +} diff --git a/swiftpkgTests/SystemProcessRunnerTests.swift b/swiftpkgTests/SystemProcessRunnerTests.swift new file mode 100644 index 0000000..3691ce8 --- /dev/null +++ b/swiftpkgTests/SystemProcessRunnerTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +/// Exercises the real SystemProcessRunner (not RecordingRunner) — these guard +/// the concurrent pipe-drain that prevents a large-output deadlock. +struct SystemProcessRunnerTests { + + // ~1.1 MB of stdout, far past the ~64 KB pipe buffer. A runner that waits + // before draining (or drains sequentially) would hang here forever. + @Test("captures large stdout completely and in order") + func capturesLargeStdout() throws { + let result = try SystemProcessRunner().run(executable: "/usr/bin/seq", arguments: ["1", "200000"]) + #expect(result.status == 0) + let lines = result.stdoutString.split(separator: "\n", omittingEmptySubsequences: false) + .filter { !$0.isEmpty } + #expect(lines.count == 200_000) + #expect(lines.first == "1") + #expect(lines.last == "200000") + } + + @Test("separates stderr and reports non-zero status") + func separatesStderr() throws { + let result = try SystemProcessRunner().run(executable: "/bin/ls", arguments: ["/no/such/path/swiftpkg-test-xyz"]) + #expect(result.status != 0) + #expect(result.stdout.isEmpty) + #expect(!result.stderrString.isEmpty) + } + + @Test("reports a structured error when the executable cannot launch") + func reportsLaunchFailure() { + #expect(throws: SwiftPkgError.self) { + try SystemProcessRunner().run(executable: "/nonexistent/tool/swiftpkg-should-not-exist", arguments: []) + } + } +} diff --git a/swiftpkgTests/TestSupport.swift b/swiftpkgTests/TestSupport.swift index 9edd2a3..71c1644 100644 --- a/swiftpkgTests/TestSupport.swift +++ b/swiftpkgTests/TestSupport.swift @@ -24,10 +24,14 @@ final class RecordingRunner: ProcessRunning, @unchecked Sendable { var calls: [Call] = [] var result = ProcessResult(status: 0, stdout: Data(), stderr: Data()) + /// When set, chooses the result per call; falls back to `result` when nil. + var resultProvider: ((_ executable: String, _ arguments: [String]) -> ProcessResult)? + var onRun: ((String, [String]) throws -> Void)? func run(executable: String, arguments: [String]) throws -> ProcessResult { calls.append(Call(executable: executable, arguments: arguments)) - return result + try onRun?(executable, arguments) + return resultProvider?(executable, arguments) ?? result } } diff --git a/swiftpkgTests/VersionOverrideTests.swift b/swiftpkgTests/VersionOverrideTests.swift new file mode 100644 index 0000000..d625812 --- /dev/null +++ b/swiftpkgTests/VersionOverrideTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import SwiftPkgCore + +struct VersionOverrideTests { + + @Test("withVersion changes only the version, before substitution") + func withVersionChangesOnlyVersion() { + let base = PackageConfiguration.defaults(for: URL(fileURLWithPath: "/tmp/Thing")) + let bumped = base.withVersion("9.9") + #expect(bumped.version == "9.9") + #expect(bumped.name == base.name) // "${version}" not yet substituted + #expect(bumped.identifier == base.identifier) + } + + @Test("load with a version override replaces version and ${version} in name") + func loadWithOverride() throws { + let temp = try TemporaryDirectory() + defer { temp.remove() } + let project = temp.url.appendingPathComponent("P", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: false) + try write( + #"{"name":"App-${version}.pkg","identifier":"com.example.app","version":"1.0"}"#, + to: project.appendingPathComponent("build-info.json") + ) + + let overridden = try BuildInfoStore.load(from: project, requestedFormat: nil, versionOverride: "2.5") + #expect(overridden.version == "2.5") + #expect(overridden.name == "App-2.5.pkg") + + let normal = try BuildInfoStore.load(from: project, requestedFormat: nil) + #expect(normal.version == "1.0") + #expect(normal.name == "App-1.0.pkg") + } +}