Skip to content

feat(NODE-6773): add support for $lookup on encrypted collections #4427

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ test/lambda/env.json

# files generated by tooling in drivers-evergreen-tools
secrets-export.sh
secrets-export.fish
mo-expansion.sh
mo-expansion.yml
expansions.sh
Expand Down
39 changes: 39 additions & 0 deletions etc/bash_to_fish.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createReadStream, promises as fs } from 'node:fs';
import path from 'node:path';
import readline from 'node:readline/promises';

/**
* Takes an "exports" only bash script file
* and converts it to fish syntax.
* Will crash on any line that isn't:
* - a comment
* - an empty line
* - a bash 'set' call
* - export VAR=VAL
*/

const fileName = process.argv[2];
const outFileName = path.basename(fileName, '.sh') + '.fish';
const input = createReadStream(process.argv[2]);
const lines = readline.createInterface({ input });
const output = await fs.open(outFileName, 'w');

for await (let line of lines) {
line = line.trim();

if (!line.startsWith('export ')) {
if (line.startsWith('#')) continue;
if (line === '') continue;
if (line.startsWith('set')) continue;
throw new Error('Cannot translate: ' + line);
}

const varVal = line.slice('export '.length);
const variable = varVal.slice(0, varVal.indexOf('='));
const value = varVal.slice(varVal.indexOf('=') + 1);
await output.appendFile(`set -x ${variable} ${value}\n`);
}

output.close();
input.close();
lines.close();
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"js-yaml": "^4.1.0",
"mocha": "^10.8.2",
"mocha-sinon": "^2.1.2",
"mongodb-client-encryption": "^6.2.0",
"mongodb-client-encryption": "^6.3.0",
"mongodb-legacy": "^6.1.3",
"nyc": "^15.1.0",
"prettier": "^3.4.2",
Expand Down
1 change: 1 addition & 0 deletions src/client-side-encryption/auto_encrypter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export class AutoEncrypter {
this._kmsProviders = options.kmsProviders || {};

const mongoCryptOptions: MongoCryptOptions = {
enableMultipleCollinfo: true,
cryptoCallbacks
};
if (options.schemaMap) {
Expand Down
53 changes: 31 additions & 22 deletions src/client-side-encryption/state_machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { getSocks, type SocksLib } from '../deps';
import { MongoOperationTimeoutError } from '../error';
import { type MongoClient, type MongoClientOptions } from '../mongo_client';
import { type Abortable } from '../mongo_types';
import { type CollectionInfo } from '../operations/list_collections';
import { Timeout, type TimeoutContext, TimeoutError } from '../timeout';
import {
addAbortListener,
Expand Down Expand Up @@ -205,11 +206,19 @@ export class StateMachine {
const mongocryptdManager = executor._mongocryptdManager;
let result: Uint8Array | null = null;

while (context.state !== MONGOCRYPT_CTX_DONE && context.state !== MONGOCRYPT_CTX_ERROR) {
// Typescript treats getters just like properties: Once you've tested it for equality
// it cannot change. Which is exactly the opposite of what we use state and status for.
// Every call to at least `addMongoOperationResponse` and `finalize` can change the state.
// These wrappers let us write code more naturally and not add compiler exceptions
// to conditions checks inside the state machine.
const getStatus = () => context.status;
const getState = () => context.state;

while (getState() !== MONGOCRYPT_CTX_DONE && getState() !== MONGOCRYPT_CTX_ERROR) {
options.signal?.throwIfAborted();
debug(`[context#${context.id}] ${stateToString.get(context.state) || context.state}`);
debug(`[context#${context.id}] ${stateToString.get(getState()) || getState()}`);

switch (context.state) {
switch (getState()) {
case MONGOCRYPT_CTX_NEED_MONGO_COLLINFO: {
const filter = deserialize(context.nextMongoOperation());
if (!metaDataClient) {
Expand All @@ -218,22 +227,28 @@ export class StateMachine {
);
}

const collInfo = await this.fetchCollectionInfo(
const collInfoCursor = this.fetchCollectionInfo(
metaDataClient,
context.ns,
filter,
options
);
if (collInfo) {
context.addMongoOperationResponse(collInfo);

for await (const collInfo of collInfoCursor) {
context.addMongoOperationResponse(serialize(collInfo));
if (getState() === MONGOCRYPT_CTX_ERROR) break;
}

if (getState() === MONGOCRYPT_CTX_ERROR) break;

context.finishMongoOperation();
break;
}

case MONGOCRYPT_CTX_NEED_MONGO_MARKINGS: {
const command = context.nextMongoOperation();
if (getState() === MONGOCRYPT_CTX_ERROR) break;

if (!mongocryptdClient) {
throw new MongoCryptError(
'unreachable state machine state: entered MONGOCRYPT_CTX_NEED_MONGO_MARKINGS but mongocryptdClient is undefined'
Expand Down Expand Up @@ -283,22 +298,21 @@ export class StateMachine {

case MONGOCRYPT_CTX_READY: {
const finalizedContext = context.finalize();
// @ts-expect-error finalize can change the state, check for error
if (context.state === MONGOCRYPT_CTX_ERROR) {
const message = context.status.message || 'Finalization error';
if (getState() === MONGOCRYPT_CTX_ERROR) {
const message = getStatus().message || 'Finalization error';
throw new MongoCryptError(message);
}
result = finalizedContext;
break;
}

default:
throw new MongoCryptError(`Unknown state: ${context.state}`);
throw new MongoCryptError(`Unknown state: ${getState()}`);
}
}

if (context.state === MONGOCRYPT_CTX_ERROR || result == null) {
const message = context.status.message;
if (getState() === MONGOCRYPT_CTX_ERROR || result == null) {
const message = getStatus().message;
if (!message) {
debug(
`unidentifiable error in MongoCrypt - received an error status from \`libmongocrypt\` but received no error message.`
Expand Down Expand Up @@ -527,29 +541,24 @@ export class StateMachine {
* @param filter - A filter for the listCollections command
* @param callback - Invoked with the info of the requested collection, or with an error
*/
async fetchCollectionInfo(
fetchCollectionInfo(
client: MongoClient,
ns: string,
filter: Document,
options?: { timeoutContext?: TimeoutContext } & Abortable
): Promise<Uint8Array | null> {
): AsyncIterable<CollectionInfo> {
const { db } = MongoDBCollectionNamespace.fromString(ns);

const cursor = client.db(db).listCollections(filter, {
promoteLongs: false,
promoteValues: false,
timeoutContext:
options?.timeoutContext && new CursorTimeoutContext(options?.timeoutContext, Symbol()),
signal: options?.signal
signal: options?.signal,
nameOnly: false
});

// There is always exactly zero or one matching documents, so this should always exhaust the cursor
// in a single batch. We call `toArray()` just to be safe and ensure that the cursor is always
// exhausted and closed.
const collections = await cursor.toArray();

const info = collections.length > 0 ? serialize(collections[0]) : null;
return info;
return cursor;
}

/**
Expand Down
Loading