@@ -4,7 +4,7 @@ use crate::util::{FibonacciBackoffBuilder, RetryError, retry};
44use crate :: {
55 config:: { GenericError , SPICE_CLOUD_FLIGHT_ADDR , SPICE_LOCAL_FLIGHT_ADDR } ,
66 flight:: { SqlFlightClient , is_connection_reset_generic_error} ,
7- tls:: { ensure_crypto_provider, new_tls_flight_channel} ,
7+ tls:: { FlightChannelBuilder , ensure_crypto_provider, new_tls_flight_channel} ,
88} ;
99use arrow:: record_batch:: RecordBatch ;
1010use arrow_flight:: error:: FlightError ;
@@ -408,6 +408,9 @@ pub struct SpiceClientBuilder {
408408 http_url : Option < String > ,
409409 cache_control : Option < String > ,
410410 max_retries : u32 ,
411+ tls_client_certificate_file : Option < String > ,
412+ tls_client_key_file : Option < String > ,
413+ tls_ca_certificate_file : Option < String > ,
411414}
412415
413416impl Default for SpiceClientBuilder {
@@ -426,6 +429,9 @@ impl SpiceClientBuilder {
426429 http_url : None ,
427430 cache_control : None ,
428431 max_retries : MAX_RETRIES ,
432+ tls_client_certificate_file : None ,
433+ tls_client_key_file : None ,
434+ tls_ca_certificate_file : None ,
429435 }
430436 }
431437
@@ -483,21 +489,88 @@ impl SpiceClientBuilder {
483489 self
484490 }
485491
492+ /// Sets the path to a PEM-encoded client certificate file for mTLS.
493+ /// Must be used together with [`tls_client_key_file`](Self::tls_client_key_file).
494+ #[ must_use]
495+ pub fn tls_client_certificate_file ( mut self , path : & str ) -> Self {
496+ self . tls_client_certificate_file = Some ( path. to_string ( ) ) ;
497+ self
498+ }
499+
500+ /// Sets the path to a PEM-encoded client private key file for mTLS.
501+ /// Must be used together with [`tls_client_certificate_file`](Self::tls_client_certificate_file).
502+ #[ must_use]
503+ pub fn tls_client_key_file ( mut self , path : & str ) -> Self {
504+ self . tls_client_key_file = Some ( path. to_string ( ) ) ;
505+ self
506+ }
507+
508+ /// Sets the path to a custom CA certificate file for server verification.
509+ /// When set, this CA is used instead of the system certificate store.
510+ #[ must_use]
511+ pub fn tls_ca_certificate_file ( mut self , path : & str ) -> Self {
512+ self . tls_ca_certificate_file = Some ( path. to_string ( ) ) ;
513+ self
514+ }
515+
486516 /// Builds the `SpiceClient` with the specified configuration.
487517 ///
488518 /// ## Errors
489519 ///
490520 /// - `Box<dyn Error + Send + Sync>` if flight channel creation fails
491521 pub async fn build ( self ) -> Result < SpiceClient , GenericError > {
492522 ensure_crypto_provider ( ) ;
493- let flight_channel = match self . flight_url {
494- Some ( url) => new_tls_flight_channel ( & url) . await ?,
495- None => new_tls_flight_channel ( SPICE_LOCAL_FLIGHT_ADDR ) . await ?,
496- } ;
497523
498- let http_client = self
499- . http_url
500- . map ( |url| Arc :: new ( QueryHttpClient :: new ( & url, self . api_key . clone ( ) ) ) ) ;
524+ // Validate that client cert and key are either both set or both unset
525+ match ( & self . tls_client_certificate_file , & self . tls_client_key_file ) {
526+ ( Some ( _) , None ) => {
527+ return Err ( "tls_client_certificate_file is set but tls_client_key_file is missing; both must be provided together for mTLS" . into ( ) ) ;
528+ }
529+ ( None , Some ( _) ) => {
530+ return Err ( "tls_client_key_file is set but tls_client_certificate_file is missing; both must be provided together for mTLS" . into ( ) ) ;
531+ }
532+ _ => { }
533+ }
534+
535+ let url = self
536+ . flight_url
537+ . as_deref ( )
538+ . unwrap_or ( SPICE_LOCAL_FLIGHT_ADDR ) ;
539+
540+ let mut channel_builder = FlightChannelBuilder :: new ( url) ;
541+ if let ( Some ( cert) , Some ( key) ) =
542+ ( & self . tls_client_certificate_file , & self . tls_client_key_file )
543+ {
544+ channel_builder = channel_builder. with_client_certificate ( cert, key) ;
545+ }
546+ if let Some ( ca) = & self . tls_ca_certificate_file {
547+ channel_builder = channel_builder. with_ca_certificate ( ca) ;
548+ }
549+ let flight_channel = channel_builder. build ( ) . await ?;
550+
551+ let http_client = if let Some ( url) = self . http_url {
552+ let mut builder = reqwest:: Client :: builder ( ) ;
553+ if let ( Some ( cert_path) , Some ( key_path) ) =
554+ ( & self . tls_client_certificate_file , & self . tls_client_key_file )
555+ {
556+ let cert_pem = tokio:: fs:: read ( cert_path) . await ?;
557+ let key_pem = tokio:: fs:: read ( key_path) . await ?;
558+ let identity = reqwest:: Identity :: from_pem ( & [ cert_pem, key_pem] . concat ( ) ) ?;
559+ builder = builder. identity ( identity) ;
560+ }
561+ if let Some ( ca_path) = & self . tls_ca_certificate_file {
562+ let ca_pem = tokio:: fs:: read ( ca_path) . await ?;
563+ let ca = reqwest:: Certificate :: from_pem ( & ca_pem) ?;
564+ builder = builder. add_root_certificate ( ca) ;
565+ }
566+ Some ( Arc :: new ( QueryHttpClient :: with_client (
567+ builder. build ( ) ?,
568+ & url,
569+ self . api_key . clone ( ) ,
570+ ) ) )
571+ } else {
572+ None
573+ } ;
501574
502575 Ok ( SpiceClient {
503576 flight : Arc :: new ( SqlFlightClient :: new (
0 commit comments