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;