Files
dbx/crates/dbx-core/src/mongo_shell.rs
T

1704 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
},
#[serde(rename = "find")]
Find {
collection: String,
filter: String,
projection: Option<String>,
sort: Option<String>,
collation: Option<String>,
skip: u64,
limit: i64,
},
#[serde(rename = "findExplain")]
FindExplain {
collection: String,
filter: String,
projection: Option<String>,
sort: Option<String>,
collation: Option<String>,
skip: u64,
limit: i64,
verbosity: String,
},
#[serde(rename = "findOne")]
FindOne { collection: String, filter: String, projection: Option<String>, options: Option<String> },
#[serde(rename = "countDocuments")]
Count { collection: String, filter: String, accurate: bool },
#[serde(rename = "aggregate")]
Aggregate { collection: String, pipeline: String, options: Option<String> },
#[serde(rename = "distinct")]
Distinct { collection: String, field: String, filter: Option<String> },
#[serde(rename = "getIndexes")]
GetIndexes { collection: String },
#[serde(rename = "collectionStats")]
CollectionStats { collection: String, metric: String, scale: Option<serde_json::Number> },
#[serde(rename = "insert")]
Insert {
collection: String,
#[serde(rename = "docsJson")]
documents: String,
},
#[serde(rename = "update")]
Update { collection: String, filter: String, update: String, options: Option<String>, many: bool },
#[serde(rename = "delete")]
Delete { collection: String, filter: String, many: bool },
#[serde(rename = "createIndex")]
CreateIndex { collection: String, keys: String, options: Option<String> },
#[serde(rename = "dropIndexes")]
DropIndexes { collection: String, indexes: Option<String>, single: bool },
#[serde(rename = "dropCollection")]
DropCollection { collection: String },
#[serde(rename = "findOneAndUpdate")]
FindOneAndUpdate { collection: String, filter: String, update: String, options: Option<String> },
#[serde(rename = "findOneAndReplace")]
FindOneAndReplace { collection: String, filter: String, replacement: String, options: Option<String> },
#[serde(rename = "findOneAndDelete")]
FindOneAndDelete { collection: String, filter: String, options: Option<String> },
}
#[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::<serde_json::Value>(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::<i64>().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<MongoPureFieldPredicate<'_>> {
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<MongoCommand, String> {
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::<f64>()
.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.<collection>.".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>, 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>, 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<Vec<(String, Vec<String>)>, 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<i64, String> {
if args.len() != 1 {
return Err(format!("MongoDB {name}() requires one integer."));
}
let value =
args[0].trim().parse::<i64>().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<String, String> {
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<String, String> {
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<String, String> {
let transformed = transform_shell_regex_literals(input.trim())?;
let transformed = transform_shell_constructors(&transformed)?;
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())
}
struct ShellRegexLiteral {
end: usize,
pattern: String,
options: String,
}
fn shell_regex_literal_at(input: &str, index: usize) -> Result<Option<ShellRegexLiteral>, String> {
if !input[index..].starts_with('/') || !is_shell_regex_value_position(input, index) {
return Ok(None);
}
read_shell_regex_literal(input, index).map(Some)
}
fn read_shell_regex_literal(input: &str, index: usize) -> Result<ShellRegexLiteral, String> {
let mut cursor = index + 1;
let mut pattern = String::new();
let mut escaped = false;
let mut in_character_class = false;
let mut closed = false;
while cursor < input.len() {
let current = input[cursor..].chars().next().ok_or("Invalid MongoDB regex literal.")?;
if matches!(current, '\n' | '\r' | '\u{2028}' | '\u{2029}') {
return Err("MongoDB regex literals cannot contain an unescaped line break.".to_string());
}
cursor += current.len_utf8();
if escaped {
pattern.push(current);
escaped = false;
continue;
}
if current == '\\' {
pattern.push(current);
escaped = true;
continue;
}
if current == '[' {
in_character_class = true;
} else if current == ']' && in_character_class {
in_character_class = false;
} else if current == '/' && !in_character_class {
closed = true;
break;
}
pattern.push(current);
}
if !closed {
return Err("Unclosed MongoDB regex literal.".to_string());
}
let mut options = Vec::new();
while cursor < input.len() {
let option = input[cursor..].chars().next().ok_or("Invalid MongoDB regex literal.")?;
if !option.is_ascii_alphabetic() {
break;
}
// JS-only regex flags (d/g/v/y) have no server-side meaning for a
// stored regex literal — MongoDB's $regex has no global modifier — so
// drop them instead of failing the whole command.
if matches!(option, 'd' | 'g' | 'v' | 'y') {
cursor += option.len_utf8();
continue;
}
if !matches!(option, 'i' | 'm' | 's' | 'u') || options.contains(&option) {
return Err(format!("Unsupported or duplicate MongoDB regex option: {option}"));
}
options.push(option);
cursor += option.len_utf8();
}
options.sort_unstable();
Ok(ShellRegexLiteral { end: cursor, pattern, options: options.into_iter().collect() })
}
fn transform_shell_regex_literals(input: &str) -> Result<String, String> {
let mut output = String::with_capacity(input.len());
let mut index = 0;
while index < input.len() {
let rest = &input[index..];
let ch = rest.chars().next().ok_or("Invalid MongoDB argument.")?;
if matches!(ch, '"' | '\'') {
let start = index;
index += ch.len_utf8();
let mut escaped = false;
while index < input.len() {
let current = input[index..].chars().next().ok_or("Invalid MongoDB argument.")?;
index += current.len_utf8();
if escaped {
escaped = false;
} else if current == '\\' {
escaped = true;
} else if current == ch {
break;
}
}
output.push_str(&input[start..index]);
continue;
}
if rest.starts_with("//") {
let end = rest.find(['\n', '\r']).map(|offset| index + offset).unwrap_or(input.len());
output.push_str(&input[index..end]);
index = end;
continue;
}
if rest.starts_with("/*") {
let end = rest.find("*/").map(|offset| index + offset + 2).unwrap_or(input.len());
output.push_str(&input[index..end]);
index = end;
continue;
}
if ch != '/' || !is_shell_regex_value_position(input, index) {
output.push(ch);
index += ch.len_utf8();
continue;
}
let literal = shell_regex_literal_at(input, index)?.ok_or("Invalid MongoDB regex literal.")?;
output.push_str(
&serde_json::to_string(&serde_json::json!({
"$regularExpression": {
"pattern": literal.pattern,
"options": literal.options,
}
}))
.map_err(|error| error.to_string())?,
);
index = literal.end;
}
Ok(output)
}
fn is_shell_regex_value_position(input: &str, index: usize) -> bool {
let mut previous_significant = None;
let mut cursor = 0;
while cursor < index {
let rest = &input[cursor..];
let current = rest.chars().next().expect("cursor is on a character boundary");
if matches!(current, '"' | '\'') {
let quote = current;
cursor += current.len_utf8();
let mut escaped = false;
while cursor < index {
let character = input[cursor..].chars().next().expect("cursor is on a character boundary");
cursor += character.len_utf8();
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == quote {
break;
}
}
previous_significant = Some('\0');
continue;
}
if rest.starts_with("//") {
cursor = rest.find(['\n', '\r']).map(|offset| cursor + offset).unwrap_or(index).min(index);
continue;
}
if rest.starts_with("/*") {
cursor = rest.find("*/").map(|offset| cursor + offset + 2).unwrap_or(index).min(index);
continue;
}
if current == '/' && is_shell_regex_value_prefix(previous_significant) {
if let Ok(literal) = read_shell_regex_literal(input, cursor) {
if literal.end <= index {
previous_significant = Some('\0');
cursor = literal.end;
continue;
}
}
}
if !current.is_whitespace() {
previous_significant = Some(current);
}
cursor += current.len_utf8();
}
is_shell_regex_value_prefix(previous_significant)
}
fn is_shell_regex_value_prefix(previous_significant: Option<char>) -> bool {
previous_significant.is_none_or(|character| matches!(character, ':' | '[' | ',' | '('))
}
fn transform_shell_constructors(input: &str) -> Result<String, String> {
let mut output = String::with_capacity(input.len());
let mut index = 0;
while index < input.len() {
let rest = &input[index..];
let ch = rest.chars().next().ok_or("Invalid MongoDB argument.")?;
if matches!(ch, '"' | '\'') {
let start = index;
index += ch.len_utf8();
let mut escaped = false;
while index < input.len() {
let current = input[index..].chars().next().ok_or("Invalid MongoDB argument.")?;
index += current.len_utf8();
if escaped {
escaped = false;
} else if current == '\\' {
escaped = true;
} else if current == ch {
break;
}
}
output.push_str(&input[start..index]);
continue;
}
let constructor = if rest.starts_with("ObjectId(") {
Some("ObjectId(")
} else if rest.starts_with("ISODate(") {
Some("ISODate(")
} else {
None
};
let Some(constructor) = constructor else {
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<Value> {
serde_json::from_str(value).ok()
}
fn optional_json_argument(value: Option<&String>) -> Result<Option<String>, String> {
value.filter(|value| !value.trim().is_empty()).map(|value| normalized_json(value)).transpose()
}
fn legacy_update_options(value: Option<&String>) -> Result<(Option<String>, 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<String> {
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<usize> {
let mut depth = 0;
let mut quote = None;
let mut escape = false;
let mut index = open;
while index < source.len() {
let ch = source[index..].chars().next()?;
if escape {
escape = false;
index += ch.len_utf8();
continue;
}
if quote.is_some() {
if ch == '\\' {
escape = true;
} else if Some(ch) == quote {
quote = None;
}
index += ch.len_utf8();
continue;
}
if ch == '\'' || ch == '"' || ch == '`' {
quote = Some(ch);
} else if ch == '/' && is_shell_regex_value_position(source, index) {
if let Ok(Some(literal)) = shell_regex_literal_at(source, index) {
index = literal.end;
continue;
}
} else if ch == '(' {
depth += 1;
} else if ch == ')' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += ch.len_utf8();
}
None
}
fn split_top_level(source: &str) -> Vec<String> {
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;
let mut index = 0;
while index < source.len() {
let ch = source[index..].chars().next().expect("index is on a character boundary");
if escape {
escape = false;
index += ch.len_utf8();
continue;
}
if quote.is_some() {
if ch == '\\' {
escape = true;
} else if Some(ch) == quote {
quote = None;
}
index += ch.len_utf8();
continue;
}
if ch == '\'' || ch == '"' || ch == '`' {
quote = Some(ch);
} else if ch == '/' && is_shell_regex_value_position(source, index) {
if let Ok(Some(literal)) = shell_regex_literal_at(source, index) {
index = literal.end;
continue;
}
} 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;
}
index += ch.len_utf8();
}
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_mongo_shell_regex_literals_in_update_filters() {
let command = parse(
r#"db.code_info.updateMany(
{ level: "SECOND_LAYER", position: "B", packagingRatio: /^[^::]*盒/, type: { $ne: "PACK_IN" } },
{ $set: { type: "PACK_IN" }, $currentDate: { lastUpdateTime: true } }
)"#,
)
.unwrap();
let MongoCommand::Update { collection, filter, many, .. } = command else {
panic!("expected update command");
};
assert_eq!(collection, "code_info");
assert!(many);
assert_eq!(
serde_json::from_str::<Value>(&filter).unwrap(),
serde_json::json!({
"level": "SECOND_LAYER",
"position": "B",
"packagingRatio": {
"$regularExpression": {
"pattern": "^[^::]*盒",
"options": "",
}
},
"type": { "$ne": "PACK_IN" },
})
);
}
#[test]
fn drops_js_only_regex_literal_flags() {
let command = parse(r#"db.items.find({ value: /abc/gi })"#).unwrap();
let MongoCommand::Find { filter, .. } = command else {
panic!("expected find command");
};
assert_eq!(
serde_json::from_str::<Value>(&filter).unwrap(),
serde_json::json!({
"value": {
"$regularExpression": {
"pattern": "abc",
"options": "i",
}
}
})
);
}
#[test]
fn ignores_delimiters_inside_mongo_shell_regex_literals() {
let command = parse(r#"db.items.updateMany({ value: /a\),b/ }, { $set: { matched: true } })"#).unwrap();
let MongoCommand::Update { filter, many, .. } = command else {
panic!("expected update command");
};
assert!(many);
assert_eq!(
serde_json::from_str::<Value>(&filter).unwrap(),
serde_json::json!({
"value": {
"$regularExpression": {
"pattern": r#"a\),b"#,
"options": "",
}
}
})
);
}
#[test]
fn normalizes_regex_literal_edges_without_touching_strings_or_comments() {
let normalized = normalized_json(
r#"{
pattern: /a\/b[/::]/mi,
constructorText: "ObjectId('literal')",
url: "https://example.com/a/b",
// slash comments stay comments
active: true,
}"#,
)
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&normalized).unwrap(),
serde_json::json!({
"pattern": {
"$regularExpression": {
"pattern": r#"a\/b[/::]"#,
"options": "im",
}
},
"constructorText": "ObjectId('literal')",
"url": "https://example.com/a/b",
"active": true,
})
);
for source in ["{pattern: /unterminated}", "{pattern: /value/ii}", "{pattern: /value/q}"] {
assert!(normalized_json(source).is_err(), "{source}");
}
}
#[test]
fn normalizes_regex_literals_after_comments() {
let normalized = normalized_json(
r#"{
block: /* explain the pattern */ /block/i,
line: // explain the next pattern
/line/m,
}"#,
)
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&normalized).unwrap(),
serde_json::json!({
"block": { "$regularExpression": { "pattern": "block", "options": "i" } },
"line": { "$regularExpression": { "pattern": "line", "options": "m" } },
})
);
}
#[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());
}
}