1use 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 #[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
38fn 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
96fn 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
186fn 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 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
227fn 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
249fn 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; }
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; }
267 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
291fn 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
302fn 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 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 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 ("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}