This is an automated email from the ASF dual-hosted git repository.

CritasWang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iotdb-client-rust.git

commit 460dfd8156781cb6338522cfa30ebc8c172bd4a0
Author: CritasWang <[email protected]>
AuthorDate: Mon Jul 13 14:53:08 2026 +0800

    TLS hardening: nodejs-style dispatch tests (wire-byte assertion), mutual 
TLS client identity (cert/key PEM), real end-to-end TLS verified vs IoTDB 2.0.6 
(enable_thrift_ssl, TLSv1.3, full cert verification)
---
 .gitignore                         |   2 +
 README.md                          |  15 ++-
 README_ZH.md                       |  15 ++-
 src/client/session.rs              |  89 +++++++++++++-
 src/client/table_session.rs        |  28 ++++-
 src/connection/mod.rs              | 242 ++++++++++++++++++++++++++++++++++++-
 tests/fixtures/tls/README.md       |  57 ++++++++-
 tests/fixtures/tls/client-cert.pem |  20 +++
 tests/fixtures/tls/client-key.pem  |  28 +++++
 9 files changed, 487 insertions(+), 9 deletions(-)

diff --git a/.gitignore b/.gitignore
index 96ef6c0..f362b0b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
 /target
 Cargo.lock
+# derived from cert.pem/key.pem for the live TLS test — see 
tests/fixtures/tls/README.md
+tests/fixtures/tls/server-keystore.p12
diff --git a/README.md b/README.md
index 823be0b..2b5dfd5 100644
--- a/README.md
+++ b/README.md
@@ -200,7 +200,20 @@ let config = SessionConfig {
 // or: TableSession::builder().use_ssl(true).ca_cert_path("ca.pem")...
 ```
 
-The server needs Thrift SSL enabled (`enable_thrift_ssl=true` + key store). 
Pool configs pass both options through their embedded `session` config.
+For **mutual TLS** (server has `thrift_ssl_client_auth=true`), add a PEM 
client certificate and its PKCS#8 key — the analogue of the Node.js 
`sslOptions.cert`/`sslOptions.key`:
+
+```rust
+let config = SessionConfig {
+    use_ssl: true,
+    ca_cert_path: Some("ca.pem".into()),
+    client_cert_path: Some("client.crt".into()),  // must be set together
+    client_key_path: Some("client.key".into()),   // with client_cert_path
+    ..Default::default()
+};
+// or: 
TableSession::builder().use_ssl(true).client_cert_path("client.crt").client_key_path("client.key")...
+```
+
+The server needs Thrift SSL enabled (`enable_thrift_ssl=true` + key store; see 
`tests/fixtures/tls/README.md` for a throwaway docker setup). Pool configs pass 
all options through their embedded `session` config.
 
 ## Thrift codegen
 
diff --git a/README_ZH.md b/README_ZH.md
index 6f40256..25e384b 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -200,7 +200,20 @@ let config = SessionConfig {
 // 或:TableSession::builder().use_ssl(true).ca_cert_path("ca.pem")...
 ```
 
-服务端需开启 Thrift SSL(`enable_thrift_ssl=true` + 密钥库)。连接池配置通过其内嵌的 `session` 
配置透传这两组选项。
+**双向 TLS**(服务端 `thrift_ssl_client_auth=true`)需额外提供 PEM 客户端证书及其 PKCS#8 私钥 —— 对应 
Node.js 的 `sslOptions.cert`/`sslOptions.key`:
+
+```rust
+let config = SessionConfig {
+    use_ssl: true,
+    ca_cert_path: Some("ca.pem".into()),
+    client_cert_path: Some("client.crt".into()),  // 必须与 client_key_path
+    client_key_path: Some("client.key".into()),   // 同时设置
+    ..Default::default()
+};
+// 
或:TableSession::builder().use_ssl(true).client_cert_path("client.crt").client_key_path("client.key")...
+```
+
+服务端需开启 Thrift SSL(`enable_thrift_ssl=true` + 密钥库;一次性 docker 环境见 
`tests/fixtures/tls/README.md`)。连接池配置通过其内嵌的 `session` 配置透传全部选项。
 
 ## Thrift 代码生成
 
diff --git a/src/client/session.rs b/src/client/session.rs
index 5f4425c..91480c4 100644
--- a/src/client/session.rs
+++ b/src/client/session.rs
@@ -95,6 +95,15 @@ pub struct SessionConfig {
     /// host (e.g. when connecting by IP).
     #[cfg(feature = "tls")]
     pub domain_override: Option<String>,
+    /// PEM client certificate for mutual TLS (server has
+    /// `thrift_ssl_client_auth=true`); set together with
+    /// `client_key_path`. Mirrors the Node.js `sslOptions.cert`.
+    #[cfg(feature = "tls")]
+    pub client_cert_path: Option<std::path::PathBuf>,
+    /// PEM PKCS#8 private key for the client certificate; set together
+    /// with `client_cert_path`. Mirrors the Node.js `sslOptions.key`.
+    #[cfg(feature = "tls")]
+    pub client_key_path: Option<std::path::PathBuf>,
 }
 
 impl Default for SessionConfig {
@@ -122,6 +131,10 @@ impl Default for SessionConfig {
             accept_invalid_certs: false,
             #[cfg(feature = "tls")]
             domain_override: None,
+            #[cfg(feature = "tls")]
+            client_cert_path: None,
+            #[cfg(feature = "tls")]
+            client_key_path: None,
         }
     }
 }
