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."
},