Chapter 15
File I/O & Static File Serving
Serve HTML, CSS, JavaScript, and images from disk. We'll read files synchronously and asynchronously, detect content types, and defend against directory traversal attacks.
Reading Files with std::fs
Rust's standard library has a straightforward filesystem module. The most common operations:
use std::fs;
use std::path::Path;
// Read an entire file into a String
let html = fs::read_to_string("public/index.html")?;
// Read an entire file into bytes (for binary files like images)
let bytes: Vec<u8> = fs::read("public/logo.png")?;
// Check if a file exists
let exists = Path::new("public/style.css").exists();
// Check if it's a file (not a directory)
let is_file = Path::new("public/style.css").is_file();
// Get file metadata (size, timestamps)
let metadata = fs::metadata("public/index.html")?;
println!("Size: {} bytes", metadata.len());
println!("Is directory: {}", metadata.is_dir());The Path and PathBuf Types
Just as Rust has String and &str for text, it has PathBuf (owned) and &Path (borrowed) for filesystem paths. They handle platform differences - forward slashes on Unix, backslashes on Windows - and provide useful methods:
use std::path::{Path, PathBuf};
// Build a path
let mut path = PathBuf::from("public");
path.push("css");
path.push("style.css");
// "public/css/style.css"
// Extract parts
let file_name = path.file_name(); // Some("style.css")
let extension = path.extension(); // Some("css")
let parent = path.parent(); // Some("public/css")
// Join paths (like path.push but returns a new PathBuf)
let full = Path::new("public").join("images").join("logo.png");
// "public/images/logo.png"
// Convert to/from strings
let path_str: &str = path.to_str().unwrap();
let from_str = PathBuf::from("/var/www/html");Path/PathBuf for file paths instead of plain strings. They handle OS-specific separators, provide useful methods like .extension(), and prevent subtle bugs when joining paths.Error Handling with File I/O
Every filesystem operation returns Result. The error type is std::io::Error, which we already handle with our ServerError::Io variant. Common error kinds:
use std::io::ErrorKind;
match fs::read_to_string("missing.txt") {
Ok(contents) => println!("{}", contents),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("File not found"),
ErrorKind::PermissionDenied => println!("Permission denied"),
_ => println!("I/O error: {}", e),
}
}Async File I/O with Tokio
In our async server, blocking the thread with std::fs::read would stall all other tasks on that thread. Tokio provides async equivalents:
# Add the 'fs' feature to tokio
cargo add tokio --features fsuse tokio::fs;
// Async versions - yield while waiting for disk I/O
let html = fs::read_to_string("public/index.html").await?;
let bytes = fs::read("public/logo.png").await?;
// Metadata
let meta = fs::metadata("public/style.css").await?;Under the hood, tokio::fs runs the blocking I/O on a dedicated thread pool (via spawn_blocking) and presents an async interface. The calling task yields while the file is being read, and other tasks run in the meantime.
std::fs and caching them in memory. Async file I/O makes the most difference for large files or when file reads are infrequent.MIME Type Detection
When serving a file, you must set the Content-Type header so the browser knows how to display it. A CSS file without text/css won't be applied, an image without image/png won't render.
The simplest approach: map file extensions to MIME types. We don't need a crate for this - a match statement covers the common cases:
/// Guess the MIME type from a file extension.
fn mime_type(path: &Path) -> &'static str {
match path.extension().and_then(|ext| ext.to_str()) {
// Text
Some("html" | "htm") => "text/html; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("js" | "mjs") => "text/javascript; charset=utf-8",
Some("json") => "application/json",
Some("xml") => "application/xml",
Some("txt") => "text/plain; charset=utf-8",
Some("csv") => "text/csv",
// Images
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("svg") => "image/svg+xml",
Some("ico") => "image/x-icon",
Some("webp") => "image/webp",
// Fonts
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("ttf") => "font/ttf",
// Other
Some("pdf") => "application/pdf",
Some("wasm") => "application/wasm",
Some("zip") => "application/zip",
_ => "application/octet-stream",
}
}The fallback application/octet-stream tells the browser "this is binary data, offer to download it." The pattern Some("jpg" | "jpeg") matches either extension - a nice use of Rust's or-patterns.
mime_guess crate, which has a comprehensive database of MIME types. But for our purposes, 20 lines of match covers the vast majority of web assets.Serving Static Files from a Directory
Now let's build a static file handler. Given a root directory (like public/), it maps request paths to files on disk and serves them with the correct content type:
use std::path::{Path, PathBuf};
use tokio::fs;
use crate::error::ServerError;
use crate::response::Response;
pub struct StaticFileHandler {
root: PathBuf,
}
impl StaticFileHandler {
pub fn new(root: &str) -> Self {
StaticFileHandler {
root: PathBuf::from(root),
}
}
/// Serve a file for the given request path.
pub async fn serve(&self, request_path: &str) -> Result<Response, ServerError> {
// Map URL path to filesystem path
let relative = request_path.trim_start_matches('/');
let file_path = if relative.is_empty() {
self.root.join("index.html")
} else {
self.root.join(relative)
};
// Security check (covered in detail below)
let canonical = self.safe_resolve(&file_path)?;
// Check if path is a directory - try index.html
let final_path = if canonical.is_dir() {
let index = canonical.join("index.html");
if index.is_file() {
index
} else {
return Err(ServerError::NotFound(
request_path.to_string()
));
}
} else {
canonical
};
// Read the file
if !final_path.is_file() {
return Err(ServerError::NotFound(request_path.to_string()));
}
let bytes = fs::read(&final_path).await?;
let mime = mime_type(&final_path);
let mut resp = Response::new_bytes(200, "OK", bytes);
resp.add_header("Content-Type", mime);
Ok(resp)
}
/// Resolve a path safely, preventing directory traversal.
fn safe_resolve(&self, path: &Path) -> Result<PathBuf, ServerError> {
// Canonicalize both paths to resolve symlinks and ..
let root = self.root.canonicalize().map_err(|_| {
ServerError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"static root directory not found",
))
})?;
// If the file doesn't exist yet, we need to check the parent
let canonical = if path.exists() {
path.canonicalize()?
} else {
// File doesn't exist - return not found
return Err(ServerError::NotFound("file".to_string()));
};
// Verify the resolved path is inside the root
if !canonical.starts_with(&root) {
return Err(ServerError::ParseError(
"path traversal denied".to_string()
));
}
Ok(canonical)
}
}We also need to support binary responses. Let's add a variant to Response that holds raw bytes instead of a string:
pub struct Response {
pub status: u16,
reason: &'static str,
headers: Vec<(String, String)>,
body: Vec<u8>, // Changed from String to Vec<u8>
}
impl Response {
pub fn new(status: u16, reason: &'static str, body: &str) -> Self {
Response {
status, reason,
headers: vec![],
body: body.as_bytes().to_vec(),
}
}
pub fn new_bytes(status: u16, reason: &'static str, body: Vec<u8>) -> Self {
Response {
status, reason,
headers: vec![],
body,
}
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = format!(
"HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
self.status, self.reason, self.body.len()
);
for (k, v) in &self.headers {
out.push_str(&format!("{}: {}\r\n", k, v));
}
out.push_str("\r\n");
let mut result = out.into_bytes();
result.extend_from_slice(&self.body);
result
}
}The key change: body is now Vec<u8> instead of String. This handles both text files (HTML, CSS, JS) and binary files (images, fonts, PDFs). The to_bytes method builds the headers as text, then appends the raw body bytes.
Wire it into the server:
use std::sync::Arc;
use crate::static_files::StaticFileHandler;
#[tokio::main]
async fn main() {
let router = Arc::new(handler::build_router());
let static_handler = Arc::new(StaticFileHandler::new("public"));
let addr = "127.0.0.1:8080";
let listener = TcpListener::bind(addr).await.unwrap();
println!("Listening on http://{}", addr);
println!("Serving static files from ./public");
loop {
if let Ok((stream, _)) = listener.accept().await {
let router = Arc::clone(&router);
let static_handler = Arc::clone(&static_handler);
tokio::spawn(async move {
handle_connection(stream, router, static_handler).await;
});
}
}
}
async fn handle_connection(
mut stream: TcpStream,
router: Arc<Router>,
static_handler: Arc<StaticFileHandler>,
) {
// ... parse request ...
let response = if request.path.starts_with("/api/") {
// API routes handled by router
router.route(&request)
} else {
// Everything else tries static files first
match static_handler.serve(&request.path).await {
Ok(resp) => Ok(resp),
Err(_) => router.route(&request), // fall back to router
}
};
// ... send response ...
}Directory Traversal Prevention and Security
This is the most important section in this chapter. A directory traversal attack uses .. in a path to escape the intended directory and read arbitrary files on the server:
# An attacker requests:
GET /../../../etc/passwd HTTP/1.1
# Without protection, the server would resolve:
public/ + /../../../etc/passwd → /etc/passwd
# And serve the system password file!Our safe_resolve function prevents this with a two-step approach:
- Canonicalize both paths -
path.canonicalize()resolves..,., and symlinks to produce an absolute path with no tricks.public/../../../etc/passwdbecomes/etc/passwd. - Check containment -
canonical.starts_with(&root)verifies the resolved path is inside the root directory./etc/passwddoes not start with/path/to/public, so it's rejected.
fn safe_resolve(&self, path: &Path) -> Result<PathBuf, ServerError> {
let root = self.root.canonicalize()?;
let canonical = path.canonicalize()?;
if !canonical.starts_with(&root) {
// The resolved path escapes the root - deny it
return Err(ServerError::ParseError(
"path traversal denied".to_string()
));
}
Ok(canonical)
}../, ..%2F (URL-encoded), ..\ (Windows-style), null bytes, and other tricks. Always canonicalize and check containment. This is a real, common vulnerability - it's in the OWASP Top 10.Other Security Considerations
Hidden files
Don't serve dotfiles (.env, .git, .htaccess). Check for path segments starting with . and reject them.
fn has_hidden_segment(path: &Path) -> bool {
path.components().any(|c| {
c.as_os_str()
.to_str()
.map(|s| s.starts_with('.'))
.unwrap_or(false)
})
}File size limits
Check the file size before reading it into memory. A malicious request for a multi-gigabyte file could exhaust server memory.
const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50 MB
let metadata = fs::metadata(&path).await?;
if metadata.len() > MAX_FILE_SIZE {
return Err(ServerError::ParseError(
"file too large".to_string()
));
}Cache headers
Static assets rarely change. Add Cache-Control headers so browsers cache them:
// Immutable assets (fingerprinted filenames)
resp.add_header("Cache-Control", "public, max-age=31536000, immutable");
// HTML pages (may change)
resp.add_header("Cache-Control", "public, max-age=3600");Putting It Together
Create a public/ directory in your project and add some test files:
mkdir -p public/css
mkdir -p public/images<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Rust Webserver</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<h1>Static File Serving</h1>
<p>This HTML file is served from disk.</p>
<p>The stylesheet is a separate file request.</p>
<p>API: <a href="/api/todos">GET /api/todos</a></p>
</body>
</html>body {
font-family: system-ui, sans-serif;
max-width: 600px;
margin: 40px auto;
padding: 0 20px;
color: #e0e0e0;
background: #1a1a1a;
}
h1 { color: #dea584; }
a { color: #4f8fea; }Run the server, open http://127.0.0.1:8080 in a browser, and you should see a styled page. The browser makes two requests: one for /index.html (the page) and one for /css/style.css (the stylesheet). Both are served from disk with correct content types.
# Verify content types
curl -I http://127.0.0.1:8080/
# Content-Type: text/html; charset=utf-8
curl -I http://127.0.0.1:8080/css/style.css
# Content-Type: text/css; charset=utf-8
# Test directory traversal prevention
curl -i http://127.0.0.1:8080/../../../etc/passwd
# 400 Bad Request - path traversal denied
# Test missing file
curl -i http://127.0.0.1:8080/nope.txt
# 404 Not Found
# API still works alongside static files
curl -s http://127.0.0.1:8080/api/todos | jqExercise
- Add the hidden-file check. Verify that
curl http://localhost:8080/.envreturns 403 Forbidden (not 404). You'll need a newServerError::Forbiddenvariant. - Add a file size limit of 10 MB. Check
metadata.len()before reading. Return413 Payload Too Large. - Implement
ETagsupport: compute a hash of the file contents (usestd::collections::hash_map::DefaultHasher), send it as anETagheader, and if the client sendsIf-None-Matchwith the same value, return304 Not Modifiedwith no body. - Add a
--rootCLI argument (usingstd::env::argsfor now) that sets the static file directory. Default to"public". - Serve an
index.htmlautomatically for directory paths. Verify that/servespublic/index.htmland/css/returns 404 (no index.html in that directory).
What's Next
Our server now serves both an API and static files. It reads files asynchronously, detects MIME types, and prevents directory traversal attacks. The architecture is clean: API requests go to the router, everything else tries static files first.
In the next chapter, we revisit lifetimes - the topic we introduced briefly in Chapter 3. Now that we're passing borrowed data between request parsing, routing, and response building, we'll encounter real lifetime challenges and learn the patterns to solve them.