╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────╮ │1989 try conn.sc_cipher.writePacket(conn.io, conn.writer, reply.items);│ │1990 } │ │1991 │ │1992 +fn sendChannelClose(conn: *Conn, ch: *Channel) !void { │ │1993 + var packet: [5]u8 = undefined; │ │1994 + packet[0] = SSH_MSG_CHANNEL_CLOSE; │ │1995 + std.mem.writeInt(u32, packet[1..5], ch.remote_id, .big); │ │1996 + try conn.sc_cipher.writePacket(conn.io, conn.writer, &packet); │ │1997 +} │ │1998 + │ │1999 fn handleWindowAdjust(ch: *Channel, packet: []const u8) !void { │ │2000 // byte SSH_MSG_CHANNEL_WINDOW_ADJUST │ │2001 // uint32 recipient_channel │ ╰────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2065 var bytes: [4]u8 = undefined; │ │2066 std.mem.writeInt(u32, &bytes, value, .big); │ │2067 try buf.appendSlice(allocator, &bytes); │ │2068 +} │ │2069 + │ │2070 +test "pty dimensions have bounded axes and area, preserving normal and zero sizes" { │ │2071 + for ([_]struct { width: u32, height: u32, expected: PtySize }{ │ │2072 + .{ .width = 80, .height = 24, .expected = .{ .width_cells = 80, .height_cells = 24 } }, │ │2073 + .{ .width = 0, .height = 0, .expected = .{ .width_cells = 0, .height_cells = 0 } }, │ │2074 + .{ .width = 0xffffffff, .height = 0xffffffff, .expected = .{ .width_cells = 512, .height_cells = 128 } },│ │2075 + }) |case| { │ │2076 + var bytes: [8]u8 = undefined; │ │2077 + std.mem.writeInt(u32, bytes[0..4], case.width, .big); │ │2078 + std.mem.writeInt(u32, bytes[4..8], case.height, .big); │ │2079 + var reader = std.Io.Reader.fixed(&bytes); │ │2080 + try std.testing.expectEqual(case.expected, try takePtySize(&reader)); │ │2081 + } │ │2082 +} │ │2083 + │ │2084 +test "channel close before shell or exec is acknowledged" { │ │2085 + const io = std.testing.io; │ │2086 + const allocator = std.testing.allocator; │ │2087 + const key = [_]u8{0x5a} ** 64; │ │2088 + var encoded: [256]u8 = undefined; │ │2089 + var encoded_writer = std.Io.Writer.fixed(&encoded); │ │2090 + var sender = Cipher.init(&key, 0); │ │2091 + var open: std.ArrayList(u8) = .empty; │ │2092 + defer open.deinit(allocator); │ │2093 + try open.append(allocator, SSH_MSG_CHANNEL_OPEN); │ │2094 + try writeStringField(&open, allocator, "session"); │ │2095 + try writeU32(&open, allocator, 7); │ │2096 + try writeU32(&open, allocator, 32768); │ │2097 + try writeU32(&open, allocator, 32768); │ │2098 + try sender.writePacket(io, &encoded_writer, open.items); │ │2099 + try sender.writePacket(io, &encoded_writer, &.{ SSH_MSG_CHANNEL_CLOSE, 0, 0, 0, 0 }); │ │2100 + var reader = std.Io.Reader.fixed(encoded_writer.buffered()); │ │2101 + var output: [256]u8 = undefined; │ │2102 + var writer = std.Io.Writer.fixed(&output); │ │2103 + var conn = Conn{ │ │2104 + .io = io, │ │2105 + .allocator = allocator, │ │2106 + .reader = &reader, │ │2107 + .writer = &writer, │ │2108 + .cs_cipher = Cipher.init(&key, 0), │ │2109 + .sc_cipher = Cipher.init(&key, 0), │ │2110 + .rekey = undefined, │ │2111 + }; │ │2112 + const Handler = struct { │ │2113 + pub fn handleSession(_: *@This(), _: *SessionCtx, _: Request) !void { │ │2114 + return error.UnexpectedSession; │ │2115 + } │ │2116 + }; │ │2117 + var handler = Handler{}; │ │2118 + try runChannelLayer(&conn, &([_]u8{0} ** fingerprint_len), &handler); │ │2119 + var output_reader = std.Io.Reader.fixed(writer.buffered()); │ │2120 + var receiver = Cipher.init(&key, 0); │ │2121 + const confirmation = try receiver.readPacket(allocator, &output_reader); │ │2122 + defer allocator.free(confirmation); │ │2123 + try std.testing.expectEqual(SSH_MSG_CHANNEL_OPEN_CONFIRMATION, confirmation[0]); │ │2124 + const close = try receiver.readPacket(allocator, &output_reader); │ │2125 + defer allocator.free(close); │ │2126 + try std.testing.expectEqualSlices(u8, &.{ SSH_MSG_CHANNEL_CLOSE, 0, 0, 0, 7 }, close); │ │2127 + try std.testing.expectEqual(0, output_reader.buffered().len); │ │2128 } │ │2129 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────╮ │src/test/ssh.zig│ ╰────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │ 3 const Ed25519 = std.crypto.sign.Ed25519; │ │ 4 const X25519 = std.crypto.dh.X25519; │ │ 5 const Sha256 = std.crypto.hash.sha2.Sha256; │ │ 6 +const server_impl = @import("../serve_ssh.zig"); │ │ 7 +const StreamTerminal = @import("xit").xitui.stream_terminal.StreamTerminal;│ │ 8 │ │ 9 comptime { │ │10 std.testing.refAllDecls(@import("../serve_ssh.zig")); │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 11 +} │ │ 12 + │ │ 13 +// a post-auth session with deterministic ciphers, for exercising the packet │ │ 14 +// pump independently of the full handshake below. initialize in place. │ │ 15 +const TestSession = struct { │ │ 16 + const key = [_]u8{0x5a} ** 64; │ │ 17 + const Channel = @typeInfo(@FieldType(proto.SessionCtx, "channel")).pointer.child; │ │ 18 + reader: std.Io.Reader = undefined, │ │ 19 + output: [2048]u8 = undefined, │ │ 20 + writer: std.Io.Writer = undefined, │ │ 21 + conn: proto.Conn = undefined, │ │ 22 + channel: Channel = undefined, │ │ 23 + sess: proto.SessionCtx = undefined, │ │ 24 + │ │ 25 + fn init(self: *TestSession, input: []const u8) void { │ │ 26 + self.reader = .fixed(input); │ │ 27 + self.writer = .fixed(&self.output); │ │ 28 + self.conn = .{ │ │ 29 + .io = std.testing.io, │ │ 30 + .allocator = std.testing.allocator, │ │ 31 + .reader = &self.reader, │ │ 32 + .writer = &self.writer, │ │ 33 + .cs_cipher = proto.Cipher.init(&key, 0), │ │ 34 + .sc_cipher = proto.Cipher.init(&key, 0), │ │ 35 + .rekey = undefined, │ │ 36 + }; │ │ 37 + self.channel = .{ .local_id = 0, .remote_id = 7, .local_window = 1 << 20, .remote_window = 32768, .max_packet = 32768 };│ │ 38 + self.sess = .{ .conn = &self.conn, .channel = &self.channel, .fingerprint = undefined }; │ │ 39 + } │ │ 40 + │ │ 41 + fn deinit(self: *TestSession) void { │ │ 42 + self.sess.deinit(); │ │ 43 + } │ │ 44 +}; │ │ 45 + │ │ 46 +test "nextEvent restores credit after a full background input buffer" { │ │ 47 + const allocator = std.testing.allocator; │ │ 48 + var ctx: TestSession = .{}; │ │ 49 + ctx.init(&.{}); │ │ 50 + defer ctx.deinit(); │ │ 51 + // state left by writeBytes when it fills the input buffer while waiting │ │ 52 + // for send credit. the next event must let the peer send again. │ │ 53 + try ctx.sess.incoming_buffer.appendNTimes(allocator, 'x', 1 << 20); │ │ 54 + ctx.channel.local_window = 0; │ │ 55 + const event = try ctx.sess.nextEvent(); │ │ 56 + defer allocator.free(event.data); │ │ 57 + try std.testing.expectEqual(1 << 20, event.data.len); │ │ 58 + try std.testing.expectEqual(1 << 20, ctx.channel.local_window); │ │ 59 + │ │ 60 + var output = std.Io.Reader.fixed(ctx.writer.buffered()); │ │ 61 + var receiver = proto.Cipher.init(&TestSession.key, 0); │ │ 62 + const adjust = try receiver.readPacket(allocator, &output); │ │ 63 + defer allocator.free(adjust); │ │ 64 + try std.testing.expectEqualSlices(u8, &.{ proto.SSH_MSG_CHANNEL_WINDOW_ADJUST, 0, 0, 0, 7, 0, 0x10, 0, 0 }, adjust); │ │ 65 +} │ │ 66 + │ │ 67 +test "channel close is acknowledged once, unlike channel EOF or transport EOF" { │ │ 68 + for ([_]?u8{ proto.SSH_MSG_CHANNEL_CLOSE, proto.SSH_MSG_CHANNEL_EOF, null }) |message| { │ │ 69 + var encoded: [128]u8 = undefined; │ │ 70 + var encoded_writer = std.Io.Writer.fixed(&encoded); │ │ 71 + var sender = proto.Cipher.init(&TestSession.key, 0); │ │ 72 + if (message) |msg| try sender.writePacket(std.testing.io, &encoded_writer, &.{ msg, 0, 0, 0, 0 }); │ │ 73 + var ctx: TestSession = .{}; │ │ 74 + ctx.init(encoded_writer.buffered()); │ │ 75 + defer ctx.deinit(); │ │ 76 + const event = try ctx.sess.nextEvent(); │ │ 77 + if (message == proto.SSH_MSG_CHANNEL_EOF) { │ │ 78 + try std.testing.expectEqual(.eof, std.meta.activeTag(event)); │ │ 79 + try ctx.sess.writeBytes("remaining output"); │ │ 80 + continue; │ │ 81 + } │ │ 82 + try std.testing.expectEqual(.close, std.meta.activeTag(event)); │ │ 83 + try std.testing.expectError(error.RemoteClosed, ctx.sess.writeBytes("late output")); │ │ 84 + try ctx.sess.exit(0); │ │ 85 + try ctx.sess.exit(0); │ │ 86 + var output = std.Io.Reader.fixed(ctx.writer.buffered()); │ │ 87 + var receiver = proto.Cipher.init(&TestSession.key, 0); │ │ 88 + if (message != null) { │ │ 89 + const reply = try receiver.readPacket(std.testing.allocator, &output); │ │ 90 + defer std.testing.allocator.free(reply); │ │ 91 + try std.testing.expectEqualSlices(u8, &.{ proto.SSH_MSG_CHANNEL_CLOSE, 0, 0, 0, 7 }, reply); │ │ 92 + } │ │ 93 + try std.testing.expectEqual(0, output.buffered().len); │ │ 94 + } │ │ 95 } │ │ 96 │ │ 97 +test "Escape timeout preserves partial SSH packets and fragmented arrow keys" { │ │ 98 + const io = std.testing.io; │ │ 99 + var pipe_buffer: [256]u8 = undefined; │ │100 + var pipe = Pipe.init(&pipe_buffer); │ │101 + defer pipe.close(io); │ │102 + var read_buffer: [256]u8 = undefined; │ │103 + var pipe_reader = PipeReader.init(io, &pipe, &read_buffer); │ │104 + var ctx: TestSession = .{}; │ │105 + ctx.init(&.{}); │ │106 + defer ctx.deinit(); │ │107 + ctx.conn.reader = &pipe_reader.interface; │ │108 + var terminal_output: [1024]u8 = undefined; │ │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 + │ │114 + var encoded: [128]u8 = undefined; │ │115 + var encoded_writer = std.Io.Writer.fixed(&encoded); │ │116 + 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); │ │130 + │ │131 + encoded_writer = .fixed(&encoded); │ │132 + try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_DATA, 0, 0, 0, 0, 0, 0, 0, 1, '[' }); │ │133 + try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_DATA, 0, 0, 0, 0, 0, 0, 0, 1, 'A' }); │ │134 + try pipe.putAll(io, encoded_writer.buffered()); │ │135 + try terminal.writeBytes("\x1b"); │ │136 + for (0..2) |_| { │ │137 + const fragment = (try server_impl.nextTuiEvent(&ctx.sess, &terminal)).?; │ │138 + defer std.testing.allocator.free(fragment.data); │ │139 + try terminal.writeBytes(fragment.data); │ │140 + } │ │141 + try std.testing.expectEqual(.arrow_up, std.meta.activeTag(terminal.popKey().?)); │ │142 + try std.testing.expectEqual(null, terminal.popKey()); │ │143 +} │ │144 + │ │145 test "fingerprint format matches openssh layout" { │ │146 const seed = [_]u8{0x42} ** Ed25519.KeyPair.seed_length; │ │147 const keypair = try Ed25519.KeyPair.generateDeterministic(seed); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