Skip to main content

vault_core/
bundle_scope.rs

1//! Bundle-local template resolution (Phase 24.1, step 4).
2//!
3//! Resolves local variables, composite parts, sibling fields and optional
4//! global references. Missing inputs, cycles and excessive derived depth are
5//! errors so a partially rendered credential cannot look valid.
6
7use serde_json::Value;
8use std::collections::HashMap;
9
10const MAX_DEPTH: usize = 4;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ScopedValue {
14    pub value: String,
15    pub secret: bool,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ScopeError {
20    Unresolved(String),
21    Cycle(Vec<String>),
22    Depth(String),
23    Invalid(String),
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ScopeOutput {
28    pub value: ScopedValue,
29    pub warnings: Vec<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CompositeOutput {
34    pub value: String,
35    pub secret: bool,
36    pub warnings: Vec<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum CompositeScopeError {
41    Scope(ScopeError),
42    Composite(crate::composite::RenderError),
43}
44
45fn string_field<'a>(entry: &'a Value, key: &str) -> Option<&'a str> {
46    entry.get(key).and_then(Value::as_str)
47}
48
49fn bool_field(entry: &Value, key: &str) -> bool {
50    entry.get(key).and_then(Value::as_bool) == Some(true)
51}
52
53fn variable<'a>(entry: &'a Value, key: &str) -> Option<&'a Value> {
54    entry
55        .get("extra_vars")
56        .and_then(Value::as_array)?
57        .iter()
58        .find(|item| string_field(item, "key") == Some(key))
59}
60
61fn entry_field(entry: &Value, key: &str) -> Option<ScopedValue> {
62    if matches!(key, "version_history" | "projectIds" | "categories") {
63        return None;
64    }
65    let role = role_key(key);
66    if role != "VALUE"
67        && entry
68            .get("primary_role")
69            .and_then(Value::as_str)
70            .is_some_and(|value| role_key(value) == role)
71    {
72        return Some(ScopedValue {
73            value: string_field(entry, "api_key")?.to_owned(),
74            secret: !bool_field(entry, "primary_public"),
75        });
76    }
77    if role != "VALUE"
78        && entry
79            .get("secret_role")
80            .and_then(Value::as_str)
81            .is_some_and(|value| role_key(value) == role)
82    {
83        return Some(ScopedValue {
84            value: string_field(entry, "api_secret")?.to_owned(),
85            secret: !bool_field(entry, "secret_public"),
86        });
87    }
88    let canonical = match key.to_ascii_uppercase().as_str() {
89        "APIKEY" | "API_KEY" | "KEY" | "TOKEN" | "ACCESS_TOKEN" | "BEARER" | "SECRET_KEY"
90        | "PASSWORD" | "PASS" | "PWD" => "api_key",
91        "SECRET" | "API_SECRET" | "CLIENT_SECRET" | "SHARED_SECRET" => "api_secret",
92        "USERNAME" | "USER" | "LOGIN" | "USER_NAME" => "username",
93        "URL" | "URI" | "ENDPOINT" | "API_URL" | "BASE_URL" => "api_url",
94        "EMAIL" | "MAIL" => "email",
95        "KEY_ID" | "KEYID" | "KID" | "ID" | "CLIENT_ID" | "APP_ID" | "ACCOUNT_ID"
96        | "APPLICATION_ID" => "key_id",
97        "PATH" | "MOUNT" | "MOUNT_PATH" | "FILE" => "mount_path",
98        _ => key,
99    };
100    let (field, secret) = match canonical {
101        "api_key" => ("api_key", !bool_field(entry, "primary_public")),
102        "api_secret" => ("api_secret", !bool_field(entry, "secret_public")),
103        _ => (
104            canonical,
105            matches!(canonical, "totp_secret" | "user_agent" | "blob_data"),
106        ),
107    };
108    if let Some(value) = string_field(entry, field) {
109        return Some(ScopedValue {
110            value: value.to_owned(),
111            secret,
112        });
113    }
114    let item = variable(entry, key).or_else(|| variable(entry, canonical))?;
115    Some(ScopedValue {
116        value: string_field(item, "value")?.to_owned(),
117        secret: item.get("public").and_then(Value::as_bool) != Some(true)
118            && item.get("secret").and_then(Value::as_bool) != Some(false),
119    })
120}
121
122fn role_key(raw: &str) -> String {
123    raw.chars()
124        .map(|c| {
125            if c.is_ascii_alphanumeric() {
126                c.to_ascii_uppercase()
127            } else {
128                '_'
129            }
130        })
131        .collect()
132}
133
134/// Resolve `{local}` and `{slot.field}`. `globals` returns a value only when
135/// the caller can resolve and read that reference; callers should mark a
136/// reference secret unless its source is explicitly public.
137pub fn resolve<F>(
138    bundle: &Value,
139    members: &[Value],
140    template: &str,
141    parts: &[Value],
142    globals: F,
143) -> Result<ScopeOutput, ScopeError>
144where
145    F: Fn(&str) -> Option<ScopedValue>,
146{
147    let mut slots = HashMap::new();
148    for member in members {
149        let slot = string_field(member, "bundle_slot")
150            .filter(|s| !s.is_empty())
151            .or_else(|| string_field(member, "provider"))
152            .unwrap_or_default();
153        if slots.insert(slot.to_owned(), member).is_some() {
154            return Err(ScopeError::Invalid(format!(
155                "duplicate bundle slot \"{slot}\""
156            )));
157        }
158    }
159    let mut locals = HashMap::new();
160    if let Some(values) = bundle.get("extra_vars").and_then(Value::as_array) {
161        for item in values {
162            if let (Some(key), Some(_)) = (string_field(item, "key"), string_field(item, "value")) {
163                locals.insert(key.to_owned(), item);
164            }
165        }
166    }
167    let own: HashMap<&str, &Value> = parts
168        .iter()
169        .filter_map(|part| Some((string_field(part, "key")?, part)))
170        .collect();
171    let mut warnings = Vec::new();
172    for name in locals.keys() {
173        if own.contains_key(name.as_str())
174            || members
175                .iter()
176                .any(|m| string_field(m, "bundle_slot") == Some(name))
177        {
178            warnings.push(format!(
179                "\"{name}\" exists at more than one scope level; the part wins over the local"
180            ));
181        }
182    }
183    let mut stack = Vec::new();
184    let value = resolve_text(template, 0, &mut stack, &locals, &own, &slots, &globals)?;
185    Ok(ScopeOutput { value, warnings })
186}
187
188/// Resolve scoped references into synthetic named parts, then pass those parts
189/// through the shared zone-aware composite renderer.
190pub fn render_composite<F>(
191    bundle: &Value,
192    members: &[Value],
193    template: &str,
194    own_parts: &[Value],
195    kind: crate::composite::Kind,
196    globals: F,
197) -> Result<CompositeOutput, CompositeScopeError>
198where
199    F: Fn(&str) -> Option<ScopedValue>,
200{
201    let mut expanded = String::new();
202    let mut parts = Vec::new();
203    let mut warnings = Vec::new();
204    let mut secret = false;
205    let mut pos = 0;
206    let mut index = 0;
207    while pos < template.len() {
208        let rest = &template[pos..];
209        if rest.starts_with("{{") || rest.starts_with("}}") {
210            expanded.push_str(&rest[..2]);
211            pos += 2;
212            continue;
213        }
214        let global_end = if rest.starts_with("${") {
215            Some(rest.find('}').ok_or_else(|| {
216                CompositeScopeError::Scope(ScopeError::Invalid(format!(
217                    "unclosed global reference at {pos}"
218                )))
219            })?)
220        } else {
221            None
222        };
223        if let Some(end) = global_end {
224            let scoped = resolve(bundle, members, &rest[..=end], own_parts, &globals)
225                .map_err(CompositeScopeError::Scope)?;
226            let key = format!("scope_{index}");
227            index += 1;
228            secret |= scoped.value.secret;
229            warnings.extend(scoped.warnings);
230            parts.push(crate::composite::Part {
231                key: key.clone(),
232                value: scoped.value.value,
233            });
234            expanded.push('{');
235            expanded.push_str(&key);
236            expanded.push('}');
237            pos += end + 1;
238            continue;
239        }
240        let ch = rest.chars().next().expect("pos is within template");
241        if ch == '}' {
242            return Err(CompositeScopeError::Scope(ScopeError::Invalid(format!(
243                "unmatched closing brace at {pos}"
244            ))));
245        }
246        if ch != '{' {
247            expanded.push(ch);
248            pos += ch.len_utf8();
249            continue;
250        }
251        let end = rest.find('}').ok_or_else(|| {
252            CompositeScopeError::Scope(ScopeError::Invalid(format!("unclosed brace at {pos}")))
253        })?;
254        let scoped = resolve(bundle, members, &rest[..=end], own_parts, &globals)
255            .map_err(CompositeScopeError::Scope)?;
256        let key = format!("scope_{index}");
257        index += 1;
258        secret |= scoped.value.secret;
259        warnings.extend(scoped.warnings);
260        parts.push(crate::composite::Part {
261            key: key.clone(),
262            value: scoped.value.value,
263        });
264        expanded.push('{');
265        expanded.push_str(&key);
266        expanded.push('}');
267        pos += end + 1;
268    }
269    let rendered = crate::composite::render(&expanded, &parts, kind)
270        .map_err(CompositeScopeError::Composite)?;
271    Ok(CompositeOutput {
272        value: rendered.text,
273        secret,
274        warnings,
275    })
276}
277
278fn resolve_text<F>(
279    text: &str,
280    depth: usize,
281    stack: &mut Vec<String>,
282    locals: &HashMap<String, &Value>,
283    own: &HashMap<&str, &Value>,
284    slots: &HashMap<String, &Value>,
285    globals: &F,
286) -> Result<ScopedValue, ScopeError>
287where
288    F: Fn(&str) -> Option<ScopedValue>,
289{
290    let mut output = String::new();
291    let mut secret = false;
292    let mut pos = 0;
293    while pos < text.len() {
294        let rest = &text[pos..];
295        if let Some(tail) = rest.strip_prefix("{{") {
296            output.push('{');
297            pos = text.len() - tail.len();
298            continue;
299        }
300        if let Some(tail) = rest.strip_prefix("}}") {
301            output.push('}');
302            pos = text.len() - tail.len();
303            continue;
304        }
305        if let Some(tail) = rest.strip_prefix("${") {
306            let close = tail.find('}').ok_or_else(|| {
307                ScopeError::Invalid(format!("unclosed global reference at {pos}"))
308            })?;
309            let name = &tail[..close];
310            let value =
311                globals(name).ok_or_else(|| ScopeError::Unresolved(format!("\u{24}{{{name}}}")))?;
312            secret |= value.secret;
313            output.push_str(&value.value);
314            pos += 2 + close + 1;
315            continue;
316        }
317        let ch = rest.chars().next().expect("pos is within text");
318        if ch == '}' {
319            return Err(ScopeError::Invalid(format!(
320                "unmatched closing brace at {pos}"
321            )));
322        }
323        if ch != '{' {
324            output.push(ch);
325            pos += ch.len_utf8();
326            continue;
327        }
328        let close = rest
329            .find('}')
330            .ok_or_else(|| ScopeError::Invalid(format!("unclosed brace at {pos}")))?;
331        let name = &rest[1..close];
332        if !valid_name(name) {
333            return Err(ScopeError::Invalid(format!("invalid reference \"{name}\"")));
334        }
335        let resolved = if let Some(part) = own.get(name) {
336            value_from_var(part)?
337        } else if let Some(local) = locals.get(name) {
338            if stack.iter().any(|item| item == name) {
339                let mut path = stack.clone();
340                path.push(name.to_owned());
341                return Err(ScopeError::Cycle(path));
342            }
343            let raw = string_field(local, "value").unwrap_or_default();
344            let kind = string_field(local, "kind").unwrap_or_default();
345            let inner = if kind == "template" {
346                if depth >= MAX_DEPTH && has_reference(raw) {
347                    return Err(ScopeError::Depth(name.to_owned()));
348                }
349                stack.push(name.to_owned());
350                let result = resolve_text(raw, depth + 1, stack, locals, own, slots, globals);
351                stack.pop();
352                result?
353            } else {
354                ScopedValue {
355                    value: raw.to_owned(),
356                    secret: false,
357                }
358            };
359            ScopedValue {
360                value: inner.value,
361                secret: inner.secret
362                    || (!bool_field(local, "public")
363                        && local.get("secret").and_then(Value::as_bool) != Some(false)),
364            }
365        } else if let Some((slot, field)) = name.split_once('.') {
366            slots
367                .get(slot)
368                .and_then(|entry| entry_field(entry, field))
369                .ok_or_else(|| ScopeError::Unresolved(name.to_owned()))?
370        } else {
371            return Err(ScopeError::Unresolved(name.to_owned()));
372        };
373        secret |= resolved.secret;
374        output.push_str(&resolved.value);
375        pos += close + 1;
376    }
377    Ok(ScopedValue {
378        value: output,
379        secret,
380    })
381}
382
383fn valid_name(name: &str) -> bool {
384    let mut parts = name.split('.');
385    let valid_ident = |s: &str| {
386        let mut chars = s.chars();
387        chars
388            .next()
389            .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
390            && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
391    };
392    let first = parts.next().is_some_and(valid_ident);
393    first && parts.next().is_none_or(valid_ident) && parts.next().is_none()
394}
395
396fn has_reference(text: &str) -> bool {
397    let bytes = text.as_bytes();
398    bytes.windows(2).any(|w| w == b"${") || bytes.windows(2).any(|w| w[0] == b'{' && w[1] != b'{')
399}
400
401fn value_from_var(value: &Value) -> Result<ScopedValue, ScopeError> {
402    let raw = string_field(value, "value")
403        .ok_or_else(|| ScopeError::Invalid("part has no string value".to_owned()))?;
404    Ok(ScopedValue {
405        value: raw.to_owned(),
406        secret: value.get("public").and_then(Value::as_bool) != Some(true)
407            && value.get("secret").and_then(Value::as_bool) != Some(false),
408    })
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use serde_json::json;
415
416    #[test]
417    fn matches_the_shared_typescript_parity_fixture() {
418        let fixture: Value = serde_json::from_str(include_str!(
419            "../../tests/fixtures/parity/bundle-scope.json"
420        ))
421        .unwrap();
422        for case in fixture["cases"].as_array().unwrap() {
423            let members = case["members"].as_array().unwrap();
424            let out = resolve(
425                &case["bundle"],
426                members,
427                case["template"].as_str().unwrap(),
428                &[],
429                |_| None,
430            )
431            .unwrap_or_else(|err| panic!("{}: {err:?}", case["name"]));
432            assert_eq!(out.value.value, case["expected"]["value"].as_str().unwrap());
433            assert_eq!(
434                out.value.secret,
435                case["expected"]["secret"].as_bool().unwrap()
436            );
437        }
438        for case in fixture["composites"].as_array().unwrap() {
439            let out = render_composite(
440                &case["bundle"],
441                case["members"].as_array().unwrap(),
442                case["template"].as_str().unwrap(),
443                case["own_parts"].as_array().unwrap(),
444                crate::composite::Kind::parse(case["kind"].as_str().unwrap()),
445                |_| None,
446            )
447            .unwrap_or_else(|err| panic!("{}: {err:?}", case["name"]));
448            assert_eq!(out.value, case["expected"]["value"].as_str().unwrap());
449            assert_eq!(out.secret, case["expected"]["secret"].as_bool().unwrap());
450        }
451    }
452
453    #[test]
454    fn resolves_siblings_and_propagates_taint() {
455        let bundle = json!({"secretType":"bundle", "extra_vars":[{"key":"prefix","value":">","public":true}]});
456        let member = json!({"provider":"Discord", "bundle_slot":"discord", "api_key":"token", "extra_vars":[{"key":"id","value":"9007199254740993","public":true}]});
457        let out = resolve(
458            &bundle,
459            &[member],
460            "{prefix}{discord.id}:{discord.key}",
461            &[],
462            |_| None,
463        )
464        .unwrap();
465        assert_eq!(
466            out.value,
467            ScopedValue {
468                value: ">9007199254740993:token".into(),
469                secret: true
470            }
471        );
472    }
473
474    #[test]
475    fn unresolved_cycles_and_depth_refuse_rendering() {
476        let missing = resolve(&json!({}), &[], "{missing}", &[], |_| None).unwrap_err();
477        assert_eq!(missing, ScopeError::Unresolved("missing".into()));
478        let cycle = json!({"extra_vars":[{"key":"a","value":"{b}","kind":"template"},{"key":"b","value":"{a}","kind":"template"}]});
479        assert!(matches!(
480            resolve(&cycle, &[], "{a}", &[], |_| None),
481            Err(ScopeError::Cycle(_))
482        ));
483        let deep = json!({"extra_vars":[
484            {"key":"a","value":"{b}","kind":"template"}, {"key":"b","value":"{c}","kind":"template"},
485            {"key":"c","value":"{d}","kind":"template"}, {"key":"d","value":"{e}","kind":"template"},
486            {"key":"e","value":"{f}","kind":"template"}, {"key":"f","value":"end","kind":"template"}
487        ]});
488        assert!(matches!(
489            resolve(&deep, &[], "{a}", &[], |_| None),
490            Err(ScopeError::Depth(_))
491        ));
492    }
493}