1use std::collections::{BTreeMap, BTreeSet};
14
15use serde_yaml_ng::Value as Y;
16
17use super::super::shared::{Tx, pairs, secretish, var_name, yget, yscalar};
18use super::super::{JwtSpec, VarKind, Variable};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Gen {
23 Password {
25 len: u32,
26 symbols: bool,
27 },
28 User,
29 Alnum(u32),
31 RealBase64(u32),
33 Hex(u32),
35 Jwt(&'static str),
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Magic {
42 Url {
43 name: String,
44 port: Option<u16>,
45 },
46 Fqdn {
47 name: String,
48 port: Option<u16>,
49 },
50 Name(String),
52 Gen(Gen),
53}
54
55fn split_port(s: &str) -> (String, Option<u16>) {
57 if let Some((name, port)) = s.rsplit_once('_') {
58 if let Ok(p) = port.parse::<u16>() {
59 if p > 0 && !name.is_empty() {
60 return (name.to_string(), Some(p));
61 }
62 }
63 }
64 (s.to_string(), None)
65}
66
67fn sized<'a>(tail: &'a str, sizes: &[u32], default: u32) -> (u32, &'a str) {
70 for n in sizes {
71 if let Some(rest) = tail.strip_prefix(&format!("{n}_")) {
72 if !rest.is_empty() {
73 return (*n, rest);
74 }
75 }
76 }
77 (default, tail)
78}
79
80pub fn classify(token: &str) -> Option<Magic> {
83 let rest = token.strip_prefix("SERVICE_")?;
84 if !rest
85 .chars()
86 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
87 {
88 return None;
89 }
90 let (kind, tail) = rest.split_once('_')?;
91 if tail.is_empty() {
92 return None;
93 }
94 Some(match kind {
95 "URL" => {
96 let (name, port) = split_port(tail);
97 Magic::Url { name, port }
98 }
99 "FQDN" => {
100 let (name, port) = split_port(tail);
101 Magic::Fqdn { name, port }
102 }
103 "NAME" => Magic::Name(tail.to_string()),
104 "USER" | "LOWERCASEUSER" => Magic::Gen(Gen::User),
105 "PASSWORD" | "PASSWORDWITHSYMBOLS" => Magic::Gen(Gen::Password {
106 len: sized(tail, &[64], 32).0,
107 symbols: kind == "PASSWORDWITHSYMBOLS",
108 }),
109 "BASE64" => Magic::Gen(Gen::Alnum(sized(tail, &[32, 64, 128], 32).0)),
110 "REALBASE64" => Magic::Gen(Gen::RealBase64(sized(tail, &[32, 64, 128], 32).0)),
111 "HEX" => {
112 let (n, id) = sized(tail, &[32, 64, 128], 0);
113 if n == 0 || id.is_empty() {
114 return None;
115 }
116 Magic::Gen(Gen::Hex(n))
117 }
118 "SUPABASEANON" => Magic::Gen(Gen::Jwt("anon")),
119 "SUPABASESERVICE" => Magic::Gen(Gen::Jwt("service_role")),
120 _ => return None,
121 })
122}
123
124pub fn normalized(service: &str) -> String {
126 service
127 .to_ascii_uppercase()
128 .chars()
129 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
130 .collect()
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct Decl {
137 pub service: String,
139 pub name: String,
140 pub port: Option<u16>,
141 pub path: Option<String>,
143}
144
145pub fn scan(v: &Y, tokens: &mut BTreeSet<String>, refs: &mut BTreeSet<String>) {
148 match v {
149 Y::String(s) => scan_text(s, tokens, refs),
150 Y::Sequence(s) => s.iter().for_each(|x| scan(x, tokens, refs)),
151 Y::Mapping(m) => {
152 for (k, x) in m {
153 if let Some(k) = k.as_str() {
154 scan_text(k, tokens, refs);
155 if k == "content" {
156 if let Some(s) = x.as_str() {
158 scan_text(s, tokens, &mut BTreeSet::new());
159 }
160 continue;
161 }
162 }
163 scan(x, tokens, refs);
164 }
165 }
166 _ => {}
167 }
168}
169
170fn name_at(s: &str, from: usize) -> &str {
171 let rest = &s[from..];
172 let end = rest
173 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
174 .unwrap_or(rest.len());
175 &rest[..end]
176}
177
178fn scan_text(s: &str, tokens: &mut BTreeSet<String>, refs: &mut BTreeSet<String>) {
179 let b = s.as_bytes();
180 let mut i = 0;
181 while i < b.len() {
182 let word_start = i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_');
184 if word_start && s[i..].starts_with("SERVICE_") {
185 let t = name_at(s, i);
186 if classify(t).is_some() {
187 tokens.insert(t.to_string());
188 }
189 i += t.len().max(1);
190 continue;
191 }
192 if b[i] == b'$' && b.get(i + 1) == Some(&b'$') {
193 i += 2;
194 continue;
195 }
196 if b[i] == b'$' {
197 let from = if b.get(i + 1) == Some(&b'{') {
198 i + 2
199 } else {
200 i + 1
201 };
202 let n = name_at(s, from);
203 if n.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
204 refs.insert(n.to_string());
205 }
206 }
207 i += 1;
208 while !s.is_char_boundary(i) {
209 i += 1;
210 }
211 }
212}
213
214pub fn declarations(
216 services: &serde_yaml_ng::Mapping,
217 skipped: &dyn Fn(&str) -> bool,
218) -> Vec<Decl> {
219 let mut out = Vec::new();
220 for (name, s) in services {
221 let Some(name) = yscalar(name) else { continue };
222 if skipped(&name) {
223 continue;
224 }
225 let Some(env) = s.as_mapping().and_then(|m| yget(m, "environment")) else {
226 continue;
227 };
228 for (k, v) in pairs(env) {
229 let (name_, port) = match classify(&k) {
230 Some(Magic::Url { name, port } | Magic::Fqdn { name, port }) => (name, port),
231 _ => continue,
232 };
233 let v = v.unwrap_or_default();
234 if !(v.is_empty() || v.starts_with('/')) {
237 continue;
238 }
239 let path = (v.len() > 1).then_some(v);
240 out.push(Decl {
241 service: name.clone(),
242 name: name_,
243 port,
244 path,
245 });
246 }
247 }
248 out
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum Mode {
255 Compose,
256 Content,
257}
258
259pub struct Cx<'a> {
261 pub keys: &'a BTreeMap<String, String>,
263 pub aliases: &'a [(String, String)],
265 pub port_hint: Option<u16>,
267 by_norm: BTreeMap<String, String>,
269 pub decls: Vec<Decl>,
270 hosts: BTreeMap<String, String>,
272 known: BTreeSet<String>,
274}
275
276impl<'a> Cx<'a> {
277 pub fn new(
280 doc: &Y,
281 services: &serde_yaml_ng::Mapping,
282 names: (&'a BTreeMap<String, String>, &'a [(String, String)]),
283 port_hint: Option<u16>,
284 tx: &mut Tx,
285 ) -> Cx<'a> {
286 let (keys, aliases) = names;
287 let by_norm: BTreeMap<String, String> =
288 keys.keys().map(|s| (normalized(s), s.clone())).collect();
289 let mut decls = declarations(services, &|s| !keys.contains_key(s));
290 let (mut tokens, mut known) = (BTreeSet::new(), BTreeSet::new());
291 scan(doc, &mut tokens, &mut known);
292 known.retain(|n| classify(n).is_none());
293 for t in tokens {
294 let (name, port) = match classify(&t) {
295 Some(Magic::Url { name, port } | Magic::Fqdn { name, port }) => (name, port),
296 _ => continue,
297 };
298 if decls.iter().any(|d| d.name == name) {
299 continue;
300 }
301 let owner = by_norm.get(&name).map(|s| (s, port)).or_else(|| {
303 by_norm
304 .get(&format!("{name}_{}", port.unwrap_or(0)))
305 .map(|s| (s, None))
306 });
307 if let Some((svc, port)) = owner {
308 decls.push(Decl {
309 service: svc.clone(),
310 name,
311 port,
312 path: None,
313 });
314 }
315 }
316 let mut hosts = BTreeMap::new();
317 for d in &decls {
318 if !hosts.contains_key(&d.name) {
319 let n = tx.fresh_name(&format!("domain_{}", d.name));
320 tx.vars.push(Variable {
321 name: n.clone(),
322 kind: VarKind::Domain,
323 ..Default::default()
324 });
325 hosts.insert(d.name.clone(), n);
326 }
327 }
328 Cx {
329 keys,
330 aliases,
331 port_hint,
332 by_norm,
333 decls,
334 hosts,
335 known,
336 }
337 }
338
339 pub fn host_var(&self, name: &str) -> Option<&str> {
341 self.hosts.get(name).map(String::as_str)
342 }
343
344 fn path_of(&self, name: &str, port: Option<u16>) -> Option<&str> {
346 self.decls
347 .iter()
348 .find(|d| d.name == name && d.port == port && d.path.is_some())
349 .and_then(|d| d.path.as_deref())
350 }
351
352 pub fn var(&self, name: &str, tx: &mut Tx) -> String {
354 if let Some(n) = tx.names.get(name) {
355 return n.clone();
356 }
357 let n = tx.fresh_name(name);
358 tx.names.insert(name.to_string(), n.clone());
359 tx.vars.push(Variable {
360 name: n.clone(),
361 ..Default::default()
362 });
363 n
364 }
365
366 fn generated(&self, token: &str, g: &Gen, tx: &mut Tx) -> String {
368 if let Some(n) = tx.names.get(token) {
369 return n.clone();
370 }
371 let secret_var = match g {
372 Gen::Jwt(_) => Some(self.generated(
373 "SERVICE_PASSWORD_JWT",
374 &Gen::Password {
375 len: 32,
376 symbols: false,
377 },
378 tx,
379 )),
380 _ => None,
381 };
382 let n = tx.fresh_name(&var_name(token.trim_start_matches("SERVICE_")));
383 let mut v = Variable {
384 name: n.clone(),
385 ..Default::default()
386 };
387 match g {
388 Gen::Password { len, symbols } => {
389 v.kind = VarKind::Password;
390 v.length = Some(*len);
391 if *symbols {
392 tx.note(format!("{token} has no symbols (letters and digits only)"));
393 }
394 }
395 Gen::User => {
396 v.kind = VarKind::Username;
397 v.length = Some(16);
398 }
399 Gen::Alnum(len) => {
400 v.kind = VarKind::Password;
401 v.length = Some(*len);
402 }
403 Gen::RealBase64(bytes) => {
404 v.kind = VarKind::Base64;
405 v.bytes = Some(*bytes);
406 }
407 Gen::Hex(chars) => {
408 v.kind = VarKind::Hex;
409 v.bytes = Some(chars / 2);
410 v.secret = Some(true);
411 }
412 Gen::Jwt(role) => {
413 v.kind = VarKind::Jwt;
414 v.jwt = Some(JwtSpec {
415 secret: secret_var.unwrap_or_default(),
416 payload: Some(format!("{{\"role\":\"{role}\",\"iss\":\"supabase\"}}")),
417 });
418 }
419 }
420 tx.names.insert(token.to_string(), n.clone());
421 tx.vars.push(v);
422 n
423 }
424
425 pub fn magic(&self, token: &str, m: &Magic, tx: &mut Tx) -> String {
427 match m {
428 Magic::Url { name, port } | Magic::Fqdn { name, port } => {
429 let Some(h) = self.host_var(name) else {
430 tx.refuse(format!("{token}: no service declares or is named {name}"));
431 return String::new();
432 };
433 if matches!(m, Magic::Fqdn { .. }) {
434 format!("${{{h}}}")
435 } else {
436 let path = self.path_of(name, *port).unwrap_or("");
437 format!("https://${{{h}}}{}", path.replace('$', "$$"))
438 }
439 }
440 Magic::Name(n) => match self.by_norm.get(n).and_then(|s| self.keys.get(s)) {
441 Some(k) => format!("${{host:{k}}}"),
442 None => {
443 tx.refuse(format!("{token}: no service named {n}"));
444 String::new()
445 }
446 },
447 Magic::Gen(g) => format!("${{{}}}", self.generated(token, g, tx)),
448 }
449 }
450
451 fn set_default(&self, native: &str, d: String, tx: &mut Tx) {
453 let Some(v) = tx.vars.iter_mut().find(|v| v.name == native) else {
454 return;
455 };
456 match &v.default {
457 None => v.default = Some(d),
458 Some(e) if *e != d => {
459 let msg = format!(
460 "{native} has different defaults in different places; the first is used"
461 );
462 tx.note(msg);
463 }
464 Some(_) => {}
465 }
466 }
467
468 fn reference(&self, name: &str, op: &str, tx: &mut Tx) -> String {
470 if let Some(m) = classify(name) {
471 return self.magic(name, &m, tx);
472 }
473 let native = self.var(name, tx);
474 if let Some(d) = op.strip_prefix(":-").or_else(|| op.strip_prefix('-')) {
475 let d = self.expr(d, Mode::Compose, tx);
476 self.set_default(&native, d, tx);
477 } else if op.starts_with(":?") || op.starts_with('?') {
478 if let Some(v) = tx.vars.iter_mut().find(|v| v.name == native) {
479 v.required = Some(true);
480 }
481 } else if !op.is_empty() {
482 tx.refuse(format!("${{{name}{op}}}: conditional interpolation"));
483 }
484 format!("${{{native}}}")
485 }
486
487 fn expands_in_content(&self, name: &str) -> bool {
489 classify(name).is_some() || self.known.contains(name)
490 }
491
492 pub fn expr(&self, s: &str, mode: Mode, tx: &mut Tx) -> String {
495 let mut out = String::new();
496 let mut i = 0;
497 while i < s.len() {
498 let Some(off) = s[i..].find('$') else {
499 out.push_str(&s[i..]);
500 break;
501 };
502 out.push_str(&s[i..i + off]);
503 i += off;
504 i = self.dollar(s, i, mode, &mut out, tx);
505 }
506 out
507 }
508
509 fn dollar(&self, s: &str, i: usize, mode: Mode, out: &mut String, tx: &mut Tx) -> usize {
511 let rest = &s[i + 1..];
512 if rest.starts_with('$') {
513 out.push_str(if mode == Mode::Compose { "$$" } else { "$$$$" });
514 return i + 2;
515 }
516 if rest.starts_with('{') {
517 let Some(end) = matching_brace(s, i + 1) else {
518 if mode == Mode::Compose {
519 tx.refuse(format!("unterminated ${{ in {s:?}"));
520 }
521 out.push_str("$$");
522 return i + 1;
523 };
524 let inner = &s[i + 2..end];
525 let name = name_at(inner, 0);
526 let valid = name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_');
527 if valid && (mode == Mode::Compose || self.expands_in_content(name)) {
528 out.push_str(&self.reference(name, &inner[name.len()..], tx));
529 } else if mode == Mode::Compose {
530 tx.refuse(format!("${{{inner}}} is not a variable"));
531 } else {
532 out.push_str(&s[i..=end].replace('$', "$$"));
533 }
534 return end + 1;
535 }
536 let name = name_at(s, i + 1);
537 let valid = name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_');
538 if valid && (mode == Mode::Compose || classify(name).is_some()) {
539 out.push_str(&self.reference(name, "", tx));
540 return i + 1 + name.len();
541 }
542 out.push_str("$$");
543 i + 1
544 }
545
546 pub fn bare(&self, name: &str, tx: &mut Tx) -> String {
549 self.reference(name, "", tx)
550 }
551
552 pub fn finish(&self, tx: &mut Tx) {
555 let names: BTreeMap<String, String> = tx
556 .names
557 .iter()
558 .map(|(orig, native)| (native.clone(), orig.clone()))
559 .collect();
560 for v in &mut tx.vars {
561 if v.kind != VarKind::String {
562 continue;
563 }
564 if v.default.is_none() && v.required != Some(true) {
565 v.default = Some(String::new());
566 }
567 if v.default.is_some() {
568 v.required = None;
569 }
570 let literal = v
571 .default
572 .as_deref()
573 .is_some_and(|d| !d.is_empty() && !d.contains("${"));
574 if literal && names.get(&v.name).is_some_and(|o| secretish(o)) {
575 v.secret = Some(true);
576 }
577 }
578 }
579}
580
581fn matching_brace(s: &str, open: usize) -> Option<usize> {
583 let mut depth = 0;
584 for (j, c) in s[open..].char_indices() {
585 match c {
586 '{' => depth += 1,
587 '}' => {
588 depth -= 1;
589 if depth == 0 {
590 return Some(open + j);
591 }
592 }
593 _ => {}
594 }
595 }
596 None
597}