Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/terminalwire/codec.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ defmodule Terminalwire.Codec do
@doc """
Decode MessagePack bytes for exactly one frame. Returns the frame map (string
keys). Raises `ProtocolError` for anything that isn't a well-formed frame.
`bin` values come back as plain binaries (unpack with binary: true).
`bin` values come back wrapped in `Msgpax.Bin` (binary: true) so a decode then
re-encode preserves the MessagePack `bin` type instead of degrading to `str`;
consumers unwrap them before use (see `Terminalwire.Server.Session`).
"""
def decode(bytes) when is_binary(bytes) do
frame =
Expand Down
96 changes: 96 additions & 0 deletions test/codec_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
defmodule Terminalwire.CodecTest do
use ExUnit.Case, async: true

alias Terminalwire.{Codec, Frames, ProtocolError}

describe "encode/1" do
test "encodes a frame map to a MessagePack binary" do
bytes = Codec.encode(%{"t" => "hello", "sid" => 0})
assert is_binary(bytes)
end

test "raises for a non-map frame" do
assert_raise ProtocolError, ~r/frame must be a map/, fn -> Codec.encode([1, 2, 3]) end
assert_raise ProtocolError, ~r/frame must be a map/, fn -> Codec.encode("nope") end
end
end

describe "round-trip" do
test "encode then decode returns an equivalent frame (string keys)" do
frame = %{"t" => "request", "sid" => 7, "resource" => "file", "method" => "read"}
assert frame |> Codec.encode() |> Codec.decode() == frame
end

test "binary payloads decode as Msgpax.Bin so a re-encode keeps them MessagePack bin" do
raw = <<0, 255, 1, 2, 254>>
decoded = Frames.data(3, raw) |> Codec.encode() |> Codec.decode()
# Decoding keeps `bin` wrapped in Msgpax.Bin (binary: true) so re-encoding a
# decoded frame preserves the bin type instead of degrading to str. Consumers
# (the Session) unwrap it before handing bytes to the CLI. The bytes are intact.
assert %Msgpax.Bin{data: ^raw} = decoded["bytes"]
end

test "the control stream id (0) round-trips" do
assert %{"t" => "signal", "sid" => 0} |> Codec.encode() |> Codec.decode() == %{
"t" => "signal",
"sid" => 0
}
end

test "the maximum signed-64-bit sid round-trips" do
max = 0x7FFFFFFFFFFFFFFF

assert %{"t" => "data", "sid" => max} |> Codec.encode() |> Codec.decode() == %{
"t" => "data",
"sid" => max
}
end
end

describe "decode/1 rejects malformed input" do
test "non-map MessagePack" do
bytes = Msgpax.pack!(123, iodata: false)
assert_raise ProtocolError, ~r/frame must be a map/, fn -> Codec.decode(bytes) end
end

test "corrupt MessagePack bytes" do
# 0xC1 is the one byte MessagePack never assigns — a clean 'malformed' probe.
assert_raise ProtocolError, ~r/malformed msgpack/, fn -> Codec.decode(<<0xC1>>) end
end

test "missing 't'" do
bytes = Codec.encode(%{"sid" => 1})
assert_raise ProtocolError, ~r/missing string 't'/, fn -> Codec.decode(bytes) end
end

test "empty 't'" do
bytes = Codec.encode(%{"t" => "", "sid" => 1})
assert_raise ProtocolError, ~r/missing string 't'/, fn -> Codec.decode(bytes) end
end

test "non-string 't'" do
bytes = Codec.encode(%{"t" => 5, "sid" => 1})
assert_raise ProtocolError, ~r/missing string 't'/, fn -> Codec.decode(bytes) end
end

test "missing 'sid'" do
bytes = Codec.encode(%{"t" => "data"})
assert_raise ProtocolError, ~r/missing integer 'sid'/, fn -> Codec.decode(bytes) end
end

test "non-integer 'sid'" do
bytes = Codec.encode(%{"t" => "data", "sid" => "1"})
assert_raise ProtocolError, ~r/missing integer 'sid'/, fn -> Codec.decode(bytes) end
end

test "negative 'sid'" do
bytes = Codec.encode(%{"t" => "data", "sid" => -1})
assert_raise ProtocolError, ~r/missing integer 'sid'/, fn -> Codec.decode(bytes) end
end

test "'sid' beyond the signed 64-bit range (would wrap negative in Go)" do
bytes = Codec.encode(%{"t" => "data", "sid" => 0x8000000000000000})
assert_raise ProtocolError, ~r/missing integer 'sid'/, fn -> Codec.decode(bytes) end
end
end
end
73 changes: 73 additions & 0 deletions test/protocol_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
defmodule Terminalwire.ProtocolTest do
use ExUnit.Case, async: true

alias Terminalwire.Protocol

