M3: refuse positional paths instead of destroying arrays; fix the drop use-after-free #5

Merged
dev merged 4 commits from m3-positional-refusal into main 2026-08-10 15:48:40 +00:00
5 changed files with 843 additions and 64 deletions

37
PLAN.md
View File

@@ -1045,6 +1045,43 @@ has to be its own commit with its own re-recorded scorecard.
`$out`/`$merge` durability semantics, whether the expression evaluator is `$out`/`$merge` durability semantics, whether the expression evaluator is
shared with M3's pipeline updates, and whether `allowDiskUse` has to stop shared with M3's pipeline updates, and whether `allowDiskUse` has to stop
being a lie. being a lie.
- **`drop` unlocked the collection it had just freed** — *fixed.* Found while
writing the positional-refusal tests and not caused by them; it reproduced
at `3c5eee2` with that work stashed.
A use-after-free, not an allocator quirk. Dispatch held `drop`'s collection
lock across the handler, the handler freed the `Collection` the lock lives
in, and dispatch then ran `unlock_collection` on freed memory — an atomic
read-modify-write inside `Io.RwLock.unlock`. One insert and one drop was
enough.
The wire path not faulting was luck, not safety: 25 insert/drop cycles
against a live server pass because the general allocator leaves the freed
page mapped, so the atomic write lands somewhere harmless. It was the same
undefined behaviour either way, and `testing.allocator` is what made it
visible. **`drop` had no unit test at all**, which is why it went unseen.
Fixed by giving `drop` no collection lock. The catalog lock is what actually
excludes: every collection lock in the engine — dispatch, the TTL sweep,
`compact`'s rebuild, `write_catalog`, `slab_stats`, reclamation — is taken
while holding the catalog at least shared, so `drop` holding it exclusively
already keeps all of them out. The collection lock bought exclusion that was
already there and paid for it by locking an object about to cease existing.
That change also made the dispatch epilogue's predicate wrong, and it turned
out to have been wrong already: it fired on `locks.coll == .exclusive` as a
stand-in for "this was a write", and **`dropDatabase` is the one write that
never held a collection lock**, so it had never reached the commit and
checkpoint epilogue at all. Now keyed on `kind == .write`.
Still open, and deliberately not fixed here: `drop_collection` writes no log
record, so a dropped collection resurrects on reopen unless a checkpoint
happened to run — a pre-existing limitation with its own test at
`db.zig:5542`. Separately, `apply_pending_write` (`$out`/`$merge`) calls
`drop_collection` under `engine.rwlock` rather than the catalog lock, so it
is not excluded by the reasoning above; it holds no collection lock, so it
is not this crash, but the two drops disagree about which lock protects the
namespace and that wants one answer.
- **M3 update operators** — open. `distinct` landed first because it was a - **M3 update operators** — open. `distinct` landed first because it was a
whole missing command with no dependencies, and measuring it turned up three whole missing command with no dependencies, and measuring it turned up three
things worth keeping, none of which are `distinct`'s to fix: things worth keeping, none of which are `distinct`'s to fix:

View File

