╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │193 read_timeout: std.Io.Timeout = .none, │ │194 │ │195 fn readPacket(self: *Conn) ![]u8 { │ │196 - if (self.read_timeout != .none) { │ │196 + if (self.read_timeout != .none and !try self.cs_cipher.hasBufferedPacket(self.reader)) { │ │197 // peek keeps partial packets in the reader if the timer wins. │ │198 // join both tasks before consuming bytes or changing cipher state. │ │199 const Result = union(enum) { ready: anyerror!void, timeout: std.Io.Cancelable!void };│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │412 try parseChannelId(packet, self.channel.local_id); │ │413 self.incoming_eof = true; │ │414 self.remote_closed = true; │ │415 - try sendChannelClose(conn, self.channel); │ │415 + try sendChannelMessage(conn, self.channel, SSH_MSG_CHANNEL_CLOSE);│ │416 }, │ │417 SSH_MSG_GLOBAL_REQUEST => try handleGlobalRequest(conn, packet), │ │418 else => try sendUnimplemented(conn), │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │450 } │ │451 │ │452 // EOF then CLOSE │ │453 - { │ │454 - var eof: std.ArrayList(u8) = .empty; │ │455 - defer eof.deinit(conn.allocator); │ │456 - try eof.append(conn.allocator, SSH_MSG_CHANNEL_EOF); │ │457 - try writeU32(&eof, conn.allocator, self.channel.remote_id); │ │458 - try conn.sc_cipher.writePacket(conn.io, conn.writer, eof.items);│ │459 - } │ │460 - try sendChannelClose(conn, self.channel); │ │453 + try sendChannelMessage(conn, self.channel, SSH_MSG_CHANNEL_EOF); │ │454 + try sendChannelMessage(conn, self.channel, SSH_MSG_CHANNEL_CLOSE); │ │455 } │ │456 drainConnection(conn); │ │457 } │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────╮ │1421 const len = 4 + (try self.bodyLength(try reader.peekArray(4))) + Poly1305.mac_length;│ │1422 if (len > reader.buffer.len) return error.ReadBufferTooSmall; │ │1423 _ = try reader.peek(len); │ │1424 + } │ │1425 + │ │1426 + fn hasBufferedPacket(self: *const Cipher, reader: *std.Io.Reader) !bool { │ │1427 + const bytes = reader.buffered(); │ │1428 + if (bytes.len < 4) return false; │ │1429 + return bytes.len >= 4 + (try self.bodyLength(bytes[0..4])) + Poly1305.mac_length; │ │1430 } │ │1431 │ │1432 pub fn writePacket( │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │1826 SSH_MSG_CHANNEL_CLOSE => { │ │1827 if (channel) |*c| { │ │1828 try parseChannelId(packet, c.local_id); │ │1829 - try sendChannelClose(conn, c); │ │1829 + try sendChannelMessage(conn, c, SSH_MSG_CHANNEL_CLOSE);│ │1830 drainConnection(conn); │ │1831 } │ │1832 return; │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1982 │ │1983 fn replyChannelRequest(conn: *Conn, ch: *Channel, want_reply: bool, success: bool) !void { │ │1984 if (!want_reply) return; │ │1985 - var reply: std.ArrayList(u8) = .empty; │ │1986 - defer reply.deinit(conn.allocator); │ │1987 - try reply.append(conn.allocator, if (success) SSH_MSG_CHANNEL_SUCCESS else SSH_MSG_CHANNEL_FAILURE);│ │1988 - try writeU32(&reply, conn.allocator, ch.remote_id); │ │1989 - try conn.sc_cipher.writePacket(conn.io, conn.writer, reply.items); │ │1985 + try sendChannelMessage(conn, ch, if (success) SSH_MSG_CHANNEL_SUCCESS else SSH_MSG_CHANNEL_FAILURE);│ │1986 } │ │1987 │ │1992 -fn sendChannelClose(conn: *Conn, ch: *Channel) !void { │ │1988 +fn sendChannelMessage(conn: *Conn, ch: *Channel, msg_type: u8) !void { │ │1989 var packet: [5]u8 = undefined; │ │1994 - packet[0] = SSH_MSG_CHANNEL_CLOSE; │ │1990 + packet[0] = msg_type; │ │1991 std.mem.writeInt(u32, packet[1..5], ch.remote_id, .big); │ │1992 try conn.sc_cipher.writePacket(conn.io, conn.writer, &packet); │ │1993 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────╮ │src/test/ssh.zig│ ╰────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │ 94 } │ │ 95 } │ │ 96 │ │ 97 -test "Escape timeout preserves partial SSH packets and fragmented arrow keys" { │ │ 97 +test "Escape timeout preserves partial packets, arrow keys, and buffered input" {│ │ 98 const io = std.testing.io; │ │ 99 var pipe_buffer: [256]u8 = undefined; │ │100 var pipe = Pipe.init(&pipe_buffer); │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │109 var terminal_writer = std.Io.Writer.fixed(&terminal_output); │ │110 var terminal = try StreamTerminal.init(std.testing.allocator, &terminal_writer, .{ .width = 80, .height = 24 });│ │111 defer terminal.deinit(); │ │112 - try terminal.writeBytes("\x1b"); │ │113 - │ │112 var encoded: [128]u8 = undefined; │ │113 var encoded_writer = std.Io.Writer.fixed(&encoded); │ │114 var sender = proto.Cipher.init(&TestSession.key, 0); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │117 - try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_DATA, 0, 0, 0, 0, 0, 0, 0, 1, 'x' }); │ │118 - // let the read consume only part of the encrypted length field │ │119 - try pipe.putAll(io, encoded_writer.buffered()[0..2]); │ │120 - try std.testing.expectEqual(null, try server_impl.nextTuiEvent(&ctx.sess, &terminal)); │ │121 - try std.testing.expectEqual(.escape, std.meta.activeTag(terminal.popKey().?)); │ │122 - try std.testing.expectEqual(null, terminal.popKey()); │ │123 - // rendering is allowed before the rest of the packet arrives │ │124 - try ctx.sess.writeBytes("render"); │ │125 - try pipe.putAll(io, encoded_writer.buffered()[2..]); │ │126 - const event = (try server_impl.nextTuiEvent(&ctx.sess, &terminal)).?; │ │127 - defer std.testing.allocator.free(event.data); │ │128 - try std.testing.expectEqualStrings("x", event.data); │ │129 - try std.testing.expectEqual(1, ctx.conn.cs_cipher.seq); │ │115 + // time out inside the length field and just before the tag is complete │ │116 + for ([_]bool{ false, true }) |near_tag| { │ │117 + encoded_writer = .fixed(&encoded); │ │118 + try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_DATA, 0, 0, 0, 0, 0, 0, 0, 1, 'x' });│ │119 + const split = if (near_tag) encoded_writer.buffered().len - 1 else 2; │ │120 + try terminal.writeBytes("\x1b"); │ │121 + try pipe.putAll(io, encoded_writer.buffered()[0..split]); │ │122 + try std.testing.expectEqual(null, try server_impl.nextTuiEvent(&ctx.sess, &terminal)); │ │123 + try std.testing.expectEqual(.escape, std.meta.activeTag(terminal.popKey().?)); │ │124 + try std.testing.expectEqual(null, terminal.popKey()); │ │125 + // rendering is allowed before the rest of the packet arrives │ │126 + try ctx.sess.writeBytes("render"); │ │127 + try pipe.putAll(io, encoded_writer.buffered()[split..]); │ │128 + const event = (try server_impl.nextTuiEvent(&ctx.sess, &terminal)).?; │ │129 + defer std.testing.allocator.free(event.data); │ │130 + try std.testing.expectEqualStrings("x", event.data); │ │131 + try std.testing.expectEqual(sender.seq, ctx.conn.cs_cipher.seq); │ │132 + } │ │133 │ │134 encoded_writer = .fixed(&encoded); │ │135 try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_DATA, 0, 0, 0, 0, 0, 0, 0, 1, '[' }); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │143 } │ │144 try std.testing.expectEqual(.arrow_up, std.meta.activeTag(terminal.popKey().?)); │ │145 try std.testing.expectEqual(null, terminal.popKey()); │ │146 + │ │147 + // a complete buffered packet needs neither a clock nor concurrent tasks │ │148 + encoded_writer = .fixed(&encoded); │ │149 + try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_EOF, 0, 0, 0, 0 });│ │150 + ctx.reader = .fixed(encoded_writer.buffered()); │ │151 + ctx.conn.reader = &ctx.reader; │ │152 + ctx.conn.io = .failing; │ │153 + ctx.conn.read_timeout = .{ .deadline = .{ .raw = .zero, .clock = .awake } }; │ │154 + try std.testing.expectEqual(.eof, std.meta.activeTag(try ctx.sess.nextEvent())); │ │155 } │ │156 │ │157 test "fingerprint format matches openssh layout" { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