commit fd85c12da3f357464767498d1c16a412a02d279a Author: soup Date: Thu Oct 10 00:34:40 2024 -0400 [uhttp] recv working diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..64fb825 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "shelves" +version = "0.1.0" +dependencies = [ + "uhttp", +] + +[[package]] +name = "uhttp" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c21e4ec --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +members = [ + "crates/uhttp" +] +resolver = "2" + +[package] +name = "shelves" +version = "0.1.0" +edition = "2021" + +[dependencies] +uhttp = { path = "crates/uhttp" } diff --git a/crates/uhttp/Cargo.toml b/crates/uhttp/Cargo.toml new file mode 100644 index 0000000..2057426 --- /dev/null +++ b/crates/uhttp/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "uhttp" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/crates/uhttp/README.md b/crates/uhttp/README.md new file mode 100644 index 0000000..fd638f6 --- /dev/null +++ b/crates/uhttp/README.md @@ -0,0 +1,27 @@ +# uhttp + +A small allocation-free, sans-IO HTTP implementation + +## Goals + +1. Enable development of HTTP-speaking applications +2. Simple to understand codebase +3. Fast to compile + +## Non-goals + +1. Ultimate speed + +## Scope +### HTTP/1.1 +??? + +### HTTP/2 +??? + +## Non-scope +### HTTP/1.1 +??? + +### HTTP/2 +??? diff --git a/crates/uhttp/src/common.rs b/crates/uhttp/src/common.rs new file mode 100644 index 0000000..fa34fed --- /dev/null +++ b/crates/uhttp/src/common.rs @@ -0,0 +1,41 @@ +use crate::Error; + +pub enum Eat { + NeedMoreData, + Consumed { + amt: usize, + result: Result, + }, +} +impl Eat { + pub fn map(self, f: impl FnOnce(T) -> U) -> Eat { + match self { + Eat::NeedMoreData => Eat::NeedMoreData, + Eat::Consumed { + amt, + result: Err(e), + } => Eat::Consumed { + amt, + result: Err(e), + }, + Eat::Consumed { amt, result: Ok(v) } => Eat::Consumed { + amt, + result: Ok(f(v)), + }, + } + } + + pub fn and_then(self, f: impl FnOnce(usize, T) -> Eat) -> Eat { + match self { + Eat::NeedMoreData => Eat::NeedMoreData, + Eat::Consumed { + amt, + result: Err(e), + } => Eat::Consumed { + amt, + result: Err(e), + }, + Eat::Consumed { amt, result: Ok(v) } => f(amt, v), + } + } +} diff --git a/crates/uhttp/src/lib.rs b/crates/uhttp/src/lib.rs new file mode 100644 index 0000000..dbd45dc --- /dev/null +++ b/crates/uhttp/src/lib.rs @@ -0,0 +1,182 @@ +#![no_std] +pub mod common; +pub mod parse; +pub mod parts; + +pub use common::*; +pub use parts::*; + +#[derive(Debug)] +pub enum Error { + InvalidConnectionState, + Parse(&'static str), + TrailingBytes, +} + +#[derive(Default, Debug)] +pub enum Event<'a> { + #[default] + Empty, + RequestLine(RequestLine<'a>), + Header(Header<'a>), + HeadersEnd, + BodyChunk(&'a [u8]), + Done, +} + +#[derive(Debug)] +pub enum Role { + Client, + Server, +} + +#[derive(Debug, Copy, Clone)] +enum BodyState { + ContentLength(usize), + Chunked, +} + +#[derive(Debug, Copy, Clone)] +enum StateRecv { + RequestLine, + Headers(Option), + Body(BodyState), + ValidateDone, +} + +#[derive(Debug, Copy, Clone)] +enum StateSend { + TODO, +} + +#[derive(Debug, Copy, Clone)] +enum StateConnection { + Recv(StateRecv), + Send(StateSend), +} + +#[derive(Debug)] +pub struct Connection { + role: Role, + state: StateConnection, +} +impl Connection { + pub fn new(role: Role) -> Self { + let state = match role { + Role::Server => StateConnection::Recv(StateRecv::RequestLine), + Role::Client => StateConnection::Send(StateSend::TODO), + }; + + Self { state, role } + } + + pub fn is_sending(&self) -> bool { + matches!(self.state, StateConnection::Send(_)) + } + + pub fn is_recving(&self) -> bool { + matches!(self.state, StateConnection::Recv(_)) + } + + pub fn handle_recv<'a>(&mut self, bytes: &'a [u8]) -> Eat> { + let recv = match &self.state { + StateConnection::Recv(r) => r, + _ => { + return Eat::Consumed { + amt: 0, + result: Err(Error::InvalidConnectionState), + } + }, + }; + + let n = match recv { + StateRecv::RequestLine => parse::request_line(bytes).map(|rl| { + ( + Event::RequestLine(rl), + StateConnection::Recv(StateRecv::Headers(None)), + ) + }), + StateRecv::Headers(body_state) => { + if bytes.starts_with(b"\r\n") { + Eat::Consumed { + amt: 2, + result: Ok(( + Event::HeadersEnd, + match body_state { + None => StateConnection::Send(StateSend::TODO), + Some(b) => { + StateConnection::Recv(StateRecv::Body(*b)) + }, + }, + )), + } + } else { + parse::header(bytes).map(|h| { + let b = match h { + Header::Special( + HeaderSpecial::TransferEncodingChunked, + ) => Some(BodyState::Chunked), + Header::Special(HeaderSpecial::ContentLength( + c, + )) => Some(BodyState::ContentLength(c)), + _ => *body_state, + }; + + ( + Event::Header(h), + StateConnection::Recv(StateRecv::Headers(b)), + ) + }) + } + }, + StateRecv::Body(body_state) => match body_state { + BodyState::ContentLength(remaining) => { + if bytes.len() < *remaining { + Eat::Consumed { + amt: bytes.len(), + result: Ok(( + Event::BodyChunk(bytes), + StateConnection::Recv(StateRecv::Body( + BodyState::ContentLength( + remaining - bytes.len(), + ), + )), + )), + } + } else { + Eat::Consumed { + amt: *remaining, + result: Ok(( + Event::BodyChunk(bytes), + StateConnection::Recv(StateRecv::ValidateDone), + )), + } + } + }, + _ => todo!(), + }, + StateRecv::ValidateDone => { + if bytes.is_empty() { + Eat::Consumed { + amt: 0, + result: Ok(( + Event::Done, + StateConnection::Send(StateSend::TODO), + )), + } + } else { + return Eat::Consumed { + amt: 0, + result: Err(Error::TrailingBytes), + }; + } + }, + }; + + n.map(|(ev, next_state)| { + self.state = next_state; + + ev + }) + } +} diff --git a/crates/uhttp/src/parse.rs b/crates/uhttp/src/parse.rs new file mode 100644 index 0000000..f577f45 --- /dev/null +++ b/crates/uhttp/src/parse.rs @@ -0,0 +1,133 @@ +use crate::{ + common::Eat, + parts::{RequestLine, Version}, + Error, Header, HeaderOther, HeaderSpecial, +}; + +pub type Parse = Eat; + +pub fn split_crlf(d: &[u8]) -> Option<(&[u8], usize)> { + let p = d.windows(2).position(|w| w == b"\r\n")?; + + Some((&d[..p], p + 2)) +} + +pub fn request_line(d: &[u8]) -> Parse { + let (line, amt) = match split_crlf(d) { + Some(l) => l, + None => { + return Parse::NeedMoreData; + }, + }; + + let mut it = line + .split(|b| b.is_ascii_whitespace()) + .filter(|bs| !bs.is_empty()); + + let (method, target, version) = match (it.next(), it.next(), it.next()) { + (Some(m), Some(t), Some(v)) => (m, t, v), + _ => { + return Parse::Consumed { + amt, + result: Err(Error::Parse( + "request line doesn't have required number of elements", + )), + } + }, + }; + + let method = match core::str::from_utf8(method) { + Ok(m) => m, + _ => { + return Parse::Consumed { + amt, + result: Err(Error::Parse("expected method to be ascii")), + } + }, + }; + + let target = match core::str::from_utf8(target) { + Ok(m) => m, + _ => { + return Parse::Consumed { + amt, + result: Err(Error::Parse("expected target to be ascii")), + } + }, + }; + + let version = match () { + _ if version.eq_ignore_ascii_case(b"http/1.1") => Version::HTTP1_1, + _ => { + return Parse::Consumed { + amt, + result: Err(Error::Parse("unknown http version")), + } + }, + }; + + Parse::Consumed { + amt, + result: Ok(RequestLine { + method, + target, + version, + }), + } +} + +pub fn header(d: &[u8]) -> Parse
{ + let (line, amt) = match split_crlf(d) { + Some(l) => l, + None => { + return Parse::NeedMoreData; + }, + }; + + let mut it = line + .split(|b| b.is_ascii_whitespace()) + .filter(|bs| !bs.is_empty()); + + let (name, value) = match (it.next(), it.next()) { + (Some(n), Some(v)) => (n, v), + _ => { + return Parse::Consumed { + amt, + result: Err(Error::Parse( + "header doesn't have required number of elements", + )), + } + }, + }; + + let name = match core::str::from_utf8(name) { + Ok(m) => m, + _ => { + return Parse::Consumed { + amt, + result: Err(Error::Parse("expected target to be ascii")), + } + }, + }; + let name = name.split_once(":").map(|(f, _)| f).unwrap_or(name); + + let h = match () { + _ if name.eq_ignore_ascii_case("transfer-encoding") + && value.eq_ignore_ascii_case(b"chunked") => + { + Header::Special(HeaderSpecial::TransferEncodingChunked) + }, + _ if name.eq_ignore_ascii_case("content-length") => { + match core::str::from_utf8(value) + .ok() + .and_then(|s| s.parse().ok()) + { + Some(v) => Header::Special(HeaderSpecial::ContentLength(v)), + _ => Header::Other(HeaderOther { name, value }), + } + }, + _ => Header::Other(HeaderOther { name, value }), + }; + + Parse::Consumed { amt, result: Ok(h) } +} diff --git a/crates/uhttp/src/parts.rs b/crates/uhttp/src/parts.rs new file mode 100644 index 0000000..0005879 --- /dev/null +++ b/crates/uhttp/src/parts.rs @@ -0,0 +1,30 @@ +#[derive(Debug)] +pub enum Version { + HTTP1_1, +} + +#[derive(Debug)] +pub struct RequestLine<'a> { + pub method: &'a str, + pub target: &'a str, + pub version: Version, +} + +/// Headers that impact the state of the connection +#[derive(Debug)] +pub enum HeaderSpecial { + TransferEncodingChunked, + ContentLength(usize), +} + +#[derive(Debug)] +pub struct HeaderOther<'a> { + pub name: &'a str, + pub value: &'a [u8], +} + +#[derive(Debug)] +pub enum Header<'a> { + Special(HeaderSpecial), + Other(HeaderOther<'a>), +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..bd6e71e --- /dev/null +++ b/flake.lock @@ -0,0 +1,165 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1728344376, + "narHash": "sha256-lxTce2XE6mfJH8Zk6yBbqsbu9/jpwdymbSH5cCbiVOA=", + "owner": "ipetkov", + "repo": "crane", + "rev": "fd86b78f5f35f712c72147427b1eb81a9bd55d0b", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "deno-flake": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 0, + "narHash": "sha256-RsFYFS4k28JneAcEQO8ITXqIXKyBIa8FIKEPfz3NJHA=", + "type": "git", + "url": "file:///home/n/src/deno-flake" + }, + "original": { + "type": "git", + "url": "file:///home/n/src/deno-flake" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1728455642, + "narHash": "sha256-abYGwrL6ak5sBRqwPh+V3CPJ6Pa89p378t51b7BO1lE=", + "owner": "nix-community", + "repo": "fenix", + "rev": "3b47535a5c782e4f4ad59cd4bdb23636b6926e03", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1725930920, + "narHash": "sha256-RVhD9hnlTT2nJzPHlAqrWqCkA7T6CYrP41IoVRkciZM=", + "path": "/nix/store/20yis5w6g397plssim663hqxdiiah2wr-source", + "rev": "44a71ff39c182edaf25a7ace5c9454e7cba2c658", + "type": "path" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "crane": "crane", + "deno-flake": "deno-flake", + "fenix": "fenix", + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1728386838, + "narHash": "sha256-Lk64EoJkvp3WMGVJK3CR1TYcNghX0/BqHPLW5zdvmLE=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "efaf8bd5de34e2f47bd57425b83e0c7974902176", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..61d666b --- /dev/null +++ b/flake.nix @@ -0,0 +1,46 @@ +{ + inputs.nixpkgs.url = "nixpkgs"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + inputs.deno-flake = { + url = "git+file:///home/n/src/deno-flake"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + inputs.fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + inputs.crane = { + url = "github:ipetkov/crane"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + + outputs = i: + i.flake-utils.lib.eachDefaultSystem (system: + let + pkgs = i.nixpkgs.legacyPackages.${system}; + pkgsDeno = i.deno-flake.packages.${system}; + pkgsFenix = i.fenix.packages.${system}; + nightly = pkgsFenix.default; + stable = pkgsFenix.stable; + in { + devShells.default = pkgs.mkShell { + packages = [ + pkgsDeno.deno-latest + (pkgs.sqlite.override { interactive = true; }) + pkgs.unzip + pkgs.entr + pkgs.gnumake + pkgs.fd + + (pkgsFenix.combine [ + (stable.withComponents [ + "cargo" "rustc" "rust-src" "rust-analyzer" "clippy" + ]) + nightly.rustfmt + ]) + pkgs.cargo-watch + ]; + }; + }); +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..7000591 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2021" +hard_tabs = true +match_block_trailing_comma = true +max_width = 80 +empty_item_single_line = false diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8477007 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,34 @@ +use std::{error::Error, io::Read, net::TcpListener}; + +fn main() -> Result<(), Box> { + let listener = TcpListener::bind("127.0.0.1:8089")?; + loop { + let (mut stream, _) = listener.accept()?; + let mut buf = vec![0; 1024]; + + let mut conn = uhttp::Connection::new(uhttp::Role::Server); + + let end = stream.read(&mut buf)?; + let mut data = &buf[..end]; + + loop { + dbg!(&conn, data); + match conn.handle_recv(data) { + uhttp::Eat::NeedMoreData => todo!(), + uhttp::Eat::Consumed { amt, result } => match result { + Ok(ev) => match ev { + uhttp::Event::Done => break, + _ => { + dbg!(ev); + data = &data[amt..]; + }, + }, + + Err(e) => { + dbg!(e); + }, + }, + } + } + } +}