Project · after Level 4

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:7878 and serves files from a site/ folder next to your project.
  • Answers GET requests: / serves site/index.html, /style.css serves that file, and friendly URLs work (/about finds about.html).
  • Sends the right Content-Type for 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.toml must 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.
🧭 What even is HTTP? Less than you fear. Your browser opens a TCP connection and writes text like 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.

Stretch goals

Make it yours

  • A custom 404 page. Serve site/404.html if 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_ROOT at its docs/ folder, and your server is serving the entire Rusty School locally. Full circle. 🦀🧡
🎓 Workshop complete Nine projects: a game, four real tools, a study app, a parser, a world, and a web server. Nobody handed you the code; you built it from specs, like the job. Whatever you make next does not need a hint ladder. Go make it, and then teach someone else: that is what this school is for.