first commit

This commit is contained in:
2025-11-09 01:06:25 +02:00
commit dbaa2feb48
28 changed files with 1551 additions and 0 deletions

1
stdsrv/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target

9
stdsrv/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "stdsrv"
version = "0.1.0"
edition = "2024"
# local dependencies
[dependencies]
cracked_md = { path = "../cracked_md" }
fstools = { path = "../fstools" }

58
stdsrv/src/args.rs Normal file
View File

@@ -0,0 +1,58 @@
use std::path::PathBuf;
use crate::error::Error;
use crate::error::ErrorKind;
pub struct ProgramArgs {
pub outdir: PathBuf,
pub indir: PathBuf,
pub generate: bool,
pub force: bool,
pub addr: String,
}
impl Default for ProgramArgs {
fn default() -> Self {
Self {
indir: PathBuf::from("./web"),
outdir: PathBuf::from("./html"),
generate: false,
force: false,
addr: "0.0.0.0:8080".to_string(),
}
}
}
impl TryFrom<std::env::Args> for ProgramArgs {
type Error = crate::error::Error;
fn try_from(mut value: std::env::Args) -> Result<Self, Self::Error> {
let mut a = Self::default();
let _ = value.next(); // ignore executable path
while let Some(v) = value.next() {
match v.as_str() {
"-i" => {
a.indir = value
.next()
.ok_or(Error::new(
ErrorKind::CommandLineArgsParse,
"Expected input directory after option `-i`",
))?
.into();
}
"-a" => {
a.addr = value.next().ok_or(Error::new(
ErrorKind::CommandLineArgsParse,
"Expected listener address after option `-a`",
))?;
}
"-g" => a.generate = true,
"-f" => a.force = true,
_ => {
a.outdir = v.into();
}
}
}
Ok(a)
}
}

53
stdsrv/src/error.rs Normal file
View File

@@ -0,0 +1,53 @@
//! Custom Error type and Result enum and their most standard trait implementations.
use std::fmt::{Debug, Display};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
#[allow(dead_code)]
pub enum ErrorKind {
StreamReadFailed,
CommandLineArgsParse,
UnsupportedHttpMethod,
UnsupportedHttpVersion,
RequestParse,
FileNotFound,
DirNotFound,
Io,
TcpBind,
NotImplemented,
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct Error {
kind: ErrorKind,
msg: String,
}
impl Error {
pub fn new(kind: ErrorKind, msg: &str) -> Self {
Self {
kind,
msg: msg.to_string(),
}
}
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}: {}", self.kind, self.msg)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self {
kind: ErrorKind::Io,
msg: value.to_string(),
}
}
}

74
stdsrv/src/fileserver.rs Normal file
View File

@@ -0,0 +1,74 @@
//! FileServer
use std::fs;
use std::path::PathBuf;
use crate::{
error::*,
request::{HttpMethod, HttpRequest},
responder::Responder,
response::{HttpResponse, HttpStatus},
};
pub struct FileServer {
root: PathBuf,
}
impl FileServer {
pub fn new(root: &PathBuf) -> Result<FileServer> {
if !root.is_dir() {
return Err(Error::new(
ErrorKind::DirNotFound,
&root.display().to_string(),
));
}
Ok(Self {
root: root.to_owned(),
})
}
pub fn get_contents(&self, path: PathBuf) -> Option<Vec<u8>> {
let mut fullpath = self.root.as_path().join(path);
// default to index.html
if fullpath.is_dir() {
fullpath.push("index.html");
}
fs::read(fullpath).ok()
}
}
impl Responder for FileServer {
fn respond(&self, req: HttpRequest) -> HttpResponse {
if req.version != "HTTP/1.1" {
return HttpResponse::new_empty(HttpStatus::HTTPVersionNotSupported);
}
if req.method != HttpMethod::GET {
return HttpResponse::new_empty(HttpStatus::MethodNotAllowed);
}
let content_type = match req.path.extension() {
Some(s) => match s.as_encoded_bytes() {
b"html" | b"htm" => "text/html",
b"txt" => "text/plain",
b"css" => "text/css",
b"js" => "text/javascript",
b"pdf" => "application/pdf",
b"json" => "application/json",
b"xml" => "application/xml",
b"gif" => "image/gif",
b"jpeg" | b"jpg" => "image/jpg",
b"png" => "image/png",
_ => "text/plain",
},
None => "text/html",
};
if let Some(content) = self.get_contents(req.path) {
HttpResponse::new(HttpStatus::Ok, content).add_header("Content-Type", content_type)
} else {
HttpResponse::new_empty(HttpStatus::NotFound).add_header("Connection", "close")
}
}
}