@@ -0,0 +1,334 @@
# M3 design review — `arrayFilters`, and the positional operators
Written before any code, for the same reason the M2 review was: the previous
two milestones both began with a gate that pointed somewhere other than the
work, and both times the measurement said so before the implementation did.
This one is no exception, but it fails in a new direction — the plan names a
*missing feature*, and the measurement found a *data-loss bug*.
Everything below was measured on 2026-08-10 against mongod 8.3.7 on
`:27099` and this server at `5942f5e` on `:27020`, running the identical
probe against both.
---
## 1. The plan names a feature; the measurement found data loss
PLAN §3 lists `arrayFilters` in M3's scope, and PLAN §6 sizes it at "14 cases".
That framing says: a feature is absent, and implementing it converts 14
failures into passes.
What is actually happening is worse and cheaper to describe. Take the corpus's
own document and its own update:
```js
// { _id: 1, y: [ {b: 3}, {b: 1} ] }
updateOne({}, {$set: {'y.$[i].b': 2}}, {arrayFilters: [{'i.b': 3}]})
```
| | result |
|---|---|
| mongod 8.3.7 | `{_id: 1, y: [{b: 2}, {b: 1}]}` |
| this server | `{_id: 1, y: {"$[i]": {"b": 2}}}` |
The array is gone. Not "not updated" — **replaced by a document whose single
key is the literal text `$[i]`**, with every element and everything in them
discarded. The server answers `ok: 1`, `matchedCount: 1`, `modifiedCount: 1`.
No error, no warning, nothing in the log.
This is the class M2 spent a milestone eliminating — a construct the engine
does not implement answering confidently instead of refusing — except that
here the wrong answer is not a `0` in a report, it is destruction of the
client's stored data, and it is reachable by any unauthenticated client
issuing a completely ordinary MongoDB update.
### It is not an `arrayFilters` bug
`arrayFilters` is not consulted at all. The string `arrayFilters` appears
**nowhere in `src/`** — the option is accepted off the wire and dropped. The
destruction comes from the *path*, and every path form that MongoDB gives a
non-numeric array segment triggers it:
| update | mongod | this server |
|---|---|---|
| `{$set: {'y.$[i].b': 2}}` + filters | `y: [{b:2},{b:1}]` | `y: {"$[i]": {"b": 2}}` |
| `{$set: {'y.$[].b': 9}}` | `y: [{b:9},{b:9}]` | `y: {"$[]": {"b": 9}}` |
| `{$set: {'y.$.b': 7}}` after `{'y.b': 3}` | `y: [{b:7},{b:1}]` | `y: {"$": {"b": 7}}` |
| `{$inc: {'y.$[i].b': 10}}` + filters | `y: [{b:13},{b:1}]` | `y: {"$[i]": 10}` |
The last row is worth a second look: `$inc` did not increment anything. It
created a field and gave it the *operand*. So this is not `$set`-specific
either — it is below the operator, in the path walk that every operator uses.
**Three operators, one root cause.** `$` (positional, MongoDB 2.x),
`$[]` (all-positional, 3.6) and `$[<ident>]` (filtered-positional, 3.6) all
land in the same branch.
### The branch
`src/update.zig:283`, inside `set_path`, reached when a path segment under an
array is not a number:
```zig
.array => |arr| {
const index = parse_index(segs[1]) orelse {
// treat as non-array: replace with a doc
var sub_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
...
pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
return;
};
```
The comment states the behaviour plainly and is not wrong about what the code
does. `parse_index("$[i]")` fails, so the array is treated as a thing that
should have been a document, and is overwritten with one. For a genuinely
unknown field name under an array — `y.nope.b` — that is arguably a defensible
reading of MongoDB's create-intermediate-paths rule. For `$`, `$[]` and
`$[i]`, which are the three ways MongoDB spells "descend into this array", it
is the exact inversion of the intent.
Out of 17 probe cases covering the operators, their edge cases and their
refusals, **16 diverge from mongod and 1 agrees**. The one that agrees is
`arrayFilters` supplied alongside a *replacement* document, where both servers
ignore it.
---
## 2. Where the implementation actually is
| | state |
|---|---|
| `arrayFilters` parsing | absent from `src/` entirely |
| `$[<ident>]` | destroys the array |
| `$[]` | destroys the array |
| `$` positional | destroys the array |
| `update.apply` signature | `(doc, update)` — no filter, no arrayFilters |
| path model | `split_path``[]const []const u8`, max 16 segments |
| target model | exactly one target per path, resolved while writing |
| `modifiedCount` | byte comparison in `engine.replace` |
Two of these rows are load-bearing for the design, and both are good news.
**`modifiedCount` comes free.** `n_modified` is incremented only when
`engine.replace` returns `.modified`, and that is decided by whether the
serialized bytes changed — "a write that would store the same bytes is neither
logged nor counted". So the moment the corruption stops, an update whose
filters match nothing leaves the document byte-identical and is correctly
reported as `modifiedCount: 0`. **5 of the 14 corpus failures are
`modifiedCount` assertions and need no counting work at all.** (The runner
reports the first failing assertion, so some of those 5 have a wrong outcome
behind them too; the point is that none of them needs a *counter*.)
**`update.apply` has no access to the filter.** This is the one real
architectural fork, and it separates the three operators:
- `$[]` and `$[<ident>]` need the document and the filter *list*. Both can be
passed down; nothing else is required.
- `$` needs **which element the query matched** — information that exists only
during query evaluation and is thrown away before `apply` is called. Three
call sites (`cmd_update` at `commands.zig:1976`, `cmd_find_and_modify` at
`:2099`, and `:4131`) would each have to carry a matched-index out of the
match and into the update.
So `$` is not "one more case of the same fix". It is the same *refusal*, but a
different *implementation*, and it can be sequenced separately.
---
## 3. The gate named in the plan cannot see the worst of this
M3's gate in PLAN §3 reads: *"remaining crud coverage; e2e3/e2e4 green"*.
- **`e2e3.js` and `e2e4.js` contain zero occurrences** of `arrayFilters`, `$[`,
or a positional path. They are green today, they would stay green through
every version of this bug, and they will stay green after it is fixed. As a
gate for this work they measure nothing.
- **The crud corpus covers `$[<ident>]` only.** Five files use it; four
contribute the 14 failures, and `client-bulkWrite-update-options.json`
is skipped entirely (needs server ≥ 8.0).
- **The corpus contains no `$[]` case and no bare `$` case at all.** Grepped
the whole pinned unified corpus: zero.
That last line is the finding. A fix scoped to what the gate measures would
leave `$` and `$[]` still overwriting arrays with `{"$": {...}}`, and the
scorecard would go green on 14 new passes while two of the three ways to
destroy a client's data remained. The gate is not merely pointing away from
the work — **it would certify the bug as fixed.**
The 14, for the record:
| file | cases | first failing assertion |
|---|---|---|
| `updateOne-arrayFilters.json` | 5 | 3 outcome, 2 modifiedCount |
| `bulkWrite-arrayFilters.json` | 3 | 2 outcome, 1 modifiedCount |
| `findOneAndUpdate-arrayFilters.json` | 3 | 3 outcome |
| `updateMany-arrayFilters.json` | 3 | 1 outcome, 2 modifiedCount |
---
## 4. What the measurement says the fix has to look like
Recorded because none of it is guessable, and because two of these directly
contradict how `set_path` behaves today.
### One path can name many targets
```
y.$[].c.$[].d on y: [ {c: [{d:1},{d:2}]}, {c: [{d:3}]} ]
-> y: [ {c: [{d:0},{d:0}]}, {c: [{d:0}]} ]
```
A genuine cross-product: three leaves, all written. `set_path`'s contract —
walk to one place, write there — cannot express this. The path model has to
become *enumerate the matching targets, then apply the operator to each*, which
also means the resolution must sit **below** the operator dispatch, since
`$unset` through `$[i]` works the same way (measured).
### A positional segment never creates anything
This is the sharp reversal. `$set: {'a.b': 1}` on a document without `a`
creates the intermediate document — that is MongoDB's rule and `set_path`
implements it. But:
| situation | mongod |
|---|---|
| `y.$[i].b` where `y` is missing | error 2, "The path 'y' must exist in the document in order to apply array update" |
| `y.$[i].b` where `y` is `5` | error 2, "Cannot apply array updates to non-array element y: 5" |
| upsert with `$[i]`, nothing matched | error 2, same "path must exist" — the upsert does *not* get a special case |
So a positional segment is a *filter over what is there*, never a constructor.
The upsert row means no separate upsert path is needed: it falls out.
### Identifiers are matched as a query against each element
`arrayFilters: [{'i.b.q': 2}]` selects elements whose nested `b.q` is 2, and
operators work (`{'i.b': {$gte: 0}}` matched every element). So each filter is
an ordinary query document, rooted at the identifier, evaluated against each
array element — `query.matches` should apply directly with the identifier
stripped from the front of each key.
### The refusals, measured
| condition | code | message |
|---|---|---|
| identifier in path with no matching filter (including no `arrayFilters` at all) | 2 | `No array filter found for identifier 'k' in path 'y.$[k].b'` |
| identifier is not lowercase-alphanumeric | 2 | `Error parsing array filter :: caused by :: The top-level field name must be an alphanumeric string beginning with a lowercase letter, found '1x'` |
| the path's array does not exist | 2 | `The path 'y' must exist in the document in order to apply array update` |
| the path's element is not an array | 2 | `Cannot apply array updates to non-array element y: 5` |
| `$` when the query did not match the array | 2 | `Plan executor error during update :: caused by :: The positional operator did not find the match needed from the query.` |
| a filter entry the update never uses | 9 | `The array filter for identifier 'i' was not used in the update { $set: { y.b: 2 } }` |
| two filters with the same identifier | 9 | `Found multiple array filters with the same top-level field name i` |
| a filter with no top-level field | 9 | `Cannot use an expression without a top-level field name in arrayFilters` |
| a filter with two top-level fields | 9 | `Error parsing array filter :: caused by :: Expected a single top-level field name, found 'i' and 'j'` |
| `arrayFilters` not an array | 14 | `BSON field 'update.updates.arrayFilters' is the wrong type 'string', expected type 'array'` |
Mostly this splits as *9 = the `arrayFilters` array judged on its own*, *2 =
anything needing the update document or the stored document*. **It is not a
clean rule**, and it should not be tidied into one: "identifier is not
lowercase-alphanumeric" carries the same `Error parsing array filter` prefix as
the 9s and is nevertheless a 2. Recorded as measured. Inventing the rule and
deriving the codes from it would get that row wrong, which is precisely the
failure mode this repo has hit three times.
`UpdateError` is a closed set consumed at three call sites that currently
collapse everything to `bad_value(reply, "bad update")`. Ten distinct
refusals with two codes and specific messages will not fit that shape; the
error → reply mapping has to move or widen.
---
## 5. Gate options
### Option A — the 14 corpus cases
*"`updateOne`/`updateMany`/`findOneAndUpdate`/`bulkWrite`-arrayFilters: 0 fail."*
Cheap, external, already written. And, per §3, it certifies the bug fixed
while `$` and `$[]` still destroy arrays. It is the plan's implied gate and it
is the one option the measurement rules out on its own.
### Option B — the 14, plus a purpose-built positional corpus
Option A plus a recorded corpus for the two operators the pinned suite forgot,
built exactly like `tests/spec/aggregate/`: inputs authored in `sources/`,
every expectation recorded from mongod 8.3.7 by a `record.js`, run through the
shared runner with `--suite-dir`.
That machinery exists and was built for this situation. The M2.5 corpus caught
`$avg` over a no-numeric group returning `0` instead of `null` — a bug no
hand-written expectation would have contained, because the author would have
written down what they believed. The same argument applies here with more
force: the beliefs about `$[]`, missing paths and upserts in §4 were wrong
before I measured them.
Cost: one `sources/positional.json`, one recorder run. The recorder is ~120
lines and already written for aggregation; the update shape needs its own but
the pattern transfers.
### Option C — differential fuzzing against mongod
Generate random documents and random positional updates, apply to both servers,
compare. Strongest possible coverage of the target-enumeration logic, which is
where the cross-product and the create-nothing rule will actually break.
Rejected as *the gate* for the same reason M2 rejected it: it is not
reproducible as a committed artifact, a red run names a random seed rather than
a behaviour, and it needs a live mongod in the loop. Worth having as a
one-off during development; not worth being the thing a milestone is judged on.
### Option D — refuse, do not implement
Detect `$`, `$[]` and `$[<ident>]` and answer BadValue instead of corrupting.
Gate: the 14 cases fail *with the right code* rather than passing.
This is not a joke option and it should not be dismissed quickly. It stops the
data loss in roughly a day, it is the M2 doctrine applied exactly ("refuse what
the engine does not implement rather than answering confidently"), and it can
ship before the real implementation is designed. It leaves 14 cases red.
---
## 6. Recommendation
**Option D first, as its own commit, then Option B.**
The reasoning is the split between the two halves of this problem. They have
very different urgency and very different sizes:
- The data loss is a bug, it is remotely reachable, it needs no feature work to
fix, and every day it stays in `main` is a day the server can silently
destroy an array. Refusing costs a path scan and a `BadValue`.
- The feature is a genuine redesign of the path model — one path to many
targets, resolution below operator dispatch, a create-nothing rule that
inverts the current one. It deserves to be designed against a corpus rather
than rushed to make a bug stop.
Shipping D first also makes B's corpus honest: recorded against mongod while
this server refuses cleanly, so every expectation in it is measured before a
line of the implementation exists — the discipline that made M2.5's corpus
worth having, where each tier was recorded red and then driven to green.
Within B, sequence `$[]` and `$[<ident>]` together (same plumbing, both need
only document + filters), and `$` after (needs the matched index carried out of
query evaluation, three call sites).
---
## 7. What this review does not cover
- **The other M3 operators.** `$setOnInsert`, `$addToSet`, `$mul`, `$min`/`$max`,
`$pop`, `$pullAll`, `$currentDate`, pipeline-form updates. Independent of this
work, and the ~10 pipeline-update failures are a separate design question.
- **Index correctness after a positional update.** A multikey index over `y.b`
must be regenerated when an element changes. The normal `engine.replace` path
presumably handles it since it re-derives entries from the new document, but
that is an assumption stated, not a measurement — it needs a test in whichever
option is chosen.
- **`max_path_segments = 16`.** Whether a positional segment costs one or two,
and what mongod's own limit is. Not measured.
- **`$` in a projection** (`{'y.$': 1}`), which is the same sigil in a different
position and is not in scope here.
- **The unknown-operator gap** recorded in PLAN §6 during the `distinct` work
(`{x: {$bogus: 1}}` matches nothing instead of erroring). Same silent-wrong-
answer class, different code path, still open.

View File

@@ -81,6 +81,10 @@ pub const ErrorCode = enum(i32) {
query_plan_killed = 175, query_plan_killed = 175,
unauthorized = 13, unauthorized = 13,
type_mismatch = 14, type_mismatch = 14,
/// `PathNotViable`, measured on mongod 8.3.7: what an update answers when
/// a path segment names a field inside something that cannot hold one --
/// in practice, a non-numeric segment applied to an array.
path_not_viable = 28,
operation_failed = 96, operation_failed = 96,
// Session and transaction codes, measured against mongod 8.3.7 with a raw // Session and transaction codes, measured against mongod 8.3.7 with a raw
// OP_MSG probe -- the driver rewrites `lsid` with its own session, so a // OP_MSG probe -- the driver rewrites `lsid` with its own session, so a
@@ -194,7 +198,19 @@ const command_table = [_]Command{
// Writes: the target collection exclusively; create/drop take the // Writes: the target collection exclusively; create/drop take the
// catalog exclusively (they mutate the maps). // catalog exclusively (they mutate the maps).
.{ .name = "create", .kind = .write, .locks = .{ .catalog = .exclusive, .coll = .exclusive }, .handler = cmd_create }, .{ .name = "create", .kind = .write, .locks = .{ .catalog = .exclusive, .coll = .exclusive }, .handler = cmd_create },
.{ .name = "drop", .kind = .write, .locks = .{ .catalog = .exclusive, .coll = .exclusive }, .handler = cmd_drop }, // `drop` takes the catalog exclusively and **no collection lock**: it frees
// the very Collection a lock would live in, and dispatch then unlocked the
// freed memory. That was a use-after-free on an `Io.RwLock`, and under
// testing.allocator it is a hard SIGSEGV on the first insert-then-drop.
//
// Nothing is lost by dropping the lock, because the catalog lock is what
// actually excludes here: every collection lock in this engine -- dispatch,
// the TTL sweep, `compact`'s rebuild, `write_catalog`, `slab_stats`,
// reclamation -- is taken while holding the catalog at least shared, so
// holding it exclusively already keeps every one of them out. The
// collection lock was buying exclusion that was already there, and paying
// for it by locking an object about to cease existing.
.{ .name = "drop", .kind = .write, .locks = .{ .catalog = .exclusive }, .handler = cmd_drop },
.{ .name = "dropDatabase", .kind = .write, .locks = .{ .catalog = .exclusive }, .handler = cmd_drop_database }, .{ .name = "dropDatabase", .kind = .write, .locks = .{ .catalog = .exclusive }, .handler = cmd_drop_database },
.{ .name = "createIndexes", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_create_indexes }, .{ .name = "createIndexes", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_create_indexes },
.{ .name = "dropIndexes", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_drop_indexes }, .{ .name = "dropIndexes", .kind = .write, .locks = .{ .catalog = .shared, .coll = .exclusive }, .handler = cmd_drop_indexes },
@@ -309,7 +325,15 @@ pub fn dispatch(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
}; };
try ctx.engine.commit(); try ctx.engine.commit();
} }
if (cmd.locks.coll == .exclusive) { // Keyed on what the command *is*, not on which lock it happened to take.
// These two agreed for every command until `drop` gave up its collection
// lock, at which point the old `locks.coll == .exclusive` would have
// silently stopped committing and checkpointing after a drop. It was
// already wrong for `dropDatabase`, the one write that never held a
// collection lock: it has been skipping this epilogue all along, so a
// dropped database waited for some later write to trigger a checkpoint
// before the catalog recording it was written.
if (cmd.kind == .write) {
// Durability (seal + fsync) coalesces across concurrent writers. A // Durability (seal + fsync) coalesces across concurrent writers. A
// commit error deliberately wins over the handler's captured `result`: // commit error deliberately wins over the handler's captured `result`:
// whether the write reached disk matters more to the client than why // whether the write reached disk matters more to the client than why
@@ -1951,7 +1975,9 @@ fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
if (matched.items.len == 0) { if (matched.items.len == 0) {
if (upsert) { if (upsert) {
const new_doc = try build_upsert_doc(reply, q, u_doc); var up_diag: update.Diagnostic = .{};
const new_doc = build_upsert_doc(reply, q, u_doc, &up_diag) catch |err|
return update_refusal(reply, err, up_diag);
ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) { ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) {
error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc), error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc),
else => return err, else => return err,
@@ -1973,10 +1999,9 @@ fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
// and a rejected update must not corrupt the stored document. // and a rejected update must not corrupt the stored document.
const doc = try doc_tree(reply.arena_alloc(), coll, off); const doc = try doc_tree(reply.arena_alloc(), coll, off);
const copy = try clone_doc(reply, doc); const copy = try clone_doc(reply, doc);
update.apply(copy, &.{ .arena = undefined, .pairs = u_doc }) catch |err| switch (err) { var diag: update.Diagnostic = .{};
error.ImmutableId, error.InvalidUpdate => return bad_value(reply, "bad update"), update.apply(copy, &.{ .arena = undefined, .pairs = u_doc }, &diag) catch |err|
else => return err, return update_refusal(reply, err, diag);
};
const written = ctx.engine.replace(db_name, coll_name, copy, ctx.oid_gen) catch |err| switch (err) { const written = ctx.engine.replace(db_name, coll_name, copy, ctx.oid_gen) catch |err| switch (err) {
error.DuplicateKey, error.DuplicateKeyIndex => { error.DuplicateKey, error.DuplicateKeyIndex => {
const e = try reply.arena_alloc().alloc(bson.Pair, 3); const e = try reply.arena_alloc().alloc(bson.Pair, 3);
@@ -2079,7 +2104,9 @@ fn cmd_find_and_modify(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !v
if (target == null and do_update and upsert) { if (target == null and do_update and upsert) {
const u_doc = doc_arg(msg.body.get("update")) orelse return bad_value(reply, "update must be a document"); const u_doc = doc_arg(msg.body.get("update")) orelse return bad_value(reply, "update must be a document");
const new_doc = try build_upsert_doc(reply, q, u_doc); var up_diag: update.Diagnostic = .{};
const new_doc = build_upsert_doc(reply, q, u_doc, &up_diag) catch |err|
return update_refusal(reply, err, up_diag);
ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) { ctx.engine.insert(db_name, coll_name, new_doc, ctx.oid_gen) catch |err| switch (err) {
error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc), error.DuplicateKey, error.DuplicateKeyIndex => return duplicate_key_error(ctx, reply, db_name, coll_name, new_doc),
else => return err, else => return err,
@@ -2096,10 +2123,9 @@ fn cmd_find_and_modify(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !v
const u_doc = doc_arg(msg.body.get("update")) orelse return bad_value(reply, "update must be a document"); const u_doc = doc_arg(msg.body.get("update")) orelse return bad_value(reply, "update must be a document");
const before = try bson.copy_pairs(arena, target.?.pairs); const before = try bson.copy_pairs(arena, target.?.pairs);
const copy = try clone_doc(reply, target.?); const copy = try clone_doc(reply, target.?);
update.apply(copy, &.{ .arena = undefined, .pairs = u_doc }) catch |err| switch (err) { var diag: update.Diagnostic = .{};
error.ImmutableId, error.InvalidUpdate => return bad_value(reply, "bad update"), update.apply(copy, &.{ .arena = undefined, .pairs = u_doc }, &diag) catch |err|
else => return err, return update_refusal(reply, err, diag);
};
// findAndModify reports `n` (matched) and `updatedExisting`, neither of // findAndModify reports `n` (matched) and `updatedExisting`, neither of
// which distinguishes a no-op, so whether it wrote is not needed here. // which distinguishes a no-op, so whether it wrote is not needed here.
_ = try ctx.engine.replace(db_name, coll_name, copy, ctx.oid_gen); _ = try ctx.engine.replace(db_name, coll_name, copy, ctx.oid_gen);
@@ -4108,12 +4134,48 @@ fn clone_doc(reply: *wire.Reply, doc: *const bson.Document) !*bson.Document {
return owned; return owned;
} }
/// The reply an update refusal turns into.
///
/// Shared by all four places that apply an update, because a refusal wired
/// into three of them would be a silent divergence between `update`,
/// `findAndModify` and the upsert path -- and the two new ones exist to stop
/// a silent divergence in the first place.
///
/// The messages are this server's own words. mongod's `PathNotViable` text
/// embeds a shell-syntax rendering of the offending element (`Cannot create
/// field 'nope' in element {y: [ { b: 3 }, { b: 1 } ]}`), and there is no BSON
/// formatter here that produces it. The code is what the corpus asserts and
/// the code is exact; a half-copy of the text would be worse than a clear
/// sentence that does not pretend.
fn update_refusal(reply: *wire.Reply, err: anyerror, diag: update.Diagnostic) !void {
const arena = reply.arena_alloc();
switch (err) {
error.PositionalUnsupported => return bad_value(reply, try std.fmt.allocPrint(
arena,
"the positional operator '{s}' in path '{s}' is not implemented by this server",
.{ diag.segment, diag.path },
)),
error.PathNotViable => return reply.put_error(
@intFromEnum(ErrorCode.path_not_viable),
"PathNotViable",
try std.fmt.allocPrint(
arena,
"Cannot create field '{s}' in an array, at path '{s}'",
.{ diag.segment, diag.path },
),
),
error.ImmutableId, error.InvalidUpdate => return bad_value(reply, "bad update"),
else => return err,
}
}
/// Build the document for an upsert: equality fields from the filter, then /// Build the document for an upsert: equality fields from the filter, then
/// the update operators applied. Owned by the reply arena. /// the update operators applied. Owned by the reply arena.
fn build_upsert_doc( fn build_upsert_doc(
reply: *wire.Reply, reply: *wire.Reply,
q: []const bson.Pair, q: []const bson.Pair,
u_doc: []const bson.Pair, u_doc: []const bson.Pair,
diag: *update.Diagnostic,
) !*bson.Document { ) !*bson.Document {
const arena = reply.arena_alloc(); const arena = reply.arena_alloc();
var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty; var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
@@ -4128,10 +4190,7 @@ fn build_upsert_doc(
const owned = try arena.create(bson.Document); const owned = try arena.create(bson.Document);
owned.* = bson.Document{ .arena = std.heap.ArenaAllocator.init(arena), .pairs = try pairs.toOwnedSlice(arena) }; owned.* = bson.Document{ .arena = std.heap.ArenaAllocator.init(arena), .pairs = try pairs.toOwnedSlice(arena) };
// Apply update operators to build the final doc; _id handled by insert. // Apply update operators to build the final doc; _id handled by insert.
update.apply(owned, &.{ .arena = undefined, .pairs = u_doc }) catch |err| switch (err) { try update.apply(owned, &.{ .arena = undefined, .pairs = u_doc }, diag);
error.ImmutableId, error.InvalidUpdate => return error.InvalidUpdate,
else => return err,
};
return owned; return owned;
} }
@@ -5494,6 +5553,122 @@ test "distinct applies its filter before collecting" {
try testing.expectEqual(@as(i32, 33), values[1].int32); try testing.expectEqual(@as(i32, 33), values[1].int32);
} }
test "drop does not unlock the collection it just freed" {
// Regression test for a use-after-free: dispatch held `drop`'s collection
// lock across the handler, the handler freed the Collection the lock lives
// in, and dispatch then ran `unlock_collection` on freed memory. One
// insert and one drop was enough -- SIGSEGV inside `Io.RwLock.unlock`.
//
// Two things kept it hidden. `drop` had no unit test at all: before this
// one, every `parse_fake_msg("drop", ...)` in the tree was inside a test
// written to hunt it. And over the wire it did not fault -- 25
// insert/drop cycles against a live server pass -- because the general
// allocator leaves the freed page mapped, so the atomic write lands
// somewhere harmless. testing.allocator is what makes it visible, which
// is exactly why this test belongs here rather than in an e2e script.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tdb = try TestDb.init(io);
defer tdb.deinit();
var ctx = tdb.ctx(io);
try dispatch_insert(&tdb, io, "arr", &.{
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} },
});
try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "drop", .{ .string = "arr" }, &.{}));
try testing.expect(ctx.engine.get_collection("test", "arr") == null);
// The namespace is reusable afterwards, and dropping it again is
// NamespaceNotFound rather than a second free.
try dispatch_insert(&tdb, io, "arr", &.{
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 2 } }} },
});
try testing.expectEqual(@as(i32, 1), try doc_count(&ctx, "arr"));
try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "drop", .{ .string = "arr" }, &.{}));
try testing.expectEqual(
@as(?i32, @intFromEnum(ErrorCode.namespace_not_found)),
try run_for_code(&ctx, "drop", .{ .string = "arr" }, &.{}),
);
}
test "dropDatabase frees its collections without unlocking them" {
// The same shape one level up, and the reason the epilogue is now keyed on
// `kind == .write`: dropDatabase is the one write that never held a
// collection lock, so it never reached the commit/checkpoint epilogue.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tdb = try TestDb.init(io);
defer tdb.deinit();
var ctx = tdb.ctx(io);
try dispatch_insert(&tdb, io, "one", &.{
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} },
});
try dispatch_insert(&tdb, io, "two", &.{
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 2 } }} },
});
try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "dropDatabase", .{ .int32 = 1 }, &.{}));
try testing.expect(ctx.engine.get_collection("test", "one") == null);
try testing.expect(ctx.engine.get_collection("test", "two") == null);
}
test "a positional update is refused on the wire and stores nothing" {
// The end of the chain the unit tests start: the refusal has to reach the
// client as a code, and the stored document -- not just the working copy
// -- has to be the one that was there before.
//
// Every case here previously answered ok: 1 with nModified: 1, having
// replaced `y` with a document keyed by the path segment's text.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tdb = try TestDb.init(io);
defer tdb.deinit();
var ctx = tdb.ctx(io);
const cases = [_]struct { coll: []const u8, path: []const u8, code: i32 }{
.{ .coll = "a1", .path = "y.$[i].b", .code = 2 }, // filtered positional
.{ .coll = "a2", .path = "y.$[].b", .code = 2 }, // all-positional
.{ .coll = "a3", .path = "y.$.b", .code = 2 }, // positional
.{ .coll = "a4", .path = "y.nope.b", .code = 28 }, // PathNotViable, same branch
};
for (cases) |c| {
try dispatch_insert(&tdb, io, c.coll, &.{
.{ .doc = &.{
.{ .key = "_id", .value = .{ .int32 = 1 } },
.{ .key = "y", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 3 } }} },
} } },
} },
});
const updates = [_]bson.Value{.{ .doc = &.{
.{ .key = "q", .value = .{ .doc = &.{} } },
.{ .key = "u", .value = .{ .doc = &.{
.{ .key = "$set", .value = .{ .doc = &.{
.{ .key = c.path, .value = .{ .int32 = 2 } },
} } },
} } },
} }};
try testing.expectEqual(@as(?i32, c.code), try run_for_code(&ctx, "update", .{ .string = c.coll }, &.{
.{ .key = "updates", .value = .{ .array = &updates } },
}));
// Read it back through `distinct`: if the array survived it still has
// an element with `b: 3`, and if it was overwritten by a document
// there is nothing at `y.b` at all.
var reply = wire.Reply.init(testing.allocator);
defer reply.deinit();
const values = try distinct_values(&tdb, io, &reply, c.coll, &.{
.{ .key = "key", .value = .{ .string = "y.b" } },
});
try testing.expectEqual(@as(usize, 1), values.len);
try testing.expectEqual(@as(i32, 3), values[0].int32);
}
}
test "aggregate $sort without a preceding $group sorts and frees correctly" { test "aggregate $sort without a preceding $group sorts and frees correctly" {
// Regression test for a remote, client-triggerable invalid free: the // Regression test for a remote, client-triggerable invalid free: the
// $sort stage materialized its document list from the reply arena and // $sort stage materialized its document list from the reply arena and

View File

@@ -6,7 +6,42 @@ const std = @import("std");
const bson = @import("bson.zig"); const bson = @import("bson.zig");
const query = @import("query.zig"); const query = @import("query.zig");
pub const UpdateError = error{ ImmutableId, InvalidUpdate, OutOfMemory }; pub const UpdateError = error{
ImmutableId,
InvalidUpdate,
/// A `$`, `$[]` or `$[<identifier>]` segment: MongoDB's three ways of
/// saying "descend into this array", none of them implemented here.
PositionalUnsupported,
/// A non-numeric segment applied to an array, which is never a field to
/// create. mongod's `PathNotViable`.
PathNotViable,
OutOfMemory,
};
/// Which path a refusal was about, so the reply can name it instead of saying
/// "bad update". Borrowed from the update document, which outlives the call.
pub const Diagnostic = struct {
path: []const u8 = "",
segment: []const u8 = "",
};
fn note(diag: ?*Diagnostic, path: []const u8, segment: []const u8) void {
if (diag) |d| d.* = .{ .path = path, .segment = segment };
}
/// `$`, `$[]` and `$[<identifier>]` -- the three spellings of "descend into
/// this array".
///
/// None is implemented, and until they are each has to be refused rather than
/// walked. `set_path` used to reach the array, fail to read the segment as an
/// index, and overwrite the array with a document keyed by the segment's
/// literal text: `{$set: {"y.$[i].b": 2}}` turned `y: [{b: 3}, {b: 1}]` into
/// `y: {"$[i]": {"b": 2}}` and answered ok: 1. Every element was discarded,
/// under every operator -- `$inc` stored its operand rather than incrementing.
fn is_positional(seg: []const u8) bool {
if (std.mem.eql(u8, seg, "$")) return true;
return seg.len >= 3 and std.mem.startsWith(u8, seg, "$[") and seg[seg.len - 1] == ']';
}
const max_path_segments = 16; const max_path_segments = 16;
@@ -28,8 +63,16 @@ fn is_operator_key(key: []const u8) bool {
} }
/// Apply an update document to `doc`: a replacement, or a set of operators. /// Apply an update document to `doc`: a replacement, or a set of operators.
pub fn apply(doc: *bson.Document, update: *const bson.Document) UpdateError!void { pub fn apply(
doc: *bson.Document,
update: *const bson.Document,
diag: ?*Diagnostic,
) UpdateError!void {
// A replacement carries data, not paths, so nothing below applies to it --
// which is also why mongod ignores `arrayFilters` alongside one, the single
// case out of seventeen where this server already agreed with it.
if (is_replacement(update.pairs)) return apply_replacement(doc, update.pairs); if (is_replacement(update.pairs)) return apply_replacement(doc, update.pairs);
try reject_positional(update.pairs, diag);
const arena = doc.arena.allocator(); const arena = doc.arena.allocator();
var pairs = try copy_to_list(bson.Pair, arena, doc.pairs); var pairs = try copy_to_list(bson.Pair, arena, doc.pairs);
for (update.pairs) |op| { for (update.pairs) |op| {
@@ -37,11 +80,40 @@ pub fn apply(doc: *bson.Document, update: *const bson.Document) UpdateError!void
// is not one is a mixed document -- which MongoDB rejects rather than // is not one is a mixed document -- which MongoDB rejects rather than
// guessing at. // guessing at.
if (!is_operator_key(op.key)) return error.InvalidUpdate; if (!is_operator_key(op.key)) return error.InvalidUpdate;
try apply_operator(arena, &pairs, op.key, op.value); try apply_operator(arena, &pairs, op.key, op.value, diag);
} }
doc.pairs = try pairs.toOwnedSlice(arena); doc.pairs = try pairs.toOwnedSlice(arena);
} }
/// Refuse every positional path in the update before any of it is applied.
///
/// Up front rather than at the point of use, because one update names several
/// paths: checking as we walk would refuse the third path having already
/// rewritten what the first two named. The caller discards its copy on error
/// either way, so this is not what makes the refusal safe -- it is what makes
/// the refusal *about the update* rather than about however far a walk got.
fn reject_positional(update: []const bson.Pair, diag: ?*Diagnostic) UpdateError!void {
for (update) |op| {
const ops = doc_pairs(op.value) orelse continue;
for (ops) |p| {
try reject_positional_path(p.key, diag);
// `$rename`'s destination is a path too, and it is the *value*.
if (std.mem.eql(u8, op.key, "$rename") and p.value == .string) {
try reject_positional_path(p.value.string, diag);
}
}
}
}
fn reject_positional_path(path: []const u8, diag: ?*Diagnostic) UpdateError!void {
var it = std.mem.splitScalar(u8, path, '.');
while (it.next()) |seg| {
if (!is_positional(seg)) continue;
note(diag, path, seg);
return error.PositionalUnsupported;
}
}
/// Replace every field of `doc` with `replacement`'s, except `_id`. /// Replace every field of `doc` with `replacement`'s, except `_id`.
/// ///
/// `_id` is immutable, so it survives and keeps its position at the front (which /// `_id` is immutable, so it survives and keeps its position at the front (which
@@ -97,6 +169,7 @@ fn apply_operator(
pairs: *std.ArrayListUnmanaged(bson.Pair), pairs: *std.ArrayListUnmanaged(bson.Pair),
op: []const u8, op: []const u8,
value: bson.Value, value: bson.Value,
diag: ?*Diagnostic,
) UpdateError!void { ) UpdateError!void {
if (std.mem.eql(u8, op, "$set")) { if (std.mem.eql(u8, op, "$set")) {
const ops = doc_pairs(value) orelse return error.InvalidUpdate; const ops = doc_pairs(value) orelse return error.InvalidUpdate;
@@ -104,7 +177,7 @@ fn apply_operator(
if (std.mem.eql(u8, p.key, "_id")) return error.ImmutableId; if (std.mem.eql(u8, p.key, "_id")) return error.ImmutableId;
var segs: [max_path_segments][]const u8 = undefined; var segs: [max_path_segments][]const u8 = undefined;
const n = split_path(p.key, &segs) orelse return error.InvalidUpdate; const n = split_path(p.key, &segs) orelse return error.InvalidUpdate;
try set_path(arena, pairs, segs[0..n], try bson.copy_value(arena, p.value)); try set_path(arena, pairs, segs[0..n], try bson.copy_value(arena, p.value), p.key, diag);
} }
return; return;
} }
@@ -125,7 +198,7 @@ fn apply_operator(
const current = get_value(pairs.items, segs[0..n]) orelse bson.Value{ .int32 = 0 }; const current = get_value(pairs.items, segs[0..n]) orelse bson.Value{ .int32 = 0 };
if (!current.is_number() or !p.value.is_number()) return error.InvalidUpdate; if (!current.is_number() or !p.value.is_number()) return error.InvalidUpdate;
const sum = try numeric_add(current, p.value); const sum = try numeric_add(current, p.value);
try set_path(arena, pairs, segs[0..n], sum); try set_path(arena, pairs, segs[0..n], sum, p.key, diag);
} }
return; return;
} }
@@ -151,12 +224,12 @@ fn apply_operator(
else => return error.InvalidUpdate, else => return error.InvalidUpdate,
}; };
for (arr) |item| try items.append(arena, try bson.copy_value(arena, item)); for (arr) |item| try items.append(arena, try bson.copy_value(arena, item));
try set_path(arena, pairs, segs[0..n], .{ .array = try items.toOwnedSlice(arena) }); try set_path(arena, pairs, segs[0..n], .{ .array = try items.toOwnedSlice(arena) }, p.key, diag);
continue; continue;
} }
} }
try items.append(arena, try bson.copy_value(arena, p.value)); try items.append(arena, try bson.copy_value(arena, p.value));
try set_path(arena, pairs, segs[0..n], .{ .array = try items.toOwnedSlice(arena) }); try set_path(arena, pairs, segs[0..n], .{ .array = try items.toOwnedSlice(arena) }, p.key, diag);
} }
return; return;
} }
@@ -177,7 +250,7 @@ fn apply_operator(
try items.append(arena, elem); try items.append(arena, elem);
} }
} }
try set_path(arena, pairs, segs[0..n], .{ .array = try items.toOwnedSlice(arena) }); try set_path(arena, pairs, segs[0..n], .{ .array = try items.toOwnedSlice(arena) }, p.key, diag);
} }
return; return;
} }
@@ -192,7 +265,7 @@ fn apply_operator(
unset_path(arena, pairs, old_segs[0..old_n]); unset_path(arena, pairs, old_segs[0..old_n]);
var new_segs: [max_path_segments][]const u8 = undefined; var new_segs: [max_path_segments][]const u8 = undefined;
const new_n = split_path(p.value.string, &new_segs) orelse return error.InvalidUpdate; const new_n = split_path(p.value.string, &new_segs) orelse return error.InvalidUpdate;
try set_path(arena, pairs, new_segs[0..new_n], v); try set_path(arena, pairs, new_segs[0..new_n], v, p.value.string, diag);
} }
return; return;
} }
@@ -258,6 +331,8 @@ fn set_path(
pairs: *std.ArrayListUnmanaged(bson.Pair), pairs: *std.ArrayListUnmanaged(bson.Pair),
segs: []const []const u8, segs: []const []const u8,
value: bson.Value, value: bson.Value,
path: []const u8,
diag: ?*Diagnostic,
) UpdateError!void { ) UpdateError!void {
if (segs.len == 1) { if (segs.len == 1) {
if (find_pair(pairs.items, segs[0])) |idx| { if (find_pair(pairs.items, segs[0])) |idx| {
@@ -270,23 +345,31 @@ fn set_path(
const idx = find_pair(pairs.items, segs[0]) orelse { const idx = find_pair(pairs.items, segs[0]) orelse {
const is_array = parse_index(segs[1]) != null; const is_array = parse_index(segs[1]) != null;
try pairs.append(arena, .{ .key = try arena.dupe(u8, segs[0]), .value = if (is_array) .{ .array = &.{} } else .{ .doc = &.{} } }); try pairs.append(arena, .{ .key = try arena.dupe(u8, segs[0]), .value = if (is_array) .{ .array = &.{} } else .{ .doc = &.{} } });
return set_path(arena, pairs, segs, value); return set_path(arena, pairs, segs, value, path, diag);
}; };
switch (pairs.items[idx].value) { switch (pairs.items[idx].value) {
.doc => |sub| { .doc => |sub| {
var sub_pairs = try copy_to_list(bson.Pair, arena, sub); var sub_pairs = try copy_to_list(bson.Pair, arena, sub);
defer sub_pairs.deinit(arena); defer sub_pairs.deinit(arena);
try set_path(arena, &sub_pairs, segs[1..], value); try set_path(arena, &sub_pairs, segs[1..], value, path, diag);
pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) }; pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
}, },
.array => |arr| { .array => |arr| {
const index = parse_index(segs[1]) orelse { const index = parse_index(segs[1]) orelse {
// treat as non-array: replace with a doc // A non-numeric segment under an array is never a field to
var sub_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty; // create. This branch used to read "treat as non-array:
defer sub_pairs.deinit(arena); // replace with a doc" and did exactly that -- `y.nope.b`
try set_path(arena, &sub_pairs, segs[1..], value); // turned `y: [{b: 3}]` into `y: {nope: {b: 2}}`, discarding
pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) }; // every element and answering ok: 1. mongod refuses with
return; // PathNotViable and leaves the document alone.
//
// The positional spellings took this same branch and are the
// reason it was found; they are refused earlier, by
// `reject_positional`. What reaches here is the rest of the
// class: a plain field name, and a `$`-prefixed one that is
// not positional.
note(diag, path, segs[1]);
return error.PathNotViable;
}; };
var items = try copy_to_list(bson.Value, arena, arr); var items = try copy_to_list(bson.Value, arena, arr);
defer items.deinit(arena); defer items.deinit(arena);
@@ -300,13 +383,13 @@ fn set_path(
.doc => |sub| { .doc => |sub| {
var sub_pairs = try copy_to_list(bson.Pair, arena, sub); var sub_pairs = try copy_to_list(bson.Pair, arena, sub);
defer sub_pairs.deinit(arena); defer sub_pairs.deinit(arena);
try set_path(arena, &sub_pairs, segs[2..], value); try set_path(arena, &sub_pairs, segs[2..], value, path, diag);
items.items[index] = .{ .doc = try sub_pairs.toOwnedSlice(arena) }; items.items[index] = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
}, },
else => { else => {
var sub_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty; var sub_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
defer sub_pairs.deinit(arena); defer sub_pairs.deinit(arena);
try set_path(arena, &sub_pairs, segs[2..], value); try set_path(arena, &sub_pairs, segs[2..], value, path, diag);
items.items[index] = .{ .doc = try sub_pairs.toOwnedSlice(arena) }; items.items[index] = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
}, },
} }
@@ -316,7 +399,7 @@ fn set_path(
else => { else => {
var sub_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty; var sub_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
defer sub_pairs.deinit(arena); defer sub_pairs.deinit(arena);
try set_path(arena, &sub_pairs, segs[1..], value); try set_path(arena, &sub_pairs, segs[1..], value, path, diag);
pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) }; pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
}, },
} }
@@ -425,7 +508,7 @@ test "$set, $inc, $unset, $rename" {
.{ .key = "$inc", .value = .{ .doc = &.{.{ .key = "user.age", .value = .{ .int32 = 2 } }} } }, .{ .key = "$inc", .value = .{ .doc = &.{.{ .key = "user.age", .value = .{ .int32 = 2 } }} } },
.{ .key = "$unset", .value = .{ .doc = &.{.{ .key = "gone", .value = .{ .string = "" } }} } }, .{ .key = "$unset", .value = .{ .doc = &.{.{ .key = "gone", .value = .{ .string = "" } }} } },
.{ .key = "$rename", .value = .{ .doc = &.{.{ .key = "new", .value = .{ .string = "renamed" } }} } }, .{ .key = "$rename", .value = .{ .doc = &.{.{ .key = "new", .value = .{ .string = "renamed" } }} } },
})); }), null);
const user = bson.get_pair(doc.pairs, "user").?; const user = bson.get_pair(doc.pairs, "user").?;
try testing.expectEqualStrings("alice", user.doc[0].value.string); try testing.expectEqualStrings("alice", user.doc[0].value.string);
@@ -445,13 +528,13 @@ test "$push and $pull" {
try apply(&doc, &doc_of(&.{ try apply(&doc, &doc_of(&.{
.{ .key = "$push", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .string = "c" } }} } }, .{ .key = "$push", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .string = "c" } }} } },
})); }), null);
try testing.expectEqual(@as(usize, 3), bson.get_pair(doc.pairs, "tags").?.array.len); try testing.expectEqual(@as(usize, 3), bson.get_pair(doc.pairs, "tags").?.array.len);
try testing.expectEqualStrings("c", bson.get_pair(doc.pairs, "tags").?.array[2].string); try testing.expectEqualStrings("c", bson.get_pair(doc.pairs, "tags").?.array[2].string);
try apply(&doc, &doc_of(&.{ try apply(&doc, &doc_of(&.{
.{ .key = "$pull", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .string = "b" } }} } }, .{ .key = "$pull", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .string = "b" } }} } },
})); }), null);
const tags = bson.get_pair(doc.pairs, "tags").?.array; const tags = bson.get_pair(doc.pairs, "tags").?.array;
try testing.expectEqual(@as(usize, 2), tags.len); try testing.expectEqual(@as(usize, 2), tags.len);
try testing.expectEqualStrings("a", tags[0].string); try testing.expectEqualStrings("a", tags[0].string);
@@ -459,7 +542,7 @@ test "$push and $pull" {
try apply(&doc, &doc_of(&.{ try apply(&doc, &doc_of(&.{
.{ .key = "$push", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .doc = &.{.{ .key = "$each", .value = .{ .array = &.{ .{ .string = "x" }, .{ .string = "y" } } } }} } }} } }, .{ .key = "$push", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .doc = &.{.{ .key = "$each", .value = .{ .array = &.{ .{ .string = "x" }, .{ .string = "y" } } } }} } }} } },
})); }), null);
try testing.expectEqual(@as(usize, 4), bson.get_pair(doc.pairs, "tags").?.array.len); try testing.expectEqual(@as(usize, 4), bson.get_pair(doc.pairs, "tags").?.array.len);
} }
@@ -475,7 +558,7 @@ test "$set nested creation and _id protection" {
.{ .key = "a.b.c", .value = .{ .int32 = 42 } }, .{ .key = "a.b.c", .value = .{ .int32 = 42 } },
.{ .key = "arr.1", .value = .{ .string = "x" } }, .{ .key = "arr.1", .value = .{ .string = "x" } },
} } }, } } },
})); }), null);
const a = bson.get_pair(doc.pairs, "a").?; const a = bson.get_pair(doc.pairs, "a").?;
const b = bson.get_pair(a.doc, "b").?; const b = bson.get_pair(a.doc, "b").?;
try testing.expectEqual(@as(i64, 42), bson.get_pair(b.doc, "c").?.int32); try testing.expectEqual(@as(i64, 42), bson.get_pair(b.doc, "c").?.int32);
@@ -485,7 +568,7 @@ test "$set nested creation and _id protection" {
try testing.expectError(error.ImmutableId, apply(&doc, &doc_of(&.{ try testing.expectError(error.ImmutableId, apply(&doc, &doc_of(&.{
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 2 } }} } }, .{ .key = "$set", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 2 } }} } },
}))); }), null));
} }
test "a replacement keeps _id and drops every other field" { test "a replacement keeps _id and drops every other field" {
@@ -502,7 +585,7 @@ test "a replacement keeps _id and drops every other field" {
try apply(&doc, &doc_of(&.{ try apply(&doc, &doc_of(&.{
.{ .key = "fresh", .value = .{ .int32 = 42 } }, .{ .key = "fresh", .value = .{ .int32 = 42 } },
})); }), null);
try testing.expectEqual(@as(usize, 2), doc.pairs.len); try testing.expectEqual(@as(usize, 2), doc.pairs.len);
// _id survives, and stays at the front where it is stored. // _id survives, and stays at the front where it is stored.
@@ -523,7 +606,7 @@ test "an empty replacement leaves a document holding only its _id" {
// Legal, and the reason `is_replacement` treats an empty document as one // Legal, and the reason `is_replacement` treats an empty document as one
// rather than as a no-op set of operators. // rather than as a no-op set of operators.
try apply(&doc, &doc_of(&.{})); try apply(&doc, &doc_of(&.{}), null);
try testing.expectEqual(@as(usize, 1), doc.pairs.len); try testing.expectEqual(@as(usize, 1), doc.pairs.len);
try testing.expectEqualStrings("_id", doc.pairs[0].key); try testing.expectEqualStrings("_id", doc.pairs[0].key);
} }
@@ -545,7 +628,7 @@ test "a replacement may repeat the _id it is replacing, but not change it" {
try apply(&doc, &doc_of(&.{ try apply(&doc, &doc_of(&.{
.{ .key = "_id", .value = .{ .int32 = 7 } }, .{ .key = "_id", .value = .{ .int32 = 7 } },
.{ .key = "b", .value = .{ .int32 = 2 } }, .{ .key = "b", .value = .{ .int32 = 2 } },
})); }), null);
try testing.expectEqual(@as(i64, 7), doc.pairs[0].value.int32); try testing.expectEqual(@as(i64, 7), doc.pairs[0].value.int32);
try testing.expectEqual(@as(i64, 2), bson.get_pair(doc.pairs, "b").?.int32); try testing.expectEqual(@as(i64, 2), bson.get_pair(doc.pairs, "b").?.int32);
// And only once, not twice. // And only once, not twice.
@@ -554,14 +637,14 @@ test "a replacement may repeat the _id it is replacing, but not change it" {
// A different _id: refused. // A different _id: refused.
try testing.expectError(error.ImmutableId, apply(&doc, &doc_of(&.{ try testing.expectError(error.ImmutableId, apply(&doc, &doc_of(&.{
.{ .key = "_id", .value = .{ .int32 = 8 } }, .{ .key = "_id", .value = .{ .int32 = 8 } },
}))); }), null));
// Equal across numeric types is the same _id, matching the canonical key // Equal across numeric types is the same _id, matching the canonical key
// encoding the _id_ index descends on. // encoding the _id_ index descends on.
try apply(&doc, &doc_of(&.{ try apply(&doc, &doc_of(&.{
.{ .key = "_id", .value = .{ .double = 7.0 } }, .{ .key = "_id", .value = .{ .double = 7.0 } },
.{ .key = "c", .value = .{ .int32 = 3 } }, .{ .key = "c", .value = .{ .int32 = 3 } },
})); }), null);
try testing.expectEqual(@as(i64, 3), bson.get_pair(doc.pairs, "c").?.int32); try testing.expectEqual(@as(i64, 3), bson.get_pair(doc.pairs, "c").?.int32);
} }
@@ -578,7 +661,7 @@ test "a replacement supplies the _id when the document has none" {
try apply(&doc, &doc_of(&.{ try apply(&doc, &doc_of(&.{
.{ .key = "a", .value = .{ .int32 = 1 } }, .{ .key = "a", .value = .{ .int32 = 1 } },
.{ .key = "_id", .value = .{ .int32 = 99 } }, .{ .key = "_id", .value = .{ .int32 = 99 } },
})); }), null);
try testing.expectEqual(@as(usize, 2), doc.pairs.len); try testing.expectEqual(@as(usize, 2), doc.pairs.len);
try testing.expectEqualStrings("_id", doc.pairs[0].key); try testing.expectEqualStrings("_id", doc.pairs[0].key);
@@ -596,13 +679,13 @@ test "a mixed update document is refused from either side" {
try testing.expectError(error.InvalidUpdate, apply(&doc, &doc_of(&.{ try testing.expectError(error.InvalidUpdate, apply(&doc, &doc_of(&.{
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, .{ .key = "$set", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
.{ .key = "plain", .value = .{ .int32 = 1 } }, .{ .key = "plain", .value = .{ .int32 = 1 } },
}))); }), null));
// Starts with data, so it is a replacement and an operator has no meaning. // Starts with data, so it is a replacement and an operator has no meaning.
try testing.expectError(error.InvalidUpdate, apply(&doc, &doc_of(&.{ try testing.expectError(error.InvalidUpdate, apply(&doc, &doc_of(&.{
.{ .key = "plain", .value = .{ .int32 = 1 } }, .{ .key = "plain", .value = .{ .int32 = 1 } },
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } }, .{ .key = "$set", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
}))); }), null));
} }
test "is_replacement decides on the first field only" { test "is_replacement decides on the first field only" {
@@ -613,3 +696,153 @@ test "is_replacement decides on the first field only" {
// replacement path then stores it, which is what MongoDB does with it. // replacement path then stores it, which is what MongoDB does with it.
try testing.expect(is_replacement(&.{.{ .key = "", .value = .{ .int32 = 1 } }})); try testing.expect(is_replacement(&.{.{ .key = "", .value = .{ .int32 = 1 } }}));
} }
/// A document with one array field, rebuilt per case so a refusal can be
/// checked against untouched bytes.
fn array_doc(arena: std.mem.Allocator) !bson.Document {
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(arena), .pairs = &.{} };
doc.pairs = try doc.arena.allocator().dupe(bson.Pair, &.{
.{ .key = "y", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 3 } }} },
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 1 } }} },
} } },
});
return doc;
}
fn expect_y_untouched(doc: *const bson.Document) !void {
const y = doc.get("y") orelse return error.TestUnexpectedResult;
// The array is still an array. Before this refusal existed it was a
// document keyed by the path segment's literal text, and everything in it
// was gone.
try testing.expect(y == .array);
try testing.expectEqual(@as(usize, 2), y.array.len);
try testing.expectEqual(@as(i32, 3), y.array[0].doc[0].value.int32);
try testing.expectEqual(@as(i32, 1), y.array[1].doc[0].value.int32);
}
test "a positional path is refused and the array survives" {
// The load-bearing test of the refusal. Each of these used to answer
// success having replaced `y` with `{"<segment>": ...}` -- the array and
// both its elements discarded, `ok: 1`, `modifiedCount: 1`.
//
// Mutation check: delete the `reject_positional` call in `apply` and every
// case here goes red on `expect_y_untouched`, not on the error.
const cases = [_][]const u8{
"y.$[i].b", // filtered positional
"y.$[].b", // all-positional
"y.$.b", // positional
"y.$[i]", // as the leaf
"$", // in first position
"y.$[i].c.$[j].d", // nested, two identifiers
};
for (cases) |path| {
var doc = try array_doc(testing.allocator);
defer doc.arena.deinit();
var diag: Diagnostic = .{};
try testing.expectError(error.PositionalUnsupported, apply(&doc, &doc_of(&.{
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = path, .value = .{ .int32 = 2 } }} } },
}), &diag));
try testing.expectEqualStrings(path, diag.path);
try expect_y_untouched(&doc);
}
}
test "every operator refuses a positional path, not just $set" {
// The destruction was below the operator, in the shared path walk: `$inc`
// through `$[i]` stored its operand instead of incrementing. So the
// refusal has to be below the operator too.
const ops = [_]struct { op: []const u8, value: bson.Value }{
.{ .op = "$set", .value = .{ .int32 = 2 } },
.{ .op = "$inc", .value = .{ .int32 = 10 } },
.{ .op = "$unset", .value = .{ .string = "" } },
.{ .op = "$push", .value = .{ .int32 = 1 } },
.{ .op = "$pull", .value = .{ .int32 = 1 } },
};
for (ops) |o| {
var doc = try array_doc(testing.allocator);
defer doc.arena.deinit();
try testing.expectError(error.PositionalUnsupported, apply(&doc, &doc_of(&.{
.{ .key = o.op, .value = .{ .doc = &.{.{ .key = "y.$[i].b", .value = o.value }} } },
}), null));
try expect_y_untouched(&doc);
}
}
test "$rename checks its destination, which is the value not the key" {
var doc = try array_doc(testing.allocator);
defer doc.arena.deinit();
var diag: Diagnostic = .{};
try testing.expectError(error.PositionalUnsupported, apply(&doc, &doc_of(&.{
.{ .key = "$rename", .value = .{ .doc = &.{
.{ .key = "y", .value = .{ .string = "z.$[i]" } },
} } },
}), &diag));
try testing.expectEqualStrings("z.$[i]", diag.path);
try expect_y_untouched(&doc);
}
test "nothing in the update is applied when one of its paths is refused" {
// The refusal is taken before the first write, so the good path in this
// update does not land either. Mutation check: move `reject_positional`
// inside the operator loop and `ok` appears on the document.
var doc = try array_doc(testing.allocator);
defer doc.arena.deinit();
try testing.expectError(error.PositionalUnsupported, apply(&doc, &doc_of(&.{
.{ .key = "$set", .value = .{ .doc = &.{
.{ .key = "ok", .value = .{ .int32 = 1 } },
.{ .key = "y.$[i].b", .value = .{ .int32 = 2 } },
} } },
}), null));
try testing.expect(doc.get("ok") == null);
try expect_y_untouched(&doc);
}
test "a non-numeric segment under an array is PathNotViable, not a new field" {
// The rest of the class the positional forms belonged to. `y.nope.b` is
// not a positional operator and is refused for a different reason with a
// different code -- measured on mongod 8.3.7, which answers 28 here and 2
// for the positional forms.
const cases = [_][]const u8{ "y.nope.b", "y.nope", "y.$x.b" };
for (cases) |path| {
var doc = try array_doc(testing.allocator);
defer doc.arena.deinit();
var diag: Diagnostic = .{};
try testing.expectError(error.PathNotViable, apply(&doc, &doc_of(&.{
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = path, .value = .{ .int32 = 2 } }} } },
}), &diag));
try expect_y_untouched(&doc);
}
}
test "a numeric segment still addresses an array element" {
// The regression guard for the refusal above: indexed paths are the one
// way into an array that does work, and they must keep working, including
// the null padding past the end.
var doc = try array_doc(testing.allocator);
defer doc.arena.deinit();
try apply(&doc, &doc_of(&.{
.{ .key = "$set", .value = .{ .doc = &.{
.{ .key = "y.0.b", .value = .{ .int32 = 7 } },
.{ .key = "y.3.b", .value = .{ .int32 = 8 } },
} } },
}), null);
const y = doc.get("y").?;
try testing.expectEqual(@as(usize, 4), y.array.len);
try testing.expectEqual(@as(i32, 7), y.array[0].doc[0].value.int32);
try testing.expect(y.array[2] == .null);
try testing.expectEqual(@as(i32, 8), y.array[3].doc[0].value.int32);
}
test "a replacement is not a path, so it is not refused" {
// The one case out of seventeen where this server already agreed with
// mongod: `arrayFilters` alongside a replacement is ignored by both, and
// a replacement field named like a path is data, not a path.
var doc = try array_doc(testing.allocator);
defer doc.arena.deinit();
try apply(&doc, &doc_of(&.{
.{ .key = "z", .value = .{ .int32 = 1 } },
}), null);
try testing.expect(doc.get("y") == null);
try testing.expectEqual(@as(i32, 1), doc.get("z").?.int32);
}

