Skip to main content

schema_core/common/
index_prefix.rs

1//! Validation for the deployment-wide index prefix.
2//!
3//! flusso can prepend a literal prefix to every index name it owns, so several
4//! deployments (dev / staging / nightly) can share one OpenSearch cluster
5//! without colliding. The prefix is a plain string (the caller includes any
6//! separator — `dev_`, `staging-`), resolved at runtime from config, the
7//! `FLUSSO_INDEX_PREFIX` env var, or the `--index-prefix` flag.
8//!
9//! It is prepended verbatim, so the *combined* name must be a legal OpenSearch
10//! index name. [`validate_index_prefix`] enforces the part of that contract the
11//! prefix controls: lowercase, no characters OpenSearch forbids, and a leading
12//! character an index name may legally start with. An empty prefix is the
13//! no-op default and always valid.
14//!
15//! ```
16//! use schema_core::validate_index_prefix;
17//! assert!(validate_index_prefix("dev_").is_ok());
18//! assert!(validate_index_prefix("").is_ok());        // no prefix
19//! assert!(validate_index_prefix("Dev_").is_err());   // uppercase
20//! assert!(validate_index_prefix("_dev").is_err());   // illegal leading char
21//! ```
22
23/// Characters an index name (and therefore a prefix) may never contain — the
24/// OpenSearch-forbidden set, plus the comma flusso uses to join indexes in a
25/// combined search.
26const FORBIDDEN: &[char] = &[
27    ' ', ',', ':', '"', '*', '+', '/', '\\', '|', '?', '#', '<', '>',
28];
29
30/// Check that `prefix` is a legal leading fragment of an OpenSearch index name.
31///
32/// An empty prefix is valid (the default — no prefix). A non-empty prefix must
33/// be lowercase, contain none of the OpenSearch-forbidden characters, and start
34/// with an ASCII letter or digit (an index name may not begin with `_`/`-`/`+`).
35/// Returns a human-readable reason on failure, suitable for surfacing at config
36/// resolution time.
37pub fn validate_index_prefix(prefix: &str) -> Result<(), String> {
38    if prefix.is_empty() {
39        return Ok(());
40    }
41    if prefix.len() > 50 {
42        return Err(format!(
43            "index prefix {prefix:?} is too long ({} bytes); keep it under 50",
44            prefix.len()
45        ));
46    }
47    if let Some(bad) = prefix.chars().find(|c| FORBIDDEN.contains(c)) {
48        return Err(format!(
49            "index prefix {prefix:?} contains the illegal character {bad:?}"
50        ));
51    }
52    if prefix.chars().any(|c| c.is_ascii_uppercase()) {
53        return Err(format!(
54            "index prefix {prefix:?} must be lowercase (OpenSearch index names are lowercase)"
55        ));
56    }
57    let first = prefix.chars().next().unwrap_or_default();
58    if !first.is_ascii_alphanumeric() {
59        return Err(format!(
60            "index prefix {prefix:?} must start with a letter or digit, not {first:?}"
61        ));
62    }
63    Ok(())
64}
65
66#[cfg(test)]
67mod tests;