37
stdsrv/src/http_header.rs Normal file
View File

@@ -0,0 +1,37 @@
use std::{collections::HashMap, fmt::Display};
#[derive(Debug)]
pub struct HttpHeaders {
_inner: HashMap<String, String>,
}
impl HttpHeaders {
pub fn new() -> Self {
HttpHeaders {
_inner: HashMap::new(),
}
}
pub fn add(&mut self, k: &str, v: &str) {
self._inner.insert(k.to_string(), v.to_string());
}
#[cfg(test)]
pub fn get(&self, k: &str) -> Option<&String> {
self._inner.get(k)
}
#[cfg(test)]
pub fn len(&self) -> usize {
self._inner.len()
}
}
impl Display for HttpHeaders {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (k, v) in self._inner.iter() {
write!(f, "{}: {}\r\n", k, v)?;
}
Ok(())
}
}

43
stdsrv/src/http_stream.rs Normal file
View File

@@ -0,0 +1,43 @@
use crate::error::ErrorKind;
use crate::log;
use crate::logger::Level;
use std::io::Read;
use std::net::{TcpListener, TcpStream};
use crate::{error::Error, request::HttpRequest};
pub struct HttpStream {
tcp_listener: TcpListener,
}
impl HttpStream {
pub fn new(addr: &str) -> Self {
let tcp_listener = TcpListener::bind(addr)
.unwrap_or_else(|e| panic!("Failed to bind on address `{}`: {}", addr, e));
log!(Level::Info, "Listening on `{}`", addr);
Self { tcp_listener }
}
}
impl Iterator for HttpStream {
type Item = (HttpRequest, TcpStream);
fn next(&mut self) -> Option<Self::Item> {
// safe to unwrap, because Incoming never returns None
let mut stream = self.tcp_listener.incoming().next().unwrap().ok()?;
let mut buf = [0; 1024];
let _read_bytes = stream
.read(&mut buf)
.or(Err(Error::new(
ErrorKind::StreamReadFailed,
"Reading from TCP stream failed",
)))
.ok()?;
Some((
String::from_utf8_lossy(&buf[..]).trim().try_into().ok()?,
stream,
))
}
}

39
stdsrv/src/logger.rs Normal file
View File

@@ -0,0 +1,39 @@
use std::fmt::Display;
#[derive(Debug)]
#[allow(dead_code)]
pub enum Level {
Error,
Warn,
Info,
Debug,
}
impl Display for Level {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}]\x1b[0m",
match self {
Self::Error => "\x1b[1;31m[ERROR",
Self::Warn => "\x1b[1;33m[WARN",
Self::Info => "\x1b[0;32m[INFO",
Self::Debug => "\x1b[0;36m[DEBUG",
}
)
}
}
#[macro_export]
macro_rules! log {
($level:expr, $($arg:tt)*) => {{
println!(
"{} {}:{}:{}: {}",
$level,
std::module_path!(),
std::file!(),
std::line!(),
format!($($arg)*)
);
}};
}

88
stdsrv/src/main.rs Normal file
View File

