diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c389657..4891968 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,18 +3,26 @@ on: push: branches: ["main"] pull_request: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 with: + toolchain: 1.94.1 components: rustfmt, clippy - name: Install cargo-deny - run: cargo install cargo-deny@0.18.3 --locked + run: cargo install cargo-deny@0.20.2 --locked + - name: Install cargo-audit + run: cargo install cargo-audit@0.22.2 --locked - name: Install cbindgen - run: cargo install cbindgen --locked + run: cargo install cbindgen@0.29.4 --locked - name: Quality gates run: make check - name: Verify generated C header is up to date @@ -22,11 +30,54 @@ jobs: make cbindgen git diff --exit-code ffi/docprims-ffi/docprims.h diff -u ffi/docprims-ffi/docprims.h bindings/go/docprims/include/docprims.h + publish-check: + name: publish-check + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 + - name: Verify release tooling dependencies + run: | + python3 -c 'import sys; assert sys.version_info >= (3, 11)' + command -v jq + command -v gpg + - name: Install checksum-pinned Decernor for release tests + run: | + tool_dir="$(mktemp -d "$RUNNER_TEMP/decernor.XXXXXX")" + archive="$(awk '{ print $2 }' config/release/decernor-ci-sha256.txt)" + version="${archive#decernor_}" + version="${version%%_*}" + curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \ + "https://github.com/3leaps/decernor/releases/download/v${version}/${archive}" \ + --output "$tool_dir/$archive" + (cd "$tool_dir" && sha256sum --check "$GITHUB_WORKSPACE/config/release/decernor-ci-sha256.txt") + tar -xzf "$tool_dir/$archive" -C "$tool_dir" decernor + echo "DOCPRIMS_DECERNOR_BIN=$tool_dir/decernor" >> "$GITHUB_ENV" + - name: Package workspace + run: make release-check + - name: Release tooling tests + run: make release-tooling-test + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.88.0 + - name: Build and test on MSRV + run: make msrv test-go-vendored: runs-on: macos-14 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.22.x" - name: Run Go tests (vendored libs) diff --git a/.github/workflows/go-bindings-prep.yml b/.github/workflows/go-bindings-prep.yml index f771277..048b849 100644 --- a/.github/workflows/go-bindings-prep.yml +++ b/.github/workflows/go-bindings-prep.yml @@ -10,6 +10,11 @@ on: type: string env: CARGO_TERM_COLOR: always +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: validate: name: Validate Inputs @@ -17,7 +22,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Validate version matches VERSION file id: version run: | @@ -34,7 +39,8 @@ jobs: name: Build FFI (Linux) runs-on: ubuntu-latest container: - image: ghcr.io/fulmenhq/goneat-tools-runner-glibc:v0.3.1 + # goneat-tools-runner-glibc v0.5.6 + image: ghcr.io/fulmenhq/goneat-tools-runner-glibc@sha256:afcffa00283c765928dfef40966c282f2ca190653cf4443f634cb8f67c1d51ab options: --user root needs: validate strategy: @@ -53,7 +59,7 @@ jobs: glibc: "" artifact_suffix: linux-arm64-musl steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Verify cargo-zigbuild is available shell: bash run: | @@ -105,7 +111,7 @@ jobs: cp "target/${TARGET_DIR}/release/libdocprims_ffi.so" "dist/ffi/${{ matrix.artifact_suffix }}/" fi - name: Upload FFI artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ffi-${{ matrix.artifact_suffix }} path: dist/ffi/${{ matrix.artifact_suffix }} @@ -116,14 +122,15 @@ jobs: env: MACOSX_DEPLOYMENT_TARGET: "11.0" steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 with: + toolchain: 1.94.1 targets: aarch64-apple-darwin - name: Build FFI (arm64) run: | set -euo pipefail - cargo build --release --target aarch64-apple-darwin -p docprims-ffi + cargo build --release --target aarch64-apple-darwin -p docprims-ffi --locked - name: Package FFI run: | set -euo pipefail @@ -135,7 +142,7 @@ jobs: # This allows consumers to set rpath or place dylib alongside executable. install_name_tool -id "@rpath/libdocprims_ffi.dylib" dist/ffi/darwin-arm64/libdocprims_ffi.dylib - name: Upload FFI artifact (darwin-arm64) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ffi-darwin-arm64 path: dist/ffi/darwin-arm64 @@ -144,12 +151,13 @@ jobs: runs-on: windows-latest needs: validate steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 with: + toolchain: 1.94.1 targets: x86_64-pc-windows-gnu - name: Install MinGW - uses: msys2/setup-msys2@v2 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -159,7 +167,7 @@ jobs: shell: msys2 {0} run: | set -euo pipefail - cargo build --release --target x86_64-pc-windows-gnu -p docprims-ffi + cargo build --release --target x86_64-pc-windows-gnu -p docprims-ffi --locked - name: Package FFI shell: bash run: | @@ -169,7 +177,7 @@ jobs: cp target/x86_64-pc-windows-gnu/release/docprims_ffi.dll dist/ffi/windows-amd64/ cp target/x86_64-pc-windows-gnu/release/libdocprims_ffi.dll.a dist/ffi/windows-amd64/ - name: Upload FFI artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ffi-windows-amd64 path: dist/ffi/windows-amd64 @@ -177,18 +185,19 @@ jobs: name: Generate C Header runs-on: ubuntu-latest container: - image: ghcr.io/fulmenhq/goneat-tools-runner-glibc:v0.3.1 + # goneat-tools-runner-glibc v0.5.6 + image: ghcr.io/fulmenhq/goneat-tools-runner-glibc@sha256:afcffa00283c765928dfef40966c282f2ca190653cf4443f634cb8f67c1d51ab options: --user root needs: validate steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate C header shell: bash run: | set -euo pipefail cbindgen --config cbindgen.toml --crate docprims-ffi --output docprims.h - name: Upload header - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: c-header path: docprims.h @@ -200,16 +209,16 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Download all FFI artifacts - uses: actions/download-artifact@v4.1.3 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: ffi-* path: ffi-libs - name: Download C header - uses: actions/download-artifact@v4.1.3 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: c-header path: ffi-libs @@ -258,9 +267,9 @@ jobs: echo "Go bindings updated for $VERSION" ls -la bindings/go/docprims/lib/*/ || true - name: Create Pull Request - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ github.token }} base: main branch: go-bindings/v${{ needs.validate.outputs.version }} title: "feat(bindings): update Go prebuilt libs for v${{ needs.validate.outputs.version }}" diff --git a/.github/workflows/go-bindings.yml b/.github/workflows/go-bindings.yml index f4639f8..6ca67c1 100644 --- a/.github/workflows/go-bindings.yml +++ b/.github/workflows/go-bindings.yml @@ -2,19 +2,24 @@ name: go-bindings on: pull_request: workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: go-test-linux-amd64: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 with: - toolchain: 1.88.0 - - uses: actions/setup-go@v5 + toolchain: 1.94.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.22.x" - name: Install cbindgen - run: cargo install cbindgen --locked + run: cargo install cbindgen@0.29.4 --locked - name: Build FFI and run Go tests (glibc) run: | make go-test @@ -22,8 +27,8 @@ jobs: go-test-darwin-arm64-vendored: runs-on: macos-14 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.22.x" - name: Run Go tests (vendored darwin-arm64) @@ -33,26 +38,26 @@ jobs: go-test-darwin-arm64-shared: runs-on: macos-14 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 with: - toolchain: 1.88.0 - - uses: actions/setup-go@v5 + toolchain: 1.94.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.22.x" - name: Install cbindgen - run: cargo install cbindgen --locked + run: cargo install cbindgen@0.29.4 --locked - name: Build FFI and run Go tests (shared) run: make go-test-shared go-test-linux-amd64-musl: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 with: - toolchain: 1.88.0 - - uses: actions/setup-go@v5 + toolchain: 1.94.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.22.x" - name: Install toolchain @@ -60,11 +65,11 @@ jobs: sudo apt-get update sudo apt-get install -y musl-tools rustup target add x86_64-unknown-linux-musl - cargo install cbindgen --locked + cargo install cbindgen@0.29.4 --locked - name: Build musl static lib and run Go tests run: | make cbindgen - cargo build --release -p docprims-ffi --target x86_64-unknown-linux-musl + cargo build --release -p docprims-ffi --target x86_64-unknown-linux-musl --locked mkdir -p bindings/go/docprims/include cp ffi/docprims-ffi/docprims.h bindings/go/docprims/include/docprims.h mkdir -p bindings/go/docprims/lib/local/linux-amd64-musl diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26a86bf..94eec0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,35 +1,92 @@ +# Build the exact release asset set. A tag push opens an unsigned draft +# release; a manual run builds and validates the same set without releasing. +# Signing keys remain local to the maintainer ceremony. name: Release on: push: tags: ['v*'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false env: CARGO_TERM_COLOR: always MACOSX_DEPLOYMENT_TARGET: "11.0" jobs: - validate-version: - name: Validate Version + validate: + name: Validate version and package set runs-on: ubuntu-latest outputs: - version: ${{ steps.version.outputs.version }} + commit: ${{ steps.release.outputs.commit }} + version: ${{ steps.release.outputs.version }} + defaults: + run: + shell: bash steps: - - uses: actions/checkout@v4 - - name: Validate VERSION matches tag - id: version + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + # The strict tag guard fetches origin/main in this read-only job. + persist-credentials: true + - name: Install Rust 1.94.1 + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 + - name: Bind annotated tag to main + if: github.event_name == 'push' + env: + DOCPRIMS_RELEASE_TAG: ${{ github.ref_name }} + DOCPRIMS_REQUIRE_TAG: '1' + run: ./scripts/release-guard-tag-version.sh + - name: Record commit and version + id: release run: | - FILE_VERSION=$(cat VERSION) - TAG_VERSION=${GITHUB_REF_NAME#v} - if [ "$FILE_VERSION" != "$TAG_VERSION" ]; then - echo "VERSION mismatch: file=$FILE_VERSION tag=$TAG_VERSION" - exit 1 - fi - echo "version=$FILE_VERSION" >> "$GITHUB_OUTPUT" - echo "Version validated: $FILE_VERSION" + set -euo pipefail + ./scripts/release-guard-tag-version.sh + printf 'commit=%s\n' "$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" + printf 'version=%s\n' "$(cat VERSION)" >>"$GITHUB_OUTPUT" + - name: Verify package archives without publishing + run: make release-check + verify-signature: + name: Verify pinned tag signature and publication policy + if: github.event_name == 'push' + needs: validate + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Checkout exact signed tag and committed pin + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify committed pin and GitHub signature + env: + GH_TOKEN: ${{ github.token }} + DOCPRIMS_RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + ./scripts/verify-pinned-tag.sh + EXPECTED_OBJECT="$(git rev-parse "refs/tags/$DOCPRIMS_RELEASE_TAG")" + tag_json="$(gh api "repos/3leaps/docprims/git/tags/$EXPECTED_OBJECT")" + jq -e --arg tag "$DOCPRIMS_RELEASE_TAG" --arg commit "$(git rev-parse HEAD)" \ + '.tag == $tag and .object.type == "commit" and .object.sha == $commit and + .verification.verified == true and .verification.reason == "valid"' \ + <<<"$tag_json" >/dev/null + ./scripts/release-guard-tag-ruleset.sh --read-only --verify-tag-attestation "$EXPECTED_OBJECT" verify-go-bindings-assets: name: Verify Go Binding Assets + if: github.event_name == 'push' runs-on: ubuntu-latest - needs: validate-version + needs: validate steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Verify Go module contains prebuilt libs run: | set -euo pipefail @@ -88,36 +145,40 @@ jobs: echo "Go binding assets verified." build-linux: - name: Build Linux + name: CLI (${{ matrix.platform }}) runs-on: ubuntu-latest container: - image: ghcr.io/fulmenhq/goneat-tools-runner-glibc:v0.3.1 + # goneat-tools-runner-glibc v0.5.6 + image: ghcr.io/fulmenhq/goneat-tools-runner-glibc@sha256:afcffa00283c765928dfef40966c282f2ca190653cf4443f634cb8f67c1d51ab options: --user root - needs: validate-version + needs: validate strategy: + fail-fast: false matrix: include: - target: x86_64-unknown-linux-gnu glibc: "2.17" - artifact_suffix: linux-amd64 + platform: linux-amd64 - target: x86_64-unknown-linux-musl glibc: "" - artifact_suffix: linux-amd64-musl + platform: linux-amd64-musl - target: aarch64-unknown-linux-gnu glibc: "2.17" - artifact_suffix: linux-arm64 + platform: linux-arm64 - target: aarch64-unknown-linux-musl glibc: "" - artifact_suffix: linux-arm64-musl + platform: linux-arm64-musl steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Build CLI run: | + target="${{ matrix.target }}" if [ -n "${{ matrix.glibc }}" ]; then - cargo zigbuild --release --target ${{ matrix.target }}.${{ matrix.glibc }} -p docprims-cli - else - cargo zigbuild --release --target ${{ matrix.target }} -p docprims-cli + target="${target}.${{ matrix.glibc }}" fi + cargo zigbuild --release --locked --target "$target" -p docprims --features cli - name: Package CLI shell: bash run: | @@ -145,48 +206,71 @@ jobs: mkdir -p dist/cli cp "target/${TARGET_DIR}/release/docprims" dist/cli/docprims cp LICENSE-MIT LICENSE-APACHE dist/cli/ - VERSION=${{ needs.validate-version.outputs.version }} - tar -czf docprims-${VERSION}-${{ matrix.artifact_suffix }}.tar.gz -C dist/cli . + VERSION=${{ needs.validate.outputs.version }} + tar -czf "docprims-${VERSION}-${{ matrix.platform }}.tar.gz" -C dist/cli \ + docprims LICENSE-APACHE LICENSE-MIT - name: Upload CLI artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: cli-${{ matrix.artifact_suffix }} - path: docprims-${{ needs.validate-version.outputs.version }}-${{ matrix.artifact_suffix }}.tar.gz + name: cli-${{ matrix.platform }} + path: docprims-${{ needs.validate.outputs.version }}-${{ matrix.platform }}.tar.gz + if-no-files-found: error + retention-days: 7 build-macos: - name: Build macOS + name: CLI (${{ matrix.platform }}) runs-on: macos-14 - needs: validate-version - env: - MACOSX_DEPLOYMENT_TARGET: "11.0" + needs: validate + strategy: + matrix: + include: + - target: aarch64-apple-darwin + platform: darwin-arm64 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 with: - targets: aarch64-apple-darwin + toolchain: 1.94.1 + targets: ${{ matrix.target }} - name: Build CLI - run: cargo build --release --target aarch64-apple-darwin -p docprims-cli + run: cargo build --release --target ${{ matrix.target }} -p docprims --features cli --locked - name: Package CLI run: | + set -euo pipefail mkdir -p dist/cli - cp target/aarch64-apple-darwin/release/docprims dist/cli/docprims + cp target/${{ matrix.target }}/release/docprims dist/cli/docprims cp LICENSE-MIT LICENSE-APACHE dist/cli/ - tar -czf docprims-${{ needs.validate-version.outputs.version }}-darwin-arm64.tar.gz -C dist/cli . + # No AppleDouble or extended-attribute entries in the archive. + COPYFILE_DISABLE=1 tar --no-mac-metadata -czf \ + "docprims-${{ needs.validate.outputs.version }}-${{ matrix.platform }}.tar.gz" -C dist/cli \ + docprims LICENSE-APACHE LICENSE-MIT - name: Upload CLI artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: cli-darwin-arm64 - path: docprims-${{ needs.validate-version.outputs.version }}-darwin-arm64.tar.gz + name: cli-${{ matrix.platform }} + path: docprims-${{ needs.validate.outputs.version }}-${{ matrix.platform }}.tar.gz + if-no-files-found: error + retention-days: 7 build-windows: - name: Build Windows + name: CLI (${{ matrix.platform }}) runs-on: windows-latest - needs: validate-version + needs: validate + strategy: + matrix: + include: + - target: x86_64-pc-windows-gnu + platform: windows-amd64 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - targets: x86_64-pc-windows-gnu + persist-credentials: false + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 + targets: ${{ matrix.target }} - name: Install MinGW - uses: msys2/setup-msys2@v2 + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: MINGW64 update: true @@ -194,114 +278,169 @@ jobs: path-type: inherit - name: Build CLI (GNU target) shell: msys2 {0} - run: cargo build --release --target x86_64-pc-windows-gnu -p docprims-cli + run: cargo build --release --target ${{ matrix.target }} -p docprims --features cli --locked - name: Package CLI shell: bash run: | + set -euo pipefail mkdir -p dist/cli - cp target/x86_64-pc-windows-gnu/release/docprims.exe dist/cli/docprims.exe + cp target/${{ matrix.target }}/release/docprims.exe dist/cli/docprims.exe cp LICENSE-MIT LICENSE-APACHE dist/cli/ - cd dist/cli && 7z a -tzip ../../docprims-${{ needs.validate-version.outputs.version }}-windows-amd64.zip . + cd dist/cli && 7z a -tzip "../../docprims-${{ needs.validate.outputs.version }}-${{ matrix.platform }}.zip" \ + docprims.exe LICENSE-APACHE LICENSE-MIT - name: Upload CLI artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: cli-windows-amd64 - path: docprims-${{ needs.validate-version.outputs.version }}-windows-amd64.zip + name: cli-${{ matrix.platform }} + path: docprims-${{ needs.validate.outputs.version }}-${{ matrix.platform }}.zip + if-no-files-found: error + retention-days: 7 cbindgen: name: Generate C Header runs-on: ubuntu-latest container: - image: ghcr.io/fulmenhq/goneat-tools-runner-glibc:v0.3.1 + # goneat-tools-runner-glibc v0.5.6 + image: ghcr.io/fulmenhq/goneat-tools-runner-glibc@sha256:afcffa00283c765928dfef40966c282f2ca190653cf4443f634cb8f67c1d51ab options: --user root - needs: validate-version + needs: validate steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Generate C header run: cbindgen --config cbindgen.toml --crate docprims-ffi --output docprims.h - name: Upload header - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: c-header path: docprims.h - publish: - name: Publish Release + if-no-files-found: error + retention-days: 7 + sbom: + name: CycloneDX SBOM + needs: validate runs-on: ubuntu-latest - needs: - - validate-version - - verify-go-bindings-assets - - build-linux - - build-macos - - build-windows - - cbindgen - permissions: - contents: write + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Generate SBOM with pinned Syft image + run: | + set -euo pipefail + docker run --rm \ + --volume "$PWD:/src" \ + --workdir /src \ + anchore/syft@sha256:f94e5d9fce1f2278491a8e3a63bd5f6ddb81fdfdbb8bf7a1637565c1d5344357 \ + dir:. \ + --output "cyclonedx-json=sbom-${{ needs.validate.outputs.version }}.cdx.json" + test -s "sbom-${{ needs.validate.outputs.version }}.cdx.json" + - name: Upload SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom + path: sbom-${{ needs.validate.outputs.version }}.cdx.json + if-no-files-found: error + retention-days: 7 + assemble: + name: Assemble and validate the exact asset set + needs: [validate, build-linux, build-macos, build-windows, cbindgen, sbom] + runs-on: ubuntu-latest + defaults: + run: + shell: bash steps: - - uses: actions/checkout@v4 - - name: Download all CLI artifacts - uses: actions/download-artifact@v4.1.3 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download CLI archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: cli-* - path: release-cli + path: dist/release + merge-multiple: true - name: Download C header - uses: actions/download-artifact@v4.1.3 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: c-header - path: release-header - - name: Organize release files + path: dist/release + - name: Download SBOM + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sbom + path: dist/release + - name: Assert exact unsigned inventory + env: + DOCPRIMS_RELEASE_TAG: v${{ needs.validate.outputs.version }} run: | - mkdir -p release - - # CLI archives - find release-cli -type f \( -name '*.tar.gz' -o -name '*.zip' \) -exec mv {} release/ \; - - # Header - mv release-header/docprims.h release/ - - # Licenses - cp LICENSE-MIT LICENSE-APACHE release/ - - ls -la release/ - - name: Create Release - uses: softprops/action-gh-release@v2 + set -euo pipefail + cp LICENSE-MIT LICENSE-APACHE dist/release/ + ./scripts/validate-release-assets.sh dist/release base + - name: Upload release asset set + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-assets + path: dist/release + if-no-files-found: error + retention-days: 7 + draft: + name: Create exact unsigned draft + if: github.event_name == 'push' + needs: [validate, verify-signature, verify-go-bindings-assets, assemble] + runs-on: ubuntu-latest + permissions: + contents: write + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Download release asset set + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-assets + path: dist/release + - name: Assert exact unsigned inventory + env: + GITHUB_TOKEN: ${{ github.token }} + DOCPRIMS_RELEASE_TAG: ${{ github.ref_name }} + DOCPRIMS_REQUIRE_TAG: '1' + run: | + set -euo pipefail + auth_header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + git config --local http.https://github.com/.extraheader "$auth_header" + cleanup_credentials() { + git config --local --unset-all http.https://github.com/.extraheader || true + } + trap cleanup_credentials EXIT + ./scripts/release-guard-tag-version.sh + cleanup_credentials + trap - EXIT + ./scripts/validate-release-assets.sh dist/release base + - name: Refuse unsafe existing release state + env: + GH_TOKEN: ${{ github.token }} + DOCPRIMS_RELEASE_TAG: ${{ github.ref_name }} + run: ./scripts/check-draft-release.sh before + - name: Create draft GitHub release + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: - files: release/* - generate_release_notes: true draft: true - body: | - ## docprims v${{ needs.validate-version.outputs.version }} - - GPL-free document text extraction primitives for Rust with Go bindings. - - ### Installation - - Download the appropriate archive for your platform and extract. - - ### Verification - - **Note:** This release is unsigned. Signed checksums will be uploaded after manual signing. - - Once signed artifacts are available: - ```bash - # Verify checksum signature - minisign -Vm SHA256SUMS -p docprims-minisign.pub - - # Verify file checksums - shasum -a 256 -c SHA256SUMS - ``` - - ### Platform Matrix - - | Platform | CLI | Go Bindings | - |----------|-----|-------------| - | Linux x64 (glibc 2.17+) | `docprims-*-linux-amd64.tar.gz` | Supported | - | Linux x64 (musl) | `docprims-*-linux-amd64-musl.tar.gz` | Supported | - | Linux arm64 (glibc 2.17+) | `docprims-*-linux-arm64.tar.gz` | Supported | - | Linux arm64 (musl) | `docprims-*-linux-arm64-musl.tar.gz` | Supported | - | macOS arm64 | `docprims-*-darwin-arm64.tar.gz` | Supported | - | Windows x64 | `docprims-*-windows-amd64.zip` | Supported | - - ### Go Bindings - - ```bash - go get github.com/3leaps/docprims/bindings/go/docprims@v${{ needs.validate-version.outputs.version }} - ``` + prerelease: false + target_commitish: ${{ needs.validate.outputs.commit }} + body_path: docs/releases/${{ github.ref_name }}.md + files: | + dist/release/* + - name: Verify exact draft state and remote inventory + env: + GH_TOKEN: ${{ github.token }} + DOCPRIMS_RELEASE_TAG: ${{ github.ref_name }} + run: ./scripts/check-draft-release.sh after diff --git a/.github/workflows/typescript-bindings.yml b/.github/workflows/typescript-bindings.yml index de0e8cd..3a6be4e 100644 --- a/.github/workflows/typescript-bindings.yml +++ b/.github/workflows/typescript-bindings.yml @@ -3,6 +3,11 @@ on: push: branches: ["main"] pull_request: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test-typescript: name: Test TypeScript Bindings @@ -12,21 +17,23 @@ jobs: os: [ubuntu-latest, macos-14, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 - name: Install deps and run tests run: | cd bindings/typescript/docprims - npm install + npm ci npm run test:ci test-typescript-alpine: name: Test TypeScript Bindings (Alpine/musl) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Run tests in node:22-alpine run: | docker run --rm \ @@ -36,8 +43,8 @@ jobs: sh -lc ' set -eu apk add --no-cache bash curl build-base python3 ca-certificates - curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.88.0 + curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.94.1 . "$HOME/.cargo/env" - npm install + npm ci npm run test:ci ' diff --git a/.github/workflows/typescript-napi-prebuilds.yml b/.github/workflows/typescript-napi-prebuilds.yml index 1be9d59..a40b455 100644 --- a/.github/workflows/typescript-napi-prebuilds.yml +++ b/.github/workflows/typescript-napi-prebuilds.yml @@ -3,20 +3,27 @@ on: workflow_dispatch: env: CARGO_TERM_COLOR: always +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: build-linux: name: Build Linux prebuilds runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 - name: Install Zig - uses: goto-bus-stop/setup-zig@v2 + uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2.2.1 with: version: 0.12.0 - name: Install Rust targets @@ -27,33 +34,35 @@ jobs: rustup target add \ x86_64-unknown-linux-musl \ aarch64-unknown-linux-musl + - name: Install cargo-zigbuild (pinned; napi --cross-compile would otherwise fetch latest) + run: cargo install cargo-zigbuild --locked --version 0.23.4 - name: Install Node dependencies working-directory: bindings/typescript/docprims - run: npm install + run: npm ci - name: Build .node files (zig) working-directory: bindings/typescript/docprims shell: bash run: | set -euo pipefail - rm -f docprims.*.node + rm -rf prebuilds mkdir -p prebuilds # glibc baseline (2.17) for broad compatibility - npx napi build --cargo-cwd native --release --platform --js false --strip \ - --target x86_64-unknown-linux-gnu --zig --zig-abi-suffix 2.17 - mv docprims.linux-x64-gnu.node prebuilds/ + npx napi build --manifest-path native/Cargo.toml --release --platform --no-js --strip --output-dir prebuilds \ + --target x86_64-unknown-linux-gnu --use-napi-cross + test -f prebuilds/docprims.linux-x64-gnu.node - npx napi build --cargo-cwd native --release --platform --js false --strip \ - --target x86_64-unknown-linux-musl --zig - mv docprims.linux-x64-musl.node prebuilds/ + npx napi build --manifest-path native/Cargo.toml --release --platform --no-js --strip --output-dir prebuilds \ + --target x86_64-unknown-linux-musl --cross-compile + test -f prebuilds/docprims.linux-x64-musl.node # arm64-gnu built natively on ubuntu-latest-arm64-s (build-linux-arm64 job) - npx napi build --cargo-cwd native --release --platform --js false --strip \ - --target aarch64-unknown-linux-musl --zig - mv docprims.linux-arm64-musl.node prebuilds/ + npx napi build --manifest-path native/Cargo.toml --release --platform --no-js --strip --output-dir prebuilds \ + --target aarch64-unknown-linux-musl --cross-compile + test -f prebuilds/docprims.linux-arm64-musl.node - name: Upload prebuilds - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ts-prebuilds-linux path: bindings/typescript/docprims/prebuilds/*.node @@ -61,27 +70,29 @@ jobs: name: Build Linux arm64 prebuild (native) runs-on: ubuntu-latest-arm64-s steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 - name: Install Node dependencies working-directory: bindings/typescript/docprims - run: npm install + run: npm ci - name: Build .node file working-directory: bindings/typescript/docprims shell: bash run: | set -euo pipefail - rm -f docprims.*.node + rm -rf prebuilds mkdir -p prebuilds - npx napi build --cargo-cwd native --release --platform --js false --strip - mv docprims.linux-arm64-gnu.node prebuilds/ + npx napi build --manifest-path native/Cargo.toml --release --platform --no-js --strip --output-dir prebuilds + test -f prebuilds/docprims.linux-arm64-gnu.node - name: Upload prebuild - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ts-prebuilds-linux-arm64-gnu-native path: bindings/typescript/docprims/prebuilds/*.node @@ -89,27 +100,29 @@ jobs: name: Build macOS arm64 prebuild runs-on: macos-14 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 - name: Install Node dependencies working-directory: bindings/typescript/docprims - run: npm install + run: npm ci - name: Build .node file working-directory: bindings/typescript/docprims shell: bash run: | set -euo pipefail - rm -f docprims.*.node + rm -rf prebuilds mkdir -p prebuilds - npx napi build --cargo-cwd native --release --platform --js false --strip - mv docprims.darwin-arm64.node prebuilds/ + npx napi build --manifest-path native/Cargo.toml --release --platform --no-js --strip --output-dir prebuilds + test -f prebuilds/docprims.darwin-arm64.node - name: Upload prebuild - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ts-prebuilds-darwin-arm64 path: bindings/typescript/docprims/prebuilds/*.node @@ -117,27 +130,30 @@ jobs: name: Build Windows x64 prebuild runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 - name: Install Node dependencies working-directory: bindings/typescript/docprims - run: npm install + run: npm ci - name: Build .node file working-directory: bindings/typescript/docprims shell: pwsh run: | $ErrorActionPreference = 'Stop' - Remove-Item -Force docprims.*.node -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force prebuilds -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path prebuilds | Out-Null - npx napi build --cargo-cwd native --release --platform --js false --strip - Move-Item docprims.win32-x64-msvc.node prebuilds/ + npx napi build --manifest-path native/Cargo.toml --release --platform --no-js --strip --output-dir prebuilds + $node = 'prebuilds/docprims.win32-x64-msvc.node' + if (-not (Test-Path $node)) { throw "missing $node" } - name: Upload prebuild - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ts-prebuilds-win32-x64-msvc path: bindings/typescript/docprims/prebuilds/*.node @@ -146,16 +162,18 @@ jobs: runs-on: ubuntu-latest needs: [build-linux, build-linux-arm64, build-macos-arm64, build-windows] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 - name: Install Node dependencies working-directory: bindings/typescript/docprims - run: npm install + run: npm ci - name: Build JS dist (root package) working-directory: bindings/typescript/docprims run: npm run clean && npm run build && npm run build:native @@ -166,7 +184,7 @@ jobs: set -euo pipefail rm -f dist/native/docprims.*.node || true - name: Download prebuilds - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: ts-prebuilds-* path: downloaded-prebuilds @@ -176,16 +194,7 @@ jobs: run: | set -euo pipefail rm -rf npm - npx napi create-npm-dir -t . - - name: Remove unsupported platform packages - working-directory: bindings/typescript/docprims - shell: bash - run: | - set -euo pipefail - # darwin-x64 (Intel Mac) is not supported - # win32-arm64 is not supported (no Go bindings either) - rm -rf npm/darwin-x64 npm/win32-arm64-msvc - echo "Removed unsupported platforms: darwin-x64, win32-arm64-msvc" + npx napi create-npm-dirs - name: Stage .node files into npm packages working-directory: bindings/typescript/docprims shell: bash @@ -200,8 +209,11 @@ jobs: mkdir -p "$(dirname "$dest")" cp "$f" "$dest" done + - name: Verify staged platform packages + working-directory: bindings/typescript/docprims + run: node scripts/set-platform-packages.js --check npm - name: Upload npm dir - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ts-npm-dir path: bindings/typescript/docprims/npm diff --git a/.github/workflows/typescript-npm-publish.yml b/.github/workflows/typescript-npm-publish.yml index 98ce517..97d3724 100644 --- a/.github/workflows/typescript-npm-publish.yml +++ b/.github/workflows/typescript-npm-publish.yml @@ -26,6 +26,9 @@ permissions: id-token: write # Required for OIDC trusted publishing contents: read actions: read # Required to download artifacts from other runs +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: publish: name: Publish to npm @@ -34,9 +37,9 @@ jobs: steps: # Note: Do NOT use fetch-tags here. When running from a tag ref, # it causes a git fetch conflict. The tag is already checked out. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' registry-url: 'https://registry.npmjs.org' @@ -190,56 +193,17 @@ jobs: echo "Downloaded packages:" ls -la npm-packages/ - - name: Verify packages - run: | - set -euo pipefail - VERSION="${{ env.version }}" - - # Expected platform packages (docprims does not support darwin-x64 or win32-arm64) - PLATFORMS=( - "linux-x64-gnu" - "linux-x64-musl" - "linux-arm64-gnu" - "linux-arm64-musl" - "darwin-arm64" - "win32-x64-msvc" - ) - - echo "Verifying platform packages..." - for plat in "${PLATFORMS[@]}"; do - if [ ! -d "npm-packages/${plat}" ]; then - echo "::error::Missing platform package: ${plat}" - exit 1 - fi - if [ ! -f "npm-packages/${plat}/docprims.${plat}.node" ]; then - echo "::error::Missing .node file in ${plat}" - exit 1 - fi - - PKG_JSON="npm-packages/${plat}/package.json" - if [ ! -f "$PKG_JSON" ]; then - echo "::error::Missing package.json in ${plat}" - exit 1 - fi - PKG_VERSION=$(node -p "require('./' + process.argv[1]).version" "$PKG_JSON") - if [ "$PKG_VERSION" != "$VERSION" ]; then - echo "::error::Version mismatch in ${plat} package.json (${PKG_VERSION} != ${VERSION})" - exit 1 - fi - echo " ${plat}: OK" - done - - echo "All platform packages verified" - name: Build root package working-directory: bindings/typescript/docprims run: | set -euo pipefail - # Use npm install instead of npm ci because: - # 1. Optional dependencies (platform packages) are being published in this workflow - # 2. They don't exist yet when we build the root package - # 3. We only need devDependencies for TypeScript compilation - npm install --omit=optional + npm ci npm run build + - name: Verify platform packages and point the root package at them + working-directory: bindings/typescript/docprims + # Fails unless npm-packages holds exactly one package per napi target, + # each with its addon, at the root version. + run: node scripts/set-platform-packages.js ../../../npm-packages - name: Publish (dry run) if: ${{ inputs.dry_run }} working-directory: bindings/typescript/docprims diff --git a/.github/workflows/validate-release.yml b/.github/workflows/validate-release.yml index b71e17b..9aec83f 100644 --- a/.github/workflows/validate-release.yml +++ b/.github/workflows/validate-release.yml @@ -6,13 +6,18 @@ on: description: "Release tag to validate (e.g. v0.1.0)" required: true type: string +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: validate-go: name: Validate Go Bindings runs-on: ubuntu-latest steps: - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.22' - name: Test Go module is fetchable @@ -40,17 +45,19 @@ jobs: matrix: os: [ubuntu-latest, macos-14, windows-latest] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.tag }} - - uses: actions/setup-node@v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: 1.94.1 - name: Build and test TypeScript bindings run: | cd bindings/typescript/docprims - npm install + npm ci npm run test:ci validate-typescript-musl: name: Validate TypeScript Bindings (Alpine/musl) @@ -58,7 +65,7 @@ jobs: container: image: node:22-alpine steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.tag }} - name: Install build dependencies @@ -70,7 +77,7 @@ jobs: shell: bash run: | set -euo pipefail - curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.88.0 + curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.94.1 . "$HOME/.cargo/env" rustc -V cargo -V @@ -80,5 +87,5 @@ jobs: run: | set -euo pipefail . "$HOME/.cargo/env" - npm install + npm ci npm run test:ci diff --git a/.gitignore b/.gitignore index 8ccbfe5..0bcb68f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Rust /target/ -Cargo.lock # IDE .idea/ @@ -45,8 +44,7 @@ bindings/typescript/**/node_modules/ bindings/typescript/**/dist/ bindings/typescript/**/dist-test/ bindings/typescript/**/.npmrc -bindings/typescript/**/package-lock.json -package-lock.json +/package-lock.json # Test artifacts *.profraw diff --git a/.goneat/dependencies.yaml b/.goneat/dependencies.yaml new file mode 100644 index 0000000..970fd42 --- /dev/null +++ b/.goneat/dependencies.yaml @@ -0,0 +1,41 @@ +# goneat dependency policy for docprims. +# The license list mirrors deny.toml, which cargo-deny enforces in CI. +version: v1 +licenses: + forbidden: + - GPL-2.0 + - GPL-3.0 + - AGPL-3.0 + - LGPL-2.1 + - LGPL-3.0 + allowed: + - MIT + - Apache-2.0 + - BSD-3-Clause + - ISC + - Zlib + - Unicode-3.0 + - MPL-2.0 + - BSL-1.0 + - Unlicense +# Supply chain cooling: new releases wait before adoption. +cooling: + enabled: true + min_age_days: 7 + min_downloads: 100 + alert_only: false + grace_period_days: 3 + exceptions: + - pattern: "github.com/3leaps/*" + reason: "3leaps packages" + - pattern: "github.com/fulmenhq/*" + reason: "Fulmen ecosystem packages" +policy_engine: + type: embedded +languages: + - language: go + paths: + - bindings/go/docprims/go.mod + - language: rust + paths: + - Cargo.toml diff --git a/.goneat/hooks.yaml b/.goneat/hooks.yaml new file mode 100644 index 0000000..a8f874b --- /dev/null +++ b/.goneat/hooks.yaml @@ -0,0 +1,24 @@ +version: "1.0.0" +hooks: + pre-commit: + - command: "assess" + args: + - "--check" + - "--categories" + - "format,lint,typecheck" + - "--fail-on" + - "critical" + - "--lint-new-from-rev" + - "HEAD~" + priority: 10 + timeout: "5m" + pre-push: + - command: "assess" + args: ["--check", "--categories", "format,lint,typecheck,security,repo-status", "--fail-on", "high"] + priority: 10 + timeout: "5m" +optimization: + cache_results: true + content_source: working + only_changed_files: false + parallel: auto diff --git a/AGENTS.md b/AGENTS.md index d2fcd5f..72c8e9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,7 +220,7 @@ All parser code must: | `crates/docprims-core/` | Shared types, errors, traits | | `crates/docprims-text/` | Markdown, HTML, XML extraction | | `crates/docprims-ooxml/` | DOCX, XLSX, PPTX extraction | -| `crates/docprims-cli/` | CLI binary | +| `crates/docprims/` | Library entry point; CLI (`cli` feature) | | `ffi/docprims-ffi/` | C-ABI exports via cbindgen | | `bindings/` | Go, TypeScript, Python wrappers | | `docs/decisions/` | ADRs, SDRs, DDRs | diff --git a/CHANGELOG.md b/CHANGELOG.md index faed71e..88e8413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,23 +10,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.1.5] - 2026-07-07 +## [0.2.0] - 2026-09-25 ### Added -- **npm trusted publishing runtime guard**: Added `scripts/check-npm-trusted-publish-runtime.sh` and wired it into `make lint` plus `npm-publish-prereqs-check`. +- **`docprims` crate**: The library's entry point. `extract_file` and `extract_bytes` return the `extract/v0` structure and choose the parser from the path or source URI extension, or from an explicit `Format` (`extract_file_as`, `extract_bytes_as`); content is never inspected to choose a parser. Each format is a feature (`markdown`, `html`, `xml`, `docx`, `xlsx`, `pptx`, grouped as `text` and `ooxml`; all enabled by default). `docprims-core`, `docprims-text` and `docprims-ooxml` carry no stability promise beyond what `docprims` re-exports. +- **npm trusted publishing runtime guard**: `scripts/check-npm-trusted-publish-runtime.sh`, run by `make lint` and `make npm-publish-prereqs-check`. ### Changed -- **Rust MSRV**: Raised the workspace minimum supported Rust version to 1.88.0. -- **TypeScript runtime floor**: Raised `@3leaps/docprims` and repo tooling Node.js engine requirements to Node 22 or newer. -- **TypeScript workflows**: Moved TypeScript binding CI, release validation, and N-API prebuild workflows to Node 22 validation; npm publish remains on Node 24 with explicit Node >=22.14.0 and npm >=11.5.1 trusted-publishing checks. -- **Repository guidance**: Refreshed agent guidance, canonical documentation pointers, YAML linting configuration, and local-only planning artifact conventions. -- **README install guidance**: Replaced the unpublished CLI crate install command with repo-root `cargo install --path crates/docprims-cli` guidance. +- **BREAKING: `DocprimsError`, `DocprimsQualityStatus` and `DocprimsContainerKind` are `#[non_exhaustive]`**: Matches on them outside docprims need a wildcard arm. `DocprimsQualityStatus` implements `PartialEq` and `Eq`. +- **CLI**: The `docprims` command-line tool is now built from the `docprims` crate with the `cli` feature (`cargo install docprims --features cli`). The binary name, commands and output are unchanged. +- **C, Go and TypeScript bindings** extract through the `docprims` crate; results and error codes are unchanged. +- **Dependencies**: quick-xml 0.42, zip 8, scraper 0.27 and rsfulmen 0.2. The TypeScript binding builds with napi-rs 3 and TypeScript 7. +- **Rust MSRV**: The workspace minimum supported Rust version is 1.88.0. +- **TypeScript runtime floor**: `@3leaps/docprims` requires Node.js 22 or newer. npm publishing checks for Node >=22.14.0 and npm >=11.5.1. + +### Removed + +- **BREAKING: `--timeout-ms` and `ExtractOptions::timeout_ms`**: The extraction timeout option was accepted but never applied, and is removed. Passing `--timeout-ms` to the CLI is now a usage error. +- **BREAKING: `ExtractOptions`**: Removed from `docprims-core`; none of its fields were used. Limits are set with `ExtractLimits`. +- **BREAKING: `docprims-cli` crate**: Replaced by the `docprims` crate's `cli` feature. +- **BREAKING: `docprims_ooxml::common`**: No longer public. ### Fixed -- **Current stable clippy compatibility**: Updated parser match handling flagged by newer clippy lints. +- **Extracted text character set**: Extracted text from every format (Markdown, HTML, XML, DOCX, XLSX, PPTX) contains only XML 1.0 characters, excluding DEL and C1 controls (U+007F–U+009F); other characters are dropped before block byte ranges are computed. Markdown and HTML keep their parsers' U+FFFD replacement for NUL. Numeric character references (`é`, `😀`) resolve under the same rule, including in DOCX, XLSX and PPTX, which previously dropped all numeric references. Extracted text changes for documents that contain such characters or references. +- **OOXML decompression limits**: The decompressed size of DOCX, XLSX and PPTX parts is now bounded by the bytes actually read, not the size declared in the archive: 100 MiB per part and 400 MiB across all parts of one archive. Exceeding either limit is a `ResourceLimit` error. +- **Output limits**: When `max_output_bytes` truncates extraction, the result no longer contains an empty block or a trailing separator. +- **Current stable clippy compatibility**: Parser match handling updated for newer clippy lints. ## [0.1.4] - 2026-01-31 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..52e1c52 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1983 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.6", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "docprims" +version = "0.2.0" +dependencies = [ + "assert_cmd", + "base64", + "clap", + "docprims-core", + "docprims-ooxml", + "docprims-text", + "jsonschema", + "predicates", + "rsfulmen", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", + "zip", +] + +[[package]] +name = "docprims-core" +version = "0.2.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", + "time", +] + +[[package]] +name = "docprims-ffi" +version = "0.2.0" +dependencies = [ + "base64", + "docprims", + "docprims-core", + "serde", + "serde_json", +] + +[[package]] +name = "docprims-ooxml" +version = "0.2.0" +dependencies = [ + "docprims-core", + "encoding_rs", + "jsonschema", + "quick-xml", + "serde_json", + "zip", +] + +[[package]] +name = "docprims-text" +version = "0.2.0" +dependencies = [ + "docprims-core", + "jsonschema", + "pulldown-cmark", + "quick-xml", + "scraper", + "serde_json", +] + +[[package]] +name = "docprims-ts-napi" +version = "0.2.0" +dependencies = [ + "docprims", + "docprims-core", + "napi", + "napi-build", + "napi-derive", + "serde", + "serde_json", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71160ed5f6dbe36a2d6be79f4ca03ee09a99d2866f340faed49458913449aefa" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d2120d8466ffcdc1b3be4b88ff0e9191bf5b6b09b1b1af1eef9aff8f465ea3" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbfa40a42415369d940b3848f3ce08099f1f1ac04d73e8580f4d652617c4f44" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "napi" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b12638855cec016784a7e17cc4ecbdee7ca8b0a3456aa50640d0ee5ed1a42f8" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0941983559cd62bfa7f04646a16428967a847365adeef27711e4e3927046bbed" + +[[package]] +name = "napi-derive" +version = "3.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1d232c24dc6754979b27efdf2047a5849efc7c92c1245de87e6bedbb9f5c3c" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6856fe1686867da90ed0dc292d7c0c87a695eb7c578b913360f33619ee01e3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 2.0.119", +] + +[[package]] +name = "napi-sys" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6801fc65ba4d7035636e93635e281113f00410401a237dcd4f55b1565ead2115" +dependencies = [ + "libloading", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "referencing" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06f6798be4fed305e74df8b1fb90cf59c6b8cc17aa3febeb1830c0c6b627b3e" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom", + "hashbrown", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsfulmen" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58499c875a8706aa72494fa86e518b6c407893ce1eb6378d387d985ace39b13f" +dependencies = [ + "ctrlc", + "libc", + "once_cell", + "serde", + "serde_yaml_ng", + "signal-hook", + "thiserror 1.0.69", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 253306d..7f1ba96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,42 +4,45 @@ members = [ "crates/docprims-core", "crates/docprims-text", "crates/docprims-ooxml", - "crates/docprims-cli", + "crates/docprims", "ffi/docprims-ffi", "bindings/typescript/docprims/native", ] [workspace.package] -version = "0.1.5" +version = "0.2.0" edition = "2021" rust-version = "1.88.0" license = "MIT OR Apache-2.0" repository = "https://github.com/3leaps/docprims" authors = ["3leaps Architecture Council"] +# Crates opt in to crates.io with `publish = true`; config/release/publishable-crates.txt lists them. +publish = false [workspace.dependencies] -docprims-core = { path = "crates/docprims-core" } -docprims-text = { path = "crates/docprims-text" } -docprims-ooxml = { path = "crates/docprims-ooxml" } +docprims = { version = "0.2.0", path = "crates/docprims" } +docprims-core = { version = "0.2.0", path = "crates/docprims-core" } +docprims-text = { version = "0.2.0", path = "crates/docprims-text", default-features = false } +docprims-ooxml = { version = "0.2.0", path = "crates/docprims-ooxml", default-features = false } # Fulmen Ecosystem -rsfulmen = { version = "=0.1.2", default-features = false, features = ["foundry-core"] } +rsfulmen = { version = "=0.2.0", default-features = false, features = ["foundry-core"] } # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" # Time -time = { version = "0.3", features = ["formatting", "serde"] } +time = { version = "0.3.47", features = ["formatting", "serde"] } # XML parsing (GPL-free) -quick-xml = "0.39" +quick-xml = "0.42" # ZIP archive handling (GPL-free) -zip = { version = "2", default-features = false, features = ["deflate"] } +zip = { version = "8", default-features = false, features = ["deflate-flate2-zlib-rs"] } # HTML parsing (GPL-free) -scraper = "0.25" +scraper = "0.27" # Markdown parsing (GPL-free) pulldown-cmark = "0.13" diff --git a/Makefile b/Makefile index b970b4c..1432c51 100644 --- a/Makefile +++ b/Makefile @@ -11,17 +11,22 @@ .PHONY: all help bootstrap bootstrap-force tools check test fmt lint build clean version install .PHONY: precommit prepush deps-check audit deny miri msrv fmt-check npm-publish-prereqs-check .PHONY: build-release build-ffi cbindgen -.PHONY: release-clean release-download release-checksums release-sign +.PHONY: release-clean release-download release-checksums release-sign release-tag release-push-tag +.PHONY: release-verify-tag release-verify-remote-tag release-insert-anchors .PHONY: release-export-keys release-verify-checksums release-verify-signatures .PHONY: release-verify-keys release-notes release-upload release .PHONY: build-local-go go-test go-test-shared -.PHONY: version-patch version-minor version-major version-set version-sync +.PHONY: version-patch version-minor version-major version-set version-sync version-check +.PHONY: release-check release-crates-list release-crates-dry-run release-crates-verify +.PHONY: release-tooling-test release-preflight release-guard-tag-version .PHONY: check-windows check-windows-msvc check-windows-gnu # ----------------------------------------------------------------------------- # Configuration # ----------------------------------------------------------------------------- +.NOTPARALLEL: release + # Version from VERSION file (SSOT) VERSION := $(shell cat VERSION 2>/dev/null || echo "dev") @@ -29,8 +34,8 @@ VERSION := $(shell cat VERSION 2>/dev/null || echo "dev") BIN_DIR := $(CURDIR)/bin # Pinned tool versions for reproducibility -SFETCH_VERSION := latest -GONEAT_VERSION ?= v0.5.1 +SFETCH_VERSION := v0.4.11 +GONEAT_VERSION ?= v0.6.0 # Tool paths SFETCH = $(shell [ -x "$(BIN_DIR)/sfetch" ] && echo "$(BIN_DIR)/sfetch" || command -v sfetch 2>/dev/null) @@ -38,6 +43,12 @@ GONEAT = $(shell command -v goneat 2>/dev/null) # Rust toolchain CARGO = cargo +# Build against the committed Cargo.lock; fail rather than re-resolve. +CARGO_LOCKED = --locked +# Build every feature (including the `cli` binary) in workspace-wide targets. +CARGO_ALL = --all-features +# Minimum supported Rust version (workspace rust-version); verified by `make msrv`. +MSRV = 1.88.0 # ----------------------------------------------------------------------------- # Default and Help @@ -47,52 +58,8 @@ all: check help: ## Show available targets @echo "docprims - GPL-free Document Text Extraction" - @echo "Extract text from documents without license contamination." - @echo "" - @echo "Development:" - @echo " help Show this help message" - @echo " bootstrap Install tools (sfetch -> goneat)" - @echo " build Build all crates (debug)" - @echo " build-release Build all crates (release)" - @echo " build-ffi Build FFI library with C header" - @echo " install Install docprims binary to ~/.local/bin" - @echo " clean Remove build artifacts" - @echo "" - @echo "Go bindings:" - @echo " build-local-go Build FFI for local Go development" - @echo " go-test Run Go binding tests" - @echo " go-test-shared Run Go binding tests (shared library)" - @echo "" - @echo "Release (manual signing):" - @echo " release-clean Remove dist/release contents" - @echo " release-download Download draft release assets" - @echo " release-checksums Generate SHA256SUMS and SHA512SUMS" - @echo " release-sign Sign checksum manifests (minisign/optional PGP)" - @echo " release-export-keys Export public signing keys" - @echo " release-verify Verify checksums/signatures/keys" - @echo " release-upload Upload signed assets and publish" - @echo "" - @echo "Quality gates:" - @echo " check Run all quality checks (fmt, lint, test, deny)" - @echo " test Run test suite" - @echo " fmt Format code (cargo fmt)" - @echo " lint Run linting (cargo clippy)" - @echo " precommit Pre-commit checks (fast: fmt, clippy)" - @echo " prepush Pre-push checks (thorough: fmt, clippy, test, deny)" - @echo " deny Run cargo-deny license and advisory checks" - @echo " audit Run cargo-audit security scan" - @echo " miri Run Miri UB detection on unsafe code (nightly)" - @echo " msrv Verify build with MSRV (Rust 1.88.0)" - @echo " npm-publish-prereqs-check Verify npm trusted publishing runtime guard" - @echo " check-windows Cross-check Windows targets (no SDK required)" @echo "" - @echo "Version management:" - @echo " version Print current version" - @echo " version-patch Bump patch version (0.1.0 -> 0.1.1)" - @echo " version-minor Bump minor version (0.1.0 -> 0.2.0)" - @echo " version-major Bump major version (0.1.0 -> 1.0.0)" - @echo " version-set Set explicit version (V=X.Y.Z)" - @echo " version-sync Sync VERSION to Cargo.toml" + @awk 'BEGIN { FS = ":.*## " } /^[a-zA-Z0-9_-]+:.*## / { printf " %-26s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) @echo "" @echo "Current version: $(VERSION)" @@ -122,7 +89,7 @@ bootstrap: ## Install required tools (sfetch -> goneat -> tools) @mkdir -p "$(BIN_DIR)" @if [ ! -x "$(BIN_DIR)/sfetch" ] && ! command -v sfetch >/dev/null 2>&1; then \ echo "[..] Installing sfetch (trust anchor)..."; \ - curl -fsSL https://github.com/3leaps/sfetch/releases/download/$(SFETCH_VERSION)/install-sfetch.sh | bash -s -- --dest "$(BIN_DIR)"; \ + curl -fsSL https://github.com/3leaps/sfetch/releases/download/$(SFETCH_VERSION)/install-sfetch.sh | bash -s -- --dir "$(BIN_DIR)" --tag $(SFETCH_VERSION) --yes; \ else \ echo "[ok] sfetch already installed"; \ fi @@ -206,12 +173,12 @@ tools: ## Verify external tools are available # Quality Gates # ----------------------------------------------------------------------------- -check: fmt-check lint test deny ## Run all quality checks +check: fmt-check lint test lean-lib-check doc-check deny audit ## Run all quality checks @echo "[ok] All quality checks passed" test: ## Run test suite @echo "Running tests..." - $(CARGO) test --workspace + $(CARGO) test --workspace $(CARGO_ALL) $(CARGO_LOCKED) @echo "[ok] Tests passed" fmt: ## Format code (goneat assess or cargo fmt) @@ -234,32 +201,37 @@ fmt-check: ## Check formatting without modifying fi @echo "[ok] Formatting check passed" -lint: ## Run linting (goneat assess or cargo clippy) +lint: ## Run linting (goneat assess + cargo clippy) @echo "Linting..." @bash scripts/check-npm-trusted-publish-runtime.sh @if command -v goneat >/dev/null 2>&1; then \ goneat assess --categories lint; \ - else \ - echo "[--] goneat not found, using cargo clippy"; \ - $(CARGO) clippy --workspace --all-targets -- -D warnings; \ fi + @# goneat v0.6.0 does not run the Rust lint pass; clippy runs directly as the gap-filler. + $(CARGO) clippy --workspace --all-targets $(CARGO_ALL) $(CARGO_LOCKED) -- -D warnings @echo "[ok] Linting passed" npm-publish-prereqs-check: ## Verify npm trusted publishing runtime guard @bash scripts/check-npm-trusted-publish-runtime.sh +doc-check: ## Build docs for the published crates as docs.rs does, failing on warnings + RUSTDOCFLAGS="--cfg docsrs -D warnings" $(CARGO) doc --no-deps $(CARGO_LOCKED) -p docprims --features text,ooxml + RUSTDOCFLAGS="--cfg docsrs -D warnings" $(CARGO) doc --no-deps $(CARGO_LOCKED) -p docprims-core -p docprims-text -p docprims-ooxml + @echo "[ok] Documentation builds cleanly" + +lean-lib-check: ## Verify library builds of the docprims crate pull no CLI or unused format dependencies + @bash scripts/check-lean-lib.sh + @echo "[ok] Lean library dependency check passed" + deny: ## Run cargo-deny license and advisory checks @echo "Running cargo-deny..." @if command -v cargo-deny >/dev/null 2>&1; then \ - cargo-deny check bans licenses sources; \ + cargo-deny --locked check; \ else \ echo "[!!] cargo-deny not found (run 'make bootstrap')"; \ exit 1; \ fi @echo "[ok] cargo-deny passed" -# NOTE: advisories check temporarily skipped due to cargo-deny CVSS 4.0 parsing issue -# See: https://github.com/EmbarkStudios/cargo-deny/issues/804 -# Re-enable with `cargo-deny check` when fixed upstream audit: ## Run cargo-audit security scan @echo "Running cargo-audit..." @@ -283,14 +255,14 @@ miri: ## Run Miri to detect undefined behavior in unsafe code (requires nightly) fi @echo "[ok] Miri passed" -msrv: ## Verify build with Minimum Supported Rust Version (1.88.0) - @echo "Checking MSRV (1.88.0)..." - @if rustup run 1.88.0 cargo --version >/dev/null 2>&1; then \ - rustup run 1.88.0 cargo build --workspace && \ - rustup run 1.88.0 cargo test --workspace; \ +msrv: ## Verify build and tests with the Minimum Supported Rust Version + @echo "Checking MSRV ($(MSRV))..." + @if rustup run $(MSRV) cargo --version >/dev/null 2>&1; then \ + CARGO_TARGET_DIR=target/msrv rustup run $(MSRV) cargo build --workspace $(CARGO_ALL) $(CARGO_LOCKED) && \ + CARGO_TARGET_DIR=target/msrv rustup run $(MSRV) cargo test --workspace $(CARGO_ALL) $(CARGO_LOCKED); \ else \ - echo "[!!] Rust 1.88.0 not installed. Install with:"; \ - echo " rustup install 1.88.0"; \ + echo "[!!] Rust $(MSRV) not installed. Install with:"; \ + echo " rustup install $(MSRV)"; \ exit 1; \ fi @echo "[ok] MSRV check passed" @@ -309,7 +281,7 @@ check-windows-msvc: ## Cross-check Windows MSVC target (type checking only) echo "[..] Installing x86_64-pc-windows-msvc target..."; \ rustup target add x86_64-pc-windows-msvc; \ fi - $(CARGO) check --target x86_64-pc-windows-msvc --workspace + $(CARGO) check --target x86_64-pc-windows-msvc --workspace $(CARGO_ALL) @echo "[ok] Windows MSVC check passed" check-windows-gnu: ## Cross-check Windows GNU target (type checking only) @@ -318,7 +290,7 @@ check-windows-gnu: ## Cross-check Windows GNU target (type checking only) echo "[..] Installing x86_64-pc-windows-gnu target..."; \ rustup target add x86_64-pc-windows-gnu; \ fi - $(CARGO) check --target x86_64-pc-windows-gnu --workspace + $(CARGO) check --target x86_64-pc-windows-gnu --workspace $(CARGO_ALL) @echo "[ok] Windows GNU check passed" deps-check: ## Check dependencies for cooling violations @@ -336,17 +308,17 @@ deps-check: ## Check dependencies for cooling violations build: ## Build all crates (debug) @echo "Building (debug)..." - $(CARGO) build --workspace + $(CARGO) build --workspace $(CARGO_ALL) $(CARGO_LOCKED) @echo "[ok] Build complete" build-release: ## Build all crates (release) @echo "Building (release)..." - $(CARGO) build --workspace --release + $(CARGO) build --workspace --release $(CARGO_ALL) $(CARGO_LOCKED) @echo "[ok] Release build complete" build-ffi: cbindgen ## Build FFI library with C header @echo "Building FFI library..." - $(CARGO) build --package docprims-ffi --release + $(CARGO) build --package docprims-ffi --release $(CARGO_LOCKED) @echo "[ok] FFI build complete" @echo "Library: target/release/libdocprims_ffi.*" @echo "Header: ffi/docprims-ffi/docprims.h" @@ -375,7 +347,7 @@ GO_BINDINGS_DIR := bindings/go/docprims build-local-go: cbindgen ## Build FFI for local Go development @echo "Building FFI for local Go development..." - $(CARGO) build --release -p docprims-ffi + $(CARGO) build --release -p docprims-ffi $(CARGO_LOCKED) @# Sync header and local static lib into Go module layout @PLATFORM=""; \ UNAME_S="$$(uname -s)"; \ @@ -445,95 +417,142 @@ install: build-release ## Install docprims binary to INSTALL_BINDIR precommit: fmt-check lint ## Run pre-commit checks (fast) @echo "[ok] Pre-commit checks passed" -prepush: check ## Run pre-push checks (thorough) +prepush: check version-check release-tooling-test ## Run pre-push checks (thorough) @echo "[ok] Pre-push checks passed" # ----------------------------------------------------------------------------- -# Release Workflow +# Release Governance (crates.io, version, tag guard) # ----------------------------------------------------------------------------- -# -# Manual signing workflow (CI builds unsigned, maintainer signs locally): -# -# 1. CI creates draft release on tag push (release workflow) -# 2. Download artifacts: make release-download -# 3. Generate checksums: make release-checksums -# 4. Sign checksums: make release-sign (requires DOCPRIMS_MINISIGN_KEY) -# 5. Export public keys: make release-export-keys -# 6. Verify everything: make release-verify -# 7. Upload signed artifacts: make release-upload - -DIST_RELEASE := dist/release -DOCPRIMS_RELEASE_TAG ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo v$(shell cat VERSION)) - -# Signing keys (set these environment variables) -DOCPRIMS_MINISIGN_KEY ?= -DOCPRIMS_MINISIGN_PUB ?= -DOCPRIMS_PGP_KEY_ID ?= -DOCPRIMS_GPG_HOMEDIR ?= - -release-clean: ## Remove dist/release contents - @echo "Cleaning release directory..." - rm -rf $(DIST_RELEASE) - @echo "[ok] Release directory cleaned" - -release-download: ## Download release assets from GitHub - @if [ -z "$(DOCPRIMS_RELEASE_TAG)" ] || [ "$(DOCPRIMS_RELEASE_TAG)" = "v" ]; then \ - echo "Error: No release tag found. Set DOCPRIMS_RELEASE_TAG=vX.Y.Z"; \ + +release-check: version-check ## Version consistency + crate package check (does not publish) + @./scripts/check-packages.sh + @echo "[ok] Package check passed; cargo publish was not run" + +release-crates-list: ## Print the validated crates.io publication order + @./scripts/release-crates.py list + +release-crates-dry-run: ## Dry-run crates.io publishing from the guarded tag + @./scripts/release-crates-dry-run.sh + +release-crates-verify: ## Wait for the released version on crates.io and verify it (CRATE= for one) + @CRATE="$(CRATE)" ./scripts/release-crates-verify.sh + +release-guard-tag-version: ## Validate the canonical release tag + @./scripts/release-guard-tag-version.sh + +release-tooling-test: ## Run release guard, signing, asset, crate, version and notes tests + @./scripts/release-decernor.test.sh + @./scripts/release-tag-controls.test.sh + @./scripts/verify-pinned-tag.test.sh + @./scripts/release-crates.test.sh + @./scripts/release-crates-verify.test.sh + @./scripts/release-guard-tag-version.test.sh + @./scripts/release-assets.test.sh + @./scripts/release-github-state.test.sh + @./scripts/release-safety.test.sh + @./scripts/check-version.test.sh + @./scripts/check-release-notes.test.sh + @echo "[ok] Release tooling tests passed" + +release-preflight: ## Verify clean-tree pre-tag requirements + @./scripts/validate-release-anchors.sh + @echo "Running release preflight checks..." + @if [ -n "$$(git status --porcelain 2>/dev/null)" ]; then \ + echo "[!!] Working tree not clean - commit or stash changes first"; \ + git status --short; \ exit 1; \ fi - ./scripts/download-release-assets.sh $(DOCPRIMS_RELEASE_TAG) $(DIST_RELEASE) + @$(MAKE) prepush --silent + @$(MAKE) release-check --silent + @grep -Eq "^## v$(VERSION) — [0-9]{4}-[0-9]{2}-[0-9]{2}$$" RELEASE_NOTES.md || \ + { echo "[!!] RELEASE_NOTES.md lacks the exact v$(VERSION) heading"; exit 1; } + @grep -Eq "^## \[$(VERSION)\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$$" CHANGELOG.md || \ + { echo "[!!] CHANGELOG.md lacks the [$(VERSION)] heading"; exit 1; } + @./scripts/check-release-notes.sh "v$(VERSION)" + @git fetch origin main + @test "$$(git rev-parse HEAD)" = "$$(git rev-parse origin/main)" || \ + { echo "[!!] HEAD must equal fetched origin/main"; exit 1; } + @test -z "$$(git status --porcelain)" || \ + { echo "[!!] Preflight gates changed the working tree"; exit 1; } + @echo "[ok] All preflight checks passed - ready to tag v$(VERSION)" -release-checksums: ## Generate SHA256SUMS and SHA512SUMS - ./scripts/generate-checksums.sh $(DIST_RELEASE) +# ----------------------------------------------------------------------------- +# Release Ceremony (maintainer, local keys; see RELEASE_CHECKLIST.md) +# ----------------------------------------------------------------------------- +# +# CI builds the exact unsigned asset set and opens a draft release on a signed +# tag push. The maintainer signs checksum manifests locally and uploads them. +# Every target takes the tag from DOCPRIMS_RELEASE_TAG only. -release-sign: ## Sign checksum manifests (requires DOCPRIMS_MINISIGN_KEY) - @if [ -z "$(DOCPRIMS_MINISIGN_KEY)" ]; then \ - echo "Error: DOCPRIMS_MINISIGN_KEY not set"; \ - echo ""; \ - echo "Set the path to your minisign secret key:"; \ - echo " export DOCPRIMS_MINISIGN_KEY=/path/to/docprims.key"; \ - exit 1; \ - fi - DOCPRIMS_MINISIGN_KEY=$(DOCPRIMS_MINISIGN_KEY) \ - DOCPRIMS_PGP_KEY_ID=$(DOCPRIMS_PGP_KEY_ID) \ - DOCPRIMS_GPG_HOMEDIR=$(DOCPRIMS_GPG_HOMEDIR) \ - ./scripts/sign-release-assets.sh $(DOCPRIMS_RELEASE_TAG) $(DIST_RELEASE) - -release-export-keys: ## Export public signing keys - DOCPRIMS_MINISIGN_KEY=$(DOCPRIMS_MINISIGN_KEY) \ - DOCPRIMS_MINISIGN_PUB=$(DOCPRIMS_MINISIGN_PUB) \ - DOCPRIMS_PGP_KEY_ID=$(DOCPRIMS_PGP_KEY_ID) \ - DOCPRIMS_GPG_HOMEDIR=$(DOCPRIMS_GPG_HOMEDIR) \ - ./scripts/export-release-keys.sh $(DIST_RELEASE) - -release-verify-checksums: ## Verify checksums match artifacts - @echo "Verifying checksums..." - cd $(DIST_RELEASE) && shasum -a 256 -c SHA256SUMS - @echo "[ok] Checksums verified" - -release-verify-signatures: ## Verify minisign/PGP signatures - ./scripts/verify-signatures.sh $(DIST_RELEASE) - -release-verify-keys: ## Verify exported keys are public-only - ./scripts/verify-public-keys.sh $(DIST_RELEASE) - -release-verify: release-verify-checksums release-verify-signatures release-verify-keys ## Run all release verification - @echo "[ok] All release verifications passed" - -release-notes: ## Copy release notes to dist - @src="docs/releases/$(DOCPRIMS_RELEASE_TAG).md"; \ - if [ -f "$$src" ]; then \ - cp "$$src" "$(DIST_RELEASE)/release-notes-$(DOCPRIMS_RELEASE_TAG).md"; \ - echo "[ok] Copied release notes"; \ - else \ - echo "[--] No release notes found at $$src"; \ - fi +RELEASE_DIR := $(CURDIR)/dist/release + +release-tag: ## Create and verify a local signed version tag + @./scripts/release-tag.sh -release-upload: release-verify release-notes ## Upload signed artifacts to GitHub release - ./scripts/upload-release-assets.sh $(DOCPRIMS_RELEASE_TAG) $(DIST_RELEASE) +release-push-tag: ## Publish and verify the signed version tag + @./scripts/release-push-tag.sh -release: release-clean release-download release-checksums release-sign release-export-keys release-upload ## Full release workflow (after CI build) - @echo "[ok] Release $(DOCPRIMS_RELEASE_TAG) complete" +release-verify-tag: ## Verify the tag using only the committed public pin + @./scripts/release-verify-tag.sh + +release-verify-remote-tag: ## Compare local and remote tag objects and GitHub verification + @./scripts/release-verify-remote-tag.sh + +release-insert-anchors: ## Maintainer-only: generate and review public fingerprint anchors + @./scripts/release-insert-anchors.sh + +release-clean: ## Safely empty the repository release staging directory + @./scripts/release-clean.sh "$(RELEASE_DIR)" + +release-download: ## Download exact unsigned assets from the trusted draft + @./scripts/download-release-assets.sh "$(RELEASE_DIR)" + +release-notes: ## Add the exact per-cut notes to the signable asset set + @DOCPRIMS_REQUIRE_TAG=1 ./scripts/release-guard-tag-version.sh >/dev/null + @test -f "docs/releases/$${DOCPRIMS_RELEASE_TAG}.md" || \ + { echo "[!!] Exact per-cut release notes are missing"; exit 1; } + @./scripts/validate-release-assets.sh "$(RELEASE_DIR)" base >/dev/null + @cp "docs/releases/$${DOCPRIMS_RELEASE_TAG}.md" \ + "$(RELEASE_DIR)/release-notes-$${DOCPRIMS_RELEASE_TAG}.md" + @./scripts/stage-release-anchors.sh "$(RELEASE_DIR)" + @./scripts/validate-release-assets.sh "$(RELEASE_DIR)" signable >/dev/null + @echo "[ok] Per-cut release notes added to the signed set" + +release-checksums: ## Generate exact SHA256 and SHA512 manifests + @./scripts/generate-checksums.sh "$(RELEASE_DIR)" + +release-sign: ## Sign checksum manifests with local MFA-held keys + @./scripts/sign-release-assets.sh "$(RELEASE_DIR)" + +release-export-keys: ## Export and prove public verification material + @./scripts/export-release-keys.sh "$(RELEASE_DIR)" + +release-verify-checksums: ## Verify exact dual checksum manifests + @./scripts/verify-checksums.sh "$(RELEASE_DIR)" + +release-verify-signatures: ## Verify every configured signature + @./scripts/verify-signatures.sh "$(RELEASE_DIR)" + +release-verify-keys: ## Verify exported public keys against staged anchors + @./scripts/verify-public-keys.sh "$(RELEASE_DIR)" + +release-verify: release-verify-checksums release-verify-signatures release-verify-keys ## Verify signed release set + @./scripts/validate-release-assets.sh "$(RELEASE_DIR)" signed >/dev/null + @echo "[ok] Signed release set verified" + +release-upload: ## Verify once and update the exact trusted draft release + @./scripts/upload-release-assets.sh "$(RELEASE_DIR)" + +release: release-guard-tag-version ## Run the serialized local signing ceremony + @DOCPRIMS_REQUIRE_TAG=1 ./scripts/release-guard-tag-version.sh >/dev/null + @$(MAKE) release-clean + @$(MAKE) release-download + @$(MAKE) release-notes + @$(MAKE) release-checksums + @$(MAKE) release-sign + @$(MAKE) release-export-keys + @$(MAKE) release-upload + @echo "[ok] Release assets signed and uploaded; GitHub release remains draft" # ----------------------------------------------------------------------------- # Version Management @@ -545,49 +564,30 @@ version: ## Print current version @echo "$(VERSION)" version-patch: ## Bump patch version (0.1.0 -> 0.1.1) - @current=$$(cat $(VERSION_FILE)); \ - major=$$(echo $$current | cut -d. -f1); \ - minor=$$(echo $$current | cut -d. -f2); \ - patch=$$(echo $$current | cut -d. -f3); \ - new_patch=$$((patch + 1)); \ - new_version="$$major.$$minor.$$new_patch"; \ - echo "$$new_version" > $(VERSION_FILE); \ - sed -i '' "s/^version = \"$$current\"/version = \"$$new_version\"/" Cargo.toml; \ - echo "Version bumped: $$current -> $$new_version" + @current=$$(tr -d ' \t\r\n' < $(VERSION_FILE)); \ + IFS=. read -r major minor patch <<< "$$current"; \ + echo "$$major.$$minor.$$((patch + 1))" > $(VERSION_FILE); \ + ./scripts/version-sync.py version-minor: ## Bump minor version (0.1.0 -> 0.2.0) - @current=$$(cat $(VERSION_FILE)); \ - major=$$(echo $$current | cut -d. -f1); \ - minor=$$(echo $$current | cut -d. -f2); \ - new_minor=$$((minor + 1)); \ - new_version="$$major.$$new_minor.0"; \ - echo "$$new_version" > $(VERSION_FILE); \ - sed -i '' "s/^version = \"$$current\"/version = \"$$new_version\"/" Cargo.toml; \ - echo "Version bumped: $$current -> $$new_version" + @current=$$(tr -d ' \t\r\n' < $(VERSION_FILE)); \ + IFS=. read -r major minor patch <<< "$$current"; \ + echo "$$major.$$((minor + 1)).0" > $(VERSION_FILE); \ + ./scripts/version-sync.py version-major: ## Bump major version (0.1.0 -> 1.0.0) - @current=$$(cat $(VERSION_FILE)); \ - major=$$(echo $$current | cut -d. -f1); \ - new_major=$$((major + 1)); \ - new_version="$$new_major.0.0"; \ - echo "$$new_version" > $(VERSION_FILE); \ - sed -i '' "s/^version = \"$$current\"/version = \"$$new_version\"/" Cargo.toml; \ - echo "Version bumped: $$current -> $$new_version" + @current=$$(tr -d ' \t\r\n' < $(VERSION_FILE)); \ + IFS=. read -r major minor patch <<< "$$current"; \ + echo "$$((major + 1)).0.0" > $(VERSION_FILE); \ + ./scripts/version-sync.py version-set: ## Set explicit version (V=X.Y.Z) - @if [ -z "$(V)" ]; then \ - echo "Usage: make version-set V=1.2.3"; \ - exit 1; \ - fi + @if [ -z "$(V)" ]; then echo "Usage: make version-set V=1.2.3"; exit 1; fi @echo "$(V)" > $(VERSION_FILE) - @echo "Version set to $(V)" + @./scripts/version-sync.py -version-sync: ## Sync VERSION file to Cargo.toml (requires cargo-edit) - @ver=$$(cat $(VERSION_FILE)); \ - if command -v cargo-set-version >/dev/null 2>&1; then \ - cargo set-version --workspace "$$ver"; \ - echo "[ok] Synced Cargo.toml to $$ver"; \ - else \ - echo "[!!] cargo-edit not installed (cargo install cargo-edit)"; \ - echo "Manual update required: set version = \"$$ver\" in Cargo.toml"; \ - fi +version-sync: ## Write VERSION to Cargo.toml, Cargo.lock and the npm manifests + @./scripts/version-sync.py + +version-check: ## Validate version consistency across Cargo and npm manifests + @./scripts/check-version.sh diff --git a/README.md b/README.md index dfe8396..d36aafa 100644 --- a/README.md +++ b/README.md @@ -102,12 +102,34 @@ From a consumer project using `file:` protocol: } ``` +### Rust library + +```toml +[dependencies] +docprims = "0.2" +``` + +All formats are enabled by default. To build only what you need, disable +default features and pick formats (`markdown`, `html`, `xml`, `docx`, `xlsx`, +`pptx`) or groups (`text`, `ooxml`): + +```toml +docprims = { version = "0.2", default-features = false, features = ["markdown", "docx"] } +``` + ### CLI -From the repository root: +The CLI is behind the `cli` feature: + +```bash +cargo install docprims --features cli +``` + +`cargo install docprims` without `--features cli` fails, because the binary +requires that feature. From a repository checkout: ```bash -cargo install --path crates/docprims-cli +cargo install --path crates/docprims --features cli ``` ## Usage @@ -115,15 +137,25 @@ cargo install --path crates/docprims-cli ### Rust ```rust -use docprims_ooxml::extract_docx; - -fn main() -> Result<(), Box> { - let text = extract_docx("document.docx")?; - println!("{}", text.content); +use docprims::ExtractLimits; + +fn main() -> Result<(), docprims::DocprimsError> { + let extract = docprims::extract_file("document.docx", ExtractLimits::default())?; + println!("{}", extract.document.text); + for block in &extract.document.blocks { + let range = &block.doc_text_range; + println!("{} [{}..{}]", block.kind, range.start_byte, range.end_byte); + } Ok(()) } ``` +The format is chosen from the file extension (or an explicit `docprims::Format` +with `extract_file_as` / `extract_bytes_as`); docprims does not inspect content +to pick a parser. `docprims` is the supported entry point; the +`docprims-core`, `docprims-text` and `docprims-ooxml` crates carry no stability +promise beyond what it re-exports. + ### CLI ```bash @@ -142,10 +174,10 @@ docprims extract report.docx slides.pptx data.xlsx notes.md ``` docprims/ ├── crates/ -│ ├── docprims-core/ # Shared types, traits, errors +│ ├── docprims/ # Library entry point; CLI behind the `cli` feature +│ ├── docprims-core/ # Shared types, errors │ ├── docprims-text/ # Markdown, HTML, XML -│ ├── docprims-ooxml/ # DOCX, XLSX, PPTX -│ └── docprims-cli/ # CLI binary +│ └── docprims-ooxml/ # DOCX, XLSX, PPTX ├── ffi/ │ └── docprims-ffi/ # C-ABI for language bindings └── bindings/ @@ -154,6 +186,10 @@ docprims/ └── python/ # Python binding ``` +Every binding returns the same `extract/v0` JSON structure. Its stability +level, output guarantees and data-sensitivity rules are in +[docs/standards/extract-contract-stability.md](docs/standards/extract-contract-stability.md). + ## Development Project documentation conventions (what is canonical vs planning notes): diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index f2ec6db..34c0c4c 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,53 +1,72 @@ # Release Checklist -This document walks maintainers through the build/sign/upload flow for each docprims release. - -docprims uses the same high-level release posture as sysprims: - -- CI builds unsigned artifacts. -- Maintainers sign checksum manifests locally and upload signatures + public keys. +This checklist covers release preparation, the signed tag and unsigned +draft, the maintainer MFA signing ceremony, and npm and crates.io +publication. CI never receives signing keys and never publishes a GitHub +release. Earlier unsigned annotated tags remain historical; signing begins +with v0.2.0. ## Prerequisites -- `gh` CLI authenticated with push access -- `minisign` installed (required) -- `gpg` installed (optional; only if you also PGP sign) -- Signing keys configured and available on your machine - -## 1. Pre-Release Preparation - -### Code Quality Gates - -- [ ] Ensure `main` is clean: `git status` shows no uncommitted changes -- [ ] Run quality gates: `make check` -- [ ] Run full tests: `cargo test --workspace` -- [ ] Verify cargo-deny passes: `cargo deny check` - -### Version & Documentation - -- [ ] Update `VERSION` file with new semver (e.g. `0.1.1`) -- [ ] Ensure workspace version matches `VERSION` (if applicable) -- [ ] Update `CHANGELOG.md` (if present) - -### Commit & Push - -- [ ] Commit changes: - ```bash - git add -A - git commit -m "release: prepare vX.Y.Z" - ``` -- [ ] Push to main: - ```bash - git push origin main - ``` - -- [ ] Verify local/remote sync (required before running workflows): - ```bash - git fetch origin - git log --oneline origin/main..HEAD - git log --oneline HEAD..origin/main - ``` - Both commands must show no output. +- `make bootstrap` and `make tools` are green +- `gh`, minisign, and optional GPG tooling are available +- `gh` is authenticated for `3leaps/docprims` +- Approved signing material is loaded from an external secret store without + printing values or paths + +Required ceremony variables: + +- `DOCPRIMS_RELEASE_TAG` — the sole canonical tag input, for example + `v0.2.0` when `VERSION` contains `0.2.0` +- `DOCPRIMS_MINISIGN_KEY` — minisign secret-key file outside the repository +- `DOCPRIMS_MINISIGN_PUB` — explicit minisign public-key file +- `DOCPRIMS_DECERNOR_BIN` — absolute executable Decernor v0.1.8+ binary + selected by the maintainer; self-reported identity does not authenticate + the file, so bind it to a trusted tag-built regular file (not a symlink) +- `DOCPRIMS_TAG_MESSAGE_DIR` — external per-cut directory ending in the + canonical tag; its only message input is `message.txt` +- `DOCPRIMS_TAGGER_NAME` / `DOCPRIMS_TAGGER_EMAIL` — fixed infosec + tagger identity (`3 Leaps Infosec Team `) +- `DOCPRIMS_GPG_SIGNING_FINGERPRINT` — full 40-hex authorized primary + fingerprint; `DOCPRIMS_PGP_KEY_ID` selects an exact 40-hex signing + subkey with a trailing `!` + +GPG tag signing requires `DOCPRIMS_PGP_KEY_ID` and +`DOCPRIMS_GPG_HOMEDIR`. Both manifest signatures are made during the +subsequent maintainer ceremony. Partial configuration fails closed. + +## 1. Write and prepare + +- [ ] Set the version everywhere: `make version-set V=X.Y.Z` (writes `VERSION`, + `Cargo.toml`, `Cargo.lock` and the npm manifests), then `make version-check` +- [ ] Check release-tooling fixtures for hard-coded prior-version values; + run `make release-tooling-test` and confirm mismatched tags fail +- [ ] Move `CHANGELOG.md` `[Unreleased]` entries under `## [X.Y.Z] - YYYY-MM-DD` +- [ ] Add `## vX.Y.Z — YYYY-MM-DD` to `RELEASE_NOTES.md` (newest first, at most + three cuts) and copy that exact section to `docs/releases/vX.Y.Z.md` +- [ ] Confirm README MSRV prose matches `[workspace.package].rust-version` +- [ ] Run `make prepush` and `make release-check` +- [ ] Commit the release preparation and merge it through the normal review + process; confirm required CI on `main` is green +- [ ] Prove the release build matrix without releasing: run the Release + workflow manually on `main` (`gh workflow run release.yml --ref main`) + and confirm its `assemble` job validates the exact asset set +- [ ] Merge the release-tag and anchor tooling through a reviewed PR **before** + adding its public pin and anchors. A disposable worktree of the local + tooling branch may be used to rehearse the steps below; perform the real + public-pin/anchor change on a separate branch from updated `main` and + review it in a separate PR before tagging +- [ ] Confirm the reviewed public pin and decernor-generated anchor pair are + committed; Dave generates them with `make release-insert-anchors` using + `DOCPRIMS_DECERNOR_BIN` set to an absolute executable Decernor + v0.1.8 or newer. The ceremony never falls back to `DECERNOR_BIN` or PATH; + generation verifies the installed pair against both public exports +- [ ] Complete the Go bindings prep below and merge its PR +- [ ] From a clean, freshly fetched `main`, run `make release-preflight` + +The preflight requires a clean tree, the full `make prepush` gate, exact +release-note extraction, a successful fetch, and exact equality between +`HEAD` and fetched `origin/main`. ### Go Bindings Prep (Required) @@ -77,31 +96,154 @@ Go bindings must be fetchable via `go get` without requiring Rust. make go-test-shared # shared (from repo root) ``` -### Create and Push Tags +### Initial public pin and anchors (maintainer only) + +Export the **existing approved key's public portion** into the repository for +review; this does not generate a new key or change the key already registered +on GitHub. GitHub's Verified badge is a secondary check, while the committed +pin authorizes the signing key used by CI. Run the commands from the repository +root on the separate public-pin/anchor branch in a fresh shell. Set the intended +release tag by hand +**before** loading the approved external environment so its per-cut message +directory is derived from the intended tag. +Confirm the external message directory ends in that tag. Do not use an ambient +GPG home: + +```bash +export DOCPRIMS_RELEASE_TAG=v0.2.0 # example: first signed cut; set each cut explicitly +# Load the approved external environment for this repository before continuing. +( + set -euo pipefail # a failed guard stops this entire block, even in an interactive shell + : "${DOCPRIMS_RELEASE_TAG:?set the approved tag for this cut}" + : "${DOCPRIMS_GPG_HOMEDIR:?load the approved GPG home}" + : "${DOCPRIMS_PGP_KEY_ID:?load the exact signing-subkey selector}" + : "${DOCPRIMS_GPG_SIGNING_FINGERPRINT:?load the approved primary fingerprint}" + : "${DOCPRIMS_DECERNOR_BIN:?load the trusted Decernor executable}" + : "${DOCPRIMS_TAG_MESSAGE_DIR:?load the per-cut message directory}" + : "${DOCPRIMS_MINISIGN_PUB:?load the approved minisign public export}" + [[ "$DOCPRIMS_GPG_HOMEDIR" == /* && -d "$DOCPRIMS_GPG_HOMEDIR" ]] + [[ "$DOCPRIMS_DECERNOR_BIN" == /* && -f "$DOCPRIMS_DECERNOR_BIN" && + ! -L "$DOCPRIMS_DECERNOR_BIN" && -x "$DOCPRIMS_DECERNOR_BIN" ]] + python3 - "$DOCPRIMS_GPG_HOMEDIR" "$HOME/.gnupg" <<'PY' +from pathlib import Path +import sys +if Path(sys.argv[1]).resolve() == Path(sys.argv[2]).resolve(): + raise SystemExit('STOP: default GPG home is not approved for this ceremony') +PY + [[ "$DOCPRIMS_PGP_KEY_ID" == *'!' ]] + [[ "${DOCPRIMS_TAG_MESSAGE_DIR%/}" == */"$DOCPRIMS_RELEASE_TAG" ]] + bash -c 'source scripts/release-decernor.sh; resolve_release_decernor ceremony' + pin=docs/security/release-signing-keys.asc + [[ ! -e "$pin" && ! -L "$pin" ]] || { echo 'STOP: public pin already exists' >&2; exit 1; } + set -C # never overwrite a public pin + gpg --homedir "$DOCPRIMS_GPG_HOMEDIR" --batch --armor \ + --export "$DOCPRIMS_PGP_KEY_ID" > "$pin" + require_public_only() { + if "$DOCPRIMS_DECERNOR_BIN" fingerprint "$1" --kind "$2" \ + --class private --fail-on-empty --path-mode none >/dev/null; then + echo 'STOP: private material in public export' >&2; exit 1 + else + decernor_rc=$? + [[ "$decernor_rc" -eq 3 ]] || { echo 'STOP: private-material check failed' >&2; exit 1; } + fi + } + require_public_only "$pin" gpg + [[ -s "$DOCPRIMS_MINISIGN_PUB" && ! -L "$DOCPRIMS_MINISIGN_PUB" ]] + require_public_only "$DOCPRIMS_MINISIGN_PUB" minisign + grep -q '^untrusted comment:' "$DOCPRIMS_MINISIGN_PUB" + verify_tmp="$(mktemp -d)" + trap 'rm -rf "$verify_tmp"' EXIT + "$DOCPRIMS_DECERNOR_BIN" fingerprint "$pin" --kind gpg \ + --class public --fail-on-empty --path-mode none >"$verify_tmp/gpg.ndjson" + python3 - "$verify_tmp/gpg.ndjson" "$DOCPRIMS_GPG_SIGNING_FINGERPRINT" \ + "$DOCPRIMS_PGP_KEY_ID" <<'PY' +import json +import pathlib +import re +import sys +records = [json.loads(line) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()] +primary, selector = sys.argv[2:] +if not re.fullmatch('[0-9A-F]{40}', primary) or not re.fullmatch('[0-9A-F]{40}!', selector): + raise SystemExit('STOP: invalid configured fingerprints') +if len(records) != 2 or any(r.get('kind') != 'gpg' or r.get('class') != 'public' for r in records): + raise SystemExit('STOP: expected exactly one public primary and signing subkey') +by_role = {r.get('key_role'): r.get('fingerprint') for r in records} +if by_role != {'primary': primary, 'subkey': selector[:-1]}: + raise SystemExit('STOP: exported public key differs from approved selector') +PY + gpg --homedir "$verify_tmp" --batch --show-keys \ + --fingerprint --with-subkey-fingerprint "$pin" # inspect readable expiry/revocation +) +``` -Create and push tags that point to the SAME commit (the commit that includes the merged Go bindings PR). +The `!` selects one signing subkey; the export includes its primary public key. +Decernor must find **no private record**. With `--fail-on-empty`, exit 3 is the +expected no-match result; exit 0 means private material was found and every +other result is a stop. The block asserts exactly one primary matching the +approved fingerprint and one subkey matching the `!` selector. Before +proceeding, inspect the human-readable GPG output for unexpired, non-revoked +primary and signing subkey; it uses a throwaway home, not the ceremony keyring. +Only after those checks pass, run: ```bash -VERSION=$(cat VERSION) +( + set -euo pipefail # stop before later checks if generation or verification fails + make release-insert-anchors + ./scripts/validate-release-anchors.sh + make release-tooling-test + make prepush + git status --short +) +``` + +The minisign export starts with `untrusted comment:`. After insertion, Decernor reports +`fingerprint verify: records=2 findings=0` and the script reports +`[ok] generated public fingerprint anchors for review`. The two generated +files contain exactly `gpg <40 uppercase hex>` and `minisign <64 lowercase hex>` +in TXT, with corresponding primary and public-blob NDJSON records. The GPG +anchor equals the approved primary. Only the public pin and those two anchors +should appear in the working-tree diff; reviewers re-derive the fingerprints +from the public exports before approving the PR. Stop on any unexpected file, +private marker, missing or extra key, expiry/revocation, mismatch, invalid +Decernor version, or nonzero generation/verification result. Fix the cause and +regenerate; never hand-edit an anchor. If a post-export check fails, Dave must +confirm that the new untracked public pin is his intended export, remove that +failed export, then rerun from the beginning. Never overwrite an existing pin. + +## 2. Create the signed tag and unsigned draft + +Only after an explicit tag cue, from clean `main` at the preflighted commit: -# Canonical repo tag -git tag -a "v${VERSION}" -m "v${VERSION}: docprims release" +```bash +: "${DOCPRIMS_RELEASE_TAG:?load the approved cut}" +: "${DOCPRIMS_TAG_MESSAGE_DIR:?load the external per-cut message dir}" +test -s "${DOCPRIMS_TAG_MESSAGE_DIR}/message.txt" +make release-tag # creates and verifies locally, no push +make release-push-tag # explicit maintainer action; verifies remote object +``` -# Go submodule tag (required for subdir Go module) -git tag -a "bindings/go/docprims/v${VERSION}" -m "bindings/go/docprims/v${VERSION}" +Then tag the Go submodule at the same commit (required for the subdirectory +Go module): -git push origin "v${VERSION}" "bindings/go/docprims/v${VERSION}" +```bash +git tag -a "bindings/go/docprims/${DOCPRIMS_RELEASE_TAG}" -m "bindings/go/docprims/${DOCPRIMS_RELEASE_TAG}" +git push origin "bindings/go/docprims/${DOCPRIMS_RELEASE_TAG}" ``` -### CI Verification +- [ ] Confirm the tag workflow is green +- [ ] Confirm the tag has GitHub **Verified** status and the + `verify-signature` gate passed before a draft is created +- [ ] Confirm the GitHub release is still a draft +- [ ] Confirm it contains the CLI archives from + `config/release/cli-platforms.txt`, `docprims.h`, the CycloneDX SBOM, + and both licenses +- [ ] Do not sign until the draft inventory is complete -- [ ] Wait for GitHub Actions workflows on the tag (release build, if configured) -- [ ] (Recommended) Run Go bindings staged validation: - - `.github/workflows/go-bindings.yml` (glibc on PR; musl on manual) -- [ ] (Recommended) Validate the tag (includes Go + TypeScript): +### Release validation + +- [ ] Validate the tag (Go and TypeScript): ```bash - VERSION=$(cat VERSION) - gh workflow run "Validate Release" -f tag="v${VERSION}" + gh workflow run "Validate Release" -f tag="${DOCPRIMS_RELEASE_TAG}" ``` ### TypeScript N-API Prebuilds (Required for npm) @@ -119,84 +261,57 @@ Run the prebuilds workflow on the release tag to build cross-platform native bin ``` - [ ] Verify all platforms built successfully -The prebuilds must complete before running the npm publish workflow in Section 3. - -## 2. Manual Signing (Local Machine) +The prebuilds must complete before running the npm publish workflow in Section 4. -docprims signs checksum manifests for release artifacts. +## 3. Maintainer MFA sign and upload -### Set Environment Variables +Use a clean detached checkout. Fetch both the exact tag and `main`; the strict +guard peels the annotated tag and requires the tag, `HEAD`, and fetched +`origin/main` to be the same commit. ```bash -export DOCPRIMS_RELEASE_TAG=v$(cat VERSION) +: "${DOCPRIMS_RELEASE_TAG:?load the approved release tag}" +: "${DOCPRIMS_MINISIGN_KEY:?load the approved secret key}" +: "${DOCPRIMS_MINISIGN_PUB:?load the approved public key}" +test -z "$(git status --porcelain)" +git fetch origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/tags/${DOCPRIMS_RELEASE_TAG}:refs/tags/${DOCPRIMS_RELEASE_TAG}" +git checkout --detach "$DOCPRIMS_RELEASE_TAG" +DOCPRIMS_REQUIRE_TAG=1 make release-guard-tag-version +make release-verify-tag +make release-verify-remote-tag +make release +``` -export DOCPRIMS_MINISIGN_KEY=/path/to/docprims.key -export DOCPRIMS_MINISIGN_PUB=/path/to/docprims.pub +`make release` performs the only serialized walk: -# Optional PGP signing -export DOCPRIMS_PGP_KEY_ID="keyid!" -export DOCPRIMS_GPG_HOMEDIR=/path/to/gpg/homedir -``` +Keep `DOCPRIMS_DECERNOR_BIN` bound to the reviewed absolute v0.1.8+ +executable throughout the ceremony; `release-verify-keys` re-derives both +exported publics against the anchors staged into the signed set. + +1. Safely empty the repository-owned `dist/release` +2. Download and structurally validate the exact unsigned draft assets +3. Add the exact per-cut release notes and staged public fingerprint anchors +4. Generate exact SHA-256 and SHA-512 manifests +5. Sign both manifests +6. Export and prove public verification keys +7. Verify once, upload the explicit provenance set, and recheck the exact + remote inventory -### Signing Steps - -1. Clean previous release artifacts: - ```bash - make release-clean - ``` - -2. Download artifacts from the GitHub draft release: - ```bash - make release-download - ``` - -3. Generate checksum manifests: - ```bash - make release-checksums - ``` - Produces: `SHA256SUMS`, `SHA512SUMS` - -4. Sign checksum manifests: - ```bash - make release-sign - ``` - Produces: `.minisig` and optional `.asc` signatures - - > **Note (Ghostty users):** If GPG fails with "Screen or window too small", prefix with: - > ```bash - > TERM=xterm-256color make release-sign - > ``` - -5. Export public keys into the release directory: - ```bash - make release-export-keys - ``` - Produces: - - `docprims-minisign.pub` - - `docprims-release-signing-key.asc` (optional) - -6. Verify everything before upload: - ```bash - make release-verify - ``` - -7. Copy release notes (optional): - ```bash - make release-notes - ``` - -8. Upload signed assets to GitHub and publish the release: - ```bash - make release-upload - ``` - -## 3. TypeScript npm Publishing +- [ ] Confirm the release remains a draft after upload +- [ ] Independently download into an empty directory and verify both checksum + manifests and minisign signatures +- [ ] Review the final release notes and asset list +- [ ] Publish the GitHub release only as a separate, explicit maintainer action + +## 4. TypeScript npm Publishing After signing and undrafting the GitHub release, publish to npm. ### Prerequisites -- Prebuilds workflow completed successfully (Section 1) +- Prebuilds workflow completed successfully (Section 2) - GitHub release is public (not draft) - `publish-npm` environment configured on GitHub (for OIDC trusted publishing) @@ -229,9 +344,64 @@ npm publish --access public This publishes with local platform binary only. Run the workflow publish afterward for cross-platform support. -## 4. Post-Release Verification +## 5. crates.io (maintainer action after the tag) + +CI has no registry token and never uploads crates. A registry version cannot +be overwritten: a correction takes a new patch version. + +The only ordered list is `config/release/publishable-crates.txt` +(`docprims-core`, `docprims-text`, `docprims-ooxml`, `docprims`); print and +validate it with `make release-crates-list`. `docprims-ffi` and +`docprims-ts-napi` are unpublished (`publish = false`), which +`make release-tooling-test` proves. + +- [ ] In a clean detached checkout of the pushed tag: + ```bash + git checkout --detach "v$(cat VERSION)" + DOCPRIMS_RELEASE_TAG="v$(cat VERSION)" DOCPRIMS_REQUIRE_TAG=1 make release-guard-tag-version + DOCPRIMS_RELEASE_TAG="v$(cat VERSION)" make release-verify-remote-tag + DOCPRIMS_RELEASE_TAG="v$(cat VERSION)" make release-crates-dry-run + ``` + The dry run patches earlier workspace crates in by path; it uploads nothing. +- [ ] Use a crates.io token scoped to the publishable names, kept outside the + repository and CI, with an expiry of 30–90 days. The first upload needs + `publish-new` and `publish-update`; later versions need only + `publish-update`. Do not grant `yank`. Load it only into the environment + of each publish command; do not use `cargo login`. +- [ ] Publish **one crate at a time** in list order, verifying each before the next: + ```bash + make release-crates-list + export DOCPRIMS_RELEASE_TAG="v$(cat VERSION)" + cargo publish --locked -p "${crate:?set the next ordered crate}" + make release-crates-verify CRATE="$crate" + # Repeat for each remaining entry; after the last: + make release-crates-verify + ``` + Each verification waits for `cargo info --registry crates-io @` + and checks the exact version on the crates.io API. +- [ ] Review the crates.io pages and the docs.rs builds. + +## 6. Post-Release Verification - [ ] Verify release is public: `gh release view v$(cat VERSION)` - [ ] Download and verify checksums/signatures using the published public keys - [ ] Verify npm package: `npm view @3leaps/docprims` +- [ ] Verify crates: `cargo info --registry crates-io docprims@$(cat VERSION)` - [ ] Test Go module: `go get github.com/3leaps/docprims/bindings/go/docprims@v$(cat VERSION)` + +## Rotate release signing keys + +Before the first cut with a new key, generate a new public export and anchors +under maintainer identity and land both through a reviewed PR. CI must see the +new pinned public key _in the tagged commit_; an account-level GitHub Verified +indicator alone cannot authorize a release. Re-derive both fingerprint records +with `decernor fingerprint`, review the primary/subkey relationship, and confirm +the tagger account has the matching public key and verified email. Check the +primary and signing-subkey expiration with: + +```bash +gpg --homedir "$DOCPRIMS_GPG_HOMEDIR" --list-keys --with-subkey-fingerprint --with-colons "$DOCPRIMS_GPG_SIGNING_FINGERPRINT" +``` + +An expired key is a rotation nobody scheduled. Do not sign or publish until a +reviewed replacement pin is on `main` and the tag ruleset is active. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 052646c..cac22b3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,47 +6,88 @@ --- -## v0.1.5 - 2026-07-07 +## v0.2.0 — 2026-09-25 -**Status:** Patch Release (runtime floors + repository guidance) +**Status:** Minor Release (library entry point, extraction fixes, dependency refresh) -Refreshes package runtime floors and repository guidance before the next coordinated dependency modernization pass. +### Summary + +v0.2.0 adds the `docprims` crate as the library's entry point, fixes several +extraction edge cases, and updates dependencies and toolchains across the +Rust crates and the C, Go and TypeScript bindings. It contains breaking +changes for Rust users of the subcrates; bindings keep their results and +error codes. ### Highlights -- **Rust MSRV**: Raised the workspace MSRV to Rust 1.88.0. -- **TypeScript Node floor**: `@3leaps/docprims` now requires Node.js 22 or newer. -- **Trusted publishing guard**: npm publishing now hard-checks Node >=22.14.0 and npm >=11.5.1. -- **TypeScript workflow alignment**: Binding CI, release validation, and N-API prebuild workflows validate on Node 22. -- **Repository guidance cleanup**: Agent guidance, local-only planning conventions, and YAML linting configuration were refreshed. +- **`docprims` crate**: One dependency for all formats, with a feature per format. +- **Extracted text character set**: Every format emits only XML 1.0 characters, excluding DEL and C1 controls. +- **Numeric character references** resolve in DOCX, XLSX and PPTX. +- **OOXML decompression limits** are enforced on bytes actually read, per part and per archive. +- **Output limits** never produce an empty block or a trailing separator. +- **CLI** is installed with `cargo install docprims --features cli`. -### Runtime Floors +### Using the `docprims` crate -v0.1.5 aligns the project with currently supported runtime floors: +```toml +[dependencies] +docprims = "0.2" +``` -| Component | Floor | -|-----------|-------| -| Rust | 1.88.0 | -| TypeScript package | Node.js 22+ | -| npm trusted publishing | Node.js >=22.14.0 and npm >=11.5.1 | +```rust +use docprims::{extract_file, ExtractLimits}; -The TypeScript package remains on TypeScript 5.x and napi-rs 2.x for this release. TypeScript 6.x and napi-rs 3.x are reserved for a later coordinated modernization pass. +let result = extract_file("report.docx", ExtractLimits::default())?; +println!("{}", result.document.text); +``` -### Release Workflow Hardening +The parser is chosen from the file extension, or explicitly with +`extract_file_as` / `extract_bytes_as` and a `Format`. Content is never +inspected to choose a parser. -The npm publish workflow now uses a dedicated runtime guard that fails fast if the publish runner is below the trusted-publishing floor. The same guard is available locally through: +Formats are features: `markdown`, `html`, `xml`, `docx`, `xlsx`, `pptx`, +grouped as `text` and `ooxml`, all on by default. A Markdown-only build: -```bash -make npm-publish-prereqs-check +```toml +docprims = { version = "0.2", default-features = false, features = ["markdown"] } ``` -`make lint` also runs this guard so workflow drift is caught during normal quality checks. +`docprims-core`, `docprims-text` and `docprims-ooxml` are published as +components of `docprims` and carry no stability promise beyond what +`docprims` re-exports. + +### Breaking Changes + +| Change | Migration | +|--------|-----------| +| `docprims-cli` crate removed | `cargo install docprims --features cli` (same binary name, commands and output) | +| `--timeout-ms` / `ExtractOptions::timeout_ms` removed (never applied) | Remove the flag or field | +| `ExtractOptions` removed from `docprims-core` | Use `ExtractLimits` | +| `docprims_ooxml::common` no longer public | Use `docprims::extract_*` | +| `DocprimsError`, `DocprimsQualityStatus`, `DocprimsContainerKind` are `#[non_exhaustive]` | Add a wildcard arm to matches | + +### Extraction Changes + +Extracted text may differ from v0.1.x for documents that contain: + +- control characters, DEL or C1 controls (now dropped); +- numeric character references in DOCX, XLSX or PPTX (now resolved); +- output truncated by `max_output_bytes` (no empty final block). + +Block byte ranges are computed after characters are dropped, so they always +index the emitted text. + +### Runtime Floors + +| Component | Floor | +|-----------|-------| +| Rust (MSRV) | 1.88.0 | +| TypeScript package | Node.js 22+ | +| npm trusted publishing | Node.js >=22.14.0 and npm >=11.5.1 | -### Documentation Updates +### Changelog -- README CLI install guidance now uses `cargo install --path crates/docprims-cli` from a repo checkout. -- Repository guidance now treats planning artifacts as local-only and non-canonical. -- YAML linting configuration is explicit at repo root. +See `CHANGELOG.md` for the full list of changes. --- diff --git a/VERSION b/VERSION index 9faa1b7..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.5 +0.2.0 diff --git a/bindings/go/docprims/docprims.go b/bindings/go/docprims/docprims.go index ccd5100..75db579 100644 --- a/bindings/go/docprims/docprims.go +++ b/bindings/go/docprims/docprims.go @@ -20,6 +20,10 @@ import ( "unsafe" ) +// Limits bounds an extraction. A zero field means "use the default": the +// fields are omitted from the request when zero, so Go callers cannot set a +// limit to 0 (which in Rust and TypeScript means literally zero). No limit is +// useful at 0, and a zero value can never disable a limit. type Limits struct { MaxInputBytes uint64 `json:"max_input_bytes,omitempty"` MaxOutputBytes uint64 `json:"max_output_bytes,omitempty"` diff --git a/bindings/go/docprims/docprims_contract_test.go b/bindings/go/docprims/docprims_contract_test.go new file mode 100644 index 0000000..00b9cd1 --- /dev/null +++ b/bindings/go/docprims/docprims_contract_test.go @@ -0,0 +1,135 @@ +package docprims + +import ( + "encoding/json" + "errors" + "fmt" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" +) + +// The Go surface must reproduce the committed v0 goldens exactly: the same +// contract the Rust CLI and TypeScript surfaces are held to. +func TestExtractBytesMatchesV0Goldens(t *testing.T) { + cases := []struct{ golden, fixture, uri string }{ + {"markdown-simple", "testdata/fixtures/text/simple.md", "testdata/fixtures/text/simple.md"}, + {"html-simple", "testdata/fixtures/text/simple.html", "testdata/fixtures/text/simple.html"}, + {"xml-simple", "testdata/fixtures/text/simple.xml", "testdata/fixtures/text/simple.xml"}, + {"docx-mini", "testdata/fixtures/ooxml/mini.docx.b64", "testdata/fixtures/ooxml/mini.docx"}, + {"xlsx-mini", "testdata/fixtures/ooxml/mini.xlsx.b64", "testdata/fixtures/ooxml/mini.xlsx"}, + {"pptx-mini", "testdata/fixtures/ooxml/mini.pptx.b64", "testdata/fixtures/ooxml/mini.pptx"}, + } + for _, c := range cases { + t.Run(c.golden, func(t *testing.T) { + var data []byte + if strings.HasSuffix(c.fixture, ".b64") { + data = readB64Fixture(c.fixture) + } else { + data = readFixture(c.fixture) + } + out, err := ExtractBytes(c.uri, data, nil) + if err != nil { + t.Fatalf("ExtractBytes: %v", err) + } + + var got, want map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("output is not JSON: %v", err) + } + wantBytes := readFixture(filepath.Join("testdata", "golden", "v0", c.golden+".json")) + if err := json.Unmarshal(wantBytes, &want); err != nil { + t.Fatal(err) + } + // Goldens pin the contract, not the library version. + got["generator"].(map[string]any)["version"] = want["generator"].(map[string]any)["version"] + if !reflect.DeepEqual(got, want) { + t.Fatalf("output differs from golden\n got: %s", out) + } + }) + } +} + +func errorCode(t *testing.T, err error) uint32 { + t.Helper() + var e *Error + if !errors.As(err, &e) { + t.Fatalf("expected *docprims.Error, got %T: %v", err, err) + } + return e.Code +} + +func TestErrorCodesAcrossFailureClasses(t *testing.T) { + docx := readB64Fixture("testdata/fixtures/ooxml/mini.docx.b64") + cases := []struct { + name string + uri string + data []byte + opts *Options + code uint32 + }{ + {"not an archive", "mem://x.docx", []byte("plain text"), nil, 60}, + // Zero-length input crosses the cgo boundary as a nil pointer. + {"empty input", "mem://x.docx", nil, nil, 60}, + {"truncated archive", "mem://x.pptx", docx[:len(docx)/2], nil, 60}, + {"unknown extension", "mem://x.bin", []byte("x"), nil, 60}, + {"missing extension", "mem://noext", []byte("x"), nil, 60}, + {"invalid utf-8 text", "mem://x.md", []byte{'#', ' ', 0xff, 0xfe}, nil, 60}, + {"input over limit", "mem://x.md", []byte("# heading"), &Options{Limits: &Limits{MaxInputBytes: 1}}, 70}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out, err := ExtractBytes(c.uri, c.data, c.opts) + if err == nil { + t.Fatalf("expected error, got output %s", out) + } + if got := errorCode(t, err); got != c.code { + t.Fatalf("code = %d, want %d (%v)", got, c.code, err) + } + if err.Error() == "" { + t.Fatal("error carries no message") + } + }) + } +} + +// Error detail lives in thread-local FFI state. Concurrent callers must each +// see their own message, and a success must not surface a stale failure. +func TestConcurrentCallsKeepErrorsSeparate(t *testing.T) { + const workers = 32 + var wg sync.WaitGroup + failures := make(chan string, workers*20) + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < 20; i++ { + ext := fmt.Sprintf("e%dx%d", w, i) + _, err := ExtractBytes("mem://file."+ext, []byte("x"), nil) + if err == nil || !strings.Contains(err.Error(), ext) { + failures <- fmt.Sprintf("worker %d iter %d: want message naming %q, got %v", w, i, ext, err) + } + out, err := ExtractBytes("mem://ok.md", []byte("# ok\n"), nil) + if err != nil || !strings.Contains(string(out), `"ok`) { + failures <- fmt.Sprintf("worker %d iter %d: success call failed: %v", w, i, err) + } + } + }(w) + } + wg.Wait() + close(failures) + for f := range failures { + t.Error(f) + } +} + +func TestVersionReporting(t *testing.T) { + if Version() == "" { + t.Fatal("Version() is empty") + } + if got := ABIVersion(); got != 1 { + t.Fatalf("ABIVersion() = %d, want 1", got) + } +} diff --git a/bindings/typescript/docprims/native/Cargo.toml b/bindings/typescript/docprims/native/Cargo.toml index 5e9aa1c..2f0c9a3 100644 --- a/bindings/typescript/docprims/native/Cargo.toml +++ b/bindings/typescript/docprims/native/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +publish.workspace = true rust-version.workspace = true description = "Node-API addon for @3leaps/docprims (TypeScript bindings)" @@ -12,14 +13,13 @@ description = "Node-API addon for @3leaps/docprims (TypeScript bindings)" crate-type = ["cdylib"] [dependencies] -napi = { version = "2.16.16", default-features = false, features = ["napi8"] } -napi-derive = "2.16.13" +napi = { version = "3", default-features = false, features = ["napi8"] } +napi-derive = "3" serde = { workspace = true, features = ["derive"] } serde_json.workspace = true docprims-core.workspace = true -docprims-text.workspace = true -docprims-ooxml.workspace = true +docprims = { workspace = true } [build-dependencies] -napi-build = "2.0.1" +napi-build = "2.3" diff --git a/bindings/typescript/docprims/native/src/lib.rs b/bindings/typescript/docprims/native/src/lib.rs index c69e0bc..1179441 100644 --- a/bindings/typescript/docprims/native/src/lib.rs +++ b/bindings/typescript/docprims/native/src/lib.rs @@ -114,16 +114,7 @@ pub fn docprims_extract_file_json(path: String, options_json: String) -> Docprim Err(code) => return err_json(code, "invalid options_json"), }; - let ext = ext_lowercase(&path); - let extracted = match ext.as_str() { - "docx" => docprims_ooxml::extract_docx_v0(&path, limits), - "xlsx" => docprims_ooxml::extract_xlsx_v0(&path, limits), - "pptx" => docprims_ooxml::extract_pptx_v0(&path, limits), - "md" | "markdown" => docprims_text::extract_markdown_v0(&path, limits), - "html" | "htm" => docprims_text::extract_html_v0(&path, limits), - "xml" => docprims_text::extract_xml_v0(&path, limits), - _ => Err(DocprimsError::UnknownFormat(ext)), - }; + let extracted = docprims::extract_file(&path, limits); match extracted { Ok(extract) => match serde_json::to_string(&extract) { @@ -170,33 +161,7 @@ pub fn docprims_extract_bytes_json( ); } - let bytes = data.as_ref(); - let extracted = match ext.as_str() { - "docx" => { - docprims_ooxml::extract_docx_v0_reader(std::io::Cursor::new(bytes), &source_uri, limits) - } - "xlsx" => { - docprims_ooxml::extract_xlsx_v0_reader(std::io::Cursor::new(bytes), &source_uri, limits) - } - "pptx" => { - docprims_ooxml::extract_pptx_v0_reader(std::io::Cursor::new(bytes), &source_uri, limits) - } - "md" | "markdown" => match std::str::from_utf8(bytes) { - Ok(s) => docprims_text::markdown::extract_v0_str(s, &source_uri, limits), - Err(_) => Err(DocprimsError::Malformed( - "non-utf8 markdown input".to_string(), - )), - }, - "html" | "htm" => match std::str::from_utf8(bytes) { - Ok(s) => docprims_text::html::extract_v0_str(s, &source_uri, limits), - Err(_) => Err(DocprimsError::Malformed("non-utf8 html input".to_string())), - }, - "xml" => match std::str::from_utf8(bytes) { - Ok(s) => docprims_text::xml::extract_v0_str(s, &source_uri, limits), - Err(_) => Err(DocprimsError::Malformed("non-utf8 xml input".to_string())), - }, - _ => Err(DocprimsError::UnknownFormat(ext)), - }; + let extracted = docprims::extract_bytes(&source_uri, data.as_ref(), limits); match extracted { Ok(extract) => match serde_json::to_string(&extract) { diff --git a/bindings/typescript/docprims/package-lock.json b/bindings/typescript/docprims/package-lock.json new file mode 100644 index 0000000..1f0f46e --- /dev/null +++ b/bindings/typescript/docprims/package-lock.json @@ -0,0 +1,2404 @@ +{ + "name": "@3leaps/docprims", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@3leaps/docprims", + "version": "0.2.0", + "license": "MIT OR Apache-2.0", + "devDependencies": { + "@napi-rs/cli": "^3.10.5", + "@types/node": "^22.20.4", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.5.tgz", + "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.2.tgz", + "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.3.tgz", + "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/external-editor": "^3.0.5", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.5.tgz", + "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", + "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.6.tgz", + "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.3.tgz", + "integrity": "sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.2.tgz", + "integrity": "sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.7.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.2.tgz", + "integrity": "sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.5", + "@inquirer/confirm": "^6.3.2", + "@inquirer/editor": "^5.3.3", + "@inquirer/expand": "^5.1.5", + "@inquirer/input": "^5.1.6", + "@inquirer/number": "^4.2.3", + "@inquirer/password": "^5.2.2", + "@inquirer/rawlist": "^5.3.5", + "@inquirer/search": "^4.3.3", + "@inquirer/select": "^5.2.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.5.tgz", + "integrity": "sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.3.tgz", + "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.5.tgz", + "integrity": "sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cli": { + "version": "3.10.5", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-3.10.5.tgz", + "integrity": "sha512-GF4WXmLLQk3uQvkPuLCOSH50DN4GUKZw1zHlMDh4rRF5TWpfOj3a8DkZ2PWCTwpPDMD4ex05sAjrCEwNdukEjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.5.2", + "@napi-rs/cross-toolchain": "^1.0.3", + "@napi-rs/wasm-tools": "^1.1.0", + "@octokit/rest": "^22.0.1", + "clipanion": "^4.0.0-rc.4", + "colorette": "^2.0.20", + "es-toolkit": "^1.47.0", + "js-yaml": "^5.0.0", + "obug": "^3.0.0", + "semver": "^7.8.2", + "typanion": "^3.14.0", + "typescript": "^6.0.3" + }, + "bin": { + "napi": "dist/cli.js", + "napi-raw": "cli.mjs" + }, + "engines": { + "node": "^20.17.0 || ^22.13.0 || >= 23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4", + "emnapi": "^1.7.1 || ^2.0.0-alpha.4" + }, + "peerDependenciesMeta": { + "@emnapi/core": { + "optional": true + }, + "@emnapi/runtime": { + "optional": true + }, + "emnapi": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cli/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@napi-rs/cross-toolchain": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/cross-toolchain/-/cross-toolchain-1.0.3.tgz", + "integrity": "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==", + "dev": true, + "license": "MIT", + "workspaces": [ + ".", + "arm64/*", + "x64/*" + ], + "dependencies": { + "@napi-rs/lzma": "^1.4.5", + "@napi-rs/tar": "^1.1.0", + "debug": "^4.4.1" + }, + "peerDependencies": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" + }, + "peerDependenciesMeta": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-x86_64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-x86_64": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma/-/lzma-1.4.5.tgz", + "integrity": "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/lzma-android-arm-eabi": "1.4.5", + "@napi-rs/lzma-android-arm64": "1.4.5", + "@napi-rs/lzma-darwin-arm64": "1.4.5", + "@napi-rs/lzma-darwin-x64": "1.4.5", + "@napi-rs/lzma-freebsd-x64": "1.4.5", + "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", + "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", + "@napi-rs/lzma-linux-arm64-musl": "1.4.5", + "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", + "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", + "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", + "@napi-rs/lzma-linux-x64-gnu": "1.4.5", + "@napi-rs/lzma-linux-x64-musl": "1.4.5", + "@napi-rs/lzma-wasm32-wasi": "1.4.5", + "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", + "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", + "@napi-rs/lzma-win32-x64-msvc": "1.4.5" + } + }, + "node_modules/@napi-rs/lzma-android-arm-eabi": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.4.5.tgz", + "integrity": "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-android-arm64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.4.5.tgz", + "integrity": "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-darwin-arm64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.4.5.tgz", + "integrity": "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-darwin-x64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.4.5.tgz", + "integrity": "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-freebsd-x64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.4.5.tgz", + "integrity": "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.4.5.tgz", + "integrity": "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.4.5.tgz", + "integrity": "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-musl": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.4.5.tgz", + "integrity": "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.4.5.tgz", + "integrity": "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.4.5.tgz", + "integrity": "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-s390x-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.4.5.tgz", + "integrity": "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.4.5.tgz", + "integrity": "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-musl": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-musl/-/lzma-linux-x64-musl-1.4.5.tgz", + "integrity": "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.4.5.tgz", + "integrity": "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/lzma-win32-arm64-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.4.5.tgz", + "integrity": "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-win32-ia32-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.4.5.tgz", + "integrity": "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-win32-x64-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.4.5.tgz", + "integrity": "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar/-/tar-1.1.1.tgz", + "integrity": "sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/tar-android-arm-eabi": "1.1.1", + "@napi-rs/tar-android-arm64": "1.1.1", + "@napi-rs/tar-darwin-arm64": "1.1.1", + "@napi-rs/tar-darwin-x64": "1.1.1", + "@napi-rs/tar-freebsd-x64": "1.1.1", + "@napi-rs/tar-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/tar-linux-arm64-gnu": "1.1.1", + "@napi-rs/tar-linux-arm64-musl": "1.1.1", + "@napi-rs/tar-linux-ppc64-gnu": "1.1.1", + "@napi-rs/tar-linux-s390x-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-musl": "1.1.1", + "@napi-rs/tar-wasm32-wasi": "1.1.1", + "@napi-rs/tar-win32-arm64-msvc": "1.1.1", + "@napi-rs/tar-win32-ia32-msvc": "1.1.1", + "@napi-rs/tar-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/tar-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm-eabi/-/tar-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-cAhnA10cSusAUbcE9HtjQY/tZ9BH/0w2sKtRcQc94TzIlnm7QSr1htJSd/PPrbWNPtrv1orXb2CkrHlVlbnlHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm64/-/tar-android-arm64-1.1.1.tgz", + "integrity": "sha512-EslUWHCDBY/g5abTPBiHLsMaML4GagV0TXLm5WL9hAjx/DDtlxz9fegMb77RJ+f7nFLOIsUxF/3QWFvgOT0sMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-arm64/-/tar-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-+A42/6ES5G9CQ35BOwzwA+WBjLID28r2jNPgc0dteD2hhClIhng0mva7D2ujUlXBNmgNOsr1LHn3stA4uTf4NQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-x64/-/tar-darwin-x64-1.1.1.tgz", + "integrity": "sha512-RYtE8w1dkEvj8hSJCDV5Jw0Rz2i13fsM7u893zv5O9n/4Ad5GNsw/f4RQ7/0YGSFaenkVxqPFrjmEvUHlKzsrg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-freebsd-x64/-/tar-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-rEepBvCJUwcuvUYkY83e8aot8RsR5Jcnal4PsG3tbWGKW1yAvcXhyMXf0fN6ZGpVRZFnB+FJqDyBxvsCPEXKhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm-gnueabihf/-/tar-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-an1bJdfyhI5FpZYyTQ20mrqwR+a676i8GkaYc4Uy12dH/a7TJIfrK6Qa2Gm46arZvxUvx56qxoRKXbpOjUPvwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-gnu/-/tar-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-w++Vtx36T2yHTKws7GVnmHHcUT1ybB59xLWSh9A8bwEpJVG4dG7Qub9mFe5cpcbfrJ+XP2mKKxC3oUJSunK3iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-musl/-/tar-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-Rh6UFhNtj3i4deJHOBINFIeRL0072mgbeyuK5rl1HokKnNoMKx8qKIZNEzBTTqpogMfDHWGvzyTQdnVxes5dpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-ppc64-gnu/-/tar-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-Cp+AxFbv9zcyAXtnzQi0OzmgDnQgy2w9D4Ubr+iwzMtVgJcztzcEoCcCrN1k2ATdEB01LX2Vb49IaocGOZhC9Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-s390x-gnu/-/tar-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-ZyscC3SYKTBWyDRYjLOKAd5TyJ7q0KACRdQ8bWrb3rgrra1CCIJD66CsGTH6Dh0AVSdfLwZ8MfIIXU6+14BMjQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-gnu/-/tar-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-LlIv+zg4fiOQge9LQX/ieBdRWE2fhVDjCTHxnunZkbugNmdhdelxWf1RpZb/6ZujWpNF4LPu4N/MW7ygg2oYAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-musl/-/tar-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-gZBeoKLjanOVj55qk4EMu13P2i9M0SuINmlGQkOxm1niIJofexzddHUYtqO5o/5QqtyL8lADmAcZplLILMLhHA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-wasm32-wasi/-/tar-wasm32-wasi-1.1.1.tgz", + "integrity": "sha512-rwtQ1Mdt/ft6g6I54fJzbUeLspl4yTwj6I3UJ6mitKnrN42soJkcDrdh3Y/FGvlpqZTad2YMQ96fGJl3EtAm2Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/tar-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-arm64-msvc/-/tar-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-30PVp1AehRpfwxmv5wI4cg0yj3WmWBsZ+1QnLGnvEELu7Eu/+dhNU0nrmhI7VfPgLwSRK2eg9DQTB3tP7Wv9bA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-ia32-msvc/-/tar-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-aI3/rmz+izUChiSeaPxcasAOxhf3FpJNuIHMXlxS/vpW+HIxUsSDR5+XV61PEG5DL4L/75iENVUxmSGM5l2yaw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-x64-msvc/-/tar-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@napi-rs/wasm-tools": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools/-/wasm-tools-1.1.0.tgz", + "integrity": "sha512-VjHyKEqXAwYZK+HY7iJctYvRm3TFEbaQxeZwvAG1QRkoo1a39phMY8J6x9tUEqJI03W6MysB8F2jacI6wvcx+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.22.0" + }, + "optionalDependencies": { + "@napi-rs/wasm-tools-android-arm-eabi": "1.1.0", + "@napi-rs/wasm-tools-android-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-x64": "1.1.0", + "@napi-rs/wasm-tools-freebsd-x64": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-musl": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-musl": "1.1.0", + "@napi-rs/wasm-tools-wasm32-wasi": "1.1.0", + "@napi-rs/wasm-tools-win32-arm64-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-ia32-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm-eabi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.1.0.tgz", + "integrity": "sha512-p6J8PB59I8d/XItXB/go5JH6nKW+xIbpzaL43EBTV0hi7mrS/Z4gs+MsB04ZrlqZN29BdZV8fChRyasuXLhRaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm64/-/wasm-tools-android-arm64-1.1.0.tgz", + "integrity": "sha512-lWoKN3suypeBSCIRPIw+++sH9V2K6nQkhtdt1opu7XY3v9JwLs6Gw063HWRqkNjphlYpkd/Qy8XcfSPGbJj7nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-arm64/-/wasm-tools-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-jfw5vyNDUf6oe0kP8lMveFN9U7cLk1cUosS7uMIfw/xmqmopYfKQ198DAx2g/6aEF7Tm+CqER2gpMpYKui30LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-x64/-/wasm-tools-darwin-x64-1.1.0.tgz", + "integrity": "sha512-R+pjeudAB7BYdH1vKkOJM61Tfv5jB6uXkxmFscYd+KKpdUpWBlNG+s4hr0w4i1rMBM91VhIAETZn2pz+MDHK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-freebsd-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-freebsd-x64/-/wasm-tools-freebsd-x64-1.1.0.tgz", + "integrity": "sha512-hQJTe+aazrT++Vgm6I4lUd9099ItUCFYdd+aKg6Ys6nax6d/cZ1barDLTwA2lwOoVDsXMekJI/FOL6ZvVlIYBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-gnu/-/wasm-tools-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-1TAXJxUHsWGar90k3W/MknavvBMwOWzjh7Q6Spxo8twRcWJbBD5Kow/Q2KhhDq5hxh2sKGDXn3uLc1tdtz4WUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-musl/-/wasm-tools-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-7rw3nlubTjNAVRH2LwphCxHy1b/N2/TerXocQ6XRn4Q+buaY1Z7P/hbdALy1i1ex2yfOU2Xcij7ib7ZLi/lKfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-gnu/-/wasm-tools-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-1sel0t9MRjI/tdT89M8Dd6gPfANeeFP24Xa46R11WeHNwhjsXXZh+xUk50uWCRTSGcaCy3ugm3AMK/lmHYQJkg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-musl/-/wasm-tools-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-o2jH5AMfor4EKF2HII1LBnMQxoWu7+usPifTEY8Zk6e9OiSi4EJkAXf9v3ANlX7TI2V/cUEV34OEW7r10GiVIA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-wasm32-wasi/-/wasm-tools-wasm32-wasi-1.1.0.tgz", + "integrity": "sha512-s6YDtDR1UWrsqJPtaxf+JLYLceWVyn3l8OpQYElHkDhf3Qfz9R6Ba3S0OgznTBv38L5/TIHysQ9Q4yO73Z0csg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-arm64-msvc/-/wasm-tools-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-x+NuxbG84VxU68tU8w7Rf5lSyq0l584M6dVlke5DTweHYFZoMyeqkpbwEq+qsyAX6ivfipK8xRsmFwamb5uDnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-ia32-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-ia32-msvc/-/wasm-tools-win32-ia32-msvc-1.1.0.tgz", + "integrity": "sha512-mdD96QDEp70SX67rXFTY6c725nVYeqEEjyDqzzbNh6u1APj7CI7IMNpMmvE75XbCRl4C2MHZVU4U6AWdAzvyQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-x64-msvc/-/wasm-tools-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-bVVjuvhlyVX++3eJXfDR63cXdw1ay5QYac6iq0MKQw8wZARInTM+bXCtByDT4fzVFI3+7ZthYb/ERWRdBNIqgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.8.tgz", + "integrity": "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.5", + "@octokit/request": "^10.0.16", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.5.tgz", + "integrity": "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.5.tgz", + "integrity": "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.16", + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.16.tgz", + "integrity": "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.5", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.2.tgz", + "integrity": "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^29.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-type": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz", + "integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/js-yaml": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/json-with-bigint": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typanion": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ] + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/bindings/typescript/docprims/package.json b/bindings/typescript/docprims/package.json index b6c879c..ddb90e1 100644 --- a/bindings/typescript/docprims/package.json +++ b/bindings/typescript/docprims/package.json @@ -1,6 +1,6 @@ { "name": "@3leaps/docprims", - "version": "0.1.5", + "version": "0.2.0", "description": "GPL-free document text extraction (TypeScript bindings via Node-API native addon)", "license": "MIT OR Apache-2.0", "repository": { @@ -20,22 +20,19 @@ "node": ">=22" }, "napi": { - "name": "docprims", - "triples": { - "defaults": false, - "additional": [ - "x86_64-unknown-linux-gnu", - "x86_64-unknown-linux-musl", - "aarch64-unknown-linux-gnu", - "aarch64-unknown-linux-musl", - "aarch64-apple-darwin", - "x86_64-pc-windows-msvc" - ] - } + "binaryName": "docprims", + "targets": [ + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc" + ] }, "scripts": { "clean": "node scripts/clean.js", - "build:native": "napi build --cargo-cwd native --release --platform --strip dist/native", + "build:native": "napi build --manifest-path native/Cargo.toml --release --platform --strip --output-dir dist/native", "build": "tsc -p tsconfig.build.json", "build:test": "tsc -p tsconfig.test.json", "test": "npm run clean && npm run build && npm run build:native && npm run build:test && node scripts/run-tests.js", @@ -43,17 +40,9 @@ "typecheck": "tsc -p tsconfig.build.json --noEmit" }, "dependencies": {}, - "optionalDependencies": { - "@3leaps/docprims-linux-x64-gnu": "0.1.5", - "@3leaps/docprims-linux-x64-musl": "0.1.5", - "@3leaps/docprims-linux-arm64-gnu": "0.1.5", - "@3leaps/docprims-linux-arm64-musl": "0.1.5", - "@3leaps/docprims-darwin-arm64": "0.1.5", - "@3leaps/docprims-win32-x64-msvc": "0.1.5" - }, "devDependencies": { - "@napi-rs/cli": "^2.18.4", - "@types/node": "^22.10.0", - "typescript": "^5.6.3" + "@napi-rs/cli": "^3.10.5", + "@types/node": "^22.20.4", + "typescript": "^7.0.2" } } diff --git a/bindings/typescript/docprims/scripts/set-platform-packages.js b/bindings/typescript/docprims/scripts/set-platform-packages.js new file mode 100644 index 0000000..5916319 --- /dev/null +++ b/bindings/typescript/docprims/scripts/set-platform-packages.js @@ -0,0 +1,85 @@ +// Point the root package at the platform packages published with it. +// +// The committed package.json lists no platform packages, so the lock file +// stays installable with `npm ci`. Before publishing, this script checks the +// platform package directory against `napi.targets` and writes +// optionalDependencies for exactly those packages, at the root version. +// +// Usage: node scripts/set-platform-packages.js [--check] [package.json] +// +// must hold one directory per napi target, named by napi's platform +// suffix (for example linux-x64-gnu), each with a package.json for +// - at the root version and its ..node. +// --check verifies without writing. + +const fs = require("node:fs"); +const path = require("node:path"); +const { parseTriple } = require("@napi-rs/cli"); + +function fail(message) { + console.error(`error: ${message}`); + process.exit(1); +} + +function expectedPlatforms(root) { + const targets = root.napi?.targets ?? []; + if (targets.length === 0) fail("package.json has no napi.targets"); + return targets.map((triple) => parseTriple(triple).platformArchABI).sort(); +} + +function main() { + const args = process.argv.slice(2); + const check = args[0] === "--check"; + if (check) args.shift(); + const [dir, rootArg] = args; + if (!dir || args.length > 2) { + fail("usage: set-platform-packages.js [--check] [package.json]"); + } + + const rootPath = rootArg ?? path.resolve(__dirname, "..", "package.json"); + const root = JSON.parse(fs.readFileSync(rootPath, "utf8")); + if (root.optionalDependencies) { + fail("package.json already lists optionalDependencies"); + } + + const expected = expectedPlatforms(root); + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const unexpected = entries.filter((e) => !e.isDirectory()).map((e) => e.name); + if (unexpected.length > 0) fail(`unexpected entries: ${unexpected.join(", ")}`); + const actual = entries.map((e) => e.name).sort(); + if (actual.join(",") !== expected.join(",")) { + fail(`platform directories [${actual}] != napi.targets [${expected}]`); + } + + const binaryName = root.napi?.binaryName; + if (!binaryName) fail("package.json has no napi.binaryName"); + const names = []; + for (const platform of actual) { + const manifest = JSON.parse( + fs.readFileSync(path.join(dir, platform, "package.json"), "utf8"), + ); + if (manifest.name !== `${root.name}-${platform}`) { + fail(`${platform}: package name ${manifest.name}`); + } + if (manifest.version !== root.version) { + fail(`${manifest.name}: version ${manifest.version} != ${root.version}`); + } + const addon = path.join(dir, platform, `${binaryName}.${platform}.node`); + if (!fs.existsSync(addon) || fs.statSync(addon).size === 0) { + fail(`${manifest.name}: missing ${path.basename(addon)}`); + } + names.push(manifest.name); + } + + if (check) { + console.log(`[ok] ${names.length} platform packages match napi.targets`); + return; + } + root.optionalDependencies = Object.fromEntries( + names.map((name) => [name, root.version]), + ); + fs.writeFileSync(rootPath, `${JSON.stringify(root, null, 2)}\n`); + console.log(`[ok] ${names.length} platform packages at ${root.version}`); +} + +main(); diff --git a/bindings/typescript/docprims/src/types.ts b/bindings/typescript/docprims/src/types.ts index 4e2bd45..0b68d93 100644 --- a/bindings/typescript/docprims/src/types.ts +++ b/bindings/typescript/docprims/src/types.ts @@ -1,3 +1,8 @@ +/** + * Resource limits. An omitted field uses the default. A field that is set is + * literal: `0` means zero, not "unlimited" (0 output bytes or blocks yields + * empty, `partial` output). + */ export type ExtractLimits = { max_input_bytes?: number; max_output_bytes?: number; diff --git a/bindings/typescript/docprims/test/contract.test.ts b/bindings/typescript/docprims/test/contract.test.ts new file mode 100644 index 0000000..e5e8767 --- /dev/null +++ b/bindings/typescript/docprims/test/contract.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + abiVersion, + DocprimsError, + DocprimsErrorCode, + extractBytes, + extractFile, + version, +} from "../src/index"; +import { loadNativeBinding } from "../src/native"; + +function packageRoot(): string { + let current = __dirname; + for (let i = 0; i < 10; i++) { + if (fs.existsSync(path.join(current, "package.json"))) return current; + current = path.dirname(current); + } + throw new Error("package.json not found"); +} + +const repoRoot = path.resolve(packageRoot(), "..", "..", ".."); + +function fixtureBytes(rel: string): Buffer { + const p = path.join(repoRoot, rel); + if (!rel.endsWith(".b64")) return fs.readFileSync(p); + return Buffer.from(fs.readFileSync(p, "utf8").replace(/\s+/g, ""), "base64"); +} + +function expectCode(fn: () => unknown, code: DocprimsErrorCode): void { + assert.throws(fn, (e: unknown) => { + assert.ok(e instanceof DocprimsError, `not a DocprimsError: ${e}`); + assert.equal(e.code, code, `code ${e.code} (${e.message})`); + return true; + }); +} + +// The native addon must reproduce the committed v0 goldens exactly: the same +// contract the Rust CLI and FFI surfaces are held to. +const goldens: Array<[string, string, string]> = [ + ["markdown-simple", "testdata/fixtures/text/simple.md", "testdata/fixtures/text/simple.md"], + ["html-simple", "testdata/fixtures/text/simple.html", "testdata/fixtures/text/simple.html"], + ["xml-simple", "testdata/fixtures/text/simple.xml", "testdata/fixtures/text/simple.xml"], + ["docx-mini", "testdata/fixtures/ooxml/mini.docx.b64", "testdata/fixtures/ooxml/mini.docx"], + ["xlsx-mini", "testdata/fixtures/ooxml/mini.xlsx.b64", "testdata/fixtures/ooxml/mini.xlsx"], + ["pptx-mini", "testdata/fixtures/ooxml/mini.pptx.b64", "testdata/fixtures/ooxml/mini.pptx"], +]; + +for (const [name, fixture, uri] of goldens) { + test(`extractBytes() reproduces the ${name} v0 golden`, () => { + const expected = JSON.parse( + fs.readFileSync(path.join(repoRoot, "testdata", "golden", "v0", `${name}.json`), "utf8"), + ); + const got = extractBytes(uri, fixtureBytes(fixture)); + // Goldens pin the contract, not the library version. + got.generator.version = expected.generator.version; + assert.deepEqual(got, expected); + }); +} + +test("extractBytes() accepts a plain Uint8Array, not only Buffer", () => { + const u8 = new TextEncoder().encode("# Title\n\nbody text\n"); + assert.ok(!Buffer.isBuffer(u8)); + assert.ok(extractBytes("mem://x.md", u8).document.text.includes("body text")); +}); + +test("extractBytes() honours the byteOffset of a view into a larger buffer", () => { + const docx = fixtureBytes("testdata/fixtures/ooxml/mini.docx.b64"); + const whole = extractBytes("mem://x.docx", docx); + + // Surround the archive with junk so a binding that ignored the view's + // offset or length would see a corrupt archive. + const backing = Buffer.alloc(docx.length + 64, 0x5a); + docx.copy(backing, 32); + const view = new Uint8Array(backing.buffer, backing.byteOffset + 32, docx.length); + assert.deepEqual(extractBytes("mem://x.docx", view), whole); +}); + +test("malformed and unrecognised inputs map to DataInvalid", () => { + const docx = fixtureBytes("testdata/fixtures/ooxml/mini.docx.b64"); + expectCode(() => extractBytes("mem://x.docx", Buffer.from("not a zip")), DocprimsErrorCode.DataInvalid); + expectCode(() => extractBytes("mem://x.docx", new Uint8Array(0)), DocprimsErrorCode.DataInvalid); + expectCode( + () => extractBytes("mem://x.xlsx", docx.subarray(0, docx.length >> 1)), + DocprimsErrorCode.DataInvalid, + ); + expectCode(() => extractBytes("mem://x.bin", Buffer.from("x")), DocprimsErrorCode.DataInvalid); + expectCode(() => extractBytes("mem://noext", Buffer.from("x")), DocprimsErrorCode.DataInvalid); + expectCode( + () => extractBytes("mem://x.md", Buffer.from([0x23, 0x20, 0xff, 0xfe])), + DocprimsErrorCode.DataInvalid, + ); +}); + +test("invalid options are a usage error, not silently defaulted", () => { + expectCode( + () => + extractBytes("mem://x.md", Buffer.from("# x"), { + limits: { max_blocks: -1 }, + }), + DocprimsErrorCode.Usage, + ); +}); + +test("file-path errors map to Io and ResourceLimit", () => { + const missing = path.join(os.tmpdir(), `docprims-missing-${process.pid}.md`); + expectCode(() => extractFile(missing), DocprimsErrorCode.Io); + + const md = path.join(repoRoot, "testdata", "fixtures", "text", "simple.md"); + expectCode( + () => extractFile(md, { limits: { max_input_bytes: 1 } }), + DocprimsErrorCode.ResourceLimit, + ); +}); + +test("version() and abiVersion() describe the loaded addon", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot(), "package.json"), "utf8")); + assert.equal(version(), pkg.version); + assert.equal(abiVersion(), 1); +}); + +test("loader explains a missing native addon instead of crashing", (t) => { + const empty = fs.mkdtempSync(path.join(os.tmpdir(), "docprims-noaddon-")); + let platformPkgInstalled = false; + try { + loadNativeBinding(empty); + platformPkgInstalled = true; + } catch (e) { + assert.ok(e instanceof Error); + assert.match(e.message, /Failed to load docprims native addon/); + assert.match(e.message, /@3leaps\/docprims-/); + } + if (platformPkgInstalled) { + t.skip("platform package installed; absent-addon path not reachable here"); + } +}); diff --git a/bindings/typescript/docprims/test/platform-packages.test.ts b/bindings/typescript/docprims/test/platform-packages.test.ts new file mode 100644 index 0000000..da6af32 --- /dev/null +++ b/bindings/typescript/docprims/test/platform-packages.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +function packageRoot(): string { + let current = __dirname; + for (let i = 0; i < 10; i++) { + if (fs.existsSync(path.join(current, "package.json"))) return current; + current = path.dirname(current); + } + throw new Error("package.json not found"); +} + +const root = packageRoot(); +const script = path.join(root, "scripts", "set-platform-packages.js"); +const manifest = JSON.parse( + fs.readFileSync(path.join(root, "package.json"), "utf8"), +); +const platforms = [ + "darwin-arm64", + "linux-arm64-gnu", + "linux-arm64-musl", + "linux-x64-gnu", + "linux-x64-musl", + "win32-x64-msvc", +]; + +// A platform package directory as the prebuilds workflow stages it. +function stage(dir: string, platform: string, overrides: object = {}): void { + fs.mkdirSync(path.join(dir, platform), { recursive: true }); + fs.writeFileSync( + path.join(dir, platform, "package.json"), + JSON.stringify({ + name: `${manifest.name}-${platform}`, + version: manifest.version, + ...overrides, + }), + ); + fs.writeFileSync(path.join(dir, platform, `docprims.${platform}.node`), "addon"); +} + +function fixture( + edit: (npmDir: string) => void = () => {}, +): { npmDir: string; pkg: string } { + const work = fs.mkdtempSync(path.join(os.tmpdir(), "docprims-platforms-")); + const npmDir = path.join(work, "npm"); + for (const platform of platforms) stage(npmDir, platform); + edit(npmDir); + const pkg = path.join(work, "package.json"); + fs.copyFileSync(path.join(root, "package.json"), pkg); + return { npmDir, pkg }; +} + +function run(...args: string[]) { + return spawnSync(process.execPath, [script, ...args], { encoding: "utf8" }); +} + +test("committed package.json lists no platform packages", () => { + assert.equal(manifest.optionalDependencies, undefined); +}); + +test("writes optionalDependencies for exactly the napi targets", () => { + const { npmDir, pkg } = fixture(); + assert.equal(run("--check", npmDir, pkg).status, 0); + assert.equal(JSON.parse(fs.readFileSync(pkg, "utf8")).optionalDependencies, undefined); + + const result = run(npmDir, pkg); + assert.equal(result.status, 0, result.stderr); + const written = JSON.parse(fs.readFileSync(pkg, "utf8")).optionalDependencies; + assert.deepEqual( + written, + Object.fromEntries( + platforms.map((p) => [`${manifest.name}-${p}`, manifest.version]), + ), + ); + // A second run refuses to overwrite. + assert.notEqual(run(npmDir, pkg).status, 0); +}); + +test("rejects platform directories that differ from napi.targets", () => { + const cases: Array<[string, (npmDir: string) => void]> = [ + ["missing platform", (d) => fs.rmSync(path.join(d, "win32-x64-msvc"), { recursive: true })], + ["extra platform", (d) => stage(d, "darwin-x64")], + ["renamed platform", (d) => fs.renameSync(path.join(d, "linux-x64-gnu"), path.join(d, "linux-x64"))], + ["stray file", (d) => fs.writeFileSync(path.join(d, "README.md"), "")], + [ + "file in place of a platform directory", + (d) => { + fs.rmSync(path.join(d, "win32-x64-msvc"), { recursive: true }); + fs.writeFileSync(path.join(d, "win32-x64-msvc"), ""); + }, + ], + ["wrong package name", (d) => stage(d, "darwin-arm64", { name: `${manifest.name}-other` })], + ["wrong version", (d) => stage(d, "darwin-arm64", { version: "0.0.0" })], + ["missing addon", (d) => fs.rmSync(path.join(d, "linux-arm64-musl", "docprims.linux-arm64-musl.node"))], + ["empty addon", (d) => fs.writeFileSync(path.join(d, "linux-arm64-musl", "docprims.linux-arm64-musl.node"), "")], + ]; + for (const [label, edit] of cases) { + const { npmDir, pkg } = fixture(edit); + for (const args of [["--check", npmDir, pkg], [npmDir, pkg]]) { + const result = run(...args); + assert.notEqual(result.status, 0, `${label} (${args[0]}) passed`); + assert.match(result.stderr, /^error: /, label); + } + assert.equal( + JSON.parse(fs.readFileSync(pkg, "utf8")).optionalDependencies, + undefined, + `${label} wrote package.json`, + ); + } +}); diff --git a/bindings/typescript/docprims/tsconfig.json b/bindings/typescript/docprims/tsconfig.json index 42a6e50..01bdfbc 100644 --- a/bindings/typescript/docprims/tsconfig.json +++ b/bindings/typescript/docprims/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "Node", + "module": "Node16", + "moduleResolution": "Node16", "declaration": true, "declarationMap": true, "sourceMap": true, @@ -10,6 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, + "types": ["node"], "outDir": "dist", "rootDir": "src" } diff --git a/config/release/cli-platforms.txt b/config/release/cli-platforms.txt new file mode 100644 index 0000000..0a053db --- /dev/null +++ b/config/release/cli-platforms.txt @@ -0,0 +1,6 @@ +linux-amd64 tar.gz docprims +linux-amd64-musl tar.gz docprims +linux-arm64 tar.gz docprims +linux-arm64-musl tar.gz docprims +darwin-arm64 tar.gz docprims +windows-amd64 zip docprims.exe diff --git a/config/release/decernor-ci-sha256.txt b/config/release/decernor-ci-sha256.txt new file mode 100644 index 0000000..9d308ea --- /dev/null +++ b/config/release/decernor-ci-sha256.txt @@ -0,0 +1 @@ +2f034a1945772983e39dae7143ac2f5ba2c3aa79aa249902fb585960a4582261 decernor_0.1.8_linux_amd64.tar.gz diff --git a/config/release/publishable-crates.txt b/config/release/publishable-crates.txt new file mode 100644 index 0000000..16dd0e6 --- /dev/null +++ b/config/release/publishable-crates.txt @@ -0,0 +1,4 @@ +docprims-core +docprims-text +docprims-ooxml +docprims diff --git a/config/release/tagger-identity.txt b/config/release/tagger-identity.txt new file mode 100644 index 0000000..a3a83ac --- /dev/null +++ b/config/release/tagger-identity.txt @@ -0,0 +1 @@ +3 Leaps Infosec Team diff --git a/crates/docprims-cli/Cargo.toml b/crates/docprims-cli/Cargo.toml deleted file mode 100644 index 7868b5d..0000000 --- a/crates/docprims-cli/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "docprims-cli" -description = "CLI for docprims document text extraction" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true - -[[bin]] -name = "docprims" -path = "src/main.rs" - -[dependencies] -docprims-core = { workspace = true } -docprims-text = { workspace = true } -docprims-ooxml = { workspace = true } -clap = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } - -# Fulmen ecosystem -rsfulmen = { workspace = true } - -# Observability -tracing = { workspace = true } -tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } - -[dev-dependencies] -assert_cmd = "2.0" -predicates = "3.1" -base64 = "0.22" -jsonschema = "0.32" diff --git a/crates/docprims-core/Cargo.toml b/crates/docprims-core/Cargo.toml index 6daf54f..52f2a85 100644 --- a/crates/docprims-core/Cargo.toml +++ b/crates/docprims-core/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +publish = true +documentation = "https://docs.rs/docprims-core" +readme = "README.md" [dependencies] serde = { workspace = true } diff --git a/crates/docprims-core/LICENSE-APACHE b/crates/docprims-core/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/docprims-core/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/docprims-core/LICENSE-MIT b/crates/docprims-core/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/docprims-core/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/docprims-core/README.md b/crates/docprims-core/README.md new file mode 100644 index 0000000..ec9991d --- /dev/null +++ b/crates/docprims-core/README.md @@ -0,0 +1,9 @@ +# docprims-core + +Core types, errors and limits for [docprims](https://crates.io/crates/docprims). + +This crate is an internal component of docprims and carries no stability +promise beyond what the `docprims` crate re-exports. Depend on +[`docprims`](https://docs.rs/docprims) instead. + +Licensed under either of MIT or Apache-2.0 at your option. diff --git a/crates/docprims-core/src/lib.rs b/crates/docprims-core/src/lib.rs index de72aa2..adc1259 100644 --- a/crates/docprims-core/src/lib.rs +++ b/crates/docprims-core/src/lib.rs @@ -4,7 +4,13 @@ //! //! This crate provides the shared foundation used by format-specific extractors //! (docprims-text, docprims-ooxml, etc.). +//! +//! Most users should depend on the [`docprims`](https://docs.rs/docprims) +//! crate, which is the supported entry point. This crate's API carries no +//! stability promise beyond what `docprims` re-exports. +#[doc(hidden)] +pub mod xml; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::path::Path; @@ -43,6 +49,7 @@ pub fn truncate_to_utf8_boundary(s: &str, max_bytes: usize) -> &str { /// Errors that can occur during document extraction. #[derive(Error, Debug)] +#[non_exhaustive] pub enum DocprimsError { /// I/O error reading the document #[error("I/O error: {0}")] @@ -94,7 +101,7 @@ impl ExtractedText { /// Create a new ExtractedText with complete extraction. pub fn complete(content: String) -> Self { Self { - content, + content: xml::retain_output_chars(content), metadata: None, quality: ExtractionQuality::Complete, } @@ -103,7 +110,7 @@ impl ExtractedText { /// Create a new ExtractedText with partial extraction. pub fn partial(content: String, reason: String) -> Self { Self { - content, + content: xml::retain_output_chars(content), metadata: None, quality: ExtractionQuality::Partial { reason }, } @@ -167,26 +174,19 @@ pub trait Extractor { fn extensions(&self) -> &'static [&'static str]; } -/// Options for text extraction. -#[derive(Debug, Clone, Default)] -pub struct ExtractOptions { - /// Include document metadata in output - pub include_metadata: bool, - - /// Maximum output size in bytes (0 = unlimited) - pub max_output_size: usize, - - /// Timeout for extraction in milliseconds (0 = unlimited) - pub timeout_ms: u64, -} - /// Resource limits for extraction. /// /// These defaults are intentionally conservative since docprims parses untrusted input. +/// Every limit is literal: `0` means zero, not "unlimited". A limit of `0` for +/// output bytes or blocks yields empty, `partial` output. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct ExtractLimits { + /// Largest input accepted, in bytes; larger input is rejected before parsing. pub max_input_bytes: usize, + /// Largest document text emitted, in bytes; output is truncated at a + /// character boundary and marked `partial`. pub max_output_bytes: usize, + /// Most blocks emitted; further blocks are dropped and output is marked `partial`. pub max_blocks: usize, } @@ -282,8 +282,9 @@ impl DocprimsQuality { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum DocprimsQualityStatus { Complete, Partial, @@ -370,6 +371,7 @@ pub struct DocprimsContainer { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum DocprimsContainerKind { File, Archive, diff --git a/crates/docprims-core/src/xml.rs b/crates/docprims-core/src/xml.rs new file mode 100644 index 0000000..9d7b55a --- /dev/null +++ b/crates/docprims-core/src/xml.rs @@ -0,0 +1,124 @@ +//! XML character rules shared by the extractors that parse XML. + +/// Whether `c` is an XML 1.0 `Char` (XML 1.0 §2.2): tab, line feed, carriage +/// return, and the ranges U+0020–U+D7FF, U+E000–U+FFFD and U+10000–U+10FFFF. +pub fn is_xml_char(c: char) -> bool { + matches!(c, + '\t' | '\n' | '\r' + | '\u{20}'..='\u{D7FF}' + | '\u{E000}'..='\u{FFFD}' + | '\u{10000}'..='\u{10FFFF}') +} + +/// Whether `c` may appear in extracted text: an XML 1.0 `Char` that is not +/// DEL or a C1 control (U+007F–U+009F). XML 1.0 allows those but discourages +/// them, and they act as terminal controls. +pub fn is_output_char(c: char) -> bool { + is_xml_char(c) && !matches!(c, '\u{7F}'..='\u{9F}') +} + +/// Remove every character for which [`is_output_char`] is false. +/// +/// Applied to extracted text before block byte ranges are computed, so the +/// output of every extractor contains no C0 controls other than tab, line +/// feed and carriage return, no DEL, no C1 controls, and nothing outside the +/// XML 1.0 character set. +pub fn retain_output_chars(text: String) -> String { + if text.chars().all(is_output_char) { + return text; + } + text.chars().filter(|&c| is_output_char(c)).collect() +} + +/// Resolve the body of a numeric character reference (`#233` or `#xE9`, i.e. +/// the text between `&` and `;`). +/// +/// Returns `None` if the reference is malformed or does not name an XML 1.0 +/// `Char`; callers drop such references. +pub fn resolve_char_ref(reference: &str) -> Option { + let s = reference.strip_prefix('#')?; + let code = match s.strip_prefix('x').or_else(|| s.strip_prefix('X')) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => s.parse::().ok()?, + }; + char::from_u32(code).filter(|&c| is_xml_char(c)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_decimal_and_hex_references_to_xml_chars() { + assert_eq!(resolve_char_ref("#233"), Some('\u{e9}')); + assert_eq!(resolve_char_ref("#xE9"), Some('\u{e9}')); + assert_eq!(resolve_char_ref("#XE9"), Some('\u{e9}')); + assert_eq!(resolve_char_ref("#x1F600"), Some('\u{1f600}')); + assert_eq!(resolve_char_ref("#9"), Some('\t')); + assert_eq!(resolve_char_ref("#xA"), Some('\n')); + assert_eq!(resolve_char_ref("#xD"), Some('\r')); + } + + #[test] + fn boundaries_of_the_xml_char_ranges() { + for (reference, allowed) in [ + ("#x1F", false), + ("#x20", true), + ("#xD7FF", true), + ("#xD800", false), + ("#xDFFF", false), + ("#xE000", true), + ("#xFFFD", true), + ("#xFFFE", false), + ("#xFFFF", false), + ("#x10000", true), + ("#x10FFFF", true), + ("#x110000", false), + ] { + assert_eq!( + resolve_char_ref(reference).is_some(), + allowed, + "{reference}" + ); + } + } + + #[test] + fn control_characters_other_than_tab_lf_cr_are_rejected() { + for code in (0u32..0x20).filter(|c| ![0x9, 0xA, 0xD].contains(c)) { + assert_eq!(resolve_char_ref(&format!("#{code}")), None, "#{code}"); + } + } + + #[test] + fn output_chars_exclude_del_and_c1_but_keep_neighbours() { + for c in ['\u{7F}', '\u{80}', '\u{85}', '\u{9B}', '\u{9F}'] { + assert!(is_xml_char(c), "{c:?} is an XML Char"); + assert!(!is_output_char(c), "{c:?}"); + } + for c in ['\u{7E}', '\u{A0}', '\t', '\n', '\r', 'é'] { + assert!(is_output_char(c), "{c:?}"); + } + assert_eq!( + retain_output_chars("a\u{7f}b\u{9b}31mc\u{1b}d".into()), + "ab31mcd" + ); + } + + #[test] + fn legacy_extracted_text_is_filtered() { + assert_eq!( + crate::ExtractedText::complete("a\u{1}b\u{9b}c".into()).content, + "abc" + ); + let partial = crate::ExtractedText::partial("a\u{1b}b".into(), "limit".into()); + assert_eq!(partial.content, "ab"); + } + + #[test] + fn malformed_references_are_rejected() { + for reference in ["", "#", "#x", "#xZZ", "#-1", "#99999999999", "amp", "x41"] { + assert_eq!(resolve_char_ref(reference), None, "{reference:?}"); + } + } +} diff --git a/crates/docprims-ooxml/Cargo.toml b/crates/docprims-ooxml/Cargo.toml index 6248c62..898ca8d 100644 --- a/crates/docprims-ooxml/Cargo.toml +++ b/crates/docprims-ooxml/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +publish = true +documentation = "https://docs.rs/docprims-ooxml" +readme = "README.md" [features] default = ["docx", "xlsx", "pptx"] @@ -21,5 +24,5 @@ zip = { workspace = true } encoding_rs = { workspace = true } [dev-dependencies] -jsonschema = "0.32" +jsonschema = { version = "0.57", default-features = false } serde_json = { workspace = true } diff --git a/crates/docprims-ooxml/LICENSE-APACHE b/crates/docprims-ooxml/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/docprims-ooxml/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/docprims-ooxml/LICENSE-MIT b/crates/docprims-ooxml/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/docprims-ooxml/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/docprims-ooxml/README.md b/crates/docprims-ooxml/README.md new file mode 100644 index 0000000..1cb4a14 --- /dev/null +++ b/crates/docprims-ooxml/README.md @@ -0,0 +1,9 @@ +# docprims-ooxml + +DOCX, XLSX and PPTX extraction for [docprims](https://crates.io/crates/docprims). + +This crate is an internal component of docprims and carries no stability +promise beyond what the `docprims` crate re-exports. Depend on +[`docprims`](https://docs.rs/docprims) instead. + +Licensed under either of MIT or Apache-2.0 at your option. diff --git a/crates/docprims-ooxml/src/common.rs b/crates/docprims-ooxml/src/common.rs index 255874c..5b195f1 100644 --- a/crates/docprims-ooxml/src/common.rs +++ b/crates/docprims-ooxml/src/common.rs @@ -8,35 +8,81 @@ use zip::ZipArchive; use encoding_rs::Encoding; -/// Maximum decompressed size to prevent zip bombs (100 MB default). +/// Maximum decompressed size of any single archive part (100 MB default). pub const MAX_DECOMPRESSED_SIZE: u64 = 100 * 1024 * 1024; +/// Maximum decompressed size summed over every part read from one archive. +pub const MAX_TOTAL_DECOMPRESSED_SIZE: u64 = 4 * MAX_DECOMPRESSED_SIZE; + /// Maximum number of files in archive to prevent DoS. pub const MAX_ARCHIVE_FILES: usize = 10_000; +/// Upper bound on buffer pre-allocation. The header-declared size is untrusted, +/// so it only sizes the initial buffer up to this cap. +const MAX_PREALLOC: u64 = 1024 * 1024; + +/// A ZIP archive opened for OOXML extraction. +/// +/// Carries a decompression budget shared by every part read from it, so an +/// archive of many individually legal parts cannot inflate without bound. +pub struct OoxmlArchive { + zip: ZipArchive, + part_limit: u64, + remaining: u64, +} + /// Open a ZIP archive with safety limits. -pub fn open_archive(reader: R) -> Result> { - let archive = ZipArchive::new(reader) +pub fn open_archive(reader: R) -> Result> { + open_archive_with_limits(reader, MAX_DECOMPRESSED_SIZE, MAX_TOTAL_DECOMPRESSED_SIZE) +} + +fn open_archive_with_limits( + reader: R, + part_limit: u64, + total_limit: u64, +) -> Result> { + let zip = ZipArchive::new(reader) .map_err(|e| DocprimsError::Malformed(format!("Invalid ZIP archive: {}", e)))?; // Check file count limit - if archive.len() > MAX_ARCHIVE_FILES { + if zip.len() > MAX_ARCHIVE_FILES { return Err(DocprimsError::ResourceLimit(format!( "Archive contains too many files: {} (max {})", - archive.len(), + zip.len(), MAX_ARCHIVE_FILES ))); } - Ok(archive) + Ok(OoxmlArchive { + zip, + part_limit, + remaining: total_limit, + }) } -/// Read a file from the archive with size limits. +/// Read a file from the archive, bounding the bytes actually decompressed. +/// +/// The declared uncompressed size in the archive header is attacker-controlled +/// and is used only as an early reject; the read itself is capped at the +/// smaller of the per-part limit and the archive's remaining budget. pub fn read_archive_file( - archive: &mut ZipArchive, + archive: &mut OoxmlArchive, name: &str, ) -> Result> { - let file = match archive.by_name(name) { + let part_too_large = || { + DocprimsError::ResourceLimit(format!( + "File {} too large: decompressed size exceeds {} bytes", + name, archive.part_limit + )) + }; + let total_too_large = || { + DocprimsError::ResourceLimit(format!( + "Archive too large: total decompressed size exceeds budget at {}", + name + )) + }; + + let file = match archive.zip.by_name(name) { Ok(f) => f, Err(zip::result::ZipError::FileNotFound) => return Ok(None), Err(e) => { @@ -47,22 +93,45 @@ pub fn read_archive_file( } }; - // Check decompressed size - let size = file.size(); - if size > MAX_DECOMPRESSED_SIZE { - return Err(DocprimsError::ResourceLimit(format!( - "File {} too large: {} bytes (max {})", - name, size, MAX_DECOMPRESSED_SIZE - ))); + let declared = file.size(); + if declared > archive.part_limit { + return Err(part_too_large()); + } + if declared > archive.remaining { + return Err(total_too_large()); } - let mut content = Vec::with_capacity(size as usize); - let mut reader = std::io::BufReader::new(file); + let limit = archive.part_limit.min(archive.remaining); + let Some(content) = read_bounded(file, name, limit, declared)? else { + return Err(if limit < archive.part_limit { + total_too_large() + } else { + part_too_large() + }); + }; + archive.remaining -= content.len() as u64; + + Ok(Some(decode_xml_bytes(&content)?)) +} + +/// Read at most `limit` bytes. Returns `None`, without reading further, if the +/// stream holds more. `size_hint` sizes the initial buffer, capped at +/// `MAX_PREALLOC`. +fn read_bounded( + reader: R, + name: &str, + limit: u64, + size_hint: u64, +) -> Result>> { + let mut content = Vec::with_capacity(size_hint.min(limit).min(MAX_PREALLOC) as usize); reader + .take(limit.saturating_add(1)) .read_to_end(&mut content) .map_err(|e| DocprimsError::Malformed(format!("Error reading {}: {}", name, e)))?; - - Ok(Some(decode_xml_bytes(&content)?)) + if content.len() as u64 > limit { + return Ok(None); + } + Ok(Some(content)) } fn decode_xml_bytes(bytes: &[u8]) -> Result { @@ -140,9 +209,215 @@ fn sniff_xml_decl_encoding(bytes: &[u8]) -> Option { Some(after_quote[..end].trim().to_string()) } +/// Resolve an XML entity reference to its string value. +/// +/// Handles predefined XML entities (lt, gt, amp, apos, quot) and numeric +/// character references (&#NNN; or &#xHHH;). A numeric reference resolves +/// only when it names an XML 1.0 `Char` (see `docprims_core::xml`); other +/// numeric references and unknown named entities resolve to `None`. +pub fn resolve_entity(name: &str) -> Option> { + if let Some(resolved) = resolve_xml_entity(name) { + return Some(Cow::Borrowed(resolved)); + } + docprims_core::xml::resolve_char_ref(name).map(|c| Cow::Owned(c.to_string())) +} + #[cfg(test)] mod tests { use super::*; + use std::io::{Cursor, Write}; + use zip::write::SimpleFileOptions; + + /// Build a single-entry deflated archive. If `declared_size` is set, the + /// uncompressed-size fields in both the local and central headers are + /// rewritten to that value, as a hostile archive would. + fn archive_with(name: &str, content: &[u8], declared_size: Option) -> Vec { + let mut bytes = archive_of(&[(name, content)]); + if let Some(size) = declared_size { + patch_uncompressed_size(&mut bytes, size); + } + bytes + } + + fn archive_of(entries: &[(&str, &[u8])]) -> Vec { + let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (name, content) in entries { + w.start_file(*name, opts).unwrap(); + w.write_all(content).unwrap(); + } + w.finish().unwrap().into_inner() + } + + fn patch_uncompressed_size(bytes: &mut [u8], size: u32) { + let mut patched = 0; + for i in 0..bytes.len().saturating_sub(4) { + let offset = match bytes[i..i + 4] { + [0x50, 0x4b, 0x03, 0x04] => 22, // local file header + [0x50, 0x4b, 0x01, 0x02] => 24, // central directory header + _ => continue, + }; + bytes[i + offset..i + offset + 4].copy_from_slice(&size.to_le_bytes()); + patched += 1; + } + assert_eq!(patched, 2, "expected one local and one central header"); + } + + fn read(bytes: Vec, limit: u64) -> Result> { + let mut archive = open_archive_with_limits(Cursor::new(bytes), limit, u64::MAX).unwrap(); + read_archive_file(&mut archive, "part.xml") + } + + #[test] + fn lying_header_cannot_bypass_decompression_limit() { + let content = vec![b'a'; 64 * 1024]; + let bytes = archive_with("part.xml", &content, Some(100)); + match read(bytes, 16 * 1024) { + Err(DocprimsError::ResourceLimit(msg)) => assert!(msg.contains("exceeds 16384")), + other => panic!("expected ResourceLimit, got {other:?}"), + } + } + + #[test] + fn decompression_limit_is_inclusive() { + let limit = 4096; + let at_limit = archive_with("part.xml", &vec![b'a'; limit], None); + assert_eq!(read(at_limit, limit as u64).unwrap().unwrap().len(), limit); + + let over = archive_with("part.xml", &vec![b'a'; limit + 1], None); + assert!(matches!( + read(over, limit as u64), + Err(DocprimsError::ResourceLimit(_)) + )); + } + + #[test] + fn declared_size_over_limit_is_rejected_before_reading() { + let bytes = archive_with("part.xml", b"", Some(u32::MAX)); + match read(bytes, 1024) { + // Content is 4 bytes, so only the declared size can trigger this. + Err(DocprimsError::ResourceLimit(_)) => {} + other => panic!("expected ResourceLimit, got {other:?}"), + } + } + + #[test] + fn understated_size_within_limit_reads_full_content() { + // A header that under-reports must not truncate what we extract. + let content = "".to_string() + &"y".repeat(10_000) + ""; + let bytes = archive_with("part.xml", content.as_bytes(), Some(10)); + assert_eq!(read(bytes, 1024 * 1024).unwrap().unwrap(), content); + } + + /// Endless source that fails the test if pulled past `allowed` bytes. + struct Tripwire { + pulled: u64, + allowed: u64, + } + + impl Read for Tripwire { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + assert!( + self.pulled + buf.len() as u64 <= self.allowed, + "read past the limit: {} bytes already pulled", + self.pulled + ); + buf.fill(b'a'); + self.pulled += buf.len() as u64; + Ok(buf.len()) + } + } + + #[test] + fn bounded_read_stops_at_limit_on_endless_stream() { + let limit = 8192; + let src = Tripwire { + pulled: 0, + allowed: limit + 1, + }; + assert!(read_bounded(src, "part.xml", limit, 10).unwrap().is_none()); + } + + #[test] + fn bounded_read_ignores_huge_size_hint_for_allocation() { + let content = read_bounded(&b""[..], "part.xml", MAX_DECOMPRESSED_SIZE, u64::MAX) + .unwrap() + .unwrap(); + assert_eq!(content, b""); + assert!(content.capacity() <= MAX_PREALLOC as usize); + } + + #[test] + fn missing_part_is_none_not_error() { + let bytes = archive_with("other.xml", b"", None); + assert!(read(bytes, 1024).unwrap().is_none()); + } + + fn three_parts(each: usize) -> Vec { + let body = vec![b'a'; each]; + archive_of(&[("p1.xml", &body), ("p2.xml", &body), ("p3.xml", &body)]) + } + + #[test] + fn aggregate_budget_spans_parts_that_are_individually_legal() { + // Each part is under the per-part limit; together they exceed the total. + let mut archive = + open_archive_with_limits(Cursor::new(three_parts(3000)), 4096, 8192).unwrap(); + assert!(read_archive_file(&mut archive, "p1.xml").unwrap().is_some()); + assert!(read_archive_file(&mut archive, "p2.xml").unwrap().is_some()); + match read_archive_file(&mut archive, "p3.xml") { + Err(DocprimsError::ResourceLimit(msg)) => assert!(msg.contains("total"), "{msg}"), + other => panic!("expected aggregate ResourceLimit, got {other:?}"), + } + } + + #[test] + fn aggregate_budget_holds_when_headers_understate_sizes() { + let mut bytes = three_parts(3000); + let mut patched = 0; + for i in 0..bytes.len() - 4 { + let offset = match bytes[i..i + 4] { + [0x50, 0x4b, 0x03, 0x04] => 22, + [0x50, 0x4b, 0x01, 0x02] => 24, + _ => continue, + }; + bytes[i + offset..i + offset + 4].copy_from_slice(&1u32.to_le_bytes()); + patched += 1; + } + assert_eq!(patched, 6); + let mut archive = open_archive_with_limits(Cursor::new(bytes), 4096, 8192).unwrap(); + read_archive_file(&mut archive, "p1.xml").unwrap(); + read_archive_file(&mut archive, "p2.xml").unwrap(); + assert!(matches!( + read_archive_file(&mut archive, "p3.xml"), + Err(DocprimsError::ResourceLimit(_)) + )); + } + + #[test] + fn declared_size_over_remaining_budget_is_rejected_before_reading() { + // Content is 4 bytes, so only the declared size can trigger this. + let bytes = archive_with("part.xml", b"", Some(5000)); + let mut archive = open_archive_with_limits(Cursor::new(bytes), 8192, 4096).unwrap(); + match read_archive_file(&mut archive, "part.xml") { + Err(DocprimsError::ResourceLimit(msg)) => assert!(msg.contains("total"), "{msg}"), + other => panic!("expected aggregate ResourceLimit, got {other:?}"), + } + } + + #[test] + fn missing_parts_do_not_consume_budget() { + let mut archive = + open_archive_with_limits(Cursor::new(three_parts(3000)), 4096, 6000).unwrap(); + for _ in 0..10 { + assert!(read_archive_file(&mut archive, "absent.xml") + .unwrap() + .is_none()); + } + read_archive_file(&mut archive, "p1.xml").unwrap(); + read_archive_file(&mut archive, "p2.xml").unwrap(); + } #[test] fn sniff_xml_decl_encoding_handles_whitespace() { @@ -174,39 +449,3 @@ mod tests { assert_eq!(out, ""); } } - -/// Resolve an XML entity reference to its string value. -/// -/// Handles predefined XML entities (lt, gt, amp, apos, quot) and numeric -/// character references (&#NNN; or &#xHHH;). -pub fn resolve_entity(entity: &[u8]) -> Option<&'static str> { - let name = std::str::from_utf8(entity).ok()?; - - // Try predefined entities first - if let Some(resolved) = resolve_xml_entity(name) { - return Some(resolved); - } - - // For numeric entities, we'd need to return an owned String, - // but for OOXML content this is rarely needed - None -} - -/// OOXML namespace constants. -pub mod ns { - /// Word processing namespace - pub const WORDPROCESSING: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - - /// Spreadsheet namespace - pub const SPREADSHEET: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; - - /// Presentation namespace - pub const PRESENTATION: &str = "http://schemas.openxmlformats.org/presentationml/2006/main"; - - /// Drawing namespace - pub const DRAWING: &str = "http://schemas.openxmlformats.org/drawingml/2006/main"; - - /// Relationships namespace - pub const RELATIONSHIPS: &str = - "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; -} diff --git a/crates/docprims-ooxml/src/docx.rs b/crates/docprims-ooxml/src/docx.rs index 12f3593..0ad0f91 100644 --- a/crates/docprims-ooxml/src/docx.rs +++ b/crates/docprims-ooxml/src/docx.rs @@ -48,6 +48,11 @@ pub fn extract_v0( let mut block_index = 0usize; for (p_idx, p) in paragraphs.into_iter().enumerate() { + // Paragraphs arrive trimmed; filtering removes no whitespace, so + // re-trimming yields the same result as filtering before the trim. + let p = docprims_core::xml::retain_output_chars(p) + .trim() + .to_string(); if p.is_empty() { continue; } @@ -73,6 +78,13 @@ pub fn extract_v0( } else { p }; + if text.is_empty() { + // Nothing fits: drop this block's separator rather than emit an empty block. + if start > 0 { + doc_text.pop(); + } + break; + } doc_text.push_str(&text); let end = doc_text.len(); @@ -145,17 +157,17 @@ fn extract_text_from_document(xml: &str) -> Result { Ok(Event::Start(e)) => { let local_name = e.local_name(); match local_name.as_ref() { - b"p" => { + "p" => { // Paragraph start - add newline if we have content if !text.is_empty() && !text.ends_with('\n') { text.push('\n'); } } - b"br" => { + "br" => { // Line break text.push('\n'); } - b"tab" => { + "tab" => { // Tab character text.push('\t'); } @@ -163,14 +175,12 @@ fn extract_text_from_document(xml: &str) -> Result { } } Ok(Event::Text(e)) => { - let decoded = e - .decode() - .map_err(|e| DocprimsError::Parse(format!("XML decode error: {}", e)))?; + let decoded = e.into_inner(); text.push_str(&decoded); } Ok(Event::GeneralRef(e)) => { if let Some(resolved) = resolve_entity(&e) { - text.push_str(resolved); + text.push_str(&resolved); } } Ok(Event::Eof) => break, @@ -196,20 +206,20 @@ fn extract_paragraphs(xml: &str) -> Result> { loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => match e.local_name().as_ref() { - b"p" => { + "p" => { in_p = true; cur.clear(); } - b"br" if in_p => { + "br" if in_p => { cur.push('\n'); } - b"tab" if in_p => { + "tab" if in_p => { cur.push('\t'); } _ => {} }, Ok(Event::End(e)) => { - if e.local_name().as_ref() == b"p" { + if e.local_name().as_ref() == "p" { in_p = false; let t = cur.trim().to_string(); if !t.is_empty() { @@ -220,16 +230,14 @@ fn extract_paragraphs(xml: &str) -> Result> { } Ok(Event::Text(e)) => { if in_p { - let decoded = e - .decode() - .map_err(|e| DocprimsError::Parse(format!("XML decode error: {}", e)))?; + let decoded = e.into_inner(); cur.push_str(&decoded); } } Ok(Event::GeneralRef(e)) => { if in_p { if let Some(resolved) = resolve_entity(&e) { - cur.push_str(resolved); + cur.push_str(&resolved); } } } diff --git a/crates/docprims-ooxml/src/lib.rs b/crates/docprims-ooxml/src/lib.rs index f8e5a57..98b77ac 100644 --- a/crates/docprims-ooxml/src/lib.rs +++ b/crates/docprims-ooxml/src/lib.rs @@ -2,6 +2,10 @@ //! //! Office Open XML (OOXML) text extraction for docprims. //! +//! Most users should depend on the [`docprims`](https://docs.rs/docprims) +//! crate, which is the supported entry point. This crate's API carries no +//! stability promise beyond what `docprims` re-exports. +//! //! This crate extracts text from: //! - DOCX (Word documents) //! - XLSX (Excel spreadsheets) @@ -22,12 +26,16 @@ //! println!("{}", text.content); //! ``` +#![cfg_attr( + not(any(feature = "docx", feature = "xlsx", feature = "pptx")), + allow(unused_imports) +)] use docprims_core::{DocprimsError, DocprimsExtract, ExtractLimits, ExtractedText, Result}; use std::fs::File; use std::io::{BufReader, Read}; use std::path::Path; -pub mod common; +pub(crate) mod common; #[cfg(feature = "docx")] pub mod docx; @@ -61,9 +69,14 @@ pub enum OoxmlFormat { pub fn extract(path: impl AsRef) -> Result { let path = path.as_ref(); match detect_format(path) { + #[cfg(feature = "docx")] Some(OoxmlFormat::Docx) => extract_docx(path), + #[cfg(feature = "xlsx")] Some(OoxmlFormat::Xlsx) => extract_xlsx(path), + #[cfg(feature = "pptx")] Some(OoxmlFormat::Pptx) => extract_pptx(path), + #[allow(unreachable_patterns)] + Some(format) => Err(DocprimsError::UnsupportedFormat(format!("{format:?}"))), None => Err(DocprimsError::UnknownFormat(path.display().to_string())), } } @@ -195,9 +208,12 @@ pub fn extract_pptx_v0_reader( } #[cfg(test)] +#[cfg_attr( + not(all(feature = "docx", feature = "xlsx", feature = "pptx")), + allow(dead_code) +)] pub(crate) mod test_support { use docprims_core::DocprimsExtract; - use jsonschema::Resource; use std::io::{Cursor, Write}; use zip::write::SimpleFileOptions; @@ -217,20 +233,26 @@ pub(crate) mod test_support { )) .unwrap(); - let validator = jsonschema::draft202012::options() - .with_resources([ - ( + let validator = { + let registry = jsonschema::Registry::new() + .add( "https://schemas.3leaps.dev/docprims/extract/v0/docprims-block.schema.json", - Resource::from_contents(block_schema).unwrap(), - ), - ( + block_schema, + ) + .unwrap() + .add( "https://schemas.3leaps.dev/docprims/extract/v0/docprims-location.schema.json", - Resource::from_contents(loc_schema).unwrap(), - ), - ] - .into_iter()) - .build(&root_schema) - .unwrap(); + loc_schema, + ) + .unwrap() + .prepare() + .unwrap(); + jsonschema::draft202012::options() + .offline() + .with_registry(®istry) + .build(&root_schema) + .unwrap() + }; let value = serde_json::to_value(extract).unwrap(); let errors: Vec<_> = validator.iter_errors(&value).collect(); diff --git a/crates/docprims-ooxml/src/pptx.rs b/crates/docprims-ooxml/src/pptx.rs index bdc74c4..2fa862d 100644 --- a/crates/docprims-ooxml/src/pptx.rs +++ b/crates/docprims-ooxml/src/pptx.rs @@ -1,6 +1,6 @@ //! PPTX (PowerPoint) text extraction. -use crate::common::{open_archive, read_archive_file, resolve_entity}; +use crate::common::{open_archive, read_archive_file, resolve_entity, OoxmlArchive}; use docprims_core::{ DocprimsBlock, DocprimsByteRange, DocprimsDocument, DocprimsError, DocprimsExtract, DocprimsGenerator, DocprimsQuality, DocprimsSource, ExtractLimits, ExtractedText, Result, @@ -66,6 +66,11 @@ pub fn extract_v0( let paras = extract_slide_paragraphs(&xml)?; for (p_idx, p) in paras.into_iter().enumerate() { + // Paragraphs arrive trimmed; filtering removes no whitespace, so + // re-trimming yields the same result as filtering before the trim. + let p = docprims_core::xml::retain_output_chars(p) + .trim() + .to_string(); if p.is_empty() { continue; } @@ -91,6 +96,13 @@ pub fn extract_v0( } else { p }; + if text.is_empty() { + // Nothing fits: drop this block's separator rather than emit an empty block. + if start > 0 { + doc_text.pop(); + } + break; + } doc_text.push_str(&text); let end = doc_text.len(); @@ -156,7 +168,7 @@ pub fn extract_v0( )) } -fn enumerate_slides(archive: &mut zip::ZipArchive) -> Result> { +fn enumerate_slides(archive: &mut OoxmlArchive) -> Result> { let pres_xml = read_archive_file(archive, PRESENTATION_PATH)? .ok_or_else(|| DocprimsError::Malformed("Missing ppt/presentation.xml".to_string()))?; let rels_xml = read_archive_file(archive, PRESENTATION_RELS_PATH)?.ok_or_else(|| { @@ -183,10 +195,10 @@ fn parse_presentation_slide_rids(xml: &str) -> Result> { loop { match reader.read_event_into(&mut buf) { Ok(Event::Empty(e)) | Ok(Event::Start(e)) => { - if e.local_name().as_ref() == b"sldId" { + if e.local_name().as_ref() == "sldId" { for attr in e.attributes().flatten() { - if attr.key.as_ref() == b"r:id" { - rids.push(String::from_utf8_lossy(&attr.value).to_string()); + if attr.key.as_ref() == "r:id" { + rids.push(attr.value.to_string()); } } } @@ -215,15 +227,13 @@ fn parse_relationships(xml: &str) -> Result> { loop { match reader.read_event_into(&mut buf) { Ok(Event::Empty(e)) | Ok(Event::Start(e)) => { - if e.local_name().as_ref() == b"Relationship" { + if e.local_name().as_ref() == "Relationship" { let mut id: Option = None; let mut target: Option = None; for attr in e.attributes().flatten() { match attr.key.as_ref() { - b"Id" => id = Some(String::from_utf8_lossy(&attr.value).to_string()), - b"Target" => { - target = Some(String::from_utf8_lossy(&attr.value).to_string()) - } + "Id" => id = Some(attr.value.to_string()), + "Target" => target = Some(attr.value.to_string()), _ => {} } } @@ -282,7 +292,7 @@ fn extract_slide_text(xml: &str) -> Result { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => { let local_name = e.local_name(); - if local_name.as_ref() == b"p" { + if local_name.as_ref() == "p" { // Paragraph start - flush and prepare for new paragraph let trimmed = current_part.trim(); if !trimmed.is_empty() { @@ -293,7 +303,7 @@ fn extract_slide_text(xml: &str) -> Result { } Ok(Event::End(e)) => { let local_name = e.local_name(); - if local_name.as_ref() == b"p" { + if local_name.as_ref() == "p" { // Paragraph end - flush current content let trimmed = current_part.trim(); if !trimmed.is_empty() { @@ -303,14 +313,12 @@ fn extract_slide_text(xml: &str) -> Result { } } Ok(Event::Text(e)) => { - let decoded = e - .decode() - .map_err(|e| DocprimsError::Parse(format!("XML decode error: {}", e)))?; + let decoded = e.into_inner(); current_part.push_str(&decoded); } Ok(Event::GeneralRef(e)) => { if let Some(resolved) = resolve_entity(&e) { - current_part.push_str(resolved); + current_part.push_str(&resolved); } } Ok(Event::Eof) => { @@ -342,7 +350,7 @@ fn extract_slide_paragraphs(xml: &str) -> Result> { loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => { - if e.local_name().as_ref() == b"p" { + if e.local_name().as_ref() == "p" { let trimmed = current.trim(); if !trimmed.is_empty() { paras.push(trimmed.to_string()); @@ -351,7 +359,7 @@ fn extract_slide_paragraphs(xml: &str) -> Result> { } } Ok(Event::End(e)) => { - if e.local_name().as_ref() == b"p" { + if e.local_name().as_ref() == "p" { let trimmed = current.trim(); if !trimmed.is_empty() { paras.push(trimmed.to_string()); @@ -360,14 +368,12 @@ fn extract_slide_paragraphs(xml: &str) -> Result> { } } Ok(Event::Text(e)) => { - let decoded = e - .decode() - .map_err(|e| DocprimsError::Parse(format!("XML decode error: {}", e)))?; + let decoded = e.into_inner(); current.push_str(&decoded); } Ok(Event::GeneralRef(e)) => { if let Some(resolved) = resolve_entity(&e) { - current.push_str(resolved); + current.push_str(&resolved); } } Ok(Event::Eof) => { diff --git a/crates/docprims-ooxml/src/xlsx.rs b/crates/docprims-ooxml/src/xlsx.rs index 0513175..ed46c94 100644 --- a/crates/docprims-ooxml/src/xlsx.rs +++ b/crates/docprims-ooxml/src/xlsx.rs @@ -1,6 +1,6 @@ //! XLSX (Excel) text extraction. -use crate::common::{open_archive, read_archive_file, resolve_entity}; +use crate::common::{open_archive, read_archive_file, resolve_entity, OoxmlArchive}; use docprims_core::{ DocprimsBlock, DocprimsByteRange, DocprimsDocument, DocprimsError, DocprimsExtract, DocprimsGenerator, DocprimsQuality, DocprimsSource, ExtractLimits, ExtractedText, Result, @@ -97,7 +97,8 @@ pub fn extract_v0( }; let rows = extract_sheet_rows(&sheet_xml, &shared_strings)?; - for row in rows { + for mut row in rows { + row.text = docprims_core::xml::retain_output_chars(row.text); if row.text.is_empty() { continue; } @@ -123,6 +124,13 @@ pub fn extract_v0( } else { row.text }; + if text.is_empty() { + // Nothing fits: drop this block's separator rather than emit an empty block. + if start > 0 { + doc_text.pop(); + } + break; + } doc_text.push_str(&text); let end = doc_text.len(); @@ -191,7 +199,7 @@ pub fn extract_v0( )) } -fn enumerate_sheets(archive: &mut zip::ZipArchive) -> Result> { +fn enumerate_sheets(archive: &mut OoxmlArchive) -> Result> { let workbook_xml = read_archive_file(archive, WORKBOOK_PATH)? .ok_or_else(|| DocprimsError::Malformed("Missing xl/workbook.xml".to_string()))?; let rels_xml = read_archive_file(archive, WORKBOOK_RELS_PATH)?.ok_or_else(|| { @@ -223,15 +231,13 @@ fn parse_workbook_sheets(xml: &str) -> Result> { loop { match reader.read_event_into(&mut buf) { Ok(Event::Empty(e)) | Ok(Event::Start(e)) => { - if e.local_name().as_ref() == b"sheet" { + if e.local_name().as_ref() == "sheet" { let mut name: Option = None; let mut rid: Option = None; for attr in e.attributes().flatten() { match attr.key.as_ref() { - b"name" => { - name = Some(String::from_utf8_lossy(&attr.value).to_string()) - } - b"r:id" => rid = Some(String::from_utf8_lossy(&attr.value).to_string()), + "name" => name = Some(attr.value.to_string()), + "r:id" => rid = Some(attr.value.to_string()), _ => {} } } @@ -270,15 +276,13 @@ fn parse_relationships(xml: &str) -> Result> { loop { match reader.read_event_into(&mut buf) { Ok(Event::Empty(e)) | Ok(Event::Start(e)) => { - if e.local_name().as_ref() == b"Relationship" { + if e.local_name().as_ref() == "Relationship" { let mut id: Option = None; let mut target: Option = None; for attr in e.attributes().flatten() { match attr.key.as_ref() { - b"Id" => id = Some(String::from_utf8_lossy(&attr.value).to_string()), - b"Target" => { - target = Some(String::from_utf8_lossy(&attr.value).to_string()) - } + "Id" => id = Some(attr.value.to_string()), + "Target" => target = Some(attr.value.to_string()), _ => {} } } @@ -339,30 +343,28 @@ fn parse_shared_strings(xml: &str) -> Result> { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => { let local_name = e.local_name(); - if local_name.as_ref() == b"si" { + if local_name.as_ref() == "si" { in_si = true; current_string.clear(); } } Ok(Event::End(e)) => { let local_name = e.local_name(); - if local_name.as_ref() == b"si" { + if local_name.as_ref() == "si" { strings.push(current_string.trim().to_string()); in_si = false; } } Ok(Event::Text(e)) => { if in_si { - let decoded = e - .decode() - .map_err(|e| DocprimsError::Parse(format!("XML decode error: {}", e)))?; + let decoded = e.into_inner(); current_string.push_str(&decoded); } } Ok(Event::GeneralRef(e)) => { if in_si { if let Some(resolved) = resolve_entity(&e) { - current_string.push_str(resolved); + current_string.push_str(&resolved); } } } @@ -398,21 +400,21 @@ fn extract_sheet_rows(xml: &str, shared_strings: &[String]) -> Result match e.local_name().as_ref() { - b"row" => { + "row" => { in_row = true; row_text.clear(); row_index = None; for attr in e.attributes().flatten() { - if attr.key.as_ref() == b"r" { - row_index = String::from_utf8_lossy(&attr.value).parse::().ok(); + if attr.key.as_ref() == "r" { + row_index = attr.value.parse::().ok(); } } } - b"c" => { + "c" => { cell_type = None; for attr in e.attributes().flatten() { - if attr.key.as_ref() == b"t" { - cell_type = Some(String::from_utf8_lossy(&attr.value).to_string()); + if attr.key.as_ref() == "t" { + cell_type = Some(attr.value.to_string()); } } cell_value.clear(); @@ -420,7 +422,7 @@ fn extract_sheet_rows(xml: &str, shared_strings: &[String]) -> Result {} }, Ok(Event::End(e)) => match e.local_name().as_ref() { - b"c" => { + "c" => { if in_row { let value = resolve_cell_value(cell_value.trim(), &cell_type, shared_strings); @@ -432,7 +434,7 @@ fn extract_sheet_rows(xml: &str, shared_strings: &[String]) -> Result { + "row" => { in_row = false; let t = row_text.trim_end().to_string(); if !t.is_empty() { @@ -444,14 +446,12 @@ fn extract_sheet_rows(xml: &str, shared_strings: &[String]) -> Result {} }, Ok(Event::Text(e)) => { - let decoded = e - .decode() - .map_err(|e| DocprimsError::Parse(format!("XML decode error: {}", e)))?; + let decoded = e.into_inner(); cell_value.push_str(&decoded); } Ok(Event::GeneralRef(e)) => { if let Some(resolved) = resolve_entity(&e) { - cell_value.push_str(resolved); + cell_value.push_str(&resolved); } } Ok(Event::Eof) => break, @@ -485,17 +485,17 @@ fn extract_sheet_text(xml: &str, shared_strings: &[String]) -> Result { Ok(Event::Start(e)) => { let local_name = e.local_name(); match local_name.as_ref() { - b"row" => { + "row" => { if !text.is_empty() && !text.ends_with('\n') { text.push('\n'); } } - b"c" => { + "c" => { // Cell - check type attribute cell_type = None; for attr in e.attributes().flatten() { - if attr.key.as_ref() == b"t" { - cell_type = Some(String::from_utf8_lossy(&attr.value).to_string()); + if attr.key.as_ref() == "t" { + cell_type = Some(attr.value.to_string()); } } cell_value.clear(); @@ -505,7 +505,7 @@ fn extract_sheet_text(xml: &str, shared_strings: &[String]) -> Result { } Ok(Event::End(e)) => { let local_name = e.local_name(); - if local_name.as_ref() == b"c" { + if local_name.as_ref() == "c" { // Output cell value let value = resolve_cell_value(cell_value.trim(), &cell_type, shared_strings); if !value.is_empty() { @@ -517,14 +517,12 @@ fn extract_sheet_text(xml: &str, shared_strings: &[String]) -> Result { } } Ok(Event::Text(e)) => { - let decoded = e - .decode() - .map_err(|e| DocprimsError::Parse(format!("XML decode error: {}", e)))?; + let decoded = e.into_inner(); cell_value.push_str(&decoded); } Ok(Event::GeneralRef(e)) => { if let Some(resolved) = resolve_entity(&e) { - cell_value.push_str(resolved); + cell_value.push_str(&resolved); } } Ok(Event::Eof) => break, diff --git a/crates/docprims-ooxml/tests/hostile_archives.rs b/crates/docprims-ooxml/tests/hostile_archives.rs new file mode 100644 index 0000000..95425e7 --- /dev/null +++ b/crates/docprims-ooxml/tests/hostile_archives.rs @@ -0,0 +1,295 @@ +//! Behavior of the OOXML extractors on hostile and malformed archives. +//! +//! Every fixture is generated in-test from benign content. The properties +//! checked are the ones an embedding host relies on: malformed input yields +//! an error rather than a panic, resource use stays bounded, and text is +//! neither lost nor double-decoded. + +#![cfg(all(feature = "docx", feature = "xlsx", feature = "pptx"))] + +use std::io::{Cursor, Write}; +use std::time::{Duration, Instant}; + +use docprims_core::{DocprimsError, ExtractLimits}; +use docprims_ooxml::{ + extract_docx_reader, extract_docx_v0_reader, extract_pptx_reader, extract_xlsx_reader, +}; +use zip::write::SimpleFileOptions; + +const DECL: &str = r#""#; +const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; +const S_NS: &str = r#"xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main""#; +const R_NS: &str = + r#"xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; + +fn zip_of(parts: &[(&str, &str)]) -> Vec { + let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (name, body) in parts { + w.start_file(*name, opts).unwrap(); + w.write_all(body.as_bytes()).unwrap(); + } + w.finish().unwrap().into_inner() +} + +fn docx(body: &str) -> Vec { + let doc = format!("{DECL}{body}"); + zip_of(&[("word/document.xml", &doc)]) +} + +fn xlsx(sheet_data: &str, shared: &[&str]) -> Vec { + let workbook = format!( + r#"{DECL}"# + ); + let rels = format!( + r#"{DECL}"# + ); + let sst: String = shared + .iter() + .map(|s| format!("{s}")) + .collect(); + let sst = format!("{DECL}{sst}"); + let sheet = format!("{DECL}{sheet_data}"); + zip_of(&[ + ("xl/workbook.xml", &workbook), + ("xl/_rels/workbook.xml.rels", &rels), + ("xl/sharedStrings.xml", &sst), + ("xl/worksheets/sheet1.xml", &sheet), + ]) +} + +fn para(text: &str) -> String { + format!(r#"{text}"#) +} + +// --- archive-level failures: error, never panic -------------------------- + +#[test] +fn non_archive_inputs_are_malformed_for_every_format() { + let good = docx(¶("hello")); + let cases: [(&str, Vec); 3] = [ + ("empty", Vec::new()), + ("not a zip", b"plain text, no archive here".to_vec()), + ("truncated", good[..good.len() / 2].to_vec()), + ]; + for (label, bytes) in cases { + for (fmt, result) in [ + ( + "docx", + extract_docx_reader(Cursor::new(bytes.clone())).err(), + ), + ( + "xlsx", + extract_xlsx_reader(Cursor::new(bytes.clone())).err(), + ), + ( + "pptx", + extract_pptx_reader(Cursor::new(bytes.clone())).err(), + ), + ] { + assert!( + matches!(result, Some(DocprimsError::Malformed(_))), + "{fmt}/{label}: expected Malformed, got {result:?}" + ); + } + } +} + +#[test] +fn archive_without_main_part_is_an_error() { + let bytes = zip_of(&[("[Content_Types].xml", "")]); + assert!(extract_docx_reader(Cursor::new(bytes.clone())).is_err()); + assert!(extract_xlsx_reader(Cursor::new(bytes.clone())).is_err()); + assert!(extract_pptx_reader(Cursor::new(bytes)).is_err()); +} + +#[test] +fn archive_entry_count_is_limited() { + let names: Vec = (0..10_001).map(|i| format!("f{i}")).collect(); + let parts: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "")).collect(); + let err = extract_docx_reader(Cursor::new(zip_of(&parts))).unwrap_err(); + assert!( + matches!(err, DocprimsError::ResourceLimit(_)), + "got {err:?}" + ); +} + +// --- XML-level robustness ------------------------------------------------- + +/// Best-of-3 extraction time for one cell carrying `n` distinct attributes. +fn time_unique_attributes(n: usize) -> Duration { + let attrs: String = (0..n).map(|i| format!(r#" a{i}="v""#)).collect(); + let row = format!(r#"0"#); + let bytes = xlsx(&row, &["kept"]); + (0..3) + .map(|_| { + let start = Instant::now(); + let out = extract_xlsx_reader(Cursor::new(bytes.clone())).unwrap(); + let elapsed = start.elapsed(); + assert_eq!(out.content.trim(), "kept"); + elapsed + }) + .min() + .unwrap() +} + +#[test] +fn many_unique_attributes_on_one_element_scale_linearly() { + // Quadruple the attribute count: linear work grows ~4x, a quadratic + // duplicate-attribute check ~16x. A ratio is used rather than an absolute + // bound so host speed and build profile do not decide the outcome. + let small = time_unique_attributes(25_000); + let large = time_unique_attributes(100_000); + let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-3); + assert!( + ratio < 10.0, + "4x attributes took {ratio:.1}x as long ({small:?} -> {large:?})" + ); + // Hang guard only; the ratio above is the real check. + assert!(large < Duration::from_secs(60), "took {large:?}"); +} + +#[test] +fn deeply_nested_elements_do_not_exhaust_the_stack() { + let depth = 200_000; + let body = format!( + "{}{}{}", + "".repeat(depth), + para("deep"), + "".repeat(depth) + ); + let out = extract_docx_reader(Cursor::new(docx(&body))).unwrap(); + assert_eq!(out.content.trim(), "deep"); +} + +#[test] +fn unterminated_document_xml_does_not_panic() { + let doc = format!("{DECL}cut off"); + let bytes = zip_of(&[("word/document.xml", &doc)]); + // Either a parse error or best-effort partial text is acceptable; a panic + // across the FFI/napi boundary is not. + let _ = extract_docx_reader(Cursor::new(bytes)); +} + +#[test] +fn shared_string_index_out_of_range_does_not_panic() { + let row = r#"9918446744073709551616-1"#; + let out = extract_xlsx_reader(Cursor::new(xlsx(row, &["only"]))).unwrap(); + assert!( + !out.content.contains("only"), + "unrelated shared string leaked: {:?}", + out.content + ); +} + +// --- text fidelity -------------------------------------------------------- + +#[test] +fn escaped_markup_is_decoded_exactly_once() { + let out = extract_docx_reader(Cursor::new(docx(¶( + "a &lt;b&gt; <c> "d"", + )))) + .unwrap(); + assert_eq!(out.content.trim(), r#"a <b> "d""#); +} + +#[test] +fn non_ascii_text_survives_and_offsets_stay_on_char_boundaries() { + let text = "caf\u{e9} \u{65e5}\u{672c} \u{1f600} end"; + let bytes = docx(&(para(text) + ¶("second"))); + let out = + extract_docx_v0_reader(Cursor::new(bytes), "mem.docx", ExtractLimits::default()).unwrap(); + let doc_text = &out.document.text; + assert!(doc_text.contains(text), "got {doc_text:?}"); + + let value = serde_json::to_value(&out).unwrap(); + for block in value["document"]["blocks"].as_array().unwrap() { + let range = &block["doc_text_range"]; + let (start, end) = ( + range["start_byte"].as_u64().unwrap() as usize, + range["end_byte"].as_u64().unwrap() as usize, + ); + let slice = doc_text + .get(start..end) + .unwrap_or_else(|| panic!("range {start}..{end} not on char boundaries")); + assert_eq!(slice, block["text"].as_str().unwrap()); + } +} + +#[test] +fn text_split_across_runs_is_joined_in_order() { + let body = r#"Hello, world"#; + let out = extract_docx_reader(Cursor::new(docx(body))).unwrap(); + assert_eq!(out.content.trim(), "Hello, world"); +} + +#[test] +fn understated_entry_size_does_not_truncate_text() { + // The declared uncompressed size is advisory; a header that under-reports + // must not silently drop content. + let long = "x".repeat(50_000); + let mut bytes = docx(¶(&long)); + let mut patched = 0; + for i in 0..bytes.len() - 4 { + let off = match bytes[i..i + 4] { + [0x50, 0x4b, 0x03, 0x04] => 22, + [0x50, 0x4b, 0x01, 0x02] => 24, + _ => continue, + }; + bytes[i + off..i + off + 4].copy_from_slice(&64u32.to_le_bytes()); + patched += 1; + } + assert_eq!(patched, 2); + let out = extract_docx_reader(Cursor::new(bytes)).unwrap(); + assert_eq!(out.content.trim(), long); +} + +// --- numeric character references ------------------------------------------ + +fn pptx(slide_text: &str) -> Vec { + const P_NS: &str = r#"xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + let pres = format!( + r#"{DECL}"# + ); + let rels = format!( + r#"{DECL}"# + ); + let slide = format!( + r#"{DECL}{slide_text}"# + ); + zip_of(&[ + ("ppt/presentation.xml", &pres), + ("ppt/_rels/presentation.xml.rels", &rels), + ("ppt/slides/slide1.xml", &slide), + ]) +} + +const CHAR_REFS: &str = "café 日本 😀 &lt;"; +const CHAR_REFS_DECODED: &str = "caf\u{e9} \u{65e5}\u{672c} \u{1f600} <"; + +#[test] +fn numeric_character_references_resolve_in_every_format() { + let docx_out = extract_docx_reader(Cursor::new(docx(¶(CHAR_REFS)))).unwrap(); + assert_eq!(docx_out.content.trim(), CHAR_REFS_DECODED); + + let row = r#"0"#; + let xlsx_out = extract_xlsx_reader(Cursor::new(xlsx(row, &[CHAR_REFS]))).unwrap(); + assert_eq!(xlsx_out.content.trim(), CHAR_REFS_DECODED); + + let inline = + format!(r#"{CHAR_REFS}"#); + let inline_out = extract_xlsx_reader(Cursor::new(xlsx(&inline, &[]))).unwrap(); + assert_eq!(inline_out.content.trim(), CHAR_REFS_DECODED); + + let pptx_out = extract_pptx_reader(Cursor::new(pptx(CHAR_REFS))).unwrap(); + assert_eq!(pptx_out.content.trim(), CHAR_REFS_DECODED); +} + +#[test] +fn unresolvable_character_references_are_dropped() { + // Policy (shared with the text/xml extractor): a reference that does not + // name a Unicode scalar value is dropped; surrounding text is kept. + let refs = "a�b�c�d&#xZZ;e&#;f&unknown;g"; + let out = extract_docx_reader(Cursor::new(docx(¶(refs)))).unwrap(); + assert_eq!(out.content.trim(), "abcdefg"); +} diff --git a/crates/docprims-text/Cargo.toml b/crates/docprims-text/Cargo.toml index a09dbb6..d93834a 100644 --- a/crates/docprims-text/Cargo.toml +++ b/crates/docprims-text/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +publish = true +documentation = "https://docs.rs/docprims-text" +readme = "README.md" [features] default = ["markdown", "html", "xml"] @@ -21,5 +24,5 @@ scraper = { workspace = true, optional = true } quick-xml = { workspace = true, optional = true } [dev-dependencies] -jsonschema = "0.32" +jsonschema = { version = "0.57", default-features = false } serde_json = { workspace = true } diff --git a/crates/docprims-text/LICENSE-APACHE b/crates/docprims-text/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/docprims-text/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/docprims-text/LICENSE-MIT b/crates/docprims-text/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/docprims-text/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/docprims-text/README.md b/crates/docprims-text/README.md new file mode 100644 index 0000000..a0022c9 --- /dev/null +++ b/crates/docprims-text/README.md @@ -0,0 +1,9 @@ +# docprims-text + +Markdown, HTML and XML extraction for [docprims](https://crates.io/crates/docprims). + +This crate is an internal component of docprims and carries no stability +promise beyond what the `docprims` crate re-exports. Depend on +[`docprims`](https://docs.rs/docprims) instead. + +Licensed under either of MIT or Apache-2.0 at your option. diff --git a/crates/docprims-text/src/html.rs b/crates/docprims-text/src/html.rs index dc1b63a..7f922d0 100644 --- a/crates/docprims-text/src/html.rs +++ b/crates/docprims-text/src/html.rs @@ -167,6 +167,8 @@ fn extract_block_text(el: &ElementRef<'_>) -> String { } if let Some(t) = node.value().as_text() { + let t = docprims_core::xml::retain_output_chars(t.to_string()); + let t = t.as_str(); if is_pre { out.push_str(t); } else { @@ -260,6 +262,13 @@ pub fn extract_v0_str( } else { text }; + if text.is_empty() { + // Nothing fits: drop this block's separator rather than emit an empty block. + if start > 0 { + doc_text.pop(); + } + break; + } doc_text.push_str(&text); let end = doc_text.len(); diff --git a/crates/docprims-text/src/lib.rs b/crates/docprims-text/src/lib.rs index ddd329a..ec7ca35 100644 --- a/crates/docprims-text/src/lib.rs +++ b/crates/docprims-text/src/lib.rs @@ -2,6 +2,10 @@ //! //! Text-based format extraction for docprims. //! +//! Most users should depend on the [`docprims`](https://docs.rs/docprims) +//! crate, which is the supported entry point. This crate's API carries no +//! stability promise beyond what `docprims` re-exports. +//! //! This crate extracts text from: //! - Markdown files //! - HTML documents @@ -13,6 +17,10 @@ //! - `html` - Enable HTML extraction (default) //! - `xml` - Enable XML extraction (default) +#![cfg_attr( + not(any(feature = "markdown", feature = "html", feature = "xml")), + allow(unused_imports, dead_code) +)] use docprims_core::{DocprimsExtract, ExtractLimits, ExtractedText, Result}; use std::fs::File; use std::io::Read; @@ -126,7 +134,6 @@ pub fn extract_xml_str(content: &str) -> Result { #[cfg(test)] pub(crate) mod test_support { use docprims_core::DocprimsExtract; - use jsonschema::Resource; pub(crate) fn assert_v0_schema_valid(extract: &DocprimsExtract) { let root_schema: serde_json::Value = serde_json::from_str(include_str!( @@ -144,20 +151,26 @@ pub(crate) mod test_support { )) .unwrap(); - let validator = jsonschema::draft202012::options() - .with_resources([ - ( + let validator = { + let registry = jsonschema::Registry::new() + .add( "https://schemas.3leaps.dev/docprims/extract/v0/docprims-block.schema.json", - Resource::from_contents(block_schema).unwrap(), - ), - ( + block_schema, + ) + .unwrap() + .add( "https://schemas.3leaps.dev/docprims/extract/v0/docprims-location.schema.json", - Resource::from_contents(loc_schema).unwrap(), - ), - ] - .into_iter()) - .build(&root_schema) - .unwrap(); + loc_schema, + ) + .unwrap() + .prepare() + .unwrap(); + jsonschema::draft202012::options() + .offline() + .with_registry(®istry) + .build(&root_schema) + .unwrap() + }; let value = serde_json::to_value(extract).unwrap(); let errors: Vec<_> = validator.iter_errors(&value).collect(); diff --git a/crates/docprims-text/src/markdown.rs b/crates/docprims-text/src/markdown.rs index 2daa242..3ae58a4 100644 --- a/crates/docprims-text/src/markdown.rs +++ b/crates/docprims-text/src/markdown.rs @@ -111,7 +111,10 @@ pub fn extract_v0_str( cur_kind: &mut Option, cur_text: &mut String| { if let Some(kind) = cur_kind.take() { - let t = cur_text.trim_end().to_string(); + // Filtering removes no whitespace, so re-trimming equals filtering first. + let t = docprims_core::xml::retain_output_chars(cur_text.trim_end().to_string()) + .trim_end() + .to_string(); cur_text.clear(); if !t.is_empty() { raw_blocks.push((kind, t)); @@ -263,6 +266,13 @@ pub fn extract_v0_str( } else { text }; + if text.is_empty() { + // Nothing fits: drop this block's separator rather than emit an empty block. + if start > 0 { + doc_text.pop(); + } + break; + } doc_text.push_str(&text); let end = doc_text.len(); diff --git a/crates/docprims-text/src/xml.rs b/crates/docprims-text/src/xml.rs index a29f5e0..7a5de25 100644 --- a/crates/docprims-text/src/xml.rs +++ b/crates/docprims-text/src/xml.rs @@ -22,29 +22,26 @@ pub fn extract(content: &str) -> Result { loop { match reader.read_event_into(&mut buf) { Ok(Event::Text(e)) => { - let decoded = e - .decode() - .map_err(|e| DocprimsError::Parse(format!("XML decode error: {}", e)))?; + let decoded = e.into_inner(); current_part.push_str(&decoded); } Ok(Event::GeneralRef(e)) => { // Resolve entity reference (e.g., "lt" -> "<", "amp" -> "&") - let entity_name = std::str::from_utf8(&e) - .map_err(|e| DocprimsError::Parse(format!("Invalid entity encoding: {}", e)))?; + let entity_name: &str = &e; if let Some(resolved) = resolve_xml_entity(entity_name) { current_part.push_str(resolved); } else if entity_name.starts_with('#') { // Numeric character reference (&#NNN; or &#xHHH;) - if let Some(ch) = parse_numeric_entity(entity_name) { + if let Some(ch) = docprims_core::xml::resolve_char_ref(entity_name) { current_part.push(ch); } } // Unknown entities are silently dropped } Ok(Event::CData(e)) => { - let cdata_content = String::from_utf8_lossy(&e); - current_part.push_str(&cdata_content); + let cdata_content: &str = &e; + current_part.push_str(cdata_content); } Ok(Event::Start(_) | Event::End(_)) => { // Element boundaries - flush current text part @@ -94,29 +91,27 @@ pub fn extract_v0_str( loop { match reader.read_event_into(&mut buf) { Ok(Event::Text(e)) => { - let decoded = e - .decode() - .map_err(|e| DocprimsError::Parse(format!("XML decode error: {}", e)))?; + let decoded = e.into_inner(); current_part.push_str(&decoded); } Ok(Event::GeneralRef(e)) => { - let entity_name = std::str::from_utf8(&e) - .map_err(|e| DocprimsError::Parse(format!("Invalid entity encoding: {}", e)))?; + let entity_name: &str = &e; if let Some(resolved) = resolve_xml_entity(entity_name) { current_part.push_str(resolved); } else if entity_name.starts_with('#') { - if let Some(ch) = parse_numeric_entity(entity_name) { + if let Some(ch) = docprims_core::xml::resolve_char_ref(entity_name) { current_part.push(ch); } } } Ok(Event::CData(e)) => { - let cdata_content = String::from_utf8_lossy(&e); - current_part.push_str(&cdata_content); + let cdata_content: &str = &e; + current_part.push_str(cdata_content); } Ok(Event::Start(_) | Event::End(_)) => { - let trimmed = current_part.trim(); + let filtered = docprims_core::xml::retain_output_chars(current_part.clone()); + let trimmed = filtered.trim(); if !trimmed.is_empty() { if raw_parts.len() < limits.max_blocks { raw_parts.push(trimmed.to_string()); @@ -128,7 +123,8 @@ pub fn extract_v0_str( current_part.clear(); } Ok(Event::Eof) => { - let trimmed = current_part.trim(); + let filtered = docprims_core::xml::retain_output_chars(current_part.clone()); + let trimmed = filtered.trim(); if !trimmed.is_empty() { if raw_parts.len() < limits.max_blocks { raw_parts.push(trimmed.to_string()); @@ -178,6 +174,13 @@ pub fn extract_v0_str( } else { text }; + if text.is_empty() { + // Nothing fits: drop this block's separator rather than emit an empty block. + if start > 0 { + doc_text.pop(); + } + break; + } doc_text.push_str(&text); let end = doc_text.len(); @@ -229,16 +232,6 @@ pub fn extract_v0_str( } /// Parse a numeric character reference like "#65" or "#x41" to a char. -fn parse_numeric_entity(entity: &str) -> Option { - let s = entity.strip_prefix('#')?; - let code = if let Some(hex) = s.strip_prefix('x').or_else(|| s.strip_prefix('X')) { - u32::from_str_radix(hex, 16).ok()? - } else { - s.parse::().ok()? - }; - char::from_u32(code) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/docprims/Cargo.toml b/crates/docprims/Cargo.toml new file mode 100644 index 0000000..4ea902b --- /dev/null +++ b/crates/docprims/Cargo.toml @@ -0,0 +1,85 @@ +[package] +name = "docprims" +description = "GPL-free text extraction with provenance for Markdown, HTML, XML, DOCX, XLSX and PPTX" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish = true +documentation = "https://docs.rs/docprims" +readme = "../../README.md" +keywords = ["text-extraction", "docx", "xlsx", "markdown", "html"] +categories = ["text-processing", "parser-implementations"] + +[features] +default = ["text", "ooxml"] +# Format groups +text = ["markdown", "html", "xml"] +ooxml = ["docx", "xlsx", "pptx"] +# Individual formats +markdown = ["dep:docprims-text", "docprims-text/markdown"] +html = ["dep:docprims-text", "docprims-text/html"] +xml = ["dep:docprims-text", "docprims-text/xml"] +docx = ["dep:docprims-ooxml", "docprims-ooxml/docx"] +xlsx = ["dep:docprims-ooxml", "docprims-ooxml/xlsx"] +pptx = ["dep:docprims-ooxml", "docprims-ooxml/pptx"] +# The `docprims` command-line tool: `cargo install docprims --features cli` +cli = [ + "text", + "ooxml", + "dep:clap", + "dep:rsfulmen", + "dep:serde", + "dep:serde_json", + "dep:tracing", + "dep:tracing-subscriber", +] + +[dependencies] +docprims-core = { workspace = true } +docprims-text = { workspace = true, optional = true } +docprims-ooxml = { workspace = true, optional = true } + +# CLI only +clap = { workspace = true, optional = true } +rsfulmen = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true, features = ["env-filter", "json"] } + +[dev-dependencies] +assert_cmd = "2.0" +predicates = "3.1" +base64 = "0.23" +jsonschema = { version = "0.57", default-features = false } +serde_json = { workspace = true } +zip = { workspace = true } + +# Integration tests exercise every format. +[[test]] +name = "golden_v0" +required-features = ["text", "ooxml"] + +[[test]] +name = "char_refs" +required-features = ["text", "ooxml"] + +[[test]] +name = "dispatch" +required-features = ["text", "ooxml"] + +[[test]] +name = "limits" +required-features = ["text", "ooxml"] + +[[bin]] +name = "docprims" +path = "src/main.rs" +required-features = ["cli"] + +[package.metadata.docs.rs] +features = ["text", "ooxml"] +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/docprims/LICENSE-APACHE b/crates/docprims/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/docprims/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/docprims/LICENSE-MIT b/crates/docprims/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/docprims/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/docprims/src/lib.rs b/crates/docprims/src/lib.rs new file mode 100644 index 0000000..2c5b73e --- /dev/null +++ b/crates/docprims/src/lib.rs @@ -0,0 +1,313 @@ +//! GPL-free text extraction with provenance. +//! +//! `docprims` extracts text from Markdown, HTML, XML, DOCX, XLSX and PPTX +//! documents into the `extract/v0` structure: the document text, blocks with +//! byte ranges into that text, and a locator for where each block came from. +//! Input is treated as untrusted; [`ExtractLimits`] bounds input size, output +//! size and block count. +//! +//! ```no_run +//! let extract = docprims::extract_file("report.docx", docprims::ExtractLimits::default())?; +//! for block in &extract.document.blocks { +//! println!("{}: {}", block.kind, block.text); +//! } +//! # Ok::<(), docprims::DocprimsError>(()) +//! ``` +//! +//! # Features +//! +//! Each format is a feature; `default` enables all of them. +//! +//! | Feature | Formats | +//! |---------|---------| +//! | `text` | `markdown`, `html`, `xml` | +//! | `ooxml` | `docx`, `xlsx`, `pptx` | +//! | `cli` | all formats, plus the `docprims` binary | +//! +//! Extracting a format whose feature is disabled returns +//! [`DocprimsError::UnsupportedFormat`], naming the feature to enable. +//! +//! # Choosing the parser +//! +//! The format comes from the caller: the extension of the path or source URI +//! ([`extract_file`], [`extract_bytes`]) or an explicit [`Format`] +//! ([`extract_file_as`], [`extract_bytes_as`]). docprims never inspects the +//! content to pick a different parser. Input that does not match its declared +//! format fails in that format's parser, typically with +//! [`DocprimsError::Malformed`]. +//! +//! # Command-line tool +//! +//! The `docprims` binary is built with the `cli` feature: +//! +//! ```text +//! cargo install docprims --features cli +//! ``` +//! +//! Without `--features cli`, `cargo install docprims` fails because the binary +//! requires that feature. +//! +//! This crate is the supported entry point. The `docprims-core`, +//! `docprims-text` and `docprims-ooxml` crates it builds on carry no stability +//! promise beyond what is re-exported here. +//! +//! The re-exported types keep their `Docprims` prefix (`DocprimsExtract`, +//! `DocprimsBlock`, ...) on purpose: the same names are used by the +//! `extract/v0` JSON schema and by the C, Go and TypeScript bindings. + +use std::path::Path; + +pub use docprims_core::{ + DocprimsBlock, DocprimsByteRange, DocprimsContainer, DocprimsContainerKind, DocprimsDocument, + DocprimsError, DocprimsExtract, DocprimsFormat, DocprimsGenerator, DocprimsLocation, + DocprimsQuality, DocprimsQualityStatus, DocprimsSource, ExtractLimits, Result, +}; + +/// A document format docprims can extract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Format { + /// Markdown (`.md`, `.markdown`) + Markdown, + /// HTML (`.html`, `.htm`) + Html, + /// XML (`.xml`) + Xml, + /// Word document (`.docx`) + Docx, + /// Excel workbook (`.xlsx`) + Xlsx, + /// PowerPoint presentation (`.pptx`) + Pptx, +} + +impl Format { + /// Every format, whether or not its feature is enabled. + pub const ALL: [Format; 6] = [ + Format::Markdown, + Format::Html, + Format::Xml, + Format::Docx, + Format::Xlsx, + Format::Pptx, + ]; + + /// The format for a file extension, ignoring ASCII case. + pub fn from_extension(extension: &str) -> Option { + match extension.to_ascii_lowercase().as_str() { + "md" | "markdown" => Some(Format::Markdown), + "html" | "htm" => Some(Format::Html), + "xml" => Some(Format::Xml), + "docx" => Some(Format::Docx), + "xlsx" => Some(Format::Xlsx), + "pptx" => Some(Format::Pptx), + _ => None, + } + } + + /// The format for a path or URI, from its extension. + pub fn from_path(path: impl AsRef) -> Option { + path.as_ref() + .extension() + .and_then(|e| e.to_str()) + .and_then(Format::from_extension) + } + + /// The format's name as used in `extract/v0` output (`source.format.kind`). + pub fn name(self) -> &'static str { + match self { + Format::Markdown => "markdown", + Format::Html => "html", + Format::Xml => "xml", + Format::Docx => "docx", + Format::Xlsx => "xlsx", + Format::Pptx => "pptx", + } + } + + /// Whether this build of docprims can extract the format. + pub fn is_enabled(self) -> bool { + match self { + Format::Markdown => cfg!(feature = "markdown"), + Format::Html => cfg!(feature = "html"), + Format::Xml => cfg!(feature = "xml"), + Format::Docx => cfg!(feature = "docx"), + Format::Xlsx => cfg!(feature = "xlsx"), + Format::Pptx => cfg!(feature = "pptx"), + } + } +} + +fn format_for(path: &Path) -> Result { + Format::from_path(path).ok_or_else(|| { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + DocprimsError::UnknownFormat(ext) + }) +} + +#[allow(dead_code)] // unused when every format feature is enabled +fn disabled(format: Format) -> DocprimsError { + DocprimsError::UnsupportedFormat(format!( + "{} (docprims was built without the `{}` feature)", + format.name(), + format.name() + )) +} + +/// Extract a file, choosing the format from its extension. +pub fn extract_file(path: impl AsRef, limits: ExtractLimits) -> Result { + let path = path.as_ref(); + extract_file_as(format_for(path)?, path, limits) +} + +/// Extract a file as the given format. +#[cfg_attr( + not(any( + feature = "markdown", + feature = "html", + feature = "xml", + feature = "docx", + feature = "xlsx", + feature = "pptx" + )), + allow(unused_variables) +)] +pub fn extract_file_as( + format: Format, + path: impl AsRef, + limits: ExtractLimits, +) -> Result { + let path = path.as_ref(); + match format { + #[cfg(feature = "markdown")] + Format::Markdown => docprims_text::extract_markdown_v0(path, limits), + #[cfg(feature = "html")] + Format::Html => docprims_text::extract_html_v0(path, limits), + #[cfg(feature = "xml")] + Format::Xml => docprims_text::extract_xml_v0(path, limits), + #[cfg(feature = "docx")] + Format::Docx => docprims_ooxml::extract_docx_v0(path, limits), + #[cfg(feature = "xlsx")] + Format::Xlsx => docprims_ooxml::extract_xlsx_v0(path, limits), + #[cfg(feature = "pptx")] + Format::Pptx => docprims_ooxml::extract_pptx_v0(path, limits), + #[allow(unreachable_patterns)] + other => Err(disabled(other)), + } +} + +/// Extract in-memory document bytes, choosing the format from the extension +/// of `source_uri`. `source_uri` is recorded in the output as the source. +pub fn extract_bytes( + source_uri: &str, + data: &[u8], + limits: ExtractLimits, +) -> Result { + extract_bytes_as(format_for(Path::new(source_uri))?, source_uri, data, limits) +} + +/// Extract in-memory document bytes as the given format. +/// +/// Markdown, HTML and XML input must be UTF-8. +#[cfg_attr( + not(any( + feature = "markdown", + feature = "html", + feature = "xml", + feature = "docx", + feature = "xlsx", + feature = "pptx" + )), + allow(unused_variables) +)] +pub fn extract_bytes_as( + format: Format, + source_uri: &str, + data: &[u8], + limits: ExtractLimits, +) -> Result { + if data.len() > limits.max_input_bytes { + return Err(DocprimsError::ResourceLimit(format!( + "input exceeds max_input_bytes ({} > {})", + data.len(), + limits.max_input_bytes + ))); + } + match format { + #[cfg(feature = "markdown")] + Format::Markdown => { + docprims_text::markdown::extract_v0_str(utf8(data, format)?, source_uri, limits) + } + #[cfg(feature = "html")] + Format::Html => { + docprims_text::html::extract_v0_str(utf8(data, format)?, source_uri, limits) + } + #[cfg(feature = "xml")] + Format::Xml => docprims_text::xml::extract_v0_str(utf8(data, format)?, source_uri, limits), + #[cfg(feature = "docx")] + Format::Docx => { + docprims_ooxml::extract_docx_v0_reader(std::io::Cursor::new(data), source_uri, limits) + } + #[cfg(feature = "xlsx")] + Format::Xlsx => { + docprims_ooxml::extract_xlsx_v0_reader(std::io::Cursor::new(data), source_uri, limits) + } + #[cfg(feature = "pptx")] + Format::Pptx => { + docprims_ooxml::extract_pptx_v0_reader(std::io::Cursor::new(data), source_uri, limits) + } + #[allow(unreachable_patterns)] + other => Err(disabled(other)), + } +} + +#[cfg(any(feature = "markdown", feature = "html", feature = "xml"))] +fn utf8(data: &[u8], format: Format) -> Result<&str> { + std::str::from_utf8(data) + .map_err(|_| DocprimsError::Malformed(format!("non-utf8 {} input", format.name()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_mapping_is_case_insensitive_and_complete() { + for format in Format::ALL { + assert_eq!( + Format::from_extension(format.name()), + Some(format), + "{format:?}" + ); + } + assert_eq!(Format::from_extension("MD"), Some(Format::Markdown)); + assert_eq!(Format::from_extension("Htm"), Some(Format::Html)); + assert_eq!(Format::from_path("a/b/report.DOCX"), Some(Format::Docx)); + assert_eq!(Format::from_path("noext"), None); + assert_eq!(Format::from_path("archive.zip"), None); + } + + #[test] + fn unknown_extension_is_unknown_format() { + match extract_bytes("mem://file.bin", b"x", ExtractLimits::default()) { + Err(DocprimsError::UnknownFormat(ext)) => assert_eq!(ext, "bin"), + other => panic!("expected UnknownFormat, got {other:?}"), + } + } + + #[cfg(not(feature = "docx"))] + #[test] + fn disabled_format_names_its_feature() { + assert!(!Format::Docx.is_enabled()); + match extract_bytes("mem://file.docx", b"PK", ExtractLimits::default()) { + Err(DocprimsError::UnsupportedFormat(msg)) => { + assert!(msg.contains("`docx` feature"), "{msg}") + } + other => panic!("expected UnsupportedFormat, got {other:?}"), + } + } +} diff --git a/crates/docprims-cli/src/main.rs b/crates/docprims/src/main.rs similarity index 93% rename from crates/docprims-cli/src/main.rs rename to crates/docprims/src/main.rs index 8808937..c503a20 100644 --- a/crates/docprims-cli/src/main.rs +++ b/crates/docprims/src/main.rs @@ -91,10 +91,6 @@ struct ExtractArgs { /// Maximum input file size in bytes. #[arg(long, value_name = "BYTES", default_value = "104857600")] max_input_bytes: usize, - - /// Extraction timeout in milliseconds. - #[arg(long, value_name = "MS", default_value = "30000")] - timeout_ms: u64, } #[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] @@ -295,40 +291,16 @@ fn extract_file_v0(path: &PathBuf, args: &ExtractArgs) -> Result docprims_ooxml::extract_docx_v0(path, limits).map_err(|e| CliError::Extraction { - path: path.clone(), - source: e.to_string(), - }), - "xlsx" => docprims_ooxml::extract_xlsx_v0(path, limits).map_err(|e| CliError::Extraction { - path: path.clone(), - source: e.to_string(), - }), - "pptx" => docprims_ooxml::extract_pptx_v0(path, limits).map_err(|e| CliError::Extraction { - path: path.clone(), - source: e.to_string(), - }), - "md" | "markdown" => { - docprims_text::extract_markdown_v0(path, limits).map_err(|e| CliError::Extraction { - path: path.clone(), - source: e.to_string(), - }) - } - "html" | "htm" => { - docprims_text::extract_html_v0(path, limits).map_err(|e| CliError::Extraction { - path: path.clone(), - source: e.to_string(), - }) - } - "xml" => docprims_text::extract_xml_v0(path, limits).map_err(|e| CliError::Extraction { - path: path.clone(), - source: e.to_string(), - }), - _ => Err(CliError::UnsupportedFormat { + let Some(format) = docprims::Format::from_extension(&ext) else { + return Err(CliError::UnsupportedFormat { path: path.clone(), extension: ext, - }), - } + }); + }; + docprims::extract_file_as(format, path, limits).map_err(|e| CliError::Extraction { + path: path.clone(), + source: e.to_string(), + }) } /// Extract text from a single file. diff --git a/crates/docprims/tests/char_refs.rs b/crates/docprims/tests/char_refs.rs new file mode 100644 index 0000000..504cf12 --- /dev/null +++ b/crates/docprims/tests/char_refs.rs @@ -0,0 +1,259 @@ +//! Extracted text contains only XML 1.0 characters and no DEL or C1 controls, +//! on every format. +//! +//! Numeric character references resolve only if they name an XML 1.0 `Char` +//! (shared by the XML and OOXML extractors). Separately, every extractor drops +//! non-XML characters, DEL and C1 controls from its text before block byte ranges are computed, so +//! literal control bytes and HTML/Markdown references to them never reach the +//! output and provenance ranges still slice the text exactly. + +use std::io::{Cursor, Write}; + +use docprims::ExtractLimits; + +const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + +fn via_xml(body: &str) -> String { + let xml = format!("{body}"); + docprims::extract_bytes("mem.xml", xml.as_bytes(), ExtractLimits::default()) + .unwrap() + .document + .text +} + +fn via_docx(body: &str) -> String { + let doc = format!( + r#"{body}"# + ); + let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); + w.start_file( + "word/document.xml", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + w.write_all(doc.as_bytes()).unwrap(); + let bytes = w.finish().unwrap().into_inner(); + docprims::extract_bytes("mem.docx", &bytes, ExtractLimits::default()) + .unwrap() + .document + .text +} + +fn via_markdown(body: &str) -> String { + docprims::extract_bytes("mem.md", body.as_bytes(), ExtractLimits::default()) + .unwrap() + .document + .text +} + +fn via_html(body: &str) -> String { + let html = format!("

