Skip to content

Commit f7329bb

Browse files
committed
test(storage): cover GCS HTTPS OpenDAL TLS
1 parent 0b26a07 commit f7329bb

4 files changed

Lines changed: 198 additions & 3 deletions

File tree

quickwit/Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

quickwit/quickwit-storage/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,13 @@ reqwest = { workspace = true, optional = true }
6262
http = { workspace = true }
6363
mockall = { workspace = true }
6464
proptest = { workspace = true }
65+
# Match OpenDAL's internal reqwest major. `default-features = false` is
66+
# intentional: the HTTPS regression test should get TLS only from OpenDAL's
67+
# `reqwest-rustls-tls` feature, not from this dev-dependency.
68+
reqwest-013 = { package = "reqwest", version = "0.13", default-features = false }
69+
rustls = { workspace = true }
6570
tokio = { workspace = true }
71+
tokio-rustls = { workspace = true }
6672
tracing-subscriber = { workspace = true }
6773

6874
aws-sdk-s3 = { workspace = true }

quickwit/quickwit-storage/src/opendal_storage/base.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,30 @@ impl OpendalStorage {
6060
cfg: opendal::services::Gcs,
6161
) -> Result<Self, StorageResolverError> {
6262
let op = Operator::new(cfg)?.finish();
63-
Ok(Self {
63+
Ok(Self::from_operator(uri, op))
64+
}
65+
66+
fn from_operator(uri: Uri, op: Operator) -> Self {
67+
Self {
6468
uri,
6569
op,
6670
// limits are the same as on S3
6771
multipart_policy: MultiPartPolicy::default(),
68-
})
72+
}
73+
}
74+
75+
#[cfg(test)]
76+
// Lets local HTTPS tests trust a private CA without changing global trust,
77+
// while still using Quickwit's GCS storage construction and read path.
78+
pub(super) fn new_google_cloud_storage_with_http_client_for_test(
79+
uri: Uri,
80+
cfg: opendal::services::Gcs,
81+
http_client: opendal::raw::HttpClient,
82+
) -> Result<Self, StorageResolverError> {
83+
let op = Operator::new(cfg)?
84+
.layer(opendal::layers::HttpClientLayer::new(http_client))
85+
.finish();
86+
Ok(Self::from_operator(uri, op))
6987
}
7088

7189
#[cfg(feature = "integration-testsuite")]

quickwit/quickwit-storage/src/opendal_storage/google_cloud_storage.rs

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,58 @@ fn parse_google_uri(uri: &Uri) -> Option<(String, PathBuf)> {
113113

114114
#[cfg(test)]
115115
mod 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

Comments
 (0)