diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 390041d2c..88668400d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,65 @@ jobs: - name: Rustfmt run: cargo fmt --check - cargo-test-linux: + msrv: + name: MSRV (1.88) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # Keep this in sync with `rust-version` in Cargo.toml. + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: "1.88" + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Install dependencies + run: sudo apt-get install -y openssl libkrb5-dev + - name: Check on MSRV + run: cargo check --features all + + semver: + name: semver-checks (advisory) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + # Advisory during 0.x: reports API-breaking changes vs the target branch + # without blocking (breaking changes are allowed pre-1.0, but should be + # visible in review). + continue-on-error: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Install dependencies + run: sudo apt-get install -y openssl libkrb5-dev + - name: Install cargo-semver-checks + uses: taiki-e/install-action@cargo-semver-checks + - name: Fetch baseline branch + # `actions/checkout` leaves the PR base branch without a local ref, so + # `--baseline-rev origin/` can't be resolved; fetch it explicitly + # and compare against FETCH_HEAD. + run: git fetch --no-tags --depth=100 origin "${{ github.base_ref }}" + - name: Check for semver-breaking changes + # Scope to one coherent, non-conflicting feature set. Checking all + # features at once enables the mutually-exclusive TLS backends + # (native-tls + rustls + vendored-openssl) together, whose duplicate + # `TlsStream` definitions make rustdoc fail to build (E0428). These are + # also the crate's original features, so they exist in every baseline + # slice this PR is compared against. + run: >- + cargo semver-checks --baseline-rev FETCH_HEAD + --only-explicit-features + --features rustls,chrono,time,tds73,rust_decimal,bigdecimal + + smoke: + name: integration smoke (SQL 2022) + # Fast real-server signal on the dev lane; the full matrix runs on qa. + if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/dev') runs-on: ubuntu-latest strategy: @@ -109,7 +167,73 @@ jobs: - name: Run tests run: cargo test ${{matrix.features}} - cargo-test-windows: + integration-linux-next: + name: linux (SQL 2025, experimental) + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/qa') + runs-on: ubuntu-latest + # Newest server; kept non-blocking so a not-yet-published image or a + # server-side behaviour change on the bleeding edge cannot red-wall qa. + continue-on-error: true + env: + TIBERIUS_TEST_CONNECTION_STRING: "server=tcp:localhost,1433;user=SA;password=;TrustServerCertificate=true" + RUSTFLAGS: "-Dwarnings" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Start SQL Server 2025 + run: DOCKER_BUILDKIT=1 docker compose -f docker-compose.yml up -d mssql-2025 + - name: Install dependencies + run: sudo apt-get install -y openssl libkrb5-dev + - name: Wait for SQL Server + # Authenticated readiness (not just an open port) — see the smoke job. + run: | + pw='' + for _ in $(seq 1 60); do + if docker run --rm --network host mcr.microsoft.com/mssql-tools \ + /opt/mssql-tools/bin/sqlcmd -S localhost,1433 -U SA -P "$pw" -Q "SELECT 1" >/dev/null 2>&1; then + echo "SQL Server ready (authenticated login succeeded)"; exit 0 + fi + sleep 3 + done + echo "SQL Server did not accept an authenticated login in time" >&2 + docker compose -f docker-compose.yml logs mssql-2025 || true + exit 1 + - name: Run tests + run: cargo test --features=all + + integration-macos: + name: macos build + unit tests + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/qa') + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + features: + - "--no-default-features --features=rustls,chrono,time,tds73,sql-browser-tokio,sql-browser-smol,rust_decimal,bigdecimal" + - "--no-default-features --features=vendored-openssl" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + # No live SQL Server on hosted macOS runners; this verifies the crate + # builds and its unit tests pass on macOS/Apple targets. + - name: Build + run: cargo build ${{ matrix.features }} + - name: Run library unit tests + run: cargo test --lib ${{ matrix.features }} + + integration-windows: + name: windows (SQL 2019, integrated auth) + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/qa') runs-on: windows-latest strategy: @@ -216,42 +340,3 @@ jobs: - name: Run normal tests shell: powershell run: cargo test ${{matrix.features}} - - cargo-test-macos: - runs-on: macos-26-intel - - strategy: - fail-fast: false - matrix: - features: - - "--no-default-features --features=rustls,chrono,time,tds73,sql-browser-async-std,sql-browser-tokio,sql-browser-smol,integrated-auth-gssapi,rust_decimal,bigdecimal" - - "--no-default-features --features=vendored-openssl" - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: stable - - - name: Compute cache key - shell: bash - run: | - key="${{ matrix.features }}" - key="${key//,/+}" - echo "RUST_CACHE_KEY=$key" >> "$GITHUB_ENV" - - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - shared-key: ${{ env.RUST_CACHE_KEY }} - - # macOS runners can't host the Linux SQL Server container, so this lane is - # compile + library unit tests only (no server-dependent integration tests, - # which run on the Linux and Windows lanes). - - name: Build - run: cargo build ${{matrix.features}} - - - name: Run library unit tests - run: cargo test --lib ${{matrix.features}} diff --git a/Cargo.toml b/Cargo.toml index 173d75522..33b8ccb32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,20 +13,15 @@ license = "MIT/Apache-2.0" name = "tiberius" readme = "README.md" repository = "https://github.com/prisma/tiberius" -version = "0.12.3" +version = "0.13.0" [workspace] -members = ["runtimes-macro"] +members = ["runtimes-macro", "tiberius-macros"] [[test]] path = "tests/query.rs" name = "query" -[[test]] -path = "tests/named-instance-async.rs" -name = "named-instance-async" -required-features = ["sql-browser-async-std"] - [[test]] path = "tests/named-instance-tokio.rs" name = "named-instance-tokio" @@ -37,16 +32,20 @@ path = "tests/named-instance-smol.rs" name = "named-instance-smol" required-features = ["sql-browser-smol"] +[[example]] +name = "named-pipes" +path = "examples/named-pipes.rs" + [dependencies] enumflags2 = "0.7" byteorder = "1.0" encoding_rs = "0.8" once_cell = "1.3" -thiserror = "1.0" +thiserror = "2" bytes = "1.0" -pretty-hex = "0.3" +pretty-hex = "0.4" pin-project-lite = "0.2" -asynchronous-codec = "0.6" +asynchronous-codec = "0.7" async-trait = "0.1" connection-string = "0.2" num-traits = "0.2" @@ -54,14 +53,15 @@ uuid = "1.0" zeroize = "1.8.2" [target.'cfg(windows)'.dependencies] -winauth = { version = "0.0.4", optional = true } +winauth = { version = "0.0.5", optional = true } [target.'cfg(unix)'.dependencies] -libgssapi = { version = "0.8.1", optional = true, default-features = false } +libgssapi = { version = "0.11", optional = true, default-features = false } +sspi = { version = "0.18", optional = true } +libc = "0.2" [dependencies.async-native-tls] -version = "0.4" -features = ["runtime-async-std"] +version = "0.6" optional = true [dependencies.tokio] @@ -74,11 +74,6 @@ version = "0.7" features = ["compat"] optional = true -[dependencies.async-std] -version = "1" -optional = true -features = ["attributes"] - [dependencies.chrono] version = "0.4" optional = true @@ -102,20 +97,25 @@ version = "1.6" optional = true [dependencies.bigdecimal_] -version = "0.3" +version = "0.4" optional = true package = "bigdecimal" +[dependencies.serde] +version = "1.0" +optional = true +features = ["derive", "rc"] + [dependencies.async-io] -version = "1.8" +version = "2" optional = true [dependencies.async-net] -version = "1.7" +version = "2" optional = true [dependencies.futures-lite] -version = "1.12.0" +version = "2" optional = true [dependencies.tokio-rustls] @@ -157,45 +157,59 @@ features = [ ] version = "1.0" -[dev-dependencies.async-std] -features = ["attributes"] -version = "1" +[dev-dependencies.smol] +version = "2" [dev-dependencies.runtimes-macro] path = "./runtimes-macro" +[dependencies.tiberius-macros] +path = "./tiberius-macros" +version = "0.1.0" + [dev-dependencies] names = "0.14" anyhow = "1" env_logger = "0.11" -azure_identity = "0.20.0" -url = "2.2.2" -reqwest = "0.12" +azure_identity = "1.0" +azure_core = "1" +url = "2.5" +reqwest = "0.13" paste = "1.0" indicatif = "0.18" chrono = "0.4.38" -indoc = "1.0.7" +indoc = "2" +serde_json = "1.0" [package.metadata.docs.rs] -features = ["all", "docs"] +features = ["all"] +# docs.rs builds on nightly with this cfg set, enabling #[doc(cfg(...))] +# annotations (feature(doc_cfg)) without requiring nightly for normal builds. +rustdoc-args = ["--cfg", "docsrs"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(docsrs)'] } [features] all = [ "chrono", "time", "tds73", - "sql-browser-async-std", + "tds80", "sql-browser-tokio", "sql-browser-smol", "integrated-auth-gssapi", "rust_decimal", "bigdecimal", "native-tls", + "serde", + "sspi-rs", ] -default = ["tds73", "winauth", "native-tls"] +default = ["tds80", "winauth", "native-tls"] tds73 = [] -docs = [] -sql-browser-async-std = ["async-std"] +# Enables TDS 8.0 support, including the `Strict` encryption level (TLS before +# the TDS prelogin, TDS 8.0 "strict" mode). Requires a TLS backend. +tds80 = ["tds73"] sql-browser-tokio = ["tokio", "tokio-util"] sql-browser-smol = ["async-io", "async-net", "futures-lite"] integrated-auth-gssapi = ["libgssapi"] @@ -203,3 +217,10 @@ bigdecimal = ["bigdecimal_"] rustls = ["tokio-rustls", "tokio-util", "rustls-native-certs", "rustls-webpki"] native-tls = ["async-native-tls"] vendored-openssl = ["opentls"] +# Enables Windows-style SSPI/NTLM authentication (`AuthMethod::Windows`) on Unix +# platforms without requiring Kerberos, via the pure-Rust `sspi` crate. On +# Windows the same authentication is provided by the `winauth` feature. +sspi-rs = ["sspi"] +# Optional serde Serialize/Deserialize impls for query result types +# (Row, Column, ColumnData, Numeric, ColumnType and time/xml types). +serde = ["dep:serde", "uuid/serde"] diff --git a/deny.toml b/deny.toml index d384d581a..cefa0e485 100644 --- a/deny.toml +++ b/deny.toml @@ -6,19 +6,11 @@ [advisories] yanked = "deny" ignore = [ - # async-std is discontinued upstream. In tiberius it is reachable ONLY through - # the opt-in `sql-browser-async-std` feature (and dev-deps); it is not part of - # the default build. Tracked for migration to smol/tokio. - { id = "RUSTSEC-2025-0052", reason = "async-std: opt-in `sql-browser-async-std` feature + dev-deps only; not in the default shipped graph" }, - # The following are ALL dev-dependency-only (test harness + the aad-auth # example) and are never compiled into the published library. { id = "RUSTSEC-2024-0375", reason = "atty: dev-dependency only (via `names` -> clap 3); not shipped" }, { id = "RUSTSEC-2024-0370", reason = "proc-macro-error: dev-dependency only (via `names` -> clap 3); not shipped" }, - { id = "RUSTSEC-2024-0384", reason = "instant: transitive dev-dependency only; not shipped" }, { id = "RUSTSEC-2024-0436", reason = "paste: dev/test only (tests/bulk.rs + azure_identity example); not shipped" }, - { id = "RUSTSEC-2026-0174", reason = "http-types: dev-only via azure_identity in the aad-auth example; not shipped" }, - { id = "RUSTSEC-2026-0275", reason = "azure_core: dev-only via azure_identity in the aad-auth example; not shipped (the crate never handles AAD tokens itself)" }, ] [bans] diff --git a/docs/GUIDE.md b/docs/GUIDE.md new file mode 100644 index 000000000..ecc73940c --- /dev/null +++ b/docs/GUIDE.md @@ -0,0 +1,314 @@ +# Tiberius Guide + +A practical tour of the driver. For the full API see [docs.rs](https://docs.rs/tiberius). +Every example imports the crate as `tiberius` (the package is `tiberius`; see +the [README](../README.md#installation)). + +- [Connecting](#connecting) +- [Configuration](#configuration) +- [Encryption & TLS](#encryption--tls) +- [Authentication](#authentication) +- [Querying](#querying) +- [Reading rows](#reading-rows) +- [Bulk insert](#bulk-insert) +- [Stored procedures, OUT params & TVPs](#stored-procedures-out-params--tvps) +- [Transactions](#transactions) +- [`IN (…)` lists](#in--lists) +- [Named instances (SQL Browser)](#named-instances-sql-browser) +- [Query cancellation](#query-cancellation) +- [Connection pooling](#connection-pooling) +- [Error handling](#error-handling) + +## Connecting + +Tiberius is runtime-independent: you create the `TcpStream` and hand it to the +[`Client`]. + +**Tokio** (wrap the stream with `tokio_util::compat`): + +```rust +use tiberius::{Client, Config, AuthMethod}; +use tokio::net::TcpStream; +use tokio_util::compat::TokioAsyncWriteCompatExt; + +# async fn f() -> anyhow::Result<()> { +let mut config = Config::new(); +config.host("localhost"); +config.port(1433); +config.authentication(AuthMethod::sql_server("SA", "")); +config.trust_cert(); // dev only + +let tcp = TcpStream::connect(config.get_addr()).await?; +tcp.set_nodelay(true)?; +let mut client = Client::connect(config, tcp.compat_write()).await?; +# Ok(()) } +``` + +**smol** (pass the stream directly — no compat layer): + +```rust,ignore +let tcp = smol::net::TcpStream::connect(config.get_addr()).await?; +tcp.set_nodelay(true)?; +let mut client = tiberius::Client::connect(config, tcp).await?; +``` + +## Configuration + +Build a [`Config`] fluently, or parse a connection string: + +```rust,ignore +// ADO.NET +let config = Config::from_ado_string( + "Server=tcp:localhost,1433;User Id=SA;Password=pw;Encrypt=strict;", +)?; + +// JDBC +let config = Config::from_jdbc_string( + "jdbc:sqlserver://localhost:1433;user=SA;password=pw;encrypt=true", +)?; + +// Builder +let config = Config::builder() + .host("localhost") + .port(1433) + .authentication(AuthMethod::sql_server("SA", "pw")) + .build(); +``` + +## Encryption & TLS + +TLS is on by default. Pick a backend via a feature flag (mutually exclusive): +`native-tls` (default), `rustls`, or `vendored-openssl`. + +Encryption levels (`Config::encryption`): `NotSupported`, `Off`, `Required` +(default), and **`Strict`** — TDS 8.0, TLS *before* the pre-login, with the +`tds/8.0` ALPN (requires the `tds80` feature; native-tls or rustls). + +```rust,ignore +use tiberius::EncryptionLevel; +config.encryption(EncryptionLevel::Strict); +config.hostname_in_certificate("my-sql-host"); // validate against a specific CN/SAN +config.client_certificate("client.pem", "client.key"); // mutual TLS +// or: config.client_certificate_pkcs12("client.pfx", "password"); +``` + +## Authentication + +```rust,ignore +// SQL Server login (password buffers are zeroized) +config.authentication(AuthMethod::sql_server("user", "pw")); + +// Windows integrated auth: SSPI on Windows (winauth), NTLM on Unix (sspi-rs) +config.authentication(AuthMethod::windows("user", "pw")); + +// Kerberos on Unix (integrated-auth-gssapi feature) +config.authentication(AuthMethod::Integrated); + +// Azure AD access token +config.authentication(AuthMethod::aad_token(token)); +``` + +## Querying + +From the [`Client`] when parameters are known at the call site: + +```rust,ignore +// Rows back +let stream = client.query("SELECT @P1, @P2", &[&1i32, &"foo"]).await?; + +// Rows affected +let result = client.execute("UPDATE t SET x = @P1 WHERE id = @P2", &[&1i32, &2i32]).await?; +println!("{} rows", result.total()); +``` + +For dynamic or owned parameters, use the [`Query`] object: + +```rust,ignore +use tiberius::Query; +let mut select = Query::new("SELECT @P1, @P2"); +for p in ["a", "b"] { select.bind(p); } +let stream = select.query(&mut client).await?; +``` + +## Reading rows + +A query returns a stream. Collect it, or take the first row: + +```rust,ignore +// All rows of the first result set +let rows = client.query("SELECT id, name FROM users", &[]).await? + .into_first_result().await?; + +for row in rows { + let id: i32 = row.get("id").unwrap(); + let name: &str = row.get("name").unwrap(); +} + +// Just the first row +let row = client.query("SELECT 1 AS n", &[]).await?.into_row().await?.unwrap(); +let n: i32 = row.get("n").unwrap(); +``` + +Type mappings are available via `FromSql`/`ToSql`, with optional `chrono`, +`time`, `rust_decimal`, `bigdecimal`, and `serde` support behind their features. +`Client::column_metadata()` exposes column type, size, precision/scale, +nullability and identity flags. + +## Bulk insert + +Efficiently stream many rows into a table: + +```rust,ignore +use tiberius::IntoRow; + +let mut req = client.bulk_insert("dbo.target").await?; // all columns +// or a specific column list: +// let mut req = client.bulk_insert_columns("dbo.target", &["foo", "bar"]).await?; + +for i in 0..1000i32 { + req.send(i.into_row()).await?; +} +let res = req.finalize().await?; +println!("{} rows", res.total()); +``` + +## Stored procedures, OUT params & TVPs + +Named RPC with input, output and table-valued parameters is supported via the +[`Command`] API. A TVP row type derives `TableValueRow`: + +```rust,ignore +use tiberius::TableValueRow; + +#[derive(TableValueRow)] +struct Item { + #[colname = "Id"] id: i32, + #[colname = "Name"] name: String, +} +``` + +## Transactions + +Real Transaction Manager requests (not T-SQL batches), with isolation levels: + +```rust,ignore +client.begin_transaction().await?; +// ... work ... +client.commit_transaction().await?; +// or client.rollback_transaction().await?; +``` + +## `IN (…)` lists + +Helpers make variable-length `IN` lists and the 2,100-parameter limit ergonomic — +see the `Query` docs on docs.rs for `in_clause`/parameter-expansion helpers. + +## Named instances (SQL Browser) + +On Windows, a named instance's port is resolved through SQL Browser. Enable +`sql-browser-tokio` or `sql-browser-smol` and use the `SqlBrowser` extension: + +```rust,ignore +use tiberius::SqlBrowser; +use tokio::net::TcpStream; +use tokio_util::compat::TokioAsyncWriteCompatExt; + +config.port(1434); +config.instance_name("INSTANCE"); +let tcp = TcpStream::connect_named(&config).await?; +let mut client = Client::connect(config, tcp.compat_write()).await?; +``` + +## Query cancellation + +```rust,ignore +client.cancel_query().await?; // sends a TDS Attention signal +``` + +## Connection pooling + +Pooling is delegated to the async pool crates rather than built in. This keeps +Tiberius runtime-agnostic (a pool has to pick a runtime's timers and tasks) and +lets the connection lifecycle — sizing, health checks, idle reaping — evolve +independently of the driver. Use [`bb8`](https://crates.io/crates/bb8), +[`deadpool`](https://crates.io/crates/deadpool), or +[`mobc`](https://crates.io/crates/mobc) with a small connection manager. + +Because Tiberius has no MARS (one in-flight request per connection), a pool is +also the natural way to get concurrency: check out a connection per task. + +Here is a minimal [`bb8`](https://crates.io/crates/bb8) manager over Tokio: + +```rust,ignore +use bb8::{ManageConnection, Pool}; +use tiberius::{Client, Config}; +use tokio::net::TcpStream; +use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; + +struct TiberiusManager { + config: Config, +} + +#[async_trait::async_trait] +impl ManageConnection for TiberiusManager { + type Connection = Client>; + type Error = tiberius::error::Error; + + async fn connect(&self) -> Result { + let tcp = TcpStream::connect(self.config.get_addr()).await?; + tcp.set_nodelay(true)?; + Client::connect(self.config.clone(), tcp.compat_write()).await + } + + async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> { + // Cheap round-trip to confirm the connection is still alive. + conn.simple_query("SELECT 1").await?.into_row().await?; + Ok(()) + } + + fn has_broken(&self, _conn: &mut Self::Connection) -> bool { + false + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut config = Config::new(); + config.host("localhost"); + config.port(1433); + config.authentication(tiberius::AuthMethod::sql_server("SA", "")); + config.trust_cert(); // don't do this in production + + let pool = Pool::builder() + .max_size(16) + .build(TiberiusManager { config }) + .await?; + + // Check out a connection; it returns to the pool on drop. + let mut conn = pool.get().await?; + let row = conn + .query("SELECT @P1 AS n", &[&1i32]) + .await? + .into_row() + .await? + .unwrap(); + assert_eq!(Some(1i32), row.get("n")); + Ok(()) +} +``` + +The [`deadpool`](https://crates.io/crates/deadpool) and +[`mobc`](https://crates.io/crates/mobc) integrations follow the same shape — +implement their manager trait with the `connect`/`is_valid` logic above. + +## Error handling + +All fallible calls return `tiberius::Result` (`Err` is [`tiberius::error::Error`]). +Server-side errors surface as `Error::Server(TokenError { .. })` carrying the SQL +Server error code, state, class, message, procedure and line. + +[`Client`]: https://docs.rs/tiberius/latest/tiberius/struct.Client.html +[`Config`]: https://docs.rs/tiberius/latest/tiberius/struct.Config.html +[`Query`]: https://docs.rs/tiberius/latest/tiberius/struct.Query.html +[`Command`]: https://docs.rs/tiberius/latest/tiberius/struct.Command.html +[`tiberius::error::Error`]: https://docs.rs/tiberius/latest/tiberius/error/enum.Error.html diff --git a/docs/TDS_COMPATIBILITY.md b/docs/TDS_COMPATIBILITY.md new file mode 100644 index 000000000..2349b86aa --- /dev/null +++ b/docs/TDS_COMPATIBILITY.md @@ -0,0 +1,98 @@ +# TDS Protocol Compatibility + +This document tracks `tiberius`'s coverage of the Microsoft Tabular Data +Stream (MS-TDS) protocol. It is a maintained snapshot — code references may +drift; treat the ratings as the source of truth and file an issue for +discrepancies. + +Legend: ✅ full · 🟡 partial · ❌ missing · N/A not applicable. + +## Protocol-version support + +| TDS version | SQL Server | Rating | Notes | +|---|---|---|---| +| **8.0** | 2022 | ✅ Full | TLS-before-prelogin "strict" mode (`EncryptionLevel::Strict`), `tds/8.0` ALPN, and client-certificate (mutual-TLS) login on native-tls + rustls. 8.0 reuses 7.4 tokens over mandatory TLS; the only backend caveat is that opentls cannot advertise ALPN (see below). | +| **7.4** | 2012–2019 | ✅ Full | Login (`FeatureLevel::SqlServerN`), routing ENVCHANGE, `fReadOnlyIntent`, FedAuth prelogin option + FeatureExt, FEATUREEXTACK, FEDAUTHINFO (0xEE), SESSIONSTATE (0xE4). The only deferral is *transparent reconnect* — SESSIONSTATE is decoded and stored, but not yet replayed to silently re-establish a dropped session. | +| **7.3 A/B** | 2008 / R2 | ✅ Full (`tds73`) | date / time / datetime2 / datetimeoffset types, NBCROW. | +| **7.2** | 2005 | ✅ Full | PLP / varchar(max), XML, MARS / transaction-descriptor headers, SQL_VARIANT (read + write), UDT (0xF0) raw-value decode. | +| **7.1** | 2000 | ✅ Full | Collation, UCS-2 strings, `n`-prefixed var-len types, LOGIN7 layout. | +| **7.0** | 7.0 | ❌ None | Legacy fixed non-nullable types unsupported; the client only negotiates 7.4. Not a target. | + +There is **no Microsoft "TDS 6.0"** — the Microsoft protocol line is +7.0 → 7.1 → 7.2 → 7.3A → 7.3B → 7.4 → 8.0. Sybase-era TDS 4.2/5.0 predate +MS-TDS and are a separate protocol lineage, out of scope for this driver. +TDS 8.0 has no distinct LOGIN7 version — it reuses 7.4 tokens over mandatory +TLS, so "8.0" is a transport/ALPN distinction. + +**Summary:** TDS 7.1 through 8.0 are fully supported. The only remaining +elements are optional and rarely used: transparent session recovery (the +SESSIONSTATE token is decoded and stored, just not replayed on reconnect), +CLR UDT *object* deserialization (raw bytes are surfaced), and `tds/8.0` ALPN +on the opentls backend (an upstream-crate limitation) — none of which any +standard query, bulk-load, RPC, or transaction path depends on. + +## Feature matrix + +### Client → server messages +| Message | Status | +|---|---| +| PRELOGIN (0x12) | ✅ (VERSION/ENCRYPTION/INSTOPT/THREADID/MARS emitted; server options decoded and INSTOPT validated) | +| LOGIN7 (0x10) | ✅ | +| SQL Batch (0x01) | ✅ | +| RPC request (0x03) | ✅ by-ID procs and named procs, incl. OUT params and table-valued parameters (#328) | +| Bulk load (0x07) | ✅ whole-table and column-list | +| SSPI (0x11) | ✅ | +| FedAuth token | ✅ via LOGIN7 FeatureExt | +| Attention / cancel (0x06) | ✅ (`Client::cancel_query`) | +| Transaction Manager request (0x14) | ✅ (`begin`/`commit`/`rollback` with isolation levels) | + +### Server → client tokens +| Token | Status | +|---|---| +| COLMETADATA, ROW, NBCROW, DONE/PROC/INPROC | ✅ | +| ENVCHANGE, ERROR/INFO, LOGINACK | ✅ | +| RETURNVALUE, RETURNSTATUS, ORDER, SSPI | ✅ | +| FEATUREEXTACK | ✅ | +| ALTMETADATA / ALTROW (compute-by) | ✅ | +| COLINFO (0xA5) | ✅ | +| TABNAME (0xA4) | ✅ | +| FEDAUTHINFO (0xEE) | ✅ (STSURL + SPN surfaced for AAD flows) | +| SESSIONSTATE (0xE4) | ✅ decoded + stored (transparent-reconnect replay not yet implemented) | + +### Data types +| Type | Status | +|---|---| +| Fixed-len ints/bit/float/money/datetime(4) | ✅ | +| Nullable var-len (Intn/Bitn/Floatn/Guid/Money/Datetimen/Decimaln/Numericn) | ✅ | +| Char/binary + collation, Text/NText/Image | ✅ | +| PLP (max types), XML | ✅ | +| date / time / datetime2 / datetimeoffset (7.3) | ✅ (`tds73`) | +| Numeric / Decimal (incl. automatic scale rescaling of params) | ✅ | +| SQL_VARIANT (0x62) | ✅ read + write | +| UDT (0xF0) | ✅ raw-value decode (surfaced as bytes; CLR object deserialization out of scope) | + +### Encryption & auth +| Feature | Status | +|---|---| +| Encryption NotSupported / Off / On / Required | ✅ | +| Strict (TDS 8.0, TLS-first) | ✅ (`tds80`) | +| `tds/8.0` ALPN | 🟡 native-tls + rustls ✅, opentls ❌ (backend cannot advertise ALPN) | +| ENCRYPT_CLIENT_CERT (mutual TLS) | ✅ (`Config::client_certificate` / `client_certificate_pkcs12`) | +| SQL auth (zeroized) | ✅ | +| Windows NTLM/SSPI (Windows `winauth`; Unix `sspi-rs`) | ✅ | +| Kerberos/GSSAPI (Unix `integrated-auth-gssapi`) | ✅ | +| AAD / federated token | ✅ (`AuthMethod::aad_token`) | + +## Remaining optional items + +None of the following block standard operation; they are tracked for +completeness and would be additive, backward-compatible features: + +- **Transparent session recovery** — the SESSIONSTATE token is already decoded + and stored; replaying it to silently re-establish a dropped connection is the + remaining step. +- **CLR UDT object deserialization** — UDT values are decoded to raw bytes; + interpreting them into CLR objects (e.g. `geometry`) is left to the caller. +- **opentls `tds/8.0` ALPN** — verified infeasible with `opentls` 0.2.1's public + API (no ALPN setter; the wrapped `SslConnector` is private). Use native-tls or + rustls for TDS 8.0 strict mode. Tracked upstream against the `opentls` crate. diff --git a/examples/aad-auth.rs b/examples/aad-auth.rs index e280a8b94..413f593bc 100644 --- a/examples/aad-auth.rs +++ b/examples/aad-auth.rs @@ -8,8 +8,9 @@ //! - CLIENT_SECRET: service principal secret; //! - TENANT_ID: tenant id of service principal and sql instance; //! - SERVER: SQL server URI -use azure_identity::client_credentials_flow; -use std::{env, sync::Arc}; +use azure_core::credentials::{Secret, TokenCredential}; +use azure_identity::ClientSecretCredential; +use std::env; use tiberius::{AuthMethod, Client, Config, Query}; use tokio::net::TcpStream; use tokio_util::compat::TokioAsyncWriteCompatExt; @@ -21,23 +22,18 @@ async fn main() -> anyhow::Result<()> { env::var("CLIENT_SECRET").expect("Missing CLIENT_SECRET environment variable."); let tenant_id = env::var("TENANT_ID").expect("Missing TENANT_ID environment variable."); - let client = Arc::new(reqwest::Client::new()); - let token = client_credentials_flow::perform( - client, - &client_id, - &client_secret, - &["https://management.azure.com/"], - &tenant_id, - ) - .await?; + let credential = + ClientSecretCredential::new(&tenant_id, client_id, Secret::new(client_secret), None)?; + + let token = credential + .get_token(&["https://database.windows.net/.default"], None) + .await?; let mut config = Config::new(); let server = env::var("SERVER").expect("Missing SERVER environment variable."); config.host(server); config.port(1433); - config.authentication(AuthMethod::AADToken( - token.access_token().secret().to_owned(), - )); + config.authentication(AuthMethod::AADToken(token.token.secret().to_owned())); config.trust_cert(); let tcp = TcpStream::connect(config.get_addr()).await?; diff --git a/examples/async-std.rs b/examples/async-std.rs deleted file mode 100644 index 88fcf1c8d..000000000 --- a/examples/async-std.rs +++ /dev/null @@ -1,50 +0,0 @@ -use async_std::net::TcpStream; -use once_cell::sync::Lazy; -use std::env; -use tiberius::{Client, Config}; - -static CONN_STR: Lazy = Lazy::new(|| { - env::var("TIBERIUS_TEST_CONNECTION_STRING").unwrap_or_else(|_| { - "server=tcp:localhost,1433;IntegratedSecurity=true;TrustServerCertificate=true".to_owned() - }) -}); - -#[cfg(not(all(windows, feature = "sql-browser-async-std")))] -#[async_std::main] -async fn main() -> anyhow::Result<()> { - let config = Config::from_ado_string(&CONN_STR)?; - - let tcp = TcpStream::connect(config.get_addr()).await?; - tcp.set_nodelay(true)?; - - let mut client = Client::connect(config, tcp).await?; - - let stream = client.query("SELECT @P1", &[&1i32]).await?; - let row = stream.into_row().await?.unwrap(); - - println!("{:?}", row); - assert_eq!(Some(1), row.get(0)); - - Ok(()) -} - -#[cfg(all(windows, feature = "sql-browser-async-std"))] -#[async_std::main] -async fn main() -> anyhow::Result<()> { - use tiberius::SqlBrowser; - - let config = Config::from_ado_string(&CONN_STR)?; - - let tcp = TcpStream::connect_named(&config).await?; - tcp.set_nodelay(true)?; - - let mut client = Client::connect(config, tcp).await?; - - let stream = client.query("SELECT @P1", &[&1i32]).await?; - let row = stream.into_row().await?.unwrap(); - - println!("{:?}", row); - assert_eq!(Some(1), row.get(0)); - - Ok(()) -} diff --git a/examples/named-pipes.rs b/examples/named-pipes.rs new file mode 100644 index 000000000..fb135dbb2 --- /dev/null +++ b/examples/named-pipes.rs @@ -0,0 +1,43 @@ +//! Connecting to SQL Server over a Windows named pipe. +//! +//! SQL Server exposes a named pipe endpoint (by default +//! `\\.\pipe\sql\query` for the default instance). Because a named pipe +//! implements `AsyncRead`/`AsyncWrite`, it can be handed to +//! [`Client::connect`] exactly like a TCP stream once it is wrapped with the +//! `tokio-util` compatibility layer. +//! +//! Named pipes are a Windows-only transport, so the real example is compiled +//! only on Windows; on other platforms `main` panics with an unsupported +//! message. See tiberius issues #131 and #53 for background. + +#[cfg(windows)] +#[tokio::main] +async fn main() -> anyhow::Result<()> { + use tiberius::{AuthMethod, Client, Config}; + use tokio::net::windows::named_pipe::ClientOptions; + use tokio_util::compat::TokioAsyncWriteCompatExt; + + // The default named pipe for a default SQL Server instance. A named + // instance uses `\\.\pipe\MSSQL$\sql\query`. + const PIPE_NAME: &str = r"\\.\pipe\sql\query"; + + let mut config = Config::new(); + config.authentication(AuthMethod::Integrated); + config.trust_cert(); + + let pipe = ClientOptions::new().open(PIPE_NAME)?; + let mut client = Client::connect(config, pipe.compat_write()).await?; + + let stream = client.query("SELECT @P1", &[&1i32]).await?; + let row = stream.into_row().await?.unwrap(); + + println!("{row:?}"); + assert_eq!(Some(1), row.get(0)); + + Ok(()) +} + +#[cfg(not(windows))] +fn main() { + panic!("Named pipe connections are only supported on Windows."); +} diff --git a/runtimes-macro/Cargo.toml b/runtimes-macro/Cargo.toml index 6bf114b2a..4b0f68311 100644 --- a/runtimes-macro/Cargo.toml +++ b/runtimes-macro/Cargo.toml @@ -1,14 +1,19 @@ +# Test-only helper crate (dev-dependency, path-only): the `#[test_on_runtimes]` +# attribute that runs each integration test on tokio and smol. Not shipped, not +# published. `license`/`publish` set so the cargo-deny license gate is satisfied +# and it can never be accidentally published. [package] name = "runtimes-macro" version = "0.1.0" authors = ["Eric Sheppard "] -edition = "2018" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false [lib] proc-macro = true [dependencies] quote = "1" -syn = "1" -darling = "0.14" +syn = { version = "2", features = ["full"] } proc-macro2 = "1" diff --git a/runtimes-macro/src/lib.rs b/runtimes-macro/src/lib.rs index cc1d2cabc..94a6b0464 100644 --- a/runtimes-macro/src/lib.rs +++ b/runtimes-macro/src/lib.rs @@ -1,50 +1,70 @@ +//! Internal test-only proc-macro for tiberius. +//! +//! `#[test_on_runtimes]` takes an `async fn(client) -> Result<()>` and generates +//! one integration test per supported async runtime, so every test proves the +//! (runtime-independent) driver works on each of them. Currently: **tokio** and +//! **smol**. extern crate proc_macro; -use darling::FromMeta; -#[derive(Debug, FromMeta)] -struct MacroArgs { - #[darling(default)] - connection_string: Option, +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{parse_macro_input, ItemFn, LitStr}; + +/// Optional `connection_string = "IDENT"` attribute argument naming the `&str` +/// constant to connect with. Defaults to `CONN_STR`. +struct Args { + conn_str: String, } -#[proc_macro_attribute] -pub fn test_on_runtimes( - args: proc_macro::TokenStream, - input: proc_macro::TokenStream, -) -> proc_macro::TokenStream { - let attr_args = syn::parse_macro_input!(args as syn::AttributeArgs); - - let args = match MacroArgs::from_list(&attr_args) { - Ok(v) => v, - Err(e) => { - return proc_macro::TokenStream::from(e.write_errors()); - } - }; +impl syn::parse::Parse for Args { + fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { + let mut conn_str = String::from("CONN_STR"); - let func = syn::parse_macro_input!(input as syn::ItemFn); + if !input.is_empty() { + let ident: syn::Ident = input.parse()?; + if ident != "connection_string" { + return Err(syn::Error::new( + ident.span(), + "expected `connection_string = \"...\"`", + )); + } + input.parse::()?; + let lit: LitStr = input.parse()?; + conn_str = lit.value(); + } - let conn_str_ident_str = args.connection_string.unwrap_or_else(|| "CONN_STR".into()); + Ok(Args { conn_str }) + } +} - let conn_str_ident = - proc_macro2::Ident::new(&conn_str_ident_str, proc_macro2::Span::call_site()); +#[proc_macro_attribute] +pub fn test_on_runtimes(args: TokenStream, input: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as Args); + let func = parse_macro_input!(input as ItemFn); + let conn_str_ident = format_ident!("{}", args.conn_str); let func_name = func.sig.ident.clone(); - let async_std_test = quote::format_ident!("{}_{}", func_name, "async_std"); - let tokio_test = quote::format_ident!("{}_{}", func_name, "tokio"); + let tokio_test = format_ident!("{}_tokio", func_name); + let smol_test = format_ident!("{}_smol", func_name); - let tokens = quote::quote! { + let tokens = quote! { #func #[test] - fn #async_std_test()-> Result<()> { + fn #tokio_test() -> Result<()> { LOGGER_SETUP.call_once(|| { - env_logger::init(); + let _ = env_logger::builder().is_test(true).try_init(); }); - async_std::task::block_on(async { + + use tokio_util::compat::TokioAsyncWriteCompatExt; + + let rt = tokio::runtime::Runtime::new()?; + + rt.block_on(async { let config = tiberius::Config::from_ado_string(&#conn_str_ident)?; - let tcp = async_std::net::TcpStream::connect(config.get_addr()).await?; + let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?; tcp.set_nodelay(true)?; - let mut client = tiberius::Client::connect(config, tcp).await?; + let client = tiberius::Client::connect(config, tcp.compat_write()).await?; #func_name(client).await?; Ok(()) @@ -52,19 +72,16 @@ pub fn test_on_runtimes( } #[test] - fn #tokio_test()-> Result<()> { + fn #smol_test() -> Result<()> { LOGGER_SETUP.call_once(|| { - env_logger::init(); + let _ = env_logger::builder().is_test(true).try_init(); }); - use tokio_util::compat::TokioAsyncWriteCompatExt; - - let mut rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { + smol::block_on(async { let config = tiberius::Config::from_ado_string(&#conn_str_ident)?; - let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?; + let tcp = smol::net::TcpStream::connect(config.get_addr()).await?; tcp.set_nodelay(true)?; - let mut client = tiberius::Client::connect(config, tcp.compat_write()).await?; + let client = tiberius::Client::connect(config, tcp).await?; #func_name(client).await?; Ok(()) @@ -72,5 +89,5 @@ pub fn test_on_runtimes( } }; - proc_macro::TokenStream::from(tokens) + TokenStream::from(tokens) } diff --git a/src/client.rs b/src/client.rs index c31ff2c38..244a3e22e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -14,6 +14,7 @@ pub use auth::*; pub use config::*; pub(crate) use connection::*; +use crate::tds::codec::RpcValue; use crate::tds::stream::ReceivedToken; use crate::{ result::ExecuteResult, @@ -21,9 +22,12 @@ use crate::{ codec::{self, IteratorJoin}, stream::{QueryStream, TokenStream}, }, - BulkLoadRequest, ColumnFlag, SqlReadBytes, ToSql, + BulkLoadRequest, ColumnFlag, MetaDataColumn, SqlReadBytes, ToSql, +}; +use codec::{ + BatchRequest, ColumnData, IsolationLevel, PacketHeader, RpcParam, RpcProcId, TokenRpcRequest, + TransactionManagerRequest, }; -use codec::{BatchRequest, ColumnData, PacketHeader, RpcParam, RpcProcId, TokenRpcRequest}; use enumflags2::BitFlags; use futures_util::io::{AsyncRead, AsyncWrite}; use futures_util::stream::TryStreamExt; @@ -57,6 +61,19 @@ use std::{borrow::Cow, fmt::Debug}; /// # } /// ``` /// +/// # Cancellation safety +/// +/// A single [`Client`] drives one connection and one request at a time. If a +/// `query`/`execute`/`simple_query` future — or the result stream it returns — +/// is dropped before the request has been sent in full and the response fully +/// consumed (for example under a `tokio::time::timeout` or a `select!` branch +/// that loses the race), the connection may be left mid-message and out of sync +/// with the server. A cancelled *write* is detected and any further use of that +/// connection fails cleanly; a result stream dropped mid-response cannot be +/// recovered. In both cases the safe course is to drop the `Client` and open a +/// new connection (a connection pool should discard the connection on error) +/// rather than reuse it. +/// /// [`Config`]: struct.Config.html #[derive(Debug)] pub struct Client { @@ -68,6 +85,11 @@ impl Client { /// options required to connect to the database using an established /// tcp connection /// + /// Note: `tcp_stream` is a connected stream, so some parts of the `Config` + /// (such as multi-subnet failover, which selects between resolved + /// addresses) must be handled while establishing that stream, outside of + /// this constructor. + /// /// [`Config`]: struct.Config.html pub async fn connect(config: Config, tcp_stream: S) -> crate::Result> { Ok(Client { @@ -357,15 +379,68 @@ impl Client { table: &'a str, columns: &'a [&'a str], ) -> crate::Result> { - // Start the bulk request + // Retrieve column metadata from the server, keeping only the updateable + // columns as bulk targets (identity/computed columns are skipped). + let columns: Vec<_> = self + .column_metadata(table, columns) + .await? + .into_iter() + .filter(|column| column.base.flags.contains(ColumnFlag::Updateable)) + .collect(); + + // now start bulk upload + self.connection.flush_stream().await?; + let col_data = columns.iter().map(|c| format!("{}", c)).join(", "); + let query = format!("INSERT BULK {} ({})", table, col_data); + + let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()); + let id = self.connection.context_mut().next_packet_id(); + + self.connection.send(PacketHeader::batch(id), req).await?; + + let ts = TokenStream::new(&mut self.connection); + ts.flush_done().await?; + + BulkLoadRequest::new(&mut self.connection, columns) + } + + /// Retrieve the column metadata for a set of columns of a table, including + /// the column names, types (with their size, precision and scale) and flags + /// such as nullability and whether a column is an identity column. + /// + /// Pass `&["*"]` as `columns` to return the metadata for every column of the + /// table. + /// + /// ```no_run + /// # use tiberius::Config; + /// # use tokio_util::compat::TokioAsyncWriteCompatExt; + /// # use std::env; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let c_str = env::var("TIBERIUS_TEST_CONNECTION_STRING").unwrap_or( + /// # "server=tcp:localhost,1433;integratedSecurity=true;TrustServerCertificate=true".to_owned(), + /// # ); + /// # let config = Config::from_ado_string(&c_str)?; + /// # let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?; + /// # tcp.set_nodelay(true)?; + /// # let mut client = tiberius::Client::connect(config, tcp.compat_write()).await?; + /// let meta = client.column_metadata("some_table", &["*"]).await?; + /// assert!(meta[0].base().is_identity()); + /// # Ok(()) + /// # } + /// ``` + pub async fn column_metadata( + &mut self, + table: &str, + columns: &[&str], + ) -> crate::Result>> { self.connection.flush_stream().await?; - // retrieve column metadata from server + // Ask the server for the column layout without returning any rows. let columns = columns.join(", "); let query = format!("SELECT TOP 0 {columns} FROM {table}"); let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()); - let id = self.connection.context_mut().next_packet_id(); self.connection.send(PacketHeader::batch(id), req).await?; @@ -381,33 +456,159 @@ impl Client { }) .await?; - // now start bulk upload - let columns: Vec<_> = columns - .ok_or_else(|| { - crate::Error::Protocol("expecting column metadata from query but not found".into()) - })? + let columns = columns.ok_or_else(|| { + crate::Error::Protocol("expecting column metadata from query but not found".into()) + })?; + + // Own the column names so the returned metadata is not tied to the + // lifetime of the token stream. + Ok(columns .into_iter() - .filter(|column| column.base.flags.contains(ColumnFlag::Updateable)) - .collect(); + .map(|c| MetaDataColumn { + base: c.base, + col_name: std::borrow::Cow::Owned(c.col_name.into_owned()), + }) + .collect()) + } - self.connection.flush_stream().await?; - let col_data = columns.iter().map(|c| format!("{}", c)).join(", "); - let query = format!("INSERT BULK {} ({})", table, col_data); + /// Sends a TDS Attention signal to the server (packet type `0x06`, + /// MS-TDS section 2.2.1.6) to cancel the request that is currently in + /// flight on this connection, and drains the acknowledging token stream so + /// the connection can be reused for further queries. + /// + /// The server responds to the Attention signal by aborting the running + /// batch or RPC and returning a `DONE` token with the `DONE_ATTN` status + /// bit set. This method waits for that acknowledgement before returning, + /// discarding any remaining rows or tokens from the cancelled request. + /// + /// # Query cancellation and futures + /// + /// Dropping a [`query`], [`execute`] or [`simple_query`] future (for + /// example when a `tokio::time::timeout` elapses or a `select!` branch is + /// cancelled) stops the client from polling the stream, but it does *not* + /// tell the server to stop working on the request. To actually cancel the + /// in-flight work on the server, keep the [`Client`] and call + /// `cancel_query` on it. Because `cancel_query` borrows the client + /// mutably, it can only be issued once the borrowing result stream has + /// been dropped — typically from a separate task holding the client, or + /// after a cancelled/timed-out future has released its borrow. + /// + /// [`query`]: #method.query + /// [`execute`]: #method.execute + /// [`simple_query`]: #method.simple_query + pub async fn cancel_query(&mut self) -> crate::Result<()> { + self.connection.cancel_request().await?; + Ok(()) + } - let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()); - let id = self.connection.context_mut().next_packet_id(); + /// Closes this database connection explicitly. + pub async fn close(self) -> crate::Result<()> { + self.connection.close().await + } - self.connection.send(PacketHeader::batch(id), req).await?; + /// Begins a new transaction using a Transaction Manager request + /// (`TM_BEGIN_XACT`, MS-TDS 2.2.6.8) instead of a `BEGIN TRAN` T-SQL + /// batch. + /// + /// On success the server replies with a `BeginTransaction` environment + /// change token whose descriptor is stored in the connection context and + /// automatically attached to subsequent requests, scoping them to the + /// transaction. Commit the work with [`commit_transaction`] or discard it + /// with [`rollback_transaction`]. + /// + /// The transaction uses the server's default isolation level. Use + /// [`begin_transaction_with_isolation`] to request a specific one. + /// + /// [`commit_transaction`]: #method.commit_transaction + /// [`rollback_transaction`]: #method.rollback_transaction + /// [`begin_transaction_with_isolation`]: #method.begin_transaction_with_isolation + pub async fn begin_transaction(&mut self) -> crate::Result<()> { + self.begin_transaction_with_isolation(IsolationLevel::Unspecified) + .await + } - let ts = TokenStream::new(&mut self.connection); - ts.flush_done().await?; + /// Begins a new transaction with an explicit isolation level using a + /// Transaction Manager request (`TM_BEGIN_XACT`, MS-TDS 2.2.6.8). + /// + /// See [`begin_transaction`] for details on transaction scoping. + /// + /// [`begin_transaction`]: #method.begin_transaction + pub async fn begin_transaction_with_isolation( + &mut self, + isolation_level: IsolationLevel, + ) -> crate::Result<()> { + let req = TransactionManagerRequest::begin( + self.connection.context().transaction_descriptor(), + isolation_level, + "", + ); - BulkLoadRequest::new(&mut self.connection, columns) + self.send_transaction_manager_request(req).await } - /// Closes this database connection explicitly. - pub async fn close(self) -> crate::Result<()> { - self.connection.close().await + /// Commits the active transaction using a Transaction Manager request + /// (`TM_COMMIT_XACT`, MS-TDS 2.2.6.8). + /// + /// After a successful commit the connection is no longer scoped to a + /// transaction. + pub async fn commit_transaction(&mut self) -> crate::Result<()> { + let req = TransactionManagerRequest::commit( + self.connection.context().transaction_descriptor(), + "", + ); + + self.send_transaction_manager_request(req).await + } + + /// Rolls back the active transaction using a Transaction Manager request + /// (`TM_ROLLBACK_XACT`, MS-TDS 2.2.6.8). + /// + /// After a successful rollback the connection is no longer scoped to a + /// transaction. + pub async fn rollback_transaction(&mut self) -> crate::Result<()> { + let req = TransactionManagerRequest::rollback( + self.connection.context().transaction_descriptor(), + "", + ); + + self.send_transaction_manager_request(req).await + } + + /// Creates a named savepoint in the active transaction using a Transaction + /// Manager request (`TM_SAVE_XACT`, MS-TDS 2.2.6.8). + /// + /// The savepoint can later be targeted by a T-SQL `ROLLBACK TRANSACTION + /// ` to undo work performed after it while keeping the surrounding + /// transaction open. + pub async fn save_transaction<'a>( + &mut self, + name: impl Into>, + ) -> crate::Result<()> { + let req = TransactionManagerRequest::save( + self.connection.context().transaction_descriptor(), + name, + ); + + self.send_transaction_manager_request(req).await + } + + async fn send_transaction_manager_request( + &mut self, + req: TransactionManagerRequest<'_>, + ) -> crate::Result<()> { + self.connection.flush_stream().await?; + + let id = self.connection.context_mut().next_packet_id(); + self.connection + .send(PacketHeader::transaction_manager(id), req) + .await?; + + // The server responds with a DONE token (plus an ENVCHANGE token that + // the token stream applies to the connection context, updating the + // active transaction descriptor). + TokenStream::new(&mut self.connection).flush_done().await?; + + Ok(()) } pub(crate) fn rpc_params<'a>(query: impl Into>) -> Vec> { @@ -415,12 +616,12 @@ impl Client { RpcParam { name: Cow::Borrowed("stmt"), flags: BitFlags::empty(), - value: ColumnData::String(Some(query.into())), + value: RpcValue::Scalar(ColumnData::String(Some(query.into()))), }, RpcParam { name: Cow::Borrowed("params"), flags: BitFlags::empty(), - value: ColumnData::I32(Some(0)), + value: RpcValue::Scalar(ColumnData::I32(Some(0))), }, ] } @@ -446,12 +647,12 @@ impl Client { rpc_params.push(RpcParam { name: Cow::Owned(format!("@P{}", i + 1)), flags: BitFlags::empty(), - value: param, + value: RpcValue::Scalar(param), }); } if let Some(params) = rpc_params.iter_mut().find(|x| x.name == "params") { - params.value = ColumnData::String(Some(param_str.into())); + params.value = RpcValue::Scalar(ColumnData::String(Some(param_str.into()))); } let req = TokenRpcRequest::new( @@ -465,4 +666,55 @@ impl Client { Ok(()) } + + /// Sends a named-procedure RPC request with the given parameters. The caller + /// is responsible for flushing the connection beforehand and for consuming + /// the resulting token stream. + pub(crate) async fn rpc_run_command<'a, 'b>( + &'a mut self, + command_name: Cow<'b, str>, + rpc_params: Vec>, + ) -> crate::Result<()> + where + 'a: 'b, + { + let req = TokenRpcRequest::new( + command_name, + rpc_params, + self.connection.context().transaction_descriptor(), + ); + + let id = self.connection.context_mut().next_packet_id(); + self.connection.send(PacketHeader::rpc(id), req).await?; + + Ok(()) + } + + /// Runs a batch query solely to retrieve its column metadata. Used to + /// resolve the column layout of a table-valued parameter type. + pub(crate) async fn query_run_for_metadata<'b>( + &mut self, + query: String, + ) -> crate::Result>>> { + self.connection.flush_stream().await?; + + let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()); + + let id = self.connection.context_mut().next_packet_id(); + self.connection.send(PacketHeader::batch(id), req).await?; + + let token_stream = TokenStream::new(&mut self.connection).try_unfold(); + + let columns = token_stream + .try_fold(None, |mut columns, token| async move { + if let ReceivedToken::NewResultset(metadata) = token { + columns = Some(metadata.columns.clone()); + }; + + Ok(columns) + }) + .await?; + + Ok(columns) + } } diff --git a/src/client/auth.rs b/src/client/auth.rs index 3abf42df8..c440d204b 100644 --- a/src/client/auth.rs +++ b/src/client/auth.rs @@ -23,16 +23,22 @@ impl Debug for SqlServerAuth { } #[derive(Clone, PartialEq, Eq)] -#[cfg(any(all(windows, feature = "winauth"), doc))] -#[cfg_attr(feature = "docs", doc(all(windows, feature = "winauth")))] +#[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs"), doc))] +#[cfg_attr( + docsrs, + doc(cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs")))) +)] pub struct WindowsAuth { pub(crate) user: String, pub(crate) password: String, pub(crate) domain: Option, } -#[cfg(any(all(windows, feature = "winauth"), doc))] -#[cfg_attr(feature = "docs", doc(all(windows, feature = "winauth")))] +#[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs"), doc))] +#[cfg_attr( + docsrs, + doc(cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs")))) +)] impl Debug for WindowsAuth { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WindowsAuth") @@ -48,9 +54,14 @@ impl Debug for WindowsAuth { pub enum AuthMethod { /// Authenticate directly with SQL Server. SqlServer(SqlServerAuth), - /// Authenticate with Windows credentials. - #[cfg(any(all(windows, feature = "winauth"), doc))] - #[cfg_attr(feature = "docs", doc(cfg(all(windows, feature = "winauth"))))] + /// Authenticate with Windows credentials. On Windows this uses SSPI via the + /// `winauth` feature; on Unix it uses NTLM (no Kerberos) via the `sspi-rs` + /// feature. + #[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs"), doc))] + #[cfg_attr( + docsrs, + doc(cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs")))) + )] Windows(WindowsAuth), /// Authenticate as the currently logged in user. On Windows uses SSPI and /// Kerberos on Unix platforms. @@ -60,7 +71,7 @@ pub enum AuthMethod { doc ))] #[cfg_attr( - feature = "docs", + docsrs, doc(cfg(any(windows, all(unix, feature = "integrated-auth-gssapi")))) )] Integrated, @@ -81,8 +92,11 @@ impl AuthMethod { } /// Construct a new Windows authentication configuration. - #[cfg(any(all(windows, feature = "winauth"), doc))] - #[cfg_attr(feature = "docs", doc(cfg(all(windows, feature = "winauth"))))] + #[cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs"), doc))] + #[cfg_attr( + docsrs, + doc(cfg(any(all(windows, feature = "winauth"), all(unix, feature = "sspi-rs")))) + )] pub fn windows(user: impl AsRef, password: impl ToString) -> Self { let (domain, user) = match user.as_ref().find('\\') { Some(idx) => (Some(&user.as_ref()[..idx]), &user.as_ref()[idx + 1..]), diff --git a/src/client/config.rs b/src/client/config.rs index 3d6994f0f..c7fada926 100644 --- a/src/client/config.rs +++ b/src/client/config.rs @@ -18,10 +18,17 @@ use jdbc::*; /// When using an [ADO.NET connection string], it can be /// constructed using the [`from_ado_string`] function. /// +/// Alternatively, a [`ConfigBuilder`] can be used for an ergonomic, +/// chainable construction. Create one via [`builder`], call its +/// setter methods and finalize it with [`build`]. +/// /// [`Client`]: struct.Client.html /// [ADO.NET connection string]: https://docs.microsoft.com/en-us/dotnet/framework/data/adonet/connection-strings /// [`from_ado_string`]: struct.Config.html#method.from_ado_string /// [`get_addr`]: struct.Config.html#method.get_addr +/// [`ConfigBuilder`]: struct.ConfigBuilder.html +/// [`builder`]: struct.Config.html#method.builder +/// [`build`]: struct.ConfigBuilder.html#method.build pub struct Config { pub(crate) host: Option, pub(crate) port: Option, @@ -33,6 +40,15 @@ pub struct Config { pub(crate) auth: AuthMethod, pub(crate) readonly: bool, pub(crate) packet_size: Option, + pub(crate) hostname_in_certificate: Option, + pub(crate) client_name: Option, + pub(crate) multi_subnet_failover: bool, + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + pub(crate) client_cert: Option, } #[derive(Clone, Debug)] @@ -43,6 +59,68 @@ pub(crate) enum TrustConfig { Default, } +/// A client certificate and its private key, presented to the server during the +/// TLS handshake to authenticate the *client* (mutual TLS / TDS 8.0 +/// `ENCRYPT_CLIENT_CERT`). +/// +/// Construct one indirectly via [`Config::client_certificate`] (PEM/DER +/// certificate + private-key files) or [`Config::client_certificate_pkcs12`] +/// (a PKCS#12 / PFX bundle, `native-tls` and `vendored-openssl` only). +#[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" +))] +#[derive(Clone, Debug)] +pub(crate) struct ClientCertificate { + pub(crate) source: ClientCertSource, +} + +#[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" +))] +#[derive(Clone)] +pub(crate) enum ClientCertSource { + /// A certificate file and a separate private-key file. Both may be PEM + /// (`.pem`/`.crt` for the certificate, `.pem`/`.key` for the key) or DER + /// (`.der`); the concrete format is detected from the file extension by the + /// active TLS backend. + CertAndKey { cert: PathBuf, key: PathBuf }, + /// A PKCS#12 / PFX bundle path together with its decryption password. Only + /// supported by the `native-tls` and `vendored-openssl` backends. + #[cfg(any(feature = "native-tls", feature = "vendored-openssl"))] + Pkcs12 { + path: PathBuf, + password: zeroize::Zeroizing, + }, +} + +// Manual `Debug` so the PKCS#12 password is never printed. +#[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" +))] +impl std::fmt::Debug for ClientCertSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientCertSource::CertAndKey { cert, key } => f + .debug_struct("CertAndKey") + .field("cert", cert) + .field("key", key) + .finish(), + #[cfg(any(feature = "native-tls", feature = "vendored-openssl"))] + ClientCertSource::Pkcs12 { path, .. } => f + .debug_struct("Pkcs12") + .field("path", path) + .field("password", &"") + .finish(), + } + } +} + impl Default for Config { fn default() -> Self { Self { @@ -67,6 +145,15 @@ impl Default for Config { auth: AuthMethod::None, readonly: false, packet_size: None, + hostname_in_certificate: None, + client_name: None, + multi_subnet_failover: false, + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + client_cert: None, } } } @@ -77,6 +164,33 @@ impl Config { Self::default() } + /// Create a new [`ConfigBuilder`] initialized with the default settings. + /// + /// This provides an ergonomic, chainable alternative to constructing a + /// [`Config`] via its individual setter methods. + /// + /// # Example + /// + /// ``` + /// # use tiberius::{Config, AuthMethod}; + /// let config = Config::builder() + /// .host("localhost") + /// .port(1433) + /// .database("master") + /// .authentication(AuthMethod::sql_server("SA", "")) + /// .build(); + /// + /// assert_eq!("localhost:1433", config.get_addr()); + /// ``` + /// + /// [`ConfigBuilder`]: struct.ConfigBuilder.html + /// [`Config`]: struct.Config.html + pub fn builder() -> ConfigBuilder { + ConfigBuilder { + inner: Self::default(), + } + } + /// A host or ip address to connect to. /// /// - Defaults to `localhost`. @@ -175,6 +289,28 @@ impl Config { } } + /// Sets the hostname that the server certificate is validated against, + /// instead of the value given to [`host`]. + /// + /// This is useful when connecting through an IP address, a tunnel, or a + /// load balancer whose certificate carries a different subject/SAN than the + /// address used to reach it (see issue #340). + /// + /// - Defaults to the value of [`host`]. + /// + /// [`host`]: Config::host + pub fn hostname_in_certificate(&mut self, hostname: impl ToString) { + self.hostname_in_certificate = Some(hostname.to_string()); + } + + /// Sets the client / workstation name reported to the server in the login + /// record (queryable with `HOST_NAME()`). + /// + /// - Defaults to the local workstation id (the machine hostname). + pub fn client_name(&mut self, name: impl ToString) { + self.client_name = Some(name.to_string()); + } + /// Sets the authentication method. /// /// - Defaults to `None`. @@ -189,6 +325,110 @@ impl Config { self.readonly = readnoly; } + /// Enable multi-subnet failover. + /// + /// When enabled and the server host name resolves to more than one IP + /// address (for example, an Always On availability group listener spread + /// across subnets), connections are attempted to all resolved addresses in + /// parallel and the first one to succeed is used. This mirrors the ADO.NET + /// `MultiSubnetFailover` connection-string keyword. + /// + /// - Defaults to `false`. + pub fn multi_subnet_failover(&mut self, multi_subnet_failover: bool) { + self.multi_subnet_failover = multi_subnet_failover; + } + + /// Returns whether multi-subnet failover is enabled. + pub fn get_multi_subnet_failover(&self) -> bool { + self.multi_subnet_failover + } + + /// Supplies a client certificate and private key used to authenticate the + /// client to the server during the TLS handshake (mutual TLS). This is + /// required for TDS 8.0 "strict" connections that use client-certificate + /// authentication (`ENCRYPT_CLIENT_CERT`), and may also be used with the + /// classic (pre-8.0) TLS handshake when the server requests a client + /// certificate. + /// + /// Both arguments are paths to files: + /// + /// - `cert`: the client certificate, PEM (`.pem`/`.crt`) or DER (`.der`). + /// - `key`: the matching private key, PEM (`.pem`/`.key`) or DER (`.der`, + /// PKCS#8). + /// + /// Backend support: + /// + /// - `rustls`: PEM and DER certificate/key files. + /// - `native-tls`: PEM certificate + PEM PKCS#8 key only (DER files are + /// rejected at connect time; use [`client_certificate_pkcs12`] for a + /// bundled DER identity). + /// - `vendored-openssl` (opentls): does not support separate certificate/key + /// files; use [`client_certificate_pkcs12`] instead. + /// + /// - Defaults to no client certificate. + /// + /// [`client_certificate_pkcs12`]: Config::client_certificate_pkcs12 + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + #[cfg_attr( + docsrs, + doc(cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))) + )] + pub fn client_certificate(&mut self, cert: impl Into, key: impl Into) { + self.client_cert = Some(ClientCertificate { + source: ClientCertSource::CertAndKey { + cert: cert.into(), + key: key.into(), + }, + }); + } + + /// Supplies a client identity from a PKCS#12 / PFX bundle (certificate, + /// private key and any chain, encrypted with `password`) used to + /// authenticate the client to the server during the TLS handshake (mutual + /// TLS). + /// + /// Only supported by the `native-tls` and `vendored-openssl` backends; the + /// `rustls` backend rejects PKCS#12 identities at connect time (supply + /// separate PEM/DER files via [`client_certificate`] instead). + /// + /// - Defaults to no client certificate. + /// + /// [`client_certificate`]: Config::client_certificate + #[cfg(any(feature = "native-tls", feature = "vendored-openssl"))] + #[cfg_attr( + docsrs, + doc(cfg(any(feature = "native-tls", feature = "vendored-openssl"))) + )] + pub fn client_certificate_pkcs12( + &mut self, + path: impl Into, + password: impl Into, + ) { + self.client_cert = Some(ClientCertificate { + source: ClientCertSource::Pkcs12 { + path: path.into(), + password: zeroize::Zeroizing::new(password.into()), + }, + }); + } + + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + pub(crate) fn get_client_certificate(&self) -> Option<&ClientCertificate> { + self.client_cert.as_ref() + } + pub(crate) fn get_host(&self) -> &str { self.host .as_deref() @@ -196,6 +436,17 @@ impl Config { .unwrap_or("localhost") } + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + pub(crate) fn get_hostname_in_certificate(&self) -> &str { + self.hostname_in_certificate + .as_deref() + .unwrap_or_else(|| self.get_host()) + } + pub(crate) fn get_port(&self) -> u16 { match (self.port, self.instance_name.as_ref()) { // A user-defined port, we must use that. @@ -228,8 +479,11 @@ impl Config { /// |`database`|``|The name of the database.| /// |`TrustServerCertificate`|`true`,`false`,`yes`,`no`|Specifies whether the driver trusts the server certificate when connecting using TLS. Cannot be used toghether with `TrustServerCertificateCA`| /// |`TrustServerCertificateCA`|``|Path to a `pem`, `crt` or `der` certificate file. Cannot be used together with `TrustServerCertificate`| - /// |`encrypt`|`true`,`false`,`yes`,`no`,`DANGER_PLAINTEXT`|Specifies whether the driver uses TLS to encrypt communication.| + /// |`encrypt`|`strict`,`true`,`false`,`yes`,`no`,`DANGER_PLAINTEXT`|Specifies whether the driver uses TLS to encrypt communication. `strict` (TDS 8.0) requires the `tds80` feature.| /// |`Application Name`, `ApplicationName`|``|Sets the application name for the connection.| + /// |`HostNameInCertificate`, `HostName In Certificate`|``|The hostname the server certificate is validated against. Defaults to the value of the `Server` keyword (host).| + /// |`WorkstationID`, `Workstation ID`|``|The client / workstation name reported to the server.| + /// |`MultiSubnetFailover`|`true`,`false`,`yes`,`no`|When enabled, connections are attempted in parallel to all IP addresses the server resolves to, and the first to succeed is used.| /// /// [ADO.NET connection string]: https://docs.microsoft.com/en-us/dotnet/framework/data/adonet/connection-strings pub fn from_ado_string(s: &str) -> crate::Result { @@ -283,14 +537,217 @@ impl Config { builder.trust_cert_ca(ca); } + if let Some(hostname_in_cert) = s.hostname_in_certificate() { + builder.hostname_in_certificate(hostname_in_cert); + } + builder.encryption(s.encrypt()?); builder.readonly(s.readonly()); + if let Some(client_name) = s.client_name() { + builder.client_name(client_name); + } + builder.multi_subnet_failover(s.multi_subnet_failover()?); + Ok(builder) } } +/// A builder for [`Config`], providing an ergonomic, chainable way to +/// construct a connection configuration. +/// +/// Create a builder with [`Config::builder`], set the desired options by +/// calling its methods (each returns the builder to allow chaining) and +/// finalize it with [`build`]. +/// +/// # Example +/// +/// ``` +/// # use tiberius::{Config, AuthMethod, EncryptionLevel}; +/// let config = Config::builder() +/// .host("localhost") +/// .port(1433) +/// .database("master") +/// .encryption(EncryptionLevel::NotSupported) +/// .authentication(AuthMethod::sql_server("SA", "")) +/// .build(); +/// ``` +/// +/// [`Config`]: struct.Config.html +/// [`Config::builder`]: struct.Config.html#method.builder +/// [`build`]: struct.ConfigBuilder.html#method.build +#[derive(Clone, Debug)] +pub struct ConfigBuilder { + inner: Config, +} + +impl ConfigBuilder { + /// A host or ip address to connect to. + /// + /// - Defaults to `localhost`. + pub fn host(mut self, host: impl ToString) -> Self { + self.inner.host = Some(host.to_string()); + self + } + + /// The server port. + /// + /// - Defaults to `1433`. + pub fn port(mut self, port: u16) -> Self { + self.inner.port = Some(port); + self + } + + /// The database to connect to. + /// + /// - Defaults to `master`. + pub fn database(mut self, database: impl ToString) -> Self { + self.inner.database = Some(database.to_string()); + self + } + + /// The instance name as defined in the SQL Browser. Only available on + /// Windows platforms. + /// + /// If specified, the port is replaced with the value returned from the + /// browser. + /// + /// - Defaults to no name specified. + pub fn instance_name(mut self, name: impl ToString) -> Self { + self.inner.instance_name = Some(name.to_string()); + self + } + + /// Sets the application name to the connection, queryable with the + /// `APP_NAME()` command. + /// + /// - Defaults to no name specified. + pub fn application_name(mut self, name: impl ToString) -> Self { + self.inner.application_name = Some(name.to_string()); + self + } + + /// Set the preferred encryption level. + /// + /// - With `tls` feature, defaults to `Required`. + /// - Without `tls` feature, defaults to `NotSupported`. + pub fn encryption(mut self, encryption: EncryptionLevel) -> Self { + self.inner.encryption = encryption; + self + } + + /// If set, the server certificate will not be validated and it is accepted + /// as-is. + /// + /// On production setting, the certificate should be added to the local key + /// storage (or use `trust_cert_ca` instead), using this setting is potentially dangerous. + /// + /// # Panics + /// Will panic in case `trust_cert_ca` was called before. + /// + /// - Defaults to `default`, meaning server certificate is validated against system-truststore. + pub fn trust_cert(mut self) -> Self { + if let TrustConfig::CaCertificateLocation(_) = &self.inner.trust { + panic!("'trust_cert' and 'trust_cert_ca' are mutual exclusive! Only use one.") + } + self.inner.trust = TrustConfig::TrustAll; + self + } + + /// If set, the server certificate will be validated against the given CA certificate in + /// in addition to the system-truststore. + /// Useful when using self-signed certificates on the server without having to disable the + /// trust-chain. + /// + /// # Panics + /// Will panic in case `trust_cert` was called before. + /// + /// - Defaults to validating the server certificate is validated against system's certificate storage. + pub fn trust_cert_ca(mut self, path: impl ToString) -> Self { + if let TrustConfig::TrustAll = &self.inner.trust { + panic!("'trust_cert' and 'trust_cert_ca' are mutual exclusive! Only use one.") + } else { + self.inner.trust = TrustConfig::CaCertificateLocation(PathBuf::from(path.to_string())) + } + self + } + + /// Sets the authentication method. + /// + /// - Defaults to `None`. + pub fn authentication(mut self, auth: AuthMethod) -> Self { + self.inner.auth = auth; + self + } + + /// Sets ApplicationIntent readonly. + /// + /// - Defaults to `false`. + pub fn readonly(mut self, readonly: bool) -> Self { + self.inner.readonly = readonly; + self + } + + /// Supplies a client certificate and private key for mutual TLS. + /// + /// See [`Config::client_certificate`] for details and backend support. + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + #[cfg_attr( + docsrs, + doc(cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))) + )] + pub fn client_certificate(mut self, cert: impl Into, key: impl Into) -> Self { + self.inner.client_certificate(cert, key); + self + } + + /// Supplies a client identity from a PKCS#12 / PFX bundle for mutual TLS. + /// + /// See [`Config::client_certificate_pkcs12`] for details and backend + /// support. + #[cfg(any(feature = "native-tls", feature = "vendored-openssl"))] + #[cfg_attr( + docsrs, + doc(cfg(any(feature = "native-tls", feature = "vendored-openssl"))) + )] + pub fn client_certificate_pkcs12( + mut self, + path: impl Into, + password: impl Into, + ) -> Self { + self.inner.client_certificate_pkcs12(path, password); + self + } + + /// Produces the finalized [`Config`] from this builder. + /// + /// [`Config`]: struct.Config.html + pub fn build(self) -> Config { + self.inner + } +} + +impl From for ConfigBuilder { + fn from(config: Config) -> Self { + ConfigBuilder { inner: config } + } +} + +impl From for Config { + fn from(builder: ConfigBuilder) -> Self { + builder.inner + } +} + pub(crate) struct ServerDefinition { host: Option, port: Option, @@ -328,7 +785,23 @@ pub(crate) trait ConfigString { (None, None) => Ok(AuthMethod::Integrated), _ => Ok(AuthMethod::windows(user.unwrap_or(""), pw.unwrap_or(""))), }, - #[cfg(feature = "integrated-auth-gssapi")] + // On Unix with `sspi-rs`, `IntegratedSecurity=SSPI` (or a truthy + // value) uses NTLM when a username/password is supplied, and falls + // back to Kerberos (`Integrated`) only if `integrated-auth-gssapi` + // is also enabled and no credentials are given. + #[cfg(all(unix, feature = "sspi-rs"))] + Some(val) if val.to_lowercase() == "sspi" || Self::parse_bool(val)? => { + match (user, pw) { + (Some(user), Some(pw)) => Ok(AuthMethod::windows(user, pw)), + #[cfg(feature = "integrated-auth-gssapi")] + (None, None) => Ok(AuthMethod::Integrated), + _ => Ok(AuthMethod::windows(user.unwrap_or(""), pw.unwrap_or(""))), + } + } + #[cfg(all( + feature = "integrated-auth-gssapi", + not(all(unix, feature = "sspi-rs")) + ))] Some(val) if val.to_lowercase() == "sspi" || Self::parse_bool(val)? => { Ok(AuthMethod::Integrated) } @@ -364,6 +837,20 @@ pub(crate) trait ConfigString { .map(|ca| ca.to_string()) } + fn hostname_in_certificate(&self) -> Option { + self.dict() + .get("hostnameincertificate") + .or_else(|| self.dict().get("hostname in certificate")) + .map(|host| host.to_string()) + } + + fn client_name(&self) -> Option { + self.dict() + .get("workstationid") + .or_else(|| self.dict().get("workstation id")) + .map(|name| name.to_string()) + } + #[cfg(any( feature = "rustls", feature = "native-tls", @@ -376,6 +863,9 @@ pub(crate) trait ConfigString { Ok(true) => Ok(EncryptionLevel::Required), Ok(false) => Ok(EncryptionLevel::Off), Err(_) if val == "DANGER_PLAINTEXT" => Ok(EncryptionLevel::NotSupported), + Err(_) if val.eq_ignore_ascii_case("strict") && cfg!(feature = "tds80") => { + Ok(EncryptionLevel::Strict) + } Err(e) => Err(e), }) .unwrap_or(Ok(EncryptionLevel::Off)) @@ -406,4 +896,145 @@ pub(crate) trait ConfigString { .filter(|val| val.trim().eq_ignore_ascii_case("ReadOnly")) .is_some() } + + fn multi_subnet_failover(&self) -> crate::Result { + self.dict() + .get("multisubnetfailover") + .map(Self::parse_bool) + .unwrap_or(Ok(false)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_builder_constructs_config() { + let config = Config::builder() + .host("db.example.com") + .port(4433) + .database("northwind") + .application_name("my-app") + .authentication(AuthMethod::sql_server("SA", "secret")) + .readonly(true) + .build(); + + assert_eq!("db.example.com", config.get_host()); + assert_eq!(4433, config.get_port()); + assert_eq!("db.example.com:4433", config.get_addr()); + assert_eq!(Some("northwind"), config.database.as_deref()); + assert_eq!(Some("my-app"), config.application_name.as_deref()); + assert!(config.readonly); + assert!(matches!(config.auth, AuthMethod::SqlServer(_))); + assert!(matches!(config.trust, TrustConfig::Default)); + } + + #[test] + fn config_builder_roundtrips_via_from() { + let config = Config::builder().host("localhost").port(1433).build(); + let builder: ConfigBuilder = config.into(); + let config = builder.database("master").build(); + + assert_eq!("localhost:1433", config.get_addr()); + assert_eq!(Some("master"), config.database.as_deref()); + } + + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + #[test] + fn client_certificate_sets_cert_and_key_source() { + let mut config = Config::new(); + assert!(config.get_client_certificate().is_none()); + + config.client_certificate("/tmp/client.pem", "/tmp/client.key"); + + let cert = config + .get_client_certificate() + .expect("client certificate should be set"); + match &cert.source { + ClientCertSource::CertAndKey { cert, key } => { + assert_eq!(cert, &PathBuf::from("/tmp/client.pem")); + assert_eq!(key, &PathBuf::from("/tmp/client.key")); + } + #[allow(unreachable_patterns)] + other => panic!("expected CertAndKey source, got {other:?}"), + } + } + + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + #[test] + fn config_builder_sets_client_certificate() { + let config = Config::builder() + .host("localhost") + .client_certificate("cert.der", "key.der") + .build(); + + match &config + .get_client_certificate() + .expect("client certificate should be set") + .source + { + ClientCertSource::CertAndKey { cert, key } => { + assert_eq!(cert, &PathBuf::from("cert.der")); + assert_eq!(key, &PathBuf::from("key.der")); + } + #[allow(unreachable_patterns)] + other => panic!("expected CertAndKey source, got {other:?}"), + } + } + + #[cfg(any(feature = "native-tls", feature = "vendored-openssl"))] + #[test] + fn client_certificate_pkcs12_sets_bundle_source() { + let mut config = Config::new(); + config.client_certificate_pkcs12("/tmp/identity.pfx", "s3cr3t"); + + match &config + .get_client_certificate() + .expect("client certificate should be set") + .source + { + ClientCertSource::Pkcs12 { path, password } => { + assert_eq!(path, &PathBuf::from("/tmp/identity.pfx")); + assert_eq!(password.as_str(), "s3cr3t"); + } + other => panic!("expected Pkcs12 source, got {other:?}"), + } + } + + #[cfg(any(feature = "native-tls", feature = "vendored-openssl"))] + #[test] + fn client_certificate_debug_redacts_pkcs12_password() { + let mut config = Config::new(); + config.client_certificate_pkcs12("/tmp/identity.pfx", "topsecret"); + + let dbg = format!("{:?}", config.get_client_certificate().unwrap()); + assert!(dbg.contains("")); + assert!(!dbg.contains("topsecret")); + } + + #[cfg(all(unix, feature = "sspi-rs"))] + #[test] + fn ado_integrated_security_sspi_with_credentials_uses_windows_ntlm() { + let config = Config::from_ado_string( + "server=tcp:localhost,1433;IntegratedSecurity=SSPI;uid=DOMAIN\\user;pwd=secret", + ) + .unwrap(); + + match config.auth { + AuthMethod::Windows(auth) => { + assert_eq!("user", auth.user); + assert_eq!(Some("DOMAIN"), auth.domain.as_deref()); + } + other => panic!("expected Windows NTLM auth, got {other:?}"), + } + } } diff --git a/src/client/config/ado_net.rs b/src/client/config/ado_net.rs index 018f92da7..ac4fb9738 100644 --- a/src/client/config/ado_net.rs +++ b/src/client/config/ado_net.rs @@ -470,6 +470,53 @@ mod tests { Ok(()) } + #[test] + #[cfg(feature = "tds80")] + fn encryption_parsing_strict() -> crate::Result<()> { + let test_str = "encrypt=strict"; + let ado: AdoNetConfig = test_str.parse()?; + + assert_eq!(EncryptionLevel::Strict, ado.encrypt()?); + + Ok(()) + } + + #[test] + fn client_name_parsing() -> crate::Result<()> { + let test_str = "workstationid=meow"; + let ado: AdoNetConfig = test_str.parse()?; + + assert_eq!(Some("meow".into()), ado.client_name()); + + let test_str = "Workstation ID=meow"; + let ado: AdoNetConfig = test_str.parse()?; + + assert_eq!(Some("meow".into()), ado.client_name()); + + Ok(()) + } + + #[test] + fn hostname_in_certificate_parsing() -> crate::Result<()> { + let test_str = "HostNameInCertificate=foo.example.com"; + let ado: AdoNetConfig = test_str.parse()?; + + assert_eq!( + Some("foo.example.com".into()), + ado.hostname_in_certificate() + ); + + let test_str = "HostName In Certificate=foo.example.com"; + let ado: AdoNetConfig = test_str.parse()?; + + assert_eq!( + Some("foo.example.com".into()), + ado.hostname_in_certificate() + ); + + Ok(()) + } + #[test] fn application_name_parsing() -> crate::Result<()> { let test_str = "Application Name=meow"; @@ -505,4 +552,43 @@ mod tests { Ok(()) } + + #[test] + fn multi_subnet_failover_parsing() -> crate::Result<()> { + let test_str = "MultiSubnetFailover=true"; + let ado: AdoNetConfig = test_str.parse()?; + assert!(ado.multi_subnet_failover()?); + + let test_str = "MultiSubnetFailover=yes"; + let ado: AdoNetConfig = test_str.parse()?; + assert!(ado.multi_subnet_failover()?); + + let test_str = "MultiSubnetFailover=false"; + let ado: AdoNetConfig = test_str.parse()?; + assert!(!ado.multi_subnet_failover()?); + + Ok(()) + } + + #[test] + fn multi_subnet_failover_parsing_missing() -> crate::Result<()> { + let test_str = ""; + let ado: AdoNetConfig = test_str.parse()?; + assert!(!ado.multi_subnet_failover()?); + + Ok(()) + } + + #[test] + fn multi_subnet_failover_from_ado_string() -> crate::Result<()> { + let config = crate::Config::from_ado_string( + "server=tcp:my-server.com,1433;MultiSubnetFailover=true", + )?; + assert!(config.get_multi_subnet_failover()); + + let config = crate::Config::from_ado_string("server=tcp:my-server.com,1433")?; + assert!(!config.get_multi_subnet_failover()); + + Ok(()) + } } diff --git a/src/client/connection.rs b/src/client/connection.rs index 14ce262ae..1729aa0b1 100644 --- a/src/client/connection.rs +++ b/src/client/connection.rs @@ -18,7 +18,7 @@ use crate::{ }; use asynchronous_codec::Framed; use bytes::BytesMut; -#[cfg(any(windows, feature = "integrated-auth-gssapi"))] +#[cfg(any(windows, feature = "integrated-auth-gssapi", feature = "sspi-rs"))] use codec::TokenSspi; use futures_util::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use futures_util::ready; @@ -32,6 +32,11 @@ use libgssapi::{ oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, }; use pretty_hex::*; +#[cfg(all(unix, feature = "sspi-rs"))] +use sspi::{ + AuthIdentity, BufferType, ClientRequestFlags, CredentialUse, DataRepresentation, Ntlm, + SecurityBuffer, Sspi, SspiImpl, Username, +}; #[cfg(all(unix, feature = "integrated-auth-gssapi"))] use std::ops::Deref; use std::{cmp, fmt::Debug, io, pin::Pin, task}; @@ -73,6 +78,9 @@ impl Debug for Connection { impl Connection { /// Creates a new connection + /// + /// Note: `tcp_stream` is a connected stream, so some parts of the + /// [`Config`] need to be handled outside of this method. pub(crate) async fn connect(config: Config, tcp_stream: S) -> crate::Result> { let context = { let mut context = Context::new(); @@ -80,6 +88,34 @@ impl Connection { context }; + // In TDS 8.0 "strict" mode the TLS handshake happens *before* the + // prelogin, so we wrap the stream in TLS up front. In every other mode + // the connection starts in the clear and TLS (if any) is negotiated + // during the prelogin. + #[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + ))] + let transport = match config.encryption { + EncryptionLevel::Strict => { + event!(Level::DEBUG, "Performing a TLS handshake (TDS 8.0 strict)"); + let mut pre_login_stream = TlsPreloginWrapper::new(tcp_stream); + // No prelogin framing is used for the strict handshake; pass the + // raw TLS bytes straight through. + pre_login_stream.handshake_complete(); + let stream = create_tls_stream(&config, pre_login_stream).await?; + event!(Level::DEBUG, "TLS handshake successful"); + Framed::new(MaybeTlsStream::Tls(stream), PacketCodec) + } + _ => Framed::new(MaybeTlsStream::Raw(tcp_stream), PacketCodec), + }; + + #[cfg(not(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + )))] let transport = Framed::new(MaybeTlsStream::Raw(tcp_stream), PacketCodec); let mut connection = Self { @@ -92,7 +128,11 @@ impl Connection { let fed_auth_required = matches!(config.auth, AuthMethod::AADToken(_)); let prelogin = connection - .prelogin(config.encryption, fed_auth_required) + .prelogin( + config.encryption, + fed_auth_required, + config.instance_name.clone(), + ) .await?; let encryption = prelogin.negotiated_encryption(config.encryption)?; @@ -106,6 +146,7 @@ impl Connection { config.database, config.host, config.application_name, + config.client_name, config.readonly, config.packet_size, prelogin, @@ -122,7 +163,7 @@ impl Connection { TokenStream::new(self).flush_done().await } - #[cfg(any(windows, feature = "integrated-auth-gssapi"))] + #[cfg(any(windows, feature = "integrated-auth-gssapi", feature = "sspi-rs"))] /// Flush the incoming token stream until receiving `SSPI` token. async fn flush_sspi(&mut self) -> crate::Result { TokenStream::new(self).flush_sspi().await @@ -261,6 +302,24 @@ impl Connection { self.transport.flush().await } + /// Sends a TDS Attention signal (packet type `0x06`, MS-TDS section + /// 2.2.1.6) to request cancellation of the request currently in flight on + /// this connection, then drains the token stream until the acknowledging + /// DONE token (with the `DONE_ATTN` status bit set) is received. + /// + /// The Attention message carries no payload, so it is written to the wire + /// as a single end-of-message packet. Draining the acknowledgement leaves + /// the connection clean and ready to be reused for further queries. + pub(crate) async fn cancel_request(&mut self) -> crate::Result { + let id = self.context.next_packet_id(); + let header = PacketHeader::attention(id); + + self.write_to_wire(header, BytesMut::new()).await?; + self.flush_sink().await?; + + TokenStream::new(self).flush_done_attention().await + } + /// Cleans the packet stream from previous use. It is important to use the /// whole stream before using the connection again. Flushing the stream /// makes sure we don't have any old data causing undefined behaviour after @@ -309,10 +368,12 @@ impl Connection { &mut self, encryption: EncryptionLevel, fed_auth_required: bool, + instance_name: Option, ) -> crate::Result { let mut msg = PreloginMessage::new(); msg.encryption = encryption; msg.fed_auth_required = fed_auth_required; + msg.instance_name = instance_name.clone(); let id = self.context.next_packet_id(); self.send(PacketHeader::pre_login(id), msg).await?; @@ -320,6 +381,8 @@ impl Connection { let response: PreloginMessage = codec::collect_from(self).await?; // threadid (should be empty when sent from server to client) debug_assert_eq!(response.thread_id, 0); + // ensure the server accepted the instance we asked it to validate + response.validate_instance(instance_name.as_deref())?; Ok(response) } @@ -333,6 +396,7 @@ impl Connection { db: Option, server_name: Option, application_name: Option, + client_name: Option, readonly: bool, packet_size: Option, prelogin: PreloginMessage, @@ -351,6 +415,10 @@ impl Connection { login_message.app_name(app_name); } + if let Some(client_name) = client_name { + login_message.hostname(client_name); + } + login_message.readonly(readonly); if let Some(size) = packet_size { @@ -388,14 +456,14 @@ impl Connection { } #[cfg(all(unix, feature = "integrated-auth-gssapi"))] AuthMethod::Integrated => { - let mut s = OidSet::new()?; - s.add(&GSS_MECH_KRB5)?; + let mut s = OidSet::new(); + s.add(GSS_MECH_KRB5)?; let client_cred = Cred::acquire(None, None, CredUsage::Initiate, Some(&s))?; let mut ctx = ClientCtx::new( Some(client_cred), - Name::new(self.context.spn().as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL))?, + Name::new(self.context.spn().as_bytes(), Some(GSS_NT_KRB5_PRINCIPAL))?, CtxFlags::GSS_C_MUTUAL_FLAG | CtxFlags::GSS_C_SEQUENCE_FLAG, None, ); @@ -427,6 +495,84 @@ impl Connection { self.send(header, next_token).await?; } + #[cfg(all(unix, feature = "sspi-rs"))] + AuthMethod::Windows(auth) => { + let mut ntlm = Ntlm::new(); + + let username = + Username::new(&auth.user, auth.domain.as_deref()).map_err(sspi::Error::from)?; + + let identity = AuthIdentity { + username, + password: auth.password.clone().into(), + }; + + let mut creds = ntlm + .acquire_credentials_handle() + .with_credential_use(CredentialUse::Outbound) + .with_auth_data(&identity) + .execute(&mut ntlm)?; + + let spn = self.context.spn().to_string(); + + // First leg of the NTLM handshake: produce the NEGOTIATE token + // and ship it in the login packet as integrated security data. + let mut input = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)]; + let mut output = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)]; + + let mut builder = ntlm + .initialize_security_context() + .with_credentials_handle(&mut creds.credentials_handle) + .with_context_requirements( + ClientRequestFlags::CONFIDENTIALITY | ClientRequestFlags::ALLOCATE_MEMORY, + ) + .with_target_data_representation(DataRepresentation::Native) + .with_target_name(&spn) + .with_input(&mut input) + .with_output(&mut output); + + ntlm.initialize_security_context_impl(&mut builder)? + .resolve_to_result()?; + + login_message.integrated_security(Some(output[0].buffer.clone())); + + let id = self.context.next_packet_id(); + self.send(PacketHeader::login(id), login_message).await?; + self = self.post_login_encryption(encryption); + + // Second leg: consume the server's CHALLENGE token and reply + // with the AUTHENTICATE token. + let sspi_bytes = self.flush_sspi().await?; + + let mut input = vec![SecurityBuffer::new( + sspi_bytes.as_ref().to_vec(), + BufferType::Token, + )]; + let mut output = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)]; + + let mut builder = ntlm + .initialize_security_context() + .with_credentials_handle(&mut creds.credentials_handle) + .with_context_requirements( + ClientRequestFlags::CONFIDENTIALITY | ClientRequestFlags::ALLOCATE_MEMORY, + ) + .with_target_data_representation(DataRepresentation::Native) + .with_target_name(&spn) + .with_input(&mut input) + .with_output(&mut output); + + ntlm.initialize_security_context_impl(&mut builder)? + .resolve_to_result()?; + + event!(Level::TRACE, authenticate_len = output[0].buffer.len()); + + let id = self.context.next_packet_id(); + self.send( + PacketHeader::login(id), + TokenSspi::new(output[0].buffer.clone()), + ) + .await?; + } #[cfg(all(windows, feature = "winauth"))] AuthMethod::Windows(auth) => { let spn = self.context.spn().to_string(); @@ -495,37 +641,50 @@ impl Connection { config: &Config, encryption: EncryptionLevel, ) -> crate::Result { - if encryption != EncryptionLevel::NotSupported { - event!(Level::DEBUG, "Performing a TLS handshake"); - - let Self { - transport, context, .. - } = self; - let mut stream = match transport.into_inner() { - MaybeTlsStream::Raw(tcp) => { - create_tls_stream(config, TlsPreloginWrapper::new(tcp)).await? - } - _ => unreachable!(), - }; + match encryption { + EncryptionLevel::NotSupported => { + event!( + Level::WARN, + "TLS encryption is not enabled. All traffic including the login credentials are not encrypted." + ); + + Ok(self) + } + // In strict mode the handshake already happened before the prelogin, + // so the transport is already a TLS stream. Nothing to do here. + EncryptionLevel::Strict => { + event!( + Level::TRACE, + "Already in a TLS stream (TDS 8.0 strict), skipping handshake." + ); - stream.get_mut().handshake_complete(); - event!(Level::DEBUG, "TLS handshake successful"); + Ok(self) + } + EncryptionLevel::Off | EncryptionLevel::On | EncryptionLevel::Required => { + event!(Level::DEBUG, "Performing a TLS handshake"); + + let Self { + transport, context, .. + } = self; + let mut stream = match transport.into_inner() { + MaybeTlsStream::Raw(tcp) => { + create_tls_stream(config, TlsPreloginWrapper::new(tcp)).await? + } + _ => unreachable!(), + }; - let transport = Framed::new(MaybeTlsStream::Tls(stream), PacketCodec); + stream.get_mut().handshake_complete(); + event!(Level::DEBUG, "TLS handshake successful"); - Ok(Self { - transport, - context, - flushed: false, - buf: BytesMut::new(), - }) - } else { - event!( - Level::WARN, - "TLS encryption is not enabled. All traffic including the login credentials are not encrypted." - ); + let transport = Framed::new(MaybeTlsStream::Tls(stream), PacketCodec); - Ok(self) + Ok(Self { + transport, + context, + flushed: false, + buf: BytesMut::new(), + }) + } } } @@ -560,7 +719,7 @@ impl Connection { feature = "vendored-openssl" )))] fn check_tls_backend_available(encryption: EncryptionLevel) -> crate::Result<()> { - if let EncryptionLevel::On | EncryptionLevel::Required = encryption { + if let EncryptionLevel::On | EncryptionLevel::Required | EncryptionLevel::Strict = encryption { return Err(crate::Error::Tls( "TLS encryption was requested but the crate was compiled without a TLS backend. \ Enable one of the `native-tls`, `rustls` or `vendored-openssl` features." diff --git a/src/client/tls_stream.rs b/src/client/tls_stream.rs index 9eba1060f..fdb03579b 100644 --- a/src/client/tls_stream.rs +++ b/src/client/tls_stream.rs @@ -1,6 +1,17 @@ use crate::Config; use futures_util::io::{AsyncRead, AsyncWrite}; +/// ALPN protocol name advertised for TDS 8.0 ("strict") encryption. +#[cfg(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" +))] +// Used by the native-tls and rustls backends to advertise TDS 8.0 strict; the +// opentls (vendored-openssl) backend cannot set ALPN, so this is unused there. +#[allow(dead_code)] +pub(crate) const TDS_ALPN_PROTOCOL_NAME: &str = "tds/8.0"; + #[cfg(feature = "native-tls")] mod native_tls_stream; diff --git a/src/client/tls_stream/native_tls_stream.rs b/src/client/tls_stream/native_tls_stream.rs index 73cd10595..5d69c288f 100644 --- a/src/client/tls_stream/native_tls_stream.rs +++ b/src/client/tls_stream/native_tls_stream.rs @@ -1,19 +1,83 @@ use crate::{ - client::{config::Config, TrustConfig}, + client::{ + config::{ClientCertSource, ClientCertificate, Config}, + TrustConfig, + }, error::{Error, IoErrorKind}, }; pub(crate) use async_native_tls::TlsStream; -use async_native_tls::{Certificate, TlsConnector}; +use async_native_tls::{Certificate, Identity, TlsConnector}; use futures_util::io::{AsyncRead, AsyncWrite}; use std::fs; use tracing::{event, Level}; +/// Loads a client identity from the configured source for `native-tls`. +fn load_identity(cert: &ClientCertificate) -> crate::Result { + match &cert.source { + ClientCertSource::CertAndKey { cert, key } => { + let is_pem = |p: &std::path::Path| { + matches!( + p.extension().and_then(|e| e.to_str()), + Some(ext) if ext.eq_ignore_ascii_case("pem") || ext.eq_ignore_ascii_case("crt") || ext.eq_ignore_ascii_case("key") + ) + }; + + if !is_pem(cert) || !is_pem(key) { + return Err(Error::Tls( + "The native-tls backend requires PEM certificate and key files; \ + for a DER-bundled identity use `Config::client_certificate_pkcs12`." + .to_string(), + )); + } + + let cert_buf = fs::read(cert).map_err(|e| Error::Io { + kind: IoErrorKind::InvalidData, + message: format!( + "Could not read client certificate {}: {e}", + cert.to_string_lossy() + ), + })?; + let key_buf = fs::read(key).map_err(|e| Error::Io { + kind: IoErrorKind::InvalidData, + message: format!( + "Could not read client private key {}: {e}", + key.to_string_lossy() + ), + })?; + + Ok(Identity::from_pkcs8(&cert_buf, &key_buf)?) + } + ClientCertSource::Pkcs12 { path, password } => { + let buf = fs::read(path).map_err(|e| Error::Io { + kind: IoErrorKind::InvalidData, + message: format!( + "Could not read PKCS#12 identity {}: {e}", + path.to_string_lossy() + ), + })?; + Ok(Identity::from_pkcs12(&buf, password)?) + } + } +} + pub(crate) async fn create_tls_stream( config: &Config, stream: S, ) -> crate::Result> { let mut builder = TlsConnector::new(); + if matches!(config.encryption, crate::EncryptionLevel::Strict) { + builder = builder.request_alpns(&[super::TDS_ALPN_PROTOCOL_NAME]); + } + + if let Some(cert) = config.get_client_certificate() { + event!( + Level::DEBUG, + "Presenting a client certificate for mutual TLS." + ); + builder = builder.identity(load_identity(cert)?); + } + match &config.trust { TrustConfig::CaCertificateLocation(path) => { if let Ok(buf) = fs::read(path) { @@ -56,5 +120,7 @@ pub(crate) async fn create_tls_stream( } } - Ok(builder.connect(config.get_host(), stream).await?) + Ok(builder + .connect(config.get_hostname_in_certificate(), stream) + .await?) } diff --git a/src/client/tls_stream/opentls_tls_stream.rs b/src/client/tls_stream/opentls_tls_stream.rs index fa8009a65..ec99555fd 100644 --- a/src/client/tls_stream/opentls_tls_stream.rs +++ b/src/client/tls_stream/opentls_tls_stream.rs @@ -1,19 +1,64 @@ use crate::{ - client::{config::Config, TrustConfig}, + client::{ + config::{ClientCertSource, ClientCertificate, Config}, + TrustConfig, + }, error::{Error, IoErrorKind}, }; use futures_util::io::{AsyncRead, AsyncWrite}; pub(crate) use opentls::async_io::{TlsConnector, TlsStream}; -use opentls::Certificate; +use opentls::{Certificate, Identity}; use std::fs; use tracing::{event, Level}; +/// Loads a client identity from the configured source for the `opentls` +/// (vendored OpenSSL) backend. +/// +/// `opentls` only exposes `Identity::from_pkcs12`, so only a PKCS#12 / PFX +/// bundle (supplied via [`Config::client_certificate_pkcs12`]) is supported; +/// separate PEM/DER certificate and key files cannot be loaded by this backend. +fn load_identity(cert: &ClientCertificate) -> crate::Result { + match &cert.source { + ClientCertSource::Pkcs12 { path, password } => { + let buf = fs::read(path).map_err(|e| Error::Io { + kind: IoErrorKind::InvalidData, + message: format!( + "Could not read PKCS#12 identity {}: {e}", + path.to_string_lossy() + ), + })?; + Ok(Identity::from_pkcs12(&buf, password)?) + } + ClientCertSource::CertAndKey { .. } => Err(Error::Tls( + "The vendored-openssl (opentls) backend does not support separate \ + certificate/key files for client authentication; supply a PKCS#12 \ + bundle via `Config::client_certificate_pkcs12` instead." + .to_string(), + )), + } +} + pub(crate) async fn create_tls_stream( config: &Config, stream: S, ) -> crate::Result> { let mut builder = TlsConnector::new(); + if matches!(config.encryption, crate::EncryptionLevel::Strict) { + event!( + Level::WARN, + "OpenTLS does not support ALPN, so the TDS 8.0 ALPN protocol will not be requested. SQL Server will assume TDS 8.0." + ); + } + + if let Some(cert) = config.get_client_certificate() { + event!( + Level::DEBUG, + "Presenting a client certificate for mutual TLS." + ); + builder = builder.identity(load_identity(cert)?); + } + match &config.trust { TrustConfig::CaCertificateLocation(path) => { if let Ok(buf) = fs::read(path) { @@ -56,5 +101,7 @@ pub(crate) async fn create_tls_stream( } } - Ok(builder.connect(config.get_host(), stream).await?) + Ok(builder + .connect(config.get_hostname_in_certificate(), stream) + .await?) } diff --git a/src/client/tls_stream/rustls_tls_stream.rs b/src/client/tls_stream/rustls_tls_stream.rs index 88871ba07..0690cca13 100644 --- a/src/client/tls_stream/rustls_tls_stream.rs +++ b/src/client/tls_stream/rustls_tls_stream.rs @@ -1,5 +1,8 @@ use crate::{ - client::{config::Config, TrustConfig}, + client::{ + config::{ClientCertSource, ClientCertificate, Config}, + TrustConfig, + }, error::IoErrorKind, Error, }; @@ -17,7 +20,7 @@ use tokio_rustls::{ WantsClientCert, }, crypto::aws_lc_rs, - pki_types::{pem::PemObject, CertificateDer, ServerName, UnixTime}, + pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer, ServerName, UnixTime}, ClientConfig, ConfigBuilder, DigitallySignedStruct, Error as RustlsError, RootCertStore, SignatureScheme, WantsVerifier, }, @@ -87,7 +90,10 @@ impl ServerCertVerifier for NoCertVerifier { } fn get_server_name(config: &Config) -> crate::Result> { - match (ServerName::try_from(config.get_host()), &config.trust) { + match ( + ServerName::try_from(config.get_hostname_in_certificate()), + &config.trust, + ) { (Ok(sn), _) => Ok(sn.to_owned()), (Err(_), TrustConfig::TrustAll) => { Ok(ServerName::try_from("placeholder.domain.com").unwrap()) @@ -105,7 +111,9 @@ impl TlsStream { .with_safe_default_protocol_versions() .map_err(|e| crate::Error::Tls(e.to_string()))?; - let client_config = match &config.trust { + // First select the server-certificate verification strategy, yielding a + // builder that still awaits the client-authentication decision. + let cc_builder: ConfigBuilder = match &config.trust { TrustConfig::CaCertificateLocation(path) => { if let Ok(buf) = fs::read(path) { let cert = match path.extension() { @@ -147,9 +155,7 @@ impl TlsStream { }; let mut cert_store = RootCertStore::empty(); cert_store.add(cert)?; - builder - .with_root_certificates(cert_store) - .with_no_client_auth() + builder.with_root_certificates(cert_store) } else { return Err(Error::Io { kind: IoErrorKind::InvalidData, @@ -165,14 +171,38 @@ impl TlsStream { builder .dangerous() .with_custom_certificate_verifier(Arc::new(NoCertVerifier)) - .with_no_client_auth() } TrustConfig::Default => { event!(Level::DEBUG, "Using default trust configuration."); - builder.with_native_roots().with_no_client_auth() + builder.with_native_roots() } }; + // Present a client certificate (mutual TLS / TDS 8.0 + // `ENCRYPT_CLIENT_CERT`) if one was configured, otherwise finalize + // without client authentication. + let mut client_config = match config.get_client_certificate() { + Some(cert) => { + event!( + Level::DEBUG, + "Presenting a client certificate for mutual TLS." + ); + let (chain, key) = load_client_auth(cert)?; + cc_builder + .with_client_auth_cert(chain, key) + .map_err(|e| crate::Error::Tls(e.to_string()))? + } + None => cc_builder.with_no_client_auth(), + }; + + // TDS 8.0 "strict" mode advertises the `tds/8.0` ALPN protocol so the + // server knows to speak TDS directly over the TLS stream. + if matches!(config.encryption, crate::EncryptionLevel::Strict) { + client_config + .alpn_protocols + .push(super::TDS_ALPN_PROTOCOL_NAME.as_bytes().to_vec()); + } + let connector = TlsConnector::from(Arc::new(client_config)); let tls_stream = connector @@ -219,6 +249,95 @@ impl AsyncWrite for TlsStream { } } +/// Loads a client certificate chain and private key from the configured source +/// for use with rustls' `with_client_auth_cert`. +fn load_client_auth( + cert: &ClientCertificate, +) -> crate::Result<(Vec>, PrivateKeyDer<'static>)> { + match &cert.source { + ClientCertSource::CertAndKey { cert, key } => { + let cert_buf = fs::read(cert).map_err(|e| crate::Error::Io { + kind: IoErrorKind::InvalidData, + message: format!( + "Could not read client certificate {}: {e}", + cert.to_string_lossy() + ), + })?; + + // Certificate: PEM (possibly a chain) or a single DER cert. + let chain: Vec> = match cert.extension() { + Some(ext) if ext.eq_ignore_ascii_case("pem") || ext.eq_ignore_ascii_case("crt") => { + CertificateDer::pem_slice_iter(&cert_buf) + .collect::, _>>() + .map_err(|e| crate::Error::Io { + kind: IoErrorKind::InvalidData, + message: format!("Failed to parse PEM client certificate: {e}"), + })? + } + Some(ext) if ext.eq_ignore_ascii_case("der") => { + vec![CertificateDer::from(cert_buf)] + } + Some(_) | None => { + return Err(crate::Error::Io { + kind: IoErrorKind::InvalidInput, + message: "Client certificate has an unsupported file-extension! Supported types are pem, crt and der.".to_string(), + }) + } + }; + + if chain.is_empty() { + return Err(crate::Error::Io { + kind: IoErrorKind::InvalidInput, + message: format!( + "Client certificate file {} contains no certificates", + cert.to_string_lossy() + ), + }); + } + + // Private key: PEM (any of PKCS#8, PKCS#1 or SEC1) or DER (PKCS#8). + let key_buf = fs::read(key).map_err(|e| crate::Error::Io { + kind: IoErrorKind::InvalidData, + message: format!( + "Could not read client private key {}: {e}", + key.to_string_lossy() + ), + })?; + + let key: PrivateKeyDer<'static> = match key.extension() { + Some(ext) + if ext.eq_ignore_ascii_case("pem") || ext.eq_ignore_ascii_case("key") => + { + PrivateKeyDer::from_pem_slice(&key_buf).map_err(|e| crate::Error::Io { + kind: IoErrorKind::InvalidData, + message: format!("Failed to parse PEM private key: {e}"), + })? + } + Some(ext) if ext.eq_ignore_ascii_case("der") => PrivateKeyDer::try_from(key_buf) + .map_err(|e| crate::Error::Io { + kind: IoErrorKind::InvalidData, + message: format!("Failed to parse DER private key: {e}"), + })?, + Some(_) | None => { + return Err(crate::Error::Io { + kind: IoErrorKind::InvalidInput, + message: "Client private key has an unsupported file-extension! Supported types are pem, key and der.".to_string(), + }) + } + }; + + Ok((chain, key)) + } + #[cfg(any(feature = "native-tls", feature = "vendored-openssl"))] + ClientCertSource::Pkcs12 { .. } => Err(crate::Error::Tls( + "The rustls backend does not support PKCS#12 client certificates; \ + supply separate PEM/DER certificate and key files via \ + `Config::client_certificate` instead." + .to_string(), + )), + } +} + trait ConfigBuilderExt { fn with_native_roots(self) -> ConfigBuilder; } diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 000000000..33cb97ff9 --- /dev/null +++ b/src/command.rs @@ -0,0 +1,338 @@ +use std::borrow::Cow; + +use enumflags2::BitFlags; +use futures_util::io::{AsyncRead, AsyncWrite}; + +use crate::{ + tds::{ + codec::{RpcParam, RpcStatus::ByRefValue, RpcValue, TypeInfoTvp}, + stream::{CommandStream, TokenStream}, + }, + Client, ColumnData, IntoSql, +}; + +#[doc(inline)] +pub use tiberius_macros::TableValueRow; + +/// A structure that represents a single row of a table-valued parameter (TVP) +/// implements this trait. +/// +/// It can be derived with `#[derive(TableValueRow)]` for structs with named +/// fields. +pub trait TableValueRow<'a> { + /// Binds this row's field values. Called by [`Command`] before making the + /// call to the server; implementations must call + /// [`SqlTableDataRow::add_field`] once per column, in column order. + fn bind_fields(&self, data_row: &mut SqlTableDataRow<'a>); + /// The database type name that represents this TVP, e.g. `dbo.MyType`. + fn get_db_type() -> &'static str; +} + +/// A collection of [`TableValueRow`] values that can be bound as a +/// table-valued parameter. Implemented for any `IntoIterator` of rows. +pub trait TableValue<'a> { + /// Converts this collection into the internal table data representation. + fn into_sql(self) -> SqlTableData<'a>; +} + +impl<'a, R, C> TableValue<'a> for C +where + R: TableValueRow<'a> + 'a, + C: IntoIterator, +{ + fn into_sql(self) -> SqlTableData<'a> { + let mut data = Vec::new(); + for row in self { + let mut data_row = SqlTableDataRow::new(); + row.bind_fields(&mut data_row); + data.push(data_row); + } + + SqlTableData { + rows: data, + db_type: R::get_db_type(), + } + } +} + +/// A remote command (stored procedure or user-defined function) with bound +/// parameters, executed by name via an RPC request. +#[derive(Debug)] +pub struct Command<'a> { + name: Cow<'a, str>, + // The server rejects repeated parameter names, so uniqueness is not checked here. + params: Vec>, +} + +#[derive(Debug)] +struct CommandParam<'a> { + name: Cow<'a, str>, + out: bool, + data: CommandParamData<'a>, +} + +#[derive(Debug)] +enum CommandParamData<'a> { + Scalar(ColumnData<'a>), + Table(SqlTableData<'a>), +} + +/// The internal representation of a table-valued parameter's data. +#[derive(Debug)] +pub struct SqlTableData<'a> { + rows: Vec>, + db_type: &'a str, +} + +/// A single row of a table-valued parameter, used by [`TableValueRow`] +/// implementations to bind column values. +#[derive(Debug)] +pub struct SqlTableDataRow<'a> { + col_data: Vec>, +} + +impl<'a> SqlTableDataRow<'a> { + fn new() -> SqlTableDataRow<'a> { + SqlTableDataRow { + col_data: Vec::new(), + } + } + + /// Adds a field value to this TVP row. Must be called once per column; the + /// values are sent to the server in call order. + pub fn add_field(&mut self, data: impl IntoSql<'a> + 'a) { + self.col_data.push(data.into_sql()); + } +} + +impl<'a> Command<'a> { + /// Constructs a new command with the given procedure or function name. + pub fn new(proc_name: impl Into>) -> Self { + Self { + name: proc_name.into(), + params: Vec::new(), + } + } + + /// Binds a scalar input parameter with the given name. + pub fn bind_param(&mut self, name: impl Into>, data: impl IntoSql<'a> + 'a) { + self.params.push(CommandParam { + name: name.into(), + out: false, + data: CommandParamData::Scalar(data.into_sql()), + }); + } + + /// Binds a by-ref (OUT) scalar parameter. The returned value can be found by + /// the same name in the [`CommandResult`] returned values. + /// + /// [`CommandResult`]: crate::CommandResult + pub fn bind_out_param(&mut self, name: impl Into>, data: impl IntoSql<'a> + 'a) { + self.params.push(CommandParam { + name: name.into(), + out: true, + data: CommandParamData::Scalar(data.into_sql()), + }); + } + + /// Binds a table-valued parameter. The provided argument must implement + /// [`TableValue`]. + /// + /// # Example + /// + /// ```no_run + /// # use std::env; + /// # use tiberius::Config; + /// # use tiberius::{numeric::Numeric, Command, TableValueRow}; + /// # use tokio_util::compat::TokioAsyncWriteCompatExt; + /// #[derive(TableValueRow)] + /// struct SomeGeoList { + /// eid: i32, + /// lat: Numeric, + /// lon: Numeric, + /// } + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let c_str = env::var("TIBERIUS_TEST_CONNECTION_STRING").unwrap_or( + /// # "server=tcp:localhost,1433;integratedSecurity=true;TrustServerCertificate=true".to_owned(), + /// # ); + /// # let config = Config::from_ado_string(&c_str)?; + /// # let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?; + /// # tcp.set_nodelay(true)?; + /// # let client = tiberius::Client::connect(config, tcp.compat_write()).await?; + /// let r1 = SomeGeoList { + /// eid: 1, + /// lat: Numeric::new_with_scale(10, 6), + /// lon: Numeric::new_with_scale(14, 6), + /// }; + /// let r2 = SomeGeoList { + /// eid: 4, + /// lat: Numeric::new_with_scale(101, 6), + /// lon: Numeric::new_with_scale(142, 6), + /// }; + /// + /// let tbl = vec![r1, r2]; + /// + /// let mut cmd = Command::new("dbo.usp_TheGeoProcedure"); + /// cmd.bind_table("@table", tbl); + /// # Ok(()) + /// # } + /// ``` + pub fn bind_table(&mut self, name: impl Into>, data: impl TableValue<'a> + 'a) { + self.params.push(CommandParam { + name: name.into(), + out: false, + data: CommandParamData::Table(data.into_sql()), + }); + } + + /// The same as [`bind_table`](Self::bind_table), but overrides the database + /// type name used for the TVP. + pub fn bind_table_with_dbtype( + &mut self, + name: impl Into>, + db_type: &'a str, + data: impl TableValue<'a> + 'a, + ) { + self.params.push(CommandParam { + name: name.into(), + out: false, + data: CommandParamData::Table(SqlTableData { + db_type, + ..data.into_sql() + }), + }); + } + + /// Executes the command on the server, returning a [`CommandStream`] that + /// can be collected into a [`CommandResult`] for convenience. + /// + /// [`CommandResult`]: crate::CommandResult + /// + /// # Example + /// + /// ```no_run + /// # use tiberius::{Config, Command}; + /// # use tokio_util::compat::TokioAsyncWriteCompatExt; + /// # use std::env; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let c_str = env::var("TIBERIUS_TEST_CONNECTION_STRING").unwrap_or( + /// # "server=tcp:localhost,1433;integratedSecurity=true;TrustServerCertificate=true".to_owned(), + /// # ); + /// # let config = Config::from_ado_string(&c_str)?; + /// # let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?; + /// # tcp.set_nodelay(true)?; + /// # let mut client = tiberius::Client::connect(config, tcp.compat_write()).await?; + /// let mut cmd = Command::new("dbo.usp_SomeStoredProc"); + /// + /// cmd.bind_param("@foo", 34i32); + /// cmd.bind_out_param("@bar", "bar"); + /// let res = cmd.exec(&mut client).await?.into_command_result().await?; + /// + /// let rv: Option<&str> = res.try_return_value("@bar")?; + /// let rc = res.return_code(); + /// # Ok(()) + /// # } + /// ``` + pub async fn exec<'b, S>(self, client: &'b mut Client) -> crate::Result> + where + S: AsyncRead + AsyncWrite + Unpin + Send, + { + let rpc_params = Command::build_rpc_params(self.params, client).await?; + + client.connection.flush_stream().await?; + client.rpc_run_command(self.name, rpc_params).await?; + + let ts = TokenStream::new(&mut client.connection); + let result = CommandStream::new(ts.try_unfold()); + + Ok(result) + } + + async fn build_rpc_params<'b, S>( + cmd_params: Vec>, + client: &'b mut Client, + ) -> crate::Result>> + where + S: AsyncRead + AsyncWrite + Unpin + Send, + { + let mut rpc_params = Vec::new(); + for p in cmd_params { + let rpc_val = match p.data { + CommandParamData::Scalar(col) => RpcValue::Scalar(col), + CommandParamData::Table(t) => { + let type_info_tvp = TypeInfoTvp::new( + t.db_type, + t.rows.into_iter().map(|r| r.col_data).collect(), + ); + // Resolve the TVP column layout from the server. + let cols_metadata = client + .query_run_for_metadata(format!( + "DECLARE @P AS {};SELECT TOP 0 * FROM @P", + t.db_type + )) + .await?; + RpcValue::Table(if let Some(cm) = cols_metadata { + type_info_tvp.with_metadata(cm) + } else { + type_info_tvp + }) + } + }; + let rpc_param = RpcParam { + name: p.name, + flags: if p.out { + BitFlags::from_flag(ByRefValue) + } else { + BitFlags::empty() + }, + value: rpc_val, + }; + rpc_params.push(rpc_param); + } + Ok(rpc_params) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestRow; + + impl<'a> TableValueRow<'a> for TestRow { + fn bind_fields(&self, row: &mut SqlTableDataRow<'a>) { + row.add_field(1i32); + } + + fn get_db_type() -> &'static str { + "default.Type" + } + } + + #[test] + fn bind_table_with_dbtype_uses_the_explicit_db_type() { + // The explicit db_type argument must override the row's own get_db_type(). + let mut cmd = Command::new("proc"); + cmd.bind_table_with_dbtype("@tvp", "explicit.Type", vec![TestRow]); + + assert_eq!(cmd.params.len(), 1); + assert_eq!(cmd.params[0].name, "@tvp"); + match &cmd.params[0].data { + CommandParamData::Table(t) => assert_eq!(t.db_type, "explicit.Type"), + other => panic!("expected a table parameter, got {other:?}"), + } + } + + #[test] + fn bind_table_uses_the_rows_db_type() { + let mut cmd = Command::new("proc"); + cmd.bind_table("@tvp", vec![TestRow]); + + match &cmd.params[0].data { + CommandParamData::Table(t) => assert_eq!(t.db_type, "default.Type"), + other => panic!("expected a table parameter, got {other:?}"), + } + } +} diff --git a/src/error.rs b/src/error.rs index 504ca8c18..e9df23655 100644 --- a/src/error.rs +++ b/src/error.rs @@ -41,13 +41,15 @@ pub enum Error { /// An error in the TLS handshake. Tls(String), #[cfg(any(all(unix, feature = "integrated-auth-gssapi"), doc))] - #[cfg_attr( - feature = "docs", - doc(cfg(all(unix, feature = "integrated-auth-gssapi"))) - )] + #[cfg_attr(docsrs, doc(cfg(all(unix, feature = "integrated-auth-gssapi"))))] /// An error from the GSSAPI library. #[error("GSSAPI Error: {}", _0)] Gssapi(String), + #[cfg(any(all(unix, feature = "sspi-rs"), doc))] + #[cfg_attr(docsrs, doc(cfg(all(unix, feature = "sspi-rs"))))] + /// An error from the `sspi` (sspi-rs) library. + #[error("sspi-rs Error: {}", _0)] + SspiRs(String), #[error( "Server requested a connection to an alternative address: `{}:{}`", host, @@ -83,7 +85,7 @@ impl Error { impl From for Error { fn from(e: uuid::Error) -> Self { - Self::Conversion(format!("Error convertiong a Guid value {}", e).into()) + Self::Conversion(format!("Error converting a Guid value {}", e).into()) } } @@ -148,12 +150,150 @@ impl From for Error { } #[cfg(all(unix, feature = "integrated-auth-gssapi"))] -#[cfg_attr( - feature = "docs", - doc(cfg(all(unix, feature = "integrated-auth-gssapi"))) -)] +#[cfg_attr(docsrs, doc(cfg(all(unix, feature = "integrated-auth-gssapi"))))] impl From for Error { fn from(err: libgssapi::error::Error) -> Error { Error::Gssapi(format!("{}", err)) } } + +#[cfg(all(unix, feature = "sspi-rs"))] +#[cfg_attr(docsrs, doc(cfg(all(unix, feature = "sspi-rs"))))] +impl From for Error { + fn from(err: sspi::Error) -> Error { + Error::SspiRs(format!("{}", err)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn token_error(code: u32) -> TokenError { + TokenError { + code, + state: 1, + class: 16, + message: "boom".to_string(), + server: "srv".to_string(), + procedure: "proc".to_string(), + line: 3, + } + } + + #[test] + fn code_and_is_deadlock() { + let deadlock = Error::Server(token_error(1205)); + assert_eq!(deadlock.code(), Some(1205)); + assert!(deadlock.is_deadlock()); + + let other = Error::Server(token_error(500)); + assert_eq!(other.code(), Some(500)); + assert!(!other.is_deadlock()); + + let non_server = Error::Utf8; + assert_eq!(non_server.code(), None); + assert!(!non_server.is_deadlock()); + } + + #[test] + fn display_variants() { + assert_eq!( + format!("{}", Error::Protocol("bad".into())), + "Protocol error: bad" + ); + assert_eq!( + format!("{}", Error::Encoding("bad".into())), + "Encoding error: bad" + ); + assert_eq!( + format!("{}", Error::Conversion("bad".into())), + "Conversion error: bad" + ); + assert_eq!(format!("{}", Error::Utf8), "UTF-8 error"); + assert_eq!(format!("{}", Error::Utf16), "UTF-16 error"); + assert_eq!( + format!("{}", Error::BulkInput("bad".into())), + "BULK UPLOAD input failure: bad" + ); + + let routing = Error::Routing { + host: "host".to_string(), + port: 1234, + }; + assert!(format!("{}", routing).contains("host:1234")); + } + + #[test] + fn from_io_error() { + let io_err = io::Error::new(io::ErrorKind::UnexpectedEof, "eof"); + let err: Error = io_err.into(); + match err { + Error::Io { kind, message } => { + assert_eq!(kind, io::ErrorKind::UnexpectedEof); + assert!(message.contains("eof")); + } + _ => panic!("expected Io"), + } + } + + #[test] + fn from_parse_int_error() { + let parse_err = "not-a-number".parse::().unwrap_err(); + let err: Error = parse_err.into(); + assert!(matches!(err, Error::ParseInt(_))); + } + + #[test] + #[allow(invalid_from_utf8)] // intentionally-invalid bytes to exercise the error path + fn from_utf8_and_utf16_errors() { + let utf8_err = String::from_utf8(vec![0xff, 0xfe]).unwrap_err(); + assert!(matches!(Error::from(utf8_err), Error::Utf8)); + + let invalid: &[u8] = &[0xff, 0xfe]; + let str_utf8 = std::str::from_utf8(invalid).unwrap_err(); + assert!(matches!(Error::from(str_utf8), Error::Utf8)); + + let utf16_err = String::from_utf16(&[0xd800]).unwrap_err(); + assert!(matches!(Error::from(utf16_err), Error::Utf16)); + } + + #[test] + fn from_uuid_error() { + let uuid_err = uuid::Uuid::parse_str("not-a-uuid").unwrap_err(); + assert!(matches!(Error::from(uuid_err), Error::Conversion(_))); + } + + #[test] + fn equality_between_errors() { + assert_eq!(Error::Utf8, Error::Utf8); + assert_ne!(Error::Utf8, Error::Utf16); + } + + #[test] + fn from_connection_string_error() { + let cs_err = connection_string::Error::new("bad connection string"); + let err: Error = cs_err.into(); + match err { + Error::Conversion(msg) => assert!(msg.contains("bad connection string")), + _ => panic!("expected Conversion"), + } + } + + #[cfg(all(unix, feature = "sspi-rs"))] + #[test] + fn from_sspi_error() { + let sspi_err = sspi::Error::new(sspi::ErrorKind::InternalError, "sspi boom"); + assert!(matches!(Error::from(sspi_err), Error::SspiRs(_))); + } + + #[cfg(all(unix, feature = "integrated-auth-gssapi"))] + #[test] + fn from_gssapi_error() { + let gss_err = libgssapi::error::Error { + major: libgssapi::error::MajorFlags::empty(), + minor: 0, + }; + assert!(matches!(Error::from(gss_err), Error::Gssapi(_))); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1115a5e2a..829117008 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,61 +1,9 @@ //! An asynchronous, runtime-independent, pure-rust Tabular Data Stream (TDS) //! implementation for Microsoft SQL Server. //! -//! # Connecting with async-std -//! -//! Being not bound to any single runtime, a `TcpStream` must be created -//! separately and injected to the [`Client`]. -//! -//! ```no_run -//! use tiberius::{Client, Config, Query, AuthMethod}; -//! use async_std::net::TcpStream; -//! -//! #[async_std::main] -//! async fn main() -> anyhow::Result<()> { -//! // Using the builder method to construct the options. -//! let mut config = Config::new(); -//! -//! config.host("localhost"); -//! config.port(1433); -//! -//! // Using SQL Server authentication. -//! config.authentication(AuthMethod::sql_server("SA", "")); -//! -//! // on production, it is not a good idea to do this -//! config.trust_cert(); -//! -//! // Taking the address from the configuration, using async-std's -//! // TcpStream to connect to the server. -//! let tcp = TcpStream::connect(config.get_addr()).await?; -//! -//! // We'll disable the Nagle algorithm. Buffering is handled -//! // internally with a `Sink`. -//! tcp.set_nodelay(true)?; -//! -//! // Handling TLS, login and other details related to the SQL Server. -//! let mut client = Client::connect(config, tcp).await?; -//! -//! // Constructing a query object with one parameter annotated with `@P1`. -//! // This requires us to bind a parameter that will then be used in -//! // the statement. -//! let mut select = Query::new("SELECT @P1"); -//! select.bind(-4i32); -//! -//! // A response to a query is a stream of data, that must be -//! // polled to the end before querying again. Using streams allows -//! // fetching data in an asynchronous manner, if needed. -//! let stream = select.query(&mut client).await?; -//! -//! // In this case, we know we have only one query, returning one row -//! // and one column, so calling `into_row` will consume the stream -//! // and return us the first row of the first result. -//! let row = stream.into_row().await?; -//! -//! assert_eq!(Some(-4i32), row.unwrap().get(0)); -//! -//! Ok(()) -//! } -//! ``` +//! Tiberius is not bound to any single async runtime: a `TcpStream` is created +//! separately and injected into the [`Client`], so it works with Tokio, smol, +//! and other runtimes that provide `futures::io::{AsyncRead, AsyncWrite}`. //! //! # Connecting with Tokio //! @@ -180,22 +128,24 @@ //! //! On Windows platforms, connecting to the SQL Server might require going through //! the SQL Browser service to get the correct port for the named instance. This -//! feature requires either the `sql-browser-async-std` or `sql-browser-tokio` feature -//! flag to be enabled and has a bit different way of connecting: +//! feature requires the `sql-browser-tokio` (or `sql-browser-smol`) feature flag +//! to be enabled and has a bit different way of connecting: //! //! ```no_run -//! # #[cfg(any(feature = "sql-browser-async-std", feature = "sql-browser-tokio"))] +//! # #[cfg(feature = "sql-browser-tokio")] //! use tiberius::{Client, Config, AuthMethod}; -//! # #[cfg(any(feature = "sql-browser-async-std", feature = "sql-browser-tokio"))] -//! use async_std::net::TcpStream; +//! # #[cfg(feature = "sql-browser-tokio")] +//! use tokio::net::TcpStream; +//! # #[cfg(feature = "sql-browser-tokio")] +//! use tokio_util::compat::TokioAsyncWriteCompatExt; //! //! // An extra trait that allows connecting to a named instance with the given //! // `TcpStream`. -//! # #[cfg(any(feature = "sql-browser-async-std", feature = "sql-browser-tokio"))] +//! # #[cfg(feature = "sql-browser-tokio")] //! use tiberius::SqlBrowser; //! -//! #[async_std::main] -//! # #[cfg(any(feature = "sql-browser-async-std", feature = "sql-browser-tokio"))] +//! # #[cfg(feature = "sql-browser-tokio")] +//! #[tokio::main] //! async fn main() -> anyhow::Result<()> { //! let mut config = Config::new(); //! @@ -211,16 +161,16 @@ //! // on production, it is not a good idea to do this //! config.trust_cert(); //! -//! // This will create a new `TcpStream` from `async-std`, connected to the -//! // right port of the named instance. +//! // This will create a new `TcpStream`, connected to the right port of the +//! // named instance. //! let tcp = TcpStream::connect_named(&config).await?; //! //! // And from here on continue the connection process in a normal way. -//! let mut client = Client::connect(config, tcp).await?; +//! let mut client = Client::connect(config, tcp.compat_write()).await?; //! # client.query("SELECT @P1", &[&-4i32]).await?; //! Ok(()) //! } -//! # #[cfg(any(not(feature = "sql-browser-async-std"), not(feature = "sql-browser-tokio")))] +//! # #[cfg(not(feature = "sql-browser-tokio"))] //! # fn main() {} //! ``` //! @@ -243,13 +193,23 @@ //! [`time`]: time/index.html //! [ways of authentication]: enum.AuthMethod.html //! [ADO.NET connection string]: https://docs.microsoft.com/en-us/dotnet/framework/data/adonet/connection-strings -#![cfg_attr(feature = "docs", feature(doc_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #![recursion_limit = "512"] #![warn(missing_docs)] #![warn(missing_debug_implementations, rust_2018_idioms)] #![doc(test(attr(deny(rust_2018_idioms, warnings))))] #![doc(test(attr(allow(unused_extern_crates, unused_variables))))] +#[cfg(all( + feature = "tds80", + not(any( + feature = "rustls", + feature = "native-tls", + feature = "vendored-openssl" + )) +))] +compile_error!("The `tds80` feature requires one of the TLS features to be enabled."); + #[cfg(feature = "bigdecimal")] pub(crate) extern crate bigdecimal_ as bigdecimal; @@ -257,6 +217,7 @@ pub(crate) extern crate bigdecimal_ as bigdecimal; mod macros; mod client; +mod command; mod from_sql; mod query; mod sql_read_bytes; @@ -269,17 +230,23 @@ mod tds; mod sql_browser; -pub use client::{AuthMethod, Client, Config}; +pub use client::{AuthMethod, Client, Config, ConfigBuilder}; +pub use command::{Command, SqlTableData, SqlTableDataRow, TableValue, TableValueRow}; pub(crate) use error::Error; pub use from_sql::{FromSql, FromSqlOwned}; pub use query::Query; pub use result::*; -pub use row::{Column, ColumnType, Row}; +pub use row::{Column, ColumnType, QueryIdx, Row}; pub use sql_browser::SqlBrowser; pub use tds::{ - codec::{BulkLoadRequest, ColumnData, ColumnFlag, IntoRow, TokenRow, TypeLength}, + codec::{ + AltMetaDataColumn, BaseMetaDataColumn, BulkLoadRequest, ColumnData, ColumnFlag, + FixedLenType, IntoRow, IsolationLevel, MetaDataColumn, TokenAltMetaData, TokenAltRow, + TokenRow, TypeInfo, TypeLength, VarLenContext, VarLenType, + }, + collation::Collation, numeric, - stream::QueryStream, + stream::{CommandReturnValue, CommandStream, QueryStream}, time, xml, EncryptionLevel, }; pub use to_sql::{IntoSql, ToSql}; @@ -292,11 +259,61 @@ use tds::codec::*; pub type Result = std::result::Result; pub(crate) fn get_driver_version() -> u64 { - env!("CARGO_PKG_VERSION") + encode_driver_version(env!("CARGO_PKG_VERSION")) +} + +/// Packs a dotted version string into the little-endian byte layout the TDS +/// login record expects: the first component in the low byte, the next in bits +/// 8..16, and so on (up to six components). Non-numeric components contribute +/// zero. +fn encode_driver_version(version: &str) -> u64 { + version .splitn(6, '.') .enumerate() .fold(0u64, |acc, part| match part.1.parse::() { Ok(num) => acc | num << (part.0 * 8), - _ => acc | 0 << (part.0 * 8), + // A non-numeric component contributes nothing. + _ => acc, }) } + +#[cfg(test)] +mod driver_version_tests { + use super::encode_driver_version; + + #[test] + fn packs_each_component_into_its_own_byte() { + // Each component occupies its own byte, low component first. + assert_eq!(encode_driver_version("1.2.3"), 0x03_02_01); + assert_eq!(encode_driver_version("4.5.6.7"), 0x07_06_05_04); + } + + #[test] + fn shift_moves_components_left_not_right() { + // The minor version is shifted up by 8 bits, not down. + assert_eq!(encode_driver_version("34.17"), 34 | (17 << 8)); + assert_ne!(encode_driver_version("34.17"), 34); + } + + #[test] + fn components_are_combined_with_or_not_xor() { + // Overlapping bits are combined with OR, not XOR. + assert_eq!(encode_driver_version("257.1"), 0x101); + } + + #[test] + fn non_numeric_components_contribute_zero() { + assert_eq!(encode_driver_version("1.beta.3"), 1 | (3 << 16)); + assert_eq!(encode_driver_version("notaversion"), 0); + } + + #[test] + fn get_driver_version_encodes_the_crate_version() { + // The wrapper encodes the crate's own version and is non-zero. + assert_eq!( + super::get_driver_version(), + encode_driver_version(env!("CARGO_PKG_VERSION")) + ); + assert_ne!(super::get_driver_version(), 0); + } +} diff --git a/src/result.rs b/src/result.rs index 19ba6faf9..d4bafe64f 100644 --- a/src/result.rs +++ b/src/result.rs @@ -1,7 +1,9 @@ -pub use crate::tds::stream::{QueryItem, ResultMetadata}; +pub use crate::tds::stream::{CommandItem, QueryItem, ResultMetadata}; use crate::{ client::Connection, - tds::stream::{ReceivedToken, TokenStream}, + error::Error, + tds::stream::{CommandReturnValue, ReceivedToken, TokenStream}, + FromSql, Row, }; use futures_util::io::{AsyncRead, AsyncWrite}; use futures_util::stream::TryStreamExt; @@ -113,3 +115,176 @@ impl IntoIterator for ExecuteResult { self.rows_affected.into_iter() } } + +/// A materialized result from executing a [`Command`], carrying the number of +/// affected rows, the return code, the values of any OUT parameters and any +/// record sets returned by the command. +/// +/// [`Command`]: crate::Command +/// +/// # Example +/// +/// ```no_run +/// # use tiberius::{Config, Command}; +/// # use tokio_util::compat::TokioAsyncWriteCompatExt; +/// # use std::env; +/// # #[tokio::main] +/// # async fn main() -> Result<(), Box> { +/// # let c_str = env::var("TIBERIUS_TEST_CONNECTION_STRING").unwrap_or( +/// # "server=tcp:localhost,1433;integratedSecurity=true;TrustServerCertificate=true".to_owned(), +/// # ); +/// # let config = Config::from_ado_string(&c_str)?; +/// # let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?; +/// # tcp.set_nodelay(true)?; +/// # let mut client = tiberius::Client::connect(config, tcp.compat_write()).await?; +/// let mut cmd = Command::new("dbo.usp_SomeStoredProc"); +/// +/// cmd.bind_param("@foo", 34i32); +/// cmd.bind_out_param("@bar", "bar"); +/// let res = cmd.exec(&mut client).await?.into_command_result().await?; +/// +/// let rv: Option<&str> = res.try_return_value("@bar")?; +/// let rc = res.return_code(); +/// let ra = res.rows_affected(); +/// +/// let rs0 = res.to_query_result(0); +/// # Ok(()) +/// # } +/// ``` +/// +#[derive(Debug)] +pub struct CommandResult { + pub(crate) rows_affected: Vec, + pub(crate) return_code: u32, + pub(crate) return_values: Vec, + pub(crate) query_results: Vec>, +} + +impl<'a> CommandResult { + /// A slice of the numbers of rows affected, in the same order as the + /// statements ran by the command. + pub fn rows_affected(&self) -> &[u64] { + self.rows_affected.as_slice() + } + + /// The return code of the command, as returned by the server. + pub fn return_code(&self) -> u32 { + self.return_code + } + + /// The number of returned values (OUT parameters) available. + pub fn return_values_len(&self) -> usize { + self.return_values.len() + } + + /// Gets a returned value by its OUT parameter name, converting it to `T`. + /// Returns `None` if the value is `NULL`, and an error if no OUT parameter + /// with the given name was returned. + pub fn try_return_value(&'a self, name: &str) -> crate::Result> + where + T: FromSql<'a>, + { + let col_data = self + .return_values + .iter() + .find(|p| p.name.eq(name)) + .ok_or_else(|| { + Error::Conversion(format!("Could not find return value {}", name).into()) + })?; + + T::from_sql(&col_data.data) + } + + /// Gets a returned record set by its zero-based index. Returns `None` if the + /// index is out of range. + pub fn to_query_result(&self, idx: usize) -> Option<&Vec> { + self.query_results.get(idx) + } +} + +impl IntoIterator for CommandResult { + type Item = Vec; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.query_results.into_iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tds::codec::ColumnData; + + impl ExecuteResult { + fn from_counts(counts: Vec) -> Self { + Self { + rows_affected: counts, + } + } + } + + #[test] + fn execute_result_rows_affected_preserves_order_and_values() { + let res = ExecuteResult::from_counts(vec![3, 0, 7]); + assert_eq!(res.rows_affected(), &[3, 0, 7]); + } + + #[test] + fn execute_result_total_sums_every_count() { + assert_eq!(ExecuteResult::from_counts(vec![3, 0, 7]).total(), 10); + } + + #[test] + fn execute_result_into_iter_yields_each_count() { + let counts: Vec = ExecuteResult::from_counts(vec![5, 9]).into_iter().collect(); + assert_eq!(counts, vec![5, 9]); + } + + fn return_value(name: &str, value: i32) -> CommandReturnValue { + CommandReturnValue { + name: name.to_string(), + ord: 0, + data: ColumnData::I32(Some(value)), + } + } + + fn command_result() -> CommandResult { + CommandResult { + rows_affected: vec![2, 4], + return_code: 7, + return_values: vec![return_value("@a", 1), return_value("@b", 42)], + // Two (empty) record sets so `to_query_result` has Some values to return. + query_results: vec![Vec::new(), Vec::new()], + } + } + + #[test] + fn command_result_scalar_accessors() { + let res = command_result(); + assert_eq!(res.rows_affected(), &[2, 4]); + assert_eq!(res.return_code(), 7); + assert_eq!(res.return_values_len(), 2); + } + + #[test] + fn command_result_to_query_result_indexes_record_sets() { + let res = command_result(); + assert!(res.to_query_result(0).is_some()); + assert!(res.to_query_result(1).is_some()); + assert!(res.to_query_result(2).is_none()); + } + + #[test] + fn command_result_try_return_value_reads_named_out_param() { + let res = command_result(); + let got: Option = res.try_return_value("@b").unwrap(); + assert_eq!(got, Some(42)); + assert!(res.try_return_value::("@missing").is_err()); + } + + #[test] + fn command_result_into_iter_yields_each_record_set() { + assert_eq!(command_result().into_iter().count(), 2); + } +} diff --git a/src/row.rs b/src/row.rs index e55c4f3fc..55b7454a1 100644 --- a/src/row.rs +++ b/src/row.rs @@ -7,6 +7,7 @@ use std::{fmt::Display, sync::Arc}; /// A column of data from a query. #[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Column { pub(crate) name: String, pub(crate) column_type: ColumnType, @@ -30,6 +31,7 @@ impl Column { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] /// The type of the column. pub enum ColumnType { /// The column doesn't have a specified type. @@ -192,6 +194,7 @@ impl From<&TypeInfo> for ColumnType { VarLenType::SSVariant => Self::SSVariant, }, TypeInfo::Xml { .. } => Self::Xml, + TypeInfo::Udt(_) => Self::Udt, } } } @@ -246,16 +249,24 @@ impl From<&TypeInfo> for ColumnType { /// [`try_get`]: #method.try_get /// [`IntoIterator`]: #impl-IntoIterator #[derive(Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Row { pub(crate) columns: Arc>, pub(crate) data: TokenRow<'static>, pub(crate) result_index: usize, } +/// A type that can address a column within a [`Row`], either by its zero-based +/// position (`usize`) or by name (`&str`). +/// +/// Implement this for a custom column identifier (for example a generated +/// column-name enum) to index rows with it via [`Row::get`]/[`Row::try_get`]. pub trait QueryIdx where Self: Display, { + /// Resolves this index to the column's zero-based position in `row`, or + /// `None` if it does not name/point to a column in the row. fn idx(&self, row: &Row) -> Option; } @@ -423,7 +434,24 @@ impl Row { Error::Conversion(format!("Could not find column with index {}", idx).into()) })?; - Ok(self.data.get(idx).unwrap()) + // `idx` was validated against the column metadata; the cell should exist, + // but a malformed ROW/NBCROW with fewer cells than columns must not + // panic here — return an error instead of unwrapping. + self.data.get(idx).ok_or_else(|| { + Error::Protocol(format!("row has no data for column index {idx}").into()) + }) + } + + /// Consumes the row, returning the underlying [`TokenRow`] holding the raw + /// column data as received from the server. + /// + /// This is useful when direct access to the raw [`ColumnData`] values is + /// needed instead of converting them through [`get`] or [`try_get`]. + /// + /// [`get`]: #method.get + /// [`try_get`]: #method.try_get + pub fn into_token_row(self) -> TokenRow<'static> { + self.data } } @@ -457,6 +485,20 @@ mod tests { } } + #[test] + fn result_index_reflects_the_field() { + // `result_index()` returns the row's stored result index. + let columns = Arc::new(vec![Column::new("c".to_string(), ColumnType::Int4)]); + let mut data = TokenRow::new(); + data.push(ColumnData::I32(Some(1))); + let row = Row { + columns, + data, + result_index: 3, + }; + assert_eq!(row.result_index(), 3); + } + // Regression test for #211: an out-of-range usize index must not panic. #[test] fn try_get_out_of_range_index_returns_none() { @@ -482,4 +524,225 @@ mod tests { assert_eq!(Some(2i32), row.get::("r#type")); } + + #[test] + fn row_accessors() { + let row = make_row(); + + assert_eq!(2, row.columns().len()); + assert_eq!(2, row.len()); + assert_eq!(0, row.result_index()); + + let cells: Vec<_> = row.cells().collect(); + assert_eq!(2, cells.len()); + assert_eq!("foo", cells[0].0.name()); + + let token_row = row.into_token_row(); + assert_eq!(2, token_row.len()); + } + + #[test] + fn row_into_iterator_yields_column_data() { + let row = make_row(); + let values: Vec<_> = row.into_iter().collect(); + assert_eq!( + vec![ColumnData::I32(Some(1)), ColumnData::I32(Some(2))], + values + ); + } + + #[test] + fn get_column_data_missing_cell_errors() { + let columns = Arc::new(vec![ + Column::new("a".to_string(), ColumnType::Int4), + Column::new("b".to_string(), ColumnType::Int4), + ]); + + // Malformed row: metadata says 2 columns, but only 1 cell present. + let mut data = TokenRow::new(); + data.push(ColumnData::I32(Some(1))); + + let row = Row { + columns, + data, + result_index: 0, + }; + + let err = row.get_column_data(1usize).unwrap_err(); + assert!(format!("{}", err).contains("row has no data for column index")); + } + + #[test] + fn column_new_and_accessors() { + let column = Column::new("id".to_string(), ColumnType::Int8); + assert_eq!("id", column.name()); + assert_eq!(ColumnType::Int8, column.column_type()); + } + + #[test] + fn column_type_from_fixed_len_type_info() { + use crate::tds::codec::FixedLenType; + + let cases = [ + (FixedLenType::Int1, ColumnType::Int1), + (FixedLenType::Bit, ColumnType::Bit), + (FixedLenType::Int2, ColumnType::Int2), + (FixedLenType::Int4, ColumnType::Int4), + (FixedLenType::Datetime4, ColumnType::Datetime4), + (FixedLenType::Float4, ColumnType::Float4), + (FixedLenType::Money, ColumnType::Money), + (FixedLenType::Datetime, ColumnType::Datetime), + (FixedLenType::Float8, ColumnType::Float8), + (FixedLenType::Money4, ColumnType::Money4), + (FixedLenType::Int8, ColumnType::Int8), + (FixedLenType::Null, ColumnType::Null), + ]; + + for (flt, expected) in cases { + let ti = TypeInfo::FixedLen(flt); + assert_eq!(ColumnType::from(&ti), expected); + } + } + + #[test] + fn column_type_from_var_len_sized_type_info() { + use crate::tds::codec::VarLenType; + use crate::VarLenContext; + + let cases = [ + (VarLenType::Guid, 16, ColumnType::Guid), + (VarLenType::Intn, 1, ColumnType::Int1), + (VarLenType::Intn, 2, ColumnType::Int2), + (VarLenType::Intn, 4, ColumnType::Int4), + (VarLenType::Intn, 8, ColumnType::Int8), + (VarLenType::Intn, 3, ColumnType::Intn), + (VarLenType::Bitn, 1, ColumnType::Bitn), + (VarLenType::Decimaln, 17, ColumnType::Decimaln), + (VarLenType::Numericn, 17, ColumnType::Numericn), + (VarLenType::Floatn, 4, ColumnType::Float4), + (VarLenType::Floatn, 8, ColumnType::Float8), + (VarLenType::Floatn, 2, ColumnType::Floatn), + (VarLenType::Money, 8, ColumnType::Money), + (VarLenType::Datetimen, 8, ColumnType::Datetimen), + (VarLenType::BigVarBin, 8000, ColumnType::BigVarBin), + (VarLenType::BigVarChar, 8000, ColumnType::BigVarChar), + (VarLenType::BigBinary, 8000, ColumnType::BigBinary), + (VarLenType::BigChar, 8000, ColumnType::BigChar), + (VarLenType::NVarchar, 4000, ColumnType::NVarchar), + (VarLenType::NChar, 4000, ColumnType::NChar), + (VarLenType::Xml, 0, ColumnType::Xml), + (VarLenType::Udt, 0, ColumnType::Udt), + (VarLenType::Text, 0, ColumnType::Text), + (VarLenType::Image, 0, ColumnType::Image), + (VarLenType::NText, 0, ColumnType::NText), + (VarLenType::SSVariant, 0, ColumnType::SSVariant), + ]; + + for (ty, len, expected) in cases { + let ti = TypeInfo::VarLenSized(VarLenContext::new(ty, len, None)); + assert_eq!(ColumnType::from(&ti), expected, "{:?} len {}", ty, len); + } + } + + #[test] + fn column_type_from_var_len_sized_precision_type_info() { + use crate::tds::codec::VarLenType; + + let cases = [ + (VarLenType::Guid, ColumnType::Guid), + (VarLenType::Intn, ColumnType::Intn), + (VarLenType::Bitn, ColumnType::Bitn), + (VarLenType::Decimaln, ColumnType::Decimaln), + (VarLenType::Numericn, ColumnType::Numericn), + (VarLenType::Floatn, ColumnType::Floatn), + (VarLenType::Money, ColumnType::Money), + (VarLenType::Datetimen, ColumnType::Datetimen), + (VarLenType::BigVarBin, ColumnType::BigVarBin), + (VarLenType::BigVarChar, ColumnType::BigVarChar), + (VarLenType::BigBinary, ColumnType::BigBinary), + (VarLenType::BigChar, ColumnType::BigChar), + (VarLenType::NVarchar, ColumnType::NVarchar), + (VarLenType::NChar, ColumnType::NChar), + (VarLenType::Xml, ColumnType::Xml), + (VarLenType::Udt, ColumnType::Udt), + (VarLenType::Text, ColumnType::Text), + (VarLenType::Image, ColumnType::Image), + (VarLenType::NText, ColumnType::NText), + (VarLenType::SSVariant, ColumnType::SSVariant), + ]; + + for (ty, expected) in cases { + let ti = TypeInfo::VarLenSizedPrecision { + ty, + size: 38, + precision: 38, + scale: 2, + }; + assert_eq!(ColumnType::from(&ti), expected, "{:?}", ty); + } + } + + #[test] + fn column_type_from_xml_and_udt_type_info() { + use crate::tds::codec::UdtInfo; + use crate::tds::xml::XmlSchema; + use std::sync::Arc as StdArc; + + let ti = TypeInfo::Xml { + schema: None::>, + size: 0, + }; + assert_eq!(ColumnType::from(&ti), ColumnType::Xml); + + let ti = TypeInfo::Udt(UdtInfo { + max_byte_size: 0xffff, + db_name: "db".to_string(), + schema_name: "dbo".to_string(), + type_name: "geometry".to_string(), + assembly_qualified_name: "asm".to_string(), + }); + assert_eq!(ColumnType::from(&ti), ColumnType::Udt); + } + + #[cfg(feature = "tds73")] + #[test] + fn column_type_from_var_len_sized_tds73_type_info() { + use crate::tds::codec::VarLenType; + use crate::VarLenContext; + + let cases = [ + (VarLenType::Daten, ColumnType::Daten), + (VarLenType::Timen, ColumnType::Timen), + (VarLenType::Datetime2, ColumnType::Datetime2), + (VarLenType::DatetimeOffsetn, ColumnType::DatetimeOffsetn), + ]; + + for (ty, expected) in cases { + let ti = TypeInfo::VarLenSized(VarLenContext::new(ty, 8, None)); + assert_eq!(ColumnType::from(&ti), expected, "{:?}", ty); + } + } + + #[cfg(feature = "tds73")] + #[test] + fn column_type_from_var_len_sized_precision_tds73_type_info() { + use crate::tds::codec::VarLenType; + + let cases = [ + (VarLenType::Daten, ColumnType::Daten), + (VarLenType::Timen, ColumnType::Timen), + (VarLenType::Datetime2, ColumnType::Datetime2), + (VarLenType::DatetimeOffsetn, ColumnType::DatetimeOffsetn), + ]; + + for (ty, expected) in cases { + let ti = TypeInfo::VarLenSizedPrecision { + ty, + size: 8, + precision: 0, + scale: 7, + }; + assert_eq!(ColumnType::from(&ti), expected, "{:?}", ty); + } + } } diff --git a/src/sql_browser.rs b/src/sql_browser.rs index b07e8ee22..5166674d5 100644 --- a/src/sql_browser.rs +++ b/src/sql_browser.rs @@ -1,9 +1,6 @@ #[cfg(feature = "sql-browser-tokio")] mod tokio; -#[cfg(feature = "sql-browser-async-std")] -mod async_std; - #[cfg(feature = "sql-browser-smol")] mod smol; @@ -27,11 +24,7 @@ pub trait SqlBrowser { Self: Sized + Send + Sync; } -#[cfg(any( - feature = "sql-browser-async-std", - feature = "sql-browser-tokio", - feature = "sql-browser-smol" -))] +#[cfg(any(feature = "sql-browser-tokio", feature = "sql-browser-smol"))] fn get_port_from_sql_browser_reply( mut buf: Vec, len: usize, diff --git a/src/sql_browser/async_std.rs b/src/sql_browser/async_std.rs deleted file mode 100644 index 14f55de57..000000000 --- a/src/sql_browser/async_std.rs +++ /dev/null @@ -1,72 +0,0 @@ -use super::SqlBrowser; -use async_std::{ - io, - net::{self, ToSocketAddrs}, -}; -use async_trait::async_trait; -use futures_util::future::TryFutureExt; -use std::time; -use tracing::Level; - -#[async_trait] -impl SqlBrowser for net::TcpStream { - /// This method can be used to connect to SQL Server named instances - /// when on a Windows platform with the `sql-browser-async-std` feature - /// enabled. Please see the crate examples for more detailed examples. - async fn connect_named(builder: &crate::client::Config) -> crate::Result { - let addrs = builder.get_addr().to_socket_addrs().await?; - - for mut addr in addrs { - if let Some(ref instance_name) = builder.instance_name { - // First resolve the instance to a port via the - // SSRP protocol/MS-SQLR protocol [1] - // [1] https://msdn.microsoft.com/en-us/library/cc219703.aspx - - let local_bind: std::net::SocketAddr = if addr.is_ipv4() { - "0.0.0.0:0".parse().unwrap() - } else { - "[::]:0".parse().unwrap() - }; - - tracing::event!( - Level::TRACE, - "Connecting to instance `{}` using SQL Browser in port `{}`", - instance_name, - builder.get_port() - ); - - let msg = [&[4u8], instance_name.as_bytes()].concat(); - let mut buf = vec![0u8; 4096]; - - let socket = net::UdpSocket::bind(&local_bind).await?; - socket.send_to(&msg, &addr).await?; - - let timeout = time::Duration::from_millis(1000); - - let len = io::timeout(timeout, socket.recv(&mut buf)) - .map_err(|_| { - crate::error::Error::Conversion( - format!( - "SQL browser timeout during resolving instance {}. Please check if browser is running in port {} and does the instance exist.", - instance_name, - builder.get_port(), - ) - .into(), - ) - }) - .await?; - - let port = super::get_port_from_sql_browser_reply(buf, len, instance_name)?; - tracing::event!(Level::TRACE, "Found port `{}` from SQL Browser", port); - addr.set_port(port); - }; - - if let Ok(stream) = net::TcpStream::connect(addr).await { - stream.set_nodelay(true)?; - return Ok(stream); - } - } - - Err(io::Error::new(io::ErrorKind::NotFound, "Could not resolve server host").into()) - } -} diff --git a/src/sql_browser/smol.rs b/src/sql_browser/smol.rs index 252b834e3..311c11a6d 100644 --- a/src/sql_browser/smol.rs +++ b/src/sql_browser/smol.rs @@ -5,7 +5,10 @@ use async_net::{resolve, TcpStream, UdpSocket}; use async_trait::async_trait; use futures_lite::FutureExt; use futures_util::future::TryFutureExt; +use futures_util::stream::FuturesUnordered; +use futures_util::StreamExt; use std::io; +use std::net::SocketAddr; use std::time::Duration; use tracing::Level; @@ -16,64 +19,87 @@ impl SqlBrowser for TcpStream { /// enabled. Please see the crate examples for more detailed examples. async fn connect_named(builder: &Config) -> crate::Result { let addrs = resolve(builder.get_addr()).await?; + let mut first_error = None; - for mut addr in addrs { - if let Some(ref instance_name) = builder.instance_name { - // First resolve the instance to a port via the - // SSRP protocol/MS-SQLR protocol [1] - // [1] https://msdn.microsoft.com/en-us/library/cc219703.aspx - - let local_bind: std::net::SocketAddr = if addr.is_ipv4() { - "0.0.0.0:0".parse().unwrap() - } else { - "[::]:0".parse().unwrap() + if builder.multi_subnet_failover { + let mut futures = addrs + .into_iter() + .map(|addr| connect_addr(builder, addr)) + .collect::>(); + while let Some(connection) = futures.next().await { + match connection { + Ok(connection) => return Ok(connection), + Err(error) => first_error.get_or_insert(error), + }; + } + } else { + for addr in addrs { + match connect_addr(builder, addr).await { + Ok(connection) => return Ok(connection), + Err(error) => first_error.get_or_insert(error), }; + } + } + + // If we end up here, there was no successful connection. + Err(first_error.unwrap_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Could not resolve server host").into() + })) + } +} - tracing::event!( - Level::TRACE, - "Connecting to instance `{}` using SQL Browser in port `{}`", - instance_name, - builder.get_port() - ); +async fn connect_addr(builder: &Config, mut addr: SocketAddr) -> crate::Result { + if let Some(ref instance_name) = builder.instance_name { + // First resolve the instance to a port via the + // SSRP protocol/MS-SQLR protocol [1] + // [1] https://msdn.microsoft.com/en-us/library/cc219703.aspx - let msg = [&[4u8], instance_name.as_bytes()].concat(); - let mut buf = vec![0u8; 4096]; + let local_bind: std::net::SocketAddr = if addr.is_ipv4() { + "0.0.0.0:0".parse().unwrap() + } else { + "[::]:0".parse().unwrap() + }; - let socket = UdpSocket::bind(&local_bind).await?; - socket.send_to(&msg, &addr).await?; + tracing::event!( + Level::TRACE, + "Connecting to instance `{}` using SQL Browser in port `{}`", + instance_name, + builder.get_port() + ); - let timeout = Duration::from_millis(1000); + let msg = [&[4u8], instance_name.as_bytes()].concat(); + let mut buf = vec![0u8; 4096]; - let len = socket.recv(&mut buf).or(async { - Timer::after(timeout).await; - Err(std::io::ErrorKind::TimedOut.into()) - }) - .map_err(|e| { - if e.kind() == std::io::ErrorKind::TimedOut { - crate::error::Error::Conversion( - format!( - "SQL browser timeout during resolving instance {}. Please check if browser is running in port {} and does the instance exist.", - instance_name, - builder.get_port(), - ) - .into(), - ) - } else { - e.into() - } - }).await?; + let socket = UdpSocket::bind(&local_bind).await?; + socket.send_to(&msg, &addr).await?; - let port = super::get_port_from_sql_browser_reply(buf, len, instance_name)?; - tracing::event!(Level::TRACE, "Found port `{}` from SQL Browser", port); - addr.set_port(port); - }; + let timeout = Duration::from_millis(1000); - if let Ok(stream) = TcpStream::connect(addr).await { - stream.set_nodelay(true)?; - return Ok(stream); - } - } + let len = socket.recv(&mut buf).or(async { + Timer::after(timeout).await; + Err(std::io::ErrorKind::TimedOut.into()) + }) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::TimedOut { + crate::error::Error::Conversion( + format!( + "SQL browser timeout during resolving instance {}. Please check if browser is running in port {} and does the instance exist.", + instance_name, + builder.get_port(), + ) + .into(), + ) + } else { + e.into() + } + }).await?; - Err(io::Error::new(io::ErrorKind::NotFound, "Could not resolve server host").into()) - } + let port = super::get_port_from_sql_browser_reply(buf, len, instance_name)?; + tracing::event!(Level::TRACE, "Found port `{}` from SQL Browser", port); + addr.set_port(port); + }; + + let stream = TcpStream::connect(addr).await?; + stream.set_nodelay(true)?; + Ok(stream) } diff --git a/src/sql_browser/tokio.rs b/src/sql_browser/tokio.rs index 1fbf6e0e3..547ad09d9 100644 --- a/src/sql_browser/tokio.rs +++ b/src/sql_browser/tokio.rs @@ -2,8 +2,10 @@ use super::SqlBrowser; use crate::client::Config; use async_trait::async_trait; use futures_util::future::TryFutureExt; +use futures_util::stream::FuturesUnordered; +use futures_util::StreamExt; use net::{TcpStream, UdpSocket}; -use std::io; +use std::{io, net::SocketAddr}; use tokio::{ net, time::{self, error::Elapsed, Duration}, @@ -17,58 +19,80 @@ impl SqlBrowser for TcpStream { /// enabled. Please see the crate examples for more detailed examples. async fn connect_named(builder: &Config) -> crate::Result { let addrs = net::lookup_host(builder.get_addr()).await?; + let mut first_error = None; - for mut addr in addrs { - if let Some(ref instance_name) = builder.instance_name { - // First resolve the instance to a port via the - // SSRP protocol/MS-SQLR protocol [1] - // [1] https://msdn.microsoft.com/en-us/library/cc219703.aspx - - let local_bind: std::net::SocketAddr = if addr.is_ipv4() { - "0.0.0.0:0".parse().unwrap() - } else { - "[::]:0".parse().unwrap() + if builder.multi_subnet_failover { + let mut futures = addrs + .map(|addr| connect_addr(builder, addr)) + .collect::>(); + while let Some(connection) = futures.next().await { + match connection { + Ok(connection) => return Ok(connection), + Err(error) => first_error.get_or_insert(error), + }; + } + } else { + for addr in addrs { + match connect_addr(builder, addr).await { + Ok(connection) => return Ok(connection), + Err(error) => first_error.get_or_insert(error), }; + } + } + + // If we end up here, there was no successful connection. + Err(first_error.unwrap_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Could not resolve server host").into() + })) + } +} - tracing::event!( - Level::TRACE, - "Connecting to instance `{}` using SQL Browser in port `{}`", - instance_name, - builder.get_port() - ); +async fn connect_addr(builder: &Config, mut addr: SocketAddr) -> crate::Result { + if let Some(ref instance_name) = builder.instance_name { + // First resolve the instance to a port via the + // SSRP protocol/MS-SQLR protocol [1] + // [1] https://msdn.microsoft.com/en-us/library/cc219703.aspx - let msg = [&[4u8], instance_name.as_bytes()].concat(); - let mut buf = vec![0u8; 4096]; + let local_bind: std::net::SocketAddr = if addr.is_ipv4() { + "0.0.0.0:0".parse().unwrap() + } else { + "[::]:0".parse().unwrap() + }; - let socket = UdpSocket::bind(&local_bind).await?; - socket.send_to(&msg, &addr).await?; + tracing::event!( + Level::TRACE, + "Connecting to instance `{}` using SQL Browser in port `{}`", + instance_name, + builder.get_port() + ); - let timeout = Duration::from_millis(1000); + let msg = [&[4u8], instance_name.as_bytes()].concat(); + let mut buf = vec![0u8; 4096]; - let len = time::timeout(timeout, socket.recv(&mut buf)) - .map_err(|_: Elapsed| { - crate::error::Error::Conversion( - format!( - "SQL browser timeout during resolving instance {}. Please check if browser is running in port {} and does the instance exist.", - instance_name, - builder.get_port(), - ) - .into(), - ) - }) - .await??; + let socket = UdpSocket::bind(&local_bind).await?; + socket.send_to(&msg, &addr).await?; - let port = super::get_port_from_sql_browser_reply(buf, len, instance_name)?; - tracing::event!(Level::TRACE, "Found port `{}` from SQL Browser", port); - addr.set_port(port); - }; + let timeout = Duration::from_millis(1000); - if let Ok(stream) = TcpStream::connect(addr).await { - stream.set_nodelay(true)?; - return Ok(stream); - } - } + let len = time::timeout(timeout, socket.recv(&mut buf)) + .map_err(|_: Elapsed| { + crate::error::Error::Conversion( + format!( + "SQL browser timeout during resolving instance {}. Please check if browser is running in port {} and does the instance exist.", + instance_name, + builder.get_port(), + ) + .into(), + ) + }) + .await??; - Err(io::Error::new(io::ErrorKind::NotFound, "Could not resolve server host").into()) - } + let port = super::get_port_from_sql_browser_reply(buf, len, instance_name)?; + tracing::event!(Level::TRACE, "Found port `{}` from SQL Browser", port); + addr.set_port(port); + }; + + let stream = TcpStream::connect(addr).await?; + stream.set_nodelay(true)?; + Ok(stream) } diff --git a/src/tds.rs b/src/tds.rs index f4b6f9253..5c23b39d3 100644 --- a/src/tds.rs +++ b/src/tds.rs @@ -1,5 +1,5 @@ pub mod codec; -mod collation; +pub(crate) mod collation; mod context; pub mod numeric; pub mod stream; @@ -25,6 +25,37 @@ uint_enum! { NotSupported = 2, /// Encrypt everything and fail if not possible Required = 3, + /// Start encryption before the TDS prelogin (TDS 8.0 "strict" mode) and + /// encrypt everything, failing if not possible. + Strict = 4, } } + +impl EncryptionLevel { + /// The value sent on the wire in the prelogin `ENCRYPTION` option. + /// + /// `Strict` (TDS 8.0) is negotiated out-of-band via a TLS handshake before + /// the prelogin, so when a prelogin is emitted at all it advertises the + /// classic `Required` value. + pub(crate) fn as_wire_value(&self) -> u8 { + match self { + EncryptionLevel::Strict => EncryptionLevel::Required as u8, + other => *other as u8, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encryption_level_as_wire_value() { + assert_eq!(EncryptionLevel::Off.as_wire_value(), 0); + assert_eq!(EncryptionLevel::On.as_wire_value(), 1); + assert_eq!(EncryptionLevel::NotSupported.as_wire_value(), 2); + assert_eq!(EncryptionLevel::Required.as_wire_value(), 3); + assert_eq!(EncryptionLevel::Strict.as_wire_value(), 3); + } +} diff --git a/src/tds/codec.rs b/src/tds/codec.rs index 07f133106..44ec88db2 100644 --- a/src/tds/codec.rs +++ b/src/tds/codec.rs @@ -11,7 +11,9 @@ mod packet; mod pre_login; mod rpc_request; mod token; +mod transaction_manager; mod type_info; +mod type_info_tvp; pub use batch_request::*; pub use bulk_load::*; @@ -27,7 +29,9 @@ pub use packet::*; pub use pre_login::*; pub use rpc_request::*; pub use token::*; +pub use transaction_manager::*; pub use type_info::*; +pub use type_info_tvp::*; const HEADER_BYTES: usize = 8; const ALL_HEADERS_LEN_TX: usize = 22; diff --git a/src/tds/codec/column_data.rs b/src/tds/codec/column_data.rs index 054d10a2e..c6e78b915 100644 --- a/src/tds/codec/column_data.rs +++ b/src/tds/codec/column_data.rs @@ -15,10 +15,12 @@ mod image; mod int; mod money; mod plp; +mod sql_variant; mod string; mod text; #[cfg(feature = "tds73")] mod time; +mod udt; mod var_len; mod xml; @@ -27,7 +29,7 @@ use super::{Encode, FixedLenType, TypeInfo, VarLenType}; use crate::tds::time::{Date, DateTime2, DateTimeOffset, Time}; use crate::{ tds::{time::DateTime, time::SmallDateTime, xml::XmlData, Numeric}, - SqlReadBytes, + FromSql, FromSqlOwned, IntoSql, SqlReadBytes, ToSql, }; use bytes::BufMut; pub(crate) use bytes_mut_with_type_info::BytesMutWithTypeInfo; @@ -36,7 +38,48 @@ use uuid::Uuid; const MAX_NVARCHAR_SIZE: usize = 1 << 30; +/// Number of days between `0001-01-01` (the `DateTime2`/`Date` epoch) and +/// `1900-01-01` (the `datetime`/`Datetimen` epoch). +#[cfg(feature = "tds73")] +const DAYS_YEAR_1_TO_1900: u32 = 693_595; + +/// Converts a [`DateTime2`] value into the legacy `datetime` ([`DateTime`]) +/// wire representation. +/// +/// This is used when bulk-inserting a `DateTime2`/`Date` value into a column +/// whose server-side type is `datetime` (`Datetimen`). The `datetime` type +/// counts days from `1900-01-01` and stores the time of day as 1/300-second +/// fragments, so the sub-second precision of the source value is degraded to +/// match. Returns a [`Conversion`] error if the date is earlier than +/// `1900-01-01`, which `datetime` cannot represent. +/// +/// [`Conversion`]: crate::Error::Conversion +#[cfg(feature = "tds73")] +fn datetime2_to_datetime(dt2: &DateTime2) -> crate::Result { + let dt2_days = dt2.date().days(); + + let days = dt2_days.checked_sub(DAYS_YEAR_1_TO_1900).ok_or_else(|| { + crate::Error::Conversion( + format!( + "invalid datetime, expecting a date not earlier than 1900-01-01 but got {} days after year 1", + dt2_days + ) + .into(), + ) + })? as i32; + + // `increments` are counted in 10^-scale seconds; convert to nanoseconds and + // then to the 1/300-second fragments used by `datetime`, degrading the + // sub-second precision in the process. + let time = dt2.time(); + let nanos = time.increments() as u128 * 10u128.pow(9 - time.scale() as u32); + let seconds_fragments = (nanos * 300 / 1_000_000_000) as u32; + + Ok(DateTime::new(days, seconds_fragments)) +} + #[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] /// A container of a value that can be represented as a TDS value. pub enum ColumnData<'a> { /// 8-bit integer, unsigned. @@ -68,19 +111,19 @@ pub enum ColumnData<'a> { /// A small DateTime value. SmallDateTime(Option), #[cfg(feature = "tds73")] - #[cfg_attr(feature = "docs", doc(cfg(feature = "tds73")))] + #[cfg_attr(docsrs, doc(cfg(feature = "tds73")))] /// Time value. Time(Option