diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..2f94403 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,85 @@ +name: Bug report +description: Report a reproducible fojin-cli defect +title: "bug: " +body: + - type: markdown + attributes: + value: | + Thanks for helping improve fojin-cli. Search existing issues before filing. + + **Do not disclose security vulnerabilities here.** Use the private process in [SECURITY.md](https://github.com/xr843/fojin-cli/blob/master/SECURITY.md). + - type: input + id: version + attributes: + label: fojin version + description: Paste the output of `fojin --version`. + placeholder: "fojin 0.x.y" + validations: + required: true + - type: dropdown + id: installation + attributes: + label: Installation method + options: + - cargo install + - install.sh + - GitHub Release archive + - Built from source + - Other package or integration + validations: + required: true + - type: input + id: environment + attributes: + label: Operating system and architecture + placeholder: "Ubuntu 24.04 x86_64, macOS arm64, or Windows x64" + validations: + required: true + - type: textarea + id: command + attributes: + label: Command and input + description: Provide the smallest command/input that reproduces the problem. Remove private data. + render: shell + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: Include exact output and exit code when useful. Remove credentials and private data. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Include whether the issue also occurs with `--offline` or a fresh `--data-dir`, when relevant. + validations: + required: true + - type: textarea + id: data_status + attributes: + label: Dataset status + description: For data/query issues, paste `fojin data status` and `fojin data verify` output. Do not attach the dataset itself. + render: text + - type: textarea + id: additional + attributes: + label: Additional context + description: Add minimal logs or screenshots that do not contain sensitive information. + - type: checkboxes + id: checks + attributes: + label: Checklist + options: + - label: I searched for an existing report of this issue. + required: true + - label: This report does not disclose a security vulnerability or sensitive data. + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..db9f1b0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +## Summary + +Describe the problem and the focused change that solves it. Note user-visible behavior and compatibility impact. + +## Verification + +List the exact commands you ran and their results, for example: + +```text +cargo +stable test --all --locked +``` + +## Checklist + +- [ ] The change is focused and contains no credentials, generated datasets, build artifacts, or public vulnerability details. +- [ ] I added or updated tests for behavior changes, or explained why tests are not applicable. +- [ ] `cargo +stable fmt --all --check` passes. +- [ ] `cargo +stable clippy --all-targets --locked -- -D warnings` passes. +- [ ] `cargo +stable test --all --locked` passes. +- [ ] `cargo +1.95.0 test --all --locked` passes when the change affects Rust code or dependencies. +- [ ] Relevant Python and shell checks from `CONTRIBUTING.md` pass. +- [ ] Documentation and the unreleased changelog entry are updated when user-visible behavior changes. +- [ ] Dependency changes include the intended `Cargo.lock` update and retain the MSRV. +- [ ] Data changes comply with `DATA_LICENSE`; code changes are acceptable under MIT OR Apache-2.0. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1871ee4..204258c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,43 @@ name: ci on: [push, pull_request] + +permissions: + contents: read + jobs: msrv: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.77.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: 1.95.0 - run: cargo test --all --locked - run: cargo install --path . --locked rust: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: + toolchain: stable components: clippy, rustfmt - run: cargo fmt --all --check - - run: cargo clippy --all-targets -- -D warnings - - run: cargo test --all + - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo test --all --locked windows-data: runs-on: windows-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable - run: cargo test --all --locked release-build: name: release build (${{ matrix.target }}) @@ -44,17 +59,32 @@ jobs: os: windows-latest archive_ext: zip steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - name: Install release target - run: rustup target add ${{ matrix.target }} + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + targets: ${{ matrix.target }} - run: cargo build --release --locked --target ${{ matrix.target }} python-parity: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' - run: pip install pytest - run: cd data-pipeline && python -m pytest tests/ -q + release-contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: ShellCheck release scripts + run: shellcheck install.sh scripts/*.sh tests/*.sh + - run: bash tests/release-scripts.sh + - run: bash tests/install-script.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b095966..eec6b9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,24 +1,63 @@ -# Builds cross-platform `fojin` binaries and attaches them to the GitHub -# Release created for a pushed `vN.N.N` tag (binary release, e.g. v0.1.0). -# -# NOTE: this ships the CLI binary only. On first run the binary downloads -# the data artifact (the parallels SQLite DB), which is published -# separately under its own `data-vN` tag (see src/cli.rs). Cutting a -# binary release does NOT publish or update the data artifact. +# Builds cross-platform `fojin` archives for a stable vX.Y.Z tag. A manual +# dispatch exercises the complete build and verification path without publishing. name: release on: push: tags: - "v*" + workflow_dispatch: -permissions: - contents: write +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: + prepare: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + - name: Determine and validate release version + id: version + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + metadata="$(cargo metadata --locked --no-deps --format-version 1)" + version="$(python3 -c 'import json, sys; data=json.load(sys.stdin); print(next(package["version"] for package in data["packages"] if package["name"] == "fojin-cli"))' <<<"$metadata")" + if [[ "$EVENT_NAME" == "push" ]]; then + scripts/check-release-version.sh "$REF_NAME" "$version" + fi + printf 'version=%s\n' "$version" >>"$GITHUB_OUTPUT" + - name: Upload release verifiers + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: verification-tools + path: | + scripts/check-release-archive.sh + scripts/check-release-availability.sh + if-no-files-found: error + retention-days: 1 + build: name: build (${{ matrix.target }}) + needs: prepare runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: fail-fast: false matrix: @@ -29,49 +68,165 @@ jobs: - target: aarch64-apple-darwin os: macos-latest archive_ext: tar.gz - # Cross-compiled from the arm64 runner: dedicated Intel (macos-13) - # runners are scarce and can sit queued for an hour+. - target: x86_64-apple-darwin os: macos-latest archive_ext: tar.gz - target: x86_64-pc-windows-msvc os: windows-latest archive_ext: zip - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: + toolchain: stable targets: ${{ matrix.target }} - - name: Build release binary - run: cargo build --release --target ${{ matrix.target }} - - - name: Package archive (unix) + shell: bash + env: + TARGET: ${{ matrix.target }} + run: cargo build --release --locked --target "$TARGET" + - name: Package archive (Unix) if: matrix.archive_ext == 'tar.gz' shell: bash + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail - version="${GITHUB_REF_NAME#v}" - staging="fojin-${version}-${{ matrix.target }}" - mkdir -p "$staging" - cp "target/${{ matrix.target }}/release/fojin" "$staging/" - tar -czf "${staging}.tar.gz" "$staging" - echo "ASSET=${staging}.tar.gz" >> "$GITHUB_ENV" - - - name: Package archive (windows) + staging="fojin-${VERSION}-${TARGET}" + mkdir "$staging" + cp "target/${TARGET}/release/fojin" "$staging/fojin" + cp README.md LICENSE-MIT LICENSE-APACHE "$staging/" + chmod 755 "$staging/fojin" + COPYFILE_DISABLE=1 tar -czf "${staging}.tar.gz" "$staging" + printf 'ASSET=%s\n' "${staging}.tar.gz" >>"$GITHUB_ENV" + - name: Package archive (Windows) if: matrix.archive_ext == 'zip' shell: pwsh + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ needs.prepare.outputs.version }} run: | - $version = $env:GITHUB_REF_NAME.TrimStart("v") - $staging = "fojin-$version-${{ matrix.target }}" + $ErrorActionPreference = 'Stop' + $staging = "fojin-$env:VERSION-$env:TARGET" + $archive = "$staging.zip" New-Item -ItemType Directory -Path $staging | Out-Null - Copy-Item "target/${{ matrix.target }}/release/fojin.exe" "$staging/" - Compress-Archive -Path $staging -DestinationPath "$staging.zip" - Add-Content -Path $env:GITHUB_ENV -Value "ASSET=$staging.zip" + Copy-Item -LiteralPath "target/$env:TARGET/release/fojin.exe" -Destination "$staging/fojin.exe" + Copy-Item -LiteralPath "README.md" -Destination "$staging/README.md" + Copy-Item -LiteralPath "LICENSE-MIT" -Destination "$staging/LICENSE-MIT" + Copy-Item -LiteralPath "LICENSE-APACHE" -Destination "$staging/LICENSE-APACHE" + Compress-Archive -LiteralPath $staging -DestinationPath $archive + Add-Content -LiteralPath $env:GITHUB_ENV -Value "ASSET=$archive" + - name: Upload target archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-${{ matrix.target }} + path: ${{ env.ASSET }} + if-no-files-found: error + compression-level: 0 - - name: Upload release asset - uses: softprops/action-gh-release@v2 + aggregate: + needs: [prepare, build] + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Download archive verifier + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: verification-tools + path: verification-tools + - name: Download target archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-* + path: dist + merge-multiple: true + - name: Validate archive set and contents + shell: bash + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + targets=( + x86_64-unknown-linux-gnu + aarch64-apple-darwin + x86_64-apple-darwin + x86_64-pc-windows-msvc + ) + archives=( + "fojin-${VERSION}-x86_64-unknown-linux-gnu.tar.gz" + "fojin-${VERSION}-aarch64-apple-darwin.tar.gz" + "fojin-${VERSION}-x86_64-apple-darwin.tar.gz" + "fojin-${VERSION}-x86_64-pc-windows-msvc.zip" + ) + mapfile -t actual < <(find dist -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort) + mapfile -t expected < <(printf '%s\n' "${archives[@]}" | LC_ALL=C sort) + if [[ "${#actual[@]}" -ne 4 ]] || ! diff -u <(printf '%s\n' "${expected[@]}") <(printf '%s\n' "${actual[@]}"); then + printf 'expected exactly four target archives\n' >&2 + exit 1 + fi + chmod +x verification-tools/check-release-archive.sh + for index in "${!archives[@]}"; do + verification-tools/check-release-archive.sh "dist/${archives[$index]}" "${targets[$index]}" "$VERSION" + done + ( + cd dist + sha256sum "${archives[@]}" >SHA256SUMS + sha256sum -c SHA256SUMS + ) + - name: Upload verified release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-assets + path: | + dist/fojin-${{ needs.prepare.outputs.version }}-x86_64-unknown-linux-gnu.tar.gz + dist/fojin-${{ needs.prepare.outputs.version }}-aarch64-apple-darwin.tar.gz + dist/fojin-${{ needs.prepare.outputs.version }}-x86_64-apple-darwin.tar.gz + dist/fojin-${{ needs.prepare.outputs.version }}-x86_64-pc-windows-msvc.zip + dist/SHA256SUMS + if-no-files-found: error + compression-level: 0 + + publish: + needs: [prepare, aggregate] + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download verified release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-assets + path: dist + - name: Download release verifiers + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: verification-tools + path: verification-tools + - name: Recheck release checksums + shell: bash + run: cd dist && sha256sum -c SHA256SUMS + - name: Make release availability verifier executable + shell: bash + run: chmod +x verification-tools/check-release-availability.sh + - name: Reject an existing release for this tag + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + REF_NAME: ${{ github.ref_name }} + run: verification-tools/check-release-availability.sh "$REPOSITORY" "$REF_NAME" + - name: Publish GitHub Release assets + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: - files: ${{ env.ASSET }} + files: | + dist/fojin-${{ needs.prepare.outputs.version }}-x86_64-unknown-linux-gnu.tar.gz + dist/fojin-${{ needs.prepare.outputs.version }}-aarch64-apple-darwin.tar.gz + dist/fojin-${{ needs.prepare.outputs.version }}-x86_64-apple-darwin.tar.gz + dist/fojin-${{ needs.prepare.outputs.version }}-x86_64-pc-windows-msvc.zip + dist/SHA256SUMS + fail_on_unmatched_files: true + overwrite_files: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2f2e3f1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.3.0] - Unreleased + +Version 0.3.0 is prepared but has not been published. Its stabilization work includes: + +- Data verification: strengthen `fojin data verify` and dataset compatibility checks. +- Query correctness: make short-query matching literal and remove duplicate parallel text within a match group and language. +- SQLite safety: upgrade the bundled SQLite and verify its runtime version. +- Release integrity: validate release versions, locked builds, archive contents, checksums, and installer verification. +- Project governance: document private security reporting and contribution checks, and add issue and pull request templates. + +These changes are not released until the 0.3.0 release tag is published. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..466f5a3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# 贡献指南 / Contributing + +感谢你改进 fojin-cli。请先搜索已有 issue 和 pull request;行为变更或范围较大的工作,建议先用普通 +issue 说明问题与预期结果。安全问题不要公开提交,请遵循 [SECURITY.md](SECURITY.md)。 + +## 开发环境 + +- Rust 1.95.0 是最低支持版本(MSRV);日常格式、Clippy 与测试也应使用当前 stable。 +- Python parity job 使用 Python 3.12 与 pytest。 +- release contract 使用 Bash 与 ShellCheck。 + +安装所需 Rust 工具链与组件: + +```bash +rustup toolchain install 1.95.0 +rustup toolchain install stable --component rustfmt --component clippy +``` + +## 提交前检查 + +以下命令与 `.github/workflows/ci.yml` 中的 Linux CI 对应,请从仓库根目录运行。 + +Stable Rust 检查: + +```bash +cargo +stable fmt --all --check +cargo +stable clippy --all-targets --locked -- -D warnings +cargo +stable test --all --locked +``` + +MSRV 检查: + +```bash +cargo +1.95.0 test --all --locked +cargo +1.95.0 install --path . --locked +``` + +Python parity 检查: + +```bash +python3.12 -m pip install pytest +cd data-pipeline +python3.12 -m pytest tests/ -q +cd .. +``` + +Release/installer shell contract: + +```bash +shellcheck install.sh scripts/*.sh tests/*.sh +bash tests/release-scripts.sh +bash tests/install-script.sh +``` + +跨平台 release build 也在 CI 中覆盖 Linux x86_64、macOS ARM/Intel 与 Windows x64,执行的构建命令为: + +```bash +cargo +stable build --release --locked --target +``` + +`` 由 CI matrix 提供;本地无需模拟不属于当前主机的 target。 + +所有 Cargo 命令保留 `--locked`,避免无意更新依赖解析。若确需更新依赖,请同时提交并说明 +`Cargo.lock` 的变更。 + +## Pull request + +- 保持改动聚焦,并说明用户可见行为与兼容性影响。 +- 为行为修复或新增功能补充测试;文档应与实际命令和输出一致。 +- 不要提交生成的数据集、构建产物、凭据或安全漏洞细节。 +- 需要 changelog 的用户可见改动,请更新 `CHANGELOG.md` 的未发布版本条目。 +- 确认适用的 CI 命令通过,并在 PR 模板中列出实际运行的命令。 + +提交贡献即表示你同意代码按本仓库的 MIT OR Apache-2.0 双许可证提供;数据文件仍受 +`DATA_LICENSE` 中的独立条款约束。 diff --git a/Cargo.lock b/Cargo.lock index 56c0654..db6e0a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -8,18 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "anstream" version = "0.6.21" @@ -264,7 +252,7 @@ dependencies = [ [[package]] name = "fojin-cli" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -320,24 +308,6 @@ dependencies = [ "wasip2", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown", -] - [[package]] name = "heck" version = "0.5.0" @@ -383,9 +353,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" dependencies = [ "cc", "pkg-config", @@ -501,14 +471,13 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ "bitflags", "fallible-iterator", "fallible-streaming-iterator", - "hashlink", "libsqlite3-sys", "smallvec", ] @@ -975,26 +944,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "zerocopy" -version = "0.8.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zeroize" version = "1.8.2" diff --git a/Cargo.toml b/Cargo.toml index 2178c6b..97bfc3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "fojin-cli" -version = "0.2.1" +version = "0.3.0" edition = "2021" -rust-version = "1.77" +rust-version = "1.95" description = "fojin 跨藏对读 CLI —— 离线查询佛典汉文的梵/巴/藏平行 (offline, no login)" license = "MIT OR Apache-2.0" repository = "https://github.com/xr843/fojin-cli" @@ -21,7 +21,7 @@ path = "src/main.rs" [dependencies] clap = { version = "=4.5.53", features = ["derive"] } -rusqlite = { version = "=0.32.1", features = ["bundled"] } +rusqlite = { version = "=0.40.1", default-features = false, features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" directories = "5" diff --git a/README.md b/README.md index 528f194..5b67df1 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ $ fojin parallel "色即是空" ## 安装 +从 crates.io 或源码构建要求 **Rust 1.95 或更新版本**(MSRV 1.95);使用预编译二进制不需要安装 Rust。 + 通过 [crates.io](https://crates.io/crates/fojin-cli) 安装(命令为 `fojin`): ```bash @@ -36,12 +38,31 @@ cargo install fojin-cli --locked curl -fsSL https://raw.githubusercontent.com/xr843/fojin-cli/master/install.sh | sh ``` +这项校验合同从 **v0.3.0** 起适用:安装脚本要求它解析出的最新版本或 `FOJIN_VERSION` 指定的目标 +**二进制 release** 同时提供 `SHA256SUMS`,并在解压和安装前用 `sha256sum` 或 `shasum -a 256` +核对 archive;缺少校验工具、校验记录不唯一或摘要不匹配时都会停止安装。 + +如果目标二进制 release 早于 v0.3.0(包括 v0.3.0 尚未发布的过渡窗口中,脚本自动解析到旧版), +旧 release 没有 `SHA256SUMS` 时脚本会在解压前安全失败。此时请改用 crates.io 当前已发布版本, +或从源码构建;这段说明不表示 v0.3.0 已经发布。 + 也可从 [Releases](https://github.com/xr843/fojin-cli/releases/latest) 手动下载各平台二进制(含 Windows x64 zip),或从源码安装: ```bash cargo install --git https://github.com/xr843/fojin-cli --locked ``` +手动下载时请一并下载同一 release 的 `SHA256SUMS`,并在解压前核对所下载 archive 的 SHA-256。 +例如 GNU `sha256sum` 可从清单中筛选对应文件后校验(将占位符替换为 release 中的实际名称): + +```bash +archive="fojin--.tar.gz" +grep " ${archive}$" SHA256SUMS | sha256sum -c - +``` + +macOS 可将最后一段换为 `shasum -a 256 -c -`;Windows 可用 `Get-FileHash -Algorithm SHA256` +并与 `SHA256SUMS` 中对应的唯一记录比较。 + 首次运行 `fojin parallel` 会自动下载对齐数据集(约 183 MB,带进度显示,见下方「数据集」),之后完全离线。 ## 功能 / Usage @@ -161,8 +182,9 @@ fojin parallel "<汉文短语>" --json --offline - 藏 / Tibetan:676,898 条 - 梵 / Sanskrit:231,722 条 - 来源:Dharmamitra 的 [MITRA-parallel](https://github.com/dharmamitra/mitra-parallel) 对齐数据集([Nehrdich & Keutzer, 2026](https://arxiv.org/pdf/2601.06400)),以 GitHub Release(`data-v1`)形式分发;学术使用请引用原论文(BibTeX 见 [`DATA_LICENSE`](DATA_LICENSE))。 +- 当前二进制把官方下载地址、SHA-256 与兼容元数据固定在 `data-v1`;`fojin data update` 只会重新获取这份固定数据,不会自动切换到未来的数据主版本。版本、归一化规则或查询所需 schema 不兼容的数据会被拒绝。 - 首次运行时下载,压缩包约 **183 MB**,解压后约 **561 MB**(SQLite)。下载后完全离线可用。 -- 当前不含巴利对齐(上游 MITRA-parallel 尚未覆盖巴利),默认输出不显示巴利行;显式 `--lang pi` 仍可查询(如实答「未找到对齐」)。上游补充后将随新数据版本自动显示,无需升级程序。 +- 当前不含巴利对齐(上游 MITRA-parallel 尚未覆盖巴利),默认输出不显示巴利行;显式 `--lang pi` 仍可查询(如实答「未找到对齐」)。程序的渲染路径可兼容未来新增语言行,但当前官方下载通道仍固定为 `data-v1`;上游出现新语言不代表当前二进制会自动获得它。**渲染兼容不等于官方更新通道无需升级**,未来数据版本可能要求升级二进制或明确切换数据发布。 - 许可:**CC BY-SA 4.0**(Dharmamitra + fojin)。 - 范围:仅含 MITRA 跨藏平行;fojin 自有的精选对齐(alignment_pairs)**未包含**在本数据集中。 - 未来可能提供体积更小的 lite 子集,供带宽/存储受限场景使用(尚未实现)。 @@ -194,8 +216,9 @@ fojin data verify # verify version, SQLite, and FTS integrity ``` - **Input**: Chinese only (traditional/simplified folded, punctuation ignored); literal substring matching over normalized text. 2-to-12-character phrases work best. +- **Build/install integrity**: building from crates.io or source requires Rust 1.95+ (MSRV 1.95). Starting with v0.3.0, the shell installer requires the target binary release to provide `SHA256SUMS` and verifies the archive before extraction. It fails closed for an older latest or explicitly selected release without that file, including the transition before v0.3.0 is published; use the currently published crates.io version or a source build instead. This does not state that v0.3.0 has been released. - **For AI agents**: pure-JSON stdout, semantic exit codes (`0` ok / `1` runtime / `2` usage), zero network with `--offline`. Ready-made Claude Code integration in [`examples/claude/`](examples/claude/). -- **Data**: 908,620 zh↔sa/bo alignments from Dharmamitra's [MITRA-parallel](https://github.com/dharmamitra/mitra-parallel) dataset, redistributed under CC BY-SA 4.0. Academic use: please cite [Nehrdich & Keutzer (2026)](https://arxiv.org/pdf/2601.06400) — BibTeX in [`DATA_LICENSE`](DATA_LICENSE). +- **Data**: 908,620 zh↔sa/bo alignments from Dharmamitra's [MITRA-parallel](https://github.com/dharmamitra/mitra-parallel) dataset, redistributed under CC BY-SA 4.0. The official URL, checksum, and compatibility contract remain pinned to `data-v1`; rendering support for future language rows does not mean the official update channel can adopt them without a binary upgrade. Academic use: please cite [Nehrdich & Keutzer (2026)](https://arxiv.org/pdf/2601.06400) — BibTeX in [`DATA_LICENSE`](DATA_LICENSE). - **Not in scope**: semantic search, Pāli, translation — use [Dharmamitra](https://dharmamitra.org)'s online APIs for those; the two are complementary. - **License**: code MIT OR Apache-2.0; data CC BY-SA 4.0. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..76b231a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# 安全政策 / Security Policy + +## 报告安全问题 + +请优先使用 GitHub 的 [Private Vulnerability Reporting](https://github.com/xr843/fojin-cli/security/advisories/new) +私下报告漏洞。请不要在公开 issue、pull request、Discussion、日志或社交媒体中披露漏洞细节。 + +报告中请尽量包含: + +- 受影响的版本、平台与安装方式; +- 可复现问题的最小步骤或概念验证; +- 预期影响,以及已知的利用条件; +- 可行的缓解或修复建议(如有)。 + +请删除令牌、凭据、个人数据和不必要的大型数据文件。若 GitHub 没有显示私密报告表单,请不要改用公开 +issue 披露细节;保留报告,或仅通过你与维护者已经建立的私密渠道联系。项目没有公布安全报告邮箱。 + +维护者会在私密报告中协调确认、修复与披露。在修复可用并完成协调披露前,请保持细节私密。 + +## Reporting a vulnerability + +Please use GitHub [Private Vulnerability Reporting](https://github.com/xr843/fojin-cli/security/advisories/new) +as the preferred reporting channel. Do not disclose vulnerability details in a public issue, pull request, +Discussion, log, or social-media post. + +Include the affected version and platform, minimal reproduction steps or a proof of concept, the expected +impact and prerequisites, and any suggested mitigation. Remove credentials, personal data, and unrelated +large datasets. If the private form is unavailable, do not substitute a public report containing details; +retain the report or use only a private channel you have already established with a maintainer. This project +does not publish a security-reporting email address. + +Please keep the report private while maintainers coordinate validation, remediation, and disclosure. diff --git a/install.sh b/install.sh index e1d4c40..ff24052 100755 --- a/install.sh +++ b/install.sh @@ -48,12 +48,73 @@ else fi asset="fojin-${version#v}-${target}.tar.gz" -url="https://github.com/$REPO/releases/download/$version/$asset" +release_url="https://github.com/$REPO/releases/download/$version" +url="$release_url/$asset" +checksums_url="$release_url/SHA256SUMS" + +if command -v sha256sum >/dev/null 2>&1; then + checksum_tool="sha256sum" +elif command -v shasum >/dev/null 2>&1; then + checksum_tool="shasum" +else + die "需要 sha256sum 或 shasum 来验证下载文件" +fi say "下载 fojin $version ($target) ..." tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT curl -fsSL "$url" -o "$tmp/$asset" || die "下载失败: $url" +curl -fsSL "$checksums_url" -o "$tmp/SHA256SUMS" || die "下载失败: $checksums_url" + +expected_checksum=$(awk -v asset="$asset" ' + { + candidate = 0 + for (field = 1; field <= NF; field++) { + if ($field == asset || $field == "*" asset) { + candidate = 1 + } + } + if (!candidate) { + next + } + + candidates++ + if (NF != 2 || $2 != asset || length($1) != 64 || $1 ~ /[^0-9A-Fa-f]/) { + malformed = 1 + } else { + checksum = tolower($1) + } + } + END { + if (candidates != 1 || malformed) { + exit 1 + } + print checksum + } +' "$tmp/SHA256SUMS") || die "SHA256SUMS 中缺少唯一且格式正确的 $asset 记录" + +case "$checksum_tool" in + sha256sum) checksum_output=$(sha256sum <"$tmp/$asset") || die "无法计算下载文件的 SHA-256" ;; + shasum) checksum_output=$(shasum -a 256 <"$tmp/$asset") || die "无法计算下载文件的 SHA-256" ;; +esac +actual_checksum=$(printf '%s\n' "$checksum_output" | awk ' + { + lines++ + if (NF != 2 || $2 != "-" || length($1) != 64 || $1 ~ /[^0-9A-Fa-f]/) { + malformed = 1 + } else { + checksum = tolower($1) + } + } + END { + if (lines != 1 || malformed) { + exit 1 + } + print checksum + } +') || die "SHA-256 工具返回了格式错误的结果" +[ "$actual_checksum" = "$expected_checksum" ] || die "下载文件的 SHA-256 校验失败: $asset" + tar -xzf "$tmp/$asset" -C "$tmp" mkdir -p "$INSTALL_DIR" diff --git a/scripts/check-release-archive.sh b/scripts/check-release-archive.sh new file mode 100755 index 0000000..b33d037 --- /dev/null +++ b/scripts/check-release-archive.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + printf 'usage: %s \n' "$0" >&2 + exit 2 +fi + +python3 - "$1" "$2" "$3" <<'PY' +import pathlib +import stat +import sys +import tarfile +import zipfile + + +archive = pathlib.Path(sys.argv[1]) +target = sys.argv[2] +version = sys.argv[3] +top = f"fojin-{version}-{target}" +binary = "fojin.exe" if target.endswith("-windows-msvc") else "fojin" +required_files = {binary, "README.md", "LICENSE-MIT", "LICENSE-APACHE"} +expected = {top, *(f"{top}/{name}" for name in required_files)} + + +def checked_name(name: str) -> str: + if "\\" in name: + raise ValueError(f"archive member uses a backslash: {name}") + path = pathlib.PurePosixPath(name) + if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts): + raise ValueError(f"unsafe archive member path: {name}") + return name.rstrip("/") + + +def validate_members(members): + seen = set() + unix_binary_mode = None + for name, kind, mode in members: + name = checked_name(name) + if name in seen: + raise ValueError(f"duplicate archive member: {name}") + seen.add(name) + if name not in expected: + raise ValueError(f"unexpected archive member: {name}") + if name == top: + if kind != "directory": + raise ValueError(f"top-level member is not a directory: {name}") + elif kind != "file": + raise ValueError(f"archive member is not a regular file: {name}") + if name == f"{top}/{binary}": + unix_binary_mode = mode + + missing = expected - seen + if missing: + raise ValueError("missing archive members: " + ", ".join(sorted(missing))) + if binary == "fojin" and (unix_binary_mode is None or unix_binary_mode & 0o111 == 0): + raise ValueError("Unix release binary is not executable") + + +def tar_members(): + with tarfile.open(archive, "r:gz") as bundle: + for member in bundle.getmembers(): + if member.isdir(): + kind = "directory" + elif member.isfile(): + kind = "file" + else: + kind = "other" + yield member.name, kind, member.mode + + +def zip_members(): + with zipfile.ZipFile(archive) as bundle: + for member in bundle.infolist(): + mode = member.external_attr >> 16 + if member.create_system == 3: + if stat.S_ISLNK(mode): + kind = "other" + elif stat.S_ISDIR(mode): + kind = "directory" + elif stat.S_ISREG(mode): + kind = "file" + else: + kind = "other" + elif member.is_dir(): + kind = "directory" + else: + kind = "file" + yield member.filename, kind, mode + + +try: + if archive.name.endswith(".tar.gz"): + validate_members(tar_members()) + elif archive.suffix == ".zip": + validate_members(zip_members()) + else: + raise ValueError(f"unsupported archive extension: {archive.name}") +except (OSError, tarfile.TarError, zipfile.BadZipFile, ValueError) as error: + print(f"release archive validation failed: {error}", file=sys.stderr) + raise SystemExit(1) + +print(f"release archive validated: {archive.name}") +PY diff --git a/scripts/check-release-availability.sh b/scripts/check-release-availability.sh new file mode 100755 index 0000000..0d738aa --- /dev/null +++ b/scripts/check-release-availability.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + printf 'usage: %s \n' "$0" >&2 + exit 2 +fi + +repository="$1" +tag="$2" + +if [[ -z "${GH_TOKEN:-}" ]]; then + printf 'GH_TOKEN is required to inspect draft and published releases\n' >&2 + exit 1 +fi + +if ! releases="$(gh api --paginate --slurp "repos/$repository/releases?per_page=100")"; then + printf 'failed to list existing releases for %s\n' "$repository" >&2 + exit 1 +fi + +python3 - "$tag" 3<<<"$releases" <<'PY' +import json +import os +import sys + + +requested_tag = sys.argv[1] +try: + pages = json.load(os.fdopen(3)) +except (OSError, UnicodeError, json.JSONDecodeError) as error: + print(f"invalid release API JSON: {error}", file=sys.stderr) + raise SystemExit(1) + +if not isinstance(pages, list): + print("invalid release API JSON: expected an array of pages", file=sys.stderr) + raise SystemExit(1) + +for page in pages: + if not isinstance(page, list): + print("invalid release API JSON: expected each page to be an array", file=sys.stderr) + raise SystemExit(1) + for release in page: + if not isinstance(release, dict) or not isinstance(release.get("tag_name"), str): + print("invalid release API JSON: release lacks a string tag_name", file=sys.stderr) + raise SystemExit(1) + if release["tag_name"] == requested_tag: + print(f"release already exists for tag {requested_tag}", file=sys.stderr) + raise SystemExit(1) + +print(f"no existing release found for tag {requested_tag}") +PY diff --git a/scripts/check-release-version.sh b/scripts/check-release-version.sh new file mode 100755 index 0000000..838d08b --- /dev/null +++ b/scripts/check-release-version.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + printf 'usage: %s \n' "$0" >&2 + exit 2 +fi + +tag="$1" +package_version="$2" +stable_component='(0|[1-9][0-9]*)' + +if [[ ! "$tag" =~ ^v${stable_component}\.${stable_component}\.${stable_component}$ ]]; then + printf 'release tag must have stable vX.Y.Z form: %s\n' "$tag" >&2 + exit 1 +fi + +if [[ "$tag" != "v${package_version}" ]]; then + printf 'release tag %s does not match package version %s\n' "$tag" "$package_version" >&2 + exit 1 +fi + +printf 'release tag %s matches package version %s\n' "$tag" "$package_version" diff --git a/src/data.rs b/src/data.rs index 868828d..74f5a9b 100644 --- a/src/data.rs +++ b/src/data.rs @@ -575,17 +575,21 @@ pub fn dataset_stats(conn: &rusqlite::Connection) -> Result { other => Err(other), })?) }; - let total: u64 = conn.query_row("SELECT COUNT(*) FROM parallels", [], |r| r.get(0))?; + let count = |row: &rusqlite::Row<'_>, index: usize| -> rusqlite::Result { + let value = row.get::<_, i64>(index)?; + u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(index, value)) + }; + let total = conn.query_row("SELECT COUNT(*) FROM parallels", [], |r| count(r, 0))?; let texts: u64 = conn.query_row( "SELECT COUNT(DISTINCT cbeta_id) FROM parallels WHERE cbeta_id IS NOT NULL", [], - |r| r.get(0), + |r| count(r, 0), )?; let mut stmt = conn.prepare( "SELECT foreign_lang, COUNT(*) FROM parallels GROUP BY foreign_lang ORDER BY foreign_lang", )?; let by_lang = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .query_map([], |r| Ok((r.get::<_, String>(0)?, count(r, 1)?)))? .collect::>>()?; Ok(DatasetStats { version: meta_get("version")?, diff --git a/src/query.rs b/src/query.rs index db44aa5..65ce7ef 100644 --- a/src/query.rs +++ b/src/query.rs @@ -44,8 +44,8 @@ fn fetch_rows(conn: &Connection, norm_query: &str) -> rusqlite::Result> ( "SELECT zh_text,zh_norm,foreign_lang,foreign_text,confidence,\ cbeta_id,title_zh,juan_num \ - FROM parallels WHERE zh_norm LIKE ?1", - format!("%{norm_query}%"), + FROM parallels WHERE instr(zh_norm, ?1) > 0", + norm_query.to_owned(), ) }; let mut stmt = conn.prepare(sql)?; @@ -262,13 +262,17 @@ pub fn texts_matching( WHERE cbeta_id IS NOT NULL AND title_zh IS NOT NULL \ GROUP BY cbeta_id, title_zh, foreign_lang ORDER BY cbeta_id, foreign_lang", )?; + let count = |row: &rusqlite::Row<'_>, index: usize| -> rusqlite::Result { + let value = row.get::<_, i64>(index)?; + u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(index, value)) + }; let rows = stmt .query_map([], |r| { Ok(( r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, - r.get::<_, u64>(3)?, + count(r, 3)?, )) })? .collect::>>()?; @@ -295,15 +299,26 @@ pub fn texts_matching( fn cap_per_lang(parallels: Vec, top: usize) -> Vec { let mut by_lang: BTreeMap> = BTreeMap::new(); for p in parallels { - by_lang.entry(p.lang.clone()).or_default().push(p); + let items = by_lang.entry(p.lang.clone()).or_default(); + if let Some(existing) = items.iter_mut().find(|existing| existing.text == p.text) { + if p.confidence + .unwrap_or(0.0) + .total_cmp(&existing.confidence.unwrap_or(0.0)) + == Ordering::Greater + { + *existing = p; + } + } else { + items.push(p); + } } let mut out = Vec::new(); for (_lang, mut items) in by_lang { items.sort_by(|a, b| { b.confidence .unwrap_or(0.0) - .partial_cmp(&a.confidence.unwrap_or(0.0)) - .unwrap_or(Ordering::Equal) + .total_cmp(&a.confidence.unwrap_or(0.0)) + .then_with(|| a.text.cmp(&b.text)) }); items.truncate(top.max(1)); // floor of 1: top=0 would yield a useless 0-parallel result out.extend(items); diff --git a/src/render.rs b/src/render.rs index bd7b4c0..839136a 100644 --- a/src/render.rs +++ b/src/render.rs @@ -83,7 +83,7 @@ fn group_digits(n: u64) -> String { let s = n.to_string(); let mut out = String::with_capacity(s.len() + s.len() / 3); for (i, ch) in s.chars().enumerate() { - if i > 0 && (s.len() - i) % 3 == 0 { + if i > 0 && (s.len() - i).is_multiple_of(3) { out.push(','); } out.push(ch); diff --git a/tests/install-script.sh b/tests/install-script.sh new file mode 100755 index 0000000..c7d16e6 --- /dev/null +++ b/tests/install-script.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +set -uo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +installer="$repo_root/install.sh" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +real_awk="$(command -v awk)" +real_cp="$(command -v cp)" +real_gzip="$(command -v gzip)" +real_install="$(command -v install)" +real_mkdir="$(command -v mkdir)" +real_mktemp="$(command -v mktemp)" +real_rm="$(command -v rm)" +real_sh="$(command -v sh)" +real_shasum="$(command -v shasum || true)" +real_sha256sum="$(command -v sha256sum || true)" +real_tar="$(command -v tar)" + +version="v9.8.7" +target="x86_64-unknown-linux-gnu" +asset="fojin-${version#v}-${target}.tar.gz" +release_base="https://github.com/xr843/fojin-cli/releases/download/$version" +fixture_root="$tmp/fixture/fojin-${version#v}-${target}" +fixture_archive="$tmp/$asset" +mkdir -p "$fixture_root" +cat >"$fixture_root/fojin" <<'EOF' +#!/bin/sh +printf 'fojin 9.8.7\n' +EOF +chmod 755 "$fixture_root/fojin" +tar -C "$tmp/fixture" -czf "$fixture_archive" "fojin-${version#v}-${target}" + +if [[ -n "$real_sha256sum" ]]; then + digest="$($real_sha256sum "$fixture_archive" | awk '{ print $1 }')" +elif [[ -n "$real_shasum" ]]; then + digest="$($real_shasum -a 256 "$fixture_archive" | awk '{ print $1 }')" +else + printf 'FAIL: test host needs sha256sum or shasum\n' >&2 + exit 1 +fi + +failures=0 + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + failures=$((failures + 1)) +} + +write_common_tools() { + local bin="$1" + + mkdir -p "$bin" + ln -s "$real_awk" "$bin/awk" + ln -s "$real_gzip" "$bin/gzip" + ln -s "$real_mkdir" "$bin/mkdir" + ln -s "$real_mktemp" "$bin/mktemp" + ln -s "$real_rm" "$bin/rm" + + cat >"$bin/uname" <<'EOF' +#!/bin/sh +case "${1:-}" in + -s) printf 'Linux\n' ;; + -m) printf 'x86_64\n' ;; + *) exit 1 ;; +esac +EOF + + cat >"$bin/curl" <<'EOF' +#!/bin/sh +set -eu +url= +output= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + shift + output="$1" + ;; + https://*) url="$1" ;; + esac + shift +done +[ -n "$url" ] && [ -n "$output" ] || exit 2 +printf '%s\n' "$url" >>"$CURL_LOG" +case "$url" in + */"$ASSET") "$REAL_CP" "$FIXTURE_ARCHIVE" "$output" ;; + */SHA256SUMS) + [ "$CHECKSUM_MODE" != "download-failure" ] || exit 22 + "$REAL_CP" "$CHECKSUM_FIXTURE" "$output" + ;; + *) exit 22 ;; +esac +EOF + + cat >"$bin/tar" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >>"$TAR_LOG" +exec "$REAL_TAR" "$@" +EOF + + cat >"$bin/install" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >>"$INSTALL_LOG" +exec "$REAL_INSTALL" "$@" +EOF + + chmod +x "$bin/curl" "$bin/install" "$bin/tar" "$bin/uname" +} + +write_hash_tool() { + local bin="$1" + local kind="$2" + + case "$kind" in + sha256sum) + [[ -n "$real_sha256sum" ]] || return 1 + cat >"$bin/sha256sum" <<'EOF' +#!/bin/sh +printf 'sha256sum %s\n' "$*" >>"$HASH_LOG" +PATH=/usr/bin:/bin exec "$REAL_SHA256SUM" "$@" +EOF + chmod +x "$bin/sha256sum" + ;; + shasum) + [[ -n "$real_shasum" ]] || return 1 + cat >"$bin/shasum" <<'EOF' +#!/bin/sh +printf 'shasum %s\n' "$*" >>"$HASH_LOG" +PATH=/usr/bin:/bin exec "$REAL_SHASUM" "$@" +EOF + chmod +x "$bin/shasum" + ;; + hash-extra-field) + cat >"$bin/sha256sum" <<'EOF' +#!/bin/sh +printf 'sha256sum %s\n' "$*" >>"$HASH_LOG" +printf '%s - unexpected\n' "$EXPECTED_DIGEST" +EOF + chmod +x "$bin/sha256sum" + ;; + hash-extra-line) + cat >"$bin/sha256sum" <<'EOF' +#!/bin/sh +printf 'sha256sum %s\n' "$*" >>"$HASH_LOG" +printf '%s -\nunexpected output\n' "$EXPECTED_DIGEST" +EOF + chmod +x "$bin/sha256sum" + ;; + none) : ;; + *) return 1 ;; + esac +} + +write_checksum_fixture() { + local scenario="$1" + local destination="$2" + local short_digest="${digest%?}" + local non_hex_digest="${digest%?}z" + + case "$scenario" in + valid) + printf '%s unrelated-one.tar.gz\n%s %s\n%s unrelated-two.zip\n' \ + "$digest" "$digest" "$asset" "$digest" >"$destination" + ;; + missing-record) printf '%s unrelated.tar.gz\n' "$digest" >"$destination" ;; + duplicate-record) + printf '%s %s\n%s %s\n' "$digest" "$asset" "$digest" "$asset" >"$destination" + ;; + valid-plus-marker-target) + printf '%s %s\n%s marker %s\n' "$digest" "$asset" "$digest" "$asset" >"$destination" + ;; + valid-plus-star-target) + printf '%s %s\n%s *%s\n' "$digest" "$asset" "$digest" "$asset" >"$destination" + ;; + malformed-digest) printf '%s %s\n' "$short_digest" "$asset" >"$destination" ;; + non-hex-digest) printf '%s %s\n' "$non_hex_digest" "$asset" >"$destination" ;; + extra-field) printf '%s %s unexpected\n' "$digest" "$asset" >"$destination" ;; + wrong-digest) printf '%064d %s\n' 0 "$asset" >"$destination" ;; + download-failure) : >"$destination" ;; + *) return 1 ;; + esac +} + +prepare_case() { + local name="$1" + local scenario="$2" + local hash_kind="$3" + local case_dir="$tmp/cases/$name" + + mkdir -p "$case_dir/home" "$case_dir/install" + : >"$case_dir/curl.log" + : >"$case_dir/hash.log" + : >"$case_dir/install.log" + : >"$case_dir/tar.log" + write_checksum_fixture "$scenario" "$case_dir/SHA256SUMS" + write_common_tools "$case_dir/bin" + write_hash_tool "$case_dir/bin" "$hash_kind" + printf '%s\n' "$case_dir" +} + +run_installer() { + local case_dir="$1" + local scenario="$2" + + PATH="$case_dir/bin" \ + HOME="$case_dir/home" \ + FOJIN_INSTALL_DIR="$case_dir/install" \ + FOJIN_VERSION="$version" \ + ASSET="$asset" \ + CHECKSUM_FIXTURE="$case_dir/SHA256SUMS" \ + CHECKSUM_MODE="$scenario" \ + CURL_LOG="$case_dir/curl.log" \ + HASH_LOG="$case_dir/hash.log" \ + INSTALL_LOG="$case_dir/install.log" \ + TAR_LOG="$case_dir/tar.log" \ + FIXTURE_ARCHIVE="$fixture_archive" \ + REAL_CP="$real_cp" \ + REAL_INSTALL="$real_install" \ + REAL_SHA256SUM="$real_sha256sum" \ + REAL_SHASUM="$real_shasum" \ + REAL_TAR="$real_tar" \ + EXPECTED_DIGEST="$digest" \ + "$real_sh" "$installer" +} + +assert_release_urls() { + local name="$1" + local curl_log="$2" + local expected="$release_base/$asset +$release_base/SHA256SUMS" + local actual + actual="$(cat "$curl_log")" + [[ "$actual" == "$expected" ]] || fail "$name did not download archive and SHA256SUMS from the same release" +} + +expect_success() { + local name="$1" + local hash_kind="$2" + local failures_before="$failures" + local case_dir + case_dir="$(prepare_case "$name" valid "$hash_kind")" || { + fail "$name could not prepare $hash_kind fixture" + return + } + + if ! run_installer "$case_dir" valid >"$case_dir/output" 2>&1; then + cat "$case_dir/output" >&2 + fail "$name unexpectedly failed" + return + fi + assert_release_urls "$name" "$case_dir/curl.log" + [[ "$(wc -l <"$case_dir/tar.log")" -eq 1 ]] || fail "$name did not extract exactly once" + [[ "$(wc -l <"$case_dir/install.log")" -eq 1 ]] || fail "$name did not invoke install exactly once" + grep -q "^$hash_kind " "$case_dir/hash.log" || fail "$name did not use $hash_kind" + [[ -x "$case_dir/install/fojin" ]] || fail "$name did not install the binary" + if [[ "$failures" -eq "$failures_before" ]]; then + printf 'PASS: %s\n' "$name" + fi +} + +expect_failure_before_extract() { + local name="$1" + local scenario="$2" + local hash_kind="${3:-sha256sum}" + local failures_before="$failures" + local case_dir + case_dir="$(prepare_case "$name" "$scenario" "$hash_kind")" || { + fail "$name could not prepare fixture" + return + } + + if run_installer "$case_dir" "$scenario" >"$case_dir/output" 2>&1; then + fail "$name unexpectedly installed" + fi + if [[ -s "$case_dir/tar.log" ]]; then + fail "$name invoked tar before rejecting checksum metadata: $(cat "$case_dir/tar.log")" + fi + if [[ -s "$case_dir/install.log" ]]; then + fail "$name invoked install after checksum rejection: $(cat "$case_dir/install.log")" + fi + if [[ -e "$case_dir/install/fojin" ]]; then + fail "$name installed a binary after checksum rejection" + fi + if [[ "$failures" -eq "$failures_before" ]]; then + printf 'PASS: %s failed before extraction\n' "$name" + fi +} + +expect_success "valid checksum via sha256sum" sha256sum +if [[ -n "$real_shasum" ]]; then + expect_success "valid checksum via shasum fallback" shasum +else + fail "test host does not provide shasum for fallback coverage" +fi + +expect_failure_before_extract "missing SHA256SUMS download" download-failure +expect_failure_before_extract "missing target checksum record" missing-record +expect_failure_before_extract "duplicate target checksum record" duplicate-record +expect_failure_before_extract "valid plus malformed target candidate" valid-plus-marker-target +expect_failure_before_extract "valid plus star target candidate" valid-plus-star-target +expect_failure_before_extract "malformed checksum digest" malformed-digest +expect_failure_before_extract "non-hex checksum digest" non-hex-digest +expect_failure_before_extract "checksum record with extra field" extra-field +expect_failure_before_extract "wrong archive checksum" wrong-digest +expect_failure_before_extract "missing checksum utility" valid none +expect_failure_before_extract "hash output with extra field" valid hash-extra-field +expect_failure_before_extract "hash output with extra line" valid hash-extra-line + +if ((failures != 0)); then + printf '%d installer test(s) failed\n' "$failures" >&2 + exit 1 +fi + +printf 'installer script checks passed\n' diff --git a/tests/query.rs b/tests/query.rs index ace605f..7c326b9 100644 --- a/tests/query.rs +++ b/tests/query.rs @@ -73,13 +73,40 @@ fn no_match_is_empty_not_error() { } #[test] -fn short_query_uses_like_fallback() { +fn short_query_uses_substring_fallback() { let conn = fixture(); let g = search(&conn, "色即", None, 3).unwrap(); // 2 chars < 3 assert_eq!(g.len(), 1); assert_eq!(g[0].zh_text, "色即是空"); } +#[test] +fn short_query_treats_percent_and_underscore_literally() { + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + for (zt, zn) in [ + ("literal-percent", "經%甲文"), + ("percent-decoy", "經別甲文"), + ("literal-underscore", "經_乙文"), + ("underscore-decoy", "經別乙文"), + ] { + conn.execute( + "INSERT INTO parallels(zh_text,zh_norm,foreign_lang,foreign_text,confidence,cbeta_id,title_zh,juan_num) + VALUES (?1,?2,'sa',?1,1.0,'T0001','測試',1)", + params![zt, zn], + ) + .unwrap(); + } + + let percent = search(&conn, "%甲", None, 3).unwrap(); + assert_eq!(percent.len(), 1); + assert_eq!(percent[0].zh_text, "literal-percent"); + + let underscore = search(&conn, "_乙", None, 3).unwrap(); + assert_eq!(underscore.len(), 1); + assert_eq!(underscore[0].zh_text, "literal-underscore"); +} + #[test] fn per_lang_cap_and_order() { let conn = Connection::open_in_memory().unwrap(); @@ -103,6 +130,79 @@ fn per_lang_cap_and_order() { ); } +#[test] +fn duplicate_parallel_keeps_highest_confidence_before_cap() { + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + for (text, confidence) in [ + ("duplicate", 0.95_f64), + ("duplicate", 0.90), + ("runner-up", 0.80), + ] { + conn.execute( + "INSERT INTO parallels(zh_text,zh_norm,foreign_lang,foreign_text,confidence,cbeta_id,title_zh,juan_num) + VALUES ('色即是空','色即是空','sa',?1,?2,'T0251','心經',1)", + params![text, confidence], + ) + .unwrap(); + } + + let groups = search(&conn, "色即是空", None, 2).unwrap(); + let parallels = &groups[0].parallels; + assert_eq!(parallels.len(), 2); + assert_eq!(parallels[0].text, "duplicate"); + assert_eq!(parallels[0].confidence, Some(0.95)); + assert_eq!(parallels[1].text, "runner-up"); +} + +#[test] +fn duplicate_text_is_preserved_across_groups_and_languages() { + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + for (zt, lang, confidence, cbeta_id) in [ + ("甲色即是空", "sa", 0.90_f64, "T0001"), + ("甲色即是空", "bo", 0.80, "T0001"), + ("乙色即是空", "sa", 0.70, "T0002"), + ] { + conn.execute( + "INSERT INTO parallels(zh_text,zh_norm,foreign_lang,foreign_text,confidence,cbeta_id,title_zh,juan_num) + VALUES (?1,'色即是空',?2,'shared text',?3,?4,'測試',1)", + params![zt, lang, confidence, cbeta_id], + ) + .unwrap(); + } + + let groups = search(&conn, "色即是空", None, 3).unwrap(); + assert_eq!(groups.len(), 2, "the same text remains in both groups"); + assert_eq!(groups[0].parallels.len(), 2); + assert_eq!(groups[0].parallels[0].lang, "bo"); + assert_eq!(groups[0].parallels[1].lang, "sa"); + assert_eq!(groups[1].parallels.len(), 1); + assert_eq!(groups[1].parallels[0].text, "shared text"); +} + +#[test] +fn equal_confidence_parallels_use_text_tie_breaker() { + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + for text in ["zeta", "alpha"] { + conn.execute( + "INSERT INTO parallels(zh_text,zh_norm,foreign_lang,foreign_text,confidence,cbeta_id,title_zh,juan_num) + VALUES ('色即是空','色即是空','sa',?1,0.9,'T0251','心經',1)", + [text], + ) + .unwrap(); + } + + let groups = search(&conn, "色即是空", None, 3).unwrap(); + let texts: Vec<_> = groups[0] + .parallels + .iter() + .map(|parallel| parallel.text.as_str()) + .collect(); + assert_eq!(texts, ["alpha", "zeta"]); +} + #[test] fn top_zero_floors_to_one() { let conn = fixture(); diff --git a/tests/release-scripts.sh b/tests/release-scripts.sh new file mode 100755 index 0000000..ea24138 --- /dev/null +++ b/tests/release-scripts.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version_check="$repo_root/scripts/check-release-version.sh" +archive_check="$repo_root/scripts/check-release-archive.sh" +availability_check="$repo_root/scripts/check-release-availability.sh" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +expect_pass() { + local name="$1" + shift + if ! "$@" >"$tmp/output" 2>&1; then + cat "$tmp/output" >&2 + fail "$name unexpectedly failed" + fi +} + +expect_fail() { + local name="$1" + shift + if "$@" >"$tmp/output" 2>&1; then + fail "$name unexpectedly passed" + fi +} + +expect_pass "matching stable release tag" "$version_check" v1.2.3 1.2.3 +expect_fail "mismatched release tag" "$version_check" v1.2.4 1.2.3 +expect_fail "missing v prefix" "$version_check" 1.2.3 1.2.3 +expect_fail "prerelease tag" "$version_check" v1.2.3-rc.1 1.2.3-rc.1 +expect_fail "non-numeric tag" "$version_check" v1.2.x 1.2.x + +mkdir "$tmp/fake-bin" +cat >"$tmp/fake-bin/gh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +expected=(api --paginate --slurp "repos/acme/fojin/releases?per_page=100") +actual=("$@") +if [[ $# -ne ${#expected[@]} ]]; then + printf 'unexpected gh argument count: %s\n' "$#" >&2 + exit 64 +fi +for index in "${!expected[@]}"; do + if [[ "${actual[$index]}" != "${expected[$index]}" ]]; then + printf 'unexpected gh argument %s: %s\n' "$index" "${actual[$index]}" >&2 + exit 64 + fi +done +if [[ "${FAKE_GH_FAIL:-0}" == 1 ]]; then + printf 'simulated gh API failure\n' >&2 + exit 42 +fi +printf '%s\n' "${FAKE_GH_RESPONSE-[]}" +SH +chmod +x "$tmp/fake-bin/gh" +fake_path="$tmp/fake-bin:$PATH" + +expect_pass "release availability with no releases" env PATH="$fake_path" GH_TOKEN=test-token \ + FAKE_GH_RESPONSE='[]' "$availability_check" acme/fojin v0.3.0 +expect_pass "release availability with a different tag" env PATH="$fake_path" GH_TOKEN=test-token \ + FAKE_GH_RESPONSE='[[{"tag_name":"v0.2.0","draft":false,"assets":[]}]]' \ + "$availability_check" acme/fojin v0.3.0 +expect_fail "existing published release" env PATH="$fake_path" GH_TOKEN=test-token \ + FAKE_GH_RESPONSE='[[{"tag_name":"v0.3.0","draft":false,"assets":[]}]]' \ + "$availability_check" acme/fojin v0.3.0 +expect_fail "existing draft release" env PATH="$fake_path" GH_TOKEN=test-token \ + FAKE_GH_RESPONSE='[[{"tag_name":"v0.3.0","draft":true,"assets":[]}]]' \ + "$availability_check" acme/fojin v0.3.0 +expect_fail "existing empty release" env PATH="$fake_path" GH_TOKEN=test-token \ + FAKE_GH_RESPONSE='[[{"tag_name":"v0.3.0","assets":[]}]]' \ + "$availability_check" acme/fojin v0.3.0 +expect_fail "existing release with assets" env PATH="$fake_path" GH_TOKEN=test-token \ + FAKE_GH_RESPONSE='[[{"tag_name":"v0.3.0","assets":[{"name":"fojin.tar.gz"}]}]]' \ + "$availability_check" acme/fojin v0.3.0 +expect_fail "gh API failure" env PATH="$fake_path" GH_TOKEN=test-token FAKE_GH_FAIL=1 \ + "$availability_check" acme/fojin v0.3.0 +expect_fail "malformed release JSON" env PATH="$fake_path" GH_TOKEN=test-token \ + FAKE_GH_RESPONSE='not-json' "$availability_check" acme/fojin v0.3.0 + +target="x86_64-unknown-linux-gnu" +version="1.2.3" +staging="fojin-${version}-${target}" +mkdir -p "$tmp/$staging" +printf '#!/bin/sh\nexit 0\n' >"$tmp/$staging/fojin" +chmod 755 "$tmp/$staging/fojin" +printf 'readme\n' >"$tmp/$staging/README.md" +printf 'MIT\n' >"$tmp/$staging/LICENSE-MIT" +printf 'Apache\n' >"$tmp/$staging/LICENSE-APACHE" +tar -C "$tmp" -czf "$tmp/valid.tar.gz" "$staging" +expect_pass "valid Unix archive" "$archive_check" "$tmp/valid.tar.gz" "$target" "$version" + +printf 'extra\n' >"$tmp/$staging/EXTRA" +tar -C "$tmp" -czf "$tmp/extra.tar.gz" "$staging" +expect_fail "archive with an extra file" "$archive_check" "$tmp/extra.tar.gz" "$target" "$version" +rm "$tmp/$staging/EXTRA" + +rm "$tmp/$staging/LICENSE-APACHE" +tar -C "$tmp" -czf "$tmp/missing.tar.gz" "$staging" +expect_fail "archive missing a license" "$archive_check" "$tmp/missing.tar.gz" "$target" "$version" +printf 'Apache\n' >"$tmp/$staging/LICENSE-APACHE" + +chmod 644 "$tmp/$staging/fojin" +tar -C "$tmp" -czf "$tmp/not-executable.tar.gz" "$staging" +expect_fail "archive with non-executable Unix binary" "$archive_check" "$tmp/not-executable.tar.gz" "$target" "$version" +chmod 755 "$tmp/$staging/fojin" + +rm "$tmp/$staging/README.md" +ln -s LICENSE-MIT "$tmp/$staging/README.md" +tar -C "$tmp" -czf "$tmp/symlink.tar.gz" "$staging" +expect_fail "archive containing a symlink" "$archive_check" "$tmp/symlink.tar.gz" "$target" "$version" +rm "$tmp/$staging/README.md" +printf 'readme\n' >"$tmp/$staging/README.md" + +windows_target="x86_64-pc-windows-msvc" +windows_staging="fojin-${version}-${windows_target}" +mkdir -p "$tmp/$windows_staging" +printf 'binary\n' >"$tmp/$windows_staging/fojin.exe" +printf 'readme\n' >"$tmp/$windows_staging/README.md" +printf 'MIT\n' >"$tmp/$windows_staging/LICENSE-MIT" +printf 'Apache\n' >"$tmp/$windows_staging/LICENSE-APACHE" +python3 -c 'import shutil, sys; shutil.make_archive(sys.argv[2], "zip", root_dir=sys.argv[1], base_dir=sys.argv[3])' \ + "$tmp" "$tmp/valid" "$windows_staging" +expect_pass "valid Windows archive" "$archive_check" "$tmp/valid.zip" "$windows_target" "$version" + +python3 - "$tmp/symlink-directory.zip" "$tmp/fifo-file.zip" "$windows_staging" <<'PY' +import stat +import sys +import zipfile + + +symlink_archive, fifo_archive, staging = sys.argv[1:] +files = ("fojin.exe", "README.md", "LICENSE-MIT", "LICENSE-APACHE") + + +def add_member(bundle, name, mode, data=b"fixture\n"): + member = zipfile.ZipInfo(name) + member.create_system = 3 + member.external_attr = mode << 16 + bundle.writestr(member, data) + + +with zipfile.ZipFile(symlink_archive, "w") as bundle: + add_member(bundle, f"{staging}/", stat.S_IFLNK | 0o777, b"elsewhere") + for name in files: + add_member(bundle, f"{staging}/{name}", stat.S_IFREG | 0o644) + +with zipfile.ZipFile(fifo_archive, "w") as bundle: + add_member(bundle, f"{staging}/", stat.S_IFDIR | 0o755, b"") + for name in files: + mode = stat.S_IFIFO | 0o644 if name == "README.md" else stat.S_IFREG | 0o644 + add_member(bundle, f"{staging}/{name}", mode) +PY +expect_fail "ZIP directory-shaped symlink" "$archive_check" "$tmp/symlink-directory.zip" "$windows_target" "$version" +expect_fail "ZIP expected file encoded as FIFO" "$archive_check" "$tmp/fifo-file.zip" "$windows_target" "$version" + +(cd "$tmp" && sha256sum valid.tar.gz valid.zip >SHA256SUMS) +expect_pass "generated checksums" sh -c "cd '$tmp' && sha256sum -c SHA256SUMS" +printf 'tampered\n' >>"$tmp/valid.zip" +expect_fail "tampered archive checksum" sh -c "cd '$tmp' && sha256sum -c SHA256SUMS" + +expect_pass "release publication refuses asset overwrite" python3 - "$repo_root/.github/workflows/release.yml" <<'PY' +from pathlib import Path +import sys + + +def refuses_overwrite(workflow): + lines = workflow.splitlines() + uses_index = next( + index for index, line in enumerate(lines) if "uses: softprops/action-gh-release@" in line + ) + uses_indent = len(lines[uses_index]) - len(lines[uses_index].lstrip()) + step = [] + for line in lines[uses_index + 1 :]: + indent = len(line) - len(line.lstrip()) + if line.strip() and indent <= uses_indent and line.lstrip().startswith("-"): + break + step.append(line.strip()) + values = [line.partition(":")[2].strip() for line in step if line.startswith("overwrite_files:")] + return values == ["false"] + + +workflow = Path(sys.argv[1]).read_text() +setting = " overwrite_files: false\n" +if workflow.count(setting) != 1 or not refuses_overwrite(workflow): + raise SystemExit("softprops release step must set overwrite_files: false exactly once") +if refuses_overwrite(workflow.replace(setting, "", 1)): + raise SystemExit("missing overwrite_files setting was accepted") +if refuses_overwrite(workflow.replace("overwrite_files: false", "overwrite_files: true", 1)): + raise SystemExit("overwrite_files: true was accepted") + +concurrency = "concurrency:\n group: release-${{ github.ref }}\n cancel-in-progress: false\n" +if concurrency not in workflow: + raise SystemExit("release workflow must serialize runs per ref without cancellation") +guard_index = workflow.find('run: verification-tools/check-release-availability.sh "$REPOSITORY" "$REF_NAME"') +publish_index = workflow.find("uses: softprops/action-gh-release@") +if guard_index < 0 or publish_index < 0 or guard_index >= publish_index: + raise SystemExit("release availability guard must run before softprops publication") +for required in ( + "GH_TOKEN: ${{ github.token }}", + "REPOSITORY: ${{ github.repository }}", + "REF_NAME: ${{ github.ref_name }}", + "scripts/check-release-availability.sh", +): + if required not in workflow: + raise SystemExit(f"release availability workflow wiring missing: {required}") +PY + +printf 'release script checks passed\n' diff --git a/tests/schema.rs b/tests/schema.rs index 2323350..c6ed0fb 100644 --- a/tests/schema.rs +++ b/tests/schema.rs @@ -1,5 +1,14 @@ use rusqlite::Connection; +#[test] +fn bundled_sqlite_is_at_least_3_53_2() { + assert!( + rusqlite::version_number() >= 3_053_002, + "runtime SQLite {} must be at least 3.53.2", + rusqlite::version() + ); +} + #[test] fn schema_creates_tables_and_fts_autopopulates() { let conn = Connection::open_in_memory().unwrap();