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}