#![allow(dead_code)]
pub fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
pub fn scheme(db_kind: Option<&str>) -> Option<&'static str> {
match db_kind.map(str::to_ascii_lowercase).as_deref() {
Some("postgres" | "postgresql") => Some("postgresql"),
Some("mysql" | "mariadb") => Some("mysql"),
Some("redis") => Some("redis"),
Some("mongodb" | "mongo") => Some("mongodb"),
Some("rabbitmq") => Some("amqp"),
Some("nats") => Some("nats"),
Some("kafka") => None,
_ => Some("http"),
}
}
#[derive(Debug, Default, Clone)]
pub struct ConnTarget<'a> {
pub host: &'a str,
pub port: u16,
pub database: Option<&'a str>,
pub user: Option<&'a str>,
pub password: Option<&'a str>,
pub auth_source: Option<&'a str>,
}
fn non_empty(s: Option<&str>) -> Option<&str> {
s.filter(|v| !v.is_empty())
}
pub fn connection_url(db_kind: Option<&str>, t: &ConnTarget<'_>) -> String {
let hp = format!("{}:{}", t.host, t.port);
let Some(scheme) = scheme(db_kind) else {
return hp;
};
let (user, pass) = (non_empty(t.user), non_empty(t.password));
let userinfo = match (user, pass) {
(None, None) => String::new(),
(Some(u), None) => format!("{}@", pct_encode(u)),
(u, Some(p)) => format!("{}:{}@", pct_encode(u.unwrap_or("")), pct_encode(p)),
};
let mut url = format!("{scheme}://{userinfo}{hp}");
let mongo = scheme == "mongodb";
match non_empty(t.database) {
Some(d) => {
url.push('/');
url.push_str(d);
}
None if mongo => url.push('/'),
None => {}
}
if mongo && user.is_some() {
if let Some(src) = non_empty(t.auth_source).or(non_empty(t.database)) {
url.push_str("?authSource=");
url.push_str(src);
} else {
url.push_str("?authSource=admin");
}
}
url
}
pub fn credentials(
db_kind: Option<&str>,
get: impl Fn(&str) -> Option<String>,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<&'static str>,
) {
let g = |k: &str| get(k).filter(|v| !v.is_empty());
match db_kind.map(str::to_ascii_lowercase).as_deref() {
Some("postgres" | "postgresql") => (
g("POSTGRES_DB"),
g("POSTGRES_USER"),
g("POSTGRES_PASSWORD"),
None,
),
Some("mysql" | "mariadb") => (
g("MYSQL_DATABASE"),
g("MYSQL_USER"),
g("MYSQL_PASSWORD"),
None,
),
Some("mongodb" | "mongo") => (
None,
g("MONGO_INITDB_ROOT_USERNAME"),
g("MONGO_INITDB_ROOT_PASSWORD"),
Some("admin"),
),
Some("redis") => (None, None, g("REDIS_PASSWORD"), None),
Some("rabbitmq") => (
None,
g("RABBITMQ_DEFAULT_USER"),
g("RABBITMQ_DEFAULT_PASS"),
None,
),
_ => (None, None, None, None),
}
}
pub fn compose_services(content: &str) -> Vec<(String, Option<String>)> {
let mut out: Vec<(String, Option<String>)> = vec![];
let mut in_services = false;
let mut key_indent: Option<usize> = None;
for line in content.lines() {
let trimmed = line.trim_start();
let blank = trimmed.is_empty() || trimmed.starts_with('#');
let indent = line.len() - trimmed.len();
if !in_services {
if line.trim_end() == "services:" {
in_services = true;
}
continue;
}
if blank {
continue;
}
if indent == 0 {
break; }
let ki = *key_indent.get_or_insert(indent);
if indent == ki {
if let Some((k, _)) = trimmed.split_once(':') {
let k = k.trim().trim_matches(|c| c == '"' || c == '\'');
if !k.is_empty() {
out.push((k.to_string(), None));
}
}
} else if indent > ki
&& let Some(img) = trimmed.strip_prefix("image:")
&& let Some(last) = out.last_mut()
{
let img = img.trim().trim_matches(|c| c == '"' || c == '\'');
last.1 = Some(img.to_string());
}
}
out
}
pub fn compose_host(
content: &str,
ingress_service: Option<&str>,
db_kind: Option<&str>,
) -> Option<String> {
if let Some(i) = ingress_service.filter(|s| !s.is_empty()) {
return Some(i.to_string());
}
let services = compose_services(content);
if let [(k, _)] = services.as_slice() {
return Some(k.clone());
}
let kind = db_kind?.to_ascii_lowercase();
let needle = match kind.as_str() {
"postgresql" => "postgres",
"mongo" => "mongo",
k => k,
};
let mut matches = services
.iter()
.filter(|(_, img)| img.as_deref().is_some_and(|i| i.contains(needle)));
match (matches.next(), matches.next()) {
(Some((k, _)), None) => Some(k.clone()),
_ => None,
}
}
pub fn is_sql_or_mongo(db_kind: Option<&str>) -> bool {
matches!(
db_kind.map(str::to_ascii_lowercase).as_deref(),
Some("postgres" | "postgresql" | "mysql" | "mariadb" | "mongodb" | "mongo")
)
}
pub fn validate_shared_rules(
name: &str,
source: &shared::ServiceSource,
db_kind: Option<&str>,
shared: bool,
) -> Result<(), String> {
let reserved = |n: &str| {
n.to_ascii_lowercase()
.starts_with(shared::SHARED_ALIAS_PREFIX)
};
if reserved(name) {
return Err(format!(
"o prefixo \"{}\" é reservado para bancos compartilhados",
shared::SHARED_ALIAS_PREFIX
));
}
if let shared::ServiceSource::Compose(c) = source
&& let Some((k, _)) = compose_services(&c.content)
.iter()
.find(|(k, _)| reserved(k))
{
return Err(format!(
"o serviço Compose \"{k}\" usa o prefixo reservado \"{}\"",
shared::SHARED_ALIAS_PREFIX
));
}
if shared {
if !is_sql_or_mongo(db_kind) {
return Err(
"só Postgres, MySQL, MariaDB e MongoDB podem ser servidor compartilhado".into(),
);
}
if !matches!(source, shared::ServiceSource::Compose(_)) {
return Err("servidor compartilhado precisa ser um serviço Compose".into());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn t<'a>(h: &'a str, p: u16) -> ConnTarget<'a> {
ConnTarget {
host: h,
port: p,
..Default::default()
}
}
#[test]
fn postgres_uri_padrao_com_senha_escapada() {
let u = connection_url(
Some("postgres"),
&ConnTarget {
database: Some("app"),
user: Some("app"),
password: Some("p@ss:w/rd"),
..t("rp-shared-x-postgres", 5432)
},
);
assert_eq!(
u,
"postgresql://app:p%40ss%3Aw%2Frd@rp-shared-x-postgres:5432/app"
);
}
#[test]
fn mariadb_usa_esquema_mysql() {
let u = connection_url(
Some("mariadb"),
&ConnTarget {
user: Some("u"),
password: Some("p"),
database: Some("d"),
..t("h", 3306)
},
);
assert_eq!(u, "mysql://u:p@h:3306/d");
}
#[test]
fn mongo_auth_source_e_o_database() {
let u = connection_url(
Some("mongodb"),
&ConnTarget {
user: Some("u"),
password: Some("p"),
database: Some("proj"),
..t("h", 27017)
},
);
assert_eq!(u, "mongodb://u:p@h:27017/proj?authSource=proj");
let root = connection_url(
Some("mongodb"),
&ConnTarget {
user: Some("root"),
password: Some("p"),
auth_source: Some("admin"),
..t("h", 27017)
},
);
assert_eq!(root, "mongodb://root:p@h:27017/?authSource=admin");
}
#[test]
fn sem_credenciais_e_sem_esquema() {
assert_eq!(
connection_url(Some("postgres"), &t("h", 5432)),
"postgresql://h:5432"
);
assert_eq!(
connection_url(
Some("redis"),
&ConnTarget {
password: Some("s"),
..t("h", 6379)
}
),
"redis://:s@h:6379"
);
assert_eq!(connection_url(Some("kafka"), &t("h", 9092)), "h:9092");
assert_eq!(connection_url(None, &t("h", 80)), "http://h:80");
}
const YAML: &str = "# c\nservices:\n\n # nota\n db:\n image: postgres:18\n environment:\n A: 1\n pooler:\n image: edoburu/pgbouncer\nvolumes:\n d:\n";
#[test]
fn prefixo_reservado_e_shared_so_em_banco() {
use shared::{ComposeSource, ServiceSource};
let c = |y: &str| {
ServiceSource::Compose(ComposeSource {
content: y.into(),
ingress_service: None,
})
};
assert!(validate_shared_rules("ok", &c(YAML), Some("postgres"), true).is_ok());
assert!(validate_shared_rules("rp-shared-x", &c(YAML), None, false).is_err());
assert!(
validate_shared_rules(
"ok",
&c("services:\n rp-shared-a:\n image: x\n"),
None,
false
)
.is_err()
);
assert!(validate_shared_rules("ok", &c(YAML), Some("redis"), true).is_err());
assert!(
validate_shared_rules(
"ok",
&ServiceSource::Registry { image: "x".into() },
Some("postgres"),
true
)
.is_err()
);
}
#[test]
fn compose_host_regras() {
assert_eq!(
compose_host(YAML, Some("pooler"), Some("postgres")).as_deref(),
Some("pooler")
);
assert_eq!(
compose_host(YAML, None, Some("postgres")).as_deref(),
Some("db")
);
assert_eq!(compose_host(YAML, None, None), None);
assert_eq!(
compose_host("services:\n rp_db:\n image: x\n", None, None).as_deref(),
Some("rp_db")
);
assert_eq!(compose_host("image: x", None, None), None);
}
}