diff --git a/src/commands.zig b/src/commands.zig index 3f7c05b..47f0822 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -52,6 +52,16 @@ pub const ErrorCode = enum(i32) { unauthorized = 13, type_mismatch = 14, operation_failed = 96, + // 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 + // malformed one cannot be sent through it and none of this could have been + // checked the usual way. The two five-digit ones are IDL parser codes: they + // are what mongod's generated command parsers answer, not hand-written + // checks, which is why they look unlike the rest of the table. + illegal_operation = 20, + invalid_uuid = 207, + idl_failed_to_parse = 40414, + idl_unknown_field = 40415, }; /// Which lock (if any) a command needs on the engine. Contract: only @@ -151,6 +161,12 @@ pub fn dispatch(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void { return reply.put_error(@intFromEnum(ErrorCode.command_not_found), "CommandNotFound", errmsg); }; + // Session and transaction fields are checked here: after the command is + // known -- mongod answers CommandNotFound to an unknown command carrying a + // malformed lsid, measured, not assumed -- and before any lock is taken, + // for the reason the comment below records at length. + if (try reject_bad_session_fields(msg, reply, name)) return; + // Lock the catalog (shared for most commands, exclusive for DDL), then // the target collection, then run the handler. The collection lock is // taken while the catalog lock is held, so a concurrent drop can never @@ -381,6 +397,168 @@ fn cmd_server_status(ctx: *Context, _: *wire.Message, reply: *wire.Reply) !void try reply.put_ok(); } +// --------------------------------------------------------------------------- +// Sessions +// +// This server keeps no session registry, and that is a decision rather than an +// omission: a session here would own nothing. There are no transactions to +// scope, no cursors that outlive their connection differently because of one, +// and no retryable writes -- a driver disables those for a standalone. A +// registry would be a mutex on the dispatch path guarding state nothing reads. +// M4 gets to design it, when the transaction state machine says what it needs. +// +// What is *not* optional is telling the truth about the fields a driver sends +// anyway. `lsid` rides on every acknowledged command already, because +// `add_server_info` advertises `logicalSessionTimeoutMinutes`. Accepting it and +// doing nothing is honest -- there is nothing to do. Accepting `txnNumber` and +// doing nothing is not: it would run a transactional write non-transactionally +// and answer ok, and the client would only find out at `commitTransaction`, +// long after the data was on disk. +// --------------------------------------------------------------------------- + +/// mongod's name for a binary subtype, for the one message that quotes it. +fn subtype_name(subtype: u8) []const u8 { + return switch (subtype) { + 0x00 => "general", + 0x01 => "function", + 0x02 => "binary", + 0x03 => "uuid_old", + 0x04 => "UUID", + 0x05 => "MD5", + 0x06 => "encrypt", + else => "unknown", + }; +} + +/// The first `lsid` field this server does not know, for the message that has +/// to name it. Walked again on the error path only, so that `Message.lsid` +/// stays a yes-or-no answer. +fn unknown_lsid_field(msg: *wire.Message) []const u8 { + const doc = switch (msg.body.get("lsid") orelse return "?") { + .doc => |d| d, + else => return "?", + }; + for (doc) |pair| { + const known = std.mem.eql(u8, pair.key, "id") or std.mem.eql(u8, pair.key, "uid") or + std.mem.eql(u8, pair.key, "txnNumber") or std.mem.eql(u8, pair.key, "txnUUID"); + if (!known) return pair.key; + } + return "?"; +} + +/// Writes the reply for a malformed `lsid` and answers whether it did. +fn reject_bad_lsid(msg: *wire.Message, reply: *wire.Reply, cmd: []const u8) !bool { + _ = msg.lsid() catch |err| { + const arena = reply.arena_alloc(); + const text = switch (err) { + error.LsidNotDocument => try std.fmt.allocPrint( + arena, + "BSON field '{s}.lsid' is the wrong type '{s}', expected type 'object'", + .{ cmd, msg.body.get("lsid").?.type_name() }, + ), + error.LsidIdMissing => try std.fmt.allocPrint( + arena, + "BSON field '{s}.lsid.id' is missing but a required field", + .{cmd}, + ), + error.LsidIdNotBinary => try std.fmt.allocPrint( + arena, + "BSON field '{s}.lsid.id' is the wrong type '{s}', expected type 'binData'", + .{ cmd, bson.get_pair(msg.body.get("lsid").?.doc, "id").?.type_name() }, + ), + error.LsidIdNotUuid => try std.fmt.allocPrint( + arena, + "BSON field '{s}.lsid.id' is the wrong binData type '{s}', expected type 'UUID'", + .{ cmd, subtype_name(bson.get_pair(msg.body.get("lsid").?.doc, "id").?.binary.subtype) }, + ), + error.LsidUnknownField => try std.fmt.allocPrint( + arena, + "BSON field '{s}.lsid.{s}' is an unknown field.", + .{ cmd, unknown_lsid_field(msg) }, + ), + else => "", + }; + switch (err) { + error.LsidNotDocument, error.LsidIdNotBinary, error.LsidIdNotUuid => try reply.put_error( + @intFromEnum(ErrorCode.type_mismatch), + "TypeMismatch", + text, + ), + error.LsidIdMissing => try reply.put_error( + @intFromEnum(ErrorCode.idl_failed_to_parse), + "IDLFailedToParse", + text, + ), + error.LsidUnknownField => try reply.put_error( + @intFromEnum(ErrorCode.idl_unknown_field), + "IDLUnknownField", + text, + ), + error.LsidIdWrongLength => try reply.put_error( + @intFromEnum(ErrorCode.invalid_uuid), + "InvalidUUID", + "uuid must be a 16-byte binary field with UUID (4) subtype", + ), + error.LsidTxnNumberWithoutTxnUuid => try invalid_arg( + reply, + "Cannot specify txnNumber in lsid without specifying txnUUID", + ), + error.LsidInternalSession => try invalid_arg( + reply, + "Internal sessions are not supported outside of transactions", + ), + } + return true; + }; + return false; +} + +/// Writes the reply for a transaction field this server cannot honour, and +/// answers whether it did. +/// +/// Refusing rather than ignoring is the whole point. A driver that is told +/// `ok` for a write carrying a `txnNumber` has been told the write is part of +/// a transaction; it is not, it is already durable, and the first the client +/// hears of it is a failing `commitTransaction`. The order of the checks and +/// every message below are mongod 8.3.7's, measured. +fn reject_txn_fields(msg: *wire.Message, reply: *wire.Reply, cmd: []const u8) !bool { + const txn_number = msg.body.get("txnNumber"); + if (msg.body.get("startTransaction") != null and msg.body.get("autocommit") == null) { + try invalid_arg(reply, "'startTransaction' field requires 'autocommit' field to also be specified"); + return true; + } + if (msg.body.get("autocommit") != null and txn_number == null) { + try invalid_arg(reply, "'autocommit' field requires a transaction number to also be specified"); + return true; + } + const n = txn_number orelse return false; + if (n != .int64 and n != .int32) { + const text = try std.fmt.allocPrint( + reply.arena_alloc(), + "BSON field '{s}.txnNumber' is the wrong type '{s}', expected type 'long'", + .{ cmd, n.type_name() }, + ); + try reply.put_error(@intFromEnum(ErrorCode.type_mismatch), "TypeMismatch", text); + return true; + } + if (msg.body.get("lsid") == null) { + try invalid_arg(reply, "Transaction number requires a session ID to also be specified"); + return true; + } + try reply.put_error( + @intFromEnum(ErrorCode.illegal_operation), + "IllegalOperation", + "Transaction numbers are only allowed on a replica set member or mongos", + ); + return true; +} + +/// Answers whether an error reply was written, in which case dispatch is done. +fn reject_bad_session_fields(msg: *wire.Message, reply: *wire.Reply, cmd: []const u8) !bool { + if (try reject_bad_lsid(msg, reply, cmd)) return true; + return reject_txn_fields(msg, reply, cmd); +} + fn cmd_end_sessions(_: *Context, _: *wire.Message, reply: *wire.Reply) !void { try reply.put_ok(); } @@ -2869,6 +3047,168 @@ fn parse_fake_msg(name: []const u8, value: bson.Value, extra: []const bson.Pair) return wire.Message.parse(testing.allocator, msg.items); } +/// Runs one command and hands back its reply's `code`, or null on ok:1. +fn run_for_code(ctx: *Context, name: []const u8, value: bson.Value, extra: []const bson.Pair) !?i32 { + var reply = wire.Reply.init(testing.allocator); + defer reply.deinit(); + var msg = try parse_fake_msg(name, value, extra); + defer msg.deinit(); + try dispatch(ctx, &msg, &reply); + if (bson.get_pair(reply.pairs.items, "code")) |c| return c.int32; + try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); + return null; +} + +fn doc_count(ctx: *Context, coll: []const u8) !i64 { + var reply = wire.Reply.init(testing.allocator); + defer reply.deinit(); + var msg = try parse_fake_msg("count", .{ .string = coll }, &.{}); + defer msg.deinit(); + try dispatch(ctx, &msg, &reply); + return bson.get_pair(reply.pairs.items, "n").?.int64; +} + +test "a well-formed session id changes nothing and is not echoed" { + 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{0x5A} ** 16; + const lsid = bson.Value{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, + } }; + + var reply = wire.Reply.init(testing.allocator); + defer reply.deinit(); + var msg = try parse_fake_msg("insert", .{ .string = "sess" }, &.{ + .{ .key = "documents", .value = .{ .array = &.{.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} }} } }, + .{ .key = "lsid", .value = lsid }, + }); + defer msg.deinit(); + try dispatch(&ctx, &msg, &reply); + try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double); + // mongod answers a well-formed lsid with exactly `{ok: 1}` and no echo, + // measured; a driver reads only `$clusterTime` and `operationTime` back. + try testing.expect(bson.get_pair(reply.pairs.items, "lsid") == null); + try testing.expectEqual(@as(i64, 1), try doc_count(&ctx, "sess")); +} + +test "a malformed session id is refused without leaking a lock" { + // The second half is the point. `reject_bad_session_fields` runs before any + // lock is taken, and the way to show it is to keep using the engine after + // each refusal: a leaked *shared* catalog lock is invisible to readers and + // only stops the next write that needs it exclusively. That is exactly how + // the nameless-command bug hid for a milestone. + 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{0x7C} ** 16; + const good = bson.Value{ .binary = .{ .subtype = 4, .data = &uuid } }; + // Every code here was measured against mongod 8.3.7, not recalled. + const cases = [_]struct { code: i32, lsid: bson.Value }{ + .{ .code = 14, .lsid = .{ .int32 = 5 } }, + .{ .code = 40414, .lsid = .{ .doc = &.{} } }, + .{ .code = 14, .lsid = .{ .doc = &.{.{ .key = "id", .value = .{ .string = "nope" } }} } }, + .{ .code = 14, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 0, .data = &uuid } } }, + } } }, + .{ .code = 207, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = uuid[0..15] } } }, + } } }, + .{ .code = 40415, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = good }, + .{ .key = "bogus", .value = .{ .int32 = 1 } }, + } } }, + .{ .code = 72, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = good }, + .{ .key = "txnUUID", .value = good }, + } } }, + }; + + for (cases, 0..) |c, i| { + const insert_doc = bson.Value{ .array = &.{.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} }} }; + const code = try run_for_code(&ctx, "insert", .{ .string = "leaky" }, &.{ + .{ .key = "documents", .value = insert_doc }, + .{ .key = "lsid", .value = c.lsid }, + }); + try testing.expectEqual(c.code, code.?); + // A write that needs the catalog exclusive to create a collection: the + // one operation a leaked shared lock would block. + var name_buf: [16]u8 = undefined; + const fresh = try std.fmt.bufPrint(&name_buf, "after{d}", .{i}); + try testing.expectEqual(@as(?i32, null), try run_for_code(&ctx, "insert", .{ .string = fresh }, &.{ + .{ .key = "documents", .value = insert_doc }, + })); + } +} + +test "a transactional write is refused rather than applied" { + // The count is the assertion. Ignoring `txnNumber` would answer ok, write + // the document, and leave the client to discover at commitTransaction that + // its transaction was never one -- long after the data was durable. + 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{0x3E} ** 16; + const lsid = bson.Value{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, + } }; + const docs = bson.Value{ .array = &.{.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} }} }; + + try testing.expectEqual(@as(i32, 20), (try run_for_code(&ctx, "insert", .{ .string = "txn" }, &.{ + .{ .key = "documents", .value = docs }, + .{ .key = "lsid", .value = lsid }, + .{ .key = "txnNumber", .value = .{ .int64 = 1 } }, + .{ .key = "startTransaction", .value = .{ .bool = true } }, + .{ .key = "autocommit", .value = .{ .bool = false } }, + })).?); + try testing.expectEqual(@as(i64, 0), try doc_count(&ctx, "txn")); + + // The order the checks fire in is mongod's, measured: a missing session id + // is reported before the standalone refusal, and a bad type before both. + try testing.expectEqual(@as(i32, 72), (try run_for_code(&ctx, "insert", .{ .string = "txn" }, &.{ + .{ .key = "documents", .value = docs }, + .{ .key = "txnNumber", .value = .{ .int64 = 1 } }, + })).?); + try testing.expectEqual(@as(i32, 14), (try run_for_code(&ctx, "insert", .{ .string = "txn" }, &.{ + .{ .key = "documents", .value = docs }, + .{ .key = "lsid", .value = lsid }, + .{ .key = "txnNumber", .value = .{ .string = "nope" } }, + })).?); + try testing.expectEqual(@as(i32, 72), (try run_for_code(&ctx, "insert", .{ .string = "txn" }, &.{ + .{ .key = "documents", .value = docs }, + .{ .key = "lsid", .value = lsid }, + .{ .key = "startTransaction", .value = .{ .bool = true } }, + })).?); + try testing.expectEqual(@as(i64, 0), try doc_count(&ctx, "txn")); +} + +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. + 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 testing.expectEqual(@as(i32, 59), (try run_for_code(&ctx, "nosuchcmd", .{ .int32 = 1 }, &.{ + .{ .key = "lsid", .value = .{ .int32 = 5 } }, + })).?); +} + test "concurrent insert/find commands on a threaded Io" { // Exercises dispatch's lock classification end-to-end: writer fibers run // `insert` under the exclusive lock, reader fibers run `count`/`find`