1use std::collections::BTreeSet;
9use std::fmt::{self, Display, Formatter};
10use std::time::Duration;
11
12use http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header};
13use reqwest::{Certificate, Method, Url, redirect::Policy};
14
15use crate::transport::{Client, authorization_value};
16use crate::{ClientError, CredentialProviderError};
17
18pub const MAX_ELASTICSEARCH_COMPATIBILITY_REQUEST_BYTES: usize = 2 * 1024 * 1024;
19pub const MAX_ELASTICSEARCH_COMPATIBILITY_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
20pub const MAX_ELASTICSEARCH_COMPATIBILITY_HEADERS: usize = 64;
21pub const MAX_ELASTICSEARCH_COMPATIBILITY_HEADER_BYTES: usize = 32 * 1024;
22
23const COLLECTION_ID_HEADER: &str = "x-lodestone-collection-id";
24const INDEX_NAME_HEADER: &str = "x-lodestone-index-name";
25const INDEX_ID_HEADER: &str = "x-lodestone-index-id";
26const EFFECT_HEADER: &str = "x-lodestone-compatibility-effect";
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct ElasticsearchCompatibilityEndpointV1 {
30 origin: Url,
31}
32
33impl ElasticsearchCompatibilityEndpointV1 {
34 pub fn parse(value: &str) -> Result<Self, ElasticsearchCompatibilityErrorV1> {
35 let origin = Url::parse(value).map_err(|_| ElasticsearchCompatibilityErrorV1::InvalidRequest)?;
36 if origin.scheme() != "https"
37 || origin.cannot_be_a_base()
38 || origin.username() != ""
39 || origin.password().is_some()
40 || origin.query().is_some()
41 || origin.fragment().is_some()
42 || origin.host_str().is_none()
43 || !matches!(origin.path(), "" | "/")
44 {
45 return Err(ElasticsearchCompatibilityErrorV1::InvalidRequest);
46 }
47 Ok(Self { origin })
48 }
49
50 fn request_url(&self, path: &str, query: Option<&str>) -> Result<Url, ElasticsearchCompatibilityErrorV1> {
51 let mut url = self
52 .origin
53 .join(path.trim_start_matches('/'))
54 .map_err(|_| ElasticsearchCompatibilityErrorV1::InvalidRequest)?;
55 url.set_query(query);
56 Ok(url)
57 }
58}
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum ElasticsearchCompatibilityMethodV1 {
62 Delete,
63 Get,
64 Head,
65 Post,
66 Put,
67}
68
69impl ElasticsearchCompatibilityMethodV1 {
70 fn reqwest(self) -> Method {
71 match self {
72 Self::Delete => Method::DELETE,
73 Self::Get => Method::GET,
74 Self::Head => Method::HEAD,
75 Self::Post => Method::POST,
76 Self::Put => Method::PUT,
77 }
78 }
79}
80
81#[derive(Clone, PartialEq, Eq)]
82pub struct ElasticsearchCompatibilityHeaderV1 {
83 pub name: String,
84 pub value: Vec<u8>,
85}
86
87pub struct ElasticsearchCompatibilityRequestV1 {
88 pub operation_id: String,
89 pub index_name: String,
90 pub expected_index_id: String,
91 pub method: ElasticsearchCompatibilityMethodV1,
92 pub path: String,
93 pub query: Option<String>,
94 pub headers: Vec<ElasticsearchCompatibilityHeaderV1>,
95 pub body: Vec<u8>,
96 pub timeout: Duration,
97}
98
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
100pub enum ElasticsearchCompatibilityEffectV1 {
101 ReadOnly,
102 MutationCommitted,
103 MutationNoEffect,
104}
105
106pub struct ElasticsearchCompatibilityResponseV1 {
107 pub status: StatusCode,
108 pub headers: Vec<ElasticsearchCompatibilityHeaderV1>,
109 pub body: Vec<u8>,
110 pub effect: ElasticsearchCompatibilityEffectV1,
111}
112
113#[derive(Clone, Copy, Debug, Eq, PartialEq)]
114pub enum ElasticsearchCompatibilityErrorV1 {
115 InvalidRequest,
116 CredentialUnavailable,
117 NotSubmitted,
118 Indeterminate,
119 DeadlineExceeded,
120 DependencyUnavailable,
121 InvalidResponse,
122}
123
124impl Display for ElasticsearchCompatibilityErrorV1 {
125 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
126 formatter.write_str(match self {
127 Self::InvalidRequest => "Elasticsearch compatibility request is invalid",
128 Self::CredentialUnavailable => "Elasticsearch compatibility credential is unavailable",
129 Self::NotSubmitted => "Elasticsearch compatibility request was not submitted",
130 Self::Indeterminate => "Elasticsearch compatibility mutation outcome is indeterminate",
131 Self::DeadlineExceeded => "Elasticsearch compatibility deadline was exceeded",
132 Self::DependencyUnavailable => "Elasticsearch compatibility dependency is unavailable",
133 Self::InvalidResponse => "Elasticsearch compatibility response is invalid",
134 })
135 }
136}
137
138impl std::error::Error for ElasticsearchCompatibilityErrorV1 {}
139
140#[derive(Clone)]
141pub struct ElasticsearchCompatibilityExecutorV1 {
142 client: Client,
143 endpoint: ElasticsearchCompatibilityEndpointV1,
144}
145
146impl ElasticsearchCompatibilityExecutorV1 {
147 pub fn new(
148 client: Client,
149 endpoint: ElasticsearchCompatibilityEndpointV1,
150 ) -> Result<Self, ElasticsearchCompatibilityErrorV1> {
151 if endpoint.origin.host_str() != Some(client.transport_config().identity.tls_server_name()) {
152 return Err(ElasticsearchCompatibilityErrorV1::InvalidRequest);
153 }
154 Ok(Self { client, endpoint })
155 }
156
157 pub async fn execute(
158 &self,
159 request: ElasticsearchCompatibilityRequestV1,
160 ) -> Result<ElasticsearchCompatibilityResponseV1, ElasticsearchCompatibilityErrorV1> {
161 validate_request(&request)?;
162 let mutation = request_is_mutation(request.method, &request.path);
163 self.execute_validated(request, mutation).await
164 }
165
166 pub(crate) async fn execute_validated(
167 &self,
168 request: ElasticsearchCompatibilityRequestV1,
169 mutation: bool,
170 ) -> Result<ElasticsearchCompatibilityResponseV1, ElasticsearchCompatibilityErrorV1> {
171 let material = self.client.credential().await.map_err(map_client_error)?;
172 let authorization = authorization_value(&material).map_err(map_client_error)?;
173 let trust = self
174 .client
175 .transport_config()
176 .trust
177 .certificates_der()
178 .iter()
179 .map(|certificate| {
180 Certificate::from_der(certificate).map_err(|_| ElasticsearchCompatibilityErrorV1::InvalidRequest)
181 })
182 .collect::<Result<Vec<_>, _>>()?;
183 let client = reqwest::Client::builder()
184 .https_only(true)
185 .no_proxy()
186 .redirect(Policy::none())
187 .connect_timeout(self.client.pool_config().connect_timeout)
188 .timeout(request.timeout)
189 .tls_certs_only(trust)
190 .build()
191 .map_err(|_| ElasticsearchCompatibilityErrorV1::DependencyUnavailable)?;
192 let url = self.endpoint.request_url(&request.path, request.query.as_deref())?;
193 let mut outbound = client
194 .request(request.method.reqwest(), url)
195 .header(header::AUTHORIZATION, sensitive_header(authorization.as_slice())?)
196 .header(COLLECTION_ID_HEADER, self.client.expected_collection_id())
197 .header(INDEX_NAME_HEADER, &request.index_name)
198 .header(INDEX_ID_HEADER, &request.expected_index_id)
199 .header("x-opaque-id", &request.operation_id)
200 .body(request.body);
201 for field in request.headers {
202 let name = HeaderName::from_bytes(field.name.as_bytes())
203 .map_err(|_| ElasticsearchCompatibilityErrorV1::InvalidRequest)?;
204 let value =
205 HeaderValue::from_bytes(&field.value).map_err(|_| ElasticsearchCompatibilityErrorV1::InvalidRequest)?;
206 outbound = outbound.header(name, value);
207 }
208 let mut response = outbound
209 .send()
210 .await
211 .map_err(|error| map_reqwest_error(error, mutation))?;
212 if response.content_length().is_some_and(|length| {
213 length > u64::try_from(MAX_ELASTICSEARCH_COMPATIBILITY_RESPONSE_BYTES).unwrap_or(u64::MAX)
214 }) {
215 return Err(ElasticsearchCompatibilityErrorV1::InvalidResponse);
216 }
217 let status = response.status();
218 let effect = parse_effect(response.headers(), mutation)?;
219 let headers = response_headers(response.headers())?;
220 let mut body = Vec::new();
221 while let Some(chunk) = response.chunk().await.map_err(|_| {
222 if mutation {
223 ElasticsearchCompatibilityErrorV1::Indeterminate
224 } else {
225 ElasticsearchCompatibilityErrorV1::DependencyUnavailable
226 }
227 })? {
228 if body.len().saturating_add(chunk.len()) > MAX_ELASTICSEARCH_COMPATIBILITY_RESPONSE_BYTES {
229 return Err(ElasticsearchCompatibilityErrorV1::InvalidResponse);
230 }
231 body.extend_from_slice(&chunk);
232 }
233 Ok(ElasticsearchCompatibilityResponseV1 {
234 status,
235 headers,
236 body,
237 effect,
238 })
239 }
240}
241
242fn validate_request(request: &ElasticsearchCompatibilityRequestV1) -> Result<(), ElasticsearchCompatibilityErrorV1> {
243 let mut header_names = BTreeSet::new();
244 let header_bytes = request.headers.iter().try_fold(0usize, |total, field| {
245 if !valid_header_name(&field.name)
246 || !header_names.insert(field.name.as_str())
247 || forbidden_header(&field.name)
248 || field.value.len() > 8 * 1024
249 || field.value.iter().any(|byte| matches!(byte, b'\r' | b'\n' | 0))
250 {
251 return Err(ElasticsearchCompatibilityErrorV1::InvalidRequest);
252 }
253 total
254 .checked_add(field.name.len())
255 .and_then(|value| value.checked_add(field.value.len()))
256 .ok_or(ElasticsearchCompatibilityErrorV1::InvalidRequest)
257 })?;
258 let request_bytes = request
259 .path
260 .len()
261 .checked_add(request.query.as_deref().map_or(0, str::len))
262 .and_then(|value| value.checked_add(header_bytes))
263 .and_then(|value| value.checked_add(request.body.len()))
264 .ok_or(ElasticsearchCompatibilityErrorV1::InvalidRequest)?;
265 if !valid_uuid_v7(&request.operation_id)
266 || !valid_index_name(&request.index_name)
267 || !valid_lower_hex(&request.expected_index_id, 16)
268 || !valid_path(&request.path)
269 || request.query.as_deref().is_some_and(|value| {
270 value.len() > 4_096 || value.starts_with('?') || value.contains('#') || !value.is_ascii()
271 })
272 || request.headers.len() > MAX_ELASTICSEARCH_COMPATIBILITY_HEADERS
273 || header_bytes > MAX_ELASTICSEARCH_COMPATIBILITY_HEADER_BYTES
274 || request_bytes > MAX_ELASTICSEARCH_COMPATIBILITY_REQUEST_BYTES
275 || request.timeout.is_zero()
276 || request.timeout > Duration::from_secs(60)
277 || !supported_route(request.method, &request.path, &request.index_name)
278 {
279 return Err(ElasticsearchCompatibilityErrorV1::InvalidRequest);
280 }
281 Ok(())
282}
283
284fn supported_route(method: ElasticsearchCompatibilityMethodV1, path: &str, admitted_index: &str) -> bool {
285 let segments = path.trim_start_matches('/').split('/').collect::<Vec<_>>();
286 let exact = |observed: &str| observed == admitted_index;
287 match (method, segments.as_slice()) {
288 (ElasticsearchCompatibilityMethodV1::Put | ElasticsearchCompatibilityMethodV1::Post, ["_bulk"])
289 | (ElasticsearchCompatibilityMethodV1::Post, ["_mget" | "_msearch" | "_search"])
290 | (
291 ElasticsearchCompatibilityMethodV1::Get | ElasticsearchCompatibilityMethodV1::Post,
292 ["_validate", "query"],
293 )
294 | (ElasticsearchCompatibilityMethodV1::Get, ["_search"]) => true,
295 (ElasticsearchCompatibilityMethodV1::Put | ElasticsearchCompatibilityMethodV1::Post, [index, "_bulk"])
296 if exact(index) =>
297 {
298 true
299 }
300 (ElasticsearchCompatibilityMethodV1::Put | ElasticsearchCompatibilityMethodV1::Post, [index, "_doc", _])
301 if exact(index) =>
302 {
303 true
304 }
305 (ElasticsearchCompatibilityMethodV1::Put, [index, "_create", _]) if exact(index) => true,
306 (ElasticsearchCompatibilityMethodV1::Post, [index, "_update", _]) if exact(index) => true,
307 (
308 ElasticsearchCompatibilityMethodV1::Get
309 | ElasticsearchCompatibilityMethodV1::Head
310 | ElasticsearchCompatibilityMethodV1::Delete,
311 [index, "_doc", _],
312 ) if exact(index) => true,
313 (
314 ElasticsearchCompatibilityMethodV1::Get | ElasticsearchCompatibilityMethodV1::Post,
315 [index, "_search" | "_count" | "_terms_enum" | "_field_caps"],
316 ) if exact(index) => true,
317 (ElasticsearchCompatibilityMethodV1::Post, [index, "_mget" | "_msearch"]) if exact(index) => true,
318 (
319 ElasticsearchCompatibilityMethodV1::Get | ElasticsearchCompatibilityMethodV1::Post,
320 [index, "_validate", "query"],
321 ) if exact(index) => true,
322 _ => false,
323 }
324}
325
326fn request_is_mutation(method: ElasticsearchCompatibilityMethodV1, path: &str) -> bool {
327 match method {
328 ElasticsearchCompatibilityMethodV1::Get | ElasticsearchCompatibilityMethodV1::Head => false,
329 ElasticsearchCompatibilityMethodV1::Put | ElasticsearchCompatibilityMethodV1::Delete => true,
330 ElasticsearchCompatibilityMethodV1::Post => {
331 !(matches!(path, "/_search" | "/_msearch" | "/_mget")
332 || path.ends_with("/_search")
333 || path.ends_with("/_msearch")
334 || path.ends_with("/_mget")
335 || path.ends_with("/_count")
336 || path.ends_with("/_terms_enum")
337 || path.ends_with("/_field_caps")
338 || path.ends_with("/_validate/query"))
339 }
340 }
341}
342
343fn parse_effect(
344 headers: &HeaderMap,
345 mutation: bool,
346) -> Result<ElasticsearchCompatibilityEffectV1, ElasticsearchCompatibilityErrorV1> {
347 let mut values = headers.get_all(EFFECT_HEADER).iter();
348 let value = values.next().and_then(|value| value.to_str().ok());
349 if values.next().is_some() {
350 return Err(ElasticsearchCompatibilityErrorV1::InvalidResponse);
351 }
352 match value {
353 Some("read_only") if !mutation => Ok(ElasticsearchCompatibilityEffectV1::ReadOnly),
354 Some("mutation_committed") if mutation => Ok(ElasticsearchCompatibilityEffectV1::MutationCommitted),
355 Some("mutation_no_effect") if mutation => Ok(ElasticsearchCompatibilityEffectV1::MutationNoEffect),
356 Some("mutation_indeterminate") if mutation => Err(ElasticsearchCompatibilityErrorV1::Indeterminate),
357 _ => Err(ElasticsearchCompatibilityErrorV1::InvalidResponse),
358 }
359}
360
361fn response_headers(
362 headers: &HeaderMap,
363) -> Result<Vec<ElasticsearchCompatibilityHeaderV1>, ElasticsearchCompatibilityErrorV1> {
364 let mut output = Vec::new();
365 let mut bytes = 0usize;
366 for (name, value) in headers {
367 let name = name.as_str();
368 if name == EFFECT_HEADER || forbidden_response_header(name) {
369 continue;
370 }
371 bytes = bytes
372 .checked_add(name.len())
373 .and_then(|total| total.checked_add(value.as_bytes().len()))
374 .ok_or(ElasticsearchCompatibilityErrorV1::InvalidResponse)?;
375 if output.len() >= MAX_ELASTICSEARCH_COMPATIBILITY_HEADERS
376 || bytes > MAX_ELASTICSEARCH_COMPATIBILITY_HEADER_BYTES
377 {
378 return Err(ElasticsearchCompatibilityErrorV1::InvalidResponse);
379 }
380 output.push(ElasticsearchCompatibilityHeaderV1 {
381 name: name.to_owned(),
382 value: value.as_bytes().to_vec(),
383 });
384 }
385 Ok(output)
386}
387
388fn valid_path(value: &str) -> bool {
389 value.starts_with('/')
390 && !value.starts_with("//")
391 && value.len() <= 2_048
392 && !value.contains(['?', '#', '\\'])
393 && !value.split('/').any(|segment| segment == "..")
394 && value.bytes().all(|byte| byte.is_ascii_graphic())
395}
396
397fn valid_index_name(value: &str) -> bool {
398 !value.is_empty()
399 && value.len() <= 255
400 && value
401 .bytes()
402 .all(|byte| byte.is_ascii_graphic() && byte != b'/' && byte != b'\\')
403}
404
405fn valid_header_name(value: &str) -> bool {
406 !value.is_empty()
407 && value.len() <= 128
408 && value
409 .bytes()
410 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
411}
412
413fn forbidden_header(name: &str) -> bool {
414 name.starts_with("x-lodestone-")
415 || matches!(
416 name,
417 "authorization"
418 | "proxy-authorization"
419 | "cookie"
420 | "host"
421 | "connection"
422 | "content-length"
423 | "keep-alive"
424 | "proxy-connection"
425 | "te"
426 | "trailer"
427 | "transfer-encoding"
428 | "upgrade"
429 | "forwarded"
430 | "x-forwarded-for"
431 | "x-forwarded-host"
432 | "x-forwarded-proto"
433 | "x-opaque-id"
434 )
435}
436
437fn forbidden_response_header(name: &str) -> bool {
438 name.starts_with("x-lodestone-")
439 || matches!(
440 name,
441 "content-length"
442 | "connection"
443 | "keep-alive"
444 | "proxy-connection"
445 | "te"
446 | "trailer"
447 | "transfer-encoding"
448 | "upgrade"
449 | "set-cookie"
450 | "proxy-authenticate"
451 | "www-authenticate"
452 )
453}
454
455fn sensitive_header(value: &[u8]) -> Result<HeaderValue, ElasticsearchCompatibilityErrorV1> {
456 let mut header =
457 HeaderValue::from_bytes(value).map_err(|_| ElasticsearchCompatibilityErrorV1::CredentialUnavailable)?;
458 header.set_sensitive(true);
459 Ok(header)
460}
461
462fn map_client_error(error: ClientError) -> ElasticsearchCompatibilityErrorV1 {
463 match error {
464 ClientError::Credential(
465 CredentialProviderError::Unavailable
466 | CredentialProviderError::NotFound
467 | CredentialProviderError::InvalidMaterial
468 | CredentialProviderError::RandomUnavailable,
469 ) => ElasticsearchCompatibilityErrorV1::CredentialUnavailable,
470 _ => ElasticsearchCompatibilityErrorV1::DependencyUnavailable,
471 }
472}
473
474fn map_reqwest_error(error: reqwest::Error, mutation: bool) -> ElasticsearchCompatibilityErrorV1 {
475 if error.is_builder() {
476 ElasticsearchCompatibilityErrorV1::InvalidRequest
477 } else if error.is_connect() {
478 ElasticsearchCompatibilityErrorV1::NotSubmitted
479 } else if mutation {
480 ElasticsearchCompatibilityErrorV1::Indeterminate
481 } else if error.is_timeout() {
482 ElasticsearchCompatibilityErrorV1::DeadlineExceeded
483 } else {
484 ElasticsearchCompatibilityErrorV1::DependencyUnavailable
485 }
486}
487
488fn valid_lower_hex(value: &str, bytes: usize) -> bool {
489 value.len() == bytes.saturating_mul(2)
490 && value
491 .bytes()
492 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
493 && value.bytes().any(|byte| byte != b'0')
494}
495
496fn valid_uuid_v7(value: &str) -> bool {
497 value.len() == 36
498 && value.as_bytes().get(8) == Some(&b'-')
499 && value.as_bytes().get(13) == Some(&b'-')
500 && value.as_bytes().get(18) == Some(&b'-')
501 && value.as_bytes().get(23) == Some(&b'-')
502 && value.as_bytes().get(14) == Some(&b'7')
503 && value
504 .as_bytes()
505 .get(19)
506 .is_some_and(|value| matches!(value, b'8' | b'9' | b'a' | b'b'))
507 && value.bytes().enumerate().all(|(index, byte)| {
508 matches!(index, 8 | 13 | 18 | 23) || byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
509 })
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn endpoint_and_exact_data_matrix_are_closed() {
518 assert!(ElasticsearchCompatibilityEndpointV1::parse("https://core.example:8443").is_ok());
519 assert!(ElasticsearchCompatibilityEndpointV1::parse("http://core.example:8443").is_err());
520 assert!(forbidden_header("x-lodestone-private-authority"));
521 assert!(forbidden_response_header("x-lodestone-private-evidence"));
522 assert!(supported_route(
523 ElasticsearchCompatibilityMethodV1::Post,
524 "/logs/_search",
525 "logs"
526 ));
527 assert!(supported_route(
528 ElasticsearchCompatibilityMethodV1::Post,
529 "/_bulk",
530 "logs"
531 ));
532 assert!(!supported_route(
533 ElasticsearchCompatibilityMethodV1::Post,
534 "/other/_search",
535 "logs"
536 ));
537 assert!(!supported_route(
538 ElasticsearchCompatibilityMethodV1::Post,
539 "/_reindex",
540 "logs"
541 ));
542 }
543
544 #[test]
545 fn response_effect_requires_protocol_and_mutation_agreement() {
546 let mut headers = HeaderMap::new();
547 headers.insert(EFFECT_HEADER, HeaderValue::from_static("mutation_committed"));
548 assert_eq!(
549 parse_effect(&headers, true),
550 Ok(ElasticsearchCompatibilityEffectV1::MutationCommitted)
551 );
552 assert_eq!(
553 parse_effect(&headers, false),
554 Err(ElasticsearchCompatibilityErrorV1::InvalidResponse)
555 );
556 headers.insert(EFFECT_HEADER, HeaderValue::from_static("mutation_indeterminate"));
557 assert_eq!(
558 parse_effect(&headers, true),
559 Err(ElasticsearchCompatibilityErrorV1::Indeterminate)
560 );
561 headers.remove(EFFECT_HEADER);
562 assert_eq!(
563 parse_effect(&headers, true),
564 Err(ElasticsearchCompatibilityErrorV1::InvalidResponse)
565 );
566 assert_eq!(
567 parse_effect(&headers, false),
568 Err(ElasticsearchCompatibilityErrorV1::InvalidResponse)
569 );
570 }
571
572 #[test]
573 fn connection_failure_and_response_loss_never_retry_mutations() {
574 assert!(request_is_mutation(
575 ElasticsearchCompatibilityMethodV1::Post,
576 "/logs/_update/1"
577 ));
578 assert!(!request_is_mutation(
579 ElasticsearchCompatibilityMethodV1::Post,
580 "/logs/_search"
581 ));
582 }
583}