@@ -0,0 +1,88 @@
//! This is my very monolithic file server with zero dependencies (other than rust
//! stdlib).
//!
//! Documentation is a work in progress, go see my webpage at [jlux.dev](https://jlux.dev).
use std::{
io::{BufReader, BufWriter},
net::TcpListener,
process,
};
use args::ProgramArgs;
use cracked_md::generate;
use fileserver::FileServer;
use logger::Level;
use request::HttpRequest;
use responder::Responder;
mod args;
mod error;
mod fileserver;
mod http_header;
//mod http_stream;
mod logger;
mod request;
mod responder;
mod response;
/// Entrypoint to the program.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: ProgramArgs = std::env::args().try_into()?;
if args.generate {
match generate(&args.indir, &args.outdir, args.force) {
Ok(_) => log!(
Level::Info,
"HTML generation from `{}` to `{}` successful",
args.indir.display(),
args.outdir.display()
),
Err(cracked_md::Error::OutDirIsNotEmpty) => {
log!(
Level::Error,
"HTML generation failed, run `rm -r {}` and retry",
args.outdir.display()
);
process::exit(1);
}
Err(e) => {
log!(Level::Error, "HTML generation failed with error: {}", e,);
process::exit(1);
}
};
}
let listener = TcpListener::bind(&args.addr)?;
log!(Level::Info, "Listening on addr `{}`", &args.addr);
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let outdir = args.outdir.to_owned();
std::thread::spawn(move || {
log!(Level::Debug, "TcpStream handler spawned");
let mut reader = BufReader::new(&stream);
let mut writer = BufWriter::new(&stream);
let server = match FileServer::new(&outdir) {
Ok(s) => s,
Err(_e) => return,
};
while let Ok(req) = HttpRequest::try_from(&mut reader) {
let _ = server
.respond(req)
.add_header("Server", "stdsrv")
.add_header("Connection", "keep-alive")
.send(&mut writer)
.map_err(|e| log!(Level::Error, "{}", e));
}
log!(Level::Debug, "TcpStream handler exited");
});
}
Err(e) => log!(Level::Warn, "Connection failed: {}", e),
}
}
Ok(())
}

243
stdsrv/src/request.rs Normal file
View File

