sui_castore/
env_expand.rs1use std::env;
24
25#[derive(Debug, thiserror::Error)]
27pub enum ExpandEnvError {
28 #[error("config references ${{{name}}} but environment variable `{name}` is not set")]
30 Missing {
31 name: String,
33 },
34}
35
36pub fn expand_env_vars(text: &str) -> Result<String, ExpandEnvError> {
41 expand_with(text, |name| {
42 env::var(name).map_err(|_| ExpandEnvError::Missing {
43 name: name.to_string(),
44 })
45 })
46}
47
48fn expand_with<F>(text: &str, mut lookup: F) -> Result<String, ExpandEnvError>
51where
52 F: FnMut(&str) -> Result<String, ExpandEnvError>,
53{
54 let mut out = String::with_capacity(text.len());
55 let bytes = text.as_bytes();
56 let mut i = 0;
57 while i < bytes.len() {
58 if bytes[i] == b'$' {
59 if bytes.get(i + 1) == Some(&b'$') && bytes.get(i + 2) == Some(&b'{') {
61 out.push_str("${");
62 i += 3;
63 continue;
64 }
65 if bytes.get(i + 1) == Some(&b'{') {
67 if let Some(close) = text[i + 2..].bytes().position(|b| b == b'}') {
68 let name = &text[i + 2..i + 2 + close];
69 if is_valid_name(name) {
70 out.push_str(&lookup(name)?);
71 i = i + 2 + close + 1; continue;
73 }
74 }
75 }
76 }
77 let ch = text[i..].chars().next().expect("i on a char boundary");
80 out.push(ch);
81 i += ch.len_utf8();
82 }
83 Ok(out)
84}
85
86fn is_valid_name(name: &str) -> bool {
87 let mut chars = name.chars();
88 match chars.next() {
89 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
90 _ => return false,
91 }
92 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 fn table<'a>(
101 pairs: &'a [(&'a str, &'a str)],
102 ) -> impl FnMut(&str) -> Result<String, ExpandEnvError> + 'a {
103 move |name| {
104 pairs
105 .iter()
106 .find(|(k, _)| *k == name)
107 .map(|(_, v)| (*v).to_string())
108 .ok_or_else(|| ExpandEnvError::Missing {
109 name: name.to_string(),
110 })
111 }
112 }
113
114 #[test]
115 fn no_tokens_roundtrips_byte_identical() {
116 let dsn = r#"{"type":"pg","url":"postgres://sui@sui-cache-pg:5432/suicache","max_conns":8}"#;
118 assert_eq!(expand_with(dsn, table(&[])).unwrap(), dsn);
119 }
120
121 #[test]
122 fn expands_a_dsn_user_and_password() {
123 let tmpl = "postgres://${U}:${P}@h:5432/db";
124 let got = expand_with(tmpl, table(&[("U", "sui"), ("P", "s3cr3t")])).unwrap();
125 assert_eq!(got, "postgres://sui:s3cr3t@h:5432/db");
126 }
127
128 #[test]
129 fn missing_var_is_a_hard_error_never_empty() {
130 let err = expand_with("postgres://${U}:${P}@h/db", table(&[("U", "sui")])).unwrap_err();
132 let ExpandEnvError::Missing { name } = err;
133 assert_eq!(name, "P");
134 }
135
136 #[test]
137 fn double_dollar_brace_is_a_literal() {
138 assert_eq!(
139 expand_with("$${NOT_A_VAR}", table(&[])).unwrap(),
140 "${NOT_A_VAR}"
141 );
142 }
143
144 #[test]
145 fn bare_dollar_and_invalid_ref_left_untouched() {
146 let s = "cost is $5 and ${bad name} and ${}";
147 assert_eq!(expand_with(s, table(&[])).unwrap(), s);
148 }
149
150 #[test]
151 fn expands_inside_a_tiered_json_l2_url() {
152 let json = r#"{"type":"tiered","l2":{"type":"pg","url":"postgres://${U}:${P}@sui-cache-pg-rw:5432/suicache","max_conns":8}}"#;
153 let got = expand_with(json, table(&[("U", "sui"), ("P", "pw")])).unwrap();
154 assert!(got.contains("postgres://sui:pw@sui-cache-pg-rw:5432/suicache"));
155 assert!(got.starts_with(r#"{"type":"tiered","l2":{"type":"pg","url":"postgres://"#));
157 }
158
159 #[test]
160 fn redis_password_only_dsn() {
161 let got = expand_with("redis://:${RP}@sui-cache-redis:6379", table(&[("RP", "rpw")])).unwrap();
162 assert_eq!(got, "redis://:rpw@sui-cache-redis:6379");
163 }
164}