Skip to main content

elasticctl_api/value_lists/
stems.rs

1use std::collections::BTreeMap;
2
3use elasticctl_core::Result;
4use sha2::{Digest, Sha256};
5
6use super::invalid;
7
8pub(super) fn validate_id(id: &str) -> Result<()> {
9    if id.is_empty() || id.len() > 512 || id.chars().any(char::is_control) {
10        return Err(invalid(
11            "value-list id must be non-empty, at most 512 UTF-8 bytes, and contain no control character",
12        ));
13    }
14    Ok(())
15}
16
17pub fn file_stem(id: &str) -> String {
18    let safe = !id.is_empty()
19        && id.len() <= 64
20        && id
21            .bytes()
22            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"-_.".contains(&b))
23        && !id.starts_with('.')
24        && !id.ends_with('.')
25        && !device_name(id);
26    if safe {
27        return id.to_owned();
28    }
29    let mut prefix: String = id
30        .chars()
31        .map(|c| {
32            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
33                c
34            } else {
35                '_'
36            }
37        })
38        .collect::<String>()
39        .to_lowercase();
40    let mut end = prefix.len().min(48);
41    while !prefix.is_char_boundary(end) {
42        end -= 1;
43    }
44    prefix.truncate(end);
45    let prefix = prefix.trim_end_matches('.');
46    format!("{prefix}-{}", &sha256_hex(id.as_bytes())[..12])
47}
48
49fn sha256_hex(bytes: &[u8]) -> String {
50    Sha256::digest(bytes)
51        .iter()
52        .map(|byte| format!("{byte:02x}"))
53        .collect()
54}
55
56fn device_name(id: &str) -> bool {
57    let base = id
58        .split('.')
59        .next()
60        .unwrap_or_default()
61        .to_ascii_lowercase();
62    matches!(base.as_str(), "con" | "prn" | "aux" | "nul")
63        || ((base.starts_with("com") || base.starts_with("lpt"))
64            && base.len() == 4
65            && matches!(base.as_bytes()[3], b'1'..=b'9'))
66}
67
68pub fn stem_collisions<'a>(
69    names: impl IntoIterator<Item = (&'a str, &'a str)>,
70) -> Vec<(String, String)> {
71    let mut by_name: BTreeMap<String, Vec<&str>> = BTreeMap::new();
72    let mut collisions = Vec::new();
73    for (id, stem) in names {
74        let previous = by_name.entry(stem.to_lowercase()).or_default();
75        for other in previous.iter() {
76            if *other != id {
77                collisions.push(((*other).to_owned(), id.to_owned()));
78            }
79        }
80        previous.push(id);
81    }
82    collisions
83}
84
85pub fn plan_stems<'a>(ids: impl IntoIterator<Item = &'a str>) -> Result<BTreeMap<String, String>> {
86    let mut names = BTreeMap::new();
87    for id in ids {
88        validate_id(id)?;
89        names.insert(id.to_owned(), file_stem(id));
90    }
91    let collisions = stem_collisions(names.iter().map(|(id, stem)| (id.as_str(), stem.as_str())));
92    if !collisions.is_empty() {
93        let pairs: Vec<String> = collisions
94            .iter()
95            .map(|(a, b)| format!("{a:?} and {b:?}"))
96            .collect();
97        return Err(invalid(format!(
98            "case-insensitive file stem collisions: {}",
99            pairs.join("; ")
100        )));
101    }
102    Ok(names)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::sha256_hex as hex;
108
109    #[test]
110    fn hashes_standard_vectors_across_padding_boundaries() {
111        for (input, expected) in [
112            (
113                "",
114                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
115            ),
116            (
117                "abc",
118                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
119            ),
120            (
121                "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
122                "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1",
123            ),
124        ] {
125            assert_eq!(hex(input.as_bytes()), expected);
126        }
127        assert_eq!(
128            hex(&vec![b'a'; 1_000_000]),
129            "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
130        );
131    }
132}