diff --git a/src/ewe.gleam b/src/ewe.gleam index 1856a9b..16ddd39 100644 --- a/src/ewe.gleam +++ b/src/ewe.gleam @@ -1,3 +1,5 @@ +import ewe/internal/connection +import ewe/internal/handler as handler_ import gleam/erlang/process import gleam/http import gleam/http/request @@ -115,7 +117,10 @@ pub opaque type Builder { tls: Option(TlsConfig), listener_name: process.Name(listener.Message), connection_factory_name: process.Name( - factory.Message(socket.Socket, process.Subject(handler.Message(Nil))), + factory.Message( + socket.Socket, + process.Subject(handler.Message(connection.Message)), + ), ), on_start: fn(http.Scheme, SocketAddress) -> Nil, ) @@ -128,7 +133,10 @@ pub opaque type Builder { pub fn new( listener_name listener_name: process.Name(listener.Message), connection_factory_name connection_factory_name: process.Name( - factory.Message(socket.Socket, process.Subject(handler.Message(Nil))), + factory.Message( + socket.Socket, + process.Subject(handler.Message(connection.Message)), + ), ), handler handler: fn(request.Request(Connection)) -> response.Response(Body), ) { @@ -258,8 +266,8 @@ pub fn start( glisten.new( listener_name: builder.listener_name, connection_factory_name: builder.connection_factory_name, - on_init: todo, - loop: todo, + on_init: handler_.on_init, + loop: handler_.loop, ) let pool = case builder.tls { diff --git a/src/ewe/internal/connection.gleam b/src/ewe/internal/connection.gleam new file mode 100644 index 0000000..1bf80a8 --- /dev/null +++ b/src/ewe/internal/connection.gleam @@ -0,0 +1,18 @@ +import gleam/erlang/process +import glisten/internal/handler +import glisten/socket +import glisten/transport + +pub type Connection { + Http1( + transport: transport.Transport, + socket: socket.Socket, + self: process.Subject(handler.Message(Message)), + buffer: BitArray, + ) + Http2 +} + +pub type Message { + Timeout +} diff --git a/src/ewe/internal/ewe_ffi.erl b/src/ewe/internal/ewe_ffi.erl index 5d0eb71..b53dd36 100644 --- a/src/ewe/internal/ewe_ffi.erl +++ b/src/ewe/internal/ewe_ffi.erl @@ -30,4 +30,4 @@ ensure_http_date_table() -> end; _ -> ?MODULE - end. \ No newline at end of file + end. diff --git a/src/ewe/internal/handler.gleam b/src/ewe/internal/handler.gleam index 704efc0..f4a8eea 100644 --- a/src/ewe/internal/handler.gleam +++ b/src/ewe/internal/handler.gleam @@ -1,17 +1,96 @@ +import ewe/internal/connection +import ewe/internal/http1 +import gleam/bit_array import gleam/erlang/process import gleam/option import glisten +/// The state of a connection for its entire lifetime. It starts at +/// `Initialised`, is classified into `Http1` or `Http2` and then stays in that +/// variant until the connection closes. pub type State { - Initialised(self: process.Subject(Nil)) - Http1(idle_timer: option.Option(process.Timer), self: process.Subject(Nil)) + /// Accumulates bytes in `buffer` until `sniff_preface` can tell HTTP/1.x + /// apart from an HTTP/2 prior-knowledge preface. + Initialised(buffer: BitArray) + Http1(http1.State) + /// No HTTP/2 connection handling exists yet! + Http2 } -pub fn on_init(_connection: glisten.Connection(Nil)) { - let self = process.new_subject() - let selector = - process.new_selector() - |> process.select(self) +pub fn on_init( + _connection: glisten.Connection(connection.Message), +) -> #(State, option.Option(process.Selector(connection.Message))) { + #(Initialised(buffer: <<>>), option.None) +} + +pub fn loop( + state: State, + message: glisten.Message(connection.Message), + connection: glisten.Connection(connection.Message), +) -> glisten.Next(State, glisten.Message(connection.Message)) { + case message { + glisten.User(connection.Timeout) -> todo as "Timeout not implemented yet" + glisten.Packet(data) -> + case state { + Initialised(buffer:) -> classify(<>, connection) + Http1(..) -> todo as "HTTP/1.x connection loop not implemented yet" + Http2(..) -> todo as "HTTP/2 connection handling not implemented yet" + } + } +} + +// Runs the preface sniff exactly once, on the accumulated bytes from +// `Initialised`, and hands off to whichever protocol state it resolves to. +fn classify( + buffer: BitArray, + connection: glisten.Connection(connection.Message), +) -> glisten.Next(State, glisten.Message(connection.Message)) { + case sniff_preface(buffer) { + NeedMoreData -> glisten.continue(Initialised(buffer:)) + Http2Preface(_remaining) -> glisten.continue(Http2) + NotHttp2(buffer:) -> { + let connection = + connection.Http1( + connection.transport, + connection.socket, + connection.subject, + buffer: <<>>, + ) + + let next = + http1.State(buffer:, idle_timer: option.None) + |> http1.handle_message(connection) + + case next { + http1.Continue(state) -> glisten.continue(Http1(state)) + http1.Close -> glisten.stop() + } + } + } +} + +pub type Sniff { + /// Not enough bytes yet to decide. + NeedMoreData + /// The 24-byte preface matched fully. `remaining` is whatever followed it, + /// i.e. the client's initial SETTINGS frame. + Http2Preface(remaining: BitArray) + /// Diverged from the preface. + NotHttp2(buffer: BitArray) +} + +const preface = <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n":utf8>> - #(Initialised(self:), option.Some(selector)) +/// Distinguishes an HTTP/2 prior-knowledge preface (RFC 9113 §3.4) from +/// everything else. Checked once per connection. +pub fn sniff_preface(buffer: BitArray) -> Sniff { + case buffer { + <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n":utf8, remaining:bits>> -> + Http2Preface(remaining:) + _buffer -> + case bit_array.starts_with(preface, buffer) { + True -> NeedMoreData + False -> NotHttp2(buffer:) + } + } } diff --git a/src/ewe/internal/http1.gleam b/src/ewe/internal/http1.gleam index 94b9bdd..467c59c 100644 --- a/src/ewe/internal/http1.gleam +++ b/src/ewe/internal/http1.gleam @@ -1,9 +1,24 @@ +import ewe/internal/connection import gleam/bit_array +import gleam/erlang/process import gleam/http import gleam/int import gleam/list import gleam/option +pub type State { + State(buffer: BitArray, idle_timer: option.Option(process.Timer)) +} + +pub type Next { + Continue(State) + Close +} + +pub fn handle_message(state: State, connection: connection.Connection) -> Next { + Continue(state) +} + pub type Version { Http10 Http11 @@ -26,7 +41,12 @@ pub type Head { /// Cheap conclusions drawn from `Head.headers` in one pass. pub type Metadata { - Metadata(content_length: option.Option(Int), chunked: Bool, keep_alive: Bool) + Metadata( + content_length: option.Option(Int), + chunked: Bool, + keep_alive: Bool, + upgrade: option.Option(String), + ) } pub type ParseError { @@ -43,6 +63,7 @@ pub type ParseError { DuplicateHost BadHost MissingHost + AmbiguousFraming } pub type Parsed { @@ -78,9 +99,11 @@ pub fn parse(buffer: BitArray) -> Parsed { state.host, )) + use metadata <- try_step(resolve_metadata(state, version)) + Done(Complete( Head(method:, host:, port:, path:, query:, version:, headers:), - state.metadata, + metadata, remaining, )) } @@ -340,26 +363,52 @@ fn parse_port_digits(bits: BitArray, acc: Int) -> Result(Int, Nil) { } } -// Threaded through `parse_headers`. `metadata` mirrors `Head.headers` in -// one pass, `host` holds the `Host` header once seen. +// Threaded through `parse_headers`. type HeaderState { HeaderState( - metadata: Metadata, + content_length: option.Option(Int), + chunked: Bool, + connection: option.Option(Bool), + connection_upgrade: Bool, + upgrade: option.Option(String), host: option.Option(#(String, option.Option(Int))), ) } fn initial_header_state() -> HeaderState { HeaderState( - metadata: Metadata( - content_length: option.None, - chunked: False, - keep_alive: True, - ), + content_length: option.None, + chunked: False, + connection: option.None, + connection_upgrade: False, + upgrade: option.None, host: option.None, ) } +// HTTP/1.1 connections default to persistent, HTTP/1.0 ones default to +// closing (RFC 9112 §9.3); an explicit `Connection` header overrides +// either default. A message framed by both `Content-Length` and +// `Transfer-Encoding` is rejected outright, since the two disagree on +// where the body ends. (RFC 9112 §6.1). +fn resolve_metadata(state: HeaderState, version: Version) -> Step(Metadata) { + case state.content_length, state.chunked { + option.Some(_length), True -> ParseError(AmbiguousFraming) + content_length, chunked -> { + let keep_alive = case state.connection, version { + option.Some(keep_alive), _version -> keep_alive + option.None, Http11 -> True + option.None, Http10 -> False + } + let upgrade = case state.connection_upgrade { + True -> state.upgrade + False -> option.None + } + Done(Metadata(content_length:, chunked:, keep_alive:, upgrade:)) + } + } +} + fn parse_headers( buffer: BitArray, acc: List(#(String, String)), @@ -408,37 +457,51 @@ fn parse_header_line( } } -// Updates `metadata` and `host` from a single already decoded header. +// Updates `content_length`, `chunked`, `connection`, `connection_upgrade`, +// `upgrade`, and `host` from a single already decoded header. fn classify( name: String, value: String, state: HeaderState, ) -> Step(HeaderState) { - let meta = state.metadata case name { "content-length" -> - case meta.content_length { + case state.content_length { option.Some(_length) -> ParseError(DuplicateContentLength) option.None -> case int.parse(value) { - Ok(length) if length >= 0 -> { - let content_length = option.Some(length) - let metadata = Metadata(..meta, content_length:) - Done(HeaderState(..state, metadata:)) - } + Ok(length) if length >= 0 -> + Done(HeaderState(..state, content_length: option.Some(length))) _bad -> ParseError(BadContentLength) } } "transfer-encoding" -> { let lowered = value |> bit_array.from_string |> lowercase_ascii - let chunked = meta.chunked || lowered == <<"chunked":utf8>> - Done(HeaderState(..state, metadata: Metadata(..meta, chunked:))) + let chunked = state.chunked || has_token(lowered, <<"chunked":utf8>>) + Done(HeaderState(..state, chunked:)) } "connection" -> { let lowered = value |> bit_array.from_string |> lowercase_ascii - let closing = has_token(lowered, <<"close":utf8>>) - HeaderState(..state, metadata: Metadata(..meta, keep_alive: !closing)) - |> Done + let connection = case has_token(lowered, <<"close":utf8>>) { + True -> option.Some(False) + False -> + case has_token(lowered, <<"keep-alive":utf8>>) { + True -> option.Some(True) + False -> state.connection + } + } + let connection_upgrade = + state.connection_upgrade || has_token(lowered, <<"upgrade":utf8>>) + Done(HeaderState(..state, connection:, connection_upgrade:)) + } + "upgrade" -> { + // ASCII-lowered bytes of a validated `String` are valid UTF-8. + let lowered = + value + |> bit_array.from_string + |> lowercase_ascii + |> unsafe_to_string + Done(HeaderState(..state, upgrade: option.Some(lowered))) } "host" -> case state.host { @@ -554,8 +617,8 @@ fn trim_trailing_ows(bits: BitArray) -> BitArray { 0 -> bits size -> case bits { - <> -> trim_trailing_ows(init) - <> -> trim_trailing_ows(init) + <> -> trim_trailing_ows(init) + <> -> trim_trailing_ows(init) _bits -> bits } } diff --git a/test/ewe/internal/handler_test.gleam b/test/ewe/internal/handler_test.gleam new file mode 100644 index 0000000..e1171c3 --- /dev/null +++ b/test/ewe/internal/handler_test.gleam @@ -0,0 +1,35 @@ +import ewe/internal/handler + +pub fn full_preface_in_one_read_test() { + let buffer = <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n":utf8>> + assert handler.sniff_preface(buffer) == handler.Http2Preface(<<>>) +} + +pub fn preface_with_trailing_settings_frame_test() { + let buffer = <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n":utf8, 0, 0, 0, 4, 0>> + let assert handler.Http2Preface(remaining) = handler.sniff_preface(buffer) + + assert remaining == <<0, 0, 0, 4, 0>> +} + +pub fn preface_split_across_reads_test() { + let first = <<"PRI * HTTP/2":utf8>> + assert handler.sniff_preface(first) == handler.NeedMoreData + + let second = <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n":utf8>> + assert handler.sniff_preface(second) == handler.Http2Preface(<<>>) +} + +pub fn empty_buffer_needs_more_data_test() { + assert handler.sniff_preface(<<>>) == handler.NeedMoreData +} + +pub fn ordinary_http1_request_diverges_immediately_test() { + let buffer = <<"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n":utf8>> + assert handler.sniff_preface(buffer) == handler.NotHttp2(buffer) +} + +pub fn preface_lookalike_diverging_partway_test() { + let buffer = <<"PRI / HTTP/1.1\r\n\r\n":utf8>> + assert handler.sniff_preface(buffer) == handler.NotHttp2(buffer) +} diff --git a/test/ewe/internal/http1_test.gleam b/test/ewe/internal/http1_test.gleam index 34abbbf..4643e17 100644 --- a/test/ewe/internal/http1_test.gleam +++ b/test/ewe/internal/http1_test.gleam @@ -107,6 +107,16 @@ pub fn mixed_case_and_ows_headers_test() { assert remaining == <<"HELLO WORLD!!":utf8>> } +pub fn tab_ows_trimmed_test() { + let buffer = << + "GET / HTTP/1.1\r\nHost: example.com\r\nX-Name:\t\tvalue\t\t\r\n\r\n":utf8, + >> + + let assert http1.Complete(head, _metadata, _remaining) = http1.parse(buffer) + + assert head.headers == [#("host", "example.com"), #("x-name", "value")] +} + pub fn chunked_transfer_encoding_test() { let buffer = << "PUT /x HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n":utf8, @@ -117,6 +127,32 @@ pub fn chunked_transfer_encoding_test() { assert metadata.chunked } +pub fn transfer_encoding_chunked_among_multiple_tokens_test() { + let buffer = << + "PUT /x HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: gzip, chunked\r\n\r\n":utf8, + >> + + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert metadata.chunked +} + +pub fn conflicting_content_length_and_chunked_rejected_test() { + let buffer = << + "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\nhello":utf8, + >> + + assert http1.parse(buffer) == http1.Failed(http1.AmbiguousFraming) +} + +pub fn conflicting_transfer_encoding_and_content_length_reversed_order_rejected_test() { + let buffer = << + "POST / HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\n\r\nhello":utf8, + >> + + assert http1.parse(buffer) == http1.Failed(http1.AmbiguousFraming) +} + pub fn connection_close_lookalike_is_not_close_test() { let buffer = << "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close-enough\r\n\r\n":utf8, @@ -137,6 +173,35 @@ pub fn connection_close_among_multiple_tokens_test() { assert !metadata.keep_alive } +pub fn http11_defaults_to_keep_alive_test() { + let buffer = <<"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n":utf8>> + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert metadata.keep_alive + as "HTTP/1.1 without Connection defaults to keep-alive" +} + +pub fn http10_defaults_to_close_test() { + let buffer = <<"GET / HTTP/1.0\r\n\r\n":utf8>> + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert !metadata.keep_alive as "HTTP/1.0 without Connection defaults to close" +} + +pub fn http10_explicit_keep_alive_test() { + let buffer = <<"GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n":utf8>> + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert metadata.keep_alive +} + +pub fn http10_explicit_close_stays_close_test() { + let buffer = <<"GET / HTTP/1.0\r\nConnection: close\r\n\r\n":utf8>> + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert !metadata.keep_alive +} + pub fn custom_method_test() { let buffer = <<"PROPFIND /dav HTTP/1.1\r\nHost: example.com\r\n\r\n":utf8>> let assert http1.Complete(head, _metadata, _remaining) = http1.parse(buffer) @@ -215,7 +280,71 @@ pub fn no_query_string_test() { version: http1.Http11, headers: [#("host", "example.com")], ), - http1.Metadata(content_length: None, chunked: False, keep_alive: True), + http1.Metadata( + content_length: None, + chunked: False, + keep_alive: True, + upgrade: None, + ), <<>>, ) } + +pub fn websocket_upgrade_requested_test() { + let buffer = << + "GET /ws HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n":utf8, + >> + + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert metadata.upgrade == Some("websocket") +} + +pub fn upgrade_token_case_insensitive_test() { + let buffer = << + "GET /ws HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: WebSocket\r\n\r\n":utf8, + >> + + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert metadata.upgrade == Some("websocket") +} + +pub fn upgrade_among_multiple_connection_tokens_test() { + let buffer = << + "GET /h2c HTTP/1.1\r\nHost: example.com\r\nConnection: keep-alive, Upgrade\r\nUpgrade: h2c\r\n\r\n":utf8, + >> + + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert metadata.upgrade == Some("h2c") +} + +pub fn upgrade_header_without_connection_token_ignored_test() { + let buffer = << + "GET /ws HTTP/1.1\r\nHost: example.com\r\nUpgrade: websocket\r\n\r\n":utf8, + >> + + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert metadata.upgrade == None + as "Upgrade requires Connection: upgrade to be honored (RFC 9110 §7.8)" +} + +pub fn upgrade_header_before_connection_header_test() { + let buffer = << + "GET /ws HTTP/1.1\r\nHost: example.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n":utf8, + >> + + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert metadata.upgrade == Some("websocket") + as "order of Upgrade vs. Connection headers shouldn't matter" +} + +pub fn no_upgrade_requested_test() { + let buffer = <<"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n":utf8>> + let assert http1.Complete(_head, metadata, _remaining) = http1.parse(buffer) + + assert metadata.upgrade == None +}