{body}

"); + docprims::extract_bytes("mem.html", html.as_bytes(), ExtractLimits::default()) + .unwrap() + .document + .text +} + +/// (reference, text expected between `a` and `b` on the XML-based paths) +const CASES: &[(&str, &str)] = &[ + ("�", ""), + ("", ""), + ("", ""), + ("", ""), + (" ", "\t"), + (" ", "\n"), + (" ", "\r"), + (" ", " "), + ("퟿", "\u{d7ff}"), + ("�", ""), + ("", "\u{e000}"), + ("�", "\u{fffd}"), + ("￾", ""), + ("￿", ""), + ("𐀀", "\u{10000}"), + ("􏿿", "\u{10ffff}"), + ("�", ""), + ("é", "\u{e9}"), + // XML Chars that are DEL or C1 controls: resolved, then dropped. + ("", ""), + ("…", ""), + ("›", ""), + (" ", "\u{a0}"), +]; + +#[test] +fn xml_and_ooxml_resolve_character_references_identically() { + for (reference, expected) in CASES { + let body = format!("a{reference}b"); + let want = format!("a{expected}b"); + assert_eq!(via_xml(&body), want, "xml path: {reference}"); + assert_eq!(via_docx(&body), want, "docx path: {reference}"); + } +} + +#[test] +fn literal_control_characters_are_dropped_on_xml_paths() { + let body = "a\u{1}b\u{1b}c"; + assert_eq!(via_xml(body), "abc"); + assert_eq!(via_docx(body), "abc"); +} + +// Markdown and HTML decode references per the HTML spec (NUL becomes U+FFFD, +// which is an XML character and is kept); the final-text filter then drops +// anything outside the XML character set. +#[test] +fn markdown_and_html_output_excludes_control_characters() { + for (reference, expected) in [ + ("�", "\u{fffd}"), + ("", ""), + ("", ""), + (" ", "\t"), + ("￾", ""), + ("é", "\u{e9}"), + ("", ""), + ("", ""), + ] { + let body = format!("a{reference}b"); + let want = format!("a{expected}b"); + assert_eq!( + via_markdown(&body).trim_end(), + want, + "markdown: {reference}" + ); + assert_eq!(via_html(&body).trim_end(), want, "html: {reference}"); + } + // HTML maps references in U+0080-U+009F to Windows-1252 characters where + // one is defined ("€", "…", "›"); Markdown does not, so they are C1 + // controls there and are dropped. Either way no C1 control is emitted. + for (reference, html_expected) in [ + ("€", "\u{20ac}"), + ("…", "\u{2026}"), + ("›", "\u{203a}"), + ] { + let body = format!("a{reference}b"); + assert_eq!( + via_markdown(&body).trim_end(), + "ab", + "markdown: {reference}" + ); + assert_eq!( + via_html(&body).trim_end(), + format!("a{html_expected}b"), + "html: {reference}" + ); + } + assert_eq!( + via_markdown("a\u{1b}[31mred\u{9b}0m\n").trim_end(), + "a[31mred0m" + ); + assert_eq!(via_html("a\u{1b}[31mred\u{9b}0m").trim_end(), "a[31mred0m"); +} + +fn zip_parts(parts: &[(&str, &str)]) -> Vec { + let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, body) in parts { + w.start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + w.write_all(body.as_bytes()).unwrap(); + } + w.finish().unwrap().into_inner() +} + +/// Every block's byte range slices the document text to exactly the block's +/// text, and no non-XML character survives anywhere. +fn assert_clean_and_consistent(label: &str, extract: &docprims::DocprimsExtract) { + let doc = &extract.document.text; + assert!( + doc.chars().all(docprims_core::xml::is_output_char), + "{label}: disallowed char in {doc:?}" + ); + assert!(!extract.document.blocks.is_empty(), "{label}: no blocks"); + for block in &extract.document.blocks { + let r = &block.doc_text_range; + let slice = doc + .get(r.start_byte..r.end_byte) + .unwrap_or_else(|| panic!("{label}: range {r:?} invalid for {doc:?}")); + assert_eq!(slice, block.text, "{label}: block {}", block.id); + assert!(!block.text.is_empty(), "{label}: empty block {}", block.id); + } +} + +#[test] +fn block_ranges_slice_filtered_text_on_every_format() { + let l = ExtractLimits::default; + // Each input has a clean block, a block with control bytes mid-text, and a + // block made only of control bytes (which must vanish, not leave a gap). + let md = "first\n\nmid\u{1}d\u{7f}l\u{85}\u{9b}e \u{1b}[0m\n\n\u{7}\u{8}\n\nlast\n"; + let e = docprims::extract_bytes("m.md", md.as_bytes(), l()).unwrap(); + assert_clean_and_consistent("markdown", &e); + assert_eq!(e.document.text, "first\nmiddle [0m\nlast"); + + let html = + "

first

mid\u{1}d\u{7f}l\u{85}\u{9b}e \u{1b}bold

\u{7}

last

"; + let e = docprims::extract_bytes("h.html", html.as_bytes(), l()).unwrap(); + assert_clean_and_consistent("html", &e); + assert_eq!(e.document.text, "first\nmiddle bold\nlast"); + + let xml = + "
firstmid\u{1}d\u{7f}l\u{85}\u{9b}e\u{7}last
"; + let e = docprims::extract_bytes("x.xml", xml.as_bytes(), l()).unwrap(); + assert_clean_and_consistent("xml", &e); + assert_eq!(e.document.text, "first middle last"); + + let p = |t: &str| format!(r#"{t}"#); + let docx = zip_parts(&[( + "word/document.xml", + &format!( + "{}{}{}{}", + p("first"), + p("mid\u{1}d\u{7f}l\u{85}\u{9b}e "), + p("\u{7}"), + p("last") + ), + )]); + let e = docprims::extract_bytes("d.docx", &docx, l()).unwrap(); + assert_clean_and_consistent("docx", &e); + assert_eq!(e.document.text, "first\nmiddle\nlast"); + + const S: &str = r#"xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main""#; + const R: &str = + r#"xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; + let xlsx = zip_parts(&[ + ( + "xl/workbook.xml", + &format!( + r#""# + ), + ), + ( + "xl/_rels/workbook.xml.rels", + r#""#, + ), + ( + "xl/sharedStrings.xml", + &format!( + "mid\u{1}d\u{7f}l\u{85}\u{9b}e\u{7}" + ), + ), + ( + "xl/worksheets/sheet1.xml", + &format!( + r#"first01last"# + ), + ), + ]); + let e = docprims::extract_bytes("s.xlsx", &xlsx, l()).unwrap(); + assert_clean_and_consistent("xlsx", &e); + assert_eq!(e.document.text, "first\nmiddle\nlast"); + + const P: &str = r#"xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + let ap = |t: &str| format!("{t}"); + let pptx = zip_parts(&[ + ("ppt/presentation.xml", &format!(r#""#)), + ("ppt/_rels/presentation.xml.rels", r#""#), + ("ppt/slides/slide1.xml", &format!("{}{}{}{}", ap("first"), ap("mid\u{1}d\u{7f}l\u{85}\u{9b}e"), ap("\u{7}"), ap("last"))), + ]); + let e = docprims::extract_bytes("p.pptx", &pptx, l()).unwrap(); + assert_clean_and_consistent("pptx", &e); + assert_eq!(e.document.text, "first\nmiddle\nlast"); +} diff --git a/crates/docprims/tests/dispatch.rs b/crates/docprims/tests/dispatch.rs new file mode 100644 index 0000000..4939eb2 --- /dev/null +++ b/crates/docprims/tests/dispatch.rs @@ -0,0 +1,100 @@ +//! The caller's declared format picks the parser; content never does. + +use std::io::{Cursor, Write}; + +use docprims::{DocprimsError, ExtractLimits, Format}; + +fn docx_bytes() -> Vec { + let doc = r#"inside docx"#; + let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); + w.start_file( + "word/document.xml", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + w.write_all(doc.as_bytes()).unwrap(); + w.finish().unwrap().into_inner() +} + +#[test] +fn docx_payload_named_markdown_goes_to_the_markdown_parser() { + match docprims::extract_bytes("upload.md", &docx_bytes(), ExtractLimits::default()) { + Err(DocprimsError::Malformed(msg)) => assert!(msg.contains("markdown"), "{msg}"), + Ok(out) => { + assert_eq!(out.source.format.kind, "markdown"); + assert!(!out.document.text.contains("inside docx")); + } + Err(other) => panic!("unexpected error {other:?}"), + } +} + +#[test] +fn markdown_named_docx_goes_to_the_ooxml_parser() { + let err = docprims::extract_bytes("notes.docx", b"# heading\n", ExtractLimits::default()) + .unwrap_err(); + assert!(matches!(err, DocprimsError::Malformed(_)), "{err:?}"); +} + +#[test] +fn explicit_format_overrides_the_name() { + let out = docprims::extract_bytes_as( + Format::Docx, + "upload.bin", + &docx_bytes(), + ExtractLimits::default(), + ) + .unwrap(); + assert_eq!(out.source.format.kind, "docx"); + assert_eq!(out.source.uri, "upload.bin"); + assert_eq!(out.document.text, "inside docx"); + + let out = docprims::extract_bytes_as( + Format::Markdown, + "notes.docx", + b"# heading\n", + ExtractLimits::default(), + ) + .unwrap(); + assert_eq!(out.source.format.kind, "markdown"); +} + +#[test] +fn file_and_bytes_paths_agree() { + let dir = std::env::temp_dir().join(format!("docprims-dispatch-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.docx"); + std::fs::write(&path, docx_bytes()).unwrap(); + let from_file = docprims::extract_file(&path, ExtractLimits::default()).unwrap(); + let from_bytes = docprims::extract_bytes( + path.to_str().unwrap(), + &docx_bytes(), + ExtractLimits::default(), + ) + .unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); + assert_eq!( + serde_json::to_value(&from_file.document).unwrap(), + serde_json::to_value(&from_bytes.document).unwrap() + ); +} + +#[test] +fn oversized_input_is_rejected_before_parsing() { + let limits = ExtractLimits { + max_input_bytes: 4, + ..ExtractLimits::default() + }; + let err = docprims::extract_bytes("a.md", b"# heading", limits).unwrap_err(); + assert!(matches!(err, DocprimsError::ResourceLimit(_)), "{err:?}"); +} + +/// Every type reachable through a public field of the output is nameable +/// from this crate alone. +#[test] +fn output_field_types_are_reexported() { + use docprims::{DocprimsContainerKind, DocprimsQualityStatus}; + let out = docprims::extract_bytes("a.docx", &docx_bytes(), ExtractLimits::default()).unwrap(); + assert_eq!(out.document.quality.status, DocprimsQualityStatus::Complete); + let kind: DocprimsContainerKind = out.document.blocks[0].loc.container.kind; + assert_eq!(kind, DocprimsContainerKind::Archive); +} diff --git a/crates/docprims-cli/tests/golden_v0.rs b/crates/docprims/tests/golden_v0.rs similarity index 71% rename from crates/docprims-cli/tests/golden_v0.rs rename to crates/docprims/tests/golden_v0.rs index 12e200d..2c4f141 100644 --- a/crates/docprims-cli/tests/golden_v0.rs +++ b/crates/docprims/tests/golden_v0.rs @@ -1,8 +1,6 @@ use base64::engine::general_purpose::STANDARD; use base64::Engine; -use docprims_core::{DocprimsExtract, ExtractLimits}; -use jsonschema::Resource; -use std::io::Cursor; +use docprims::{DocprimsExtract, ExtractLimits}; use std::path::{Path, PathBuf}; fn limits() -> ExtractLimits { @@ -29,22 +27,26 @@ fn v0_validator() -> jsonschema::Validator { )) .unwrap(); - jsonschema::draft202012::options() - .with_resources( - [ - ( - "https://schemas.3leaps.dev/docprims/extract/v0/docprims-block.schema.json", - Resource::from_contents(block_schema).unwrap(), - ), - ( - "https://schemas.3leaps.dev/docprims/extract/v0/docprims-location.schema.json", - Resource::from_contents(loc_schema).unwrap(), - ), - ] - .into_iter(), - ) - .build(&root_schema) - .unwrap() + { + let registry = jsonschema::Registry::new() + .add( + "https://schemas.3leaps.dev/docprims/extract/v0/docprims-block.schema.json", + block_schema, + ) + .unwrap() + .add( + "https://schemas.3leaps.dev/docprims/extract/v0/docprims-location.schema.json", + loc_schema, + ) + .unwrap() + .prepare() + .unwrap(); + jsonschema::draft202012::options() + .offline() + .with_registry(®istry) + .build(&root_schema) + .unwrap() + } } fn read_text_fixture(path: &str) -> String { @@ -58,7 +60,7 @@ fn read_b64_fixture(path: &str) -> Vec { } fn repo_root() -> PathBuf { - // CARGO_MANIFEST_DIR points at crates/docprims-cli; repo root is two levels up. + // CARGO_MANIFEST_DIR points at crates/docprims; repo root is two levels up. Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } @@ -93,7 +95,7 @@ fn assert_golden(name: &str, got: &DocprimsExtract) { fn golden_markdown_simple_v0() { let src = "testdata/fixtures/text/simple.md"; let s = read_text_fixture(src); - let got = docprims_text::markdown::extract_v0_str(&s, src, limits()).unwrap(); + let got = docprims::extract_bytes(src, s.as_bytes(), limits()).unwrap(); assert_golden("markdown-simple", &got); } @@ -101,7 +103,7 @@ fn golden_markdown_simple_v0() { fn golden_html_simple_v0() { let src = "testdata/fixtures/text/simple.html"; let s = read_text_fixture(src); - let got = docprims_text::html::extract_v0_str(&s, src, limits()).unwrap(); + let got = docprims::extract_bytes(src, s.as_bytes(), limits()).unwrap(); assert_golden("html-simple", &got); } @@ -109,7 +111,7 @@ fn golden_html_simple_v0() { fn golden_xml_simple_v0() { let src = "testdata/fixtures/text/simple.xml"; let s = read_text_fixture(src); - let got = docprims_text::xml::extract_v0_str(&s, src, limits()).unwrap(); + let got = docprims::extract_bytes(src, s.as_bytes(), limits()).unwrap(); assert_golden("xml-simple", &got); } @@ -117,8 +119,7 @@ fn golden_xml_simple_v0() { fn golden_docx_mini_v0() { let src = "testdata/fixtures/ooxml/mini.docx"; let zip_bytes = read_b64_fixture("testdata/fixtures/ooxml/mini.docx.b64"); - let got = - docprims_ooxml::extract_docx_v0_reader(Cursor::new(zip_bytes), src, limits()).unwrap(); + let got = docprims::extract_bytes(src, &zip_bytes, limits()).unwrap(); assert_golden("docx-mini", &got); } @@ -126,8 +127,7 @@ fn golden_docx_mini_v0() { fn golden_xlsx_mini_v0() { let src = "testdata/fixtures/ooxml/mini.xlsx"; let zip_bytes = read_b64_fixture("testdata/fixtures/ooxml/mini.xlsx.b64"); - let got = - docprims_ooxml::extract_xlsx_v0_reader(Cursor::new(zip_bytes), src, limits()).unwrap(); + let got = docprims::extract_bytes(src, &zip_bytes, limits()).unwrap(); assert_golden("xlsx-mini", &got); } @@ -135,7 +135,6 @@ fn golden_xlsx_mini_v0() { fn golden_pptx_mini_v0() { let src = "testdata/fixtures/ooxml/mini.pptx"; let zip_bytes = read_b64_fixture("testdata/fixtures/ooxml/mini.pptx.b64"); - let got = - docprims_ooxml::extract_pptx_v0_reader(Cursor::new(zip_bytes), src, limits()).unwrap(); + let got = docprims::extract_bytes(src, &zip_bytes, limits()).unwrap(); assert_golden("pptx-mini", &got); } diff --git a/crates/docprims/tests/limits.rs b/crates/docprims/tests/limits.rs new file mode 100644 index 0000000..7280956 --- /dev/null +++ b/crates/docprims/tests/limits.rs @@ -0,0 +1,173 @@ +//! Output limits never produce empty blocks, stray separators or text beyond +//! the limit, at any limit value. + +use std::io::{Cursor, Write}; + +use docprims::{DocprimsExtract, DocprimsQualityStatus, ExtractLimits}; + +fn zip_parts(parts: &[(&str, &str)]) -> Vec { + let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, body) in parts { + w.start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + w.write_all(body.as_bytes()).unwrap(); + } + w.finish().unwrap().into_inner() +} + +const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; +const S_NS: &str = r#"xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main""#; +const R_NS: &str = + r#"xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; +const P_NS: &str = r#"xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + +/// Block texts start with multi-byte characters so that truncating to a byte +/// limit can leave nothing of a block. +const BLOCKS: [&str; 3] = ["日本 one", "é two", "three"]; + +/// (source uri, input bytes, separator between blocks) for every format. +fn inputs() -> Vec<(&'static str, Vec, char)> { + let [a, b, c] = BLOCKS; + let docx = zip_parts(&[( + "word/document.xml", + &format!( + "{}", + BLOCKS + .iter() + .map(|t| format!("{t}")) + .collect::() + ), + )]); + let rows: String = BLOCKS + .iter() + .enumerate() + .map(|(i, t)| { + format!( + r#"{t}"#, + i + 1 + ) + }) + .collect(); + let xlsx = zip_parts(&[ + ( + "xl/workbook.xml", + &format!( + r#""# + ), + ), + ( + "xl/_rels/workbook.xml.rels", + r#""#, + ), + ( + "xl/worksheets/sheet1.xml", + &format!("{rows}"), + ), + ]); + let paras: String = BLOCKS + .iter() + .map(|t| format!("{t}")) + .collect(); + let pptx = zip_parts(&[ + ( + "ppt/presentation.xml", + &format!(r#""#), + ), + ( + "ppt/_rels/presentation.xml.rels", + r#""#, + ), + ( + "ppt/slides/slide1.xml", + &format!("{paras}"), + ), + ]); + vec![ + ("m.md", format!("{a}\n\n{b}\n\n{c}\n").into_bytes(), '\n'), + ( + "h.html", + format!("

{a}

{b}

{c}

").into_bytes(), + '\n', + ), + ( + "x.xml", + format!("{a}{b}{c}").into_bytes(), + ' ', + ), + ("d.docx", docx, '\n'), + ("s.xlsx", xlsx, '\n'), + ("p.pptx", pptx, '\n'), + ] +} + +fn check(label: &str, out: &DocprimsExtract, limit: usize, full_len: usize, sep: char) { + let doc = &out.document; + assert!( + doc.text.len() <= limit, + "{label}: {} bytes > limit", + doc.text.len() + ); + + // The text is exactly the blocks joined by single separators. + let mut rebuilt = String::new(); + for (i, block) in doc.blocks.iter().enumerate() { + assert!(!block.text.is_empty(), "{label}: empty block {}", block.id); + if i > 0 { + rebuilt.push(sep); + } + let r = &block.doc_text_range; + assert_eq!( + r.start_byte, + rebuilt.len(), + "{label}: block {} start", + block.id + ); + rebuilt.push_str(&block.text); + assert_eq!(r.end_byte, rebuilt.len(), "{label}: block {} end", block.id); + } + assert_eq!(doc.text, rebuilt, "{label}: text is not the joined blocks"); + + let cut = doc.text.len() < full_len; + assert_eq!( + doc.quality.status == DocprimsQualityStatus::Partial, + cut, + "{label}: quality {:?} with {} of {full_len} bytes", + doc.quality, + doc.text.len() + ); +} + +#[test] +fn every_output_limit_yields_well_formed_output() { + for (uri, bytes, sep) in inputs() { + let full = docprims::extract_bytes(uri, &bytes, ExtractLimits::default()).unwrap(); + let full_len = full.document.text.len(); + assert_eq!(full.document.blocks.len(), 3, "{uri}: fixture blocks"); + for limit in 0..=full_len + 2 { + let limits = ExtractLimits { + max_output_bytes: limit, + ..ExtractLimits::default() + }; + let out = docprims::extract_bytes(uri, &bytes, limits).unwrap(); + check(&format!("{uri} limit={limit}"), &out, limit, full_len, sep); + } + } +} + +#[test] +fn zero_output_limit_yields_no_blocks() { + let limits = ExtractLimits { + max_output_bytes: 0, + ..ExtractLimits::default() + }; + for (uri, bytes, _) in inputs() { + let out = docprims::extract_bytes(uri, &bytes, limits).unwrap(); + assert!(out.document.blocks.is_empty(), "{uri}"); + assert_eq!(out.document.text, "", "{uri}"); + assert_eq!( + out.document.quality.status, + DocprimsQualityStatus::Partial, + "{uri}" + ); + } +} diff --git a/deny.toml b/deny.toml index f231f21..f8a4bf7 100644 --- a/deny.toml +++ b/deny.toml @@ -3,9 +3,20 @@ # # Run: cargo deny check +# Evaluate the graph for every platform docprims ships artifacts for, so +# target-specific dependencies are checked too. +[graph] +targets = [ + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-pc-windows-gnu", +] + [advisories] -db-path = "~/.cargo/advisory-db" -db-urls = ["https://github.com/rustsec/advisory-db"] ignore = [] [licenses] @@ -32,15 +43,23 @@ multiple-versions = "warn" wildcards = "allow" highlight = "all" -# Deny GPL and LGPL - this is the core mission of docprims deny = [ + # Deny GPL and LGPL - this is the core mission of docprims { name = "poppler-rs" }, # GPL { name = "mupdf" }, # AGPL { name = "mupdf-sys" }, # AGPL { name = "lopdf", version = "<0.30" }, # Older versions had issues + + # TLS: rustls only; no OpenSSL or platform TLS in any graph + { name = "openssl" }, + { name = "openssl-sys" }, + { name = "native-tls" }, + + # Minimum parser versions for untrusted input + { name = "quick-xml", version = "<0.41" }, ] [sources] -unknown-registry = "warn" -unknown-git = "warn" +unknown-registry = "deny" +unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index d05bd6b..a397967 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -30,6 +30,7 @@ docprims is a Rust library (with CLI + FFI bindings) that extracts text from doc |-----------|-----------|-----------| | Defensive parsing | Untrusted input is the norm | ADR-0003 | | Schema-driven contracts | Consumers need stability guarantees | `schemas/v0/extract/` | +| Output guarantees | Extracted text contains only XML 1.0 characters, excluding DEL and C1 controls (U+007F–U+009F); other characters are dropped. Markdown and HTML keep their parsers' U+FFFD replacement for NUL. | [extract/v0 contract](../standards/extract-contract-stability.md) | | Minimal core dependencies | Keep docprims-core embeddable | ADR-0002 | | CLI composability | Stdout purity for pipelines | ADR-0004 | | GPL-free license policy | Safe for commercial embedding | ADR-0001 | @@ -51,7 +52,7 @@ flowchart LR Text[docprims-text] OOXML[docprims-ooxml] FFI[docprims-ffi] - CLIBin[docprims-cli] + CLIBin[docprims] end subgraph Downstream[Example downstream systems] @@ -84,7 +85,7 @@ docprims/ │ ├── docprims-core/ # Shared types, traits, errors, structured model │ ├── docprims-text/ # Markdown, HTML, XML extraction │ ├── docprims-ooxml/ # DOCX, XLSX, PPTX extraction -│ └── docprims-cli/ # CLI binary +│ └── docprims/ # Library entry point; CLI (`cli` feature) ├── ffi/ │ └── docprims-ffi/ # C-ABI for language bindings ├── bindings/ @@ -118,7 +119,9 @@ docprims/ - ZIP archive handling with security controls - XML namespace handling for Office namespaces -**docprims-cli**: +**docprims** (library entry point): +- `extract_file` / `extract_bytes` with format dispatch by extension or explicit `Format`; no content sniffing +- Per-format features; the `cli` feature adds the `docprims` binary - `docprims extract ` - unified extraction command - Format auto-detection - JSON and plain text output modes @@ -261,7 +264,7 @@ flowchart TD ENC[Encoding validation] end - V --> D[Format Detection] + V --> D[Format Dispatch: extension or explicit format] D --> P{Format Parser} P -->|markdown| TM[docprims-text::markdown] @@ -296,11 +299,11 @@ docprims is classified as **security-sensitive** because it parses untrusted inp | Threat | Mitigation | |--------|------------| -| Zip bombs | Decompression ratio limits, file count limits | -| XML bombs (billion laughs) | Entity expansion disabled, depth limits | +| Zip bombs | Per-part and per-archive decompressed-size limits (bytes actually read), file count limits | +| XML bombs (billion laughs) | No DTD or custom entity expansion; iterative (non-recursive) parsing | | Path traversal | Archive path validation, no `..` allowed | | Memory exhaustion | `max_input_bytes`, `max_output_bytes` limits | -| CPU exhaustion | `timeout_ms`, iteration limits | +| CPU exhaustion | Linear-time parsing; input, output, block and decompression limits. No wall-clock timeout. | | Malformed input crashes | `Result` everywhere, no panics on bad input | ### Resource Limits (ExtractLimits) @@ -451,6 +454,8 @@ schemas/ During alpha (`v0.x`), schemas live under `schemas/v0/`. Post-1.0, schemas will be versioned independently. +`extract/v0` is classified **Evolving**; see [extract/v0 contract](../standards/extract-contract-stability.md) for its stability level, output guarantees and data sensitivity. + ## Testing Strategy (Contract First) ### Test Categories diff --git a/docs/cicd.md b/docs/cicd.md new file mode 100644 index 0000000..b949a59 --- /dev/null +++ b/docs/cicd.md @@ -0,0 +1,56 @@ +# CI/CD + +## Local gates + +| Target | Runs | +|--------|------| +| `make precommit` | `fmt-check`, `lint` | +| `make check` | format, lint, tests, lean-library check, docs build, `cargo-deny`, `cargo-audit` | +| `make prepush` | `check`, `version-check`, `release-tooling-test` | +| `make release-check` | `version-check`, then packages and verify-builds every publishable crate without publishing | +| `make release-preflight` | pre-tag gate: anchors, clean tree, `prepush`, `release-check`, release notes, `HEAD` == `origin/main` | + +`make release-tooling-test` runs the release guard, signing, asset, crate, +version and release-notes tests. The Decernor test uses `DOCPRIMS_DECERNOR_BIN` +when set, otherwise `decernor` on `PATH`. + +## Workflows + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| `ci` | push to `main`, PR | quality gates, header drift, MSRV, `publish-check` (package check and release tooling tests with a checksum-pinned Decernor), Go vendored-lib tests | +| `go-bindings` | PR, manual | Go bindings against locally built libraries | +| `typescript-bindings` | push to `main`, PR | TypeScript build and tests on glibc and musl | +| `typescript-napi-prebuilds` | manual | cross-platform `.node` prebuilds and the npm package directory; publishes nothing | +| `go-bindings-prep` | manual | builds the Go prebuilt libraries and opens a PR with them | +| `release` | `v*` tag push, manual | builds and validates the exact release asset set; on a tag push only, verifies the signed tag and opens an unsigned draft release | +| `validate-release` | manual | validates a pushed tag from Go and TypeScript | +| `typescript-npm-publish` | manual | publishes the npm packages with OIDC trusted publishing | + +All workflows default to read-only `contents` permission and pin every action +to a commit SHA. Jobs that write (the release draft, the Go prep PR, npm OIDC) +declare their own permissions. + +## Release workflow + +A manual run of `release` builds every CLI archive, the C header and the SBOM, +then validates the assembled set against `config/release/cli-platforms.txt`. +It creates no release, so the build matrix can be proven before a tag exists. + +On a tag push, `validate` binds the annotated tag to `VERSION` and +`origin/main`, and `verify-signature` checks the tag signature against the +committed public pin and GitHub verification. The draft job runs only after +both pass. + +### Credentials + +The strict release guard fetches the release tag and `origin/main`. The +read-only `validate` job keeps its read-scoped checkout credential for that +guard. The draft job has a write-scoped token, so it provides an authenticated +Git header only while running the guard and removes it before the steps that +create the draft. + +After a tag workflow failure, confirm whether the draft exists and its unsigned +asset inventory is complete before running `make release`. Repair the workflow +and recreate the annotated tag only when the failed workflow did not create a +draft. diff --git a/docs/decisions/ADR-0002-crate-structure.md b/docs/decisions/ADR-0002-crate-structure.md index bf3418b..9b87066 100644 --- a/docs/decisions/ADR-0002-crate-structure.md +++ b/docs/decisions/ADR-0002-crate-structure.md @@ -1,9 +1,13 @@ # ADR-0002: Crate Structure -> **Status**: Accepted +> **Status**: Accepted; amended by [PDR-0001](PDR-0001-crates-io-publication.md) > **Date**: 2025-01-25 > **Authors**: 3leaps Architecture Council +> **Amendment (v0.2.0):** `docprims-cli` is replaced by the `docprims` crate, the +> library entry point, whose `cli` feature builds the `docprims` binary. See +> PDR-0001. + ## Context docprims needs to support multiple document formats (OOXML, PDF, HTML, Markdown, XML) with: diff --git a/docs/decisions/PDR-0001-crates-io-publication.md b/docs/decisions/PDR-0001-crates-io-publication.md new file mode 100644 index 0000000..68229f7 --- /dev/null +++ b/docs/decisions/PDR-0001-crates-io-publication.md @@ -0,0 +1,110 @@ +--- +id: "PDR-0001" +title: "Publish the docprims crates to crates.io after the release tag" +status: "accepted" +date: "2026-09-25" +deciders: + - "@3leapsdave" +scope: "docprims crate publication, public API and release process" +tags: + - "process" + - "release" + - "crates-io" + - "semver" +relates-to: + - "ADR-0002-crate-structure.md" + - "ADR-0004-stdout-purity.md" + - "RELEASE_CHECKLIST.md" +--- + +# PDR-0001: Publish the docprims crates to crates.io after the release tag + +## Decision + +### One front door, four published crates + +`docprims` is the library's only documented entry point. It re-exports the +public types and dispatches to a parser per format, with each format behind a +feature. The command-line tool is the same crate built with the `cli` feature +(`cargo install docprims --features cli`); the binary name is `docprims`. + +A published crate cannot depend on unpublished path-only crates, so +`docprims-core`, `docprims-text` and `docprims-ooxml` are published too. They +are components of `docprims`: their READMEs and docs point at it, and their +APIs carry no stability promise beyond what `docprims` re-exports. Changing +them to `publish = false` would break `docprims`. + +`docprims-ffi` and `docprims-ts-napi` are not published. The C library and the +Go and npm packages ship through their own channels. + +### Why this shape + +Other 3leaps primitive libraries, such as sysprims, publish only independent +subsystem crates and keep their CLI unpublished. docprims differs on purpose: +its crates are format variants of one capability rather than independent +subsystems, and its CLI is a supported artifact with a versioned JSON output +contract (ADR-0004 and `schemas/v0/extract/`). A single name therefore serves +both `docprims = "0.2"` and `cargo install`. This is not a default for other +repositories. + +Merging all formats into one crate was not chosen: the workspace split keeps +the FFI and Node addon crates small, and a later format can arrive as a +subcrate plus a `docprims` feature without a new top-level name. + +### crates.io after the tag + +Publish manually from a clean, guarded checkout of the pushed release tag, in +the order of `config/release/publishable-crates.txt`: `docprims-core`, +`docprims-text`, `docprims-ooxml`, then `docprims`. Wait for the crates.io +index and API after each upload, including the last. The first registry +version is v0.2.0; earlier tags are not backfilled. + +The maintainer alone holds a short-lived, crate-scoped token outside the +repository and authorizes each irreversible upload. CI has no registry token +and no publish step. `make release-crates-dry-run` exercises each package with +local path patches for earlier unpublished crates; the real upload has no +patches. + +### npm platform packages + +The committed npm manifest lists no platform packages, so its lock file +installs with `npm ci`. The publish workflow adds `optionalDependencies` for +exactly the platform packages it publishes, at the same version. + +## Public API and semver + +- **`serde_json` is a public dependency.** Location hints and metadata expose + `serde_json::Value` and `serde_json::Map`. A `serde_json` major version bump + is a breaking change for docprims. This is deliberate: those fields are open + JSON by design, and `serde_json` has kept one major version. +- **Output structs are not `#[non_exhaustive]`.** The subcrates construct them + across crate boundaries with struct literals. While docprims is 0.x, an + additive schema field is released as a new 0.x minor version. How structs + are constructed is decided before 1.0. +- **Public enums are `#[non_exhaustive]`** (`DocprimsError`, + `DocprimsQualityStatus`, `DocprimsContainerKind`), so matches on them need a + wildcard arm and new variants are not breaking for matching code. `serde` + deserialization still rejects an unknown variant: a consumer that parses + output from a newer docprims with older types gets an error for a new value. + That is acceptable while 0.x minors may break; whether 1.0 adds a catch-all + variant is part of the same pre-1.0 decision. + +## Positioning + +`extract` is a one-shot operation: one document in, one `extract/v0` result +out. docprims does not define a persisted bundle format or a batch, watch or +service mode. A consumer that stores results wraps `extract/v0` in its own +envelope. If docprims later grows a persisted export or a long-running mode, +it adopts the shared contracts for those shapes rather than extending +`extract/v0`. + +## Consequences + +- Package checks on the first registry cut may need a rerun once earlier + crates reach the index. The GitHub draft is not signed until package checks + and the release workflow are green. +- A new library crate needs an entry in the ordered list and an explicit + `publish = true`. The list check fails closed on a mismatch and on a + published FFI or Node addon crate. +- The index and API checks confirm availability, name, version and unyanked + status; they do not prove byte-for-byte identity with a local archive. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d1056a4..41995b5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -9,6 +9,7 @@ This directory contains architectural, design, and security decision records for | ADR | Architecture Decision Record | Technical architecture choices | | DDR | Design Decision Record | API design, data structures | | SDR | Security Decision Record | Security-related decisions | +| PDR | Process Decision Record | Release, publication and process | ## Index @@ -28,6 +29,12 @@ This directory contains architectural, design, and security decision records for | -------- | -------------------- | -------- | ---------- | | _None yet_ | | | | +### Process Decision Records (PDR) + +| ID | Title | Status | Date | +| -------- | -------------------- | -------- | ---------- | +| [PDR-0001](PDR-0001-crates-io-publication.md) | Publish the docprims crates to crates.io after the release tag | Accepted | 2026-09-25 | + ### Security Decision Records (SDR) | ID | Title | Status | Date | diff --git a/docs/releases/v0.1.5.md b/docs/releases/v0.1.5.md deleted file mode 100644 index ad7f3ef..0000000 --- a/docs/releases/v0.1.5.md +++ /dev/null @@ -1,93 +0,0 @@ -# docprims v0.1.5 - -**Release Date:** 2026-07-07 -**Status:** Patch Release (runtime floors + repository guidance) - -## Summary - -This release refreshes package runtime floors and repository guidance after the v0.1.4 publishing fixes. It keeps the TypeScript 6.x and napi-rs 3.x upgrade out of scope for the later coordinated modernization pass. - -## Highlights - -- **Rust MSRV**: Raised the workspace minimum supported Rust version to 1.88.0. -- **TypeScript Node floor**: `@3leaps/docprims` now requires Node.js 22 or newer. -- **npm trusted publishing hardening**: Publish workflow checks Node >=22.14.0 and npm >=11.5.1 before publishing. -- **TypeScript workflow alignment**: Binding tests, release validation, and N-API prebuilds now validate on Node 22. -- **Repository guidance cleanup**: Agent guidance, local-only planning conventions, and YAML linting configuration were refreshed. - -## Runtime Floors - -| Component | Floor | -|-----------|-------| -| Rust workspace | 1.88.0 | -| TypeScript package | Node.js 22+ | -| TypeScript CI/prebuild validation | Node.js 22 | -| npm trusted publishing | Node.js >=22.14.0 and npm >=11.5.1 | - -## TypeScript Package Changes - -The TypeScript package metadata now declares: - -```json -{ - "engines": { - "node": ">=22" - } -} -``` - -Platform optional dependencies were advanced to the v0.1.5 package set: - -| Platform | Package | -|----------|---------| -| Linux x64 (glibc) | `@3leaps/docprims-linux-x64-gnu` | -| Linux x64 (musl) | `@3leaps/docprims-linux-x64-musl` | -| Linux arm64 (glibc) | `@3leaps/docprims-linux-arm64-gnu` | -| Linux arm64 (musl) | `@3leaps/docprims-linux-arm64-musl` | -| macOS arm64 | `@3leaps/docprims-darwin-arm64` | -| Windows x64 | `@3leaps/docprims-win32-x64-msvc` | - -## Trusted Publishing Guard - -The npm publish workflow runs on Node 24 but explicitly enforces the trusted-publishing minimums before publishing: - -- Node.js >=22.14.0 -- npm >=11.5.1 - -The same static guard runs locally through: - -```bash -make npm-publish-prereqs-check -``` - -It is also wired into `make lint`, so the normal quality gate catches workflow drift. - -## Repository Guidance - -v0.1.5 refreshes public repository guidance: - -- Planning artifacts are local-only and non-canonical. -- `docs/orientation/sources-of-truth.md` remains the canonical guide for source-of-truth boundaries. -- YAML linting is configured explicitly at repo root. -- Commit attribution guidance uses bare role slugs. - -## Installation Notes - -### CLI from a checkout - -```bash -cargo install --path crates/docprims-cli -``` - -### TypeScript from a checkout - -```bash -cd bindings/typescript/docprims -npm install -npm run build -npm run build:native -``` - -## Changelog - -See `CHANGELOG.md` for detailed changes. diff --git a/docs/releases/v0.2.0.md b/docs/releases/v0.2.0.md new file mode 100644 index 0000000..7caf59a --- /dev/null +++ b/docs/releases/v0.2.0.md @@ -0,0 +1,82 @@ +## v0.2.0 — 2026-09-25 + +**Status:** Minor Release (library entry point, extraction fixes, dependency refresh) + +### Summary + +v0.2.0 adds the `docprims` crate as the library's entry point, fixes several +extraction edge cases, and updates dependencies and toolchains across the +Rust crates and the C, Go and TypeScript bindings. It contains breaking +changes for Rust users of the subcrates; bindings keep their results and +error codes. + +### Highlights + +- **`docprims` crate**: One dependency for all formats, with a feature per format. +- **Extracted text character set**: Every format emits only XML 1.0 characters, excluding DEL and C1 controls. +- **Numeric character references** resolve in DOCX, XLSX and PPTX. +- **OOXML decompression limits** are enforced on bytes actually read, per part and per archive. +- **Output limits** never produce an empty block or a trailing separator. +- **CLI** is installed with `cargo install docprims --features cli`. + +### Using the `docprims` crate + +```toml +[dependencies] +docprims = "0.2" +``` + +```rust +use docprims::{extract_file, ExtractLimits}; + +let result = extract_file("report.docx", ExtractLimits::default())?; +println!("{}", result.document.text); +``` + +The parser is chosen from the file extension, or explicitly with +`extract_file_as` / `extract_bytes_as` and a `Format`. Content is never +inspected to choose a parser. + +Formats are features: `markdown`, `html`, `xml`, `docx`, `xlsx`, `pptx`, +grouped as `text` and `ooxml`, all on by default. A Markdown-only build: + +```toml +docprims = { version = "0.2", default-features = false, features = ["markdown"] } +``` + +`docprims-core`, `docprims-text` and `docprims-ooxml` are published as +components of `docprims` and carry no stability promise beyond what +`docprims` re-exports. + +### Breaking Changes + +| Change | Migration | +|--------|-----------| +| `docprims-cli` crate removed | `cargo install docprims --features cli` (same binary name, commands and output) | +| `--timeout-ms` / `ExtractOptions::timeout_ms` removed (never applied) | Remove the flag or field | +| `ExtractOptions` removed from `docprims-core` | Use `ExtractLimits` | +| `docprims_ooxml::common` no longer public | Use `docprims::extract_*` | +| `DocprimsError`, `DocprimsQualityStatus`, `DocprimsContainerKind` are `#[non_exhaustive]` | Add a wildcard arm to matches | + +### Extraction Changes + +Extracted text may differ from v0.1.x for documents that contain: + +- control characters, DEL or C1 controls (now dropped); +- numeric character references in DOCX, XLSX or PPTX (now resolved); +- output truncated by `max_output_bytes` (no empty final block). + +Block byte ranges are computed after characters are dropped, so they always +index the emitted text. + +### Runtime Floors + +| Component | Floor | +|-----------|-------| +| Rust (MSRV) | 1.88.0 | +| TypeScript package | Node.js 22+ | +| npm trusted publishing | Node.js >=22.14.0 and npm >=11.5.1 | + +### Changelog + +See `CHANGELOG.md` for the full list of changes. diff --git a/docs/standards/extract-contract-stability.md b/docs/standards/extract-contract-stability.md new file mode 100644 index 0000000..ea13b47 --- /dev/null +++ b/docs/standards/extract-contract-stability.md @@ -0,0 +1,61 @@ +# extract/v0 Contract: Stability and Output Guarantees + +This page states what consumers of docprims output can rely on: the stability +level of the `extract/v0` schema, the guarantees every result meets, and how +to treat the sensitivity of extracted text. It applies to the `docprims` crate, +the CLI's JSON output, and the C, Go and TypeScript bindings, which all return +the same structure. + +## Schema stability: Evolving + +`extract/v0` (`schemas/v0/extract/`) is classified **Evolving** under the +3leaps [Schema Stability Classification](https://github.com/3leaps/crucible/blob/main/docs/standards/schema-stability-classification.md) +standard. + +| Aspect | What it means for `extract/v0` | +|--------|--------------------------------| +| Compatibility | Additive changes (new optional fields, new block kinds) keep existing consumers working | +| Breaking changes | Only with notice: a `CHANGELOG.md` entry and a new docprims minor version while docprims is 0.x | +| Versioning | `schema_version` in every result follows the bump rules in `docs/architecture/overview.md` | +| Consumer advice | Safe for development; track the changelog | + +Changes to *what text is extracted* from a given document (for example, which +characters are kept) are contract changes and are announced the same way. + +Stable status (full compatibility within a major version) is planned with +docprims 1.0. The Rust types behind the schema follow +[PDR-0001](../decisions/PDR-0001-crates-io-publication.md). + +## Output guarantees + +Every result meets these properties. Each is covered by tests across all +supported formats. + +- **Character set.** Extracted text contains only XML 1.0 characters, excluding DEL and C1 controls (U+007F–U+009F); other characters are dropped. Markdown and HTML keep their parsers' U+FFFD replacement for NUL. +- **Parser choice.** The parser is chosen from the path or source URI + extension, or from an explicit format. Document content is never inspected + to choose a parser. +- **Byte ranges.** Each block's `doc_text_range` indexes the emitted + `document.text`, and the text is the blocks joined in order by one + separator each. +- **Limits.** `document.text` never exceeds `max_output_bytes`. When a limit + truncates output, `quality.status` is `partial`, and no block is empty. + +The character-set guarantee is not a display-safety claim. Bidirectional and +other format controls (for example U+202E) are legitimate content in +right-to-left text and are kept; presenting untrusted text safely is the +renderer's responsibility. + +## Data sensitivity + +docprims extracts text from documents it does not classify. Under the 3leaps +[Data Sensitivity Classification](https://github.com/3leaps/crucible/blob/main/docs/standards/data-sensitivity-classification.md) +standard: + +- Extracted text and metadata carry the **same sensitivity as the input + document**. Extraction never lowers it. +- Output from an unclassified document is **UNKNOWN** and is handled as such + until the consumer classifies it. + +`extract/v0` has no sensitivity field; consumers that store or forward results +carry the classification alongside them. diff --git a/ffi/docprims-ffi/Cargo.toml b/ffi/docprims-ffi/Cargo.toml index 9ad5949..ebcf222 100644 --- a/ffi/docprims-ffi/Cargo.toml +++ b/ffi/docprims-ffi/Cargo.toml @@ -7,16 +7,16 @@ rust-version.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +publish.workspace = true [lib] crate-type = ["staticlib", "cdylib", "rlib"] [dependencies] docprims-core = { workspace = true } -docprims-text = { workspace = true } -docprims-ooxml = { workspace = true } +docprims = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } [dev-dependencies] -base64 = "0.22" +base64 = "0.23" diff --git a/ffi/docprims-ffi/src/lib.rs b/ffi/docprims-ffi/src/lib.rs index 6db7bcc..b87a05d 100644 --- a/ffi/docprims-ffi/src/lib.rs +++ b/ffi/docprims-ffi/src/lib.rs @@ -184,22 +184,7 @@ pub unsafe extern "C" fn docprims_extract_file_json( } }; - let path = Path::new(path_str); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_default(); - - let extracted = match ext.as_str() { - "docx" => docprims_ooxml::extract_docx_v0(path, limits), - "xlsx" => docprims_ooxml::extract_xlsx_v0(path, limits), - "pptx" => docprims_ooxml::extract_pptx_v0(path, limits), - "md" | "markdown" => docprims_text::extract_markdown_v0(path, limits), - "html" | "htm" => docprims_text::extract_html_v0(path, limits), - "xml" => docprims_text::extract_xml_v0(path, limits), - _ => Err(DocprimsError::UnknownFormat(ext)), - }; + let extracted = docprims::extract_file(Path::new(path_str), limits); match extracted { Ok(extract) => { @@ -317,51 +302,7 @@ pub unsafe extern "C" fn docprims_extract_bytes_json( } let bytes = std::slice::from_raw_parts(data, len); - let extracted = match ext.as_str() { - "docx" => docprims_ooxml::extract_docx_v0_reader( - std::io::Cursor::new(bytes), - source_uri_str, - limits, - ), - "xlsx" => docprims_ooxml::extract_xlsx_v0_reader( - std::io::Cursor::new(bytes), - source_uri_str, - limits, - ), - "pptx" => docprims_ooxml::extract_pptx_v0_reader( - std::io::Cursor::new(bytes), - source_uri_str, - limits, - ), - "md" | "markdown" => match std::str::from_utf8(bytes) { - Ok(s) => docprims_text::markdown::extract_v0_str(s, source_uri_str, limits), - Err(_) => { - let e = DocprimsError::Malformed("non-utf8 markdown input".to_string()); - let code = map_err(&e); - error::set_last_error(code, e.to_string()); - return code; - } - }, - "html" | "htm" => match std::str::from_utf8(bytes) { - Ok(s) => docprims_text::html::extract_v0_str(s, source_uri_str, limits), - Err(_) => { - let e = DocprimsError::Malformed("non-utf8 html input".to_string()); - let code = map_err(&e); - error::set_last_error(code, e.to_string()); - return code; - } - }, - "xml" => match std::str::from_utf8(bytes) { - Ok(s) => docprims_text::xml::extract_v0_str(s, source_uri_str, limits), - Err(_) => { - let e = DocprimsError::Malformed("non-utf8 xml input".to_string()); - let code = map_err(&e); - error::set_last_error(code, e.to_string()); - return code; - } - }, - _ => Err(DocprimsError::UnknownFormat(ext)), - }; + let extracted = docprims::extract_bytes(source_uri_str, bytes, limits); match extracted { Ok(extract) => { diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ef450a1 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.94.1" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/schemas/release/fingerprint-record.v0.schema.json b/schemas/release/fingerprint-record.v0.schema.json new file mode 100644 index 0000000..e55a2c0 --- /dev/null +++ b/schemas/release/fingerprint-record.v0.schema.json @@ -0,0 +1,246 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.3leaps.dev/decernor/fingerprint-record.v0.schema.json", + "title": "Decernor Fingerprint Record v0", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "kind", + "class", + "algorithm", + "fingerprint", + "fingerprint_scheme", + "confidence" + ], + "properties": { + "schema_version": { + "const": "v0" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "kind": { + "enum": [ + "gpg", + "ssh", + "minisign" + ] + }, + "class": { + "enum": [ + "public", + "private", + "other" + ] + }, + "algorithm": { + "enum": [ + "openpgp-fingerprint", + "sha256", + "minisign-key-id" + ] + }, + "fingerprint": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "fingerprint_scheme": { + "enum": [ + "openpgp-fingerprint-v1", + "ssh-rfc4253-public-blob-sha256-v1", + "minisign-key-id-v1", + "minisign-public-blob-sha256-v1" + ] + }, + "key_id": { + "type": "string", + "minLength": 1 + }, + "key_role": { + "enum": [ + "primary", + "subkey" + ] + }, + "confidence": { + "enum": [ + "high", + "medium", + "low" + ] + }, + "reason": { + "enum": [ + "encrypted-private-no-public-counterpart", + "helper-unavailable", + "parse-unsupported", + "too-large", + "unreadable", + "unsupported-kind", + "unsupported-version" + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "gpg" + }, + "fingerprint": { + "type": "string" + } + }, + "required": [ + "kind", + "fingerprint" + ] + }, + "then": { + "required": [ + "key_role", + "key_id" + ], + "properties": { + "algorithm": { + "const": "openpgp-fingerprint" + }, + "fingerprint_scheme": { + "const": "openpgp-fingerprint-v1" + }, + "key_role": { + "enum": [ + "primary", + "subkey" + ] + }, + "key_id": { + "type": "string", + "pattern": "^[0-9A-F]{16}$" + }, + "fingerprint": { + "type": "string", + "pattern": "^[0-9A-F]{40}$" + } + } + }, + "else": { + "not": { + "required": [ + "key_role" + ] + } + } + }, + { + "if": { + "properties": { + "fingerprint_scheme": { + "const": "openpgp-fingerprint-v1" + }, + "fingerprint": { + "type": "string" + } + }, + "required": [ + "fingerprint_scheme", + "fingerprint" + ] + }, + "then": { + "required": [ + "key_role", + "key_id" + ], + "properties": { + "kind": { + "const": "gpg" + }, + "algorithm": { + "const": "openpgp-fingerprint" + }, + "key_role": { + "enum": [ + "primary", + "subkey" + ] + }, + "key_id": { + "type": "string", + "pattern": "^[0-9A-F]{16}$" + }, + "fingerprint": { + "type": "string", + "pattern": "^[0-9A-F]{40}$" + } + } + } + }, + { + "if": { + "properties": { + "fingerprint_scheme": { + "const": "minisign-public-blob-sha256-v1" + }, + "fingerprint": { + "type": "string" + } + }, + "required": [ + "fingerprint_scheme", + "fingerprint" + ] + }, + "then": { + "properties": { + "kind": { + "const": "minisign" + }, + "algorithm": { + "const": "sha256" + }, + "fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + }, + { + "if": { + "properties": { + "fingerprint_scheme": { + "const": "ssh-rfc4253-public-blob-sha256-v1" + }, + "fingerprint": { + "type": "string" + } + }, + "required": [ + "fingerprint_scheme", + "fingerprint" + ] + }, + "then": { + "properties": { + "kind": { + "const": "ssh" + }, + "algorithm": { + "const": "sha256" + }, + "fingerprint": { + "type": "string", + "pattern": "^SHA256:[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]$" + } + } + } + } + ] +} diff --git a/scripts/build-go-ffi-lib.sh b/scripts/build-go-ffi-lib.sh index fd4f5b2..083896a 100755 --- a/scripts/build-go-ffi-lib.sh +++ b/scripts/build-go-ffi-lib.sh @@ -35,7 +35,7 @@ esac echo "[docprims] building FFI static lib for $PLATFORM" make cbindgen -cargo build --release -p docprims-ffi +cargo build --release -p docprims-ffi --locked mkdir -p "bindings/go/docprims/include" cp "ffi/docprims-ffi/docprims.h" "bindings/go/docprims/include/docprims.h" diff --git a/scripts/check-draft-release.sh b/scripts/check-draft-release.sh new file mode 100755 index 0000000..43454ee --- /dev/null +++ b/scripts/check-draft-release.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Check whether draft creation is safe, or verify the created exact draft. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=release-common.sh +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/release-common.sh" + +mode="${1:?usage: check-draft-release.sh before-or-after}" +tag="$(release_tag)" + +if [[ -n "${GH_REPO+x}" ]]; then + echo "error: GH_REPO must be unset; repository authority is fixed" >&2 + exit 1 +fi + +case "$mode" in +before) + releases="$(mktemp "${TMPDIR:-/tmp}/docprims-release-list.XXXXXX")" + trap 'rm -f "$releases"' EXIT + gh release list --repo "$DOCPRIMS_REPOSITORY" --limit 100 \ + --json tagName >"$releases" + if jq -e --arg tag "$tag" \ + 'any(.[]; .tagName == $tag)' "$releases" >/dev/null; then + assert_github_release_state release_base_assets + else + [[ "$(gh repo view "$DOCPRIMS_REPOSITORY" \ + --json nameWithOwner --jq .nameWithOwner)" == "$DOCPRIMS_REPOSITORY" ]] || { + echo "error: GitHub repository identity mismatch" >&2 + exit 1 + } + fi + ;; +after) + assert_github_release_state release_base_assets + ;; +*) + echo "error: mode must be before or after" >&2 + exit 1 + ;; +esac + +echo "[ok] draft release state is safe for $mode" diff --git a/scripts/check-lean-lib.sh b/scripts/check-lean-lib.sh new file mode 100755 index 0000000..2deee0d --- /dev/null +++ b/scripts/check-lean-lib.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Verify that library builds of the `docprims` crate pull only what their +# features need: no CLI dependencies without `cli`, and no dependencies of +# formats that are not enabled. +set -euo pipefail + +cd "$(dirname "$0")/.." + +fail=0 + +# check