Skip to main content

xianyun_core/
naming.rs

1//! Port of `internal/naming`: cluster naming strategies for generating
2//! deterministic, RFC 1123-compliant cluster names.
3
4use thiserror::Error;
5
6pub const PREFIX: &str = "xianyun";
7/// The project's former name, recognized wherever a profile/directory
8/// name might already carry it (so renaming the project doesn't
9/// double-prefix directories literally named `caproni-something`, and
10/// so a profile name generated before the rename is still recognized as
11/// "already prefixed" by [`context_name`]). Not used to generate new
12/// names -- only to recognize old ones.
13const LEGACY_PREFIX: &str = "caproni";
14pub const STRATEGY_STATIC: &str = "static";
15pub const STRATEGY_DIR_HASH: &str = "directory-hash";
16
17const MAX_LABEL_LENGTH: usize = 32;
18const HASH_LENGTH: usize = 6;
19const SEPARATOR_COUNT: usize = 2;
20const RESERVED_FOR_SUFFIX: usize = HASH_LENGTH + SEPARATOR_COUNT;
21
22#[derive(Debug, Error, PartialEq, Eq)]
23pub enum NamingError {
24    #[error("unknown naming strategy: {0}")]
25    UnknownStrategy(String),
26}
27
28/// Resolves the naming strategy together with the config directory to
29/// generate a profile name for the cluster.
30pub fn resolve(strategy: &str, config_dir: &str) -> Result<String, NamingError> {
31    match strategy {
32        STRATEGY_STATIC | "" => Ok(PREFIX.to_string()),
33        STRATEGY_DIR_HASH => Ok(create_directory_hash(config_dir)),
34        other => Err(NamingError::UnknownStrategy(other.to_string())),
35    }
36}
37
38/// Returns the kubeconfig context name for a profile.
39pub fn context_name(profile: &str) -> String {
40    if profile.is_empty() {
41        return PREFIX.to_string();
42    }
43
44    if profile == PREFIX
45        || profile.starts_with(&format!("{PREFIX}-"))
46        || profile == LEGACY_PREFIX
47        || profile.starts_with(&format!("{LEGACY_PREFIX}-"))
48    {
49        return profile.to_string();
50    }
51
52    format!("{PREFIX}-{profile}")
53}
54
55fn create_directory_hash(config_dir: &str) -> String {
56    let dir_name = base_name(config_dir);
57    let hash = &sha256sum(config_dir)[..HASH_LENGTH];
58
59    let mut sanitized = to_rfc1123_label(dir_name);
60
61    if let Some(stripped) = sanitized
62        .strip_prefix(PREFIX)
63        .or_else(|| sanitized.strip_prefix(LEGACY_PREFIX))
64    {
65        sanitized = stripped.to_string();
66    }
67
68    let max_dir_len = MAX_LABEL_LENGTH - PREFIX.len() - RESERVED_FOR_SUFFIX;
69    if sanitized.len() > max_dir_len {
70        sanitized.truncate(max_dir_len);
71    }
72    sanitized = sanitized.trim_matches('-').to_string();
73
74    if sanitized.is_empty() {
75        return format!("{PREFIX}-{hash}");
76    }
77
78    format!("{PREFIX}-{sanitized}-{hash}")
79}
80
81fn base_name(path: &str) -> &str {
82    let trimmed = path.trim_end_matches('/');
83    if trimmed.is_empty() {
84        return "/";
85    }
86    trimmed.rsplit('/').next().unwrap_or(trimmed)
87}
88
89fn to_rfc1123_label(s: &str) -> String {
90    let lower = s.to_lowercase();
91    let mut out = String::with_capacity(lower.len());
92    for c in lower.chars() {
93        if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {
94            out.push(c);
95        } else {
96            out.push('-');
97        }
98    }
99
100    // collapse runs of '-'
101    let mut collapsed = String::with_capacity(out.len());
102    let mut prev_hyphen = false;
103    for c in out.chars() {
104        if c == '-' {
105            if !prev_hyphen {
106                collapsed.push(c);
107            }
108            prev_hyphen = true;
109        } else {
110            collapsed.push(c);
111            prev_hyphen = false;
112        }
113    }
114
115    collapsed.trim_matches('-').to_string()
116}
117
118fn sha256sum(s: &str) -> String {
119    use sha2::{Digest, Sha256};
120    let mut hasher = Sha256::new();
121    hasher.update(s.as_bytes());
122    let digest = hasher.finalize();
123    digest.iter().map(|b| format!("{b:02x}")).collect()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    // Port of TestResolve (internal/naming/naming_test.go).
131    #[test]
132    fn resolve_cases() {
133        let rfc1123 = |s: &str| -> bool {
134            !s.is_empty()
135                && s.chars()
136                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
137                && !s.starts_with('-')
138                && !s.ends_with('-')
139        };
140
141        let cases: &[(&str, &str, &str, Option<&str>)] = &[
142            (
143                "static strategy",
144                STRATEGY_STATIC,
145                "/any/path",
146                Some("xianyun"),
147            ),
148            (
149                "empty strategy defaults to static",
150                "",
151                "/any/path",
152                Some("xianyun"),
153            ),
154            (
155                "unknown strategy returns error",
156                "random",
157                "/any/path",
158                None,
159            ),
160            (
161                "directory-hash basic",
162                STRATEGY_DIR_HASH,
163                "/home/user/demo",
164                Some("xianyun-demo-9fd39f"),
165            ),
166            (
167                "directory-hash uppercase coerced",
168                STRATEGY_DIR_HASH,
169                "/home/user/UPPER",
170                Some("xianyun-upper-ce5dc1"),
171            ),
172            (
173                "directory-hash special chars",
174                STRATEGY_DIR_HASH,
175                "/home/user/My Project!",
176                Some("xianyun-my-project-148160"),
177            ),
178            (
179                "directory-hash deterministic",
180                STRATEGY_DIR_HASH,
181                "/home/user/demo",
182                Some("xianyun-demo-9fd39f"),
183            ),
184            (
185                "directory-hash different path same dirname",
186                STRATEGY_DIR_HASH,
187                "/other/path/demo",
188                Some("xianyun-demo-d2c668"),
189            ),
190            (
191                "directory-hash with dashes",
192                STRATEGY_DIR_HASH,
193                "/other/path/-demo-",
194                Some("xianyun-demo-2a331e"),
195            ),
196            (
197                "directory-hash caproni prefix (legacy project name, still stripped)",
198                STRATEGY_DIR_HASH,
199                "/other/path/caproni-demo",
200                Some("xianyun-demo-5a3655"),
201            ),
202            (
203                "directory-hash xianyun prefix",
204                STRATEGY_DIR_HASH,
205                "/other/path/xianyun-demo",
206                Some("xianyun-demo-a0b29d"),
207            ),
208            (
209                "directory-hash all special chars dir",
210                STRATEGY_DIR_HASH,
211                "/home/user/---special---",
212                Some("xianyun-special-e1a6cf"),
213            ),
214            (
215                "directory-hash root dir",
216                STRATEGY_DIR_HASH,
217                "/",
218                Some("xianyun-8a5eda"),
219            ),
220        ];
221
222        for (name, strategy, dir, want) in cases {
223            let got = resolve(strategy, dir);
224            match want {
225                Some(expected) => {
226                    let result = got.unwrap_or_else(|e| panic!("{name}: unexpected error {e}"));
227                    assert_eq!(&result, expected, "{name}");
228                    assert!(result.len() <= MAX_LABEL_LENGTH, "{name}");
229                    assert!(rfc1123(&result), "{name}");
230                }
231                None => {
232                    assert!(got.is_err(), "{name}: expected error");
233                }
234            }
235        }
236    }
237
238    // Port of TestResolve's directory-hash long dirname truncated case, split
239    // out because it needs a computed `str::repeat` expected value.
240    #[test]
241    fn resolve_long_dirname_is_truncated() {
242        let dir = format!("/home/user/{}", "a".repeat(100));
243        let want = format!("xianyun-{}-a90ac6", "a".repeat(17));
244        assert_eq!(resolve(STRATEGY_DIR_HASH, &dir).unwrap(), want);
245    }
246
247    // Port of TestToRFC1123Label.
248    #[test]
249    fn to_rfc1123_label_cases() {
250        let cases = [
251            ("demo", "demo"),
252            ("UPPER", "upper"),
253            ("My Project!", "my-project"),
254            ("---hyphens---", "hyphens"),
255            ("hello_world", "hello-world"),
256            ("a..b", "a-b"),
257            ("/", ""),
258            ("", ""),
259        ];
260
261        for (input, expected) in cases {
262            assert_eq!(to_rfc1123_label(input), expected, "{input}");
263        }
264    }
265
266    // Port of TestContextName.
267    #[test]
268    fn context_name_cases() {
269        let cases = [
270            ("xianyun", "xianyun"),
271            ("xianyun-demo-d06f00d", "xianyun-demo-d06f00d"),
272            ("xianyun-x", "xianyun-x"),
273            ("demo", "xianyun-demo"),
274            ("", "xianyun"),
275            ("xianyunify", "xianyun-xianyunify"),
276            ("xianyunix", "xianyun-xianyunix"),
277            // Profile names generated before the xianyun rename are still
278            // recognized as already-prefixed (the legacy "caproni" prefix),
279            // not double-prefixed into "xianyun-caproni-...".
280            ("caproni", "caproni"),
281            ("caproni-demo-d06f00d", "caproni-demo-d06f00d"),
282            ("caproni-x", "caproni-x"),
283            ("capronify", "xianyun-capronify"),
284            ("capronix", "xianyun-capronix"),
285        ];
286
287        for (input, expected) in cases {
288            assert_eq!(context_name(input), expected, "{input}");
289        }
290    }
291}