Build the Server 🚢
The website you are reading is served by a web server written in Rust with zero dependencies. For your capstone, you will build it: a real HTTP server, from a raw TCP socket up, in about a hundred lines of std. When your browser renders a page your code served, you are a systems programmer.
📋 The spec
Build a static-file web server that:
- Listens on
127.0.0.1:7878and serves files from asite/folder next to your project. - Answers
GETrequests:/servessite/index.html,/style.cssserves that file, and friendly URLs work (/aboutfindsabout.html). - Sends the right
Content-Typefor at least html, css, js, svg, and png. - Returns 404 for missing files and 405 for non-GET methods.
- Refuses path traversal: a request for
/../Cargo.tomlmust never escape the site folder. This is a real security requirement; treat it like one. - Handles each connection on its own thread.
- Has unit tests for path resolution (including the sneaky paths) and MIME types.
GET /index.html HTTP/1.1 plus some headers. The server writes
back a status line, headers, a blank line, and the file bytes. That's it.
That's the web. You already know sockets exist (this site's Lesson 16
threads them); today you speak the protocol yourself.
The new toys: TcpListener and TcpStream
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:7878").expect("port taken?");
for stream in listener.incoming() {
// each Ok(stream) is one browser connection
}
A TcpStream is a two-way pipe of bytes: wrap it in a
BufReader to read the request line by line
(Project 3's file skills, pointed at a socket), and call
write_all to send bytes back. Everything else in this project
is code you have written before: match, Option,
PathBuf, threads, tests.
Hints, in order of desperation
Hint 1: The shape of the whole server
fn main()
-> bind the listener, loop over incoming(),
thread::spawn(move || handle_connection(stream))
fn handle_connection(stream)
-> read the request line, split into method + path
405 if not GET, else resolve() the path
200 with the file, or 404
fn resolve(raw_path) -> Option<PathBuf>
-> the safety-critical part: URL path to file path, or None
fn mime_type(path) -> &'static str
fn respond(stream, status, mime, body)
Five functions. Write them in this order and test
resolve before ever binding a socket: it is pure and
needs no network.
Hint 2: Reading the request line
use std::io::{BufRead, BufReader};
let reader = BufReader::new(&stream);
let request_line = match reader.lines().next() {
Some(Ok(line)) => line, // "GET /about.html HTTP/1.1"
_ => return, // browser hung up; nothing to do
};
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("");
let raw_path = parts.next().unwrap_or("/");
You only need the first line. Browsers send more headers, but a static file server can ignore every one of them.
Hint 3: resolve(), the part that matters
fn resolve(raw_path: &str) -> Option<PathBuf> {
let path = raw_path.split(['?', '#']).next().unwrap_or("/");
// the security rule: never let a path escape the site folder
if path.contains("..") {
return None;
}
let mut file_path = PathBuf::from(SITE_ROOT);
for segment in path.split('/').filter(|s| !s.is_empty()) {
file_path.push(segment);
}
if file_path.is_dir() {
file_path.push("index.html");
}
if file_path.extension().is_none() {
file_path.set_extension("html");
}
if file_path.is_file() { Some(file_path) } else { None }
}
Read the .. check again and imagine the server without
it: GET /../../home/you/.ssh/id_rsa. Path traversal is one
of the oldest, most exploited bugs on the internet, and your test suite
should prove yours is closed forever.
Hint 4: Writing a response
Status line, headers, blank line, body. \r\n endings are
part of the protocol:
fn respond(stream: &mut TcpStream, status: &str, mime: &str, body: &[u8]) {
let header = format!(
"HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {}\r\n\r\n",
body.len()
);
stream.write_all(header.as_bytes()).ok();
stream.write_all(body).ok();
}
Why .ok() instead of expect? If the browser
already disconnected, the write fails and there is nobody left to tell.
Deliberately ignoring an error is fine when you can say why;
that sentence is the difference between sloppy and considered.
Hint 5: The full reference solution
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::thread;
const ADDRESS: &str = "127.0.0.1:7878";
const SITE_ROOT: &str = "site";
fn main() {
let listener = TcpListener::bind(ADDRESS)
.expect("could not bind to port 7878. Is another server running?");
println!("🦀 serving {SITE_ROOT}/ at http://{ADDRESS} (Ctrl+C to stop)");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
thread::spawn(move || handle_connection(stream));
}
Err(e) => eprintln!("connection failed: {e}"),
}
}
}
fn handle_connection(mut stream: TcpStream) {
let reader = BufReader::new(&stream);
// First line of a request: GET /about.html HTTP/1.1
let request_line = match reader.lines().next() {
Some(Ok(line)) => line,
_ => return,
};
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("");
let raw_path = parts.next().unwrap_or("/");
if method != "GET" {
respond(&mut stream, "405 Method Not Allowed", "text/plain", b"405");
return;
}
match resolve(raw_path) {
Some(path) => match fs::read(&path) {
Ok(body) => respond(&mut stream, "200 OK", mime_type(&path), &body),
Err(_) => not_found(&mut stream),
},
None => not_found(&mut stream),
}
}
/// Turn a URL path into a safe file path inside SITE_ROOT, or None.
fn resolve(raw_path: &str) -> Option<PathBuf> {
let path = raw_path.split(['?', '#']).next().unwrap_or("/");
// the security rule: never let a path escape the site folder
if path.contains("..") {
return None;
}
let mut file_path = PathBuf::from(SITE_ROOT);
for segment in path.split('/').filter(|s| !s.is_empty()) {
file_path.push(segment);
}
if file_path.is_dir() {
file_path.push("index.html");
}
if file_path.extension().is_none() {
file_path.set_extension("html");
}
if file_path.is_file() { Some(file_path) } else { None }
}
fn mime_type(path: &PathBuf) -> &'static str {
match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
"html" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "text/javascript; charset=utf-8",
"svg" => "image/svg+xml",
"png" => "image/png",
_ => "application/octet-stream",
}
}
fn respond(stream: &mut TcpStream, status: &str, mime: &str, body: &[u8]) {
let header = format!(
"HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {}\r\n\r\n",
body.len()
);
stream.write_all(header.as_bytes()).ok();
stream.write_all(body).ok();
}
fn not_found(stream: &mut TcpStream) {
let body = b"<h1>404</h1><p>not found</p>";
respond(stream, "404 Not Found", "text/html; charset=utf-8", body);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sneaky_paths_are_rejected() {
assert_eq!(resolve("/../secret.txt"), None);
assert_eq!(resolve("/a/../../etc/passwd"), None);
}
#[test]
fn mime_types_match_extensions() {
assert_eq!(mime_type(&PathBuf::from("site/x.css")), "text/css; charset=utf-8");
assert_eq!(mime_type(&PathBuf::from("site/x.zzz")), "application/octet-stream");
}
}
Make a site/ folder with an index.html saying
anything, cargo run, and open
http://127.0.0.1:7878. That page in your browser came
through code you wrote, byte by byte. Take the moment. 🏔️
After you build it: read the real one
This site's production server is the same design with a few more comforts: a custom 404 page, more MIME types, and doc comments mapping every technique to the lesson that taught it. Read it side by side with yours: src/main.rs on GitHub. Where you made different choices, ask which is better. Sometimes it will be yours; that is the day the training wheels come off.
Make it yours
- A custom 404 page. Serve
site/404.htmlif it exists, with a plain fallback if not (the production server does this). - Request logging. Print method, path, status, and response size for every request: instant server logs.
- A thread pool. Thread-per-connection is honest but unbounded. Chapter 21 of the Rust Book builds a pool; yours is the perfect codebase to follow along with.
- HEAD support. Same headers as GET, no body. One match arm.
- Serve this school. Clone the site repo, point
SITE_ROOTat itsdocs/folder, and your server is serving the entire Rusty School locally. Full circle. 🦀🧡