diff --git a/lib/mysqlex/auth/native_password.ex b/lib/mysqlex/auth/native_password.ex new file mode 100644 index 0000000..0aeca04 --- /dev/null +++ b/lib/mysqlex/auth/native_password.ex @@ -0,0 +1,40 @@ +defmodule Mysqlex.Auth.NativePassword do + @moduledoc """ + The `mysql_native_password` authentication method. + + A challenge-response scheme: the server sends a random 20-byte scramble in its + handshake, the client answers with a value derived from both the scramble and + the password. The password itself never crosses the wire, and because the + scramble differs per connection the answer cannot be replayed. + + Not the default since MySQL 8 — `caching_sha2_password` is — but it is the + simplest method to get a connection working, and the server will use it for any + account created with `IDENTIFIED WITH mysql_native_password`. + """ + + @doc """ + Builds the reply to the server's scramble. + + `SHA1(password) XOR SHA1(scramble <> SHA1(SHA1(password)))`. + + The server holds only `SHA1(SHA1(password))`, which is enough to compute the + same value and compare, and not enough to recover the password. + + An account with no password answers with no bytes at all rather than with a + hash of the empty string. + """ + def reply("", _scramble), do: "" + + def reply(password, scramble) do + stage1 = :crypto.hash(:sha, password) + token = :crypto.hash(:sha, scramble <> :crypto.hash(:sha, stage1)) + + :crypto.exor(stage1, token) + end + + @doc """ + `SHA1(SHA1(password))` — what the server keeps in + `mysql.user.authentication_string`, prefixed there with `*` and hex encoded. + """ + def stored_hash(password), do: :crypto.hash(:sha, :crypto.hash(:sha, password)) +end diff --git a/lib/mysqlex/client.ex b/lib/mysqlex/client.ex index 3285efa..a5b240e 100644 --- a/lib/mysqlex/client.ex +++ b/lib/mysqlex/client.ex @@ -1,12 +1,74 @@ defmodule Mysqlex.Client do @moduledoc """ - Reads and writes packets over a `Mysqlex.Transport`. + Reads and writes packets over a `Mysqlex.Transport`, and runs the connection + phase. This is where the socket lives. Everything it hands upwards is a whole message, so no parser above it ever sees a partial read. """ + alias Mysqlex.Auth.NativePassword alias Mysqlex.Packet + alias Mysqlex.Protocol.{Codec, Err, Handshake, HandshakeResponse} + + defstruct [:transport, :socket, :capabilities, :connection_id, :server_version, buffer: ""] + + @default_port 3306 + @default_timeout 5_000 + + @doc """ + Opens a connection and completes the connection phase. + """ + def connect(opts) do + transport = Keyword.get(opts, :transport, Mysqlex.Tcp) + host = opts |> Keyword.fetch!(:hostname) |> to_charlist() + port = Keyword.get(opts, :port, @default_port) + timeout = Keyword.get(opts, :connect_timeout, @default_timeout) + socket_opts = Keyword.get(opts, :socket_options, []) + + with {:ok, socket} <- transport.connect(host, port, socket_opts, timeout) do + handshake(transport, socket, opts) + end + end + + @doc """ + Runs the connection phase over an already-open socket. + + The server speaks first, so this reads before it writes: greeting, response, + then however the server answers. Separate from `connect/1` so the conversation + can be driven by a scripted transport. + """ + def handshake(transport, socket, opts) do + timeout = Keyword.get(opts, :connect_timeout, @default_timeout) + username = Keyword.fetch!(opts, :username) + password = Keyword.get(opts, :password) || "" + database = Keyword.get(opts, :database) + + client = %__MODULE__{ + transport: transport, + socket: socket, + capabilities: HandshakeResponse.capabilities(database) + } + + with {:ok, sequence_id, greeting, buffer} <- recv_message(transport, socket, "", timeout), + {:ok, handshake} <- Handshake.decode(greeting), + response = + HandshakeResponse.encode( + username, + NativePassword.reply(password, handshake.auth_plugin_data), + database + ), + :ok <- send_message(transport, socket, response, sequence_id + 1), + {:ok, buffer} <- authenticate(client, password, buffer, timeout) do + {:ok, + %{ + client + | connection_id: handshake.connection_id, + server_version: handshake.server_version, + buffer: buffer + }} + end + end @doc """ Reads one message, reading again for as long as the buffer holds less than a @@ -27,4 +89,50 @@ defmodule Mysqlex.Client do end end end + + @doc """ + Frames a payload and writes it to the socket. + """ + def send_message(transport, socket, payload, sequence_id) do + payload + |> IO.iodata_to_binary() + |> Packet.encode(sequence_id) + |> then(&transport.send(socket, &1)) + end + + # The greeting names the server's default authentication plugin, chosen before + # it knows which account is connecting. When the account uses a different one + # the server answers with an AuthSwitchRequest naming what it actually wants, + # with a fresh scramble. So the plugin in the greeting is not something to + # refuse on — switching is how the protocol settles the mismatch. + defp authenticate(%__MODULE__{} = client, password, buffer, timeout) do + with {:ok, sequence_id, payload, buffer} <- + recv_message(client.transport, client.socket, buffer, timeout) do + case payload do + <<0x00, _rest::binary>> -> + {:ok, buffer} + + <<0xFF, _rest::binary>> -> + {:ok, error} = Err.decode(payload, client.capabilities) + {:error, error} + + <<0xFE, rest::binary>> -> + {plugin, data} = Codec.decode_string_nul(rest) + switch_auth(client, plugin, data, password, buffer, sequence_id, timeout) + end + end + end + + defp switch_auth(client, "mysql_native_password", data, password, buffer, sequence_id, timeout) + when byte_size(data) >= 20 do + # the fresh scramble is 20 bytes and a terminator, as in the greeting + reply = NativePassword.reply(password, :binary.part(data, 0, 20)) + + with :ok <- send_message(client.transport, client.socket, reply, sequence_id + 1) do + authenticate(client, password, buffer, timeout) + end + end + + defp switch_auth(_client, plugin, _data, _password, _buffer, _sequence_id, _timeout), + do: {:error, {:unsupported_auth_plugin, plugin}} end diff --git a/lib/mysqlex/protocol/handshake.ex b/lib/mysqlex/protocol/handshake.ex new file mode 100644 index 0000000..9daed5c --- /dev/null +++ b/lib/mysqlex/protocol/handshake.ex @@ -0,0 +1,66 @@ +defmodule Mysqlex.Protocol.Handshake do + @moduledoc """ + `Protocol::HandshakeV10`, the greeting the server sends on connect. + + MySQL speaks first, so this is the first packet any client sees and the one + that decides everything after it: which capabilities are on offer, and which + authentication method the server wants. + + See `docs/mysql-protocol/page_protocol_connection_phase_packets_protocol_handshake_v10.md`. + """ + + import Bitwise + + alias Mysqlex.Protocol.Codec + + defstruct [ + :protocol_version, + :server_version, + :connection_id, + :capabilities, + :character_set, + :status_flags, + :auth_plugin_name, + :auth_plugin_data + ] + + @doc """ + Decodes a HandshakeV10 payload. + + Two fields are easy to get wrong and both are sized by the protocol rather + than fixed. `server_version` is NUL-terminated, not a fixed width — it is + "8.4.4" on one server and "8.0.33-0ubuntu0.22.04.2" on another. The scramble + arrives in two pieces with unrelated fields between them, and the second piece + is `MAX(13, auth_plugin_data_len - 8)` bytes of which the last is a + terminator, so the scramble itself is `auth_plugin_data_len - 1` bytes. + """ + def decode(<<10, rest::binary>>) do + {server_version, rest} = Codec.decode_string_nul(rest) + + <> = rest + + tail_length = max(13, auth_plugin_data_len - 8) + <> = rest + {auth_plugin_name, _rest} = Codec.decode_string_nul(rest) + + scramble_length = auth_plugin_data_len - 1 + + <> = + scramble_head <> scramble_tail + + {:ok, + %__MODULE__{ + protocol_version: 10, + server_version: server_version, + connection_id: connection_id, + # the server splits its capability bitmask across two fields, nine bytes apart + capabilities: capabilities_upper <<< 16 ||| capabilities_lower, + character_set: character_set, + status_flags: status_flags, + auth_plugin_name: auth_plugin_name, + auth_plugin_data: auth_plugin_data + }} + end +end diff --git a/lib/mysqlex/protocol/handshake_response.ex b/lib/mysqlex/protocol/handshake_response.ex new file mode 100644 index 0000000..cbb7cb1 --- /dev/null +++ b/lib/mysqlex/protocol/handshake_response.ex @@ -0,0 +1,70 @@ +defmodule Mysqlex.Protocol.HandshakeResponse do + @moduledoc """ + `Protocol::HandshakeResponse41` — the client's answer to the server's greeting, + and the first packet this driver ever sends. + + The capability flags here are a claim rather than a question. Whatever is set + decides the shape of nearly every packet that follows, which is why + `capabilities/1` is public: the same value has to be handed to the response + parsers so they read the right fields. + + See + `docs/mysql-protocol/page_protocol_connection_phase_packets_protocol_handshake_response.md`. + """ + + import Bitwise + + alias Mysqlex.Protocol.Codec + + @connect_with_db 1 <<< 3 + @protocol_41 1 <<< 9 + @transactions 1 <<< 13 + # named CLIENT_RESERVED2 in current docs; modern servers behave as if it is + # always set, older ones require it for 4.1-style authentication + @secure_connection 1 <<< 15 + @plugin_auth 1 <<< 19 + + @capabilities @protocol_41 ||| @transactions ||| @secure_connection ||| @plugin_auth + + # Deliberately not claimed: + # CLIENT_LOCAL_FILES a malicious server can ask the client for any file + # CLIENT_MULTI_STATEMENTS turns one injected query into several + # CLIENT_SESSION_TRACK changes the OK packet's shape; not parsed yet + # CLIENT_DEPRECATE_EOF changes how resultsets terminate; a phase 3 call + # CLIENT_CONNECT_ATTRS sends client metadata the driver has no use for + + # utf8mb4_general_ci, understood by every server since 5.5 + @character_set 45 + @max_packet_size 0xFFFFFF + @auth_plugin_name "mysql_native_password" + + @doc """ + The capability flags the driver claims, which depend only on whether a + database is being selected on connect. + """ + def capabilities(nil), do: @capabilities + def capabilities(_database), do: @capabilities ||| @connect_with_db + + @doc """ + Builds the response payload as iodata. + + `auth_response` is the output of the authentication method — for + `mysql_native_password` that is `Mysqlex.Auth.NativePassword.reply/2`. It is + length-prefixed with a single byte, which is the form used when + `CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA` is not claimed. + """ + def encode(username, auth_response, database) do + [ + <>, + Codec.encode_string_nul(username), + <>, + auth_response, + database_field(database), + Codec.encode_string_nul(@auth_plugin_name) + ] + end + + defp database_field(nil), do: [] + defp database_field(database), do: Codec.encode_string_nul(database) +end diff --git a/lib/mysqlex/tcp.ex b/lib/mysqlex/tcp.ex index eda2002..ab5d60d 100644 --- a/lib/mysqlex/tcp.ex +++ b/lib/mysqlex/tcp.ex @@ -7,6 +7,7 @@ defmodule Mysqlex.Tcp do @socket_opts [packet: :raw, mode: :binary, active: false] + @impl true def connect(host, port, opts \\ [], timeout \\ 5_000) do :gen_tcp.connect(host, port, @socket_opts ++ opts, timeout) end @@ -16,10 +17,12 @@ defmodule Mysqlex.Tcp do :gen_tcp.recv(socket, bytes, timeout) end + @impl true def send(socket, packet) do :gen_tcp.send(socket, packet) end + @impl true def close(socket) do :gen_tcp.close(socket) end diff --git a/lib/mysqlex/transport.ex b/lib/mysqlex/transport.ex index 9644408..faeda85 100644 --- a/lib/mysqlex/transport.ex +++ b/lib/mysqlex/transport.ex @@ -2,14 +2,21 @@ defmodule Mysqlex.Transport do @moduledoc """ The socket operations the client needs. - Reading is expressed against this behaviour rather than `:gen_tcp` directly so - that framing and, later, the connection phase can be driven by a scripted - transport in tests. `Mysqlex.Tcp` is the real implementation. + The client is written against this rather than `:gen_tcp` directly so the + connection phase can be driven by a scripted transport in tests — capability + negotiation, authentication failures and plugin mismatches are all branches + that would otherwise need specially configured live servers. - Callbacks are added as the client comes to need them. + `Mysqlex.Tcp` is the real implementation. `:ssl` has the same shape, so TLS + will be another module behind the same behaviour rather than a branch threaded + through every read and write. """ @type socket :: term + @callback connect(host :: charlist, port :: :inet.port_number(), opts :: keyword, timeout) :: + {:ok, socket} | {:error, term} @callback recv(socket, bytes :: non_neg_integer, timeout) :: {:ok, binary} | {:error, term} + @callback send(socket, iodata) :: :ok | {:error, term} + @callback close(socket) :: :ok end diff --git a/test/fixtures/handshake_v10_8.4.4.bin b/test/fixtures/handshake_v10_8.4.4.bin new file mode 100644 index 0000000..3a86472 Binary files /dev/null and b/test/fixtures/handshake_v10_8.4.4.bin differ diff --git a/test/integration/login_test.exs b/test/integration/login_test.exs new file mode 100644 index 0000000..98a5176 --- /dev/null +++ b/test/integration/login_test.exs @@ -0,0 +1,50 @@ +defmodule Mysqlex.LoginIntegrationTest do + use ExUnit.Case, async: false + + @moduletag :integration + + alias Mysqlex.{Client, Error, Tcp} + + # docker run -d --name mysqlex-dev -p 3307:3306 \ + # -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=mysqlex_test \ + # mysql:8.0 --mysql-native-password=ON + # + # CREATE USER 'mysqlex'@'%' IDENTIFIED WITH mysql_native_password BY 'secret'; + # GRANT ALL ON mysqlex_test.* TO 'mysqlex'@'%'; + # + # MySQL 8.4 ships mysql_native_password disabled, which is why this pins 8.0. + @opts [ + hostname: "127.0.0.1", + port: 3307, + username: "mysqlex", + password: "secret", + database: "mysqlex_test" + ] + + test "logs in to a real server" do + assert {:ok, client} = Client.connect(@opts) + + on_exit(fn -> Tcp.close(client.socket) end) + + assert client.server_version =~ "8.0" + assert is_integer(client.connection_id) and client.connection_id > 0 + end + + test "a wrong password is refused in the server's own words" do + assert {:error, %Error{} = error} = Client.connect(Keyword.put(@opts, :password, "wrong")) + + assert error.code == 1045 + assert error.sql_state == "28000" + assert error.message =~ "Access denied" + end + + test "a database the account cannot reach is refused after authentication" do + assert {:error, %Error{} = error} = Client.connect(Keyword.put(@opts, :database, "nope")) + + # 1044 rather than 1049: the account is granted only mysqlex_test, so the + # server reports access denied to the database rather than that it is unknown + assert error.code == 1044 + assert error.sql_state == "42000" + assert error.message =~ "denied" + end +end diff --git a/test/mysqlex/auth/native_password_test.exs b/test/mysqlex/auth/native_password_test.exs new file mode 100644 index 0000000..5c1e004 --- /dev/null +++ b/test/mysqlex/auth/native_password_test.exs @@ -0,0 +1,39 @@ +defmodule Mysqlex.Auth.NativePasswordTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Mysqlex.Auth.NativePassword + + @scramble Base.decode16!("6344555B255D623B3A1E1147626D276F0F4F0B74") + + test "the doubly hashed password is what the server stores" do + # mysql.user.authentication_string for a native-password account is '*' + # followed by SHA1(SHA1(password)) in uppercase hex. MySQL 8.4.4 computing + # it with its own functions returns exactly this. + assert Base.encode16(NativePassword.stored_hash("secret")) == + "14E65567ABDB5135D0CFD9A70B3032C179A49EE7" + end + + test "produces the expected reply for a known scramble" do + assert Base.encode16(NativePassword.reply("secret", @scramble), case: :lower) == + "ec75977987f3398d9333ef89de2a34c5bded7ca7" + end + + test "sends nothing at all when the account has no password" do + assert NativePassword.reply("", @scramble) == "" + end + + property "a reply is always 20 bytes and changes with the scramble" do + check all( + password <- string(:alphanumeric, min_length: 1), + scramble <- binary(length: 20), + other <- binary(length: 20), + scramble != other + ) do + reply = NativePassword.reply(password, scramble) + + assert byte_size(reply) == 20 + assert reply != NativePassword.reply(password, other) + end + end +end diff --git a/test/mysqlex/client_login_test.exs b/test/mysqlex/client_login_test.exs new file mode 100644 index 0000000..e4c68be --- /dev/null +++ b/test/mysqlex/client_login_test.exs @@ -0,0 +1,91 @@ +defmodule Mysqlex.ClientLoginTest do + use ExUnit.Case, async: true + + alias Mysqlex.Auth.NativePassword + alias Mysqlex.{Client, Error, FakeTransport} + alias Mysqlex.Packet + alias Mysqlex.Protocol.HandshakeResponse + + @handshake File.read!("test/fixtures/handshake_v10_8.4.4.bin") + # the same captured bytes with the plugin name swapped, since the server this + # was taken from offers caching_sha2_password and MySQL 8.4 ships + # mysql_native_password disabled + @native_handshake String.replace(@handshake, "caching_sha2_password", "mysql_native_password") + + @scramble Base.decode16!("6344555B255D623B3A1E1147626D276F0F4F0B74") + + defp framed(payload, sequence_id), do: IO.iodata_to_binary(Packet.encode(payload, sequence_id)) + + defp opts(socket) do + [ + transport: FakeTransport, + socket: socket, + username: "mysqlex", + password: "secret", + database: "mysqlex_test" + ] + end + + test "completes a login and reports what the server said about itself" do + ok = <<0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00>> + socket = FakeTransport.script([framed(@native_handshake, 0), framed(ok, 2)]) + + assert {:ok, client} = Client.handshake(FakeTransport, socket, opts(socket)) + + assert client.connection_id == 78_814 + assert client.server_version == "8.4.4" + assert client.capabilities == HandshakeResponse.capabilities("mysqlex_test") + end + + test "answers the server's scramble with the right bytes, as sequence id 1" do + ok = <<0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00>> + socket = FakeTransport.script([framed(@native_handshake, 0), framed(ok, 2)]) + + assert {:ok, _client} = Client.handshake(FakeTransport, socket, opts(socket)) + + expected = + "mysqlex" + |> HandshakeResponse.encode(NativePassword.reply("secret", @scramble), "mysqlex_test") + |> IO.iodata_to_binary() + |> framed(1) + + assert FakeTransport.sent(socket) == expected + end + + test "returns the server's error when the login is refused" do + err = <<0xFF, 0x15, 0x04, ?#, "28000", "Access denied for user 'mysqlex'@'localhost'">> + socket = FakeTransport.script([framed(@native_handshake, 0), framed(err, 2)]) + + assert {:error, %Error{} = error} = Client.handshake(FakeTransport, socket, opts(socket)) + + assert error.code == 1045 + assert error.sql_state == "28000" + assert error.message =~ "Access denied" + end + + test "answers an auth switch with a reply built from the fresh scramble" do + fresh = :binary.copy(<<9>>, 20) + switch = <<0xFE, "mysql_native_password", 0, fresh::binary, 0>> + ok = <<0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00>> + + socket = + FakeTransport.script([framed(@handshake, 0), framed(switch, 2), framed(ok, 4)]) + + assert {:ok, _client} = Client.handshake(FakeTransport, socket, opts(socket)) + + # the switch reply is the bare auth data, as sequence id 3 + assert String.ends_with?( + FakeTransport.sent(socket), + framed(NativePassword.reply("secret", fresh), 3) + ) + end + + test "refuses a switch to a method it cannot speak, rather than guessing" do + scramble = :binary.copy(<<7>>, 20) + switch = <<0xFE, "sha256_password", 0, scramble::binary, 0>> + socket = FakeTransport.script([framed(@handshake, 0), framed(switch, 2)]) + + assert {:error, {:unsupported_auth_plugin, "sha256_password"}} = + Client.handshake(FakeTransport, socket, opts(socket)) + end +end diff --git a/test/mysqlex/protocol/handshake_response_test.exs b/test/mysqlex/protocol/handshake_response_test.exs new file mode 100644 index 0000000..f5412a8 --- /dev/null +++ b/test/mysqlex/protocol/handshake_response_test.exs @@ -0,0 +1,52 @@ +defmodule Mysqlex.Protocol.HandshakeResponseTest do + use ExUnit.Case, async: true + + import Bitwise + + alias Mysqlex.Protocol.HandshakeResponse + + @auth_response Base.decode16!("EC75977987F3398D9333EF89DE2A34C5BDED7CA7") + + test "claims only the capabilities the driver implements" do + caps = HandshakeResponse.capabilities(nil) + + assert band(caps, 0x0200) != 0, "CLIENT_PROTOCOL_41" + assert band(caps, 0x8000) != 0, "CLIENT_SECURE_CONNECTION" + assert band(caps, 0x2000) != 0, "CLIENT_TRANSACTIONS" + assert band(caps, 1 <<< 19) != 0, "CLIENT_PLUGIN_AUTH" + + refute band(caps, 0x0080) != 0, "CLIENT_LOCAL_FILES must stay off" + refute band(caps, 1 <<< 16) != 0, "CLIENT_MULTI_STATEMENTS must stay off" + refute band(caps, 1 <<< 23) != 0, "CLIENT_SESSION_TRACK is not parsed" + refute band(caps, 1 <<< 24) != 0, "CLIENT_DEPRECATE_EOF is a phase 3 decision" + refute band(caps, 0x0008) != 0, "CLIENT_CONNECT_WITH_DB without a database" + end + + test "announces a database only when one is given" do + assert band(HandshakeResponse.capabilities("mysqlex_test"), 0x0008) != 0 + end + + test "builds the packet the server expects" do + payload = + HandshakeResponse.encode("mysqlex", @auth_response, "mysqlex_test") + |> IO.iodata_to_binary() + + capabilities = HandshakeResponse.capabilities("mysqlex_test") + + assert payload == + <> + end + + test "omits the database when there is none" do + payload = + HandshakeResponse.encode("mysqlex", @auth_response, nil) + |> IO.iodata_to_binary() + + capabilities = HandshakeResponse.capabilities(nil) + + assert payload == + <> + end +end diff --git a/test/mysqlex/protocol/handshake_test.exs b/test/mysqlex/protocol/handshake_test.exs new file mode 100644 index 0000000..d380123 --- /dev/null +++ b/test/mysqlex/protocol/handshake_test.exs @@ -0,0 +1,30 @@ +defmodule Mysqlex.Protocol.HandshakeTest do + use ExUnit.Case, async: true + + alias Mysqlex.Protocol.Handshake + + test "parses the handshake captured from MySQL 8.4.4" do + payload = File.read!("test/fixtures/handshake_v10_8.4.4.bin") + + assert {:ok, handshake} = Handshake.decode(payload) + + assert handshake.protocol_version == 10 + assert handshake.server_version == "8.4.4" + assert handshake.connection_id == 78_814 + assert handshake.character_set == 255 + assert handshake.status_flags == 0x0002 + assert handshake.capabilities == 0xDFFFFFFF + assert handshake.auth_plugin_name == "caching_sha2_password" + end + + test "joins the scramble that the server splits across two fields" do + payload = File.read!("test/fixtures/handshake_v10_8.4.4.bin") + + assert {:ok, handshake} = Handshake.decode(payload) + + assert byte_size(handshake.auth_plugin_data) == 20 + + assert Base.encode16(handshake.auth_plugin_data, case: :lower) == + "6344555b255d623b3a1e1147626d276f0f4f0b74" + end +end diff --git a/test/support/fake_transport.ex b/test/support/fake_transport.ex index c67a0b0..def32a4 100644 --- a/test/support/fake_transport.ex +++ b/test/support/fake_transport.ex @@ -1,24 +1,41 @@ defmodule Mysqlex.FakeTransport do @moduledoc """ - A transport that replays a scripted list of byte chunks, so framing and the - connection phase can be driven without a server. + A transport that replays scripted byte chunks and records what was sent, so + the connection phase can be driven without a server. - Each `recv/3` returns the next chunk, which lets a test deliver one packet - across several reads, or several packets in one read. + Each `recv/3` hands back the next chunk, which lets a test deliver one packet + across several reads or several packets in one read. """ @behaviour Mysqlex.Transport def script(chunks) do - {:ok, agent} = Agent.start_link(fn -> chunks end) + {:ok, agent} = Agent.start_link(fn -> %{chunks: chunks, sent: []} end) agent end + @doc "Everything the client has written to this socket, in order." + def sent(agent) do + agent |> Agent.get(& &1.sent) |> Enum.reverse() |> IO.iodata_to_binary() + end + + @impl true + def connect(_host, _port, _opts, _timeout), do: {:error, :use_script_instead} + @impl true def recv(agent, _bytes, _timeout) do Agent.get_and_update(agent, fn - [chunk | rest] -> {{:ok, chunk}, rest} - [] -> {{:error, :closed}, []} + %{chunks: [chunk | rest]} = state -> {{:ok, chunk}, %{state | chunks: rest}} + %{chunks: []} = state -> {{:error, :closed}, state} end) end + + @impl true + def send(agent, data) do + Agent.update(agent, fn state -> %{state | sent: [data | state.sent]} end) + :ok + end + + @impl true + def close(_agent), do: :ok end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..584eef3 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,4 @@ +# Integration tests need a MySQL server; see test/integration for how to start +# one. Run them with: mix test --include integration +ExUnit.configure(exclude: [:integration]) ExUnit.start()