View File

@@ -209,9 +209,9 @@ aggregate-rawdata.json SKIP Aggregate with rawData option needs server >= 8.2.0
aggregate-write-readPreference.json SKIP * needs topology replicaset/sharded/load-balanced aggregate-write-readPreference.json SKIP * needs topology replicaset/sharded/load-balanced
aggregate.json SKIP aggregate with a document comment - pre 4.4 needs server <= 4.2.99 aggregate.json SKIP aggregate with a document comment - pre 4.4 needs server <= 4.2.99
aggregate.json SKIP aggregate with comment does not set comment on getMore - pre 4.4 needs server <= 4.3.99 aggregate.json SKIP aggregate with comment does not set comment on getMore - pre 4.4 needs server <= 4.3.99
bulkWrite-arrayFilters.json FAIL BulkWrite updateOne with arrayFilters outcome crud-tests.test[0].y: expected an array, got {"$[i]":{"b":2}} bulkWrite-arrayFilters.json FAIL BulkWrite updateOne with arrayFilters MongoBulkWriteError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
bulkWrite-arrayFilters.json FAIL BulkWrite updateMany with arrayFilters outcome crud-tests.test[0].y: expected an array, got {"$[i]":{"b":2}} bulkWrite-arrayFilters.json FAIL BulkWrite updateMany with arrayFilters MongoBulkWriteError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
bulkWrite-arrayFilters.json FAIL BulkWrite with arrayFilters bulkWrite.modifiedCount: expected 3, got 2 bulkWrite-arrayFilters.json FAIL BulkWrite with arrayFilters MongoBulkWriteError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
bulkWrite-collation.json FAIL BulkWrite with delete operations and collation bulkWrite.deletedCount: expected 4, got 0 bulkWrite-collation.json FAIL BulkWrite with delete operations and collation bulkWrite.deletedCount: expected 4, got 0
bulkWrite-collation.json FAIL BulkWrite with update operations and collation bulkWrite.matchedCount: expected 6, got 2 bulkWrite-collation.json FAIL BulkWrite with update operations and collation bulkWrite.matchedCount: expected 6, got 2
bulkWrite-comment.json SKIP BulkWrite with comment - pre 4.4 needs server <= 4.2.99 bulkWrite-comment.json SKIP BulkWrite with comment - pre 4.4 needs server <= 4.2.99
@@ -361,9 +361,9 @@ findOneAndReplace-upsert.json FAIL FindOneAndReplace when no documents match wit
findOneAndReplace-upsert.json FAIL FindOneAndReplace when no documents match with id specified with upsert returning the document after modification findOneAndReplace: expected a document, got null findOneAndReplace-upsert.json FAIL FindOneAndReplace when no documents match with id specified with upsert returning the document after modification findOneAndReplace: expected a document, got null
findOneAndReplace.json FAIL FindOneAndReplace when many documents match returning the document after modification findOneAndReplace.x: expected 32, got 22 findOneAndReplace.json FAIL FindOneAndReplace when many documents match returning the document after modification findOneAndReplace.x: expected 32, got 22
findOneAndReplace.json FAIL FindOneAndReplace when one document matches returning the document after modification findOneAndReplace.x: expected 32, got 22 findOneAndReplace.json FAIL FindOneAndReplace when one document matches returning the document after modification findOneAndReplace.x: expected 32, got 22
findOneAndUpdate-arrayFilters.json FAIL FindOneAndUpdate when no document matches arrayFilters outcome crud-v1.coll[0].y: expected an array, got {"$[i]":{"b":2}} findOneAndUpdate-arrayFilters.json FAIL FindOneAndUpdate when no document matches arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
findOneAndUpdate-arrayFilters.json FAIL FindOneAndUpdate when one document matches arrayFilters outcome crud-v1.coll[0].y: expected an array, got {"$[i]":{"b":2}} findOneAndUpdate-arrayFilters.json FAIL FindOneAndUpdate when one document matches arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
findOneAndUpdate-arrayFilters.json FAIL FindOneAndUpdate when multiple documents match arrayFilters outcome crud-v1.coll[0].y: expected an array, got {"$[i]":{"b":2}} findOneAndUpdate-arrayFilters.json FAIL FindOneAndUpdate when multiple documents match arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
findOneAndUpdate-collation.json FAIL FindOneAndUpdate when many documents match with collation returning the document before modification findOneAndUpdate: expected a document, got null findOneAndUpdate-collation.json FAIL FindOneAndUpdate when many documents match with collation returning the document before modification findOneAndUpdate: expected a document, got null
findOneAndUpdate-comment.json FAIL findOneAndUpdate with string comment MongoServerError: update must be a document findOneAndUpdate-comment.json FAIL findOneAndUpdate with string comment MongoServerError: update must be a document
findOneAndUpdate-comment.json FAIL findOneAndUpdate with document comment MongoServerError: update must be a document findOneAndUpdate-comment.json FAIL findOneAndUpdate with document comment MongoServerError: update must be a document
@@ -403,9 +403,9 @@ replaceOne-let.json SKIP ReplaceOne with let option needs server >= 5.0
replaceOne-let.json FAIL ReplaceOne with let option unsupported (server-side error) replaceOne: expected an error, the operation succeeded replaceOne-let.json FAIL ReplaceOne with let option unsupported (server-side error) replaceOne: expected an error, the operation succeeded
replaceOne-rawdata.json SKIP ReplaceOne with rawData option needs server >= 8.2.0 replaceOne-rawdata.json SKIP ReplaceOne with rawData option needs server >= 8.2.0
replaceOne-sort.json SKIP ReplaceOne with sort option needs server >= 8.0 replaceOne-sort.json SKIP ReplaceOne with sort option needs server >= 8.0
updateMany-arrayFilters.json FAIL UpdateMany when no documents match arrayFilters updateMany.modifiedCount: expected 0, got 2 updateMany-arrayFilters.json FAIL UpdateMany when no documents match arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
updateMany-arrayFilters.json FAIL UpdateMany when one document matches arrayFilters updateMany.modifiedCount: expected 1, got 2 updateMany-arrayFilters.json FAIL UpdateMany when one document matches arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
updateMany-arrayFilters.json FAIL UpdateMany when multiple documents match arrayFilters outcome crud-v1.coll[0].y: expected an array, got {"$[i]":{"b":2}} updateMany-arrayFilters.json FAIL UpdateMany when multiple documents match arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
updateMany-collation.json FAIL UpdateMany when many documents match with collation updateMany.matchedCount: expected 2, got 1 updateMany-collation.json FAIL UpdateMany when many documents match with collation updateMany.matchedCount: expected 2, got 1
updateMany-comment.json SKIP UpdateMany with comment - pre 4.4 needs server <= 4.2.99 updateMany-comment.json SKIP UpdateMany with comment - pre 4.4 needs server <= 4.2.99
updateMany-dots_and_dollars.json SKIP Updating document to set top-level dollar-prefixed key on 5.0+ server needs server >= 5.0 updateMany-dots_and_dollars.json SKIP Updating document to set top-level dollar-prefixed key on 5.0+ server needs server >= 5.0
@@ -416,11 +416,11 @@ updateMany-let.json SKIP updateMany with let option needs server >= 5.0
updateMany-let.json FAIL updateMany with let option unsupported (server-side error) updateMany: error message "update spec requires u" does not contain "'update.let' is an unknown field" updateMany-let.json FAIL updateMany with let option unsupported (server-side error) updateMany: error message "update spec requires u" does not contain "'update.let' is an unknown field"
updateMany-pipeline.json FAIL UpdateMany using pipelines MongoServerError: update spec requires u updateMany-pipeline.json FAIL UpdateMany using pipelines MongoServerError: update spec requires u
updateMany-rawdata.json SKIP updateMany with rawData option needs server >= 8.2.0 updateMany-rawdata.json SKIP updateMany with rawData option needs server >= 8.2.0
updateOne-arrayFilters.json FAIL UpdateOne when no document matches arrayFilters updateOne.modifiedCount: expected 0, got 1 updateOne-arrayFilters.json FAIL UpdateOne when no document matches arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
updateOne-arrayFilters.json FAIL UpdateOne when one document matches arrayFilters outcome crud-v1.coll[0].y: expected an array, got {"$[i]":{"b":2}} updateOne-arrayFilters.json FAIL UpdateOne when one document matches arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
updateOne-arrayFilters.json FAIL UpdateOne when multiple documents match arrayFilters outcome crud-v1.coll[0].y: expected an array, got {"$[i]":{"b":2}} updateOne-arrayFilters.json FAIL UpdateOne when multiple documents match arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].b' is not implemented by this server
updateOne-arrayFilters.json FAIL UpdateOne when no documents match multiple arrayFilters updateOne.modifiedCount: expected 0, got 1 updateOne-arrayFilters.json FAIL UpdateOne when no documents match multiple arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].c.$[j].d' is not implemented by this server
updateOne-arrayFilters.json FAIL UpdateOne when one document matches multiple arrayFilters outcome crud-v1.coll[2].y: expected an array, got {"$[i]":{"c":{"$[j]":{"d":0}}}} updateOne-arrayFilters.json FAIL UpdateOne when one document matches multiple arrayFilters MongoServerError: the positional operator '$[i]' in path 'y.$[i].c.$[j].d' is not implemented by this server
updateOne-collation.json FAIL UpdateOne when one document matches with collation updateOne.matchedCount: expected 1, got 0 updateOne-collation.json FAIL UpdateOne when one document matches with collation updateOne.matchedCount: expected 1, got 0
updateOne-comment.json SKIP UpdateOne with comment - pre 4.4 needs server <= 4.2.99 updateOne-comment.json SKIP UpdateOne with comment - pre 4.4 needs server <= 4.2.99
updateOne-dots_and_dollars.json SKIP Updating document to set top-level dollar-prefixed key on 5.0+ server needs server >= 5.0 updateOne-dots_and_dollars.json SKIP Updating document to set top-level dollar-prefixed key on 5.0+ server needs server >= 5.0