╭ changed ───────╮ │src/test/ssh.zig│ ╰────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │ 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); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