# These values are part of the cross-language wire contract (Ruby, Go, Elixir all
# agree). Pinning them here guards against an accidental change that would silently
# break interop; a deliberate change must move in lockstep with the corpus.
describe "version constants" do
test "speaks protocol version 2, with min == max == 2" do
assert Protocol.version() == 2
assert Protocol.min_version() == 2
assert Protocol.max_version() == 2
end

test "the control stream id is 0" do
assert Protocol.control_sid() == 0
end
end

describe "flow-control windows" do
test "default per-stream window is 256 KiB" do
assert Protocol.default_window() == 256 * 1024
end

test "max window ceiling is 16 MiB and is 64x the default offer" do
assert Protocol.max_window() == 16 * 1024 * 1024
assert Protocol.max_window() == Protocol.default_window() * 64
end
end

describe "capabilities" do
test "advertises the full v2 capability set" do
assert Protocol.capabilities() == ~w(
stdio file directory browser env signal flow raw-input terminal-query
)
end
end

describe "frame type tokens" do
test "covers all eleven frame types with their wire spelling" do
alias Protocol.Type

assert {Type.hello(), Type.welcome(), Type.incompatible()} ==
{"hello", "welcome", "incompatible"}

assert {Type.exit(), Type.open(), Type.data(), Type.close()} ==
{"exit", "open", "data", "close"}

assert {Type.request(), Type.response(), Type.signal(), Type.window_adjust()} ==
{"request", "response", "signal", "window_adjust"}
end
end

describe "signal names" do
test "carries resize and interrupt" do
assert Protocol.Signal.resize() == "resize"
assert Protocol.Signal.interrupt() == "interrupt"
end
end

describe "error codes" do
test "covers every error code carried on a failed response" do
alias Protocol.ErrorCode

assert ErrorCode.denied() == "denied"
assert ErrorCode.not_found() == "not_found"
assert ErrorCode.io() == "io"
assert ErrorCode.protocol() == "protocol"
assert ErrorCode.internal() == "internal"
end
end
end
101 changes: 101 additions & 0 deletions test/window_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
defmodule Terminalwire.WindowTest do
use ExUnit.Case, async: true

alias Terminalwire.{Protocol, Window}

@max Protocol.max_window()

describe "new/1" do
test "stores the offered size as available credit" do
assert Window.available(Window.new(1024)) == 1024
end

test "a zero window starts with no credit" do
assert Window.available(Window.new(0)) == 0
end

test "clamps an offer above the protocol ceiling to max_window" do
assert Window.available(Window.new(@max + 1)) == @max
assert Window.available(Window.new(@max * 10)) == @max
end

test "an offer at exactly the ceiling is kept" do
assert Window.available(Window.new(@max)) == @max
end
end

describe "take/2" do
test "takes the full amount when credit covers it and debits the window" do
{taken, w} = Window.take(Window.new(1000), 400)
assert taken == 400
assert Window.available(w) == 600
end

test "takes only what's available when the request exceeds credit" do
{taken, w} = Window.take(Window.new(300), 1000)
assert taken == 300
assert Window.available(w) == 0
end

test "takes nothing from an empty window" do
{taken, w} = Window.take(Window.new(0), 500)
assert taken == 0
assert Window.available(w) == 0
end

test "taking zero is a no-op" do
{taken, w} = Window.take(Window.new(500), 0)
assert taken == 0
assert Window.available(w) == 500
end

test "a negative request never returns negative or grows the window" do
{taken, w} = Window.take(Window.new(500), -100)
assert taken == 0
assert Window.available(w) == 500
end

test "successive takes drain the window exactly" do
w = Window.new(100)
{a, w} = Window.take(w, 60)
{b, w} = Window.take(w, 60)
{c, w} = Window.take(w, 60)
assert {a, b, c} == {60, 40, 0}
assert Window.available(w) == 0
end
end

describe "grant/2" do
test "extends available credit when a window_adjust arrives" do
w = Window.new(100) |> Window.grant(250)
assert Window.available(w) == 350
end

test "clamps the total to the protocol ceiling so a peer can't grow it unbounded" do
w = Window.new(@max) |> Window.grant(1)
assert Window.available(w) == @max
end

test "a single oversized grant is clamped to the ceiling" do
w = Window.new(0) |> Window.grant(@max * 5)
assert Window.available(w) == @max
end

test "granting zero leaves the window unchanged" do
assert Window.available(Window.grant(Window.new(100), 0)) == 100
end
end

describe "take/grant interplay" do
test "a drained window refills on grant and can be taken from again" do
w = Window.new(100)
{100, w} = Window.take(w, 100)
assert Window.available(w) == 0

w = Window.grant(w, 50)
{taken, w} = Window.take(w, 80)
assert taken == 50
assert Window.available(w) == 0
end
end
end
Loading