diff --git a/.github/actions/publish-vps-mirror/action.yml b/.github/actions/publish-vps-mirror/action.yml index aefe7504..0d980bcc 100644 --- a/.github/actions/publish-vps-mirror/action.yml +++ b/.github/actions/publish-vps-mirror/action.yml @@ -89,6 +89,13 @@ runs: remote_cleanup_enabled=false ssh_options=() + python3 "${GITHUB_WORKSPACE}/tools/validate-gui-update-manifest.py" \ + --channel "${MIRROR_CHANNEL}" \ + --app-tag "${MIRROR_APP_TAG}" \ + --app-dir "${MIRROR_APP_DIR}" \ + --manifest "${MIRROR_APP_MANIFEST}" \ + --github-repository "${GITHUB_REPOSITORY}" + cleanup() { local exit_code=$? local cleanup_remote_command="" diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index f7a40386..a1c775b4 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -27,13 +27,22 @@ jobs: - name: Test release asset contracts run: | + go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -shellcheck= -pyflakes= \ + .github/workflows/release.yml \ + .github/workflows/dev-build.yml \ + .github/workflows/publish-release.yml bash tools/detect-changed-driver-agents.test.sh bash tools/generate-driver-agent-revisions.test.sh python3 tools/generate-driver-release-manifest.test.py python3 tools/generate-update-latest-manifest.test.py + python3 tools/validate-gui-update-manifest.test.py python3 tools/prepare-vps-release-payload.test.py bash tools/vps-release-commit.test.sh python3 tools/package-driver-release-assets.test.py + python3 tools/cli-release-assets.test.py + python3 tools/npm-cli-wrapper.test.py + python3 tools/validate-npm-cli-package-version.test.py + python3 tools/generate-winget-cli-manifest.test.py python3 tools/legal-release-assets.test.py python3 tools/windows-release-artifacts.test.py python3 tools/generate-release-notes.test.py @@ -275,6 +284,85 @@ jobs: fi echo "release_source=dev-latest" >> "$GITHUB_OUTPUT" + cli: + name: Build dev CLI ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: amd64 + extension: tar.gz + binary: gonavi + - goos: darwin + goarch: arm64 + extension: tar.gz + binary: gonavi + - goos: linux + goarch: amd64 + extension: tar.gz + binary: gonavi + - goos: linux + goarch: arm64 + extension: tar.gz + binary: gonavi + - goos: windows + goarch: amd64 + extension: zip + binary: gonavi.exe + - goos: windows + goarch: arm64 + extension: zip + binary: gonavi.exe + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Build and package dev CLI + shell: bash + run: | + set -euo pipefail + version="dev-${GITHUB_SHA:0:7}" + asset="gonavi-cli_${version}_${{ matrix.goos }}_${{ matrix.goarch }}.${{ matrix.extension }}" + stage="${RUNNER_TEMP}/gonavi-cli-${{ matrix.goos }}-${{ matrix.goarch }}" + mkdir -p "$stage" + ./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.goos }}/${{ matrix.goarch }}" + CGO_ENABLED=0 GOOS="${{ matrix.goos }}" GOARCH="${{ matrix.goarch }}" \ + go build -trimpath -ldflags="-s -w -X GoNavi-Wails/internal/cli.Version=${version}" \ + -o "$stage/${{ matrix.binary }}" ./cmd/gonavi + install -m 0644 LICENSE NOTICE "$stage/" + if [[ "${{ matrix.extension }}" == "zip" ]]; then + (cd "$stage" && zip -q -X "${GITHUB_WORKSPACE}/${asset}" "${{ matrix.binary }}" LICENSE NOTICE) + else + tar -C "$stage" -czf "$asset" "${{ matrix.binary }}" LICENSE NOTICE + fi + test -s "$asset" + expected_entries="$(printf '%s\n' "${{ matrix.binary }}" LICENSE NOTICE | sort)" + if [[ "${{ matrix.extension }}" == "zip" ]]; then + actual_entries="$(unzip -Z1 "$asset" | sort)" + else + actual_entries="$(tar -tzf "$asset" | sed 's#^\./##' | sort)" + fi + if [[ "$actual_entries" != "$expected_entries" ]]; then + echo "CLI archive contents are invalid for $asset" >&2 + printf 'expected:\n%s\nactual:\n%s\n' "$expected_entries" "$actual_entries" >&2 + exit 1 + fi + + - name: Upload dev CLI artifact + uses: actions/upload-artifact@v6 + with: + name: dev-cli-artifact-${{ matrix.goos }}-${{ matrix.goarch }} + path: gonavi-cli_* + if-no-files-found: error + retention-days: 1 + build: name: Build ${{ matrix.platform }} needs: @@ -540,7 +628,7 @@ jobs: id: version shell: bash run: | - SHORT_SHA=$(git rev-parse --short HEAD) + SHORT_SHA="${GITHUB_SHA:0:7}" DEV_VERSION="dev-${SHORT_SHA}" echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" echo "📌 Dev 版本号: ${DEV_VERSION}" @@ -831,12 +919,12 @@ jobs: VERSION="${{ steps.version.outputs.version }}" cd build/bin - APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1) - if [ -z "$APP_PATH" ]; then - echo "❌ 未找到 .app 应用包!" + APP_PATH="./GoNavi.app" + if [ ! -d "$APP_PATH" ]; then + echo "❌ 未找到固定名称 GoNavi.app 应用包!" exit 1 fi - APP_NAME=$(basename "$APP_PATH") + APP_NAME="GoNavi.app" mkdir -p "$APP_NAME/Contents/Resources" cp ../../LICENSE "$APP_NAME/Contents/Resources/LICENSE" cp ../../NOTICE "$APP_NAME/Contents/Resources/NOTICE" @@ -848,10 +936,7 @@ jobs: fi echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。" - echo "🔏 正在进行 Ad-hoc 签名..." - if command -v xattr >/dev/null 2>&1; then - xattr -cr "$APP_NAME" || true - fi + echo "🔏 Signing dev DMG with an ad-hoc identity (not for production distribution)..." codesign --force --deep --sign - "$APP_NAME" DMG_NAME="${{ matrix.build_name }}.dmg" @@ -871,9 +956,9 @@ jobs: VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dev-verify.XXXXXX") hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null - PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1) - if [ -z "$PACKAGED_APP" ]; then - echo "❌ DMG 内未找到 .app 应用包!" + PACKAGED_APP="$VERIFY_MOUNT_DIR/GoNavi.app" + if [ ! -d "$PACKAGED_APP" ]; then + echo "❌ DMG 内未找到固定名称 GoNavi.app 应用包!" hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true exit 1 fi @@ -1093,6 +1178,7 @@ jobs: name: Publish Dev Pre-release needs: - build + - cli - driver_agents # Serialize only the publication stage with stable releases so both # channels cannot publish shared mirror targets concurrently. @@ -1119,12 +1205,44 @@ jobs: pattern: dev-build-artifacts-* merge-multiple: true + - name: Download dev CLI artifacts + uses: actions/download-artifact@v7 + with: + path: cli-assets + pattern: dev-cli-artifact-* + merge-multiple: true + + - name: Validate dev CLI artifact staging + shell: bash + run: | + set -euo pipefail + version="dev-${GITHUB_SHA:0:7}" + expected=( + "gonavi-cli_${version}_darwin_amd64.tar.gz" + "gonavi-cli_${version}_darwin_arm64.tar.gz" + "gonavi-cli_${version}_linux_amd64.tar.gz" + "gonavi-cli_${version}_linux_arm64.tar.gz" + "gonavi-cli_${version}_windows_amd64.zip" + "gonavi-cli_${version}_windows_arm64.zip" + ) + mapfile -t actual < <(find cli-assets -type f -printf '%P\n' | sort) + expected_list="$(printf '%s\n' "${expected[@]}" | sort)" + actual_list="$(printf '%s\n' "${actual[@]}")" + if [[ "$actual_list" != "$expected_list" ]]; then + echo "CLI artifact set is invalid" >&2 + printf 'expected:\n%s\nactual:\n%s\n' "$expected_list" "$actual_list" >&2 + exit 1 + fi + for asset in "${expected[@]}"; do + test -s "cli-assets/${asset}" + done + - name: Add legal documents shell: bash run: install -m 0644 LICENSE NOTICE release-assets/ - name: List Assets - run: ls -R release-assets + run: ls -R release-assets cli-assets - name: Download Previous Driver Manifest if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all' @@ -1190,26 +1308,107 @@ jobs: rm -rf drivers driver-provenance echo "has_driver_assets=true" >> "$GITHUB_OUTPUT" + - name: Generate dev CLI checksums + shell: bash + run: | + set -euo pipefail + version="dev-${GITHUB_SHA:0:7}" + expected=( + "gonavi-cli_${version}_darwin_amd64.tar.gz" + "gonavi-cli_${version}_darwin_arm64.tar.gz" + "gonavi-cli_${version}_linux_amd64.tar.gz" + "gonavi-cli_${version}_linux_arm64.tar.gz" + "gonavi-cli_${version}_windows_amd64.zip" + "gonavi-cli_${version}_windows_arm64.zip" + ) + (cd cli-assets && sha256sum "${expected[@]}" > "gonavi-cli_${version}_checksums.txt") + - name: Generate SHA256SUMS shell: bash run: | - cd release-assets - FILES=() + set -euo pipefail + output="release-assets/SHA256SUMS" + count=0 + declare -A seen=() + : > "$output" while IFS= read -r file; do - if [ -n "$file" ]; then - case "$file" in - SHA256SUMS|latest.json|latest-dev.json) continue ;; - esac - FILES+=("$file") + name="$(basename "$file")" + if [[ -n "${seen[$name]:-}" ]]; then + echo "Duplicate release asset name across staging directories: ${name}" >&2 + exit 1 fi - done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort) - if [ ${#FILES[@]} -eq 0 ]; then + seen["$name"]=1 + digest="$(sha256sum "$file" | awk '{ print $1 }')" + printf '%s %s\n' "$digest" "$name" >> "$output" + count=$((count + 1)) + done < <(find release-assets cli-assets -maxdepth 1 -type f \ + ! -name SHA256SUMS ! -name latest.json ! -name latest-dev.json -print | sort) + if [ "$count" -eq 0 ]; then echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS" - : > SHA256SUMS - else - sha256sum "${FILES[@]}" > SHA256SUMS fi + - name: Verify dev CLI release assets + shell: bash + run: | + set -euo pipefail + version="dev-${GITHUB_SHA:0:7}" + cli_checksum_name="gonavi-cli_${version}_checksums.txt" + expected=( + "gonavi-cli_${version}_darwin_amd64.tar.gz" + "gonavi-cli_${version}_darwin_arm64.tar.gz" + "gonavi-cli_${version}_linux_amd64.tar.gz" + "gonavi-cli_${version}_linux_arm64.tar.gz" + "gonavi-cli_${version}_windows_amd64.zip" + "gonavi-cli_${version}_windows_arm64.zip" + ) + expected_cli_files=("${expected[@]}" "$cli_checksum_name") + mapfile -t actual_cli_files < <(find cli-assets -maxdepth 1 -type f -name 'gonavi-cli_*' -printf '%f\n' | sort) + expected_cli_list="$(printf '%s\n' "${expected_cli_files[@]}" | sort)" + actual_cli_list="$(printf '%s\n' "${actual_cli_files[@]}")" + if [[ "$actual_cli_list" != "$expected_cli_list" ]]; then + echo "CLI release asset set is invalid" >&2 + printf 'expected:\n%s\nactual:\n%s\n' "$expected_cli_list" "$actual_cli_list" >&2 + exit 1 + fi + test -s "cli-assets/${cli_checksum_name}" + if ! awk 'NF != 2 || length($1) != 64 || $1 !~ /^[0-9a-fA-F]+$/ { exit 1 }' "cli-assets/${cli_checksum_name}"; then + echo "CLI checksum file contents are invalid" >&2 + exit 1 + fi + mapfile -t checksum_assets < <(awk '{ print $2 }' "cli-assets/${cli_checksum_name}" | sort) + checksum_asset_list="$(printf '%s\n' "${checksum_assets[@]}")" + expected_checksum_list="$(printf '%s\n' "${expected[@]}" | sort)" + if [[ "$checksum_asset_list" != "$expected_checksum_list" ]]; then + echo "CLI checksum file contents are invalid" >&2 + printf 'expected:\n%s\nactual:\n%s\n' "$expected_checksum_list" "$checksum_asset_list" >&2 + exit 1 + fi + for asset in "${expected[@]}"; do + test -s "cli-assets/${asset}" + grep -Fq " ${asset}" release-assets/SHA256SUMS + global_digest="$(awk -v name="$asset" '$2 == name { print $1 }' release-assets/SHA256SUMS)" + cli_digest="$(awk -v name="$asset" '$2 == name { print $1 }' "cli-assets/${cli_checksum_name}")" + actual_digest="$(sha256sum "cli-assets/${asset}" | awk '{ print $1 }')" + [[ -n "$global_digest" && "$global_digest" == "$cli_digest" && "$cli_digest" == "$actual_digest" ]] + done + grep -Fq " ${cli_checksum_name}" release-assets/SHA256SUMS + (cd cli-assets && sha256sum --check "$cli_checksum_name") + while read -r expected_digest asset; do + if [[ -f "release-assets/${asset}" && -f "cli-assets/${asset}" ]]; then + echo "Duplicate release asset name across staging directories: ${asset}" >&2 + exit 1 + elif [[ -f "release-assets/${asset}" ]]; then + file="release-assets/${asset}" + elif [[ -f "cli-assets/${asset}" ]]; then + file="cli-assets/${asset}" + else + echo "Release asset listed in SHA256SUMS is missing: ${asset}" >&2 + exit 1 + fi + actual_digest="$(sha256sum "$file" | awk '{ print $1 }')" + [[ "$actual_digest" == "$expected_digest" ]] + done < release-assets/SHA256SUMS + - name: Generate Driver SHA256SUMS if: steps.driver_assets.outputs.has_driver_assets == 'true' shell: bash @@ -1347,6 +1546,7 @@ jobs: --version "$DEV_VERSION" \ --tag dev-latest \ --channel dev \ + --component gui \ --name "Dev Build (${DEV_VERSION})" \ --download-base-url "https://download.syngnat.top/gonavi/dev/releases/download" \ --download-tag "$DEV_VERSION" \ @@ -1482,7 +1682,9 @@ jobs: tag_name: dev-latest name: "🧪 Dev Build (${{ steps.version.outputs.version }})" target_commitish: ${{ github.sha }} - files: release-assets/* + files: | + release-assets/* + cli-assets/* prerelease: true draft: false body_path: ${{ steps.changelog.outputs.body_file }} @@ -1499,21 +1701,12 @@ jobs: app_dir="release-assets" driver_dir="driver-release-assets" - jq -e \ - --arg version "${DEV_VERSION}" \ - --arg mirror_base "https://download.syngnat.top/gonavi/dev/releases/download" \ - --arg github_base "https://github.com/${GITHUB_REPOSITORY}/releases/download/dev-latest" \ - ' - .channel == "dev" - and .tagName == "dev-latest" - and .version == $version - and (.assets | type == "array" and length > 0) - and all(.assets[]; - (.url | type == "string" and startswith($mirror_base + "/" + $version + "/")) - and (.apiUrl | type == "string" and startswith($github_base + "/")) - and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$")) - ) - ' "${app_dir}/latest-dev.json" >/dev/null + python3 tools/validate-gui-update-manifest.py \ + --channel dev \ + --app-tag "${DEV_VERSION}" \ + --app-dir "${app_dir}" \ + --manifest "${app_dir}/latest-dev.json" \ + --github-repository "${GITHUB_REPOSITORY}" driver_latest_file="${RUNNER_TEMP}/driver-dev-latest-index.json" if [[ "${HAS_DRIVER_ASSETS}" == "true" ]]; then diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index f07ca978..c7e57121 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -24,6 +24,7 @@ jobs: matrix: image_name: - gonavi-mcp-server + - gonavi-cli - gonavi-web-server - gonavi-build-env platform: @@ -33,6 +34,9 @@ jobs: - image_name: gonavi-mcp-server dockerfile: Dockerfile.mcp-server description: GoNavi MCP Server container image + - image_name: gonavi-cli + dockerfile: Dockerfile.cli + description: GoNavi command-line interface container image - image_name: gonavi-web-server dockerfile: Dockerfile.web-server description: GoNavi Web Server (browser UI) container image @@ -66,6 +70,11 @@ jobs: set -euo pipefail echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" echo "platform_pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-')" >> "$GITHUB_OUTPUT" + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "version=dev-${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" + fi - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -95,6 +104,8 @@ jobs: platforms: ${{ matrix.platform }} load: true tags: codex-smoke/${{ matrix.image_name }}:local + build-args: | + VERSION=${{ steps.prep.outputs.version }} cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }} - name: Smoke test MCP Server image @@ -114,6 +125,25 @@ jobs: echo "MCP Server image smoke test failed" >&2 exit 1 + - name: Smoke test CLI image + if: matrix.image_name == 'gonavi-cli' + shell: bash + run: | + set -euo pipefail + docker run --rm codex-smoke/${{ matrix.image_name }}:local --version | grep -Fq '"version"' + docker run --rm codex-smoke/${{ matrix.image_name }}:local --help | grep -Fq 'GoNavi CLI' + data_dir="$(mktemp -d)" + trap 'rm -rf "$data_dir"' EXIT + printf '%s\n' '{"connections":[{"id":"cli-docker-smoke","name":"CLI Docker smoke","config":{"id":"cli-docker-smoke","type":"sqlite"}}]}' > "$data_dir/connections.json" + output="$(docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e HOME=/data \ + -e GONAVI_LOG_DIR=/data/logs \ + -v "$data_dir:/data" \ + codex-smoke/${{ matrix.image_name }}:local list-connections)" + grep -Fq '"id":"cli-docker-smoke"' <<< "$output" + grep -Fq '"name":"CLI Docker smoke"' <<< "$output" + - name: Smoke test Web Server image if: matrix.image_name == 'gonavi-web-server' shell: bash @@ -167,6 +197,8 @@ jobs: platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} tags: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }} + build-args: | + VERSION=${{ steps.prep.outputs.version }} outputs: type=image,push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }} cache-to: type=gha,mode=max,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }} @@ -197,6 +229,8 @@ jobs: include: - image_name: gonavi-mcp-server description: GoNavi MCP Server container image + - image_name: gonavi-cli + description: GoNavi command-line interface container image - image_name: gonavi-web-server description: GoNavi Web Server (browser UI) container image - image_name: gonavi-build-env diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3d2a268e..af6288be 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -123,7 +123,7 @@ jobs: } const version = tag.slice(1); - const expectedAssets = [ + const requiredAssets = [ 'LICENSE', 'NOTICE', 'SHA256SUMS', @@ -139,9 +139,39 @@ jobs: `GoNavi-${version}-Windows-Arm64-Installer.msi`, `GoNavi-${version}-Windows-Arm64-Portable.exe`, `GoNavi-${version}-Windows-Arm64-Portable.zip`, + `gonavi-cli_${version}_darwin_amd64.tar.gz`, + `gonavi-cli_${version}_darwin_arm64.tar.gz`, + `gonavi-cli_${version}_linux_amd64.tar.gz`, + `gonavi-cli_${version}_linux_arm64.tar.gz`, + `gonavi-cli_${version}_windows_amd64.zip`, + `gonavi-cli_${version}_windows_arm64.zip`, + `gonavi-cli_${version}_checksums.txt`, + ]; + // Linux AppImage creation is best-effort in release.yml because it + // depends on external linuxdeploy downloads. Accept either variant + // when present without making a transient tooling outage block the + // required tar.gz release contract. + const optionalAssets = [ + `GoNavi-${version}-Linux-Amd64.AppImage`, + `GoNavi-${version}-Linux-Amd64-WebKit41.AppImage`, ]; const assetsByName = new Map(release.assets.map((asset) => [asset.name, asset])); - const missingAssets = expectedAssets.filter((name) => { + const allowedAssetNames = new Set([...requiredAssets, ...optionalAssets]); + const duplicateAssets = release.assets + .map((asset) => asset.name) + .filter((name, index, names) => names.indexOf(name) !== index); + const unexpectedAssets = release.assets + .filter((asset) => !allowedAssetNames.has(asset.name)) + .map((asset) => asset.name); + if (duplicateAssets.length > 0 || unexpectedAssets.length > 0) { + const details = [ + duplicateAssets.length > 0 ? `duplicates: ${[...new Set(duplicateAssets)].join(', ')}` : '', + unexpectedAssets.length > 0 ? `unexpected: ${[...new Set(unexpectedAssets)].join(', ')}` : '', + ].filter(Boolean).join('; '); + core.setFailed(`Release assets are not an exact contract: ${details}`); + return; + } + const missingAssets = requiredAssets.filter((name) => { const asset = assetsByName.get(name); return !asset || asset.state !== 'uploaded' || asset.size <= 0; }); @@ -149,6 +179,14 @@ jobs: core.setFailed(`Release assets are incomplete: ${missingAssets.join(', ')}`); return; } + const invalidOptionalAssets = optionalAssets.filter((name) => { + const asset = assetsByName.get(name); + return asset && (asset.state !== 'uploaded' || asset.size <= 0); + }); + if (invalidOptionalAssets.length > 0) { + core.setFailed(`Optional release assets are incomplete: ${invalidOptionalAssets.join(', ')}`); + return; + } core.setOutput('tag', tag); core.setOutput('release_id', String(release.id)); @@ -157,6 +195,16 @@ jobs: - name: Checkout release tooling uses: actions/checkout@v5 + with: + # Publish the package and mirror payload from the exact immutable tag + # whose assets were validated above, never from the dispatch branch. + ref: ${{ steps.validate.outputs.tag }} + + - name: Validate stable npm CLI package version + env: + RELEASE_TAG: ${{ steps.validate.outputs.tag }} + shell: bash + run: python3 tools/validate-npm-cli-package-version.py --tag "$RELEASE_TAG" - name: Prepare and verify stable mirror payload id: mirror_payload @@ -176,6 +224,49 @@ jobs: driver_dir="${RUNNER_TEMP}/driver-release" mkdir -p "${app_dir}" "${driver_dir}" gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --dir "${app_dir}" + [[ -s "${app_dir}/SHA256SUMS" ]] || { + echo "SHA256SUMS is missing from the GitHub Release" >&2 + exit 1 + } + (cd "${app_dir}" && sha256sum --check SHA256SUMS) + cli_checksum_name="gonavi-cli_${RELEASE_TAG#v}_checksums.txt" + cli_assets=( + "gonavi-cli_${RELEASE_TAG#v}_darwin_amd64.tar.gz" + "gonavi-cli_${RELEASE_TAG#v}_darwin_arm64.tar.gz" + "gonavi-cli_${RELEASE_TAG#v}_linux_amd64.tar.gz" + "gonavi-cli_${RELEASE_TAG#v}_linux_arm64.tar.gz" + "gonavi-cli_${RELEASE_TAG#v}_windows_amd64.zip" + "gonavi-cli_${RELEASE_TAG#v}_windows_arm64.zip" + ) + [[ -s "${app_dir}/${cli_checksum_name}" ]] || { + echo "CLI checksum file is missing from the GitHub Release" >&2 + exit 1 + } + if ! awk 'NF != 2 || length($1) != 64 || $1 !~ /^[0-9a-fA-F]+$/ { exit 1 }' "${app_dir}/${cli_checksum_name}"; then + echo "CLI checksum file contents are invalid" >&2 + exit 1 + fi + mapfile -t listed_cli_assets < <(awk '{ print $2 }' "${app_dir}/${cli_checksum_name}" | sort) + expected_cli_list="$(printf '%s\n' "${cli_assets[@]}" | sort)" + actual_cli_list="$(printf '%s\n' "${listed_cli_assets[@]}")" + if [[ "${actual_cli_list}" != "${expected_cli_list}" ]]; then + echo "CLI checksum file contents are invalid" >&2 + printf 'expected:\n%s\nactual:\n%s\n' "${expected_cli_list}" "${actual_cli_list}" >&2 + exit 1 + fi + (cd "${app_dir}" && sha256sum --check "${cli_checksum_name}") + for cli_asset in "${cli_assets[@]}"; do + grep -Fq " ${cli_asset}" "${app_dir}/SHA256SUMS" || { + echo "CLI asset is missing from SHA256SUMS: ${cli_asset}" >&2 + exit 1 + } + global_digest="$(awk -v name="${cli_asset}" '$2 == name { print $1 }' "${app_dir}/SHA256SUMS")" + cli_digest="$(awk -v name="${cli_asset}" '$2 == name { print $1 }' "${app_dir}/${cli_checksum_name}")" + [[ -n "${global_digest}" && "${global_digest}" == "${cli_digest}" ]] || { + echo "CLI checksums disagree for ${cli_asset}" >&2 + exit 1 + } + done driver_release_json="${RUNNER_TEMP}/driver-release.json" driver_status="$(curl --silent --show-error \ @@ -204,19 +295,12 @@ jobs: esac echo "has_driver_release=${has_driver_release}" >> "$GITHUB_OUTPUT" - jq -e \ - --arg tag "${RELEASE_TAG}" \ - --arg mirror_base "https://download.syngnat.top/gonavi/releases/download" \ - --arg github_base "https://github.com/${GITHUB_REPOSITORY}/releases/download" \ - ' - .tagName == $tag - and (.assets | type == "array" and length > 0) - and all(.assets[]; - (.url | type == "string" and startswith($mirror_base + "/" + $tag + "/")) - and (.apiUrl | type == "string" and startswith($github_base + "/" + $tag + "/")) - and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$")) - ) - ' "${app_dir}/latest.json" >/dev/null + python3 tools/validate-gui-update-manifest.py \ + --channel stable \ + --app-tag "${RELEASE_TAG}" \ + --app-dir "${app_dir}" \ + --manifest "${app_dir}/latest.json" \ + --github-repository "${GITHUB_REPOSITORY}" if [[ "${has_driver_release}" == true ]]; then jq -e '.assets | type == "object" and length > 0' \ "${driver_dir}/GoNavi-DriverAgents-Index.json" >/dev/null @@ -320,6 +404,132 @@ jobs: } core.notice(`Verified GitHub latest release ${tag}`); + - name: Validate npm publication credentials + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + shell: bash + run: | + set -euo pipefail + : "${NPM_TOKEN:?NPM_TOKEN secret is required; refusing to skip npm publication}" + + # npm postinstall downloads the exact CLI archive from GitHub. The + # release must therefore be public and verified before npm publish. + - name: Verify public CLI release assets for npm postinstall + env: + RELEASE_TAG: ${{ steps.validate.outputs.tag }} + shell: bash + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + release_base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}" + cli_assets=( + "gonavi-cli_${version}_darwin_amd64.tar.gz" + "gonavi-cli_${version}_darwin_arm64.tar.gz" + "gonavi-cli_${version}_linux_amd64.tar.gz" + "gonavi-cli_${version}_linux_arm64.tar.gz" + "gonavi-cli_${version}_windows_amd64.zip" + "gonavi-cli_${version}_windows_arm64.zip" + ) + for asset in "${cli_assets[@]}"; do + curl --retry 5 --retry-delay 2 --retry-all-errors --fail --silent --show-error \ + --head --location "${release_base}/${asset}" >/dev/null + done + + - name: Setup Node for npm CLI publication + uses: actions/setup-node@v5 + with: + node-version: '20' + registry-url: https://registry.npmjs.org + + - name: Publish npm CLI package + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + RELEASE_TAG: ${{ steps.validate.outputs.tag }} + shell: bash + run: | + set -euo pipefail + : "${NPM_TOKEN:?NPM_TOKEN secret is required; refusing to skip npm publication}" + version="${RELEASE_TAG#v}" + package_name="@syngnat/gonavi-cli" + existing_metadata="$RUNNER_TEMP/npm-cli-existing-${version}.json" + existing_error="$RUNNER_TEMP/npm-cli-existing-${version}.err" + if npm view "${package_name}@${version}" --json >"$existing_metadata" 2>"$existing_error"; then + echo "npm ${package_name}@${version} already exists; metadata will be verified after this step" + else + status=$? + if ! grep -Eiq '(^|[^0-9])E404([^0-9]|$)|404 Not Found' "$existing_error"; then + cat "$existing_error" >&2 + exit "$status" + fi + npm publish npm/gonavi-cli --access public --ignore-scripts + fi + + - name: Verify npm CLI package metadata + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + RELEASE_TAG: ${{ steps.validate.outputs.tag }} + shell: bash + run: | + set -euo pipefail + : "${NPM_TOKEN:?NPM_TOKEN secret is required; refusing to skip npm metadata verification}" + version="${RELEASE_TAG#v}" + metadata_file="$RUNNER_TEMP/npm-cli-${version}.json" + metadata_error="$RUNNER_TEMP/npm-cli-${version}.err" + for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do + if npm view "@syngnat/gonavi-cli@${version}" --json >"$metadata_file" 2>"$metadata_error"; then + break + fi + if [[ "$attempt" == 12 ]]; then + cat "$metadata_error" >&2 + exit 1 + fi + sleep 5 + done + node - "$metadata_file" "$version" <<'NODE' + const fs = require('node:fs'); + const [metadataPath, expectedVersion] = process.argv.slice(2); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + if (!metadata || metadata.name !== '@syngnat/gonavi-cli' || metadata.version !== expectedVersion) { + throw new Error(`npm metadata identity mismatch: ${JSON.stringify(metadata)}`); + } + if (!metadata.dist || typeof metadata.dist.tarball !== 'string' || !metadata.dist.tarball.startsWith('https://registry.npmjs.org/')) { + throw new Error('npm metadata has no public registry tarball URL'); + } + if (typeof metadata.dist.integrity !== 'string' || !metadata.dist.integrity.startsWith('sha512-')) { + throw new Error('npm metadata has no sha512 integrity value'); + } + console.log(`verified npm metadata for ${metadata.name}@${metadata.version}`); + NODE + + - name: Generate and retain WinGet CLI manifest + env: + RELEASE_TAG: ${{ steps.validate.outputs.tag }} + shell: bash + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + output_dir="$RUNNER_TEMP/winget-cli-manifest" + mkdir -p "$output_dir" + python3 tools/generate-winget-cli-manifest.py \ + --version "$version" \ + --checksums "$RUNNER_TEMP/gonavi-release/gonavi-cli_${version}_checksums.txt" \ + --output "$output_dir/Syngnat.GoNavi.CLI.yaml" + test -s "$output_dir/Syngnat.GoNavi.CLI.yaml" + grep -Fq "PackageIdentifier: Syngnat.GoNavi.CLI" "$output_dir/Syngnat.GoNavi.CLI.yaml" + grep -Fq "PackageVersion: ${version}" "$output_dir/Syngnat.GoNavi.CLI.yaml" + grep -Fq "gonavi-cli_${version}_windows_amd64.zip" "$output_dir/Syngnat.GoNavi.CLI.yaml" + grep -Fq "gonavi-cli_${version}_windows_arm64.zip" "$output_dir/Syngnat.GoNavi.CLI.yaml" + + - name: Upload WinGet CLI manifest artifact + uses: actions/upload-artifact@v6 + with: + name: winget-cli-manifest-${{ steps.validate.outputs.tag }} + path: ${{ runner.temp }}/winget-cli-manifest/Syngnat.GoNavi.CLI.yaml + if-no-files-found: error + retention-days: 90 + - name: Mirror stable release to Gatewaysentry uses: ./.github/actions/publish-vps-mirror with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f2c9124..d2c7a313 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,15 +22,28 @@ jobs: with: go-version-file: 'go.mod' + - name: Validate stable npm CLI package version + shell: bash + run: python3 tools/validate-npm-cli-package-version.py --tag "$GITHUB_REF_NAME" + - name: Test release asset contracts run: | + go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -shellcheck= -pyflakes= \ + .github/workflows/release.yml \ + .github/workflows/dev-build.yml \ + .github/workflows/publish-release.yml bash tools/detect-changed-driver-agents.test.sh bash tools/generate-driver-agent-revisions.test.sh python3 tools/generate-driver-release-manifest.test.py python3 tools/generate-update-latest-manifest.test.py + python3 tools/validate-gui-update-manifest.test.py python3 tools/prepare-vps-release-payload.test.py bash tools/vps-release-commit.test.sh python3 tools/package-driver-release-assets.test.py + python3 tools/cli-release-assets.test.py + python3 tools/npm-cli-wrapper.test.py + python3 tools/validate-npm-cli-package-version.test.py + python3 tools/generate-winget-cli-manifest.test.py python3 tools/legal-release-assets.test.py python3 tools/windows-release-artifacts.test.py python3 tools/generate-release-notes.test.py @@ -224,6 +237,85 @@ jobs: echo "🧭 Driver build/release plumbing changed; preserve global driver rebuild set on every platform" fi + cli: + name: Build CLI ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: amd64 + extension: tar.gz + binary: gonavi + - goos: darwin + goarch: arm64 + extension: tar.gz + binary: gonavi + - goos: linux + goarch: amd64 + extension: tar.gz + binary: gonavi + - goos: linux + goarch: arm64 + extension: tar.gz + binary: gonavi + - goos: windows + goarch: amd64 + extension: zip + binary: gonavi.exe + - goos: windows + goarch: arm64 + extension: zip + binary: gonavi.exe + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Build and package CLI + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + asset="gonavi-cli_${version}_${{ matrix.goos }}_${{ matrix.goarch }}.${{ matrix.extension }}" + stage="${RUNNER_TEMP}/gonavi-cli-${{ matrix.goos }}-${{ matrix.goarch }}" + mkdir -p "$stage" + ./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.goos }}/${{ matrix.goarch }}" + CGO_ENABLED=0 GOOS="${{ matrix.goos }}" GOARCH="${{ matrix.goarch }}" \ + go build -trimpath -ldflags="-s -w -X GoNavi-Wails/internal/cli.Version=${version}" \ + -o "$stage/${{ matrix.binary }}" ./cmd/gonavi + install -m 0644 LICENSE NOTICE "$stage/" + if [[ "${{ matrix.extension }}" == "zip" ]]; then + (cd "$stage" && zip -q -X "${GITHUB_WORKSPACE}/${asset}" "${{ matrix.binary }}" LICENSE NOTICE) + else + tar -C "$stage" -czf "$asset" "${{ matrix.binary }}" LICENSE NOTICE + fi + test -s "$asset" + expected_entries="$(printf '%s\n' "${{ matrix.binary }}" LICENSE NOTICE | sort)" + if [[ "${{ matrix.extension }}" == "zip" ]]; then + actual_entries="$(unzip -Z1 "$asset" | sort)" + else + actual_entries="$(tar -tzf "$asset" | sed 's#^\./##' | sort)" + fi + if [[ "$actual_entries" != "$expected_entries" ]]; then + echo "CLI archive contents are invalid for $asset" >&2 + printf 'expected:\n%s\nactual:\n%s\n' "$expected_entries" "$actual_entries" >&2 + exit 1 + fi + + - name: Upload CLI artifact + uses: actions/upload-artifact@v6 + with: + name: cli-artifact-${{ matrix.goos }}-${{ matrix.goarch }} + path: gonavi-cli_* + if-no-files-found: error + retention-days: 1 + # Phase 1: Build in parallel and output artifacts build: name: Build ${{ matrix.platform }} @@ -769,22 +861,41 @@ jobs: # macOS Packaging - name: Package macOS DMG if: contains(matrix.platform, 'darwin') + env: + MACOS_SIGNING_CERTIFICATE_P12: ${{ secrets.MACOS_SIGNING_CERTIFICATE_P12 }} + MACOS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_SIGNING_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | brew install create-dmg VERSION="${{ github.ref_name }}" VERSION="${VERSION#v}" cd build/bin - APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1) - if [ -z "$APP_PATH" ]; then - echo "❌ 未找到 .app 应用包!" + APP_PATH="./GoNavi.app" + if [ ! -d "$APP_PATH" ]; then + echo "❌ 未找到固定名称 GoNavi.app 应用包!" exit 1 fi - APP_NAME=$(basename "$APP_PATH") + APP_NAME="GoNavi.app" mkdir -p "$APP_NAME/Contents/Resources" cp ../../LICENSE "$APP_NAME/Contents/Resources/LICENSE" cp ../../NOTICE "$APP_NAME/Contents/Resources/NOTICE" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ macOS stable bundle version must be X.Y.Z: ${VERSION}" >&2 + exit 1 + fi + APP_INFO_PLIST="$APP_PATH/Contents/Info.plist" + test -f "$APP_INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${VERSION}" "$APP_INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${VERSION}" "$APP_INFO_PLIST" + plutil -lint "$APP_INFO_PLIST" + [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_INFO_PLIST")" == "$VERSION" ]] + [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_INFO_PLIST")" == "$VERSION" ]] + APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1) if [ -z "$APP_BIN" ]; then echo "❌ 未找到 macOS 应用主程序!" @@ -792,13 +903,50 @@ jobs: fi echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。" - echo "🔏 正在进行 Ad-hoc 签名..." - # 注意:Ad-hoc + hardened runtime(--options runtime)在未配置 entitlements 时, - # 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。 - if command -v xattr >/dev/null 2>&1; then - xattr -cr "$APP_NAME" || true + : "${MACOS_SIGNING_CERTIFICATE_P12:?macOS production signing certificate is required}" + : "${MACOS_SIGNING_CERTIFICATE_PASSWORD:?macOS production signing certificate password is required}" + : "${MACOS_SIGNING_IDENTITY:?macOS Developer ID signing identity is required}" + : "${APPLE_ID:?Apple ID is required for notarization}" + : "${APPLE_APP_SPECIFIC_PASSWORD:?Apple app-specific password is required for notarization}" + : "${APPLE_TEAM_ID:?Apple team ID is required for notarization}" + if [[ ! "$MACOS_SIGNING_IDENTITY" =~ ^Developer\ ID\ Application:\ .+\ \([A-Z0-9]{10}\)$ ]]; then + echo "macOS signing identity must be a Developer ID Application certificate" >&2 + exit 1 fi - codesign --force --deep --sign - "$APP_NAME" + signing_team_id="${MACOS_SIGNING_IDENTITY##*\(}" + signing_team_id="${signing_team_id%\)}" + if [[ "$signing_team_id" != "$APPLE_TEAM_ID" ]]; then + echo "macOS signing identity team does not match APPLE_TEAM_ID" >&2 + exit 1 + fi + keychain_path="$RUNNER_TEMP/gonavi-signing.keychain-db" + security create-keychain -p "$MACOS_SIGNING_CERTIFICATE_PASSWORD" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$MACOS_SIGNING_CERTIFICATE_PASSWORD" "$keychain_path" + certificate_path="$RUNNER_TEMP/gonavi-signing.p12" + if ! printf '%s' "$MACOS_SIGNING_CERTIFICATE_P12" | base64 --decode > "$certificate_path" 2>/dev/null; then + : > "$certificate_path" + printf '%s' "$MACOS_SIGNING_CERTIFICATE_P12" | base64 -D > "$certificate_path" + fi + security import "$certificate_path" -k "$keychain_path" -P "$MACOS_SIGNING_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security list-keychain -d user -s "$keychain_path" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_SIGNING_CERTIFICATE_PASSWORD" "$keychain_path" + if ! security find-identity -v -p codesigning "$keychain_path" | grep -Fq "\"$MACOS_SIGNING_IDENTITY\""; then + echo "Developer ID Application signing identity was not imported" >&2 + exit 1 + fi + verify_team_identifier() { + local target="$1" + local actual_team_id + actual_team_id="$(codesign -dv --verbose=4 "$target" 2>&1 | awk -F= '$1 == "TeamIdentifier" { print $2 }')" + if [[ "$actual_team_id" != "$APPLE_TEAM_ID" ]]; then + echo "unexpected TeamIdentifier for $target: ${actual_team_id:-not set}" >&2 + exit 1 + fi + } + codesign --force --deep --options runtime --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$APP_NAME" + codesign --verify --deep --strict --verbose=4 "$APP_NAME" + verify_team_identifier "$APP_NAME" DMG_NAME="${{ matrix.build_name }}.dmg" FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg" @@ -815,15 +963,47 @@ jobs: "$DMG_NAME" \ "$APP_NAME" + codesign --force --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$DMG_NAME" + codesign --verify --verbose=4 "$DMG_NAME" + verify_team_identifier "$DMG_NAME" + NOTARY_RESULT="$RUNNER_TEMP/gonavi-notary-${{ matrix.arch_name }}.json" + xcrun notarytool submit "$DMG_NAME" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --output-format json > "$NOTARY_RESULT" + python3 - "$NOTARY_RESULT" <<'PY' + import json + import sys + + with open(sys.argv[1], encoding="utf-8") as result_file: + result = json.load(result_file) + status = result.get("status") + if status != "Accepted": + raise SystemExit(f"notarization was not accepted: {status or 'missing status'}") + print(f"notarization accepted: {result.get('id', 'unknown submission')}") + PY + xcrun stapler staple "$DMG_NAME" + xcrun stapler validate "$DMG_NAME" + codesign --verify --verbose=4 "$DMG_NAME" + verify_team_identifier "$DMG_NAME" + VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-release-verify.XXXXXX") hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null - PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1) - if [ -z "$PACKAGED_APP" ]; then - echo "❌ DMG 内未找到 .app 应用包!" + PACKAGED_APP="$VERIFY_MOUNT_DIR/GoNavi.app" + if [ ! -d "$PACKAGED_APP" ]; then + echo "❌ DMG 内未找到固定名称 GoNavi.app 应用包!" hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true exit 1 fi codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP" + verify_team_identifier "$PACKAGED_APP" + PACKAGED_INFO_PLIST="$PACKAGED_APP/Contents/Info.plist" + [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PACKAGED_INFO_PLIST")" == "$VERSION" ]] + [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PACKAGED_INFO_PLIST")" == "$VERSION" ]] + spctl -a -t exec -vv "$PACKAGED_APP" + spctl --assess --type open --context context:primary-signature -vv "$DMG_NAME" hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true mv "$DMG_NAME" "../../$FINAL_NAME" @@ -1059,6 +1239,7 @@ jobs: name: Publish Release needs: - build + - cli - driver_agents runs-on: ubuntu-latest steps: @@ -1077,12 +1258,44 @@ jobs: pattern: build-artifacts-* merge-multiple: true + - name: Download CLI artifacts + uses: actions/download-artifact@v7 + with: + path: cli-assets + pattern: cli-artifact-* + merge-multiple: true + + - name: Validate CLI artifact staging + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + expected=( + "gonavi-cli_${version}_darwin_amd64.tar.gz" + "gonavi-cli_${version}_darwin_arm64.tar.gz" + "gonavi-cli_${version}_linux_amd64.tar.gz" + "gonavi-cli_${version}_linux_arm64.tar.gz" + "gonavi-cli_${version}_windows_amd64.zip" + "gonavi-cli_${version}_windows_arm64.zip" + ) + mapfile -t actual < <(find cli-assets -type f -printf '%P\n' | sort) + expected_list="$(printf '%s\n' "${expected[@]}" | sort)" + actual_list="$(printf '%s\n' "${actual[@]}")" + if [[ "$actual_list" != "$expected_list" ]]; then + echo "CLI artifact set is invalid" >&2 + printf 'expected:\n%s\nactual:\n%s\n' "$expected_list" "$actual_list" >&2 + exit 1 + fi + for asset in "${expected[@]}"; do + test -s "cli-assets/${asset}" + done + - name: Add legal documents shell: bash run: install -m 0644 LICENSE NOTICE release-assets/ - name: List Assets - run: ls -R release-assets + run: ls -R release-assets cli-assets - name: Download Previous Driver Manifest if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all' @@ -1189,26 +1402,121 @@ jobs: rm -rf drivers driver-provenance echo "has_driver_assets=true" >> "$GITHUB_OUTPUT" + - name: Generate CLI checksums + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + expected=( + "gonavi-cli_${version}_darwin_amd64.tar.gz" + "gonavi-cli_${version}_darwin_arm64.tar.gz" + "gonavi-cli_${version}_linux_amd64.tar.gz" + "gonavi-cli_${version}_linux_arm64.tar.gz" + "gonavi-cli_${version}_windows_amd64.zip" + "gonavi-cli_${version}_windows_arm64.zip" + ) + (cd cli-assets && sha256sum "${expected[@]}" > "gonavi-cli_${version}_checksums.txt") + - name: Generate SHA256SUMS shell: bash run: | - cd release-assets - FILES=() + set -euo pipefail + output="release-assets/SHA256SUMS" + count=0 + declare -A seen=() + : > "$output" while IFS= read -r file; do - if [ -n "$file" ]; then - case "$file" in - SHA256SUMS|latest.json|latest-dev.json) continue ;; - esac - FILES+=("$file") + name="$(basename "$file")" + if [[ -n "${seen[$name]:-}" ]]; then + echo "Duplicate release asset name across staging directories: ${name}" >&2 + exit 1 fi - done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort) - if [ ${#FILES[@]} -eq 0 ]; then + seen["$name"]=1 + digest="$(sha256sum "$file" | awk '{ print $1 }')" + printf '%s %s\n' "$digest" "$name" >> "$output" + count=$((count + 1)) + done < <(find release-assets cli-assets -maxdepth 1 -type f \ + ! -name SHA256SUMS ! -name latest.json ! -name latest-dev.json -print | sort) + if [ "$count" -eq 0 ]; then echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS" - : > SHA256SUMS - else - sha256sum "${FILES[@]}" > SHA256SUMS fi + - name: Verify CLI release assets + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + cli_checksum_name="gonavi-cli_${version}_checksums.txt" + expected=( + "gonavi-cli_${version}_darwin_amd64.tar.gz" + "gonavi-cli_${version}_darwin_arm64.tar.gz" + "gonavi-cli_${version}_linux_amd64.tar.gz" + "gonavi-cli_${version}_linux_arm64.tar.gz" + "gonavi-cli_${version}_windows_amd64.zip" + "gonavi-cli_${version}_windows_arm64.zip" + ) + expected_cli_files=("${expected[@]}" "$cli_checksum_name") + mapfile -t actual_cli_files < <(find cli-assets -maxdepth 1 -type f -name 'gonavi-cli_*' -printf '%f\n' | sort) + expected_cli_list="$(printf '%s\n' "${expected_cli_files[@]}" | sort)" + actual_cli_list="$(printf '%s\n' "${actual_cli_files[@]}")" + if [[ "$actual_cli_list" != "$expected_cli_list" ]]; then + echo "CLI release asset set is invalid" >&2 + printf 'expected:\n%s\nactual:\n%s\n' "$expected_cli_list" "$actual_cli_list" >&2 + exit 1 + fi + test -s "cli-assets/${cli_checksum_name}" + if ! awk 'NF != 2 || length($1) != 64 || $1 !~ /^[0-9a-fA-F]+$/ { exit 1 }' "cli-assets/${cli_checksum_name}"; then + echo "CLI checksum file contents are invalid" >&2 + exit 1 + fi + mapfile -t checksum_assets < <(awk '{ print $2 }' "cli-assets/${cli_checksum_name}" | sort) + checksum_asset_list="$(printf '%s\n' "${checksum_assets[@]}")" + expected_checksum_list="$(printf '%s\n' "${expected[@]}" | sort)" + if [[ "$checksum_asset_list" != "$expected_checksum_list" ]]; then + echo "CLI checksum file contents are invalid" >&2 + printf 'expected:\n%s\nactual:\n%s\n' "$expected_checksum_list" "$checksum_asset_list" >&2 + exit 1 + fi + for asset in "${expected[@]}"; do + test -s "cli-assets/${asset}" + grep -Fq " ${asset}" release-assets/SHA256SUMS + global_digest="$(awk -v name="$asset" '$2 == name { print $1 }' release-assets/SHA256SUMS)" + cli_digest="$(awk -v name="$asset" '$2 == name { print $1 }' "cli-assets/${cli_checksum_name}")" + actual_digest="$(sha256sum "cli-assets/${asset}" | awk '{ print $1 }')" + [[ -n "$global_digest" && "$global_digest" == "$cli_digest" && "$cli_digest" == "$actual_digest" ]] + done + grep -Fq " ${cli_checksum_name}" release-assets/SHA256SUMS + (cd cli-assets && sha256sum --check "$cli_checksum_name") + while read -r expected_digest asset; do + if [[ -f "release-assets/${asset}" && -f "cli-assets/${asset}" ]]; then + echo "Duplicate release asset name across staging directories: ${asset}" >&2 + exit 1 + elif [[ -f "release-assets/${asset}" ]]; then + file="release-assets/${asset}" + elif [[ -f "cli-assets/${asset}" ]]; then + file="cli-assets/${asset}" + else + echo "Release asset listed in SHA256SUMS is missing: ${asset}" >&2 + exit 1 + fi + actual_digest="$(sha256sum "$file" | awk '{ print $1 }')" + [[ "$actual_digest" == "$expected_digest" ]] + done < release-assets/SHA256SUMS + + - name: Generate WinGet CLI manifest + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + python3 tools/generate-winget-cli-manifest.py \ + --version "$version" \ + --checksums "cli-assets/gonavi-cli_${version}_checksums.txt" \ + --output "$RUNNER_TEMP/Syngnat.GoNavi.CLI.yaml" + test -s "$RUNNER_TEMP/Syngnat.GoNavi.CLI.yaml" + grep -Fq "PackageIdentifier: Syngnat.GoNavi.CLI" "$RUNNER_TEMP/Syngnat.GoNavi.CLI.yaml" + grep -Fq "gonavi-cli_${version}_windows_amd64.zip" "$RUNNER_TEMP/Syngnat.GoNavi.CLI.yaml" + grep -Fq "gonavi-cli_${version}_windows_arm64.zip" "$RUNNER_TEMP/Syngnat.GoNavi.CLI.yaml" + # 静态更新清单:客户端优先下载 github.com/.../latest/download/latest.json, # 避免终端用户检查更新打爆 api.github.com 未认证配额。 - name: Generate static update manifest (latest.json) @@ -1222,6 +1530,7 @@ jobs: --version "$VERSION" \ --tag "$TAG" \ --channel latest \ + --component gui \ --download-base-url https://download.syngnat.top/gonavi/releases/download \ --output release-assets/latest.json test -s release-assets/latest.json @@ -1306,6 +1615,7 @@ jobs: --version "$VERSION" \ --tag "$TAG" \ --channel latest \ + --component gui \ --download-base-url https://download.syngnat.top/gonavi/releases/download \ --release-notes-file "$CHANGELOG_FILE" \ --output release-assets/latest.json @@ -1316,7 +1626,9 @@ jobs: uses: softprops/action-gh-release@v3 if: startsWith(github.ref, 'refs/tags/') with: - files: release-assets/* + files: | + release-assets/* + cli-assets/* draft: true make_latest: true body_path: ${{ steps.changelog.outputs.changelog_file }} diff --git a/Dockerfile.cli b/Dockerfile.cli new file mode 100644 index 00000000..fa7f71ae --- /dev/null +++ b/Dockerfile.cli @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7 + +FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder + +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev + +WORKDIR /src + +COPY go.mod go.sum ./ +COPY third_party/highgo-pq/go.mod ./third_party/highgo-pq/go.mod +COPY third_party/go-irisnative/go.mod third_party/go-irisnative/go.sum ./third_party/go-irisnative/ +RUN go mod download + +COPY . . +RUN ./tools/generate-driver-agent-revisions.sh --platform "${TARGETOS}/${TARGETARCH}" + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w -X GoNavi-Wails/internal/cli.Version=${VERSION}" -o /out/gonavi ./cmd/gonavi + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tzdata \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --create-home --home-dir /var/lib/gonavi --uid 10001 gonavi \ + && mkdir -p /data /var/lib/gonavi/logs /usr/share/doc/gonavi \ + && chown -R gonavi:gonavi /data /var/lib/gonavi + +COPY --from=builder /out/gonavi /usr/local/bin/gonavi +COPY --from=builder /src/LICENSE /usr/share/doc/gonavi/LICENSE +COPY --from=builder /src/NOTICE /usr/share/doc/gonavi/NOTICE + +ENV HOME=/var/lib/gonavi \ + GONAVI_DATA_ROOT=/data \ + GONAVI_LOG_DIR=/var/lib/gonavi/logs + +VOLUME ["/data"] + +USER gonavi + +ENTRYPOINT ["/usr/local/bin/gonavi"] +CMD ["--help"] diff --git a/README.md b/README.md index f508009c..b5e143a4 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,54 @@ Artifacts → `build/bin`. Grab the latest build from **[Releases](https://github.com/Syngnat/GoNavi/releases)** (macOS AMD64/ARM64 · Windows AMD64 · Linux WebKitGTK 4.0/4.1). +### Standalone CLI + +The standalone CLI release track is being introduced. Dev builds already carry +headless `gonavi` archives, and the first stable CLI release will publish the +same assets under the `gonavi-cli_${VERSION}_${GOOS}_${GOARCH}` namespace with a +matching `gonavi-cli_${VERSION}_checksums.txt` file. Until that stable release +exists, use the dev archives for testing only. Extract an archive and run: + +```bash +gonavi list-connections +gonavi query --conn CONNECTION_ID --sql 'SELECT * FROM orders LIMIT 10' +gonavi export --conn CONNECTION_ID --output orders.csv --sql 'SELECT * FROM orders' +gonavi batch --conn CONNECTION_ID --file migration.sql --allow-write +``` + +After the first stable CLI release, the verified npm wrapper will be published +and will select the matching CLI archive and checksum file for the host +platform. Until the package appears in the npm registry, install from a release +archive instead. Once it is published, install with: + +```bash +npm install -g @syngnat/gonavi-cli +``` + +The stable workflow also generates and retains a checksum-driven WinGet +manifest artifact for `Syngnat.GoNavi.CLI`; it must be accepted by the WinGet +community repository before the package is available there. The existing +desktop GoNavi package is separate. After acceptance, install with: + +```powershell +winget install --id Syngnat.GoNavi.CLI -e +``` + +The CLI shares the active data root with the desktop application: +`GONAVI_DATA_ROOT` first, then `~/.gonavi/storage_root.json`, then `~/.gonavi`. +Use a `0600` owner-only `--connection-file` for transient credentials; secrets +are never accepted as command-line flags. Query output defaults to JSONL, while +diagnostics go to stderr. Mutating SQL also requires the stored AI safety level, +connection protections, and `--allow-write`. + +For Linux containers, copy `docker.cli.env.example`, set the host data root and +your host UID/GID, then run the CLI against the same mounted `/data` directory: + +```bash +cp docker.cli.env.example docker.cli.env +docker compose --env-file docker.cli.env -f docker-compose.cli.yml run --rm gonavi-cli list-connections +``` + --- ## 🌐 Web Server (Experimental) @@ -418,15 +466,9 @@ Tracker / discussion: [#672](https://github.com/Syngnat/GoNavi/issues/672).
-macOS: “App is damaged and can’t be opened” +macOS Gatekeeper -Without Apple notarization, Gatekeeper may block the app: - -```bash -sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app -``` - -Or right-click → Open (Control-click flow). Move the app to **Applications** first. +The next stable macOS release is gated on Developer ID signing and Apple notarization. Download the DMG from the matching GitHub Release, move GoNavi to **Applications**, and open it normally. Do not remove Gatekeeper quarantine metadata.
diff --git a/README.zh-CN.md b/README.zh-CN.md index 43455a40..89e3d9e0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -260,6 +260,50 @@ wails build -clean # 发布前推荐 前往 **[Releases](https://github.com/Syngnat/GoNavi/releases)** 下载 (macOS AMD64/ARM64 · Windows AMD64 · Linux WebKitGTK 4.0/4.1)。 +### 独立 CLI + +独立 CLI 发布链正在首发。dev 构建已经包含无界面的 `gonavi` 归档;首个 +稳定 CLI Release 将使用 `gonavi-cli_${VERSION}_${GOOS}_${GOARCH}` 命名空间, +并附带对应的 `gonavi-cli_${VERSION}_checksums.txt`。稳定版首发前请只将 dev +归档用于测试。解压归档后可直接运行: + +```bash +gonavi list-connections +gonavi query --conn CONNECTION_ID --sql 'SELECT * FROM orders LIMIT 10' +gonavi export --conn CONNECTION_ID --output orders.csv --sql 'SELECT * FROM orders' +gonavi batch --conn CONNECTION_ID --file migration.sql --allow-write +``` + +首个稳定 CLI Release 发布后,经过校验的 npm 平台包装器会按当前平台下载对应 +归档,并先验证独立的 checksum 文件。在 npm 包实际出现前,请改用 Release +归档安装;npm 包发布后可执行: + +```bash +npm install -g @syngnat/gonavi-cli +``` + +稳定发布工作流还会生成并保留使用同一 checksum 的 WinGet manifest artifact, +包 ID 为 `Syngnat.GoNavi.CLI`。该 manifest 还需通过 WinGet 社区仓库审核后才可 +安装;它与既有桌面版 GoNavi 包相互独立。审核通过后可执行: + +```powershell +winget install --id Syngnat.GoNavi.CLI -e +``` + +CLI 与桌面版共用活动数据根,优先级为:`GONAVI_DATA_ROOT`、 +`~/.gonavi/storage_root.json`、`~/.gonavi`。临时凭据只能放在属主可读的 +`0600` `--connection-file` 中,不能通过命令行参数传入。查询默认输出 JSONL, +诊断信息输出到 stderr。变更类 SQL 还必须同时通过已保存的 AI 安全级别、连接保护和 +`--allow-write` 确认。 + +Linux 容器可复制 `docker.cli.env.example`,设置宿主数据目录及 UID/GID, +再通过同一个挂载到 `/data` 的目录运行: + +```bash +cp docker.cli.env.example docker.cli.env +docker compose --env-file docker.cli.env -f docker-compose.cli.yml run --rm gonavi-cli list-connections +``` + --- ## 🌐 Web Server(实验中) @@ -418,15 +462,9 @@ Bootstrapper 在完全断网环境会失败。请改用 **Evergreen Standalone I
-macOS:提示「应用已损坏,无法打开」 +macOS Gatekeeper -未做 Apple Notarization 时,Gatekeeper 可能拦截: - -```bash -sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app -``` - -也可在 Finder 中右键打开。建议先移到「应用程序」。 +下一稳定版 macOS DMG 的发布门禁包含 Developer ID 签名和 Apple 公证。请从对应 GitHub Release 下载,移到「应用程序」后正常打开;不要移除 Gatekeeper 的隔离属性。
diff --git a/cmd/gonavi/main.go b/cmd/gonavi/main.go new file mode 100644 index 00000000..a0091d9d --- /dev/null +++ b/cmd/gonavi/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "GoNavi-Wails/internal/cli" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + os.Exit(cli.Run(ctx, os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/docker-compose.cli.yml b/docker-compose.cli.yml new file mode 100644 index 00000000..fb4d0c1f --- /dev/null +++ b/docker-compose.cli.yml @@ -0,0 +1,15 @@ +services: + gonavi-cli: + image: ghcr.io/syngnat/gonavi-cli:latest + build: + context: . + dockerfile: Dockerfile.cli + environment: + GONAVI_DATA_ROOT: /data + GONAVI_LOG_DIR: /data/logs + HOME: /data + volumes: + - ${GONAVI_HOST_DATA_ROOT:?Set GONAVI_HOST_DATA_ROOT to the GoNavi data directory}:/data + user: "${GONAVI_CONTAINER_UID:?Set GONAVI_CONTAINER_UID to the host UID}:${GONAVI_CONTAINER_GID:?Set GONAVI_CONTAINER_GID to the host GID}" + entrypoint: ["/usr/local/bin/gonavi"] + command: ["--help"] diff --git a/docker.cli.env.example b/docker.cli.env.example new file mode 100644 index 00000000..9444e212 --- /dev/null +++ b/docker.cli.env.example @@ -0,0 +1,10 @@ +# Active GoNavi data directory on the host. It can be shared with the desktop +# application and should contain connections.json and daily_secrets.json. +GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data + +# Run the CLI with the host identity so the bind-mounted data root remains +# writable without making it world writable. On Linux, set these with: +# export GONAVI_CONTAINER_UID="$(id -u)" +# export GONAVI_CONTAINER_GID="$(id -g)" +GONAVI_CONTAINER_UID= +GONAVI_CONTAINER_GID= diff --git a/internal/app/app.go b/internal/app/app.go index 9131e914..a24f6708 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -30,6 +30,7 @@ import ( syncbackend "GoNavi-Wails/internal/sync" "GoNavi-Wails/internal/synccdc" "GoNavi-Wails/internal/syncjob" + "GoNavi-Wails/internal/uievents" "GoNavi-Wails/shared/i18n" "github.com/google/uuid" "golang.org/x/sync/singleflight" @@ -162,6 +163,7 @@ type managedSQLTransaction struct { type App struct { ctx context.Context webRuntime bool + headlessRuntime bool startedAt time.Time dbCache map[string]cachedDatabase // Cache for DB connections connectFailures map[string]cachedConnectFailure @@ -259,6 +261,16 @@ func NewWebApp() *App { return app } +// NewHeadlessApp creates an App with only the lifecycle resources required by +// non-GUI callers such as the CLI and MCP server. +func NewHeadlessApp(ctx context.Context, configDir string) (*App, error) { + app := NewApp() + if err := InitializeHeadlessLifecycle(app, ctx, configDir); err != nil { + return nil, err + } + return app, nil +} + func NewAppWithSecretStore(store secretstore.SecretStore) *App { if store == nil { store = secretstore.NewUnavailableStore("secret store unavailable") @@ -387,6 +399,52 @@ func InitializeLifecycle(a *App, ctx context.Context) { a.startup(ctx) } +type headlessEventEmitter struct{} + +func (headlessEventEmitter) Emit(string, ...any) {} + +// InitializeHeadlessLifecycle attaches a non-Wails context and starts the +// shared config, import-job, proxy, and SQL-audit services. It intentionally +// excludes desktop window APIs, keep-alives, cloud backup, and data-sync +// schedulers. +func InitializeHeadlessLifecycle(a *App, ctx context.Context, configDir string) error { + if a == nil { + return errors.New("application is unavailable") + } + if ctx == nil { + ctx = context.Background() + } + configDir = strings.TrimSpace(configDir) + if configDir == "" { + configDir = resolveAppConfigDir() + } + if err := os.MkdirAll(configDir, 0o755); err != nil { + return err + } + + a.headlessRuntime = true + a.ctx = uievents.WithEmitter(ctx, headlessEventEmitter{}) + a.startedAt = time.Now() + a.configDir = configDir + db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(configDir)) + logger.Init() + if err := migrateDailySecretsIfNeeded(a); err != nil { + logger.Warnf("无头运行时迁移日常密文失败:%v", err) + } + // A headless process can run alongside the desktop app. Opening the shared + // store is required by batch commands, but crash recovery is desktop-owned: + // without a process lease it cannot distinguish stale jobs from work that a + // live GUI process is still executing. + if _, err := a.ensureImportJobStore(); err != nil { + a.Shutdown() + return fmt.Errorf("initialize SQL-file job store: %w", err) + } + a.loadPersistedGlobalProxy() + a.activateSQLAudit() + logger.Infof("无头运行时启动完成") + return nil +} + // HandleFrontendDomReady 在 WebView 每次完成导航(含前端刷新)后调用。 // // SQL 编辑器待提交事务的 ID 只存在于前端组件内存,刷新后无法再被提交或回滚, @@ -1222,6 +1280,39 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro return a.getDatabaseWithPing(config, false) } +type databaseWaitResult struct { + instance db.Database + err error +} + +// getDatabaseWithContext makes waiting for cache lookup, singleflight, and a +// driver's non-context-aware Connect call cancellable. The physical Connect +// may finish in the worker after the caller leaves; the normal flight and +// shutdown checks still decide whether that instance may enter the cache. +func (a *App) getDatabaseWithContext(ctx context.Context, config connection.ConnectionConfig, forcePing bool) (db.Database, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return nil, err + } + resultCh := make(chan databaseWaitResult, 1) + go func() { + instance, err := a.getDatabaseWithPing(config, forcePing) + resultCh <- databaseWaitResult{instance: instance, err: err} + }() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-resultCh: + if err := ctx.Err(); err != nil { + return nil, err + } + return result.instance, result.err + } +} + func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Database, error) { effectiveConfig, err := a.resolveEffectiveConnectionConfig(config) if err != nil { diff --git a/internal/app/application_icon_darwin.go b/internal/app/application_icon_darwin.go index a758008f..72c01ed3 100644 --- a/internal/app/application_icon_darwin.go +++ b/internal/app/application_icon_darwin.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && cgo package app diff --git a/internal/app/application_icon_stub.go b/internal/app/application_icon_stub.go index b80a84db..695fa48e 100644 --- a/internal/app/application_icon_stub.go +++ b/internal/app/application_icon_stub.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin || !cgo package app diff --git a/internal/app/application_icon_stub_test.go b/internal/app/application_icon_stub_test.go index 75c02b4d..25a6a5ac 100644 --- a/internal/app/application_icon_stub_test.go +++ b/internal/app/application_icon_stub_test.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin || !cgo package app diff --git a/internal/app/cli_query_executor.go b/internal/app/cli_query_executor.go new file mode 100644 index 00000000..66a6d792 --- /dev/null +++ b/internal/app/cli_query_executor.go @@ -0,0 +1,36 @@ +package app + +import ( + "context" + + "GoNavi-Wails/internal/connection" +) + +// CLIQueryExecutor is the dedicated headless query entry point. Its audit +// source is backend-owned so command-line arguments cannot impersonate it. +type CLIQueryExecutor struct { + app *App +} + +func NewCLIQueryExecutor(app *App) *CLIQueryExecutor { + return &CLIQueryExecutor{app: app} +} + +func (executor *CLIQueryExecutor) DBQueryMulti( + ctx context.Context, + config connection.ConnectionConfig, + dbName string, + query string, + queryID string, +) connection.QueryResult { + if executor == nil || executor.app == nil { + return connection.QueryResult{Success: false, Message: "CLI query executor is unavailable"} + } + return executor.app.dbQueryMulti(config, dbName, query, queryID, dbQueryMultiAuditOptions{ + auditAll: true, + auditWrites: true, + source: "cli", + executionContext: ctx, + classifyConnectionErrors: true, + }) +} diff --git a/internal/app/cloud_backup.go b/internal/app/cloud_backup.go index 04dfb2e5..7e2969aa 100644 --- a/internal/app/cloud_backup.go +++ b/internal/app/cloud_backup.go @@ -585,15 +585,30 @@ func (a *App) buildCloudBackupPayload(config CloudBackupConfig) ([]byte, error) if len(selected) == 0 { return nil, errors.New("select at least one cloud backup category") } - connections := connectionPackagePayload{} - if _, ok := selected[CloudBackupCategoryConnections]; ok { - var err error - connections, err = a.buildConnectionPackagePayload(nil, nil) - if err != nil { - return nil, err + var connections connectionPackagePayload + var files []cloudBackupFile + buildSnapshot := func(repo *savedConnectionRepository) error { + if _, ok := selected[CloudBackupCategoryConnections]; ok { + var err error + connections, err = a.buildConnectionPackagePayloadUnlocked(repo, nil, nil) + if err != nil { + return err + } } + var err error + files, err = a.collectCloudBackupFiles(selected) + return err + } + + var err error + if cloudBackupSelectionTouchesSavedConnectionState(selected) { + repo := a.savedConnectionRepository() + err = repo.withWriteLock(func() error { + return buildSnapshot(repo) + }) + } else { + err = buildSnapshot(nil) } - files, err := a.collectCloudBackupFiles(selected) if err != nil { return nil, err } @@ -601,6 +616,14 @@ func (a *App) buildCloudBackupPayload(config CloudBackupConfig) ([]byte, error) return json.Marshal(payload) } +func cloudBackupSelectionTouchesSavedConnectionState(selected map[string]struct{}) bool { + if _, ok := selected[CloudBackupCategoryConnections]; ok { + return true + } + _, ok := selected[CloudBackupCategoryDailySecrets] + return ok +} + func (a *App) collectCloudBackupFiles(selected map[string]struct{}) ([]cloudBackupFile, error) { root := strings.TrimSpace(a.configDir) if root == "" { @@ -934,57 +957,75 @@ func (a *App) CloudBackupRestore(request CloudBackupRestoreRequest) (CloudBackup } } - var connectionSnapshot cloudBackupConnectionFilesSnapshot - if restoreConnections { - connectionSnapshot, err = a.captureCloudBackupConnectionFilesSnapshot() - if err != nil { - return CloudBackupRestorePreview{}, err - } - settingsFiles, err = a.preserveLocalOnlyConnectionSecrets(settingsFiles, payload.Connections) - if err != nil { - return CloudBackupRestorePreview{}, err - } - } if err := a.consumeCloudBackupRestoreConfirmationToken(request.ConfirmationToken, payload); err != nil { return CloudBackupRestorePreview{}, err } - rollbackSettings := func() error { return nil } - if len(settingsFiles) > 0 { - rollbackSettings, err = a.restoreCloudBackupFiles(settingsFiles) - if err != nil { - return CloudBackupRestorePreview{}, err - } - } - rollbackMutations := func() error { - var rollbackErr error + repo := a.savedConnectionRepository() + restoreMutations := func() error { + filesToRestore := append([]cloudBackupFile(nil), settingsFiles...) + var connectionSnapshot cloudBackupConnectionFilesSnapshot if restoreConnections { - rollbackErr = errors.Join(rollbackErr, connectionSnapshot.restore(a)) + var snapshotErr error + connectionSnapshot, snapshotErr = captureCloudBackupConnectionFilesSnapshotUnlocked(repo) + if snapshotErr != nil { + return snapshotErr + } + filesToRestore, snapshotErr = a.preserveLocalOnlyConnectionSecrets(filesToRestore, payload.Connections) + if snapshotErr != nil { + return snapshotErr + } } - return errors.Join(rollbackErr, rollbackSettings()) + + rollbackSettings := func() error { return nil } + if len(filesToRestore) > 0 { + var restoreErr error + rollbackSettings, restoreErr = a.restoreCloudBackupFilesUnlocked(filesToRestore) + if restoreErr != nil { + return restoreErr + } + } + rollbackMutations := func() error { + var rollbackErr error + if restoreConnections { + rollbackErr = errors.Join(rollbackErr, connectionSnapshot.restoreUnlocked(repo)) + } + return errors.Join(rollbackErr, rollbackSettings()) + } + + if restoreConnections { + if _, importErr := a.importConnectionPackagePayloadUnlocked(repo, payload.Connections); importErr != nil { + if rollbackErr := rollbackMutations(); rollbackErr != nil { + return fmt.Errorf("restore connections failed: %w (rollback failed: %v)", importErr, rollbackErr) + } + return importErr + } + } + if len(savedQueryFiles) > 0 { + currentConnections, listErr := repo.List() + if listErr != nil { + if rollbackErr := rollbackMutations(); rollbackErr != nil { + return fmt.Errorf("restore saved queries failed: %w (rollback failed: %v)", listErr, rollbackErr) + } + return listErr + } + if _, importErr := a.savedQueryRepository().Import(savedQueryPayload, currentConnections); importErr != nil { + if rollbackErr := rollbackMutations(); rollbackErr != nil { + return fmt.Errorf("restore saved queries failed: %w (rollback failed: %v)", importErr, rollbackErr) + } + return importErr + } + } + return nil } - if restoreConnections { - if _, err := a.importConnectionPackagePayload(payload.Connections); err != nil { - if rollbackErr := rollbackMutations(); rollbackErr != nil { - return CloudBackupRestorePreview{}, fmt.Errorf("restore connections failed: %w (rollback failed: %v)", err, rollbackErr) - } - return CloudBackupRestorePreview{}, err - } + + if restoreConnections || cloudBackupFilesTouchSavedConnectionState(settingsFiles) { + err = repo.withWriteLock(restoreMutations) + } else { + err = restoreMutations() } - if len(savedQueryFiles) > 0 { - currentConnections, listErr := a.savedConnectionRepository().List() - if listErr != nil { - if rollbackErr := rollbackMutations(); rollbackErr != nil { - return CloudBackupRestorePreview{}, fmt.Errorf("restore saved queries failed: %w (rollback failed: %v)", listErr, rollbackErr) - } - return CloudBackupRestorePreview{}, listErr - } - if _, err := a.savedQueryRepository().Import(savedQueryPayload, currentConnections); err != nil { - if rollbackErr := rollbackMutations(); rollbackErr != nil { - return CloudBackupRestorePreview{}, fmt.Errorf("restore saved queries failed: %w (rollback failed: %v)", err, rollbackErr) - } - return CloudBackupRestorePreview{}, err - } + if err != nil { + return CloudBackupRestorePreview{}, err } a.markCloudBackupDirty() @@ -1162,6 +1203,16 @@ func buildCloudBackupSavedQueryImportPayload(files []cloudBackupFile) (connectio func (a *App) captureCloudBackupConnectionFilesSnapshot() (cloudBackupConnectionFilesSnapshot, error) { repo := a.savedConnectionRepository() + var snapshot cloudBackupConnectionFilesSnapshot + err := repo.withWriteLock(func() error { + var captureErr error + snapshot, captureErr = captureCloudBackupConnectionFilesSnapshotUnlocked(repo) + return captureErr + }) + return snapshot, err +} + +func captureCloudBackupConnectionFilesSnapshotUnlocked(repo *savedConnectionRepository) (cloudBackupConnectionFilesSnapshot, error) { snapshot := cloudBackupConnectionFilesSnapshot{} var err error snapshot.connectionsData, snapshot.connectionsExists, err = readOptionalFile(repo.connectionsPath()) @@ -1177,6 +1228,12 @@ func (a *App) captureCloudBackupConnectionFilesSnapshot() (cloudBackupConnection func (snapshot cloudBackupConnectionFilesSnapshot) restore(a *App) error { repo := a.savedConnectionRepository() + return repo.withWriteLock(func() error { + return snapshot.restoreUnlocked(repo) + }) +} + +func (snapshot cloudBackupConnectionFilesSnapshot) restoreUnlocked(repo *savedConnectionRepository) error { return errors.Join( restoreCloudBackupOptionalFile(repo.connectionsPath(), snapshot.connectionsExists, snapshot.connectionsData, 0o644), restoreCloudBackupOptionalFile(repo.dailySecrets().Path(), snapshot.dailySecretsExists, snapshot.dailySecretsData, 0o600), @@ -1338,6 +1395,35 @@ func cloudBackupRestoreRequiresRestart(files []cloudBackupFile) bool { } func (a *App) restoreCloudBackupFiles(files []cloudBackupFile) (func() error, error) { + if !cloudBackupFilesTouchSavedConnectionState(files) { + return a.restoreCloudBackupFilesUnlocked(files) + } + + repo := a.savedConnectionRepository() + var rollbackUnlocked func() error + err := repo.withWriteLock(func() error { + var restoreErr error + rollbackUnlocked, restoreErr = a.restoreCloudBackupFilesUnlocked(files) + return restoreErr + }) + if err != nil { + return nil, err + } + return func() error { + return repo.withWriteLock(rollbackUnlocked) + }, nil +} + +func cloudBackupFilesTouchSavedConnectionState(files []cloudBackupFile) bool { + for _, file := range files { + if filepath.Clean(filepath.FromSlash(file.Path)) == "daily_secrets.json" { + return true + } + } + return false +} + +func (a *App) restoreCloudBackupFilesUnlocked(files []cloudBackupFile) (func() error, error) { root := strings.TrimSpace(a.configDir) if root == "" { root = resolveAppConfigDir() @@ -1451,6 +1537,9 @@ func (a *App) initializeCloudBackup(ctx context.Context) { } func (a *App) restartCloudBackupScheduler() { + if a == nil || a.headlessRuntime { + return + } a.cloudBackupSchedulerMu.Lock() if a.cloudBackupSchedulerCancel != nil { a.cloudBackupSchedulerCancel() @@ -1499,6 +1588,9 @@ func cloudBackupScheduleInterval(schedule string) time.Duration { } func (a *App) markCloudBackupDirty() { + if a == nil || a.headlessRuntime { + return + } config, err := a.loadCloudBackupConfig() if err != nil || !config.Enabled { return @@ -1532,6 +1624,9 @@ func (a *App) clearCloudBackupDirty(revision uint64) { } func (a *App) shutdownCloudBackup() { + if a == nil || a.headlessRuntime { + return + } a.cloudBackupSchedulerMu.Lock() if a.cloudBackupSchedulerCancel != nil { a.cloudBackupSchedulerCancel() diff --git a/internal/app/cloud_backup_test.go b/internal/app/cloud_backup_test.go index 8aeafe7e..30c9c746 100644 --- a/internal/app/cloud_backup_test.go +++ b/internal/app/cloud_backup_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "GoNavi-Wails/internal/appdata" "GoNavi-Wails/internal/cloudbackup" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/secretstore" @@ -961,6 +962,217 @@ func TestCloudBackupRestoreFilesProtectsSecretsAndSupportsRollback(t *testing.T) } } +func TestCloudBackupConnectionSnapshotWaitsForSharedStorageLock(t *testing.T) { + application := NewAppWithSecretStore(newFakeAppSecretStore()) + application.configDir = t.TempDir() + repository := application.savedConnectionRepository() + if _, err := repository.Save(connection.SavedConnectionInput{ + ID: "snapshot-connection", Name: "Snapshot connection", + Config: connection.ConnectionConfig{ID: "snapshot-connection", Type: "mysql", Password: "snapshot-secret"}, + }); err != nil { + t.Fatalf("seed connection: %v", err) + } + + lock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(application.configDir)) + if err != nil { + t.Fatalf("acquire shared storage lock: %v", err) + } + released := false + t.Cleanup(func() { + if !released { + _ = lock.Close() + } + }) + + type snapshotResult struct { + snapshot cloudBackupConnectionFilesSnapshot + err error + } + finished := make(chan snapshotResult, 1) + go func() { + snapshot, captureErr := application.captureCloudBackupConnectionFilesSnapshot() + finished <- snapshotResult{snapshot: snapshot, err: captureErr} + }() + select { + case result := <-finished: + t.Fatalf("snapshot acquired shared lock before release: %#v", result) + case <-time.After(50 * time.Millisecond): + } + if _, err := repository.saveUnlocked(connection.SavedConnectionInput{ + ID: "snapshot-connection", Name: "Snapshot connection after lock", + Config: connection.ConnectionConfig{ID: "snapshot-connection", Type: "mysql", Host: "db-after-lock", Password: "after-lock-secret"}, + }); err != nil { + t.Fatalf("write paired connection snapshot while holding lock: %v", err) + } + if err := lock.Close(); err != nil { + t.Fatalf("release shared storage lock: %v", err) + } + released = true + select { + case result := <-finished: + if result.err != nil { + t.Fatalf("snapshot after lock release: %v", result.err) + } + if len(result.snapshot.connectionsData) == 0 || len(result.snapshot.dailySecretsData) == 0 { + t.Fatalf("snapshot did not capture paired connection files: %#v", result.snapshot) + } + if !strings.Contains(string(result.snapshot.connectionsData), "db-after-lock") || !strings.Contains(string(result.snapshot.dailySecretsData), "after-lock-secret") { + t.Fatalf("snapshot mixed pre-lock metadata and secret revisions: connections=%s secrets=%s", result.snapshot.connectionsData, result.snapshot.dailySecretsData) + } + case <-time.After(2 * time.Second): + t.Fatal("snapshot did not acquire shared lock after release") + } +} + +func TestCloudBackupPayloadKeepsConnectionAndSecretSnapshotTogether(t *testing.T) { + application := NewAppWithSecretStore(newFakeAppSecretStore()) + application.configDir = t.TempDir() + repository := application.savedConnectionRepository() + if _, err := repository.Save(connection.SavedConnectionInput{ + ID: "payload-connection", Name: "Payload connection", + Config: connection.ConnectionConfig{ID: "payload-connection", Type: "mysql", Host: "db-before-lock", Password: "before-lock-secret"}, + }); err != nil { + t.Fatalf("seed connection: %v", err) + } + lock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(application.configDir)) + if err != nil { + t.Fatalf("acquire shared storage lock: %v", err) + } + released := false + t.Cleanup(func() { + if !released { + _ = lock.Close() + } + }) + finished := make(chan struct { + data []byte + err error + }, 1) + go func() { + data, buildErr := application.buildCloudBackupPayload(CloudBackupConfig{ + BackupCategories: []string{CloudBackupCategoryConnections, CloudBackupCategoryDailySecrets}, + }) + finished <- struct { + data []byte + err error + }{data: data, err: buildErr} + }() + select { + case result := <-finished: + t.Fatalf("cloud backup payload acquired shared lock before release: %v", result.err) + case <-time.After(50 * time.Millisecond): + } + if _, err := repository.saveUnlocked(connection.SavedConnectionInput{ + ID: "payload-connection", Name: "Payload connection after lock", + Config: connection.ConnectionConfig{ID: "payload-connection", Type: "mysql", Host: "db-after-lock", Password: "after-lock-secret"}, + }); err != nil { + t.Fatalf("write paired payload snapshot while holding lock: %v", err) + } + if err := lock.Close(); err != nil { + t.Fatalf("release shared storage lock: %v", err) + } + released = true + select { + case result := <-finished: + if result.err != nil { + t.Fatalf("build cloud backup payload: %v", result.err) + } + var payload cloudBackupPayload + if err := json.Unmarshal(result.data, &payload); err != nil { + t.Fatalf("decode cloud backup payload: %v", err) + } + if len(payload.Connections.Connections) != 1 || payload.Connections.Connections[0].Config.Host != "db-after-lock" || payload.Connections.Connections[0].Secrets.Password != "after-lock-secret" { + t.Fatalf("cloud backup payload mixed connection revisions: %#v", payload.Connections.Connections) + } + var dailySecrets []cloudBackupFile + for _, file := range payload.Files { + if file.Path == "daily_secrets.json" { + dailySecrets = append(dailySecrets, file) + } + } + if len(dailySecrets) != 1 || !strings.Contains(string(dailySecrets[0].Data), "after-lock-secret") { + t.Fatalf("cloud backup payload did not include paired daily secrets: %#v", dailySecrets) + } + case <-time.After(2 * time.Second): + t.Fatal("cloud backup payload did not acquire shared lock after release") + } +} + +func TestCloudBackupRestoreFilesWaitsForSharedStorageLock(t *testing.T) { + application := NewAppWithSecretStore(newFakeAppSecretStore()) + application.configDir = t.TempDir() + secretPath := filepath.Join(application.configDir, "daily_secrets.json") + if err := os.WriteFile(secretPath, []byte(`{"old":true}`), 0o600); err != nil { + t.Fatalf("write original daily secrets: %v", err) + } + + lock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(application.configDir)) + if err != nil { + t.Fatalf("acquire shared storage lock: %v", err) + } + released := false + t.Cleanup(func() { + if !released { + _ = lock.Close() + } + }) + type restoreResult struct { + rollback func() error + err error + } + finished := make(chan restoreResult, 1) + go func() { + rollback, restoreErr := application.restoreCloudBackupFiles([]cloudBackupFile{{Path: "daily_secrets.json", Data: []byte(`{"new":true}`)}}) + finished <- restoreResult{rollback: rollback, err: restoreErr} + }() + select { + case result := <-finished: + t.Fatalf("restore acquired shared lock before release: %#v", result.err) + case <-time.After(50 * time.Millisecond): + } + if err := lock.Close(); err != nil { + t.Fatalf("release shared storage lock: %v", err) + } + released = true + select { + case result := <-finished: + if result.err != nil { + t.Fatalf("restore after lock release: %v", result.err) + } + if data, readErr := os.ReadFile(secretPath); readErr != nil || string(data) != `{"new":true}` { + t.Fatalf("restored daily secrets mismatch: data=%q err=%v", data, readErr) + } + if result.rollback == nil { + t.Fatal("restore did not return rollback") + } + rollbackLock, lockErr := appdata.AcquireFileLock(appdata.SharedStorageLockPath(application.configDir)) + if lockErr != nil { + t.Fatalf("acquire rollback shared storage lock: %v", lockErr) + } + rollbackDone := make(chan error, 1) + go func() { rollbackDone <- result.rollback() }() + select { + case rollbackErr := <-rollbackDone: + _ = rollbackLock.Close() + t.Fatalf("rollback acquired shared lock before release: %v", rollbackErr) + case <-time.After(50 * time.Millisecond): + } + if lockErr := rollbackLock.Close(); lockErr != nil { + t.Fatalf("release rollback shared storage lock: %v", lockErr) + } + select { + case rollbackErr := <-rollbackDone: + if rollbackErr != nil { + t.Fatalf("rollback after lock release: %v", rollbackErr) + } + case <-time.After(2 * time.Second): + t.Fatal("rollback did not acquire shared lock after release") + } + case <-time.After(2 * time.Second): + t.Fatal("restore did not acquire shared lock after release") + } +} + func TestCloudBackupRestoreRequiresRestartForRuntimeSettingsAndCredentials(t *testing.T) { for _, path := range []string{"ai_config.json", "global_proxy.json", "daily_secrets.json", "update_channel.json"} { if !cloudBackupRestoreRequiresRestart([]cloudBackupFile{{Path: path}}) { diff --git a/internal/app/connection_package_transfer.go b/internal/app/connection_package_transfer.go index e1f5c964..ecbc6e71 100644 --- a/internal/app/connection_package_transfer.go +++ b/internal/app/connection_package_transfer.go @@ -5,6 +5,7 @@ import ( "encoding/xml" "errors" "fmt" + "os" "strconv" "strings" "time" @@ -139,6 +140,24 @@ func (a *App) buildConnectionPackagePayload( connectionIDs []string, ) (connectionPackagePayload, error) { repo := a.savedConnectionRepository() + var payload connectionPackagePayload + err := repo.withWriteLock(func() error { + var buildErr error + payload, buildErr = a.buildConnectionPackagePayloadUnlocked(repo, redisDbAliases, connectionIDs) + return buildErr + }) + return payload, err +} + +// buildConnectionPackagePayloadUnlocked must run while the saved-connection +// shared storage lock is held. Keeping the metadata and daily-secret reads in +// one critical section prevents a package or cloud snapshot from pairing two +// different connection revisions. +func (a *App) buildConnectionPackagePayloadUnlocked( + repo *savedConnectionRepository, + redisDbAliases map[string]map[string]string, + connectionIDs []string, +) (connectionPackagePayload, error) { items, err := repo.List() if err != nil { return connectionPackagePayload{}, err @@ -325,24 +344,43 @@ func normalizeImportedSavedConnectionInput(input connection.SavedConnectionInput func (a *App) importSavedConnectionsAtomically(inputs []connection.SavedConnectionInput) ([]connection.SavedConnectionView, error) { repo := a.savedConnectionRepository() - normalizedInputs := make([]connection.SavedConnectionInput, 0, len(inputs)) - for _, input := range inputs { - normalizedInputs = append(normalizedInputs, normalizeImportedSavedConnectionInput(input)) + var result []connection.SavedConnectionView + err := repo.withWriteLock(func() error { + var importErr error + result, importErr = a.importSavedConnectionsUnlocked(repo, inputs) + return importErr + }) + if err != nil { + return nil, err } - finalInputs := dedupeImportedSavedConnectionInputs(normalizedInputs) + return result, nil +} + +// importSavedConnectionsUnlocked applies an import while the caller owns the +// saved-connection shared storage lock. Cloud restore uses this form so its +// pre-restore snapshot, import, and any rollback remain one atomic operation. +func (a *App) importSavedConnectionsUnlocked(repo *savedConnectionRepository, inputs []connection.SavedConnectionInput) ([]connection.SavedConnectionView, error) { + preparedInputs := make([]connection.SavedConnectionInput, 0, len(inputs)) + for _, input := range inputs { + prepared, err := prepareSavedConnectionInput(normalizeImportedSavedConnectionInput(input)) + if err != nil { + return nil, err + } + preparedInputs = append(preparedInputs, prepared) + } + finalInputs := dedupeImportedSavedConnectionInputs(preparedInputs) + result := make([]connection.SavedConnectionView, 0, len(finalInputs)) rollbackSnapshot, err := captureConnectionImportRollbackSnapshot(a, finalInputs) if err != nil { return nil, err } - - result := make([]connection.SavedConnectionView, 0, len(finalInputs)) for _, input := range finalInputs { - view, err := repo.Save(input) - if err != nil { - if rollbackErr := rollbackSnapshot.restore(a); rollbackErr != nil { - return nil, errors.Join(err, fmt.Errorf("restore connection import rollback: %w", rollbackErr)) + view, saveErr := repo.saveUnlocked(input) + if saveErr != nil { + if rollbackErr := rollbackSnapshot.restoreUnlocked(a); rollbackErr != nil { + return nil, errors.Join(saveErr, fmt.Errorf("restore connection import rollback: %w", rollbackErr)) } - return nil, err + return nil, saveErr } result = append(result, view) } @@ -357,6 +395,14 @@ func (a *App) importConnectionPackagePayload(payload connectionPackagePayload) ( return a.importSavedConnectionsAtomically(inputs) } +func (a *App) importConnectionPackagePayloadUnlocked(repo *savedConnectionRepository, payload connectionPackagePayload) ([]connection.SavedConnectionView, error) { + inputs := make([]connection.SavedConnectionInput, 0, len(payload.Connections)) + for _, item := range payload.Connections { + inputs = append(inputs, newSavedConnectionInputFromPackageItem(item)) + } + return a.importSavedConnectionsUnlocked(repo, inputs) +} + func connectionPackageImportResultFromViews(views []connection.SavedConnectionView, redisDbAliases map[string]map[string]string) ConnectionPackageImportResult { return ConnectionPackageImportResult{ Connections: sanitizeSavedConnectionViews(views), @@ -471,10 +517,12 @@ func (a *App) ImportConnectionsPayload(raw string, password string) (ConnectionP } type connectionPackageImportRollbackSnapshot struct { - connectionsFileExists bool - connectionsFileData []byte - connectionSecrets map[string]securityUpdateSecretSnapshot - connectionCleanupRefs []string + connectionsFileExists bool + connectionsFileData []byte + dailySecretsFileExists bool + dailySecretsFileData []byte + connectionSecrets map[string]securityUpdateSecretSnapshot + connectionCleanupRefs []string } func captureConnectionImportRollbackSnapshot(a *App, inputs []connection.SavedConnectionInput) (connectionPackageImportRollbackSnapshot, error) { @@ -489,6 +537,12 @@ func captureConnectionImportRollbackSnapshot(a *App, inputs []connection.SavedCo } snapshot.connectionsFileExists = connectionFileExists snapshot.connectionsFileData = connectionFileData + dailySecretsFileData, dailySecretsFileExists, err := readOptionalFile(repo.dailySecrets().Path()) + if err != nil { + return snapshot, err + } + snapshot.dailySecretsFileExists = dailySecretsFileExists + snapshot.dailySecretsFileData = dailySecretsFileData existingConnections, err := repo.load() if err != nil { @@ -547,14 +601,22 @@ func captureConnectionImportRollbackSnapshot(a *App, inputs []connection.SavedCo return snapshot, nil } -func (s connectionPackageImportRollbackSnapshot) restore(a *App) error { +func (s connectionPackageImportRollbackSnapshot) restoreUnlocked(a *App) error { repo := a.savedConnectionRepository() - if err := restoreOptionalFile(repo.connectionsPath(), s.connectionsFileExists, s.connectionsFileData); err != nil { - return err + var restoreErr error + if err := repo.dailySecrets().RestoreUnlocked(s.dailySecretsFileExists, s.dailySecretsFileData); err != nil { + restoreErr = errors.Join(restoreErr, err) + } + if s.connectionsFileExists { + if err := writeSavedConnectionsFileAtomic(repo.connectionsPath(), s.connectionsFileData); err != nil { + restoreErr = errors.Join(restoreErr, err) + } + } else if err := os.Remove(repo.connectionsPath()); err != nil && !os.IsNotExist(err) { + restoreErr = errors.Join(restoreErr, err) } for ref, secretSnapshot := range s.connectionSecrets { if err := restoreSecurityUpdateSecretSnapshot(a.secretStore, ref, secretSnapshot); err != nil { - return err + restoreErr = errors.Join(restoreErr, err) } } for _, ref := range s.connectionCleanupRefs { @@ -562,10 +624,10 @@ func (s connectionPackageImportRollbackSnapshot) restore(a *App) error { continue } if err := deleteSecurityUpdateSecretRef(a.secretStore, ref); err != nil { - return err + restoreErr = errors.Join(restoreErr, err) } } - return nil + return restoreErr } // --- MySQL Workbench XML import --- diff --git a/internal/app/connection_package_transfer_test.go b/internal/app/connection_package_transfer_test.go index e67867a3..af9dad69 100644 --- a/internal/app/connection_package_transfer_test.go +++ b/internal/app/connection_package_transfer_test.go @@ -1,6 +1,7 @@ package app import ( + "bytes" "encoding/json" "errors" "os" @@ -457,6 +458,86 @@ func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T) } } +func TestImportSavedConnectionsRollbackRestoresDailySecretsAndMetadata(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + _, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "conn-existing", + Name: "Existing", + Config: connection.ConnectionConfig{ + ID: "conn-existing", + Type: "postgres", + Host: "old.example.test", + Password: "old-secret", + }, + }) + if err != nil { + t.Fatalf("seed SaveConnection: %v", err) + } + repository := app.savedConnectionRepository() + connectionsBefore, err := os.ReadFile(repository.connectionsPath()) + if err != nil { + t.Fatalf("read connections snapshot: %v", err) + } + secretsBefore, err := os.ReadFile(repository.dailySecrets().Path()) + if err != nil { + t.Fatalf("read daily secrets snapshot: %v", err) + } + + originalWriter := writeSavedConnectionsFileAtomicFunc + t.Cleanup(func() { writeSavedConnectionsFileAtomicFunc = originalWriter }) + writes := 0 + writeSavedConnectionsFileAtomicFunc = func(path string, payload []byte) error { + writes++ + if writes == 2 { + return errors.New("injected second connection metadata write failure") + } + return writeSavedConnectionsFileAtomic(path, payload) + } + + _, err = app.importConnectionPackagePayload(connectionPackagePayload{Connections: []connectionPackageItem{ + { + ID: "conn-existing", + Name: "Updated", + Config: connection.ConnectionConfig{ID: "conn-existing", Type: "postgres", Host: "new.example.test"}, + Secrets: connectionSecretBundle{Password: "new-secret"}, + }, + { + ID: "conn-new", + Name: "New", + Config: connection.ConnectionConfig{ID: "conn-new", Type: "mysql", Host: "new-db.example.test"}, + Secrets: connectionSecretBundle{Password: "new-db-secret"}, + }, + }}) + if err == nil || !strings.Contains(err.Error(), "injected second") { + t.Fatalf("import error = %v, want injected metadata failure", err) + } + + connectionsAfter, err := os.ReadFile(repository.connectionsPath()) + if err != nil { + t.Fatalf("read restored connections: %v", err) + } + secretsAfter, err := os.ReadFile(repository.dailySecrets().Path()) + if err != nil { + t.Fatalf("read restored daily secrets: %v", err) + } + if !bytes.Equal(connectionsAfter, connectionsBefore) { + t.Fatalf("connections metadata was not restored:\nbefore=%s\nafter=%s", connectionsBefore, connectionsAfter) + } + if !bytes.Equal(secretsAfter, secretsBefore) { + t.Fatalf("daily secrets were not restored:\nbefore=%s\nafter=%s", secretsBefore, secretsAfter) + } + + saved, err := app.GetSavedConnections() + if err != nil || len(saved) != 1 || saved[0].Name != "Existing" || saved[0].Config.Host != "old.example.test" { + t.Fatalf("restored connections = %#v err=%v", saved, err) + } + resolved, err := app.resolveConnectionSecrets(saved[0].Config) + if err != nil || resolved.Password != "old-secret" { + t.Fatalf("restored existing secret = %q err=%v", resolved.Password, err) + } +} + func TestImportLegacyConnectionsRollbackRemovesGeneratedSecretRefs(t *testing.T) { withTestGOOS(t, "linux") diff --git a/internal/app/connection_secret_params.go b/internal/app/connection_secret_params.go index 34f6bae7..bdb2a48a 100644 --- a/internal/app/connection_secret_params.go +++ b/internal/app/connection_secret_params.go @@ -34,6 +34,15 @@ func partitionConnectionParams(raw string) (public string, sensitive string) { return publicValues.Encode(), sensitiveValues.Encode() } +// HasSensitiveConnectionParams reports whether raw contains credential-like +// parameters. It intentionally exposes no key or value so command adapters can +// reject argv secrets without retaining or logging them. Malformed input is +// treated as sensitive by partitionConnectionParams and therefore fails closed. +func HasSensitiveConnectionParams(raw string) bool { + _, sensitive := partitionConnectionParams(raw) + return strings.TrimSpace(sensitive) != "" +} + func isSensitiveConnectionParamKey(key string) bool { compact := strings.ToLower(strings.TrimSpace(key)) compact = strings.NewReplacer("_", "", "-", "", ".", "", " ", "").Replace(compact) diff --git a/internal/app/connection_secret_params_test.go b/internal/app/connection_secret_params_test.go new file mode 100644 index 00000000..fa8266bd --- /dev/null +++ b/internal/app/connection_secret_params_test.go @@ -0,0 +1,23 @@ +package app + +import "testing" + +func TestHasSensitiveConnectionParamsFailsClosed(t *testing.T) { + tests := []struct { + name string + raw string + want bool + }{ + {name: "public parameters", raw: "application_name=gonavi&connect_timeout=10", want: false}, + {name: "password", raw: "application_name=gonavi&password=secret", want: true}, + {name: "access token", raw: "access_token=secret", want: true}, + {name: "malformed", raw: "token=secret;broken", want: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := HasSensitiveConnectionParams(test.raw); got != test.want { + t.Fatalf("HasSensitiveConnectionParams(%q) = %t, want %t", test.raw, got, test.want) + } + }) + } +} diff --git a/internal/app/connection_secret_resolution.go b/internal/app/connection_secret_resolution.go index 37e7702c..4dac0d7d 100644 --- a/internal/app/connection_secret_resolution.go +++ b/internal/app/connection_secret_resolution.go @@ -11,32 +11,47 @@ import ( ) func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (connection.ConnectionConfig, error) { + if config.HasResolvedSavedSnapshot() { + return config, nil + } if strings.TrimSpace(config.ID) == "" { return config, nil } repo := newSavedConnectionRepository(a.configDir, a.secretStore) - view, err := repo.Find(config.ID) + view, bundle, err := repo.loadConnectionSnapshot(config.ID) if err != nil { if shouldFallbackToInlineConnectionSecrets(config, err) { - return config, nil + base := config + if strings.TrimSpace(view.ID) != "" && (a.headlessRuntime || connectionMetadataLooksEmpty(base)) { + base = view.Config + } + resolved := mergeInlineConnectionSecrets(base, config) + if a.headlessRuntime { + resolved = resolved.WithResolvedSavedSnapshot() + } + return resolved, nil } return config, a.normalizeConnectionSecretResolutionError(config, err) } base := config - if connectionMetadataLooksEmpty(base) { + if a.headlessRuntime { + // Headless callers resolve a stable saved ID. Always pair the current + // metadata with the secret bundle captured under the same lock instead + // of trusting a view that may have been read before a concurrent save. base = view.Config - } - bundle, err := repo.loadSecretBundle(view) - if err != nil { - if shouldFallbackToInlineConnectionSecrets(config, err) { - return mergeInlineConnectionSecrets(base, config), nil + if config.QueryTimeout > 0 { + base.QueryTimeout = config.QueryTimeout } - return base, a.normalizeConnectionSecretResolutionError(base, err) + } else if connectionMetadataLooksEmpty(base) { + base = view.Config } resolved := mergeConnectionSecretBundleIntoConfig(base, bundle) resolved.ID = view.ID + if a.headlessRuntime { + resolved = resolved.WithResolvedSavedSnapshot() + } return resolved, nil } diff --git a/internal/app/daily_secret_migration.go b/internal/app/daily_secret_migration.go index 508033db..d4bcedfc 100644 --- a/internal/app/daily_secret_migration.go +++ b/internal/app/daily_secret_migration.go @@ -51,54 +51,51 @@ func migrateSavedConnectionSecrets(repo *savedConnectionRepository, legacy legac return nil } - // 与 Save/Delete/Duplicate 共用同一把包级锁:本函数直接走 load/saveAll 的读改写序列, - // 不经过 Save,因此不会重入。 - savedConnectionsMu.Lock() - defer savedConnectionsMu.Unlock() - - items, err := repo.load() - if err != nil { - return err - } - - changed := false - for index, item := range items { - bundle, found, err := repo.resolveMigrationConnectionBundle(item, legacy) + return repo.withWriteLock(func() error { + items, err := repo.load() if err != nil { return err } - if found && bundle.hasAny() { - if err := repo.saveSecretBundle(item.ID, bundle); err != nil { + + changed := false + for index, item := range items { + bundle, found, err := repo.resolveMigrationConnectionBundle(item, legacy) + if err != nil { return err } - normalized := item - normalized.Config = stripConnectionSecretFields(normalized.Config) - normalized.SecretRef = "" - applyConnectionBundleFlags(&normalized, bundle) - items[index] = normalized + if found && bundle.hasAny() { + if err := repo.saveSecretBundle(item.ID, bundle); err != nil { + return err + } + normalized := item + normalized.Config = stripConnectionSecretFields(normalized.Config) + normalized.SecretRef = "" + applyConnectionBundleFlags(&normalized, bundle) + items[index] = normalized + changed = true + continue + } + + inline := extractConnectionSecretBundle(item.Config) + if !inline.hasAny() && !savedConnectionViewHasSecrets(item) && strings.TrimSpace(item.SecretRef) == "" { + continue + } + if err := repo.deleteSecretBundle(item.ID); err != nil { + return err + } + item.Config = stripConnectionSecretFields(item.Config) + item.SecretRef = "" + applyConnectionBundleFlags(&item, connectionSecretBundle{}) + items[index] = item changed = true - continue + logger.Warnf("日常连接密文未回填:连接=%s,已停用旧系统密文引用,请重新保存连接密码", strings.TrimSpace(item.ID)) } - inline := extractConnectionSecretBundle(item.Config) - if !inline.hasAny() && !savedConnectionViewHasSecrets(item) && strings.TrimSpace(item.SecretRef) == "" { - continue + if changed { + return repo.saveAll(items) } - if err := repo.deleteSecretBundle(item.ID); err != nil { - return err - } - item.Config = stripConnectionSecretFields(item.Config) - item.SecretRef = "" - applyConnectionBundleFlags(&item, connectionSecretBundle{}) - items[index] = item - changed = true - logger.Warnf("日常连接密文未回填:连接=%s,已停用旧系统密文引用,请重新保存连接密码", strings.TrimSpace(item.ID)) - } - - if changed { - return repo.saveAll(items) - } - return nil + return nil + }) } func (r *savedConnectionRepository) resolveMigrationConnectionBundle(view connection.SavedConnectionView, legacy legacyWebKitVisibleConfig) (connectionSecretBundle, bool, error) { diff --git a/internal/app/headless.go b/internal/app/headless.go new file mode 100644 index 00000000..95892819 --- /dev/null +++ b/internal/app/headless.go @@ -0,0 +1,449 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "GoNavi-Wails/internal/appdata" + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/sqlaudit" +) + +// HeadlessRuntimeOptions configures the narrow runtime used by command-line +// callers. An empty DataRoot follows the normal GoNavi root resolution rules. +type HeadlessRuntimeOptions struct { + DataRoot string +} + +// HeadlessQueryOptions contains the explicit policy acknowledgement required +// before a command-line query may contain mutating statements. +type HeadlessQueryOptions struct { + AllowMutating bool +} + +const ( + headlessResultErrorKindPolicy = "policy" + headlessResultErrorKindConnection = "connection" +) + +func headlessPolicyFailure(err error) connection.QueryResult { + message := "headless SQL policy denied the request" + if err != nil { + message = err.Error() + } + return connection.QueryResult{ + Success: false, + Message: message, + Data: map[string]any{"errorKind": headlessResultErrorKindPolicy}, + } +} + +func headlessConnectionFailure(err error) connection.QueryResult { + message := "headless database connection failed" + if err != nil { + message = err.Error() + } + data := map[string]any{"errorKind": headlessResultErrorKindConnection} + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + data["cancelled"] = true + } + return connection.QueryResult{ + Success: false, + Message: message, + Data: data, + } +} + +// HeadlessSQLTransactionMode controls how a CLI SQL file is applied. The +// default is single so a batch is rejected unless its atomicity can be proven. +type HeadlessSQLTransactionMode string + +const ( + HeadlessSQLTransactionModeSingle HeadlessSQLTransactionMode = "single" + HeadlessSQLTransactionModeOff HeadlessSQLTransactionMode = "off" +) + +// HeadlessSQLFileOptions controls a streaming SQL-file execution. +type HeadlessSQLFileOptions struct { + AllowMutating bool + ContinueOnError bool + TransactionMode HeadlessSQLTransactionMode + JobID string + MaxStatementSize int64 +} + +// AmbiguousConnectionNameError tells a command-line caller to retry with one +// of the stable connection IDs instead of guessing which matching name to use. +type AmbiguousConnectionNameError struct { + Name string + IDs []string +} + +func (err *AmbiguousConnectionNameError) Error() string { + if err == nil { + return "connection name is ambiguous" + } + return fmt.Sprintf("connection name %q is ambiguous; use one of: %s", err.Name, strings.Join(err.IDs, ", ")) +} + +// HeadlessRuntime owns only the backend resources needed for CLI work. It +// deliberately does not start Wails, connection keep-alives, cloud backup, or +// data-sync schedulers. +type HeadlessRuntime struct { + app *App + executor *CLIQueryExecutor +} + +func NewHeadlessRuntime(ctx context.Context, options HeadlessRuntimeOptions) (*HeadlessRuntime, error) { + if ctx == nil { + ctx = context.Background() + } + + root := strings.TrimSpace(options.DataRoot) + var err error + if root == "" { + root, err = appdata.ResolveActiveRoot() + } else { + root, err = appdata.ResolveRoot(root) + } + if err != nil { + return nil, err + } + a, err := NewHeadlessApp(ctx, root) + if err != nil { + return nil, err + } + + return &HeadlessRuntime{app: a, executor: NewCLIQueryExecutor(a)}, nil +} + +func (runtime *HeadlessRuntime) Close() { + if runtime == nil || runtime.app == nil { + return + } + runtime.app.Shutdown() +} + +func (runtime *HeadlessRuntime) GetSavedConnections() ([]connection.SavedConnectionView, error) { + if runtime == nil || runtime.app == nil { + return nil, errors.New("headless runtime is unavailable") + } + return runtime.app.GetSavedConnections() +} + +func (runtime *HeadlessRuntime) SaveConnection(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) { + if runtime == nil || runtime.app == nil { + return connection.SavedConnectionView{}, errors.New("headless runtime is unavailable") + } + return runtime.app.SaveConnection(input) +} + +func (runtime *HeadlessRuntime) ImportLegacyConnections(items []connection.LegacySavedConnection) ([]connection.SavedConnectionView, error) { + if runtime == nil || runtime.app == nil { + return nil, errors.New("headless runtime is unavailable") + } + return runtime.app.ImportLegacyConnections(items) +} + +// ResolveSavedConnection accepts a stable ID first, then an exact unique name. +// It never resolves or returns the stored secret bundle. +func (runtime *HeadlessRuntime) ResolveSavedConnection(selector string) (connection.SavedConnectionView, error) { + selector = strings.TrimSpace(selector) + if selector == "" { + return connection.SavedConnectionView{}, errors.New("connection selector is required") + } + connections, err := runtime.GetSavedConnections() + if err != nil { + return connection.SavedConnectionView{}, err + } + for _, item := range connections { + if item.ID == selector { + item.Config.ID = item.ID + return item, nil + } + } + + matches := make([]connection.SavedConnectionView, 0, 1) + for _, item := range connections { + if item.Name == selector { + matches = append(matches, item) + } + } + switch len(matches) { + case 0: + return connection.SavedConnectionView{}, fmt.Errorf("saved connection not found: %s", selector) + case 1: + matches[0].Config.ID = matches[0].ID + return matches[0], nil + default: + ids := make([]string, 0, len(matches)) + for _, item := range matches { + ids = append(ids, item.ID) + } + return connection.SavedConnectionView{}, &AmbiguousConnectionNameError{Name: selector, IDs: ids} + } +} + +func (runtime *HeadlessRuntime) InspectSQL(config connection.ConnectionConfig, sql string) SQLInspection { + return InspectSQL(resolveDDLDBType(config), sql) +} + +func (runtime *HeadlessRuntime) Query(ctx context.Context, config connection.ConnectionConfig, dbName string, sql string, options HeadlessQueryOptions) connection.QueryResult { + if runtime == nil || runtime.app == nil || runtime.executor == nil { + return connection.QueryResult{Success: false, Message: "headless runtime is unavailable"} + } + sql = strings.TrimSpace(sql) + if sql == "" { + return connection.QueryResult{Success: false, Message: "SQL is required"} + } + var err error + config, err = runtime.app.resolveConnectionSecrets(config) + if err != nil { + return headlessConnectionFailure(err) + } + if err := runtime.authorizeHeadlessSQL(config, sql, options.AllowMutating, false); err != nil { + return headlessPolicyFailure(err) + } + queryID := runtime.app.GenerateQueryID() + return runtime.executor.DBQueryMulti(ctx, config, dbName, sql, queryID) +} + +// ExportQueryToPath performs a SELECT/WITH export without any desktop dialog. +// The temporary file is synced and atomically replaced only after the query and +// writer have both completed successfully. +func (runtime *HeadlessRuntime) ExportQueryToPath(ctx context.Context, config connection.ConnectionConfig, dbName string, sql string, filePath string, options ExportFileOptions, overwrite bool) (result connection.QueryResult) { + if runtime == nil || runtime.app == nil { + return connection.QueryResult{Success: false, Message: "headless runtime is unavailable"} + } + if ctx == nil { + ctx = context.Background() + } + sql = strings.TrimSpace(sql) + options = normalizeExportFileOptions("", options) + if sql == "" { + return connection.QueryResult{Success: false, Message: runtime.app.appText("file.backend.error.query_required", nil)} + } + config, err := runtime.app.resolveConnectionSecrets(config) + if err != nil { + return headlessConnectionFailure(err) + } + inspection := runtime.InspectSQL(config, sql) + if !looksLikeSelectOrWith(sql) || inspection.StatementCount != 1 || !inspection.ReadOnly { + return headlessPolicyFailure(errors.New(runtime.app.appText("file.backend.error.select_with_query_required", nil))) + } + if err := validateExportColumnsSelection(options); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if options.Format == "" { + return connection.QueryResult{Success: false, Message: "export format is required"} + } + if options.Format != "sql" { + if err := verifyOptionalDriverAgentReadyForExport(config); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + } + target, err := resolveHeadlessExportTarget(filePath, options.Format, overwrite) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if err := ctx.Err(); err != nil { + return buildQueryExecutionFailure(ctx, err, err.Error(), "") + } + + queryID := runtime.app.GenerateQueryID() + runConfig := normalizeRunConfig(config, dbName) + startedAt := time.Now() + defer func() { + result.QueryID = queryID + runtime.app.recordSQLAuditQuery(sqlAuditQueryInput{ + Config: runConfig, + Database: dbName, + DBType: resolveDDLDBType(runConfig), + QueryID: queryID, + SQL: sql, + Source: "cli", + CommitMode: "auto", + Duration: time.Since(startedAt), + Result: result, + }) + }() + + dbInst, err := runtime.app.getDatabaseWithContext(ctx, runConfig, false) + if err != nil { + return headlessConnectionFailure(err) + } + + directory := filepath.Dir(target) + temporary, err := os.CreateTemp(directory, ".gonavi-export-*.tmp") + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + temporaryPath := temporary.Name() + cleanupTemporary := true + defer func() { + if temporary != nil { + _ = temporary.Close() + } + if cleanupTemporary { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0o600); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + rows, columns, err := exportQueryResultToFileWithContext(ctx, temporary, dbInst, runConfig, sql, options, nil) + if err != nil { + return buildQueryExecutionFailure(ctx, err, err.Error(), queryID) + } + if err := temporary.Sync(); err != nil { + return buildQueryExecutionFailure(ctx, err, err.Error(), queryID) + } + if err := closeExportFile(temporary); err != nil { + return buildQueryExecutionFailure(ctx, err, err.Error(), queryID) + } + temporary = nil + publish := atomicReplaceSQLAuditFile + if !overwrite { + publish = atomicCreateSQLAuditFile + } + if err := publish(temporaryPath, target); err != nil { + return buildQueryExecutionFailure(ctx, err, err.Error(), queryID) + } + cleanupTemporary = false + return connection.QueryResult{ + Success: true, + Data: map[string]any{ + "path": target, + "rows": rows, + "columns": columns, + }, + } +} + +func (runtime *HeadlessRuntime) ExecuteSQLFile(ctx context.Context, config connection.ConnectionConfig, dbName string, filePath string, options HeadlessSQLFileOptions) connection.QueryResult { + if runtime == nil || runtime.app == nil { + return connection.QueryResult{Success: false, Message: "headless runtime is unavailable"} + } + transactionMode, err := normalizeHeadlessSQLTransactionMode(options.TransactionMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if !options.AllowMutating { + return headlessPolicyFailure(errors.New("SQL-file execution requires --allow-write")) + } + config, err = runtime.app.resolveConnectionSecrets(config) + if err != nil { + return headlessConnectionFailure(err) + } + if transactionMode == HeadlessSQLTransactionModeSingle && !isSQLFileSingleTransactionDialectSupported(resolveDDLDBType(config)) { + return headlessPolicyFailure(errors.New("single-transaction SQL-file execution cannot prove atomicity for this database type; use --transaction=off")) + } + if options.ContinueOnError && transactionMode != HeadlessSQLTransactionModeOff { + return connection.QueryResult{Success: false, Message: "--continue-on-error requires --transaction=off"} + } + for _, protection := range []connectionProtectionKey{ + connectionProtectionScriptExecution, + connectionProtectionDataImport, + } { + if err := ensureConnectionAllowsActionWithText( + config, + protection, + "connection.backend.action.import_data", + runtime.app.appText, + ); err != nil { + return headlessPolicyFailure(err) + } + } + safetyLevel := runtime.GetSQLSafetyLevel() + statementGuard := func(_ int, statement string) error { + if err := runtime.authorizeHeadlessSQLAtSafetyLevel(config, statement, true, false, safetyLevel); err != nil { + return err + } + if transactionMode == HeadlessSQLTransactionModeSingle { + if err := validateSQLFileSingleTransactionStatement(resolveDDLDBType(config), statement); err != nil { + return &HeadlessSQLPolicyError{Message: err.Error()} + } + } + return nil + } + return runtime.app.executeSQLFileWithStatementLimitPolicyContextWithPolicy( + ctx, + config, + dbName, + filePath, + options.JobID, + options.ContinueOnError, + options.MaxStatementSize, + false, + "cli", + sqlFileExecutionPolicy{ + TransactionMode: sqlFileTransactionMode(transactionMode), + // Headless policy must reject every disallowed statement before the + // database is opened, including when transaction mode is off. + ForceFullPreflight: true, + StatementGuard: statementGuard, + }, + ) +} + +func normalizeHeadlessSQLTransactionMode(mode HeadlessSQLTransactionMode) (HeadlessSQLTransactionMode, error) { + switch HeadlessSQLTransactionMode(strings.ToLower(strings.TrimSpace(string(mode)))) { + case "", HeadlessSQLTransactionModeSingle: + return HeadlessSQLTransactionModeSingle, nil + case HeadlessSQLTransactionModeOff: + return HeadlessSQLTransactionModeOff, nil + default: + return "", fmt.Errorf("unsupported SQL-file transaction mode %q", mode) + } +} + +func (runtime *HeadlessRuntime) ExportSQLAuditToPath(filter sqlaudit.Filter, format string, filePath string, overwrite bool) connection.QueryResult { + if runtime == nil || runtime.app == nil { + return connection.QueryResult{Success: false, Message: "headless runtime is unavailable"} + } + content, normalizedFormat, err := runtime.app.buildSQLAuditExport(filter, format) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + target, err := resolveHeadlessExportTarget(filePath, normalizedFormat, overwrite) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if err := runtime.app.validateSQLAuditExportTarget(target); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + var writeErr error + if overwrite { + writeErr = writeSQLAuditExportAtomically(target, content) + } else { + writeErr = writeSQLAuditExportAtomicallyNoReplace(target, content) + } + if err := writeErr; err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: map[string]string{"path": target}} +} + +func resolveHeadlessExportTarget(filePath string, format string, overwrite bool) (string, error) { + target := normalizeExportTargetPath(filePath, format) + if target == "" { + return "", errors.New("output file path is required") + } + if info, err := os.Stat(target); err == nil { + if info.IsDir() { + return "", fmt.Errorf("output path is a directory: %s", target) + } + if !overwrite { + return "", fmt.Errorf("output file already exists: %s (use --force to replace it)", target) + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + return target, nil +} diff --git a/internal/app/headless_lifecycle_test.go b/internal/app/headless_lifecycle_test.go new file mode 100644 index 00000000..55a90f08 --- /dev/null +++ b/internal/app/headless_lifecycle_test.go @@ -0,0 +1,261 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/importjob" +) + +type headlessSecretCaptureDB struct { + sqlAuditTestDatabase + connectedConfig connection.ConnectionConfig +} + +func (database *headlessSecretCaptureDB) Connect(config connection.ConnectionConfig) error { + database.connectedConfig = config + database.connected = true + return nil +} + +func TestHeadlessLifecycleSkipsDesktopSchedulers(t *testing.T) { + application, err := NewHeadlessApp(context.Background(), t.TempDir()) + if err != nil { + t.Fatalf("NewHeadlessApp returned error: %v", err) + } + defer application.Shutdown() + + if !application.headlessRuntime { + t.Fatal("headless lifecycle did not mark the App as headless") + } + if application.keepAliveCancel != nil || application.keepAliveDone != nil { + t.Fatal("headless lifecycle must not start the database keep-alive loop") + } + if application.dataSyncJobManager != nil || application.dataSyncJobStore != nil { + t.Fatal("headless lifecycle must not start data-sync jobs") + } + if application.cloudBackupSchedulerCancel != nil { + t.Fatal("headless lifecycle must not start cloud-backup scheduling") + } + if application.sqlAuditStore == nil || !application.sqlAuditRuntimeActive { + t.Fatal("headless lifecycle must activate the shared SQL audit store") + } +} + +func TestHeadlessLifecycleDoesNotRecoverImportJobsOwnedByAnotherProcess(t *testing.T) { + root := t.TempDir() + store, err := importjob.Open(filepath.Join(root, "import-jobs")) + if err != nil { + t.Fatalf("open import job store: %v", err) + } + created, err := store.Put(importjob.Job{ + ID: "desktop-running", + Kind: importjob.KindSQL, + Status: importjob.StatusRunning, + }) + if err != nil { + t.Fatalf("create running import job: %v", err) + } + + application, err := NewHeadlessApp(context.Background(), root) + if err != nil { + t.Fatalf("NewHeadlessApp returned error: %v", err) + } + defer application.Shutdown() + + current, err := store.Get(created.ID) + if err != nil { + t.Fatalf("read running import job: %v", err) + } + if current.Status != importjob.StatusRunning || current.Revision != created.Revision { + t.Fatalf("headless startup changed another process job: status=%s revision=%d, want status=%s revision=%d", current.Status, current.Revision, created.Status, created.Revision) + } +} + +func TestHeadlessLifecycleNeverStartsImmediateOrOnExitCloudBackup(t *testing.T) { + requests := make(chan struct{}, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests <- struct{}{} + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + application := NewAppWithSecretStore(newFakeAppSecretStore()) + if err := InitializeHeadlessLifecycle(application, context.Background(), t.TempDir()); err != nil { + t.Fatalf("InitializeHeadlessLifecycle returned error: %v", err) + } + closed := false + defer func() { + if !closed { + application.Shutdown() + } + }() + + baseConfig := CloudBackupConfigInput{ + Enabled: true, + Provider: CloudBackupProviderWebDAV, + WebDAVEndpoint: server.URL, + WebDAVFilePath: "backup.gonavi", + WebDAVUsername: "user", + WebDAVPassword: "pass", + EncryptionPassword: "backup-pass", + } + baseConfig.Schedule = CloudBackupScheduleImmediate + if _, err := application.SaveCloudBackupConfig(baseConfig); err != nil { + t.Fatalf("save immediate cloud backup config: %v", err) + } + if _, err := application.SaveConnection(connection.SavedConnectionInput{ + ID: "headless-no-cloud-sync", + Name: "Headless no cloud sync", + Config: connection.ConnectionConfig{ + ID: "headless-no-cloud-sync", + Type: "sqlite", + }, + }); err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + assertNoHeadlessCloudBackupRequest(t, requests) + + baseConfig.Schedule = CloudBackupScheduleOnExit + baseConfig.WebDAVUsername = "" + baseConfig.WebDAVPassword = "" + baseConfig.EncryptionPassword = "" + if _, err := application.SaveCloudBackupConfig(baseConfig); err != nil { + t.Fatalf("save on-exit cloud backup config: %v", err) + } + application.Shutdown() + closed = true + assertNoHeadlessCloudBackupRequest(t, requests) +} + +func assertNoHeadlessCloudBackupRequest(t *testing.T, requests <-chan struct{}) { + t.Helper() + select { + case <-requests: + t.Fatal("headless lifecycle unexpectedly contacted the cloud backup endpoint") + case <-time.After(150 * time.Millisecond): + } +} + +func TestHeadlessResolveSavedConnectionKeepsSecretsOutOfViewAndRestoresThemForQuery(t *testing.T) { + root := t.TempDir() + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: root}) + if err != nil { + t.Fatalf("NewHeadlessRuntime returned error: %v", err) + } + defer runtime.Close() + + view, err := runtime.SaveConnection(connection.SavedConnectionInput{ + ID: "headless-secret", + Name: "Headless secret", + Config: connection.ConnectionConfig{ + ID: "headless-secret", + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "postgres", + Password: "postgres-secret", + }, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + if view.Config.Password != "" { + t.Fatal("SaveConnection returned a plaintext password") + } + + resolved, err := runtime.ResolveSavedConnection("headless-secret") + if err != nil { + t.Fatalf("ResolveSavedConnection returned error: %v", err) + } + if resolved.Config.Password != "" { + t.Fatal("ResolveSavedConnection exposed a plaintext password") + } + + database := &headlessSecretCaptureDB{sqlAuditTestDatabase: sqlAuditTestDatabase{ + rows: []map[string]interface{}{{"value": 1}}, + columns: []string{"value"}, + }} + originalNewDatabaseFunc := newDatabaseFunc + originalDriverSupportFunc := driverRuntimeSupportStatusFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + driverRuntimeSupportStatusFunc = originalDriverSupportFunc + }) + newDatabaseFunc = func(string) (db.Database, error) { return database, nil } + driverRuntimeSupportStatusFunc = func(string) (bool, string) { return true, "" } + + result := runtime.Query(context.Background(), resolved.Config, "app", "SELECT 1", HeadlessQueryOptions{}) + if !result.Success { + t.Fatalf("headless Query returned failure: %s", result.Message) + } + if database.connectedConfig.Password != "postgres-secret" { + t.Fatalf("database received password %q, want daily_secrets password", database.connectedConfig.Password) + } +} + +func TestHeadlessQueryRefreshesSavedMetadataAndSecretsAsOneSnapshot(t *testing.T) { + root := t.TempDir() + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: root}) + if err != nil { + t.Fatalf("NewHeadlessRuntime returned error: %v", err) + } + defer runtime.Close() + + stale, err := runtime.SaveConnection(connection.SavedConnectionInput{ + ID: "rotated-connection", + Name: "Rotated connection", + Config: connection.ConnectionConfig{ + ID: "rotated-connection", + Type: "postgres", + Host: "old-db.local", + Port: 5432, + User: "postgres", + Password: "old-secret", + }, + }) + if err != nil { + t.Fatalf("save initial connection: %v", err) + } + if _, err := runtime.SaveConnection(connection.SavedConnectionInput{ + ID: "rotated-connection", + Name: "Rotated connection", + Config: connection.ConnectionConfig{ + ID: "rotated-connection", + Type: "postgres", + Host: "new-db.local", + Port: 5432, + User: "postgres", + Password: "new-secret", + }, + }); err != nil { + t.Fatalf("rotate saved connection: %v", err) + } + + database := &headlessSecretCaptureDB{sqlAuditTestDatabase: sqlAuditTestDatabase{ + rows: []map[string]interface{}{{"value": 1}}, + columns: []string{"value"}, + }} + originalNewDatabaseFunc := newDatabaseFunc + originalDriverSupportFunc := driverRuntimeSupportStatusFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + driverRuntimeSupportStatusFunc = originalDriverSupportFunc + }) + newDatabaseFunc = func(string) (db.Database, error) { return database, nil } + driverRuntimeSupportStatusFunc = func(string) (bool, string) { return true, "" } + + result := runtime.Query(context.Background(), stale.Config, "app", "SELECT 1", HeadlessQueryOptions{}) + if !result.Success { + t.Fatalf("headless Query returned failure: %s", result.Message) + } + if database.connectedConfig.Host != "new-db.local" || database.connectedConfig.Password != "new-secret" { + t.Fatalf("database received a mixed or stale connection snapshot: host=%q password=%q", database.connectedConfig.Host, database.connectedConfig.Password) + } +} diff --git a/internal/app/headless_runtime_test.go b/internal/app/headless_runtime_test.go new file mode 100644 index 00000000..d8bbc5c1 --- /dev/null +++ b/internal/app/headless_runtime_test.go @@ -0,0 +1,78 @@ +package app + +import ( + "context" + "errors" + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestHeadlessRuntimeResolveSavedConnectionRejectsDuplicateNames(t *testing.T) { + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: t.TempDir()}) + if err != nil { + t.Fatalf("NewHeadlessRuntime returned error: %v", err) + } + defer runtime.Close() + + for _, id := range []string{"conn-one", "conn-two"} { + if _, saveErr := runtime.SaveConnection(connection.SavedConnectionInput{ + ID: id, + Name: "Production", + Config: connection.ConnectionConfig{ + ID: id, + Type: "mysql", + }, + }); saveErr != nil { + t.Fatalf("SaveConnection(%s) returned error: %v", id, saveErr) + } + } + + _, err = runtime.ResolveSavedConnection("Production") + var ambiguous *AmbiguousConnectionNameError + if !errors.As(err, &ambiguous) { + t.Fatalf("ResolveSavedConnection returned %v, want AmbiguousConnectionNameError", err) + } + if ambiguous.Name != "Production" || len(ambiguous.IDs) != 2 || ambiguous.IDs[0] != "conn-one" || ambiguous.IDs[1] != "conn-two" { + t.Fatalf("unexpected ambiguity details: %#v", ambiguous) + } +} + +func TestHeadlessRuntimeResolveSavedConnectionPrefersStableIDOverName(t *testing.T) { + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: t.TempDir()}) + if err != nil { + t.Fatalf("NewHeadlessRuntime returned error: %v", err) + } + defer runtime.Close() + + for _, input := range []connection.SavedConnectionInput{ + { + ID: "stable-id", + Name: "Production", + Config: connection.ConnectionConfig{ + ID: "stable-id", + Type: "mysql", + }, + }, + { + ID: "another-id", + Name: "stable-id", + Config: connection.ConnectionConfig{ + ID: "another-id", + Type: "mysql", + }, + }, + } { + if _, saveErr := runtime.SaveConnection(input); saveErr != nil { + t.Fatalf("SaveConnection(%s): %v", input.ID, saveErr) + } + } + + resolved, err := runtime.ResolveSavedConnection("stable-id") + if err != nil { + t.Fatalf("ResolveSavedConnection returned error: %v", err) + } + if resolved.ID != "stable-id" || resolved.Name != "Production" { + t.Fatalf("ResolveSavedConnection resolved %#v, want stable ID match", resolved) + } +} diff --git a/internal/app/headless_safety.go b/internal/app/headless_safety.go new file mode 100644 index 00000000..ae9c3532 --- /dev/null +++ b/internal/app/headless_safety.go @@ -0,0 +1,252 @@ +package app + +import ( + "fmt" + "strings" + + "GoNavi-Wails/internal/ai" + aiservice "GoNavi-Wails/internal/ai/service" + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" +) + +// HeadlessSQLSafetyStatement identifies one statement considered by the +// command-line safety policy. It intentionally excludes SQL text so callers +// can report a denial without exposing statement values. +type HeadlessSQLSafetyStatement struct { + Index int + Keyword string + Operation ai.SQLOperationType +} + +// HeadlessSQLSafetyDecision is the shared AI-safety decision used by headless +// callers. AllowMutating is an acknowledgement only; it cannot override a +// disallowed operation. +type HeadlessSQLSafetyDecision struct { + SafetyLevel ai.SQLPermissionLevel + Inspection SQLInspection + RequiresAllowMutating bool + Disallowed []HeadlessSQLSafetyStatement + ConfirmRequired []HeadlessSQLSafetyStatement +} + +// HeadlessSQLPolicyError is returned before a headless command can dispatch a +// statement that is blocked by the AI safety policy or connection protection. +type HeadlessSQLPolicyError struct { + Message string +} + +func (err *HeadlessSQLPolicyError) Error() string { + if err == nil || strings.TrimSpace(err.Message) == "" { + return "headless SQL policy denied the request" + } + return err.Message +} + +// GetSQLSafetyLevel reads the current shared AI safety setting for this data +// root. A missing or unreadable configuration fails closed to read-only. +func (runtime *HeadlessRuntime) GetSQLSafetyLevel() ai.SQLPermissionLevel { + if runtime == nil || runtime.app == nil { + return ai.PermissionReadOnly + } + inspection, err := aiservice.NewProviderConfigStore(runtime.app.configDir, nil).Inspect() + if err != nil { + logger.Warnf("headless SQL safety configuration unavailable; using readonly policy: %v", err) + return ai.PermissionReadOnly + } + return normalizeHeadlessSQLSafetyLevel(inspection.Snapshot.SafetyLevel) +} + +// EvaluateSQLSafety classifies every statement with the same safety levels +// used by AI and MCP execution. It is safe for CLI callers to display the +// returned metadata because it does not include SQL values. +func (runtime *HeadlessRuntime) EvaluateSQLSafety(config connection.ConnectionConfig, sql string) HeadlessSQLSafetyDecision { + return evaluateHeadlessSQLSafety(runtime.GetSQLSafetyLevel(), resolveDDLDBType(config), sql) +} + +func evaluateHeadlessSQLSafety(level ai.SQLPermissionLevel, dbType string, sql string) HeadlessSQLSafetyDecision { + level = normalizeHeadlessSQLSafetyLevel(level) + decision := HeadlessSQLSafetyDecision{ + SafetyLevel: level, + Inspection: SQLInspection{ + ReadOnly: true, + Statements: []SQLStatementInspection{}, + }, + Disallowed: []HeadlessSQLSafetyStatement{}, + ConfirmRequired: []HeadlessSQLSafetyStatement{}, + } + + for _, statement := range splitSQLStatementsForDialect(dbType, sql) { + statement = strings.TrimSpace(statement) + if statement == "" { + continue + } + inspection := SQLStatementInspection{ + Index: len(decision.Inspection.Statements) + 1, + Keyword: leadingSQLKeyword(statement), + ReadOnly: isReadOnlySQLQuery(dbType, statement), + } + decision.Inspection.Statements = append(decision.Inspection.Statements, inspection) + if !inspection.ReadOnly { + decision.Inspection.ReadOnly = false + } + + safetyStatement := HeadlessSQLSafetyStatement{ + Index: inspection.Index, + Keyword: inspection.Keyword, + Operation: classifyHeadlessSQLOperation(dbType, statement, inspection), + } + if !isHeadlessSQLOperationAllowed(level, safetyStatement.Operation) { + decision.Disallowed = append(decision.Disallowed, safetyStatement) + continue + } + if safetyStatement.Operation != ai.SQLOpQuery { + decision.RequiresAllowMutating = true + decision.ConfirmRequired = append(decision.ConfirmRequired, safetyStatement) + } + } + decision.Inspection.StatementCount = len(decision.Inspection.Statements) + return decision +} + +func classifyHeadlessSQLOperation(dbType, statement string, inspection SQLStatementInspection) ai.SQLOperationType { + if inspection.ReadOnly { + return ai.SQLOpQuery + } + if isBatchableWriteSQLStatement(dbType, statement) { + return ai.SQLOpDML + } + keyword, _ := sqlDataOperationInfo(statement) + switch keyword { + case "create", "alter", "drop", "truncate", "rename": + return ai.SQLOpDDL + default: + return ai.SQLOpOther + } +} + +func normalizeHeadlessSQLSafetyLevel(level ai.SQLPermissionLevel) ai.SQLPermissionLevel { + switch level { + case ai.PermissionReadOnly, ai.PermissionReadWrite, ai.PermissionFull: + return level + default: + return ai.PermissionReadOnly + } +} + +func isHeadlessSQLOperationAllowed(level ai.SQLPermissionLevel, operation ai.SQLOperationType) bool { + switch normalizeHeadlessSQLSafetyLevel(level) { + case ai.PermissionReadOnly: + return operation == ai.SQLOpQuery + case ai.PermissionReadWrite: + return operation == ai.SQLOpQuery || operation == ai.SQLOpDML + case ai.PermissionFull: + return true + default: + return operation == ai.SQLOpQuery + } +} + +func (runtime *HeadlessRuntime) authorizeHeadlessSQL(config connection.ConnectionConfig, sql string, allowMutating bool, requireDataImportProtection bool) error { + return runtime.authorizeHeadlessSQLAtSafetyLevel( + config, + sql, + allowMutating, + requireDataImportProtection, + runtime.GetSQLSafetyLevel(), + ) +} + +func (runtime *HeadlessRuntime) authorizeHeadlessSQLAtSafetyLevel(config connection.ConnectionConfig, sql string, allowMutating bool, requireDataImportProtection bool, level ai.SQLPermissionLevel) error { + if runtime == nil || runtime.app == nil { + return &HeadlessSQLPolicyError{Message: "headless runtime is unavailable"} + } + decision := evaluateHeadlessSQLSafety(level, resolveDDLDBType(config), sql) + if decision.Inspection.StatementCount == 0 { + return &HeadlessSQLPolicyError{Message: "SQL is required"} + } + if len(decision.Disallowed) > 0 { + return &HeadlessSQLPolicyError{Message: fmt.Sprintf( + "SQL is blocked by AI safety level %q: %s", + decision.SafetyLevel, + formatHeadlessSQLSafetyStatements(decision.Disallowed), + )} + } + if decision.RequiresAllowMutating && !allowMutating { + return &HeadlessSQLPolicyError{Message: "mutating SQL requires --allow-write"} + } + + if !decision.Inspection.ReadOnly { + if err := runtime.app.authorizeHeadlessConnectionProtections(config, decision); err != nil { + return err + } + } + if requireDataImportProtection { + if err := ensureConnectionAllowsActionWithText( + config, + connectionProtectionDataImport, + "connection.backend.action.import_data", + runtime.app.appText, + ); err != nil { + return &HeadlessSQLPolicyError{Message: err.Error()} + } + } + return nil +} + +func (a *App) authorizeHeadlessConnectionProtections(config connection.ConnectionConfig, decision HeadlessSQLSafetyDecision) error { + if a == nil { + return &HeadlessSQLPolicyError{Message: "headless runtime is unavailable"} + } + if err := ensureConnectionAllowsActionWithText( + config, + connectionProtectionScriptExecution, + "connection.backend.action.import_data", + a.appText, + ); err != nil { + return &HeadlessSQLPolicyError{Message: err.Error()} + } + for _, statement := range decision.ConfirmRequired { + switch statement.Operation { + case ai.SQLOpDML: + if err := ensureConnectionAllowsActionWithText(config, connectionProtectionDataEdit, "connection.backend.action.apply_result_changes", a.appText); err != nil { + return &HeadlessSQLPolicyError{Message: err.Error()} + } + case ai.SQLOpDDL: + if err := ensureConnectionAllowsActionWithText(config, connectionProtectionStructureEdit, "connection.backend.action.import_data", a.appText); err != nil { + return &HeadlessSQLPolicyError{Message: err.Error()} + } + case ai.SQLOpOther: + // An unclassified statement can affect either data or structure. + for _, protection := range []connectionProtectionKey{connectionProtectionDataEdit, connectionProtectionStructureEdit} { + if err := ensureConnectionAllowsActionWithText(config, protection, "connection.backend.action.import_data", a.appText); err != nil { + return &HeadlessSQLPolicyError{Message: err.Error()} + } + } + } + } + return nil +} + +// AuthorizeMCPConnectionSQL applies the same saved-connection write +// protections as the standalone CLI. MCP performs its own shared AI-safety and +// allowMutating checks before calling this method. +func (a *App) AuthorizeMCPConnectionSQL(config connection.ConnectionConfig, sql string) error { + decision := evaluateHeadlessSQLSafety(ai.PermissionFull, resolveDDLDBType(config), sql) + if decision.Inspection.StatementCount == 0 || decision.Inspection.ReadOnly { + return nil + } + return a.authorizeHeadlessConnectionProtections(config, decision) +} + +func formatHeadlessSQLSafetyStatements(statements []HeadlessSQLSafetyStatement) string { + items := make([]string, 0, len(statements)) + for _, statement := range statements { + keyword := strings.TrimSpace(statement.Keyword) + if keyword == "" { + keyword = "unknown" + } + items = append(items, fmt.Sprintf("#%d %s", statement.Index, keyword)) + } + return strings.Join(items, ", ") +} diff --git a/internal/app/headless_write_policy_test.go b/internal/app/headless_write_policy_test.go new file mode 100644 index 00000000..fecf6d0c --- /dev/null +++ b/internal/app/headless_write_policy_test.go @@ -0,0 +1,481 @@ +package app + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "GoNavi-Wails/internal/ai" + aiservice "GoNavi-Wails/internal/ai/service" + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/sqlaudit" +) + +func saveHeadlessSafetyLevel(t *testing.T, runtime *HeadlessRuntime, level ai.SQLPermissionLevel) { + t.Helper() + store := aiservice.NewProviderConfigStore(runtime.app.configDir, nil) + if err := store.Save(aiservice.ProviderConfigStoreSnapshot{ + Providers: []ai.ProviderConfig{}, + SafetyLevel: level, + ContextLevel: ai.ContextSchemaOnly, + }); err != nil { + t.Fatalf("save AI safety level: %v", err) + } +} + +func installHeadlessTestDatabase(t *testing.T, database db.Database) { + t.Helper() + previousNewDatabase := newDatabaseFunc + previousDriverSupport := driverRuntimeSupportStatusFunc + newDatabaseFunc = func(string) (db.Database, error) { return database, nil } + driverRuntimeSupportStatusFunc = func(string) (bool, string) { return true, "" } + t.Cleanup(func() { + newDatabaseFunc = previousNewDatabase + driverRuntimeSupportStatusFunc = previousDriverSupport + }) +} + +func TestHeadlessQueryUsesSharedAISafetyAndConnectionProtections(t *testing.T) { + tests := []struct { + name string + level ai.SQLPermissionLevel + config connection.ConnectionConfig + sql string + allow bool + wantOK bool + wantReason string + }{ + {name: "readonly blocks DML despite acknowledgement", level: ai.PermissionReadOnly, sql: "UPDATE demo SET value = 1", allow: true, wantReason: "AI safety"}, + {name: "readwrite allows DML with acknowledgement", level: ai.PermissionReadWrite, sql: "UPDATE demo SET value = 1", allow: true, wantOK: true}, + {name: "readwrite blocks DDL", level: ai.PermissionReadWrite, sql: "CREATE TABLE demo(id INT)", allow: true, wantReason: "AI safety"}, + {name: "full still requires acknowledgement", level: ai.PermissionFull, sql: "DELETE FROM demo", wantReason: "allow-write"}, + {name: "data protection blocks DML", level: ai.PermissionFull, config: connection.ConnectionConfig{Protection: connection.ConnectionProtectionConfig{RestrictDataEdit: true}}, sql: "UPDATE demo SET value = 1", allow: true, wantReason: "not allowed"}, + {name: "structure protection blocks DDL", level: ai.PermissionFull, config: connection.ConnectionConfig{Protection: connection.ConnectionProtectionConfig{RestrictStructureEdit: true}}, sql: "CREATE TABLE demo(id INT)", allow: true, wantReason: "not allowed"}, + {name: "script protection blocks DML", level: ai.PermissionFull, config: connection.ConnectionConfig{Protection: connection.ConnectionProtectionConfig{RestrictScriptExecution: true}}, sql: "UPDATE demo SET value = 1", allow: true, wantReason: "not allowed"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: t.TempDir()}) + if err != nil { + t.Fatalf("NewHeadlessRuntime: %v", err) + } + defer runtime.Close() + saveHeadlessSafetyLevel(t, runtime, test.level) + database := &headlessSecretCaptureDB{sqlAuditTestDatabase: sqlAuditTestDatabase{ + rows: []map[string]interface{}{{"ok": 1}}, + columns: []string{"ok"}, + affected: 1, + }} + installHeadlessTestDatabase(t, database) + test.config.Type = "postgres" + result := runtime.Query(context.Background(), test.config, "app", test.sql, HeadlessQueryOptions{AllowMutating: test.allow}) + if result.Success != test.wantOK { + t.Fatalf("Query success = %t, want %t; message=%s", result.Success, test.wantOK, result.Message) + } + if test.wantReason != "" && !containsFold(result.Message, test.wantReason) { + t.Fatalf("Query message %q does not contain %q", result.Message, test.wantReason) + } + if !test.wantOK { + data, _ := result.Data.(map[string]any) + if data["errorKind"] != headlessResultErrorKindPolicy { + t.Fatalf("policy result metadata = %#v", result.Data) + } + } + if !test.wantOK && database.connected { + t.Fatal("policy-denied query opened a database connection") + } + }) + } +} + +func TestMCPAuthorizedExecutionRechecksLatestConnectionProtection(t *testing.T) { + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: t.TempDir()}) + if err != nil { + t.Fatalf("NewHeadlessRuntime: %v", err) + } + defer runtime.Close() + saveHeadlessSafetyLevel(t, runtime, ai.PermissionFull) + + initial, err := runtime.SaveConnection(connection.SavedConnectionInput{ + ID: "mcp-toctou", + Name: "MCP TOCTOU", + Config: connection.ConnectionConfig{ + ID: "mcp-toctou", + Type: "postgres", + Host: "127.0.0.1", + Port: 5432, + }, + }) + if err != nil { + t.Fatalf("save initial connection: %v", err) + } + if _, err := runtime.SaveConnection(connection.SavedConnectionInput{ + ID: "mcp-toctou", + Name: "MCP TOCTOU", + Config: connection.ConnectionConfig{ + ID: "mcp-toctou", + Type: "postgres", + Host: "127.0.0.1", + Port: 5432, + ReadOnly: true, + }, + }); err != nil { + t.Fatalf("tighten connection protection: %v", err) + } + + database := &headlessSecretCaptureDB{sqlAuditTestDatabase: sqlAuditTestDatabase{ + rows: []map[string]interface{}{{"ok": 1}}, + columns: []string{"ok"}, + }} + installHeadlessTestDatabase(t, database) + stale := initial.Config + stale.ID = initial.ID + result := NewMCPQueryExecutor(runtime.app).DBQueryMultiAuthorizedContext( + context.Background(), stale, "app", "UPDATE demo SET value = 1", true, + ) + if result.Success { + t.Fatalf("MCP execution bypassed latest read-only protection: %#v", result) + } + if !containsFold(result.Message, "not allowed") { + t.Fatalf("unexpected stale-protection denial: %q", result.Message) + } + if database.connected { + t.Fatal("MCP policy denial opened a database connection") + } +} + +func TestHeadlessSingleTransactionPreflightsBeforeOpeningDatabase(t *testing.T) { + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: t.TempDir()}) + if err != nil { + t.Fatalf("NewHeadlessRuntime: %v", err) + } + defer runtime.Close() + saveHeadlessSafetyLevel(t, runtime, ai.PermissionFull) + filePath := t.TempDir() + "/migration.sql" + if err := os.WriteFile(filePath, []byte("INSERT INTO demo(id) VALUES (1);\nCREATE TABLE later(id INT);\n"), 0o600); err != nil { + t.Fatalf("write SQL file: %v", err) + } + database := &fakeBatchWriteDB{} + connected := false + previousNewDatabase := newDatabaseFunc + previousDriverSupport := driverRuntimeSupportStatusFunc + newDatabaseFunc = func(string) (db.Database, error) { + connected = true + return database, nil + } + driverRuntimeSupportStatusFunc = func(string) (bool, string) { return true, "" } + t.Cleanup(func() { + newDatabaseFunc = previousNewDatabase + driverRuntimeSupportStatusFunc = previousDriverSupport + }) + + result := runtime.ExecuteSQLFile(context.Background(), connection.ConnectionConfig{Type: "postgres"}, "app", filePath, HeadlessSQLFileOptions{AllowMutating: true}) + if result.Success || !containsFold(result.Message, "atomicity") { + t.Fatalf("single preflight result = %#v, want atomicity rejection", result) + } + data, _ := result.Data.(map[string]any) + if data["errorKind"] != headlessResultErrorKindPolicy { + t.Fatalf("single preflight policy metadata = %#v", result.Data) + } + if connected { + t.Fatal("single preflight opened the database before rejecting the second statement") + } +} + +func TestHeadlessOffModePreflightsSafetyBeforeOpeningDatabase(t *testing.T) { + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: t.TempDir()}) + if err != nil { + t.Fatalf("NewHeadlessRuntime: %v", err) + } + defer runtime.Close() + saveHeadlessSafetyLevel(t, runtime, ai.PermissionReadWrite) + filePath := t.TempDir() + "/migration.sql" + if err := os.WriteFile(filePath, []byte("INSERT INTO demo(id) VALUES (1);\nCREATE TABLE later(id INT);\n"), 0o600); err != nil { + t.Fatalf("write SQL file: %v", err) + } + connected := false + previousNewDatabase := newDatabaseFunc + previousDriverSupport := driverRuntimeSupportStatusFunc + newDatabaseFunc = func(string) (db.Database, error) { + connected = true + return &fakeBatchWriteDB{}, nil + } + driverRuntimeSupportStatusFunc = func(string) (bool, string) { return true, "" } + t.Cleanup(func() { + newDatabaseFunc = previousNewDatabase + driverRuntimeSupportStatusFunc = previousDriverSupport + }) + + result := runtime.ExecuteSQLFile(context.Background(), connection.ConnectionConfig{Type: "postgres"}, "app", filePath, HeadlessSQLFileOptions{ + AllowMutating: true, + TransactionMode: HeadlessSQLTransactionModeOff, + }) + if result.Success || !containsFold(result.Message, "AI safety") { + t.Fatalf("off-mode preflight result = %#v, want safety rejection", result) + } + if connected { + t.Fatal("off-mode safety preflight opened the database") + } +} + +func TestHeadlessExportRejectsWriteCTEBeforeOpeningDatabase(t *testing.T) { + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: t.TempDir()}) + if err != nil { + t.Fatalf("NewHeadlessRuntime: %v", err) + } + defer runtime.Close() + database := &headlessSecretCaptureDB{} + installHeadlessTestDatabase(t, database) + outputPath := t.TempDir() + "/export.json" + result := runtime.ExportQueryToPath( + context.Background(), + connection.ConnectionConfig{Type: "postgres"}, + "app", + "WITH moved AS (DELETE FROM demo RETURNING id) SELECT id FROM moved", + outputPath, + ExportFileOptions{Format: "json"}, + false, + ) + if result.Success { + t.Fatalf("write CTE export unexpectedly succeeded: %#v", result) + } + if database.connected { + t.Fatal("write CTE export opened the database") + } + if _, err := os.Stat(outputPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("write CTE export created a target file: %v", err) + } +} + +func TestHeadlessSQLFileRetainsCLIAuditSource(t *testing.T) { + runtime, err := NewHeadlessRuntime(context.Background(), HeadlessRuntimeOptions{DataRoot: t.TempDir()}) + if err != nil { + t.Fatalf("NewHeadlessRuntime: %v", err) + } + defer runtime.Close() + saveHeadlessSafetyLevel(t, runtime, ai.PermissionReadWrite) + filePath := t.TempDir() + "/migration.sql" + if err := os.WriteFile(filePath, []byte("INSERT INTO demo(id) VALUES (1);\n"), 0o600); err != nil { + t.Fatalf("write SQL file: %v", err) + } + database := &headlessTransactionTestDB{} + installHeadlessTestDatabase(t, database) + + result := runtime.ExecuteSQLFile(context.Background(), connection.ConnectionConfig{Type: "postgres"}, "app", filePath, HeadlessSQLFileOptions{AllowMutating: true}) + if !result.Success { + t.Fatalf("ExecuteSQLFile: %#v", result) + } + events := loadSQLAuditEvents(t, runtime.app, sqlaudit.Filter{Source: "cli"}) + if len(events) != 1 || events[0].Source != "cli" || events[0].StatementCount != 1 { + t.Fatalf("headless SQL-file audit = %#v, want one cli event", events) + } +} + +func TestExecuteSQLFileSingleTransactionUsesOneTransactionAndReportsUnknownOutcomes(t *testing.T) { + t.Run("uses one pinned textual transaction", func(t *testing.T) { + database := &fakeSQLFileBatchDB{} + result, err := executeSQLFileStream(context.Background(), database, stringsReader("INSERT INTO demo(id) VALUES (1);\nUPDATE demo SET id = 2;"), sqlFileExecutionOptions{ + DBType: "postgres", + TransactionMode: sqlFileTransactionModeSingle, + }, nil) + if err != nil || result.Executed != 2 || result.Failed != 0 { + t.Fatalf("textual single transaction result = %#v, err=%v", result, err) + } + wantQueries := []string{"BEGIN", "INSERT INTO demo(id) VALUES (1)", "UPDATE demo SET id = 2", "COMMIT"} + if database.session == nil || !database.session.closed || database.batchCalls != 0 || strings.Join(database.execQueries, "|") != strings.Join(wantQueries, "|") { + t.Fatalf("unexpected pinned transaction execution: queries=%#v session=%#v batchCalls=%d", database.execQueries, database.session, database.batchCalls) + } + }) + + t.Run("commits once", func(t *testing.T) { + database := &headlessTransactionTestDB{} + result, err := executeSQLFileStream(context.Background(), database, stringsReader("INSERT INTO demo(id) VALUES (1);\nUPDATE demo SET id = 2;"), sqlFileExecutionOptions{ + DBType: "postgres", + TransactionMode: sqlFileTransactionModeSingle, + }, nil) + if err != nil || result.Executed != 2 || result.Failed != 0 { + t.Fatalf("single transaction result = %#v, err=%v", result, err) + } + if database.tx == nil || database.tx.commitCalls != 1 || database.tx.rollbackCalls != 0 { + t.Fatalf("unexpected transaction lifecycle: %#v", database.tx) + } + }) + + t.Run("commit failure is unknown", func(t *testing.T) { + database := &headlessTransactionTestDB{commitErr: errors.New("commit response lost")} + result, err := executeSQLFileStream(context.Background(), database, stringsReader("INSERT INTO demo(id) VALUES (1);"), sqlFileExecutionOptions{ + DBType: "postgres", + TransactionMode: sqlFileTransactionModeSingle, + }, nil) + if err == nil || !result.OutcomeUnknown { + t.Fatalf("commit failure result = %#v, err=%v; want unknown", result, err) + } + if database.tx == nil || database.tx.commitCalls != 1 || database.tx.rollbackCalls != 1 { + t.Fatalf("commit failure cleanup = %#v", database.tx) + } + }) + + t.Run("rollback failure is unknown", func(t *testing.T) { + database := &headlessTransactionTestDB{ + rollbackErr: errors.New("rollback response lost"), + fakeBatchWriteDB: fakeBatchWriteDB{execErr: map[string]error{ + "INSERT INTO demo(id) VALUES (1)": errors.New("statement failed"), + }}, + } + result, err := executeSQLFileStream(context.Background(), database, stringsReader("INSERT INTO demo(id) VALUES (1);"), sqlFileExecutionOptions{ + DBType: "postgres", + TransactionMode: sqlFileTransactionModeSingle, + }, nil) + if err == nil || !result.OutcomeUnknown { + t.Fatalf("rollback failure result = %#v, err=%v; want unknown", result, err) + } + }) + + t.Run("statement response loss is unknown", func(t *testing.T) { + database := &headlessTransactionTestDB{fakeBatchWriteDB: fakeBatchWriteDB{execErr: map[string]error{ + "INSERT INTO demo(id) VALUES (1)": db.MarkWriteOutcomeUnknown(errors.New("statement response lost")), + }}} + result, err := executeSQLFileStream(context.Background(), database, stringsReader("INSERT INTO demo(id) VALUES (1);"), sqlFileExecutionOptions{ + DBType: "postgres", + TransactionMode: sqlFileTransactionModeSingle, + }, nil) + if err == nil || !result.OutcomeUnknown { + t.Fatalf("statement unknown result = %#v, err=%v; want unknown", result, err) + } + }) + + t.Run("in-flight cancellation is unknown", func(t *testing.T) { + started := make(chan string, 1) + database := &headlessTransactionTestDB{fakeBatchWriteDB: fakeBatchWriteDB{ + execStarted: started, + execRelease: make(chan struct{}), + }} + ctx, cancel := context.WithCancel(context.Background()) + type executionResult struct { + result sqlFileExecutionResult + err error + } + done := make(chan executionResult, 1) + go func() { + result, err := executeSQLFileStream(ctx, database, stringsReader("INSERT INTO demo(id) VALUES (1);"), sqlFileExecutionOptions{ + DBType: "postgres", + TransactionMode: sqlFileTransactionModeSingle, + }, nil) + done <- executionResult{result: result, err: err} + }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("statement did not start") + } + cancel() + select { + case execution := <-done: + if !errors.Is(execution.err, context.Canceled) || !execution.result.OutcomeUnknown { + t.Fatalf("cancel result = %#v, err=%v; want unknown cancellation", execution.result, execution.err) + } + case <-time.After(2 * time.Second): + t.Fatal("cancelled statement did not return") + } + }) +} + +func TestHeadlessSQLFileTransactionModeRejectsContinueOnErrorExceptOff(t *testing.T) { + database := &fakeBatchWriteDB{} + if _, err := executeSQLFileStream(context.Background(), database, stringsReader("INSERT INTO demo(id) VALUES (1);"), sqlFileExecutionOptions{ + DBType: "postgres", + TransactionMode: sqlFileTransactionModeSingle, + ContinueOnError: true, + }, nil); err == nil { + t.Fatal("single transaction unexpectedly accepted continue-on-error") + } + result, err := executeSQLFileStream(context.Background(), database, stringsReader("INSERT INTO demo(id) VALUES (1);"), sqlFileExecutionOptions{ + DBType: "postgres", + TransactionMode: sqlFileTransactionModeOff, + ContinueOnError: true, + }, nil) + if err != nil || result.Executed != 1 { + t.Fatalf("off transaction mode result = %#v, err=%v", result, err) + } +} + +func TestSingleTransactionPolicyRejectsUnprovableStatements(t *testing.T) { + tests := []struct { + name string + stmt string + wantErr bool + }{ + {name: "query", stmt: "SELECT 1"}, + {name: "DML", stmt: "UPDATE demo SET value = 1"}, + {name: "explicit begin", stmt: "BEGIN TRANSACTION", wantErr: true}, + {name: "transaction setting", stmt: "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", wantErr: true}, + {name: "DDL", stmt: "CREATE TABLE demo(id INT)", wantErr: true}, + {name: "unknown side effect", stmt: "CALL refresh_demo()", wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateSQLFileSingleTransactionStatement("postgres", test.stmt) + if (err != nil) != test.wantErr { + t.Fatalf("validate error = %v, wantErr=%t", err, test.wantErr) + } + }) + } + if err := validateSQLFileSingleTransactionStatement("mysql", "INSERT INTO demo(id) VALUES (1)"); err == nil { + t.Fatal("MySQL single transaction unexpectedly claimed proven atomicity") + } +} + +func TestSQLImportOptionsHashSeparatesTransactionModes(t *testing.T) { + off := buildSQLImportOptionsHashWithTransactionMode(false, DefaultSQLImportMaxStatementBytes, sqlFileTransactionModeOff) + single := buildSQLImportOptionsHashWithTransactionMode(false, DefaultSQLImportMaxStatementBytes, sqlFileTransactionModeSingle) + if off == single { + t.Fatal("single and off transaction modes share a managed-job options hash") + } +} + +type headlessTransactionTestDB struct { + fakeBatchWriteDB + tx *headlessTransactionTestSession + commitErr error + rollbackErr error +} + +func (database *headlessTransactionTestDB) OpenTransactionExecer(context.Context) (db.TransactionExecer, error) { + database.tx = &headlessTransactionTestSession{ + fakeBatchWriteSession: fakeBatchWriteSession{parent: &database.fakeBatchWriteDB}, + commitErr: database.commitErr, + rollbackErr: database.rollbackErr, + } + return database.tx, nil +} + +type headlessTransactionTestSession struct { + fakeBatchWriteSession + commitCalls int + rollbackCalls int + commitErr error + rollbackErr error +} + +func (session *headlessTransactionTestSession) Commit() error { + session.commitCalls++ + return session.commitErr +} + +func (session *headlessTransactionTestSession) Rollback() error { + session.rollbackCalls++ + return session.rollbackErr +} + +func containsFold(value, want string) bool { + return strings.Contains(strings.ToLower(value), strings.ToLower(want)) +} + +func stringsReader(value string) *strings.Reader { + return strings.NewReader(value) +} diff --git a/internal/app/import_job_identity.go b/internal/app/import_job_identity.go index 653e031c..15f146a9 100644 --- a/internal/app/import_job_identity.go +++ b/internal/app/import_job_identity.go @@ -87,14 +87,23 @@ func buildImportFileOptionsHash(options ImportFileOptions) string { } func buildSQLImportOptionsHash(continueOnError bool, maxStatementBytes int64) string { + return buildSQLImportOptionsHashWithTransactionMode(continueOnError, maxStatementBytes, sqlFileTransactionModeOff) +} + +func buildSQLImportOptionsHashWithTransactionMode(continueOnError bool, maxStatementBytes int64, transactionMode sqlFileTransactionMode) string { if maxStatementBytes <= 0 { maxStatementBytes = DefaultSQLImportMaxStatementBytes } + if transactionMode != sqlFileTransactionModeSingle { + transactionMode = sqlFileTransactionModeOff + } return hashImportJobContract(struct { - ContinueOnError bool `json:"continueOnError"` - MaxStatementBytes int64 `json:"maxStatementBytes"` + ContinueOnError bool `json:"continueOnError"` + MaxStatementBytes int64 `json:"maxStatementBytes"` + TransactionMode string `json:"transactionMode"` }{ ContinueOnError: continueOnError, MaxStatementBytes: maxStatementBytes, + TransactionMode: string(transactionMode), }) } diff --git a/internal/app/methods_data_root.go b/internal/app/methods_data_root.go index 31d76853..b0f27c27 100644 --- a/internal/app/methods_data_root.go +++ b/internal/app/methods_data_root.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" stdRuntime "runtime" + "sort" "strings" "GoNavi-Wails/internal/appdata" @@ -20,8 +21,9 @@ import ( ) var dataRootMigrationExcludedEntries = map[string]struct{}{ - filepath.Base(appdata.BootstrapPath()): {}, - filepath.Base(appdata.BootstrapLockPath()): {}, + filepath.Base(appdata.BootstrapPath()): {}, + filepath.Base(appdata.BootstrapLockPath()): {}, + filepath.Base(appdata.SharedStorageLockPath("")): {}, } type dataRootTextFunc func(string, map[string]any) string @@ -250,6 +252,24 @@ func migrateDataRootContentsWithText(sourceRoot string, targetRoot string, text if err := os.MkdirAll(targetRoot, 0o755); err != nil { return dataRootWrapError(text, "app.data_root.backend.error.create_target_failed", err, nil) } + sourceInfo, err := os.Stat(sourceRoot) + if err != nil { + return dataRootWrapError(text, "app.data_root.backend.error.read_source_root_failed", err, nil) + } + targetInfo, err := os.Stat(targetRoot) + if err != nil { + return dataRootWrapError(text, "app.data_root.backend.error.create_target_failed", err, nil) + } + if os.SameFile(sourceInfo, targetInfo) { + return nil + } + + return withDataRootSharedStorageLocks(sourceRoot, targetRoot, text, func() error { + return migrateDataRootContentsUnlocked(sourceRoot, targetRoot, text) + }) +} + +func migrateDataRootContentsUnlocked(sourceRoot string, targetRoot string, text dataRootTextFunc) error { entries, err := os.ReadDir(sourceRoot) if err != nil { return dataRootWrapError(text, "app.data_root.backend.error.read_source_root_failed", err, nil) @@ -293,6 +313,56 @@ func migrateDataRootContentsWithText(sourceRoot string, targetRoot string, text return nil } +type dataRootSharedStorageLockTarget struct { + key string + root string +} + +func withDataRootSharedStorageLocks(sourceRoot string, targetRoot string, text dataRootTextFunc, operation func() error) error { + targets := make([]dataRootSharedStorageLockTarget, 0, 2) + for _, root := range []string{sourceRoot, targetRoot} { + canonical, err := filepath.EvalSymlinks(root) + if err != nil { + return dataRootWrapError(text, "app.data_root.backend.error.migrate_directory_failed", err, map[string]any{"entry": "storage_lock"}) + } + canonical = filepath.Clean(canonical) + key := canonical + if stdRuntime.GOOS == "windows" { + key = strings.ToLower(key) + } + targets = append(targets, dataRootSharedStorageLockTarget{key: key, root: canonical}) + } + sort.Slice(targets, func(i, j int) bool { + return targets[i].key < targets[j].key + }) + + locks := make([]io.Closer, 0, len(targets)) + for index, target := range targets { + if index > 0 && target.key == targets[index-1].key { + continue + } + lock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(target.root)) + if err != nil { + var closeErr error + for closeIndex := len(locks) - 1; closeIndex >= 0; closeIndex-- { + closeErr = errors.Join(closeErr, locks[closeIndex].Close()) + } + return dataRootWrapError(text, "app.data_root.backend.error.migrate_directory_failed", errors.Join(err, closeErr), map[string]any{"entry": "storage_lock"}) + } + locks = append(locks, lock) + } + + operationErr := operation() + var closeErr error + for index := len(locks) - 1; index >= 0; index-- { + closeErr = errors.Join(closeErr, locks[index].Close()) + } + if closeErr != nil { + closeErr = dataRootWrapError(text, "app.data_root.backend.error.migrate_directory_failed", closeErr, map[string]any{"entry": "storage_lock"}) + } + return errors.Join(operationErr, closeErr) +} + func replaceMigratedAuditDirectory(sourceRoot string, targetRoot string) error { sourceAudit := filepath.Join(sourceRoot, "audit") targetAudit := filepath.Join(targetRoot, "audit") diff --git a/internal/app/methods_data_root_test.go b/internal/app/methods_data_root_test.go index 81289e5e..fd2c9d03 100644 --- a/internal/app/methods_data_root_test.go +++ b/internal/app/methods_data_root_test.go @@ -276,3 +276,128 @@ func TestMigrateDataRootContentsCopiesDailySecretsForSavedConnections(t *testing t.Fatalf("expected migrated DSN to be restored, got %q", resolved.DSN) } } + +func TestMigrateDataRootContentsWaitsForSourceSharedStorageLock(t *testing.T) { + sourceRoot := t.TempDir() + targetRoot := filepath.Join(t.TempDir(), "gonavi-data") + if err := os.WriteFile(filepath.Join(sourceRoot, "connections.json"), []byte(`{"connections":[{"id":"locked"}]}`), 0o644); err != nil { + t.Fatalf("write source connections: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceRoot, "daily_secrets.json"), []byte(`{"connections":{"locked":{"password":"secret"}}}`), 0o600); err != nil { + t.Fatalf("write source daily secrets: %v", err) + } + + lock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(sourceRoot)) + if err != nil { + t.Fatalf("acquire source shared storage lock: %v", err) + } + released := false + t.Cleanup(func() { + if !released { + _ = lock.Close() + } + }) + finished := make(chan error, 1) + go func() { + finished <- migrateDataRootContents(sourceRoot, targetRoot) + }() + select { + case migrateErr := <-finished: + t.Fatalf("migration acquired source shared lock before release: %v", migrateErr) + case <-time.After(50 * time.Millisecond): + } + if err := os.WriteFile(filepath.Join(sourceRoot, "connections.json"), []byte(`{"connections":[{"id":"after-lock"}]}`), 0o644); err != nil { + t.Fatalf("update source connections while holding lock: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceRoot, "daily_secrets.json"), []byte(`{"connections":{"after-lock":{"password":"after-lock-secret"}}}`), 0o600); err != nil { + t.Fatalf("update source daily secrets while holding lock: %v", err) + } + if err := lock.Close(); err != nil { + t.Fatalf("release source shared storage lock: %v", err) + } + released = true + select { + case migrateErr := <-finished: + if migrateErr != nil { + t.Fatalf("migration after source lock release: %v", migrateErr) + } + connections, readErr := os.ReadFile(filepath.Join(targetRoot, "connections.json")) + if readErr != nil || string(connections) != `{"connections":[{"id":"after-lock"}]}` { + t.Fatalf("migrated connections snapshot = %q err=%v", connections, readErr) + } + secrets, readErr := os.ReadFile(filepath.Join(targetRoot, "daily_secrets.json")) + if readErr != nil || string(secrets) != `{"connections":{"after-lock":{"password":"after-lock-secret"}}}` { + t.Fatalf("migrated daily secrets snapshot = %q err=%v", secrets, readErr) + } + case <-time.After(2 * time.Second): + t.Fatal("migration did not acquire source shared lock after release") + } +} + +func TestMigrateDataRootContentsWaitsForTargetSharedStorageLock(t *testing.T) { + sourceRoot := t.TempDir() + targetRoot := filepath.Join(t.TempDir(), "gonavi-data") + if err := os.WriteFile(filepath.Join(sourceRoot, "connections.json"), []byte(`{"connections":[]}`), 0o644); err != nil { + t.Fatalf("write source connections: %v", err) + } + if err := os.MkdirAll(targetRoot, 0o755); err != nil { + t.Fatalf("create target root: %v", err) + } + + lock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(targetRoot)) + if err != nil { + t.Fatalf("acquire target shared storage lock: %v", err) + } + released := false + t.Cleanup(func() { + if !released { + _ = lock.Close() + } + }) + finished := make(chan error, 1) + go func() { + finished <- migrateDataRootContents(sourceRoot, targetRoot) + }() + select { + case migrateErr := <-finished: + t.Fatalf("migration acquired target shared lock before release: %v", migrateErr) + case <-time.After(50 * time.Millisecond): + } + if err := lock.Close(); err != nil { + t.Fatalf("release target shared storage lock: %v", err) + } + released = true + select { + case migrateErr := <-finished: + if migrateErr != nil { + t.Fatalf("migration after target lock release: %v", migrateErr) + } + case <-time.After(2 * time.Second): + t.Fatal("migration did not acquire target shared lock after release") + } +} + +func TestMigrateDataRootContentsUsesStableLockOrderForOppositeMigrations(t *testing.T) { + firstRoot := t.TempDir() + secondRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(firstRoot, "connections.json"), []byte(`{"connections":[{"id":"first"}]}`), 0o644); err != nil { + t.Fatalf("write first source connections: %v", err) + } + if err := os.WriteFile(filepath.Join(secondRoot, "connections.json"), []byte(`{"connections":[{"id":"second"}]}`), 0o644); err != nil { + t.Fatalf("write second source connections: %v", err) + } + + results := make(chan error, 2) + go func() { results <- migrateDataRootContents(firstRoot, secondRoot) }() + go func() { results <- migrateDataRootContents(secondRoot, firstRoot) }() + for index := 0; index < 2; index++ { + select { + case err := <-results: + if err != nil { + t.Fatalf("opposite migration %d returned error: %v", index, err) + } + case <-time.After(3 * time.Second): + t.Fatal("opposite migrations deadlocked while acquiring roots") + } + } +} diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 1e864527..c880034c 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -14,7 +14,6 @@ import ( "GoNavi-Wails/internal/db" "GoNavi-Wails/internal/logger" "GoNavi-Wails/internal/sqlaudit" - "GoNavi-Wails/internal/utils" "GoNavi-Wails/shared/i18n" ) @@ -29,13 +28,23 @@ func normalizeTestConnectionConfig(config connection.ConnectionConfig) connectio } func newQueryExecutionContext(config connection.ConnectionConfig) (context.Context, context.CancelFunc) { + return newQueryExecutionContextWithParent(context.Background(), config) +} + +// newQueryExecutionContextWithParent keeps query cancellation linked to the +// caller while deliberately keeping connection establishment timeout separate +// from the query deadline. +func newQueryExecutionContextWithParent(parent context.Context, config connection.ConnectionConfig) (context.Context, context.CancelFunc) { + if parent == nil { + parent = context.Background() + } if config.QueryTimeout > 0 { - return utils.ContextWithTimeout(time.Duration(config.QueryTimeout) * time.Second) + return context.WithTimeout(parent, time.Duration(config.QueryTimeout)*time.Second) } // Connection timeout is only for establishing the connection. Do not reuse it // as a query deadline; long-running queries remain cancellable via CancelQuery. - return context.WithCancel(context.Background()) + return context.WithCancel(parent) } func validateTestConnectionInput(config connection.ConnectionConfig) error { @@ -1047,16 +1056,48 @@ func (a *App) MySQLShowCreateTable(config connection.ConnectionConfig, dbName st } type dbQueryAuditOptions struct { - trackHistory bool - auditAll bool - auditWrites bool - source string + trackHistory bool + auditAll bool + auditWrites bool + source string + executionContext context.Context + classifyConnectionErrors bool } type dbQueryMultiAuditOptions struct { - auditAll bool - auditWrites bool - source string + auditAll bool + auditWrites bool + source string + executionContext context.Context + classifyConnectionErrors bool +} + +func buildQueryConnectionFailure(err error, queryID string, classify bool) connection.QueryResult { + result := connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + result.Data = map[string]any{"cancelled": true} + return result + } + if classify { + result.Data = map[string]any{"errorKind": headlessResultErrorKindConnection} + } + return result +} + +// buildQueryExecutionFailure preserves cancellation provenance when a driver +// returns context.Canceled or context.DeadlineExceeded from an in-flight read. +// The query context may already have been cleaned up by the time the CLI maps +// the result, so the marker must travel with the QueryResult itself. +func buildQueryExecutionFailure(ctx context.Context, err error, message string, queryID string) connection.QueryResult { + if message == "" && err != nil { + message = err.Error() + } + result := connection.QueryResult{Success: false, Message: message, QueryID: queryID} + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || + (ctx != nil && (errors.Is(ctx.Err(), context.Canceled) || errors.Is(ctx.Err(), context.DeadlineExceeded))) { + result.Data = map[string]any{"cancelled": true} + } + return result } func containsSQLAuditWrite(dbType string, query string) bool { @@ -1073,6 +1114,42 @@ func containsSQLAuditWrite(dbType string, query string) bool { return false } +// writeExecutionOutcomeUnknown covers both a driver-level ambiguous response +// and a caller cancellation observed while a write was in flight. The latter +// must be treated as unknown even when a driver returns an opaque error rather +// than context.Canceled after it may already have dispatched the statement. +func writeExecutionOutcomeUnknown(ctx context.Context, err error) bool { + return db.IsWriteOutcomeUnknown(err) || db.IsAmbiguousWriteResponse(err) || (ctx != nil && ctx.Err() != nil) +} + +// classifyDispatchedWriteError treats opaque connection-loss messages as an +// unknown write outcome. Once a write has been dispatched, reconnecting and +// replaying it is unsafe even when the driver did not return a typed network +// error. +func classifyDispatchedWriteError(err error) error { + if err == nil || db.IsWriteOutcomeUnknown(err) || !shouldRefreshCachedConnection(err) { + return err + } + return db.MarkWriteOutcomeUnknown(err) +} + +// buildWriteExecutionFailure keeps the no-retry/unknown-outcome contract +// visible to headless callers. Drivers normally attach WriteOutcomeUnknown +// themselves; transport failures and a cancelled in-flight context are +// conservatively treated the same way so a statement that may have reached +// the server is never reported as rejected. +func buildWriteExecutionFailure(ctx context.Context, err error, queryID string) connection.QueryResult { + data := map[string]any{} + if writeExecutionOutcomeUnknown(ctx, err) { + data["outcomeUnknown"] = true + } + result := connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + if len(data) > 0 { + result.Data = data + } + return result +} + func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult { return a.dbQueryWithCancel(config, dbName, query, "", dbQueryAuditOptions{ auditAll: a.webRuntime, @@ -1140,17 +1217,17 @@ func (a *App) dbQueryWithCancel( return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} } - ctx, cancel := newQueryExecutionContext(runConfig) + ctx, cancel := newQueryExecutionContextWithParent(auditOptions.executionContext, runConfig) cleanupRunningQuery := a.registerRunningQuery(queryID, cancel, true) defer func() { cancel() cleanupRunningQuery() }() - dbInst, err := a.getDatabase(runConfig) + dbInst, err := a.getDatabaseWithContext(ctx, runConfig, false) if err != nil { logger.Error(err, "DBQuery 获取连接失败:%s", formatConnSummary(runConfig)) - return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + return buildQueryConnectionFailure(err, queryID, auditOptions.classifyConnectionErrors) } isReadQuery := isReadOnlySQLQuery(runConfig.Type, query) @@ -1191,12 +1268,12 @@ func (a *App) dbQueryWithCancel( if isReadQuery || tryQueryFirst { data, columns, messages, err := runReadQueryWithMessages(dbInst) - if err != nil && shouldRefreshCachedConnection(err) { + if err != nil && isReadQuery && shouldRefreshCachedConnection(err) { if a.invalidateCachedDatabase(runConfig, err) { - retryInst, retryErr := a.getDatabaseForcePing(runConfig) + retryInst, retryErr := a.getDatabaseWithContext(ctx, runConfig, true) if retryErr != nil { logger.Error(retryErr, "DBQuery 重建连接失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: retryErr.Error()} + return buildQueryConnectionFailure(retryErr, queryID, auditOptions.classifyConnectionErrors) } data, columns, messages, err = runReadQueryWithMessages(retryInst) } @@ -1206,24 +1283,24 @@ func (a *App) dbQueryWithCancel( } if isReadQuery { logger.Error(err, "DBQuery 查询失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + return buildQueryExecutionFailure(ctx, err, err.Error(), queryID) } + if shouldRefreshCachedConnection(err) { + a.invalidateCachedDatabase(runConfig, err) + } + err = classifyDispatchedWriteError(err) + logger.Error(err, "DBQuery 写入查询失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) + return buildWriteExecutionFailure(ctx, err, queryID) } affected, err := runExecQuery(dbInst) - if err != nil && shouldRefreshCachedConnection(err) { - if a.invalidateCachedDatabase(runConfig, err) { - retryInst, retryErr := a.getDatabaseForcePing(runConfig) - if retryErr != nil { - logger.Error(retryErr, "DBQuery 重建连接失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: retryErr.Error()} - } - affected, err = runExecQuery(retryInst) - } - } if err != nil { + if shouldRefreshCachedConnection(err) { + a.invalidateCachedDatabase(runConfig, err) + } + err = classifyDispatchedWriteError(err) logger.Error(err, "DBQuery 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + return buildWriteExecutionFailure(ctx, err, queryID) } return connection.QueryResult{Success: true, Data: map[string]int64{"affectedRows": affected}, QueryID: queryID} } @@ -1321,17 +1398,17 @@ func (a *App) dbQueryMulti( return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} } - ctx, cancel := newQueryExecutionContext(runConfig) + ctx, cancel := newQueryExecutionContextWithParent(auditOptions.executionContext, runConfig) cleanupRunningQuery := a.registerRunningQuery(queryID, cancel, true) defer func() { cancel() cleanupRunningQuery() }() - dbInst, err := a.getDatabase(runConfig) + dbInst, err := a.getDatabaseWithContext(ctx, runConfig, false) if err != nil { logger.Error(err, "DBQueryMulti 获取连接失败:%s", formatConnSummary(runConfig)) - return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + return buildQueryConnectionFailure(err, queryID, auditOptions.classifyConnectionErrors) } defer func() { // A successful SQL round trip is at least as strong a health signal as Ping. @@ -1427,10 +1504,10 @@ func (a *App) dbQueryMulti( results, resultMessages, err := runMultiQuery(dbInst) if err != nil && shouldRefreshCachedConnection(err) { if a.invalidateCachedDatabase(runConfig, err) { - retryInst, retryErr := a.getDatabaseForcePing(runConfig) + retryInst, retryErr := a.getDatabaseWithContext(ctx, runConfig, true) if retryErr != nil { logger.Error(retryErr, "DBQueryMulti 重建连接失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: retryErr.Error(), QueryID: queryID} + return buildQueryConnectionFailure(retryErr, queryID, auditOptions.classifyConnectionErrors) } dbInst = retryInst results, resultMessages, err = runMultiQuery(retryInst) @@ -1438,7 +1515,7 @@ func (a *App) dbQueryMulti( } if err != nil { logger.Error(err, "DBQueryMulti 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + return buildQueryExecutionFailure(ctx, err, err.Error(), queryID) } // 某些 optional driver-agent 的原生多结果集路径会异常返回“成功但无可展示列/行”。 @@ -1539,24 +1616,13 @@ func (a *App) dbQueryMulti( measureQueryExecution(func() { affected, batchErr = batcher.ExecBatchContext(ctx, query) }) - if batchErr != nil && shouldRefreshCachedConnection(batchErr) { - if a.invalidateCachedDatabase(runConfig, batchErr) { - retryInst, retryErr := a.getDatabaseForcePing(runConfig) - if retryErr != nil { - logger.Error(retryErr, "DBQueryMulti 批量写重建连接失败:%s", formatConnSummary(runConfig)) - return connection.QueryResult{Success: false, Message: retryErr.Error(), QueryID: queryID} - } - dbInst = retryInst - if retryBatcher, ok2 := retryInst.(db.BatchWriteExecer); ok2 { - measureQueryExecution(func() { - affected, batchErr = retryBatcher.ExecBatchContext(ctx, query) - }) - } - } - } if batchErr != nil { + if shouldRefreshCachedConnection(batchErr) { + a.invalidateCachedDatabase(runConfig, batchErr) + } + batchErr = classifyDispatchedWriteError(batchErr) logger.Error(batchErr, "DBQueryMulti 批量写执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: batchErr.Error(), QueryID: queryID} + return buildWriteExecutionFailure(ctx, batchErr, queryID) } logger.Infof("DBQueryMulti 批量写执行成功:%s 语句数=%d affectedRows=%d", formatConnSummary(runConfig), len(statements), affected) return connection.QueryResult{ @@ -1692,8 +1758,18 @@ func (a *App) dbQueryMulti( logger.Error(err, "DBQueryMulti 逐条查询失败(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt)) errMsg := buildStatementExecutionFailedMessage(idx+1, err, len(resultSets)) appendStatementAudit(stmt, idx+1, statementStartedAt, 0, 0, err) - return connection.QueryResult{Success: false, Message: errMsg, QueryID: queryID} + return buildQueryExecutionFailure(ctx, err, errMsg, queryID) } + if shouldRefreshCachedConnection(err) { + a.invalidateCachedDatabase(runConfig, err) + } + err = classifyDispatchedWriteError(err) + logger.Error(err, "DBQueryMulti 写入查询失败(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt)) + errMsg := buildStatementExecutionFailedMessage(idx+1, err, len(resultSets)) + appendStatementAudit(stmt, idx+1, statementStartedAt, 0, 0, err) + failure := buildWriteExecutionFailure(ctx, err, queryID) + failure.Message = errMsg + return failure } var affected int64 @@ -1709,9 +1785,16 @@ func (a *App) dbQueryMulti( } }) if err != nil { + if shouldRefreshCachedConnection(err) { + a.invalidateCachedDatabase(runConfig, err) + } + err = classifyDispatchedWriteError(err) logger.Error(err, "DBQueryMulti 逐条执行失败(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt)) errMsg := buildStatementExecutionFailedMessage(idx+1, err, len(resultSets)) appendStatementAudit(stmt, idx+1, statementStartedAt, 0, 0, err) + if writeExecutionOutcomeUnknown(ctx, err) { + return connection.QueryResult{Success: false, Message: errMsg, Data: map[string]any{"outcomeUnknown": true}, QueryID: queryID} + } return connection.QueryResult{Success: false, Message: errMsg, QueryID: queryID} } resultSets = append(resultSets, connection.ResultSetData{ @@ -1969,6 +2052,9 @@ func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string, logger.Error(err, "DBQueryIsolated 查询失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) return connection.QueryResult{Success: false, Message: err.Error()} } + err = classifyDispatchedWriteError(err) + logger.Error(err, "DBQueryIsolated 写入查询失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) + return buildWriteExecutionFailure(ctx, err, "") } var affected int64 @@ -1980,8 +2066,9 @@ func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string, affected, err = dbInst.Exec(query) } if err != nil { + err = classifyDispatchedWriteError(err) logger.Error(err, "DBQueryIsolated 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: err.Error()} + return buildWriteExecutionFailure(ctx, err, "") } return connection.QueryResult{Success: true, Data: map[string]int64{"affectedRows": affected}} } diff --git a/internal/app/methods_db_audited.go b/internal/app/methods_db_audited.go index f88c4bda..00ffb069 100644 --- a/internal/app/methods_db_audited.go +++ b/internal/app/methods_db_audited.go @@ -1,6 +1,7 @@ package app import ( + "context" "strings" "time" @@ -119,14 +120,64 @@ func (executor *MCPQueryExecutor) DBQueryMulti( config connection.ConnectionConfig, dbName string, query string, +) connection.QueryResult { + return executor.DBQueryMultiContext(context.Background(), config, dbName, query) +} + +// DBQueryMultiContext binds an MCP request lifecycle to the underlying +// database query. This keeps an HTTP client disconnect or stdio shutdown from +// leaving a query running after its MCP caller has gone away. +func (executor *MCPQueryExecutor) DBQueryMultiContext( + ctx context.Context, + config connection.ConnectionConfig, + dbName string, + query string, +) connection.QueryResult { + return executor.dbQueryMultiAuthorizedContext(ctx, config, dbName, query, true) +} + +// DBQueryMultiAuthorizedContext is the MCP execution boundary. It resolves +// the current saved metadata and secrets together, then re-evaluates the +// shared AI safety level and connection protections immediately before the +// SQL call. The resolved-snapshot marker prevents a later execution layer +// from mixing in a newer secret bundle. +func (executor *MCPQueryExecutor) DBQueryMultiAuthorizedContext( + ctx context.Context, + config connection.ConnectionConfig, + dbName string, + query string, + allowMutating bool, +) connection.QueryResult { + return executor.dbQueryMultiAuthorizedContext(ctx, config, dbName, query, allowMutating) +} + +func (executor *MCPQueryExecutor) dbQueryMultiAuthorizedContext( + ctx context.Context, + config connection.ConnectionConfig, + dbName string, + query string, + allowMutating bool, ) connection.QueryResult { if executor == nil || executor.app == nil { return connection.QueryResult{Success: false, Message: "MCP query executor is unavailable"} } - return executor.app.dbQueryMulti(config, dbName, query, "", dbQueryMultiAuditOptions{ - auditAll: true, - auditWrites: true, - source: "mcp", + resolvedConfig, err := executor.app.resolveConnectionSecrets(config) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + runtime := &HeadlessRuntime{app: executor.app} + if err := runtime.authorizeHeadlessSQL(resolvedConfig, query, allowMutating, false); err != nil { + return connection.QueryResult{ + Success: false, + Message: err.Error(), + Data: map[string]any{"errorKind": headlessResultErrorKindPolicy}, + } + } + return executor.app.dbQueryMulti(resolvedConfig, dbName, query, "", dbQueryMultiAuditOptions{ + auditAll: true, + auditWrites: true, + source: "mcp", + executionContext: ctx, }) } diff --git a/internal/app/methods_db_audited_test.go b/internal/app/methods_db_audited_test.go index ebdad1f6..8c730ca5 100644 --- a/internal/app/methods_db_audited_test.go +++ b/internal/app/methods_db_audited_test.go @@ -1,6 +1,7 @@ package app import ( + "context" "os" "path/filepath" "strings" @@ -365,6 +366,10 @@ func TestAIEntryPointAndMCPExecutorRecordAuditSources(t *testing.T) { if !mcpResult.Success { t.Fatalf("MCPQueryExecutor returned failure: %s", mcpResult.Message) } + cliResult := NewCLIQueryExecutor(app).DBQueryMulti(context.Background(), config, "app", "SELECT id FROM users", "cli-audit-source") + if !cliResult.Success { + t.Fatalf("CLIQueryExecutor returned failure: %s", cliResult.Message) + } spoofedResult := app.DBQueryAudited(config, "app", "UPDATE users SET email = 'private@example.test' WHERE id = 7", "mcp") if !spoofedResult.Success { t.Fatalf("DBQueryAudited returned failure: %s", spoofedResult.Message) @@ -381,8 +386,54 @@ func TestAIEntryPointAndMCPExecutorRecordAuditSources(t *testing.T) { if len(mcpEvents) != 1 || mcpEvents[0].Source != "mcp" { t.Fatalf("MCP query did not retain its backend-owned source: %#v", mcpEvents) } + cliEvents := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: cliResult.QueryID}) + if len(cliEvents) != 1 || cliEvents[0].Source != "cli" { + t.Fatalf("CLI query did not retain its backend-owned source: %#v", cliEvents) + } spoofedEvents := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: spoofedResult.QueryID}) if len(spoofedEvents) != 1 || spoofedEvents[0].Source != "application_api" { t.Fatalf("public audited query was able to spoof a privileged source: %#v", spoofedEvents) } } + +type mcpContextCancellationDatabase struct { + sqlAuditTestDatabase + started chan struct{} +} + +func (database *mcpContextCancellationDatabase) QueryContext(ctx context.Context, _ string) ([]map[string]interface{}, []string, error) { + close(database.started) + <-ctx.Done() + return nil, nil, ctx.Err() +} + +func TestMCPQueryExecutorCancelsUnderlyingQueryWithRequestContext(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + database := &mcpContextCancellationDatabase{started: make(chan struct{})} + newDatabaseFunc = func(string) (db.Database, error) { return database, nil } + application := newSQLAuditTestApp(t) + config := connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432, Database: "app"} + requestCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resultCh := make(chan connection.QueryResult, 1) + go func() { + resultCh <- NewMCPQueryExecutor(application).DBQueryMultiContext(requestCtx, config, "app", "SELECT 1") + }() + select { + case <-database.started: + case <-time.After(2 * time.Second): + t.Fatal("MCP query did not reach the context-aware database") + } + cancel() + + select { + case result := <-resultCh: + if result.Success { + t.Fatalf("cancelled MCP query unexpectedly succeeded: %#v", result) + } + case <-time.After(2 * time.Second): + t.Fatal("cancelled MCP query did not return") + } +} diff --git a/internal/app/methods_db_cancel_test.go b/internal/app/methods_db_cancel_test.go index dcfc2956..fb525e1e 100644 --- a/internal/app/methods_db_cancel_test.go +++ b/internal/app/methods_db_cancel_test.go @@ -143,20 +143,12 @@ func TestDBQueryMulti_CanBeCancelledWhileConnecting(t *testing.T) { firstCancel := app.CancelQuery(queryID) secondCancel := app.CancelQuery(queryID) - close(database.connectRelease) - released = true var result connection.QueryResult select { case result = <-resultCh: case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for cancelled query to return") - } - var observedContextErr error - select { - case observedContextErr = <-database.queryContextErr: - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for query context observation") + t.Fatal("cancelled query did not return while Connect was still blocked") } if !firstCancel.Success { @@ -165,12 +157,21 @@ func TestDBQueryMulti_CanBeCancelledWhileConnecting(t *testing.T) { if !secondCancel.Success { t.Errorf("repeated cancellation should succeed until the query owner exits, got: %s", secondCancel.Message) } - if observedContextErr != context.Canceled { - t.Errorf("query should receive the cancellation requested during connect, got context error: %v", observedContextErr) - } if result.Success { t.Fatalf("query should not execute successfully after cancellation, got: %+v", result) } + if !strings.Contains(strings.ToLower(result.Message), "canceled") { + t.Fatalf("cancelled connect returned unexpected result: %+v", result) + } + select { + case observedContextErr := <-database.queryContextErr: + t.Fatalf("SQL execution started after connect cancellation: %v", observedContextErr) + default: + } + + close(database.connectRelease) + released = true + app.Shutdown() app.queryMu.RLock() _, stillRegistered := app.runningQueries[queryID] @@ -311,6 +312,114 @@ func TestNewQueryExecutionContext_UsesExplicitQueryTimeout(t *testing.T) { } } +func TestCLIQueryExecutor_CancelledOpaqueWriteFailureIsUnknown(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + + const statement = "CREATE TABLE cancelled_write_probe (id INTEGER)" + database := &fakeBatchWriteDB{ + execErr: map[string]error{statement: errors.New("driver returned an opaque write failure")}, + execIgnoreContext: true, + } + execStarted := make(chan string, 1) + execRelease := make(chan struct{}) + database.execStarted = execStarted + database.execRelease = execRelease + newDatabaseFunc = func(string) (db.Database, error) { return database, nil } + + ctx, cancel := context.WithCancel(context.Background()) + resultCh := make(chan connection.QueryResult, 1) + go func() { + resultCh <- NewCLIQueryExecutor(NewApp()).DBQueryMulti(ctx, connection.ConnectionConfig{ + Type: "postgres", + Host: "127.0.0.1", + Port: 5432, + }, "app", statement, "cli-cancelled-write") + }() + select { + case <-execStarted: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for write execution") + } + cancel() + close(execRelease) + result := <-resultCh + if result.Success { + t.Fatalf("cancelled write should fail, got %#v", result) + } + data, _ := result.Data.(map[string]any) + if data["outcomeUnknown"] != true { + t.Fatalf("cancelled opaque write must be outcome-unknown, got %#v", result) + } +} + +func TestCLIQueryExecutor_CancelledBeforeConnectionIsCancelled(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + newDatabaseFunc = func(string) (db.Database, error) { return &fakeBatchWriteDB{}, nil } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + result := NewCLIQueryExecutor(NewApp()).DBQueryMulti(ctx, connection.ConnectionConfig{ + Type: "postgres", + Host: "cancel-before-connect.test", + Port: 5432, + }, "app", "CREATE TABLE cancelled_write_probe (id INTEGER)", "cli-cancel-before-connect") + if result.Success { + t.Fatalf("cancelled query should fail, got %#v", result) + } + data, _ := result.Data.(map[string]any) + if data["cancelled"] != true || data["errorKind"] != nil || data["outcomeUnknown"] != nil { + t.Fatalf("pre-connect cancellation must be classified as cancelled, got %#v", result) + } +} + +func TestCLIQueryExecutor_ContextDeadlineDuringReadIsCancelled(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + + const statement = "SELECT 1" + database := &fakeBatchWriteDB{ + queryErr: map[string]error{statement: context.DeadlineExceeded}, + } + newDatabaseFunc = func(string) (db.Database, error) { return database, nil } + + result := NewCLIQueryExecutor(NewApp()).DBQueryMulti( + context.Background(), + connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432, QueryTimeout: 1}, + "app", + statement, + "cli-deadline-read", + ) + if result.Success { + t.Fatalf("deadline read should fail, got %#v", result) + } + data, _ := result.Data.(map[string]any) + if data["cancelled"] != true || data["outcomeUnknown"] != nil { + t.Fatalf("deadline read must be classified as cancelled, got %#v", result) + } +} + +func TestCLIQueryExecutor_ConnectionFailureIsStructured(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + newDatabaseFunc = func(string) (db.Database, error) { + return nil, errors.New("database authentication failed") + } + + result := NewCLIQueryExecutor(newSQLAuditTestApp(t)).DBQueryMulti( + context.Background(), + connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432}, + "app", + "SELECT 1", + "cli-connection-failure", + ) + data, _ := result.Data.(map[string]any) + if result.Success || data["errorKind"] != headlessResultErrorKindConnection { + t.Fatalf("CLI connection failure should be structured, got %#v", result) + } +} + func TestNewQueryExecutionContext_AllDataSourcesDoNotApplyConnectTimeout(t *testing.T) { tests := []struct { name string diff --git a/internal/app/methods_db_multi_test.go b/internal/app/methods_db_multi_test.go index 80c6e127..64f5cc9f 100644 --- a/internal/app/methods_db_multi_test.go +++ b/internal/app/methods_db_multi_test.go @@ -15,25 +15,27 @@ import ( ) type fakeBatchWriteDB struct { - batchCalls int - execCalls int - pingCalls int - execQueries []string - lastQuery string - lastCtx context.Context - queryCalls int - queryQueries []string - queryMap map[string][]map[string]interface{} - fieldMap map[string][]string - messageMap map[string][]string - multiResult map[string][]connection.ResultSetData - queryErr map[string]error - execErr map[string]error - execAffected map[string]int64 - execDelay map[string]time.Duration - execStarted chan<- string - execRelease <-chan struct{} - session *fakeBatchWriteSession + batchCalls int + execCalls int + pingCalls int + execQueries []string + lastQuery string + lastCtx context.Context + queryCalls int + queryQueries []string + queryMap map[string][]map[string]interface{} + fieldMap map[string][]string + messageMap map[string][]string + multiResult map[string][]connection.ResultSetData + queryErr map[string]error + execErr map[string]error + execAffected map[string]int64 + batchErr error + execDelay map[string]time.Duration + execStarted chan<- string + execRelease <-chan struct{} + execIgnoreContext bool + session *fakeBatchWriteSession } type fakeNativeMultiResultDB struct { @@ -188,10 +190,14 @@ func (f *fakeBatchWriteDB) ExecContext(ctx context.Context, query string) (int64 } } if f.execRelease != nil { - select { - case <-f.execRelease: - case <-ctx.Done(): - return 0, ctx.Err() + if f.execIgnoreContext { + <-f.execRelease + } else { + select { + case <-f.execRelease: + case <-ctx.Done(): + return 0, ctx.Err() + } } } if delay := f.execDelay[query]; delay > 0 { @@ -230,6 +236,9 @@ func (f *fakeBatchWriteDB) QueryContextWithMessages(ctx context.Context, query s func (f *fakeBatchWriteDB) ExecBatchContext(ctx context.Context, query string) (int64, error) { f.batchCalls++ f.lastQuery = query + if f.batchErr != nil { + return 0, f.batchErr + } return 500, nil } @@ -1996,6 +2005,74 @@ func TestDBQueryMultiDoesNotBatchExecStoredProcedureAsWriteStatement(t *testing. } } +func TestDBQueryMultiSurfacesUnknownBatchWriteOutcome(t *testing.T) { + installFakeOptionalDriverRuntime(t) + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + + query := "UPDATE demo SET value = 2" + fakeDB := &fakeBatchWriteDB{batchErr: db.MarkWriteOutcomeUnknown(errors.New("write response lost"))} + newDatabaseFunc = func(string) (db.Database, error) { return fakeDB, nil } + + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + result := app.DBQueryMulti(connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432}, "app", query, "cli-unknown-batch") + if result.Success { + t.Fatalf("unknown batch write unexpectedly succeeded: %#v", result) + } + data, ok := result.Data.(map[string]any) + if !ok || data["outcomeUnknown"] != true { + t.Fatalf("unknown batch write did not expose outcomeUnknown: %#v", result) + } + if fakeDB.batchCalls != 1 { + t.Fatalf("unknown batch write was retried: batchCalls=%d", fakeDB.batchCalls) + } +} + +func TestDBQueryMultiDoesNotReplayOpaqueConnectionLossWrite(t *testing.T) { + installFakeOptionalDriverRuntime(t) + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + + query := "UPDATE demo SET value = 3" + fakeDB := &fakeBatchWriteDB{batchErr: errors.New("connection reset by peer")} + newDatabaseFunc = func(string) (db.Database, error) { return fakeDB, nil } + + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + result := app.DBQueryMulti(connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432}, "app", query, "cli-opaque-batch") + if result.Success { + t.Fatalf("opaque connection-loss write unexpectedly succeeded: %#v", result) + } + data, ok := result.Data.(map[string]any) + if !ok || data["outcomeUnknown"] != true { + t.Fatalf("opaque connection-loss write did not expose outcomeUnknown: %#v", result) + } + if fakeDB.batchCalls != 1 { + t.Fatalf("opaque connection-loss write was replayed: batchCalls=%d", fakeDB.batchCalls) + } +} + +func TestDBQueryWithCancelDoesNotReplayOpaqueConnectionLossReturningWrite(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + + query := "INSERT INTO audit_logs(id) VALUES (3) RETURNING id" + fakeDB := &fakeBatchWriteDB{queryErr: map[string]error{query: errors.New("connection reset by peer")}} + newDatabaseFunc = func(string) (db.Database, error) { return fakeDB, nil } + + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + result := app.DBQueryWithCancel(connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432}, "app", query, "cli-opaque-returning") + if result.Success { + t.Fatalf("opaque connection-loss returning write unexpectedly succeeded: %#v", result) + } + data, ok := result.Data.(map[string]any) + if !ok || data["outcomeUnknown"] != true { + t.Fatalf("opaque connection-loss returning write did not expose outcomeUnknown: %#v", result) + } + if fakeDB.queryCalls != 1 || fakeDB.execCalls != 0 { + t.Fatalf("opaque connection-loss returning write was replayed: queryCalls=%d execCalls=%d", fakeDB.queryCalls, fakeDB.execCalls) + } +} + func TestDBQueryMultiRunsSQLServerStatisticsBatchNatively(t *testing.T) { installFakeOptionalDriverRuntime(t) originalNewDatabaseFunc := newDatabaseFunc diff --git a/internal/app/methods_db_transaction.go b/internal/app/methods_db_transaction.go index 090fd170..c693eb55 100644 --- a/internal/app/methods_db_transaction.go +++ b/internal/app/methods_db_transaction.go @@ -557,6 +557,11 @@ func executeManagedSQLTransactionStatementsWithObserver( emitObservation(0, 0, statementErr) return nil, statementErr } + // Query-first writes may already have reached the server. Falling + // through to Exec would replay the same statement in this transaction. + statementErr := buildStatementExecutionFailedError(statementIndex, classifyDispatchedWriteError(err)) + emitObservation(0, 0, statementErr) + return nil, statementErr } affected, err := session.ExecContext(ctx, stmt) diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index af445f36..4f651468 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -29,7 +29,6 @@ import ( "GoNavi-Wails/internal/logger" "GoNavi-Wails/internal/sqlaudit" "GoNavi-Wails/internal/uievents" - "GoNavi-Wails/internal/utils" "github.com/google/uuid" "github.com/wailsapp/wails/v2/pkg/runtime" @@ -86,10 +85,25 @@ type sqlFileExecutionOptions struct { MaxStatementBytes int64 ContinueOnError bool PreflightEachStatement bool + TransactionMode sqlFileTransactionMode + StatementGuard func(index int, stmt string) error Text fileBackendTextFunc OnProgress func(sqlFileExecutionProgress) } +type sqlFileTransactionMode string + +const ( + sqlFileTransactionModeOff sqlFileTransactionMode = "off" + sqlFileTransactionModeSingle sqlFileTransactionMode = "single" +) + +type sqlFileExecutionPolicy struct { + TransactionMode sqlFileTransactionMode + ForceFullPreflight bool + StatementGuard func(index int, stmt string) error +} + type sqlFileExecutionResult struct { Executed int Failed int @@ -124,6 +138,27 @@ type sqlFilePreflightRejectedError struct { outcomeUnknown bool } +// sqlFilePolicyRejectedError marks a statement guard denial found while the +// source is still being preflighted. The outer runner can then report it as a +// pre-execution failure rather than an open-file error. +type sqlFilePolicyRejectedError struct { + err error +} + +func (err *sqlFilePolicyRejectedError) Error() string { + if err == nil || err.err == nil { + return "SQL file execution policy rejected the source" + } + return err.err.Error() +} + +func (err *sqlFilePolicyRejectedError) Unwrap() error { + if err == nil { + return nil + } + return err.err +} + func (err *sqlFilePreflightRejectedError) Error() string { if err == nil { return "" @@ -160,9 +195,10 @@ func buildSQLFilePreflightFailurePayload(err *sqlFilePreflightRejectedError) map func isSQLFilePreExecutionValidationError(err error) bool { var preflightErr *sqlFilePreflightRejectedError + var policyErr *sqlFilePolicyRejectedError var statementLimitErr *SQLStatementTooLargeError var sourceLimitErr *SQLImportSourceLimitError - return errors.As(err, &preflightErr) || errors.As(err, &statementLimitErr) || errors.As(err, &sourceLimitErr) + return errors.As(err, &preflightErr) || errors.As(err, &policyErr) || errors.As(err, &statementLimitErr) || errors.As(err, &sourceLimitErr) } func (e *sqlFileStoppedOnError) Error() string { @@ -1452,6 +1488,9 @@ func normalizeSQLFileExecutionOptions(options sqlFileExecutionOptions) sqlFileEx if options.MaxStatementBytes <= 0 { options.MaxStatementBytes = DefaultSQLImportMaxStatementBytes } + if options.TransactionMode != sqlFileTransactionModeSingle { + options.TransactionMode = sqlFileTransactionModeOff + } return options } @@ -1902,25 +1941,27 @@ func executeSQLFileBatch(ctx context.Context, execer sqlFileStatementExecer, bat func executeSQLFileBatchWithOutcome(ctx context.Context, execer sqlFileStatementExecer, batcher sqlFileBatchStatementExecer, dbType string, batchSQL string, useTransaction bool, text fileBackendTextFunc) (canFallback bool, outcomeUnknown bool, err error) { if !useTransaction { _, err = batcher.ExecBatchContext(ctx, batchSQL) - return false, false, err + return false, db.IsWriteOutcomeUnknown(err) || db.IsAmbiguousWriteResponse(err), err } beginSQL, commitSQL, rollbackSQL, ok := sqlFileBatchTransactionSQL(dbType) if !ok { _, err = batcher.ExecBatchContext(ctx, batchSQL) - return false, false, err + return false, db.IsWriteOutcomeUnknown(err) || db.IsAmbiguousWriteResponse(err), err } if _, err := execSQLFileStatement(ctx, execer, beginSQL); err != nil { + unknown := db.IsWriteOutcomeUnknown(err) || db.IsAmbiguousWriteResponse(err) if rollbackErr := rollbackSQLFileTransaction(execer, rollbackSQL); rollbackErr != nil { return false, true, errors.New(fileBackendText(text, "file.backend.error.sql_file_batch_rollback_failed", map[string]any{ "detail": sanitizeSQLFileExecutionErr(err), "rollbackDetail": sanitizeSQLFileExecutionErr(rollbackErr), })) } - return false, false, err + return false, unknown, err } if _, err := batcher.ExecBatchContext(ctx, batchSQL); err != nil { + unknown := db.IsWriteOutcomeUnknown(err) || db.IsAmbiguousWriteResponse(err) if rollbackErr := rollbackSQLFileTransaction(execer, rollbackSQL); rollbackErr != nil { return false, true, errors.New(fileBackendText(text, "file.backend.error.sql_file_batch_rollback_failed", map[string]any{ "detail": sanitizeSQLFileExecutionErr(err), @@ -1931,6 +1972,9 @@ func executeSQLFileBatchWithOutcome(ctx context.Context, execer sqlFileStatement // ROLLBACK therefore cannot prove that a partially executed batch left no // writes behind. Stop and surface the uncertainty instead of inviting a // blind replay. + if unknown { + return false, true, err + } return true, isSQLFileMySQLCompatibleDialect(dbType), err } if _, err := execSQLFileStatement(ctx, execer, commitSQL); err != nil { @@ -1947,8 +1991,294 @@ func executeSQLFileBatchWithOutcome(ctx context.Context, execer sqlFileStatement return false, false, nil } +func isSQLFileSingleTransactionDialectSupported(dbType string) bool { + switch normalizeSQLClassifierDBType(dbType) { + case "postgres", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb", "sqlite", "duckdb", "iris", "sqlserver", "oracle", "dameng": + return true + default: + return false + } +} + +func sqlFileSingleTransactionRequiresDriverExecer(dbType string) bool { + switch normalizeSQLClassifierDBType(dbType) { + case "oracle", "dameng": + return true + default: + return false + } +} + +func sqlFileSingleTransactionSQL(dbType string) (beginSQL string, commitSQL string, rollbackSQL string, ok bool) { + switch normalizeSQLClassifierDBType(dbType) { + case "sqlserver": + return "BEGIN TRANSACTION", "COMMIT TRANSACTION", "ROLLBACK TRANSACTION", true + case "postgres", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb", "sqlite", "duckdb", "iris": + return "BEGIN", "COMMIT", "ROLLBACK", true + default: + return "", "", "", false + } +} + +func isSQLFileSingleTransactionControlStatement(dbType, stmt string) bool { + if isSQLTransactionControlStatement(stmt) { + return true + } + keyword, keywordEnd := nextSQLKeyword(stmt, 0) + switch keyword { + case "end": + return sqlFileStatementIsTransactionEndAlias(dbType, stmt, keywordEnd) + case "abort": + switch normalizeSQLClassifierDBType(dbType) { + case "postgres", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb", "duckdb": + return true + } + case "set": + // Session and transaction settings can alter the outer transaction's + // semantics. Other SET statements are rejected below as unknown too. + return sqlContainsKeyword(stmt, "autocommit") || sqlContainsKeyword(stmt, "transaction") || sqlContainsKeyword(stmt, "isolation") + } + return false +} + +func validateSQLFileSingleTransactionStatement(dbType, stmt string) error { + dbType = normalizeSQLClassifierDBType(dbType) + if !isSQLFileSingleTransactionDialectSupported(dbType) { + return fmt.Errorf("single-transaction SQL-file execution cannot prove atomicity for database type %q", dbType) + } + if isSQLFileMySQLCompatibleDialect(dbType) || sqlFileMySQLImplicitCommitBeforeStatement(dbType, stmt) { + return errors.New("single-transaction SQL-file execution rejects MySQL-family implicit commits") + } + if isSQLFileSingleTransactionControlStatement(dbType, stmt) { + return errors.New("single-transaction SQL-file execution rejects explicit transaction control or transaction settings") + } + if isReadOnlySQLQuery(dbType, stmt) || isBatchableWriteSQLStatement(dbType, stmt) { + return nil + } + return errors.New("single-transaction SQL-file execution rejects statements whose atomicity cannot be proven") +} + +func executeSQLFileSingleTransactionStream(ctx context.Context, dbInst db.Database, reader io.Reader, options sqlFileExecutionOptions, bytesRead func() int64) (result sqlFileExecutionResult, runErr error) { + if options.ContinueOnError { + return result, errors.New("single-transaction SQL-file execution does not support continue-on-error") + } + if !isSQLFileSingleTransactionDialectSupported(options.DBType) { + return result, fmt.Errorf("single-transaction SQL-file execution cannot prove atomicity for database type %q", normalizeSQLClassifierDBType(options.DBType)) + } + + var execer sqlFileStatementExecer + var closeHandle func() error + var discardHandle func() error + var rollbackTransaction func() error + var commitTransaction func() error + transactionActive := false + discardOnCleanup := false + + if provider, ok := dbInst.(db.TransactionExecerProvider); ok { + transaction, err := provider.OpenTransactionExecer(ctx) + if err != nil { + return result, err + } + execer = transaction + transactionActive = true + closeHandle = transaction.Close + if discarder, ok := transaction.(db.StatementExecerDiscarter); ok { + discardHandle = discarder.Discard + } + rollbackTransaction = func() error { + transactionActive = false + return transaction.Rollback() + } + commitTransaction = func() error { + err := transaction.Commit() + if err == nil { + transactionActive = false + } + return err + } + } else { + if sqlFileSingleTransactionRequiresDriverExecer(options.DBType) { + return result, errors.New("single-transaction SQL-file execution requires a driver-backed transaction handle for this database type") + } + provider, ok := dbInst.(db.SessionExecerProvider) + if !ok { + return result, errors.New("single-transaction SQL-file execution requires a pinned database session") + } + session, err := provider.OpenSessionExecer(ctx) + if err != nil { + return result, err + } + execer = session + closeHandle = session.Close + if discarder, ok := session.(db.StatementExecerDiscarter); ok { + discardHandle = discarder.Discard + } + beginSQL, commitSQL, rollbackSQL, ok := sqlFileSingleTransactionSQL(options.DBType) + if !ok { + _ = session.Close() + return result, errors.New("single-transaction SQL-file execution cannot open a dialect transaction") + } + if _, err := execSQLFileStatement(ctx, execer, beginSQL); err != nil { + discardOnCleanup = true + if discardHandle != nil { + _ = discardHandle() + } + _ = session.Close() + return result, err + } + transactionActive = true + rollbackTransaction = func() error { + transactionActive = false + return rollbackSQLFileTransaction(execer, rollbackSQL) + } + commitTransaction = func() error { + _, err := execSQLFileStatement(ctx, execer, commitSQL) + if err == nil { + transactionActive = false + } + return err + } + } + + defer func() { + if transactionActive && rollbackTransaction != nil { + if err := rollbackTransaction(); err != nil { + result.OutcomeUnknown = true + discardOnCleanup = true + logger.Warnf("ExecuteSQLFile single transaction rollback failed: type=%s err=%s", options.DBType, sanitizeSQLFileExecutionErr(err)) + } + } + if discardOnCleanup && discardHandle != nil { + if err := discardHandle(); err != nil { + logger.Warnf("ExecuteSQLFile single transaction discard failed: type=%s err=%s", options.DBType, sanitizeSQLFileExecutionErr(err)) + } + } + if closeHandle != nil { + if err := closeHandle(); err != nil { + if discardHandle != nil { + _ = discardHandle() + } + logger.Warnf("ExecuteSQLFile single transaction session close failed: type=%s err=%s", options.DBType, sanitizeSQLFileExecutionErr(err)) + } + } + }() + + readBytes := func() int64 { + if bytesRead == nil { + return 0 + } + return bytesRead() + } + var lastProgressAt time.Time + emitProgress := func(currentSQL string) { + if options.OnProgress == nil { + return + } + total := result.Executed + result.Failed + options.OnProgress(sqlFileExecutionProgress{ + Status: "running", + Executed: result.Executed, + Failed: result.Failed, + Total: total, + BytesRead: readBytes(), + CurrentSQL: currentSQL, + }) + lastProgressAt = time.Now() + } + shouldEmitProgress := func() bool { + total := result.Executed + result.Failed + if total <= 10 || total%sqlFileProgressStatementInterval == 0 { + return true + } + return !lastProgressAt.IsZero() && time.Since(lastProgressAt) >= sqlFileProgressTimeInterval + } + recordError := func(index int, stmt string, err error) string { + result.Failed++ + detail := fileBackendText(options.Text, "file.backend.message.statement_failed", map[string]any{ + "index": index + 1, + "detail": sanitizeSQLFileExecutionError(err.Error()), + "sql": sqlFileStatementSnippet(stmt, 200), + }) + if len(result.Errors) < sqlFileMaxErrorDetails { + result.Errors = append(result.Errors, detail) + } + logger.Warnf("ExecuteSQLFile %s", detail) + return detail + } + + _, streamErr := StreamSQLFileWithOptions(reader, SQLStreamOptions{ + DBType: options.DBType, + MaxStatementBytes: options.MaxStatementBytes, + }, func(index int, stmt string) error { + if err := ctx.Err(); err != nil { + return errSQLFileCancelled + } + stmt = strings.TrimSpace(stmt) + if stmt == "" { + return nil + } + if options.PreflightEachStatement { + preflightResult := PreflightSQLStatement(stmt, options.DBType, index) + if !preflightResult.Safe && preflightResult.Reason != nil { + return &sqlFilePreflightRejectedError{reason: *preflightResult.Reason} + } + } + if options.StatementGuard != nil { + if err := options.StatementGuard(index, stmt); err != nil { + return err + } + } + if err := validateSQLFileSingleTransactionStatement(options.DBType, stmt); err != nil { + return err + } + + if _, err := execSQLFileStatement(ctx, execer, stmt); err != nil { + if db.IsWriteOutcomeUnknown(err) || db.IsAmbiguousWriteResponse(err) { + // A driver can lose the response after dispatch without the + // context being cancelled. Do not flatten that into an ordinary + // statement failure: the transaction's server-side state is not + // knowable from the client. + result.OutcomeUnknown = true + } + if ctx.Err() != nil { + // The driver may have sent the statement before cancellation was + // observed, so a later rollback result cannot prove the outcome. + result.OutcomeUnknown = true + return errSQLFileCancelled + } + detail := recordError(index, stmt, err) + if shouldEmitProgress() { + emitProgress(sqlFileStatementSnippet(stmt, 100)) + } + return &sqlFileStoppedOnError{detail: detail} + } + result.Executed++ + if shouldEmitProgress() { + emitProgress(sqlFileStatementSnippet(stmt, 100)) + } + return nil + }) + if streamErr != nil { + return result, streamErr + } + if err := ctx.Err(); err != nil { + return result, errSQLFileCancelled + } + if err := commitTransaction(); err != nil { + // A commit response can be lost after the server has committed. Retain + // the ambiguity even when a best-effort rollback succeeds during cleanup. + result.OutcomeUnknown = true + discardOnCleanup = true + return result, fmt.Errorf("single-transaction SQL-file commit failed: %w", err) + } + return result, nil +} + func executeSQLFileStream(ctx context.Context, dbInst db.Database, reader io.Reader, options sqlFileExecutionOptions, bytesRead func() int64) (sqlFileExecutionResult, error) { options = normalizeSQLFileExecutionOptions(options) + if options.TransactionMode == sqlFileTransactionModeSingle { + return executeSQLFileSingleTransactionStream(ctx, dbInst, reader, options, bytesRead) + } var result sqlFileExecutionResult var batch []sqlFilePendingStatement var batchBytes int @@ -2067,6 +2397,12 @@ func executeSQLFileStream(ctx context.Context, dbInst db.Database, reader io.Rea return false, errSQLFileCancelled } if _, err := execSQLFileStatement(ctx, execer, item.SQL); err != nil { + unknown := db.IsWriteOutcomeUnknown(err) || db.IsAmbiguousWriteResponse(err) + if unknown { + // A lost response must stop the file even in transaction=off + // continue mode; replaying the statement could duplicate a write. + result.OutcomeUnknown = true + } if sqlFileStatementFinishesTransaction(item.SQL) { // A user-authored COMMIT/ROLLBACK may have reached the server even // when its result (including cancellation) was not observed. @@ -2077,6 +2413,12 @@ func executeSQLFileStream(ctx context.Context, dbInst db.Database, reader io.Rea return false, errSQLFileCancelled } errLog := recordError(item.Index, item.SQL, err) + if unknown { + if shouldEmitProgress() { + emitProgress(sqlFileStatementSnippet(item.SQL, 100)) + } + return false, &sqlFileStoppedOnError{detail: errLog} + } if !options.ContinueOnError { if shouldEmitProgress() { emitProgress(sqlFileStatementSnippet(item.SQL, 100)) @@ -2138,6 +2480,14 @@ func executeSQLFileStream(ctx context.Context, dbInst db.Database, reader io.Rea canFallback, outcomeUnknown, err := executeSQLFileBatchWithOutcome(ctx, execer, batcher, options.DBType, batchSQL, useTransactionalBatch, options.Text) if outcomeUnknown { result.OutcomeUnknown = true + // Never bisect or replay a batch after a response whose server-side + // outcome cannot be established. + if err != nil { + return errors.New(fileBackendText(options.Text, "file.backend.error.sql_file_batch_execution_failed", map[string]any{ + "index": items[0].Index + 1, + "detail": sanitizeSQLFileExecutionErr(err), + })) + } } if err == nil { result.Executed += len(items) @@ -2242,6 +2592,11 @@ func executeSQLFileStream(ctx context.Context, dbInst db.Database, reader io.Rea } } } + if options.StatementGuard != nil { + if err := options.StatementGuard(index, stmt); err != nil { + return err + } + } if supportsBatch && !safeSequentialContinue && userTransactionDepth == 0 && !mysqlAutocommitDisabled && !mysqlTablesLocked && isSQLFileBatchableWriteStatement(options.DBType, stmt) { stmtBytes := len(stmt) @@ -2456,6 +2811,43 @@ func prepareSQLFileExecutionSource(filePath, dbType string, maxStatementBytes in } func prepareSQLFileExecutionSourceWithContext(ctx context.Context, filePath, dbType string, maxStatementBytes int64, rawObserver io.Writer, preflightRawObserver io.Writer) (*preparedSQLFileExecutionSource, error) { + return prepareSQLFileExecutionSourceWithPolicyContext(ctx, filePath, dbType, maxStatementBytes, rawObserver, preflightRawObserver, sqlFileExecutionPolicy{}) +} + +func preflightSQLFileExecutionSourceWithPolicy(reader io.Reader, dbType string, maxStatementBytes int64, statementGuard func(index int, stmt string) error) (SQLImportPreflightResult, error) { + if statementGuard == nil { + return PreflightSQLImportWithOptions(reader, SQLStreamOptions{ + DBType: dbType, + MaxStatementBytes: maxStatementBytes, + }) + } + + result := SQLImportPreflightResult{Safe: true} + normalizedType := normalizeExplainLexicalDBType(dbType) + _, err := StreamSQLFileWithOptions(reader, SQLStreamOptions{ + DBType: normalizedType, + MaxStatementBytes: maxStatementBytes, + }, func(index int, stmt string) error { + statementResult := PreflightSQLStatement(stmt, normalizedType, index) + if !statementResult.Safe { + result = statementResult + return errSQLImportPreflightRejected + } + if err := statementGuard(index, strings.TrimSpace(stmt)); err != nil { + return &sqlFilePolicyRejectedError{err: err} + } + return nil + }) + if errors.Is(err, errSQLImportPreflightRejected) { + return result, nil + } + if err != nil { + return SQLImportPreflightResult{}, err + } + return result, nil +} + +func prepareSQLFileExecutionSourceWithPolicyContext(ctx context.Context, filePath, dbType string, maxStatementBytes int64, rawObserver io.Writer, preflightRawObserver io.Writer, policy sqlFileExecutionPolicy) (*preparedSQLFileExecutionSource, error) { if ctx == nil { ctx = context.Background() } @@ -2469,7 +2861,7 @@ func prepareSQLFileExecutionSourceWithContext(ctx context.Context, filePath, dbT if info.IsDir() { return nil, fmt.Errorf("SQL import source is a directory") } - fullPreflight := shouldFullyPreflightSQLFile(info.Size()) + fullPreflight := policy.ForceFullPreflight || shouldFullyPreflightSQLFile(info.Size()) var preamble []byte if fullPreflight { preflightSource, err := OpenSQLImportSource(filePath, SQLImportSourceOptions{RawObserver: preflightRawObserver}) @@ -2483,10 +2875,7 @@ func prepareSQLFileExecutionSourceWithContext(ctx context.Context, filePath, dbT }) if readErr == nil { var preflightResult SQLImportPreflightResult - preflightResult, readErr = PreflightSQLImportWithOptions(preflightReader, SQLStreamOptions{ - DBType: dbType, - MaxStatementBytes: maxStatementBytes, - }) + preflightResult, readErr = preflightSQLFileExecutionSourceWithPolicy(preflightReader, dbType, maxStatementBytes, policy.StatementGuard) if readErr == nil && !preflightResult.Safe && preflightResult.Reason != nil { readErr = &sqlFilePreflightRejectedError{reason: *preflightResult.Reason} } @@ -2669,13 +3058,63 @@ func (a *App) executeSQLFileWithStatementLimit(config connection.ConnectionConfi } func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.ConnectionConfig, dbName string, filePath string, jobID string, continueOnError bool, maxStatementBytes int64, requirePinnedSession bool) (result connection.QueryResult) { + return a.executeSQLFileWithStatementLimitPolicyContext( + context.Background(), + config, + dbName, + filePath, + jobID, + continueOnError, + maxStatementBytes, + requirePinnedSession, + "sql_file", + ) +} + +// executeSQLFileWithStatementLimitPolicyContext is the shared streaming +// runner used by desktop and headless callers. The audit source stays an +// internal argument so an external caller cannot forge a provenance value. +func (a *App) executeSQLFileWithStatementLimitPolicyContext(parent context.Context, config connection.ConnectionConfig, dbName string, filePath string, jobID string, continueOnError bool, maxStatementBytes int64, requirePinnedSession bool, auditSource string) (result connection.QueryResult) { + return a.executeSQLFileWithStatementLimitPolicyContextWithPolicy( + parent, + config, + dbName, + filePath, + jobID, + continueOnError, + maxStatementBytes, + requirePinnedSession, + auditSource, + sqlFileExecutionPolicy{TransactionMode: sqlFileTransactionModeOff}, + ) +} + +func (a *App) executeSQLFileWithStatementLimitPolicyContextWithPolicy(parent context.Context, config connection.ConnectionConfig, dbName string, filePath string, jobID string, continueOnError bool, maxStatementBytes int64, requirePinnedSession bool, auditSource string, policy sqlFileExecutionPolicy) (result connection.QueryResult) { + if parent == nil { + parent = context.Background() + } + if policy.TransactionMode != sqlFileTransactionModeSingle { + policy.TransactionMode = sqlFileTransactionModeOff + } + if policy.TransactionMode == sqlFileTransactionModeSingle { + policy.ForceFullPreflight = true + if continueOnError { + return connection.QueryResult{Success: false, Message: "single-transaction SQL-file execution does not support continue-on-error"} + } + if !isSQLFileSingleTransactionDialectSupported(resolveDDLDBType(config)) { + return connection.QueryResult{Success: false, Message: "single-transaction SQL-file execution cannot prove atomicity for this database type"} + } + } if maxStatementBytes <= 0 { maxStatementBytes = DefaultSQLImportMaxStatementBytes } + if strings.ToLower(strings.TrimSpace(auditSource)) != "cli" { + auditSource = "sql_file" + } auditSQL := "EXECUTE SQL FILE" auditStatementCount := 0 auditSafeError := "SQL file task failed before an execution summary was available" - defer a.beginSQLAuditUserActionWithOptions(config, dbName, "sql_file", &auditSQL, &result, sqlAuditUserActionOptions{ + defer a.beginSQLAuditUserActionWithOptions(config, dbName, auditSource, &auditSQL, &result, sqlAuditUserActionOptions{ StatementCount: &auditStatementCount, SafeError: &auditSafeError, })() @@ -2692,7 +3131,7 @@ func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.Connectio } logger.Warnf("ExecuteSQLFile 开始:source=%s size=%d db=%s jobID=%s", sourceIdentity.Token, sourceIdentity.Size, dbName, jobID) - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(parent) cleanupRegistration, registered := a.registerImportTask(jobID, cancel, importjob.KindSQL) if !registered { cancel() @@ -2711,7 +3150,7 @@ func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.Connectio TargetFingerprint: buildImportTargetFingerprint(config, dbName, ""), ConnectionID: config.ID, DatabaseName: dbName, - OptionsHash: buildSQLImportOptionsHash(continueOnError, maxStatementBytes), + OptionsHash: buildSQLImportOptionsHashWithTransactionMode(continueOnError, maxStatementBytes, policy.TransactionMode), }) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} @@ -2772,7 +3211,7 @@ func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.Connectio return jobPersistErr }} fileDigest := sha256.New() - preparedSource, err := prepareSQLFileExecutionSourceWithContext(ctx, filePath, resolveDDLDBType(config), maxStatementBytes, fileDigest, preflightObserver) + preparedSource, err := prepareSQLFileExecutionSourceWithPolicyContext(ctx, filePath, resolveDDLDBType(config), maxStatementBytes, fileDigest, preflightObserver, policy) if err != nil { if jobPersistErr != nil { return connection.QueryResult{ @@ -2794,6 +3233,10 @@ func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.Connectio if errors.As(err, &preflightErr) { data = buildSQLFilePreflightFailurePayload(preflightErr) } + var policyErr *HeadlessSQLPolicyError + if errors.As(err, &policyErr) { + data["errorKind"] = headlessResultErrorKindPolicy + } return connection.QueryResult{ Success: false, Data: data, @@ -2823,7 +3266,7 @@ func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.Connectio // GoNavi 的 MySQL 整库备份会在脚本中创建并 USE 源库,因此不能先连接到该库。 runConfig := resolveSQLFileExecutionRunConfig(config, dbName, preamble) - dbInst, err := a.getDatabase(runConfig) + dbInst, err := a.getDatabaseWithContext(ctx, runConfig, false) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) { return connection.QueryResult{ @@ -2833,7 +3276,11 @@ func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.Connectio } } logger.Errorf("ExecuteSQLFile 获取连接失败:%s err=%s", formatConnSummary(runConfig), sanitizeSQLFileExecutionErr(err)) - return connection.QueryResult{Success: false, Message: sanitizeSQLFileExecutionErr(err)} + result := connection.QueryResult{Success: false, Message: sanitizeSQLFileExecutionErr(err)} + if strings.EqualFold(strings.TrimSpace(auditSource), "cli") { + result.Data = map[string]interface{}{"errorKind": headlessResultErrorKindConnection} + } + return result } if err := ctx.Err(); err != nil { return connection.QueryResult{ @@ -2921,6 +3368,8 @@ func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.Connectio DBType: resolveDDLDBType(runConfig), MaxStatementBytes: maxStatementBytes, ContinueOnError: continueOnError, + TransactionMode: policy.TransactionMode, + StatementGuard: policy.StatementGuard, // Keep the callback guard even after a full small-file preflight so a // source replacement between the two opens cannot send client commands // to the database. @@ -2949,7 +3398,7 @@ func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.Connectio auditSQL = fmt.Sprintf("EXECUTE SQL FILE EXECUTED_%d FAILED_%d", executedCount, failedCount) auditSafeError = fmt.Sprintf("SQL file task failed after executing %d statement(s); %d statement(s) failed", executedCount, failedCount) rawBytesRead := preparedSource.source.RawBytesRead() - mayHaveDatabaseSideEffects = mayHaveDatabaseSideEffects || executedCount > 0 || failedCount > 0 + mayHaveDatabaseSideEffects = mayHaveDatabaseSideEffects || executedCount > 0 || failedCount > 0 || execResult.OutcomeUnknown contentSHA256 := "" if totalSizeKnown && rawBytesRead == totalSize { contentSHA256 = hex.EncodeToString(fileDigest.Sum(nil)) @@ -3030,6 +3479,10 @@ func (a *App) executeSQLFileWithStatementLimitPolicy(config connection.Connectio } else if execResult.OutcomeUnknown || failedCount > 0 { data["outcomeUnknown"] = true } + var policyErr *HeadlessSQLPolicyError + if errors.As(streamErr, &policyErr) { + data["errorKind"] = headlessResultErrorKindPolicy + } return connection.QueryResult{ Success: false, Data: data, @@ -6440,23 +6893,42 @@ func (a *App) ExportQueryWithOptions(config connection.ConnectionConfig, dbName } func queryDataForExport(dbInst db.Database, config connection.ConnectionConfig, query string) ([]map[string]interface{}, []string, error) { + return queryDataForExportWithContext(context.Background(), dbInst, config, query) +} + +// queryDataForExportWithContext is the buffered export fallback. It retains +// the caller's cancellation signal instead of creating an unrelated +// background deadline, which is required for the headless CLI export path. +func queryDataForExportWithContext(parent context.Context, dbInst db.Database, config connection.ConnectionConfig, query string) ([]map[string]interface{}, []string, error) { + if parent == nil { + parent = context.Background() + } timeout := getExportQueryTimeout(config) dbType := resolveDDLDBType(config) if dbType == "clickhouse" { logger.Infof("ClickHouse 导出查询开始:timeout=%s SQL片段=%q", timeout, sqlSnippet(query)) } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() if q, ok := dbInst.(interface { QueryContext(context.Context, string) ([]map[string]interface{}, []string, error) }); ok { - ctx, cancel := utils.ContextWithTimeout(timeout) - defer cancel() data, columns, err := q.QueryContext(ctx, query) + if err == nil && ctx.Err() != nil { + err = ctx.Err() + } if err != nil && dbType == "clickhouse" { logger.Warnf("ClickHouse 导出查询失败:timeout=%s SQL片段=%q err=%v", timeout, sqlSnippet(query), err) } return data, columns, err } + if err := ctx.Err(); err != nil { + return nil, nil, err + } data, columns, err := dbInst.Query(query) + if err == nil && ctx.Err() != nil { + err = ctx.Err() + } if err != nil && dbType == "clickhouse" { logger.Warnf("ClickHouse 导出查询失败(无 QueryContext):timeout=%s SQL片段=%q err=%v", timeout, sqlSnippet(query), err) } @@ -6464,6 +6936,9 @@ func queryDataForExport(dbInst db.Database, config connection.ConnectionConfig, } func getExportQueryTimeout(config connection.ConnectionConfig) time.Duration { + if config.QueryTimeout > 0 { + return time.Duration(config.QueryTimeout) * time.Second + } timeout := time.Duration(config.Timeout) * time.Second if timeout <= 0 { timeout = minExportQueryTimeout @@ -7215,12 +7690,21 @@ func newExportFileWriter(f *os.File, options ExportFileOptions) (exportFileWrite } func streamQueryDataForExport(dbInst db.Database, config connection.ConnectionConfig, query string, consumer db.QueryStreamConsumer) error { + return streamQueryDataForExportWithContext(context.Background(), dbInst, config, query, consumer) +} + +// streamQueryDataForExportWithContext preserves the existing streaming and +// fallback behavior while allowing headless callers to cancel a live export. +func streamQueryDataForExportWithContext(ctx context.Context, dbInst db.Database, config connection.ConnectionConfig, query string, consumer db.QueryStreamConsumer) error { if consumer == nil { return fmt.Errorf("export consumer required") } + if ctx == nil { + ctx = context.Background() + } timeout := getExportQueryTimeout(config) - ctx, cancel := utils.ContextWithTimeout(timeout) + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() if streamer, ok := dbInst.(db.StreamQueryExecer); ok { @@ -7240,15 +7724,21 @@ func streamQueryDataForExport(dbInst db.Database, config connection.ConnectionCo } logger.Warnf("导出流式查询不可用,回退到缓冲导出:type=%s", strings.TrimSpace(config.Type)) - data, columns, err := queryDataForExport(dbInst, config, query) + data, columns, err := queryDataForExportWithContext(ctx, dbInst, config, query) if err != nil { return err } columns = resolveExportColumns(columns, data) + if err := ctx.Err(); err != nil { + return err + } if err := consumer.SetColumns(columns); err != nil { return err } for _, row := range data { + if err := ctx.Err(); err != nil { + return err + } if err := consumer.ConsumeRow(row); err != nil { return err } @@ -7257,6 +7747,10 @@ func streamQueryDataForExport(dbInst db.Database, config connection.ConnectionCo } func exportQueryResultToFile(f *os.File, dbInst db.Database, config connection.ConnectionConfig, query string, options ExportFileOptions, reporter *exportProgressReporter) (int64, []string, error) { + return exportQueryResultToFileWithContext(context.Background(), f, dbInst, config, query, options, reporter) +} + +func exportQueryResultToFileWithContext(ctx context.Context, f *os.File, dbInst db.Database, config connection.ConnectionConfig, query string, options ExportFileOptions, reporter *exportProgressReporter) (int64, []string, error) { options = normalizeExportFileOptions("", options) if err := validateExportColumnsSelection(options); err != nil { return 0, nil, err @@ -7279,7 +7773,7 @@ func exportQueryResultToFile(f *os.File, dbInst db.Database, config connection.C delegate = projection } consumer := &countingExportConsumer{delegate: delegate, reporter: reporter} - streamErr := streamQueryDataForExport(dbInst, config, query, consumer) + streamErr := streamQueryDataForExportWithContext(ctx, dbInst, config, query, consumer) if reporter != nil && streamErr == nil { reporter.Finalizing(consumer.rowCount) } diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go index fac9cf77..8a16141a 100644 --- a/internal/app/methods_file_export_test.go +++ b/internal/app/methods_file_export_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -641,6 +642,34 @@ func TestQueryDataForExport_UsesLargerConfiguredTimeout(t *testing.T) { } } +func TestGetExportQueryTimeout_ExplicitQueryTimeoutOverridesExportMinimum(t *testing.T) { + timeout := getExportQueryTimeout(connection.ConnectionConfig{ + Type: "mysql", + Timeout: 900, + QueryTimeout: 17, + }) + if timeout != 17*time.Second { + t.Fatalf("explicit query timeout should take precedence, want=%s got=%s", 17*time.Second, timeout) + } +} + +func TestQueryDataForExportWithContext_PreservesCallerCancellation(t *testing.T) { + fake := &fakeExportQueryDB{ + data: []map[string]interface{}{{"v": 1}}, + cols: []string{"v"}, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := queryDataForExportWithContext(ctx, fake, connection.ConnectionConfig{QueryTimeout: 60}, "SELECT 1") + if !errors.Is(err, context.Canceled) { + t.Fatalf("buffered export fallback should return caller cancellation, got %v", err) + } + if !fake.hasContextDeadline { + t.Fatal("buffered export fallback must still apply its query deadline") + } +} + func TestResolveExportTotalRowsFromRows_PrefersNamedTotalColumn(t *testing.T) { total, ok := resolveExportTotalRowsFromRows([]map[string]interface{}{ {"COUNT": "96000", "other": 1}, diff --git a/internal/app/methods_file_sql_execution_test.go b/internal/app/methods_file_sql_execution_test.go index 5219741d..05e44771 100644 --- a/internal/app/methods_file_sql_execution_test.go +++ b/internal/app/methods_file_sql_execution_test.go @@ -122,6 +122,39 @@ func TestExecuteSQLFileStreamRedactsBatchExecutionErrors(t *testing.T) { } } +func TestExecuteSQLFileStreamDoesNotContinueAfterUnknownWriteOutcome(t *testing.T) { + database := &fakeSQLFileBatchDB{execError: func(query string) error { + if strings.HasPrefix(query, "CREATE TABLE") { + return db.MarkWriteOutcomeUnknown(errors.New("write response lost")) + } + return nil + }} + result, err := executeSQLFileStream(context.Background(), database, strings.NewReader("CREATE TABLE demo(id integer); INSERT INTO demo(id) VALUES (1);"), sqlFileExecutionOptions{ + DBType: "postgres", + TransactionMode: sqlFileTransactionModeOff, + ContinueOnError: true, + }, nil) + if err == nil || !result.OutcomeUnknown { + t.Fatalf("unknown write result = %#v, err=%v; want stopped unknown outcome", result, err) + } + if len(database.execQueries) != 1 || database.execQueries[0] != "CREATE TABLE demo(id integer)" { + t.Fatalf("unknown write was continued or replayed: %#v", database.execQueries) + } +} + +func TestExecuteSQLFileBatchUnknownOutcomeDisablesFallback(t *testing.T) { + database := &fakeSQLFileBatchDB{ + failBatch: true, + batchError: db.MarkWriteOutcomeUnknown(errors.New("batch response lost")), + } + canFallback, outcomeUnknown, err := executeSQLFileBatchWithOutcome( + context.Background(), database, database, "mysql", "INSERT INTO demo(id) VALUES (1)", false, nil, + ) + if err == nil || canFallback || !outcomeUnknown { + t.Fatalf("batch unknown result = canFallback=%t outcomeUnknown=%t err=%v; want no fallback and unknown", canFallback, outcomeUnknown, err) + } +} + func (f *fakeSQLFileBatchDB) GetDatabases() ([]string, error) { return nil, nil } diff --git a/internal/app/methods_sql_audit.go b/internal/app/methods_sql_audit.go index b01345d2..eb0007b8 100644 --- a/internal/app/methods_sql_audit.go +++ b/internal/app/methods_sql_audit.go @@ -1140,6 +1140,14 @@ func (a *App) buildSQLAuditExport(filter sqlaudit.Filter, format string) ([]byte } func writeSQLAuditExportAtomically(fileName string, content []byte) error { + return writeSQLAuditExportAtomicallyWithReplace(fileName, content, true) +} + +func writeSQLAuditExportAtomicallyNoReplace(fileName string, content []byte) error { + return writeSQLAuditExportAtomicallyWithReplace(fileName, content, false) +} + +func writeSQLAuditExportAtomicallyWithReplace(fileName string, content []byte, replace bool) error { directory := filepath.Dir(filepath.Clean(fileName)) temporary, err := os.CreateTemp(directory, ".gonavi-sql-audit-*.tmp") if err != nil { @@ -1162,8 +1170,14 @@ func writeSQLAuditExportAtomically(fileName string, content []byte) error { if err := temporary.Close(); err != nil { return err } - if err := replaceSQLAuditFile(temporaryName, fileName); err != nil { - return err + var publishErr error + if replace { + publishErr = replaceSQLAuditFile(temporaryName, fileName) + } else { + publishErr = atomicCreateSQLAuditFile(temporaryName, fileName) + } + if publishErr != nil { + return publishErr } return os.Chmod(fileName, 0o600) } diff --git a/internal/app/methods_sql_audit_test.go b/internal/app/methods_sql_audit_test.go index 870b67c7..d3ae576e 100644 --- a/internal/app/methods_sql_audit_test.go +++ b/internal/app/methods_sql_audit_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "testing" "time" @@ -547,6 +548,68 @@ func TestWriteSQLAuditExportPreservesExistingFileWhenAtomicReplacementFails(t *t } } +func TestWriteSQLAuditExportNoReplacePreservesExistingFile(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "audit.json") + if err := os.WriteFile(target, []byte("original"), 0o600); err != nil { + t.Fatalf("write original export: %v", err) + } + + if err := writeSQLAuditExportAtomicallyNoReplace(target, []byte("replacement")); err == nil { + t.Fatal("expected no-replace export to fail when target already exists") + } + content, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read preserved export: %v", err) + } + if string(content) != "original" { + t.Fatalf("existing export was overwritten: %q", content) + } +} + +func TestWriteSQLAuditExportNoReplacePublishesNewFile(t *testing.T) { + target := filepath.Join(t.TempDir(), "audit.json") + if err := writeSQLAuditExportAtomicallyNoReplace(target, []byte("new content")); err != nil { + t.Fatalf("no-replace export failed for a new target: %v", err) + } + content, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read new export: %v", err) + } + if string(content) != "new content" { + t.Fatalf("new export content = %q", content) + } +} + +func TestWriteSQLAuditExportNoReplaceAllowsOnlyOneConcurrentPublisher(t *testing.T) { + target := filepath.Join(t.TempDir(), "audit.json") + const writers = 8 + results := make(chan error, writers) + var group sync.WaitGroup + group.Add(writers) + for i := 0; i < writers; i++ { + go func(index int) { + defer group.Done() + results <- writeSQLAuditExportAtomicallyNoReplace(target, []byte("publisher")) + }(i) + } + group.Wait() + close(results) + + successes := 0 + for err := range results { + if err == nil { + successes++ + } + } + if successes != 1 { + t.Fatalf("concurrent no-replace publishers succeeded %d times, want exactly one", successes) + } + if content, err := os.ReadFile(target); err != nil || string(content) != "publisher" { + t.Fatalf("published export = %q, err=%v", content, err) + } +} + func TestExportSQLAuditFileRejectsWebRuntimeBeforeOpeningDesktopDialog(t *testing.T) { app := NewWebApp() app.configDir = t.TempDir() diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index c3d5a657..34752b22 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -2218,11 +2218,9 @@ on run argv "rm -rf " & quoted form of tmpPath & " " & quoted form of bakPath & "; " & ¬ "/usr/bin/ditto " & quoted form of srcPath & " " & quoted form of tmpPath & "; " & ¬ "if [ ! -x " & quoted form of (tmpPath & "/" & binRel) & " ]; then echo 'tmp app binary missing' >> " & quoted form of logPath & "; exit 1; fi; " & ¬ - "xattr -rd com.apple.quarantine " & quoted form of tmpPath & " >> " & quoted form of logPath & " 2>&1 || true; " & ¬ "if [ -d " & quoted form of dstPath & " ]; then mv " & quoted form of dstPath & " " & quoted form of bakPath & "; fi; " & ¬ "mv " & quoted form of tmpPath & " " & quoted form of dstPath & "; " & ¬ - "rm -rf " & quoted form of bakPath & "; " & ¬ - "xattr -rd com.apple.quarantine " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1 || true" + "rm -rf " & quoted form of bakPath do shell script cmd with administrator privileges end run APPLESCRIPT @@ -2235,7 +2233,6 @@ replace_app_direct() { log "tmp app binary missing: $TMP_APP/$APP_BIN_REL" return 1 fi - /usr/bin/xattr -rd com.apple.quarantine "$TMP_APP" >>"$LOG_FILE" 2>&1 || true if [ -d "$TARGET_APP" ]; then /bin/mv "$TARGET_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 fi @@ -2248,7 +2245,6 @@ replace_app_direct() { return 1 fi /bin/rm -rf "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true - /usr/bin/xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true return 0 } diff --git a/internal/app/methods_update_mac_script_test.go b/internal/app/methods_update_mac_script_test.go index a2649b83..41fd26fc 100644 --- a/internal/app/methods_update_mac_script_test.go +++ b/internal/app/methods_update_mac_script_test.go @@ -41,6 +41,9 @@ func TestBuildMacScriptContainsHardeningGuards(t *testing.T) { if strings.Contains(script, `rm -rf "$MOUNT_DIR" "$DMG" "$STAGED"`) { t.Fatal("mac update script must not delete STAGED while the script may still be running from it") } + if strings.Contains(script, "com.apple.quarantine") || strings.Contains(script, "xattr -rd") { + t.Fatal("mac updater must preserve Gatekeeper quarantine metadata; notarized releases must not depend on clearing it") + } // 确保不会在 relaunch 之前删除 updates 目录。 rmIdx := strings.Index(script, `exec /bin/rm -rf "$UPDATES_DIR"`) relaunchIdx := strings.Index(script, "if ! relaunch_app; then") diff --git a/internal/app/saved_connections.go b/internal/app/saved_connections.go index 886d8a07..e83a17c9 100644 --- a/internal/app/saved_connections.go +++ b/internal/app/saved_connections.go @@ -2,6 +2,7 @@ package app import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -15,7 +16,7 @@ import ( "github.com/google/uuid" ) -// savedConnectionsMu 串行化 connections.json 的「读取→修改→整体重写」序列。 +// savedConnectionsMu 串行化单进程内 connections.json 的「读取→修改→整体重写」序列。 // // 必须是包级锁:savedConnectionRepository() 每次调用都返回一个新实例 // (methods_saved_connections.go:9-11),实例级锁起不到任何作用。 @@ -23,7 +24,8 @@ import ( // web-server 多请求都会真并发进入这些写路径;无锁时后写者会用自己那份旧列表整体覆盖前写者, // 导致已保存的连接静默丢失,或产生「有密码标记但密文已被删除」的僵尸连接。 // -// 注意不要把锁下沉进 load()/saveAll():Save/Delete/Duplicate 内部都会调用它们,会造成重入死锁。 +// 跨进程写路径还必须持有 connections.json.lock。注意不要把锁下沉进 +// load()/saveAll():Save/Delete/Duplicate 内部都会调用它们,会造成重入死锁。 var savedConnectionsMu sync.Mutex const ( @@ -389,6 +391,93 @@ func (r *savedConnectionRepository) dailySecrets() *dailysecret.Store { return dailysecret.NewStore(r.configDir) } +func (r *savedConnectionRepository) withWriteLock(operation func() error) (resultErr error) { + savedConnectionsMu.Lock() + defer savedConnectionsMu.Unlock() + if err := os.MkdirAll(r.configDir, 0o755); err != nil { + return err + } + sharedLock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(r.configDir)) + if err != nil { + return err + } + defer func() { + resultErr = errors.Join(resultErr, sharedLock.Close()) + }() + fileLock, err := appdata.AcquireFileLock(r.connectionsPath() + ".lock") + if err != nil { + return err + } + defer func() { + resultErr = errors.Join(resultErr, fileLock.Close()) + }() + if operation == nil { + return nil + } + return operation() +} + +type savedConnectionFilesSnapshot struct { + connectionsExists bool + connectionsData []byte + secretsExists bool + secretsData []byte +} + +func (r *savedConnectionRepository) captureFilesSnapshotUnlocked() (savedConnectionFilesSnapshot, error) { + var snapshot savedConnectionFilesSnapshot + connectionsData, connectionsExists, err := readOptionalFile(r.connectionsPath()) + if err != nil { + return snapshot, err + } + secretsData, secretsExists, err := readOptionalFile(r.dailySecrets().Path()) + if err != nil { + return snapshot, err + } + snapshot.connectionsExists = connectionsExists + snapshot.connectionsData = connectionsData + snapshot.secretsExists = secretsExists + snapshot.secretsData = secretsData + return snapshot, nil +} + +func (snapshot savedConnectionFilesSnapshot) restoreUnlocked(r *savedConnectionRepository) error { + var restoreErr error + if err := r.dailySecrets().RestoreUnlocked(snapshot.secretsExists, snapshot.secretsData); err != nil { + restoreErr = errors.Join(restoreErr, err) + } + if snapshot.connectionsExists { + if err := writeSavedConnectionsFileAtomic(r.connectionsPath(), snapshot.connectionsData); err != nil { + restoreErr = errors.Join(restoreErr, err) + } + } else if err := os.Remove(r.connectionsPath()); err != nil && !os.IsNotExist(err) { + restoreErr = errors.Join(restoreErr, err) + } + return restoreErr +} + +// withWriteTransaction keeps the metadata and daily-secret files coherent +// when a multi-file mutation reports an error. The shared cross-process lock +// remains held while both the mutation and any rollback are performed. +func (r *savedConnectionRepository) withWriteTransaction(operation func() error) error { + return r.withWriteLock(func() error { + snapshot, err := r.captureFilesSnapshotUnlocked() + if err != nil { + return err + } + if operation == nil { + return nil + } + if err := operation(); err != nil { + if restoreErr := snapshot.restoreUnlocked(r); restoreErr != nil { + return errors.Join(err, fmt.Errorf("restore saved connection files: %w", restoreErr)) + } + return err + } + return nil + }) +} + func (r *savedConnectionRepository) load() ([]connection.SavedConnectionView, error) { data, err := os.ReadFile(r.connectionsPath()) if err != nil { @@ -437,9 +526,11 @@ func (r *savedConnectionRepository) saveAll(connections []connection.SavedConnec // (或并发读者恰好进入)会得到一个空的/半截的 connections.json,全部已保存连接一次性丢失。 // 改成临时文件 + Sync + rename 后,读者要么看到旧文件、要么看到完整新文件, // 因此 List/Find 这类只读路径无需加锁。 - return writeSavedConnectionsFileAtomic(r.connectionsPath(), payload) + return writeSavedConnectionsFileAtomicFunc(r.connectionsPath(), payload) } +var writeSavedConnectionsFileAtomicFunc = writeSavedConnectionsFileAtomic + // writeSavedConnectionsFileAtomic 以「临时文件 + Sync + 原子替换」写入 connections.json。 // 复用 replaceSavedQueryTempFile 的替换逻辑(其中包含 Windows 上 rename 失败的回退处理)。 func writeSavedConnectionsFileAtomic(targetPath string, payload []byte) error { @@ -478,14 +569,12 @@ func writeSavedConnectionsFileAtomic(targetPath string, payload []byte) error { return nil } -func (r *savedConnectionRepository) Save(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) { - savedConnectionsMu.Lock() - defer savedConnectionsMu.Unlock() +func prepareSavedConnectionInput(input connection.SavedConnectionInput) (connection.SavedConnectionInput, error) { if err := validateDatabasePatterns("include", input.IncludeDatabasePatterns); err != nil { - return connection.SavedConnectionView{}, err + return connection.SavedConnectionInput{}, err } if err := validateDatabasePatterns("exclude", input.ExcludeDatabasePatterns); err != nil { - return connection.SavedConnectionView{}, err + return connection.SavedConnectionInput{}, err } if strings.TrimSpace(input.ID) == "" && strings.TrimSpace(input.Config.ID) == "" { @@ -495,7 +584,13 @@ func (r *savedConnectionRepository) Save(input connection.SavedConnectionInput) input.ID = strings.TrimSpace(input.Config.ID) } input.Config.ID = input.ID + return input, nil +} +// saveUnlocked persists one already-normalized connection while the caller +// holds withWriteLock. Keeping this operation separate lets a multi-item import +// retain the same cross-process lock across snapshot, every item, and rollback. +func (r *savedConnectionRepository) saveUnlocked(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) { connections, err := r.load() if err != nil { return connection.SavedConnectionView{}, err @@ -545,6 +640,24 @@ func (r *savedConnectionRepository) Save(input connection.SavedConnectionInput) return view, nil } +func (r *savedConnectionRepository) Save(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) { + prepared, err := prepareSavedConnectionInput(input) + if err != nil { + return connection.SavedConnectionView{}, err + } + + var saved connection.SavedConnectionView + err = r.withWriteTransaction(func() error { + var saveErr error + saved, saveErr = r.saveUnlocked(prepared) + return saveErr + }) + if err != nil { + return connection.SavedConnectionView{}, err + } + return saved, nil +} + func (r *savedConnectionRepository) Find(id string) (connection.SavedConnectionView, error) { connections, err := r.load() if err != nil { @@ -558,12 +671,41 @@ func (r *savedConnectionRepository) Find(id string) (connection.SavedConnectionV return connection.SavedConnectionView{}, fmt.Errorf("saved connection not found: %s", id) } +// loadConnectionSnapshot reads one saved connection and its daily-secret +// bundle while holding the same cross-process lock used by writers. This is +// the only read path that may return both files' contents as one execution +// snapshot. +func (r *savedConnectionRepository) loadConnectionSnapshot(id string) (connection.SavedConnectionView, connectionSecretBundle, error) { + var view connection.SavedConnectionView + var bundle connectionSecretBundle + err := r.withWriteLock(func() error { + connections, err := r.load() + if err != nil { + return err + } + connectionID := strings.TrimSpace(id) + for _, item := range connections { + if item.ID != connectionID { + continue + } + view = item + bundle, err = r.loadSecretBundle(item) + return err + } + return fmt.Errorf("saved connection not found: %s", id) + }) + if err != nil { + return view, bundle, err + } + return view, bundle, nil +} + func (r *savedConnectionRepository) saveSecretBundle(id string, bundle connectionSecretBundle) error { - return r.dailySecrets().PutConnection(id, toDailyConnectionBundle(bundle)) + return r.dailySecrets().PutConnectionUnlocked(id, toDailyConnectionBundle(bundle)) } func (r *savedConnectionRepository) deleteSecretBundle(id string) error { - return r.dailySecrets().DeleteConnection(id) + return r.dailySecrets().DeleteConnectionUnlocked(id) } func (r *savedConnectionRepository) storeSecretBundle(id string, existingRef string, bundle connectionSecretBundle) (string, error) { @@ -687,70 +829,74 @@ func (r *savedConnectionRepository) List() ([]connection.SavedConnectionView, er } func (r *savedConnectionRepository) Delete(id string) error { - savedConnectionsMu.Lock() - defer savedConnectionsMu.Unlock() - - connections, err := r.load() - if err != nil { - return err - } - filtered := make([]connection.SavedConnectionView, 0, len(connections)) - for _, item := range connections { - if item.ID == strings.TrimSpace(id) { - if deleteErr := r.deleteSecretBundle(item.ID); deleteErr != nil { - return deleteErr - } - continue + return r.withWriteTransaction(func() error { + connections, err := r.load() + if err != nil { + return err } - filtered = append(filtered, item) - } - return r.saveAll(filtered) + filtered := make([]connection.SavedConnectionView, 0, len(connections)) + for _, item := range connections { + if item.ID == strings.TrimSpace(id) { + if deleteErr := r.deleteSecretBundle(item.ID); deleteErr != nil { + return deleteErr + } + continue + } + filtered = append(filtered, item) + } + return r.saveAll(filtered) + }) } func (r *savedConnectionRepository) Duplicate(id string, unnamedName string, copySuffix string) (connection.SavedConnectionView, error) { - savedConnectionsMu.Lock() - defer savedConnectionsMu.Unlock() + var saved connection.SavedConnectionView + err := r.withWriteTransaction(func() error { + connections, err := r.load() + if err != nil { + return err + } - connections, err := r.load() + index := -1 + for i, item := range connections { + if item.ID == strings.TrimSpace(id) { + index = i + break + } + } + if index < 0 { + return fmt.Errorf("saved connection not found: %s", id) + } + + original := connections[index] + duplicate := original + duplicate.ID = "conn-" + uuid.New().String()[:8] + duplicate.Config.ID = duplicate.ID + duplicate.Name = buildDuplicateConnectionName(original.Name, connections, unnamedName, copySuffix) + duplicate.IncludeDatabasePatterns = cloneStringSlice(original.IncludeDatabasePatterns) + duplicate.ExcludeDatabasePatterns = cloneStringSlice(original.ExcludeDatabasePatterns) + duplicate.SchemaVisibilityByDatabase = cloneSchemaVisibilityByDatabase(original.SchemaVisibilityByDatabase) + + bundle, err := r.loadSecretBundle(original) + if err != nil { + return err + } + if bundle.hasAny() { + if storeErr := r.saveSecretBundle(duplicate.ID, bundle); storeErr != nil { + return storeErr + } + } + duplicate.SecretRef = "" + applyConnectionBundleFlags(&duplicate, bundle) + + connections = append(connections, duplicate) + if err := r.saveAll(connections); err != nil { + return err + } + saved = duplicate + return nil + }) if err != nil { return connection.SavedConnectionView{}, err } - - index := -1 - for i, item := range connections { - if item.ID == strings.TrimSpace(id) { - index = i - break - } - } - if index < 0 { - return connection.SavedConnectionView{}, fmt.Errorf("saved connection not found: %s", id) - } - - original := connections[index] - duplicate := original - duplicate.ID = "conn-" + uuid.New().String()[:8] - duplicate.Config.ID = duplicate.ID - duplicate.Name = buildDuplicateConnectionName(original.Name, connections, unnamedName, copySuffix) - duplicate.IncludeDatabasePatterns = cloneStringSlice(original.IncludeDatabasePatterns) - duplicate.ExcludeDatabasePatterns = cloneStringSlice(original.ExcludeDatabasePatterns) - duplicate.SchemaVisibilityByDatabase = cloneSchemaVisibilityByDatabase(original.SchemaVisibilityByDatabase) - - bundle, err := r.loadSecretBundle(original) - if err != nil { - return connection.SavedConnectionView{}, err - } - if bundle.hasAny() { - if storeErr := r.saveSecretBundle(duplicate.ID, bundle); storeErr != nil { - return connection.SavedConnectionView{}, storeErr - } - } - duplicate.SecretRef = "" - applyConnectionBundleFlags(&duplicate, bundle) - - connections = append(connections, duplicate) - if err := r.saveAll(connections); err != nil { - return connection.SavedConnectionView{}, err - } - return duplicate, nil + return saved, nil } diff --git a/internal/app/saved_connections_concurrency_test.go b/internal/app/saved_connections_concurrency_test.go index 9a8feb46..59653598 100644 --- a/internal/app/saved_connections_concurrency_test.go +++ b/internal/app/saved_connections_concurrency_test.go @@ -2,13 +2,19 @@ package app import ( "encoding/json" + "errors" "fmt" "os" + "os/exec" "path/filepath" + "strings" "sync" "testing" + "time" + "GoNavi-Wails/internal/appdata" "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/dailysecret" "GoNavi-Wails/internal/secretstore" ) @@ -76,6 +82,281 @@ func TestSaveConnectionConcurrentWritesDoNotLoseEntries(t *testing.T) { } } +func TestSavedConnectionRepositoryWaitsForExternalFileLock(t *testing.T) { + app := newSavedConnectionTestApp(t) + repository := app.savedConnectionRepository() + externalLock, err := appdata.AcquireFileLock(repository.connectionsPath() + ".lock") + if err != nil { + t.Fatalf("acquire external connections lock: %v", err) + } + defer externalLock.Close() + + finished := make(chan error, 1) + go func() { + _, err := repository.Save(connection.SavedConnectionInput{ + ID: "locked-connection", + Name: "Locked connection", + Config: connection.ConnectionConfig{ID: "locked-connection", Type: "mysql"}, + }) + finished <- err + }() + select { + case err := <-finished: + t.Fatalf("Save acquired connections lock before external release: %v", err) + case <-time.After(50 * time.Millisecond): + } + if err := externalLock.Close(); err != nil { + t.Fatalf("release external connections lock: %v", err) + } + select { + case err := <-finished: + if err != nil { + t.Fatalf("Save after external lock release: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Save did not acquire connections lock after external release") + } +} + +func TestSavedConnectionRepositoryWaitsForSharedStorageLock(t *testing.T) { + app := newSavedConnectionTestApp(t) + repository := app.savedConnectionRepository() + sharedLock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(app.configDir)) + if err != nil { + t.Fatalf("acquire shared storage lock: %v", err) + } + + finished := make(chan error, 1) + go func() { + _, saveErr := repository.Save(connection.SavedConnectionInput{ + ID: "shared-locked-connection", + Name: "Shared locked connection", + Config: connection.ConnectionConfig{ID: "shared-locked-connection", Type: "mysql"}, + }) + finished <- saveErr + }() + select { + case err := <-finished: + t.Fatalf("Save acquired shared lock before external release: %v", err) + case <-time.After(50 * time.Millisecond): + } + if err := sharedLock.Close(); err != nil { + t.Fatalf("release shared storage lock: %v", err) + } + select { + case err := <-finished: + if err != nil { + t.Fatalf("Save after shared lock release: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Save did not acquire shared lock after external release") + } +} + +const ( + crossProcessConnectionWriterRootEnv = "GONAVI_TEST_CONNECTION_WRITER_ROOT" + crossProcessConnectionWriterPrefixEnv = "GONAVI_TEST_CONNECTION_WRITER_PREFIX" + crossProcessConnectionWriterCount = 12 +) + +// TestSavedConnectionCrossProcessWriterHelper is executed in child test +// processes by TestSavedConnectionRepositoryCrossProcessWritesKeepConnectionsAndSecrets. +func TestSavedConnectionCrossProcessWriterHelper(t *testing.T) { + root := os.Getenv(crossProcessConnectionWriterRootEnv) + prefix := os.Getenv(crossProcessConnectionWriterPrefixEnv) + if root == "" || prefix == "" { + return + } + + application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + application.configDir = root + repository := application.savedConnectionRepository() + for index := 0; index < crossProcessConnectionWriterCount; index++ { + id := fmt.Sprintf("%s-%02d", prefix, index) + _, err := repository.Save(connection.SavedConnectionInput{ + ID: id, + Name: id, + Config: connection.ConnectionConfig{ + ID: id, + Type: "mysql", + Host: "127.0.0.1", + Port: 3306, + Password: id + "-secret", + }, + }) + if err != nil { + t.Fatalf("Save(%s): %v", id, err) + } + } +} + +func TestSavedConnectionRepositoryCrossProcessWritesKeepConnectionsAndSecrets(t *testing.T) { + root := t.TempDir() + commands := make([]*exec.Cmd, 0, 2) + for _, prefix := range []string{"desktop", "cli"} { + command := exec.Command(os.Args[0], "-test.run=^TestSavedConnectionCrossProcessWriterHelper$") + command.Env = append( + os.Environ(), + crossProcessConnectionWriterRootEnv+"="+root, + crossProcessConnectionWriterPrefixEnv+"="+prefix, + ) + command.Stdout = os.Stderr + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + t.Fatalf("start %s writer: %v", prefix, err) + } + commands = append(commands, command) + } + for _, command := range commands { + if err := command.Wait(); err != nil { + t.Fatalf("cross-process writer failed: %v", err) + } + } + + application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + application.configDir = root + items, err := application.savedConnectionRepository().List() + if err != nil { + t.Fatalf("List after cross-process writes: %v", err) + } + want := 2 * crossProcessConnectionWriterCount + if len(items) != want { + t.Fatalf("cross-process connection count = %d, want %d", len(items), want) + } + + secrets := dailysecret.NewStore(root) + for _, prefix := range []string{"desktop", "cli"} { + for index := 0; index < crossProcessConnectionWriterCount; index++ { + id := fmt.Sprintf("%s-%02d", prefix, index) + bundle, found, err := secrets.GetConnection(id) + if err != nil { + t.Fatalf("GetConnection(%s): %v", id, err) + } + if !found || bundle.Password != id+"-secret" { + t.Fatalf("connection secret %s was lost or changed: %#v found=%t", id, bundle, found) + } + } + } +} + +func TestSavedConnectionMutationsRollBackSecretsWhenMetadataWriteFails(t *testing.T) { + app := newSavedConnectionTestApp(t) + repository := app.savedConnectionRepository() + _, err := repository.Save(connection.SavedConnectionInput{ + ID: "atomic-connection", + Name: "Before", + Config: connection.ConnectionConfig{ + ID: "atomic-connection", + Type: "postgres", + Host: "before.local", + Password: "before-secret", + }, + }) + if err != nil { + t.Fatalf("seed connection: %v", err) + } + + originalWriter := writeSavedConnectionsFileAtomicFunc + t.Cleanup(func() { writeSavedConnectionsFileAtomicFunc = originalWriter }) + writeSavedConnectionsFileAtomicFunc = func(string, []byte) error { + return errors.New("injected metadata write failure") + } + + _, err = repository.Save(connection.SavedConnectionInput{ + ID: "atomic-connection", + Name: "After", + Config: connection.ConnectionConfig{ + ID: "atomic-connection", + Type: "postgres", + Host: "after.local", + Password: "after-secret", + }, + }) + if err == nil || !strings.Contains(err.Error(), "injected metadata write failure") { + t.Fatalf("Save error = %v, want injected metadata failure", err) + } + + resolved, err := app.resolveConnectionSecrets(connection.ConnectionConfig{ID: "atomic-connection"}) + if err != nil { + t.Fatalf("resolve rolled-back connection: %v", err) + } + if resolved.Host != "before.local" || resolved.Password != "before-secret" { + t.Fatalf("failed Save left mixed state: host=%q password=%q", resolved.Host, resolved.Password) + } +} + +func TestDeleteConnectionRollsBackSecretWhenMetadataWriteFails(t *testing.T) { + app := newSavedConnectionTestApp(t) + repository := app.savedConnectionRepository() + _, err := repository.Save(connection.SavedConnectionInput{ + ID: "delete-atomic", + Name: "Delete atomic", + Config: connection.ConnectionConfig{ + ID: "delete-atomic", + Type: "mysql", + Host: "db.local", + Password: "keep-secret", + }, + }) + if err != nil { + t.Fatalf("seed connection: %v", err) + } + + originalWriter := writeSavedConnectionsFileAtomicFunc + t.Cleanup(func() { writeSavedConnectionsFileAtomicFunc = originalWriter }) + writeSavedConnectionsFileAtomicFunc = func(string, []byte) error { + return errors.New("injected delete metadata failure") + } + if err := repository.Delete("delete-atomic"); err == nil || !strings.Contains(err.Error(), "injected delete metadata failure") { + t.Fatalf("Delete error = %v, want injected metadata failure", err) + } + + resolved, err := app.resolveConnectionSecrets(connection.ConnectionConfig{ID: "delete-atomic"}) + if err != nil { + t.Fatalf("resolve rolled-back deleted connection: %v", err) + } + if resolved.Password != "keep-secret" { + t.Fatalf("failed Delete removed stored password: %q", resolved.Password) + } +} + +func TestDuplicateConnectionRollsBackNewSecretWhenMetadataWriteFails(t *testing.T) { + app := newSavedConnectionTestApp(t) + repository := app.savedConnectionRepository() + _, err := repository.Save(connection.SavedConnectionInput{ + ID: "duplicate-source", + Name: "Duplicate source", + Config: connection.ConnectionConfig{ + ID: "duplicate-source", + Type: "mysql", + Password: "source-secret", + }, + }) + if err != nil { + t.Fatalf("seed connection: %v", err) + } + + originalWriter := writeSavedConnectionsFileAtomicFunc + t.Cleanup(func() { writeSavedConnectionsFileAtomicFunc = originalWriter }) + writeSavedConnectionsFileAtomicFunc = func(string, []byte) error { + return errors.New("injected duplicate metadata failure") + } + if _, err := repository.Duplicate("duplicate-source", "Unnamed", " Copy"); err == nil || !strings.Contains(err.Error(), "injected duplicate metadata failure") { + t.Fatalf("Duplicate error = %v, want injected metadata failure", err) + } + + secrets, err := repository.dailySecrets().Load() + if err != nil { + t.Fatalf("load daily secrets after failed Duplicate: %v", err) + } + if len(secrets.Connections) != 1 { + t.Fatalf("failed Duplicate left %d secret bundles, want only the source", len(secrets.Connections)) + } + if _, ok := secrets.Connections["duplicate-source"]; !ok { + t.Fatal("failed Duplicate removed source secret") + } +} + // TestSaveAndDeleteConnectionsConcurrentlyKeepFileValid 并发混合 Save/Delete, // 断言 connections.json 始终是完整可解析的 JSON(非原子截断写会留下空/半截文件)。 func TestSaveAndDeleteConnectionsConcurrentlyKeepFileValid(t *testing.T) { diff --git a/internal/app/sql_audit_atomic_replace_other.go b/internal/app/sql_audit_atomic_replace_other.go index 5f3a6715..48d61fd8 100644 --- a/internal/app/sql_audit_atomic_replace_other.go +++ b/internal/app/sql_audit_atomic_replace_other.go @@ -18,3 +18,21 @@ func atomicReplaceSQLAuditFile(source, target string) error { } return errors.Join(directory.Sync(), directory.Close()) } + +// atomicCreateSQLAuditFile publishes source at target without replacing an +// existing target. Both paths are created in the same directory, so a hard +// link gives us an atomic CREATE_NEW-style destination while the temporary +// source remains private until it is removed. +func atomicCreateSQLAuditFile(source, target string) error { + if err := os.Link(source, target); err != nil { + return err + } + if err := os.Remove(source); err != nil { + return err + } + directory, err := os.Open(filepath.Dir(target)) + if err != nil { + return err + } + return errors.Join(directory.Sync(), directory.Close()) +} diff --git a/internal/app/sql_audit_atomic_replace_windows.go b/internal/app/sql_audit_atomic_replace_windows.go index 7d005a48..c4b34098 100644 --- a/internal/app/sql_audit_atomic_replace_windows.go +++ b/internal/app/sql_audit_atomic_replace_windows.go @@ -19,3 +19,18 @@ func atomicReplaceSQLAuditFile(source, target string) error { windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH, ) } + +// atomicCreateSQLAuditFile publishes source only when target does not exist. +// MoveFileEx without MOVEFILE_REPLACE_EXISTING maps to a CREATE_NEW-style +// operation and keeps the destination hidden until the move completes. +func atomicCreateSQLAuditFile(source, target string) error { + sourcePath, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + targetPath, err := windows.UTF16PtrFromString(target) + if err != nil { + return err + } + return windows.MoveFileEx(sourcePath, targetPath, windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/app/sql_sanitize.go b/internal/app/sql_sanitize.go index 3906cdac..634aa050 100644 --- a/internal/app/sql_sanitize.go +++ b/internal/app/sql_sanitize.go @@ -448,6 +448,9 @@ func isReadOnlySQLQuery(dbType string, query string) bool { batch, err := esconsole.ParseSource(query, "gonavi-default-index") return err == nil && !batch.Blocked && !batch.ContainsWrite && !batch.ContainsScript } + if hasExecutableSQLComment(dbType, query) { + return false + } keyword, withHasWrite := sqlDataOperationInfo(query) if withHasWrite { diff --git a/internal/app/sql_sanitize_test.go b/internal/app/sql_sanitize_test.go index c489a62d..4daac763 100644 --- a/internal/app/sql_sanitize_test.go +++ b/internal/app/sql_sanitize_test.go @@ -93,6 +93,36 @@ func TestIsReadOnlySQLQuery_TreatsSelectIntoAsWrite(t *testing.T) { } } +func TestIsReadOnlySQLQueryRejectsExecutableMySQLComments(t *testing.T) { + unsafeQueries := []struct { + dbType string + query string + }{ + {dbType: "mysql", query: "SELECT 1 /*!50000 INTO OUTFILE '/tmp/gonavi' */"}, + {dbType: "mysql", query: "/*!50000 DELETE FROM accounts */ SELECT 1"}, + {dbType: "mariadb", query: "SELECT 1 /*M!100100 INTO OUTFILE '/tmp/gonavi' */"}, + {dbType: "oceanbase", query: "SELECT /*!50700 SQL_NO_CACHE */ 1"}, + } + for _, test := range unsafeQueries { + if isReadOnlySQLQuery(test.dbType, test.query) { + t.Fatalf("%s executable comment was classified read-only: %q", test.dbType, test.query) + } + } + + for _, query := range []string{ + "SELECT /* ordinary comment */ 1", + "SELECT /*+ MAX_EXECUTION_TIME(1000) */ 1", + "SELECT '/*!50000 DELETE FROM accounts */'", + } { + if !isReadOnlySQLQuery("mysql", query) { + t.Fatalf("ordinary MySQL SELECT was classified as write: %q", query) + } + } + if !isReadOnlySQLQuery("postgres", "SELECT 1 /*!50000 ignored by PostgreSQL */") { + t.Fatal("non-MySQL dialect treated an ordinary block comment as executable") + } +} + func TestIsReadOnlySQLQuery_TreatsKafkaConsumeAsReadOnly(t *testing.T) { if !isReadOnlySQLQuery("kafka", `CONSUME GROUP "analytics" FROM "orders.events" LIMIT 20`) { t.Fatal("Kafka CONSUME should be treated as read-only") diff --git a/internal/app/window_style_darwin.go b/internal/app/window_style_darwin.go index 78f95744..a2b94f32 100644 --- a/internal/app/window_style_darwin.go +++ b/internal/app/window_style_darwin.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && cgo package app diff --git a/internal/app/window_style_stub.go b/internal/app/window_style_stub.go index d22e7dbe..9d102d34 100644 --- a/internal/app/window_style_stub.go +++ b/internal/app/window_style_stub.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin || !cgo package app diff --git a/internal/app/window_style_stub_test.go b/internal/app/window_style_stub_test.go index d133f613..babe3cba 100644 --- a/internal/app/window_style_stub_test.go +++ b/internal/app/window_style_stub_test.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin || !cgo package app diff --git a/internal/app/window_translucency_darwin.go b/internal/app/window_translucency_darwin.go index 5b2f3f2b..9d2b4ca5 100644 --- a/internal/app/window_translucency_darwin.go +++ b/internal/app/window_translucency_darwin.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && cgo package app diff --git a/internal/app/window_translucency_stub.go b/internal/app/window_translucency_stub.go index 0f7f7afe..a64b679a 100644 --- a/internal/app/window_translucency_stub.go +++ b/internal/app/window_translucency_stub.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin || !cgo package app diff --git a/internal/appdata/root.go b/internal/appdata/root.go index b175ee81..ac0fb3c9 100644 --- a/internal/appdata/root.go +++ b/internal/appdata/root.go @@ -12,6 +12,7 @@ import ( const ( bootstrapFileName = "storage_root.json" bootstrapLockFileName = bootstrapFileName + ".lock" + sharedStorageLockFileName = ".gonavi-storage-write.lock" configuredLogFileName = "gonavi.log" savedQueryDirectoryName = "saved_queries" savedQueryDirectoryProbePrefix = ".gonavi-saved-query-" @@ -24,6 +25,40 @@ var ( bootstrapConfigMu sync.Mutex ) +// AcquireFileLock obtains an exclusive, cross-process lock for a caller-owned +// file path. It is shared by the data-root, connection, and daily-secret +// stores so their read-modify-write operations cannot overwrite each other. +func AcquireFileLock(path string) (*bootstrapFileLock, error) { + return acquireBootstrapFileLock(path) +} + +// AtomicReplaceFile replaces target with source using the platform-specific +// durable rename implementation used by the bootstrap configuration. +func AtomicReplaceFile(source string, target string) error { + return atomicReplaceBootstrapFile(source, target) +} + +// SharedStorageLockPath returns the lock shared by the saved connection and +// daily-secret stores. Those files are updated as one logical operation by the +// GUI and CLI, so per-file locks alone cannot prevent a cross-process +// read-modify-write race. +func SharedStorageLockPath(root string) string { + trimmed := strings.TrimSpace(root) + if trimmed == "" { + return filepath.Join(trimmed, sharedStorageLockFileName) + } + if absolute, err := filepath.Abs(trimmed); err == nil { + trimmed = absolute + } + // Resolve aliases when the root already exists so callers using a symlink + // and callers using its real path coordinate on the same lock file. Keep the + // absolute fallback for a not-yet-created root. + if resolved, err := filepath.EvalSymlinks(trimmed); err == nil { + trimmed = resolved + } + return filepath.Join(filepath.Clean(trimmed), sharedStorageLockFileName) +} + type setActiveRootError struct { kind error detail error diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 00000000..8f792858 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,1254 @@ +// Package cli implements the standalone GoNavi command-line interface. +package cli + +import ( + "context" + "encoding/csv" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode" + + appcore "GoNavi-Wails/internal/app" + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/mcpserver" + "GoNavi-Wails/internal/sqlaudit" +) + +const ( + ExitSuccess = 0 + ExitUsage = 2 + ExitConnection = 3 + ExitPolicyDenied = 4 + ExitExecution = 5 + ExitCancelled = 6 + ExitUnknownOutcome = 7 +) + +// Version is set by release builds with -ldflags. +var Version = "dev" + +type globalOptions struct { + dataRoot string +} + +var ( + errConnectionSourceConflict = errors.New("use either --conn or --connection-file") + errConnectionSourceMissing = errors.New("one of --conn or --connection-file is required") + + runMCPStdioServer = mcpserver.RunAppStdioServer + runMCPHTTPServer = mcpserver.RunAppStreamableHTTPServer +) + +// backend is intentionally small: command parsing does not need access to the +// desktop App or to any connection secret material. +type backend interface { + Close() + GetSavedConnections() ([]connection.SavedConnectionView, error) + SaveConnection(connection.SavedConnectionInput) (connection.SavedConnectionView, error) + ImportLegacyConnections([]connection.LegacySavedConnection) ([]connection.SavedConnectionView, error) + ResolveSavedConnection(string) (connection.SavedConnectionView, error) + Query(context.Context, connection.ConnectionConfig, string, string, appcore.HeadlessQueryOptions) connection.QueryResult + ExportQueryToPath(context.Context, connection.ConnectionConfig, string, string, string, appcore.ExportFileOptions, bool) connection.QueryResult + ExecuteSQLFile(context.Context, connection.ConnectionConfig, string, string, appcore.HeadlessSQLFileOptions) connection.QueryResult + ExportSQLAuditToPath(sqlaudit.Filter, string, string, bool) connection.QueryResult +} + +var newBackend = func(ctx context.Context, options appcore.HeadlessRuntimeOptions) (backend, error) { + return appcore.NewHeadlessRuntime(ctx, options) +} + +type errorReport struct { + OK bool `json:"ok"` + Code string `json:"code"` + Message string `json:"message"` +} + +type jsonlResultSetEvent struct { + Type string `json:"type"` + ResultSet int `json:"resultSet"` + Columns []string `json:"columns"` + RowCount int `json:"rowCount"` +} + +type jsonlRowEvent struct { + Type string `json:"type"` + ResultSet int `json:"resultSet"` + Data map[string]any `json:"data"` +} + +type jsonlSummaryEvent struct { + Type string `json:"type"` + Success bool `json:"success"` + QueryID string `json:"queryId,omitempty"` + Message string `json:"message,omitempty"` + Messages []string `json:"messages,omitempty"` + Data any `json:"data,omitempty"` + ResultSets int `json:"resultSets"` + Rows int `json:"rows"` +} + +// Run executes one CLI invocation. Successful command data is written to +// stdout; machine-readable diagnostics are written to stderr. +func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { + if ctx == nil { + ctx = context.Background() + } + if stdout == nil { + stdout = io.Discard + } + if stderr == nil { + stderr = io.Discard + } + if isVersionInvocation(args) { + return emitOutput(stdout, stderr, map[string]string{"version": Version}) + } + + options, remaining, showHelp, err := parseGlobalOptions(args) + if err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + if showHelp { + writeRootUsage(stdout) + return ExitSuccess + } + if len(remaining) == 0 { + writeRootUsage(stderr) + return ExitUsage + } + + restoreDataRoot, err := applyDataRootOverride(options.dataRoot) + if err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + defer restoreDataRoot() + + command := strings.ToLower(strings.TrimSpace(remaining[0])) + commandArgs := remaining[1:] + switch command { + case "help", "--help", "-h": + writeRootUsage(stdout) + return ExitSuccess + case "version", "--version", "-version": + if len(commandArgs) != 0 { + return fail(stderr, ExitUsage, "usage", errors.New("version does not accept arguments")) + } + return emitOutput(stdout, stderr, map[string]string{"version": Version}) + case "mcp": + return runMCP(ctx, commandArgs, stdout, stderr) + case "list-connections", "connections": + if commandHelpRequested(command, commandArgs) { + return runListConnections(commandArgs, nil, stdout, stderr) + } + return withBackend(ctx, options, stderr, func(runtime backend) int { + return runListConnections(commandArgs, runtime, stdout, stderr) + }) + case "connection": + if commandHelpRequested(command, commandArgs) { + return runConnection(commandArgs, nil, stdout, stderr) + } + return withBackend(ctx, options, stderr, func(runtime backend) int { + return runConnection(commandArgs, runtime, stdout, stderr) + }) + case "query": + if commandHelpRequested(command, commandArgs) { + return runQuery(ctx, commandArgs, nil, stdout, stderr) + } + return withBackend(ctx, options, stderr, func(runtime backend) int { + return runQuery(ctx, commandArgs, runtime, stdout, stderr) + }) + case "export": + if commandHelpRequested(command, commandArgs) { + return runExport(ctx, commandArgs, nil, stdout, stderr) + } + return withBackend(ctx, options, stderr, func(runtime backend) int { + return runExport(ctx, commandArgs, runtime, stdout, stderr) + }) + case "batch", "exec-file": + if commandHelpRequested(command, commandArgs) { + return runBatch(ctx, commandArgs, nil, stdout, stderr) + } + return withBackend(ctx, options, stderr, func(runtime backend) int { + return runBatch(ctx, commandArgs, runtime, stdout, stderr) + }) + case "audit": + // Validate the subcommand before starting the headless runtime. A bare + // `audit` and unknown subcommands are usage errors, while explicit help + // remains available without loading configuration or drivers. + if len(commandArgs) == 0 || commandHelpRequested(command, commandArgs) || !strings.EqualFold(strings.TrimSpace(commandArgs[0]), "export") { + return runAudit(commandArgs, nil, stdout, stderr) + } + return withBackend(ctx, options, stderr, func(runtime backend) int { + return runAudit(commandArgs, runtime, stdout, stderr) + }) + default: + return fail(stderr, ExitUsage, "usage", fmt.Errorf("unknown command %q", command)) + } +} + +func commandHelpRequested(command string, args []string) bool { + for _, arg := range args { + if arg == "--help" || arg == "-h" { + return true + } + } + if len(args) == 0 { + return false + } + switch command { + case "connection", "audit": + return strings.EqualFold(strings.TrimSpace(args[0]), "help") + default: + return false + } +} + +func withBackend(ctx context.Context, _ globalOptions, stderr io.Writer, run func(backend) int) int { + // Run has already mapped --data-root to GONAVI_DATA_ROOT for this process. + // Keep every CLI runtime on ResolveActiveRoot rather than creating a second + // root-resolution path here. + runtime, err := newBackend(ctx, appcore.HeadlessRuntimeOptions{}) + if err != nil { + return fail(stderr, ExitConnection, "runtime_unavailable", err) + } + defer runtime.Close() + return run(runtime) +} + +func parseGlobalOptions(args []string) (globalOptions, []string, bool, error) { + var options globalOptions + fs := newFlagSet("gonavi") + fs.StringVar(&options.dataRoot, "data-root", "", "GoNavi data root") + help := fs.Bool("help", false, "show help") + if err := fs.Parse(args); err != nil { + return globalOptions{}, nil, false, err + } + return options, fs.Args(), *help, nil +} + +func isVersionInvocation(args []string) bool { + if len(args) != 1 { + return false + } + switch strings.ToLower(strings.TrimSpace(args[0])) { + case "version", "--version", "-version": + return true + default: + return false + } +} + +func applyDataRootOverride(root string) (func(), error) { + root = strings.TrimSpace(root) + if root == "" { + return func() {}, nil + } + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + previous, existed := os.LookupEnv("GONAVI_DATA_ROOT") + if err := os.Setenv("GONAVI_DATA_ROOT", abs); err != nil { + return nil, err + } + return func() { + if existed { + _ = os.Setenv("GONAVI_DATA_ROOT", previous) + return + } + _ = os.Unsetenv("GONAVI_DATA_ROOT") + }, nil +} + +func runListConnections(args []string, runtime backend, stdout io.Writer, stderr io.Writer) int { + fs := newFlagSet("list-connections") + help := fs.Bool("help", false, "show help") + if err := fs.Parse(args); err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + if *help { + writeListConnectionsUsage(stdout) + return ExitSuccess + } + if fs.NArg() != 0 { + return fail(stderr, ExitUsage, "usage", errors.New("list-connections does not accept positional arguments")) + } + connections, err := runtime.GetSavedConnections() + if err != nil { + return fail(stderr, ExitConnection, "connections_unavailable", err) + } + for _, item := range connections { + if code := emitOutput(stdout, stderr, item); code != ExitSuccess { + return code + } + } + return ExitSuccess +} + +func runConnection(args []string, runtime backend, stdout io.Writer, stderr io.Writer) int { + if len(args) == 0 { + return fail(stderr, ExitUsage, "usage", errors.New("connection requires a subcommand")) + } + switch strings.ToLower(strings.TrimSpace(args[0])) { + case "list": + return runListConnections(args[1:], runtime, stdout, stderr) + case "add": + return runConnectionAdd(args[1:], runtime, stdout, stderr) + case "import": + return runConnectionImport(args[1:], runtime, stdout, stderr) + case "help", "--help", "-h": + writeConnectionUsage(stdout) + return ExitSuccess + default: + return fail(stderr, ExitUsage, "usage", fmt.Errorf("unknown connection command %q", args[0])) + } +} + +func runConnectionAdd(args []string, runtime backend, stdout io.Writer, stderr io.Writer) int { + fs := newFlagSet("connection add") + var ( + inputFile string + name string + id string + environment string + dbType string + host string + port int + user string + database string + params string + paramsEnv string + passwordEnv string + dsnEnv string + uriEnv string + readOnly bool + ) + fs.StringVar(&inputFile, "file", "", "JSON SavedConnectionInput file") + fs.StringVar(&id, "id", "", "connection ID") + fs.StringVar(&name, "name", "", "connection name") + fs.StringVar(&environment, "environment", "", "connection environment") + fs.StringVar(&dbType, "type", "", "database type") + fs.StringVar(&host, "host", "", "database host") + fs.IntVar(&port, "port", 0, "database port") + fs.StringVar(&user, "user", "", "database user") + fs.StringVar(&database, "database", "", "default database") + fs.StringVar(¶ms, "connection-params", "", "connection parameters") + fs.StringVar(¶msEnv, "connection-params-env", "", "environment variable containing complete connection parameters") + fs.StringVar(&passwordEnv, "password-env", "", "environment variable containing the password") + fs.StringVar(&dsnEnv, "dsn-env", "", "environment variable containing the DSN") + fs.StringVar(&uriEnv, "uri-env", "", "environment variable containing the URI") + fs.BoolVar(&readOnly, "read-only", false, "save the connection as read-only") + help := fs.Bool("help", false, "show help") + if err := fs.Parse(args); err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + if *help { + writeConnectionAddUsage(stdout) + return ExitSuccess + } + if fs.NArg() != 0 { + return fail(stderr, ExitUsage, "usage", errors.New("connection add does not accept positional arguments")) + } + + input := connection.SavedConnectionInput{} + if strings.TrimSpace(inputFile) != "" { + loaded, err := loadSingleConnectionInput(inputFile) + if err != nil { + return fail(stderr, ExitUsage, "invalid_connection_input", err) + } + input = loaded + } + visited := visitedFlags(fs) + if visited["id"] { + input.ID = strings.TrimSpace(id) + input.Config.ID = input.ID + } + if visited["name"] { + input.Name = strings.TrimSpace(name) + } + if visited["environment"] { + input.EnvironmentType = strings.TrimSpace(environment) + } + if visited["type"] { + input.Config.Type = strings.TrimSpace(dbType) + } + if visited["host"] { + input.Config.Host = strings.TrimSpace(host) + } + if visited["port"] { + input.Config.Port = port + } + if visited["user"] { + input.Config.User = strings.TrimSpace(user) + } + if visited["database"] { + input.Config.Database = strings.TrimSpace(database) + } + if visited["connection-params"] && visited["connection-params-env"] { + return fail(stderr, ExitUsage, "usage", errors.New("use either --connection-params or --connection-params-env")) + } + if visited["connection-params"] { + if appcore.HasSensitiveConnectionParams(params) { + return fail(stderr, ExitUsage, "usage", errors.New("sensitive connection parameters must be supplied with --connection-params-env")) + } + input.Config.ConnectionParams = strings.TrimSpace(params) + } + if visited["connection-params-env"] { + if err := assignConnectionEnvSecret(&input.Config.ConnectionParams, paramsEnv); err != nil { + return fail(stderr, ExitUsage, "missing_secret_environment", err) + } + } + if visited["read-only"] { + input.Config.ReadOnly = readOnly + } + if err := assignConnectionEnvSecret(&input.Config.Password, passwordEnv); err != nil { + return fail(stderr, ExitUsage, "missing_secret_environment", err) + } + if err := assignConnectionEnvSecret(&input.Config.DSN, dsnEnv); err != nil { + return fail(stderr, ExitUsage, "missing_secret_environment", err) + } + if err := assignConnectionEnvSecret(&input.Config.URI, uriEnv); err != nil { + return fail(stderr, ExitUsage, "missing_secret_environment", err) + } + if strings.TrimSpace(input.Name) == "" { + return fail(stderr, ExitUsage, "usage", errors.New("connection name is required")) + } + if strings.TrimSpace(input.Config.Type) == "" { + return fail(stderr, ExitUsage, "usage", errors.New("connection type is required")) + } + + saved, err := runtime.SaveConnection(input) + if err != nil { + return fail(stderr, ExitConnection, "connection_save_failed", err) + } + return emitOutput(stdout, stderr, saved) +} + +func runConnectionImport(args []string, runtime backend, stdout io.Writer, stderr io.Writer) int { + fs := newFlagSet("connection import") + filePath := fs.String("file", "", "JSON file containing connection inputs") + help := fs.Bool("help", false, "show help") + if err := fs.Parse(args); err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + if *help { + writeConnectionImportUsage(stdout) + return ExitSuccess + } + if fs.NArg() != 0 || strings.TrimSpace(*filePath) == "" { + return fail(stderr, ExitUsage, "usage", errors.New("connection import requires --file")) + } + inputs, err := loadConnectionInputs(*filePath) + if err != nil { + return fail(stderr, ExitUsage, "invalid_connection_input", err) + } + if len(inputs) == 0 { + return fail(stderr, ExitUsage, "invalid_connection_input", errors.New("connection import file is empty")) + } + saved, err := runtime.ImportLegacyConnections(inputs) + if err != nil { + return fail(stderr, ExitConnection, "connection_import_failed", err) + } + for _, item := range saved { + if code := emitOutput(stdout, stderr, item); code != ExitSuccess { + return code + } + } + return ExitSuccess +} + +func runQuery(ctx context.Context, args []string, runtime backend, stdout io.Writer, stderr io.Writer) int { + fs := newFlagSet("query") + connectionSelector := fs.String("conn", "", "connection ID or exact name") + connectionFile := fs.String("connection-file", "", "temporary ConnectionConfig JSON file") + database := fs.String("database", "", "database or schema") + sqlText := fs.String("sql", "", "SQL text") + sqlFile := fs.String("sql-file", "", "SQL file") + format := fs.String("format", "jsonl", "jsonl, json, csv, or md") + allowWrite := false + fs.BoolVar(&allowWrite, "allow-write", false, "allow non-read-only SQL") + fs.BoolVar(&allowWrite, "allow-mutating", false, "deprecated alias for --allow-write") + queryTimeout := fs.Int("query-timeout", 0, "query timeout in seconds") + help := fs.Bool("help", false, "show help") + if err := fs.Parse(args); err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + if *help { + writeQueryUsage(stdout) + return ExitSuccess + } + if *queryTimeout < 0 { + return fail(stderr, ExitUsage, "usage", errors.New("query timeout must not be negative")) + } + queryFormat := strings.ToLower(strings.TrimSpace(*format)) + if !isCLIQueryFormat(queryFormat) { + return fail(stderr, ExitUsage, "usage", fmt.Errorf("unsupported query format %q", *format)) + } + sql, err := resolveSQLInput(*sqlText, *sqlFile, fs.Args()) + if err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + config, err := resolveCommandConnection(runtime, *connectionSelector, *connectionFile) + if err != nil { + return failCommandConnection(stderr, err) + } + if *queryTimeout > 0 { + config.QueryTimeout = *queryTimeout + } + result := runtime.Query(ctx, config, *database, sql, appcore.HeadlessQueryOptions{AllowMutating: allowWrite}) + if !result.Success { + return failResult(ctx, stderr, result) + } + return renderQueryResult(stdout, stderr, result, queryFormat) +} + +func runExport(ctx context.Context, args []string, runtime backend, stdout io.Writer, stderr io.Writer) int { + fs := newFlagSet("export") + connectionSelector := fs.String("conn", "", "connection ID or exact name") + connectionFile := fs.String("connection-file", "", "temporary ConnectionConfig JSON file") + database := fs.String("database", "", "database or schema") + sqlText := fs.String("sql", "", "SELECT/WITH SQL text") + sqlFile := fs.String("sql-file", "", "SQL file") + output := fs.String("output", "", "output file path") + format := fs.String("format", "", "csv, json, md, html, or xlsx") + columns := fs.String("columns", "", "comma-separated output columns") + xlsxRows := fs.Int("xlsx-max-rows-per-sheet", 0, "maximum XLSX data rows per worksheet") + force := fs.Bool("force", false, "replace an existing output file") + queryTimeout := fs.Int("query-timeout", 0, "query timeout in seconds") + help := fs.Bool("help", false, "show help") + if err := fs.Parse(args); err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + if *help { + writeExportUsage(stdout) + return ExitSuccess + } + if strings.TrimSpace(*output) == "" { + return fail(stderr, ExitUsage, "usage", errors.New("export requires --output")) + } + if *queryTimeout < 0 { + return fail(stderr, ExitUsage, "usage", errors.New("query timeout must not be negative")) + } + sql, err := resolveSQLInput(*sqlText, *sqlFile, fs.Args()) + if err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + resolvedFormat := strings.ToLower(strings.TrimSpace(*format)) + if resolvedFormat == "" { + resolvedFormat = strings.TrimPrefix(strings.ToLower(filepath.Ext(*output)), ".") + } + if !isCLIExportFormat(resolvedFormat) { + return fail(stderr, ExitUsage, "usage", fmt.Errorf("unsupported export format %q", resolvedFormat)) + } + config, err := resolveCommandConnection(runtime, *connectionSelector, *connectionFile) + if err != nil { + return failCommandConnection(stderr, err) + } + if *queryTimeout > 0 { + config.QueryTimeout = *queryTimeout + } + result := runtime.ExportQueryToPath(ctx, config, *database, sql, *output, appcore.ExportFileOptions{ + Format: resolvedFormat, + Columns: splitCSVList(*columns), + XLSXMaxRowsPerSheet: *xlsxRows, + }, *force) + if !result.Success { + return failResult(ctx, stderr, result) + } + return emitOutput(stdout, stderr, sanitizeQueryResult(result)) +} + +func runBatch(ctx context.Context, args []string, runtime backend, stdout io.Writer, stderr io.Writer) int { + fs := newFlagSet("batch") + connectionSelector := fs.String("conn", "", "connection ID or exact name") + connectionFile := fs.String("connection-file", "", "temporary ConnectionConfig JSON file") + database := fs.String("database", "", "database or schema") + filePath := fs.String("file", "", "SQL or SQL.GZ file") + aliasFilePath := fs.String("sql-file", "", "SQL or SQL.GZ file") + allowWrite := false + fs.BoolVar(&allowWrite, "allow-write", false, "allow SQL-file execution") + fs.BoolVar(&allowWrite, "allow-mutating", false, "deprecated alias for --allow-write") + transaction := fs.String("transaction", string(appcore.HeadlessSQLTransactionModeSingle), "single or off") + continueOnError := fs.Bool("continue-on-error", false, "continue after statement errors") + stopOnError := fs.Bool("stop-on-error", false, "stop after the first statement error (default)") + jobID := fs.String("job-id", "", "durable job ID") + maxStatementBytes := fs.Int64("max-statement-bytes", 0, "maximum decoded bytes in one statement") + help := fs.Bool("help", false, "show help") + if err := fs.Parse(args); err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + if *help { + writeBatchUsage(stdout) + return ExitSuccess + } + if !allowWrite { + return fail(stderr, ExitPolicyDenied, "policy_denied", errors.New("batch requires --allow-write")) + } + transactionMode, err := parseBatchTransactionMode(*transaction) + if err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + if *continueOnError && *stopOnError { + return fail(stderr, ExitUsage, "usage", errors.New("use either --continue-on-error or --stop-on-error")) + } + if *continueOnError && transactionMode != appcore.HeadlessSQLTransactionModeOff { + return fail(stderr, ExitUsage, "usage", errors.New("--continue-on-error requires --transaction=off")) + } + if strings.TrimSpace(*filePath) != "" && strings.TrimSpace(*aliasFilePath) != "" { + return fail(stderr, ExitUsage, "usage", errors.New("use either --file or --sql-file")) + } + if strings.TrimSpace(*filePath) == "" { + *filePath = *aliasFilePath + } + if strings.TrimSpace(*filePath) == "" { + return fail(stderr, ExitUsage, "usage", errors.New("batch requires --file")) + } + if fs.NArg() != 0 { + return fail(stderr, ExitUsage, "usage", errors.New("batch does not accept positional arguments")) + } + if *maxStatementBytes < 0 { + return fail(stderr, ExitUsage, "usage", errors.New("max statement bytes must not be negative")) + } + if _, err := os.Stat(*filePath); err != nil { + return fail(stderr, ExitUsage, "sql_file_unavailable", err) + } + config, err := resolveCommandConnection(runtime, *connectionSelector, *connectionFile) + if err != nil { + return failCommandConnection(stderr, err) + } + result := runtime.ExecuteSQLFile(ctx, config, *database, *filePath, appcore.HeadlessSQLFileOptions{ + AllowMutating: allowWrite, + ContinueOnError: *continueOnError && !*stopOnError, + TransactionMode: transactionMode, + JobID: strings.TrimSpace(*jobID), + MaxStatementSize: *maxStatementBytes, + }) + if !result.Success { + return failResult(ctx, stderr, result) + } + return emitOutput(stdout, stderr, sanitizeQueryResult(result)) +} + +func parseBatchTransactionMode(value string) (appcore.HeadlessSQLTransactionMode, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", string(appcore.HeadlessSQLTransactionModeSingle): + return appcore.HeadlessSQLTransactionModeSingle, nil + case string(appcore.HeadlessSQLTransactionModeOff): + return appcore.HeadlessSQLTransactionModeOff, nil + default: + return "", fmt.Errorf("unsupported transaction mode %q (use single or off)", value) + } +} + +func runAudit(args []string, runtime backend, stdout io.Writer, stderr io.Writer) int { + if len(args) == 0 { + return fail(stderr, ExitUsage, "usage", errors.New("audit requires a subcommand (export)")) + } + if strings.EqualFold(strings.TrimSpace(args[0]), "help") || strings.EqualFold(strings.TrimSpace(args[0]), "--help") { + writeAuditUsage(stdout) + return ExitSuccess + } + if !strings.EqualFold(strings.TrimSpace(args[0]), "export") { + return fail(stderr, ExitUsage, "usage", fmt.Errorf("unknown audit command %q", args[0])) + } + + fs := newFlagSet("audit export") + output := fs.String("output", "", "output file path") + format := fs.String("format", "json", "json or csv") + force := fs.Bool("force", false, "replace an existing output file") + connectionID := fs.String("connection-id", "", "audit connection ID filter") + database := fs.String("database", "", "audit database filter") + dbType := fs.String("db-type", "", "audit database type filter") + status := fs.String("status", "", "audit status filter") + source := fs.String("source", "", "audit source filter") + search := fs.String("search", "", "audit search filter") + from := fs.String("from", "", "RFC3339 or Unix milliseconds") + to := fs.String("to", "", "RFC3339 or Unix milliseconds") + help := fs.Bool("help", false, "show help") + if err := fs.Parse(args[1:]); err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + if *help { + writeAuditUsage(stdout) + return ExitSuccess + } + if fs.NArg() != 0 || strings.TrimSpace(*output) == "" { + return fail(stderr, ExitUsage, "usage", errors.New("audit export requires --output")) + } + resolvedFormat := strings.ToLower(strings.TrimSpace(*format)) + if !isCLIAuditFormat(resolvedFormat) { + return fail(stderr, ExitUsage, "usage", fmt.Errorf("unsupported audit export format %q", *format)) + } + fromTimestamp, err := parseTimestamp(*from) + if err != nil { + return fail(stderr, ExitUsage, "usage", fmt.Errorf("invalid --from: %w", err)) + } + toTimestamp, err := parseTimestamp(*to) + if err != nil { + return fail(stderr, ExitUsage, "usage", fmt.Errorf("invalid --to: %w", err)) + } + result := runtime.ExportSQLAuditToPath(sqlaudit.Filter{ + Search: strings.TrimSpace(*search), + ConnectionID: strings.TrimSpace(*connectionID), + Database: strings.TrimSpace(*database), + DBType: strings.TrimSpace(*dbType), + Status: strings.TrimSpace(*status), + Source: strings.TrimSpace(*source), + FromTimestamp: fromTimestamp, + ToTimestamp: toTimestamp, + }, resolvedFormat, *output, *force) + if !result.Success { + return failResult(context.Background(), stderr, result) + } + return emitOutput(stdout, stderr, sanitizeQueryResult(result)) +} + +func runMCP(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { + if len(args) == 0 { + return finishMCPInvocation(ctx, stderr, runMCPStdioServer(ctx)) + } + switch strings.ToLower(strings.TrimSpace(args[0])) { + case "stdio", "--stdio": + return finishMCPInvocation(ctx, stderr, runMCPStdioServer(ctx)) + case "http", "--http", "streamable-http", "--streamable-http": + options, err := mcpserver.ParseHTTPServerOptions(args[1:]) + if err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + return finishMCPInvocation(ctx, stderr, runMCPHTTPServer(ctx, options)) + case "remote-config", "--remote-config": + if err := mcpserver.WriteRemoteMCPClientConfig(stdout, args[1:]); err != nil { + return fail(stderr, ExitUsage, "usage", err) + } + return ExitSuccess + case "help", "--help", "-h": + writeMCPUsage(stdout) + return ExitSuccess + default: + return fail(stderr, ExitUsage, "usage", fmt.Errorf("unknown mcp mode %q", args[0])) + } +} + +func finishMCPInvocation(ctx context.Context, stderr io.Writer, err error) int { + // The HTTP server treats a context-triggered graceful shutdown as a clean + // server return. The command invocation still ended by cancellation, so its + // process-level status must remain distinct from a successful server exit. + if ctx != nil && ctx.Err() != nil { + return fail(stderr, ExitCancelled, "cancelled", ctx.Err()) + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return fail(stderr, ExitCancelled, "cancelled", err) + } + if err != nil { + return fail(stderr, ExitExecution, "mcp_failed", err) + } + return ExitSuccess +} + +func resolveCommandConnection(runtime backend, selector string, filePath string) (connection.ConnectionConfig, error) { + selector = strings.TrimSpace(selector) + filePath = strings.TrimSpace(filePath) + if selector != "" && filePath != "" { + return connection.ConnectionConfig{}, errConnectionSourceConflict + } + if filePath != "" { + return loadTemporaryConnectionConfig(filePath) + } + if selector == "" { + return connection.ConnectionConfig{}, errConnectionSourceMissing + } + view, err := runtime.ResolveSavedConnection(selector) + if err != nil { + return connection.ConnectionConfig{}, err + } + return view.Config, nil +} + +func resolveSQLInput(sqlText string, sqlFile string, positional []string) (string, error) { + provided := 0 + if strings.TrimSpace(sqlText) != "" { + provided++ + } + if strings.TrimSpace(sqlFile) != "" { + provided++ + } + if len(positional) > 0 { + provided++ + } + if provided != 1 { + return "", errors.New("provide SQL with one of --sql, --sql-file, or a single positional argument") + } + if strings.TrimSpace(sqlText) != "" { + return strings.TrimSpace(sqlText), nil + } + if strings.TrimSpace(sqlFile) != "" { + return readSQLFile(sqlFile) + } + if len(positional) != 1 { + return "", errors.New("SQL must be one positional argument") + } + return strings.TrimSpace(positional[0]), nil +} + +func readSQLFile(filePath string) (string, error) { + const maxSQLTextBytes = 64 << 20 + file, err := os.Open(strings.TrimSpace(filePath)) + if err != nil { + return "", err + } + defer file.Close() + reader := io.LimitReader(file, maxSQLTextBytes+1) + contents, err := io.ReadAll(reader) + if err != nil { + return "", err + } + if len(contents) > maxSQLTextBytes { + return "", fmt.Errorf("SQL text file exceeds %d bytes", maxSQLTextBytes) + } + text := strings.TrimSpace(string(contents)) + if text == "" { + return "", errors.New("SQL text is empty") + } + return text, nil +} + +func loadSingleConnectionInput(filePath string) (connection.SavedConnectionInput, error) { + data, err := os.ReadFile(strings.TrimSpace(filePath)) + if err != nil { + return connection.SavedConnectionInput{}, err + } + var input connection.SavedConnectionInput + if err := json.Unmarshal(data, &input); err != nil { + return connection.SavedConnectionInput{}, err + } + return input, nil +} + +func loadConnectionInputs(filePath string) ([]connection.LegacySavedConnection, error) { + data, err := os.ReadFile(strings.TrimSpace(filePath)) + if err != nil { + return nil, err + } + var list []connection.LegacySavedConnection + if err := json.Unmarshal(data, &list); err == nil { + return list, nil + } + var wrapped struct { + Connections []connection.LegacySavedConnection `json:"connections"` + } + if err := json.Unmarshal(data, &wrapped); err != nil { + return nil, err + } + if wrapped.Connections == nil { + return nil, errors.New("expected a JSON array or an object with a connections array") + } + return wrapped.Connections, nil +} + +func assignConnectionEnvSecret(target *string, envName string) error { + envName = strings.TrimSpace(envName) + if envName == "" { + return nil + } + value, ok := os.LookupEnv(envName) + if !ok { + return fmt.Errorf("environment variable %s is not set", envName) + } + if target == nil { + return errors.New("secret target is unavailable") + } + *target = value + return nil +} + +func renderQueryResult(stdout io.Writer, stderr io.Writer, result connection.QueryResult, format string) int { + format = strings.ToLower(strings.TrimSpace(format)) + if format == "" { + format = "jsonl" + } + if format == "json" { + return emitOutput(stdout, stderr, sanitizeQueryResult(result)) + } + sets, err := queryResultSets(result) + switch format { + case "jsonl": + // Writes and other successful non-tabular actions return metadata such + // as affectedRows instead of result sets. They still use the stable + // JSONL summary contract; only tabular data emits result_set/row events. + if err != nil { + sanitized := sanitizeQueryResult(result) + return emitOutput(stdout, stderr, jsonlSummaryEvent{ + Type: "summary", + Success: sanitized.Success, + QueryID: sanitized.QueryID, + Message: sanitized.Message, + Messages: sanitized.Messages, + Data: sanitized.Data, + ResultSets: 0, + Rows: 0, + }) + } + rows := 0 + for index, set := range sets { + if code := emitOutput(stdout, stderr, jsonlResultSetEvent{ + Type: "result_set", + ResultSet: index + 1, + Columns: set.Columns, + RowCount: len(set.Rows), + }); code != ExitSuccess { + return code + } + for _, row := range set.Rows { + if code := emitOutput(stdout, stderr, jsonlRowEvent{Type: "row", ResultSet: index + 1, Data: row}); code != ExitSuccess { + return code + } + rows++ + } + } + sanitized := sanitizeQueryResult(result) + return emitOutput(stdout, stderr, jsonlSummaryEvent{ + Type: "summary", + Success: sanitized.Success, + QueryID: sanitized.QueryID, + Message: sanitized.Message, + Messages: sanitized.Messages, + ResultSets: len(sets), + Rows: rows, + }) + case "csv", "md", "markdown": + if err != nil { + return fail(stderr, ExitExecution, "invalid_result", err) + } + if len(sets) != 1 { + return fail(stderr, ExitUsage, "unsupported_result_shape", errors.New("csv and markdown require exactly one result set")) + } + switch format { + case "csv": + writer := csv.NewWriter(stdout) + if err := writer.Write(sets[0].Columns); err != nil { + return fail(stderr, ExitExecution, "output_failed", err) + } + for _, row := range sets[0].Rows { + record := make([]string, len(sets[0].Columns)) + for index, column := range sets[0].Columns { + record[index] = formatOutputValue(row[column]) + } + if err := writer.Write(record); err != nil { + return fail(stderr, ExitExecution, "output_failed", err) + } + } + writer.Flush() + if err := writer.Error(); err != nil { + return fail(stderr, ExitExecution, "output_failed", err) + } + return ExitSuccess + case "md", "markdown": + if _, err := fmt.Fprintf(stdout, "| %s |\n", strings.Join(sets[0].Columns, " | ")); err != nil { + return fail(stderr, ExitExecution, "output_failed", err) + } + separator := make([]string, len(sets[0].Columns)) + for index := range separator { + separator[index] = "---" + } + if _, err := fmt.Fprintf(stdout, "| %s |\n", strings.Join(separator, " | ")); err != nil { + return fail(stderr, ExitExecution, "output_failed", err) + } + for _, row := range sets[0].Rows { + record := make([]string, len(sets[0].Columns)) + for index, column := range sets[0].Columns { + value := strings.ReplaceAll(formatOutputValue(row[column]), "|", "\\|") + record[index] = strings.ReplaceAll(value, "\n", "
") + } + if _, err := fmt.Fprintf(stdout, "| %s |\n", strings.Join(record, " | ")); err != nil { + return fail(stderr, ExitExecution, "output_failed", err) + } + } + return ExitSuccess + } + default: + return fail(stderr, ExitUsage, "usage", fmt.Errorf("unsupported query format %q", format)) + } + return ExitExecution +} + +func isCLIQueryFormat(format string) bool { + switch strings.ToLower(strings.TrimSpace(format)) { + case "json", "jsonl", "csv", "md", "markdown": + return true + default: + return false + } +} + +func queryResultSets(result connection.QueryResult) ([]connection.ResultSetData, error) { + if result.Data == nil { + return []connection.ResultSetData{{Columns: result.Fields, Rows: []map[string]any{}}}, nil + } + if sets, ok := result.Data.([]connection.ResultSetData); ok { + return sets, nil + } + if rows, ok := result.Data.([]map[string]any); ok { + return []connection.ResultSetData{{Columns: result.Fields, Rows: rows}}, nil + } + return nil, fmt.Errorf("query result is not tabular") +} + +func sanitizeQueryResult(result connection.QueryResult) connection.QueryResult { + result.Message = sqlaudit.RedactError(result.Message) + if len(result.Messages) > 0 { + messages := make([]string, 0, len(result.Messages)) + for _, message := range result.Messages { + messages = append(messages, sqlaudit.RedactError(message)) + } + result.Messages = messages + } + return result +} + +func formatOutputValue(value any) string { + if value == nil { + return "" + } + switch value.(type) { + case map[string]any, []any, []string, []byte: + if encoded, err := json.Marshal(value); err == nil { + return string(encoded) + } + } + return fmt.Sprint(value) +} + +func splitCSVList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if normalized := strings.TrimSpace(part); normalized != "" { + result = append(result, normalized) + } + } + return result +} + +func isCLIExportFormat(format string) bool { + switch strings.ToLower(strings.TrimSpace(format)) { + case "csv", "json", "md", "html", "xlsx": + return true + default: + return false + } +} + +func isCLIAuditFormat(format string) bool { + switch strings.ToLower(strings.TrimSpace(format)) { + case "json", "csv": + return true + default: + return false + } +} + +func parseTimestamp(value string) (int64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, nil + } + if milliseconds, err := strconv.ParseInt(value, 10, 64); err == nil { + return milliseconds, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return 0, err + } + return parsed.UnixMilli(), nil +} + +func resultDataBool(result connection.QueryResult, key string) bool { + data, ok := result.Data.(map[string]any) + if !ok { + return false + } + value, _ := data[key].(bool) + return value +} + +func resultHasUnknownOutcome(result connection.QueryResult) bool { + return resultDataBool(result, "outcomeUnknown") +} + +func resultWasCancelled(result connection.QueryResult) bool { + return resultDataBool(result, "cancelled") +} + +func resultErrorKind(result connection.QueryResult) string { + data, ok := result.Data.(map[string]any) + if !ok { + return "" + } + kind, _ := data["errorKind"].(string) + return strings.ToLower(strings.TrimSpace(kind)) +} + +func failResult(ctx context.Context, stderr io.Writer, result connection.QueryResult) int { + if resultHasUnknownOutcome(result) { + return fail(stderr, ExitUnknownOutcome, "outcome_unknown", errors.New(result.Message)) + } + if resultWasCancelled(result) { + return fail(stderr, ExitCancelled, "cancelled", errors.New(result.Message)) + } + if ctx != nil && ctx.Err() != nil { + return fail(stderr, ExitCancelled, "cancelled", ctx.Err()) + } + if resultErrorKind(result) == "connection" { + return fail(stderr, ExitConnection, "connection_failed", errors.New(result.Message)) + } + if resultErrorKind(result) == "policy" { + return fail(stderr, ExitPolicyDenied, "policy_denied", errors.New(result.Message)) + } + if hasCancellationToken(result.Message) { + return fail(stderr, ExitCancelled, "cancelled", errors.New(result.Message)) + } + if strings.Contains(strings.ToLower(result.Message), "allow-write") || strings.Contains(strings.ToLower(result.Message), "allow-mutating") || strings.Contains(strings.ToLower(result.Message), "read-only") || strings.Contains(result.Message, "只读") { + return fail(stderr, ExitPolicyDenied, "policy_denied", errors.New(result.Message)) + } + return fail(stderr, ExitExecution, "execution_failed", errors.New(result.Message)) +} + +// hasCancellationToken deliberately matches standalone cancellation words. +// Substrings such as "cancellation_reason" are ordinary database identifiers, +// not evidence that an operation was cancelled. +func hasCancellationToken(message string) bool { + for _, token := range strings.FieldsFunc(strings.ToLower(message), func(r rune) bool { + return !unicode.IsLetter(r) + }) { + switch token { + case "cancel", "canceled", "cancelled": + return true + } + } + return false +} + +func failResolveSavedConnection(stderr io.Writer, err error) int { + var ambiguous *appcore.AmbiguousConnectionNameError + if errors.As(err, &ambiguous) { + return fail(stderr, ExitConnection, "connection_ambiguous", err) + } + return fail(stderr, ExitConnection, "connection_not_found", err) +} + +func failCommandConnection(stderr io.Writer, err error) int { + if errors.Is(err, errConnectionSourceConflict) || errors.Is(err, errConnectionSourceMissing) { + return fail(stderr, ExitUsage, "usage", err) + } + var ambiguous *appcore.AmbiguousConnectionNameError + if errors.As(err, &ambiguous) { + return failResolveSavedConnection(stderr, err) + } + if strings.Contains(strings.ToLower(err.Error()), "saved connection not found") { + return failResolveSavedConnection(stderr, err) + } + return fail(stderr, ExitConnection, "connection_file_invalid", err) +} + +func newFlagSet(name string) *flag.FlagSet { + fs := flag.NewFlagSet("gonavi "+name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + return fs +} + +func visitedFlags(fs *flag.FlagSet) map[string]bool { + result := make(map[string]bool) + fs.Visit(func(item *flag.Flag) { + result[item.Name] = true + }) + return result +} + +func emit(writer io.Writer, value any) int { + if err := encode(writer, value); err != nil { + return ExitExecution + } + return ExitSuccess +} + +func emitOutput(stdout io.Writer, stderr io.Writer, value any) int { + if err := encode(stdout, value); err != nil { + return fail(stderr, ExitExecution, "output_failed", err) + } + return ExitSuccess +} + +func encode(writer io.Writer, value any) error { + encoder := json.NewEncoder(writer) + encoder.SetEscapeHTML(false) + return encoder.Encode(value) +} + +func fail(writer io.Writer, exitCode int, code string, err error) int { + message := "operation failed" + if err != nil { + message = sqlaudit.RedactError(err.Error()) + } + _ = emit(writer, errorReport{OK: false, Code: code, Message: message}) + return exitCode +} + +func writeRootUsage(writer io.Writer) { + _, _ = io.WriteString(writer, `GoNavi CLI + +Usage: + gonavi [--data-root PATH] list-connections + gonavi [--data-root PATH] connection + gonavi [--data-root PATH] query (--conn ID_OR_NAME|--connection-file FILE) [--sql SQL|--sql-file FILE|SQL] + gonavi [--data-root PATH] export (--conn ID_OR_NAME|--connection-file FILE) --output FILE [--sql SQL|--sql-file FILE|SQL] + gonavi [--data-root PATH] batch (--conn ID_OR_NAME|--connection-file FILE) --file FILE --allow-write + gonavi [--data-root PATH] audit export --output FILE + gonavi [--data-root PATH] mcp +`) +} + +func writeListConnectionsUsage(writer io.Writer) { + _, _ = io.WriteString(writer, "Usage: gonavi list-connections\n") +} + +func writeConnectionUsage(writer io.Writer) { + _, _ = io.WriteString(writer, "Usage: gonavi connection \n") +} + +func writeConnectionAddUsage(writer io.Writer) { + _, _ = io.WriteString(writer, "Usage: gonavi connection add --name NAME --type TYPE [--host HOST --port PORT --user USER --database DB] [--connection-params PARAMS|--connection-params-env NAME] [--password-env NAME] [--file INPUT.json]\n") +} + +func writeConnectionImportUsage(writer io.Writer) { + _, _ = io.WriteString(writer, "Usage: gonavi connection import --file CONNECTIONS.json\n") +} + +func writeQueryUsage(writer io.Writer) { + _, _ = io.WriteString(writer, "Usage: gonavi query (--conn ID_OR_NAME|--connection-file FILE) [--database DB] [--allow-write] [--format jsonl|json|csv|md] (--sql SQL|--sql-file FILE|SQL)\n") +} + +func writeExportUsage(writer io.Writer) { + _, _ = io.WriteString(writer, "Usage: gonavi export (--conn ID_OR_NAME|--connection-file FILE) --output FILE [--format csv|json|md|html|xlsx] (--sql SQL|--sql-file FILE|SQL)\n") +} + +func writeBatchUsage(writer io.Writer) { + _, _ = io.WriteString(writer, "Usage: gonavi batch (--conn ID_OR_NAME|--connection-file FILE) --file FILE --allow-write [--transaction single|off] [--stop-on-error|--continue-on-error]\n") +} + +func writeAuditUsage(writer io.Writer) { + _, _ = io.WriteString(writer, "Usage: gonavi audit export --output FILE [--format json|csv]\n") +} + +func writeMCPUsage(writer io.Writer) { + _, _ = io.WriteString(writer, "Usage: gonavi mcp \n") +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 00000000..bc2863d7 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,772 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + appcore "GoNavi-Wails/internal/app" + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/mcpserver" + "GoNavi-Wails/internal/sqlaudit" +) + +type fakeBackend struct { + closed bool + saveCalls int + resolveCalls int + + connections []connection.SavedConnectionView + resolveErr error + queryResult connection.QueryResult + batchResult connection.QueryResult + + queryConfig connection.ConnectionConfig + querySQL string + queryOptions appcore.HeadlessQueryOptions + savedConnectionParams string + batchConfig connection.ConnectionConfig + batchFile string + batchOptions appcore.HeadlessSQLFileOptions + auditFilter sqlaudit.Filter + auditFormat string + auditPath string + auditOverwrite bool +} + +func (backend *fakeBackend) Close() { backend.closed = true } + +func (backend *fakeBackend) GetSavedConnections() ([]connection.SavedConnectionView, error) { + return backend.connections, nil +} + +func (backend *fakeBackend) SaveConnection(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) { + backend.saveCalls++ + backend.savedConnectionParams = input.Config.ConnectionParams + return connection.SavedConnectionView{ID: input.ID, Name: input.Name, Config: input.Config}, nil +} + +func (backend *fakeBackend) ImportLegacyConnections(items []connection.LegacySavedConnection) ([]connection.SavedConnectionView, error) { + result := make([]connection.SavedConnectionView, 0, len(items)) + for _, item := range items { + result = append(result, connection.SavedConnectionView{ID: item.ID, Name: item.Name, Config: item.Config}) + } + return result, nil +} + +func (backend *fakeBackend) ResolveSavedConnection(selector string) (connection.SavedConnectionView, error) { + backend.resolveCalls++ + if backend.resolveErr != nil { + return connection.SavedConnectionView{}, backend.resolveErr + } + for _, item := range backend.connections { + if item.ID == selector || item.Name == selector { + return item, nil + } + } + return connection.SavedConnectionView{}, errors.New("saved connection not found") +} + +func (backend *fakeBackend) Query(_ context.Context, config connection.ConnectionConfig, _ string, sql string, options appcore.HeadlessQueryOptions) connection.QueryResult { + backend.queryConfig = config + backend.querySQL = sql + backend.queryOptions = options + return backend.queryResult +} + +func (backend *fakeBackend) ExportQueryToPath(context.Context, connection.ConnectionConfig, string, string, string, appcore.ExportFileOptions, bool) connection.QueryResult { + return connection.QueryResult{Success: true} +} + +func (backend *fakeBackend) ExecuteSQLFile(_ context.Context, config connection.ConnectionConfig, _ string, filePath string, options appcore.HeadlessSQLFileOptions) connection.QueryResult { + backend.batchConfig = config + backend.batchFile = filePath + backend.batchOptions = options + return backend.batchResult +} + +func (backend *fakeBackend) ExportSQLAuditToPath(filter sqlaudit.Filter, format string, path string, overwrite bool) connection.QueryResult { + backend.auditFilter = filter + backend.auditFormat = format + backend.auditPath = path + backend.auditOverwrite = overwrite + return connection.QueryResult{Success: true} +} + +func runWithBackend(t *testing.T, fake *fakeBackend, args ...string) (int, string, string) { + t.Helper() + previous := newBackend + newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) { + return fake, nil + } + t.Cleanup(func() { newBackend = previous }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run(context.Background(), args, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +func TestRunVersionAcceptsFlagForm(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := Run(context.Background(), []string{"--version"}, &stdout, &stderr); code != ExitSuccess { + t.Fatalf("Run(--version) = %d, stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"version":"`) { + t.Fatalf("version output missing JSON payload: %s", stdout.String()) + } +} + +func TestRunVersionRejectsExtraArguments(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := Run(context.Background(), []string{"version", "extra"}, &stdout, &stderr); code != ExitUsage { + t.Fatalf("Run(version extra) = %d, stderr=%s, want usage exit=%d", code, stderr.String(), ExitUsage) + } + if stdout.Len() != 0 || !strings.Contains(stderr.String(), `"code":"usage"`) { + t.Fatalf("version extra output mismatch: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func TestRunAuditWithoutSubcommandRejectsBeforeBackendInitialization(t *testing.T) { + previous := newBackend + started := false + newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) { + started = true + return nil, errors.New("runtime should not start for invalid audit command") + } + t.Cleanup(func() { newBackend = previous }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run(context.Background(), []string{"audit"}, &stdout, &stderr) + if code != ExitUsage || started || stdout.Len() != 0 || !strings.Contains(stderr.String(), `"code":"usage"`) { + t.Fatalf("audit exit=%d started=%t stdout=%q stderr=%q", code, started, stdout.String(), stderr.String()) + } +} + +func TestRunCommandHelpSkipsRuntimeInitialization(t *testing.T) { + previous := newBackend + started := false + newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) { + started = true + return nil, errors.New("runtime should not start for help") + } + t.Cleanup(func() { newBackend = previous }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run(context.Background(), []string{"query", "--help"}, &stdout, &stderr) + if code != ExitSuccess || started || !strings.Contains(stdout.String(), "Usage: gonavi query") { + t.Fatalf("help exit=%d started=%t stdout=%s stderr=%s", code, started, stdout.String(), stderr.String()) + } +} + +func TestRunDataRootOverrideUsesActiveRootResolution(t *testing.T) { + root := t.TempDir() + t.Setenv("GONAVI_DATA_ROOT", "existing-root") + + previous := newBackend + var receivedOptions appcore.HeadlessRuntimeOptions + var receivedRoot string + newBackend = func(_ context.Context, options appcore.HeadlessRuntimeOptions) (backend, error) { + receivedOptions = options + receivedRoot = os.Getenv("GONAVI_DATA_ROOT") + return &fakeBackend{}, nil + } + t.Cleanup(func() { newBackend = previous }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := Run(context.Background(), []string{"--data-root", root, "list-connections"}, &stdout, &stderr); code != ExitSuccess { + t.Fatalf("Run returned %d, stderr=%s", code, stderr.String()) + } + if receivedOptions.DataRoot != "" { + t.Fatalf("CLI bypassed ResolveActiveRoot with DataRoot=%q", receivedOptions.DataRoot) + } + if receivedRoot != root { + t.Fatalf("GONAVI_DATA_ROOT during backend initialization = %q, want %q", receivedRoot, root) + } + if restored := os.Getenv("GONAVI_DATA_ROOT"); restored != "existing-root" { + t.Fatalf("GONAVI_DATA_ROOT after invocation = %q, want existing-root", restored) + } +} + +func TestRunQueryForwardsMutatingAcknowledgementAndTimeout(t *testing.T) { + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1", Type: "mysql"}}}, + queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}}}, + } + code, stdout, stderr := runWithBackend(t, fake, + "query", "--conn", "production", "--allow-mutating", "--query-timeout", "17", "UPDATE account SET active = 1", + ) + if code != ExitSuccess { + t.Fatalf("query exit = %d, stderr=%s", code, stderr) + } + if !fake.queryOptions.AllowMutating || fake.queryConfig.QueryTimeout != 17 || !strings.Contains(fake.querySQL, "UPDATE") { + t.Fatalf("query options not forwarded: %#v, %#v, %q", fake.queryOptions, fake.queryConfig, fake.querySQL) + } + if !strings.Contains(stdout, `"success":true`) { + t.Fatalf("query stdout missing result: %s", stdout) + } +} + +func TestRunQueryDefaultsToJSONLResultSetsRowsAndSummary(t *testing.T) { + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1", Type: "mysql"}}}, + queryResult: connection.QueryResult{ + Success: true, + QueryID: "query-1", + Data: []connection.ResultSetData{ + {Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}, + {Columns: []string{"name"}, Rows: []map[string]any{{"name": "GoNavi"}}}, + }, + }, + } + code, stdout, stderr := runWithBackend(t, fake, "query", "--conn", "production", "SELECT 1; SELECT 'GoNavi'") + if code != ExitSuccess { + t.Fatalf("query exit = %d, stderr=%s", code, stderr) + } + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) != 5 { + t.Fatalf("JSONL lines = %d, want 5: %s", len(lines), stdout) + } + types := make([]string, 0, len(lines)) + for _, line := range lines { + var event map[string]any + if err := json.Unmarshal([]byte(line), &event); err != nil { + t.Fatalf("invalid JSONL event %q: %v", line, err) + } + types = append(types, event["type"].(string)) + } + if got, want := strings.Join(types, ","), "result_set,row,result_set,row,summary"; got != want { + t.Fatalf("event order = %s, want %s", got, want) + } + if !strings.Contains(lines[4], `"queryId":"query-1"`) || !strings.Contains(lines[4], `"resultSets":2`) || !strings.Contains(lines[4], `"rows":2`) { + t.Fatalf("summary is incomplete: %s", lines[4]) + } +} + +func TestRunQueryJSONLSummarizesSuccessfulNonTabularWrite(t *testing.T) { + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1", Type: "mysql"}}}, + queryResult: connection.QueryResult{ + Success: true, + QueryID: "write-1", + Data: map[string]int64{"affectedRows": 3}, + }, + } + code, stdout, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--allow-write", "UPDATE account SET active = 1") + if code != ExitSuccess { + t.Fatalf("query exit = %d, stderr=%s", code, stderr) + } + var summary map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &summary); err != nil { + t.Fatalf("non-tabular JSONL summary is invalid: %v; stdout=%s", err, stdout) + } + if summary["type"] != "summary" || summary["success"] != true || summary["queryId"] != "write-1" { + t.Fatalf("unexpected non-tabular summary: %#v", summary) + } + if summary["resultSets"] != float64(0) || summary["rows"] != float64(0) { + t.Fatalf("unexpected non-tabular counts: %#v", summary) + } + data, ok := summary["data"].(map[string]any) + if !ok || data["affectedRows"] != float64(3) { + t.Fatalf("affectedRows metadata missing from summary: %#v", summary["data"]) + } +} + +func TestRunQueryFormatJSONKeepsEnvelopeCompatibility(t *testing.T) { + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}}, + queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}}}, + } + code, stdout, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--format", "json", "SELECT 1") + if code != ExitSuccess { + t.Fatalf("query exit = %d, stderr=%s", code, stderr) + } + if !strings.Contains(stdout, `"success":true`) || !strings.Contains(stdout, `"data"`) || strings.Contains(stdout, `"type":"summary"`) { + t.Fatalf("json envelope changed unexpectedly: %s", stdout) + } +} + +func TestRunQueryAcceptsAllowWriteAndLegacyAlias(t *testing.T) { + for _, flagName := range []string{"--allow-write", "--allow-mutating"} { + t.Run(flagName, func(t *testing.T) { + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}}, + queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{}}, + } + code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", flagName, "UPDATE account SET active = 1") + if code != ExitSuccess || !fake.queryOptions.AllowMutating { + t.Fatalf("query exit=%d allow=%t stderr=%s", code, fake.queryOptions.AllowMutating, stderr) + } + }) + } +} + +func TestRunQueryUsesTemporaryConnectionFileWithoutSavedConnectionLookup(t *testing.T) { + path := filepath.Join(t.TempDir(), "temporary-connection.json") + contents := []byte(`{"type":"postgres","host":"db.example.test","port":5432,"user":"cli","password":"temporary-secret","database":"app"}`) + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } + fake := &fakeBackend{ + queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{}}, + } + code, _, stderr := runWithBackend(t, fake, "query", "--connection-file", path, "SELECT 1") + if code != ExitSuccess { + t.Fatalf("query exit=%d stderr=%s", code, stderr) + } + if fake.queryConfig.ID != "" || fake.queryConfig.SavePassword || fake.queryConfig.Password != "temporary-secret" || fake.queryConfig.Type != "postgres" { + t.Fatalf("temporary config was not isolated: %#v", fake.queryConfig) + } + if fake.resolveCalls != 0 || fake.saveCalls != 0 { + t.Fatalf("temporary config touched saved connections: resolve=%d save=%d", fake.resolveCalls, fake.saveCalls) + } +} + +func TestRunConnectionAddKeepsSensitiveConnectionParamsOutOfArgv(t *testing.T) { + t.Run("rejects sensitive argv parameters without leaking them", func(t *testing.T) { + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, + "connection", "add", "--name", "production", "--type", "postgres", + "--connection-params", "application_name=gonavi&password=argv-secret", + ) + if code != ExitUsage || fake.saveCalls != 0 { + t.Fatalf("connection add exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr) + } + if !strings.Contains(stderr, "--connection-params-env") || strings.Contains(stderr, "argv-secret") { + t.Fatalf("sensitive argv rejection leaked or omitted remediation: %s", stderr) + } + }) + + t.Run("accepts public argv parameters", func(t *testing.T) { + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, + "connection", "add", "--name", "production", "--type", "postgres", + "--connection-params", "application_name=gonavi&connect_timeout=10", + ) + if code != ExitSuccess || fake.saveCalls != 1 { + t.Fatalf("connection add exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr) + } + }) + + t.Run("accepts complete sensitive parameters from environment", func(t *testing.T) { + t.Setenv("GONAVI_CLI_CONNECTION_PARAMS", "application_name=gonavi&password=environment-secret") + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, + "connection", "add", "--name", "production", "--type", "postgres", + "--connection-params-env", "GONAVI_CLI_CONNECTION_PARAMS", + ) + if code != ExitSuccess || fake.saveCalls != 1 { + t.Fatalf("connection add exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr) + } + if got := fake.savedConnectionParams; got != "application_name=gonavi&password=environment-secret" { + t.Fatalf("connection parameters from environment = %q", got) + } + }) + + t.Run("rejects conflicting direct and environment sources", func(t *testing.T) { + t.Setenv("GONAVI_CLI_CONNECTION_PARAMS", "password=environment-secret") + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, + "connection", "add", "--name", "production", "--type", "postgres", + "--connection-params", "application_name=gonavi", + "--connection-params-env", "GONAVI_CLI_CONNECTION_PARAMS", + ) + if code != ExitUsage || fake.saveCalls != 0 || !strings.Contains(stderr, "either --connection-params or --connection-params-env") { + t.Fatalf("conflicting parameter sources exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr) + } + }) +} + +func TestLoadTemporaryConnectionConfigRejectsInsecurePermissions(t *testing.T) { + if cliGOOS() == "windows" { + t.Skip("Windows ACLs are not represented by os.FileMode") + } + path := filepath.Join(t.TempDir(), "temporary-connection.json") + if err := os.WriteFile(path, []byte(`{"type":"mysql"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + _, err := loadTemporaryConnectionConfig(path) + if err == nil || !strings.Contains(err.Error(), "permissions") { + t.Fatalf("loadTemporaryConnectionConfig error = %v, want permission rejection", err) + } + + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, "query", "--connection-file", path, "SELECT 1") + if code != ExitConnection || fake.querySQL != "" || !strings.Contains(stderr, `"code":"connection_file_invalid"`) { + t.Fatalf("insecure connection file exit=%d sql=%q stderr=%s", code, fake.querySQL, stderr) + } +} + +func TestLoadTemporaryConnectionConfigAccepts0600AndRejectsSymlink(t *testing.T) { + if cliGOOS() == "windows" { + t.Skip("Windows ACLs and symlinks differ from POSIX mode checks") + } + directory := t.TempDir() + path := filepath.Join(directory, "temporary-connection.json") + if err := os.WriteFile(path, []byte(`{"type":"mysql","password":"temporary-secret"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } + config, err := loadTemporaryConnectionConfig(path) + if err != nil || config.Password != "temporary-secret" { + t.Fatalf("0600 connection file = %#v, %v", config, err) + } + + linkPath := filepath.Join(directory, "connection-link.json") + if err := os.Symlink(path, linkPath); err != nil { + t.Fatal(err) + } + if _, err := loadTemporaryConnectionConfig(linkPath); err == nil || !strings.Contains(err.Error(), "symbolic link") { + t.Fatalf("symlink error = %v, want rejection", err) + } +} + +func TestRunQueryRejectsConnectionSelectorAndFileTogether(t *testing.T) { + path := filepath.Join(t.TempDir(), "temporary-connection.json") + if err := os.WriteFile(path, []byte(`{"type":"mysql"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, "query", "--conn", "saved", "--connection-file", path, "SELECT 1") + if code != ExitUsage || fake.querySQL != "" || fake.resolveCalls != 0 { + t.Fatalf("query exit=%d sql=%q resolve=%d stderr=%s", code, fake.querySQL, fake.resolveCalls, stderr) + } + if !strings.Contains(stderr, `"code":"usage"`) { + t.Fatalf("connection source conflict was not a usage error: %s", stderr) + } +} + +func TestRunHelpUsesAllowWriteAsPrimaryFlag(t *testing.T) { + for _, args := range [][]string{{"query", "--help"}, {"batch", "--help"}} { + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := Run(context.Background(), args, &stdout, &stderr); code != ExitSuccess { + t.Fatalf("Run(%v) = %d, stderr=%s", args, code, stderr.String()) + } + if !strings.Contains(stdout.String(), "--allow-write") || strings.Contains(stdout.String(), "--allow-mutating") { + t.Fatalf("help did not make --allow-write primary: %s", stdout.String()) + } + } +} + +func TestRunQueryRejectsInvalidFormatBeforeExecution(t *testing.T) { + fake := &fakeBackend{connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}}} + code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--format", "xlsx", "SELECT 1") + if code != ExitUsage || fake.querySQL != "" { + t.Fatalf("query exit=%d sql=%q stderr=%s", code, fake.querySQL, stderr) + } +} + +func TestRunQueryReportsAmbiguousConnectionWithoutExecutingSQL(t *testing.T) { + fake := &fakeBackend{ + resolveErr: &appcore.AmbiguousConnectionNameError{Name: "production", IDs: []string{"conn-1", "conn-2"}}, + } + code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "SELECT 1") + if code != ExitConnection || fake.querySQL != "" { + t.Fatalf("query exit=%d sql=%q stderr=%s", code, fake.querySQL, stderr) + } + if !strings.Contains(stderr, `"code":"connection_ambiguous"`) || !strings.Contains(stderr, "conn-1") || !strings.Contains(stderr, "conn-2") { + t.Fatalf("ambiguous connection report lost structured candidates: %s", stderr) + } +} + +func TestRunQuerySanitizesFailure(t *testing.T) { + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}}, + queryResult: connection.QueryResult{Success: false, Message: "connect postgres://alice:driver-secret@example.test/db password=top-secret"}, + } + code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "SELECT 1") + if code != ExitExecution { + t.Fatalf("query exit = %d, stderr=%s", code, stderr) + } + if strings.Contains(stderr, "driver-secret") || strings.Contains(stderr, "top-secret") { + t.Fatalf("secret leaked in stderr: %s", stderr) + } +} + +func TestRunQueryMapsStructuredPolicyFailureToExitFour(t *testing.T) { + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}}, + queryResult: connection.QueryResult{ + Success: false, + Message: "SQL is blocked by AI safety level readonly", + Data: map[string]any{"errorKind": "policy"}, + }, + } + code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--allow-write", "UPDATE account SET active = 1") + if code != ExitPolicyDenied || !strings.Contains(stderr, `"code":"policy_denied"`) { + t.Fatalf("query policy exit=%d stderr=%s", code, stderr) + } +} + +func TestRunBatchRequiresAcknowledgementBeforeFileOrConnectionAccess(t *testing.T) { + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, "batch", "--conn", "production", "--file", "/missing.sql") + if code != ExitPolicyDenied { + t.Fatalf("batch exit = %d, stderr=%s", code, stderr) + } + if fake.batchFile != "" { + t.Fatalf("batch unexpectedly executed %q", fake.batchFile) + } +} + +func TestRunBatchUnknownOutcomeHasDedicatedExitCode(t *testing.T) { + directory := t.TempDir() + filePath := filepath.Join(directory, "migration.sql") + if err := os.WriteFile(filePath, []byte("UPDATE account SET active = 1;"), 0o600); err != nil { + t.Fatal(err) + } + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1"}}}, + batchResult: connection.QueryResult{Success: false, Message: "connection dropped after dispatch", Data: map[string]any{"outcomeUnknown": true}}, + } + code, _, stderr := runWithBackend(t, fake, "batch", "--conn", "production", "--file", filePath, "--allow-mutating", "--stop-on-error") + if code != ExitUnknownOutcome || !strings.Contains(stderr, `"code":"outcome_unknown"`) { + t.Fatalf("batch exit=%d stderr=%s", code, stderr) + } + if !fake.batchOptions.AllowMutating || fake.batchOptions.TransactionMode != appcore.HeadlessSQLTransactionModeSingle || fake.batchFile != filePath { + t.Fatalf("batch options not forwarded: %#v file=%q", fake.batchOptions, fake.batchFile) + } +} + +func TestFailResultUsesStructuredCancellationAndPreservesUnknownOutcomePriority(t *testing.T) { + tests := []struct { + name string + data map[string]any + wantExit int + wantCode string + }{ + { + name: "cancelled", + data: map[string]any{"cancelled": true}, + wantExit: ExitCancelled, + wantCode: `"code":"cancelled"`, + }, + { + name: "unknown outcome after cancellation", + data: map[string]any{"cancelled": true, "outcomeUnknown": true}, + wantExit: ExitUnknownOutcome, + wantCode: `"code":"outcome_unknown"`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stderr bytes.Buffer + code := failResult(context.Background(), &stderr, connection.QueryResult{ + Success: false, + Message: "执行已取消", + Data: test.data, + }) + if code != test.wantExit || !strings.Contains(stderr.String(), test.wantCode) { + t.Fatalf("failResult exit=%d stderr=%s, want exit=%d code=%s", code, stderr.String(), test.wantExit, test.wantCode) + } + }) + } +} + +func TestFailResultPrefersStructuredPolicyOverCancellationText(t *testing.T) { + var stderr bytes.Buffer + result := connection.QueryResult{ + Success: false, + Message: "policy denied: cancellation command is not permitted", + Data: map[string]any{"errorKind": "policy"}, + } + if code := failResult(context.Background(), &stderr, result); code != ExitPolicyDenied { + t.Fatalf("failResult exit=%d stderr=%s, want policy exit=%d", code, stderr.String(), ExitPolicyDenied) + } + if !strings.Contains(stderr.String(), `"code":"policy_denied"`) { + t.Fatalf("structured policy code missing: %s", stderr.String()) + } +} + +func TestFailResultMapsStructuredConnectionFailure(t *testing.T) { + var stderr bytes.Buffer + result := connection.QueryResult{ + Success: false, + Message: "authentication failed", + Data: map[string]any{"errorKind": "connection"}, + } + if code := failResult(context.Background(), &stderr, result); code != ExitConnection { + t.Fatalf("failResult exit=%d stderr=%s, want connection exit=%d", code, stderr.String(), ExitConnection) + } + if !strings.Contains(stderr.String(), `"code":"connection_failed"`) { + t.Fatalf("structured connection code missing: %s", stderr.String()) + } +} + +func TestFailResultDoesNotTreatOrdinaryCancellationTextAsCancellation(t *testing.T) { + var stderr bytes.Buffer + result := connection.QueryResult{ + Success: false, + Message: `column "cancellation_reason" does not exist`, + } + if code := failResult(context.Background(), &stderr, result); code != ExitExecution { + t.Fatalf("failResult exit=%d stderr=%s, want execution exit=%d", code, stderr.String(), ExitExecution) + } + if !strings.Contains(stderr.String(), `"code":"execution_failed"`) { + t.Fatalf("ordinary database error was not classified as execution failure: %s", stderr.String()) + } +} + +func TestRunBatchOnlyAllowsContinueOnErrorWithTransactionOff(t *testing.T) { + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, + "batch", "--conn", "production", "--file", "/missing.sql", "--allow-write", "--continue-on-error", + ) + if code != ExitUsage || fake.batchFile != "" || !strings.Contains(stderr, "--transaction=off") { + t.Fatalf("batch exit=%d file=%q stderr=%s", code, fake.batchFile, stderr) + } +} + +func TestRunBatchForwardsTransactionOff(t *testing.T) { + directory := t.TempDir() + filePath := filepath.Join(directory, "migration.sql") + if err := os.WriteFile(filePath, []byte("UPDATE account SET active = 1;"), 0o600); err != nil { + t.Fatal(err) + } + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}}, + batchResult: connection.QueryResult{Success: true}, + } + code, _, stderr := runWithBackend(t, fake, + "batch", "--conn", "production", "--file", filePath, "--allow-write", "--transaction=off", "--continue-on-error", + ) + if code != ExitSuccess || !fake.batchOptions.ContinueOnError || fake.batchOptions.TransactionMode != appcore.HeadlessSQLTransactionModeOff { + t.Fatalf("batch exit=%d options=%#v stderr=%s", code, fake.batchOptions, stderr) + } +} + +func TestRunAuditExportParsesTimestampAndFilters(t *testing.T) { + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, + "audit", "export", "--output", "audit.json", "--source", "cli", "--from", "2026-08-10T00:00:00Z", "--to", "123", + ) + if code != ExitSuccess { + t.Fatalf("audit exit=%d stderr=%s", code, stderr) + } + if fake.auditFormat != "json" || fake.auditPath != "audit.json" || fake.auditFilter.Source != "cli" || fake.auditFilter.FromTimestamp == 0 || fake.auditFilter.ToTimestamp != 123 { + t.Fatalf("audit args not forwarded: format=%q path=%q filter=%#v", fake.auditFormat, fake.auditPath, fake.auditFilter) + } +} + +func TestRunAuditExportRejectsUnsupportedFormatBeforeBackendCall(t *testing.T) { + fake := &fakeBackend{} + code, _, stderr := runWithBackend(t, fake, "audit", "export", "--output", "audit.out", "--format", "yaml") + if code != ExitUsage { + t.Fatalf("audit exit=%d stderr=%s, want usage exit=%d", code, stderr, ExitUsage) + } + if fake.auditFormat != "" { + t.Fatalf("unsupported audit format reached backend: %q", fake.auditFormat) + } + if !strings.Contains(stderr, `"code":"usage"`) { + t.Fatalf("unsupported audit format was not reported as usage error: %s", stderr) + } +} + +func TestRunMCPMapsInvocationTerminationToCancelledExit(t *testing.T) { + t.Run("stdio cancellation error", func(t *testing.T) { + previousStdio := runMCPStdioServer + t.Cleanup(func() { runMCPStdioServer = previousStdio }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + runMCPStdioServer = func(received context.Context) error { + if received != ctx { + t.Fatal("stdio runner received a different invocation context") + } + return received.Err() + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := runMCP(ctx, []string{"stdio"}, &stdout, &stderr); code != ExitCancelled || !strings.Contains(stderr.String(), `"code":"cancelled"`) { + t.Fatalf("stdio cancellation exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + }) + + t.Run("http graceful deadline shutdown", func(t *testing.T) { + previousHTTP := runMCPHTTPServer + t.Cleanup(func() { runMCPHTTPServer = previousHTTP }) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + runMCPHTTPServer = func(received context.Context, _ mcpserver.HTTPServerOptions) error { + if received != ctx { + t.Fatal("http runner received a different invocation context") + } + // The real HTTP server treats a context-triggered graceful shutdown as + // a nil server error, which must still map to ExitCancelled. + return nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := runMCP(ctx, []string{"http", "--token", "test-token"}, &stdout, &stderr); code != ExitCancelled || !strings.Contains(stderr.String(), `"code":"cancelled"`) { + t.Fatalf("http deadline exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + }) + + t.Run("ordinary server failure remains execution failure", func(t *testing.T) { + previousStdio := runMCPStdioServer + t.Cleanup(func() { runMCPStdioServer = previousStdio }) + runMCPStdioServer = func(context.Context) error { return errors.New("MCP transport failed") } + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := runMCP(context.Background(), nil, &stdout, &stderr); code != ExitExecution || !strings.Contains(stderr.String(), `"code":"mcp_failed"`) { + t.Fatalf("MCP failure exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + }) +} + +type failingWriter struct { + err error +} + +func (writer failingWriter) Write([]byte) (int, error) { + return 0, writer.err +} + +func TestRunReportsStdoutWriteFailureOnStderr(t *testing.T) { + fake := &fakeBackend{ + connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}}, + queryResult: connection.QueryResult{ + Success: true, + Data: []connection.ResultSetData{{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}}, + }, + } + previous := newBackend + newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) { + return fake, nil + } + t.Cleanup(func() { newBackend = previous }) + + var stderr bytes.Buffer + code := Run(context.Background(), []string{"query", "--conn", "production", "SELECT 1"}, failingWriter{err: errors.New("stdout sink unavailable")}, &stderr) + if code != ExitExecution { + t.Fatalf("query exit=%d stderr=%s, want execution exit=%d", code, stderr.String(), ExitExecution) + } + if !strings.Contains(stderr.String(), `"code":"output_failed"`) || !strings.Contains(stderr.String(), "stdout sink unavailable") { + t.Fatalf("stdout failure did not produce a structured stderr diagnostic: %s", stderr.String()) + } +} diff --git a/internal/cli/connection_file.go b/internal/cli/connection_file.go new file mode 100644 index 00000000..0382e67e --- /dev/null +++ b/internal/cli/connection_file.go @@ -0,0 +1,92 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + stdRuntime "runtime" + "strings" + + "GoNavi-Wails/internal/connection" +) + +const maxConnectionFileBytes = 1 << 20 + +var cliGOOS = func() string { + return stdRuntime.GOOS +} + +// loadTemporaryConnectionConfig reads one complete ConnectionConfig without +// touching the saved-connection repository. A connection-file is deliberately +// a raw ConnectionConfig so credentials never need to appear in argv. +func loadTemporaryConnectionConfig(path string) (connection.ConnectionConfig, error) { + path = strings.TrimSpace(path) + if path == "" { + return connection.ConnectionConfig{}, errors.New("connection file path is required") + } + + entry, err := os.Lstat(path) + if err != nil { + return connection.ConnectionConfig{}, err + } + if entry.Mode()&os.ModeSymlink != 0 { + return connection.ConnectionConfig{}, errors.New("connection file must not be a symbolic link") + } + if !entry.Mode().IsRegular() { + return connection.ConnectionConfig{}, errors.New("connection file must be a regular file") + } + if entry.Size() > maxConnectionFileBytes { + return connection.ConnectionConfig{}, fmt.Errorf("connection file exceeds %d bytes", maxConnectionFileBytes) + } + + file, err := os.Open(path) + if err != nil { + return connection.ConnectionConfig{}, err + } + defer file.Close() + opened, err := file.Stat() + if err != nil { + return connection.ConnectionConfig{}, err + } + if !os.SameFile(entry, opened) { + return connection.ConnectionConfig{}, errors.New("connection file changed while opening") + } + if err := validateConnectionFilePermissions(file, opened.Mode()); err != nil { + return connection.ConnectionConfig{}, err + } + + decoder := json.NewDecoder(io.LimitReader(file, maxConnectionFileBytes+1)) + decoder.DisallowUnknownFields() + var config connection.ConnectionConfig + if err := decoder.Decode(&config); err != nil { + return connection.ConnectionConfig{}, fmt.Errorf("decode connection file: %w", err) + } + if err := ensureOnlyOneJSONValue(decoder); err != nil { + return connection.ConnectionConfig{}, err + } + if strings.TrimSpace(config.ID) != "" { + return connection.ConnectionConfig{}, errors.New("connection file must not contain id") + } + if strings.TrimSpace(config.Type) == "" { + return connection.ConnectionConfig{}, errors.New("connection file requires type") + } + + // This config must remain entirely transient, even when it contains a + // password. An empty ID also keeps runtime secret resolution away from + // connections.json and the daily-secret store. + config.ID = "" + config.SavePassword = false + return config, nil +} + +func ensureOnlyOneJSONValue(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err == io.EOF { + return nil + } else if err != nil { + return fmt.Errorf("decode connection file: %w", err) + } + return errors.New("connection file must contain exactly one JSON object") +} diff --git a/internal/cli/connection_file_permissions_unix.go b/internal/cli/connection_file_permissions_unix.go new file mode 100644 index 00000000..848979f8 --- /dev/null +++ b/internal/cli/connection_file_permissions_unix.go @@ -0,0 +1,30 @@ +//go:build !windows + +package cli + +import ( + "errors" + "os" + "syscall" +) + +func validateConnectionFilePermissions(file *os.File, mode os.FileMode) error { + if mode.Perm()&0o077 != 0 { + return errors.New("connection file permissions must deny group and other access (for example chmod 600)") + } + if file == nil { + return errors.New("connection file handle is unavailable for owner validation") + } + info, err := file.Stat() + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("connection file owner could not be verified") + } + if uint64(stat.Uid) != uint64(os.Getuid()) { + return errors.New("connection file must be owned by the current user") + } + return nil +} diff --git a/internal/cli/connection_file_permissions_windows.go b/internal/cli/connection_file_permissions_windows.go new file mode 100644 index 00000000..1f5285fc --- /dev/null +++ b/internal/cli/connection_file_permissions_windows.go @@ -0,0 +1,83 @@ +//go:build windows + +package cli + +import ( + "errors" + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +func validateConnectionFilePermissions(file *os.File, _ os.FileMode) error { + if file == nil { + return errors.New("connection file handle is unavailable for ACL validation") + } + securityDescriptor, err := windows.GetSecurityInfo( + windows.Handle(file.Fd()), + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("read connection file ACL: %w", err) + } + owner, _, err := securityDescriptor.Owner() + if err != nil || owner == nil { + return errors.New("connection file ACL has no verifiable owner") + } + currentUser, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil || currentUser == nil || currentUser.User.Sid == nil { + return errors.New("connection file owner could not be compared with the current user") + } + if !owner.Equals(currentUser.User.Sid) { + return errors.New("connection file must be owned by the current user") + } + dacl, _, err := securityDescriptor.DACL() + if err != nil { + return fmt.Errorf("read connection file DACL: %w", err) + } + if dacl == nil { + return errors.New("connection file ACL grants unrestricted access") + } + + localSystem, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return fmt.Errorf("resolve LocalSystem SID: %w", err) + } + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return fmt.Errorf("resolve Administrators SID: %w", err) + } + allowedReaders := []*windows.SID{owner, localSystem, administrators} + readMask := windows.ACCESS_MASK(windows.GENERIC_READ | windows.GENERIC_ALL | windows.FILE_READ_DATA | windows.FILE_READ_EA) + + for index := uint16(0); index < dacl.AceCount; index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, uint32(index), &ace); err != nil { + return fmt.Errorf("read connection file ACL entry %d: %w", index, err) + } + if ace == nil || ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE || ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } + if ace.Mask&readMask == 0 { + continue + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return errors.New("connection file ACL contains an unsupported read grant") + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + allowed := false + for _, candidate := range allowedReaders { + if sid.Equals(candidate) { + allowed = true + break + } + } + if !allowed { + return fmt.Errorf("connection file ACL grants read access to %s", sid.String()) + } + } + return nil +} diff --git a/internal/connection/types.go b/internal/connection/types.go index 6acc01ee..bba92fa2 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -137,6 +137,7 @@ type ConnectionConfig struct { JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config runtimeDBOverride string // App-only selected database; never persisted or sent over RPC. runtimeDBOverrideSet bool // Distinguishes an explicit server-level override from no override. + resolvedSavedSnapshot bool // App-only marker for one lock-consistent metadata and secret snapshot. } // WithRuntimeDatabaseOverride carries a caller-selected database through runtime @@ -165,6 +166,21 @@ func (c ConnectionConfig) WithoutRuntimeDatabaseOverride() ConnectionConfig { return c } +// WithResolvedSavedSnapshot marks a config whose saved metadata and secrets +// were loaded together under the shared storage lock. The marker is not +// serialized and prevents a later execution layer from mixing in a newer +// secret bundle. +func (c ConnectionConfig) WithResolvedSavedSnapshot() ConnectionConfig { + c.resolvedSavedSnapshot = true + return c +} + +// HasResolvedSavedSnapshot reports whether the config already contains the +// complete saved connection snapshot required for one execution. +func (c ConnectionConfig) HasResolvedSavedSnapshot() bool { + return c.resolvedSavedSnapshot +} + // ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。 type ResultSetData struct { Rows []map[string]interface{} `json:"rows"` diff --git a/internal/dailysecret/store.go b/internal/dailysecret/store.go index 3b083ece..c17af217 100644 --- a/internal/dailysecret/store.go +++ b/internal/dailysecret/store.go @@ -2,9 +2,12 @@ package dailysecret import ( "encoding/json" + "errors" "os" "path/filepath" "strings" + + "GoNavi-Wails/internal/appdata" ) const ( @@ -92,6 +95,10 @@ func (s *Store) Path() string { } func (s *Store) Load() (File, error) { + return s.load() +} + +func (s *Store) load() (File, error) { if strings.TrimSpace(s.root) == "" { return File{SchemaVersion: schemaVersion}, nil } @@ -113,9 +120,42 @@ func (s *Store) Load() (File, error) { } func (s *Store) Save(file File) error { + return s.withWriteLock(func() error { + return s.saveUnlocked(file) + }) +} + +func (s *Store) withWriteLock(operation func() error) (resultErr error) { if strings.TrimSpace(s.root) == "" { return nil } + if err := os.MkdirAll(s.root, 0o700); err != nil { + return err + } + if err := os.Chmod(s.root, 0o700); err != nil { + return err + } + sharedLock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(s.root)) + if err != nil { + return err + } + defer func() { + resultErr = errors.Join(resultErr, sharedLock.Close()) + }() + fileLock, err := appdata.AcquireFileLock(s.Path() + ".lock") + if err != nil { + return err + } + defer func() { + resultErr = errors.Join(resultErr, fileLock.Close()) + }() + if operation == nil { + return nil + } + return operation() +} + +func (s *Store) saveUnlocked(file File) error { file.SchemaVersion = schemaVersion if len(file.Connections) == 0 { file.Connections = nil @@ -131,24 +171,88 @@ func (s *Store) Save(file File) error { } // 本文件以明文保存全部数据库/SSH/代理口令与 AI Provider 的 API Key,必须限制为仅属主可读。 // 目录同时收紧到 0o700,避免同机其他用户遍历目录。 - if err := os.MkdirAll(s.root, 0o700); err != nil { - return err - } payload, err := json.MarshalIndent(file, "", " ") if err != nil { return err } - if err := os.WriteFile(s.Path(), payload, 0o600); err != nil { + temporary, err := os.CreateTemp(s.root, ".daily-secrets-*.tmp") + if err != nil { return err } - // os.WriteFile 的权限参数只在创建新文件时生效;对历史上以 0o644 创建的文件必须显式收紧, - // 否则升级后的用户仍然暴露。Windows 上 Chmod 只影响只读位,此调用无实际副作用。 + temporaryPath := temporary.Name() + cleanupTemporary := true + defer func() { + if cleanupTemporary { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(payload); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := appdata.AtomicReplaceFile(temporaryPath, s.Path()); err != nil { + return err + } + cleanupTemporary = false + // Historical files may have been created with 0o644, so explicitly tighten + // the replaced target after the atomic write. On Windows Chmod only affects + // the read-only bit and is otherwise harmless. if err := os.Chmod(s.Path(), 0o600); err != nil && !os.IsNotExist(err) { return err } return nil } +// RestoreUnlocked restores a previously captured daily secret file while the +// caller holds SharedStorageLockPath(s.root). It is used to roll back a +// multi-connection import without releasing the cross-process critical +// section between metadata and secret restoration. +func (s *Store) RestoreUnlocked(exists bool, data []byte) error { + if !exists { + if err := os.Remove(s.Path()); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + var file File + if err := json.Unmarshal(data, &file); err != nil { + return err + } + return s.saveUnlocked(file) +} + +func (s *Store) update(mutator func(*File)) error { + return s.withWriteLock(func() error { + return s.updateUnlocked(mutator) + }) +} + +// updateUnlocked performs one read-modify-write operation while the caller +// already holds SharedStorageLockPath(s.root). It is intentionally kept +// separate so the saved-connection repository can update metadata and secrets +// under one cross-process critical section. +func (s *Store) updateUnlocked(mutator func(*File)) error { + file, err := s.load() + if err != nil { + return err + } + if mutator != nil { + mutator(&file) + } + return s.saveUnlocked(file) +} + func (s *Store) GetConnection(id string) (ConnectionBundle, bool, error) { file, err := s.Load() if err != nil { @@ -159,33 +263,54 @@ func (s *Store) GetConnection(id string) (ConnectionBundle, bool, error) { } func (s *Store) PutConnection(id string, bundle ConnectionBundle) error { - file, err := s.Load() - if err != nil { - return err - } - if !bundle.HasAny() { - return s.deleteConnectionFromFile(file, id) - } - if file.Connections == nil { - file.Connections = make(map[string]ConnectionBundle) - } - file.Connections[strings.TrimSpace(id)] = bundle - return s.Save(file) + return s.update(func(file *File) { + if !bundle.HasAny() { + deleteConnectionFromFile(file, id) + return + } + if file.Connections == nil { + file.Connections = make(map[string]ConnectionBundle) + } + file.Connections[strings.TrimSpace(id)] = bundle + }) +} + +// PutConnectionUnlocked updates one connection bundle while the caller holds +// SharedStorageLockPath(s.root). +func (s *Store) PutConnectionUnlocked(id string, bundle ConnectionBundle) error { + return s.updateUnlocked(func(file *File) { + if !bundle.HasAny() { + deleteConnectionFromFile(file, id) + return + } + if file.Connections == nil { + file.Connections = make(map[string]ConnectionBundle) + } + file.Connections[strings.TrimSpace(id)] = bundle + }) } func (s *Store) DeleteConnection(id string) error { - file, err := s.Load() - if err != nil { - return err - } - return s.deleteConnectionFromFile(file, id) + return s.update(func(file *File) { + deleteConnectionFromFile(file, id) + }) } -func (s *Store) deleteConnectionFromFile(file File, id string) error { +// DeleteConnectionUnlocked deletes one connection bundle while the caller +// holds SharedStorageLockPath(s.root). +func (s *Store) DeleteConnectionUnlocked(id string) error { + return s.updateUnlocked(func(file *File) { + deleteConnectionFromFile(file, id) + }) +} + +func deleteConnectionFromFile(file *File, id string) { + if file == nil { + return + } if len(file.Connections) != 0 { delete(file.Connections, strings.TrimSpace(id)) } - return s.Save(file) } func (s *Store) GetGlobalProxy() (GlobalProxyBundle, bool, error) { @@ -200,26 +325,20 @@ func (s *Store) GetGlobalProxy() (GlobalProxyBundle, bool, error) { } func (s *Store) PutGlobalProxy(bundle GlobalProxyBundle) error { - file, err := s.Load() - if err != nil { - return err - } - if !bundle.HasAny() { - file.GlobalProxy = nil - return s.Save(file) - } - copyBundle := bundle - file.GlobalProxy = ©Bundle - return s.Save(file) + return s.update(func(file *File) { + if !bundle.HasAny() { + file.GlobalProxy = nil + return + } + copyBundle := bundle + file.GlobalProxy = ©Bundle + }) } func (s *Store) DeleteGlobalProxy() error { - file, err := s.Load() - if err != nil { - return err - } - file.GlobalProxy = nil - return s.Save(file) + return s.update(func(file *File) { + file.GlobalProxy = nil + }) } func (s *Store) GetMCPHTTPServer() (MCPHTTPServerBundle, bool, error) { @@ -234,26 +353,20 @@ func (s *Store) GetMCPHTTPServer() (MCPHTTPServerBundle, bool, error) { } func (s *Store) PutMCPHTTPServer(bundle MCPHTTPServerBundle) error { - file, err := s.Load() - if err != nil { - return err - } - if !bundle.HasAny() { - file.MCPHTTPServer = nil - return s.Save(file) - } - copyBundle := bundle - file.MCPHTTPServer = ©Bundle - return s.Save(file) + return s.update(func(file *File) { + if !bundle.HasAny() { + file.MCPHTTPServer = nil + return + } + copyBundle := bundle + file.MCPHTTPServer = ©Bundle + }) } func (s *Store) DeleteMCPHTTPServer() error { - file, err := s.Load() - if err != nil { - return err - } - file.MCPHTTPServer = nil - return s.Save(file) + return s.update(func(file *File) { + file.MCPHTTPServer = nil + }) } func (s *Store) GetAIProvider(id string) (ProviderBundle, bool, error) { @@ -266,38 +379,36 @@ func (s *Store) GetAIProvider(id string) (ProviderBundle, bool, error) { } func (s *Store) PutAIProvider(id string, bundle ProviderBundle) error { - file, err := s.Load() - if err != nil { - return err - } - if !bundle.HasAny() { - return s.deleteAIProviderFromFile(file, id) - } - if file.AIProviders == nil { - file.AIProviders = make(map[string]ProviderBundle) - } - if len(bundle.SensitiveHeaders) > 0 { - cloned := make(map[string]string, len(bundle.SensitiveHeaders)) - for key, value := range bundle.SensitiveHeaders { - cloned[key] = value + return s.update(func(file *File) { + if !bundle.HasAny() { + deleteAIProviderFromFile(file, id) + return } - bundle.SensitiveHeaders = cloned - } - file.AIProviders[strings.TrimSpace(id)] = bundle - return s.Save(file) + if file.AIProviders == nil { + file.AIProviders = make(map[string]ProviderBundle) + } + if len(bundle.SensitiveHeaders) > 0 { + cloned := make(map[string]string, len(bundle.SensitiveHeaders)) + for key, value := range bundle.SensitiveHeaders { + cloned[key] = value + } + bundle.SensitiveHeaders = cloned + } + file.AIProviders[strings.TrimSpace(id)] = bundle + }) } func (s *Store) DeleteAIProvider(id string) error { - file, err := s.Load() - if err != nil { - return err - } - return s.deleteAIProviderFromFile(file, id) + return s.update(func(file *File) { + deleteAIProviderFromFile(file, id) + }) } -func (s *Store) deleteAIProviderFromFile(file File, id string) error { +func deleteAIProviderFromFile(file *File, id string) { + if file == nil { + return + } if len(file.AIProviders) != 0 { delete(file.AIProviders, strings.TrimSpace(id)) } - return s.Save(file) } diff --git a/internal/dailysecret/store_test.go b/internal/dailysecret/store_test.go index 2c1da9cf..603a499c 100644 --- a/internal/dailysecret/store_test.go +++ b/internal/dailysecret/store_test.go @@ -1,8 +1,15 @@ package dailysecret import ( + "encoding/json" + "os" + "path/filepath" "reflect" + "sync" "testing" + "time" + + "GoNavi-Wails/internal/appdata" ) func TestStorePutGetDeleteConnectionSecret(t *testing.T) { @@ -138,3 +145,119 @@ func TestStorePutGetDeleteAIProviderSecret(t *testing.T) { t.Fatal("expected provider bundle to be deleted") } } + +func TestStoreConcurrentWritersDoNotLoseConnectionBundles(t *testing.T) { + root := t.TempDir() + const total = 16 + var wg sync.WaitGroup + wg.Add(total) + for index := 0; index < total; index++ { + go func(index int) { + defer wg.Done() + store := NewStore(root) + id := "conn-" + string(rune('a'+index)) + if err := store.PutConnection(id, ConnectionBundle{Password: id + "-secret"}); err != nil { + t.Errorf("PutConnection(%s): %v", id, err) + } + }(index) + } + wg.Wait() + + payload, err := os.ReadFile(filepath.Join(root, fileName)) + if err != nil { + t.Fatalf("read daily secret file: %v", err) + } + var file File + if err := json.Unmarshal(payload, &file); err != nil { + t.Fatalf("daily secret file is not valid JSON: %v", err) + } + if len(file.Connections) != total { + t.Fatalf("connection bundle count = %d, want %d: %#v", len(file.Connections), total, file.Connections) + } + for index := 0; index < total; index++ { + id := "conn-" + string(rune('a'+index)) + if bundle, ok := file.Connections[id]; !ok || bundle.Password != id+"-secret" { + t.Errorf("bundle %s missing or changed: %#v ok=%v", id, bundle, ok) + } + } +} + +func TestStoreSaveUsesAtomicReplacementWithoutTemporaryFiles(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + if err := store.Save(File{Connections: map[string]ConnectionBundle{"conn": {Password: "secret"}}}); err != nil { + t.Fatalf("Save: %v", err) + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + for _, entry := range entries { + if filepath.Ext(entry.Name()) == ".tmp" { + t.Fatalf("temporary daily secret file left behind: %s", entry.Name()) + } + } +} + +func TestStorePutConnectionWaitsForExternalFileLock(t *testing.T) { + store := NewStore(t.TempDir()) + if err := os.MkdirAll(filepath.Dir(store.Path()), 0o700); err != nil { + t.Fatalf("create store directory: %v", err) + } + externalLock, err := appdata.AcquireFileLock(store.Path() + ".lock") + if err != nil { + t.Fatalf("acquire external daily-secret lock: %v", err) + } + defer externalLock.Close() + + finished := make(chan error, 1) + go func() { + finished <- store.PutConnection("locked", ConnectionBundle{Password: "secret"}) + }() + select { + case err := <-finished: + t.Fatalf("PutConnection acquired lock before external release: %v", err) + case <-time.After(50 * time.Millisecond): + } + if err := externalLock.Close(); err != nil { + t.Fatalf("release external daily-secret lock: %v", err) + } + select { + case err := <-finished: + if err != nil { + t.Fatalf("PutConnection after external lock release: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("PutConnection did not acquire lock after external release") + } +} + +func TestStorePutConnectionWaitsForSharedStorageLock(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + sharedLock, err := appdata.AcquireFileLock(appdata.SharedStorageLockPath(root)) + if err != nil { + t.Fatalf("acquire shared storage lock: %v", err) + } + + finished := make(chan error, 1) + go func() { + finished <- store.PutConnection("shared-locked", ConnectionBundle{Password: "secret"}) + }() + select { + case err := <-finished: + t.Fatalf("PutConnection acquired shared lock before external release: %v", err) + case <-time.After(50 * time.Millisecond): + } + if err := sharedLock.Close(); err != nil { + t.Fatalf("release shared storage lock: %v", err) + } + select { + case err := <-finished: + if err != nil { + t.Fatalf("PutConnection after shared lock release: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("PutConnection did not acquire shared lock after external release") + } +} diff --git a/internal/mcpserver/backend.go b/internal/mcpserver/backend.go index 6f16fc5c..f3716b1c 100644 --- a/internal/mcpserver/backend.go +++ b/internal/mcpserver/backend.go @@ -2,6 +2,8 @@ package mcpserver import ( "context" + "fmt" + "strings" "GoNavi-Wails/internal/ai" aiservice "GoNavi-Wails/internal/ai/service" @@ -26,9 +28,17 @@ type Backend interface { DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult - ExecuteSQLFromMCP(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult + ExecuteSQLFromMCP(context.Context, connection.ConnectionConfig, string, string) connection.QueryResult InspectSQL(dbType string, sql string) appcore.SQLInspection GetSQLSafetyLevel() ai.SQLPermissionLevel + AuthorizeSQLConnection(config connection.ConnectionConfig, sql string) error +} + +// executionAuthorizingBackend is intentionally optional for non-App backend +// implementations. Production AppBackend uses it to close the gap between the +// service's presentation-time policy check and database dispatch. +type executionAuthorizingBackend interface { + ExecuteAuthorizedSQLFromMCP(context.Context, string, connection.ConnectionConfig, string, string, bool) connection.QueryResult } // AppBackend 基于现有 internal/app.App 暴露 MCP 所需数据库能力。 @@ -37,13 +47,15 @@ type AppBackend struct { mcpQueryExecutor *appcore.MCPQueryExecutor } -func NewAppBackend(ctx context.Context) *AppBackend { +func NewAppBackend(ctx context.Context) (*AppBackend, error) { if ctx == nil { ctx = context.Background() } - a := appcore.NewApp() - appcore.InitializeLifecycle(a, ctx) - return &AppBackend{app: a, mcpQueryExecutor: appcore.NewMCPQueryExecutor(a)} + a, err := appcore.NewHeadlessApp(ctx, "") + if err != nil { + return nil, err + } + return &AppBackend{app: a, mcpQueryExecutor: appcore.NewMCPQueryExecutor(a)}, nil } func (b *AppBackend) Close(ctx context.Context) error { @@ -102,8 +114,30 @@ func (b *AppBackend) DBShowCreateTable(config connection.ConnectionConfig, dbNam return b.app.DBShowCreateTable(config, dbName, tableName) } -func (b *AppBackend) ExecuteSQLFromMCP(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult { - return b.mcpQueryExecutor.DBQueryMulti(config, dbName, query) +// ExecuteAuthorizedSQLFromMCP resolves the saved connection and checks its +// current protections immediately before dispatching SQL. The service's +// earlier display snapshot is not trusted for this authorization boundary. +func (b *AppBackend) ExecuteSQLFromMCP(ctx context.Context, config connection.ConnectionConfig, dbName string, query string) connection.QueryResult { + return b.executeAuthorizedSQLFromMCP(ctx, strings.TrimSpace(config.ID), config, dbName, query, true) +} + +// ExecuteAuthorizedSQLFromMCP is the explicit authorization-bound entry point +// used by Service. It re-reads the saved connection immediately before SQL is +// dispatched, closing the stale-view TOCTOU window. +func (b *AppBackend) ExecuteAuthorizedSQLFromMCP(ctx context.Context, connectionID string, config connection.ConnectionConfig, dbName string, query string, allowMutating bool) connection.QueryResult { + return b.executeAuthorizedSQLFromMCP(ctx, strings.TrimSpace(connectionID), config, dbName, query, allowMutating) +} + +func (b *AppBackend) executeAuthorizedSQLFromMCP(ctx context.Context, connectionID string, config connection.ConnectionConfig, dbName string, query string, allowMutating bool) connection.QueryResult { + if b == nil || b.mcpQueryExecutor == nil { + return connection.QueryResult{Success: false, Message: "MCP backend is unavailable"} + } + connectionID = strings.TrimSpace(connectionID) + if connectionID == "" { + return connection.QueryResult{Success: false, Message: "MCP saved connection ID is required"} + } + config.ID = connectionID + return b.mcpQueryExecutor.DBQueryMultiAuthorizedContext(ctx, config, dbName, query, allowMutating) } func (b *AppBackend) InspectSQL(dbType string, sql string) appcore.SQLInspection { @@ -124,3 +158,10 @@ func (b *AppBackend) GetSQLSafetyLevel() ai.SQLPermissionLevel { return ai.PermissionReadOnly } } + +func (b *AppBackend) AuthorizeSQLConnection(config connection.ConnectionConfig, sql string) error { + if b == nil || b.app == nil { + return fmt.Errorf("MCP backend is unavailable") + } + return b.app.AuthorizeMCPConnectionSQL(config, sql) +} diff --git a/internal/mcpserver/backend_test.go b/internal/mcpserver/backend_test.go new file mode 100644 index 00000000..16ebd8f3 --- /dev/null +++ b/internal/mcpserver/backend_test.go @@ -0,0 +1,23 @@ +package mcpserver + +import ( + "context" + "testing" +) + +func TestNewAppBackendInitializesWithoutGUI(t *testing.T) { + t.Setenv("GONAVI_DATA_ROOT", t.TempDir()) + backend, err := NewAppBackend(context.Background()) + if err != nil { + t.Fatalf("NewAppBackend returned error: %v", err) + } + if backend == nil { + t.Fatal("NewAppBackend returned nil backend") + } + if _, err := backend.GetSavedConnections(); err != nil { + t.Fatalf("headless backend could not read saved connections: %v", err) + } + if err := backend.Close(context.Background()); err != nil { + t.Fatalf("backend.Close returned error: %v", err) + } +} diff --git a/internal/mcpserver/run.go b/internal/mcpserver/run.go index b9368469..587b6d21 100644 --- a/internal/mcpserver/run.go +++ b/internal/mcpserver/run.go @@ -90,7 +90,10 @@ func RunAppStdioServer(ctx context.Context) error { ctx = context.Background() } - backend := NewAppBackend(ctx) + backend, err := NewAppBackend(ctx) + if err != nil { + return err + } defer backend.Close(ctx) return RunStdioServer(ctx, backend) @@ -112,7 +115,10 @@ func StartAppStreamableHTTPServer(ctx context.Context, options HTTPServerOptions ctx = context.Background() } - backend := NewAppBackend(ctx) + backend, err := NewAppBackend(ctx) + if err != nil { + return nil, err + } handle, err := StartStreamableHTTPServer(ctx, backend, options) if err != nil { _ = backend.Close(context.Background()) @@ -278,6 +284,9 @@ func normalizeHTTPServerOptions(options HTTPServerOptions) (HTTPServerOptions, e if options.Addr == "" { options.Addr = defaultStreamableHTTPAddr } + if err := validateLoopbackHTTPAddr(options.Addr); err != nil { + return HTTPServerOptions{}, err + } options.Path = strings.TrimSpace(options.Path) if options.Path == "" { options.Path = defaultStreamableHTTPPath @@ -292,6 +301,22 @@ func normalizeHTTPServerOptions(options HTTPServerOptions) (HTTPServerOptions, e return options, nil } +func validateLoopbackHTTPAddr(addr string) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("MCP HTTP address must include a loopback host and port: %w", err) + } + host = strings.Trim(host, "[]") + if strings.EqualFold(host, "localhost") { + return nil + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("MCP HTTP server must bind to loopback (127.0.0.1, ::1, or localhost), got %q", addr) + } + return nil +} + func bearerTokenAuthHandler(token string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if !hasBearerToken(req, token) { diff --git a/internal/mcpserver/run_test.go b/internal/mcpserver/run_test.go index c85b5b1d..875feb19 100644 --- a/internal/mcpserver/run_test.go +++ b/internal/mcpserver/run_test.go @@ -13,7 +13,7 @@ func TestParseHTTPServerOptionsSupportsFlagsAndEnvFallback(t *testing.T) { t.Setenv("GONAVI_MCP_HTTP_TOKEN", "env-token") options, err := ParseHTTPServerOptions([]string{ - "--addr", "0.0.0.0:8765", + "--addr", "127.0.0.1:8765", "--path", "mcp", "--token", "flag-token", "--schema-only", @@ -27,7 +27,7 @@ func TestParseHTTPServerOptionsSupportsFlagsAndEnvFallback(t *testing.T) { t.Fatalf("normalizeHTTPServerOptions returned error: %v", err) } - if normalized.Addr != "0.0.0.0:8765" { + if normalized.Addr != "127.0.0.1:8765" { t.Fatalf("expected addr from flag, got %q", normalized.Addr) } if normalized.Path != "/mcp" { @@ -44,6 +44,19 @@ func TestParseHTTPServerOptionsSupportsFlagsAndEnvFallback(t *testing.T) { } } +func TestNormalizeHTTPServerOptionsRejectsNonLoopbackAddresses(t *testing.T) { + for _, addr := range []string{"0.0.0.0:8765", ":8765", "192.0.2.10:8765", "[::]:8765"} { + if _, err := normalizeHTTPServerOptions(HTTPServerOptions{Addr: addr, Path: "/mcp", Token: "secret"}); err == nil { + t.Fatalf("normalizeHTTPServerOptions(%q) unexpectedly succeeded", addr) + } + } + for _, addr := range []string{"127.0.0.1:8765", "localhost:8765", "[::1]:8765"} { + if _, err := normalizeHTTPServerOptions(HTTPServerOptions{Addr: addr, Path: "/mcp", Token: "secret"}); err != nil { + t.Fatalf("normalizeHTTPServerOptions(%q) returned error: %v", addr, err) + } + } +} + func TestNormalizeHTTPServerOptionsRequiresBearerToken(t *testing.T) { _, err := normalizeHTTPServerOptions(HTTPServerOptions{Addr: "127.0.0.1:8765", Path: "/mcp"}) if err == nil || !strings.Contains(err.Error(), "bearer token") { diff --git a/internal/mcpserver/service.go b/internal/mcpserver/service.go index 6e6c15f5..7f8f9292 100644 --- a/internal/mcpserver/service.go +++ b/internal/mcpserver/service.go @@ -518,7 +518,6 @@ func (s *Service) GetTableDDL(ctx context.Context, req *mcp.CallToolRequest, arg } func (s *Service) ExecuteSQL(ctx context.Context, req *mcp.CallToolRequest, args executeSQLArgs) (*mcp.CallToolResult, executeSQLResult, error) { - _ = ctx _ = req view, errResult := s.resolveConnection(args.ConnectionID) @@ -535,6 +534,9 @@ func (s *Service) ExecuteSQL(ctx context.Context, req *mcp.CallToolRequest, args if inspection.StatementCount == 0 { return toolError("未识别到可执行的 SQL 语句"), executeSQLResult{}, nil } + if !isConsistentSQLInspection(inspection) { + return toolError("SQL 安全检查结果无效,已拒绝执行"), executeSQLResult{}, nil + } safetyLevel := normalizeSQLSafetyLevel(s.backend.GetSQLSafetyLevel()) safetyDecision := evaluateSQLSafety(safetyLevel, inspection) @@ -544,9 +546,12 @@ func (s *Service) ExecuteSQL(ctx context.Context, req *mcp.CallToolRequest, args if safetyDecision.requiresConfirm && !args.AllowMutating { return toolError("当前 SQL 已通过 GoNavi AI 安全控制(%s),但包含非只读语句 %s,请显式传入 allowMutating=true 后重试", safetyLevelDisplayName(safetyLevel), formatSafetyStatements(safetyDecision.confirmRequired)), executeSQLResult{}, nil } + if err := s.backend.AuthorizeSQLConnection(view.Config, sqlText); err != nil { + return toolError("连接写保护拒绝 SQL 执行: %s", strings.TrimSpace(err.Error())), executeSQLResult{}, nil + } dbName := effectiveDBName(args.DBName, view.Config) - queryResult := s.backend.ExecuteSQLFromMCP(view.Config, dbName, sqlText) + queryResult := s.executeAuthorizedSQL(ctx, view, dbName, sqlText, args.AllowMutating) if !queryResult.Success { return toolError("SQL 执行失败: %s", strings.TrimSpace(queryResult.Message)), executeSQLResult{}, nil } @@ -571,6 +576,13 @@ func (s *Service) ExecuteSQL(ctx context.Context, req *mcp.CallToolRequest, args return textResult(formatExecuteSQLResultContent(output)), output, nil } +func (s *Service) executeAuthorizedSQL(ctx context.Context, view connection.SavedConnectionView, dbName string, sqlText string, allowMutating bool) connection.QueryResult { + if backend, ok := s.backend.(executionAuthorizingBackend); ok { + return backend.ExecuteAuthorizedSQLFromMCP(ctx, view.ID, view.Config, dbName, sqlText, allowMutating) + } + return s.backend.ExecuteSQLFromMCP(ctx, view.Config, dbName, sqlText) +} + func successResult() *mcp.CallToolResult { return &mcp.CallToolResult{} } @@ -1142,6 +1154,22 @@ type sqlSafetyDecision struct { confirmRequired []sqlSafetyStatement } +func isConsistentSQLInspection(inspection appcore.SQLInspection) bool { + if inspection.StatementCount <= 0 || inspection.StatementCount != len(inspection.Statements) { + return false + } + readOnly := true + for index, statement := range inspection.Statements { + if statement.Index != index+1 { + return false + } + if !statement.ReadOnly { + readOnly = false + } + } + return inspection.ReadOnly == readOnly +} + func evaluateSQLSafety(level ai.SQLPermissionLevel, inspection appcore.SQLInspection) sqlSafetyDecision { decision := sqlSafetyDecision{ disallowed: []sqlSafetyStatement{}, diff --git a/internal/mcpserver/service_test.go b/internal/mcpserver/service_test.go index 619dd034..477c846c 100644 --- a/internal/mcpserver/service_test.go +++ b/internal/mcpserver/service_test.go @@ -2,6 +2,7 @@ package mcpserver import ( "context" + "errors" "strings" "testing" @@ -30,6 +31,12 @@ type fakeBackend struct { inspection appcore.SQLInspection safetyLevel ai.SQLPermissionLevel queryCalled bool + queryContext context.Context + authorizeErr error + authorizeCalls int + authorizedConfig connection.ConnectionConfig + authorizedSQL string + events []string } func (f *fakeBackend) Close(context.Context) error { @@ -84,8 +91,10 @@ func (f *fakeBackend) DBShowCreateTable(config connection.ConnectionConfig, dbNa return f.ddlResult } -func (f *fakeBackend) ExecuteSQLFromMCP(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult { +func (f *fakeBackend) ExecuteSQLFromMCP(ctx context.Context, config connection.ConnectionConfig, dbName string, query string) connection.QueryResult { f.queryCalled = true + f.queryContext = ctx + f.events = append(f.events, "query") return f.queryResult } @@ -100,6 +109,14 @@ func (f *fakeBackend) GetSQLSafetyLevel() ai.SQLPermissionLevel { return f.safetyLevel } +func (f *fakeBackend) AuthorizeSQLConnection(config connection.ConnectionConfig, sql string) error { + f.authorizeCalls++ + f.authorizedConfig = config + f.authorizedSQL = sql + f.events = append(f.events, "authorize") + return f.authorizeErr +} + func TestGetConnectionsReturnsSavedConnectionSummaries(t *testing.T) { backend := &fakeBackend{ savedConnections: []connection.SavedConnectionView{ @@ -643,6 +660,182 @@ func TestExecuteSQLAllowsDMLWhenAISafetyIsReadWriteAndAllowMutating(t *testing.T } } +func TestExecuteSQLRejectsConnectionWriteProtection(t *testing.T) { + backend := &fakeBackend{ + editableConnection: connection.SavedConnectionView{ + ID: "mysql-main", + Config: connection.ConnectionConfig{Type: "mysql", Database: "app"}, + }, + inspection: appcore.SQLInspection{ + StatementCount: 1, + ReadOnly: false, + Statements: []appcore.SQLStatementInspection{{Index: 1, Keyword: "update", ReadOnly: false}}, + }, + safetyLevel: ai.PermissionReadWrite, + authorizeErr: errors.New("data editing is disabled for this connection"), + } + + result, _, err := NewService(backend).ExecuteSQL(context.Background(), nil, executeSQLArgs{ + ConnectionID: "mysql-main", + SQL: "UPDATE users SET active = 1", + AllowMutating: true, + }) + if err != nil { + t.Fatalf("ExecuteSQL returned error: %v", err) + } + if result == nil || !result.IsError || backend.queryCalled { + t.Fatalf("connection protection should stop execution: result=%#v called=%t", result, backend.queryCalled) + } + if !strings.Contains(firstTextContent(result), "data editing is disabled") { + t.Fatalf("unexpected protection error: %q", firstTextContent(result)) + } + if backend.authorizeCalls != 1 { + t.Fatalf("connection authorization calls = %d, want 1", backend.authorizeCalls) + } +} + +func TestExecuteSQLAuthorizesExactlyOnceBeforeExecution(t *testing.T) { + tests := []struct { + name string + sql string + keyword string + readOnly bool + safetyLevel ai.SQLPermissionLevel + allowMutating bool + }{ + {name: "query", sql: "SELECT 1", keyword: "select", readOnly: true, safetyLevel: ai.PermissionReadOnly}, + {name: "DML", sql: "UPDATE users SET active = 1", keyword: "update", safetyLevel: ai.PermissionReadWrite, allowMutating: true}, + {name: "DDL", sql: "CREATE TABLE audit_probe(id INT)", keyword: "create", safetyLevel: ai.PermissionFull, allowMutating: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := connection.ConnectionConfig{ID: "postgres-main", Type: "postgres", Database: "app"} + backend := &fakeBackend{ + editableConnection: connection.SavedConnectionView{ID: config.ID, Config: config}, + inspection: appcore.SQLInspection{ + StatementCount: 1, + ReadOnly: test.readOnly, + Statements: []appcore.SQLStatementInspection{{Index: 1, Keyword: test.keyword, ReadOnly: test.readOnly}}, + }, + safetyLevel: test.safetyLevel, + queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{}}, + } + + result, _, err := NewService(backend).ExecuteSQL(context.Background(), nil, executeSQLArgs{ + ConnectionID: config.ID, + SQL: test.sql, + AllowMutating: test.allowMutating, + }) + if err != nil || result == nil || result.IsError { + t.Fatalf("ExecuteSQL result=%#v err=%v", result, err) + } + if backend.authorizeCalls != 1 || backend.authorizedConfig.ID != config.ID || backend.authorizedSQL != test.sql { + t.Fatalf("authorization calls=%d config=%#v sql=%q", backend.authorizeCalls, backend.authorizedConfig, backend.authorizedSQL) + } + if strings.Join(backend.events, ",") != "authorize,query" { + t.Fatalf("execution order = %v, want authorize before query", backend.events) + } + }) + } +} + +func TestExecuteSQLRejectsInconsistentSafetyInspection(t *testing.T) { + tests := []struct { + name string + inspection appcore.SQLInspection + }{ + { + name: "statement count mismatch", + inspection: appcore.SQLInspection{ + StatementCount: 1, + ReadOnly: true, + }, + }, + { + name: "aggregate read-only mismatch", + inspection: appcore.SQLInspection{ + StatementCount: 1, + ReadOnly: true, + Statements: []appcore.SQLStatementInspection{{Index: 1, Keyword: "update", ReadOnly: false}}, + }, + }, + { + name: "non-sequential statement index", + inspection: appcore.SQLInspection{ + StatementCount: 1, + ReadOnly: false, + Statements: []appcore.SQLStatementInspection{{Index: 2, Keyword: "update", ReadOnly: false}}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + backend := &fakeBackend{ + editableConnection: connection.SavedConnectionView{ + ID: "postgres-main", + Config: connection.ConnectionConfig{Type: "postgres", Database: "app"}, + }, + inspection: test.inspection, + safetyLevel: ai.PermissionFull, + queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{}}, + } + + result, _, err := NewService(backend).ExecuteSQL(context.Background(), nil, executeSQLArgs{ + ConnectionID: "postgres-main", + SQL: "UPDATE users SET active = 1", + AllowMutating: true, + }) + if err != nil { + t.Fatalf("ExecuteSQL returned error: %v", err) + } + if result == nil || !result.IsError || backend.authorizeCalls != 0 || backend.queryCalled { + t.Fatalf("inconsistent inspection crossed execution boundary: result=%#v authorize=%d query=%t", result, backend.authorizeCalls, backend.queryCalled) + } + if !strings.Contains(firstTextContent(result), "安全检查结果无效") { + t.Fatalf("unexpected error text: %q", firstTextContent(result)) + } + }) + } +} + +func TestExecuteSQLForwardsRequestContextToBackend(t *testing.T) { + backend := &fakeBackend{ + editableConnection: connection.SavedConnectionView{ + ID: "postgres-main", + Config: connection.ConnectionConfig{ + Type: "postgres", + Database: "app", + }, + }, + inspection: appcore.SQLInspection{ + StatementCount: 1, + ReadOnly: true, + Statements: []appcore.SQLStatementInspection{ + {Index: 1, Keyword: "select", ReadOnly: true}, + }, + }, + queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{}}, + } + + requestCtx, cancel := context.WithCancel(context.Background()) + cancel() + result, _, err := NewService(backend).ExecuteSQL(requestCtx, nil, executeSQLArgs{ + ConnectionID: "postgres-main", + SQL: "SELECT 1", + }) + if err != nil { + t.Fatalf("ExecuteSQL returned error: %v", err) + } + if result == nil || result.IsError || !backend.queryCalled { + t.Fatalf("ExecuteSQL did not reach the backend: result=%#v called=%t", result, backend.queryCalled) + } + if backend.queryContext == nil || backend.queryContext.Err() != context.Canceled { + t.Fatalf("backend request context = %v, want cancelled request context", backend.queryContext) + } +} + func TestExecuteSQLAllowsDDLWhenAISafetyIsFullAndAllowMutating(t *testing.T) { backend := &fakeBackend{ editableConnection: connection.SavedConnectionView{ diff --git a/npm/gonavi-cli/README.md b/npm/gonavi-cli/README.md new file mode 100644 index 00000000..c6177b18 --- /dev/null +++ b/npm/gonavi-cli/README.md @@ -0,0 +1,20 @@ +# @syngnat/gonavi-cli + +This package is published with the first stable GoNavi CLI release. It installs +the standalone `gonavi` executable for the current platform. The npm lifecycle +downloads the matching GoNavi Release archive and the independent +`gonavi-cli_${VERSION}_checksums.txt` file, verifies SHA256, checks the fixed +archive entries, and only then installs the executable. + +```bash +npm install -g @syngnat/gonavi-cli +gonavi list-connections +``` + +Before the first stable CLI release is published, install the CLI directly +from a release archive instead of the npm registry. + +The package does not store credentials or configure a separate data directory; +the executable keeps the normal `GONAVI_DATA_ROOT` and `~/.gonavi` resolution. +Set `GONAVI_CLI_RELEASE_BASE_URL` only when using a mirror that preserves the +same release asset names and checksum file. diff --git a/npm/gonavi-cli/bin/gonavi.js b/npm/gonavi-cli/bin/gonavi.js new file mode 100644 index 00000000..64f8e06f --- /dev/null +++ b/npm/gonavi-cli/bin/gonavi.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const binaryName = process.platform === 'win32' ? 'gonavi.exe' : 'gonavi'; +const binaryPath = path.join(__dirname, '.gonavi', binaryName); + +if (!fs.existsSync(binaryPath)) { + console.error('GoNavi CLI is not installed. Re-run npm install so its verified release asset can be downloaded.'); + process.exit(1); +} + +const child = spawn(binaryPath, process.argv.slice(2), { + stdio: 'inherit', + windowsHide: false, +}); + +child.on('error', (error) => { + console.error(`failed to start GoNavi CLI: ${error.message}`); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code === null ? 1 : code); +}); diff --git a/npm/gonavi-cli/install.js b/npm/gonavi-cli/install.js new file mode 100644 index 00000000..f774ad15 --- /dev/null +++ b/npm/gonavi-cli/install.js @@ -0,0 +1,216 @@ +#!/usr/bin/env node + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const http = require('node:http'); +const https = require('node:https'); + +const packageRoot = __dirname; +const packageJSON = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); +const version = String(process.env.GONAVI_CLI_VERSION || process.env.npm_package_version || packageJSON.version).trim(); +const releaseBase = String( + process.env.GONAVI_CLI_RELEASE_BASE_URL || `https://github.com/Syngnat/GoNavi/releases/download/v${version}`, +).replace(/\/+$/, ''); + +function fail(message) { + console.error(`[gonavi-cli] ${message}`); + process.exitCode = 1; +} + +function platformTarget() { + const platform = process.platform; + const architecture = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : ''; + if (!architecture || !['darwin', 'linux', 'win32'].includes(platform)) { + throw new Error(`unsupported platform or architecture: ${platform}/${process.arch}`); + } + const goos = platform === 'win32' ? 'windows' : platform; + const extension = platform === 'win32' ? 'zip' : 'tar.gz'; + const binary = platform === 'win32' ? 'gonavi.exe' : 'gonavi'; + return { + asset: `gonavi-cli_${version}_${goos}_${architecture}.${extension}`, + binary, + extension, + }; +} + +function requestBuffer(url, redirects = 0) { + if (redirects > 5) { + return Promise.reject(new Error('too many redirects while downloading a release asset')); + } + const client = url.startsWith('https:') ? https : http; + return new Promise((resolve, reject) => { + const request = client.get(url, { headers: { 'User-Agent': '@syngnat/gonavi-cli' } }, (response) => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume(); + const next = new URL(response.headers.location, url).toString(); + requestBuffer(next, redirects + 1).then(resolve, reject); + return; + } + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`release asset request returned HTTP ${response.statusCode}`)); + return; + } + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => resolve(Buffer.concat(chunks))); + response.on('error', reject); + }); + request.on('error', reject); + }); +} + +function expectedArchiveEntries(binary) { + return [binary, 'LICENSE', 'NOTICE'].sort(); +} + +function parseChecksums(text, asset) { + const matches = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/)) + .filter((match) => match && path.basename(match[2].trim()) === asset); + if (matches.length !== 1) { + throw new Error(`checksum file must contain exactly one entry for ${asset}`); + } + return matches[0][1].toLowerCase(); +} + +function assertSha256(data, expected, asset) { + const actual = crypto.createHash('sha256').update(data).digest('hex'); + if (actual !== expected) { + throw new Error(`SHA256 mismatch for ${asset}: expected ${expected}, got ${actual}`); + } +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }); + if (result.error || result.status !== 0) { + const detail = result.error ? result.error.message : String(result.stderr || '').trim(); + throw new Error(`${command} failed${detail ? `: ${detail}` : ''}`); + } + return String(result.stdout || ''); +} + +function archiveEntries(archive, extension, binary) { + let output; + if (extension === 'tar.gz') { + output = run('tar', ['-tzf', archive]); + } else { + try { + output = run('tar', ['-tf', archive]); + } catch (tarError) { + if (process.platform !== 'win32') { + throw tarError; + } + const escapePowerShell = (value) => value.replace(/'/g, "''"); + const command = [ + "Add-Type -AssemblyName System.IO.Compression.FileSystem", + `$zip = [System.IO.Compression.ZipFile]::OpenRead('${escapePowerShell(archive)}')`, + "$zip.Entries | ForEach-Object { $_.FullName }", + "$zip.Dispose()", + ].join('; '); + output = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command]); + } + } + const entries = output + .split(/\r?\n/) + .map((entry) => entry.replace(/^\.\//, '').trim()) + .filter(Boolean) + .sort(); + const expected = expectedArchiveEntries(binary); + if (entries.length !== expected.length || entries.some((entry, index) => entry !== expected[index])) { + throw new Error(`archive contents are invalid for ${path.basename(archive)}`); + } +} + +function validateExtractedArchive(destination, binary) { + const expected = expectedArchiveEntries(binary); + const entries = fs.readdirSync(destination).sort(); + if (entries.length !== expected.length || entries.some((entry, index) => entry !== expected[index])) { + throw new Error(`extracted archive contents are invalid for ${binary}`); + } + for (const entry of entries) { + const stat = fs.lstatSync(path.join(destination, entry)); + // The release contract contains regular top-level files only. lstat is + // deliberate: stat would follow a malicious symlink before installation. + if (!stat.isFile() || stat.nlink !== 1) { + throw new Error(`archive entry ${entry} must be a single regular file`); + } + } +} + +function extractArchive(archive, destination, extension) { + if (extension === 'tar.gz') { + run('tar', ['-xzf', archive, '-C', destination]); + return; + } + try { + run('tar', ['-xf', archive, '-C', destination]); + } catch (tarError) { + if (process.platform !== 'win32') { + throw tarError; + } + const escapePowerShell = (value) => value.replace(/'/g, "''"); + const command = `Expand-Archive -LiteralPath '${escapePowerShell(archive)}' -DestinationPath '${escapePowerShell(destination)}' -Force`; + run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command]); + } +} + +function installBinary(extracted, binary) { + const installDirectory = path.join(packageRoot, 'bin', '.gonavi'); + fs.mkdirSync(installDirectory, { recursive: true }); + const source = path.join(extracted, binary); + if (!fs.lstatSync(source).isFile()) { + throw new Error(`archive did not contain ${binary}`); + } + const temporary = path.join(installDirectory, `.${binary}.${process.pid}.tmp`); + fs.rmSync(temporary, { force: true }); + fs.copyFileSync(source, temporary, fs.constants.COPYFILE_EXCL); + if (process.platform !== 'win32') { + fs.chmodSync(temporary, 0o755); + } + const target = path.join(installDirectory, binary); + fs.rmSync(target, { force: true }); + fs.renameSync(temporary, target); + fs.writeFileSync(path.join(installDirectory, '.version'), `${version}\n`, { mode: 0o600 }); +} + +async function main() { + const target = platformTarget(); + const checksumName = `gonavi-cli_${version}_checksums.txt`; + const checksumText = await requestBuffer(`${releaseBase}/${checksumName}`); + const expected = parseChecksums(checksumText.toString('utf8'), target.asset); + const archive = await requestBuffer(`${releaseBase}/${target.asset}`); + assertSha256(archive, expected, target.asset); + + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gonavi-cli-install-')); + const archivePath = path.join(temporaryRoot, target.asset); + const extracted = path.join(temporaryRoot, 'extracted'); + try { + fs.mkdirSync(extracted); + fs.writeFileSync(archivePath, archive, { mode: 0o600 }); + archiveEntries(archivePath, target.extension, target.binary); + extractArchive(archivePath, extracted, target.extension); + validateExtractedArchive(extracted, target.binary); + installBinary(extracted, target.binary); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } + console.log(`[gonavi-cli] installed ${target.asset} (SHA256 verified)`); +} + +if (require.main === module) { + main().catch((error) => fail(error instanceof Error ? error.message : String(error))); +} + +module.exports = { + assertSha256, + validateExtractedArchive, + parseChecksums, + platformTarget, +}; diff --git a/npm/gonavi-cli/package.json b/npm/gonavi-cli/package.json new file mode 100644 index 00000000..937ba2d1 --- /dev/null +++ b/npm/gonavi-cli/package.json @@ -0,0 +1,29 @@ +{ + "name": "@syngnat/gonavi-cli", + "version": "0.9.3", + "description": "Verified platform wrapper for the GoNavi headless CLI", + "bin": { + "gonavi": "bin/gonavi.js" + }, + "files": [ + "bin", + "install.js", + "README.md" + ], + "scripts": { + "postinstall": "node install.js" + }, + "engines": { + "node": ">=18" + }, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/Syngnat/GoNavi.git", + "directory": "npm/gonavi-cli" + }, + "homepage": "https://github.com/Syngnat/GoNavi", + "publishConfig": { + "access": "public" + } +} diff --git a/packaging/winget/README.md b/packaging/winget/README.md new file mode 100644 index 00000000..6dea4571 --- /dev/null +++ b/packaging/winget/README.md @@ -0,0 +1,18 @@ +# GoNavi CLI WinGet manifest + +The standalone CLI uses the separate package identifier +`Syngnat.GoNavi.CLI`; the existing desktop GoNavi package is not changed. + +Generate the manifest only from a stable release's independent checksum file: + +```bash +python3 tools/generate-winget-cli-manifest.py \ + --version 0.9.3 \ + --checksums cli-assets/gonavi-cli_0.9.3_checksums.txt \ + --output Syngnat.GoNavi.CLI.yaml +``` + +The generator requires exactly the six CLI archive entries and copies only the +Windows x64/arm64 hashes into the manifest. Submit the generated file to the +WinGet community repository after the corresponding immutable release assets +are available; do not hand-edit installer URLs or SHA256 values. diff --git a/tools/cli-release-assets.test.py b/tools/cli-release-assets.test.py new file mode 100644 index 00000000..a83473b4 --- /dev/null +++ b/tools/cli-release-assets.test.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Static contract checks for CLI release assets and distribution gates.""" + +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +GITHUB_EXPRESSION = re.compile(r"\$\{\{.*?\}\}") + + +def extract_workflow_run_script(source: str, step_name: str) -> str: + """Return one literal run block without requiring a YAML dependency.""" + lines = source.splitlines() + marker = f"- name: {step_name}" + matching_lines = [index for index, line in enumerate(lines) if line.strip() == marker] + if len(matching_lines) != 1: + raise AssertionError( + f"expected exactly one workflow step named {step_name!r}, found {len(matching_lines)}" + ) + + step_index = matching_lines[0] + step_indent = len(lines[step_index]) - len(lines[step_index].lstrip()) + run_index = None + for index in range(step_index + 1, len(lines)): + line = lines[index] + stripped = line.lstrip() + indent = len(line) - len(stripped) + if stripped and indent <= step_indent: + break + if stripped == "run: |": + run_index = index + break + if run_index is None: + raise AssertionError(f"workflow step {step_name!r} has no literal run block") + + run_indent = len(lines[run_index]) - len(lines[run_index].lstrip()) + body: list[str] = [] + for line in lines[run_index + 1 :]: + stripped = line.lstrip() + indent = len(line) - len(stripped) + if stripped and indent <= run_indent: + break + body.append(line) + script = textwrap.dedent("\n".join(body)) + "\n" + return GITHUB_EXPRESSION.sub("GITHUB_EXPRESSION", script) + + +class CLIReleaseAssetsTest(unittest.TestCase): + def test_stable_and_dev_workflows_publish_the_same_six_archive_names(self) -> None: + expected_tokens = ( + "gonavi-cli_${version}_${{ matrix.goos }}_${{ matrix.goarch }}.${{ matrix.extension }}", + 'extension: tar.gz', + 'extension: zip', + ) + for name in ("release.yml", "dev-build.yml"): + source = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") + for token in expected_tokens: + self.assertIn(token, source, f"{token!r} missing from {name}") + self.assertIn("github.com/rhysd/actionlint/cmd/actionlint@v1.7.12", source) + self.assertIn(".github/workflows/publish-release.yml", source) + self.assertIn("python3 tools/cli-release-assets.test.py", source) + self.assertIn("python3 tools/validate-npm-cli-package-version.test.py", source) + self.assertIn("CLI artifact set is invalid", source) + self.assertIn("CLI release asset set is invalid", source) + self.assertIn("CLI checksum file contents are invalid", source) + self.assertIn("find cli-assets -type f -printf '%P\\n' | sort", source) + self.assertIn("find cli-assets -maxdepth 1 -type f -name 'gonavi-cli_*'", source) + self.assertIn('actual_entries="$(unzip -Z1 "$asset" | sort)"', source) + self.assertIn('actual_entries="$(tar -tzf "$asset" | sed', source) + self.assertIn('gonavi-cli_${version}_checksums.txt', source) + self.assertIn('(cd cli-assets && sha256sum "${expected[@]}"', source) + self.assertIn('sha256sum --check "$cli_checksum_name"', source) + self.assertIn( + './tools/generate-driver-agent-revisions.sh --platform "${{ matrix.goos }}/${{ matrix.goarch }}"', + source, + ) + self.assertIn("--component gui", source) + self.assertIn("--assets-dir release-assets", source) + self.assertIn("release-assets/*", source) + self.assertIn("cli-assets/*", source) + self.assertNotIn( + 'install -m 0644 "cli-assets/${asset}" "release-assets/${asset}"', + source, + ) + self.assertNotIn("xattr -cr", source) + if name == "dev-build.yml": + self.assertIn('SHORT_SHA="${GITHUB_SHA:0:7}"', source) + self.assertNotIn("git rev-parse --short HEAD", source) + + def test_release_workflow_bash_blocks_are_syntax_checked(self) -> None: + workflow_steps = { + "release.yml": ( + "Build and package CLI", + "Package macOS DMG", + "Validate CLI artifact staging", + "Generate CLI checksums", + "Generate SHA256SUMS", + "Verify CLI release assets", + "Generate static update manifest (latest.json)", + ), + "dev-build.yml": ( + "Build and package dev CLI", + "Package macOS DMG", + "Validate dev CLI artifact staging", + "Generate dev CLI checksums", + "Generate SHA256SUMS", + "Verify dev CLI release assets", + "Generate static update manifest (latest-dev.json)", + ), + "publish-release.yml": ( + "Prepare and verify stable mirror payload", + "Verify public CLI release assets for npm postinstall", + "Publish npm CLI package", + "Verify npm CLI package metadata", + "Generate and retain WinGet CLI manifest", + ), + } + for workflow_name, step_names in workflow_steps.items(): + source = (ROOT / ".github" / "workflows" / workflow_name).read_text(encoding="utf-8") + for step_name in step_names: + script = extract_workflow_run_script(source, step_name) + result = subprocess.run( + ["bash", "-n"], + input=script, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"invalid bash in {workflow_name} step {step_name!r}:\n{result.stderr}", + ) + + def test_gui_manifest_input_never_contains_cli_assets(self) -> None: + workflow_steps = { + "release.yml": ( + "Validate CLI artifact staging", + "Generate static update manifest (latest.json)", + ), + "dev-build.yml": ( + "Validate dev CLI artifact staging", + "Generate static update manifest (latest-dev.json)", + ), + } + for workflow_name, (staging_step, manifest_step) in workflow_steps.items(): + source = (ROOT / ".github" / "workflows" / workflow_name).read_text(encoding="utf-8") + staging_script = extract_workflow_run_script(source, staging_step) + manifest_script = extract_workflow_run_script(source, manifest_step) + + self.assertIn("cli-assets", staging_script) + self.assertNotIn("release-assets", staging_script) + self.assertIn("--assets-dir release-assets", manifest_script) + self.assertNotIn("cli-assets", manifest_script) + + def test_checksum_generation_keeps_cli_staging_separate(self) -> None: + cases = ( + ( + "release.yml", + "Generate CLI checksums", + "1.2.3", + {"GITHUB_REF_NAME": "v1.2.3"}, + ), + ( + "dev-build.yml", + "Generate dev CLI checksums", + "dev-a1b2c3d", + {"GITHUB_SHA": "a1b2c3d4567890"}, + ), + ) + for workflow_name, checksum_step, version, extra_env in cases: + source = (ROOT / ".github" / "workflows" / workflow_name).read_text(encoding="utf-8") + checksum_script = extract_workflow_run_script(source, checksum_step) + global_script = extract_workflow_run_script(source, "Generate SHA256SUMS") + archives = ( + f"gonavi-cli_{version}_darwin_amd64.tar.gz", + f"gonavi-cli_{version}_darwin_arm64.tar.gz", + f"gonavi-cli_{version}_linux_amd64.tar.gz", + f"gonavi-cli_{version}_linux_arm64.tar.gz", + f"gonavi-cli_{version}_windows_amd64.zip", + f"gonavi-cli_{version}_windows_arm64.zip", + ) + + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + gui_dir = root / "release-assets" + cli_dir = root / "cli-assets" + gui_dir.mkdir() + cli_dir.mkdir() + (gui_dir / f"GoNavi-{version}-Linux-Amd64.tar.gz").write_bytes(b"gui") + for archive in archives: + (cli_dir / archive).write_bytes(archive.encode("ascii")) + + environment = os.environ.copy() + environment.update(extra_env) + for script in (checksum_script, global_script): + result = subprocess.run( + ["bash"], + input=script, + cwd=root, + env=environment, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"failed to execute {workflow_name} checksum script:\n{result.stderr}", + ) + + cli_checksum_name = f"gonavi-cli_{version}_checksums.txt" + self.assertTrue((cli_dir / cli_checksum_name).is_file()) + self.assertFalse((gui_dir / cli_checksum_name).exists()) + self.assertFalse(any(path.name.startswith("gonavi-cli_") for path in gui_dir.iterdir())) + global_names = { + line.split(maxsplit=1)[1] + for line in (gui_dir / "SHA256SUMS").read_text(encoding="ascii").splitlines() + } + self.assertEqual( + global_names, + { + f"GoNavi-{version}-Linux-Amd64.tar.gz", + cli_checksum_name, + *archives, + }, + ) + + def test_stable_workflow_requires_notarized_production_signing(self) -> None: + source = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + for token in ( + "MACOS_SIGNING_CERTIFICATE_P12", + "MACOS_SIGNING_IDENTITY", + "Developer ID Application", + "APPLE_TEAM_ID", + "verify_team_identifier", + "TeamIdentifier", + "codesign --force --deep --options runtime --timestamp", + "CFBundleShortVersionString", + "CFBundleVersion", + 'codesign --force --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$DMG_NAME"', + 'codesign --verify --deep --strict --verbose=4 "$APP_NAME"', + 'codesign --verify --verbose=4 "$DMG_NAME"', + "xcrun notarytool submit", + "--output-format json", + 'status != "Accepted"', + "xcrun stapler staple", + "xcrun stapler validate", + 'spctl -a -t exec -vv "$PACKAGED_APP"', + "spctl --assess", + 'APP_PATH="./GoNavi.app"', + 'PACKAGED_APP="$VERIFY_MOUNT_DIR/GoNavi.app"', + 'PACKAGED_INFO_PLIST="$PACKAGED_APP/Contents/Info.plist"', + ): + self.assertIn(token, source) + self.assertLess( + source.index('Set :CFBundleShortVersionString ${VERSION}'), + source.index('codesign --force --deep --options runtime --timestamp'), + ) + self.assertLess( + source.index('codesign --force --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$DMG_NAME"'), + source.index("xcrun notarytool submit"), + ) + self.assertLess( + source.index("xcrun stapler validate"), + source.rindex('codesign --verify --verbose=4 "$DMG_NAME"'), + ) + + def test_dev_workflow_requires_fixed_dmg_bundle_name(self) -> None: + source = (ROOT / ".github" / "workflows" / "dev-build.yml").read_text(encoding="utf-8") + self.assertIn('APP_PATH="./GoNavi.app"', source) + self.assertIn('PACKAGED_APP="$VERIFY_MOUNT_DIR/GoNavi.app"', source) + self.assertIn("tools/validate-gui-update-manifest.py", source) + self.assertIn("--channel dev", source) + + def test_wails_config_has_a_non_default_product_version(self) -> None: + config = json.loads((ROOT / "wails.json").read_text(encoding="utf-8")) + product_version = config["info"]["productVersion"] + self.assertRegex(product_version, r"^\d+\.\d+\.\d+$") + self.assertNotEqual(product_version, "1.0.0") + + def test_publish_workflow_keeps_cli_separate_from_gui_manifest(self) -> None: + source = (ROOT / ".github" / "workflows" / "publish-release.yml").read_text(encoding="utf-8") + self.assertIn("gonavi-cli_${version}_darwin_amd64.tar.gz", source) + self.assertIn("gonavi-cli_${version}_checksums.txt", source) + self.assertIn("CLI checksums disagree", source) + self.assertIn("Release assets are not an exact contract", source) + self.assertIn("CLI checksum file is missing from the GitHub Release", source) + self.assertIn("CLI checksum file contents are invalid", source) + self.assertIn("tools/validate-gui-update-manifest.py", source) + self.assertIn("--channel stable", source) + self.assertIn("const requiredAssets = [", source) + self.assertIn("const optionalAssets = [", source) + self.assertIn("GoNavi-${version}-Linux-Amd64.AppImage", source) + self.assertIn("GoNavi-${version}-Linux-Amd64-WebKit41.AppImage", source) + self.assertIn("const allowedAssetNames = new Set([...requiredAssets, ...optionalAssets])", source) + self.assertNotIn("release.assets.length !== expectedAssets.length", source) + self.assertIn("ref: ${{ steps.validate.outputs.tag }}", source) + self.assertIn("NPM_TOKEN secret is required", source) + self.assertIn("Validate npm publication credentials", source) + self.assertIn("npm publish npm/gonavi-cli --access public --ignore-scripts", source) + self.assertIn('npm view "@syngnat/gonavi-cli@${version}" --json', source) + self.assertIn("Upload WinGet CLI manifest artifact", source) + self.assertIn("actions/upload-artifact@v6", source) + self.assertIn("winget-cli-manifest-${{ steps.validate.outputs.tag }}", source) + self.assertLess( + source.index("Verify GitHub release is published and latest"), + source.index("Publish npm CLI package"), + ) + self.assertLess( + source.index("Verify npm CLI package metadata"), + source.index("Mirror stable release to Gatewaysentry"), + ) + + def test_stable_release_validates_npm_cli_version_before_build(self) -> None: + source = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + self.assertIn( + 'python3 tools/validate-npm-cli-package-version.py --tag "$GITHUB_REF_NAME"', + source, + ) + + def test_cli_container_uses_data_volume_and_help_entrypoint(self) -> None: + dockerfile = (ROOT / "Dockerfile.cli").read_text(encoding="utf-8") + compose = (ROOT / "docker-compose.cli.yml").read_text(encoding="utf-8") + workflow = (ROOT / ".github" / "workflows" / "docker-images.yml").read_text(encoding="utf-8") + self.assertIn('ENTRYPOINT ["/usr/local/bin/gonavi"]', dockerfile) + self.assertIn('VOLUME ["/data"]', dockerfile) + self.assertIn("GONAVI_DATA_ROOT=/data", dockerfile) + self.assertIn("ARG VERSION=dev", dockerfile) + self.assertIn('ARG TARGETOS', dockerfile) + self.assertIn('ARG TARGETARCH', dockerfile) + self.assertIn( + './tools/generate-driver-agent-revisions.sh --platform "${TARGETOS}/${TARGETARCH}"', + dockerfile, + ) + self.assertIn("GoNavi-Wails/internal/cli.Version=${VERSION}", dockerfile) + self.assertIn("GONAVI_DATA_ROOT: /data", compose) + self.assertIn("GONAVI_LOG_DIR: /data/logs", compose) + self.assertIn("HOME: /data", compose) + self.assertIn("GONAVI_CONTAINER_UID", compose) + self.assertIn("GONAVI_CONTAINER_GID", compose) + self.assertIn("VERSION=${{ steps.prep.outputs.version }}", workflow) + self.assertIn('--user "$(id -u):$(id -g)"', workflow) + self.assertIn("-e GONAVI_LOG_DIR=/data/logs", workflow) + self.assertIn('> "$data_dir/connections.json"', workflow) + self.assertIn('"id":"cli-docker-smoke"', workflow) + self.assertNotIn("--data-root /data list-connections", workflow) + self.assertNotIn('chmod 0777 "$data_dir"', workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/generate-update-latest-manifest.py b/tools/generate-update-latest-manifest.py index be839bd9..1b5a25a1 100755 --- a/tools/generate-update-latest-manifest.py +++ b/tools/generate-update-latest-manifest.py @@ -47,6 +47,27 @@ SKIP_NAMES = { ".DS_Store", } +# GitHub release assets share one flat namespace. Keep the desktop updater's +# manifest deliberately narrow so headless CLI archives cannot become update +# candidates merely because they were uploaded beside the GUI packages. +GUI_ASSET_PATTERNS = ( + re.compile(r"^GoNavi-[A-Za-z0-9][A-Za-z0-9._-]*-MacOS-(?:Amd64|Arm64)\.dmg$"), + re.compile( + r"^GoNavi-[A-Za-z0-9][A-Za-z0-9._-]*-Windows-" + r"(?:Amd64|Arm64)-(?:Installer\.msi|Portable\.(?:exe|zip))$" + ), + re.compile( + r"^GoNavi-[A-Za-z0-9][A-Za-z0-9._-]*-Linux-" + r"(?:Amd64(?:-WebKit41)?\.(?:tar\.gz|AppImage)|Arm64\.tar\.gz)$" + ), +) +CLI_ASSET_PATTERNS = ( + re.compile( + r"^gonavi-cli_[A-Za-z0-9][A-Za-z0-9.-]*_(?:darwin|linux)_(?:amd64|arm64)\.tar\.gz$" + ), + re.compile(r"^gonavi-cli_[A-Za-z0-9][A-Za-z0-9.-]*_windows_(?:amd64|arm64)\.zip$"), +) + def load_release_notes(path: Path | None) -> str: if path is None: @@ -112,7 +133,29 @@ def collect_assets( hashes: dict[str, str], download_base_url: str = "", download_tag: str = "", + component: str = "gui", + version: str = "", ) -> list[dict]: + if component not in {"gui", "cli"}: + raise ValueError(f"unsupported manifest component: {component}") + # The release directory can contain assets from more than one build or a + # stale file left by a previous job. The manifest must only describe the + # exact version it was generated for, in addition to the component + # allowlist. + normalized_version = normalize_version(version) or normalize_version(tag) + if not normalized_version: + raise ValueError("manifest asset version is required") + escaped_version = re.escape(normalized_version) + if component == "gui": + patterns = tuple( + re.compile(pattern.pattern.replace("[A-Za-z0-9][A-Za-z0-9._-]*", escaped_version)) + for pattern in GUI_ASSET_PATTERNS + ) + else: + patterns = tuple( + re.compile(pattern.pattern.replace("[A-Za-z0-9][A-Za-z0-9.-]*", escaped_version)) + for pattern in CLI_ASSET_PATTERNS + ) assets: list[dict] = [] for path in sorted(assets_dir.iterdir()): if not path.is_file(): @@ -122,6 +165,8 @@ def collect_assets( continue if name.startswith("."): continue + if not any(pattern.fullmatch(name) for pattern in patterns): + continue github_url = browser_download_url(tag, name) item = { "name": name, @@ -150,16 +195,26 @@ def build_manifest( download_base_url: str = "", download_tag: str = "", release_notes: str = "", + component: str = "gui", ) -> dict: hashes = parse_sha256sums(assets_dir / "SHA256SUMS") tag = tag.strip() or f"v{normalize_version(version)}" version = normalize_version(version) or normalize_version(tag) - assets = collect_assets(assets_dir, tag, hashes, download_base_url, download_tag) + assets = collect_assets( + assets_dir, + tag, + hashes, + download_base_url, + download_tag, + component, + version, + ) if not assets: raise SystemExit(f"no release assets found under {assets_dir}") payload = { "schemaVersion": SCHEMA_VERSION, + "component": component, "channel": channel, "tagName": tag, "version": version, @@ -185,6 +240,12 @@ def main() -> int: default="latest", help="Update channel (default: latest)", ) + parser.add_argument( + "--component", + choices=("gui", "cli"), + default="gui", + help="Asset component to include (default: gui; CLI is not consumed by the desktop updater)", + ) parser.add_argument("--name", default="", help="Release display name") parser.add_argument("--published-at", default="", help="ISO8601 published time") parser.add_argument( @@ -237,6 +298,7 @@ def main() -> int: download_base_url=args.download_base_url, download_tag=args.download_tag, release_notes=release_notes, + component=args.component, ) output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") notes_hint = f", notes={len(manifest.get('releaseNotes', ''))} chars" if manifest.get("releaseNotes") else "" diff --git a/tools/generate-update-latest-manifest.test.py b/tools/generate-update-latest-manifest.test.py index d67f74f4..37e3c356 100644 --- a/tools/generate-update-latest-manifest.test.py +++ b/tools/generate-update-latest-manifest.test.py @@ -96,6 +96,107 @@ class GenerateUpdateLatestManifestTest(unittest.TestCase): self.assertNotIn("LICENSE", [a["name"] for a in data["assets"]]) self.assertNotIn("NOTICE", [a["name"] for a in data["assets"]]) + def test_gui_manifest_excludes_cli_archives_and_unrecognized_files(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + assets = Path(tmp) + gui_name = "GoNavi-1.2.3-MacOS-Arm64.dmg" + cli_name = "gonavi-cli_1.2.3_darwin_arm64.tar.gz" + unexpected_name = "GoNavi-1.2.3-Linux-Amd64-unknown.bin" + for name in (gui_name, cli_name, unexpected_name): + (assets / name).write_bytes(name.encode("ascii")) + (assets / "SHA256SUMS").write_text( + f"{'a' * 64} {gui_name}\n", + encoding="utf-8", + ) + out = assets / "latest.json" + subprocess.check_call( + [ + sys.executable, + str(SCRIPT), + "--assets-dir", + str(assets), + "--version", + "1.2.3", + "--tag", + "v1.2.3", + "--channel", + "latest", + "--component", + "gui", + "--output", + str(out), + ], + cwd=str(ROOT), + ) + data = json.loads(out.read_text(encoding="utf-8")) + self.assertEqual(data["component"], "gui") + self.assertEqual([asset["name"] for asset in data["assets"]], [gui_name]) + self.assertEqual(data["assets"][0]["sha256"], "a" * 64) + + def test_gui_manifest_excludes_assets_from_other_versions(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + assets = Path(tmp) + current = "GoNavi-1.2.3-MacOS-Arm64.dmg" + stale = "GoNavi-1.2.2-MacOS-Arm64.dmg" + (assets / current).write_bytes(b"current") + (assets / stale).write_bytes(b"stale") + out = assets / "latest.json" + subprocess.check_call( + [ + sys.executable, + str(SCRIPT), + "--assets-dir", + str(assets), + "--version", + "1.2.3", + "--tag", + "v1.2.3", + "--channel", + "latest", + "--output", + str(out), + ], + cwd=str(ROOT), + ) + data = json.loads(out.read_text(encoding="utf-8")) + self.assertEqual(data["component"], "gui") + self.assertEqual([asset["name"] for asset in data["assets"]], [current]) + + def test_cli_manifest_accepts_only_cli_archive_contract(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + assets = Path(tmp) + cli_names = ( + "gonavi-cli_1.2.3_darwin_amd64.tar.gz", + "gonavi-cli_1.2.3_windows_arm64.zip", + ) + for name in cli_names: + (assets / name).write_bytes(name.encode("ascii")) + (assets / "gonavi-cli_1.2.3_linux_amd64.exe").write_bytes(b"invalid") + (assets / "SHA256SUMS").write_text("", encoding="ascii") + out = assets / "latest-cli.json" + subprocess.check_call( + [ + sys.executable, + str(SCRIPT), + "--assets-dir", + str(assets), + "--version", + "1.2.3", + "--tag", + "v1.2.3", + "--channel", + "latest", + "--component", + "cli", + "--output", + str(out), + ], + cwd=str(ROOT), + ) + data = json.loads(out.read_text(encoding="utf-8")) + self.assertEqual(data["component"], "cli") + self.assertEqual([asset["name"] for asset in data["assets"]], list(cli_names)) + def test_dev_manifest_keeps_github_tag_but_uses_unique_mirror_tag(self) -> None: with tempfile.TemporaryDirectory() as tmp: assets = Path(tmp) diff --git a/tools/generate-winget-cli-manifest.py b/tools/generate-winget-cli-manifest.py new file mode 100644 index 00000000..df94c869 --- /dev/null +++ b/tools/generate-winget-cli-manifest.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Generate a WinGet manifest for the standalone GoNavi CLI. + +The input is the independent CLI checksum file published with a stable +release. No platform hash is accepted from command-line text, which keeps +the generated manifest tied to the release's signed asset contract. +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + + +CLI_ASSETS = { + "x64": "gonavi-cli_{version}_windows_amd64.zip", + "arm64": "gonavi-cli_{version}_windows_arm64.zip", +} +CHECKSUM_NAME = "gonavi-cli_{version}_checksums.txt" +BINARY_NAME = "gonavi.exe" +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +CHECKSUM_RE = re.compile(r"^([0-9a-fA-F]{64})\s+\*?(.+)$") + + +def load_checksums(path: Path, version: str) -> dict[str, str]: + expected_checksum_name = CHECKSUM_NAME.format(version=version) + if path.name != expected_checksum_name: + raise ValueError(f"checksum file must be named {expected_checksum_name}") + hashes: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line: + continue + match = CHECKSUM_RE.fullmatch(line) + if not match: + raise ValueError(f"invalid checksum line: {raw_line!r}") + name = Path(match.group(2).strip()).name + if name in hashes: + raise ValueError(f"duplicate checksum entry: {name}") + hashes[name] = match.group(1).lower() + + required = {pattern.format(version=version) for pattern in CLI_ASSETS.values()} + if set(hashes) != required | { + f"gonavi-cli_{version}_darwin_amd64.tar.gz", + f"gonavi-cli_{version}_darwin_arm64.tar.gz", + f"gonavi-cli_{version}_linux_amd64.tar.gz", + f"gonavi-cli_{version}_linux_arm64.tar.gz", + }: + raise ValueError("checksum file does not contain exactly the six CLI archives") + return hashes + + +def render_manifest(version: str, hashes: dict[str, str], repo: str) -> str: + lines = [ + "# Generated by tools/generate-winget-cli-manifest.py; do not edit hashes by hand.", + "PackageIdentifier: Syngnat.GoNavi.CLI", + f"PackageVersion: {version}", + "PackageLocale: en-US", + "Publisher: Syngnat", + "PublisherUrl: https://github.com/Syngnat", + "PublisherSupportUrl: https://github.com/Syngnat/GoNavi/issues", + "PackageName: GoNavi CLI", + "PackageUrl: https://github.com/Syngnat/GoNavi", + "License: Apache-2.0", + "LicenseUrl: https://github.com/Syngnat/GoNavi/blob/main/LICENSE", + "ShortDescription: Headless GoNavi database CLI", + "Description: Run GoNavi queries, exports, batches, audit exports, and MCP without the desktop GUI.", + "ReleaseNotesUrl: https://github.com/Syngnat/GoNavi/releases/tag/v" + version, + "Installers:", + ] + for architecture, pattern in CLI_ASSETS.items(): + asset = pattern.format(version=version) + lines.extend( + [ + f"- Architecture: {architecture}", + " InstallerType: zip", + " NestedInstallerType: portable", + f" InstallerUrl: https://github.com/{repo}/releases/download/v{version}/{asset}", + f" InstallerSha256: {hashes[asset]}", + " NestedInstallerFiles:", + f" - RelativeFilePath: {BINARY_NAME}", + " PortableCommandAlias: gonavi", + ] + ) + lines.extend( + [ + "ManifestType: singleton", + "ManifestVersion: 1.9.0", + "", + ] + ) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True, help="stable semantic version, for example 0.9.3") + parser.add_argument("--checksums", required=True, type=Path, help="independent CLI checksum file") + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--repo", default="Syngnat/GoNavi") + args = parser.parse_args() + version = args.version.strip().removeprefix("v") + if not VERSION_RE.fullmatch(version): + parser.error(f"invalid stable version: {args.version}") + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repo): + parser.error(f"invalid GitHub repository: {args.repo}") + try: + hashes = load_checksums(args.checksums, version) + payload = render_manifest(version, hashes, args.repo) + except (OSError, ValueError) as error: + parser.error(str(error)) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload, encoding="utf-8") + print(f"wrote {args.output} for GoNavi CLI {version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/generate-winget-cli-manifest.test.py b/tools/generate-winget-cli-manifest.test.py new file mode 100644 index 00000000..c07afdb4 --- /dev/null +++ b/tools/generate-winget-cli-manifest.test.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Tests for the checksum-driven WinGet CLI manifest generator.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "tools" / "generate-winget-cli-manifest.py" +SPEC = importlib.util.spec_from_file_location("generate_winget_cli_manifest", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class WinGetManifestTests(unittest.TestCase): + version = "1.2.3" + + def _write_checksums(self, directory: Path) -> Path: + names = [ + f"gonavi-cli_{self.version}_darwin_amd64.tar.gz", + f"gonavi-cli_{self.version}_darwin_arm64.tar.gz", + f"gonavi-cli_{self.version}_linux_amd64.tar.gz", + f"gonavi-cli_{self.version}_linux_arm64.tar.gz", + f"gonavi-cli_{self.version}_windows_amd64.zip", + f"gonavi-cli_{self.version}_windows_arm64.zip", + ] + path = directory / f"gonavi-cli_{self.version}_checksums.txt" + path.write_text("".join(f"{'a' * (64 - len(str(i)))}{i} {name}\n" for i, name in enumerate(names)), encoding="ascii") + return path + + def test_generates_both_windows_architectures_from_checksums(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + checksums = self._write_checksums(root) + output = root / "Syngnat.GoNavi.CLI.yaml" + result = subprocess.run( + [ + "python3", + str(SCRIPT), + "--version", + self.version, + "--checksums", + str(checksums), + "--output", + str(output), + ], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + manifest = output.read_text(encoding="utf-8") + self.assertIn("PackageIdentifier: Syngnat.GoNavi.CLI", manifest) + self.assertIn("PackageVersion: 1.2.3", manifest) + self.assertIn("Architecture: x64", manifest) + self.assertIn("Architecture: arm64", manifest) + self.assertIn("gonavi-cli_1.2.3_windows_amd64.zip", manifest) + self.assertIn("gonavi-cli_1.2.3_windows_arm64.zip", manifest) + self.assertEqual(manifest.count("InstallerSha256:"), 2) + self.assertEqual(manifest.count("NestedInstallerType: portable"), 2) + self.assertIn("NestedInstallerFiles:", manifest) + self.assertIn("PortableCommandAlias: gonavi", manifest) + + def test_rejects_missing_or_extra_checksum_entries(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + checksums = self._write_checksums(root) + checksums.write_text(checksums.read_text(encoding="ascii") + f"{'b' * 64} unexpected.zip\n", encoding="ascii") + with self.assertRaises(ValueError): + MODULE.load_checksums(checksums, self.version) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/npm-cli-wrapper.test.py b/tools/npm-cli-wrapper.test.py new file mode 100644 index 00000000..c18d6fe0 --- /dev/null +++ b/tools/npm-cli-wrapper.test.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Contract tests for the npm GoNavi CLI wrapper.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = ROOT / "npm" / "gonavi-cli" + + +class NpmCLIWrapperTests(unittest.TestCase): + def test_package_exposes_gonavi_and_verified_postinstall(self) -> None: + package = json.loads((PACKAGE / "package.json").read_text(encoding="utf-8")) + self.assertEqual(package["name"], "@syngnat/gonavi-cli") + self.assertRegex(package["version"], r"^\d+\.\d+\.\d+$") + self.assertEqual(package["bin"]["gonavi"], "bin/gonavi.js") + self.assertEqual(package["scripts"]["postinstall"], "node install.js") + + def test_installer_consumes_independent_checksums_and_fixed_archive(self) -> None: + installer = (PACKAGE / "install.js").read_text(encoding="utf-8") + launcher = (PACKAGE / "bin" / "gonavi.js").read_text(encoding="utf-8") + for token in ( + "gonavi-cli_${version}_checksums.txt", + "crypto.createHash('sha256')", + "assertSha256(archive, expected, target.asset)", + "archiveEntries(archivePath, target.extension, target.binary)", + "expectedArchiveEntries(binary)", + "'LICENSE'", + "'NOTICE'", + "GONAVI_CLI_RELEASE_BASE_URL", + ): + self.assertIn(token, installer) + self.assertIn("spawn(binaryPath", launcher) + self.assertNotIn("SHA256SUMS", installer) + + def test_node_sources_parse(self) -> None: + node = shutil.which("node") + if node is None: + self.skipTest("node is not installed") + for source in (PACKAGE / "install.js", PACKAGE / "bin" / "gonavi.js"): + result = subprocess.run( + [node, "--check", str(source)], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_installer_rejects_symlink_and_hardlink_archive_members(self) -> None: + node = shutil.which("node") + if node is None: + self.skipTest("node is not installed") + if os.name == "nt": + self.skipTest("creating symlinks is not consistently permitted on Windows runners") + + script = r''' +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { validateExtractedArchive } = require(process.argv[1]); + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gonavi-cli-wrapper-test-')); +const expected = ['gonavi', 'LICENSE', 'NOTICE']; + +function populate(directory) { + fs.mkdirSync(directory); + for (const entry of expected) { + fs.writeFileSync(path.join(directory, entry), entry); + } +} + +function expectReject(directory, label) { + try { + validateExtractedArchive(directory, 'gonavi'); + } catch (error) { + return; + } + throw new Error(`${label} archive member was accepted`); +} + +try { + const regular = path.join(root, 'regular'); + populate(regular); + validateExtractedArchive(regular, 'gonavi'); + + const symlink = path.join(root, 'symlink'); + populate(symlink); + fs.rmSync(path.join(symlink, 'LICENSE')); + fs.symlinkSync('gonavi', path.join(symlink, 'LICENSE')); + expectReject(symlink, 'symlink'); + + const hardlink = path.join(root, 'hardlink'); + populate(hardlink); + fs.rmSync(path.join(hardlink, 'LICENSE')); + fs.linkSync(path.join(hardlink, 'gonavi'), path.join(hardlink, 'LICENSE')); + expectReject(hardlink, 'hardlink'); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} +''' + result = subprocess.run( + [node, "-e", script, str(PACKAGE / "install.js")], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate-gui-update-manifest.py b/tools/validate-gui-update-manifest.py new file mode 100644 index 00000000..314b0154 --- /dev/null +++ b/tools/validate-gui-update-manifest.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Validate a desktop GUI update manifest before it is published or mirrored. + +The update manifest shares a GitHub Release namespace with the standalone CLI. +This validator deliberately uses the generator's GUI filename allowlist again +at the publication boundary, so an unexpected non-CLI asset cannot become a +desktop updater candidate. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import re +import sys +from pathlib import Path +from types import ModuleType +from typing import Any +from urllib.parse import quote, urlsplit + + +ROOT = Path(__file__).resolve().parents[1] +GENERATOR_PATH = ROOT / "tools" / "generate-update-latest-manifest.py" +SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") +TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +STABLE_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$") +DEV_TAG_RE = re.compile(r"^dev-[0-9a-f]{7,40}$") +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +DEFAULT_MIRROR_BASES = { + "stable": "https://download.syngnat.top/gonavi/releases/download", + "dev": "https://download.syngnat.top/gonavi/dev/releases/download", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate a GoNavi GUI update manifest and its local assets" + ) + parser.add_argument("--channel", choices=("stable", "dev"), required=True) + parser.add_argument("--app-tag", required=True) + parser.add_argument("--app-dir", type=Path, required=True) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument( + "--github-repository", + default="Syngnat/GoNavi", + help="GitHub owner/repository used for manifest URLs", + ) + parser.add_argument( + "--mirror-base", + default="", + help="Override the expected mirror download base URL", + ) + return parser.parse_args() + + +def fail(message: str) -> None: + raise ValueError(message) + + +def load_object(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"unable to read {label} {path}: {exc}") + if not isinstance(value, dict): + fail(f"{label} must be a JSON object: {path}") + return value + + +def load_generator() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "gonavi_update_manifest_generator", GENERATOR_PATH + ) + if spec is None or spec.loader is None: + fail(f"unable to load GUI asset allowlist from {GENERATOR_PATH}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def normalize_version(generator: ModuleType, version: str) -> str: + normalizer = getattr(generator, "normalize_version", None) + if not callable(normalizer): + fail("update manifest generator has no version normalizer") + normalized = normalizer(version) + if not isinstance(normalized, str) or not normalized: + fail(f"invalid normalized GUI version: {version!r}") + return normalized + + +def gui_patterns_for_version(generator: ModuleType, version: str) -> tuple[re.Pattern[str], ...]: + raw_patterns = getattr(generator, "GUI_ASSET_PATTERNS", None) + if not isinstance(raw_patterns, tuple) or not raw_patterns: + fail("update manifest generator has no GUI asset allowlist") + escaped_version = re.escape(normalize_version(generator, version)) + placeholder = "[A-Za-z0-9][A-Za-z0-9._-]*" + patterns: list[re.Pattern[str]] = [] + for raw_pattern in raw_patterns: + source = getattr(raw_pattern, "pattern", None) + if not isinstance(source, str) or placeholder not in source: + fail("update manifest generator has an invalid GUI asset allowlist") + patterns.append(re.compile(source.replace(placeholder, escaped_version))) + return tuple(patterns) + + +def validate_tag(channel: str, value: str) -> str: + if not TAG_RE.fullmatch(value) or value in {".", ".."}: + fail(f"invalid app tag: {value!r}") + if channel == "stable" and not STABLE_TAG_RE.fullmatch(value): + fail(f"invalid stable app tag: {value!r}") + if channel == "dev" and not DEV_TAG_RE.fullmatch(value): + fail(f"invalid dev app tag: {value!r}") + return value + + +def validate_repository(value: str) -> str: + if not REPOSITORY_RE.fullmatch(value): + fail(f"invalid GitHub repository: {value!r}") + return value + + +def validate_https_base(value: str, label: str) -> str: + base = value.strip().rstrip("/") + parsed = urlsplit(base) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.query + or parsed.fragment + ): + fail(f"invalid {label}: {value!r}") + return base + + +def is_nonnegative_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + fail(f"unable to read GUI manifest asset {path}: {exc}") + return digest.hexdigest() + + +def validate_asset_name( + name: Any, + *, + patterns: tuple[re.Pattern[str], ...], +) -> str: + if not isinstance(name, str) or not name: + fail(f"invalid GUI manifest asset name: {name!r}") + if Path(name).name != name or name in {".", ".."} or "/" in name or "\\" in name: + fail(f"invalid GUI manifest asset name: {name!r}") + if not any(pattern.fullmatch(name) for pattern in patterns): + fail(f"not an allowed GUI release asset: {name}") + return name + + +def validate_manifest( + *, + channel: str, + app_tag: str, + app_dir: Path, + manifest_name: str, + manifest: dict[str, Any], + repository: str, + mirror_base: str, +) -> int: + generator = load_generator() + version = normalize_version(generator, app_tag) if channel == "stable" else app_tag + expected_channel = "latest" if channel == "stable" else "dev" + expected_tag_name = app_tag if channel == "stable" else "dev-latest" + expected_manifest_name = "latest.json" if channel == "stable" else "latest-dev.json" + + if manifest_name != expected_manifest_name: + fail(f"{channel} GUI manifest must be named {expected_manifest_name!r}") + + if manifest.get("schemaVersion") != 1: + fail("GUI manifest schemaVersion must be 1") + if manifest.get("component") != "gui": + fail("manifest component must be 'gui'") + if manifest.get("channel") != expected_channel: + if channel == "stable": + fail("stable GUI manifest channel must be 'latest'") + fail("dev GUI manifest channel must be 'dev'") + if manifest.get("tagName") != expected_tag_name: + fail(f"GUI manifest tagName must be {expected_tag_name!r}") + if manifest.get("version") != version: + fail(f"GUI manifest version must be {version!r}") + + expected_html_url = f"https://github.com/{repository}/releases/tag/{expected_tag_name}" + if manifest.get("htmlUrl") != expected_html_url: + fail("GUI manifest htmlUrl does not match its release tag") + + assets = manifest.get("assets") + if not isinstance(assets, list) or not assets: + fail("GUI manifest assets must be a non-empty array") + if not app_dir.is_dir(): + fail(f"GUI manifest app directory does not exist: {app_dir}") + + patterns = gui_patterns_for_version(generator, version) + expected_url_prefix = f"{mirror_base}/{quote(app_tag, safe='')}/" + expected_api_url_prefix = ( + f"https://github.com/{repository}/releases/download/" + f"{quote(expected_tag_name, safe='')}/" + ) + seen: set[str] = set() + for entry in assets: + if not isinstance(entry, dict): + fail("GUI manifest asset entries must be objects") + name = validate_asset_name(entry.get("name"), patterns=patterns) + normalized_name = name.casefold() + if normalized_name in seen: + fail(f"duplicate GUI manifest asset: {name}") + seen.add(normalized_name) + + expected_url = expected_url_prefix + quote(name, safe="") + if entry.get("url") != expected_url: + fail(f"GUI manifest asset URL is invalid: {name}") + expected_api_url = expected_api_url_prefix + quote(name, safe="") + if entry.get("apiUrl") != expected_api_url: + fail(f"GUI manifest asset API URL is invalid: {name}") + + expected_size = entry.get("size") + if not is_nonnegative_int(expected_size) or expected_size == 0: + fail(f"GUI manifest asset size must be positive: {name}") + expected_sha = entry.get("sha256") + if not isinstance(expected_sha, str) or not SHA256_RE.fullmatch(expected_sha): + fail(f"invalid GUI manifest asset sha256: {name}") + + source = app_dir / name + if not source.is_file(): + fail(f"GUI manifest asset is missing: {name}") + if source.stat().st_size != expected_size: + fail(f"GUI manifest asset size mismatch: {name}") + if sha256_file(source) != expected_sha.lower(): + fail(f"GUI manifest asset sha256 mismatch: {name}") + return len(assets) + + +def main() -> int: + args = parse_args() + try: + app_tag = validate_tag(args.channel, args.app_tag) + repository = validate_repository(args.github_repository) + mirror_base = validate_https_base( + args.mirror_base or DEFAULT_MIRROR_BASES[args.channel], + "mirror base URL", + ) + manifest = load_object(args.manifest, "GUI manifest") + asset_count = validate_manifest( + channel=args.channel, + app_tag=app_tag, + app_dir=args.app_dir, + manifest_name=args.manifest.name, + manifest=manifest, + repository=repository, + mirror_base=mirror_base, + ) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print( + f"validated {asset_count} GUI manifest asset(s) for " + f"{args.channel} {args.app_tag}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate-gui-update-manifest.test.py b/tools/validate-gui-update-manifest.test.py new file mode 100644 index 00000000..5fb23160 --- /dev/null +++ b/tools/validate-gui-update-manifest.test.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "tools" / "validate-gui-update-manifest.py" +PUBLISH_WORKFLOW = ROOT / ".github" / "workflows" / "publish-release.yml" +DEV_WORKFLOW = ROOT / ".github" / "workflows" / "dev-build.yml" +MIRROR_ACTION = ROOT / ".github" / "actions" / "publish-vps-mirror" / "action.yml" +MIRROR_BASES = { + "stable": "https://download.syngnat.top/gonavi/releases/download", + "dev": "https://download.syngnat.top/gonavi/dev/releases/download", +} +GITHUB_BASE = "https://github.com/Syngnat/GoNavi/releases/download" + + +def sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +class ValidateGUIUpdateManifestTest(unittest.TestCase): + def write_manifest( + self, + root: Path, + *, + channel: str, + app_tag: str, + asset_name: str, + asset_bytes: bytes, + ) -> tuple[Path, Path]: + app_dir = root / "app-assets" + app_dir.mkdir() + (app_dir / asset_name).write_bytes(asset_bytes) + + if channel == "stable": + manifest_channel = "latest" + tag_name = app_tag + version = app_tag.removeprefix("v") + else: + manifest_channel = "dev" + tag_name = "dev-latest" + version = app_tag + + manifest = { + "schemaVersion": 1, + "component": "gui", + "channel": manifest_channel, + "tagName": tag_name, + "version": version, + "htmlUrl": f"https://github.com/Syngnat/GoNavi/releases/tag/{tag_name}", + "assets": [ + { + "name": asset_name, + "url": f"{MIRROR_BASES[channel]}/{app_tag}/{asset_name}", + "apiUrl": f"{GITHUB_BASE}/{tag_name}/{asset_name}", + "size": len(asset_bytes), + "sha256": sha256(asset_bytes), + } + ], + } + manifest_path = root / ("latest.json" if channel == "stable" else "latest-dev.json") + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + return app_dir, manifest_path + + def run_validator( + self, + *, + channel: str, + app_tag: str, + app_dir: Path, + manifest_path: Path, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--channel", + channel, + "--app-tag", + app_tag, + "--app-dir", + str(app_dir), + "--manifest", + str(manifest_path), + "--github-repository", + "Syngnat/GoNavi", + ], + cwd=str(ROOT), + check=False, + capture_output=True, + text=True, + ) + + def test_accepts_stable_gui_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + app_dir, manifest_path = self.write_manifest( + root, + channel="stable", + app_tag="v1.2.3", + asset_name="GoNavi-1.2.3-MacOS-Arm64.dmg", + asset_bytes=b"signed-dmg", + ) + + result = self.run_validator( + channel="stable", + app_tag="v1.2.3", + app_dir=app_dir, + manifest_path=manifest_path, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_accepts_dev_gui_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + app_dir, manifest_path = self.write_manifest( + root, + channel="dev", + app_tag="dev-a1b2c3d", + asset_name="GoNavi-dev-a1b2c3d-Linux-Amd64.tar.gz", + asset_bytes=b"linux-tarball", + ) + + result = self.run_validator( + channel="dev", + app_tag="dev-a1b2c3d", + app_dir=app_dir, + manifest_path=manifest_path, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_rejects_stable_manifest_without_latest_channel(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + app_dir, manifest_path = self.write_manifest( + root, + channel="stable", + app_tag="v1.2.3", + asset_name="GoNavi-1.2.3-MacOS-Amd64.dmg", + asset_bytes=b"signed-dmg", + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["channel"] = "dev" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + result = self.run_validator( + channel="stable", + app_tag="v1.2.3", + app_dir=app_dir, + manifest_path=manifest_path, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("stable GUI manifest channel must be 'latest'", result.stderr) + + def test_rejects_non_cli_asset_outside_gui_allowlist(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + app_dir, manifest_path = self.write_manifest( + root, + channel="stable", + app_tag="v1.2.3", + asset_name="GoNavi-1.2.3-SupportBundle.tar.gz", + asset_bytes=b"not-a-desktop-package", + ) + + result = self.run_validator( + channel="stable", + app_tag="v1.2.3", + app_dir=app_dir, + manifest_path=manifest_path, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("not an allowed GUI release asset", result.stderr) + + def test_rejects_local_asset_hash_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + app_dir, manifest_path = self.write_manifest( + root, + channel="stable", + app_tag="v1.2.3", + asset_name="GoNavi-1.2.3-Windows-Amd64-Portable.exe", + asset_bytes=b"original-binary", + ) + (app_dir / "GoNavi-1.2.3-Windows-Amd64-Portable.exe").write_bytes( + b"tampered-binary" + ) + + result = self.run_validator( + channel="stable", + app_tag="v1.2.3", + app_dir=app_dir, + manifest_path=manifest_path, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("GUI manifest asset sha256 mismatch", result.stderr) + + def test_release_and_mirror_paths_run_the_validator(self) -> None: + publish = PUBLISH_WORKFLOW.read_text(encoding="utf-8") + dev = DEV_WORKFLOW.read_text(encoding="utf-8") + mirror = MIRROR_ACTION.read_text(encoding="utf-8") + + self.assertIn("tools/validate-gui-update-manifest.py", publish) + self.assertIn("--channel stable", publish) + self.assertIn("tools/validate-gui-update-manifest.py", dev) + self.assertIn("--channel dev", dev) + self.assertIn("tools/validate-gui-update-manifest.py", mirror) + self.assertIn('--channel "${MIRROR_CHANNEL}"', mirror) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate-npm-cli-package-version.py b/tools/validate-npm-cli-package-version.py new file mode 100644 index 00000000..ea2ec901 --- /dev/null +++ b/tools/validate-npm-cli-package-version.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Validate that a stable tag matches the npm CLI package version.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +PACKAGE_NAME = "@syngnat/gonavi-cli" +STABLE_TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$") +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +DEFAULT_PACKAGE_JSON = Path(__file__).resolve().parents[1] / "npm" / "gonavi-cli" / "package.json" + + +def stable_version(tag: str) -> str: + """Return the semantic version represented by a stable ``v`` tag.""" + match = STABLE_TAG_RE.fullmatch(tag) + if not match: + raise ValueError(f"stable tag must match vX.Y.Z exactly: {tag!r}") + return match.group(1) + + +def validate_package_version(tag: str, package_json: Path = DEFAULT_PACKAGE_JSON) -> str: + """Validate the package identity and return its version.""" + expected_version = stable_version(tag) + try: + package = json.loads(package_json.read_text(encoding="utf-8")) + except OSError as error: + raise ValueError(f"cannot read npm package metadata: {package_json}: {error}") from error + except json.JSONDecodeError as error: + raise ValueError(f"npm package metadata is not valid JSON: {package_json}: {error}") from error + + if not isinstance(package, dict) or package.get("name") != PACKAGE_NAME: + actual_name = package.get("name") if isinstance(package, dict) else None + raise ValueError(f"npm package name must be {PACKAGE_NAME!r}, got {actual_name!r}") + actual_version = package.get("version") + if not isinstance(actual_version, str) or not VERSION_RE.fullmatch(actual_version): + raise ValueError(f"npm package version must be X.Y.Z, got {actual_version!r}") + if actual_version != expected_version: + raise ValueError( + f"npm package version {actual_version} does not match stable tag {tag} ({expected_version})" + ) + return actual_version + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True, help="stable release tag, for example v0.9.3") + parser.add_argument("--package-json", type=Path, default=DEFAULT_PACKAGE_JSON) + args = parser.parse_args() + try: + version = validate_package_version(args.tag, args.package_json) + except ValueError as error: + print(f"npm CLI package version validation failed: {error}", file=sys.stderr) + return 2 + print(f"npm CLI package {PACKAGE_NAME} matches stable tag {args.tag}: {version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate-npm-cli-package-version.test.py b/tools/validate-npm-cli-package-version.test.py new file mode 100644 index 00000000..b94f471f --- /dev/null +++ b/tools/validate-npm-cli-package-version.test.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Tests for the stable tag/npm CLI package version contract.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "tools" / "validate-npm-cli-package-version.py" +SPEC = importlib.util.spec_from_file_location("validate_npm_cli_package_version", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class ValidateNpmCLIPackageVersionTests(unittest.TestCase): + def test_current_package_matches_the_first_stable_cli_tag(self) -> None: + package_json = ROOT / "npm" / "gonavi-cli" / "package.json" + self.assertEqual(MODULE.validate_package_version("v0.9.3", package_json), "0.9.3") + + def test_rejects_version_drift(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + package_json = Path(temporary) / "package.json" + package_json.write_text( + json.dumps({"name": MODULE.PACKAGE_NAME, "version": "0.9.2"}), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "does not match stable tag"): + MODULE.validate_package_version("v0.9.3", package_json) + + def test_rejects_non_stable_tags(self) -> None: + with self.assertRaisesRegex(ValueError, "stable tag must match"): + MODULE.validate_package_version("dev-a1b2c3d", ROOT / "npm" / "gonavi-cli" / "package.json") + + def test_cli_reports_failure_for_invalid_tag(self) -> None: + result = subprocess.run( + ["python3", str(SCRIPT), "--tag", "v0.9.3-rc1"], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("stable tag must match", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/wails.json b/wails.json index fb82d7ee..74a287a2 100644 --- a/wails.json +++ b/wails.json @@ -14,6 +14,7 @@ "info": { "companyName": "Syngnat", "productName": "GoNavi", + "productVersion": "0.9.3", "copyright": "Copyright 2026 Syngnat", "comments": "GoNavi is licensed under the Apache License, Version 2.0." },