diff --git a/src/commands.zig b/src/commands.zig index 47f0822..2bdd392 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -559,10 +559,108 @@ fn reject_bad_session_fields(msg: *wire.Message, reply: *wire.Reply, cmd: []cons return reject_txn_fields(msg, reply, cmd); } -fn cmd_end_sessions(_: *Context, _: *wire.Message, reply: *wire.Reply) !void { +/// Still a no-op -- there is nothing to end -- but no longer a blind `ok`. +/// +/// A driver sends this on close for every session it handed out, so it is the +/// one session command that arrives in normal operation. Validating an array +/// we then discard looks like ceremony; it is not. `ok: 1` to a malformed +/// `endSessions` is the same class of answer as `ok: 1` to a transactional +/// write: the client is told the server understood, and it did not. mongod's +/// field path is `endSessions.endSessionsFromClient`, an IDL artefact -- the +/// command's own field is `endSessions` and the parsed argument has a +/// different name -- and it is reproduced rather than tidied, because a name +/// that differs from the real server's is worse than an odd one. +fn cmd_end_sessions(_: *Context, msg: *wire.Message, reply: *wire.Reply) !void { + const arena = reply.arena_alloc(); + const sessions = switch (msg.body.get("endSessions").?) { + .array => |a| a, + else => |v| { + const text = try std.fmt.allocPrint( + arena, + "BSON field 'endSessions.endSessions' is the wrong type '{s}', expected type 'array'", + .{v.type_name()}, + ); + return reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); + }, + }; + + for (sessions, 0..) |entry, i| { + if (try reject_bad_session_entry(entry, i, reply)) return; + } try reply.put_ok(); } +/// One element of `endSessions`. Answers whether an error reply was written. +fn reject_bad_session_entry(entry: bson.Value, i: usize, reply: *wire.Reply) !bool { + const arena = reply.arena_alloc(); + const prefix = "BSON field 'endSessions.endSessionsFromClient"; + const doc = switch (entry) { + .doc => |d| d, + else => { + const text = try std.fmt.allocPrint( + arena, + "{s}.{d}' is the wrong type '{s}', expected type 'object'", + .{ prefix, i, entry.type_name() }, + ); + try reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); + return true; + }, + }; + + var seen_id = false; + for (doc) |pair| { + if (std.mem.eql(u8, pair.key, "uid")) continue; + if (!std.mem.eql(u8, pair.key, "id")) { + const text = try std.fmt.allocPrint( + arena, + "{s}.{s}' is an unknown field.", + .{ prefix, pair.key }, + ); + try reply.put_error(@intFromEnum(ErrorCode.idl_unknown_field), "IDLUnknownField", text); + return true; + } + seen_id = true; + const bin = switch (pair.value) { + .binary => |b| b, + else => { + const text = try std.fmt.allocPrint( + arena, + "{s}.id' is the wrong type '{s}', expected type 'binData'", + .{ prefix, pair.value.type_name() }, + ); + try reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); + return true; + }, + }; + if (bin.subtype != 4) { + const text = try std.fmt.allocPrint( + arena, + "{s}.id' is the wrong binData type '{s}', expected type 'UUID'", + .{ prefix, subtype_name(bin.subtype) }, + ); + try reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); + return true; + } + if (bin.data.len != 16) { + try reply.put_error( + @intFromEnum(ErrorCode.invalid_uuid), + "InvalidUUID", + "uuid must be a 16-byte binary field with UUID (4) subtype", + ); + return true; + } + } + if (!seen_id) { + try reply.put_error( + @intFromEnum(ErrorCode.idl_failed_to_parse), + "IDLFailedToParse", + "BSON field 'endSessions.endSessionsFromClient.id' is missing but a required field", + ); + return true; + } + return false; +} + fn cmd_connection_status(_: *Context, _: *wire.Message, reply: *wire.Reply) !void { const auth_info = try reply.arena_alloc().alloc(bson.Pair, 2); auth_info[0] = .{ .key = "authenticatedUsers", .value = .{ .array = &.{} } }; @@ -3194,6 +3292,47 @@ test "a transactional write is refused rather than applied" { try testing.expectEqual(@as(i64, 0), try doc_count(&ctx, "txn")); } +test "endSessions judges the array it is handed" { + // Still a no-op, and that is not what is being tested. The command arrives + // in normal operation -- a driver sends it on close for every session it + // handed out -- so a blind `ok` here tells a client the server understood + // something it never looked at. Codes measured against mongod 8.3.7. + 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 uuid = [_]u8{0x6B} ** 16; + const good = bson.Value{ .binary = .{ .subtype = 4, .data = &uuid } }; + const one = bson.Value{ .doc = &.{.{ .key = "id", .value = good }} }; + + try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "endSessions", .{ .array = &.{} }, &.{})); + try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "endSessions", .{ .array = &.{one} }, &.{})); + // `uid` rides along once authentication is on, exactly as it does in lsid. + try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{ + .{ .key = "id", .value = good }, + .{ .key = "uid", .value = .{ .binary = .{ .subtype = 0, .data = &[_]u8{0} ** 32 } } }, + } }} }, &.{})); + + try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "endSessions", .{ .string = "nope" }, &.{})).?); + try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .int32 = 5 }} }, &.{})).?); + try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{ + .{ .key = "id", .value = .{ .string = "nope" } }, + } }} }, &.{})).?); + try testing.expectEqual(@as(i32, 207), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = uuid[0..15] } } }, + } }} }, &.{})).?); + try testing.expectEqual(@as(i32, 40414), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{} }} }, &.{})).?); + try testing.expectEqual(@as(i32, 40415), (try run_for_code(&ctx, "endSessions", .{ .array = &.{.{ .doc = &.{ + .{ .key = "id", .value = good }, + .{ .key = "bogus", .value = .{ .int32 = 1 } }, + } }} }, &.{})).?); + // A bad entry after a good one is still a bad entry. + try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "endSessions", .{ .array = &.{ one, .{ .int32 = 5 } } }, &.{})).?); +} + test "an unknown command is reported before its session id is judged" { // Measured: mongod answers CommandNotFound to `{nosuchcmd: 1, lsid: 5}`, // so the lookup comes first and this is where the check belongs.