Skip to main content

vault_core/
bundle_import.rs

1//! Import a Python config module as bundle-local variables (Phase 24.1).
2//!
3//! Twin of `src/ts/bundle-import.ts`, pinned by
4//! `tests/fixtures/parity/bundle-import.json`. The assignment subset only:
5//! string and f-string literals, ints, floats, `True`/`False`/`None`. Nothing is
6//! executed; any other right-hand side is imported as source text with a
7//! warning naming the line. See the TypeScript header for the rules (duplicate
8//! names, large ids, f-strings holding only plain names).
9
10use serde::Serialize;
11use std::collections::{HashMap, HashSet};
12
13#[derive(Debug, Serialize, PartialEq)]
14pub struct ImportedVar {
15    pub key: String,
16    pub value: String,
17    pub kind: String,
18    /// Safe to print: a prefix, a colour, a title, a link with no credential in
19    /// it. Set only when the importer is sure; everything else stays masked
20    /// (redaction is fail-closed). Omitted from the output when false.
21    #[serde(skip_serializing_if = "std::ops::Not::not")]
22    pub public: bool,
23}
24
25#[derive(Debug, Serialize, PartialEq)]
26pub struct BundleImport {
27    pub vars: Vec<ImportedVar>,
28    pub warnings: Vec<String>,
29}
30
31fn is_ident(s: &str) -> bool {
32    let mut c = s.chars();
33    c.next()
34        .is_some_and(|f| f.is_ascii_alphabetic() || f == '_')
35        && c.all(|f| f.is_ascii_alphanumeric() || f == '_')
36}
37
38/// `(prefix, body, rest)` for a leading quoted literal.
39fn read_quoted(source: &str) -> Option<(String, String, String)> {
40    let b: Vec<char> = source.chars().collect();
41    let mut i = 0;
42    while i < b.len() && i < 2 && "fFuUrRbB".contains(b[i]) {
43        i += 1;
44    }
45    if i >= b.len() || (b[i] != '\'' && b[i] != '"') {
46        return None;
47    }
48    let prefix: String = b[..i].iter().collect::<String>().to_lowercase();
49    let quote = b[i];
50    let start = i + 1;
51    let mut end = start;
52    while end < b.len() {
53        if b[end] == '\\' {
54            end += 2;
55            continue;
56        }
57        if b[end] == quote {
58            break;
59        }
60        end += 1;
61    }
62    if end >= b.len() || b[end] != quote {
63        return None;
64    }
65    Some((
66        prefix,
67        b[start..end].iter().collect(),
68        b[end + 1..].iter().collect(),
69    ))
70}
71
72fn unescape_py(body: &str, raw: bool) -> Option<String> {
73    if raw {
74        return Some(body.to_string());
75    }
76    let mut out = String::new();
77    let mut it = body.chars();
78    while let Some(c) = it.next() {
79        if c != '\\' {
80            out.push(c);
81            continue;
82        }
83        match it.next()? {
84            'n' => out.push('\n'),
85            'r' => out.push('\r'),
86            't' => out.push('\t'),
87            '\\' => out.push('\\'),
88            '\'' => out.push('\''),
89            '"' => out.push('"'),
90            _ => return None,
91        }
92    }
93    Some(out)
94}
95
96/// `{name}` holes only. `None` when anything else is inside braces.
97fn convert_fstring(
98    body: &str,
99    bound: &HashMap<String, String>,
100) -> Option<(String, Vec<(String, String)>)> {
101    let c: Vec<char> = body.chars().collect();
102    let mut out = String::new();
103    let mut rewrote = Vec::new();
104    let mut i = 0;
105    while i < c.len() {
106        match c[i] {
107            '{' if c.get(i + 1) == Some(&'{') => {
108                out.push_str("{{");
109                i += 2;
110            }
111            '}' if c.get(i + 1) == Some(&'}') => {
112                out.push_str("}}");
113                i += 2;
114            }
115            '{' => {
116                let end = (i..c.len()).find(|&j| c[j] == '}')?;
117                let reference: String = c[i + 1..end].iter().collect();
118                let reference = reference.trim().to_string();
119                if !is_ident(&reference) {
120                    return None;
121                }
122                let key = bound.get(&reference)?;
123                if *key != reference {
124                    rewrote.push((reference.clone(), key.clone()));
125                }
126                out.push('{');
127                out.push_str(key);
128                out.push('}');
129                i = end + 1;
130            }
131            '}' => return None,
132            ch => {
133                out.push(ch);
134                i += 1;
135            }
136        }
137    }
138    Some((out, rewrote))
139}
140
141fn all_digits(s: &str) -> bool {
142    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
143}
144
145fn signed(s: &str) -> &str {
146    s.strip_prefix(['-', '+']).unwrap_or(s)
147}
148
149fn is_radix_int(s: &str) -> bool {
150    let t = signed(s);
151    let Some(rest) = t.get(2..) else { return false };
152    match t.get(..2) {
153        Some("0x") | Some("0X") => !rest.is_empty() && rest.chars().all(|c| c.is_ascii_hexdigit()),
154        Some("0o") | Some("0O") => {
155            !rest.is_empty() && rest.chars().all(|c| ('0'..='7').contains(&c))
156        }
157        Some("0b") | Some("0B") => !rest.is_empty() && rest.chars().all(|c| c == '0' || c == '1'),
158        _ => false,
159    }
160}
161
162fn is_float(s: &str) -> bool {
163    let t = signed(s);
164    let (mant, exp) = match t.find(['e', 'E']) {
165        Some(i) => (&t[..i], Some(&t[i + 1..])),
166        None => (t, None),
167    };
168    if let Some(e) = exp {
169        if !all_digits(signed(e)) {
170            return false;
171        }
172    }
173    let mut parts = mant.splitn(2, '.');
174    let int = parts.next().unwrap_or("");
175    let frac = parts.next();
176    match frac {
177        None => all_digits(int),
178        Some(f) => {
179            (int.is_empty() || all_digits(int))
180                && (f.is_empty() || all_digits(f))
181                && !(int.is_empty() && f.is_empty())
182        }
183    }
184}
185
186/// Strip a trailing ` # comment` that holds no quote (twin of the TS regex).
187fn strip_comment(s: &str) -> &str {
188    if let Some(i) = s.rfind(" #").or_else(|| s.rfind("\t#")) {
189        let tail = &s[i + 1..];
190        if !tail.contains('\'') && !tail.contains('"') {
191            return s[..i].trim_end();
192        }
193    }
194    s
195}
196
197fn kind_for_string(name: &str, body: &str) -> &'static str {
198    let looks_id = name.eq_ignore_ascii_case("id")
199        || name.eq_ignore_ascii_case("ids")
200        || name.to_lowercase().ends_with("_id")
201        || name.to_lowercase().ends_with("_ids");
202    // `[label](http(s)://target)` — the label may hold spaces, the target may not.
203    let md = body
204        .strip_prefix('[')
205        .and_then(|b| b.strip_suffix(')'))
206        .and_then(|b| b.split_once("]("))
207        .is_some_and(|(label, target)| {
208            !label.contains(']')
209                && (target.starts_with("http://") || target.starts_with("https://"))
210                && !target.contains(char::is_whitespace)
211                && !target.contains(')')
212        });
213    let url = (body.starts_with("http://") || body.starts_with("https://"))
214        && !body.contains(char::is_whitespace);
215    if (all_digits(body) && body.len() >= 15) || (looks_id && all_digits(body) && body.len() >= 10)
216    {
217        "large_id"
218    } else if md {
219        "markdown_link"
220    } else if url {
221        "url"
222    } else {
223        "string"
224    }
225}
226
227/// A name that says what it holds. Beats every other signal.
228fn secretish_name(name: &str) -> bool {
229    let n = name.to_ascii_lowercase();
230    [
231        "key",
232        "token",
233        "secret",
234        "password",
235        "passwd",
236        "pwd",
237        "auth",
238        "credential",
239        "salt",
240        "signature",
241        "private",
242        "webhook",
243        "cert",
244    ]
245    .iter()
246    .any(|w| n.contains(w))
247}
248
249/// Query-parameter names and path pieces that mean a credential rides in the URL.
250fn url_is_public(url: &str) -> bool {
251    let u = url.trim().trim_start_matches('<').trim_end_matches('>');
252    let Some(rest) = u
253        .strip_prefix("https://")
254        .or_else(|| u.strip_prefix("http://"))
255    else {
256        return false;
257    };
258    let (authority, tail) = rest.split_once('/').unwrap_or((rest, ""));
259    if authority.contains('@') {
260        return false; // user:password@host
261    }
262    let (path, query) = tail.split_once('?').unwrap_or((tail, ""));
263    let path = path.to_ascii_lowercase();
264    if path.contains("hook") {
265        return false; // a webhook URL is the credential
266    }
267    // A long mixed letter-and-digit path segment is a token, whatever it is called.
268    let tokenish = |seg: &str| {
269        seg.len() >= 24
270            && seg
271                .chars()
272                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
273            && seg.chars().any(|c| c.is_ascii_digit())
274            && seg.chars().any(|c| c.is_ascii_alphabetic())
275    };
276    if path.split('/').any(tokenish) {
277        return false;
278    }
279    let bad = [
280        "key", "token", "secret", "pass", "pwd", "sig", "auth", "code", "session",
281    ];
282    query
283        .split(['&', ';'])
284        .filter_map(|kv| kv.split('=').next())
285        .all(|k| {
286            let k = k.to_ascii_lowercase();
287            !bad.iter().any(|b| k.contains(b))
288        })
289}
290
291/// Prose: words with spaces and no assignment or long run of mixed characters.
292fn is_prose(v: &str) -> bool {
293    v.contains(' ')
294        && !v.contains('=')
295        && !v.split_whitespace().any(|w| {
296            w.len() >= 16
297                && w.chars().any(|c| c.is_ascii_digit())
298                && w.chars().any(|c| c.is_ascii_alphabetic())
299        })
300}
301
302/// Whether the importer is sure a value is safe to print. `public_keys` are the
303/// keys already judged public, for templates (a template is public only if every
304/// input is, so a composite can never launder a secret into a printable value).
305fn is_public(name: &str, kind: &str, value: &str, public_keys: &HashSet<String>) -> bool {
306    if secretish_name(name) {
307        return false;
308    }
309    match kind {
310        "hex_int" | "bool" | "float" => true,
311        "int" => value.trim_start_matches(['-', '+']).len() <= 6,
312        "url" => url_is_public(value),
313        "markdown_link" => value
314            .split_once("](")
315            .map(|(_, t)| t.trim_end_matches(')'))
316            .is_some_and(url_is_public),
317        "template" => {
318            // The `{name}` holes; `{{` and `}}` are literal braces.
319            let mut rest = value.replace("{{", "").replace("}}", "");
320            let mut refs: Vec<String> = Vec::new();
321            while let Some(i) = rest.find('{') {
322                let Some(j) = rest[i..].find('}') else {
323                    return false;
324                };
325                refs.push(rest[i + 1..i + j].to_string());
326                rest.replace_range(i..=i + j, "");
327            }
328            refs.iter().all(|r| public_keys.contains(r))
329                && (!rest.contains("://")
330                    || url_is_public(&rest.replace(' ', ""))
331                    || is_prose(&rest))
332        }
333        "string" => {
334            let t = value.trim();
335            if t.chars().count() <= 2 {
336                return true;
337            }
338            if t.starts_with('<') && t.ends_with('>') && t.contains("://") {
339                return url_is_public(t);
340            }
341            is_prose(t)
342        }
343        _ => false,
344    }
345}
346
347pub fn import_python_config(text: &str) -> BundleImport {
348    let mut vars: Vec<ImportedVar> = Vec::new();
349    let mut warnings: Vec<String> = Vec::new();
350    let mut bound: HashMap<String, String> = HashMap::new();
351    let mut taken: HashSet<String> = HashSet::new();
352    let mut rewrites: Vec<(String, String, usize)> = Vec::new();
353    let mut public_keys: HashSet<String> = HashSet::new();
354
355    for (index, raw) in text.lines().enumerate() {
356        let line_no = index + 1;
357        let line = raw.trim();
358        let Some(eq) = line.find('=') else { continue };
359        let name = line[..eq].trim_end();
360        if !is_ident(name) || line[eq + 1..].starts_with('=') {
361            continue;
362        }
363        let source_full = line[eq + 1..].trim_start();
364        let source = strip_comment(source_full).trim();
365
366        let (value, kind, extra): (String, &str, Option<String>);
367        let quoted = read_quoted(source).filter(|(_, _, rest)| {
368            let r = rest.trim();
369            r.is_empty() || r.starts_with('#')
370        });
371        if let Some((prefix, body, _)) = quoted {
372            let is_f = prefix.contains('f');
373            match unescape_py(&body, prefix.contains('r')) {
374                None => {
375                    warnings.push(format!(
376                        "line {line_no}: unsupported escape in {name}; imported as source text"
377                    ));
378                    value = source.to_string();
379                    kind = "string";
380                    extra = None;
381                }
382                Some(body) if is_f => match convert_fstring(&body, &bound) {
383                    None => {
384                        warnings.push(format!(
385                            "line {line_no}: {name} is an f-string with an expression a template cannot hold; imported as a string"
386                        ));
387                        value = body;
388                        kind = "string";
389                        extra = None;
390                    }
391                    Some((text, rewrote)) => {
392                        for (from, to) in rewrote {
393                            match rewrites.iter_mut().find(|r| r.0 == from) {
394                                Some(r) => {
395                                    r.1 = to;
396                                    r.2 += 1;
397                                }
398                                None => rewrites.push((from, to, 1)),
399                            }
400                        }
401                        // An f-string with nothing to fill in is just a string; only a
402                        // real hole (or a literal brace) makes it a template.
403                        kind = if text.contains(['{', '}']) {
404                            "template"
405                        } else {
406                            kind_for_string(name, &text)
407                        };
408                        value = text;
409                        extra = None;
410                    }
411                },
412                Some(body) => {
413                    kind = kind_for_string(name, &body);
414                    value = body;
415                    extra = None;
416                }
417            }
418        } else if is_radix_int(source) {
419            value = source.to_string();
420            kind = "hex_int";
421            extra = None;
422        } else if all_digits(signed(source)) {
423            value = source.to_string();
424            kind = "int";
425            extra = None;
426        } else if is_float(source) {
427            value = source.to_string();
428            kind = "float";
429            extra = None;
430        } else if source == "True" || source == "False" {
431            value = (source == "True").to_string();
432            kind = "bool";
433            extra = None;
434        } else if source == "None" {
435            value = String::new();
436            kind = "string";
437            extra = Some(format!("{name} is None; imported as an empty string"));
438        } else {
439            value = source.to_string();
440            kind = "string";
441            extra = Some(format!(
442                "{name} is not a supported literal; imported as source text"
443            ));
444        }
445        if let Some(w) = extra {
446            warnings.push(format!("line {line_no}: {w}"));
447        }
448
449        let mut key = name.to_string();
450        if taken.contains(name) {
451            let mut n = 2;
452            while taken.contains(&format!("{name}_{n}")) {
453                n += 1;
454            }
455            key = format!("{name}_{n}");
456            warnings.push(format!(
457                "line {line_no}: {name} is assigned more than once; the first stays {name}, this one is imported as {key}, and later references use {key}"
458            ));
459        }
460        taken.insert(key.clone());
461        bound.insert(name.to_string(), key.clone());
462        let public = is_public(name, kind, &value, &public_keys);
463        if public {
464            public_keys.insert(key.clone());
465        }
466        vars.push(ImportedVar {
467            key,
468            value,
469            kind: kind.to_string(),
470            public,
471        });
472    }
473
474    for (from, to, n) in rewrites {
475        warnings.push(format!(
476            "{n} later reference{} to {from} now {} {to}",
477            if n == 1 { "" } else { "s" },
478            if n == 1 { "uses" } else { "use" }
479        ));
480    }
481    BundleImport { vars, warnings }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn matches_the_golden_files_the_typescript_twin_wrote() {
490        let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/fixtures/parity/");
491        for (src, gold) in [
492            ("bundle-import-synthetic.py", "bundle-import.json"),
493            // The maintainer's own bot config, values blanked (Phase 24.1 acceptance).
494            ("bundle-discord-setup.py", "bundle-discord-setup.json"),
495        ] {
496            let src = std::fs::read_to_string(format!("{root}{src}")).unwrap();
497            let gold: serde_json::Value =
498                serde_json::from_str(&std::fs::read_to_string(format!("{root}{gold}")).unwrap())
499                    .unwrap();
500            let got = serde_json::to_value(import_python_config(&src)).unwrap();
501            assert_eq!(got, gold);
502        }
503    }
504
505    #[test]
506    fn large_ids_stay_strings_and_nothing_is_executed() {
507        let r = import_python_config("owner = '708766134927442001'\nx = len(owner)\n");
508        assert_eq!(r.vars[0].kind, "large_id");
509        assert_eq!(r.vars[1].value, "len(owner)");
510        assert!(r.warnings[0].contains("not a supported literal"));
511    }
512}