Skip to main content

lodestone_client/
compatibility.rs

1//! Exact, bounded Elasticsearch compatibility execution through a validated
2//! Collection session.
3//!
4//! The executor owns Core endpoint, credential, Collection, and TLS material.
5//! One execution request supplies only the exact index incarnation and the
6//! bounded external HTTP envelope. It never retries a request.
7
8use 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}