database_replicator/mongodb/mod.rs
1// ABOUTME: MongoDB database reading utilities for migration to PostgreSQL
2// ABOUTME: Provides secure connection validation and read-only database access
3
4pub mod converter;
5pub mod reader;
6
7use anyhow::{bail, Context, Result};
8use mongodb::{options::ClientOptions, Client};
9
10/// Validate a MongoDB connection string
11///
12/// Security checks:
13/// - Verifies URL starts with mongodb:// or mongodb+srv://
14/// - Parses connection string to validate format
15/// - Checks for required database name in connection string
16///
17/// # Arguments
18///
19/// * `connection_string` - MongoDB connection URL
20///
21/// # Returns
22///
23/// Validated connection string if valid, error otherwise
24///
25/// # Security
26///
27/// CRITICAL: This function prevents invalid or malicious connection strings
28///
29/// # Examples
30///
31/// ```no_run
32/// # use database_replicator::mongodb::validate_mongodb_url;
33/// // Valid URLs
34/// assert!(validate_mongodb_url("mongodb://localhost:27017/mydb").is_ok());
35/// assert!(validate_mongodb_url("mongodb+srv://cluster.mongodb.net/mydb").is_ok());
36///
37/// // Invalid URLs
38/// assert!(validate_mongodb_url("invalid").is_err());
39/// assert!(validate_mongodb_url("postgresql://localhost/db").is_err());
40/// ```
41pub fn validate_mongodb_url(connection_string: &str) -> Result<String> {
42 if connection_string.is_empty() {
43 bail!("MongoDB connection string cannot be empty");
44 }
45
46 // Check for valid MongoDB URL prefix
47 if !connection_string.starts_with("mongodb://")
48 && !connection_string.starts_with("mongodb+srv://")
49 {
50 bail!(
51 "Invalid MongoDB connection string '{}'. \
52 Must start with 'mongodb://' or 'mongodb+srv://'",
53 connection_string
54 );
55 }
56
57 // Note: Cannot validate connection string synchronously in this function
58 // Validation happens during async connection
59
60 tracing::debug!("Validated MongoDB connection string");
61
62 Ok(connection_string.to_string())
63}
64
65/// Connect to MongoDB database
66///
67/// Opens a connection to MongoDB using the provided connection string.
68/// The connection is read-only by default (enforced at application level).
69///
70/// # Arguments
71///
72/// * `connection_string` - MongoDB connection URL (will be validated)
73///
74/// # Returns
75///
76/// MongoDB Client if successful
77///
78/// # Security
79///
80/// - Connection string is validated before use
81/// - Application enforces read-only operations
82/// - No modifications possible through this interface
83///
84/// # Examples
85///
86/// ```no_run
87/// # use database_replicator::mongodb::connect_mongodb;
88/// # async fn example() -> anyhow::Result<()> {
89/// let client = connect_mongodb("mongodb://localhost:27017/mydb").await?;
90/// // Use client to read data
91/// # Ok(())
92/// # }
93/// ```
94pub async fn connect_mongodb(connection_string: &str) -> Result<Client> {
95 // Validate connection string first
96 let validated_url = validate_mongodb_url(connection_string)?;
97
98 tracing::info!("Connecting to MongoDB database");
99
100 // Parse options and create client
101 let client_options = ClientOptions::parse(&validated_url)
102 .await
103 .with_context(|| "Failed to parse MongoDB connection options".to_string())?;
104
105 let client = Client::with_options(client_options).context("Failed to create MongoDB client")?;
106
107 // Verify connection by pinging
108 client
109 .database("admin")
110 .run_command(bson::doc! {"ping": 1})
111 .await
112 .context(
113 "Failed to ping MongoDB server (connection may be invalid or server unreachable)",
114 )?;
115
116 tracing::debug!("Successfully connected to MongoDB");
117
118 Ok(client)
119}
120
121/// Extract database name from MongoDB connection string
122///
123/// Parses the connection string and extracts the database name.
124/// MongoDB connection strings can have the database name in the path.
125///
126/// # Arguments
127///
128/// * `connection_string` - MongoDB connection URL
129///
130/// # Returns
131///
132/// Database name if present in URL, None otherwise
133///
134/// # Examples
135///
136/// ```no_run
137/// # use database_replicator::mongodb::extract_database_name;
138/// # async fn example() -> anyhow::Result<()> {
139/// assert_eq!(
140/// extract_database_name("mongodb://localhost:27017/mydb").await?,
141/// Some("mydb".to_string())
142/// );
143/// assert_eq!(
144/// extract_database_name("mongodb://localhost:27017").await?,
145/// None
146/// );
147/// # Ok(())
148/// # }
149/// ```
150pub async fn extract_database_name(connection_string: &str) -> Result<Option<String>> {
151 let options = ClientOptions::parse(connection_string)
152 .await
153 .context("Failed to parse MongoDB connection string")?;
154
155 Ok(options.default_database.clone())
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn test_validate_empty_url() {
164 let result = validate_mongodb_url("");
165 assert!(result.is_err());
166 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
167 }
168
169 #[test]
170 fn test_validate_invalid_prefix() {
171 let invalid_urls = vec![
172 "postgresql://localhost/db",
173 "mysql://localhost/db",
174 "http://localhost",
175 "localhost:27017",
176 ];
177
178 for url in invalid_urls {
179 let result = validate_mongodb_url(url);
180 assert!(result.is_err(), "Invalid URL should be rejected: {}", url);
181 }
182 }
183
184 #[test]
185 fn test_validate_valid_mongodb_url() {
186 // Note: This test validates URL format, not actual connection
187 let valid_urls = vec![
188 "mongodb://localhost:27017",
189 "mongodb://localhost:27017/mydb",
190 "mongodb://user:pass@localhost:27017/mydb",
191 ];
192
193 for url in valid_urls {
194 let result = validate_mongodb_url(url);
195 assert!(
196 result.is_ok(),
197 "Valid MongoDB URL should be accepted: {}",
198 url
199 );
200 }
201 }
202
203 #[test]
204 fn test_validate_mongodb_srv_url() {
205 // Note: This test validates URL format, not actual connection
206 let url = "mongodb+srv://cluster.mongodb.net/mydb";
207 let result = validate_mongodb_url(url);
208 assert!(result.is_ok(), "MongoDB+SRV URL should be accepted");
209 }
210
211 #[tokio::test]
212 async fn test_extract_database_name_with_db() {
213 let url = "mongodb://localhost:27017/mydb";
214 let db_name = extract_database_name(url).await.unwrap();
215 assert_eq!(db_name, Some("mydb".to_string()));
216 }
217
218 #[tokio::test]
219 async fn test_extract_database_name_without_db() {
220 let url = "mongodb://localhost:27017";
221 let db_name = extract_database_name(url).await.unwrap();
222 assert_eq!(db_name, None);
223 }
224
225 #[tokio::test]
226 async fn test_extract_database_name_with_auth() {
227 let url = "mongodb://user:pass@localhost:27017/mydb";
228 let db_name = extract_database_name(url).await.unwrap();
229 assert_eq!(db_name, Some("mydb".to_string()));
230 }
231}