From 71cc734cdd30e0641a9fcb6ffd5f13d0218409a4 Mon Sep 17 00:00:00 2001 From: Loc Nguyen <113083148+thailoc-dev@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:26:05 +0700 Subject: [PATCH] fix(mongo): support bson shell literals in filters and queries --- crates/dbx-core/src/db/mongo_driver.rs | 89 ++++++++- crates/dbx-core/src/mongo_shell.rs | 186 ++++++++++++++++++- packages/app-tests/mongoShellCommand.test.ts | 66 +++++++ packages/mongo-shell/src/json.ts | 62 ++++++- 4 files changed, 398 insertions(+), 5 deletions(-) diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 6b021124fb..514a51a05e 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -2695,7 +2695,15 @@ fn json_filter_value_to_bson(value: &serde_json::Value, field_name: Option<&str> } serde_json::Value::Object(obj) => { if obj.len() == 1 { - if obj.contains_key("$regularExpression") { + // Shell constructor wrappers (UUID/BinData/Timestamp/MinKey/MaxKey) + // and $regularExpression decode through the shared extended JSON + // parser, following the same precedent as $date below. + if obj.keys().next().is_some_and(|key| { + matches!( + key.as_str(), + "$regularExpression" | "$uuid" | "$binary" | "$timestamp" | "$minKey" | "$maxKey" + ) + }) { if let Ok(Some(value)) = parse_extended_json_value(obj) { return value; } @@ -3334,6 +3342,85 @@ mod tests { assert_eq!(op.get("$gte"), Some(&Bson::DateTime(expected))); } + #[test] + fn json_filter_to_document_decodes_uuid_binary_timestamp_and_key_constants() { + // Equality on shell constructor values must compare against the typed + // BSON value, not a raw { "$uuid": ... } document the server rejects + // with "unknown operator: $uuid" (or silently matches nothing). + let filter = serde_json::json!({ + "_id": { "$uuid": "3b241101-e2bb-4255-8caf-4136c566a962" }, + "payload": { "$binary": { "base64": "AQID", "subType": "80" } }, + "moment": { "$timestamp": { "t": 1735689600, "i": 7 } }, + "lower": { "$minKey": 1 }, + "upper": { "$maxKey": 1 }, + }); + let doc = json_filter_to_document(&filter).unwrap(); + + let expected_uuid = uuid::Uuid::parse_str("3b241101-e2bb-4255-8caf-4136c566a962").unwrap(); + assert!(matches!( + doc.get("_id"), + Some(Bson::Binary(binary)) + if binary.subtype == mongodb::bson::spec::BinarySubtype::Uuid && binary.bytes == expected_uuid.as_bytes() + )); + assert!(matches!( + doc.get("payload"), + Some(Bson::Binary(binary)) + if binary.subtype == mongodb::bson::spec::BinarySubtype::UserDefined(0x80) && binary.bytes == [1, 2, 3] + )); + assert!(matches!( + doc.get("moment"), + Some(Bson::Timestamp(timestamp)) if timestamp.time == 1735689600 && timestamp.increment == 7 + )); + assert!(matches!(doc.get("lower"), Some(Bson::MinKey))); + assert!(matches!(doc.get("upper"), Some(Bson::MaxKey))); + } + + #[test] + fn json_filter_to_document_decodes_key_constants_inside_operators() { + // Range operands must be decoded too, exactly like extended JSON dates: + // { score: { $gt: MinKey } } and { _id: { $gt: UUID(...) } } would + // otherwise compare against a sub-document and silently match nothing. + // The _id case also covers the all-$ operator document branch, where + // operators like $gt keep recursing through json_filter_value_to_bson. + let filter = serde_json::json!({ + "score": { "$gt": { "$minKey": 1 }, "$lt": { "$maxKey": 1 } }, + "_id": { "$gt": { "$uuid": "3b241101-e2bb-4255-8caf-4136c566a962" } }, + "snapshot": { "$gte": { "$timestamp": { "t": 1735689600, "i": 7 } } }, + "blob": { "$eq": { "$binary": { "base64": "AQID", "subType": "00" } } }, + }); + let doc = json_filter_to_document(&filter).unwrap(); + + let Some(Bson::Document(score)) = doc.get("score") else { + panic!("expected score operator document"); + }; + assert_eq!(score.get("$gt"), Some(&Bson::MinKey)); + assert_eq!(score.get("$lt"), Some(&Bson::MaxKey)); + + let Some(Bson::Document(id_filter)) = doc.get("_id") else { + panic!("expected _id operator document"); + }; + assert!(matches!( + id_filter.get("$gt"), + Some(Bson::Binary(binary)) if binary.subtype == mongodb::bson::spec::BinarySubtype::Uuid + )); + + let Some(Bson::Document(snapshot)) = doc.get("snapshot") else { + panic!("expected snapshot operator document"); + }; + assert!(matches!( + snapshot.get("$gte"), + Some(Bson::Timestamp(timestamp)) if timestamp.time == 1735689600 && timestamp.increment == 7 + )); + + let Some(Bson::Document(blob)) = doc.get("blob") else { + panic!("expected blob operator document"); + }; + assert!(matches!( + blob.get("$eq"), + Some(Bson::Binary(binary)) if binary.subtype == mongodb::bson::spec::BinarySubtype::Generic && binary.bytes == [1, 2, 3] + )); + } + #[test] fn bson_to_json_displays_date_as_mongo_isodate() { let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap(); diff --git a/crates/dbx-core/src/mongo_shell.rs b/crates/dbx-core/src/mongo_shell.rs index 7a8fc83010..62ed7b6d79 100644 --- a/crates/dbx-core/src/mongo_shell.rs +++ b/crates/dbx-core/src/mongo_shell.rs @@ -2,6 +2,7 @@ use chrono::{SecondsFormat, Utc}; use mongodb::bson::oid::ObjectId; use serde::Serialize; use serde_json::Value; +use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] @@ -1065,7 +1066,19 @@ fn is_shell_regex_value_prefix(previous_significant: Option) -> bool { /// Shell value constructors rewritten to extended JSON before json5 parsing. /// `Date` is only recognised after `new`, matching the shell where a bare `Date()` /// returns a string rather than a date. -const SHELL_CONSTRUCTORS: [&str; 6] = ["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal"]; +const SHELL_CONSTRUCTORS: [&str; 11] = [ + "ObjectId", + "ISODate", + "Date", + "NumberLong", + "NumberInt", + "NumberDecimal", + "UUID", + "BinData", + "Timestamp", + "MinKey", + "MaxKey", +]; fn transform_shell_constructors(input: &str) -> Result { let mut output = String::with_capacity(input.len()); @@ -1096,12 +1109,43 @@ fn transform_shell_constructors(input: &str) -> Result { index = end; continue; } + if let Some((json, end)) = shell_bare_constant(input, index) { + output.push_str(&json); + index = end; + continue; + } output.push(ch); index += ch.len_utf8(); } Ok(output) } +/// Rewrite a bare `MinKey` / `MaxKey` at `index`, as in `{ $lt: MaxKey }`. +/// A following `:` means it is an object key, not a value. +fn shell_bare_constant(input: &str, index: usize) -> Option<(String, usize)> { + if input[..index].ends_with(|ch: char| ch.is_alphanumeric() || matches!(ch, '_' | '$' | '.')) { + return None; + } + let rest = &input[index..]; + let name = ["MinKey", "MaxKey"].into_iter().find(|name| rest.starts_with(name))?; + let after = &rest[name.len()..]; + if after.starts_with(|ch: char| ch.is_alphanumeric() || matches!(ch, '_' | '$')) { + return None; + } + if after.trim_start().starts_with([':', '(']) { + return None; + } + Some((key_constant_json(name), index + name.len())) +} + +fn key_constant_json(name: &str) -> String { + if name == "MinKey" { + r#"{"$minKey":1}"#.to_string() + } else { + r#"{"$maxKey":1}"#.to_string() + } +} + /// Rewrite one `Name(...)` / `new Name(...)` call at `index`, or None when it is not a known constructor. fn shell_constructor_call(input: &str, index: usize) -> Result, String> { let rest = &input[index..]; @@ -1133,8 +1177,15 @@ fn shell_constructor_call(input: &str, index: usize) -> Result Result { + if matches!(name, "BinData" | "Timestamp") { + return two_argument_constructor_to_extended_json(name, inner); + } let integer = inner.parse::().ok(); match name { + "MinKey" | "MaxKey" if inner.is_empty() => Ok(key_constant_json(name)), + "MinKey" | "MaxKey" => Err(format!("MongoDB {name}() takes no arguments.")), + "UUID" if inner.is_empty() => Ok(extended_json("$uuid", &Uuid::new_v4().to_string())), + "UUID" => Ok(extended_json("$uuid", &parse_uuid_arg(inner)?)), "ObjectId" if inner.is_empty() => Ok(extended_json("$oid", &ObjectId::new().to_hex())), "ObjectId" => Ok(extended_json("$oid", &parse_string_arg(inner)?)), "ISODate" | "Date" if inner.is_empty() => { @@ -1167,6 +1218,51 @@ fn shell_constructor_to_extended_json(name: &str, inner: &str) -> Result Result { + let value = parse_string_arg(arg)?; + if is_canonical_uuid(&value) { + Ok(value) + } else { + Err("MongoDB UUID() requires a canonical 8-4-4-4-12 hex string.".to_string()) + } +} + +fn is_canonical_uuid(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 + && bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) +} + +fn two_argument_constructor_to_extended_json(name: &str, inner: &str) -> Result { + let args = split_top_level(inner); + if args.len() != 2 { + return Err(format!("MongoDB {name}() requires exactly two arguments.")); + } + if name == "Timestamp" { + // Timestamp(t, i): two unsigned 32-bit integers, seconds and ordinal. + let parse = |value: &str| value.trim().parse::(); + return match (parse(&args[0]), parse(&args[1])) { + (Ok(seconds), Ok(ordinal)) => Ok(format!(r#"{{"$timestamp":{{"t":{seconds},"i":{ordinal}}}}}"#)), + _ => Err("MongoDB Timestamp() requires two unsigned 32-bit integers.".to_string()), + }; + } + // BinData(subType, base64): extended JSON carries the subtype as two hex digits. + let sub_type = args[0] + .trim() + .parse::() + .map_err(|_| "MongoDB BinData() subtype must be an integer from 0 to 255.".to_string())?; + let base64 = parse_string_arg(&args[1])?; + Ok(format!( + r#"{{"$binary":{{"base64":{},"subType":"{sub_type:02x}"}}}}"#, + serde_json::to_string(&base64).unwrap_or_else(|_| "null".to_string()) + )) +} + fn extended_json(key: &str, value: &str) -> String { format!("{{\"{key}\":{}}}", serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())) } @@ -1570,6 +1666,94 @@ mod tests { ); } + #[test] + fn rewrites_uuid_binary_timestamp_and_key_constants() { + let MongoCommand::Find { filter, .. } = parse( + r#"db.c.find({ + u: UUID("3b241101-e2bb-4255-8caf-4136c566a962"), + b: BinData(128, "AQID"), + t: Timestamp(1735689600, 7), + lo: MinKey, + hi: MaxKey(), + range: {$gt: MinKey(), $lt: MaxKey}, + list: [MinKey, MaxKey] + })"#, + ) + .unwrap() else { + panic!("expected a find command"); + }; + assert_eq!( + parse_json_value(&filter).unwrap(), + serde_json::json!({ + "u": {"$uuid": "3b241101-e2bb-4255-8caf-4136c566a962"}, + "b": {"$binary": {"base64": "AQID", "subType": "80"}}, + "t": {"$timestamp": {"t": 1735689600, "i": 7}}, + "lo": {"$minKey": 1}, + "hi": {"$maxKey": 1}, + "range": {"$gt": {"$minKey": 1}, "$lt": {"$maxKey": 1}}, + "list": [{"$minKey": 1}, {"$maxKey": 1}], + }) + ); + + let MongoCommand::Find { filter, .. } = parse("db.c.find({u: UUID()})").unwrap() else { + panic!("expected a find command"); + }; + let generated = parse_json_value(&filter).unwrap()["u"]["$uuid"].as_str().unwrap().to_string(); + assert!(Uuid::parse_str(&generated).is_ok(), "{generated}"); + } + + #[test] + fn leaves_key_constant_names_alone_outside_value_positions() { + let MongoCommand::Find { filter, .. } = + parse(r#"db.c.find({MinKey: 1, MaxKey: 2, label: "MinKey", nested: {MaxKey: true}})"#).unwrap() + else { + panic!("expected a find command"); + }; + assert_eq!(filter, r#"{"MinKey":1,"MaxKey":2,"label":"MinKey","nested":{"MaxKey":true}}"#); + } + + #[test] + fn rejects_malformed_uuid_binary_timestamp_and_key_constants() { + for (source, expected) in [ + (r#"db.c.find({b: BinData(256, "x")})"#, "BinData"), + (r#"db.c.find({b: BinData(0)})"#, "BinData"), + (r#"db.c.find({b: BinData("00", "x")})"#, "BinData"), + (r#"db.c.find({t: Timestamp(1)})"#, "Timestamp"), + (r#"db.c.find({t: Timestamp(-1, 0)})"#, "Timestamp"), + (r#"db.c.find({t: Timestamp(4294967296, 0)})"#, "Timestamp"), + (r#"db.c.find({u: UUID(1)})"#, "string"), + (r#"db.c.find({a: MinKey(1)})"#, "MinKey"), + (r#"db.c.find({a: MaxKey("x")})"#, "MaxKey"), + ] { + let error = parse(source).unwrap_err(); + assert!(error.contains(expected), "{source} => {error}"); + } + } + + #[test] + fn validates_uuid_strings_at_parse_time() { + // mongosh requires the canonical 8-4-4-4-12 hex form: dashes at fixed + // positions, hex digits elsewhere, upper or lower case. + for source in [ + r#"db.c.find({u: UUID("3b241101e2bb42558caf4136c566a962")})"#, + r#"db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a96")})"#, + r#"db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a9620")})"#, + r#"db.c.find({u: UUID("zb241101-e2bb-4255-8caf-4136c566a962")})"#, + r#"db.c.find({u: UUID("3b241101_e2bb_4255_8caf_4136c566a962")})"#, + ] { + let error = parse(source).unwrap_err(); + assert!(error.contains("UUID() requires"), "{source} => {error}"); + } + + // Upper-case hex is accepted and preserved verbatim. + let MongoCommand::Find { filter, .. } = + parse(r#"db.c.find({u: UUID("3B241101-E2BB-4255-8CAF-4136C566A962")})"#).unwrap() + else { + panic!("expected a find command"); + }; + assert_eq!(filter, r#"{"u":{"$uuid":"3B241101-E2BB-4255-8CAF-4136C566A962"}}"#); + } + #[test] fn parses_show_databases_aliases_as_read_only() { for source in [ diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index f6c424a331..8ea9239722 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -380,6 +380,72 @@ test('parseMongoCommand accepts db["name"] bracket collection accessors', () => } }); +test("parseMongoFindCommand rewrites UUID, BinData, Timestamp and MinKey/MaxKey", () => { + const command = parseMongoFindCommand(`db.c.find({ + u: UUID("3b241101-e2bb-4255-8caf-4136c566a962"), + b: BinData(128, "AQID"), + t: Timestamp(1735689600, 7), + lo: MinKey, + hi: MaxKey(), + range: {$gt: MinKey(), $lt: MaxKey}, + list: [MinKey, MaxKey] + })`); + assert.ok(command); + assert.deepEqual(JSON.parse(command.filter), { + u: { $uuid: "3b241101-e2bb-4255-8caf-4136c566a962" }, + b: { $binary: { base64: "AQID", subType: "80" } }, + t: { $timestamp: { t: 1735689600, i: 7 } }, + lo: { $minKey: 1 }, + hi: { $maxKey: 1 }, + range: { $gt: { $minKey: 1 }, $lt: { $maxKey: 1 } }, + list: [{ $minKey: 1 }, { $maxKey: 1 }], + }); + + const generated = JSON.parse(parseMongoFindCommand("db.c.find({u: UUID()})")!.filter).u.$uuid; + assert.match(generated, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); +}); + +test("parseMongoFindCommand leaves MinKey/MaxKey alone outside value positions", () => { + const command = parseMongoFindCommand('db.c.find({MinKey: 1, MaxKey: 2, label: "MinKey", nested: {MaxKey: true}})'); + assert.ok(command); + assert.deepEqual(JSON.parse(command.filter), { MinKey: 1, MaxKey: 2, label: "MinKey", nested: { MaxKey: true } }); +}); + +test("parseMongoFindCommand rejects malformed UUID, BinData, Timestamp and MinKey/MaxKey", () => { + for (const source of [ + 'db.c.find({b: BinData(256, "x")})', + "db.c.find({b: BinData(0)})", + 'db.c.find({b: BinData("00", "x")})', + "db.c.find({t: Timestamp(1)})", + "db.c.find({t: Timestamp(-1, 0)})", + "db.c.find({t: Timestamp(4294967296, 0)})", + "db.c.find({u: UUID(1)})", + "db.c.find({a: MinKey(1)})", + 'db.c.find({a: MaxKey("x")})', + ]) { + assert.equal(parseMongoFindCommand(source), null, source); + } +}); + +test("parseMongoFindCommand validates UUID strings at parse time", () => { + // mongosh requires the canonical 8-4-4-4-12 hex form: dashes at fixed + // positions, hex digits elsewhere. + for (const source of [ + 'db.c.find({u: UUID("3b241101e2bb42558caf4136c566a962")})', + 'db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a96")})', + 'db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a9620")})', + 'db.c.find({u: UUID("zb241101-e2bb-4255-8caf-4136c566a962")})', + 'db.c.find({u: UUID("3b241101_e2bb_4255_8caf_4136c566a962")})', + ]) { + assert.equal(parseMongoFindCommand(source), null, source); + } + + // Upper-case hex is accepted and preserved verbatim. + const command = parseMongoFindCommand('db.c.find({u: UUID("3B241101-E2BB-4255-8CAF-4136C566A962")})'); + assert.ok(command); + assert.deepEqual(JSON.parse(command.filter), { u: { $uuid: "3B241101-E2BB-4255-8CAF-4136C566A962" } }); +}); + test("parseMongoFindCommand accepts single-quoted string values and unquoted sort keys", () => { const command = parseMongoFindCommand("db.products.find({category: 'Electronics'}).sort({price: -1}).limit(2)"); assert.ok(command); diff --git a/packages/mongo-shell/src/json.ts b/packages/mongo-shell/src/json.ts index 5f5298e86c..b078713f65 100644 --- a/packages/mongo-shell/src/json.ts +++ b/packages/mongo-shell/src/json.ts @@ -14,7 +14,7 @@ export function normalizeJsonArgument(value: string): string | null { const withoutEjsonDeserialize = replaceMongoEjsonDeserialize(withoutComments); // Rewrite mongo shell constructors that are not valid JSON into extended JSON // (mongo_driver::json_value_to_bson): ObjectId / ISODate / new Date / NumberLong / - // NumberInt / NumberDecimal. + // NumberInt / NumberDecimal / UUID / BinData / Timestamp / MinKey / MaxKey. const withExtendedJson = replaceMongoShellConstructors(withoutEjsonDeserialize); const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson)); try { @@ -546,11 +546,15 @@ function replaceMongoEjsonDeserialize(source: string): string { * `Date` is only recognised after `new`, matching the shell where a bare `Date()` * returns a string rather than a date. */ -const SHELL_CONSTRUCTORS = new Set(["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal"]); +const SHELL_CONSTRUCTORS = new Set(["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey"]); +/** `MinKey` / `MaxKey` are also valid without parentheses, as in `{ $lt: MaxKey }`. */ +const BARE_KEY_CONSTANT = /^(MinKey|MaxKey)(?![\w$])/; const CONSTRUCTOR_CALL = /^(new\s+)?([A-Za-z_$][\w$]*)\s*\(/; const QUOTED_ARGUMENT = /^(["'])([^\\]*)\1$/; const INTEGER_ARGUMENT = /^-?\d+$/; const DECIMAL_ARGUMENT = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/; +/** Canonical 8-4-4-4-12 hex form, as mongosh requires for `UUID("...")`. */ +const UUID_ARGUMENT = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; const INT64_BOUNDS = [-9223372036854775808n, 9223372036854775807n] as const; const INT32_BOUNDS = [-2147483648n, 2147483647n] as const; @@ -582,7 +586,7 @@ function replaceMongoShellConstructors(source: string): string { result += source.slice(start, index); continue; } - const call = matchShellConstructorCall(source, index); + const call = matchShellConstructorCall(source, index) ?? matchShellBareConstant(source, index); if (!call) { result += source[index++]!; continue; @@ -593,6 +597,21 @@ function replaceMongoShellConstructors(source: string): string { return result; } +/** Rewrite a bare `MinKey` / `MaxKey` at {@link index}; a following `:` means it is an object key, not a value. */ +function matchShellBareConstant(source: string, index: number): { json: string; end: number } | null { + if (index > 0 && /[\w$.]/.test(source[index - 1]!)) return null; + const match = BARE_KEY_CONSTANT.exec(source.slice(index)); + if (!match) return null; + const end = index + match[0].length; + const following = source.slice(end).trimStart(); + if (following.startsWith(":") || following.startsWith("(")) return null; + return { json: keyConstantJson(match[1]!), end }; +} + +function keyConstantJson(name: string): string { + return name === "MinKey" ? '{"$minKey":1}' : '{"$maxKey":1}'; +} + /** Rewrite one `Name(...)` / `new Name(...)` call at {@link index}, or null when it is not a known constructor. */ function matchShellConstructorCall(source: string, index: number): { json: string; end: number } | null { const match = CONSTRUCTOR_CALL.exec(source.slice(index)); @@ -612,11 +631,19 @@ function matchShellConstructorCall(source: string, index: number): { json: strin } function shellConstructorToExtendedJson(name: string, args: string[]): string | null { + // Two-argument constructors first; everything else takes at most one. + if (name === "BinData" || name === "Timestamp") return twoArgumentConstructorToExtendedJson(name, args); if (args.length > 1) return null; const arg = args[0]?.trim(); const literal = arg ? (QUOTED_ARGUMENT.exec(arg)?.[2] ?? null) : null; switch (name) { + case "MinKey": + case "MaxKey": + return arg ? null : keyConstantJson(name); + case "UUID": + if (!arg) return wrap("$uuid", generateUuid()); + return literal !== null && UUID_ARGUMENT.test(literal) ? wrap("$uuid", literal) : null; case "ObjectId": if (!arg) return wrap("$oid", generateObjectIdHex()); return literal !== null || INTEGER_ARGUMENT.test(arg) ? wrap("$oid", literal ?? arg) : null; @@ -644,6 +671,35 @@ function shellConstructorToExtendedJson(name: string, args: string[]): string | } } +const UINT32_BOUNDS = [0n, 4294967295n] as const; +const BINARY_SUBTYPE_BOUNDS = [0n, 255n] as const; + +function twoArgumentConstructorToExtendedJson(name: "BinData" | "Timestamp", args: string[]): string | null { + if (args.length !== 2) return null; + const [first, second] = args.map((value) => value.trim()) as [string, string]; + if (name === "Timestamp") { + // Timestamp(t, i): two unsigned 32-bit integers, seconds and ordinal. + if (!INTEGER_ARGUMENT.test(first) || !INTEGER_ARGUMENT.test(second)) return null; + if (!fitsIntegerBounds(first, UINT32_BOUNDS) || !fitsIntegerBounds(second, UINT32_BOUNDS)) return null; + return `{"$timestamp":{"t":${first},"i":${second}}}`; + } + // BinData(subType, base64): extended JSON carries the subtype as two hex digits. + const base64 = QUOTED_ARGUMENT.exec(second)?.[2]; + if (base64 === undefined || !INTEGER_ARGUMENT.test(first) || !fitsIntegerBounds(first, BINARY_SUBTYPE_BOUNDS)) return null; + const subType = Number(first).toString(16).padStart(2, "0"); + return `{"$binary":{"base64":${JSON.stringify(base64)},"subType":${JSON.stringify(subType)}}}`; +} + +/** Client-side UUID for a bare `UUID()`, mirroring how the shell fills one in. */ +function generateUuid(): string { + if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID(); + const hex = randomHex(16).split(""); + hex[12] = "4"; + hex[16] = "89ab"[Number.parseInt(hex[16]!, 16) & 3]!; + const raw = hex.join(""); + return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`; +} + function wrap(key: string, value: string): string { return `{${JSON.stringify(key)}:${JSON.stringify(value)}}`; }