@@ -0,0 +1,243 @@
//! An abstraction layer for parsing HTTP requests. As simple and high level as I managed.
use crate::error::{Error, ErrorKind, Result};
use crate::http_header::HttpHeaders;
use crate::log;
use crate::logger::Level;
use std::fmt::Display;
use std::io::{BufRead, BufReader};
use std::net::TcpStream;
use std::path::PathBuf;
/// Supports just GET methods for now.
#[derive(Debug, PartialEq)]
#[allow(dead_code, clippy::upper_case_acronyms)]
pub enum HttpMethod {
GET,
//PUT,
//POST,
}
impl TryFrom<&str> for HttpMethod {
type Error = Error;
fn try_from(value: &str) -> Result<Self> {
match value {
"GET" => Ok(Self::GET),
//"PUT" => Ok(Self::PUT),
//"POST" => Ok(Self::POST),
_ => Err(Error::new(ErrorKind::UnsupportedHttpMethod, value)),
}
}
}
impl Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
/// Type for HTTP requests.
#[derive(Debug)]
#[allow(dead_code)]
pub struct HttpRequest {
pub method: HttpMethod,
pub path: PathBuf,
pub version: String,
pub headers: HttpHeaders,
}
impl Display for HttpRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} {} {}\n{}",
self.method,
self.path.display(),
self.version,
self.headers
)
}
}
impl TryFrom<&mut BufReader<&TcpStream>> for HttpRequest {
type Error = Error;
fn try_from(value: &mut BufReader<&TcpStream>) -> std::result::Result<Self, Self::Error> {
let mut request_line = String::new();
value
.read_line(&mut request_line)
.map_err(|_e| Error::new(ErrorKind::RequestParse, "expected request line"))?;
let mut parts = request_line.split_whitespace();
let method: HttpMethod = parts
.next()
.ok_or(Error::new(
ErrorKind::RequestParse,
"expected request method",
))?
.try_into()?;
let path_with_prefix: PathBuf = parts
.next()
.ok_or(Error::new(ErrorKind::RequestParse, "expected request path"))?
.into();
let path = if path_with_prefix.starts_with("/") {
path_with_prefix.strip_prefix("/").unwrap().into()
} else {
path_with_prefix
};
let version = parts
.next()
.ok_or(Error::new(
ErrorKind::RequestParse,
"expected request version",
))?
.into();
let mut req = Self {
method,
path,
version,
headers: HttpHeaders::new(),
};
loop {
let mut line = String::new();
value.read_line(&mut line)?;
if line == "\r\n" || line.is_empty() {
break;
}
if let Some((header, val)) = line.split_once(": ") {
req.headers.add(header, val);
}
}
Ok(req)
}
}
impl TryFrom<&str> for HttpRequest {
type Error = Error;
fn try_from(s: &str) -> Result<Self> {
let mut lines = s.split("\r\n");
let request_line = lines
.next()
.ok_or(Error::new(ErrorKind::RequestParse, "expected request line"))?;
let mut parts = request_line.split_whitespace();
let method = parts
.next()
.ok_or(Error::new(
ErrorKind::RequestParse,
"expected request method",
))?
.try_into()?;
let path_with_prefix: PathBuf = parts
.next()
.ok_or(Error::new(ErrorKind::RequestParse, "expected request path"))?
.into();
let path = if path_with_prefix.starts_with("/") {
path_with_prefix.strip_prefix("/").unwrap().into()
} else {
path_with_prefix
};
let version = parts
.next()
.ok_or(Error::new(
ErrorKind::RequestParse,
"expected request version",
))?
.into();
let mut headers = HttpHeaders::new();
for line in lines {
if let Some(v) = line.split_once(": ") {
headers.add(v.0, v.1)
}
}
let req = Self {
method,
path,
version,
headers,
};
log!(Level::Info, "\n{}", req);
Ok(req)
}
}
// ------------------------------
// TESTS
// ------------------------------
#[cfg(test)]
mod request_test {
use super::*;
#[test]
fn http_parse_method_get() {
let s = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n";
let req: HttpRequest = s.try_into().unwrap();
assert_eq!(req.method, HttpMethod::GET);
}
#[test]
fn http_parse_path_indexhtml() {
let s = "GET /index.html HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n";
let req: HttpRequest = s.try_into().unwrap();
assert_eq!(req.path, PathBuf::from("index.html"));
}
#[test]
fn http_parse_version() {
let s = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n";
let req: HttpRequest = s.try_into().unwrap();
assert_eq!(req.version, "HTTP/1.1");
}
#[test]
fn http_parse_headers_len() {
let s = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n";
let req: HttpRequest = s.try_into().unwrap();
assert_eq!(req.headers.len(), 3);
}
#[test]
fn http_parse_useragent_header_curl() {
let s = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n";
let req: HttpRequest = s.try_into().unwrap();
assert_eq!(req.headers.get("User-Agent").unwrap(), "curl/8.14.1");
}
#[test]
fn http_parse_empty_should_fail() {
let s = "";
let req: Result<HttpRequest> = s.try_into();
assert!(req.is_err());
}
#[test]
fn http_parse_unsupported_method_delete() {
let s = "DELETE / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n";
let req: Result<HttpRequest> = s.try_into();
assert!(req.is_err());
}
#[test]
fn http_method_display() {
let method = HttpMethod::GET;
assert_eq!(method.to_string(), "GET")
}
}

55
stdsrv/src/responder.rs Normal file
View File

@@ -0,0 +1,55 @@
//! Traits helping HTTP connections
use crate::request::HttpRequest;
use crate::response::HttpResponse;
/// Responder trait. Just a respond method that turns a HttpRequest to a HttpResponse.
pub trait Responder {
fn respond(&self, req: HttpRequest) -> HttpResponse;
}
/*
/// Size trait. Number of bytes when encoded.
pub trait Size {
fn size(&self) -> usize;
}
// Standard implementations for Size trait
impl Size for u8 {
fn size(&self) -> usize {
1
}
}
impl<T> Size for Vec<T>
where
T: Size,
{
fn size(&self) -> usize {
if let Some(elem) = self.first() {
elem.size() * self.len()
} else {
0
}
}
}
impl<T> Size for Option<T>
where
T: Size,
{
fn size(&self) -> usize {
match self {
Some(t) => t.size(),
None => 0,
}
}
}
impl Size for String {
fn size(&self) -> usize {
self.len()
}
}
*/

189
stdsrv/src/response.rs Normal file
View File

