diff --git a/src/bson.zig b/src/bson.zig index 5dfa35e..7532e4e 100644 --- a/src/bson.zig +++ b/src/bson.zig @@ -74,6 +74,35 @@ pub const Value = union(enum) { }; } + /// The name mongod uses for this type in a TypeMismatch message ("is the + /// wrong type 'int', expected type 'object'"). Its own names, not Zig's: + /// a driver that matches on the text is matching on these. + pub fn type_name(self: Value) []const u8 { + return switch (self) { + .double => "double", + .string => "string", + .doc => "object", + .array => "array", + .binary => "binData", + .object_id => "objectId", + .bool => "bool", + .datetime => "date", + .null => "null", + .regex => "regex", + .code => "javascript", + .symbol => "symbol", + .int32 => "int", + .timestamp => "timestamp", + .int64 => "long", + .decimal128 => "decimal", + .min_key => "minKey", + .max_key => "maxKey", + // An unparsed value keeps only its tag byte, and the tags this + // union does not name are the ones nothing here inspects. + .opaque_val => "unknown", + }; + } + pub fn is_number(self: Value) bool { return switch (self) { .double, .int32, .int64 => true, diff --git a/src/wire.zig b/src/wire.zig index df6cc9d..e87559a 100644 --- a/src/wire.zig +++ b/src/wire.zig @@ -185,6 +185,81 @@ pub const Message = struct { }; } + /// A logical session id: a UUID, so 16 bytes of binary subtype 4. + pub const SessionId = [16]u8; + + /// Every way an `lsid` can be malformed, named after what is wrong rather + /// than after the error the caller will send: the codes belong to the + /// command layer, which is where they were measured. + pub const LsidError = error{ + LsidNotDocument, + LsidUnknownField, + LsidIdMissing, + LsidIdNotBinary, + LsidIdNotUuid, + LsidIdWrongLength, + LsidInternalSession, + LsidTxnNumberWithoutTxnUuid, + }; + + /// The logical session id, or null when the command carries no `lsid`. + /// + /// An accessor over the command envelope, like `db_name`: called from + /// dispatch, never from `parse`, because a malformed session id is a + /// command that gets an error reply and not a connection that gets torn + /// down. + /// + /// The fields it tolerates were measured against mongod 8.3.7, not + /// recalled, and the measurement contradicted the assumption it was + /// written on. mongod does *not* ignore unknown fields inside `lsid` -- + /// it answers IDLUnknownField -- and it does accept `uid`, the hash of + /// the credentials that own the session, which a driver sends as soon as + /// authentication is on. Both matter to us: the first because tolerating + /// what the server rejects is the kind of divergence that only shows up + /// under a driver nobody tested, the second because M7 would otherwise + /// break every command. + pub fn lsid(self: *const Message) LsidError!?SessionId { + const v = bson.get_pair(self.body.pairs, "lsid") orelse return null; + const doc = switch (v) { + .doc => |d| d, + else => return error.LsidNotDocument, + }; + + var id: ?bson.Binary = null; + var has_txn_number = false; + var has_txn_uuid = false; + for (doc) |pair| { + if (std.mem.eql(u8, pair.key, "id")) { + id = switch (pair.value) { + .binary => |b| b, + else => return error.LsidIdNotBinary, + }; + } else if (std.mem.eql(u8, pair.key, "uid")) { + // Accepted and ignored: it identifies the user a session + // belongs to, and this server has exactly one. + } else if (std.mem.eql(u8, pair.key, "txnNumber")) { + has_txn_number = true; + } else if (std.mem.eql(u8, pair.key, "txnUUID")) { + has_txn_uuid = true; + } else { + return error.LsidUnknownField; + } + } + + // A `txnNumber` inside the session id is not the retryable-write one + // outside it: together with `txnUUID` the two name an *internal* + // session, which only exists to run a transaction on another + // session's behalf. Neither can mean anything here, and mongod + // refuses them on a standalone too. + if (has_txn_number and !has_txn_uuid) return error.LsidTxnNumberWithoutTxnUuid; + if (has_txn_uuid) return error.LsidInternalSession; + + const bin = id orelse return error.LsidIdMissing; + if (bin.subtype != 4) return error.LsidIdNotUuid; + if (bin.data.len != 16) return error.LsidIdWrongLength; + return bin.data[0..16].*; + } + /// Documents of a batch argument (`documents`, `updates`, `deletes`). /// Drivers send them either as an OP_MSG document sequence or as an array /// inside the command body; callers should not have to care which. The @@ -378,6 +453,94 @@ test "reply serializes to a parseable message" { try testing.expectEqual(@as(usize, 0), msg.seqs.len); } +/// An OP_MSG carrying one body section, for tests that care about the command +/// envelope rather than about framing. +fn fake_op_msg(gpa: std.mem.Allocator, pairs: []const bson.Pair) !Message { + var doc: std.ArrayListUnmanaged(u8) = .empty; + defer doc.deinit(gpa); + try bson.write_doc(pairs, gpa, &doc); + + var buf: std.ArrayListUnmanaged(u8) = .empty; + defer buf.deinit(gpa); + try buf.appendSlice(gpa, &[_]u8{ 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0xDD, 0x07, 0, 0, 0, 0, 0, 0 }); + try buf.append(gpa, 0x00); + try buf.appendSlice(gpa, doc.items); + std.mem.writeInt(u32, buf.items[0..4], @intCast(buf.items.len), .little); + return Message.parse(gpa, buf.items); +} + +test "a session id is read out of a command" { + const uuid = [_]u8{0xAB} ** 16; + var msg = try fake_op_msg(testing.allocator, &.{ + .{ .key = "ping", .value = .{ .int32 = 1 } }, + .{ .key = "lsid", .value = .{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, + } } }, + }); + defer msg.deinit(); + try testing.expectEqual(uuid, (try msg.lsid()).?); +} + +test "a command with no lsid has no session" { + var msg = try fake_op_msg(testing.allocator, &.{.{ .key = "ping", .value = .{ .int32 = 1 } }}); + defer msg.deinit(); + try testing.expect((try msg.lsid()) == null); +} + +test "a session id carrying a user hash is still a session id" { + // `uid` arrives as soon as authentication is on (M7). Rejecting it as an + // unknown field would break every command the moment that lands, which is + // exactly the kind of divergence a measurement against a real server is + // for -- mongod 8.3.7 answers ok:1 to this. + const uuid = [_]u8{0x11} ** 16; + var msg = try fake_op_msg(testing.allocator, &.{ + .{ .key = "ping", .value = .{ .int32 = 1 } }, + .{ .key = "lsid", .value = .{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, + .{ .key = "uid", .value = .{ .binary = .{ .subtype = 0, .data = &[_]u8{0} ** 32 } } }, + } } }, + }); + defer msg.deinit(); + try testing.expectEqual(uuid, (try msg.lsid()).?); +} + +test "every malformed session id is named" { + const uuid = [_]u8{0x22} ** 16; + const cases = [_]struct { want: anyerror, lsid: bson.Value }{ + .{ .want = error.LsidNotDocument, .lsid = .{ .int32 = 5 } }, + .{ .want = error.LsidIdMissing, .lsid = .{ .doc = &.{} } }, + .{ .want = error.LsidIdNotBinary, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = .{ .string = "nope" } }, + } } }, + .{ .want = error.LsidIdNotUuid, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 0, .data = &uuid } } }, + } } }, + .{ .want = error.LsidIdWrongLength, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = uuid[0..15] } } }, + } } }, + .{ .want = error.LsidUnknownField, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, + .{ .key = "bogus", .value = .{ .int32 = 1 } }, + } } }, + .{ .want = error.LsidTxnNumberWithoutTxnUuid, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, + .{ .key = "txnNumber", .value = .{ .int64 = 1 } }, + } } }, + .{ .want = error.LsidInternalSession, .lsid = .{ .doc = &.{ + .{ .key = "id", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, + .{ .key = "txnUUID", .value = .{ .binary = .{ .subtype = 4, .data = &uuid } } }, + } } }, + }; + for (cases) |c| { + var msg = try fake_op_msg(testing.allocator, &.{ + .{ .key = "ping", .value = .{ .int32 = 1 } }, + .{ .key = "lsid", .value = c.lsid }, + }); + defer msg.deinit(); + try testing.expectError(c.want, msg.lsid()); + } +} + test "reject non-OP_MSG non-OP_QUERY opcodes" { var buf: [20]u8 = undefined; std.mem.writeInt(u32, buf[0..4], 20, .little);