diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3c6b39c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,16 @@
+# Check every text file out with LF endings on every platform.
+#
+# Windows git defaults to `core.autocrlf=true`, and GitHub's `windows-latest`
+# runners ship that default — so without this line `actions/checkout` writes CRLF
+# into the working tree even though the repository stores LF. That is not a
+# cosmetic difference: `the_set_of_group_lock_acquisition_sites_is_closed`
+# (`src/handles/registry.rs`) reads the source tree and splits each file on a
+# pattern containing a newline, and CRLF made that pattern miss, so the audit
+# counted the test modules and failed on Windows alone. The test normalises line
+# endings itself now; this stops the next reader of a file from having to.
+#
+# `text=auto` still lets git detect and leave binary content alone, so the
+# wildcard is safe for anything added later. Every file currently tracked is
+# text — no images, and no `.bat`/`.cmd`/`.ps1`, which are the ones that would
+# want CRLF kept.
+* text=auto eol=lf
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..d9c3669
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,106 @@
+name: Bug report
+description: Something in stackable-odbc-core behaves differently from the ODBC spec, or from its own documentation.
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ `stackable-odbc-core` is a **library**, not a loadable ODBC driver — it
+ is the database-independent half. A concrete driver crate implements
+ the `Backend` and `StatementBackend` traits and exports the C ABI.
+
+ If the wrong behaviour is in how a particular data source is queried,
+ it probably belongs in that driver's repository. If you are not sure
+ which side it falls on, file it here and say so.
+
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened
+ description: What the driver did, and what you expected instead.
+ validations:
+ required: true
+
+ - type: textarea
+ id: call-sequence
+ attributes:
+ label: ODBC call sequence
+ description: >
+ The calls that lead to it, in order, with the arguments that matter.
+ Most bugs in this crate are about a specific function under a specific
+ prior state, so the sequence is usually more useful than a stack trace.
+ placeholder: |
+ SQLAllocHandle(SQL_HANDLE_STMT, ...)
+ SQLExecDirectW(stmt, L"SELECT ...", SQL_NTS) -> SQL_SUCCESS
+ SQLFetch(stmt) -> SQL_ERROR, expected SQL_SUCCESS
+ validations:
+ required: true
+
+ - type: input
+ id: sqlstate
+ attributes:
+ label: SQLSTATE and message
+ description: >
+ From `SQLGetDiagRecW`, if the call returned `SQL_ERROR` or
+ `SQL_SUCCESS_WITH_INFO`. A wrong SQLSTATE is itself a bug worth
+ reporting, even when the call otherwise does the right thing.
+ placeholder: "07009 — Column number 3 out of range (have 5 columns)"
+
+ - type: input
+ id: spec-reference
+ attributes:
+ label: Spec reference
+ description: >
+ Link to the function's page on Microsoft Learn, and quote the row or
+ sentence you think is not being honoured. Optional, but it is what
+ makes a report immediately actionable — and pay attention to whether
+ the row carries a `(DM)` marker, which means the Driver Manager owes
+ it rather than the driver.
+ placeholder: "https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlfetch-function"
+
+ - type: input
+ id: version
+ attributes:
+ label: Version or commit
+ description: The `stackable-odbc-core` version, or the commit if building from source.
+ validations:
+ required: true
+
+ - type: input
+ id: driver
+ attributes:
+ label: Driver crate
+ description: Which driver is built on core here, and at what version.
+
+ - type: dropdown
+ id: driver-manager
+ attributes:
+ label: Driver Manager
+ description: >
+ The Windows Driver Manager is considerably stricter than unixODBC, and
+ several behaviours differ between them.
+ options:
+ - unixODBC
+ - Windows Driver Manager
+ - None (calling the exported entry points directly)
+ - Other / not sure
+ validations:
+ required: true
+
+ - type: input
+ id: platform
+ attributes:
+ label: Platform
+ description: OS and architecture, and the application if one is involved.
+ placeholder: "Ubuntu 24.04 x86_64, pyodbc 5.1 / isql"
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Driver log
+ description: >
+ Set `ODBC_LOG_LEVEL=trace` and `ODBC_LOG_FILE=/path/to/log`, reproduce,
+ and attach the relevant part. **Check it before posting** — a
+ connection string may appear in it. Passwords are redacted, but other
+ connection parameters are not.
+ render: text
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..70b1b26
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,11 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Question or discussion
+ url: https://github.com/orgs/stackabletech/discussions
+ about: For usage questions and anything that is not yet a specific defect.
+ - name: Community chat
+ url: https://discord.gg/7kZ3BNnCAF
+ about: Stackable's Discord, for a faster back-and-forth.
+ - name: Report a security vulnerability
+ url: https://github.com/stackabletech/stackable-odbc-core/security/advisories/new
+ about: Please report vulnerabilities privately rather than in an issue.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..59da597
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,62 @@
+name: Feature request
+description: An ODBC function, attribute or info type that core does not implement yet, or a change to the Backend trait.
+labels: ["enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Most additions here are driven by the ODBC spec rather than invented:
+ a function that is stubbed, an attribute that is accepted and ignored,
+ an info type answered with a default. Saying which one it is, and
+ quoting the spec, usually settles the design.
+
+ - type: textarea
+ id: what
+ attributes:
+ label: What is missing
+ description: What you need core to do that it does not do today.
+ validations:
+ required: true
+
+ - type: textarea
+ id: use-case
+ attributes:
+ label: What it unblocks
+ description: >
+ The application or driver that needs it, and what it does instead
+ today. A concrete blocked case is the strongest argument.
+ validations:
+ required: true
+
+ - type: input
+ id: spec-reference
+ attributes:
+ label: Spec reference
+ description: >
+ The relevant page on Microsoft Learn, if there is one. For an info
+ type or attribute, quote its description — the stated *purpose* often
+ decides the design, and has more than once here.
+ placeholder: "https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function"
+
+ - type: dropdown
+ id: layer
+ attributes:
+ label: Which side should own it
+ description: >
+ Core holds no database-specific code. A capability only the data source
+ can answer belongs on a `Backend` method, so the compiler asks the
+ driver author rather than core guessing — a value core invents is wrong
+ for some real driver, and wrong silently.
+ options:
+ - Core owns it entirely (same answer for every driver)
+ - A new or changed Backend trait method (each driver answers)
+ - Not sure
+ validations:
+ required: true
+
+ - type: checkboxes
+ id: breaking
+ attributes:
+ label: Compatibility
+ options:
+ - label: This would change an existing public API or trait method (a breaking change for driver crates)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..adcbb15
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,30 @@
+---
+# Every `uses:` in this repository is pinned to a full commit SHA, which is only
+# safe if something keeps those pins current. Without this file they freeze
+# permanently, and Cargo.lock drifts toward advisories cargo-deny will start
+# failing on with no PR available to fix them.
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ actions:
+ patterns: ["*"]
+
+ - package-ecosystem: cargo
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ cargo:
+ patterns: ["*"]
+ update-types: ["minor", "patch"]
+
+ # fuzz/ is a separate Cargo workspace (libFuzzer needs nightly), so the root
+ # ecosystem entry above does not cover it.
+ - package-ecosystem: cargo
+ directory: /fuzz
+ schedule:
+ interval: monthly
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..51081c6
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,47 @@
+
+
+
+## What this changes
+
+
+
+## Spec basis
+
+
+
+## Checklist
+
+- [ ] `pre-commit run --all-files` passes — this is the single source of truth for what must pass.
+- [ ] `CHANGELOG.md` has an entry under `## [Unreleased]`, if this is user-facing. Any change to a public type, trait method or exported FFI contract counts, since driver crates consume them.
+- [ ] Doc comments on any FFI function touched list every SQLSTATE from its spec diagnostics table, saying for each whether the driver returns it or why not.
+- [ ] New tests were checked by breaking the line they cover and watching them fail. A test that cannot fail reports coverage that does not exist.
+
+### If it applies
+
+- [ ] Miri, for anything touching raw pointers: `MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptest`
+- [ ] loom, for anything touching handle locking: `RUSTFLAGS="--cfg loom" cargo test --lib loom_tests`
+- [ ] Breaking changes for driver crates are called out above, so the drivers can be updated alongside.
+
+## Notes for the reviewer
+
+
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 0000000..7e214e0
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,350 @@
+---
+name: Build and Test
+
+permissions:
+ contents: read
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ merge_group:
+
+# Supersede in-flight runs on the same ref. Never cancel in a merge queue: a
+# cancelled merge_group run reports failure and evicts the PR from the queue.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+env:
+ CARGO_TERM_COLOR: always
+ RUST_TOOLCHAIN_VERSION: "1.95.0"
+
+jobs:
+ # Formatting, clippy (which is what enforces the unwrap_used / unwrap_in_result
+ # / panic denies from Cargo.toml), cargo-deny and cargo-sort. This lives here
+ # rather than in its own workflow because `needs:` cannot cross workflows, and
+ # a lint gate the required check does not observe is not a gate.
+ pre-commit:
+ name: pre-commit
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ # The cargo-test pre-commit hook links libodbc via odbc-sys.
+ - name: Install host dependencies
+ uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
+ with:
+ packages: unixodbc-dev
+ version: ubuntu-latest
+
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain
+ uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # 1.95.0
+ with:
+ toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }}
+ components: rustfmt, clippy
+
+ - name: Setup Rust Cache
+ uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
+
+ - name: Install cargo-deny and cargo-sort
+ uses: taiki-e/install-action@97a5807a604e12de3a13b52d868ebecaeeea757c # v2.75.4
+ with:
+ tool: cargo-deny,cargo-sort
+
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
+
+ unit-tests:
+ name: Unit Tests (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ strategy:
+ # One platform failing should not hide the others: the point of the
+ # matrix is to learn which platforms are broken, not just that one is.
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ steps:
+ # odbc-sys links against libodbc/libodbcinst, so the unixODBC dev
+ # libraries must be present to link the test binaries (no running Driver
+ # Manager is needed — only the libraries). Windows needs nothing: odbc32
+ # and odbccp32 ship with the platform SDK on the runner image.
+ - name: Install host dependencies (Linux)
+ if: runner.os == 'Linux'
+ uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
+ with:
+ packages: unixodbc-dev
+ version: ubuntu-latest
+
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain
+ uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # 1.95.0
+ with:
+ toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }}
+ components: clippy
+
+ - name: Setup Rust Cache
+ uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
+
+ # --locked so CI tests the dependency versions Cargo.lock pins and that
+ # cargo publish would use, and so a Cargo.toml change without a matching
+ # lockfile update fails here instead of drifting.
+ - name: Run unit tests
+ run: cargo test --locked
+
+ # The pre-commit job runs this same command, but only on Linux — so the
+ # denied lints (unwrap_used / unwrap_in_result / panic, from Cargo.toml)
+ # had never applied to any `#[cfg(windows)]` code: `ffi/setup.rs` and
+ # ConfigDSNW, which is also the least-reviewed code in the crate. Guarded
+ # to Windows because Linux is already covered and running it twice would
+ # only slow the matrix down. Same invocation as the pre-commit hook, so
+ # the two platforms are held to one standard.
+ - name: Clippy (Windows-only code paths)
+ if: runner.os == 'Windows'
+ run: cargo clippy --locked --all-targets -- -D warnings
+
+ # `test-support` is gated `#[cfg(any(test, feature = "test-support"))]`, so
+ # the job above compiles that module via `cfg(test)` no matter what the
+ # feature is set to. The configuration a *driver* consumes is the other
+ # one — feature on, `cfg(test)` off — and nothing built it. `cargo check`
+ # rather than `cargo test`: the point is that the module compiles outside
+ # `cfg(test)`, and there are no tests to run in that configuration.
+ - name: Check the test-support feature as a driver consumes it
+ run: cargo check --locked --features test-support
+
+ # The benchmark is its own crate (see bench/Cargo.toml), so nothing in
+ # the root build touches it and bench rot could otherwise merge unnoticed.
+ # harness = false means it is never run as a test either. Compile it; do
+ # not run it.
+ - name: Compile benchmarks
+ run: cargo build --benches
+ working-directory: bench
+
+ miri:
+ name: Miri (undefined behaviour + leaks)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ needs: [unit-tests]
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+
+ # Miri requires a nightly toolchain; it cannot run on the pinned stable
+ # version used everywhere else in this workflow.
+ - name: Install nightly toolchain with Miri
+ uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # nightly
+ with:
+ toolchain: nightly
+ components: miri
+
+ - name: Setup Rust Cache
+ uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
+ with:
+ key: miri
+
+ # stackable-odbc-core is pure Rust and holds all the raw-pointer
+ # marshalling, so it is where the undefined-behaviour risk lives.
+ #
+ # Proptests are skipped because they take hours under Miri; they run on
+ # stable in the unit-tests job.
+ #
+ # -Zmiri-disable-isolation is needed for the clock/filesystem access the
+ # test harness performs. Leak reporting is left ON: it is what catches
+ # handle and descriptor allocations that a teardown path forgets to free.
+ # `+nightly` is required: rust-toolchain.toml pins 1.95.0, and a bare
+ # `cargo miri` respects that file regardless of which toolchain was
+ # installed above.
+ - name: Run Miri
+ env:
+ MIRIFLAGS: -Zmiri-disable-isolation
+ run: cargo +nightly miri test --locked -p stackable-odbc-core --lib -- --skip proptest
+
+ loom:
+ name: loom
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ needs: [unit-tests]
+ steps:
+ # odbc-sys links against libodbc/libodbcinst, so linking the test binary
+ # needs the same host dependency as the other jobs.
+ - name: Install host dependencies
+ uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
+ with:
+ packages: unixodbc-dev
+ version: ubuntu-latest
+
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain
+ uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # 1.95.0
+ with:
+ toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }}
+
+ - name: Setup Rust Cache
+ uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
+ with:
+ key: loom
+
+ # `--cfg loom` swaps every lock in `src/sync.rs` for loom's instrumented
+ # equivalent, so this is a full rebuild of the crate and cannot share a
+ # cache with the normal test job. The models live in
+ # `src/handles/registry.rs` as `#[cfg(all(test, loom))]`; the `loom_tests`
+ # filter is required, not cosmetic, because every other unit test in the
+ # crate still calls the process-wide registry outside a `loom::model`,
+ # which panics once `Registry::new` resolves to loom's `RwLock`.
+ - run: RUSTFLAGS="--cfg loom" cargo test --lib loom_tests
+ env:
+ LOOM_MAX_PREEMPTIONS: "3"
+
+ fuzz:
+ name: Fuzz (ASAN smoke)
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ needs: [unit-tests]
+ steps:
+ # The fuzz binaries link stackable-odbc-core, which links libodbc.
+ - name: Install host dependencies
+ uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
+ with:
+ packages: unixodbc-dev
+ version: ubuntu-latest
+
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+
+ # cargo-fuzz builds with libFuzzer + AddressSanitizer, which require a
+ # nightly toolchain. The fuzz crate is its own Cargo workspace so the
+ # pinned stable root build never touches it.
+ - name: Install nightly toolchain
+ uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # nightly
+ with:
+ toolchain: nightly
+
+ # fuzz/ declares its own [workspace], so its build artifacts land in
+ # fuzz/target, not the root target/. Without this the cache stores an
+ # empty directory and every run rebuilds nightly + ASAN from scratch.
+ - name: Setup Rust Cache
+ uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
+ with:
+ key: fuzz
+ workspaces: "fuzz -> target"
+
+ - name: Install cargo-fuzz
+ uses: taiki-e/install-action@97a5807a604e12de3a13b52d868ebecaeeea757c # v2.75.4
+ with:
+ tool: cargo-fuzz
+
+ # A short smoke run per target: long enough to shake out an ASAN overrun
+ # in the pointer-marshalling paths, short enough for per-PR CI.
+ #
+ # --target is pinned to the gnu triple explicitly: newer cargo-fuzz
+ # defaults to x86_64-unknown-linux-musl, whose statically linked libc is
+ # incompatible with AddressSanitizer ("sanitizer is incompatible with
+ # statically linked libc"). gnu uses a dynamic libc and ships with the
+ # nightly toolchain.
+ - name: Fuzz utf16
+ run: cargo +nightly fuzz run utf16 --target x86_64-unknown-linux-gnu -- -max_total_time=30
+ - name: Fuzz column_value
+ run: cargo +nightly fuzz run column_value --target x86_64-unknown-linux-gnu -- -max_total_time=30
+
+ # Verifies the crate can actually be packaged, without publishing anything.
+ #
+ # `cargo package` is not covered by `cargo build`: it applies `exclude`,
+ # re-resolves the result as a standalone crate and compiles it from the
+ # tarball. That is what catches a source file excluded by accident, a
+ # declared-but-unpackaged target, or a path dependency with no version --
+ # each of which only shows up at publish time otherwise.
+ #
+ # `--locked` so a manifest change without a lockfile update fails here.
+ # Warnings are promoted to failures: the packaging step is short enough that
+ # a standing warning would be read as normal and hide the next one.
+ package:
+ name: Package (publish dry run)
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install host dependencies
+ run: sudo apt-get update && sudo apt-get install -y unixodbc-dev
+
+ - name: Setup Rust Cache
+ uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
+ with:
+ key: package
+
+ # The log goes to RUNNER_TEMP, not the workspace: `cargo package` refuses
+ # to run against a dirty tree, so a log file written next to Cargo.toml
+ # is itself the uncommitted change that fails the step.
+ - name: Build the package and compile it from the tarball
+ run: |
+ set -o pipefail
+ cargo package --locked 2>&1 | tee "${RUNNER_TEMP}/package.log"
+ if grep -q '^warning' "${RUNNER_TEMP}/package.log"; then
+ echo "::error::cargo package emitted warnings; see above"
+ exit 1
+ fi
+
+ # The published tarball must carry the licence and the notice, and must
+ # not carry the toolchain pin, shipped, it pins consumers to this
+ # crate's Rust version.
+ - name: Check the tarball's contents
+ run: |
+ list=$(cargo package --locked --list)
+ for required in LICENSE NOTICE README.md CHANGELOG.md; do
+ grep -qx "$required" <<<"$list" || {
+ echo "::error::$required missing from the package"; exit 1; }
+ done
+ for forbidden in rust-toolchain.toml AGENTS.md CLAUDE.md; do
+ if grep -qx "$forbidden" <<<"$list"; then
+ echo "::error::$forbidden must not be published"; exit 1
+ fi
+ done
+ echo "$list"
+
+ # Single required check for branch protection rules.
+ finished:
+ name: Finished Build and Test
+ if: always()
+ needs:
+ - pre-commit
+ - unit-tests
+ - miri
+ - loom
+ - fuzz
+ - package
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ # Derived from needs.* rather than a hand-written list of job names: a job
+ # added to `needs` above but forgotten here would otherwise be silently
+ # non-blocking, which is exactly how the lint gate went unenforced.
+ - name: Check job results
+ env:
+ RESULTS: ${{ join(needs.*.result, ' ') }}
+ run: |
+ for result in $RESULTS; do
+ if [[ "$result" != "success" ]]; then
+ echo "One or more jobs did not succeed: $RESULTS"
+ exit 1
+ fi
+ done
+ echo "All jobs passed: $RESULTS"
diff --git a/.github/workflows/security_audit.yaml b/.github/workflows/security_audit.yaml
new file mode 100644
index 0000000..070c7de
--- /dev/null
+++ b/.github/workflows/security_audit.yaml
@@ -0,0 +1,29 @@
+---
+name: Daily Security Audit
+
+on:
+ schedule:
+ # Run every day at 04:15 UTC: https://crontab.guru/#15_4_*_*_*
+ - cron: '15 4 * * *'
+ workflow_dispatch:
+
+# rustsec/audit-check reports findings by opening a GitHub issue, and records a
+# check run. With contents:read alone it cannot do either, so a new advisory
+# would fail silently and the job's green status would mean nothing.
+permissions:
+ contents: read
+ issues: write
+ checks: write
+
+jobs:
+ audit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2099c8a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+debug/
+target/
+**/*.rs.bk
+.worktrees/
+
+.idea/
+*.iws
+*.iml
+.vscode/
+
+# Generated via ctags -R.
+tags
+
+# Local agent working notes (SDD reports); never part of the shipped tree
+.superpowers/
diff --git a/.markdownlint.yaml b/.markdownlint.yaml
new file mode 100644
index 0000000..783004c
--- /dev/null
+++ b/.markdownlint.yaml
@@ -0,0 +1,28 @@
+---
+# All defaults or options can be checked here:
+# https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml
+
+# Default state for all rules
+default: true
+
+# MD013/line-length - Line length
+MD013:
+ # Number of characters
+ line_length: 9999
+ # Number of characters for headings
+ heading_line_length: 9999
+ # Number of characters for code blocks
+ code_block_line_length: 9999
+
+# MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content
+MD024:
+ # Only check sibling headings
+ siblings_only: true
+
+# MD040/fenced-code-language - Fenced code blocks should have a language specified
+# We use plain fenced blocks for ODBC config files and output examples
+MD040: false
+
+# MD060/table-column-style - Table column alignment
+# Too strict for our tables
+MD060: false
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..b731cdd
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,87 @@
+---
+default_language_version:
+ node: system
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # 5.0.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-toml
+ - id: check-merge-conflict
+ - id: mixed-line-ending
+ - id: detect-aws-credentials
+ args: ["--allow-missing-credentials"]
+ - id: detect-private-key
+
+ - repo: https://github.com/igorshubovych/markdownlint-cli
+ rev: 192ad822316c3a22fb3d3cc8aa6eafa0b8488360 # 0.45.0
+ hooks:
+ - id: markdownlint
+
+ - repo: local
+ hooks:
+ - id: cargo-test
+ name: cargo-test
+ language: system
+ entry: cargo test --locked
+ stages: [pre-commit, pre-merge-commit]
+ pass_filenames: false
+ files: \.rs$|Cargo\.(toml|lock)
+ - id: cargo-rustfmt
+ name: cargo-rustfmt
+ language: system
+ entry: cargo fmt --all -- --check
+ stages: [pre-commit, pre-merge-commit]
+ pass_filenames: false
+ files: \.rs$
+
+ - id: cargo-clippy
+ name: cargo-clippy
+ language: system
+ entry: cargo clippy --locked --all-targets -- -D warnings
+ stages: [pre-commit, pre-merge-commit]
+ pass_filenames: false
+ files: \.rs$
+
+ # Broken intra-doc links are warnings, not errors, so they reach docs.rs
+ # silently — and docs.rs is this crate's shop window, since a driver
+ # author reads the Backend trait docs before writing any code. -D warnings
+ # promotes them. Runs in well under a second because the dependency graph
+ # is already built by the hooks above.
+ #
+ # `--all-features` is load-bearing, not tidiness. `conformance` and
+ # `test_support` are gated on `#[cfg(any(test, feature = "test-support"))]`,
+ # and rustdoc documents the lib target *without* `cfg(test)` — so without
+ # this flag neither module is documented and neither is checked. The
+ # cargo-clippy hook above does not have the gap, because `--all-targets`
+ # builds the test target and that sets `cfg(test)`. Cargo.toml points
+ # docs.rs at `features = ["test-support"]`, so those are exactly the
+ # modules whose broken links ship to the shop window. One did: the
+ # conformance module's link to `info_group_inconsistencies` rendered as
+ # broken text while every local and CI run reported clean.
+ - id: cargo-doc
+ name: cargo-doc
+ language: system
+ entry: env RUSTDOCFLAGS=-Dwarnings cargo doc --locked --no-deps --all-features
+ stages: [pre-commit, pre-merge-commit]
+ pass_filenames: false
+ files: \.rs$|Cargo\.(toml|lock)
+
+ - id: cargo-sort
+ name: cargo-sort
+ language: system
+ entry: cargo sort --grouped --check
+ stages: [pre-commit, pre-merge-commit]
+ pass_filenames: false
+ files: Cargo\.toml$
+
+ - id: cargo-deny
+ name: cargo-deny
+ language: system
+ entry: cargo deny --locked check
+ stages: [pre-commit, pre-merge-commit]
+ pass_filenames: false
+ files: Cargo\.(toml|lock)|deny\.toml
diff --git a/.readme/static/borrowed/Icon_Stackable.svg b/.readme/static/borrowed/Icon_Stackable.svg
new file mode 100644
index 0000000..35e132a
--- /dev/null
+++ b/.readme/static/borrowed/Icon_Stackable.svg
@@ -0,0 +1,20 @@
+
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..98ecfca
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,1467 @@
+# Agent Guide
+
+Implementation details for anyone working on `stackable-odbc-core`, human or AI.
+
+`stackable-odbc-core` is the database-independent framework that concrete ODBC
+driver crates build on. It contains **zero** database-specific code: a driver
+implements the `Backend` and `StatementBackend` traits and calls the
+`forward_ffi!` macro to export the C ABI. This guide describes the framework
+itself and, where relevant, how a downstream driver crate consumes it.
+
+For building, testing and the commit gate, see
+[CONTRIBUTING.md](CONTRIBUTING.md). This file is the reference behind those
+commands, not a substitute for them.
+
+## Quick Reference
+
+| Topic | When to Read |
+|-------|-------------|
+| [Architecture](#architecture) | Understanding call flow, crate layout, or the key design decisions |
+| [Conventions](#conventions) | Writing any code here: logging, named constants, casts, the changelog |
+| [Adding a new ODBC function](#adding-a-new-odbc-function) | Implementing or moving a function from stubs |
+| [Adding a new driver](#adding-a-new-driver) | Creating a backend crate on top of core, and every capability it must declare |
+| [Descriptors](#descriptors) | Touching a binding, a descriptor field, the `HY021` consistency check, or a statement attribute that is a header field |
+| [Concurrency: the lock discipline](#concurrency-the-lock-discipline) | Understanding the per-connection lock, `HandleScope`, `SQLCancel`'s exemption, or loom |
+| [Testing](#testing) | Writing tests, or running Miri, loom, fuzz or the benchmarks |
+| [odbc-sys usage](#odbc-sys-usage) | Using ODBC types, enums or constants |
+| [Converting raw values](#converting-raw-values-to-strongly-typed-enums) | Handling raw integers from the C ABI |
+
+## Architecture
+
+Core exposes a generic `Backend` trait, and each driver crate generates manual
+forwarding stubs from it. That shape was chosen over proc-macros (premature)
+and a `dyn` trait (poor ergonomics for complex trait hierarchies).
+
+### How a function call flows
+
+Example: `SQLDriverConnectW` (a fully implemented function), for a driver whose
+backend type is `XyzBackend`:
+
+```text
+ODBC Application (e.g. isql)
+ -> SQLDriverConnectW(...) # C ABI entry point generated by forward_ffi! in the driver's lib.rs
+ -> ffi::connect::sql_driver_connect_w::(...) # generic impl in stackable-odbc-core
+ -> panic_safe::(...) # locks the target's group, builds a HandleScope, catches panics
+ -> scope.get::>() # validates the token against the handle registry, returns typed &mut
+ -> handle.diagnostics.clear() # spec: clear diagnostics at start of each call
+ -> validation checks # 08002, HY090 per the ODBC spec
+ -> utf16_to_string(...) # convert UTF-16 input to Rust String
+ -> merge_dsn_params(...) # parse "Key=Value;..."; if DSN= is present, resolve its keys from odbc.ini (explicit values win) and re-parse
+ -> params.set_prompter(prompter_for::(completion)) # B::prompter(), unless DriverCompletion is SQL_DRIVER_NOPROMPT
+ -> B::connect(¶ms) # Backend trait method (database-specific); runs under the connection's group lock, like every other Backend method
+ -> handle.connection = Some(conn) # store result in handle
+ -> apply_pending_autocommit::(..) # apply a SQL_ATTR_AUTOCOMMIT set before connect; tears the connection down on failure
+ -> write_utf16(...) # echo connection string to output buffer
+```
+
+### Key design decisions
+
+- **Two traits**: `Backend` (creates connections and statements) and
+ `StatementBackend` (iterates results). Split so that lifecycle is separate
+ from cursor operations.
+- **Handle registry**: an application-facing `SQLHANDLE` is an opaque token
+ packing a slot index and a generation counter, not an address. A driver-owned
+ table holds `{ generation, kind, addr, group, parent, cancel }`, and
+ `HandleScope::get()` validates a token with a bounds check plus a
+ generation and kind compare, **without dereferencing the pointer the
+ application passed**. Freeing bumps the slot's generation, so every
+ outstanding token for that slot is permanently rejected, which also closes
+ the recycled-address double-free. This is the primary safety mechanism at the
+ FFI boundary. Nothing may treat a `SQLHANDLE` as an address, or validate one
+ by reading through it. See "Concurrency: the lock discipline" for `group`,
+ `parent` and `cancel`.
+- **`panic_safe`**: wraps every FFI function except the two below. It locks the
+ target handle's group, builds the `HandleScope` the closure operates through,
+ and uses `AssertUnwindSafe` plus `catch_unwind`. On error it pushes to the
+ handle's diagnostic queue and returns the appropriate `SqlReturn`.
+
+ **The two exceptions use `panic_safe_unlocked`, because neither has a handle
+ to work through.** `SQLCancel` must not touch the diagnostic state
+ `panic_safe` clears and pushes, per the spec's carve-out for cancelling a
+ call running on another thread. `ConfigDSNW` is handed no ODBC handle at all:
+ its arguments are a window handle, a request code and two strings, so there
+ is no token to lock a group by and no queue to push to. It is still an
+ `extern "system"` boundary, and an unwind across it lands in the ODBC
+ Administrator.
+
+ **Every `extern "system"` export needs one of the two.** Neither is optional
+ for a new entry point. "It takes no handle" is a reason to reach for
+ `panic_safe_unlocked`, not a reason to skip the guard.
+
+ **`SQLCopyDesc` needs two guards, because it takes two lock phases rather
+ than one** (see "Descriptors", "The explicit-descriptor rulings", for why).
+ Phase one holds only the *source*'s group, through `HandleScope::with_group`,
+ which is a plain lock-then-call with no `catch_unwind` of its own, so it is
+ guarded by `panic::catch_panic_as_error`. That converts a panic into the same
+ `OdbcError` shape a non-panicking phase-one failure (`HY007`) returns. Phase
+ two is an ordinary `panic_safe` on the target, and it posts that error to the
+ target's queue, where the whole call's diagnostics belong.
+- **W-only for string-bearing functions**: every ODBC function that takes or
+ returns a string is exported only in its Wide (`W`-suffix) form, and the
+ Driver Manager translates an ANSI application's calls into those. Functions
+ with no strings in their signature, such as `SQLAllocHandle`, `SQLFetch` and
+ `SQLBindCol`, have one spelling and are exported unsuffixed.
+ `CORE_EXPORTED_FUNCTIONS` in `src/function_id.rs` is the authoritative list,
+ and a guard test pins every entry to a symbol that exists.
+- **No async in the trait**: `Backend` is synchronous. A driver wrapping an
+ async client library bridges to it internally, for example with a
+ current-thread tokio runtime plus `block_on`.
+
+### Crate layout
+
+Generic framework. Zero database-specific code.
+
+| Module | What it does |
+|--------|-------------|
+| `backend.rs` | `Backend` + `StatementBackend` trait definitions; `common_get_info_raw` and `default_get_info` shared helpers |
+| `types/mod.rs` | `odbc-sys` re-exports, `InfoValue` enum, submodule declarations |
+| `types/constants.rs` | All `SQL_*` named constants (spec-defined values not in `odbc-sys`) |
+| `types/conversions.rs` | `*_from_raw()` conversion functions for all ODBC ABI types |
+| `types/sql_state.rs` | `SqlState`: the five-character ODBC diagnostic code, and its factory methods |
+| `types/value.rs` | `ColumnValue`, `FetchResult`, `Nullable`, `TypeInfoRow`, `ColumnDescriptor` |
+| `types/result_cols.rs` | `TablesResultCol`, `ColumnsResultCol`, `PrimaryKeysResultCol`, `ForeignKeysResultCol`, and `CatalogResultColumnWidths` (the per-backend widths those result sets declare) |
+| `types/connect_params.rs` | `ConnectParams`: the ODBC connection string parser |
+| `types/col_attr.rs` | `ColAttrValue` and column attribute logic for `SQLColAttributeW` |
+| `types/cursor_behavior.rs` | `CursorBehavior`: the `SQL_CB_*` cursor behaviour `SQLEndTran` applies, declared by the backend and reported by `SQLGetInfoW` |
+| `types/query_timeout.rs` | `QueryTimeout`: which side enforces `SQL_ATTR_QUERY_TIMEOUT`, declared by `Backend::set_query_timeout` |
+| `types/column_size.rs` | Shared ODBC column-size formulas (`catalog_column_size`/`column_size`); keeps declared vs maximum precision distinct |
+| `types/info_type_shape.rs` | The `SQLGetInfo` spec's per-`InfoType` return-value shape, transcribed for the conformance test |
+| `types/version.rs` | Parsed data-source version numbers, for a backend gating capabilities on server version |
+| `types/odbc_version.rs` | `DeclaredOdbcVersion`: the version an application declared through `SQL_ATTR_ODBC_VERSION`, and what it changes |
+| `types/catalog_queries.rs` | The ten sealed `XxxQuery` argument objects the catalog hooks take |
+| `types/diagnostics_table.rs` | Every function's spec Diagnostics table transcribed, plus the guards that check the doc comments against it |
+| `types/redacted.rs` | `Redacted`: a `Debug` wrapper that prints `*****` for sensitive fields (e.g. passwords) |
+| `column_value.rs` | `write_column_value()`: core data marshalling for `SQLGetData` (NULL, truncation, type coercion). Also owns the spec's "SQL to C: Year-Month Intervals" and "SQL to C: Day-Time Intervals" tables, transcribed: the C interval targets, footnote [b]'s exact-numeric row, and the character and binary rows those two pages word differently from every other source |
+| `param_convert.rs` | `text_to_sql_type()`, the reverse direction: converts `SQL_C_CHAR`/`SQL_C_WCHAR` parameter text to the SQL type `SQLBindParameter` declared. The spec's "C to SQL: Character" table, transcribed. Also owns the size checks all three C-to-SQL tables share (`DecimalLiteral`, `check_declared_char_size`, `check_declared_decimal_size`, `check_declared_binary_size`) |
+| `binary_convert.rs` | The spec's "C to SQL: Binary" table, transcribed. `SQL_C_BINARY` to the targets whose byte layout ODBC defines; refuses the rest at bind with `07006` |
+| `numeric_convert.rs` | The spec's "C to SQL: Numeric" table, transcribed. Every numeric C type to any of its six target rows, including the interval row and footnote [b]'s optional `01S07`. `numeric_pairing_is_supported` is `SQLBindParameter`'s gate |
+| `prompt.rs` | `Prompter`: the trait a driver implements to present a login URL to the user during a connect. Definition only: core ships no implementation and gains no dependency |
+| `setup.rs` | `ConfigRequest`, `InstallerError` and `config_request_from_raw`: the driver-facing half of the ODBC installer's `ConfigDSN` entry point, which a driver reaches through `Backend::configure_dsn`. Nothing here is `#[cfg(windows)]`; only the `ConfigDSNW` export in `ffi/setup.rs` is |
+| `query_timer.rs` | `QueryTimer`, core-side `SQL_ATTR_QUERY_TIMEOUT` enforcement: a timer thread that calls `Backend::cancel` on expiry and relabels the resulting failure `HYT00` |
+| `cancel.rs` | `CancelState`: a backend's cancel token plus core's `timed_out` flag, and the one implementation of "a cancelled call reports `HY008`" |
+| `synthetic.rs` | `SyntheticStatement`: in-memory result set for `SQLGetTypeInfo` and catalog functions |
+| `catalog_sort.rs` | Sorts a catalog result set into its spec-mandated order; NULL placement from `Backend::null_collation` |
+| `catalog_ident.rs` | `SQL_ATTR_METADATA_ID` identifier normalisation and the `SQLTables` `TableType` value-list parser |
+| `types/catalog_rows.rs` | The ten typed catalog row structs a `Backend` returns (`TableRow`, `ColumnRow`, `PrimaryKeyRow`, `ForeignKeyRow`, `StatisticsRow`, `SpecialColumnRow`, `ProcedureRow`, `ProcedureColumnRow`, `ColumnPrivilegeRow`, `TablePrivilegeRow`), and their spec-order conversion to `ColumnValue`s |
+| `conformance.rs` | Shared support for the `SQLGetInfoW` info-type conformance test (return shape + Driver-Manager-safe value), reused by core and by driver test suites |
+| `escape.rs` | ODBC escape-sequence translation (`{fn}`, `{d/t/ts}`, `{oj}`, `{escape}`); a shared scanner with a per-backend `EscapeDialect` |
+| `errors.rs` | `OdbcError` with SQLSTATE mapping and `SqlReturn` conversion |
+| `descriptor.rs` | `DescriptorRecord`, `DescriptorRole`, the per-role field tables (`field_access`, which decides `HY091` for every identifier naming a real field; one naming none is refused earlier by `ffi::desc::field_from_raw`), the header-field mapping, and the `HY021` consistency check. No FFI, no handles |
+| `diagnostics.rs` | Per-handle diagnostic queue (`SQLGetDiagRecW` reads from here) |
+| `handles/mod.rs` | `EnvironmentHandle`, `ConnectionHandle`, `StatementHandle`, `Descriptor`, `HandleHeader`, `GetDataCursor`, `DataAtExecState`, and alloc/free (`pub(crate)`) |
+| `handles/registry.rs` | The live-handle table (`Registry`, `Slot`), per-connection `GroupLock`s, cancel tokens, and the loom models (`#[cfg(all(test, loom))] mod loom_tests`) |
+| `handles/scope.rs` | `HandleScope`: the only way to reach a handle's contents; token validation without dereferencing the application's pointer |
+| `sync.rs` | The one import path for every lock in the crate; aliases to `loom`'s primitives under `#[cfg(all(loom, test))]`, `std::sync` otherwise |
+| `utf16.rs` | `utf16_to_string`, `write_utf16` (ODBC uses UTF-16LE) |
+| `panic.rs` | `panic_safe` (locks the target's group, builds a `HandleScope`, catches panics), `panic_safe_unlocked` (`SQLCancel`'s lock-free sibling), and `catch_panic_as_error` (`SQLCopyDesc` phase one's panic-to-`OdbcError` guard) |
+| `logging.rs` | `init_logging()` via tracing, configured by `ODBC_LOG_LEVEL` / `ODBC_LOG_FILE` |
+| `function_id.rs` | `FunctionId` enum + `function_id_from_raw()` for `SQL_API_*` constants |
+| `test_support.rs` | `test-support`-feature-gated hooks a driver's test suite uses to put a connection into a handle without `SQLDriverConnectW` |
+| `ffi/handle.rs` | `sql_alloc_handle`, `sql_free_handle`, `sql_free_stmt` |
+| `ffi/env.rs` | `sql_set_env_attr`, `sql_get_env_attr` |
+| `ffi/connect.rs` | `sql_driver_connect_w`, `sql_browse_connect_w`, `sql_connect_w`, `sql_disconnect`, `sql_native_sql_w`; `merge_dsn_params` (DSN resolution) |
+| `ffi/connect_attr.rs` | `sql_set_connect_attr_w`, `sql_get_connect_attr_w` |
+| `ffi/diag.rs` | `sql_get_diag_rec_w`, `sql_get_diag_field_w` |
+| `ffi/cursor.rs` | `sql_num_result_cols`, `sql_row_count`, `sql_more_results`, `sql_close_cursor`, `sql_cancel`, `sql_get_cursor_name_w`, `sql_set_cursor_name_w`, `sql_bulk_operations`, `sql_set_pos` |
+| `ffi/execute.rs` | `sql_exec_direct_w`, `sql_prepare_w`, `sql_execute` |
+| `ffi/fetch.rs` | `sql_fetch`, `sql_fetch_scroll`, `sql_extended_fetch`, `sql_get_data` |
+| `ffi/metadata.rs` | `sql_describe_col_w`, `sql_col_attribute_w`, `sql_tables_w`, `sql_columns_w`, `sql_primary_keys_w`, `sql_foreign_keys_w`, `sql_statistics_w`, `sql_special_columns_w`, `sql_procedures_w`, `sql_procedure_columns_w`, `sql_column_privileges_w`, `sql_table_privileges_w` |
+| `ffi/params.rs` | `sql_bind_parameter`, `sql_num_params`, `sql_describe_param`, `sql_put_data`, `sql_param_data` |
+| `ffi/bind.rs` | `sql_bind_col` |
+| `ffi/desc.rs` | `sql_get_desc_field_w`, `sql_set_desc_field_w`, `sql_get_desc_rec_w`, `sql_set_desc_rec`, `sql_copy_desc`; argument marshalling over `descriptor.rs`'s tables |
+| `ffi/stmt_attr.rs` | `sql_set_stmt_attr_w`, `sql_get_stmt_attr_w` |
+| `ffi/info.rs` | `sql_get_info_w`, `sql_get_type_info`, `sql_get_functions` |
+| `ffi/tran.rs` | `sql_end_tran` |
+| `ffi/setup.rs` | `config_dsn_w` (ODBC installer entry point) |
+| `ffi/mod.rs` | `ffi` submodule declarations |
+| `forward_ffi.rs` | `forward_ffi!` macro: generates the C ABI entry points for a backend (the `SQL*` functions, plus `ConfigDSNW` on Windows) |
+| `test_utils.rs` | Shared test infrastructure (`MockBackend` and the purpose-built mocks listed under Testing) |
+
+### What a driver crate contains
+
+A driver built on core is typically laid out like this:
+
+| File | What it does |
+|------|-------------|
+| `backend.rs` | Struct definitions (`XyzBackend`, `XyzConnection`, `XyzStatement`), `connect`, `disconnect`, `end_tran`, the thin `impl Backend` delegation layer, and the central error-mapping function |
+| `backend/execute.rs` | `exec_direct`, `prepare`, `execute`; `impl StatementBackend for XyzStatement` |
+| `backend/metadata.rs` | `tables`, `columns`, `primary_keys`, `foreign_keys`; private query helpers |
+| `backend/info.rs` | `get_info`, `get_info_pre_connect`, `get_info_raw`, `get_functions`, `get_type_info` |
+| `backend/params.rs` | `bind_parameter`, `num_params`, `describe_param` (if the backend supports server-side parameters) |
+| `backend/types/connect_params.rs` | Driver-specific connection parameters parsed from the ODBC connection string |
+| `lib.rs` | Invokes `stackable_odbc_core::forward_ffi!(crate::backend::XyzBackend)`, which generates all the C ABI entry points |
+| `type_conversion.rs` | Converts backend-native column values to `ColumnValue` |
+| `escape_dialect.rs` | The backend's `EscapeDialect` for core's escape-sequence translator (identifier quoting, `{fn}` name mapping) |
+| `ffi_integration_tests.rs` | FFI-level integration tests that call the C ABI entry points directly |
+
+## Conventions
+
+- Edition 2024, resolver 3, Rust 1.95.0
+- `snafu` for errors (the `unwrap_used`, `unwrap_in_result` and `panic` clippy
+ lints are denied outside tests)
+- `tracing` for logging (not `println!` or `log`)
+- `#[repr(C)]` on all handle structs, for a defined, non-reordered layout on a
+ type that is heap-allocated via `Box::into_raw` and later reclaimed via
+ `Box::from_raw` at that same raw address. Handle validation never dereferences
+ these structs at all: it is a slot index and generation compare against the
+ registry, so no field's offset, including `HandleHeader`'s, is load-bearing.
+- `extern "system"` on all FFI exports (resolves to the correct ABI on both
+ Windows and Linux)
+- `odbc-sys` links against `libodbc`/`libodbcinst`, so building or testing needs
+ the unixODBC dev libraries installed (`unixodbc-dev` on Debian/Ubuntu). No DSN
+ or running Driver Manager is required. Miri is the exception, because it
+ interprets rather than links and so needs no system libraries.
+- **`#[cfg(windows)]` code is compilable from Linux, and should be compiled
+ before it is pushed.** A plain `cargo check` does not look at it at all, so
+ `ffi/setup.rs` and `ConfigDSNW` can be edited into a state that builds and
+ tests clean locally and fails on the Windows runner:
+
+ ```bash
+ rustup target add x86_64-pc-windows-msvc # once
+ cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings
+ ```
+
+ This links nothing and needs no Windows host, because `raw-dylib` resolves
+ `odbccp32` at link time and a `check`/`clippy` run never reaches it. It is not
+ a substitute for *running* the code, which only a Windows host with a Driver
+ Manager can do. It closes the compile-and-lint half, which is where the
+ regressions are.
+
+- **`bench/` and `fuzz/` are separate Cargo workspaces, so nothing at the repo
+ root compiles them.** Not `cargo test`, not `cargo clippy --all-targets`, and
+ not a single `pre-commit` hook. `bench/benches/handle_lookup.rs` contains a
+ full `impl Backend`, so **any change to the `Backend` or `StatementBackend`
+ trait breaks it silently**: every local check passes and CI's "Compile
+ benchmarks" step fails. After touching either trait:
+
+ ```bash
+ (cd bench && cargo build --benches)
+ (cd fuzz && cargo +nightly build --target x86_64-unknown-linux-gnu)
+ ```
+
+ `pre-commit run --all-files` covers everything *in the root workspace*, and
+ these two directories are outside it by design (see the Benchmarks and Fuzzing
+ sections for why). A detached workspace is invisible to exactly the checks you
+ would expect to catch it.
+
+### Changelog
+
+This project keeps a [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
+`CHANGELOG.md` and follows
+[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every user-facing
+change (public API, behaviour, spec-compliance fixes) gets an entry under the
+`## [Unreleased]` heading in the appropriate `Added` / `Changed` / `Fixed` /
+`Removed` group. Core is a published library consumed by driver crates, so
+treat any change to a public type, trait method, or exported FFI contract as
+user-facing.
+
+### Logging in FFI functions
+
+Every `pub unsafe fn` in `ffi/` must follow this structure:
+
+```rust
+// 1. If no parsing: single debug! at entry
+tracing::debug!("SQLFunctionW(handle={:?}, param={})", handle, param);
+
+// OR if parsing anything (raw integers to enums, or UTF-16 pointers to
+// strings):
+// 1a. TRACE: raw inputs before parse
+tracing::trace!("SQLFunctionW(handle={:?}, raw={})", handle, raw_int);
+// 1b. DEBUG: parsed/typed values after
+tracing::debug!("SQLFunctionW: attr={:?}", parsed_attr);
+// 1b. ...and for a function taking string arguments, name every one of them:
+tracing::debug!(
+ "SQLFunctionW(handle={:?}, catalog={:?}, schema={:?}, table={:?})",
+ handle, catalog, schema, table,
+);
+
+// 2. WARN: intentional spec deviations (silent accepts, ignored features)
+tracing::warn!("SQLFunctionW: accepting unrecognized X (DM compatibility)");
+
+// 3. DEBUG: return value, always; requires the
+// `let ret = unsafe { panic_safe(...) };` pattern
+tracing::debug!("SQLFunctionW -> {:?}", ret);
+```
+
+Rules: no passwords or connection string content; `error!` only for validation
+failures expressed via `OdbcError` (avoid double-logging); stubs use a single
+`debug!` entry, no exit log.
+
+**A string argument counts as parsed input.** The entry log knows only the
+handle, so a function that logs just that shows *which* call happened and not
+what it asked for, which is precisely what you need when a client's metadata
+query comes back empty. Every catalog function therefore logs its
+`parse_filter_param` results. This is the rule most easily missed when a stub
+becomes a real implementation, because the stub's single `debug!` looks like it
+already complies.
+
+### Named constants
+
+ODBC attribute values, function IDs, and bitmap constants must use named `const`
+definitions. Never write raw integer literals for ODBC-spec-defined values. Name
+them after the ODBC spec name (for example `SQL_AUTOCOMMIT_ON`,
+`SQL_CUR_USE_DRIVER`, `SQL2_FREE_CONNECT`).
+
+**This applies to tests too.** Test code is where raw literals creep back in
+most easily, usually with the spec name relegated to a trailing comment. A
+comment is not a constant:
+
+```rust
+// BAD: the value is unchecked and the name is only a comment
+sql_bind_parameter::(stmt, 1, 1 /* SQL_PARAM_INPUT */, ..., -5 /* SQL_BIGINT */, ...);
+
+// GOOD: the compiler validates both
+sql_bind_parameter::(stmt, 1, ParamType::Input as i16, ..., SqlDataType::EXT_BIG_INT.0, ...);
+```
+
+Prefer the `odbc-sys` type over defining a new constant when one exists, because
+most spec values are already modelled:
+
+| Value | Use |
+|-------|-----|
+| `SQL_PARAM_INPUT`, `SQL_PARAM_OUTPUT`, … | `ParamType::Input as i16` |
+| `SQL_BIGINT`, `SQL_VARCHAR`, `SQL_INTEGER`, … | `SqlDataType::EXT_BIG_INT.0` (note the `.0`) |
+| `SQL_C_SBIGINT`, `SQL_C_WCHAR`, … | `CDataType::SBigInt as i16` |
+| `SQL_ATTR_*` | `StatementAttribute::*` / `ConnectionAttribute::*` |
+| `SQL_HANDLE_*` | `HandleType::*` |
+
+All are re-exported from `stackable_odbc_core::types`. Only define a new `const`
+in `types/constants.rs` when `odbc-sys` genuinely lacks the value. Ordinals that
+are not spec constants (a parameter number, a column index) are fine as
+literals.
+
+### Type cast safety
+
+Use `T::try_from(x)` over bare `as T` when truncation is possible. For ODBC
+output parameters typed `*mut i16` (column counts, parameter counts), use
+`i16::try_from(n).unwrap_or_else(|_| { tracing::warn!(...); i16::MAX })`.
+
+## Adding a new ODBC function
+
+1. **Read the function's spec page.** Every function has one at
+ `https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/-function?view=sql-server-ver17`
+ (for example `sqlallochandle-function`).
+2. **Implement every check and constraint** from the spec:
+ - All parameter validation (null checks, valid handle types, valid attribute
+ values)
+ - All required error returns and SQLSTATEs listed in the spec's "Diagnostics"
+ table
+ - All state transition rules (for example "cannot call X before Y")
+ - Setting output parameters to defined values on error (for example
+ `*OutputHandlePtr = SQL_NULL_HANDLE`)
+ - If a spec requirement cannot be implemented, leave a `// TODO(spec):`
+ comment explaining why, and flag it to the user
+3. **Reference the spec URL** in the doc comment for the generic function in
+ `src/ffi/`:
+
+ ```rust
+ /// Generic implementation of SQLAllocHandle.
+ ///
+ /// Spec:
+ ```
+
+ The doc comment's SQLSTATE list is checked against the spec's own Diagnostics
+ table by `every_doc_comment_matches_the_spec_diagnostics_table`
+ (`src/types/diagnostics_table.rs`), so a new function needs its table
+ transcribed there before it will build. That module's docs give the four
+ verdict phrasings the guard recognises, and state the one thing it does not
+ check: whether the *reason* a row is not returned is true.
+
+4. **Implement the generic function** in `src/ffi/`, in the appropriate module.
+5. **Add a `Backend` (or `StatementBackend`) trait method** if the function
+ needs database-specific logic. Prefer a defaulted method so existing drivers
+ keep compiling.
+6. **Each driver implements the new trait method** in its own backend.
+7. **Add one entry to the `forward_ffi!` macro** in `src/forward_ffi.rs`, so all
+ drivers pick it up automatically.
+
+## Adding a new driver
+
+1. Create a new crate that depends on `stackable-odbc-core`.
+2. Implement `Backend` + `StatementBackend` for your backend type.
+3. In `lib.rs`, invoke
+ `stackable_odbc_core::forward_ffi!(crate::backend::YourBackend);`. No
+ `ffi.rs` is needed.
+
+### Backend error mapping
+
+Core never talks to a database; it only defines the trait boundary. A driver
+must route **every** error from its client library through a single central
+mapping function, never hand-building an `OdbcError` at the call site. That
+function is the one place that decides the SQLSTATE, so bypassing it silently
+degrades specific codes to `HY000`.
+
+Hand-built errors are correct only for *internal* invariant violations that
+never came from the client (a `get_data` before `fetch`, a missing runtime
+handle, a poisoned mutex), and for connection-setup failures where the call-site
+context is more useful than a mapped variant.
+
+### 08001 versus 08S01
+
+`08001` ("client unable to establish connection") is only valid from the
+connection functions. Once a connection exists, a failing link is `08S01`
+("communication link failure"), which is the code the diagnostics tables of
+`SQLExecute`, `SQLFetch`, `SQLGetInfo` and the rest actually list. A driver
+whose `connect` performs no network I/O only ever sees post-connection
+failures, so it maps them to `08S01`. A driver that opens a real connection in
+`connect` is where `08001` legitimately originates.
+
+### A SQLSTATE only the data source can determine
+
+Some states are the driver's to return by the spec's `(DM)` rules, yet core
+cannot produce them, because the fact they assert lives at the data source.
+`3D000` ("invalid catalog name") is the clearest case. `SQLSetConnectAttr`'s row
+carries no `(DM)` marker, but only the data source knows which catalogs exist,
+and the attribute's description has the driver *send* something to find out
+("the driver sends a **USE** *database* statement"). Core's part is threefold:
+name the state (`SqlState::invalid_catalog_name`), call the hook, and propagate
+what it returns unchanged. A backend that maps "no such catalog" to a generic
+`HY000` is the only reason an application would not see `3D000`.
+
+- **A "not returned by this driver" doc line is a claim about the whole path,
+ not about core's own code.** Core can be propagating a state whose only source
+ is the backend, so reading core's own code answers the wrong question.
+- **A pending connection attribute moves the SQLSTATE to a different function.**
+ `SQL_ATTR_CURRENT_CATALOG` and `SQL_ATTR_ACCESS_MODE` are settable either side
+ of a connection, and the spec says interoperable applications set them
+ *before*. Core therefore applies them during `SQLDriverConnectW`, so a hook
+ failure surfaces there, carrying a state that function's own diagnostics table
+ may not list (`3D000`, or `HYC00` from an unimplemented hook). Propagate it
+ rather than degrading it: a connection that failed because the catalog does
+ not exist should say so.
+
+### Prompting the user: core decides whether, the driver decides how
+
+A driver needing interactive authentication, an OAuth 2.0 external flow for
+instance, implements `prompt::Prompter` and returns it from the defaulted
+`Backend::prompter`. It reads it back inside its own `connect`, from
+`ConnectParams::prompter()`, and **never** by calling `Backend::prompter`
+directly: that method is ungated and says what the driver *could* do, not what
+this call is allowed to do.
+
+The gate is `SQLDriverConnect`'s *DriverCompletion*, and it lives in exactly one
+function, `prompter_for` in `ffi/connect.rs`.
+
+- **A withheld prompter is `None`, not an error.** Under `SQL_DRIVER_NOPROMPT`
+ the backend is simply handed nothing to call, so the spec's "do not prompt"
+ cannot be forgotten at a call site. A backend that finds `None` and needs a
+ prompt fails the connect the way the spec's own `SQL_DRIVER_NOPROMPT` clause
+ says: "otherwise, the driver returns SQL_ERROR."
+- **`SQLConnect` and `SQLBrowseConnect` have no such argument, and absence
+ permits prompting.** `SQLConnect` is the DSN path (`isql` and Excel), so those
+ are the likeliest interactive callers of the whole driver. Reading the missing
+ argument as `SQL_DRIVER_NOPROMPT` would lock DSN connections out of
+ interactive authentication, and no spec text asks for it.
+- **An unrecognised value is accepted.** `HY110` carries `(DM)` on *both* of its
+ clauses, so core adds no check, and the fallback is the most permissive
+ treatment rather than a driver-side error borrowed from a Driver-Manager row.
+
+Core ships no `Prompter` implementation and must not gain a dependency for one:
+every implementation it could offer needs a platform (a browser, a window
+system) that the database-independent half of a driver has no business
+choosing. A Windows dialog implementation would belong next to
+`SQLDriverConnectW`, but it needs its own design and its own dependency. The
+trait is shaped so it can arrive later without changing the backend-facing API.
+
+### The catalog functions: core owns the result set
+
+The ten catalog `Backend` methods (`tables`, `columns`, `primary_keys`,
+`foreign_keys`, `statistics`, `special_columns`, `procedures`,
+`procedure_columns`, `column_privileges`, `table_privileges`) return **typed
+row structs** (`TableRow`, `ColumnRow`, …), not a `Self::Statement`. Five
+consequences for a driver author:
+
+- **Return the rows in any order.** Core sorts each result set into the order
+ its spec page mandates (`SQLTables` by `TABLE_TYPE, TABLE_CAT, TABLE_SCHEM,
+ TABLE_NAME`, and so on), with NULL placement from `Backend::null_collation`.
+ A driver needs no `ORDER BY` for ODBC compliance, so one added purely for it
+ can be deleted.
+- **Core owns the column layout.** A backend fills named fields, so it cannot
+ get column order or count wrong, and a column added to a spec result set is a
+ core-only change. That is what `#[non_exhaustive]` on all ten row types buys.
+ It also rules out a struct expression outside core, including
+ `..Default::default()`, which Rust rejects cross-crate with `E0639`. Each type
+ therefore carries one consuming setter per column, generated from the same
+ field list by the `catalog_rows!` macro:
+
+ ```rust
+ let row = TableRow::default()
+ .catalog(catalog) // Option column takes a bare String
+ .name(name)
+ .table_type("TABLE"); // String column takes a &str
+ ```
+
+ Setters take `impl Into` and are named after their field, so adding a
+ column adds a setter and breaks nothing. There is deliberately no positional
+ `new(...)`: the widest row types run to well over a dozen columns, so an
+ argument list would reintroduce the ordering mistake named fields exist to
+ prevent.
+- **The `SQL_ALL_*` enumerations never reach these methods.** Core serves
+ `SQL_ALL_CATALOGS`, `SQL_ALL_SCHEMAS` and `SQL_ALL_TABLE_TYPES` from
+ `Backend::catalogs`, `Backend::schemas` and `Backend::table_types`, building
+ the all-but-one-column-NULL rows itself. The first two are only called when
+ `supports_catalogs`/`supports_schemas` already returned `true`.
+- **`SQL_ATTR_METADATA_ID` is core's job.** When it is `SQL_TRUE`, core has
+ already stripped delimiters, case-folded per `identifier_case` and escaped
+ `%`/`_` per `search_pattern_escape` before calling the backend, so these
+ methods always see ordinary pattern values. `SQLTables`' `TableType` is the
+ one exemption in the family, because the spec makes it a value list under both
+ settings. Core parses it, and `tables` reads it back from
+ `query.table_types()` rather than as a raw string.
+- **The arguments arrive as a typed query object.** Each hook takes a single
+ `&XxxQuery<'_>` (`TablesQuery`, `ForeignKeysQuery`, and so on) instead of five
+ to eight positional arguments, read through accessors:
+
+ ```rust
+ fn tables(
+ conn: &Self::Connection,
+ cancel: &Self::CancelToken,
+ query: &TablesQuery<'_>,
+ ) -> Result, Self::Error> {
+ let _ = (query.catalog(), query.schema(), query.table(), query.table_types());
+ todo!()
+ }
+ ```
+
+ These are sealed exactly as the row types are, so an argument added to a
+ catalog hook is a source-compatible change for every driver. They also name
+ the arguments: `SQLForeignKeys` takes six consecutive `Option<&str>`, where
+ swapping a primary-key argument for its foreign-key counterpart compiles
+ without complaint and `query.pk_table()` beside `query.fk_table()` cannot.
+
+ Eight are built from `Default` plus `with_*` setters. The other two take the
+ arguments that have no honest default through `new` instead:
+ `StatisticsQuery::new(unique_only)`, because `false` means `SQL_INDEX_ALL`
+ rather than "unspecified", and
+ `SpecialColumnsQuery::new(identifier_type, scope, nullable)`, because no
+ `Scope` or `IdentifierType` value is a defensible default and core does not
+ invent one.
+
+The last four (`procedures`, `procedure_columns`, `column_privileges`,
+`table_privileges`) are **defaulted to `Ok(Vec::new())`**, not to
+`NotImplemented` like `primary_keys` and its neighbours. A data source with no
+stored procedures, or no privilege metadata, genuinely has none to report, so an
+empty result set is the honest answer where an error would not be. Override one
+to report real rows.
+
+Their `HY009` handling is **not** uniform, and the difference is deliberate.
+All four return it for the spec's `SQL_ATTR_METADATA_ID` + null-`CatalogName` +
+catalogs-supported clause, which every one of the four pages states without a
+`(DM)` marker. Only `SQLColumnPrivileges` additionally rejects a null
+`TableName` unconditionally, because it is the only one of the four whose page
+carries that sentence unmarked. `SQLTablePrivileges`, `SQLProcedures` and
+`SQLProcedureColumns` must **not** check it. This mirrors the split among the
+first six, where `SQLStatistics` and `SQLSpecialColumns` check a null
+`TableName` and `SQLPrimaryKeys` and `SQLForeignKeys` do not. Tests pin both
+directions; do not "fix" any of it into consistency.
+
+### Capability methods are required, not defaulted
+
+Most of `Backend` is defaulted, so a driver implements only what it needs.
+These deliberately are not:
+
+| Method | States |
+|--------|--------|
+| `supports_catalogs` | whether the data source has ODBC catalogs |
+| `supports_schemas` | whether it has ODBC schemas |
+| `alter_table_support` | the `SQL_ALTER_TABLE` `SQL_AT_*` bitmask |
+| `outer_join_capabilities` | the `SQL_OJ_CAPABILITIES` `SQL_OJ_*` bitmask |
+| `default_txn_isolation` | `SQL_DEFAULT_TXN_ISOLATION` (`0` = no transactions) |
+| `txn_isolation_options` | `SQL_TXN_ISOLATION_OPTION` (`0` = no transactions) |
+| `group_by` | `SQL_GROUP_BY` (`0` = `GROUP BY` not supported) |
+| `null_collation` | `SQL_NULL_COLLATION` (`0` = `SQL_NC_HIGH`) |
+| `correlation_name` | `SQL_CORRELATION_NAME` (`0` = `SQL_CN_NONE`) |
+| `non_nullable_columns` | `SQL_NON_NULLABLE_COLUMNS` (`0` = `SQL_NNC_NULL`) |
+| `expressions_in_order_by` | `SQL_EXPRESSIONS_IN_ORDERBY` |
+| `identifier_case` | `SQL_IDENTIFIER_CASE` (`SQL_IC_*`); `0` is not a legal value |
+| `quoted_identifier_case` | `SQL_QUOTED_IDENTIFIER_CASE` (`SQL_IC_*`); independent of the unquoted rule |
+| `txn_capable` | `SQL_TXN_CAPABLE` (`SQL_TC_*`); `0` = `SQL_TC_NONE`, contradicting any declared isolation level |
+| `integrity` | `SQL_INTEGRITY`: whether the *data source* has the Integrity Enhancement Facility |
+| `multiple_active_txn` | `SQL_MULTIPLE_ACTIVE_TXN`: whether two transactions can be live at once |
+| `special_characters` | `SQL_SPECIAL_CHARACTERS`; an empty list is an answer, as with `keywords` |
+| `accessible_procedures` | `SQL_ACCESSIBLE_PROCEDURES`, the counterpart of `accessible_tables` |
+| `driver_name` / `driver_version` | `SQL_DRIVER_NAME` / `SQL_DRIVER_VER`; answered before a connection exists |
+| `dbms_name` / `dbms_version` | `SQL_DBMS_NAME` / `SQL_DBMS_VER`: what this connection reached |
+| `sql_conformance` | `SQL_SQL_CONFORMANCE` (`SQL_SC_*`) |
+| `timedate_add_intervals` | `SQL_TIMEDATE_ADD_INTERVALS` (`SQL_FN_TSI_*`) |
+| `timedate_diff_intervals` | `SQL_TIMEDATE_DIFF_INTERVALS` (`SQL_FN_TSI_*`) |
+| `subqueries` | `SQL_SUBQUERIES` (`SQL_SQ_*`) |
+| `column_alias` | `SQL_COLUMN_ALIAS` |
+| `concat_null_behavior` | `SQL_CONCAT_NULL_BEHAVIOR` (`0` = `SQL_CB_NULL`) |
+| `union_support` | `SQL_UNION` (`SQL_U_*`) |
+| `convert_functions` | `SQL_CONVERT_FUNCTIONS` (`SQL_FN_CVT_*`) |
+| `order_by_columns_in_select` | `SQL_ORDER_BY_COLUMNS_IN_SELECT` |
+| `accessible_tables` | `SQL_ACCESSIBLE_TABLES` |
+| `data_source_read_only` | `SQL_DATA_SOURCE_READ_ONLY` |
+| `search_pattern_escape` | `SQL_SEARCH_PATTERN_ESCAPE` |
+| `keywords` | the data source's own reserved words, *before* ODBC's are subtracted (`SQL_KEYWORDS`) |
+| `table_types` | the data source's table types, for `SQLTables`' `SQL_ALL_TABLE_TYPES` enumeration |
+
+Each states a **capability**, so any default core invents is a claim the backend
+author never made. It is also wrong silently: the author never sees the
+question, and the application never sees anything but a confident answer. The
+compiler asks instead.
+
+#### Attributes that reduce load at the data source are never emulated
+
+`SQL_ATTR_QUERY_TIMEOUT`, `SQL_ATTR_MAX_ROWS` and `SQL_ATTR_MAX_LENGTH` share
+one shape, in `offer_to_data_source` (`ffi/stmt_attr.rs`): offer the value to a
+defaulted `Backend` hook, store it if the backend accepts, substitute the
+spec's default with `01S02` if the hook is unimplemented, and propagate any
+*other* error as-is. Core emulates none of them, and the spec is explicit about
+why for two of the three: "a driver should not emulate SQL_ATTR_MAX_ROWS
+behavior", and `SQL_ATTR_MAX_LENGTH` "should be supported only when the data
+source (as opposed to the driver) ... can implement it". Each row states the
+purpose that makes emulation pointless, "this attribute is intended to reduce
+network traffic". Counting rows or bytes in the driver, after they have crossed
+the wire, achieves nothing the application asked for.
+
+`SQL_ATTR_QUERY_TIMEOUT` is the one with a core-side fallback, and it is opt-in
+rather than automatic: `Backend::set_query_timeout` returns a `QueryTimeout`,
+and only `CoreCancels` arms core's timer. Core cannot infer that. Every
+statement-producing `Backend` method is synchronous and blocks the calling
+thread, so `Backend::cancel` is the only lever, and whether a backend wired it
+up is not observable from Rust.
+
+**The timer is armed at `SQLFetch` too, not only at the statement-producing
+calls.** `SQL_ATTR_QUERY_TIMEOUT` bounds *returning the result set*, and a data
+source is free to answer with column metadata long before it has computed a row,
+so an execute-only timer can expire on nothing and bound nothing. `SQLFetch` and
+`SQLFetchScroll` both carry `HYT00` with **no `(DM)` marker**, naming this
+attribute directly.
+
+`SQLGetData` is the boundary, and the spec draws it: its diagnostics table
+carries `HYT01` and **no `HYT00` row at all**, so it is deliberately unarmed.
+The bound-column reads that run *inside* `SQLFetch` are a different thing and do
+fall under that call's deadline. `SQLFetchScroll` needs no site of its own,
+because every orientation but `SQL_FETCH_NEXT` is rejected with `HY106` and that
+one delegates to `sql_fetch`. Before arming a further site, check the function's
+own table for an `HYT00` row.
+
+Before adding a fourth attribute of this kind, check the spec row for a stated
+*purpose*. If the purpose is to reduce work at the data source, the answer is a
+hook plus the `01S02` fallback, not an implementation in core.
+
+#### Deciding whether a new info type belongs here
+
+The test is one question: **is zero "unknown", or is zero an answer?**
+
+- Zero means *unknown or no limit* → shared default in `default_get_info`.
+ `SQL_MAX_ROW_SIZE`, `SQL_MAX_INDEX_SIZE`, `SQL_MAX_STATEMENT_LEN` and the
+ `SQL_MAX_COLUMNS_IN_*` group are all of this kind: the spec explicitly
+ defines `0` as "no specified limit or the limit is unknown", so a shared `0`
+ asserts nothing.
+- Zero is a *substantive claim* → required `Backend` method. Every enum in the
+ table above has this shape. `SQL_NULL_COLLATION`'s zero is `SQL_NC_HIGH`,
+ `SQL_CORRELATION_NAME`'s is `SQL_CN_NONE` and `SQL_NON_NULLABLE_COLUMNS`'s is
+ `SQL_NNC_NULL`, each a specific, falsifiable statement about the data source
+ that core has no way to know.
+
+Two corollaries worth checking when adding an info type:
+
+- **A Y/N string has no valid empty value.** The shape-aware fallback in
+ `info_type_default_response` gives an unhandled `String`-shaped info type
+ `""`, which is the right *shape* but is not in any Y/N value list. Such a
+ type needs either a shared `"N"` arm in `default_get_info` or a hook.
+- **An empty *list* is an answer too.** `SQL_KEYWORDS` reads as an empty
+ string just like an unhandled `String`-shaped type, but it means "this data
+ source reserves nothing beyond ODBC", which applications act on when deciding
+ what to quote. It is a `Backend::keywords` hook for that reason; core owns
+ only the spec's subtraction of `ODBC_RESERVED_KEYWORDS`, which is the same for
+ every backend.
+- **Watch for info types that constrain each other.** `SQL_SQL_CONFORMANCE`
+ fixes the value of `SQL_GROUP_BY`, `SQL_CORRELATION_NAME`,
+ `SQL_NON_NULLABLE_COLUMNS`, `SQL_CONCAT_NULL_BEHAVIOR`, `SQL_SUBQUERIES`
+ and `SQL_COLUMN_ALIAS`, because the spec names what an entry-level driver
+ returns for each of those six. `SQL_TIMEDATE_FUNCTIONS` claiming
+ `SQL_FN_TD_TIMESTAMPADD` obliges `SQL_TIMEDATE_ADD_INTERVALS` to be non-zero,
+ and `SQL_CATALOG_NAME` drives the whole catalog group. Core supplying one side
+ of such a pair while the backend supplies the other is how it ends up
+ contradicting itself.
+- **Prefer deriving over adding a hook when the fact is already declared.**
+ `SQL_IDENTIFIER_QUOTE_CHAR` comes from `EscapeDialect::identifier_quotes`
+ and `SQL_CURSOR_COMMIT_BEHAVIOR` from `Backend::cursor_commit_behavior`,
+ because a second way to state the same fact is a second way to state it
+ *differently*. Check whether an existing hook already answers the question
+ before adding one.
+
+#### The rule is enforced by a test, not by review
+
+`default_get_info_answers_are_backend_derived_or_declared_core_facts`
+(`src/backend.rs`) asks one question of every info type: **does the answer move
+when the backend does?** It evaluates `default_get_info` for two mock backends
+that share no capability declaration. An info type answering identically for
+both is one core decided, so it must appear in that test's `CORE_FACTS` list
+with the reason core is entitled to decide it. Three reasons qualify: a fact
+about core's own implementation (its fetch really is forward-only, its `Backend`
+trait really is synchronous), a limit where the spec defines `0` as "no limit or
+unknown", or driver-level identity with no per-backend answer.
+
+Adding a hard-coded claim to `default_get_info` therefore fails a test that
+names the info type. If you cannot write a `CORE_FACTS` reason that is about
+*core* rather than about the data source, the value belongs on a `Backend`
+method.
+
+`supports_catalogs` and `supports_schemas` between them drive seven info types
+(`SQL_CATALOG_NAME`, `SQL_CATALOG_TERM`, `SQL_CATALOG_NAME_SEPARATOR`,
+`SQL_CATALOG_LOCATION`, `SQL_CATALOG_USAGE`, `SQL_SCHEMA_TERM`,
+`SQL_SCHEMA_USAGE`), which the `SQLGetInfo` spec defines in terms of that one
+fact. Note the asymmetry: core answers the whole group when the answer is *no*,
+because the spec mandates the empty string or zero. When the answer is *yes*, it
+returns `None` for `SQL_CATALOG_LOCATION`, `SQL_CATALOG_USAGE` and
+`SQL_SCHEMA_USAGE` rather than inventing a value, so a driver with catalogs
+answers those three itself.
+
+`Backend::set_txn_isolation` stays defaulted, and the default is only correct
+for a data source with exactly one isolation level. A backend declaring more
+than one bit in `txn_isolation_options` **must** override it, or
+`SQLSetConnectAttr(SQL_ATTR_TXN_ISOLATION)` reports `NotImplemented` rather
+than accepting a level it cannot apply.
+
+### Windows Driver Manager compatibility checklist
+
+The Windows DM is much stricter than unixODBC. These items are **required** for
+a driver to work on Windows, because omitting any one can cause silent crashes,
+`IM001` errors, or blocked `SQLGetData` calls:
+
+- **The pre-connect info group is core's job, not a checklist item.** The
+ Windows DM queries `SQL_DRIVER_ODBC_VER` (77) *before* `SQLDriverConnectW`,
+ and on `SQL_ERROR` treats the driver as ODBC 2.x and blocks 3.x features like
+ `SQL_C_SBIGINT`. Core answers the whole group without a connection:
+ `SQL_DRIVER_NAME` and `SQL_DRIVER_VER` from the required
+ `Backend::driver_name` and `Backend::driver_version`, and
+ `SQL_DRIVER_ODBC_VER`, `SQL_ASYNC_DBC_FUNCTIONS` and
+ `SQL_MAX_CONCURRENT_ACTIVITIES` from facts about itself. Declaring the two
+ hooks is all a driver does. Overriding `get_info_pre_connect` is only for a
+ *further* info type it can answer before connecting, which is rare.
+
+- **`get_functions`**: List **every** exported FFI function, not just
+ query-related ones, and **nothing core does not export**. The Windows DM uses
+ the 3.x bitmap (`func_id=999`) to build its dispatch table, so a missing entry
+ (`SetEnvAttr`, `GetStmtAttr`, `BindCol`) gives it a null function pointer to
+ call. Build the list from `CORE_EXPORTED_FUNCTIONS` and it cannot drift in
+ either direction.
+
+ The 2.x array (`func_id=0`) is a **different question with a different
+ answer**. It asks "can an ODBC 2.x application call this", so it reports the
+ deprecated functions as supported even though core exports almost none of
+ them, because the Driver Manager's mapping is what makes that true.
+ `stackable-odbc-core` derives those entries from their 3.x counterparts
+ automatically. An entry there naming a `FunctionId` absent from
+ `CORE_EXPORTED_FUNCTIONS` is correct and deliberate, not an oversight;
+ psqlODBC ships the same combination (`pfExists[SQL_API_SQLERROR] = TRUE`
+ beside a commented-out `;;SQLError` in its `.def`).
+
+- **`get_type_info`**: Include **both** ANSI and Unicode type variants.
+ pyodbc queries `SQLGetTypeInfo(SQL_VARCHAR=12)` and
+ `SQLGetTypeInfo(SQL_CHAR=1)`. If only `SQL_WVARCHAR` (-9) and `SQL_WCHAR`
+ (-8) are returned, pyodbc cannot perform type conversions and `SQLGetData`
+ fails for numeric types.
+
+- **`SQL_GETDATA_EXTENSIONS`**: Report exactly what the shared
+ `stackable-odbc-core` fetch/bind implementation supports; do not reflexively
+ return `0x0F`. `SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND` (`0x0B`)
+ is correct for a forward-only driver, because `sql_get_data`
+ (`src/ffi/fetch.rs`) never checks column order or binding state, so any
+ column, in any order, bound or not, can be read via `SQLGetData`.
+ `SQL_GD_BLOCK` must **not** be included unless the driver implements block
+ cursors: `SQLSetStmtAttrW` (`src/ffi/stmt_attr.rs`) rejects any
+ `SQL_ATTR_ROW_ARRAY_SIZE` other than 1 (substituting 1 back with `01S02`), so
+ a driver that inherits that behaviour can never produce a multi-row rowset for
+ `SQL_GD_BLOCK` to describe.
+
+- **Unknown `SQLGetInfoW` info types**: `stackable-odbc-core` returns `U32(0)`
+ for unknown info types, because returning `SQL_ERROR` corrupts the DM's
+ internal state. For the genuine per-source-type `SQL_CONVERT_*` info types it
+ returns `0xFFFFFFFF` ("all conversions supported"), because returning 0 causes
+ the DM to block `SQLGetData` with `HYC00`.
+
+ That set is **53–71, 122–126 and 173**, not the contiguous 48–73 the numbering
+ suggests. The gap matters: 48 is `SQL_CONVERT_FUNCTIONS`, a bitmask of whether
+ `CAST`/`CONVERT` syntax is supported at all, and 49–52 are the
+ numeric/string/system/timedate scalar-function bitmaps. Answering "all
+ supported" for those claims scalar functions the backend may not have, which
+ is how a BI tool comes to emit `{fn SOUNDEX(x)}` against a data source that
+ rejects it. `info_type_default_response` classifies them individually against
+ `sqlext.h` for exactly this reason.
+
+#### A 3.x driver does not export the deprecated 2.x functions
+
+Appendix G, "Mapping Deprecated Functions": a 3.x driver "does not have to
+implement the ODBC 2.x functions", and the mapping "is triggered when the driver
+is an ODBC 3.x driver and **the driver does not support the function that is
+being mapped**."
+
+So exporting one does not *add* a capability, it **removes the Driver
+Manager's**, which is usually better informed. unixODBC's `SQLSetScrollOptions`
+mapping checks the requested concurrency against the driver's own `SQLGetInfo`
+answers before setting anything, where a driver-side export is a bare
+`SQL_ERROR` that replaces all of it. `SQLError`'s mapping routes to
+`SQLGetDiagRec`, which core implements properly, where an export would answer
+`SQL_NO_DATA` and leave an ODBC 2.x application with no diagnostics at all.
+
+Core therefore exports no function whose ODBC 2.x call the Driver Manager maps,
+and psqlODBC comments out every one of them in its `.def`. Two exports sit near
+that line:
+
+- **`SQLFreeStmt` is an ODBC 3.x function in its own right.** Appendix G covers
+ only its deprecated `SQL_DROP` option, which the Windows DM passes through
+ rather than mapping, so core exports the function.
+- **`SQLExtendedFetch` is deprecated but unmapped.** Appendix G's table does not
+ list it, so exporting it displaces no Driver Manager capability, and an ODBC
+ 2.x application reaches a real implementation only if the driver provides one.
+
+Before implementing any deprecated entry point, check
+`CORE_UNEXPORTED_FUNCTIONS`: each entry records which 3.x function the DM maps
+it to. "We export it, so we should make it work" is backwards whenever the
+Driver Manager already maps the function.
+
+## Descriptors
+
+A statement owns four descriptors, the ARD, APD, IRD and IPD, and ODBC makes
+them the *definition* of a binding rather than a copy of one. `SQLBindCol`'s
+page: "when `SQLBindCol` is called, the driver sets fields in the ARD." So
+there is one storage, not a binding map beside a descriptor:
+
+| Descriptor | Reached by | Records | What they are |
+|---|---|---|---|
+| ARD | `desc_of(stmt, Ard)` | `DescriptorRecord` | what `SQLBindCol` set |
+| APD | `desc_of(stmt, Apd)` | `DescriptorRecord` | `SQLBindParameter`'s C-side buffer |
+| IPD | `desc_of(stmt, Ipd)` | `DescriptorRecord` | `SQLBindParameter`'s declared SQL type |
+| IRD | `desc_of(stmt, Ird)` | none stored | computed from `ColumnDescriptor` on read |
+
+Each is its own registered allocation rather than a field of the statement, and
+the two application descriptors may be replaced by one the application
+allocated; see "Reaching a descriptor" below.
+
+`Descriptor` carries a `role: DescriptorRole` rather than a type parameter,
+because ODBC has one record shape and four *readings* of it. `SQLSetDescField`
+accepts any field identifier against any descriptor and decides validity from
+the role.
+
+- **`SQLBindParameter` writes two descriptors.** The C-side fields are an APD
+ record and the declared type is an IPD record, under the same key, removed
+ together. One record spanning both is what makes `SQLSetDescField`
+ unimplementable. Readers take `ParamRecord<'_>`, a borrowed view of both
+ halves, from `ParamRecords::get`.
+- **The IRD is a computed view, never stored state.** `SQLGetDescField` and
+ `SQLGetDescRec` on the IRD delegate to `col_attr::get_column_attribute`,
+ which is also `SQLColAttributeW`'s implementation. The two are spellings of
+ one question, and answering them from two places is how they come to differ.
+ A read before the statement has produced column metadata is `HY007`; the
+ spec: "Until the IRD has been populated, any attempt to gain access to a
+ field of an IRD will return an error." A write is `HY016`, except the two
+ header fields that row exempts by name.
+- **A binding is a non-null `SQL_DESC_DATA_PTR`, not a present key**, but that
+ answers "is there a data buffer", not "is there a binding". A record exists
+ as soon as any one field is set, so key presence answers neither question,
+ and every site needing the first calls `DescriptorRecord::is_bound`. The
+ second has *two* pointers in it: the spec lets `SQLBindCol` unbind a column's
+ data buffer while keeping its length/indicator buffer ("An application can
+ unbind the data buffer for a column but still have a length/indicator buffer
+ bound for the column"). So `collect_bindings` admits a record carrying either
+ pointer, and skips only a record carrying neither. The mature drivers split
+ on this, MySQL Connector/ODBC keeping such a record and psqlODBC clearing the
+ whole binding, and core follows the spec sentence, which is unconditional.
+ The visible half of getting this wrong is the *indicator*:
+ `write_column_value` declines to write through a null target but writes the
+ length indicator unconditionally, which is exactly what makes the
+ indicator-only binding work and exactly what makes a stray record visible.
+- **`set_concise_type` is the only writer of the type trio.** Setting
+ `SQL_DESC_CONCISE_TYPE` also sets `SQL_DESC_TYPE` and
+ `SQL_DESC_DATETIME_INTERVAL_CODE`, and the subcode is **not** the concise
+ type: `SQL_TYPE_DATE` is 91 while `SQL_CODE_DATE` is 1. `col_attr` holds both
+ mappings (`verbose_type` and `datetime_interval_subcode`) so the descriptor
+ and `SQLColAttribute` cannot disagree about one column.
+- **Eight statement attributes are descriptor header fields**, per
+ `SQLSetStmtAttr`'s own mapping table, which says setting one sets the other.
+ `HeaderOwner::of` names them, and `HandleScope::attr_get`/`attr_set` is the
+ only way to reach an attribute's storage. `descriptor::header_attribute` is
+ the same table read in the other direction, for `SQLGetDescField`. The four
+ IRD- and IPD-side pairs (`SQL_ATTR_ROW_STATUS_PTR`,
+ `SQL_ATTR_ROWS_FETCHED_PTR`, `SQL_ATTR_PARAM_STATUS_PTR`,
+ `SQL_ATTR_PARAMS_PROCESSED_PTR`) live on `stmt.attrs`, and `attr_get` routes
+ them there so no caller needs to know.
+ **The storage is keyed by the `SQL_DESC_*` field, not by the attribute**,
+ because the mapping is not one-to-one: `SQL_DESC_ARRAY_SIZE` is
+ `SQL_ATTR_ROW_ARRAY_SIZE` on an ARD and `SQL_ATTR_PARAMSET_SIZE` on an APD.
+ One explicit descriptor may also be the ARD of one statement and the APD of
+ another, so two keys for one field would be two values for one field.
+- **`odbc-sys` misspells one of the eight.** `SQL_ATTR_PARAM_OPERATION_PTR` is
+ `StatementAttribute::ParamOpterationPtr`, transposed letters, upstream. A
+ grep for the correct spelling finds nothing and reads as "core does not
+ implement it", which is false.
+
+### The consistency check runs at all four sites
+
+`descriptor::consistency_check` returns `HY021`, and `SQLSetDescRec`'s own
+"Consistency Checks" section says when it runs: "This check is always performed
+when **SQLBindParameter** or **SQLBindCol** is called or when **SQLSetDescRec**
+is called for an APD, ARD, or IPD", plus `SQLSetDescField` when it sets
+`SQL_DESC_DATA_PTR`.
+
+**`SQLBindCol` and `SQLBindParameter` both run the check, so either can return
+`HY021`.** Each function's doc comment lists all five of the spec's clauses and
+states which core reduces and why.
+
+**Clause 5 is checked.** It reads "if `SQL_DESC_CONCISE_TYPE` is an interval
+type, `SQL_DESC_DATETIME_INTERVAL_PRECISION` is a valid interval leading
+precision". The *C to SQL: Numeric* table's interval row reads that field, so
+core enforces it: a leading precision is a digit count and cannot be negative,
+while zero passes and means the application declared none. Reading zero as
+"unspecified" rather than as a literal limit is the same reading
+`check_declared_decimal_size` gives a zero `ColumnSize`, and the conversion
+relies on it. The clauses about interval *seconds* precision remain reduced.
+
+A value core cannot honour must be refused identically through both doors:
+`SQL_DESC_ARRAY_SIZE` set through `SQLSetDescField` routes through the same
+`01S02` substitution `SQLSetStmtAttr(SQL_ATTR_ROW_ARRAY_SIZE)` applies, because
+they are one value.
+
+### Reaching a descriptor
+
+**Every descriptor is its own registered allocation.** A statement holds four
+tokens, not four `Box` fields, plus two `Option` overrides for the
+application descriptors. An explicit descriptor is parented to the
+**connection** and an implicit one to its statement. All of them join the
+connection's lock group, the one every statement on it already shares, so a
+descriptor adds no lock and no ordering rule.
+
+- **`Descriptor` has a `HasKind` impl.** A token names exactly one descriptor,
+ and the struct at that address carries its own `role`, so `HandleScope::get`
+ needs nothing the registry cannot check.
+- **`HandleScope::stmt_with_desc` is sound**, on the same footing as
+ `stmt_with_parent`: the statement holds opaque tokens the compiler cannot
+ follow, and a `Descriptor` holds no back-pointer, so neither is reachable
+ from the other. `stmt_with_parent_and_params` is the three-way form of the
+ same argument, for the calls that need a statement, its connection and both
+ parameter descriptors at once.
+- **`Drop` does not reclaim them.** `free_statement_allocation` frees the four
+ explicitly, `SQLFreeHandle` frees an explicit one, and `SQLDisconnect` frees
+ any left on the connection. Miri's leak check is what enforces all three.
+
+`HandleScope::desc_of` is the single door onto descriptor storage: it applies
+the override, so no call site can read the implicit descriptor while the
+application believes its own is in use. A site that already resolved the
+statement should copy `descriptor_token(role)` out and use
+`HandleScope::descriptor` instead, because going through `desc_of` there
+resolves the statement a second time, which `handle_lookup` measures.
+
+A `Descriptor` is never reached by casting an address, only through the
+registry, as every other handle kind is.
+
+### The explicit-descriptor rulings, and why
+
+Four questions a future reader would otherwise relitigate:
+
+- **`HY024` is core's; `HY017` is not.** `SQLSetStmtAttr`'s `HY024` row states
+ the cross-connection descriptor case verbatim and closes with the general
+ rule that makes it core's: "For all other connection and statement
+ attributes, the driver must verify the value specified in *ValuePtr*".
+ `HY017` is `(DM)` on *both* of its clauses, so core adds neither check. The
+ second clause's "other than the handle originally allocated" implies the
+ original *is* allowed, so it is accepted. The check core makes compares the
+ parent **chain**, so a descriptor of this connection and one of this
+ connection's statements both pass.
+- **`SQLFreeHandle` answers `HY000` on the ownership branch, never `HY017`.**
+ It routes by parentage rather than by alloc type: this function allocated the
+ descriptors whose parent is a connection and only those. Retiring a
+ statement's own slot would leave that statement pointing at nothing. The
+ refusal is ownership, not a spec check, and borrows no `(DM)` code to say so;
+ the same function already answers `HY000` for an unimplemented handle type,
+ whose table lists no `HYC00` either. A token that is not a descriptor at all
+ is `SQL_INVALID_HANDLE`, which is a different question.
+- **`SQLCopyDesc` never holds two group locks.** The spec permits a copy across
+ connections and even across environments, so source and target may be in two
+ groups. Phase one takes the source's group through `HandleScope::with_group`
+ and materialises an owned `DescriptorSnapshot`; that function's return type
+ carries no guard, so the release before phase two is structural rather than
+ remembered. Phase two is an ordinary `panic_safe` on the target, which is
+ where every diagnostic belongs, including the `HY007` phase one decided.
+ `opposite_direction_copies_cannot_deadlock` models it, and
+ `the_set_of_group_lock_acquisition_sites_is_closed` records the site.
+- **A shared descriptor means shared bindings.** Two statements pointed at one
+ explicit ARD have one binding set between them, so `SQLFreeStmt(SQL_UNBIND)`
+ on either clears both. That is spec-correct, since the spec makes the
+ descriptor *be* the binding, and it has a test rather than a workaround.
+
+Two smaller ones. `SQL_DESC_ALLOC_TYPE` follows the allocation, and is the one
+field `SQLCopyDesc` never copies. `DescriptorSnapshot` carries neither it nor
+the *source's* role, because the consistency check runs under the **target's**
+role, and a snapshot that remembered where it came from would invite a check
+against the wrong one.
+
+### What descriptors support
+
+All five descriptor functions are implemented and reported by `SQLGetFunctions`:
+`SQLGetDescFieldW`, `SQLSetDescFieldW`, `SQLGetDescRecW`, `SQLSetDescRec` and
+`SQLCopyDesc`. `SQLAllocHandle(SQL_HANDLE_DESC)` and
+`SQLFreeHandle(SQL_HANDLE_DESC)` work, an application descriptor can be swapped
+in through `SQL_ATTR_APP_ROW_DESC` / `SQL_ATTR_APP_PARAM_DESC`, and one
+descriptor may be shared across statements on a connection.
+
+`DescriptorRole` has a fifth variant, `App`, for an explicitly allocated
+descriptor whose role is not yet known. The spec: "it is not known whether an
+explicitly allocated application descriptor is an APD or ARD until execute
+time". `field_access(App, f)` is defined as the ARD's cell, and
+`the_ard_and_apd_field_tables_agree_everywhere` is what makes that a derived
+fact rather than a fourth hand transcription.
+
+**`SQL_OIC_CORE` is satisfied.** Core-level conformance requires allocating and
+freeing all handle types and manipulating descriptor fields through all five
+functions, which is what the above closes.
+
+Still out of scope, deliberately: bookmark records (record 0), and automatic
+population of the IPD. `SQL_ATTR_AUTO_IPD` stays `SQL_FALSE`, so the five
+footnote-[1] fields stay `Undefined` on the IPD.
+
+## Concurrency: the lock discipline
+
+Handle contents are internally synchronised, per connection, not left to the
+Driver Manager. `SQLAllocHandle`'s Comments section requires this: "Drivers
+must therefore support safe, multithread access to this information." The
+mechanism:
+
+- **A lock group is per connection, shared with every statement and descriptor
+ allocated on it.** One acquisition therefore covers a call that touches a
+ statement and its parent connection, so there is no ordering between the two
+ to get wrong. Groups are `GroupLock` (`src/handles/registry.rs`), and which
+ group a token belongs to is derived from the registry rather than stored on
+ the handle.
+- **`HandleScope` is the only way to reach a handle's contents.** `panic_safe`
+ (`src/panic.rs`) locks the target's group before constructing the scope and
+ ties the scope's lifetime to that lock. So "the group lock is held" is a fact
+ the borrow checker enforces rather than a rule a comment states. Every other
+ caller of `HandleScope::new` does the same, and `handles/scope.rs`'s module
+ doc is the authoritative list of them.
+- **A `Backend` method must never re-enter a `SQLxxx` entry point on the same
+ connection.** Every `Backend` method, including `connect`, runs while
+ `panic_safe` holds that connection's group lock, and the lock is not
+ reentrant. Calling back in, directly or through an application callback,
+ deadlocks the calling thread with no diagnostic and no `SqlReturn`, because
+ the thread never returns far enough to produce either. `Backend::cancel` is
+ the one exception, covered below.
+- **The one lock-ordering rule is environment before connection**, and
+ `SQLEndTran(SQL_HANDLE_ENV)` is its only site: it holds the environment's
+ group while walking that environment's connections via
+ `HandleScope::with_child_group`. Do not acquire a connection's group first
+ and then reach for its environment's; nothing else in the crate nests two
+ groups at all.
+- **`SQLCancel` is deliberately exempt from taking the group lock.** It may run
+ on a thread other than the one executing on the target statement. Taking that
+ statement's group lock unconditionally would make cancelling a query wait for
+ the query it was asked to cancel. Instead it clones the statement's
+ cancel token out of the registry, then attempts the group with `try_lock`. On
+ the branch where another thread holds the group, cancel signals the backend's
+ `CancelToken` and returns, touching no handle state and posting no
+ diagnostic, per the spec's carve-out for a function running on another
+ thread.
+
+ A `CancelToken` carries the crate's one bounded exception to "core never
+ touches a backend's state concurrently", so it has two obligations. It must
+ be built eagerly, with the connection's real parameters in hand, at first use
+ rather than lazily inside `cancel`; see `Backend::cancel_token`'s doc comment
+ for the MariaDB ODBC-401 failure this rule prevents. And if it aliases the
+ connection rather than standing alone, it must keep its target alive through
+ an `Arc`, because core clones the token out before doing anything else and it
+ must survive a concurrent `SQLDisconnect`.
+
+ Two consequences follow from running the cross-thread branch lock-free. A
+ `SQLGetDiagRecW` or `SQLGetDiagFieldW` immediately after such a cancel
+ **blocks** until the cancelled call has unwound through the backend, because
+ both take the connection's group and reading the diagnostic queue while
+ another thread pushes to it is undefined behaviour. `SQLCancel` itself still
+ returns promptly; the wait moves to whichever call reads diagnostics next.
+ Separately, `try_lock` cannot tell "a sibling statement on this connection is
+ busy" from "my own statement is busy". Either pushes `SQLCancel` onto the
+ cross-thread branch, so a merely idle statement's data-at-execution state is
+ occasionally left uncleared where it strictly could have been cleared, which
+ is harmless and explicitly spec-legal.
+- **A cancelled call reports `HY008`, and the token is minted per execution.**
+ `Backend::cancel` signals and `Backend::is_cancelled` observes, and they are a
+ pair. A backend implementing the first and not the second still cancels the
+ work, but the application sees whatever SQLSTATE the driver's error mapping
+ produced instead of "operation canceled". Core asks `is_cancelled` **only
+ after a backend call returned an error**, because the spec permits a
+ cancelled execution to finish anyway ("it is possible for the execution to
+ succeed and return SQL_SUCCESS while the cancel is also successful"), so `Ok`
+ is never reclassified. The single implementation is `crate::cancel`.
+
+ `mint_cancel_token` builds a **new** token at every statement-producing call,
+ and the cursor-consuming calls read that execution's token rather than
+ minting one. One token per statement would leave a cancelled statement
+ permanently unusable, because `Backend::cancel` marks the token and the next
+ execution would reuse it. The spec requires the opposite: "After the
+ statement has been canceled, the application can call SQLExecute or
+ SQLExecDirect again." Keeping a stale token as a guard buys nothing either,
+ because "a call to SQLCancel when no processing is being done on the
+ statement ... has is [sic] no effect at all."
+- **`SQLCancel` is not the only cross-thread caller of `Backend::cancel`.**
+ `src/query_timer.rs` enforces `SQL_ATTR_QUERY_TIMEOUT` for a backend that
+ answered `QueryTimeout::CoreCancels`, and it does so the only way a
+ synchronous trait allows: a timer thread calls `Backend::cancel` while the
+ calling thread is still blocked inside the backend. It holds **no lock**, the
+ same footing as `SQLCancel`'s cross-thread branch, so the rule that a
+ `cancel` implementation must never block on this connection's lock covers it
+ unchanged. It clones the token out of the registry for the same reason too,
+ because the token has to survive a statement freed while a timer is still
+ armed.
+
+ A timed-out call reports **`HYT00`**, not `HY008`. Both arrive through a
+ signalled token, so the ordering in `QueryTimer::check` is load-bearing: the
+ cancel pass runs first and would label it `HY008`, and the timeout pass runs
+ second so the more specific state wins. An application that set a deadline is
+ waiting to tell "my deadline passed" from "another thread cancelled me".
+
+ **The attribution outlives the call that made it**, and has to. A deadline
+ that expires as the backend call is returning cancels the token and leaves
+ the call successful, so the failure it causes surfaces on a *later* call
+ whose own timer never fired. `cancel::CancelState` is therefore what the
+ registry stores: the backend's token plus core's `timed_out` flag, in one
+ allocation. The flag is minted per execution and survives a freed statement
+ exactly as the token does. `QueryTimer::reclassify` reads it, which confines
+ `HYT00` to the entry points that hold a timer.
+
+ `cancel::reclassify_cancelled` deliberately does not read it. The entry
+ points that reach it without a timer, `SQLGetData`, `SQLDescribeParam`,
+ `SQLDescribeCol` and `SQLColAttribute`, have no `HYT00` row between them, so
+ those keep `HY008` on a timed-out cursor. Do not shorten that to "the
+ timer-holding entry points are exactly the ones with an `HYT00` row":
+ `SQLParamData` holds a timer and relabels, and its own table has no such row.
+ It inherits one from the sentence after its table ("it can return any
+ SQLSTATE that can be returned by the function called to execute the
+ statement"), which is the same grant its doc comment records other inherited
+ states under. Check a function's table *and* its surrounding prose before
+ deciding either way.
+- **Every lock in the crate is imported from `src/sync.rs`**, never directly
+ from `std::sync`, so that building a test with `--cfg loom` swaps every one
+ of them for loom's instrumented equivalent. A lock imported around that
+ module would be invisible to loom and silently opt its code out of the
+ interleaving proof.
+
+ **Two exceptions, each stated at its site and in `sync.rs`.**
+ `query_timer.rs` takes its `Condvar` and `Mutex` from `std::sync`, because
+ loom's `Condvar` cannot model a timeout at all; `logging.rs` takes its
+ `Mutex` from `std::sync`, because `tracing_subscriber` implements
+ `MakeWriter` for that type specifically. `src/sync.rs`'s own comment carries
+ the detail. The rule being enforced is "no lock silently opts itself out", so
+ a stated exception does not break it and a quiet one would. Before adding a
+ third, check whether loom can model the primitive at all: if it can, import
+ it from `sync.rs`.
+
+**Loom models** the primitives this discipline is built from, `Registry`,
+`GroupLock`, and the crate's own nested-lock path
+(`HandleScope::with_child_group_in`), in `src/handles/registry.rs`'s
+`#[cfg(all(test, loom))] mod loom_tests`. It does not model the FFI entry
+points above them, since `registry()` panics outside an active `loom::model`
+and cannot be called from inside one either. loom replays the same closure many
+times to explore interleavings, while a `static` only runs its initializer
+once.
+
+**`with_child_group` has an `_in` variant taking a `&Registry` so a model can
+call the real function.** A model that can only reach a function by
+re-implementing what it does proves a property of the test rather than of the
+crate. Before accepting "the model cannot reach this", check whether a
+`&Registry` parameter is all that stands in the way.
+
+Run them with:
+
+```bash
+RUSTFLAGS="--cfg loom" cargo test --lib loom_tests
+```
+
+The `loom_tests` filter is required: every other unit test in the crate also
+compiles under `--cfg loom` once it is set, and calls the process-wide
+registry outside a model, which panics as soon as `Registry::new` resolves to
+loom's `RwLock`. If a model runs long, lower `LOOM_MAX_PREEMPTIONS` before
+simplifying the model itself, because a smaller bound still proves more than no
+model. CI sets it in `.github/workflows/build.yaml`.
+
+## Testing
+
+### Unit tests
+
+Run `cargo test`. It must produce zero warnings.
+
+- **Mocks live in `test_utils.rs`.** `MockBackend` is the shared default:
+ connect and disconnect succeed, everything else returns `NotImplemented`.
+ Purpose-built mocks cover the paths it cannot reach, among them
+ `MockAltBackend`, `MockNoCatalogBackend`, `MockTypeInfoBackend`,
+ `MockFunctionsBackend`, `MockFailingCloseBackend` and the
+ `mock_isolation_backend!` / `mock_txn_backend!` families. Grep `struct Mock`
+ and `mock_` in that file for the current set.
+- **`MockAltBackend` declares a different value for every capability method**,
+ so a guard test can watch an answer move with the backend rather than passing
+ against a constant.
+- **A mock returning an empty slice makes a loop run zero times**, so
+ `MockTypeInfoBackend` and `MockFunctionsBackend` declare real rows and a real
+ function list rather than letting a test pass vacuously.
+- **Array fetch and batch parameter paths** (`SQL_ATTR_ROW_ARRAY_SIZE`,
+ `SQL_ATTR_ROWS_FETCHED_PTR`, `SQL_ATTR_PARAMSET_SIZE`) are covered by direct
+ C ABI calls with pre-allocated column and parameter buffers, which Rust
+ handles cleanly without any external dependencies.
+- **A driver crate tests its own `Backend` impl**, and adds FFI-level
+ integration tests that call the generated C ABI entry points. Those live in
+ the driver's repository, not here.
+
+### Miri (undefined behaviour and leaks)
+
+`stackable-odbc-core` is checked by Miri on every PR (the `miri` job in
+`.github/workflows/build.yaml`). Run it locally the same way:
+
+```bash
+rustup +nightly component add miri
+MIRIFLAGS="-Zmiri-disable-isolation" \
+ cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptest
+```
+
+Runtimes move with every commit, so take them from the run rather than from
+here. The CI job budgets 30 minutes and has come close to spending it, so when
+a run slows down put `-Z unstable-options --report-time` after the `--`: the
+per-test breakdown names the test responsible.
+
+- **Nightly only.** Miri cannot run on the pinned stable toolchain.
+- **Pure Rust.** All the raw-pointer marshalling lives in
+ `stackable-odbc-core`, so it is where the undefined-behaviour risk lives and
+ where Miri earns its keep.
+- **Proptests are skipped**, because they take hours under Miri. They run on
+ stable instead.
+- **A test whose cost is algorithmic rather than memory-safety-related gets
+ `#[cfg_attr(miri, ignore = "…")]`.** Miri's slowdown turns a large input into
+ minutes or hours, against a CI budget of 30. The precedent is
+ `escape::tests::pathological_nesting_returns_an_error_rather_than_killing_the_process`,
+ and skipping it loses nothing: `src/escape.rs` contains no `unsafe` for Miri
+ to check, and the neighbouring `MAX_ESCAPE_DEPTH ± 1` tests cover the limit
+ on both recursion paths. Before adding a big-input test, ask whether the code
+ under it is `unsafe` at all; if not, Miri is not the tool that should be
+ paying for it.
+
+ **A big input does not have to look big.** The guards in
+ `types/diagnostics_table.rs` take no parameters and read no files at runtime,
+ so nothing about them reads as expensive, yet each scans the FFI source that
+ module `include_str!`s and Miri interprets that byte by byte. Together they
+ are the largest single cost in the run, in a module containing no `unsafe`
+ whatsoever. Watch for `include_str!`, a full-`u16`-space scan, or any other
+ input baked in at compile time rather than passed in, because the native
+ runtime will not warn you: Miri multiplies the ratio, not the absolute time.
+ Every guard there carries `#[cfg_attr(miri, ignore = …)]`, and a further
+ scanner added to that module needs one too.
+- **Leak reporting is deliberately left on.** It is what catches a handle or
+ descriptor allocation that a teardown path forgets to free. A test that
+ allocates handles must free them, or the job goes red.
+- **A run after any source change rebuilds the crate under Miri first**, and
+ that rebuild dominates. Budget for it before assuming a run has hung.
+- **`-Zmiri-disable-isolation` is required, not optional.**
+ `column_value::current_utc_date` reads the wall clock, which the
+ `SQL_TYPE_TIME` to `SQL_C_TYPE_TIMESTAMP` conversion needs ("the date fields
+ of the timestamp structure are set to the current date"). Without the flag
+ Miri refuses `SystemTime::now` as an unsupported operation and the test
+ aborts rather than failing an assertion, which reads like a Miri bug rather
+ than a missing flag.
+
+### Alignment: what Miri does and does not catch
+
+Every access through an application-supplied pointer must be `read_unaligned` /
+`write_unaligned`, a byte-wise copy, or an element-wise loop. ODBC applications
+using row-wise binding pass pointers at arbitrary offsets into a packed buffer,
+so alignment is never guaranteed. Four operations carry an alignment
+requirement, and all four have been the source of real bugs here:
+
+| Operation | Requirement |
+|-----------|-------------|
+| `*ptr = v` / `*ptr`, including `*(p as *mut T) = v` | aligned for `T` |
+| `slice::from_raw_parts(_mut)` | aligned for the element type; **UB on construction, before anything is read** |
+| `ptr::copy_nonoverlapping` | *both* pointers aligned for `T`; cast to `*mut u8` to avoid it |
+| `&*(p as *const T)` | aligned for `T` |
+
+`u8` pointers are exempt, because `u8` has alignment 1.
+
+**Grep for the operation, not for `*ptr`.** A deref of a cast
+(`*(diag_info as *mut i32) = v`) and a multi-line `unsafe` block both evade the
+obvious pattern, and `from_raw_parts` looks nothing like a deref at all.
+
+**A misaligned access is only *sometimes* observable on x86-64.** With
+`debug-assertions` on, four of the five shapes abort the process: the standard
+library's precondition check fires and raises a **non-unwinding** panic, which
+`panic_safe`'s `catch_unwind` cannot contain.
+
+| Shape | Detected by `cargo test` (debug) |
+|---|---|
+| `*ptr = v` / `*ptr`, including through a cast | yes, abort |
+| `slice::from_raw_parts(_mut)` | yes, on construction |
+| `ptr::copy_nonoverlapping` | yes |
+| `&*(p as *const T)` | yes |
+| **`ptr::write` / `ptr::read`** | **no, silently succeeds** |
+
+`ptr::write` is precisely the aligned sibling of the `write_unaligned` this
+crate uses everywhere, so **the one regression the misalignment tests exist to
+catch is the one a debug build does not catch**. Catching it needs
+`-Zmiri-symbolic-alignment-check`. With `debug-assertions` off, nothing in the
+table is detected at all, and in release it usually just works, until it does
+not.
+
+**`-Zmiri-symbolic-alignment-check` is a manual tool, deliberately not in CI.**
+Plain Miri checks alignment against the concrete address the allocator
+returned, so a test can pass by luck: offsetting `+1` into a `Vec` is not
+reliably misaligned, because a byte allocation has alignment 1 and may already
+start on an odd address. The symbolic check ignores the concrete address and
+catches the class regardless. It is slow enough not to be worth a per-PR job,
+so run it by hand when touching pointer marshalling:
+
+```bash
+MIRIFLAGS="-Zmiri-disable-isolation -Zmiri-symbolic-alignment-check" \
+ cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptest
+```
+
+To write a test that is misaligned on every platform, offset one byte into an
+allocation of the *target* type, not into a byte buffer:
+
+```rust
+let mut arena = vec![0u16; 16];
+let ptr = unsafe { arena.as_mut_ptr().cast::().add(1) }.cast::();
+```
+
+### Fuzzing
+
+`fuzz/` holds [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) targets for
+the memory-marshalling hot paths, `write_column_value` and `utf16`. Each
+allocates its output buffer at exactly the caller-declared length, so
+AddressSanitizer catches any overrun that clippy cannot see. It is its own
+Cargo workspace, because libFuzzer needs nightly, so the root build ignores it.
+A short smoke run of both targets runs on every PR (the `fuzz` job in
+`build.yaml`).
+
+```bash
+cargo install cargo-fuzz
+cargo +nightly fuzz run utf16
+cargo +nightly fuzz run column_value
+```
+
+See [`fuzz/README.md`](fuzz/README.md) for what is and is not worth fuzzing.
+
+### Benchmarks
+
+Core has three Criterion benchmarks, all in `bench/`:
+
+```bash
+cd bench && cargo bench
+```
+
+| Benchmark | What it drives | What it cannot see |
+|---|---|---|
+| `fetch_throughput` | `SyntheticStatement::fetch()` / `get_data()` directly, in memory, with no backend | the FFI layer at all: no `panic_safe`, no registry lookup, no descriptor, no `write_column_value` |
+| `handle_lookup` | the FFI entry points with no result set open, so `HandleScope::get` in isolation | binding, fetch and data marshalling, because `BenchBackend::exec_direct` is never called |
+| `ffi_fetch` | the FFI entry points against a real result set, with `panic_safe`, the registry, the ARD and `write_column_value` on the measured path | nothing the other two reach; a connection is installed through the `test-support` feature's `attach_connection` |
+
+- **`fetch_throughput` measures `SyntheticStatement`'s own row-cloning and
+ `ColumnValue` construction cost**, not the marshalling into an application's
+ buffer, which is `ffi_fetch`'s job. `BENCH_ROWS` overrides the row count.
+- **`handle_lookup` has two shapes**, because the error path is not the success
+ path scaled: `get` (one `HandleScope::get`, then trivial work) and
+ `get_then_push_diagnostic`, where `panic_safe` also has to find the handle
+ again.
+- **`ffi_fetch` has two groups.** `ffi_fetch_bound` binds three columns with
+ `SQLBindCol` (one `i64`, one 1 KiB string, one 1 KiB bytes) and loops
+ `SQLFetch` over `BENCH_ROWS` rows. `ffi_get_data_chunked` fetches one row and
+ then drains a 64 KiB string column through a 512-byte `SQLGetData` buffer
+ until `SQL_NO_DATA`, exercising the `GetDataCursor` chunking loop that a
+ bound column never reaches.
+
+Pick the one that can actually see what you changed. A registry or locking
+change is invisible to `fetch_throughput`, a `ColumnValue` conversion inside
+`SyntheticStatement` is invisible to `handle_lookup`, and anything in
+`write_column_value`, the ARD or the `SQLGetData` chunking cursor is invisible
+to both. If nothing covers it, add a fourth rather than quote a number from the
+wrong one.
+
+`bench/` is a detached Cargo workspace so that `criterion` stays out of core's
+dependency graph and `cargo package` does not warn about a `[[bench]]` target
+excluded from the published crate. The directory is singular because cargo
+auto-discovers `benches/` as a target directory and then insists on validating
+any manifest inside it, which fails packaging.
+
+## odbc-sys usage
+
+`odbc-sys` is a minimal `-sys` crate for ODBC type definitions. It has no
+convenience methods by design (see
+[PR #47](https://github.com/pacman82/odbc-sys/pull/47) for the rationale), so
+`stackable-odbc-core` is the driver-side convenience layer on top of it.
+
+- **Always use `odbc-sys` types** where they exist: `HandleType`, `SqlReturn`,
+ `CDataType`, `SqlDataType`, `InfoType`, `Desc`, `FreeStmtOption`,
+ `AttrOdbcVersion`, `EnvironmentAttribute`, `Len`, `Pointer`, `WChar`, and so
+ on. For primitive parameters where odbc-sys 0.29 removed the type aliases
+ (the old `SmallInt`, for instance), use the Rust primitives directly (`i16`,
+ `u16`, `i32`).
+- **Never redefine** enums, structs, or constants that `odbc-sys` already
+ provides. Check `odbc-sys` before defining a new constant or enum, and use
+ what is there.
+- **Add driver-side extensions** in `stackable-odbc-core`. Orphan rules prevent
+ `impl TryFrom for odbc_sys::HandleType`, so use standalone conversion
+ functions such as `fn handle_type_from_raw(v: i16) -> Option`.
+- **Keep our own types** only for things `odbc-sys` does not have:
+ `ConnectParams`, `ColumnValue`, `ColumnDescriptor`, `FetchResult`,
+ `InfoValue`, `SqlState`, `DiagnosticQueue`, `TypeInfoRow`, `OdbcError`.
+- **ODBC function IDs** (`SQL_API_*` values) are not in `odbc-sys`. They live in
+ `src/function_id.rs` as the `FunctionId` enum, sourced from
+ `/usr/include/sql.h` and `sqlext.h`. Always use `FunctionId::ExecDirect` and
+ its siblings, never raw numeric IDs, and convert with
+ `function_id_from_raw(u16) -> Option`.
+
+## Converting raw values to strongly typed enums
+
+Raw integers from the ODBC C ABI must be converted to strongly typed Rust enums
+**as early as possible**: at the FFI boundary, before any logic runs.
+
+```rust
+// GOOD: fallible conversion, handles unknown values gracefully
+let field = desc_from_raw(field_identifier).ok_or_else(|| {
+ OdbcError::general(
+ format!("Unknown descriptor field: {field_identifier}"),
+ SqlState::optional_feature_not_implemented(),
+ )
+})?;
+tracing::debug!("SQLColAttributeW(col={}, field={:?})", col, field);
+
+// BAD: transmute on arbitrary u16 is UB if the value isn't a valid enum variant
+let field: Desc = std::mem::transmute(field_identifier); // DON'T DO THIS
+
+// BAD: passing raw u16 through multiple layers before converting
+fn do_work(field_id: u16) { ... } // loses type safety and readable logging
+```
+
+Available conversion functions (all in `src/types/conversions.rs` unless noted):
+
+- `handle_type_from_raw(i16) -> Option`
+- `desc_from_raw(u16) -> Option`
+- `info_type_from_raw(u16) -> Option`
+- `c_data_type_from_raw(i16) -> Option`
+- `param_type_from_raw(i16) -> Option`
+- `environment_attribute_from_raw(i32) -> Option`
+- `attr_odbc_version_from_raw(i32) -> Option`
+- `free_stmt_option_from_raw(u16) -> Option`
+- `statement_attribute_from_raw(i32) -> Option`
+- `completion_type_from_raw(i16) -> Option`
+- `fetch_orientation_from_raw(i16) -> Option`
+- `identifier_type_from_raw(u16) -> Option`
+- `nullable_from_raw(u16) -> Option`
+- `scope_from_raw(u16) -> Option`
+- `bulk_operation_from_raw(i16) -> Option`
+- `interval_from_raw(i16) -> Option`
+- `declared_odbc_version_from_raw(i32) -> Option`
+- `driver_connect_option_from_raw(u16) -> Option`
+- `function_id_from_raw(u16) -> Option` (in `function_id.rs`)
+
+If `odbc-sys` adds a new enum that we need to convert from raw values, add an
+`xxx_from_raw` function following the same pattern. Do not use `transmute`.
+
+`SQLSetPos`'s `Operation` and `LockType` are the one documented exception. An
+`odbc-sys` type is usable here only if the raw ABI value can be recovered from
+it. `odbc_sys::Operation` and `odbc_sys::Lock` are newtype structs over a
+**private** `i16`, with no accessor, no `From`, and no `#[repr]` enum to cast
+through. A converted value can therefore be compared against their associated
+constants and used for nothing else, and no caller or test can name a valid
+input. Those two validate against `SQL_POSITION` and `SQL_LOCK_*` in
+`types/constants.rs`, which is the exception that block's comment records.
+Before adding a conversion, check that the target type can round-trip. If it
+cannot, a named constant is the correct answer rather than a worse conversion.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..32ff2dc
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,139 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+First release, so this section describes what the crate offers rather than what
+changed.
+
+### Added
+
+**The framework.** The database-independent half of an ODBC 3.80 driver, at the
+`SQL_OIC_CORE` conformance level, on Linux and Windows. Implement the `Backend`
+and `StatementBackend` traits and invoke `forward_ffi!` once, and the crate
+generates every C entry point the standard requires. Core carries no
+database-specific code, so a driver never has to fork or patch it.
+
+**A handle is a ticket, not an address.** A `SQLHANDLE` is a slot index plus a
+generation counter, looked up in a driver-owned table rather than dereferenced.
+Freeing bumps that slot's generation, so every token still naming it stops
+matching, and use after free becomes a clean `SQL_INVALID_HANDLE` rather than
+memory corruption.
+
+**Two threads can share one connection.** Each connection owns a lock group that
+its statements and descriptors join, so a call touching a statement and its
+connection takes a single lock and no ordering rule is left to get wrong.
+`SQLCancel` takes no lock at all, because cancelling a slow query must not wait
+for the query it is cancelling.
+
+**Connections and transactions.** The three connect functions parse the
+connection string, resolve a `DSN=` key against `odbc.ini` with explicit values
+winning, and hand `Backend::connect` a typed `ConnectParams`. Attributes set
+before connecting reach that call, because the spec calls setting them early the
+interoperable choice. `SQLEndTran` commits or rolls back and applies the cursor
+behaviour the backend declares. A driver needing an interactive login implements
+`prompt::Prompter`, and core decides whether prompting is permitted from
+`SQLDriverConnect`'s DriverCompletion argument.
+
+**Statements and result sets.** Direct and prepared execution, row counts,
+multiple result sets, cursor names, and `SQLFetch` over bound columns or
+`SQLGetData` chunked through a buffer of any size. The ODBC escape sequences
+`{fn}`, `{d}`, `{t}`, `{ts}`, `{oj}` and `{escape}` are translated by a shared
+scanner driven by a per-backend `EscapeDialect`, which can rewrite a whole
+scalar-function call rather than only its name, so a function whose argument
+syntax differs from ODBC's stays translatable. `SQLDescribeParam` answers from
+`Backend::describe_param` where a backend can ask the data source, so a client
+sizing its buffers from the answer is not left guessing at `VARCHAR`.
+
+**Value conversion is done for you.** `SQLBindParameter` records the C-side
+buffer in the APD and the declared SQL type in the IPD, and core converts
+between them with all three of the spec's C-to-SQL tables, character, binary
+and numeric. The other direction covers the C types the standard defines, and
+reports truncation as `01004`, an out-of-range value as `22003` and dropped
+precision as `01S07`. Every access through an application pointer is unaligned,
+because row-wise binding hands out pointers at arbitrary offsets into a packed
+buffer.
+
+**Catalog metadata.** The ten catalog functions are implemented with core owning
+the result set: a backend returns typed row structs with named fields, and core
+puts the columns in spec order, sorts the rows as each page mandates and places
+NULLs per `Backend::null_collation`. `SQL_ATTR_METADATA_ID` normalisation and
+the `SQL_ALL_*` enumerations are core's too, so a driver writes no code for
+either and cannot get a column order wrong.
+
+**Descriptors.** All five descriptor functions work over the four descriptors a
+statement owns, an application can install its own through
+`SQL_ATTR_APP_ROW_DESC` or `SQL_ATTR_APP_PARAM_DESC`, and one descriptor can be
+shared across statements on a connection. ODBC makes a descriptor *be* the
+binding, so a binding assembled through `SQLSetDescField` fetches exactly like
+one made with `SQLBindCol`.
+
+**Diagnostics.** Every handle carries its own queue, read through
+`SQLGetDiagRec` and `SQLGetDiagField`, and a backend error keeps its native
+error code and its causal chain rather than a flattened message string. Each FFI
+function's doc comment lists every SQLSTATE in its spec diagnostics table with a
+verdict, and guard tests check those lists against the transcribed tables, so a
+missing or invented state fails the build.
+
+**Cancellation and query timeouts.** `SQLCancel` on another thread signals the
+backend's cancel token and returns without waiting, and a call stopped that way
+reports `HY008` rather than whatever the driver's error mapping produced. A
+fresh token is minted per execution, so a cancelled statement is usable again.
+`SQL_ATTR_QUERY_TIMEOUT` goes to the data source first, and a backend that can
+only be cancelled gets a core-side timer reporting `HYT00`, armed at `SQLFetch`
+as well as at execution, because a data source may answer with column metadata
+long before it has a row.
+
+**Capability reporting.** `SQLGetInfo` answers from required `Backend` methods
+wherever the answer is a claim about the data source, and from core only where
+the fact is core's own or the spec defines zero as "unknown". A guard test
+evaluates the defaults against two backends sharing no declaration and fails on
+any answer that does not move, so a hard-coded claim about somebody else's
+database cannot slip in.
+
+**Windows is a first-class target.** The info group its Driver Manager queries
+before `SQLDriverConnect` is answered without a connection, the function bitmap
+is built from the exported-function list so the two cannot drift apart, and an
+unknown info type gets a Driver-Manager-safe value rather than the `SQL_ERROR`
+that corrupts its internal state. `ConfigDSNW` is exported for the ODBC
+Administrator, with a hook so a driver's own dialog supplies the keywords.
+
+**A conformance harness drivers can run.** The `test-support` feature exposes
+the `conformance` module, so a driver's own test suite can drive core's shared
+`SQLGetInfo` checks against its real backend and catch an info type whose value
+contradicts another one it declared.
+
+**Checked by more than unit tests.** Miri runs the pointer marshalling in an
+interpreter that detects undefined behaviour and leaked handles, loom explores
+the thread interleavings a test run happens not to produce, and cargo-fuzz
+throws random input at the buffer copying under AddressSanitizer. Every
+`extern "system"` entry point catches unwinds, because a panic crossing the C
+boundary is itself undefined behaviour.
+
+### Known limitations
+
+- Results are read front to back only (`SQL_SO_FORWARD_ONLY`). `SQLFetchScroll`
+ takes `SQL_FETCH_NEXT` and rejects every other orientation with `HY106`.
+- No block cursors and no parameter arrays. `SQL_ATTR_ROW_ARRAY_SIZE` and
+ `SQL_ATTR_PARAMSET_SIZE` are fixed at 1, and asking for more returns 1 with an
+ `01S02` warning, so `SQL_GD_BLOCK` is never reported.
+- No bookmarks and no positioned updates: `SQLSetPos` and `SQLBulkOperations`
+ validate their arguments and then report `HYC00`.
+- `SQL_ATTR_AUTO_IPD` is `SQL_FALSE`, so parameter metadata is never populated
+ automatically. Bind the parameter or set the IPD fields yourself.
+- No async. `Backend` is synchronous, so a driver over an async client library
+ bridges internally, for example with a current-thread tokio runtime.
+- The deprecated ODBC 2.x functions are not exported, because the Driver Manager
+ already maps them onto the modern ones and usually does it better.
+ `SQLExtendedFetch` is the exception, since it is not mapped.
+- `SQL_ATTR_MAX_ROWS` and `SQL_ATTR_MAX_LENGTH` are offered to the data source
+ and otherwise capped to 0 with `01S02`, never emulated, because counting rows
+ in the driver after they have crossed the wire saves nothing.
+- Core ships no `Prompter` implementation, because any one it could offer needs
+ a browser or a window system a database-independent crate cannot choose.
+
+[Unreleased]: https://github.com/stackabletech/stackable-odbc-core/commits/HEAD
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b97e75c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,60 @@
+# Project Rules
+
+Read and follow @AGENTS.md. It holds the architecture, the patterns and the
+procedures.
+
+## Non-Negotiable Rules
+
+- **ODBC spec compliance is mandatory.** Read the spec page for every function
+ you implement, modify or audit. Never claim a SQLSTATE is missing or wrong
+ without checking the actual spec diagnostics table first. Pay attention to
+ **(DM)** annotations: those SQLSTATEs are returned by the Driver Manager
+ rather than the driver, so do not add driver-side checks for them. Every FFI
+ function's doc comment must list all SQLSTATEs from the spec diagnostics
+ table, and for each one note whether the driver returns it or why it does not,
+ for example "(driver-manager-handled; not returned here)". When you touch a
+ function, verify its doc comment is complete and accurate against the spec.
+ See AGENTS.md "Adding a new ODBC function" for the full checklist.
+- **When the spec is silent or ambiguous, check a mature driver before
+ deciding.** Some pages state a behaviour without a diagnostics table, describe
+ a Driver-Manager fallback without saying what the driver should do, or leave a
+ `(DM)` marker off exactly one clause of a row. Read what the established
+ drivers do. psqlODBC, MySQL Connector/ODBC, FreeTDS and unixODBC's own Driver
+ Manager are open source, and the commercial ones (Amazon Redshift, Simba,
+ DataDirect) publish supported-function tables. Prefer their source over their
+ documentation, and prefer both over inference from the spec's silence. Record
+ what you found and cite it in the doc comment, so the next reader inherits the
+ evidence rather than the conclusion. **If the drivers disagree with each
+ other, or with the spec, ask rather than picking one.**
+- **Use `odbc-sys` types.** Never redefine an enum, struct or constant it
+ already provides.
+- **Convert raw integers to typed enums at the FFI boundary**, using the
+ `xxx_from_raw()` functions. Never `transmute`.
+- **Run `pre-commit run --all-files`** before every commit. It is the single
+ source of truth for what must pass.
+
+## Scope
+
+- Do not modify files outside the scope of the current task.
+- Do not add features, refactoring or improvements beyond what was asked.
+- If unsure whether something is in scope, ask.
+
+## Data Retrieval
+
+Never read entire files by default. Survey, locate, then extract.
+
+1. **Survey first.** Check file size before reading (`stat -c%s file`). Files
+ over 50 KB must be sliced rather than read whole.
+2. **Navigate definitions with ctags.** Run `ctags -R .` once to build a tags
+ index, then `grep "^SymbolName" tags` for the exact file and line of any
+ function, struct or trait, with no file reading at all.
+3. **Locate with Grep.** Find patterns, keywords or usages before reading. Use
+ `-C` for context lines.
+4. **Extract with Read (offset + limit).** Once you know the line range, read
+ only that slice.
+5. **Structured data.** Use `jq` for JSON and `yq` for YAML. Never read raw
+ markup whole.
+6. **Filesystem survey.** Use `tree -L 2 -I '.git|target|node_modules'` rather
+ than a recursive `ls`.
+7. **Verify edits with diff.** After editing, run `git diff -u` to confirm the
+ change instead of re-reading the file.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..bb0659a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,149 @@
+# Contributing
+
+Thanks for considering a contribution. Reports of a spec rule this crate gets
+wrong, a SQLSTATE it should return and does not, or an application that will not
+work against a driver built on it are all useful.
+
+- **Questions and ideas:** [GitHub Discussions](https://github.com/orgs/stackabletech/discussions)
+ or [Discord](https://discord.gg/7kZ3BNnCAF).
+- **Bugs:** open an issue. Please say which platform, which Driver Manager
+ (unixODBC or the Windows one), which driver built on this crate, and which
+ application. A driver log helps most of all: set `ODBC_LOG_FILE` and
+ `ODBC_LOG_LEVEL=debug` and attach the result, with any passwords removed.
+- **Security problems:** do not open an issue. See [SECURITY.md](SECURITY.md).
+
+## Building
+
+You need the unixODBC development libraries, because `odbc-sys` links against
+them. You do not need a DSN or a running Driver Manager to build or to run the
+tests.
+
+```bash
+sudo apt-get install unixodbc-dev # Debian/Ubuntu
+```
+
+```bash
+git clone https://github.com/stackabletech/stackable-odbc-core
+cd stackable-odbc-core
+cargo build
+```
+
+The toolchain version is pinned in `rust-toolchain.toml`, so rustup fetches the
+right one on the first build.
+
+This crate is a library. To see it working end to end you need a driver on top
+of it, and
+[stackable-odbc-sqlite](https://github.com/stackabletech/stackable-odbc-sqlite)
+is the smallest one.
+
+### Windows code, from Linux
+
+`#[cfg(windows)]` code compiles from Linux, and should be compiled before it is
+pushed. A plain `cargo check` does not look at it at all, so `ffi/setup.rs` and
+`ConfigDSNW` can reach a state that builds and tests clean locally and fails on
+the Windows runner.
+
+```bash
+rustup target add x86_64-pc-windows-msvc # once
+cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings
+```
+
+This links nothing and needs no Windows host, because `raw-dylib` resolves
+`odbccp32` at link time and a `clippy` run never reaches it. It is not a
+substitute for running the code, which needs a Windows host with a Driver
+Manager, but it closes the compile-and-lint half where the regressions actually
+happen.
+
+## Testing
+
+```bash
+cargo test # unit and FFI tests
+cargo clippy --all-targets -- -D warnings
+```
+
+`cargo test` must produce zero warnings.
+
+All the raw-pointer marshalling lives in this crate, so three further tools run
+on every pull request and are worth running by hand when you touch that code.
+
+```bash
+# undefined behaviour and leaked handles
+MIRIFLAGS="-Zmiri-disable-isolation" \
+ cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptest
+
+# every thread interleaving of the locking code, not just the one that occurred
+RUSTFLAGS="--cfg loom" cargo test --lib loom_tests
+
+# random input at the buffer-copying paths, under AddressSanitizer
+cargo install cargo-fuzz
+cargo +nightly fuzz run utf16
+cargo +nightly fuzz run column_value
+```
+
+`bench/` and `fuzz/` are **separate Cargo workspaces**, so nothing at the repo
+root compiles them. Not `cargo test`, not `cargo clippy --all-targets`, and not
+a single `pre-commit` hook. `bench/benches/handle_lookup.rs` contains a full
+`impl Backend`, so any change to the `Backend` or `StatementBackend` trait
+breaks it while every local check still passes and CI fails. After touching
+either trait:
+
+```bash
+(cd bench && cargo build --benches)
+(cd fuzz && cargo +nightly build --target x86_64-unknown-linux-gnu)
+```
+
+## Before you commit
+
+```bash
+pre-commit run --all-files
+```
+
+That is the gate, and the single source of truth for what must pass. It runs
+rustfmt, clippy, `cargo test`, `cargo doc` with warnings denied so a broken
+intra-doc link fails the commit, `cargo sort`, `cargo deny`, markdownlint and a
+secret scan.
+
+Two of those are not cargo built-ins, so install them once:
+
+```bash
+cargo install cargo-deny cargo-sort
+```
+
+A change usually needs one more thing: **a changelog entry**, under
+`## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md), if a driver author or an
+ODBC application can observe the difference. Because this crate is a library
+that driver crates build on, treat any change to a public type, a trait method,
+or an exported FFI contract as observable.
+
+## Where things live
+
+[`AGENTS.md`](AGENTS.md) is the working reference: the architecture, how a call
+flows through the layers, the descriptor and catalog rules, the lock discipline,
+and the spec evidence behind each design decision. Read the section covering
+whatever you are about to change. It is written for AI coding agents and human
+contributors alike.
+
+Four rules are worth stating here, because they are the ones a reasonable-looking
+change breaks most easily.
+
+- **Read the ODBC spec page for every function you touch.** Every FFI function's
+ doc comment lists each SQLSTATE from that function's spec diagnostics table
+ and says whether this crate returns it, or why not. A guard test in
+ `src/types/diagnostics_table.rs` checks the doc comments against a
+ transcription of those tables, so an incomplete one fails the build.
+- **Watch the `(DM)` annotations.** A SQLSTATE marked that way is the Driver
+ Manager's to return, not the driver's, so adding a driver-side check for one
+ is wrong even though it looks like extra safety.
+- **Convert raw integers to typed enums at the FFI boundary**, using the
+ `xxx_from_raw` functions in `src/types/conversions.rs`. Never `transmute`: an
+ arbitrary integer is not necessarily a valid enum variant, and transmuting one
+ is undefined behaviour.
+- **Import every lock from `src/sync.rs`**, never from `std::sync` directly. That
+ is what lets a `--cfg loom` build swap them for loom's instrumented versions.
+ A lock imported around that module is invisible to loom and silently opts its
+ code out of the interleaving proof.
+
+## License
+
+By contributing you agree that your contribution is licensed under
+[Apache-2.0](LICENSE).
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..716f2ce
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,739 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "cc"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[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 = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "generator"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "log",
+ "rustversion",
+ "windows-link",
+ "windows-result",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[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 = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "loom"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[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 = "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-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "odbc-sys"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "245cb4fe8236df4fd352ba96075d754233c6509d654d9f1c1482158b7d6c083d"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[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 = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[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 = "proptest"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+dependencies = [
+ "bit-set",
+ "bit-vec",
+ "bitflags",
+ "num-traits",
+ "rand",
+ "rand_chacha",
+ "rand_xorshift",
+ "regex-syntax",
+ "rusty-fork",
+ "tempfile",
+ "unarray",
+]
+
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
+[[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 = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+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 = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "rusty-fork"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
+dependencies = [
+ "fnv",
+ "quick-error",
+ "tempfile",
+ "wait-timeout",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[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.3",
+]
+
+[[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 = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "snafu"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7"
+dependencies = [
+ "snafu-derive",
+]
+
+[[package]]
+name = "snafu-derive"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "stackable-odbc-core"
+version = "0.0.1"
+dependencies = [
+ "loom",
+ "odbc-sys",
+ "proptest",
+ "snafu",
+ "tracing",
+ "tracing-appender",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "symlink"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+
+[[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.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[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.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
+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-appender"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
+dependencies = [
+ "crossbeam-channel",
+ "symlink",
+ "thiserror",
+ "time",
+ "tracing-subscriber",
+]
+
+[[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-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",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[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 = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[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.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..0b2806b
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,98 @@
+[package]
+name = "stackable-odbc-core"
+version = "0.0.1"
+edition = "2024"
+rust-version = "1.95.0"
+authors = ["Stackable GmbH "]
+license = "Apache-2.0"
+
+# Publishing is switched off at the manifest, not only in `release.toml`.
+# `cargo publish` refuses outright while this is false, so neither a stray
+# `cargo release --execute` nor a hand-run publish can push this crate to
+# crates.io by accident.
+publish = false
+repository = "https://github.com/stackabletech/stackable-odbc-core"
+homepage = "https://github.com/stackabletech/stackable-odbc-core"
+documentation = "https://docs.rs/stackable-odbc-core"
+description = "Database-independent framework for building ODBC drivers in Rust. Implement two traits, get a conformant ODBC 3.80 driver."
+readme = "README.md"
+keywords = ["odbc", "database", "driver", "ffi", "sql"]
+categories = ["database", "external-ffi-bindings", "api-bindings"]
+
+# What does NOT go in the published tarball.
+#
+# `rust-toolchain.toml` is the important one: shipped, it pins *consumers* to
+# 1.95.0, because rustup honours the file it finds in a vendored or unpacked
+# dependency. That is a known docs.rs and vendoring failure mode, and the pin is
+# a contributor convenience with no business travelling with the crate.
+exclude = [
+ "rust-toolchain.toml",
+ ".github/",
+ "release/",
+ "release.toml",
+ "fuzz/",
+ "bench/",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "clippy.toml",
+ "deny.toml",
+ "rustfmt.toml",
+ ".pre-commit-config.yaml",
+ ".markdownlint.yaml",
+]
+
+# docs.rs builds for a single target by default, which would leave `ConfigDSNW`
+# and the seven other `#[cfg(windows)]` items undocumented, exactly the parts
+# a Windows driver author needs. Build the docs on Windows, and turn on the
+# `test-support` feature so the `conformance` module a driver's test suite uses
+# is documented too.
+[package.metadata.docs.rs]
+default-target = "x86_64-pc-windows-msvc"
+targets = ["x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu"]
+features = ["test-support"]
+
+[features]
+# Test support for driver crates: the `conformance` module, which drives
+# `SQLGetInfoW` through the real C ABI to check an info type's return shape.
+#
+# Default-off because it is test code. Compiled unconditionally it lands in
+# every driver's production binary, and it reaches an `unreachable!()` through a
+# public `unsafe fn` taking a caller-supplied `u16` — a panic path a shipped
+# driver has no reason to carry. Driver test suites enable it under
+# `[dev-dependencies]`.
+test-support = []
+
+[dependencies]
+odbc-sys = "0.31"
+snafu = "0.9"
+tracing = "0.1"
+tracing-appender = "0.2"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+[dev-dependencies]
+proptest = "1"
+# For `tests/logging.rs`, which installs a competing global subscriber before
+# calling `init_logging`. That has to run in its own process, so it cannot be a
+# unit test, and an integration test reaches only the public API plus these.
+tracing-subscriber = "0.3"
+
+# Exhaustive interleaving checker for the handle lock discipline. The models
+# themselves live in `src/handles/registry.rs`'s
+# `#[cfg(all(test, loom))] mod loom_tests`, and `src/sync.rs`'s type aliases
+# are gated the same way, so `loom` is only ever resolved inside a test build
+# with `--cfg loom` set. That keeps it a dev-dependency: it never appears in a
+# downstream consumer's resolved dependency graph.
+[target.'cfg(loom)'.dev-dependencies]
+loom = "0.7"
+
+[lints.rust]
+# `loom` is a custom cfg (see `src/sync.rs`), set via `RUSTFLAGS="--cfg loom"`
+# rather than a Cargo feature, so rustc does not know its name is legitimate
+# without this declaration.
+unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] }
+
+[lints.clippy]
+expect_used = "deny"
+unwrap_in_result = "deny"
+unwrap_used = "deny"
+panic = "deny"
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..282125b
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,16 @@
+stackable-odbc-core
+Copyright 2026 Stackable GmbH
+
+This product includes software developed at Stackable GmbH
+(https://stackable.tech/).
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License in the LICENSE file distributed alongside this notice, or at:
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d6fa712
--- /dev/null
+++ b/README.md
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
Stackable ODBC Core
+
+
The database-independent half of an ODBC driver, in Rust.
+
+[](https://github.com/stackabletech/stackable-odbc-core/actions/workflows/build.yaml)
+[](https://github.com/stackabletech/stackable-odbc-core/actions/workflows/security_audit.yaml)
+[](CONTRIBUTING.md)
+[](./LICENSE)
+[](#conformance)
+[](#conformance)
+
+[Stackable Data Platform](https://stackable.tech/) | [Platform Docs](https://docs.stackable.tech/) | [Discussions](https://github.com/orgs/stackabletech/discussions) | [Discord](https://discord.gg/7kZ3BNnCAF)
+
+## What is this?
+
+ODBC is the standard way desktop tools talk to a database. Excel, Tableau,
+Power BI and Python's pyodbc all speak it. Each database needs its own *driver*,
+a shared library the tool loads that translates those standard calls into
+whatever the database actually speaks.
+
+Writing one is a large job, and most of it has nothing to do with your database.
+The driver has to hand out and validate handles, convert every string to and
+from UTF-16, report errors in the exact format the standard demands, copy values
+into buffers the application supplied, and not crash when the application lies
+about how big those buffers are.
+
+`stackable-odbc-core` is that shared part, written once. What you supply is the
+part that really is about your database: how to connect and authenticate, how
+to run a query and read rows back, how your database's types map onto ODBC's,
+and how to answer the catalog questions. For a networked database that is a
+client library in its own right, and writing one is not trivial. It is just
+not ODBC work. One macro then generates the C entry points the standard
+requires.
+
+This is a library rather than a driver you can load on its own. A working driver
+is this crate plus a backend, and
+[stackable-odbc-sqlite](https://github.com/stackabletech/stackable-odbc-sqlite)
+is the smallest complete example of one.
+
+## What you get
+
+- **A database backend is two traits and one macro.** Implement `Backend` and
+ `StatementBackend`, then call `forward_ffi!`. The compiler names everything
+ still missing, so there is no list to work through by hand. Core holds no
+ database-specific code, so a driver never forks or patches it.
+
+- **A handle is a ticket number, not a memory address.** ODBC hands the
+ application a `SQLHANDLE` that refers to a connection or a running query. The
+ obvious implementation is a raw pointer, and then an application that frees a
+ handle twice, or uses one after freeing it, corrupts the driver's memory.
+ That is undefined behaviour, so the program may crash, or may quietly return a
+ wrong answer.
+
+ Here a handle is a slot number plus a counter. The driver looks it up in its
+ own table and never follows the pointer the application passed. Freeing bumps
+ that slot's counter, so every ticket still referring to it stops matching.
+ Use-after-free and double-free become a clean "invalid handle" error rather
+ than memory corruption.
+
+- **Two threads can share one connection safely.** The standard requires it,
+ because "drivers must therefore support safe, multithread access to this
+ information", and many drivers leave it to the Driver Manager instead. Each
+ connection here has one lock, shared with every query started on it, so a call
+ touching both a query and its connection takes a single lock. That leaves no
+ lock ordering to get wrong, which is the usual way a driver deadlocks.
+ `SQLCancel` takes no lock at all, because cancelling a slow query must not
+ wait for the query it is cancelling.
+
+- **The query timeout covers waiting for rows, not just sending the query.** An
+ application sets `SQL_ATTR_QUERY_TIMEOUT` to say "give up after N seconds".
+ Most drivers run that clock only while the query is being submitted, but a
+ database can answer with the column names immediately and then take much
+ longer to produce the first row. A timer covering only submission bounds
+ nothing, so this one runs during `SQLFetch` as well.
+
+- **Core builds the catalog answers.** For "what tables exist?" and its
+ relatives, the standard dictates the exact columns, their order, and how the
+ rows are sorted. A backend returns ordinary Rust structs with named fields,
+ and core puts the columns in order, sorts the rows and normalises identifier
+ case. You cannot get the column order or count wrong because you never write
+ them, and a column added to one of those result sets is a change in core
+ alone.
+
+- **Value conversion is already done.** When an application supplies a parameter
+ as text and asks for it to be treated as a number, the standard has three
+ large tables saying exactly what each conversion does, down to which warning
+ to raise when precision is lost. All three are implemented: character, binary
+ and numeric, including the interval rows and the optional `01S07` warning for
+ fractional seconds that were rounded away.
+
+- **Windows is a first-class target.** Its Driver Manager is stricter than
+ unixODBC and it fails quietly, so missing one requirement stops a feature
+ working with no error to explain why. The known traps are handled: answering
+ the version query it makes before connecting, reporting the complete function
+ list it uses to build its dispatch table, and not exporting the deprecated
+ ODBC 2.x functions, because exporting one replaces the Driver Manager's own
+ better implementation with yours.
+
+- **Checked by more than unit tests.** Three tools cover what ordinary tests
+ cannot. Miri runs the code in an interpreter that detects undefined behaviour
+ and leaked handles, loom re-runs the locking code under every thread
+ interleaving rather than the one that happened to occur, and cargo-fuzz throws
+ random input at the buffer-copying code under AddressSanitizer. All three run
+ on every pull request, alongside the unit tests on Linux and Windows.
+
+## Writing a driver
+
+```bash
+cargo new --lib stackable-odbc-xyz
+cargo add stackable-odbc-core
+```
+
+Implement `Backend` and `StatementBackend` for your database, then generate the
+C ABI in `lib.rs`:
+
+```rust,ignore
+stackable_odbc_core::forward_ffi!(crate::backend::XyzBackend);
+```
+
+That one line expands to every exported `SQL*` entry point, plus `ConfigDSNW` on
+Windows, each forwarding to the generic implementation in this crate.
+
+`Backend` has four associated types and a body of required methods, but most of
+them are one-line capability declarations such as `supports_catalogs`,
+`identifier_case` and `sql_conformance`, each answering a single question about
+your database. They are required rather than defaulted on purpose: any default
+core supplied would be a claim about your database that nobody ever checked, and
+a wrong one is invisible, because the driver would confidently tell applications
+something untrue and nothing would complain. `StatementBackend` is the opposite,
+with one associated type and no required methods, so you override only what your
+backend supports.
+
+In practice you do not look the list up. Write the four associated types, run
+`cargo check`, and the compiler names what is still missing.
+
+Two traits and one macro bound the surface, not the effort. A backend for a
+real database is a real client. Authentication, sessions, type mapping, catalog
+queries and error mapping are all yours, and in both existing drivers that adds
+up to a substantial crate. What core takes off your hands is the ODBC half: the
+handle table, the UTF-16, the diagnostics format, the buffer copying and the
+conversion tables. That half is identical for every database, and it is the
+half where a mistake corrupts memory rather than returning a wrong answer.
+
+[AGENTS.md](https://github.com/stackabletech/stackable-odbc-core/blob/main/AGENTS.md)
+has the full walkthrough: how a call flows through the layers, what each
+capability method means, the catalog and descriptor rules, and the Windows
+Driver Manager checklist.
+
+## Conformance
+
+This implements ODBC 3.80 at the `SQL_OIC_CORE` level, the base of the
+standard's three interface-conformance levels and the one an application may
+assume of any driver. All four handle types can be allocated and freed, and all
+five descriptor functions work. Descriptors are the standard's own way of
+describing a bound column or parameter, and one can be shared between queries on
+a connection.
+
+This is a Unicode driver: every function that takes or returns a string is
+exported only in its wide (`W`-suffixed) form e.g. `SQLConnectW`. The
+Driver Manager translates for ANSI applications, so they keep working and the
+driver never carries a second set of entry points. Functions with no strings
+in their signature, such as `SQLFetch`, have one spelling and are exported
+unsuffixed.
+
+`CORE_EXPORTED_FUNCTIONS` in `src/function_id.rs` is the authoritative list of
+what is exported, and a guard test pins every entry to a symbol that exists. The
+deprecated ODBC 2.x functions are left out, because the Driver Manager already
+emulates them on top of the modern ones and usually does it better than a driver
+would, so exporting your own version switches that off rather than adding
+anything. `SQLExtendedFetch` is the exception the Driver Manager does not map,
+so core exports it.
+
+## Limits
+
+Each of these is reported to the application as unsupported rather than quietly
+ignored, so a tool can react instead of trusting a wrong answer.
+
+- **Results are read front to back only** (`SQL_SO_FORWARD_ONLY`), so there is
+ no jumping to a row and no going backwards. `SQLFetchScroll` accepts
+ `SQL_FETCH_NEXT` and rejects every other direction with `HY106`.
+- **One row at a time.** There are no block cursors, so
+ `SQL_ATTR_ROW_ARRAY_SIZE` is fixed at 1. Asking for more returns 1 with an
+ `01S02` warning, and `SQL_GD_BLOCK` is never reported.
+- **No bookmarks**, which are saved row positions an application can return to
+ later, and no automatic population of parameter metadata, so
+ `SQL_ATTR_AUTO_IPD` stays `SQL_FALSE`.
+- **No async.** Every call runs to completion before returning:
+ `SQL_ASYNC_MODE` is reported as `SQL_AM_NONE`, and turning on
+ `SQL_ATTR_ASYNC_ENABLE` is refused rather than ignored. This is about the
+ calling thread, not the shape of the results. Rows still arrive one
+ `SQLFetch` at a time, the query timeout still bounds a slow query, and
+ `SQLCancel` still interrupts one. `Backend` is synchronous too, so a driver
+ built on an async client library bridges to it internally, for example with
+ a current-thread tokio runtime and `block_on`.
+
+## Drivers built on this crate
+
+Each driver is a separate crate supplying only its `Backend` and
+`StatementBackend` implementation.
+
+- [stackable-odbc-trino](https://github.com/stackabletech/stackable-odbc-trino),
+ an ODBC driver for [Trino](https://trino.io/).
+- [stackable-odbc-sqlite](https://github.com/stackabletech/stackable-odbc-sqlite),
+ a SQLite driver, used as a worked example and as the test driver for the
+ framework itself.
+
+## Resources
+
+- [ODBC API reference](https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/odbc-api-reference?view=sql-server-ver16),
+ the authoritative specification. It is the most detailed source and still not
+ an easy read.
+- [Header files](https://github.com/microsoft/ODBC-Specification/blob/master/Windows/inc/sql.h)
+ for the unreleased ODBC 4 standard, mostly valid for the older ones too.
+- [odbc-sys](https://github.com/pacman82/odbc-sys), the ODBC type definitions
+ this crate builds on.
+
+## Getting help
+
+- [GitHub Discussions](https://github.com/orgs/stackabletech/discussions) for
+ questions
+- [Discord](https://discord.gg/7kZ3BNnCAF) to talk to us
+- [Issues](https://github.com/stackabletech/stackable-odbc-core/issues) for
+ bugs, and [SECURITY.md](SECURITY.md) for anything security-related
+
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for building from source, running the
+tests, and how the repository is laid out. [CHANGELOG.md](CHANGELOG.md) records
+what changed in each release.
+
+## License
+
+Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..b961d0d
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,40 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+Please report security vulnerabilities privately, not through a public issue.
+
+The preferred channel is GitHub's private vulnerability reporting: open the
+**Security** tab of this repository and choose **Report a vulnerability**. This
+reaches the maintainers directly and keeps the report confidential until a fix
+is available.
+
+If you cannot use that channel, email `info@stackable.tech` with `SECURITY` in
+the subject line.
+
+Please include the crate version, the platform and Driver Manager in use, the
+driver built on this crate where relevant, and the steps needed to reproduce the
+issue. This crate holds the raw-pointer marshalling for every driver built on
+it, so a reproducer that shows a bad buffer length, a stale handle or a
+misaligned pointer reaching an ODBC entry point is especially useful.
+
+## What to Expect
+
+We aim to acknowledge a report within three working days and to give an initial
+assessment within ten. We will keep you informed while a fix is prepared, and we
+will credit you in the advisory unless you ask us not to.
+
+## Supported Versions
+
+Security fixes are made against the most recent release and the `main` branch.
+While the crate is below 1.0, fixes are not backported to earlier releases:
+upgrade to the current release to receive them.
+
+A driver built on this crate links it statically, so a fix here reaches
+applications only once that driver is rebuilt and re-released against the fixed
+version.
+
+## Disclosure
+
+Fixed vulnerabilities are published as GitHub Security Advisories against this
+repository, naming the affected versions and the release that carries the fix.
diff --git a/bench/.gitignore b/bench/.gitignore
new file mode 100644
index 0000000..560e4dd
--- /dev/null
+++ b/bench/.gitignore
@@ -0,0 +1,5 @@
+target
+# Not committed, matching fuzz/. The benchmark is a development tool, not a
+# shipped artefact, and pinning its dependency graph would mean a second
+# lockfile to keep current for no reproducibility anyone depends on.
+Cargo.lock
diff --git a/bench/Cargo.toml b/bench/Cargo.toml
new file mode 100644
index 0000000..4b2cfc1
--- /dev/null
+++ b/bench/Cargo.toml
@@ -0,0 +1,40 @@
+[package]
+name = "stackable-odbc-core-bench"
+version = "0.0.1"
+publish = false
+edition = "2024"
+rust-version = "1.95.0"
+
+# Own workspace, like `fuzz/`. Two reasons, and the second is the important one:
+#
+# 1. It keeps `criterion` and its tree out of core's own dependency resolution.
+# 2. It means core's manifest declares no `[[bench]]` target at all. A benchmark
+# that is excluded from the published package while still being declared as a
+# target makes `cargo package` warn on every publish, and the only ways to
+# silence that are to ship a benchmark nobody consuming the crate can run, or
+# to leave a permanent warning in the publish output where it would mask the
+# next real one.
+[workspace]
+
+[dependencies]
+# test-support: `ffi_fetch`'s two benchmarks install a connection through
+# `attach_connection` instead of parsing a real connection string, the same
+# shortcut a driver's own test suite uses to reach the connected path without
+# a data source.
+stackable-odbc-core = { path = "..", features = ["test-support"] }
+odbc-sys = "0.31"
+
+[dev-dependencies]
+criterion = { version = "0.8", features = ["html_reports"] }
+
+[[bench]]
+name = "fetch_throughput"
+harness = false
+
+[[bench]]
+name = "handle_lookup"
+harness = false
+
+[[bench]]
+name = "ffi_fetch"
+harness = false
diff --git a/bench/benches/fetch_throughput.rs b/bench/benches/fetch_throughput.rs
new file mode 100644
index 0000000..2a3f5fe
--- /dev/null
+++ b/bench/benches/fetch_throughput.rs
@@ -0,0 +1,295 @@
+//! Benchmarks for the result-set hot path: fetch + get_data.
+//!
+//! Two workload shapes:
+//! * Shape A — mixed columns (50% i64, 40% short string, 10% decimal-as-string).
+//! Defaults to BENCH_ROWS=100_000 × BENCH_COLS=20.
+//! * Shape B — 5 columns × wide strings.
+//! Defaults to BENCH_WIDE_ROWS=10_000 × BENCH_WIDE_STR_LEN=1024.
+//!
+//! Three scenarios (where supported by the harness):
+//! * `late_binding` — SQLFetch + per-cell SQLGetData. Default ODBC pattern.
+//! * `bound_columns` — SQLBindCol + SQLFetch writes directly to client buffers.
+//! Not exercised here; only the FFI-end-to-end driver benches cover it.
+//! * `repeat_get_data` — SQLGetData called BENCH_REPEAT_GET_DATA times per cell.
+//! Surfaces per-call clone cost.
+//!
+//! Run with:
+//! cargo bench -p stackable-odbc-core
+//! BENCH_ROWS=1000000 cargo bench -p stackable-odbc-core # huge-data manual run
+
+use criterion::{Criterion, criterion_group, criterion_main};
+use odbc_sys::{CDataType, SqlDataType};
+use stackable_odbc_core::backend::StatementBackend;
+use stackable_odbc_core::synthetic::SyntheticStatement;
+use stackable_odbc_core::types::{ColumnDescriptor, ColumnValue, FetchResult, Nullable};
+use std::hint::black_box;
+use std::time::Duration;
+
+#[derive(Clone, Copy)]
+struct BenchConfig {
+ rows: usize,
+ cols: usize,
+ wide_rows: usize,
+ wide_str_len: usize,
+ repeat_get_data: usize,
+}
+
+fn env_or(name: &str, default: T) -> T {
+ std::env::var(name)
+ .ok()
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(default)
+}
+
+fn bench_config() -> BenchConfig {
+ BenchConfig {
+ rows: env_or("BENCH_ROWS", 100_000),
+ cols: env_or("BENCH_COLS", 20),
+ wide_rows: env_or("BENCH_WIDE_ROWS", 10_000),
+ wide_str_len: env_or("BENCH_WIDE_STR_LEN", 1024),
+ repeat_get_data: env_or("BENCH_REPEAT_GET_DATA", 3),
+ }
+}
+
+/// Apply the large-run sample-size heuristic: fewer samples when each iteration
+/// is expected to take seconds (rows > 250k).
+fn configure_for_size(c: Criterion, rows: usize) -> Criterion {
+ if rows > 250_000 {
+ c.sample_size(20)
+ .measurement_time(Duration::from_secs(30))
+ .warm_up_time(Duration::from_secs(3))
+ } else {
+ c
+ }
+}
+
+fn string_rows(n_rows: usize, n_cols: usize, s_len: usize) -> Vec> {
+ let s = "x".repeat(s_len);
+ (0..n_rows)
+ .map(|_| {
+ (0..n_cols)
+ .map(|_| ColumnValue::String(s.clone()))
+ .collect()
+ })
+ .collect()
+}
+
+fn int_rows(n_rows: usize, n_cols: usize) -> Vec> {
+ (0..n_rows)
+ .map(|row| {
+ (0..n_cols)
+ .map(|col| ColumnValue::I64(row as i64 * n_cols as i64 + col as i64))
+ .collect()
+ })
+ .collect()
+}
+
+/// Compute the (i64, string, decimal) column split for Shape A based on `n_cols`.
+/// Ratio is 50/40/10, with any rounding remainder going to i64.
+fn shape_a_split(n_cols: usize) -> (usize, usize, usize) {
+ let s = (n_cols * 4) / 10;
+ let d = n_cols / 10;
+ let i = n_cols - s - d;
+ debug_assert_eq!(i + s + d, n_cols, "shape_a_split must total n_cols");
+ (i, s, d)
+}
+
+/// Shape A: mixed columns at 50% i64, 40% short string (32 chars), 10% decimal-as-string.
+fn shape_a_rows(n_rows: usize, n_cols: usize) -> Vec> {
+ let (n_i, n_s, n_d) = shape_a_split(n_cols);
+ let short = "x".repeat(32);
+ let decimal = String::from("12345678.90");
+ (0..n_rows)
+ .map(|row| {
+ let mut cells = Vec::with_capacity(n_cols);
+ for col in 0..n_i {
+ cells.push(ColumnValue::I64(row as i64 * n_cols as i64 + col as i64));
+ }
+ for _ in 0..n_s {
+ cells.push(ColumnValue::String(short.clone()));
+ }
+ for _ in 0..n_d {
+ cells.push(ColumnValue::String(decimal.clone()));
+ }
+ cells
+ })
+ .collect()
+}
+
+/// Shape B: wide-string rows — 5 columns × `s_len` characters each.
+fn shape_b_rows(n_rows: usize, s_len: usize) -> Vec> {
+ let s = "x".repeat(s_len);
+ (0..n_rows)
+ .map(|_| (0..5).map(|_| ColumnValue::String(s.clone())).collect())
+ .collect()
+}
+
+fn columns(n: usize) -> Vec {
+ (0..n)
+ .map(|i| {
+ ColumnDescriptor::new(format!("col{i}"), SqlDataType::EXT_W_VARCHAR)
+ .with_precision_scale(255, 0)
+ .with_nullable(Nullable::SqlNullable)
+ })
+ .collect()
+}
+
+/// Drain a SyntheticStatement by calling fetch+get_data on every column.
+/// Returns the total number of values read so the caller can pass it through `black_box`.
+fn drain(stmt: &mut SyntheticStatement, n_cols: usize) -> usize {
+ let mut count = 0usize;
+ while let FetchResult::Row = stmt.fetch().expect("fetch") {
+ for col in 1..=(n_cols as u16) {
+ stmt.get_data(col, CDataType::Default).expect("get_data");
+ count += 1;
+ }
+ }
+ count
+}
+
+fn drain_repeat(stmt: &mut SyntheticStatement, n_cols: usize, repeats: usize) -> usize {
+ let mut count = 0usize;
+ while let FetchResult::Row = stmt.fetch().expect("fetch") {
+ for col in 1..=(n_cols as u16) {
+ for _ in 0..repeats {
+ stmt.get_data(col, CDataType::Default).expect("get_data");
+ count += 1;
+ }
+ }
+ }
+ count
+}
+
+fn bench_get_data_string(c: &mut Criterion) {
+ use criterion::{BatchSize, Throughput};
+
+ let n_rows = 1_000;
+ let n_cols = 10;
+ let cols = columns(n_cols);
+ let rows = string_rows(n_rows, n_cols, 64);
+
+ let mut group = c.benchmark_group("get_data_string");
+ group.throughput(Throughput::Elements((n_rows * n_cols) as u64));
+ group.bench_function("1000x10_len64", |b| {
+ b.iter_batched(
+ || SyntheticStatement::new(cols.clone(), rows.clone()),
+ |mut stmt| {
+ black_box(drain(&mut stmt, n_cols));
+ },
+ BatchSize::SmallInput,
+ );
+ });
+ group.finish();
+}
+
+fn bench_get_data_int(c: &mut Criterion) {
+ use criterion::{BatchSize, Throughput};
+
+ let n_rows = 1_000;
+ let n_cols = 10;
+ let cols = columns(n_cols);
+ let rows = int_rows(n_rows, n_cols);
+
+ let mut group = c.benchmark_group("get_data_i64");
+ group.throughput(Throughput::Elements((n_rows * n_cols) as u64));
+ group.bench_function("1000x10", |b| {
+ b.iter_batched(
+ || SyntheticStatement::new(cols.clone(), rows.clone()),
+ |mut stmt| {
+ black_box(drain(&mut stmt, n_cols));
+ },
+ BatchSize::SmallInput,
+ );
+ });
+ group.finish();
+}
+
+fn bench_shape_a(c: &mut Criterion) {
+ use criterion::{BatchSize, BenchmarkId, Throughput};
+
+ let cfg = bench_config();
+ let cols = columns(cfg.cols);
+ let rows = shape_a_rows(cfg.rows, cfg.cols);
+ let label = format!("{}x{}", cfg.rows, cfg.cols);
+
+ let mut group = c.benchmark_group("shape_a");
+ group.throughput(Throughput::Elements((cfg.rows * cfg.cols) as u64));
+
+ group.bench_function(BenchmarkId::new("late_binding", &label), |b| {
+ b.iter_batched(
+ || SyntheticStatement::new(cols.clone(), rows.clone()),
+ |mut stmt| {
+ black_box(drain(&mut stmt, cfg.cols));
+ },
+ BatchSize::LargeInput,
+ );
+ });
+
+ group.bench_function(
+ BenchmarkId::new(format!("repeat_get_data_x{}", cfg.repeat_get_data), &label),
+ |b| {
+ b.iter_batched(
+ || SyntheticStatement::new(cols.clone(), rows.clone()),
+ |mut stmt| {
+ black_box(drain_repeat(&mut stmt, cfg.cols, cfg.repeat_get_data));
+ },
+ BatchSize::LargeInput,
+ );
+ },
+ );
+
+ group.finish();
+}
+
+fn bench_shape_b(c: &mut Criterion) {
+ use criterion::{BatchSize, BenchmarkId, Throughput};
+
+ let cfg = bench_config();
+ let n_cols = 5;
+ let cols = columns(n_cols);
+ let rows = shape_b_rows(cfg.wide_rows, cfg.wide_str_len);
+ let label = format!("{}x5_len{}", cfg.wide_rows, cfg.wide_str_len);
+
+ let mut group = c.benchmark_group("shape_b");
+ group.throughput(Throughput::Elements((cfg.wide_rows * n_cols) as u64));
+
+ group.bench_function(BenchmarkId::new("late_binding", &label), |b| {
+ b.iter_batched(
+ || SyntheticStatement::new(cols.clone(), rows.clone()),
+ |mut stmt| {
+ black_box(drain(&mut stmt, n_cols));
+ },
+ BatchSize::LargeInput,
+ );
+ });
+
+ group.bench_function(
+ BenchmarkId::new(format!("repeat_get_data_x{}", cfg.repeat_get_data), &label),
+ |b| {
+ b.iter_batched(
+ || SyntheticStatement::new(cols.clone(), rows.clone()),
+ |mut stmt| {
+ black_box(drain_repeat(&mut stmt, n_cols, cfg.repeat_get_data));
+ },
+ BatchSize::LargeInput,
+ );
+ },
+ );
+
+ group.finish();
+}
+
+fn benches() -> Criterion {
+ configure_for_size(Criterion::default(), bench_config().rows)
+}
+
+criterion_group! {
+ name = benches_group;
+ config = benches();
+ targets =
+ bench_get_data_string,
+ bench_get_data_int,
+ bench_shape_a,
+ bench_shape_b
+}
+criterion_main!(benches_group);
diff --git a/bench/benches/ffi_fetch.rs b/bench/benches/ffi_fetch.rs
new file mode 100644
index 0000000..e352234
--- /dev/null
+++ b/bench/benches/ffi_fetch.rs
@@ -0,0 +1,725 @@
+//! FFI-level fetch benchmarks: `SQLBindCol` + `SQLFetch`, and chunked `SQLGetData`.
+//!
+//! `fetch_throughput` drives `SyntheticStatement::fetch()`/`get_data()` directly
+//! and never enters the FFI layer at all -- no `panic_safe`, no handle
+//! registry lookup, no descriptor, no `write_column_value`. `handle_lookup`
+//! goes through the FFI entry points but never reaches a result set: its
+//! `BenchBackend::exec_direct`/`prepare` are never even called by either of its
+//! two benchmarked functions. This file closes the gap between them: both
+//! groups run the real `SQLBindCol`/`SQLFetch`/`SQLGetData` C ABI entry points
+//! (`stackable_odbc_core::ffi::bind::sql_bind_col`,
+//! `stackable_odbc_core::ffi::fetch::sql_fetch`,
+//! `stackable_odbc_core::ffi::fetch::sql_get_data`) against a connected
+//! statement handle, so `panic_safe`, the handle registry, the ARD and
+//! `write_column_value` are all on the measured path.
+//!
+//! The connection is installed with the `test-support` feature's
+//! [`attach_connection`], the same shortcut a driver's own test suite uses to
+//! reach the connected path without a real data source -- `Backend::connect`
+//! is never called by this benchmark, only `Backend::exec_direct`.
+//!
+//! Two groups:
+//!
+//! - `ffi_fetch_bound` -- `SQLBindCol` three columns (one `i64`, one 1 KiB
+//! `SQL_C_CHAR` string, one 1 KiB `SQL_C_BINARY` blob), then loop `SQLFetch`
+//! over `BENCH_ROWS` rows to `SQL_NO_DATA`. Every row's string and bytes
+//! value is freshly allocated inside `BoundColumnsStatement::get_data`,
+//! which is what a backend that does not cache its whole result set in
+//! memory actually does -- `fetch_throughput`'s `SyntheticStatement` clones
+//! out of a `Vec>` it built once, so it cannot see that
+//! per-row allocation cost, or the ARD lookup, bind-offset arithmetic and
+//! `write_column_value` call that turn each `ColumnValue` into bytes in the
+//! application's buffer.
+//! - `ffi_get_data_chunked` -- one row, one 64 KiB string column, read back
+//! with `SQLFetch` then repeated `SQLGetData` calls through a 512-byte
+//! buffer until `SQL_NO_DATA`. Exercises the `GetDataCursor` chunking loop
+//! in `sql_get_data` (`cursor.delivered`/`cursor.done`) that
+//! `ffi_fetch_bound`'s bound-column path never reaches at all, since a bound
+//! column is written by `SQLFetch` in one call regardless of size.
+//!
+//! `BENCH_ROWS` overrides `ffi_fetch_bound`'s row count (default 100,000),
+//! matching `fetch_throughput`'s env var so the two can be compared at the
+//! same N. `ffi_get_data_chunked`'s row count (1) and column width (64 KiB)
+//! are fixed, since the group's variable is chunk count, not throughput.
+//!
+//! Run with:
+//! (cd bench && cargo bench --bench ffi_fetch)
+//! (cd bench && cargo bench --bench ffi_fetch -- --test) # smoke test only
+//!
+//! ## Baseline (recorded 2026-07-31, `BENCH_ROWS=5000`, warm build, this host)
+//!
+//! ```text
+//! ffi_fetch_bound/5000_rows time: [1.5347 ms 1.5542 ms 1.5795 ms]
+//! thrpt: [3.1655 Melem/s 3.2171 Melem/s 3.2579 Melem/s]
+//! ffi_get_data_chunked/64KiB_over_512B_chunks time: [235.41 us 242.26 us 248.92 us]
+//! thrpt: [251.08 MiB/s 257.99 MiB/s 265.50 MiB/s]
+//! ```
+//!
+//! Re-measure rather than trusting these numbers on a different host or after
+//! a source change that touches the fetch path -- see `AGENTS.md`'s Miri
+//! section for why a stale recorded figure is worse than none.
+
+use std::borrow::Cow;
+use std::ffi::c_void;
+use std::hint::black_box;
+use std::time::Duration;
+
+use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main};
+use stackable_odbc_core::backend::{Backend, StatementBackend};
+use stackable_odbc_core::errors::OdbcError;
+use stackable_odbc_core::function_id::FunctionId;
+use stackable_odbc_core::odbc_sys::{FreeStmtOption, HandleType};
+use stackable_odbc_core::test_support::{attach_connection, detach_connection};
+use stackable_odbc_core::types::{
+ CDataType, ColumnValue, ConnectParams, ExecuteOutcome, FetchResult, InfoType, InfoValue,
+ SQL_NTS, SqlReturn, SqlState, TypeInfoRow,
+};
+
+fn env_or(name: &str, default: T) -> T {
+ std::env::var(name)
+ .ok()
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(default)
+}
+
+fn bench_rows() -> u64 {
+ env_or("BENCH_ROWS", 100_000)
+}
+
+/// Width of the string and bytes columns `ffi_fetch_bound` binds.
+const ONE_KIB: usize = 1024;
+
+/// Width of the string column `ffi_get_data_chunked` reads back in parts.
+const CHUNKED_STRING_LEN: usize = 64 * 1024;
+
+/// The `SQLGetData` target buffer size for `ffi_get_data_chunked` -- small
+/// enough against a 64 KiB value that draining it takes many calls.
+const CHUNK_BUFFER_LEN: usize = 512;
+
+/// How many `SQLGetData` calls draining [`CHUNKED_STRING_LEN`] bytes through a
+/// [`CHUNK_BUFFER_LEN`]-byte `SQL_C_CHAR` buffer takes: `write_char`
+/// (`src/column_value.rs`) reserves the buffer's last byte for the null
+/// terminator, so each call before the last delivers `CHUNK_BUFFER_LEN - 1`
+/// bytes.
+const EXPECTED_CHUNKS: u64 =
+ ((CHUNKED_STRING_LEN + CHUNK_BUFFER_LEN - 2) / (CHUNK_BUFFER_LEN - 1)) as u64;
+
+/// The exact SQL text `ffi_fetch_bound`'s setup sends. `BenchBackend` matches
+/// on it to decide which shape of statement to hand back, since
+/// `Backend::exec_direct` has no other per-call argument that could carry that
+/// choice -- this is a benchmark's own fixed request, not data an application
+/// chose, so matching on it is safe.
+const BOUND_SQL: &str = "SELECT id, payload_text, payload_bytes FROM bound_bench";
+
+/// The exact SQL text `ffi_get_data_chunked`'s setup sends. See [`BOUND_SQL`].
+const CHUNKED_SQL: &str = "SELECT wide_text FROM chunked_bench";
+
+// ---------------------------------------------------------------------------
+// Statement shapes
+// ---------------------------------------------------------------------------
+
+/// `BENCH_ROWS` rows of `(i64, 1 KiB string, 1 KiB bytes)`, for
+/// `ffi_fetch_bound`.
+///
+/// `get_data` allocates a fresh `String`/`Vec` on every call rather than
+/// cloning out of a pre-built row list, because that allocation is exactly
+/// the cost `fetch_throughput`'s in-memory `SyntheticStatement` cannot show.
+struct BoundColumnsStatement {
+ rows_left: u64,
+}
+
+impl BoundColumnsStatement {
+ fn new(rows: u64) -> Self {
+ Self { rows_left: rows }
+ }
+}
+
+impl StatementBackend for BoundColumnsStatement {
+ type Error = OdbcError;
+
+ fn column_count(&self) -> i16 {
+ 3
+ }
+
+ fn fetch(&mut self) -> Result {
+ if self.rows_left == 0 {
+ return Ok(FetchResult::NoData);
+ }
+ self.rows_left -= 1;
+ Ok(FetchResult::Row)
+ }
+
+ fn get_data(
+ &mut self,
+ col: u16,
+ _target_type: CDataType,
+ ) -> Result, Self::Error> {
+ match col {
+ 1 => Ok(Cow::Owned(ColumnValue::I64(self.rows_left as i64))),
+ 2 => Ok(Cow::Owned(ColumnValue::String("x".repeat(ONE_KIB)))),
+ 3 => Ok(Cow::Owned(ColumnValue::Bytes(vec![0xABu8; ONE_KIB]))),
+ other => Err(OdbcError::general(
+ format!("BoundColumnsStatement has no column {other}"),
+ SqlState::invalid_descriptor_index(),
+ )),
+ }
+ }
+}
+
+/// One row, one 64 KiB string column, for `ffi_get_data_chunked`.
+///
+/// `get_data` returns the whole string on every call rather than a slice
+/// starting at some offset -- exactly like `MockLongDataStatement` in core's
+/// own test suite (`src/test_utils.rs`) -- because chunking across several
+/// `SQLGetData` calls is `write_column_value_at`'s job, not the backend's; a
+/// backend that pre-sliced would only be testing itself.
+struct ChunkedStatement {
+ rows_left: u8,
+}
+
+impl ChunkedStatement {
+ fn new() -> Self {
+ Self { rows_left: 1 }
+ }
+}
+
+impl StatementBackend for ChunkedStatement {
+ type Error = OdbcError;
+
+ fn column_count(&self) -> i16 {
+ 1
+ }
+
+ fn fetch(&mut self) -> Result {
+ if self.rows_left == 0 {
+ return Ok(FetchResult::NoData);
+ }
+ self.rows_left -= 1;
+ Ok(FetchResult::Row)
+ }
+
+ fn get_data(
+ &mut self,
+ col: u16,
+ _target_type: CDataType,
+ ) -> Result, Self::Error> {
+ match col {
+ 1 => Ok(Cow::Owned(ColumnValue::String(
+ "y".repeat(CHUNKED_STRING_LEN),
+ ))),
+ other => Err(OdbcError::general(
+ format!("ChunkedStatement has no column {other}"),
+ SqlState::invalid_descriptor_index(),
+ )),
+ }
+ }
+}
+
+/// The one `Backend::Statement` type both benchmarked SQL texts produce.
+enum BenchStatement {
+ Bound(BoundColumnsStatement),
+ Chunked(ChunkedStatement),
+}
+
+impl StatementBackend for BenchStatement {
+ type Error = OdbcError;
+
+ fn column_count(&self) -> i16 {
+ match self {
+ Self::Bound(s) => s.column_count(),
+ Self::Chunked(s) => s.column_count(),
+ }
+ }
+
+ fn fetch(&mut self) -> Result {
+ match self {
+ Self::Bound(s) => s.fetch(),
+ Self::Chunked(s) => s.fetch(),
+ }
+ }
+
+ fn get_data(
+ &mut self,
+ col: u16,
+ target_type: CDataType,
+ ) -> Result, Self::Error> {
+ match self {
+ Self::Bound(s) => s.get_data(col, target_type),
+ Self::Chunked(s) => s.get_data(col, target_type),
+ }
+ }
+}
+
+struct BenchConnection;
+
+/// The backend both benchmarks in this file drive.
+///
+/// `connect` is never called: `attach_connection` (behind the `test-support`
+/// feature) puts a `BenchConnection` into the handle directly, so the
+/// benchmark never has to invent a connection string.
+struct BenchBackend;
+
+impl Backend for BenchBackend {
+ type Connection = BenchConnection;
+ type Statement = BenchStatement;
+ type Error = OdbcError;
+ type CancelToken = ();
+
+ fn connect(_: &ConnectParams) -> Result {
+ Ok(BenchConnection)
+ }
+ fn disconnect(_: &mut Self::Connection) -> Result<(), Self::Error> {
+ Ok(())
+ }
+ fn cancel_token(_: &Self::Connection) -> Self::CancelToken {}
+ fn get_functions() -> Cow<'static, [FunctionId]> {
+ Cow::Borrowed(&[])
+ }
+ fn get_type_info(_: &Self::Connection) -> Cow<'static, [TypeInfoRow]> {
+ Cow::Borrowed(&[])
+ }
+ fn table_types(_: &Self::Connection) -> Vec> {
+ Vec::new()
+ }
+ fn get_info(_: &Self::Connection, _: InfoType) -> Result {
+ Err(OdbcError::NotImplemented {
+ feature: "get_info".into(),
+ })
+ }
+ fn exec_direct(
+ _: &Self::Connection,
+ _: &Self::CancelToken,
+ sql: &str,
+ ) -> Result {
+ match sql {
+ BOUND_SQL => Ok(BenchStatement::Bound(BoundColumnsStatement::new(
+ bench_rows(),
+ ))),
+ CHUNKED_SQL => Ok(BenchStatement::Chunked(ChunkedStatement::new())),
+ other => Err(OdbcError::NotImplemented {
+ feature: format!("BenchBackend received unexpected SQL: {other}"),
+ }),
+ }
+ }
+ fn prepare(
+ conn: &Self::Connection,
+ cancel: &Self::CancelToken,
+ sql: &str,
+ ) -> Result {
+ Self::exec_direct(conn, cancel, sql)
+ }
+ fn execute(
+ _: &Self::Connection,
+ _: &Self::CancelToken,
+ _: &mut Self::Statement,
+ _: &[ColumnValue],
+ ) -> Result {
+ Ok(ExecuteOutcome::default())
+ }
+ fn tables(
+ _: &Self::Connection,
+ _: &Self::CancelToken,
+ _: &stackable_odbc_core::types::TablesQuery<'_>,
+ ) -> Result, Self::Error> {
+ Ok(Vec::new())
+ }
+ fn columns(
+ _: &Self::Connection,
+ _: &Self::CancelToken,
+ _: &stackable_odbc_core::types::ColumnsQuery<'_>,
+ ) -> Result, Self::Error> {
+ Ok(Vec::new())
+ }
+
+ fn supports_catalogs(_: &Self::Connection) -> bool {
+ false
+ }
+ fn supports_schemas(_: &Self::Connection) -> bool {
+ false
+ }
+ fn alter_table_support(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn outer_join_capabilities(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn group_by(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn null_collation(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn identifier_case(_: &Self::Connection) -> u16 {
+ stackable_odbc_core::types::SQL_IC_SENSITIVE
+ }
+ fn quoted_identifier_case(_: &Self::Connection) -> u16 {
+ stackable_odbc_core::types::SQL_IC_SENSITIVE
+ }
+ fn txn_capable(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn integrity(_: &Self::Connection) -> bool {
+ false
+ }
+ fn multiple_active_txn(_: &Self::Connection) -> bool {
+ false
+ }
+ fn special_characters(_: &Self::Connection) -> Cow<'static, str> {
+ Cow::Borrowed("")
+ }
+ fn accessible_procedures(_: &Self::Connection) -> bool {
+ false
+ }
+ fn driver_name() -> Cow<'static, str> {
+ Cow::Borrowed("bench")
+ }
+ fn driver_version() -> Cow<'static, str> {
+ Cow::Borrowed("00.00.0000")
+ }
+ fn dbms_name(_: &Self::Connection) -> Cow<'static, str> {
+ Cow::Borrowed("bench")
+ }
+ fn dbms_version(_: &Self::Connection) -> Cow<'static, str> {
+ Cow::Borrowed("00.00.0000")
+ }
+ fn correlation_name(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn non_nullable_columns(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn expressions_in_order_by(_: &Self::Connection) -> bool {
+ false
+ }
+ fn sql_conformance(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn subqueries(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn column_alias(_: &Self::Connection) -> bool {
+ false
+ }
+ fn concat_null_behavior(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn union_support(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn convert_functions(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn order_by_columns_in_select(_: &Self::Connection) -> bool {
+ false
+ }
+ fn accessible_tables(_: &Self::Connection) -> bool {
+ false
+ }
+ fn data_source_read_only(_: &Self::Connection) -> bool {
+ false
+ }
+ fn search_pattern_escape(_: &Self::Connection) -> Cow<'static, str> {
+ Cow::Borrowed("")
+ }
+ fn keywords(_: &Self::Connection) -> Cow<'static, [Cow<'static, str>]> {
+ Cow::Borrowed(&[])
+ }
+ fn timedate_add_intervals(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn timedate_diff_intervals(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn default_txn_isolation(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn txn_isolation_options(_: &Self::Connection) -> u32 {
+ 0
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Handle setup, shared by both groups
+// ---------------------------------------------------------------------------
+
+/// Allocate env -> connection -> statement, and attach a `BenchConnection` to
+/// the connection handle through the `test-support` feature's
+/// `attach_connection` -- the same shortcut a driver's own test suite uses to
+/// reach the connected path without a real data source.
+fn alloc_and_connect() -> (*mut c_void, *mut c_void, *mut c_void) {
+ use stackable_odbc_core::ffi::handle::sql_alloc_handle;
+ unsafe {
+ let mut env: *mut c_void = std::ptr::null_mut();
+ assert_eq!(
+ sql_alloc_handle::(
+ HandleType::Env as i16,
+ std::ptr::null_mut(),
+ &mut env
+ ),
+ SqlReturn::SUCCESS,
+ );
+ let mut conn: *mut c_void = std::ptr::null_mut();
+ assert_eq!(
+ sql_alloc_handle::(HandleType::Dbc as i16, env, &mut conn),
+ SqlReturn::SUCCESS,
+ );
+ attach_connection::(conn, BenchConnection).expect("attach_connection");
+ let mut stmt: *mut c_void = std::ptr::null_mut();
+ assert_eq!(
+ sql_alloc_handle::(HandleType::Stmt as i16, conn, &mut stmt),
+ SqlReturn::SUCCESS,
+ );
+ (env, conn, stmt)
+ }
+}
+
+/// Detach the connection and free all three handles, mirroring the teardown
+/// order `SQLDisconnect`/`SQLFreeHandle` would use.
+fn free_all(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) {
+ use stackable_odbc_core::ffi::handle::sql_free_handle;
+ unsafe {
+ assert_eq!(
+ sql_free_handle::(HandleType::Stmt as i16, stmt),
+ SqlReturn::SUCCESS,
+ );
+ // SQLFreeHandle(SQL_HANDLE_DBC) refuses a connection still holding a
+ // connection (HY010); detach_connection takes it back out without
+ // calling Backend::disconnect, which is what an offline benchmark
+ // (no real data source to disconnect from) wants.
+ detach_connection::(conn).expect("detach_connection");
+ assert_eq!(
+ sql_free_handle::(HandleType::Dbc as i16, conn),
+ SqlReturn::SUCCESS,
+ );
+ assert_eq!(
+ sql_free_handle::(HandleType::Env as i16, env),
+ SqlReturn::SUCCESS,
+ );
+ }
+}
+
+fn utf16_nts(s: &str) -> Vec {
+ s.encode_utf16().chain(std::iter::once(0)).collect()
+}
+
+/// Reset `stmt` for another execution of `sql` and run it, asserting success.
+///
+/// `SQLFreeStmt(SQL_CLOSE)` rather than `SQLCloseCursor`: the spec makes the
+/// former a no-op when no cursor is open, so it works both for the very first
+/// execution (no cursor yet) and every re-execution after (`ffi/handle.rs`'s
+/// `sql_free_stmt`, `FreeStmtOption::Close`), where `SQLCloseCursor` would
+/// return `24000` on the first call.
+fn reexecute(stmt: *mut c_void, sql: &str) {
+ use stackable_odbc_core::ffi::execute::sql_exec_direct_w;
+ use stackable_odbc_core::ffi::handle::sql_free_stmt;
+ let text = utf16_nts(sql);
+ unsafe {
+ assert_eq!(
+ sql_free_stmt::(stmt, FreeStmtOption::Close as u16),
+ SqlReturn::SUCCESS,
+ );
+ assert_eq!(
+ sql_exec_direct_w::(stmt, text.as_ptr(), SQL_NTS),
+ SqlReturn::SUCCESS,
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ffi_fetch_bound
+// ---------------------------------------------------------------------------
+
+/// `SQLBindCol` three columns, then loop `SQLFetch` over `BENCH_ROWS` rows.
+///
+/// Bound once, outside the measured loop: ARD bindings are their own
+/// descriptor storage, independent of the backend statement, so they survive
+/// `SQLFreeStmt(SQL_CLOSE)` and re-`SQLExecDirectW` across iterations. Only
+/// `reexecute` (producing a fresh `BENCH_ROWS`-row statement) and the
+/// `SQLFetch` loop are inside `iter_batched`'s timed routine.
+fn ffi_fetch_bound(c: &mut Criterion) {
+ use stackable_odbc_core::ffi::bind::sql_bind_col;
+ use stackable_odbc_core::ffi::fetch::sql_fetch;
+
+ let rows = bench_rows();
+ let (env, conn, stmt) = alloc_and_connect();
+
+ let mut id_buf: i64 = 0;
+ let mut id_ind: isize = 0;
+ let mut text_buf = [0u8; ONE_KIB + 1]; // +1: SQL_C_CHAR null terminator
+ let mut text_ind: isize = 0;
+ let mut bytes_buf = [0u8; ONE_KIB];
+ let mut bytes_ind: isize = 0;
+
+ unsafe {
+ assert_eq!(
+ sql_bind_col::(
+ stmt,
+ 1,
+ CDataType::SBigInt as i16,
+ std::ptr::from_mut(&mut id_buf).cast::(),
+ isize::try_from(std::mem::size_of::()).expect("size_of:: fits isize"),
+ std::ptr::from_mut(&mut id_ind),
+ ),
+ SqlReturn::SUCCESS,
+ );
+ assert_eq!(
+ sql_bind_col::(
+ stmt,
+ 2,
+ CDataType::Char as i16,
+ text_buf.as_mut_ptr().cast::(),
+ isize::try_from(text_buf.len()).expect("text_buf.len() fits isize"),
+ std::ptr::from_mut(&mut text_ind),
+ ),
+ SqlReturn::SUCCESS,
+ );
+ assert_eq!(
+ sql_bind_col::(
+ stmt,
+ 3,
+ CDataType::Binary as i16,
+ bytes_buf.as_mut_ptr().cast::(),
+ isize::try_from(bytes_buf.len()).expect("bytes_buf.len() fits isize"),
+ std::ptr::from_mut(&mut bytes_ind),
+ ),
+ SqlReturn::SUCCESS,
+ );
+ }
+
+ let mut group = c.benchmark_group("ffi_fetch_bound");
+ group.throughput(Throughput::Elements(rows));
+ group.bench_function(format!("{rows}_rows"), |b| {
+ b.iter_batched(
+ || reexecute(stmt, BOUND_SQL),
+ |()| unsafe {
+ let mut count = 0u64;
+ loop {
+ match sql_fetch::(stmt) {
+ SqlReturn::SUCCESS => {
+ count += 1;
+ black_box(id_buf);
+ black_box(&text_buf);
+ black_box(&bytes_buf);
+ }
+ SqlReturn::NO_DATA => break,
+ other => panic!("sql_fetch returned {other:?}"),
+ }
+ }
+ // Catches the PerIteration/batching mistake documented below
+ // even if it recurs in a different shape: a routine that
+ // silently fetched zero or partial rows would otherwise still
+ // report a (meaningless) time.
+ assert_eq!(count, rows, "did not fetch exactly BENCH_ROWS rows");
+ black_box(count);
+ },
+ // PerIteration, not LargeInput/SmallInput: those batch several
+ // setup() calls ahead of the timed routine() calls they belong
+ // to, and setup here mutates the one shared `stmt` handle rather
+ // than returning an independent value per call. Under batching,
+ // only the last setup() in a batch actually leaves a fresh
+ // BENCH_ROWS-row statement behind, so every routine() after the
+ // first in that batch fetches against an already-exhausted one
+ // and returns SQL_NO_DATA immediately -- silently, since nothing
+ // here asserts the loop actually ran BENCH_ROWS times.
+ // PerIteration forces exactly one setup() before each timed
+ // routine(), which is the only ordering this shared-handle
+ // design is correct under.
+ BatchSize::PerIteration,
+ );
+ });
+ group.finish();
+
+ free_all(env, conn, stmt);
+}
+
+// ---------------------------------------------------------------------------
+// ffi_get_data_chunked
+// ---------------------------------------------------------------------------
+
+/// `SQLFetch` the one row, then drain its 64 KiB string column through a
+/// 512-byte `SQLGetData` buffer until `SQL_NO_DATA`.
+///
+/// No `SQLBindCol` here: this group is late binding by construction, so every
+/// byte crosses through `sql_get_data`'s `GetDataCursor` chunking loop
+/// (`cursor.delivered` / `cursor.done`), which `ffi_fetch_bound`'s bound
+/// columns never touch at all.
+fn ffi_get_data_chunked(c: &mut Criterion) {
+ use stackable_odbc_core::ffi::fetch::{sql_fetch, sql_get_data};
+
+ let (env, conn, stmt) = alloc_and_connect();
+
+ let mut group = c.benchmark_group("ffi_get_data_chunked");
+ group.throughput(Throughput::Bytes(CHUNKED_STRING_LEN as u64));
+ group.bench_function(format!("64KiB_over_{CHUNK_BUFFER_LEN}B_chunks"), |b| {
+ b.iter_batched(
+ || reexecute(stmt, CHUNKED_SQL),
+ |()| unsafe {
+ assert_eq!(
+ sql_fetch::(stmt),
+ SqlReturn::SUCCESS,
+ "sql_fetch"
+ );
+ let mut buf = [0u8; CHUNK_BUFFER_LEN];
+ let mut ind: isize = 0;
+ let mut chunks = 0u64;
+ loop {
+ let ret = sql_get_data::(
+ stmt,
+ 1,
+ CDataType::Char as i16,
+ buf.as_mut_ptr().cast::(),
+ isize::try_from(buf.len()).expect("buf.len() fits isize"),
+ std::ptr::from_mut(&mut ind),
+ );
+ match ret {
+ SqlReturn::SUCCESS | SqlReturn::SUCCESS_WITH_INFO => {
+ chunks += 1;
+ black_box(&buf);
+ }
+ SqlReturn::NO_DATA => break,
+ other => panic!("sql_get_data returned {other:?}"),
+ }
+ }
+ // Same reasoning as ffi_fetch_bound's row-count assert:
+ // a routine that silently drained zero or a partial value
+ // would otherwise still report a (meaningless) time.
+ assert_eq!(
+ chunks, EXPECTED_CHUNKS,
+ "did not drain the full 64 KiB value"
+ );
+ black_box(chunks);
+ },
+ // PerIteration: see the comment on the same choice in
+ // ffi_fetch_bound. This group's routine is fast enough that
+ // SmallInput's batching is exactly where the bug that choice
+ // avoids was first caught -- reexecute's shared `stmt` handle
+ // got reset once per batch instead of once per iteration, so
+ // every fetch after the batch's first returned SQL_NO_DATA.
+ BatchSize::PerIteration,
+ );
+ });
+ group.finish();
+
+ free_all(env, conn, stmt);
+}
+
+fn benches_config() -> Criterion {
+ // Mirrors fetch_throughput's configure_for_size heuristic: a row count
+ // large enough to make one iteration take seconds gets fewer samples and
+ // a longer measurement window, so the whole run stays bounded.
+ if bench_rows() > 20_000 {
+ Criterion::default()
+ .sample_size(20)
+ .measurement_time(Duration::from_secs(15))
+ .warm_up_time(Duration::from_secs(2))
+ } else {
+ Criterion::default()
+ }
+}
+
+criterion_group! {
+ name = benches;
+ config = benches_config();
+ targets = ffi_fetch_bound, ffi_get_data_chunked
+}
+criterion_main!(benches);
diff --git a/bench/benches/handle_lookup.rs b/bench/benches/handle_lookup.rs
new file mode 100644
index 0000000..fa442fa
--- /dev/null
+++ b/bench/benches/handle_lookup.rs
@@ -0,0 +1,278 @@
+//! Registry-lookup cost on the FFI entry path.
+//!
+//! The sibling `fetch_throughput` benchmark drives `SyntheticStatement`
+//! directly and never touches the handle registry, so it cannot see this at
+//! all. Every `SQLxxx` entry point in the crate reaches its handle through
+//! `HandleScope::get`, which is the one place that cost is paid, and paid on
+//! every call whatever the function goes on to do.
+//!
+//! Two shapes, because the error path is not the success path scaled:
+//!
+//! - `get` — one `scope.get`, then trivial work. `SQLFreeStmt(SQL_UNBIND)`
+//! clears a binding vector that is already empty, so essentially all of what
+//! is measured is the lookup.
+//! - `get_then_push_diagnostic` — the error path, where `panic_safe` also has
+//! to find the handle again to post a diagnostic against it.
+//! `SQLNumResultCols` on a statement that was never executed is the cheapest
+//! way to reach it.
+
+use std::borrow::Cow;
+use std::ffi::c_void;
+
+use criterion::{Criterion, criterion_group, criterion_main};
+use stackable_odbc_core::backend::Backend;
+use stackable_odbc_core::errors::OdbcError;
+use stackable_odbc_core::function_id::FunctionId;
+use stackable_odbc_core::types::{ConnectParams, InfoValue, InfoType, SqlReturn, TypeInfoRow};
+use stackable_odbc_core::odbc_sys::{FreeStmtOption, HandleType};
+
+/// The smallest backend the trait allows. Nothing here is ever called: the
+/// benchmarked entry points resolve a handle and return, without reaching a
+/// connection.
+struct BenchBackend;
+
+struct BenchConnection;
+struct BenchStatement;
+
+impl stackable_odbc_core::backend::StatementBackend for BenchStatement {
+ type Error = OdbcError;
+}
+
+impl Backend for BenchBackend {
+ type Connection = BenchConnection;
+ type Statement = BenchStatement;
+ type Error = OdbcError;
+ type CancelToken = ();
+
+ fn connect(_: &ConnectParams) -> Result {
+ Ok(BenchConnection)
+ }
+ fn disconnect(_: &mut Self::Connection) -> Result<(), Self::Error> {
+ Ok(())
+ }
+ fn get_functions() -> Cow<'static, [FunctionId]> {
+ Cow::Borrowed(&[])
+ }
+ fn get_type_info(_: &Self::Connection) -> Cow<'static, [TypeInfoRow]> {
+ Cow::Borrowed(&[])
+ }
+ fn table_types(_: &Self::Connection) -> Vec> {
+ Vec::new()
+ }
+ fn cancel_token(_: &Self::Connection) -> Self::CancelToken {}
+ fn get_info(_: &Self::Connection, _: InfoType) -> Result {
+ Err(OdbcError::NotImplemented {
+ feature: "get_info".into(),
+ })
+ }
+ fn exec_direct(
+ _: &Self::Connection,
+ _: &Self::CancelToken,
+ _: &str,
+ ) -> Result {
+ Ok(BenchStatement)
+ }
+ fn prepare(
+ _: &Self::Connection,
+ _: &Self::CancelToken,
+ _: &str,
+ ) -> Result {
+ Ok(BenchStatement)
+ }
+ fn execute(
+ _: &Self::Connection,
+ _: &Self::CancelToken,
+ _: &mut Self::Statement,
+ _: &[stackable_odbc_core::types::ColumnValue],
+ ) -> Result {
+ Ok(stackable_odbc_core::types::ExecuteOutcome::default())
+ }
+ fn tables(
+ _: &Self::Connection,
+ _: &Self::CancelToken,
+ _: &stackable_odbc_core::types::TablesQuery<'_>,
+ ) -> Result, Self::Error> {
+ Ok(Vec::new())
+ }
+ fn columns(
+ _: &Self::Connection,
+ _: &Self::CancelToken,
+ _: &stackable_odbc_core::types::ColumnsQuery<'_>,
+ ) -> Result, Self::Error> {
+ Ok(Vec::new())
+ }
+
+ fn supports_catalogs(_: &Self::Connection) -> bool {
+ false
+ }
+ fn supports_schemas(_: &Self::Connection) -> bool {
+ false
+ }
+ fn alter_table_support(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn outer_join_capabilities(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn group_by(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn null_collation(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn identifier_case(_: &Self::Connection) -> u16 {
+ stackable_odbc_core::types::SQL_IC_SENSITIVE
+ }
+ fn quoted_identifier_case(_: &Self::Connection) -> u16 {
+ stackable_odbc_core::types::SQL_IC_SENSITIVE
+ }
+ fn txn_capable(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn integrity(_: &Self::Connection) -> bool {
+ false
+ }
+ fn multiple_active_txn(_: &Self::Connection) -> bool {
+ false
+ }
+ fn special_characters(_: &Self::Connection) -> Cow<'static, str> {
+ Cow::Borrowed("")
+ }
+ fn accessible_procedures(_: &Self::Connection) -> bool {
+ false
+ }
+ fn driver_name() -> Cow<'static, str> {
+ Cow::Borrowed("bench")
+ }
+ fn driver_version() -> Cow<'static, str> {
+ Cow::Borrowed("00.00.0000")
+ }
+ fn dbms_name(_: &Self::Connection) -> Cow<'static, str> {
+ Cow::Borrowed("bench")
+ }
+ fn dbms_version(_: &Self::Connection) -> Cow<'static, str> {
+ Cow::Borrowed("00.00.0000")
+ }
+ fn correlation_name(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn non_nullable_columns(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn expressions_in_order_by(_: &Self::Connection) -> bool {
+ false
+ }
+ fn sql_conformance(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn subqueries(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn column_alias(_: &Self::Connection) -> bool {
+ false
+ }
+ fn concat_null_behavior(_: &Self::Connection) -> u16 {
+ 0
+ }
+ fn union_support(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn convert_functions(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn order_by_columns_in_select(_: &Self::Connection) -> bool {
+ false
+ }
+ fn accessible_tables(_: &Self::Connection) -> bool {
+ false
+ }
+ fn data_source_read_only(_: &Self::Connection) -> bool {
+ false
+ }
+ fn search_pattern_escape(_: &Self::Connection) -> Cow<'static, str> {
+ Cow::Borrowed("")
+ }
+ fn keywords(_: &Self::Connection) -> Cow<'static, [Cow<'static, str>]> {
+ Cow::Borrowed(&[])
+ }
+ fn timedate_add_intervals(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn timedate_diff_intervals(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn default_txn_isolation(_: &Self::Connection) -> u32 {
+ 0
+ }
+ fn txn_isolation_options(_: &Self::Connection) -> u32 {
+ 0
+ }
+}
+
+/// Allocate env → connection → statement, all unconnected.
+///
+/// No connection is attached: neither benchmarked entry point reaches one, and
+/// leaving it off keeps the measurement to the registry.
+fn alloc() -> (*mut c_void, *mut c_void, *mut c_void) {
+ use stackable_odbc_core::ffi::handle::sql_alloc_handle;
+ unsafe {
+ let mut env: *mut c_void = std::ptr::null_mut();
+ assert_eq!(
+ sql_alloc_handle::(HandleType::Env as i16, std::ptr::null_mut(), &mut env),
+ SqlReturn::SUCCESS,
+ );
+ let mut conn: *mut c_void = std::ptr::null_mut();
+ assert_eq!(
+ sql_alloc_handle::(HandleType::Dbc as i16, env, &mut conn),
+ SqlReturn::SUCCESS,
+ );
+ let mut stmt: *mut c_void = std::ptr::null_mut();
+ assert_eq!(
+ sql_alloc_handle::(HandleType::Stmt as i16, conn, &mut stmt),
+ SqlReturn::SUCCESS,
+ );
+ (env, conn, stmt)
+ }
+}
+
+fn free(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) {
+ use stackable_odbc_core::ffi::handle::sql_free_handle;
+ unsafe {
+ let _ = sql_free_handle::(HandleType::Stmt as i16, stmt);
+ let _ = sql_free_handle::(HandleType::Dbc as i16, conn);
+ let _ = sql_free_handle::(HandleType::Env as i16, env);
+ }
+}
+
+fn bench_get(c: &mut Criterion) {
+ let (env, conn, stmt) = alloc();
+ let mut group = c.benchmark_group("handle_scope");
+
+ // One `scope.get`, then a no-op: the bindings vector is already empty.
+ group.bench_function("get", |b| {
+ b.iter(|| unsafe {
+ stackable_odbc_core::ffi::handle::sql_free_stmt::(
+ std::hint::black_box(stmt),
+ FreeStmtOption::Unbind as u16,
+ )
+ });
+ });
+
+ // The error path: `scope.get`, then `panic_safe` finding the handle again
+ // to post the diagnostic against it.
+ group.bench_function("get_then_push_diagnostic", |b| {
+ b.iter(|| unsafe {
+ let mut count: i16 = 0;
+ stackable_odbc_core::ffi::cursor::sql_num_result_cols::(
+ std::hint::black_box(stmt),
+ &mut count,
+ )
+ });
+ });
+
+ group.finish();
+ free(env, conn, stmt);
+}
+
+criterion_group!(benches, bench_get);
+criterion_main!(benches);
diff --git a/clippy.toml b/clippy.toml
new file mode 100644
index 0000000..906adcc
--- /dev/null
+++ b/clippy.toml
@@ -0,0 +1,8 @@
+allow-unwrap-in-tests = true
+allow-panic-in-tests = true
+allow-expect-in-tests = true
+
+disallowed-methods = [
+ { path = "std::time::SystemTime::now", reason = "core is a deterministic marshalling library, and its unit tests, proptests and fuzz targets rely on that. Exactly one wall-clock read is legitimate: `column_value::current_utc_date`, which SQL_TYPE_TIME -> SQL_C_TYPE_TIMESTAMP requires ('the date fields of the timestamp structure are set to the current date'). Each read also makes -Zmiri-disable-isolation mandatory, without which Miri aborts rather than failing an assertion. If the spec forces another one, add #[allow(clippy::disallowed_methods)] with a comment naming the rule that forces it" },
+ { path = "std::time::Instant::now", reason = "a duration measurement in a marshalling path is nondeterminism with no caller. To measure per-phase cost set ODBC_PROFILING=1 and read the tracing spans logging.rs already emits, or measure in the driver crate. Note that core is no longer entirely timing-free: query_timer.rs enforces SQL_ATTR_QUERY_TIMEOUT, but it expresses its deadline as a Condvar::wait_timeout rather than by reading a clock, which is what keeps this ban intact. Prefer that shape; if the spec forces an actual clock read, add #[allow(clippy::disallowed_methods)] with a comment naming the rule that forces it" },
+]
diff --git a/deny.toml b/deny.toml
new file mode 100644
index 0000000..ab16832
--- /dev/null
+++ b/deny.toml
@@ -0,0 +1,46 @@
+# Cargo deny configuration for stackable-odbc-core
+# Based on operator-rs conventions.
+# Run: cargo deny check
+
+# Windows is listed because the crate has #[cfg(windows)] code (ConfigDSNW) and
+# README claims it as a first-class target; without it, any Windows-only
+# dependency is never license- or advisory-scanned. macOS is deliberately
+# absent, matching the CI matrix and the platforms the crate claims.
+[graph]
+targets = [
+ { triple = "x86_64-unknown-linux-gnu" },
+ { triple = "aarch64-unknown-linux-gnu" },
+ { triple = "x86_64-pc-windows-msvc" },
+]
+
+# Stated explicitly rather than relying on cargo-deny's defaults, which have
+# changed across major versions.
+[advisories]
+yanked = "deny"
+unmaintained = "all"
+ignore = []
+
+[bans]
+multiple-versions = "allow"
+
+[licenses]
+unused-allowed-license = "allow"
+confidence-threshold = 1.0
+allow = [
+ "Apache-2.0",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "CC0-1.0",
+ "ISC",
+ "MIT",
+ "MPL-2.0",
+ "Unicode-3.0",
+ "Unicode-DFS-2016",
+ "Zlib",
+ "Unlicense",
+]
+private = { ignore = true }
+
+[sources]
+unknown-registry = "deny"
+unknown-git = "deny"
diff --git a/fuzz/.gitignore b/fuzz/.gitignore
new file mode 100644
index 0000000..5c404b9
--- /dev/null
+++ b/fuzz/.gitignore
@@ -0,0 +1,5 @@
+target
+corpus
+artifacts
+coverage
+Cargo.lock
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
new file mode 100644
index 0000000..fbee2c8
--- /dev/null
+++ b/fuzz/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "stackable-odbc-core-fuzz"
+version = "0.0.1"
+publish = false
+edition = "2021"
+
+[package.metadata]
+cargo-fuzz = true
+
+# Own workspace: keeps `cargo fuzz` from resolving against the parent workspace
+# (which pins a stable toolchain; libFuzzer needs nightly).
+[workspace]
+
+[[bin]]
+name = "utf16"
+path = "fuzz_targets/utf16.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "column_value"
+path = "fuzz_targets/column_value.rs"
+test = false
+doc = false
+bench = false
+
+[dependencies]
+arbitrary = { version = "1", features = ["derive"] }
+libfuzzer-sys = "0.4"
+stackable-odbc-core = { path = ".." }
diff --git a/fuzz/README.md b/fuzz/README.md
new file mode 100644
index 0000000..f2515f1
--- /dev/null
+++ b/fuzz/README.md
@@ -0,0 +1,56 @@
+# Fuzz targets
+
+These targets cover the `unsafe` pointer-marshalling paths in
+`stackable-odbc-core`, which is where AddressSanitizer catches what clippy
+cannot see. Each one allocates its output buffer at exactly the size a correct
+application would, so any write past the end is a reported error rather than a
+silent overrun into neighbouring memory.
+
+That size is the `BufferLength` argument for a variable-length C target, and the
+C type's own size for a fixed-length one, which ignores `BufferLength`
+altogether.
+
+- `utf16` covers `utf16_to_string` and `write_utf16`.
+- `column_value` covers `write_column_value` across every marshallable value
+ variant and C target type, which is the full coercion matrix.
+
+The pure-safe parsers, `translate_escapes`, `ConnectParams::parse` and the
+drivers' own type-name parsers, contain no `unsafe`, so AddressSanitizer adds
+nothing over property tests. They are covered by
+[`proptest`](https://docs.rs/proptest) suites next to the code, which run on
+stable under an ordinary `cargo test` and assert both never-panics and
+round-trip invariants.
+
+## Running
+
+[cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) needs nightly, because
+libFuzzer does.
+
+```bash
+cargo install cargo-fuzz
+cargo +nightly fuzz run utf16
+cargo +nightly fuzz run column_value
+```
+
+If cargo-fuzz fails with "sanitizer is incompatible with statically linked
+libc", it picked a musl target. Pin the gnu triple explicitly, which is what CI
+does:
+
+```bash
+cargo +nightly fuzz run utf16 --target x86_64-unknown-linux-gnu
+```
+
+`cargo fuzz run` runs until it finds a crash or you stop it. To bound a run,
+pass a libFuzzer flag after `--`:
+
+```bash
+cargo +nightly fuzz run column_value -- -max_total_time=60 # stop after 60s
+cargo +nightly fuzz run column_value -- -runs=1000000 # or a run count
+```
+
+## Workspace
+
+This is its own Cargo workspace, so `cargo build` in the repository root does
+not touch it and no `pre-commit` hook compiles it. Anything that changes the
+`Backend` or `StatementBackend` trait can break it while every root check still
+passes, so build it by hand after such a change.
diff --git a/fuzz/fuzz_targets/column_value.rs b/fuzz/fuzz_targets/column_value.rs
new file mode 100644
index 0000000..e580b70
--- /dev/null
+++ b/fuzz/fuzz_targets/column_value.rs
@@ -0,0 +1,213 @@
+#![no_main]
+
+use arbitrary::Arbitrary;
+use libfuzzer_sys::fuzz_target;
+use stackable_odbc_core::column_value::{NumericTarget, write_column_value};
+use stackable_odbc_core::types::{CDataType, ColumnValue};
+use std::ffi::c_void;
+
+// Fuzzes the full write_column_value coercion matrix — every marshallable
+// ColumnValue variant against every interesting C target type — the pointer
+// marshalling and narrowing/formatting code where a buffer overrun would live.
+// The output buffer is allocated at exactly the declared length, so
+// AddressSanitizer reports any write past it. Nested container variants
+// (Array/Map/Row) are omitted: they are not marshalled by SQLGetData.
+#[derive(Arbitrary, Debug)]
+enum FuzzValue {
+ Null,
+ String(String),
+ I8(i8),
+ I16(i16),
+ I32(i32),
+ I64(i64),
+ F32(f32),
+ F64(f64),
+ Bool(bool),
+ Date { year: i16, month: u16, day: u16 },
+ Time { hour: u16, minute: u16, second: u16, fraction: u32 },
+ Timestamp { year: i16, month: u16, day: u16, hour: u16, minute: u16, second: u16, fraction: u32 },
+ Bytes(Vec),
+ Guid([u8; 16]),
+ Decimal(String),
+ TimestampTz { year: i16, month: u16, day: u16, hour: u16, minute: u16, second: u16, fraction: u32, timezone_offset_minutes: i16 },
+ Json(String),
+ // `precision` is a fuzzed index rather than an `odbc_sys::Interval`, which
+ // is not `Arbitrary`; `interval_precision` maps it onto all thirteen.
+ IntervalYearMonth { years: i32, months: i32, precision: u8 },
+ IntervalDayTime { total_nanoseconds: i128, precision: u8 },
+}
+
+/// Map a fuzzed byte onto one of the thirteen interval precisions.
+///
+/// All thirteen are reachable, so the fuzzer explores every field span rather
+/// than only the one a fixed choice would pin.
+fn interval_precision(raw: u8) -> stackable_odbc_core::types::Interval {
+ use stackable_odbc_core::types::Interval::*;
+ match raw % 13 {
+ 0 => Year,
+ 1 => Month,
+ 2 => Day,
+ 3 => Hour,
+ 4 => Minute,
+ 5 => Second,
+ 6 => YearToMonth,
+ 7 => DayToHour,
+ 8 => DayToMinute,
+ 9 => DayToSecond,
+ 10 => HourToMinute,
+ 11 => HourToSecond,
+ _ => MinuteToSecond,
+ }
+}
+
+impl From for ColumnValue {
+ fn from(v: FuzzValue) -> ColumnValue {
+ match v {
+ FuzzValue::Null => ColumnValue::Null,
+ FuzzValue::String(s) => ColumnValue::String(s),
+ FuzzValue::I8(n) => ColumnValue::I8(n),
+ FuzzValue::I16(n) => ColumnValue::I16(n),
+ FuzzValue::I32(n) => ColumnValue::I32(n),
+ FuzzValue::I64(n) => ColumnValue::I64(n),
+ FuzzValue::F32(n) => ColumnValue::F32(n),
+ FuzzValue::F64(n) => ColumnValue::F64(n),
+ FuzzValue::Bool(b) => ColumnValue::Bool(b),
+ FuzzValue::Date { year, month, day } => ColumnValue::Date { year, month, day },
+ FuzzValue::Time { hour, minute, second, fraction } => {
+ ColumnValue::Time { hour, minute, second, fraction }
+ }
+ FuzzValue::Timestamp { year, month, day, hour, minute, second, fraction } => {
+ ColumnValue::Timestamp { year, month, day, hour, minute, second, fraction }
+ }
+ FuzzValue::Bytes(b) => ColumnValue::Bytes(b),
+ FuzzValue::Guid(g) => ColumnValue::Guid(g),
+ FuzzValue::Decimal(s) => ColumnValue::Decimal(s),
+ FuzzValue::TimestampTz {
+ year, month, day, hour, minute, second, fraction, timezone_offset_minutes,
+ } => ColumnValue::TimestampTz {
+ year, month, day, hour, minute, second, fraction, timezone_offset_minutes,
+ },
+ FuzzValue::Json(s) => ColumnValue::Json(s),
+ FuzzValue::IntervalYearMonth { years, months, precision } => {
+ ColumnValue::IntervalYearMonth {
+ years,
+ months,
+ precision: interval_precision(precision),
+ }
+ }
+ FuzzValue::IntervalDayTime { total_nanoseconds, precision } => {
+ ColumnValue::IntervalDayTime {
+ total_nanoseconds,
+ precision: interval_precision(precision),
+ }
+ }
+ }
+ }
+}
+
+// The interesting C target types SQLGetData can be asked to produce.
+const TARGETS: &[CDataType] = &[
+ CDataType::WChar,
+ CDataType::Char,
+ CDataType::SLong,
+ CDataType::ULong,
+ CDataType::SShort,
+ CDataType::UShort,
+ CDataType::STinyInt,
+ CDataType::UTinyInt,
+ CDataType::SBigInt,
+ CDataType::UBigInt,
+ CDataType::Float,
+ CDataType::Double,
+ CDataType::Bit,
+ CDataType::Binary,
+ CDataType::Guid,
+ CDataType::TypeDate,
+ CDataType::TypeTime,
+ CDataType::TypeTimestamp,
+ CDataType::Numeric,
+ CDataType::Default,
+];
+
+#[derive(Arbitrary, Debug)]
+struct Input {
+ value: FuzzValue,
+ target: u8,
+ buf_len: u8,
+ // The ARD's SQL_DESC_PRECISION and SQL_DESC_SCALE, which only SQL_C_NUMERIC
+ // reads. Fuzzed rather than fixed: they drive a digit-string rescale and a
+ // u128 range check inside `to_numeric_struct`, so they are live input to
+ // the newest marshalling path rather than a constant to pass through.
+ // `i16` in full, including the negative values a real application can set,
+ // because rejecting those is part of what is under test.
+ precision: i16,
+ scale: i16,
+}
+
+fuzz_target!(|input: Input| {
+ let value: ColumnValue = input.value.into();
+ let target = TARGETS[input.target as usize % TARGETS.len()];
+ let buf_len = input.buf_len as isize;
+
+ // Allocation size and the `BufferLength` argument are decoupled, exactly as
+ // in a real ODBC application:
+ //
+ // - Variable-length C targets (CHAR/WCHAR/BINARY) honour `BufferLength`, so
+ // an exact-size buffer lets ASAN catch a write past it (a buffer overrun).
+ // - Fixed-length C targets ignore `BufferLength` per the ODBC spec — an app
+ // supplies a variable of the C type's size and may pass any `BufferLength`
+ // (0 is common and legal), so buffer size is independent of the argument.
+ // Give those a buffer sized to the C type; writing `sizeof(T)` into a
+ // smaller one would be the app's contract violation, not a driver bug.
+ // 256 bytes covers every fixed ODBC C type (SQL_C_NUMERIC and the interval
+ // structs, the largest, are well under it), so a write past it would be a
+ // genuine defect.
+ // - SQL_C_DEFAULT is neither, and giving it the blanket 256 bytes is what
+ // hid a real heap overflow from this fuzzer: the *driver* picks the C
+ // type there, and it picks from the runtime `ColumnValue` variant rather
+ // than from the `sql_type` the application sized its buffer against, so
+ // `BufferLength` is the only bound that exists. A positive `BufferLength`
+ // must therefore be honoured and gets an exact-size allocation. Zero is
+ // the documented exemption — it is how an application says "not
+ // applicable" for a fixed C type, so it carries no size information — and
+ // gets the fixed-type allocation instead.
+ let variable_len = matches!(
+ target,
+ CDataType::WChar | CDataType::Char | CDataType::Binary
+ );
+ let bounded_by_buf_len = variable_len || (target == CDataType::Default && buf_len > 0);
+ let alloc = if bounded_by_buf_len {
+ input.buf_len as usize
+ } else {
+ 256
+ };
+ // Deliberately misaligned, not incidentally aligned. `vec![0u8; alloc]`
+ // handed over `buf.as_mut_ptr()` directly, which the allocator returns
+ // suitably aligned for anything this writes, so no run ever exercised the
+ // unaligned path and the `write_unaligned` calls were taken on trust. An
+ // arena of the widest-*alignment* target type (8 bytes: `i64`, `f64`,
+ // `SQLLEN`) offset by one byte is misaligned for every target on every
+ // platform, and one extra element keeps `alloc` bytes writable past the
+ // offset.
+ let mut arena = vec![0u64; alloc / 8 + 2];
+ let mut ind_arena = [0isize; 2];
+ // SAFETY: both offsets stay inside their own allocation, and every write
+ // through them is an unaligned write.
+ unsafe {
+ let buf = arena.as_mut_ptr().cast::().add(1);
+ let ind = ind_arena.as_mut_ptr().cast::().add(1).cast::();
+ debug_assert!(!ind.is_aligned(), "the point of the offset");
+ let _ = write_column_value(
+ &value,
+ target,
+ buf as *mut c_void,
+ buf_len,
+ ind,
+ NumericTarget {
+ interval_leading_precision: 0,
+ precision: input.precision,
+ scale: input.scale,
+ },
+ );
+ }
+});
diff --git a/fuzz/fuzz_targets/utf16.rs b/fuzz/fuzz_targets/utf16.rs
new file mode 100644
index 0000000..0f1f1c4
--- /dev/null
+++ b/fuzz/fuzz_targets/utf16.rs
@@ -0,0 +1,44 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+use stackable_odbc_core::utf16::{utf16_to_string, write_utf16};
+
+// Fuzzes the UTF-16 marshalling helpers. Every output buffer is allocated at
+// exactly the length passed to the function, so AddressSanitizer flags any read
+// or write past the caller's buffer — the buffer-overrun defect class that
+// clippy and Miri-less unit tests cannot see.
+fuzz_target!(|data: &[u8]| {
+ // Interpret the input as little-endian UTF-16 code units.
+ let units: Vec = data
+ .chunks_exact(2)
+ .map(|c| u16::from_le_bytes([c[0], c[1]]))
+ .collect();
+
+ // Explicit-length reading: any code units are valid; the length is exact.
+ if !units.is_empty() {
+ unsafe {
+ let _ = utf16_to_string(units.as_ptr(), units.len() as i32);
+ }
+ }
+
+ // SQL_NTS reading requires a null-terminated buffer (utf16_to_string's
+ // documented safety contract, which the Driver Manager upholds in practice).
+ // Append a terminator so the scan stays in bounds; feeding an unterminated
+ // buffer with SQL_NTS would violate the contract, not find a real bug.
+ let mut nts = units.clone();
+ nts.push(0);
+ unsafe {
+ let _ = utf16_to_string(nts.as_ptr(), -3);
+ }
+
+ // Writing side: a spread of output buffer sizes, each allocated to its exact
+ // length so an off-by-one overrun is caught.
+ let s = String::from_utf16_lossy(&units);
+ for &buf_len in &[0i16, 1, 2, 3, 5, 16] {
+ let mut buf = vec![0u16; buf_len.max(0) as usize];
+ let mut len_out: i16 = 0;
+ unsafe {
+ let _ = write_utf16(&s, buf.as_mut_ptr(), buf_len, &mut len_out);
+ }
+ }
+});
diff --git a/release.toml b/release.toml
new file mode 100644
index 0000000..6690e3c
--- /dev/null
+++ b/release.toml
@@ -0,0 +1,61 @@
+# cargo-release configuration for stackable-odbc-core.
+#
+# cargo-release is dry-run by default; --execute is required to mutate state.
+# Publication to crates.io is deliberately disabled: this configuration only
+# bumps the version, rewrites CHANGELOG.md, commits, tags and pushes.
+# Publishing is a separate, manual step for now.
+
+publish = false
+push = true
+# Signing is requested here rather than left to the releaser's `tag.gpgsign` /
+# `commit.gpgsign`, so a release tag is signed regardless of whose machine it
+# is cut on — and fails loudly instead of silently producing an unsigned tag
+# when no signing key is configured.
+sign-tag = true
+sign-commit = true
+consolidate-commits = false
+
+# Day-to-day work happens on feature branches. Without this, a stray
+# `--execute` would tag whichever branch happened to be checked out.
+allow-branch = ["main"]
+
+# No git hooks are installed in .git/hooks, so pre-commit does not run on the
+# commit cargo-release makes. Run it explicitly instead. This executes against
+# the pre-bump tree, so it validates code rather than version strings.
+pre-release-hook = ["pre-commit", "run", "--all-files"]
+
+# Both follow the conventional-commit style used throughout this repo's
+# history, rather than cargo-release's defaults.
+pre-release-commit-message = "chore(release): version {{version}}"
+tag-message = "chore(release): version {{version}}"
+
+[[pre-release-replacements]]
+file = "CHANGELOG.md"
+search = "## \\[Unreleased\\]"
+replace = "## [Unreleased]\n\n## [{{version}}] — {{date}}"
+exactly = 1
+
+# The next two rules maintain the link-reference footer. Exactly one of them
+# applies to any given release, and the order matters: replacements run
+# sequentially, so the compare-form rule must come first. Reversed, the
+# `commits/HEAD` rule would write a `compare/...` link that the compare-form
+# rule then matched in the same pass, emitting the tag link twice.
+
+# Subsequent-release case: rewrite the `[Unreleased]` compare link to point at
+# the new version, and prepend a `[{version}]` tag link. With `min = 0`, this
+# rule is a silent no-op on the first release, when the footer still holds the
+# `commits/HEAD` placeholder; after that it matches on every release.
+[[pre-release-replacements]]
+file = "CHANGELOG.md"
+search = "\\[Unreleased\\]: https://github.com/stackabletech/stackable-odbc-core/compare/v[0-9]+\\.[0-9]+\\.[0-9]+\\.\\.\\.HEAD"
+replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-core/compare/v{{version}}...HEAD\n[{{version}}]: https://github.com/stackabletech/stackable-odbc-core/releases/tag/v{{version}}"
+min = 0
+
+# First-release case: the initial placeholder points at `commits/HEAD`.
+# Fires exactly once — on the first release — after which the line is in the
+# `compare/...` form the rule above owns, and this one never matches again.
+[[pre-release-replacements]]
+file = "CHANGELOG.md"
+search = "\\[Unreleased\\]: https://github.com/stackabletech/stackable-odbc-core/commits/HEAD"
+replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-core/compare/v{{version}}...HEAD\n[{{version}}]: https://github.com/stackabletech/stackable-odbc-core/releases/tag/v{{version}}"
+min = 0
diff --git a/release/release.sh b/release/release.sh
new file mode 100755
index 0000000..8a52266
--- /dev/null
+++ b/release/release.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# release.sh — convenience wrapper around cargo-release.
+#
+# Usage:
+# release/release.sh patch # dry-run a patch release
+# release/release.sh minor # dry-run a minor release
+# release/release.sh major # dry-run a major release
+# release/release.sh minor --execute # actually perform the release
+#
+# cargo-release is dry-run by default; --execute is required to mutate state.
+# See release.toml for what a release rewrites (CHANGELOG.md) and for the
+# `main`-only branch restriction.
+set -euo pipefail
+
+if [[ $# -lt 1 ]]; then
+ echo "usage: release.sh [--execute]" >&2
+ exit 2
+fi
+
+BUMP="$1"
+shift
+
+exec cargo release "$BUMP" "$@"
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..f92020c
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,3 @@
+[toolchain]
+channel = "1.95.0"
+profile = "default"
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..01e2232
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,6 @@
+style_edition = "2024"
+imports_granularity = "Crate"
+group_imports = "StdExternalCrate"
+reorder_impl_items = true
+use_field_init_shorthand = true
+format_code_in_doc_comments = true
diff --git a/src/backend.rs b/src/backend.rs
new file mode 100644
index 0000000..efc491b
--- /dev/null
+++ b/src/backend.rs
@@ -0,0 +1,3445 @@
+//! The [`Backend`] and [`StatementBackend`] traits every driver implements,
+//! plus the shared `SQLGetInfo` helpers (`common_get_info_raw`,
+//! `default_get_info`).
+
+use std::borrow::Cow;
+
+use odbc_sys::CDataType;
+
+use crate::errors::OdbcError;
+use crate::types::{
+ CatalogResultColumnWidths, ColumnDescriptor, ColumnPrivilegeRow, ColumnRow, ColumnValue,
+ ConnectParams, ExecuteOutcome, FetchResult, ForeignKeyRow, InfoValue, PrimaryKeyRow,
+ ProcedureColumnRow, ProcedureRow, SpecialColumnRow, StatisticsRow, TablePrivilegeRow, TableRow,
+ TypeInfoRow, ValueWarning,
+};
+
+/// Core abstraction for database-specific logic. Everything in
+/// stackable-odbc-core is generic over `B: Backend`.
+///
+/// `Sized` is implicit, since all traits require it by default. It is listed
+/// for symmetry with `StatementBackend` and to make the full contract visible
+/// in one place.
+///
+/// # Re-entrancy
+///
+/// Every method here runs while core holds the target connection's group
+/// lock (see `HandleScope` and `panic_safe` in `src/panic.rs`), including
+/// [`Backend::connect`], which runs under the lock the freshly allocated
+/// connection handle already has, even though nothing yet depends on it. That
+/// lock is not reentrant. A method implementation that calls back into any
+/// `SQLxxx` entry point on the same connection, directly or indirectly through
+/// a callback into application code that does so, deadlocks the calling thread
+/// permanently: there is no diagnostic and no `SqlReturn`, because the thread
+/// never returns from the lock acquisition to produce either.
+/// [`Backend::cancel`] is the one method exempt from this, and its own doc
+/// comment explains why.
+pub trait Backend: Sized + Send + Sync + 'static {
+ /// The backend's live connection, whatever it needs to hold: a socket, a
+ /// client-library handle, a file descriptor, a runtime plus a channel.
+ ///
+ /// Core stores one inside a `ConnectionHandle` and hands it back to every
+ /// method by reference; it never inspects it. `Send + Sync` because an
+ /// application may use one connection from several threads: core's own
+ /// per-connection group lock serialises calls into a `Backend` method,
+ /// but does not confine the connection to the thread that created it.
+ type Connection: Send + Sync;
+
+ /// The backend's executed or prepared statement, from which rows are read.
+ ///
+ /// Produced by `exec_direct`, `prepare` and the catalog methods, and driven
+ /// through [`StatementBackend`].
+ type Statement: StatementBackend;
+
+ /// The one error type every [`Backend`] method returns.
+ ///
+ /// Both conversion directions are required, and they do different jobs:
+ ///
+ /// - `Into` lets core turn a backend failure into a diagnostic
+ /// record. This is what the generic FFI entry points call.
+ /// - `From` lets a *defaulted* method body in this trait
+ /// construct an error and still name `Self::Error`. Without it, no
+ /// default could report `NotImplemented`.
+ ///
+ /// `std::error::Error` is what makes the causal chain usable: core attaches
+ /// a backend error as [`OdbcError::with_source`] and walks `source()` when
+ /// building the diagnostic message. It also lets core log an error it would
+ /// otherwise have to swallow, such as a `disconnect` that fails while
+ /// unwinding a half-open connection.
+ ///
+ /// `Send + Sync + 'static` matches the handles the error travels inside;
+ /// a `#[derive(Debug, Snafu)]` error type satisfies all of this already.
+ type Error: Into + From + std::error::Error + Send + Sync + 'static;
+
+ /// Establishes a new connection using the given [`ConnectParams`].
+ ///
+ /// Called by `SQLDriverConnectW` / `SQLConnectW`. Returns the backend-specific
+ /// connection handle on success.
+ fn connect(params: &ConnectParams) -> Result;
+
+ /// Connection-string keywords whose values must never be logged.
+ ///
+ /// The backend owns its connection-string vocabulary, so it is the only
+ /// party that can name its own secrets: core sees `WalletLocation`,
+ /// `OAuthAssertion` or `KeyStorePin` as ordinary keywords and has no way to
+ /// know better. Every `ConnectParams` the generic FFI entry points build is
+ /// told this list, so declaring a keyword here is all a driver has to do.
+ ///
+ /// Matched case-insensitively against the whole keyword name. Aliases must
+ /// be listed individually.
+ ///
+ /// Defaulted to empty rather than required, because core keeps a substring
+ /// heuristic (`password`, `pwd`, `secret`, `token`, `apikey`, …) in force
+ /// underneath: a backend that declares nothing is still covered for the
+ /// common shapes, so the default understates rather than leaks. Declaring a
+ /// keyword here only ever adds redaction; it can never un-redact one the
+ /// heuristic already catches.
+ ///
+ /// `Cow<'static, [Cow<'static, str>]>`, not `&'static [&'static str]`: this
+ /// method takes no connection, so nothing about it varies at runtime, but
+ /// every other list-returning method on this trait uses the same shape for
+ /// a data source whose answer does vary, so this one matches for uniformity
+ /// of the trait's vocabulary rather than because it needs to.
+ fn sensitive_connect_keywords() -> Cow<'static, [Cow<'static, str>]> {
+ Cow::Borrowed(&[])
+ }
+
+ /// The driver's interactive prompter, if it has one.
+ ///
+ /// Core calls this while connecting and gates the result on
+ /// `SQLDriverConnect`'s *DriverCompletion*: under `SQL_DRIVER_NOPROMPT` the
+ /// backend receives `None` from
+ /// [`ConnectParams::prompter`](crate::types::ConnectParams::prompter) and
+ /// has nothing to call, so the spec's "do not prompt" cannot be forgotten
+ /// at a call site. `SQLConnect` and `SQLBrowseConnect` have no such
+ /// argument and always permit it.
+ ///
+ /// Read it from `ConnectParams` inside [`Backend::connect`], never by
+ /// calling this method directly, which is ungated and says only what the
+ /// driver *could* do, not what this call is allowed to do.
+ ///
+ /// Static, like [`Backend::connect`] and
+ /// [`Backend::sensitive_connect_keywords`]: there is no connection yet when
+ /// core asks. A driver whose prompter varies per connection string can
+ /// return one implementation that reads the difference at
+ /// [`Prompter::present_url`](crate::prompt::Prompter::present_url) time.
+ ///
+ /// Defaulted, so a driver with no interactive authentication never has to
+ /// think about it.
+ fn prompter() -> Option> {
+ None
+ }
+
+ /// Present the driver's DSN setup UI and return the data source's final
+ /// keywords.
+ ///
+ /// Called by `ConfigDSN`, the entry point the Windows ODBC Administrator's
+ /// **Add…** and **Configure…** buttons reach. Core does everything else:
+ /// validating *fRequest*, rejecting a `DRIVER=` keyword, calling
+ /// `SQLValidDSN`, and writing the data source through `SQLWriteDSNToIni` and
+ /// `SQLWritePrivateProfileString`. This hook supplies the one thing that
+ /// varies per backend: which keywords the data source needs, and how to ask
+ /// the user for them.
+ ///
+ /// # Arguments
+ ///
+ /// - `hwnd_parent` is the ODBC Administrator's window, to parent a dialog
+ /// on. It is passed through untouched and never dereferenced by core, so
+ /// it is `*mut c_void` on every platform; a driver's own `#[cfg(windows)]`
+ /// is where it becomes an `HWND`. It may be null, which the spec defines
+ /// as "the function will not display any dialog boxes".
+ /// - `request` is which of the three operations was asked for. **All three
+ /// reach this hook**, [`Remove`](crate::setup::ConfigRequest::Remove)
+ /// included, so a driver can confirm a removal or clean up something that
+ /// does not live in `ODBC.INI`, such as a cached token or a keytab.
+ /// - `attributes` is the keyword-value list the Driver Manager supplied. For
+ /// `Config` and `Remove` core has already merged in the data source's
+ /// existing keywords, read from `ODBC.INI`, so the map is complete and the
+ /// driver never has to touch `odbcinst`. The spec requires that merge
+ /// rather than merely permitting it: "for information not in
+ /// *lpszAttributes*, it uses information from the system information."
+ /// Supplied attributes win over stored ones. For `Add` there is no merge:
+ /// `SQLWriteDSNToIni` removes the old section before creating the new one,
+ /// so prefilling would resurrect exactly the keywords the caller meant to
+ /// drop. On **Add…** the map is typically empty: the dialog is what
+ /// produces the `DSN` keyword, which is why core calls this hook *before*
+ /// it looks for one.
+ ///
+ /// # Returns
+ ///
+ /// - `Ok(Some(attrs))`: proceed, writing `attrs`. Core uses the
+ /// **returned** map's `DSN` value for every request, `Remove` included.
+ ///
+ /// **If `attributes` carried a `DSN` keyword, the returned map must carry
+ /// the same one.** The spec: "if a data source name was passed to it,
+ /// **ConfigDSN** displays that name but does not allow the user to change
+ /// it." Core enforces this rather than trusting the hook, because the
+ /// failure it prevents is destructive: a hook altering `DSN=` on a
+ /// `Remove` would delete a data source the user never named. A mismatch
+ /// fails the call. When no `DSN` keyword was supplied (the **Add…** case)
+ /// the hook may return any name, since there is nothing it could have
+ /// changed.
+ /// - `Ok(None)`: the user cancelled. `ConfigDSN` returns FALSE and posts
+ /// **no** installer error, because nothing failed.
+ /// - `Err(e)`: the hook could not complete. Core posts `e.code` and
+ /// `e.message` with `SQLPostInstallerError`.
+ /// [`SetupError::request_failed`](crate::setup::SetupError::request_failed)
+ /// is the usual constructor.
+ ///
+ /// # Errors
+ ///
+ /// Whatever the driver's setup UI could not do: no display, no permission
+ /// to open a window, a value the driver itself rejected.
+ ///
+ /// # Panics
+ ///
+ /// A panic here is caught: `ConfigDSN` runs inside `panic_safe_unlocked` and
+ /// reports `ODBC_ERROR_REQUEST_FAILED`. Returning `Err` is still better,
+ /// since it carries a message.
+ ///
+ /// Defaulted to the identity function, so a driver with no setup dialog
+ /// never has to think about it and keeps core's headless behaviour.
+ fn configure_dsn(
+ hwnd_parent: *mut std::ffi::c_void,
+ request: crate::setup::ConfigRequest,
+ attributes: std::collections::HashMap,
+ ) -> Result