1use 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
134pub 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
188pub 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}