diff --git a/README.md b/README.md index 4847b981..8734538b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This package contains an implementation of [Thrift](https://thrift.apache.org/) for Elixir. It includes a Thrift IDL parser, an Elixir code generator, and -binary framed client and server implementations. +binary protocol clients and servers with framed and buffered transports. The generated serialization code is highly optimized and has been measured at **10 and 25 times faster**[why?](#why-is-it-faster-than-the-apache-implementation) @@ -50,6 +50,23 @@ defmodule MyProject.Mixfile do end ``` +## Buffered Transport + +Generated services also provide `Binary.Buffered.Client` and +`Binary.Buffered.Server`, with the same RPC methods and connection options +as their framed counterparts: + +```elixir +{:ok, server} = MyService.Binary.Buffered.Server.start_link(MyHandler, 2345) +{:ok, client} = MyService.Binary.Buffered.Client.start_link("localhost", 2345) +``` + +Both endpoints must use the same transport. Buffered transport sends binary +protocol messages without frame-length prefixes, writing each RPC in one send. +It accepts versioned and legacy headers and handles messages split across TCP +reads. Incoming messages are limited to 16 MiB and 64 levels of nesting. +Regenerate existing service code to obtain the buffered modules. + ## RPC Service Support We provide full client and server support for Thrift RPC services. The examples diff --git a/lib/thrift/binary/buffered/client.ex b/lib/thrift/binary/buffered/client.ex new file mode 100644 index 00000000..af428530 --- /dev/null +++ b/lib/thrift/binary/buffered/client.ex @@ -0,0 +1,20 @@ +defmodule Thrift.Binary.Buffered.Client do + @moduledoc """ + Binary protocol client using buffered (unframed) transport. + + Writes each RPC in one send and reads a complete binary protocol message. + Incoming messages are limited to 16 MiB and 64 levels of nesting. + + Options are the same as `Thrift.Binary.Framed.Client.start_link/3`. + Generated services expose this as `YourService.Binary.Buffered.Client`. + """ + alias Thrift.Binary.Framed.Client + + def start_link(host, port, opts \\ []), + do: Client.start_link(host, port, Keyword.put(opts, :packet, 0)) + + defdelegate close(conn), to: Client + defdelegate connect(conn, opts), to: Client + defdelegate call(conn, name, args, module, opts), to: Client + defdelegate oneway(conn, name, args, opts), to: Client +end diff --git a/lib/thrift/binary/buffered/message.ex b/lib/thrift/binary/buffered/message.ex new file mode 100644 index 00000000..3f0cda08 --- /dev/null +++ b/lib/thrift/binary/buffered/message.ex @@ -0,0 +1,102 @@ +defmodule Thrift.Binary.Buffered.Message do + @moduledoc false + + # Exact reads leave subsequent messages in the socket buffer. Bound both + # message size and nesting, and use one deadline for the entire message. + @max_size 16 * 1024 * 1024 + @max_depth 64 + + def recv(transport, socket, timeout) do + deadline = if timeout == :infinity, do: :infinity, else: now() + timeout + state = {transport, socket, deadline, [], 0} + + try do + {header, state} = read(state, 4) + + state = + case header do + <<0x80, 1, 0, type>> when type in 1..4 -> + state = value(11, state, 0) + elem(read(state, 4), 1) + + <> when size >= 0 -> + {_, state} = read(state, size) + {<>, state} = read(state, 1) + if type not in 1..4, do: throw({:error, :invalid_message}) + elem(read(state, 4), 1) + + _ -> + throw({:error, :invalid_message}) + end + + {_, _, _, chunks, _} = value(12, state, 0) + {:ok, chunks |> Enum.reverse() |> IO.iodata_to_binary()} + catch + {:error, _} = error -> error + end + end + + defp value(_, _, depth) when depth > @max_depth, do: throw({:error, :invalid_message}) + defp value(type, state, _) when type in [2, 3], do: elem(read(state, 1), 1) + defp value(6, state, _), do: elem(read(state, 2), 1) + defp value(8, state, _), do: elem(read(state, 4), 1) + defp value(type, state, _) when type in [4, 10], do: elem(read(state, 8), 1) + + defp value(11, state, _) do + {size, state} = count(state) + elem(read(state, size), 1) + end + + defp value(12, state, depth) do + case read(state, 1) do + {<<0>>, state} -> + state + + {<>, state} -> + {_, state} = read(state, 2) + state = value(type, state, depth + 1) + value(12, state, depth) + end + end + + defp value(13, state, depth) do + {<>, state} = read(state, 2) + {size, state} = count(state) + repeat(size, [key, val], state, depth + 1) + end + + defp value(type, state, depth) when type in [14, 15] do + {<>, state} = read(state, 1) + {size, state} = count(state) + repeat(size, [element], state, depth + 1) + end + + defp value(_, _, _), do: throw({:error, :invalid_message}) + + defp repeat(0, _, state, _), do: state + + defp repeat(count, types, state, depth) do + state = Enum.reduce(types, state, &value(&1, &2, depth)) + repeat(count - 1, types, state, depth) + end + + defp count(state) do + {<>, state} = read(state, 4) + if size < 0 or size > @max_size, do: throw({:error, :invalid_message}) + {size, state} + end + + defp read(state, 0), do: {<<>>, state} + + defp read({transport, socket, deadline, chunks, total}, size) do + if total + size > @max_size, do: throw({:error, :message_too_large}) + timeout = if deadline == :infinity, do: :infinity, else: max(deadline - now(), 0) + + case transport.recv(socket, size, timeout) do + {:ok, bytes} -> {bytes, {transport, socket, deadline, [bytes | chunks], total + size}} + {:error, _} = error -> throw(error) + end + end + + defp now, do: System.monotonic_time(:millisecond) +end diff --git a/lib/thrift/binary/buffered/server.ex b/lib/thrift/binary/buffered/server.ex new file mode 100644 index 00000000..f731ad38 --- /dev/null +++ b/lib/thrift/binary/buffered/server.ex @@ -0,0 +1,16 @@ +defmodule Thrift.Binary.Buffered.Server do + @moduledoc """ + Binary protocol server using buffered (unframed) transport. + + Incoming messages are limited to 16 MiB and 64 levels of nesting. + + Options are the same as `Thrift.Binary.Framed.Server.start_link/4`. + Generated services expose this as `YourService.Binary.Buffered.Server`. + """ + alias Thrift.Binary.Framed.Server + + def start_link(server_module, port, handler_module, opts \\ []), + do: Server.start_link(server_module, port, handler_module, Keyword.put(opts, :packet, 0)) + + defdelegate stop(pid), to: Server +end diff --git a/lib/thrift/binary/framed/client.ex b/lib/thrift/binary/framed/client.ex index 18be648b..8a4db23b 100644 --- a/lib/thrift/binary/framed/client.ex +++ b/lib/thrift/binary/framed/client.ex @@ -55,7 +55,8 @@ defmodule Thrift.Binary.Framed.Client do ssl_opts: [SSL.option()], timeout: integer, sock: {:gen_tcp, :gen_tcp.socket()} | {:ssl, :ssl.sslsocket()}, - seq_id: integer + seq_id: integer, + packet: 0 | 4 } defstruct host: nil, port: nil, @@ -64,7 +65,8 @@ defmodule Thrift.Binary.Framed.Client do ssl_opts: nil, timeout: 5000, sock: nil, - seq_id: 0 + seq_id: 0, + packet: 4 end require Logger @@ -82,7 +84,8 @@ defmodule Thrift.Binary.Framed.Client do port: port, tcp_opts: tcp_opts, ssl_opts: ssl_opts, - timeout: timeout + timeout: timeout, + packet: Keyword.get(opts, :packet, 4) } {:connect, :init, s} @@ -141,6 +144,7 @@ defmodule Thrift.Binary.Framed.Client do opts = opts |> Keyword.merge(@immutable_tcp_opts) + |> Keyword.put(:packet, s.packet) |> Keyword.put_new(:send_timeout, 1000) case :gen_tcp.connect(host, port, opts, timeout) do @@ -253,7 +257,7 @@ defmodule Thrift.Binary.Framed.Client do timeout = Keyword.get(tcp_opts, :timeout, default_timeout) with :ok <- transport.send(sock, [message | serialized_args]), - {:ok, message} <- transport.recv(sock, 0, timeout) do + {:ok, message} <- receive_message(transport, sock, timeout, s.packet) do reply = deserialize_message_reply(message, rpc_name, seq_id) {:reply, reply, s} else @@ -291,6 +295,12 @@ defmodule Thrift.Binary.Framed.Client do end end + defp receive_message(transport, socket, timeout, 0), + do: Thrift.Binary.Buffered.Message.recv(transport, socket, timeout) + + defp receive_message(transport, socket, timeout, 4), + do: transport.recv(socket, 0, timeout) + def deserialize_message_reply(message, rpc_name, seq_id) do handle_message(Binary.deserialize(:message_begin, message), seq_id, rpc_name) end @@ -333,9 +343,7 @@ defmodule Thrift.Binary.Framed.Client do TApplicationException.exception( type: :bad_sequence_id, message: - "Invalid sequence id. The client sent #{seq_id}, but the server replied with #{ - mismatched_seq_id - }" + "Invalid sequence id. The client sent #{seq_id}, but the server replied with #{mismatched_seq_id}" ) {:error, {:exception, exception}} diff --git a/lib/thrift/binary/framed/protocol_handler.ex b/lib/thrift/binary/framed/protocol_handler.ex index 839485bd..effa587f 100644 --- a/lib/thrift/binary/framed/protocol_handler.ex +++ b/lib/thrift/binary/framed/protocol_handler.ex @@ -25,7 +25,8 @@ defmodule Thrift.Binary.Framed.ProtocolHandler do :server_module, :handler_module, :on_connect, - :recv_timeout + :recv_timeout, + :packet ] @spec start_link(reference, port, transport, {module, module, transport_opts, [SSL.option()]}) :: @@ -60,7 +61,8 @@ defmodule Thrift.Binary.Framed.ProtocolHandler do :ok = :ranch.accept_ack(ref) {recv_timeout, tcp_opts} = Keyword.pop(tcp_opts, :recv_timeout, @default_timeout) - :ok = :inet.setopts(socket, Keyword.put(tcp_opts, :packet, 4)) + packet = Keyword.get(tcp_opts, :packet, 4) + :ok = :inet.setopts(socket, tcp_opts) state = %__MODULE__{ transport: :gen_tcp, @@ -68,7 +70,8 @@ defmodule Thrift.Binary.Framed.ProtocolHandler do server_module: server_module, handler_module: handler_module, on_connect: on_connect, - recv_timeout: recv_timeout + recv_timeout: recv_timeout, + packet: packet } case SSL.configuration(ssl_opts) do @@ -97,7 +100,7 @@ defmodule Thrift.Binary.Framed.ProtocolHandler do with :ok <- :inet.setopts(socket, packet: :raw), {:ok, first_bytes} <- :gen_tcp.recv(socket, 4, recv_timeout), :ok <- :gen_tcp.unrecv(socket, first_bytes), - :ok <- :inet.setopts(socket, packet: 4) do + :ok <- :inet.setopts(socket, packet: state.packet) do <> = first_bytes {:ok, first_byte} end @@ -114,7 +117,7 @@ defmodule Thrift.Binary.Framed.ProtocolHandler do finish_span(span, result: "ssl_rejected") close(state) - {:ok, @tcp_header_byte} when optional == :required -> + {:ok, byte} when byte in [@tcp_header_byte, 0x80] and optional == :required -> Logger.error(fn -> format_log("peek_first_byte", "SSL required", state) end) @@ -122,7 +125,7 @@ defmodule Thrift.Binary.Framed.ProtocolHandler do finish_span(span, result: "tcp_rejected") close(state) - {:ok, @tcp_header_byte} when optional in [:disabled, :optional] -> + {:ok, byte} when byte in [@tcp_header_byte, 0x80] and optional in [:disabled, :optional] -> finish_span(span, result: "tcp") on_connect(state) @@ -200,7 +203,7 @@ defmodule Thrift.Binary.Framed.ProtocolHandler do ) do span = start_span(:receive_message, state) - case transport.recv(socket, 0, recv_timeout) do + case receive_packet(transport, socket, recv_timeout, state.packet) do {:ok, serialized_message} -> finish_span(span, result: "success") deserialize_message(serialized_message, state) @@ -219,6 +222,12 @@ defmodule Thrift.Binary.Framed.ProtocolHandler do end end + defp receive_packet(transport, socket, timeout, 0), + do: Thrift.Binary.Buffered.Message.recv(transport, socket, timeout) + + defp receive_packet(transport, socket, timeout, 4), + do: transport.recv(socket, 0, timeout) + defp deserialize_message(serialized_message, state) do case Binary.deserialize(:message_begin, serialized_message) do {:ok, message} -> @@ -293,7 +302,11 @@ defmodule Thrift.Binary.Framed.ProtocolHandler do server_module.handle_thrift(method_name, args_binary, handler_module) end) - send_reply(<<0>>, :continue, state) + if state.packet == 0 do + receive_message(state) + else + send_reply(<<0>>, :continue, state) + end end defp send_reply(data, after_reply, %__MODULE__{transport: transport, socket: socket} = state) do diff --git a/lib/thrift/binary/framed/server.ex b/lib/thrift/binary/framed/server.ex index 7195276d..d3625993 100644 --- a/lib/thrift/binary/framed/server.ex +++ b/lib/thrift/binary/framed/server.ex @@ -47,7 +47,10 @@ defmodule Thrift.Binary.Framed.Server do def start_link(server_module, port, handler_module, opts) do name = Keyword.get(opts, :name, handler_module) worker_count = Keyword.get(opts, :worker_count, 1) - tcp_opts = Keyword.get(opts, :tcp_opts, []) + + tcp_opts = + Keyword.put(Keyword.get(opts, :tcp_opts, []), :packet, Keyword.get(opts, :packet, 4)) + ssl_opts = Keyword.get(opts, :ssl_opts, []) on_connect = Keyword.get(opts, :on_connect) diff --git a/lib/thrift/generator/binary/framed/client.ex b/lib/thrift/generator/binary/framed/client.ex index 32dcb6ed..60a81875 100644 --- a/lib/thrift/generator/binary/framed/client.ex +++ b/lib/thrift/generator/binary/framed/client.ex @@ -4,18 +4,21 @@ defmodule Thrift.Generator.Binary.Framed.Client do alias Thrift.AST.Function alias Thrift.Generator.{Service, Utils} - def generate(service) do + def generate(service, transport \\ :framed) do functions = service.functions |> Map.values() |> Enum.map(&generate_handler_function(&1)) |> Utils.merge_blocks() + module = Module.concat([Binary, Macro.camelize(Atom.to_string(transport)), Client]) + implementation = Module.concat([Thrift, module]) + quote do - defmodule Binary.Framed.Client do + defmodule unquote(module) do @moduledoc false - alias Thrift.Binary.Framed.Client, as: ClientImpl + alias unquote(implementation), as: ClientImpl defdelegate close(conn), to: ClientImpl defdelegate connect(conn, opts), to: ClientImpl diff --git a/lib/thrift/generator/binary/framed/server.ex b/lib/thrift/generator/binary/framed/server.ex index 08a789bb..909f4cd8 100644 --- a/lib/thrift/generator/binary/framed/server.ex +++ b/lib/thrift/generator/binary/framed/server.ex @@ -9,18 +9,21 @@ defmodule Thrift.Generator.Binary.Framed.Server do alias Thrift.Parser.FileGroup - def generate(service_module, service, file_group) do + def generate(service_module, service, file_group, transport \\ :framed) do functions = service.functions |> Map.values() |> Enum.map(&generate_handler_function(file_group, service_module, &1)) + module = Module.concat([Binary, Macro.camelize(Atom.to_string(transport)), Server]) + implementation = Module.concat([Thrift, module]) + quote do - defmodule Binary.Framed.Server do + defmodule unquote(module) do @moduledoc false require Logger - alias Thrift.Binary.Framed.Server, as: ServerImpl + alias unquote(implementation), as: ServerImpl defdelegate stop(name), to: ServerImpl def start_link(handler_module, port, opts \\ []) do diff --git a/lib/thrift/generator/service.ex b/lib/thrift/generator/service.ex index 3d5acbca..3ec89534 100644 --- a/lib/thrift/generator/service.ex +++ b/lib/thrift/generator/service.ex @@ -26,6 +26,11 @@ defmodule Thrift.Generator.Service do framed_client = Generator.Binary.Framed.Client.generate(service) framed_server = Generator.Binary.Framed.Server.generate(dest_module, service, file_group) + buffered_client = Generator.Binary.Framed.Client.generate(service, :buffered) + + buffered_server = + Generator.Binary.Framed.Server.generate(dest_module, service, file_group, :buffered) + service_module = quote do defmodule unquote(dest_module) do @@ -36,6 +41,8 @@ defmodule Thrift.Generator.Service do unquote(framed_client) unquote(framed_server) + unquote(buffered_client) + unquote(buffered_server) end end diff --git a/mix.lock b/mix.lock index 423762a6..530850c6 100644 --- a/mix.lock +++ b/mix.lock @@ -20,7 +20,7 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/test/fixtures/ssl/README.md b/test/fixtures/ssl/README.md new file mode 100644 index 00000000..4fe904f2 --- /dev/null +++ b/test/fixtures/ssl/README.md @@ -0,0 +1,6 @@ +These certificates and the private key are public test fixtures for localhost +SSL communication only. Do not use them outside tests. + +`cacerts.pem` is a test CA; `cert.pem` is its signed localhost certificate, +and `key.pem` is the corresponding unencrypted private key. The certificates +are valid for ten years from generation in October 2026. diff --git a/test/fixtures/ssl/cacerts.pem b/test/fixtures/ssl/cacerts.pem new file mode 100644 index 00000000..b046c295 --- /dev/null +++ b/test/fixtures/ssl/cacerts.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIUA0sUa5a6DW0djJufdAawj8ovbfowDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOVGhyaWZ0IFRlc3QgQ0EwHhcNMjYxMDEyMjExNDUwWhcN +MzYxMDA5MjExNDUwWjAZMRcwFQYDVQQDDA5UaHJpZnQgVGVzdCBDQTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBANgrTLaDqiYmVkmaZfLYsh8ePX+z9TI3 +GKKX7XpNdlkgFyw3jn2Oo3KvFzoj8iT/lc3F/LjZW0XaeC0gr6ClEKtMbrCd1PPX +wwnjnfF+hKfQji7ksTQzGYBLFAerGiKq2QnRx9AlXOt/iJxHHmhjqB2+OuNkKJpV +Kk/RIQFoBGOTIl2mEMouLYAE1DhmUUxCmHEo2vgTdGZX07ZmNwcjc1S40eviuZ4X +NNI9u+q68r6OIh/VXL2tsuzn+WXv/iMqh6FR6CANtvdPYB1yHjkf2L/OBcBI3mIi +5NC7Ud8SMTRj1xUSC/KTzTcmFuaJRvRd7f0HasspCEHmadaP5NW8398CAwEAAaNj +MGEwHQYDVR0OBBYEFPMtrdjgVh0FJqmcdojhNsavmDPSMB8GA1UdIwQYMBaAFPMt +rdjgVh0FJqmcdojhNsavmDPSMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD +AgEGMA0GCSqGSIb3DQEBCwUAA4IBAQDK9ZTTK25XVboEokF7LJOQ44hSNFVBUODa +bZGTkczaPVDiSCQfiyWDc4VseJNkqOpn9cPotzosIJWPVoghDKkratDn35aktfGc +eIRbiLqKm9t9wvk6OMFVKO3bScaHcLJVSvsFHzruEfKVwlbGM1XGkonQuiR1hJJT +xIMQMw0RyMh06oIpWJkyFPfvJnZ9b39WPTfCv5h/85eqXsapnoEZekUGu6D+QWb2 +fg8HawTfRxAtRgbz6aXdC/2Eu9SdgGn49359ZBYfdj//aqUlynW65t7nfAQGXMPU +vALZJZa11RH0mPByFR08BLsu28w/nlcVNkooFhyQDWYScjpGY+6/ +-----END CERTIFICATE----- diff --git a/test/fixtures/ssl/cert.pem b/test/fixtures/ssl/cert.pem new file mode 100644 index 00000000..adc8da99 --- /dev/null +++ b/test/fixtures/ssl/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBjCCAe6gAwIBAgIBATANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA5UaHJp +ZnQgVGVzdCBDQTAeFw0yNjEwMTIyMTE0NTBaFw0zNjEwMDkyMTE0NTBaMBQxEjAQ +BgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AJzXBqT/GEnHyeK+xsTnljpV8uMd4t3v1a1imTDJ5PpmZ2rgvUfH3q0IQVgePXkV +Dg58zdKTz0BnIAfKRYQ3gzXDAujETDo0TXKEnglqy6h0VK5h2eMPCiCGN4fD0dG7 +Gbm013U7vKwK5nRhvTKAEkvy7x8WHPlzA96CZpnSze8QVNxjqkAI1WL1eqEmTzKc +Qka/Y2oyRFtdsFrqrX8WyjlR6zRSpM0erE6imJp2yq2lCma0CUFz8OwheODnw8Dp +ZkBvAam4vpCDgNnJ8CfzAulbredcoDVIGTuxWRy7xwvSPOUinlkxFFqQiJLIqIja +wP+YUeoDVhS7FXGRA5qC2HsCAwEAAaNeMFwwGgYDVR0RBBMwEYIJbG9jYWxob3N0 +hwR/AAABMB0GA1UdDgQWBBSmtYxmhv7XSVutlyIBYNLmY6NYnzAfBgNVHSMEGDAW +gBTzLa3Y4FYdBSapnHaI4TbGr5gz0jANBgkqhkiG9w0BAQsFAAOCAQEAnQZ3RK8O +Xz63LhaF+EZ6AI+hXPkL9PiMvoxhCChncZ4n1glfq+K7FGzvB92EHduBE8W+kK8q +dRtm+HHFw/FAsXWOM1bCaIqB4NbK6kMqKV01G1z9YT40mEP1vWAoGXUz5/TvcKxQ +WEZ9JeE53Qu85OFh2/ONFHR29FEYj3Ypp76U62/26lzJxu7V5MPdr6pwVnC+GFtk +coUHearNUCkE3VZRQzOfRMaBTSi75lnS0g+VH1sARrqNyuXw1czLhIhgsixy+wB5 +fbk30mbPYwwssWZcOV+4PuoJyijU0v0mcz5iPnQg0bAwTEDgugaG8h94StVOoSeG +8mQAN6qwLVvwsA== +-----END CERTIFICATE----- diff --git a/test/fixtures/ssl/key.pem b/test/fixtures/ssl/key.pem new file mode 100644 index 00000000..bbd60f89 --- /dev/null +++ b/test/fixtures/ssl/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCc1wak/xhJx8ni +vsbE55Y6VfLjHeLd79WtYpkwyeT6Zmdq4L1Hx96tCEFYHj15FQ4OfM3Sk89AZyAH +ykWEN4M1wwLoxEw6NE1yhJ4JasuodFSuYdnjDwoghjeHw9HRuxm5tNd1O7ysCuZ0 +Yb0ygBJL8u8fFhz5cwPegmaZ0s3vEFTcY6pACNVi9XqhJk8ynEJGv2NqMkRbXbBa +6q1/Fso5Ues0UqTNHqxOopiadsqtpQpmtAlBc/DsIXjg58PA6WZAbwGpuL6Qg4DZ +yfAn8wLpW63nXKA1SBk7sVkcu8cL0jzlIp5ZMRRakIiSyKiI2sD/mFHqA1YUuxVx +kQOagth7AgMBAAECggEADqQSAOK2po3fHkAXN7gIsZRmvTsue5pRFO5F2k9BL/t8 +Pva7efvB9ol1PspqjFGMrjSKACoqfYVtm4c50GW1kBYOwPeGHXmBcKXJnPEaZt9z +15LXL1eEPKoFfV0odTl6kCKTabm0W05p2fEVI3rfemCItZsFBGeaCQec7VIc8D/X +/DcUvd+PnleSfdrGyzbf8XqswnXfURWljrJ2T7y6Ds7kGGbU730K6uTyL/C8pw2U +Vk9tcnDpovRZQ9+WqtwGdymwbDHR6d51MjNTT9oPH/U6/kw0uJQyOTOoRN80JP7S +rygP1DkI+cOxkgdbgKA92OMqetKqtkUzuJDhvTcIBQKBgQDajT9C4qFdJW/0lS+4 +aR1Hh3/AhRGbKBHalh77JbNuxRZTspuMegSA5mruEet7rZeMQchV+pY9B0sIqJdW +RkMc+E+4f7q03ASaXmPwtkIkPfW3FGHkDIiX5fbRlxMS6ENbQz1Bc5jI2VIZOrft +uZByFSEA/PqFAYnExrCaW+alrQKBgQC3tsvR6SrYAaIxvw4ZgP/q+kegK2EuXdVE +wwfJGrzdc17Aph3IPbTaZB2JnsKPlSZ2/IPvZkM1SnHR9A6uQAtyVKX1wEn7WfEZ +aFga1MVhuhsZRnaEsiDWc54qsCH4LWSeFOkGYKxhEDiDie77A7DWGOuaA/PLj6ED +bnnoAnQrxwKBgDGC9ETz0LioZY+USKZPcemlgcScoNt0nW7WuZgN93NtxNwbR2/w +qJqHGMxsVNvE/OMyd+86gNS8lTwV7YsHCedDPdQ/Y43bllNedOewcpWBT+w0+uRO +DpfS6jKbNh//bFr9haARTxiZWycKDWcWtemSbdKqfRqquYpRtOqTTVJVAoGARJbG +ufQnvsW9XH1MMlFrOrt+ilonyjvWJ/n4qiiFYth0A9EBRYVkRDUkogDX+utglQPp +7jNr3DWTESPhZ0UaAPGBlKLZ/DG0Rhwi6MGPf9EP1Pvtu9UVVXuNDbr8s7+e1TZR +WcQ2F3cK4nFY+/aAyy46qCHZn6JcJv/UG77uyHkCgYBJP/4Q9V0JWym27CgaaRXa +ByLAo+T/UPmdjM7vhQalcIo2v5PTa8UJau648A66Z6U+6SJ5Xmow59E7vuMNXCWQ +PXXm/Y9Q2Zsi9xJqBDXSZtemEF9V/bdw8nOm/QQa9RfjaGUAV6t+a5HxUjfYBH4B +lZGWGXgROc4a/TtbSGzjUQ== +-----END PRIVATE KEY----- diff --git a/test/support/lib/thrift_test_case.ex b/test/support/lib/thrift_test_case.ex index 3020ddea..c19077c7 100644 --- a/test/support/lib/thrift_test_case.ex +++ b/test/support/lib/thrift_test_case.ex @@ -139,7 +139,17 @@ defmodule ThriftTestCase do quote bind_quoted: [module: __MODULE__, message: message, var: var, block: block] do if module.implement?(__MODULE__) do contents = module.quoted_contents(__MODULE__, block) - name = ExUnit.Case.register_test(__ENV__, :test, message, []) + + name = + ExUnit.Case.register_test( + __ENV__.module, + __ENV__.file, + __ENV__.line, + :test, + message, + [] + ) + def unquote(name)(unquote(var)), do: unquote(contents) else ExUnit.Case.test message do diff --git a/test/thrift/binary/buffered/message_test.exs b/test/thrift/binary/buffered/message_test.exs new file mode 100644 index 00000000..79c993da --- /dev/null +++ b/test/thrift/binary/buffered/message_test.exs @@ -0,0 +1,71 @@ +defmodule Thrift.Binary.Buffered.MessageTest do + use ExUnit.Case, async: true + alias Thrift.Binary.Buffered.Message + alias Thrift.Protocol.Binary + + setup do + {:ok, listener} = :gen_tcp.listen(0, [:binary, active: false, packet: 0]) + {:ok, port} = :inet.port(listener) + + {:ok, sender} = + :gen_tcp.connect(String.to_charlist("localhost"), port, [:binary, active: false, packet: 0]) + + {:ok, receiver} = :gen_tcp.accept(listener) + :gen_tcp.close(listener) + + on_exit(fn -> + :gen_tcp.close(sender) + :gen_tcp.close(receiver) + end) + + {:ok, sender: sender, receiver: receiver} + end + + test "reads every field type and leaves the next message buffered", ctx do + fields = [ + {2, Binary.serialize(:bool, true)}, + {3, Binary.serialize(:i8, -5)}, + {6, Binary.serialize(:i16, -300)}, + {8, Binary.serialize(:i32, 999)}, + {10, Binary.serialize(:i64, -999)}, + {4, Binary.serialize(:double, 1.5)}, + {11, Binary.serialize(:string, "")}, + {12, <<11, 1::16, 3::32, 0, 1, 2, 0>>}, + {13, Binary.serialize({:map, {:string, {:list, :i64}}}, %{"a" => [1, 2]})}, + {14, Binary.serialize({:set, :string}, MapSet.new(["a", "b"]))}, + {15, Binary.serialize({:list, :i32}, [])} + ] + + body = Enum.map(fields, fn {type, bytes} -> [<>, bytes] end) + + message = + IO.iodata_to_binary([Binary.serialize(:message_begin, {:reply, 0, "all"}), body, <<0>>]) + + :ok = :gen_tcp.send(ctx.sender, [message, message]) + assert {:ok, ^message} = Message.recv(:gen_tcp, ctx.receiver, 1000) + assert {:ok, ^message} = Message.recv(:gen_tcp, ctx.receiver, 1000) + end + + test "rejects negative lengths and unknown field types", ctx do + header = Binary.serialize(:message_begin, {:reply, 0, "bad"}) + :ok = :gen_tcp.send(ctx.sender, [header, <<11, 1::16, -1::signed-32>>]) + assert {:error, :invalid_message} = Message.recv(:gen_tcp, ctx.receiver, 1000) + :ok = :gen_tcp.send(ctx.sender, [header, <<99, 1::16>>]) + assert {:error, :invalid_message} = Message.recv(:gen_tcp, ctx.receiver, 1000) + end + + test "bounds message size and nesting", ctx do + header = Binary.serialize(:message_begin, {:reply, 0, "bad"}) + :ok = :gen_tcp.send(ctx.sender, [header, <<11, 1::16, 16_777_216::32>>]) + assert {:error, :message_too_large} = Message.recv(:gen_tcp, ctx.receiver, 1000) + :ok = :gen_tcp.send(ctx.sender, [header, List.duplicate(<<12, 1::16>>, 65)]) + assert {:error, :invalid_message} = Message.recv(:gen_tcp, ctx.receiver, 1000) + end + + test "incomplete messages time out or report a closed connection", ctx do + :ok = :gen_tcp.send(ctx.sender, <<0x80, 1>>) + assert {:error, :timeout} = Message.recv(:gen_tcp, ctx.receiver, 20) + :gen_tcp.close(ctx.sender) + assert {:error, :closed} = Message.recv(:gen_tcp, ctx.receiver, 1000) + end +end diff --git a/test/thrift/binary/buffered/transport_test.exs b/test/thrift/binary/buffered/transport_test.exs new file mode 100644 index 00000000..9a1c045e --- /dev/null +++ b/test/thrift/binary/buffered/transport_test.exs @@ -0,0 +1,79 @@ +defmodule Thrift.Binary.Buffered.TransportTest do + use ThriftTestCase + + @thrift_file name: "buffered.thrift", + contents: """ + exception Rejected { 1: string message } + service Echo { + string echo(1: string text), + map> nested(1: map> value), + void fail() throws (1: Rejected rejected), + oneway void notify(), + } + """ + + alias __MODULE__.Echo.Binary.Buffered.{Client, Server} + alias Thrift.Binary.Buffered.Message + alias Thrift.Protocol.Binary + + defmodule Handler do + def echo(text), do: text + def nested(value), do: value + def notify(), do: :ok + def fail(), do: raise(Thrift.Binary.Buffered.TransportTest.Rejected, message: "rejected") + end + + setup do + {:ok, server} = Server.start_link(Handler, 0) + Process.unlink(server) + port = :ranch.get_port(Handler) + on_exit(fn -> if Process.alive?(server), do: Server.stop(server) end) + {:ok, port: port} + end + + thrift_test "generated client supports repeated calls, containers, exceptions and oneway", + ctx do + {:ok, client} = Client.start_link("127.0.0.1", ctx.port) + assert {:ok, ""} = Client.echo(client, "") + assert {:ok, nil} = Client.notify(client) + assert {:ok, "after oneway"} = Client.echo(client, "after oneway") + nested = %{"empty" => [], "numbers" => [1, -2, 3]} + assert {:ok, ^nested} = Client.nested(client, nested) + assert {:error, {:exception, %Rejected{message: "rejected"}}} = Client.fail(client) + assert "still connected" = Client.echo!(client, "still connected") + assert :ok = Client.close(client) + end + + thrift_test "raw peer can fragment and coalesce requests without frame prefixes", ctx do + {:ok, socket} = + :gen_tcp.connect(String.to_charlist("localhost"), ctx.port, [ + :binary, + active: false, + packet: 0 + ]) + + # Hand-written binary protocol requests, independent of our serializer. + request = <<0x80, 1, 0, 1, 4::32, "echo", 7::32, 11, 1::16, 3::32, "abc", 0>> + <> = request + :ok = :gen_tcp.send(socket, <>) + :ok = :gen_tcp.send(socket, [rest, request]) + response = <<0x80, 1, 0, 2, 4::32, "echo", 7::32, 11, 0::16, 3::32, "abc", 0>> + assert {:ok, ^response} = :gen_tcp.recv(socket, byte_size(response), 1000) + assert {:ok, ^response} = :gen_tcp.recv(socket, byte_size(response), 1000) + :gen_tcp.close(socket) + end + + thrift_test "server accepts legacy message headers", ctx do + {:ok, socket} = + :gen_tcp.connect(String.to_charlist("localhost"), ctx.port, [ + :binary, + active: false, + packet: 0 + ]) + + :ok = :gen_tcp.send(socket, <<4::32, "echo", 1, 9::32, 11, 1::16, 0::32, 0>>) + assert {:ok, response} = Message.recv(:gen_tcp, socket, 1000) + assert {:ok, {:reply, 9, "echo", _}} = Binary.deserialize(:message_begin, response) + :gen_tcp.close(socket) + end +end diff --git a/test/thrift/binary/framed/server_test.exs b/test/thrift/binary/framed/server_test.exs index 48572cda..3edb170b 100644 --- a/test/thrift/binary/framed/server_test.exs +++ b/test/thrift/binary/framed/server_test.exs @@ -174,7 +174,7 @@ defmodule Servers.Binary.Framed.IntegrationTest do end thrift_test "it can handle bogus data", ctx do - {:ok, socket} = :gen_tcp.connect('localhost', ctx.port, [:binary, packet: 4, active: false]) + {:ok, socket} = :gen_tcp.connect(~c"localhost", ctx.port, [:binary, packet: 4, active: false]) :ok = :gen_tcp.send(socket, <<1, 2, 3, 4, 5>>) assert {:error, :closed} == :gen_tcp.recv(socket, 0) diff --git a/test/thrift/binary/framed/ssl_test.exs b/test/thrift/binary/framed/ssl_test.exs index d53a04b7..4143202e 100644 --- a/test/thrift/binary/framed/ssl_test.exs +++ b/test/thrift/binary/framed/ssl_test.exs @@ -61,7 +61,9 @@ defmodule Servers.Binary.Framed.SSLTest do def build_ssl_client(ctx) do {:ok, ssl_client} = - Client.start_link("localhost", ctx.port, ssl_opts: ctx[:ssl_opts] ++ [enabled: true]) + Client.start_link("localhost", ctx.port, + ssl_opts: ctx[:ssl_opts] ++ [enabled: true, verify: :verify_none] + ) {:ok, ssl_client: ssl_client} end @@ -143,7 +145,7 @@ defmodule Servers.Binary.Framed.SSLTest do ## Helpers defp get_certs() do - certs_path = Application.app_dir(:ssl, "examples/certs/etc/server") + certs_path = Path.expand("../../../fixtures/ssl", __DIR__) cacerts = Path.join(certs_path, "cacerts.pem") cert = Path.join(certs_path, "cert.pem") key = Path.join(certs_path, "key.pem") diff --git a/test/thrift/generator/service_test.exs b/test/thrift/generator/service_test.exs index 87d1b17a..8b551a90 100644 --- a/test/thrift/generator/service_test.exs +++ b/test/thrift/generator/service_test.exs @@ -460,6 +460,6 @@ defmodule Thrift.Generator.ServiceTest do } # Still connected. - assert {:error, {:exception, exception}} = WrongClient.plang(client) + assert {:error, {:exception, ^exception}} = WrongClient.plang(client) end end diff --git a/test/thrift/parser/lexer_test.exs b/test/thrift/parser/lexer_test.exs index 205d2a50..901e3cf6 100644 --- a/test/thrift/parser/lexer_test.exs +++ b/test/thrift/parser/lexer_test.exs @@ -147,19 +147,19 @@ defmodule Thrift.Parser.LexerTest do assert tokenize("{'hello':'world'}") == [ {:"{", 1}, - {:string, 1, 'hello'}, + {:string, 1, ~c"hello"}, {:":", 1}, - {:string, 1, 'world'}, + {:string, 1, ~c"world"}, {:"}", 1} ] assert tokenize("{'a':1,'b':2}") == [ {:"{", 1}, - {:string, 1, 'a'}, + {:string, 1, ~c"a"}, {:":", 1}, {:int, 1, 1}, {:",", 1}, - {:string, 1, 'b'}, + {:string, 1, ~c"b"}, {:":", 1}, {:int, 1, 2}, {:"}", 1} @@ -173,11 +173,11 @@ defmodule Thrift.Parser.LexerTest do end test "identifiers" do - assert tokenize("abc_123.xzy") == [{:ident, 1, 'abc_123.xzy'}] + assert tokenize("abc_123.xzy") == [{:ident, 1, ~c"abc_123.xzy"}] end test "typedef" do - assert tokenize("typedef i32 Integer") == [{:typedef, 1}, {:i32, 1}, {:ident, 1, 'Integer'}] + assert tokenize("typedef i32 Integer") == [{:typedef, 1}, {:i32, 1}, {:ident, 1, ~c"Integer"}] end test "enum definition" do @@ -190,21 +190,21 @@ defmodule Thrift.Parser.LexerTest do } """) == [ {:enum, 1}, - {:ident, 1, 'Operation'}, + {:ident, 1, ~c"Operation"}, {:"{", 1}, - {:ident, 2, 'ADD'}, + {:ident, 2, ~c"ADD"}, {:=, 2}, {:int, 2, 1}, {:",", 2}, - {:ident, 3, 'SUBTRACT'}, + {:ident, 3, ~c"SUBTRACT"}, {:=, 3}, {:int, 3, 2}, {:",", 3}, - {:ident, 4, 'MULTIPLY'}, + {:ident, 4, ~c"MULTIPLY"}, {:=, 4}, {:int, 4, 3}, {:",", 4}, - {:ident, 5, 'DIVIDE'}, + {:ident, 5, ~c"DIVIDE"}, {:=, 5}, {:int, 5, 4}, {:"}", 6} @@ -221,30 +221,30 @@ defmodule Thrift.Parser.LexerTest do } """) == [ {:struct, 1}, - {:ident, 1, 'Work'}, + {:ident, 1, ~c"Work"}, {:"{", 1}, {:int, 2, 1}, {:":", 2}, {:i32, 2}, - {:ident, 2, 'num1'}, + {:ident, 2, ~c"num1"}, {:=, 2}, {:int, 2, 0}, {:",", 2}, {:int, 3, 2}, {:":", 3}, {:i32, 3}, - {:ident, 3, 'num2'}, + {:ident, 3, ~c"num2"}, {:",", 3}, {:int, 4, 3}, {:":", 4}, - {:ident, 4, 'Operation'}, - {:ident, 4, 'op'}, + {:ident, 4, ~c"Operation"}, + {:ident, 4, ~c"op"}, {:",", 4}, {:int, 5, 4}, {:":", 5}, {:optional, 5}, {:string, 5}, - {:ident, 5, 'comment'}, + {:ident, 5, ~c"comment"}, {:",", 5}, {:"}", 6} ] @@ -258,17 +258,17 @@ defmodule Thrift.Parser.LexerTest do } """) == [ {:exception, 1}, - {:ident, 1, 'InvalidOperation'}, + {:ident, 1, ~c"InvalidOperation"}, {:"{", 1}, {:int, 2, 1}, {:":", 2}, {:i32, 2}, - {:ident, 2, 'whatOp'}, + {:ident, 2, ~c"whatOp"}, {:",", 2}, {:int, 3, 2}, {:":", 3}, {:string, 3}, - {:ident, 3, 'why'}, + {:ident, 3, ~c"why"}, {:"}", 4} ] end @@ -282,32 +282,32 @@ defmodule Thrift.Parser.LexerTest do } """) == [ {:service, 1}, - {:ident, 1, 'Calculator'}, + {:ident, 1, ~c"Calculator"}, {:extends, 1}, - {:ident, 1, 'shared.SharedService'}, + {:ident, 1, ~c"shared.SharedService"}, {:"{", 1}, {:void, 2}, - {:ident, 2, 'ping'}, + {:ident, 2, ~c"ping"}, {:"(", 2}, {:")", 2}, {:",", 2}, {:i32, 3}, - {:ident, 3, 'add'}, + {:ident, 3, ~c"add"}, {:"(", 3}, {:int, 3, 1}, {:":", 3}, {:i32, 3}, - {:ident, 3, 'num1'}, + {:ident, 3, ~c"num1"}, {:",", 3}, {:int, 3, 2}, {:":", 3}, {:i32, 3}, - {:ident, 3, 'num2'}, + {:ident, 3, ~c"num2"}, {:")", 3}, {:",", 3}, {:oneway, 4}, {:void, 4}, - {:ident, 4, 'zip'}, + {:ident, 4, ~c"zip"}, {:"(", 4}, {:")", 4}, {:"}", 5} @@ -323,20 +323,20 @@ defmodule Thrift.Parser.LexerTest do #@namespace elixir Bar """) == [ {:namespace, 1}, - {:ident, 1, 'elixir'}, - {:ident, 1, 'Foo'}, + {:ident, 1, ~c"elixir"}, + {:ident, 1, ~c"Foo"}, {:namespace, 2}, - {:ident, 2, 'py'}, - {:ident, 2, 'foo.bar.baz'}, + {:ident, 2, ~c"py"}, + {:ident, 2, ~c"foo.bar.baz"}, {:namespace, 3}, - {:ident, 3, 'java'}, - {:ident, 3, 'com.pinterest.foo.bar.baz'}, + {:ident, 3, ~c"java"}, + {:ident, 3, ~c"com.pinterest.foo.bar.baz"}, {:namespace, 4}, {:*, 4}, - {:ident, 4, 'foo.bar'}, + {:ident, 4, ~c"foo.bar"}, {:namespace, 5}, - {:ident, 5, 'elixir'}, - {:ident, 5, 'Bar'} + {:ident, 5, ~c"elixir"}, + {:ident, 5, ~c"Bar"} ] end @@ -344,7 +344,7 @@ defmodule Thrift.Parser.LexerTest do assert tokenize("const my_const = 32") == [ {:const, 1}, - {:ident, 1, 'my_const'}, + {:ident, 1, ~c"my_const"}, {:=, 1}, {:int, 1, 32} ] @@ -355,9 +355,9 @@ defmodule Thrift.Parser.LexerTest do [ {:const, 1}, {:string, 1}, - {:ident, 1, 'my_const'}, + {:ident, 1, ~c"my_const"}, {:=, 1}, - {:ident, 1, 'MyEnum.VALUE'} + {:ident, 1, ~c"MyEnum.VALUE"} ] end end