@@ -152,6 +165,8 @@ impl SessionConfig {
                 ca_cert_path: self.ca_cert_path.clone(),
                 accept_invalid_certs: self.accept_invalid_certs,
                 domain_override: self.domain_override.clone(),
+                client_cert_path: self.client_cert_path.clone(),
+                client_key_path: self.client_key_path.clone(),
             }),
         }
     }
@@ -1027,6 +1042,8 @@ mod tests {
             assert!(cfg.ca_cert_path.is_none());
             assert!(!cfg.accept_invalid_certs);
             assert!(cfg.domain_override.is_none());
+            assert!(cfg.client_cert_path.is_none());
+            assert!(cfg.client_key_path.is_none());
         }
     }
 
@@ -1056,12 +1073,42 @@ mod tests {
                 ca_cert_path: Some("/certs/ca.pem".into()),
                 accept_invalid_certs: true,
                 domain_override: Some("iotdb.internal".into()),
+                client_cert_path: Some("/certs/client.pem".into()),
+                client_key_path: Some("/certs/client-key.pem".into()),
                 ..Default::default()
             };
             let tls = cfg.connection_options().tls.expect("tls options");
             assert_eq!(tls.ca_cert_path.as_deref(), 
Some("/certs/ca.pem".as_ref()));
             assert!(tls.accept_invalid_certs);
             assert_eq!(tls.domain_override.as_deref(), Some("iotdb.internal"));
+            assert_eq!(
+                tls.client_cert_path.as_deref(),
+                Some("/certs/client.pem".as_ref())
+            );
+            assert_eq!(
+                tls.client_key_path.as_deref(),
+                Some("/certs/client-key.pem".as_ref())
+            );
+
+            // Protocol × TLS are independent axes: every combination maps
+            // through, ConnectionOptions carries both.
+            for (compression, ssl) in [(false, false), (false, true), (true, 
false), (true, true)] {
+                let cfg = SessionConfig {
+                    enable_rpc_compression: compression,
+                    use_ssl: ssl,
+                    ..Default::default()
+                };
+                let options = cfg.connection_options();
+                assert_eq!(
+                    options.protocol,
+                    if compression {
+                        RpcProtocol::Compact
+                    } else {
+                        RpcProtocol::Binary
+                    }
+                );
+                assert_eq!(options.tls.is_some(), ssl);
+            }
         }
     }
 
@@ -1598,11 +1645,49 @@ mod tests {
             use_ssl: true,
             ca_cert_path: std::env::var_os("IOTDB_TLS_CA").map(Into::into),
             accept_invalid_certs: 
std::env::var_os("IOTDB_TLS_INSECURE").is_some(),
+            domain_override: std::env::var("IOTDB_TLS_DOMAIN").ok(),
+            // A client identity is only *verified* by a server with
+            // thrift_ssl_client_auth=true (not available in 2.0.6, whose
+            // RPC service hardcodes requireClientAuth(false)); setting these
+            // against any TLS server still proves the identity loads and
+            // the handshake tolerates it.
+            client_cert_path: 
std::env::var_os("IOTDB_TLS_CLIENT_CERT").map(Into::into),
+            client_key_path: 
std::env::var_os("IOTDB_TLS_CLIENT_KEY").map(Into::into),
             ..Default::default()
         });
         session.open().expect("open TLS session");
