@@ -113,9 +113,58 @@ fn parse_google_uri(uri: &Uri) -> Option<(String, PathBuf)> {
113113
114114#[ cfg( test) ]
115115mod tests {
116+ use std:: collections:: BTreeMap ;
117+ use std:: path:: Path ;
118+ use std:: sync:: Arc ;
119+
120+ use base64:: Engine ;
121+ use opendal:: raw:: HttpClient ;
116122 use quickwit_common:: uri:: Uri ;
123+ use rustls:: pki_types:: { CertificateDer , PrivateKeyDer , PrivateSec1KeyDer } ;
124+ use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
125+ use tokio:: net:: TcpListener ;
126+ use tokio:: task:: JoinHandle ;
127+
128+ use super :: { OpendalStorage , parse_google_uri} ;
129+ use crate :: Storage ;
130+
131+ // Test-only CA and server certificate for 127.0.0.1/localhost, valid from
132+ // 2020 to 3020. The client trusts only this CA, so the test never depends
133+ // on the host root store.
134+ // Regenerate with:
135+ // `bash -c 'set -euo pipefail; d=$(mktemp -d); trap "rm -rf $d" EXIT; openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -subj "/CN=Quickwit Test CA" -set_serial 0x1001 -not_before 20200101000000Z -not_after 30200101000000Z -extensions v3_ca -config <(printf "%s\n" "[req]" "distinguished_name=dn" "[dn]" "[v3_ca]" "basicConstraints=critical,CA:true" "keyUsage=critical,keyCertSign,cRLSign" "subjectKeyIdentifier=hash" "authorityKeyIdentifier=keyid:always") -keyout "$d/ca.key" -out "$d/ca.crt" 2>/dev/null; openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -subj "/CN=localhost" -CA "$d/ca.crt" -CAkey "$d/ca.key" -set_serial 0x1002 -not_before 20200101000000Z -not_after 30200101000000Z -extensions v3_server -config <(printf "%s\n" "[req]" "distinguished_name=dn" "[dn]" "[v3_server]" "basicConstraints=critical,CA:false" "keyUsage=critical,digitalSignature" "extendedKeyUsage=serverAuth" "subjectAltName=DNS:localhost,IP:127.0.0.1" "subjectKeyIdentifier=hash" "authorityKeyIdentifier=keyid,issuer") -keyout "$d/server.key" -out "$d/server.crt" 2>/dev/null; printf "CA_DER="; openssl x509 -in "$d/ca.crt" -outform der | base64 | tr -d "\n"; printf "\nSERVER_DER="; openssl x509 -in "$d/server.crt" -outform der | base64 | tr -d "\n"; printf "\nSERVER_KEY_DER="; openssl ec -in "$d/server.key" -outform der 2>/dev/null | base64 | tr -d "\n"; printf "\n"'`
136+ const TEST_CA_CERT_DER_BASE64 : & str = concat ! (
137+ "MIIBizCCATGgAwIBAgICEAEwCgYIKoZIzj0EAwIwGzEZMBcGA1UEAwwQUXVpY2t3" ,
138+ "aXQgVGVzdCBDQTAgFw0yMDAxMDEwMDAwMDBaGA8zMDIwMDEwMTAwMDAwMFowGzEZ" ,
139+ "MBcGA1UEAwwQUXVpY2t3aXQgVGVzdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH" ,
140+ "A0IABH+1ZvivhT0E5FydtoMGBkyenql8XPyFTPBhTfHycTjfTWJiETjILGadPLKY" ,
141+ "OZJky8ThPZUpKAux5M4SaazdX1WjYzBhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P" ,
142+ "AQH/BAQDAgEGMB0GA1UdDgQWBBQmOMvIHAegmBHwvdVGyguC/57/4zAfBgNVHSME" ,
143+ "GDAWgBQmOMvIHAegmBHwvdVGyguC/57/4zAKBggqhkjOPQQDAgNIADBFAiEAnE7M" ,
144+ "lcB35MOr+7WKDAhu/c6ZrpgRz+chqqfc3g5YTOECIEDmoPkOigkulNON67opCPaT" ,
145+ "y+MQhMA9KDEzE3t/CY9V" ,
146+ ) ;
147+
148+ const TEST_SERVER_CERT_DER_BASE64 : & str = concat ! (
149+ "MIIBszCCAVqgAwIBAgICEAIwCgYIKoZIzj0EAwIwGzEZMBcGA1UEAwwQUXVpY2t3" ,
150+ "aXQgVGVzdCBDQTAgFw0yMDAxMDEwMDAwMDBaGA8zMDIwMDEwMTAwMDAwMFowFDES" ,
151+ "MBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEowPj" ,
152+ "3vpXPAkf04MNeGhaDBvtwMsmeipV57lSWx5K2FwXH7JDmt74k4HmQFB6JESy6FbM" ,
153+ "tAVhivr7kG5dWKK/sqOBkjCBjzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIH" ,
154+ "gDATBgNVHSUEDDAKBggrBgEFBQcDATAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A" ,
155+ "AAEwHQYDVR0OBBYEFES7BP5uQpa3+PktDVlgc9zYGIqDMB8GA1UdIwQYMBaAFCY4" ,
156+ "y8gcB6CYEfC91UbKC4L/nv/jMAoGCCqGSM49BAMCA0cAMEQCIGtIKEWRn7ec82TY" ,
157+ "s1jrUoKWnhzRDbZTUtvXORk190rHAiAosxVgu45TjDyuROKU39TxJ1z+JObhNGk8" ,
158+ "J6PkuOTFqg==" ,
159+ ) ;
160+
161+ const TEST_SERVER_KEY_DER_BASE64 : & str = concat ! (
162+ "MHcCAQEEIIql19flBaZJE16Ivs8GjdJHedhuU5YFZgvIn4WaOs6HoAoGCCqGSM49" ,
163+ "AwEHoUQDQgAEowPj3vpXPAkf04MNeGhaDBvtwMsmeipV57lSWx5K2FwXH7JDmt74" ,
164+ "k4HmQFB6JESy6FbMtAVhivr7kG5dWKK/sg==" ,
165+ ) ;
117166
118- use super :: parse_google_uri ;
167+ type LocalHttpsGcsServer = ( String , JoinHandle < anyhow :: Result < ( ) > > ) ;
119168
120169 #[ test]
121170 fn test_parse_google_uri ( ) {
@@ -134,4 +183,123 @@ mod tests {
134183 assert_eq ! ( bucket, "test-bucket" ) ;
135184 assert_eq ! ( prefix. to_str( ) . unwrap( ) , "indexes" ) ;
136185 }
186+
187+ #[ tokio:: test]
188+ async fn test_gcs_storage_get_slice_over_https_with_verified_tls ( ) -> anyhow:: Result < ( ) > {
189+ let ( endpoint, server_task) = start_local_https_gcs_server ( ) . await ?;
190+ let ca_cert_der = decode_test_der ( TEST_CA_CERT_DER_BASE64 ) ?;
191+ let ca_cert = reqwest_013:: Certificate :: from_der ( & ca_cert_der) ?;
192+ let reqwest_client = reqwest_013:: Client :: builder ( )
193+ . no_proxy ( )
194+ . tls_certs_only ( [ ca_cert] )
195+ . build ( ) ?;
196+
197+ let cfg = opendal:: services:: Gcs :: default ( )
198+ . bucket ( "quickwit-test-bucket" )
199+ . endpoint ( & endpoint)
200+ . allow_anonymous ( )
201+ . disable_config_load ( )
202+ . disable_vm_metadata ( ) ;
203+ let storage = OpendalStorage :: new_google_cloud_storage_with_http_client_for_test (
204+ Uri :: for_test ( "gs://quickwit-test-bucket" ) ,
205+ cfg,
206+ HttpClient :: with ( reqwest_client) ,
207+ ) ?;
208+
209+ let bytes = storage. get_slice ( Path :: new ( "hello.txt" ) , 0 ..2 ) . await ?;
210+ assert_eq ! ( bytes. as_slice( ) , b"ok" ) ;
211+ server_task. await ??;
212+ Ok ( ( ) )
213+ }
214+
215+ async fn start_local_https_gcs_server ( ) -> anyhow:: Result < LocalHttpsGcsServer > {
216+ let cert_chain = vec ! [ CertificateDer :: from( decode_test_der(
217+ TEST_SERVER_CERT_DER_BASE64 ,
218+ ) ?) ] ;
219+ let private_key = PrivateKeyDer :: Sec1 ( PrivateSec1KeyDer :: from ( decode_test_der (
220+ TEST_SERVER_KEY_DER_BASE64 ,
221+ ) ?) ) ;
222+ let tls_config = rustls:: ServerConfig :: builder ( )
223+ . with_no_client_auth ( )
224+ . with_single_cert ( cert_chain, private_key) ?;
225+ let tls_acceptor = tokio_rustls:: TlsAcceptor :: from ( Arc :: new ( tls_config) ) ;
226+ let listener = TcpListener :: bind ( ( "127.0.0.1" , 0 ) ) . await ?;
227+ let endpoint = format ! ( "https://127.0.0.1:{}" , listener. local_addr( ) ?. port( ) ) ;
228+
229+ let server_task = tokio:: spawn ( async move {
230+ let ( stream, _) = listener. accept ( ) . await ?;
231+ let mut stream = tls_acceptor. accept ( stream) . await ?;
232+ let mut request = Vec :: new ( ) ;
233+ let mut buffer = [ 0u8 ; 1024 ] ;
234+ loop {
235+ let bytes_read = stream. read ( & mut buffer) . await ?;
236+ if bytes_read == 0 {
237+ break ;
238+ }
239+ request. extend_from_slice ( & buffer[ ..bytes_read] ) ;
240+ if request. windows ( 4 ) . any ( |window| window == b"\r \n \r \n " ) {
241+ break ;
242+ }
243+ }
244+ let request = String :: from_utf8_lossy ( & request) ;
245+ let ( header_block, _) = request
246+ . split_once ( "\r \n \r \n " )
247+ . expect ( "request must contain HTTP header terminator" ) ;
248+ let mut header_lines = header_block. lines ( ) ;
249+ let request_line = header_lines. next ( ) . expect ( "request line must be present" ) ;
250+ let mut request_line_parts = request_line. split_whitespace ( ) ;
251+ let method = request_line_parts. next ( ) ;
252+ let target = request_line_parts. next ( ) ;
253+ let version = request_line_parts. next ( ) ;
254+ assert_eq ! (
255+ method,
256+ Some ( "GET" ) ,
257+ "unexpected request line: {request_line}"
258+ ) ;
259+ assert_eq ! (
260+ target,
261+ Some ( "/storage/v1/b/quickwit-test-bucket/o/hello.txt?alt=media" ) ,
262+ "unexpected GCS request target: {request_line}"
263+ ) ;
264+ assert ! (
265+ version. is_some_and( |version| version. starts_with( "HTTP/" ) ) ,
266+ "unexpected HTTP version in request line: {request_line}"
267+ ) ;
268+ assert_eq ! (
269+ request_line_parts. next( ) ,
270+ None ,
271+ "unexpected extra request line segment: {request_line}"
272+ ) ;
273+
274+ let headers: BTreeMap < String , String > = header_lines
275+ . filter_map ( |line| line. split_once ( ':' ) )
276+ . map ( |( name, value) | ( name. to_ascii_lowercase ( ) , value. trim ( ) . to_string ( ) ) )
277+ . collect ( ) ;
278+ assert_eq ! (
279+ headers. get( "range" ) . map( String :: as_str) ,
280+ Some ( "bytes=0-1" ) ,
281+ "expected range read request header: {request}"
282+ ) ;
283+
284+ stream
285+ . write_all (
286+ b"HTTP/1.1 206 Partial Content\r \n \
287+ Content-Length: 2\r \n \
288+ Content-Range: bytes 0-1/2\r \n \
289+ Accept-Ranges: bytes\r \n \
290+ Connection: close\r \n \
291+ \r \n \
292+ ok",
293+ )
294+ . await ?;
295+ stream. shutdown ( ) . await ?;
296+ Ok ( ( ) )
297+ } ) ;
298+
299+ Ok ( ( endpoint, server_task) )
300+ }
301+
302+ fn decode_test_der ( base64_der : & str ) -> anyhow:: Result < Vec < u8 > > {
303+ Ok ( base64:: engine:: general_purpose:: STANDARD . decode ( base64_der) ?)
304+ }
137305}
0 commit comments