use serde::Serialize; use serde_json::Value; #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum MongoCommand { #[serde(rename = "version")] Version, #[serde(rename = "use")] Use { database: String }, #[serde(rename = "showDatabases")] ShowDatabases, #[serde(rename = "runCommand")] RunCommand { #[serde(rename = "commandJson")] command_json: String, }, #[serde(rename = "createUser")] CreateUser { #[serde(rename = "userJson")] user_json: String, #[serde(rename = "writeConcernJson")] write_concern_json: Option, }, #[serde(rename = "find")] Find { collection: String, filter: String, projection: Option, sort: Option, collation: Option, skip: u64, limit: i64, }, #[serde(rename = "findExplain")] FindExplain { collection: String, filter: String, projection: Option, sort: Option, collation: Option, skip: u64, limit: i64, verbosity: String, }, #[serde(rename = "findOne")] FindOne { collection: String, filter: String, projection: Option, options: Option }, #[serde(rename = "countDocuments")] Count { collection: String, filter: String, accurate: bool }, #[serde(rename = "aggregate")] Aggregate { collection: String, pipeline: String, options: Option }, #[serde(rename = "distinct")] Distinct { collection: String, field: String, filter: Option }, #[serde(rename = "getIndexes")] GetIndexes { collection: String }, #[serde(rename = "collectionStats")] CollectionStats { collection: String, metric: String, scale: Option }, #[serde(rename = "insert")] Insert { collection: String, #[serde(rename = "docsJson")] documents: String, }, #[serde(rename = "update")] Update { collection: String, filter: String, update: String, options: Option, many: bool }, #[serde(rename = "delete")] Delete { collection: String, filter: String, many: bool }, #[serde(rename = "createIndex")] CreateIndex { collection: String, keys: String, options: Option }, #[serde(rename = "dropIndexes")] DropIndexes { collection: String, indexes: Option, single: bool }, #[serde(rename = "dropCollection")] DropCollection { collection: String }, #[serde(rename = "findOneAndUpdate")] FindOneAndUpdate { collection: String, filter: String, update: String, options: Option }, #[serde(rename = "findOneAndReplace")] FindOneAndReplace { collection: String, filter: String, replacement: String, options: Option }, #[serde(rename = "findOneAndDelete")] FindOneAndDelete { collection: String, filter: String, options: Option }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MongoSafetyError { WritesDisabled, EmptyFilter, Dangerous, ProductionWrite, } impl MongoCommand { pub fn is_mutating(&self) -> bool { matches!( self, Self::RunCommand { .. } | Self::CreateUser { .. } | Self::Insert { .. } | Self::Update { .. } | Self::Delete { .. } | Self::CreateIndex { .. } | Self::DropIndexes { .. } | Self::DropCollection { .. } | Self::FindOneAndUpdate { .. } | Self::FindOneAndReplace { .. } | Self::FindOneAndDelete { .. } ) || matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline)) } pub fn is_dangerous(&self) -> bool { matches!(self, Self::RunCommand { .. } | Self::CreateUser { .. } | Self::DropCollection { .. }) || matches!(self, Self::DropIndexes { indexes: None, single: false, .. }) || matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline)) } pub fn has_empty_filter(&self) -> bool { match self { Self::Update { filter, .. } | Self::Delete { filter, .. } | Self::FindOneAndUpdate { filter, .. } | Self::FindOneAndReplace { filter, .. } | Self::FindOneAndDelete { filter, .. } => is_empty_object(filter), _ => false, } } pub fn has_effectively_unbounded_filter(&self) -> bool { match self { Self::Update { filter, .. } | Self::Delete { filter, .. } | Self::FindOneAndUpdate { filter, .. } | Self::FindOneAndReplace { filter, .. } | Self::FindOneAndDelete { filter, .. } => mongo_filter_is_effectively_unbounded(filter), _ => false, } } } pub fn validate_safety( command: &MongoCommand, allow_writes: bool, allow_dangerous: bool, production_database: bool, ) -> Result<(), MongoSafetyError> { if command.is_mutating() && !allow_writes { return Err(MongoSafetyError::WritesDisabled); } if command.has_effectively_unbounded_filter() && !allow_dangerous { return Err(MongoSafetyError::EmptyFilter); } if command.is_dangerous() && !allow_dangerous { return Err(MongoSafetyError::Dangerous); } if command.is_mutating() && production_database { return Err(MongoSafetyError::ProductionWrite); } Ok(()) } pub fn mongo_filter_is_effectively_unbounded(filter_json: &str) -> bool { serde_json::from_str::(filter_json) .ok() .as_ref() .is_none_or(|value| mongo_filter_contains_opaque_logic(value) || mongo_filter_value_is_unbounded(value)) } fn mongo_filter_contains_opaque_logic(value: &serde_json::Value) -> bool { let Some(filter) = value.as_object() else { return true; }; filter.iter().any(|(key, value)| match key.as_str() { "$comment" => false, "$where" | "$expr" | "$nor" => true, "$and" | "$or" => { let Some(clauses) = value.as_array() else { return true; }; clauses.is_empty() || clauses.iter().any(|clause| !clause.is_object() || mongo_filter_contains_opaque_logic(clause)) || (key == "$or" && clauses .iter() .any(|clause| clause.as_object().is_some_and(|document| document.contains_key("$and")))) || (key == "$or" && mongo_or_has_complementary_field_clauses(clauses)) } _ => key.starts_with('$') || mongo_field_predicate_contains_opaque_logic(value), }) } fn mongo_field_predicate_contains_opaque_logic(value: &serde_json::Value) -> bool { let Some(predicate) = value.as_object() else { return false; }; if mongo_extended_json_scalar_literal_is_valid(value) { return false; } let has_operator = predicate.keys().any(|key| key.starts_with('$')); has_operator && predicate.keys().any(|key| { !matches!(key.as_str(), "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte" | "$in" | "$nin" | "$exists") }) } fn mongo_extended_json_scalar_literal_is_valid(value: &serde_json::Value) -> bool { let Some(wrapper) = value.as_object().filter(|wrapper| wrapper.len() == 1) else { return false; }; if let Some(value) = wrapper.get("$oid").and_then(serde_json::Value::as_str) { return value.len() == 24 && value.bytes().all(|byte| byte.is_ascii_hexdigit()); } if let Some(value) = wrapper.get("$numberLong").and_then(serde_json::Value::as_str) { return value.parse::().is_ok(); } wrapper .get("$date") .and_then(serde_json::Value::as_str) .is_some_and(|value| chrono::DateTime::parse_from_rfc3339(value).is_ok()) } #[derive(Clone, Copy, PartialEq, Eq)] enum MongoFieldOperator { Eq, Ne, Gt, Gte, Lt, Lte, In, Nin, Exists, } struct MongoPureFieldPredicate<'a> { field: &'a str, operator: MongoFieldOperator, operand: &'a serde_json::Value, } fn mongo_or_has_complementary_field_clauses(clauses: &[serde_json::Value]) -> bool { clauses.iter().enumerate().any(|(index, clause)| { let Some(predicate) = mongo_pure_field_predicate(clause) else { return false; }; clauses[index + 1..] .iter() .filter_map(mongo_pure_field_predicate) .any(|other| mongo_field_predicates_are_complementary(&predicate, &other)) }) } fn mongo_pure_field_predicate(value: &serde_json::Value) -> Option> { let filter = value.as_object()?; let mut entries = filter.iter().filter(|(key, _)| key.as_str() != "$comment"); let (field, predicate) = entries.next()?; if entries.next().is_some() { return None; } if field == "$and" { let clauses = predicate.as_array()?; let mut bounded = clauses.iter().filter(|clause| !mongo_filter_value_is_unbounded(clause)); let clause = bounded.next()?; if bounded.next().is_some() { return None; } return mongo_pure_field_predicate(clause); } if field == "$or" { let clauses = predicate.as_array()?; return (clauses.len() == 1).then(|| mongo_pure_field_predicate(&clauses[0])).flatten(); } if field.starts_with('$') { return None; } let Some(operator_document) = predicate.as_object() else { return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate }); }; if mongo_extended_json_scalar_literal_is_valid(predicate) || !operator_document.keys().any(|key| key.starts_with('$')) { return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate }); } let mut operators = operator_document.iter(); let (operator, operand) = operators.next()?; if operators.next().is_some() { return None; } let operator = match operator.as_str() { "$eq" => MongoFieldOperator::Eq, "$ne" => MongoFieldOperator::Ne, "$gt" => MongoFieldOperator::Gt, "$gte" => MongoFieldOperator::Gte, "$lt" => MongoFieldOperator::Lt, "$lte" => MongoFieldOperator::Lte, "$in" => MongoFieldOperator::In, "$nin" => MongoFieldOperator::Nin, "$exists" => MongoFieldOperator::Exists, _ => return None, }; Some(MongoPureFieldPredicate { field, operator, operand }) } fn mongo_field_predicates_are_complementary( left: &MongoPureFieldPredicate<'_>, right: &MongoPureFieldPredicate<'_>, ) -> bool { if left.field != right.field { return false; } use MongoFieldOperator::{Eq, Exists, Gt, Gte, In, Lt, Lte, Ne, Nin}; match (left.operator, right.operator) { (Exists, Exists) => { left.operand.as_bool().zip(right.operand.as_bool()).is_some_and(|(left, right)| left != right) } (In, Nin) | (Nin, In) => mongo_json_sets_equal(left.operand, right.operand), (Eq, Ne) | (Ne, Eq) | (Gt, Lte) | (Lte, Gt) | (Gte, Lt) | (Lt, Gte) => left.operand == right.operand, _ => false, } } fn mongo_json_sets_equal(left: &serde_json::Value, right: &serde_json::Value) -> bool { let (Some(left), Some(right)) = (left.as_array(), right.as_array()) else { return false; }; left.iter().all(|value| right.contains(value)) && right.iter().all(|value| left.contains(value)) } fn mongo_filter_value_is_unbounded(value: &serde_json::Value) -> bool { let Some(filter) = value.as_object() else { return true; }; if filter.is_empty() || filter.contains_key("$where") || filter.contains_key("$expr") { return true; } filter.iter().all(|(key, value)| match key.as_str() { "$comment" => true, "$and" => value .as_array() .is_none_or(|clauses| clauses.is_empty() || clauses.iter().all(mongo_filter_value_is_unbounded)), "$or" => value .as_array() .is_none_or(|clauses| clauses.is_empty() || clauses.iter().any(mongo_filter_value_is_unbounded)), "$nor" => true, _ if mongo_field_predicate_is_empty_nin(value) => true, "_id" if mongo_field_predicate_is_exists_true(value) => true, _ => key.starts_with('$'), }) } fn mongo_field_predicate_is_empty_nin(value: &serde_json::Value) -> bool { value.as_object().is_some_and(|predicate| { predicate.len() == 1 && predicate.get("$nin").and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty) }) } fn mongo_field_predicate_is_exists_true(value: &serde_json::Value) -> bool { value.as_object().is_some_and(|predicate| { predicate.len() == 1 && predicate.get("$exists").and_then(serde_json::Value::as_bool) == Some(true) }) } pub fn parse(input: &str) -> Result { let show_source = trim_mongo_outer_comments(input).trim_end_matches(';').trim(); if parse_show_databases(show_source) { return Ok(MongoCommand::ShowDatabases); } let source = input.trim().trim_end_matches(';').trim(); if source.eq_ignore_ascii_case("db.version()") { return Ok(MongoCommand::Version); } if let Some(database) = parse_use_database(source) { return Ok(MongoCommand::Use { database }); } if let Some((args, tail)) = database_method_call(source, "runCommand") { if !tail.is_empty() || args.len() != 1 { return Err("MongoDB runCommand() requires exactly one command document.".to_string()); } let command_json = normalized_json(&args[0])?; let command = parse_json_value(&command_json) .and_then(|value| value.as_object().cloned()) .ok_or("MongoDB runCommand() requires a command document.")?; if command.is_empty() { return Err("MongoDB runCommand() requires a non-empty command document.".to_string()); } return Ok(MongoCommand::RunCommand { command_json }); } if let Some((args, tail)) = database_method_call(source, "createUser") { if !tail.is_empty() || !(1..=2).contains(&args.len()) { return Err("MongoDB createUser() requires a user document and optional write concern.".to_string()); } let user_json = normalized_json(&args[0])?; let user = parse_json_value(&user_json) .and_then(|value| value.as_object().cloned()) .ok_or("MongoDB createUser() requires a user document.")?; if user.get("user").and_then(Value::as_str).is_none_or(|user| user.trim().is_empty()) { return Err("MongoDB createUser() requires a non-empty user name.".to_string()); } let write_concern_json = optional_json_argument(args.get(1))?; if write_concern_json.as_deref().and_then(parse_json_value).is_some_and(|value| !value.is_object()) { return Err("MongoDB createUser() write concern must be a document.".to_string()); } return Ok(MongoCommand::CreateUser { user_json, write_concern_json }); } let (collection, prefix_end) = parse_collection_prefix(source)?; if let Some((args, tail)) = method_call(source, prefix_end, "find") { let filter = normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?; let projection = if args.get(1).is_some_and(|arg| !arg.trim().is_empty()) { Some(normalized_json(&args[1])?) } else { None }; if args.len() > 2 { return Err("MongoDB find() accepts at most filter and projection arguments.".to_string()); } let mut sort = None; let mut collation = None; let mut skip = 0; let mut limit = 100; let calls = chained_calls(&tail)?; let call_count = calls.len(); for (index, (name, call_args)) in calls.into_iter().enumerate() { match name.as_str() { "sort" => sort = Some(normalized_json(call_args.first().map(String::as_str).unwrap_or("{}"))?), "collation" => { if call_args.len() != 1 { return Err("MongoDB collation() requires one options object.".to_string()); } collation = Some(normalized_json(&call_args[0])?); } "skip" => skip = parse_integer(&call_args, "skip")? as u64, "limit" => limit = parse_integer(&call_args, "limit")?, "count" if call_args.is_empty() => { return Ok(MongoCommand::Count { collection, filter, accurate: false }); } "explain" => { if index + 1 != call_count { return Err("MongoDB explain() must be the final find() chain operation.".to_string()); } return Ok(MongoCommand::FindExplain { collection, filter, projection, sort, collation, skip, limit, verbosity: parse_explain_verbosity(&call_args)?, }); } _ => return Err(format!("Unsupported MongoDB find() chain: {name}()")), } } return Ok(MongoCommand::Find { collection, filter, projection, sort, collation, skip, limit }); } if let Some((args, tail)) = method_call(source, prefix_end, "findOne") { if !tail.is_empty() || args.len() > 3 { return Err("Invalid MongoDB findOne() command.".to_string()); } return Ok(MongoCommand::FindOne { collection, filter: normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?, projection: optional_json_argument(args.get(1))?, options: optional_json_argument(args.get(2))?, }); } for method in ["findOneAndUpdate", "findOneAndReplace"] { if let Some((args, tail)) = method_call(source, prefix_end, method) { if !tail.is_empty() || !(2..=3).contains(&args.len()) { return Err(format!("Invalid MongoDB {method}() command.")); } let filter = normalized_json(&args[0])?; let value = normalized_json(&args[1])?; let options = optional_json_argument(args.get(2))?; return Ok(if method == "findOneAndUpdate" { MongoCommand::FindOneAndUpdate { collection, filter, update: value, options } } else { MongoCommand::FindOneAndReplace { collection, filter, replacement: value, options } }); } } if let Some((args, tail)) = method_call(source, prefix_end, "findOneAndDelete") { if !tail.is_empty() || !(1..=2).contains(&args.len()) { return Err("Invalid MongoDB findOneAndDelete() command.".to_string()); } return Ok(MongoCommand::FindOneAndDelete { collection, filter: normalized_json(&args[0])?, options: optional_json_argument(args.get(1))?, }); } for (method, accurate) in [("countDocuments", true), ("count", false)] { if let Some((args, tail)) = method_call(source, prefix_end, method) { if !tail.is_empty() || args.len() > 1 { return Err(format!("Invalid MongoDB {method}() command.")); } return Ok(MongoCommand::Count { collection, filter: normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?, accurate, }); } } if let Some((args, tail)) = method_call(source, prefix_end, "aggregate") { if !tail.is_empty() || !(1..=2).contains(&args.len()) { return Err("Invalid MongoDB aggregate() command.".to_string()); } let pipeline = normalized_json(&args[0])?; if !parse_json_value(&pipeline).is_some_and(|value| value.is_array()) { return Err("MongoDB aggregate() requires a pipeline array.".to_string()); } let options = args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?; return Ok(MongoCommand::Aggregate { collection, pipeline, options }); } if let Some((args, tail)) = method_call(source, prefix_end, "distinct") { if !tail.is_empty() || !(1..=2).contains(&args.len()) { return Err("Invalid MongoDB distinct() command.".to_string()); } let field = parse_string_arg(&args[0])?; let filter = args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?; return Ok(MongoCommand::Distinct { collection, field, filter }); } if let Some((args, tail)) = method_call(source, prefix_end, "getIndexes") { if !tail.is_empty() || !args.is_empty() { return Err("Invalid MongoDB getIndexes() command.".to_string()); } return Ok(MongoCommand::GetIndexes { collection }); } for metric in ["stats", "dataSize", "storageSize", "totalIndexSize"] { if let Some((args, tail)) = method_call(source, prefix_end, metric) { if !tail.is_empty() || args.len() > 1 { return Err(format!("Invalid MongoDB {metric}() command.")); } let scale = args .first() .filter(|arg| !arg.trim().is_empty()) .map(|arg| { arg.trim() .parse::() .ok() .and_then(serde_json::Number::from_f64) .ok_or_else(|| format!("Invalid {metric} scale.")) }) .transpose()?; return Ok(MongoCommand::CollectionStats { collection, metric: metric.to_string(), scale }); } } if let Some((args, tail)) = method_call(source, prefix_end, "insertOne") { if !tail.is_empty() || args.len() != 1 { return Err("Invalid MongoDB insertOne() command.".to_string()); } return Ok(MongoCommand::Insert { collection, documents: normalized_json(&args[0])? }); } if let Some((args, tail)) = method_call(source, prefix_end, "insertMany") { if !tail.is_empty() || args.len() != 1 { return Err("Invalid MongoDB insertMany() command.".to_string()); } let documents = normalized_json(&args[0])?; if !parse_json_value(&documents).is_some_and(|value| value.is_array()) { return Err("MongoDB insertMany() requires an array.".to_string()); } return Ok(MongoCommand::Insert { collection, documents }); } // MongoDB keeps insert() for legacy shell compatibility; preserve its // single-document-or-array contract without silently ignoring options. if let Some((args, tail)) = method_call(source, prefix_end, "insert") { if !tail.is_empty() || args.len() != 1 { return Err("Invalid MongoDB insert() command.".to_string()); } let documents = normalized_json(&args[0])?; if !parse_json_value(&documents).is_some_and(|value| value.is_object() || value.is_array()) { return Err("MongoDB insert() requires a document or document array.".to_string()); } return Ok(MongoCommand::Insert { collection, documents }); } for (method, many) in [("updateOne", false), ("updateMany", true)] { if let Some((args, tail)) = method_call(source, prefix_end, method) { if !tail.is_empty() || !(2..=3).contains(&args.len()) { return Err(format!("Invalid MongoDB {method}() command.")); } return Ok(MongoCommand::Update { collection, filter: normalized_json(&args[0])?, update: normalized_json(&args[1])?, options: args .get(2) .filter(|arg| !arg.trim().is_empty()) .map(|arg| normalized_json(arg)) .transpose()?, many, }); } } if let Some((args, tail)) = method_call(source, prefix_end, "update") { if !tail.is_empty() || !(2..=3).contains(&args.len()) { return Err("Invalid MongoDB update() command.".to_string()); } let (options, many) = legacy_update_options(args.get(2))?; return Ok(MongoCommand::Update { collection, filter: normalized_json(&args[0])?, update: normalized_json(&args[1])?, options, many, }); } for (method, many) in [("deleteOne", false), ("deleteMany", true)] { if let Some((args, tail)) = method_call(source, prefix_end, method) { if !tail.is_empty() || args.len() != 1 { return Err(format!("Invalid MongoDB {method}() command.")); } return Ok(MongoCommand::Delete { collection, filter: normalized_json(&args[0])?, many }); } } if let Some((args, tail)) = method_call(source, prefix_end, "createIndex") { if !tail.is_empty() || !(1..=2).contains(&args.len()) { return Err("Invalid MongoDB createIndex() command.".to_string()); } return Ok(MongoCommand::CreateIndex { collection, keys: normalized_json(&args[0])?, options: args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?, }); } if let Some((args, tail)) = method_call(source, prefix_end, "dropIndex") { if !tail.is_empty() || args.len() != 1 { return Err("Invalid MongoDB dropIndex() command.".to_string()); } return Ok(MongoCommand::DropIndexes { collection, indexes: Some(normalized_json(&args[0])?), single: true }); } if let Some((args, tail)) = method_call(source, prefix_end, "dropIndexes") { if !tail.is_empty() || args.len() > 1 { return Err("Invalid MongoDB dropIndexes() command.".to_string()); } return Ok(MongoCommand::DropIndexes { collection, indexes: args.first().filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?, single: false, }); } if let Some((args, tail)) = method_call(source, prefix_end, "drop") { if !tail.is_empty() || !args.is_empty() { return Err("Invalid MongoDB drop() command.".to_string()); } return Ok(MongoCommand::DropCollection { collection }); } Err("Unsupported MongoDB shell command.".to_string()) } fn parse_show_databases(source: &str) -> bool { let mut words = source.split_whitespace(); words.next().is_some_and(|word| word.eq_ignore_ascii_case("show")) && words.next().is_some_and(|word| word.eq_ignore_ascii_case("dbs") || word.eq_ignore_ascii_case("databases")) && words.next().is_none() } fn trim_mongo_outer_comments(mut source: &str) -> &str { loop { source = source.trim_start(); if source.starts_with("//") || source.starts_with("--") { source = source .char_indices() .find_map(|(index, character)| (character == '\n' || character == '\r').then_some(&source[index + 1..])) .unwrap_or(""); continue; } if source.starts_with("/*") { let Some(end) = source.find("*/") else { return source; }; source = &source[end + 2..]; continue; } break; } let source = source.trim_end(); let mut body_end = 0; let mut quote = None; let mut escaped = false; let mut line_comment = false; let mut block_comment = false; let mut characters = source.char_indices().peekable(); while let Some((index, character)) = characters.next() { let next = characters.peek().map(|(_, character)| *character); if line_comment { if character == '\n' || character == '\r' { line_comment = false; } continue; } if block_comment { if character == '*' && next == Some('/') { block_comment = false; characters.next(); } continue; } if let Some(delimiter) = quote { body_end = index + character.len_utf8(); if escaped { escaped = false; } else if character == '\\' { escaped = true; } else if character == delimiter { quote = None; } continue; } if (character == '/' && next == Some('/')) || (character == '-' && next == Some('-')) { line_comment = true; characters.next(); continue; } if character == '/' && next == Some('*') { block_comment = true; characters.next(); continue; } if matches!(character, '"' | '\'' | '`') { quote = Some(character); } if !character.is_whitespace() { body_end = index + character.len_utf8(); } } source[..body_end].trim_end() } fn parse_collection_prefix(source: &str) -> Result<(String, usize), String> { if !source.get(..3).is_some_and(|prefix| prefix.eq_ignore_ascii_case("db.")) { return Err("MongoDB command must start with db..".to_string()); } let rest = &source[3..]; if rest.starts_with("getCollection") { let open = rest.find('(').ok_or("Invalid db.getCollection() command.")?; let close = matching_paren(rest, open).ok_or("Invalid db.getCollection() command.")?; let args = split_top_level(&rest[open + 1..close]); if args.len() != 1 { return Err("db.getCollection() requires one collection name.".to_string()); } let collection = parse_string_arg(&args[0])?; let end = 3 + close + 1; let suffix = &source[end..]; let trimmed = suffix.trim_start(); if !trimmed.starts_with('.') { return Err("MongoDB collection method is required.".to_string()); } return Ok((collection, end + suffix.len() - trimmed.len())); } let collection_end = rest .char_indices() .find_map(|(index, ch)| (ch == '.' || ch.is_whitespace()).then_some(index)) .ok_or("MongoDB collection method is required.")?; let collection = &rest[..collection_end]; if collection.is_empty() { return Err("Invalid MongoDB collection name.".to_string()); } let suffix = &rest[collection_end..]; let dot = suffix.find('.').ok_or("MongoDB collection method is required.")?; if !suffix[..dot].trim().is_empty() { return Err("Invalid MongoDB collection name.".to_string()); } Ok((collection.to_string(), 3 + collection_end + dot)) } fn method_call(source: &str, prefix_end: usize, method: &str) -> Option<(Vec, String)> { let raw_suffix = &source[prefix_end..]; let suffix = raw_suffix.trim_start(); let whitespace = raw_suffix.len() - suffix.len(); let expected = format!(".{method}"); if !suffix.starts_with(&expected) || !suffix[expected.len()..].starts_with('(') { return None; } let open = prefix_end + whitespace + expected.len(); let close = matching_paren(source, open)?; Some((split_top_level(&source[open + 1..close]), source[close + 1..].trim().to_string())) } fn database_method_call(source: &str, method: &str) -> Option<(Vec, String)> { if !source.get(..2).is_some_and(|prefix| prefix.eq_ignore_ascii_case("db")) { return None; } let after_db = source.get(2..)?.trim_start(); let after_dot = after_db.strip_prefix('.')?.trim_start(); if !after_dot.get(..method.len()).is_some_and(|name| name.eq_ignore_ascii_case(method)) { return None; } let after_method = after_dot.get(method.len()..)?.trim_start(); if !after_method.starts_with('(') { return None; } let open = source.len() - after_method.len(); let close = matching_paren(source, open)?; Some((split_top_level(&source[open + 1..close]), source[close + 1..].trim().to_string())) } fn chained_calls(chain: &str) -> Result)>, String> { let mut rest = chain.trim(); let mut calls = Vec::new(); while !rest.is_empty() { let Some(rest_after_dot) = rest.strip_prefix('.') else { return Err("Invalid MongoDB method chain.".to_string()); }; let open = rest_after_dot.find('(').ok_or("Invalid MongoDB method chain.")?; let name = rest_after_dot[..open].trim().to_string(); let close = matching_paren(rest_after_dot, open).ok_or("Invalid MongoDB method chain.")?; calls.push((name, split_top_level(&rest_after_dot[open + 1..close]))); rest = rest_after_dot[close + 1..].trim(); } Ok(calls) } fn parse_integer(args: &[String], name: &str) -> Result { if args.len() != 1 { return Err(format!("MongoDB {name}() requires one integer.")); } let value = args[0].trim().parse::().map_err(|_| format!("MongoDB {name}() requires a non-negative integer."))?; if value < 0 { return Err(format!("MongoDB {name}() requires a non-negative integer.")); } Ok(value) } fn parse_string_arg(arg: &str) -> Result { let value = parse_json_value(&normalized_json(arg)?).ok_or("Invalid MongoDB string argument.")?; value.as_str().map(ToOwned::to_owned).ok_or_else(|| "MongoDB argument must be a string.".to_string()) } fn parse_explain_verbosity(args: &[String]) -> Result { if args.len() > 1 { return Err("MongoDB explain() accepts at most one verbosity string.".to_string()); } let verbosity = match args.first() { Some(value) => parse_string_arg(value)?, None => "queryPlanner".to_string(), }; match verbosity.as_str() { "queryPlanner" | "executionStats" | "allPlansExecution" => Ok(verbosity), _ => Err("MongoDB explain() verbosity must be queryPlanner, executionStats, or allPlansExecution.".to_string()), } } fn normalized_json(input: &str) -> Result { let transformed = transform_shell_constructors(input.trim())?; let value: Value = json5::from_str(&transformed).map_err(|error| format!("Invalid MongoDB JSON argument: {error}"))?; serde_json::to_string(&value).map_err(|error| error.to_string()) } fn transform_shell_constructors(input: &str) -> Result { let mut output = String::with_capacity(input.len()); let mut index = 0; while index < input.len() { let rest = &input[index..]; let constructor = if rest.starts_with("ObjectId(") { Some("ObjectId(") } else if rest.starts_with("ISODate(") { Some("ISODate(") } else { None }; let Some(constructor) = constructor else { let ch = rest.chars().next().ok_or("Invalid MongoDB argument.")?; output.push(ch); index += ch.len_utf8(); continue; }; let open = index + constructor.len() - 1; let close = matching_paren(input, open).ok_or("Unclosed MongoDB value constructor.")?; let inner = input[open + 1..close].trim(); let value = parse_string_arg(inner)?; let key = if constructor.starts_with("ObjectId") { "$oid" } else { "$date" }; output.push_str(&format!("{{\"{key}\":{}}}", serde_json::to_string(&value).unwrap())); index = close + 1; } Ok(output) } fn parse_json_value(value: &str) -> Option { serde_json::from_str(value).ok() } fn optional_json_argument(value: Option<&String>) -> Result, String> { value.filter(|value| !value.trim().is_empty()).map(|value| normalized_json(value)).transpose() } fn legacy_update_options(value: Option<&String>) -> Result<(Option, bool), String> { let Some(value) = value.filter(|value| !value.trim().is_empty()) else { return Ok((None, false)); }; let normalized = normalized_json(value)?; let value = parse_json_value(&normalized).ok_or("Invalid MongoDB update() options.")?; let Value::Object(mut options) = value else { return Err("MongoDB update() options must be a document.".to_string()); }; let many = match options.remove("multi") { Some(Value::Bool(many)) => many, Some(_) => return Err("MongoDB update() multi option must be a boolean.".to_string()), None => false, }; let options = if options.is_empty() { None } else { Some(serde_json::to_string(&Value::Object(options)).map_err(|error| error.to_string())?) }; Ok((options, many)) } fn parse_use_database(source: &str) -> Option { let mut parts = source.split_whitespace(); if !parts.next()?.eq_ignore_ascii_case("use") { return None; } let database = parts.next()?; if parts.next().is_some() || database.is_empty() || !database.chars().all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')) { return None; } Some(database.to_string()) } fn is_empty_object(value: &str) -> bool { parse_json_value(value).is_some_and(|value| value.as_object().is_some_and(|object| object.is_empty())) } fn aggregate_writes(pipeline: &str) -> bool { parse_json_value(pipeline).is_some_and(|value| { value.as_array().is_some_and(|stages| { stages.iter().any(|stage| { stage .as_object() .is_some_and(|object| object.keys().any(|key| matches!(key.as_str(), "$out" | "$merge"))) }) }) }) } fn matching_paren(source: &str, open: usize) -> Option { let bytes = source.as_bytes(); let mut depth = 0; let mut quote = None; let mut escape = false; for (index, byte) in bytes.iter().enumerate().skip(open) { let ch = *byte as char; if escape { escape = false; continue; } if quote.is_some() { if ch == '\\' { escape = true; } else if Some(ch) == quote { quote = None; } continue; } if ch == '\'' || ch == '"' || ch == '`' { quote = Some(ch); } else if ch == '(' { depth += 1; } else if ch == ')' { depth -= 1; if depth == 0 { return Some(index); } } } None } fn split_top_level(source: &str) -> Vec { if source.trim().is_empty() { return Vec::new(); } let mut result = Vec::new(); let mut start = 0; let mut depth = 0; let mut quote = None; let mut escape = false; for (index, byte) in source.as_bytes().iter().enumerate() { let ch = *byte as char; if escape { escape = false; continue; } if quote.is_some() { if ch == '\\' { escape = true; } else if Some(ch) == quote { quote = None; } continue; } if ch == '\'' || ch == '"' || ch == '`' { quote = Some(ch); } else if matches!(ch, '(' | '[' | '{') { depth += 1; } else if matches!(ch, ')' | ']' | '}') { depth -= 1; } else if ch == ',' && depth == 0 { result.push(source[start..index].trim().to_string()); start = index + 1; } } result.push(source[start..].trim().to_string()); result } #[cfg(test)] mod tests { use super::*; #[test] fn parses_find_with_compass_syntax_and_chain() { assert_eq!( parse("db.products.find({_id: ObjectId('507f1f77bcf86cd799439011')}, {title: 1, _id: 0}).sort({title: 1}).limit(1)").unwrap(), MongoCommand::Find { collection: "products".to_string(), filter: r#"{"_id":{"$oid":"507f1f77bcf86cd799439011"}}"#.to_string(), projection: Some(r#"{"title":1,"_id":0}"#.to_string()), sort: Some(r#"{"title":1}"#.to_string()), collation: None, skip: 0, limit: 1, } ); } #[test] fn parses_find_with_collation_chain() { assert_eq!( parse(r#"db.t_user.find({name: 'xxx'}).collation({ locale: "en", strength: 1 }).limit(20)"#).unwrap(), MongoCommand::Find { collection: "t_user".to_string(), filter: r#"{"name":"xxx"}"#.to_string(), projection: None, sort: None, collation: Some(r#"{"locale":"en","strength":1}"#.to_string()), skip: 0, limit: 20, } ); } #[test] fn parses_find_explain_with_query_options_and_verbosity() { let command = parse( r#"db.im_msg.find({active: true}, {email: 1}).sort({email: 1}).collation({locale: "en", strength: 1}).skip(2).limit(5).explain("executionStats")"#, ) .unwrap(); assert_eq!( serde_json::to_value(command).unwrap(), serde_json::json!({ "kind": "findExplain", "collection": "im_msg", "filter": "{\"active\":true}", "projection": "{\"email\":1}", "sort": "{\"email\":1}", "collation": "{\"locale\":\"en\",\"strength\":1}", "skip": 2, "limit": 5, "verbosity": "executionStats" }) ); } #[test] fn find_explain_defaults_and_validates_verbosity() { let default = serde_json::to_value(parse("db.items.find({}).explain()").unwrap()).unwrap(); assert_eq!(default["verbosity"], "queryPlanner"); let all_plans = serde_json::to_value(parse("db.items.find({}).explain('allPlansExecution')").unwrap()).unwrap(); assert_eq!(all_plans["verbosity"], "allPlansExecution"); assert!(parse("db.items.find({}).explain('invalid')").unwrap_err().contains("verbosity")); assert!(parse("db.items.find({}).explain('executionStats').limit(1)").unwrap_err().contains("final")); } #[test] fn parses_get_collection_and_count() { assert_eq!( parse("db.getCollection('audit.logs').count()").unwrap(), MongoCommand::Count { collection: "audit.logs".to_string(), filter: "{}".to_string(), accurate: false } ); } #[test] fn identifies_dangerous_aggregate_and_empty_writes() { let aggregate = parse(r#"db.projects.aggregate([{"$out":"backup"}])"#).unwrap(); assert!(aggregate.is_mutating()); assert!(aggregate.is_dangerous()); let update = parse("db.projects.updateMany({}, {$set: {active: false}})").unwrap(); assert!(update.has_empty_filter()); let legacy_update = parse("db.projects.update({}, {$set: {active: false}}, {multi: true})").unwrap(); assert!(legacy_update.has_empty_filter()); assert_eq!(validate_safety(&legacy_update, true, false, false), Err(MongoSafetyError::EmptyFilter)); } #[test] fn parses_create_user_as_a_dangerous_write() { let command = parse( r#"db . createUser({user: "test-db", pwd: "test-password", roles: [{role: "readWrite", db: "db1"}]}, {w: "majority"})"#, ) .unwrap(); assert_eq!( command, MongoCommand::CreateUser { user_json: r#"{"user":"test-db","pwd":"test-password","roles":[{"role":"readWrite","db":"db1"}]}"# .to_string(), write_concern_json: Some(r#"{"w":"majority"}"#.to_string()), } ); assert!(command.is_mutating()); assert!(command.is_dangerous()); assert_eq!(validate_safety(&command, true, false, false), Err(MongoSafetyError::Dangerous)); assert_eq!(validate_safety(&command, true, true, false), Ok(())); assert!(parse(r#"db.createUser({pwd: "missing-user", roles: []})"#).is_err()); assert!(parse(r#"db.createUser({user: "test"}, "majority")"#).is_err()); } #[test] fn parses_run_command_as_a_dangerous_write() { let command = parse( r#"db.runCommand({ find: "orders", filter: {_id: ObjectId("507f1f77bcf86cd799439011")}, createdAt: ISODate("2025-01-01T00:00:00Z") })"#, ) .unwrap(); assert_eq!( command, MongoCommand::RunCommand { command_json: r#"{"find":"orders","filter":{"_id":{"$oid":"507f1f77bcf86cd799439011"}},"createdAt":{"$date":"2025-01-01T00:00:00Z"}}"#.to_string(), } ); assert!(command.is_mutating()); assert!(command.is_dangerous()); assert_eq!(validate_safety(&command, false, false, false), Err(MongoSafetyError::WritesDisabled)); assert_eq!(validate_safety(&command, true, false, false), Err(MongoSafetyError::Dangerous)); assert_eq!(validate_safety(&command, true, true, false), Ok(())); } #[test] fn parses_show_databases_aliases_as_read_only() { for source in [ "show dbs", "SHOW DATABASES;", "ShOw DbS ;", "/* databases */ show dbs -- list", "// databases\nshow databases; /* list */", ] { let command = parse(source).unwrap(); assert_eq!(command, MongoCommand::ShowDatabases, "{source}"); assert!(!command.is_mutating(), "{source}"); assert!(!command.is_dangerous(), "{source}"); assert_eq!(validate_safety(&command, false, false, true), Ok(()), "{source}"); } for source in ["show dbs extra", "show database", "show collections", "show"] { assert!(parse(source).is_err(), "{source}"); } } #[test] fn rejects_unsupported_run_command_shapes() { for source in [ "db.runCommand()", "db.runCommand({})", "db.runCommand('ping')", "db.runCommand({ping: 1}, {readPreference: 'primary'})", "db.runCommand({ping: 1}).valueOf()", "db.runCommand([1, 2, 3])", ] { assert!(parse(source).unwrap_err().contains("runCommand"), "{source}"); } } #[test] fn treats_effectively_unbounded_write_filters_as_dangerous() { for command in [ r#"db.items.deleteMany({_id: {$exists: true}})"#, r#"db.items.deleteMany({id: {$nin: []}})"#, r#"db.items.deleteMany({$expr: true})"#, r#"db.items.deleteMany({$or: [{id: 1}, {id: {$ne: 1}}]})"#, ] { let command = parse(command).unwrap(); assert!(command.has_effectively_unbounded_filter(), "{command:?}"); assert_eq!( validate_safety(&command, true, false, false), Err(MongoSafetyError::EmptyFilter), "{command:?}" ); } for command in [ r#"db.items.deleteMany({_id: ObjectId('507f1f77bcf86cd799439011')})"#, r#"db.items.updateMany({tenant_id: 7}, {$set: {active: false}})"#, ] { let command = parse(command).unwrap(); assert!(!command.has_effectively_unbounded_filter(), "{command:?}"); assert_eq!(validate_safety(&command, true, false, false), Ok(()), "{command:?}"); } } #[test] fn accepts_multiline_chains_and_update_options() { let command = parse( r#"db.getCollection("operation_logs") .find({_id: ObjectId("68ad51ca84c8127bc7d44cb3")}) .sort({ts: -1}) .skip(5) .limit(10)"#, ) .unwrap(); assert!(matches!(command, MongoCommand::Find { skip: 5, limit: 10, .. })); let update = parse( r#"db.orders.updateMany({status: "open"}, {$set: {"items.$[item].status": "done"}}, {arrayFilters: [{"item.id": 7}]})"#, ) .unwrap(); assert!(matches!(update, MongoCommand::Update { many: true, options: Some(_), .. })); } #[test] fn parses_legacy_update_with_single_and_multi_semantics() { assert_eq!( parse("db.projects.update({_id: 1}, {$set: {active: true}})").unwrap(), MongoCommand::Update { collection: "projects".to_string(), filter: r#"{"_id":1}"#.to_string(), update: r#"{"$set":{"active":true}}"#.to_string(), options: None, many: false, } ); let command = parse(r#"db.getCollection("xxx").update({tenantId: 7}, {$set: {active: true}}, {upsert: true})"#).unwrap(); let MongoCommand::Update { collection, update, options, many, .. } = command else { panic!("expected legacy update command"); }; assert_eq!(collection, "xxx"); assert!(!many); assert_eq!(parse_json_value(&update).unwrap(), serde_json::json!({ "$set": { "active": true } })); assert_eq!(parse_json_value(options.as_deref().unwrap()).unwrap(), serde_json::json!({ "upsert": true })); let command = parse( r#"db.projects.update({tenantId: 7}, [{$set: {active: true}}], {multi: true, arrayFilters: [{"item.id": 1}]})"#, ) .unwrap(); let MongoCommand::Update { update, options, many, .. } = command else { panic!("expected legacy multi update command"); }; assert!(many); assert_eq!(parse_json_value(&update).unwrap(), serde_json::json!([{ "$set": { "active": true } }])); assert_eq!( parse_json_value(options.as_deref().unwrap()).unwrap(), serde_json::json!({ "arrayFilters": [{ "item.id": 1 }] }) ); } #[test] fn rejects_invalid_legacy_update_arguments() { assert!(parse("db.projects.update({_id: 1})").is_err()); assert!(parse("db.projects.update({_id: 1}, {$set: {active: true}}, true)").is_err()); assert!(parse("db.projects.update({_id: 1}, {$set: {active: true}}, {multi: 'yes'})").is_err()); assert!(parse("db.projects.update({_id: 1}, {$set: {active: true}}, {}, false)").is_err()); } #[test] fn accepts_legacy_insert_and_rejects_unsupported_options() { assert_eq!( parse(r#"db.getCollection("accounting_reconciliations").insert({accountId: 999, status: "done"})"#) .unwrap(), MongoCommand::Insert { collection: "accounting_reconciliations".to_string(), documents: r#"{"accountId":999,"status":"done"}"#.to_string(), } ); assert_eq!( parse("db.products.insert([{name: 'first'}, {name: 'second'}])").unwrap(), MongoCommand::Insert { collection: "products".to_string(), documents: r#"[{"name":"first"},{"name":"second"}]"#.to_string(), } ); assert!(parse("db.products.insert({name: 'demo'}, {writeConcern: {w: 1}})").is_err()); assert!(parse("db.products.insert()").is_err()); assert!(parse("db.products.insert('demo')").is_err()); } #[test] fn parses_desktop_find_one_find_and_modify_and_use_commands() { assert_eq!( parse("db.users.findOne({name: 'Ada'}, {_id: 0}, {maxTimeMS: 500})").unwrap(), MongoCommand::FindOne { collection: "users".to_string(), filter: r#"{"name":"Ada"}"#.to_string(), projection: Some(r#"{"_id":0}"#.to_string()), options: Some(r#"{"maxTimeMS":500}"#.to_string()), } ); assert!(matches!( parse("db.users.findOneAndUpdate({_id: 1}, {$set: {active: true}}, {returnDocument: 'after'})").unwrap(), MongoCommand::FindOneAndUpdate { options: Some(_), .. } )); assert!(matches!( parse("db.users.findOneAndReplace({_id: 1}, {name: 'Grace'})").unwrap(), MongoCommand::FindOneAndReplace { .. } )); assert!(matches!(parse("db.users.findOneAndDelete({_id: 1})").unwrap(), MongoCommand::FindOneAndDelete { .. })); assert_eq!(parse("use analytics-test").unwrap(), MongoCommand::Use { database: "analytics-test".to_string() }); } #[test] fn serializes_frontend_command_contract() { let insert = serde_json::to_value(parse("db.items.insert({_id: 1})").unwrap()).unwrap(); assert_eq!(insert["kind"], "insert"); assert_eq!(insert["docsJson"], r#"{"_id":1}"#); let count = serde_json::to_value(parse("db.items.count({})").unwrap()).unwrap(); assert_eq!(count["kind"], "countDocuments"); assert_eq!(count["accurate"], false); let create_user = serde_json::to_value(parse(r#"db.createUser({user: "app", pwd: "secret", roles: []})"#).unwrap()).unwrap(); assert_eq!(create_user["kind"], "createUser"); assert_eq!(create_user["userJson"], r#"{"user":"app","pwd":"secret","roles":[]}"#); let run_command = serde_json::to_value(parse("db.runCommand({ping: 1})").unwrap()).unwrap(); assert_eq!(run_command["kind"], "runCommand"); assert_eq!(run_command["commandJson"], r#"{"ping":1}"#); let show_databases = serde_json::to_value(parse("show dbs").unwrap()).unwrap(); assert_eq!(show_databases["kind"], "showDatabases"); } #[test] fn accepts_stats_and_rejects_negative_pagination() { assert!(matches!( parse("db.users.stats(1024)").unwrap(), MongoCommand::CollectionStats { metric, scale: Some(_), .. } if metric == "stats" )); assert!(parse("db.users.find({}).skip(-1)").is_err()); } }