-        let mut dataset = session.execute_query("SHOW 
DATABASES").expect("query");
-        while dataset.next_row().expect("next_row").is_some() {}
+
+        // Full write+read roundtrip over the TLS transport, not just a
+        // metadata query.
+        const DB: &str = "root.rusttest_tls";
+        let _ = session.execute_non_query(&format!("DELETE DATABASE {DB}"));
+        session
+            .execute_non_query(&format!("CREATE DATABASE {DB}"))
+            .expect("create database");
+        session
+            .execute_non_query(&format!(
+                "CREATE TIMESERIES {DB}.d1.s1 WITH DATATYPE=INT32, 
ENCODING=PLAIN"
+            ))
+            .expect("create timeseries");
+        session
+            .execute_non_query(&format!(
+                "INSERT INTO {DB}.d1(timestamp, s1) VALUES (1, 42)"
+            ))
+            .expect("insert");
+        let mut rows = Vec::new();
+        {
+            let mut dataset = session
+                .execute_query(&format!("SELECT s1 FROM {DB}.d1"))
+                .expect("query");
+            while let Some(row) = dataset.next_row().expect("next_row") {
+                rows.push((row.timestamp.expect("timestamp"), 
row.values[0].clone()));
+            }
+        }
+        assert_eq!(rows, [(1, crate::data::Value::Int32(42))]);
+        session
+            .execute_non_query(&format!("DELETE DATABASE {DB}"))
+            .expect("cleanup");
+        session.close().expect("close session");
     }
 
     /// DATE wire-format adjudication test (goal V1). Inserts one DATE row via
diff --git a/src/client/table_session.rs b/src/client/table_session.rs
index a37b02f..b708e26 100644
--- a/src/client/table_session.rs
+++ b/src/client/table_session.rs
@@ -144,6 +144,22 @@ impl TableSessionBuilder {
         self
     }
 
+    /// PEM client certificate for mutual TLS; set together with
+    /// [`client_key_path`](Self::client_key_path) (cargo feature `tls`).
+    #[cfg(feature = "tls")]
+    pub fn client_cert_path(mut self, path: impl Into<std::path::PathBuf>) -> 
Self {
+        self.config.client_cert_path = Some(path.into());
+        self
+    }
+
+    /// PEM PKCS#8 private key for the client certificate; set together with
+    /// [`client_cert_path`](Self::client_cert_path) (cargo feature `tls`).
+    #[cfg(feature = "tls")]
+    pub fn client_key_path(mut self, path: impl Into<std::path::PathBuf>) -> 
Self {
+        self.config.client_key_path = Some(path.into());
+        self
+    }
+
     /// The [`SessionConfig`] this builder resolves to (dialect always
     /// `"table"`).
     pub fn config(&self) -> &SessionConfig {
@@ -249,12 +265,22 @@ mod tests {
                 .use_ssl(true)
                 .ca_cert_path("/certs/ca.pem")
                 .accept_invalid_certs(true)
-                .domain_override("iotdb.internal");
+                .domain_override("iotdb.internal")
+                .client_cert_path("/certs/client.pem")
+                .client_key_path("/certs/client-key.pem");
             let cfg = b.config();
             assert!(cfg.use_ssl);
             assert_eq!(cfg.ca_cert_path.as_deref(), 
Some("/certs/ca.pem".as_ref()));
             assert!(cfg.accept_invalid_certs);
             assert_eq!(cfg.domain_override.as_deref(), Some("iotdb.internal"));
+            assert_eq!(
+                cfg.client_cert_path.as_deref(),
+                Some("/certs/client.pem".as_ref())
+            );
+            assert_eq!(
+                cfg.client_key_path.as_deref(),
+                Some("/certs/client-key.pem".as_ref())
+            );
         }
     }
 
diff --git a/src/connection/mod.rs b/src/connection/mod.rs
index 5f77f6c..69dc3c7 100644
--- a/src/connection/mod.rs
+++ b/src/connection/mod.rs
@@ -71,6 +71,15 @@ pub struct TlsOptions {
     /// Hostname used for SNI + certificate validation instead of the
     /// endpoint host (e.g. when connecting by IP).
     pub domain_override: Option<String>,
+    /// PEM client certificate for mutual TLS (server has
+    /// `thrift_ssl_client_auth=true`). Must be set together with
+    /// [`client_key_path`](Self::client_key_path); mirrors the Node.js
+    /// `sslOptions.cert`.
+    pub client_cert_path: Option<std::path::PathBuf>,
+    /// PEM PKCS#8 private key for the client certificate. Must be set
+    /// together with [`client_cert_path`](Self::client_cert_path); mirrors
+    /// the Node.js `sslOptions.key`.
+    pub client_key_path: Option<std::path::PathBuf>,
 }
 
 /// How to open a [`Connection`]: timeout, wire protocol, optional TLS.
