elasticctl_api/value_lists/
stems.rs1use 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}