diff --git a/Cargo.lock b/Cargo.lock index 6270afd..4ee42d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3535,6 +3535,7 @@ dependencies = [ "oauth2", "once_cell", "openssl", + "percent-encoding", "postgres", "rand 0.9.2", "redis", diff --git a/Cargo.toml b/Cargo.toml index d1de917..642381d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ testcontainers = "0.27.1" testcontainers-modules = { version = "0.15.0", features = ["postgres", "redis", "valkey", "mysql", "mariadb", "mongo"] } postgres = "0.19.12" url = "2.5.8" +percent-encoding = "2.3.2" bollard = "0.20.0" [dev-dependencies] diff --git a/docker-compose.yml b/docker-compose.yml index b90bec9..8764d2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: LOG: debug TZ: "Europe/Paris" # TMPDIR: /scratch - EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiODY1Mjk2NDgtYmQ0Zi00MWMxLWFmNDItNGM1MzE3ZDEzY2JhIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" + EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMGNlNjVjMDQtMDJiOS00YjMzLTk5MzYtMzIzYTFhOGU4MTk3IiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ==" #CHUNK_SIZE_MB: "1" #POOLING: 1 #DATABASES_CONFIG_FILE: "config.toml" diff --git a/src/domain/mongodb/connection.rs b/src/domain/mongodb/connection.rs index 912f140..d33efed 100644 --- a/src/domain/mongodb/connection.rs +++ b/src/domain/mongodb/connection.rs @@ -1,6 +1,13 @@ use crate::services::config::DatabaseConfig; use anyhow::Result; use mongodb::Client; +use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; + +const USERINFO_ENCODE: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~'); pub async fn connect(cfg: DatabaseConfig) -> Result { let uri = get_mongo_uri(cfg)?; @@ -16,19 +23,40 @@ pub fn select_mongo_path() -> std::path::PathBuf { } pub fn get_mongo_uri(cfg: DatabaseConfig) -> Result { - if cfg.username.is_empty() || cfg.password.is_empty() { - Ok(format!( - "mongodb://{}:{}/{}", - cfg.host, cfg.port, cfg.database - )) - } else { - Ok(format!( - "mongodb://{}:{}@{}:{}/{}?authSource=admin", - cfg.username, cfg.password, cfg.host, cfg.port, cfg.database - )) - } + Ok(build_mongo_uri(&cfg, true)) } +pub fn build_mongo_uri(cfg: &DatabaseConfig, include_db: bool) -> String { + let is_srv = cfg.port == 0; + let scheme = if is_srv { "mongodb+srv" } else { "mongodb" }; + let has_auth = !cfg.username.is_empty() && !cfg.password.is_empty(); + + let credentials = if has_auth { + format!( + "{}:{}@", + utf8_percent_encode(&cfg.username, USERINFO_ENCODE), + utf8_percent_encode(&cfg.password, USERINFO_ENCODE) + ) + } else { + String::new() + }; + + let authority = if is_srv { + cfg.host.clone() + } else { + format!("{}:{}", cfg.host, cfg.port) + }; + + let path = if include_db { + format!("/{}", cfg.database) + } else { + "/".to_string() + }; + + let query = if has_auth { "?authSource=admin" } else { "" }; + + format!("{}://{}{}{}{}", scheme, credentials, authority, path, query) +} pub fn extract_db_name(dry_output: &str) -> Option { let mut dbs = std::collections::HashSet::new(); @@ -43,3 +71,70 @@ pub fn extract_db_name(dry_output: &str) -> Option { } dbs.into_iter().next() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::config::{DatabaseConfig, DbType}; + use std::collections::HashMap; + + fn cfg(host: &str, port: u16, user: &str, pass: &str) -> DatabaseConfig { + DatabaseConfig { + name: "t".into(), + database: "mydb".into(), + db_type: DbType::MongoDB, + username: user.into(), + password: pass.into(), + port, + host: host.into(), + generated_id: "id".into(), + path: String::new(), + max_packet_size: String::new(), + volume_name: String::new(), + container_name: None, + options: HashMap::new(), + } + } + + #[test] + fn standard_with_auth() { + let c = cfg("localhost", 27017, "user", "pass"); + assert_eq!( + build_mongo_uri(&c, true), + "mongodb://user:pass@localhost:27017/mydb?authSource=admin" + ); + } + + #[test] + fn standard_no_auth() { + let c = cfg("localhost", 27017, "", ""); + assert_eq!(build_mongo_uri(&c, true), "mongodb://localhost:27017/mydb"); + } + + #[test] + fn srv_with_auth() { + let c = cfg("cluster.example.mongodb.net", 0, "user", "pass"); + assert_eq!( + build_mongo_uri(&c, true), + "mongodb+srv://user:pass@cluster.example.mongodb.net/mydb?authSource=admin" + ); + } + + #[test] + fn srv_no_db_for_dryrun() { + let c = cfg("cluster.example.mongodb.net", 0, "user", "pass"); + assert_eq!( + build_mongo_uri(&c, false), + "mongodb+srv://user:pass@cluster.example.mongodb.net/?authSource=admin" + ); + } + + #[test] + fn encodes_special_chars_in_credentials() { + let c = cfg("cluster.example.mongodb.net", 0, "user", "p@ss:w/rd?"); + assert_eq!( + build_mongo_uri(&c, true), + "mongodb+srv://user:p%40ss%3Aw%2Frd%3F@cluster.example.mongodb.net/mydb?authSource=admin" + ); + } +} diff --git a/src/domain/mongodb/ping.rs b/src/domain/mongodb/ping.rs index f70e9ef..0b42908 100644 --- a/src/domain/mongodb/ping.rs +++ b/src/domain/mongodb/ping.rs @@ -19,7 +19,11 @@ pub async fn run(cfg: DatabaseConfig) -> Result { Ok(_) => Ok(true), Err(e) => { error!("--- MongoDB Connection Error Details ---"); - error!("Target Host: {}:{}", cfg.host, cfg.port); + if cfg.port == 0 { + error!("Target Host: {} (srv)", cfg.host); + } else { + error!("Target Host: {}:{}", cfg.host, cfg.port); + } error!("Error Kind: {:?}", e.kind); error!("Full Error: {}", e); error!("Check you database network connectivity"); diff --git a/src/domain/mongodb/restore.rs b/src/domain/mongodb/restore.rs index 627aa4d..06119a9 100644 --- a/src/domain/mongodb/restore.rs +++ b/src/domain/mongodb/restore.rs @@ -1,4 +1,6 @@ -use crate::domain::mongodb::connection::{extract_db_name, get_mongo_uri, select_mongo_path}; +use crate::domain::mongodb::connection::{ + build_mongo_uri, extract_db_name, get_mongo_uri, select_mongo_path, +}; use crate::services::backup::logger::JobLogger; use crate::services::config::DatabaseConfig; use anyhow::{Context, Result}; @@ -16,13 +18,7 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc required(&db.port, &db.name, "port")?, - DbType::Sqlite | DbType::DockerVolume => db.port.unwrap_or(0), + DbType::MongoDB | DbType::Sqlite | DbType::DockerVolume => db.port.unwrap_or(0), }; let database_name = match db.db_type {