Skip to main content

cfgd_core/util/
strings.rs

1use crate::config;
2
3/// Parse a `KEY=VALUE` string into an `EnvVar`.
4pub fn parse_env_var(input: &str) -> std::result::Result<config::EnvVar, String> {
5    let (key, value) = input
6        .split_once('=')
7        .ok_or_else(|| format!("invalid env var '{}' — expected KEY=VALUE", input))?;
8    validate_env_var_user_name(key)?;
9    Ok(config::EnvVar {
10        name: key.to_string(),
11        value: value.to_string(),
12    })
13}
14
15/// Validate that an environment variable name is safe for shell interpolation
16/// and is not in the reserved `CFGD_*` namespace.
17pub fn validate_env_var_user_name(name: &str) -> std::result::Result<(), String> {
18    validate_env_var_name(name)?;
19    if name.starts_with("CFGD_") {
20        return Err(format!(
21            "env var name '{}' is reserved — the CFGD_* prefix is for \
22             cfgd runtime metadata. Rename to e.g. APP_{} or MY_{}.",
23            name,
24            name.trim_start_matches("CFGD_"),
25            name.trim_start_matches("CFGD_"),
26        ));
27    }
28    if name == "BASH_ENV" || name == "ZDOTDIR" {
29        return Err(format!(
30            "env var name '{name}' is reserved — cfgd uses it for \
31             alias delivery to lifecycle scripts"
32        ));
33    }
34    Ok(())
35}
36
37/// Validate that an environment variable name is safe for shell interpolation.
38/// Accepts names matching `[A-Za-z_][A-Za-z0-9_]*`.
39pub fn validate_env_var_name(name: &str) -> std::result::Result<(), String> {
40    if name.is_empty() {
41        return Err("environment variable name must not be empty".to_string());
42    }
43    let first = name.as_bytes()[0];
44    if !first.is_ascii_alphabetic() && first != b'_' {
45        return Err(format!(
46            "invalid env var name '{}' — must start with a letter or underscore",
47            name
48        ));
49    }
50    if !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
51        return Err(format!(
52            "invalid env var name '{}' — must contain only letters, digits, and underscores",
53            name
54        ));
55    }
56    Ok(())
57}
58
59/// Validate that a shell alias name is safe for shell interpolation.
60/// Accepts names matching `[A-Za-z0-9_.-]+`.
61pub fn validate_alias_name(name: &str) -> std::result::Result<(), String> {
62    if name.is_empty() {
63        return Err("alias name must not be empty".to_string());
64    }
65    if !name
66        .bytes()
67        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
68    {
69        return Err(format!(
70            "invalid alias name '{}' — must contain only letters, digits, underscores, hyphens, and dots",
71            name
72        ));
73    }
74    Ok(())
75}
76
77/// Parse a `name=command` string into a `ShellAlias`.
78pub fn parse_alias(input: &str) -> std::result::Result<config::ShellAlias, String> {
79    let (name, command) = input
80        .split_once('=')
81        .ok_or_else(|| format!("invalid alias '{}' — expected name=command", input))?;
82    validate_alias_name(name)?;
83    Ok(config::ShellAlias {
84        name: name.to_string(),
85        command: command.to_string(),
86    })
87}
88
89/// Sanitize a string for use as a Kubernetes object name (RFC 1123 DNS label).
90/// Lowercases, replaces underscores with hyphens, filters non-alphanumeric chars,
91/// and trims leading/trailing hyphens.
92pub fn sanitize_k8s_name(name: &str) -> String {
93    name.to_ascii_lowercase()
94        .replace('_', "-")
95        .chars()
96        .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
97        .collect::<String>()
98        .trim_matches('-')
99        .to_string()
100}
101
102/// Escape a value for use in shell `export` statements.
103///
104/// Uses single quotes for values containing shell metacharacters (`$`, backtick,
105/// `\`, `"`). Single quotes within the value are escaped via `'\''`.
106/// Single-pass scan: returns double-quoted string when no metacharacters are present
107/// (zero intermediate allocations in the common case).
108pub fn shell_escape_value(value: &str) -> String {
109    if !value
110        .bytes()
111        .any(|b| matches!(b, b'$' | b'`' | b'\\' | b'"' | b'\''))
112    {
113        return format!("\"{}\"", value);
114    }
115    // Single-quote strategy: only `'` needs escaping inside single quotes
116    if !value.contains('\'') {
117        return format!("'{}'", value);
118    }
119    // Value contains both metacharacters and single quotes — break-out escaping
120    let mut out = String::with_capacity(value.len() + 8);
121    out.push('\'');
122    for c in value.chars() {
123        if c == '\'' {
124            out.push_str("'\\''");
125        } else {
126            out.push(c);
127        }
128    }
129    out.push('\'');
130    out
131}
132
133/// Escape a value for use inside bash/zsh double quotes (single pass).
134/// Escapes `\`, `"`, `` ` ``, and `!` — the four characters with special
135/// meaning inside double-quoted strings.
136pub fn escape_double_quoted(s: &str) -> String {
137    let mut out = String::with_capacity(s.len() + s.len() / 8);
138    for c in s.chars() {
139        match c {
140            '\\' | '"' | '`' | '!' => {
141                out.push('\\');
142                out.push(c);
143            }
144            _ => out.push(c),
145        }
146    }
147    out
148}
149
150/// Escape a string for safe inclusion in XML/plist content (single pass).
151pub fn xml_escape(s: &str) -> String {
152    let mut out = String::with_capacity(s.len() + s.len() / 8);
153    for c in s.chars() {
154        match c {
155            '&' => out.push_str("&amp;"),
156            '<' => out.push_str("&lt;"),
157            '>' => out.push_str("&gt;"),
158            '"' => out.push_str("&quot;"),
159            '\'' => out.push_str("&apos;"),
160            _ => out.push(c),
161        }
162    }
163    out
164}