#![allow(dead_code)]
use std::collections::BTreeMap;
pub struct Template {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
pub logo: &'static str,
pub default_port: u16,
pub compose: &'static str,
pub variables: &'static [Var],
pub env: &'static [Kv],
pub domains: &'static [Domain],
pub mounts: &'static [Mount],
}
pub struct Var {
pub key: &'static str,
pub raw: &'static str,
}
pub struct Kv {
pub key: &'static str,
pub raw: &'static str,
}
pub struct Domain {
pub service_name: &'static str,
pub port: u16,
pub host: &'static str,
pub path: &'static str,
}
pub struct Mount {
pub file_path: &'static str,
pub content: &'static str,
}
impl std::fmt::Debug for Template {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Template({})", self.id)
}
}
include!(concat!(env!("OUT_DIR"), "/templates_catalog.rs"));
pub fn all() -> &'static [Template] {
TEMPLATES
}
pub fn find(id: &str) -> Option<&'static Template> {
TEMPLATES.iter().find(|t| t.id == id)
}
pub fn filtered(search: &str) -> Vec<&'static Template> {
let search = search.to_lowercase();
let mut out: Vec<_> = TEMPLATES
.iter()
.filter(|t| {
search.is_empty()
|| t.name.to_lowercase().contains(&search)
|| t.description.to_lowercase().contains(&search)
|| t.id.contains(&search)
})
.collect();
out.sort_by_key(|a| a.name.to_lowercase());
out
}
pub fn editable_vars(t: &'static Template) -> Vec<&'static Var> {
t.variables
.iter()
.filter(|v| matches!(parse_gen(v.raw.trim()), Some(Gen::Domain)))
.collect()
}
#[derive(Debug, Default)]
pub struct Rendered {
pub compose: String,
pub env: Vec<(String, String)>,
pub domain: Option<String>,
pub port: u16,
pub mounts: Vec<(String, String)>,
}
pub fn render(t: &Template, user: &[(String, String)]) -> Rendered {
let mut rng = Rng::new();
let resolved = resolve_vars(t, user, &mut rng);
let env = t
.env
.iter()
.map(|kv| (kv.key.to_string(), substitute(kv.raw, &resolved, &mut rng)))
.collect();
let domain = t
.domains
.first()
.map(|d| substitute(d.host, &resolved, &mut rng));
let mounts = t
.mounts
.iter()
.map(|m| {
(
m.file_path.to_string(),
substitute_mode(m.content, &resolved, &mut rng, Unknown::Keep),
)
})
.collect();
Rendered {
compose: t.compose.to_string(),
env,
domain,
port: t.default_port,
mounts,
}
}
fn resolve_vars(
t: &Template,
user: &[(String, String)],
rng: &mut Rng,
) -> BTreeMap<String, String> {
let mut resolved: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in user {
if !v.trim().is_empty() {
resolved.insert(k.clone(), v.clone());
}
}
for _ in 0..=t.variables.len() {
let mut progressed = false;
for var in t.variables {
if resolved.contains_key(var.key) {
continue;
}
if let Some(val) = try_resolve(var.raw.trim(), &resolved, rng) {
resolved.insert(var.key.to_string(), val);
progressed = true;
}
}
if !progressed {
break;
}
}
for var in t.variables {
resolved
.entry(var.key.to_string())
.or_insert_with(|| substitute(var.raw.trim(), &BTreeMap::new(), rng));
}
resolved
}
fn try_resolve(raw: &str, resolved: &BTreeMap<String, String>, rng: &mut Rng) -> Option<String> {
if let Some(inner) = single_token(raw) {
if let Some(g) = parse_gen(inner) {
if !g.refs_available(resolved) {
return None;
}
return Some(g.generate(rng, resolved));
}
return resolved.get(inner).cloned();
}
if all_refs_available(raw, resolved) {
return Some(substitute(raw, resolved, rng));
}
None
}
fn single_token(s: &str) -> Option<&str> {
let inner = s.strip_prefix("${")?.strip_suffix('}')?;
if inner.contains("${") || inner.contains('}') {
return None;
}
Some(inner)
}
fn all_refs_available(s: &str, resolved: &BTreeMap<String, String>) -> bool {
for inner in tokens(s) {
match parse_gen(inner) {
Some(g) => {
if !g.refs_available(resolved) {
return false;
}
}
None => {
if !resolved.contains_key(inner) {
return false;
}
}
}
}
true
}
#[derive(Clone, Copy, PartialEq)]
enum Unknown {
Drop,
Keep,
}
fn substitute(s: &str, resolved: &BTreeMap<String, String>, rng: &mut Rng) -> String {
substitute_mode(s, resolved, rng, Unknown::Drop)
}
fn substitute_mode(
s: &str,
resolved: &BTreeMap<String, String>,
rng: &mut Rng,
unknown: Unknown,
) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
match after.find('}') {
Some(end) => {
let inner = &after[..end];
if let Some(val) = resolved.get(inner) {
out.push_str(val);
} else if let Some(g) = parse_gen(inner) {
out.push_str(&g.generate(rng, resolved));
} else if unknown == Unknown::Keep {
out.push_str("${");
out.push_str(inner);
out.push('}');
}
rest = &after[end + 1..];
}
None => {
out.push_str(&rest[start..]);
return out;
}
}
}
out.push_str(rest);
out
}
fn tokens(s: &str) -> impl Iterator<Item = &str> {
let mut rest = s;
std::iter::from_fn(move || {
let start = rest.find("${")?;
let after = &rest[start + 2..];
let end = after.find('}')?;
let tok = &after[..end];
rest = &after[end + 1..];
Some(tok)
})
}
#[derive(PartialEq)]
enum Gen<'a> {
Domain,
Password(usize),
Base64(usize),
Jwt(usize),
Hash(usize),
Email,
Username,
Uuid,
Timestamp,
Timestamps(&'a str),
Timezone,
JwtSigned {
secret: &'a str,
payload: &'a str,
},
}
fn parse_gen(inner: &str) -> Option<Gen<'_>> {
let (name, arg) = match inner.split_once(':') {
Some((n, a)) => (n, Some(a)),
None => (inner, None),
};
let num = |default: usize| arg.and_then(|a| a.parse::<usize>().ok()).unwrap_or(default);
Some(match name {
"domain" => Gen::Domain,
"password" => Gen::Password(num(16)),
"base64" => Gen::Base64(num(32)),
"jwt" => match arg.and_then(|a| a.split_once(':')) {
Some((secret, payload)) => Gen::JwtSigned { secret, payload },
None => Gen::Jwt(num(32)),
},
"hash" => Gen::Hash(num(8)),
"email" => Gen::Email,
"username" => Gen::Username,
"uuid" => Gen::Uuid,
"timestamp" => Gen::Timestamp,
"timestamps" => Gen::Timestamps(arg?),
"timezone" => Gen::Timezone,
_ => return None,
})
}
impl Gen<'_> {
fn refs_available(&self, resolved: &BTreeMap<String, String>) -> bool {
match self {
Gen::JwtSigned { secret, payload } => {
resolved.contains_key(*secret) && resolved.contains_key(*payload)
}
_ => true,
}
}
fn generate(&self, rng: &mut Rng, resolved: &BTreeMap<String, String>) -> String {
match self {
Gen::Domain => format!("app-{}.example.com", rng.hex(4)),
Gen::Password(n) => rng.password(*n),
Gen::Base64(n) => base64(&rng.bytes(*n)),
Gen::Jwt(n) => rng.hex(*n),
Gen::Hash(n) => rng.hex(*n),
Gen::Email => format!("admin-{}@example.com", rng.hex(4)),
Gen::Username => format!("admin{}", rng.hex(2)),
Gen::Uuid => rng.uuid_v4(),
Gen::Timestamp => std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.to_string(),
Gen::Timestamps(iso) => chrono::DateTime::parse_from_rfc3339(iso)
.map(|d| d.timestamp().to_string())
.unwrap_or_default(),
Gen::Timezone => "UTC".to_string(),
Gen::JwtSigned { secret, payload } => {
let get = |k: &str| resolved.get(k).map(String::as_str).unwrap_or_default();
jwt_hs256(get(secret), get(payload))
}
}
}
}
fn jwt_hs256(secret: &str, payload_json: &str) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
const HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
let signing_input = format!(
"{}.{}",
base64_url(HEADER.as_bytes()),
base64_url(payload_json.trim().as_bytes())
);
let mut mac = <Hmac<Sha256>>::new_from_slice(secret.as_bytes())
.expect("HMAC aceita chave de qualquer tamanho");
mac.update(signing_input.as_bytes());
format!(
"{signing_input}.{}",
base64_url(&mac.finalize().into_bytes())
)
}
struct Rng {
state: u64,
}
impl Rng {
fn new() -> Self {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9e3779b97f4a7c15);
Self {
state: seed ^ 0x9e3779b97f4a7c15,
}
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9e3779b97f4a7c15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
z ^ (z >> 31)
}
fn bytes(&mut self, n: usize) -> Vec<u8> {
(0..n).map(|_| (self.next_u64() & 0xff) as u8).collect()
}
fn hex(&mut self, n: usize) -> String {
self.bytes(n).iter().map(|b| format!("{b:02x}")).collect()
}
fn uuid_v4(&mut self) -> String {
let mut b = self.bytes(16);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let hex = |s: &[u8]| s.iter().map(|x| format!("{x:02x}")).collect::<String>();
format!(
"{}-{}-{}-{}-{}",
hex(&b[0..4]),
hex(&b[4..6]),
hex(&b[6..8]),
hex(&b[8..10]),
hex(&b[10..16])
)
}
fn password(&mut self, n: usize) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
(0..n)
.map(|_| ALPHABET[(self.next_u64() % ALPHABET.len() as u64) as usize] as char)
.collect()
}
}
fn base64(data: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
base64_with(data, T, true)
}
fn base64_url(data: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
base64_with(data, T, false)
}
fn base64_with(data: &[u8], table: &[u8], pad: bool) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
out.push(table[(n >> 18 & 63) as usize] as char);
out.push(table[(n >> 12 & 63) as usize] as char);
for (i, shift) in [(1usize, 6u32), (2, 0)] {
if chunk.len() > i {
out.push(table[(n >> shift & 63) as usize] as char);
} else if pad {
out.push('=');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_nonempty_and_unique() {
assert!(
all().len() > 100,
"catálogo deveria ter centenas de templates"
);
let mut ids: Vec<_> = all().iter().map(|t| t.id).collect();
ids.sort();
let before = ids.len();
ids.dedup();
assert_eq!(before, ids.len(), "ids duplicados no catálogo");
}
#[test]
fn wordpress_resolves_env_and_domain() {
let t = find("wordpress").expect("wordpress no catálogo");
let user = vec![("main_domain".to_string(), "wp.test.com".to_string())];
let r = render(t, &user);
let get = |k: &str| r.env.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
assert_eq!(get("DB_NAME").as_deref(), Some("wordpress"));
let pw = get("DB_PASSWORD").unwrap();
assert_eq!(pw.len(), 32);
assert!(!pw.contains("${"));
assert_eq!(r.domain.as_deref(), Some("wp.test.com"));
assert!(!r.compose.is_empty());
}
#[test]
fn literal_dollar_is_not_treated_as_token() {
let mut rng = Rng::new();
let map = BTreeMap::new();
let s = "$argon2id$v=19$m=65536$abc";
assert_eq!(substitute(s, &map, &mut rng), s);
}
#[test]
fn supabase_renders_signed_keys_and_pooler_uuid() {
let t = find("supabase").expect("supabase no catálogo");
let user = vec![("main_domain".to_string(), "sb.test.com".to_string())];
let r = render(t, &user);
let get = |k: &str| {
r.env
.iter()
.find(|(kk, _)| kk == k)
.map(|(_, v)| v.clone())
.unwrap_or_else(|| panic!("{k} ausente no env renderizado"))
};
let secret = get("JWT_SECRET");
for (key, role) in [("ANON_KEY", "anon"), ("SERVICE_ROLE_KEY", "service_role")] {
let token = get(key);
let parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3, "{key} não é um JWS compacto: {token}");
let expected = jwt_hs256(
&secret,
&String::from_utf8(b64url_decode(parts[1])).unwrap(),
);
assert_eq!(token, expected, "assinatura de {key} não confere");
let payload = String::from_utf8(b64url_decode(parts[1])).unwrap();
assert!(payload.contains(role), "{key} sem role {role}: {payload}");
let exp: i64 = payload
.split("\"exp\":")
.nth(1)
.and_then(|s| s.trim().trim_end_matches(['}', '\n', ' ']).parse().ok())
.unwrap_or_else(|| panic!("exp não numérico em {key}: {payload}"));
assert_eq!(exp, 1_893_456_000, "exp deveria ser 2030-01-01T00:00:00Z");
}
let tenant = get("POOLER_TENANT_ID");
assert_eq!(tenant.len(), 36, "tenant id não é um uuid: {tenant}");
assert_eq!(tenant.chars().filter(|c| *c == '-').count(), 4);
assert_eq!(
&tenant[14..15],
"4",
"versão do uuid deveria ser 4: {tenant}"
);
assert_eq!(get("REALTIME_DB_ENC_KEY").len(), 16);
assert_eq!(get("SUPABASE_PUBLIC_URL"), "https://sb.test.com");
assert_eq!(get("API_EXTERNAL_URL"), "https://sb.test.com/auth/v1");
assert_eq!(r.domain.as_deref(), Some("sb.test.com"));
let mount = |p: &str| {
r.mounts
.iter()
.find(|(path, _)| path == p)
.map(|(_, c)| c.clone())
.unwrap_or_else(|| panic!("mount {p} ausente"))
};
assert!(mount("/volumes/api/kong-entrypoint.sh").contains(r"\$((headers.authorization"));
assert!(mount("/volumes/api/kong.yml").contains("SUPABASE_PUBLISHABLE_KEY"));
}
#[test]
fn unknown_tokens_survive_in_mounts_but_not_in_env() {
let mut rng = Rng::new();
let map = BTreeMap::new();
assert_eq!(substitute("x=${nope}!", &map, &mut rng), "x=!");
assert_eq!(
substitute_mode("x=${nope}!", &map, &mut rng, Unknown::Keep),
"x=${nope}!"
);
let t = find("supabase").expect("supabase no catálogo");
let r = render(t, &[]);
let (_, vector) = r
.mounts
.iter()
.find(|(p, _)| p == "/volumes/logs/vector.yml")
.expect("vector.yml nos mounts");
assert!(
vector.contains("${LOGFLARE_PUBLIC_ACCESS_TOKEN?"),
"token do vector perdido"
);
}
fn b64url_decode(s: &str) -> Vec<u8> {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut bits = 0u32;
let mut nbits = 0u32;
let mut out = Vec::new();
for c in s.bytes() {
let v = T.iter().position(|t| *t == c).expect("char base64url") as u32;
bits = (bits << 6) | v;
nbits += 6;
if nbits >= 8 {
nbits -= 8;
out.push((bits >> nbits) as u8);
}
}
out
}
#[test]
fn editable_vars_are_only_domains() {
let t = find("wordpress").expect("wordpress");
let ed = editable_vars(t);
assert!(ed.iter().all(|v| v.key.contains("domain")));
}
}