@@ -272,6 +281,30 @@ fn tls_handshake(
     if tls.accept_invalid_certs {
         builder.danger_accept_invalid_certs(true);
     }
+    match (&tls.client_cert_path, &tls.client_key_path) {
+        (Some(cert_path), Some(key_path)) => {
+            let cert = std::fs::read(cert_path).map_err(|e| {
+                Error::Client(format!(
+                    "cannot read client certificate {}: {e}",
+                    cert_path.display()
+                ))
+            })?;
+            let key = std::fs::read(key_path).map_err(|e| {
+                Error::Client(format!(
+                    "cannot read client key {}: {e}",
+                    key_path.display()
+                ))
+            })?;
+            let identity = native_tls::Identity::from_pkcs8(&cert, 
&key).map_err(Error::Tls)?;
+            builder.identity(identity);
+        }
+        (None, None) => {}
+        _ => {
+            return Err(Error::Client(
+                "mutual TLS requires both client_cert_path and 
client_key_path".into(),
+            ))
+        }
+    }
     let connector = builder.build().map_err(Error::Tls)?;
     let domain = tls.domain_override.as_deref().unwrap_or(&endpoint.host);
     connector.connect(domain, stream).map_err(|e| match e {
@@ -396,7 +429,7 @@ mod tests {
 
     /// A local listener that accepts and immediately drops connections, so
     /// `Connection::open` (which issues no RPC) succeeds for any protocol.
-    fn accept_then_drop_listener() -> Endpoint {
+    pub(super) fn accept_then_drop_listener() -> Endpoint {
         let listener = 
std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
         let port = listener.local_addr().expect("local_addr").port();
         std::thread::spawn(move || {
@@ -410,6 +443,51 @@ mod tests {
         Endpoint::new("127.0.0.1", port)
     }
 
+    /// A local listener that accepts one connection, reports the first byte
+    /// the client puts on the wire, then closes (unblocking the client with
+    /// an EOF/reset). Lets tests assert *which* stack touched the socket:
+    /// a TLS ClientHello starts with the handshake record type `0x16`, a
+    /// plain Thrift framed message with the frame-length MSB `0x00`.
+    pub(super) fn first_byte_listener() -> (Endpoint, 
std::sync::mpsc::Receiver<u8>) {
+        let listener = 
std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
+        let port = listener.local_addr().expect("local_addr").port();
+        let (tx, rx) = std::sync::mpsc::channel();
+        std::thread::spawn(move || {
+            if let Ok((mut stream, _)) = listener.accept() {
+                let mut byte = [0u8; 1];
+                if stream.read_exact(&mut byte).is_ok() {
+                    let _ = tx.send(byte[0]);
+                }
+            }
+        });
+        (Endpoint::new("127.0.0.1", port), rx)
+    }
+
+    /// Dispatch (Node.js `Connection.test.ts` analogue): without TLS the
+    /// plain TCP stack talks to the socket — the first wire byte of an RPC
+    /// is the framed-transport length MSB (`0x00`), not a TLS ClientHello
+    /// record type (`0x16`).
+    #[test]
+    fn plain_dispatch_writes_thrift_frame_not_tls() {
+        use crate::protocol::client::TIClientRPCServiceSyncClient;
+
+        let (endpoint, first_byte) = first_byte_listener();
+        #[allow(clippy::needless_update)]
+        let options = ConnectionOptions {
+            connect_timeout: Duration::from_millis(500),
+            protocol: RpcProtocol::Binary,
+            ..Default::default()
+        };
+        let mut connection = Connection::open(endpoint, 
&options).expect("plain open");
+        // The RPC itself fails once the listener closes after one byte —
+        // only the bytes it managed to put on the wire matter here.
+        let _ = connection.client_mut().request_statement_id(1);
+        let byte = first_byte
+            .recv_timeout(Duration::from_secs(5))
+            .expect("first wire byte");
+        assert_eq!(byte, 0x00, "expected framed length MSB, got 0x{byte:02x}");
+    }
+
     /// Both protocol variants construct their transport/protocol stack and
     /// report the choice back via `Connection::protocol`. (Wire-level
     /// verification against a live server lives in the session tests.)
@@ -482,6 +560,7 @@ mod tls_tests {
                 ca_cert_path: Some(fixture("cert.pem")),
                 accept_invalid_certs: false,
                 domain_override: Some("localhost".into()),
+                ..Default::default()
             }),
         };
         let mut connection = Connection::open(endpoint, &options).expect("TLS 
handshake");
@@ -508,6 +587,7 @@ mod tls_tests {
                 ca_cert_path: None,
                 accept_invalid_certs: false,
                 domain_override: Some("localhost".into()),
+                ..Default::default()
             }),
         };
         let err = match Connection::open(endpoint, &options) {
@@ -534,6 +614,166 @@ mod tls_tests {
         assert_eq!(connection.protocol(), RpcProtocol::Compact);
     }
 
+    /// Dispatch (Node.js `Connection.test.ts` analogue), TLS side: with
+    /// `tls: Some(..)` the first wire byte is the TLS handshake record type
+    /// `0x16` (ClientHello) — the plain Thrift stack never touches the
+    /// socket. The listener is not a TLS server, so `open` itself fails.
+    #[test]
+    fn tls_dispatch_sends_client_hello() {
+        let (endpoint, first_byte) = super::tests::first_byte_listener();
+        let options = ConnectionOptions {
+            connect_timeout: Duration::from_millis(500),
+            protocol: RpcProtocol::Binary,
+            tls: Some(TlsOptions {
+                accept_invalid_certs: true,
+                ..Default::default()
+            }),
+        };
+        assert!(
+            Connection::open(endpoint, &options).is_err(),
+            "plain listener is not a TLS server"
+        );
+        let byte = first_byte
+            .recv_timeout(Duration::from_secs(5))
+            .expect("first wire byte");
+        assert_eq!(
+            byte, 0x16,
+            "expected TLS handshake record type, got 0x{byte:02x}"
+        );
+    }
+
+    /// Dispatch pair against the *same kind* of plain (non-TLS) endpoint:
+    /// `tls: None` opens fine (plain TCP), `tls: Some(..)` — even with
+    /// certificate verification disabled — dies in the handshake with a
+    /// TLS error. Together with the ClientHello byte check this proves the
+    /// `tls` option selects the code path, mirroring the Node.js test that
+    /// asserts the SSL constructor is (not) called.
+    #[test]
+    fn tls_option_selects_stack_against_plain_endpoint() {
+        let endpoint = super::tests::accept_then_drop_listener();
+
+        let plain = ConnectionOptions {
+            connect_timeout: Duration::from_millis(500),
+            protocol: RpcProtocol::Binary,
+            tls: None,
+        };
+        Connection::open(endpoint.clone(), &plain).expect("plain open against 
plain listener");
+
+        let tls = ConnectionOptions {
+            connect_timeout: Duration::from_millis(500),
+            protocol: RpcProtocol::Binary,
+            tls: Some(TlsOptions {
+                accept_invalid_certs: true,
+                ..Default::default()
+            }),
+        };
+        let err = match Connection::open(endpoint, &tls) {
+            Ok(_) => panic!("TLS handshake against a plain endpoint must 
fail"),
+            Err(e) => e,
+        };
+        assert!(matches!(err, Error::Tls(_)), "got {err:?}");
+    }
+
+    /// Mutual TLS: a PEM client certificate + PKCS#8 key load into an
+    /// identity and the handshake completes with the identity configured.
+    /// `native-tls`'s `TlsAcceptor` has no API to *request* a client
+    /// certificate, so the loopback server cannot verify it — acceptor-side
+    /// client-auth is exercised only by the live `IOTDB_TLS_URL` test
+    /// against a real server.
+    #[test]
+    fn tls_client_identity_handshake_succeeds() {
+        let endpoint = tls_acceptor_once();
+        let options = ConnectionOptions {
+            connect_timeout: Duration::from_millis(500),
+            protocol: RpcProtocol::Binary,
+            tls: Some(TlsOptions {
+                ca_cert_path: Some(fixture("cert.pem")),
+                domain_override: Some("localhost".into()),
+                client_cert_path: Some(fixture("client-cert.pem")),
+                client_key_path: Some(fixture("client-key.pem")),
+                ..Default::default()
+            }),
+        };
+        let connection = Connection::open(endpoint, &options).expect("TLS 
handshake with identity");
+        assert_eq!(connection.protocol(), RpcProtocol::Binary);
+    }
+
+    /// Setting only one of the client cert/key pair is a config error
+    /// caught before any I/O.
+    #[test]
+    fn tls_client_identity_requires_both_paths() {
+        let endpoint = tls_acceptor_once();
+        for (cert, key) in [
+            (Some(fixture("client-cert.pem")), None),
+            (None, Some(fixture("client-key.pem"))),
+        ] {
+            let options = ConnectionOptions {
+                connect_timeout: Duration::from_millis(500),
+                protocol: RpcProtocol::Binary,
+                tls: Some(TlsOptions {
+                    accept_invalid_certs: true,
+                    client_cert_path: cert.clone(),
+                    client_key_path: key.clone(),
+                    ..Default::default()
+                }),
+            };
+            let err = match Connection::open(endpoint.clone(), &options) {
+                Ok(_) => panic!("half a client identity must fail"),
+                Err(e) => e,
+            };
+            assert!(
+                matches!(&err, Error::Client(m) if m.contains("mutual TLS")),
+                "cert={cert:?} key={key:?}: got {err:?}"
+            );
+        }
+    }
+
+    /// A missing client key file is a clear client error before any connect.
+    #[test]
+    fn tls_missing_client_key_file_is_client_error() {
+        let endpoint = tls_acceptor_once();
+        let options = ConnectionOptions {
+            connect_timeout: Duration::from_millis(500),
+            protocol: RpcProtocol::Binary,
+            tls: Some(TlsOptions {
+                accept_invalid_certs: true,
+                client_cert_path: Some(fixture("client-cert.pem")),
+                client_key_path: Some(fixture("does-not-exist-key.pem")),
+                ..Default::default()
+            }),
+        };
+        let err = match Connection::open(endpoint, &options) {
+            Ok(_) => panic!("missing client key must fail"),
+            Err(e) => e,
+        };
+        assert!(
+            matches!(&err, Error::Client(m) if m.contains("cannot read client 
key")),
+            "got {err:?}"
+        );
+    }
+
+    /// A file that is not a PKCS#8 key (here: the certificate itself) fails
+    /// identity construction with a TLS error, not a panic.
+    #[test]
+    fn tls_corrupt_client_key_is_tls_error() {
+        let endpoint = tls_acceptor_once();
+        let options = ConnectionOptions {
+            connect_timeout: Duration::from_millis(500),
+            protocol: RpcProtocol::Binary,
+            tls: Some(TlsOptions {
+                accept_invalid_certs: true,
+                client_cert_path: Some(fixture("client-cert.pem")),
+                client_key_path: Some(fixture("client-cert.pem")), // not a key
+                ..Default::default()
+            }),
+        };
+        let err = match Connection::open(endpoint, &options) {
+            Ok(_) => panic!("a certificate is not a private key"),
+            Err(e) => e,
+        };
+        assert!(matches!(err, Error::Tls(_)), "got {err:?}");
+    }
+
     /// A missing CA file is a clear client error before any connect.
     #[test]
     fn tls_missing_ca_file_is_client_error() {
diff --git a/tests/fixtures/tls/README.md b/tests/fixtures/tls/README.md
index 4ff8a2a..7b5a9e5 100644
--- a/tests/fixtures/tls/README.md
+++ b/tests/fixtures/tls/README.md
@@ -19,9 +19,14 @@ under the License.
 
 # TLS test fixtures
 
-Self-signed certificate + PKCS#8 key used only by the loopback TLS tests
-(`connection::tls_tests`, cargo feature `tls`). Not a secret — the key
-never protects anything.
+Self-signed certificates + PKCS#8 keys used only by the loopback TLS tests
+(`connection::tls_tests`, cargo feature `tls`) and the opt-in live TLS test.
+Not secrets — the keys never protect anything.
+
+| File | Role |
+|---|---|
+| `cert.pem` + `key.pem` | server identity (loopback `TlsAcceptor`, 
live-server keystore) |
+| `client-cert.pem` + `client-key.pem` | client identity for the mutual-TLS 
tests |
 
 macOS SecureTransport imposes extra requirements even on explicitly
 trusted roots: validity ≤ 825 days (error −67901) and an
@@ -37,3 +42,49 @@ openssl req -x509 -newkey rsa:2048 -keyout key.pem -out 
cert.pem \
   -addext "extendedKeyUsage=serverAuth" \
   -addext "keyUsage=digitalSignature,keyEncipherment"
 ```
+
+The client certificate (expires **2028-10-10** as well) is standalone
+self-signed — `cert.pem` cannot act as its issuer because its `keyUsage`
+lacks `keyCertSign`; nothing in the tests validates the client chain
+anyway (`native-tls`'s `TlsAcceptor` cannot request client certs):
+
+```sh
+cd tests/fixtures/tls
+openssl req -x509 -newkey rsa:2048 -keyout client-key.pem -out client-cert.pem 
\
+  -days 820 -nodes -subj "/CN=iotdb-client-rust-test-client" \
+  -addext "extendedKeyUsage=clientAuth" \
+  -addext "keyUsage=digitalSignature,keyEncipherment"
+```
+
+## Live TLS server keystore
+
+The end-to-end test (`live_tls_roundtrip`, gated by `IOTDB_TLS_URL`) needs a
+TLS-enabled IoTDB. Build a PKCS#12 keystore from the server fixture pair
+(regenerate whenever `cert.pem` is regenerated; not checked in):
+
+```sh
+cd tests/fixtures/tls
+openssl pkcs12 -export -in cert.pem -inkey key.pem \
+  -out server-keystore.p12 -name iotdb -passout pass:iotdbtls
+```
+
+Then run a throwaway TLS-enabled server and point the test at it
+(the docker env-var mechanism appends any lowercase var to
+`iotdb-system.properties`; IoTDB ≥ 2.x reads `enable_thrift_ssl`,
+`key_store_path`, `key_store_pwd` — client RPC port only, internal
+cluster ports stay plain):
+
+```sh
+docker run -d --name iotdb-rust-tls-test -p 6668:6667 \
+  -e enable_thrift_ssl=true \
+  -e key_store_path=/tls/server-keystore.p12 \
+  -e key_store_pwd=iotdbtls \
+  -v "$PWD/server-keystore.p12":/tls/server-keystore.p12:ro \
+  apache/iotdb:2.0.6-standalone
+
+IOTDB_TLS_URL=127.0.0.1:6668 \
+IOTDB_TLS_CA=tests/fixtures/tls/cert.pem \
+cargo test --features tls live_tls_roundtrip
+
+docker rm -f iotdb-rust-tls-test
+```
diff --git a/tests/fixtures/tls/client-cert.pem 
b/tests/fixtures/tls/client-cert.pem
new file mode 100644
index 0000000..7fd9ba9
--- /dev/null
+++ b/tests/fixtures/tls/client-cert.pem
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDUzCCAjugAwIBAgIUPEd19SXvrayngu1MttuvSr5zI9owDQYJKoZIhvcNAQEL
+BQAwKDEmMCQGA1UEAwwdaW90ZGItY2xpZW50LXJ1c3QtdGVzdC1jbGllbnQwHhcN
+MjYwNzEzMDY0MDUxWhcNMjgxMDEwMDY0MDUxWjAoMSYwJAYDVQQDDB1pb3RkYi1j
+bGllbnQtcnVzdC10ZXN0LWNsaWVudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
+AQoCggEBAN9vhv0UheTWaJh44F0/0Hn0gvek+YQQkhP5AU7m5XRR10ahG9m/3XLv
+5AVxvowaVYtmlTBigyLOZH8AX7UrUg4RMMI/lahUmthauXFklfW8cnCj+1BEsYiD
+rXH6vmFV8gpmuNgBC8cKfJJKk9/A+DmuMHMBuNYe81SgHQTceidp8c3SRaRx9mQq
+Nhtix/a9aerFbxuK3XERl2skzJZRLib0aUKXGLrEGFIrlTea95zZ7Q9R0NbU+6XJ
+/KHHAjdWH/jP1vk+a+G1ov/jRsbhuAjIPSyuFTS+nBsHjTvccA4Jb2iALu5ImzUT
+GEvB+CBeYe+hzxW4WE7kycf3+C7GhZkCAwEAAaN1MHMwHQYDVR0OBBYEFLjH7RNa
+2AHWqxjUag9W4M3maHwFMB8GA1UdIwQYMBaAFLjH7RNa2AHWqxjUag9W4M3maHwF
+MA8GA1UdEwEB/wQFMAMBAf8wEwYDVR0lBAwwCgYIKwYBBQUHAwIwCwYDVR0PBAQD
+AgWgMA0GCSqGSIb3DQEBCwUAA4IBAQC4sjGOurJLqDYRGneoWfleQdQXI3uelmMJ
++cHMjwU/CoCC1Ri3WYwQR4FTQYSI6ONhneVNTFp4fZSH5qYOzH6U1FudpaarY8kD
+bYRi/01MnmJ+rJW7K5ni9EVMN3qH4w3GK+pTOx1SkEUFQYzRHNCtFSWvYATk8eFQ
+JdBghxHPe7WQrgOre4Z7WgUUU4IwA9nfp+rLArXaQFfzX0qpm/9LzluHyTS895Ph
+E0Jn2hdUzLQGwfW7e5W6WRbtzPAuDoXz87pvYcnX020S+//wYoEE6zRnq/mvSgMC
+GGXBAsp4ihTz1zcwYTUlUabTSYY0R8MRP7jSvAtUf1JFG4BtcMUf
+-----END CERTIFICATE-----
diff --git a/tests/fixtures/tls/client-key.pem 
b/tests/fixtures/tls/client-key.pem
new file mode 100644
index 0000000..d145123
--- /dev/null
+++ b/tests/fixtures/tls/client-key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDfb4b9FIXk1miY
+eOBdP9B59IL3pPmEEJIT+QFO5uV0UddGoRvZv91y7+QFcb6MGlWLZpUwYoMizmR/
+AF+1K1IOETDCP5WoVJrYWrlxZJX1vHJwo/tQRLGIg61x+r5hVfIKZrjYAQvHCnyS
+SpPfwPg5rjBzAbjWHvNUoB0E3HonafHN0kWkcfZkKjYbYsf2vWnqxW8bit1xEZdr
+JMyWUS4m9GlClxi6xBhSK5U3mvec2e0PUdDW1PulyfyhxwI3Vh/4z9b5PmvhtaL/
+40bG4bgIyD0srhU0vpwbB4073HAOCW9ogC7uSJs1ExhLwfggXmHvoc8VuFhO5MnH
+9/guxoWZAgMBAAECggEAE3YPG6WJOQKc1yT2I+EeSzULID9QFVrtIky2urTdSPmk
+5su7FYcC5pMy+O9skZ7skwkDFxUJh4NTGQXDEFBe5AdGyDCKHECMQSp7yv7gGeiJ
+TjrWNapaDUIs3gfhQ7Cc9Z93W6cRq8tDyOkygpN3+2wq36WHgCzvdFBC3szGXf7Q
+XIR8uyGtspriTfUeDCAAUHfpW1DyTrki8sG7VltvtTiXT5GFkDT1PKJuOZ6QLRzR
+VxvykmOG+sJo1kwp2vSCXr3aMxLVWXV77roh5jc8OTYG4nIpMY8T1zls5rcfTHX9
+UUps10Qs/tRgvYWIYPTFDe+FEzvI5rJ2ubIB7z/JwQKBgQD9BnQe4jcOJtdAubAW
+ROHU+w+fv/BVP6bqNLPspKVY/IgfNgbLd4Kh/AnUqE176CYzQ5cFnS2S4BYop+KY
+BOYKME2R+M3GRbPuWfOWTLZYb9R949EdIeZ/7fUalZNshm+j9faVANbW6re4olON
+47Fnc3fUAJxQVCFB/c9Sn9ODIQKBgQDiEAQe0L4GsH0KZlT/HOUgMzThs47jeOUL
+0q5bNtjj4Tra1OeGJw6V3wg6p41a5GLj94KdSVLa/zOmYSc95MpqYGWdXZYnaxAv
+XRFO2e9ZExgL/0zbYkSW0fEFDCerTMr9spGUBYEXwyuTD7ok7QgEpRs23SVxbYAp
+dRKniucreQKBgQDAft9q+4kNICmU2XAIkSEKSnLJg1nRUVqoRa44s84DlCPvMsga
+lXJxz/Ces+g3AxfE2oATyk94tTjGd2shYmCskbECA7pxRGguRorV5si3IWUU3XQi
+6L6Wxy84qWD+KIzYvXB6Tagk228obX6JrRczcBpS1KAXUNn4faLz5hohYQKBgQDM
+v7DbsDjMmkFGkBTf0237UiXNXvJGORNLOBDPcMfU7gR+e2MPcISXXaB4b5VqA31F
++MGGcAjpbUd8pHYEaxqiapjehQsgvKm2HFc20dElHlQjWaJk+YYiDBh+d9neHvmj
++n48URfxS8ZFtnLkSwN+IYSaloX5TDJOWkkBEp/6EQKBgH5mtkUnxU1lye7QQqwe
+j7fP1Ae0JJS1jx2F8JtaaDf6xkIXt1mbDOcYV+2h/iM+G6kBkFr++uKp+4vRSePu
+ooynmgYGufFbVpSEUl4t6hbkyH9vX6Z4iDzgBND97PFT63Nd0SE/TTDG+lvDD+Tg
+kRDuj1zcTzjLe6YLrf/QaMfk
+-----END PRIVATE KEY-----

Reply via email to