From 7b18186ae21d44d71235d8d8248103bef0eeb9f2 Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Wed, 24 Sep 2025 13:58:34 -0600 Subject: [PATCH 01/10] add options structs --- src/action/csfle/encrypt.rs | 64 +++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/action/csfle/encrypt.rs b/src/action/csfle/encrypt.rs index 066427cd7..993dea16a 100644 --- a/src/action/csfle/encrypt.rs +++ b/src/action/csfle/encrypt.rs @@ -150,6 +150,70 @@ pub struct RangeOptions { pub precision: Option, } +/// Options for a queryable encryption field supporting text queries. +#[derive(Clone, Default, Debug, Serialize, TypedBuilder)] +#[serde(rename_all = "camelCase")] +#[builder(field_defaults(default, setter(into)))] +#[non_exhaustive] +pub struct TextOptions { + /// Options for substring queries. + substring: Option, + + /// Options for prefix queries. + prefix: Option, + + /// Options for suffix queries. + suffix: Option, + + /// Whether text indexes for this field are case-sensitive. Defaults to isabeltodo. + case_sensitive: Option, + + /// Whether text indexes for this field are diacritic-sensitive. Defaults to isabeltodo. + diacritic_sensitive: Option, +} + +/// Options for substring queries. +#[derive(Clone, Default, Debug, Serialize, TypedBuilder)] +#[serde(rename_all = "camelCase")] +#[builder(field_defaults(default, setter(into)))] +#[non_exhaustive] +pub struct SubstringOptions { + /// The maximum allowed string length. Inserting a longer string will result in an error. + max_string_length: Option, + + /// The minimum allowed query length. Querying with a shorter string will result in an error. + min_query_length: Option, + + /// The maximum allowed query length. Querying with a longer string will result in an error. + max_query_length: Option, +} + +/// Options for prefix queries. +#[derive(Clone, Default, Debug, Serialize, TypedBuilder)] +#[serde(rename_all = "camelCase")] +#[builder(field_defaults(default, setter(into)))] +#[non_exhaustive] +pub struct PrefixOptions { + /// The minimum allowed query length. Querying with a shorter string will result in an error. + min_query_length: Option, + + /// The maximum allowed query length. Querying with a longer string will result in an error. + max_query_length: Option, +} + +/// Options for suffix queries. +#[derive(Clone, Default, Debug, Serialize, TypedBuilder)] +#[serde(rename_all = "camelCase")] +#[builder(field_defaults(default, setter(into)))] +#[non_exhaustive] +pub struct SuffixOptions { + /// The minimum allowed query length. Querying with a shorter string will result in an error. + min_query_length: Option, + + /// The maximum allowed query length. Querying with a longer string will result in an error. + max_query_length: Option, +} + #[option_setters(EncryptOptions, skip = [query_type])] #[export_doc(encrypt, extra = [query_type])] #[export_doc(encrypt_expr)] From 5bb06c081f9027b2d4fc84fd1da120a091368f05 Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Wed, 24 Sep 2025 14:02:15 -0600 Subject: [PATCH 02/10] use local dep --- Cargo.lock | 2 -- Cargo.toml | 7 ++++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50526ff18..ab4548560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2004,7 +2004,6 @@ dependencies = [ [[package]] name = "mongocrypt" version = "0.3.1" -source = "git+https://github.com/mongodb/libmongocrypt-rust.git?branch=main#132757496c04007b0482f9014dbc616553f1f916" dependencies = [ "bson 2.15.0", "bson 3.0.0", @@ -2016,7 +2015,6 @@ dependencies = [ [[package]] name = "mongocrypt-sys" version = "0.1.4+1.12.0" -source = "git+https://github.com/mongodb/libmongocrypt-rust.git?branch=main#132757496c04007b0482f9014dbc616553f1f916" [[package]] name = "mongodb" diff --git a/Cargo.toml b/Cargo.toml index d72b373b2..542cacc08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,9 +158,10 @@ optional = true features = ["serde"] [dependencies.mongocrypt] -version = "0.3.1" -git = "https://github.com/mongodb/libmongocrypt-rust.git" -branch = "main" +# version = "0.3.1" +# git = "https://github.com/mongodb/libmongocrypt-rust.git" +# branch = "main" +path = "/Users/isabel.atkinson/libmongocrypt-rust/mongocrypt" default-features = false optional = true From 4363a636b56f58c24602bc2cd92a0e2d01597071 Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Wed, 24 Sep 2025 14:56:42 -0600 Subject: [PATCH 03/10] add field, tentative: remove optionals --- src/action/csfle/encrypt.rs | 34 +++++++++++++------ src/client/csfle/client_encryption/encrypt.rs | 4 +++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/action/csfle/encrypt.rs b/src/action/csfle/encrypt.rs index 993dea16a..b6c5b60c6 100644 --- a/src/action/csfle/encrypt.rs +++ b/src/action/csfle/encrypt.rs @@ -123,6 +123,10 @@ pub struct EncryptOptions { /// Set the range options. This should only be set when the algorithm is /// [`Algorithm::Range`]. pub range_options: Option, + + /// Set the text options. This should only be set when the algorithm is + /// [`Algorithm::TextPreview`]. + pub text_options: Option, } /// The index options for a Queryable Encryption field supporting "range" queries. @@ -151,6 +155,7 @@ pub struct RangeOptions { } /// Options for a queryable encryption field supporting text queries. +#[skip_serializing_none] #[derive(Clone, Default, Debug, Serialize, TypedBuilder)] #[serde(rename_all = "camelCase")] #[builder(field_defaults(default, setter(into)))] @@ -165,11 +170,11 @@ pub struct TextOptions { /// Options for suffix queries. suffix: Option, - /// Whether text indexes for this field are case-sensitive. Defaults to isabeltodo. - case_sensitive: Option, + /// Whether text indexes for this field are case-sensitive. + case_sensitive: bool, - /// Whether text indexes for this field are diacritic-sensitive. Defaults to isabeltodo. - diacritic_sensitive: Option, + /// Whether text indexes for this field are diacritic-sensitive. + diacritic_sensitive: bool, } /// Options for substring queries. @@ -179,13 +184,16 @@ pub struct TextOptions { #[non_exhaustive] pub struct SubstringOptions { /// The maximum allowed string length. Inserting a longer string will result in an error. - max_string_length: Option, + #[serde(rename = "strMaxLength")] + max_string_length: i32, /// The minimum allowed query length. Querying with a shorter string will result in an error. - min_query_length: Option, + #[serde(rename = "strMinQueryLength")] + min_query_length: i32, /// The maximum allowed query length. Querying with a longer string will result in an error. - max_query_length: Option, + #[serde(rename = "strMaxQueryLength")] + max_query_length: i32, } /// Options for prefix queries. @@ -195,10 +203,12 @@ pub struct SubstringOptions { #[non_exhaustive] pub struct PrefixOptions { /// The minimum allowed query length. Querying with a shorter string will result in an error. - min_query_length: Option, + #[serde(rename = "strMinQueryLength")] + min_query_length: i32, /// The maximum allowed query length. Querying with a longer string will result in an error. - max_query_length: Option, + #[serde(rename = "strMaxQueryLength")] + max_query_length: i32, } /// Options for suffix queries. @@ -208,10 +218,12 @@ pub struct PrefixOptions { #[non_exhaustive] pub struct SuffixOptions { /// The minimum allowed query length. Querying with a shorter string will result in an error. - min_query_length: Option, + #[serde(rename = "strMinQueryLength")] + min_query_length: i32, /// The maximum allowed query length. Querying with a longer string will result in an error. - max_query_length: Option, + #[serde(rename = "strMaxQueryLength")] + max_query_length: i32, } #[option_setters(EncryptOptions, skip = [query_type])] diff --git a/src/client/csfle/client_encryption/encrypt.rs b/src/client/csfle/client_encryption/encrypt.rs index 6bcc62349..088b7393c 100644 --- a/src/client/csfle/client_encryption/encrypt.rs +++ b/src/client/csfle/client_encryption/encrypt.rs @@ -86,6 +86,10 @@ impl ClientEncryption { let options_doc = crate::bson_compat::serialize_to_document(range_options)?; builder = builder.algorithm_range(options_doc)?; } + if let Some(text_options) = &opts.text_options { + let options_doc = crate::bson_compat::serialize_to_document(text_options)?; + builder = builder.algorithm_text(options_doc)?; + } Ok(builder) } } From 1162bf8110e82a26d27ccd207d19e6cba1868b6a Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Thu, 25 Sep 2025 09:09:20 -0600 Subject: [PATCH 04/10] prose tests --- src/test.rs | 8 + src/test/csfle/prose.rs | 323 ++++++++++++++++++ .../data/encryptedFields-prefix-suffix.json | 38 +++ .../data/encryptedFields-substring.json | 30 ++ src/test/spec/unified_runner/test_file.rs | 10 +- 5 files changed, 402 insertions(+), 7 deletions(-) create mode 100644 src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-prefix-suffix.json create mode 100644 src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-substring.json diff --git a/src/test.rs b/src/test.rs index 9bfe66deb..2bbfb92be 100644 --- a/src/test.rs +++ b/src/test.rs @@ -259,6 +259,14 @@ pub(crate) async fn streaming_monitor_protocol_supported() -> bool { .is_some() } +#[cfg(feature = "in-use-encryption")] +pub(crate) fn mongocrypt_version_lt(version: &str) -> bool { + let mut actual_version = semver::Version::parse(mongocrypt::version()).unwrap(); + actual_version.pre = semver::Prerelease::EMPTY; + let requirement = semver::VersionReq::parse(&format!("<{version}")).unwrap(); + requirement.matches(&actual_version) +} + pub(crate) static DEFAULT_URI: LazyLock = LazyLock::new(get_default_uri); pub(crate) static SERVER_API: LazyLock> = LazyLock::new(|| match std::env::var("MONGODB_API_VERSION") { diff --git a/src/test/csfle/prose.rs b/src/test/csfle/prose.rs index f62af848e..d24e18a5a 100644 --- a/src/test/csfle/prose.rs +++ b/src/test/csfle/prose.rs @@ -13,6 +13,7 @@ use mongocrypt::ctx::Algorithm; use tokio::net::TcpListener; use crate::{ + action::csfle::encrypt::{PrefixOptions, SubstringOptions, SuffixOptions, TextOptions}, bson::{ doc, rawdoc, @@ -44,6 +45,7 @@ use crate::{ test::{ get_client_options, log_uncaptured, + mongocrypt_version_lt, server_version_lt, topology_is_standalone, util::{ @@ -2264,3 +2266,324 @@ async fn encrypt_expression_with_options() { .await .unwrap(); } + +// Prose test 27. Text explicit encryption +#[tokio::test] +async fn text_explicit_encryption() { + if server_version_lt(8, 2).await + || topology_is_standalone().await + || mongocrypt_version_lt("1.15.1") + { + log_uncaptured( + "skipping text_explicit_encryption: requires non-standalone topology, 8.2+, \ + libmongocrypt 1.15.1+", + ); + return; + } + + // Prefix test utils + let prefix_query_type = "prefixPreview"; + let text_prefix_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .prefix( + PrefixOptions::builder() + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let prefix_filter = |prefix: Binary| { + doc! { "$expr": { "$encStrStartsWith": { "input": "$encryptedText", "prefix": prefix } } } + }; + + // Suffix test utils + let suffix_query_type = "suffixPreview"; + let text_suffix_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .suffix( + SuffixOptions::builder() + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let suffix_filter = |suffix: Binary| { + doc! { "$expr": { "$encStrEndsWith": { "input": "$encryptedText", "suffix": suffix } } } + }; + + // Substring test utils + let substring_query_type = "substringPreview"; + let text_substring_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .substring( + SubstringOptions::builder() + .max_string_length(10) + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let substring_filter = |substring: Binary| { + doc! { "$expr": { "$encStrContains": { "input": "$encryptedText", "substring": substring } } } + }; + let substring_coll = + |client: &Client| client.database("db").collection::("substring"); + + // General utils + let prefix_suffix_coll = |client: &Client| { + client + .database("db") + .collection::("prefix-suffix") + }; + let expected = doc! { "_id": 0, "encryptedText": "foobarbaz" }; + let projection = doc! { "__safeContent__": 0 }; + + // Case 1: can find a document by prefix + let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + + let prefix = client_encryption + .encrypt("foo", key1_id, Algorithm::TextPreview) + .query_type(prefix_query_type) + .contention_factor(0) + .text_options(text_prefix_options.clone()) + .await + .unwrap(); + + let actual = prefix_suffix_coll(&encrypted_client) + .find_one(prefix_filter(prefix)) + .projection(projection.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(actual, expected); + + // Case 2: can find a document by suffix + let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + + let suffix = client_encryption + .encrypt("baz", key1_id, Algorithm::TextPreview) + .query_type(suffix_query_type) + .contention_factor(0) + .text_options(text_suffix_options.clone()) + .await + .unwrap(); + + let actual = prefix_suffix_coll(&encrypted_client) + .find_one(suffix_filter(suffix)) + .projection(projection.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(actual, expected); + + // Case 3: assert no document found by prefix + let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + + let prefix = client_encryption + .encrypt("baz", key1_id, Algorithm::TextPreview) + .query_type(prefix_query_type) + .contention_factor(0) + .text_options(text_prefix_options.clone()) + .await + .unwrap(); + + let actual = prefix_suffix_coll(&encrypted_client) + .find_one(prefix_filter(prefix)) + .projection(projection.clone()) + .await + .unwrap(); + assert!(actual.is_none(), "{actual:?}"); + + // Case 4: assert no document found by suffix + let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + + let suffix = client_encryption + .encrypt("foo", key1_id, Algorithm::TextPreview) + .query_type(suffix_query_type) + .contention_factor(0) + .text_options(text_suffix_options) + .await + .unwrap(); + + let actual = prefix_suffix_coll(&encrypted_client) + .find_one(suffix_filter(suffix)) + .projection(projection.clone()) + .await + .unwrap(); + assert!(actual.is_none(), "{actual:?}"); + + // Case 5: can find a document by substring + let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + + let substring = client_encryption + .encrypt("bar", key1_id, Algorithm::TextPreview) + .query_type(substring_query_type) + .contention_factor(0) + .text_options(text_substring_options.clone()) + .await + .unwrap(); + + let actual = substring_coll(&encrypted_client) + .find_one(substring_filter(substring)) + .projection(projection.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(actual, expected); + + // Case 6: assert no document found by substring + let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + + let substring = client_encryption + .encrypt("qux", key1_id, Algorithm::TextPreview) + .query_type(substring_query_type) + .contention_factor(0) + .text_options(text_substring_options) + .await + .unwrap(); + + let actual = substring_coll(&encrypted_client) + .find_one(substring_filter(substring)) + .projection(projection) + .await + .unwrap(); + assert!(actual.is_none(), "{actual:?}"); + + // Case 7: assert contentionFactor is required + let (_, client_encryption, key1_id) = text_explicit_encryption_setup().await; + let error = client_encryption + .encrypt("foo", key1_id, Algorithm::TextPreview) + .query_type(prefix_query_type) + .text_options(text_prefix_options) + .await + .unwrap_err(); + let message = error.message().unwrap(); + assert!(message.contains("contention factor is required for textPreview algorithm")); +} + +async fn text_explicit_encryption_setup() -> (Client, ClientEncryption, Binary) { + let util_client = Client::for_test().await; + let db = util_client.database("db"); + + let ps_encrypted_fields = load_testdata("data/encryptedFields-prefix-suffix.json").unwrap(); + db.collection::("prefix-suffix") + .drop() + .encrypted_fields(ps_encrypted_fields.clone()) + .await + .unwrap(); + db.create_collection("prefix-suffix") + .encrypted_fields(ps_encrypted_fields) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + let substring_encrypted_fields = load_testdata("data/encryptedFields-substring.json").unwrap(); + db.collection::("substring") + .drop() + .encrypted_fields(substring_encrypted_fields.clone()) + .await + .unwrap(); + db.create_collection("substring") + .encrypted_fields(substring_encrypted_fields) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + let key1_doc = load_testdata("data/keys/key1-document.json").unwrap(); + let key1_id = match key1_doc.get("_id").unwrap() { + Bson::Binary(b) => b.clone(), + other => panic!("expected binary, got {other}"), + }; + + let keyvault = util_client.database("keyvault"); + let datakeys = keyvault.collection("datakeys"); + datakeys.drop().await.unwrap(); + keyvault.create_collection("datakeys").await.unwrap(); + datakeys + .insert_one(key1_doc) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + let key_vault_client = Client::for_test().await; + let client_encryption = ClientEncryption::builder( + key_vault_client.into_client(), + datakeys.namespace(), + [LOCAL_KMS.clone()], + ) + .build() + .unwrap(); + + let encrypted_client = Client::encrypted_builder( + get_client_options().await.clone(), + datakeys.namespace(), + [LOCAL_KMS.clone()], + ) + .unwrap() + .bypass_query_analysis(true) + .build() + .await + .unwrap(); + + let text_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .prefix( + PrefixOptions::builder() + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .suffix( + SuffixOptions::builder() + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let encrypted_foobarbaz = client_encryption + .encrypt("foobarbaz", key1_id.clone(), Algorithm::TextPreview) + .contention_factor(0) + .text_options(text_options) + .await + .unwrap(); + + encrypted_client + .database("db") + .collection("prefix-suffix") + .insert_one(doc! { "_id": 0, "encryptedText": encrypted_foobarbaz }) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + let text_options = TextOptions::builder() + .case_sensitive(true) + .diacritic_sensitive(true) + .substring( + SubstringOptions::builder() + .max_string_length(10) + .max_query_length(10) + .min_query_length(2) + .build(), + ) + .build(); + let encrypted_foobarbaz = client_encryption + .encrypt("foobarbaz", key1_id.clone(), Algorithm::TextPreview) + .contention_factor(0) + .text_options(text_options) + .await + .unwrap(); + + encrypted_client + .database("db") + .collection("substring") + .insert_one(doc! { "_id": 0 , "encryptedText": encrypted_foobarbaz }) + .write_concern(WriteConcern::majority()) + .await + .unwrap(); + + (encrypted_client, client_encryption, key1_id) +} diff --git a/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-prefix-suffix.json b/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-prefix-suffix.json new file mode 100644 index 000000000..ec4489fa0 --- /dev/null +++ b/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-prefix-suffix.json @@ -0,0 +1,38 @@ +{ + "fields": [ + { + "keyId": { + "$binary": { + "base64": "EjRWeBI0mHYSNBI0VniQEg==", + "subType": "04" + } + }, + "path": "encryptedText", + "bsonType": "string", + "queries": [ + { + "queryType": "prefixPreview", + "strMinQueryLength": { + "$numberInt": "2" + }, + "strMaxQueryLength": { + "$numberInt": "10" + }, + "caseSensitive": true, + "diacriticSensitive": true + }, + { + "queryType": "suffixPreview", + "strMinQueryLength": { + "$numberInt": "2" + }, + "strMaxQueryLength": { + "$numberInt": "10" + }, + "caseSensitive": true, + "diacriticSensitive": true + } + ] + } + ] +} diff --git a/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-substring.json b/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-substring.json new file mode 100644 index 000000000..ee22def77 --- /dev/null +++ b/src/test/spec/json/testdata/client-side-encryption/data/encryptedFields-substring.json @@ -0,0 +1,30 @@ +{ + "fields": [ + { + "keyId": { + "$binary": { + "base64": "EjRWeBI0mHYSNBI0VniQEg==", + "subType": "04" + } + }, + "path": "encryptedText", + "bsonType": "string", + "queries": [ + { + "queryType": "substringPreview", + "strMaxLength": { + "$numberInt": "10" + }, + "strMinQueryLength": { + "$numberInt": "2" + }, + "strMaxQueryLength": { + "$numberInt": "10" + }, + "caseSensitive": true, + "diacriticSensitive": true + } + ] + } + ] +} diff --git a/src/test/spec/unified_runner/test_file.rs b/src/test/spec/unified_runner/test_file.rs index 3dd1de74a..56c4d7fe4 100644 --- a/src/test/spec/unified_runner/test_file.rs +++ b/src/test/spec/unified_runner/test_file.rs @@ -173,15 +173,11 @@ impl RunOnRequirement { Csfle::Version { min_libmongocrypt_version, } => { - let requirement = - semver::VersionReq::parse(&format!(">={min_libmongocrypt_version}")) - .unwrap(); - let mut version = semver::Version::parse(mongocrypt::version()).unwrap(); - version.pre = semver::Prerelease::EMPTY; - if !requirement.matches(&version) { + if crate::test::mongocrypt_version_lt(min_libmongocrypt_version) { return Err(format!( "requires at least libmongocrypt version {min_libmongocrypt_version} \ - but using version {version}" + but using version {},", + mongocrypt::version() )); } } From a0b9aebd7e5cbbb9201c2674c771f05359bec062 Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Fri, 26 Sep 2025 12:10:48 -0600 Subject: [PATCH 05/10] update dep to branch --- Cargo.lock | 2 ++ Cargo.toml | 7 +++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab4548560..a5ddd9288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2004,6 +2004,7 @@ dependencies = [ [[package]] name = "mongocrypt" version = "0.3.1" +source = "git+https://github.com/isabelatkinson/libmongocrypt-rust.git?branch=text-indexes#cb31b8b78340a2e12419c48eff0e5763e740d3cf" dependencies = [ "bson 2.15.0", "bson 3.0.0", @@ -2015,6 +2016,7 @@ dependencies = [ [[package]] name = "mongocrypt-sys" version = "0.1.4+1.12.0" +source = "git+https://github.com/isabelatkinson/libmongocrypt-rust.git?branch=text-indexes#cb31b8b78340a2e12419c48eff0e5763e740d3cf" [[package]] name = "mongodb" diff --git a/Cargo.toml b/Cargo.toml index 542cacc08..58f2129f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,10 +158,9 @@ optional = true features = ["serde"] [dependencies.mongocrypt] -# version = "0.3.1" -# git = "https://github.com/mongodb/libmongocrypt-rust.git" -# branch = "main" -path = "/Users/isabel.atkinson/libmongocrypt-rust/mongocrypt" +version = "0.3.1" +git = "https://github.com/isabelatkinson/libmongocrypt-rust.git" +branch = "text-indexes" default-features = false optional = true From 0de50bb4b5a5fec15a170ebc18ea9d40863961c0 Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Fri, 26 Sep 2025 12:37:00 -0600 Subject: [PATCH 06/10] add unstable feature flag --- .evergreen/run-csfle-tests.sh | 2 +- Cargo.toml | 6 ++++++ README.md | 1 + src/action/csfle/encrypt.rs | 5 +++++ src/client/csfle/client_encryption.rs | 7 +++++++ src/client/csfle/client_encryption/encrypt.rs | 1 + src/test/csfle/prose.rs | 11 +++++++++-- 7 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.evergreen/run-csfle-tests.sh b/.evergreen/run-csfle-tests.sh index e70e5d792..db6917d15 100755 --- a/.evergreen/run-csfle-tests.sh +++ b/.evergreen/run-csfle-tests.sh @@ -10,7 +10,7 @@ set -o xtrace export CSFLE_TLS_CERT_DIR="${DRIVERS_TOOLS}/.evergreen/x509gen" -FEATURE_FLAGS+=("in-use-encryption" "azure-kms") +FEATURE_FLAGS+=("in-use-encryption" "azure-kms", "text-indexes-unstable") CARGO_OPTIONS+=("--ignore-default-filter") if [[ "$OPENSSL" = true ]]; then diff --git a/Cargo.toml b/Cargo.toml index 58f2129f2..e5eb9abb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,12 @@ in-use-encryption-unstable = ["in-use-encryption"] # TODO: pending https://github.com/tokio-rs/tracing/issues/2036 stop depending directly on log. tracing-unstable = ["dep:tracing", "dep:log", "bson3?/serde_json-1"] +# Enables support for text indexes in explicit encryption. This feature is in preview and should be +# used for experimental workloads only. This feature is unstable and its security is not guaranteed +# until released as Generally Available (GA). The GA version of this feature may not be backwards +# compatible with the preview version. +text-indexes-unstable = [] + [dependencies] base64 = "0.13.0" bitflags = "1.1.0" diff --git a/README.md b/README.md index 7c9d7a9d4..a3e580712 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ features = ["sync"] | `compat-3-0-0` | Required for future compatibility if default features are disabled. | | `azure-oidc` | Enable support for Azure OIDC environment authentication. | | `gcp-oidc` | Enable support for GCP OIDC environment authentication. | +| `text-indexes-unstable` | Enables support for text indexes in explicit encryption. This feature is in preview and should be used for experimental workloads only. This feature is unstable and its security is not guaranteed until released as Generally Available (GA). The GA version of this feature may not be backwards compatible with the preview version. | ## Web Framework Examples diff --git a/src/action/csfle/encrypt.rs b/src/action/csfle/encrypt.rs index b6c5b60c6..d03db2dd0 100644 --- a/src/action/csfle/encrypt.rs +++ b/src/action/csfle/encrypt.rs @@ -126,6 +126,7 @@ pub struct EncryptOptions { /// Set the text options. This should only be set when the algorithm is /// [`Algorithm::TextPreview`]. + #[cfg(feature = "text-indexes-unstable")] pub text_options: Option, } @@ -160,6 +161,7 @@ pub struct RangeOptions { #[serde(rename_all = "camelCase")] #[builder(field_defaults(default, setter(into)))] #[non_exhaustive] +#[cfg(feature = "text-indexes-unstable")] pub struct TextOptions { /// Options for substring queries. substring: Option, @@ -182,6 +184,7 @@ pub struct TextOptions { #[serde(rename_all = "camelCase")] #[builder(field_defaults(default, setter(into)))] #[non_exhaustive] +#[cfg(feature = "text-indexes-unstable")] pub struct SubstringOptions { /// The maximum allowed string length. Inserting a longer string will result in an error. #[serde(rename = "strMaxLength")] @@ -201,6 +204,7 @@ pub struct SubstringOptions { #[serde(rename_all = "camelCase")] #[builder(field_defaults(default, setter(into)))] #[non_exhaustive] +#[cfg(feature = "text-indexes-unstable")] pub struct PrefixOptions { /// The minimum allowed query length. Querying with a shorter string will result in an error. #[serde(rename = "strMinQueryLength")] @@ -216,6 +220,7 @@ pub struct PrefixOptions { #[serde(rename_all = "camelCase")] #[builder(field_defaults(default, setter(into)))] #[non_exhaustive] +#[cfg(feature = "text-indexes-unstable")] pub struct SuffixOptions { /// The minimum allowed query length. Querying with a shorter string will result in an error. #[serde(rename = "strMinQueryLength")] diff --git a/src/client/csfle/client_encryption.rs b/src/client/csfle/client_encryption.rs index 8b1e22da2..735bea28a 100644 --- a/src/client/csfle/client_encryption.rs +++ b/src/client/csfle/client_encryption.rs @@ -28,6 +28,13 @@ use super::{options::KmsProviders, state_machine::CryptExecutor}; pub use super::client_builder::EncryptedClientBuilder; pub use crate::action::csfle::encrypt::{EncryptKey, RangeOptions}; +#[cfg(feature = "text-indexes-unstable")] +pub use crate::action::csfle::encrypt::{ + PrefixOptions, + SubstringOptions, + SuffixOptions, + TextOptions, +}; /// A handle to the key vault. Used to create data encryption keys, and to explicitly encrypt and /// decrypt values when auto-encryption is not an option. diff --git a/src/client/csfle/client_encryption/encrypt.rs b/src/client/csfle/client_encryption/encrypt.rs index 088b7393c..5325225c4 100644 --- a/src/client/csfle/client_encryption/encrypt.rs +++ b/src/client/csfle/client_encryption/encrypt.rs @@ -86,6 +86,7 @@ impl ClientEncryption { let options_doc = crate::bson_compat::serialize_to_document(range_options)?; builder = builder.algorithm_range(options_doc)?; } + #[cfg(feature = "text-indexes-unstable")] if let Some(text_options) = &opts.text_options { let options_doc = crate::bson_compat::serialize_to_document(text_options)?; builder = builder.algorithm_text(options_doc)?; diff --git a/src/test/csfle/prose.rs b/src/test/csfle/prose.rs index d24e18a5a..b6cc4d8e2 100644 --- a/src/test/csfle/prose.rs +++ b/src/test/csfle/prose.rs @@ -13,7 +13,6 @@ use mongocrypt::ctx::Algorithm; use tokio::net::TcpListener; use crate::{ - action::csfle::encrypt::{PrefixOptions, SubstringOptions, SuffixOptions, TextOptions}, bson::{ doc, rawdoc, @@ -45,7 +44,6 @@ use crate::{ test::{ get_client_options, log_uncaptured, - mongocrypt_version_lt, server_version_lt, topology_is_standalone, util::{ @@ -2269,7 +2267,13 @@ async fn encrypt_expression_with_options() { // Prose test 27. Text explicit encryption #[tokio::test] +#[cfg(feature = "text-indexes-unstable")] async fn text_explicit_encryption() { + use crate::{ + client_encryption::{PrefixOptions, SubstringOptions, SuffixOptions, TextOptions}, + test::mongocrypt_version_lt, + }; + if server_version_lt(8, 2).await || topology_is_standalone().await || mongocrypt_version_lt("1.15.1") @@ -2464,7 +2468,10 @@ async fn text_explicit_encryption() { assert!(message.contains("contention factor is required for textPreview algorithm")); } +#[cfg(feature = "text-indexes-unstable")] async fn text_explicit_encryption_setup() -> (Client, ClientEncryption, Binary) { + use crate::client_encryption::{PrefixOptions, SubstringOptions, SuffixOptions, TextOptions}; + let util_client = Client::for_test().await; let db = util_client.database("db"); From 94e8cb3d674f721f610ce1eea2739b5bf21bfb10 Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Fri, 26 Sep 2025 12:39:20 -0600 Subject: [PATCH 07/10] add more warning docs --- src/action/csfle/encrypt.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/action/csfle/encrypt.rs b/src/action/csfle/encrypt.rs index d03db2dd0..9380c9860 100644 --- a/src/action/csfle/encrypt.rs +++ b/src/action/csfle/encrypt.rs @@ -126,6 +126,9 @@ pub struct EncryptOptions { /// Set the text options. This should only be set when the algorithm is /// [`Algorithm::TextPreview`]. + /// + /// NOTE: This option is unstable and subject to backwards-breaking changes. It should only be + /// used in experimental workloads. #[cfg(feature = "text-indexes-unstable")] pub text_options: Option, } @@ -156,6 +159,9 @@ pub struct RangeOptions { } /// Options for a queryable encryption field supporting text queries. +/// +/// NOTE: These options are unstable and subject to backwards-breaking changes. They should only be +/// used in experimental workloads. #[skip_serializing_none] #[derive(Clone, Default, Debug, Serialize, TypedBuilder)] #[serde(rename_all = "camelCase")] @@ -180,6 +186,9 @@ pub struct TextOptions { } /// Options for substring queries. +/// +/// NOTE: These options are unstable and subject to backwards-breaking changes. They should only be +/// used in experimental workloads. #[derive(Clone, Default, Debug, Serialize, TypedBuilder)] #[serde(rename_all = "camelCase")] #[builder(field_defaults(default, setter(into)))] @@ -200,6 +209,9 @@ pub struct SubstringOptions { } /// Options for prefix queries. +/// +/// NOTE: These options are unstable and subject to backwards-breaking changes. They should only be +/// used in experimental workloads. #[derive(Clone, Default, Debug, Serialize, TypedBuilder)] #[serde(rename_all = "camelCase")] #[builder(field_defaults(default, setter(into)))] @@ -216,6 +228,9 @@ pub struct PrefixOptions { } /// Options for suffix queries. +/// +/// NOTE: These options are unstable and subject to backwards-breaking changes. They should only be +/// used in experimental workloads. #[derive(Clone, Default, Debug, Serialize, TypedBuilder)] #[serde(rename_all = "camelCase")] #[builder(field_defaults(default, setter(into)))] From 4e48c065df9ea11c6049dee6173367fc3a40b474 Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Fri, 26 Sep 2025 12:59:21 -0600 Subject: [PATCH 08/10] rename methods --- src/test/csfle/prose.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/test/csfle/prose.rs b/src/test/csfle/prose.rs index b6cc4d8e2..db585eb41 100644 --- a/src/test/csfle/prose.rs +++ b/src/test/csfle/prose.rs @@ -2268,7 +2268,7 @@ async fn encrypt_expression_with_options() { // Prose test 27. Text explicit encryption #[tokio::test] #[cfg(feature = "text-indexes-unstable")] -async fn text_explicit_encryption() { +async fn text_indexes_explicit_encryption() { use crate::{ client_encryption::{PrefixOptions, SubstringOptions, SuffixOptions, TextOptions}, test::mongocrypt_version_lt, @@ -2346,7 +2346,8 @@ async fn text_explicit_encryption() { let projection = doc! { "__safeContent__": 0 }; // Case 1: can find a document by prefix - let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; let prefix = client_encryption .encrypt("foo", key1_id, Algorithm::TextPreview) @@ -2365,7 +2366,8 @@ async fn text_explicit_encryption() { assert_eq!(actual, expected); // Case 2: can find a document by suffix - let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; let suffix = client_encryption .encrypt("baz", key1_id, Algorithm::TextPreview) @@ -2384,7 +2386,8 @@ async fn text_explicit_encryption() { assert_eq!(actual, expected); // Case 3: assert no document found by prefix - let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; let prefix = client_encryption .encrypt("baz", key1_id, Algorithm::TextPreview) @@ -2402,7 +2405,8 @@ async fn text_explicit_encryption() { assert!(actual.is_none(), "{actual:?}"); // Case 4: assert no document found by suffix - let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; let suffix = client_encryption .encrypt("foo", key1_id, Algorithm::TextPreview) @@ -2420,7 +2424,8 @@ async fn text_explicit_encryption() { assert!(actual.is_none(), "{actual:?}"); // Case 5: can find a document by substring - let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; let substring = client_encryption .encrypt("bar", key1_id, Algorithm::TextPreview) @@ -2439,7 +2444,8 @@ async fn text_explicit_encryption() { assert_eq!(actual, expected); // Case 6: assert no document found by substring - let (encrypted_client, client_encryption, key1_id) = text_explicit_encryption_setup().await; + let (encrypted_client, client_encryption, key1_id) = + text_indexes_explicit_encryption_setup().await; let substring = client_encryption .encrypt("qux", key1_id, Algorithm::TextPreview) @@ -2457,7 +2463,7 @@ async fn text_explicit_encryption() { assert!(actual.is_none(), "{actual:?}"); // Case 7: assert contentionFactor is required - let (_, client_encryption, key1_id) = text_explicit_encryption_setup().await; + let (_, client_encryption, key1_id) = text_indexes_explicit_encryption_setup().await; let error = client_encryption .encrypt("foo", key1_id, Algorithm::TextPreview) .query_type(prefix_query_type) @@ -2469,7 +2475,7 @@ async fn text_explicit_encryption() { } #[cfg(feature = "text-indexes-unstable")] -async fn text_explicit_encryption_setup() -> (Client, ClientEncryption, Binary) { +async fn text_indexes_explicit_encryption_setup() -> (Client, ClientEncryption, Binary) { use crate::client_encryption::{PrefixOptions, SubstringOptions, SuffixOptions, TextOptions}; let util_client = Client::for_test().await; From 20f14e4b5b3fac9f826722b17d5aa4692037ac86 Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Mon, 6 Oct 2025 09:00:21 -0600 Subject: [PATCH 09/10] update dep --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5ddd9288..871c661dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2004,7 +2004,7 @@ dependencies = [ [[package]] name = "mongocrypt" version = "0.3.1" -source = "git+https://github.com/isabelatkinson/libmongocrypt-rust.git?branch=text-indexes#cb31b8b78340a2e12419c48eff0e5763e740d3cf" +source = "git+https://github.com/mongodb/libmongocrypt-rust.git?branch=main#0f34015fcde37d805c0e7ead397965ade63bdb07" dependencies = [ "bson 2.15.0", "bson 3.0.0", @@ -2016,7 +2016,7 @@ dependencies = [ [[package]] name = "mongocrypt-sys" version = "0.1.4+1.12.0" -source = "git+https://github.com/isabelatkinson/libmongocrypt-rust.git?branch=text-indexes#cb31b8b78340a2e12419c48eff0e5763e740d3cf" +source = "git+https://github.com/mongodb/libmongocrypt-rust.git?branch=main#0f34015fcde37d805c0e7ead397965ade63bdb07" [[package]] name = "mongodb" diff --git a/Cargo.toml b/Cargo.toml index e5eb9abb2..e8bcb4c70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -165,8 +165,8 @@ features = ["serde"] [dependencies.mongocrypt] version = "0.3.1" -git = "https://github.com/isabelatkinson/libmongocrypt-rust.git" -branch = "text-indexes" +git = "https://github.com/mongodb/libmongocrypt-rust.git" +branch = "main" default-features = false optional = true From 6b88fc7a293a4cca01c5e231f5f3ad02d9de6a24 Mon Sep 17 00:00:00 2001 From: Isabel Atkinson Date: Mon, 6 Oct 2025 09:01:57 -0600 Subject: [PATCH 10/10] review --- .evergreen/run-csfle-tests.sh | 2 +- src/action/csfle/encrypt.rs | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.evergreen/run-csfle-tests.sh b/.evergreen/run-csfle-tests.sh index db6917d15..0fdc301af 100755 --- a/.evergreen/run-csfle-tests.sh +++ b/.evergreen/run-csfle-tests.sh @@ -10,7 +10,7 @@ set -o xtrace export CSFLE_TLS_CERT_DIR="${DRIVERS_TOOLS}/.evergreen/x509gen" -FEATURE_FLAGS+=("in-use-encryption" "azure-kms", "text-indexes-unstable") +FEATURE_FLAGS+=("in-use-encryption" "azure-kms" "text-indexes-unstable") CARGO_OPTIONS+=("--ignore-default-filter") if [[ "$OPENSSL" = true ]]; then diff --git a/src/action/csfle/encrypt.rs b/src/action/csfle/encrypt.rs index 9380c9860..c3569b658 100644 --- a/src/action/csfle/encrypt.rs +++ b/src/action/csfle/encrypt.rs @@ -170,19 +170,19 @@ pub struct RangeOptions { #[cfg(feature = "text-indexes-unstable")] pub struct TextOptions { /// Options for substring queries. - substring: Option, + pub substring: Option, /// Options for prefix queries. - prefix: Option, + pub prefix: Option, /// Options for suffix queries. - suffix: Option, + pub suffix: Option, /// Whether text indexes for this field are case-sensitive. - case_sensitive: bool, + pub case_sensitive: bool, /// Whether text indexes for this field are diacritic-sensitive. - diacritic_sensitive: bool, + pub diacritic_sensitive: bool, } /// Options for substring queries. @@ -197,15 +197,15 @@ pub struct TextOptions { pub struct SubstringOptions { /// The maximum allowed string length. Inserting a longer string will result in an error. #[serde(rename = "strMaxLength")] - max_string_length: i32, + pub max_string_length: i32, /// The minimum allowed query length. Querying with a shorter string will result in an error. #[serde(rename = "strMinQueryLength")] - min_query_length: i32, + pub min_query_length: i32, /// The maximum allowed query length. Querying with a longer string will result in an error. #[serde(rename = "strMaxQueryLength")] - max_query_length: i32, + pub max_query_length: i32, } /// Options for prefix queries. @@ -220,11 +220,11 @@ pub struct SubstringOptions { pub struct PrefixOptions { /// The minimum allowed query length. Querying with a shorter string will result in an error. #[serde(rename = "strMinQueryLength")] - min_query_length: i32, + pub min_query_length: i32, /// The maximum allowed query length. Querying with a longer string will result in an error. #[serde(rename = "strMaxQueryLength")] - max_query_length: i32, + pub max_query_length: i32, } /// Options for suffix queries. @@ -239,11 +239,11 @@ pub struct PrefixOptions { pub struct SuffixOptions { /// The minimum allowed query length. Querying with a shorter string will result in an error. #[serde(rename = "strMinQueryLength")] - min_query_length: i32, + pub min_query_length: i32, /// The maximum allowed query length. Querying with a longer string will result in an error. #[serde(rename = "strMaxQueryLength")] - max_query_length: i32, + pub max_query_length: i32, } #[option_setters(EncryptOptions, skip = [query_type])]