Skip to main content

rpi_runtime/
values.rs

1//! Explicit environment and secret-command access supplied by the host.
2use std::collections::BTreeMap;
3
4/// Freeze environment, cached commands and time for one resolution. Uncached
5/// header commands retain their existing per-expansion semantics.
6pub(crate) struct ResolutionValues<'a> {
7    source: &'a dyn ValueSource,
8    environment: std::sync::Mutex<BTreeMap<String, Option<String>>>,
9    commands: std::sync::Mutex<BTreeMap<String, Option<String>>>,
10    now: i64,
11}
12impl<'a> ResolutionValues<'a> {
13    pub(crate) fn new(source: &'a dyn ValueSource) -> Self {
14        Self {
15            source,
16            environment: Default::default(),
17            commands: Default::default(),
18            now: source.now_unix_seconds(),
19        }
20    }
21}
22impl ValueSource for ResolutionValues<'_> {
23    fn env(&self, name: &str) -> Option<String> {
24        self.environment
25            .lock()
26            .unwrap()
27            .entry(name.into())
28            .or_insert_with(|| self.source.env(name))
29            .clone()
30    }
31    fn command(&self, command: &str, cached: bool) -> Option<String> {
32        if !cached {
33            return self.source.command(command, false);
34        }
35        self.commands
36            .lock()
37            .unwrap()
38            .entry(command.into())
39            .or_insert_with(|| self.source.command(command, true))
40            .clone()
41    }
42    fn now_unix_seconds(&self) -> i64 {
43        self.now
44    }
45}
46
47/// Hosts choose which environment values and commands the runtime may access.
48/// Commands are disabled by default; the library never reads process env itself.
49pub trait ValueSource: Send + Sync {
50    fn env(&self, name: &str) -> Option<String>;
51    fn command(&self, _command: &str, _cached: bool) -> Option<String> {
52        None
53    }
54    fn now_unix_seconds(&self) -> i64 {
55        std::time::SystemTime::now()
56            .duration_since(std::time::UNIX_EPOCH)
57            .map(|duration| duration.as_secs() as i64)
58            .unwrap_or(i64::MAX)
59    }
60}
61impl ValueSource for BTreeMap<String, String> {
62    fn env(&self, name: &str) -> Option<String> {
63        self.get(name).cloned()
64    }
65}
66pub fn resolve_config_value(
67    raw: &str,
68    overlay: Option<&BTreeMap<String, String>>,
69    values: &dyn ValueSource,
70    cached: bool,
71) -> Option<String> {
72    match raw.strip_prefix('!') {
73        Some(command) => values.command(command, cached),
74        None => resolve_template(raw, overlay, values),
75    }
76}
77pub fn resolve_headers(
78    headers: &BTreeMap<String, String>,
79    overlay: Option<&BTreeMap<String, String>>,
80    values: &dyn ValueSource,
81) -> BTreeMap<String, String> {
82    headers
83        .iter()
84        .filter_map(|(name, value)| {
85            resolve_config_value(value, overlay, values, false).map(|value| (name.clone(), value))
86        })
87        .collect()
88}
89/// A parsed template part — literal text or an env-var reference.
90enum TemplatePart {
91    Literal(String),
92    Env(String),
93}
94
95/// Parse a `$VAR`/`${VAR}` template (mirrors pi `parseConfigValueTemplate`).
96/// `$$`→`$` and `$!`→`!` are escapes; `${NAME}` requires `NAME` to match
97/// `^[A-Za-z_][A-Za-z0-9_]*$` else the raw slice is kept literal; `$NAME` takes
98/// the longest `[A-Za-z_][A-Za-z0-9_]*` prefix as the name.
99fn parse_template(config: &str) -> Vec<TemplatePart> {
100    let mut parts: Vec<TemplatePart> = Vec::new();
101    let bytes = config.as_bytes();
102    let mut i = 0usize;
103    while i < bytes.len() {
104        // Find the next `$`.
105        match config[i..].find('$') {
106            None => {
107                push_literal(&mut parts, &config[i..]);
108                break;
109            }
110            Some(offset) => {
111                let dollar = i + offset;
112                push_literal(&mut parts, &config[i..dollar]);
113                let after = dollar + 1;
114                let next = bytes.get(after).copied();
115                if next == Some(b'$') || next == Some(b'!') {
116                    push_literal(&mut parts, &config[after..after + 1]);
117                    i = after + 1;
118                    continue;
119                }
120                if next == Some(b'{') {
121                    // ${NAME}
122                    if let Some(end_rel) = config[after + 1..].find('}') {
123                        let end = after + 1 + end_rel;
124                        let name = &config[after + 1..end];
125                        if is_env_name(name) {
126                            parts.push(TemplatePart::Env(name.to_string()));
127                        } else {
128                            // Not a valid name — keep the raw `${…}` literal.
129                            push_literal(&mut parts, &config[dollar..=end]);
130                        }
131                        i = end + 1;
132                        continue;
133                    }
134                    // No closing `}` — literal `$`.
135                    push_literal(&mut parts, "$");
136                    i = after;
137                    continue;
138                }
139                // $NAME (greedy prefix). Bare `$` with no name char follows.
140                if let Some(name) = env_name_prefix(&config[after..]) {
141                    parts.push(TemplatePart::Env(name.to_string()));
142                    i = after + name.len();
143                } else {
144                    push_literal(&mut parts, "$");
145                    i = after;
146                }
147            }
148        }
149    }
150    parts
151}
152
153fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
154    if value.is_empty() {
155        return;
156    }
157    if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
158        s.push_str(value);
159    } else {
160        parts.push(TemplatePart::Literal(value.to_string()));
161    }
162}
163
164fn is_env_name(s: &str) -> bool {
165    let mut chars = s.chars();
166    match chars.next() {
167        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
168        _ => return false,
169    }
170    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
171}
172
173/// The longest `[A-Za-z_][A-Za-z0-9_]*` prefix of `s` (mirrors the TS
174/// `ENV_VAR_NAME_PREFIX_RE` match), or `None` when `s` doesn't start with one.
175fn env_name_prefix(s: &str) -> Option<&str> {
176    let mut chars = s.char_indices();
177    match chars.next() {
178        Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
179        _ => return None,
180    }
181    let end = chars
182        .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
183        .map(|(idx, _)| idx)
184        .unwrap_or(s.len());
185    Some(&s[..end])
186}
187
188/// Resolve a parsed template: any referenced env var that is unset ⇒ the whole
189/// value is `None` (pi semantics). Literal-only templates pass through as-is.
190pub fn resolve_template(
191    config: &str,
192    env_overlay: Option<&BTreeMap<String, String>>,
193    values: &dyn ValueSource,
194) -> Option<String> {
195    let parts = parse_template(config);
196    let mut out = String::with_capacity(config.len());
197    for part in parts {
198        match part {
199            TemplatePart::Literal(s) => out.push_str(&s),
200            TemplatePart::Env(name) => {
201                let v = env_overlay
202                    .and_then(|overlay| overlay.get(&name).cloned())
203                    .or_else(|| values.env(&name))?;
204                out.push_str(&v)
205            }
206        }
207    }
208    Some(out)
209}