@@ -0,0 +1,189 @@
//! An abstraction layer for building and sending HTTP responses.
use crate::error::Result;
use crate::http_header::HttpHeaders;
use crate::log;
use crate::logger::Level;
use std::{fmt::Display, io::Write};
/// Macro for generating Http status codes (AI generated).
macro_rules! http_statuses {
($($name:ident => ($code:expr, $reason:expr)),+ $(,)?) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(unused_attributes, dead_code)]
pub enum HttpStatus {
$($name),+
}
impl HttpStatus {
pub fn code(&self) -> u16 {
match self {
$(HttpStatus::$name => $code,)+
}
}
pub fn reason(&self) -> &'static str {
match self {
$(HttpStatus::$name => $reason,)+
}
}
}
};
}
http_statuses!(
Ok => (200, "OK"),
NotFound => (404, "Not Found"),
MethodNotAllowed => (405, "Method Not Allowed"),
ImATeapot => (418, "I'm a teapot"),
InternalServerError => (500, "Internal Server Error"),
HTTPVersionNotSupported => (505, "HTTP Version Not Supported"),
);
impl Display for HttpStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.code(), self.reason())
}
}
/// HTTP response structure
#[derive(Debug)]
pub struct HttpResponse {
version: String,
status: HttpStatus,
headers: HttpHeaders,
body: Option<Vec<u8>>,
}
/*
impl Size for HttpResponse {
fn size(&self) -> usize {
self.as_bytes().len()
}
}
*/
impl HttpResponse {
pub fn new_empty(status: HttpStatus) -> Self {
let mut headers = HttpHeaders::new();
headers.add("Content-Length", "0");
Self {
version: "HTTP/1.1".into(),
status,
headers,
body: None,
}
}
pub fn new(status: HttpStatus, body: Vec<u8>) -> Self {
let mut headers = HttpHeaders::new();
headers.add("Content-Length", &body.len().to_string());
Self {
version: "HTTP/1.1".into(),
status,
headers,
body: Some(body),
}
}
pub fn add_header(mut self, key: &str, value: &str) -> Self {
self.headers.add(key, value);
self
}
fn add_header_inner(&mut self, key: &str, value: &str) {
self.headers.add(key, value);
}
/// sending images "cuts off"
pub fn send(&mut self, stream: &mut impl Write) -> Result<()> {
let body_len = match &self.body {
Some(v) => format!("{}", v.len()),
None => "0".to_string(),
};
self.add_header_inner("Content-Length", &body_len);
stream.write_all(self.start_bytes())?;
if let Some(b) = &self.body {
stream.write_all(b)?;
}
//stream.write_all(b"\r\n")?;
stream.flush()?;
//sleep(Duration::from_millis(100));
/*
stream.shutdown(std::net::Shutdown::Write)?;
// hack?
let _ = std::io::Read::read(stream, &mut [0u8; 1]);
*/
log!(Level::Info, "\n{}", &self);
Ok(())
}
fn start_bytes(&self) -> &[u8] {
let mut bytes: Vec<u8> = Vec::new();
bytes.extend_from_slice(self.version.as_bytes());
bytes.extend_from_slice(b" ");
bytes.extend_from_slice(self.status.to_string().as_bytes());
bytes.extend_from_slice(b"\r\n");
bytes.extend_from_slice(self.headers.to_string().as_bytes());
bytes.extend_from_slice(b"\r\n");
bytes.leak()
}
}
impl Display for HttpResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} {}\r\n{}\r\n",
self.version, self.status, self.headers,
)?;
if let Some(s) = &self.body {
write!(
f,
"{}\r\n",
String::from_utf8(s.to_vec()).unwrap_or("<binary data>".to_string())
)?;
}
Ok(())
}
}
// --------------------
// TESTS
// --------------------
#[cfg(test)]
mod response_test {
use super::*;
#[test]
fn http_status_macro_display() {
let stat = HttpStatus::ImATeapot;
assert_eq!(stat.to_string(), "418 I'm a teapot");
}
#[test]
fn http_response_new_empty() {
let resp = HttpResponse::new_empty(HttpStatus::ImATeapot);
assert_eq!(
resp.to_string(),
"HTTP/1.1 418 I'm a teapot\r\nContent-Length: 0\r\n\r\n"
);
}
#[test]
fn http_response_new_with_body() {
let resp = HttpResponse::new(HttpStatus::ImATeapot, b"teapot".into());
assert_eq!(
resp.to_string(),
"HTTP/1.1 418 I'm a teapot\r\nContent-Length: 6\r\n\r\nteapot\r\n"
);
}
}