shrimple_localhost/
lib.rs

1//! Zero-dependency simple synchronous localhost server.
2//! The 2 ways to use the library are:
3//! - [`serve_current_dir`], [`serve`], [`serve_current_dir_at`], [`serve_at`] functions, the simpler approach.
4//! - [`Server`] struct, the more complex approach.
5//!
6//! If inspecting incoming connections & requests is needed (e.g. for logging), the 2nd approach
7//! will be better, otherwise the 1st one will be easier.
8
9mod mime;
10
11use std::{
12    convert::Infallible,
13    env::current_dir,
14    fmt::{Display, Formatter},
15    fs::File,
16    io::{BufRead, BufReader, Cursor, Error, ErrorKind, Read, Seek, SeekFrom, Write},
17    net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream},
18    path::{Component, Path, PathBuf},
19    time::UNIX_EPOCH,
20};
21use mime::path_to_mime_type;
22
23fn relative_path_components(path: &Path) -> impl Iterator<Item = impl AsRef<Path> + '_> {
24    path.components().filter_map(|comp| if let Component::Normal(r) = comp {
25        Some(r)
26    } else {
27        None
28    })
29}
30
31type Result<T = (), E = std::io::Error> = std::result::Result<T, E>;
32
33/// Data associated with a successful request.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Request {
36    /// Data that was sent to the client.
37    pub sent: Response,
38    /// Value of the `If-None-Match` header
39    etag: Option<u64>,
40}
41
42impl Request {
43    /// Returns a boolean indicating whether the client relied on their own cached version of the
44    /// file;
45    ///
46    /// `true` means that the client provided an ETag that matched the current version of the file,
47    /// and expectedly received a 304 "Not Modified" response without the contents of the file.
48    pub fn client_cache_reused(&self) -> bool {
49        self.etag.is_some()
50    }
51}
52
53/// Server's response to a request.
54///
55/// To be emitted by a function provided to [`Server::serve_with_callback`].
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum Response {
58    /// Path to a local file to be sent.
59    Path(PathBuf),
60    /// Literal data to be sent.
61    ///
62    /// The MIME type of the data is assumed to be the same data as that of the file requested by
63    /// the client.
64    Data(Vec<u8>),
65}
66
67/// Made from [`Response`] to be sent to the client. Implements [`Read`].
68enum ResponseReader<'response> {
69    Path(File),
70    Data(Cursor<&'response [u8]>),
71}
72
73macro_rules! fwd {
74    { $(fn $name:ident (&mut $self:ident $(,)? $($arg:ident: $argty:ty),*) -> $ret:ty;)+ } => {
75        $(
76            fn $name(&mut $self, $($arg: $argty),*) -> $ret {
77                match $self {
78                    ResponseReader::Path(file) => file.$name($($arg),*),
79                    ResponseReader::Data(file) => file.$name($($arg),*),
80                }
81            }
82        )+
83    }
84}
85
86impl Read for ResponseReader<'_> {
87    fwd! {
88        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
89        fn read_exact(&mut self, buf: &mut [u8]) -> std::io::Result<()>;
90        fn read_to_end(&mut self, buf: &mut Vec<u8>) -> std::io::Result<usize>;
91        fn read_vectored(&mut self, bufs: &mut [std::io::IoSliceMut<'_>]) -> std::io::Result<usize>;
92    }
93}
94
95impl Seek for ResponseReader<'_> {
96    fwd! {
97        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64>;
98        fn rewind(&mut self) -> std::io::Result<()>;
99        fn seek_relative(&mut self, offset: i64) -> std::io::Result<()>;
100        fn stream_position(&mut self) -> std::io::Result<u64>;
101    }
102}
103
104impl From<PathBuf> for Response {
105    fn from(value: PathBuf) -> Self {
106        Self::Path(value)
107    }
108}
109
110impl Response {
111    /// Return a Displayable version of the response.
112    ///
113    /// If response data was fetched from the filesystem, the path is printed via
114    /// [`Path::display`].
115    ///
116    /// Otherwise, "&lt;in-memory&gt;" is printed.
117    pub fn display(&self) -> impl Display + '_ {
118        struct ResponseDisplay<'path>(Option<std::path::Display<'path>>);
119
120        impl Display for ResponseDisplay<'_> {
121            fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
122                match &self.0 {
123                    Some(display) => write!(f, "{display}"),
124                    None => f.write_str("<in-memory>"),
125                }
126            }
127        }
128
129        ResponseDisplay(match self {
130            Response::Path(path) => Some(path.display()),
131            Response::Data(_) => None,
132        })
133    }
134
135    fn etag(&self) -> Result<Option<u64>> {
136        Ok(match self {
137            Self::Path(path) => path.metadata()?.modified()?
138                .duration_since(UNIX_EPOCH)
139                .map_err(|_| Error::other(format!("mtime of {path:?} is before the Unix epoch")))?
140                .as_secs()
141                .into(),
142            Self::Data(_) => None,
143        })
144    }
145
146    fn to_reader(&self) -> Result<ResponseReader<'_>> {
147        Ok(match self {
148            Self::Path(path) => ResponseReader::Path(File::open(path)?),
149            Self::Data(vec) => ResponseReader::Data(Cursor::new(vec)),
150        })
151    }
152}
153
154/// The result of a request.
155/// This doesn't report IO errors, since in a case of such error no request is registered.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum RequestResult {
158    /// Everything went normally and the client received a 200 response
159    Ok(Request),
160    /// Unsupported or invalid HTTP method was provided in the request.
161    ///
162    /// This crate only supports GET requests.
163    InvalidHttpMethod,
164    /// No path was provided in the request.
165    NoRequestedPath,
166    /// Unsupported HTTP version provided in the request.
167    ///
168    /// This crate only supports HTTP/1.1
169    InvalidHttpVersion,
170    /// One of the headers in the request was invalid.
171    ///
172    /// At the moment, this only triggers on an invalid `If-None-Match` header, the server ignores
173    /// all other headers.
174    InvalidHeader,
175    /// Request file does not exist or is outside the root of the server.
176    /// 
177    /// Contained is the path as requested by the client ("/" is replaced with "/index.html")
178    FileNotFound(Box<str>),
179}
180
181/// Error returned by [`Server::try_serve_with_callback`] that differentiates between an IO error from
182/// within the server and an error propagated from a callback.
183#[derive(Debug)]
184pub enum ServerError<E> {
185    Io(std::io::Error),
186    Callback(E),
187}
188
189impl<E: Display> Display for ServerError<E> {
190    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
191        match self {
192            ServerError::Io(err) => write!(f, "IO error: {err}"),
193            ServerError::Callback(err) => Display::fmt(err, f),
194        }
195    }
196}
197
198impl<E> From<std::io::Error> for ServerError<E> {
199    fn from(value: std::io::Error) -> Self {
200        Self::Io(value)
201    }
202}
203
204impl<E: std::error::Error> std::error::Error for ServerError<E> {}
205
206/// Server for serving files locally
207pub struct Server {
208    root: PathBuf,
209    listener: TcpListener,
210    line_buf: String,
211    misc_buf: String,
212}
213
214impl Server {
215    /// Chosen by fair dice roll;
216    /// Guaranteed to be random.
217    pub const DEFAULT_PORT: u16 = 6969;
218
219    /// Create a new server for listening to HTTP connections at port [`Server::DEFAULT_PORT`]
220    /// and serving files from the current directory.
221    /// <br /> If a custom port needs to be provided, use [`Server::new_at`];
222    /// <br /> If a custom root needs to be provided, use [`Server::new`].
223    pub fn current_dir() -> Result<Self> {
224        Self::_new(current_dir()?, Self::DEFAULT_PORT)
225    }
226
227    /// Create a new server for listening to HTTP connections at port [`Server::DEFAULT_PORT`]
228    /// and serving files from `root`.
229    /// <br /> If a custom port needs to be provided, use [`Server::new_at`];
230    /// <br /> If `root` is only ever supposed to be the current directory, use [`Server::current_dir`].
231    pub fn new(root: impl AsRef<Path>) -> Result<Self> {
232        Self::_new(root.as_ref().canonicalize()?, Self::DEFAULT_PORT)
233    }
234
235    /// Create a new server for listening to HTTP connections at port `port`
236    /// and serving files from the current directory.
237    /// <br /> If it doesn't matter what port is used, use [`Server::current_dir`];
238    /// <br /> If a custom root needs to be provided, use [`Server::new_at`].
239    pub fn current_dir_at(port: u16) -> Result<Self> {
240        Self::_new(current_dir()?, port)
241    }
242
243    /// Create a new server for listening to HTTP connections at `addr`
244    /// and serving files from `root`.
245    /// <br /> If it doesn't matter what port is used, use [`Server::new`];
246    /// <br /> If `root` is only ever supposed to be the current directory, use [`Server::current_dir_at`]
247    pub fn new_at(root: impl AsRef<Path>, port: u16) -> Result<Self> {
248        Self::_new(root.as_ref().canonicalize()?, port)
249    }
250
251    fn _new(root: PathBuf, port: u16) -> Result<Self> {
252        Ok(Self {
253            root,
254            listener: TcpListener::bind((Ipv4Addr::LOCALHOST, port))?,
255            line_buf: String::new(),
256            misc_buf: String::new(),
257        })
258    }
259
260    fn read_http_line(reader: &mut impl BufRead, dst: &mut String) -> Result<()> {
261        dst.clear();
262        reader.read_line(dst)?;
263        if dst.pop() == Some('\n') && dst.ends_with('\r') {
264            dst.pop();
265        }
266        Ok(())
267    }
268
269    /// The boolean indicates whether the file was actually sent or if the client already has 
270    /// the current version of the file in their cache
271    fn send_file(
272        &self,
273        mut dst: impl Write,
274        data: &Response,
275        content_type: &'static str,
276        etag_to_match: Option<u64>,
277    ) -> Result<bool> {
278        let etag = data.etag()?;
279        if let Some(etag) = etag.filter(|&x| Some(x) == etag_to_match) {
280            write!(dst, "HTTP/1.1 304 Not Modified\r\n\
281                                Connection: close\r\n\
282                                ETag: \"{etag:x}\"\r\n\
283                                Cache-Control: public; must-revalidate\r\n\
284                                \r\n")?;
285            return Ok(false)
286        }
287        let mut file = data.to_reader()?;
288        let content_length = file.seek(SeekFrom::End(0))?;
289        file.rewind()?;
290        write!(dst, "HTTP/1.1 200 OK\r\n\
291                     Connection: close\r\n\
292                     Content-Type: {content_type}\r\n\
293                     Content-Length: {content_length}\r\n\
294                     Cache-Control: public; must-revalidate\r\n")?;
295        if let Some(etag) = etag {
296            write!(dst, "ETag: \"{etag:x}\"\r\n")?;
297        }
298        write!(dst, "\r\n")?;
299        std::io::copy(&mut file, &mut dst)?;
300        Ok(true)
301    }
302
303    fn send_400(mut dst: impl Write) -> Result {
304        write!(dst, "HTTP/1.1 400 Bad Request\r\n\
305                     Connection: close\r\n\
306                     \r\n")
307    }
308
309    fn send_404(mut dst: impl Write) -> Result {
310        write!(dst, "HTTP/1.1 404 Not Found\r\n\
311                     Connection: close\r\n\
312                     Content-Type: text/html\r\n\
313                     Content-Length: 18\r\n\
314                     \r\n\
315                     <h1>Not Found</h1>")
316    }
317
318    /// This method only consumes the request-line.
319    fn respond<E>(
320        &mut self,
321        conn: &mut BufReader<TcpStream>,
322    ) -> Result<RequestResult, ServerError<E>> {
323        Self::read_http_line(conn, &mut self.line_buf)?;
324        let mut etag = None;
325        loop {
326            Self::read_http_line(conn, &mut self.misc_buf)?;
327            if let Some(etag_raw) = self.misc_buf.strip_prefix("If-None-Match: ") {
328                etag = match u64::from_str_radix(etag_raw.trim_matches('"'), 16) {
329                    Ok(x) => Some(x),
330                    Err(_) => return Ok(RequestResult::InvalidHeader),
331                }
332            } else if self.misc_buf.is_empty() {
333                break;
334            }
335        }
336
337        let Some(path_and_version) = self.line_buf.strip_prefix("GET ") else {
338            return Ok(RequestResult::InvalidHttpMethod)
339        };
340        let Some((path, http_version)) = path_and_version.split_once(' ') else {
341            return Ok(RequestResult::NoRequestedPath)
342        };
343        if http_version != "HTTP/1.1" {
344            return Ok(RequestResult::InvalidHttpVersion)
345        }
346        if path.contains("..") {
347            return Ok(RequestResult::FileNotFound(Box::from(path)))
348        }
349
350
351        let path = match path.split_once('?').map_or(path, |(path, _query)| path) {
352            "/" => "/index.html",
353            path => path,
354        };
355        let mut n_comps = 0usize;
356        self.root.extend(relative_path_components(path.as_ref()).inspect(|_| n_comps += 1));
357        if self.root.extension().is_none() {
358            self.root.set_extension("html");
359        }
360        let actual_path = self.root.canonicalize();
361        for _ in 0 .. n_comps {
362            self.root.pop();
363        }
364        let Ok(actual_path) = actual_path else {
365            return Ok(RequestResult::FileNotFound(Box::from(path)))
366        };
367
368        Ok(RequestResult::Ok(Request { sent: Response::Path(actual_path), etag }))
369    }
370
371    fn handle_conn<E>(
372        &mut self,
373        conn: TcpStream,
374        addr: &SocketAddr,
375        mut on_pending_request: impl FnMut(&SocketAddr, PathBuf) -> Result<Response, E>,
376        mut on_request: impl FnMut(&SocketAddr, RequestResult) -> Result<(), E>,
377    ) -> Result<(), ServerError<E>> {
378        let mut conn = BufReader::new(conn);
379
380        while match conn.get_ref().peek(&mut [0; 4]) {
381            Ok(n) => n > 0,
382            Err(err) => match err.kind() {
383                ErrorKind::ConnectionReset | ErrorKind::BrokenPipe => false,
384                _ => return Err(err.into()),
385            }
386        } {
387            let res = match self.respond(&mut conn) {
388                Ok(RequestResult::Ok(Request { sent, mut etag })) => {
389                    let Response::Path(path) = sent else {
390                        unreachable!("Server::respond returned in-memory data instead of path");
391                    };
392                    let content_type = path_to_mime_type(&path);
393                    let res = on_pending_request(addr, path).map_err(ServerError::Callback)?;
394                    if self.send_file(conn.get_mut(), &res, content_type, etag)? {
395                        etag = None;
396                    }
397                    RequestResult::Ok(Request { sent: res, etag })
398                }
399
400                Ok(RequestResult::FileNotFound(path)) => {
401                    Self::send_404(conn.get_mut())?;
402                    RequestResult::FileNotFound(path)
403                }
404
405                Ok(res @(| RequestResult::NoRequestedPath
406                         | RequestResult::InvalidHeader
407                         | RequestResult::InvalidHttpVersion
408                         | RequestResult::InvalidHttpMethod)) => {
409                    Self::send_400(conn.get_mut())?;
410                    res
411                }
412
413                Err(ServerError::Io(err)) if err.kind() == ErrorKind::ConnectionReset => break,
414
415                Err(err) => return Err(err),
416            };
417            on_request(addr, res).map_err(ServerError::Callback)?
418        }
419        Ok(())
420    }
421
422    /// Serve all connections sequentially & indefinitely, returning only on an error, calling:
423    ///
424    /// - `on_pending_request` when a new request is about to get a 200 response. The arguments to it are:
425    ///     - IP of the sender of the request;
426    ///     - Canonical path to the file that's about to be sent.
427    ///
428    /// It returns the data or the path to the file that's to be sent. To forward the choice of the
429    /// server, return the second argument.
430    ///
431    /// - `on_request` when a new request has been processed. The arguments to it are:
432    ///     - IP of the sender of the request;
433    ///     - Result of the request.
434    ///
435    /// This function allows callbacks to return errors & disambiguates server errors & callback
436    /// errors with the [`ServerError`] enum.
437    ///
438    /// If no such error propagation is needed, consider using [`Server::serve_with_callback`] <br/>
439    /// If no observation of connections/requests is needed, consider using [`Server::serve`]
440    pub fn try_serve_with_callback<E>(
441        &mut self,
442        mut on_pending_request: impl FnMut(&SocketAddr, PathBuf) -> Result<Response, E>,
443        mut on_request: impl FnMut(&SocketAddr, RequestResult) -> Result<(), E>,
444    ) -> Result<Infallible, ServerError<E>> {
445        loop {
446            let (conn, addr) = self.listener.accept()?;
447            self.handle_conn(
448                conn,
449                &addr,
450                &mut on_pending_request,
451                &mut on_request,
452            )?;
453        }
454    }
455
456    /// Serve all connections sequentially & indefinitely, returning only on an IO error, calling:
457    /// - `on_pending_request` when a new request is about to get a 200 response. The arguments to it are:
458    ///     - IP of the sender of the request;
459    ///     - Canonical path to the file on the machine.
460    ///
461    /// It returns the data or the path to the file that's to be sent. To forward the choice of the
462    /// server, return the second argument.
463    ///
464    /// - `on_request` when a new request has been processed. The arguments to it are:
465    ///     - IP of the sender of the request;
466    ///     - Result of the request.
467    ///
468    /// This function allows callbacks to return errors & disambiguates server errors & callback
469    /// errors with the [`ServerError`] enum.
470    ///
471    /// If no observation of connections/requests is needed, consider using [`Server::serve`] <br/>
472    /// If the callbacks have to return an error, consider using [`Server::try_serve_with_callback`]
473    pub fn serve_with_callback(
474        &mut self,
475        mut on_pending_request: impl FnMut(&SocketAddr, PathBuf) -> Response,
476        mut on_request: impl FnMut(&SocketAddr, RequestResult),
477    ) -> Result<Infallible> {
478        self.try_serve_with_callback::<Infallible>(
479            |addr, path| Ok(on_pending_request(addr, path)),
480            |addr, req| Ok(on_request(addr, req)),
481        ).map_err(|err| match err {
482            ServerError::Io(err) => err,
483            ServerError::Callback(err) => match err {},
484        })
485    }
486
487    /// Serve all connections sequentially & indefinitely, returning only on an error. <br />
488    /// Equivalent to [`serve`] function and the like.
489    /// 
490    /// If connections/requests need to be observed (e.g. logged), use
491    /// [`Server::serve_with_callback`]
492    pub fn serve(&mut self) -> Result<Infallible> {
493        self.serve_with_callback(|_, path| path.into(), |_, _| ())
494    }
495}
496
497/// Default function for printing the result of a request along with the IP from which it came.
498/// Can be passed to [`Server::serve_with_callback`].
499///
500/// ```rust, no_run
501/// use shrimple_localhost::{Server, print_request_result};
502/// use std::convert::Infallible;
503/// 
504/// fn main() -> std::io::Result<Infallible> {
505///     Server::current_dir()?.serve_with_callback(
506///         |_, _| todo!(),
507///         print_request_result,
508///     )
509/// }
510/// ```
511pub fn print_request_result(addr: &SocketAddr, res: RequestResult) {
512    match res {
513        RequestResult::Ok(req) if req.client_cache_reused() => 
514            println!("{addr}:\n -> GET {}\n <- 304 Not Modified", req.sent.display()),
515        RequestResult::Ok(req) => 
516            println!("{addr}:\n -> GET {}\n <- 200 OK", req.sent.display()),
517        RequestResult::InvalidHttpMethod =>
518            println!("{addr}:\n -> <invalid HTTP method>\n <- 400 Bad Request"),
519        RequestResult::NoRequestedPath => 
520            println!("{addr}:\n -> <no requested path>\n <- 400 Bad Request"),
521        RequestResult::InvalidHttpVersion =>
522            println!("{addr}:\n -> <invalid HTTP version>\n <- 400 Bad Request"),
523        RequestResult::InvalidHeader => 
524            println!("{addr}:\n -> <invalid header(s)>\n <- 400 Bad Request"),
525        RequestResult::FileNotFound(path) =>
526            println!("{addr}:\n -> GET {path}\n <- 404 Not Found"),
527    }
528}
529
530/// Serve files from the current directory at port [`Server::DEFAULT_PORT`]
531///
532/// - If a custom port needs to be provided, use [`serve_current_dir_at`]
533/// - If a custom root needs to be provided, use [`serve`]
534pub fn serve_current_dir() -> Result<Infallible> {
535    Server::current_dir()?.serve()
536}
537
538/// Serve files from `root` at port [`Server::DEFAULT_PORT`]
539///
540/// - If a custom port needs to be provided, use [`serve_at`]
541/// - If `root` is only ever supposed to be the current directory, use [`serve_current_dir`]
542pub fn serve(root: impl AsRef<Path>) -> Result<Infallible> {
543    Server::new(root)?.serve()
544}
545
546/// Serve files from `root` at port [`Server::DEFAULT_PORT`]
547///
548/// - If it doesn't matter what port is used, use [`serve_current_dir`]
549/// - If a custom root needs to be provided, use [`serve_at`]
550pub fn serve_current_dir_at(port: u16) -> Result<Infallible> {
551    Server::current_dir_at(port)?.serve()
552}
553
554/// Serve files from `root` at `addr`
555///
556/// - If it doesn't matter what port is used, use [`serve`]
557/// - If `root` is only ever supposed to be the current directory, use [`serve_current_dir_at`]
558pub fn serve_at(root: impl AsRef<Path>, port: u16) -> Result<Infallible> {
559    Server::new_at(root, port)?.serve()
560}