use std::path::{Component, Path, PathBuf};
#[derive(Debug)]
pub enum ResolveError {
Traversal,
NotFound,
}
pub fn resolve(root: &Path, requested: &str) -> Result<PathBuf, ResolveError> {
let rel = Path::new(requested);
for component in rel.components() {
match component {
Component::Normal(_) => {}
Component::CurDir => {}
_ => return Err(ResolveError::Traversal),
}
}
let candidate = root.join(rel);
let canonical = candidate.canonicalize().map_err(|_| ResolveError::NotFound)?;
if !canonical.starts_with(root) {
return Err(ResolveError::Traversal);
}
Ok(canonical)
}
pub fn content_type_for(path: &Path) -> String {
mime_guess::from_path(path)
.first_raw()
.unwrap_or("application/octet-stream")
.to_string()
}
use std::path::PathBuf;
use std::sync::Arc;
use axum::{
body::Body,
extract::{Path as AxumPath, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
};
use tokio::fs::File;
use tokio_util::io::ReaderStream;
use crate::path_resolver::{content_type_for, resolve, ResolveError};
impl IntoResponse for ResolveError {
fn into_response(self) -> Response {
let status = match self {
ResolveError::Traversal => StatusCode::FORBIDDEN,
ResolveError::NotFound => StatusCode::NOT_FOUND,
};
status.into_response()
}
}
pub async fn serve_file(
State(root): State<Arc<PathBuf>>,
AxumPath(requested): AxumPath<String>,
) -> Result<Response, ResolveError> {
let path = resolve(root.as_ref(), &requested)?;
let file = File::open(&path).await.map_err(|_| ResolveError::NotFound)?;
let len = file
.metadata()
.await
.map(|m| m.len())
.map_err(|_| ResolveError::NotFound)?;
let content_type = content_type_for(&path);
let stream = ReaderStream::new(file);
let response = Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, len)
.body(Body::from_stream(stream))
.unwrap();
Ok(response)
}
mod handler;
mod path_resolver;
use std::path::PathBuf;
use std::sync::Arc;
use axum::{routing::get, Router};
use handler::serve_file;
#[tokio::main]
async fn main() {
let root = PathBuf::from("./public")
.canonicalize()
.expect("public directory must exist");
let app = Router::new()
.route("/files/*path", get(serve_file))
.with_state(Arc::new(root));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("failed to bind");
println!("serving on http://0.0.0.0:3000/files/");
axum::serve(listener, app).await.expect("server error");
}
This snippet shows how a small static file server is built in Rust with axum and tokio, focusing on the two things that consistently go wrong in hand-rolled static handlers: resolving an untrusted request path safely, and returning a correct Content-Type header.
In path_resolver.rs, the core defense is path canonicalization. A raw request like /../../etc/passwd decodes to path segments that could escape the intended web root, so resolve rejects anything containing .. or absolute components up front, then joins the remaining segments onto a pre-canonicalized root. After building the candidate path it calls canonicalize again and verifies the result still starts_with the root. This two-step check matters because symlinks inside the root could otherwise point outside it — checking only the textual path is not enough. The function returns a Result with a small ResolveError enum so callers can map failures to distinct HTTP statuses instead of leaking filesystem details.
The content_type_for helper derives a MIME type from the file extension using the mime_guess crate, falling back to application/octet-stream. Guessing from the extension is the pragmatic default for a static server; sniffing file contents is slower and rarely necessary when the files are trusted assets.
In handler.rs, serve_file ties it together. It extracts the wildcard path via axum's Path extractor, resolves it, and opens the file with tokio::fs::File. Rather than reading the whole file into memory, it wraps the handle in a ReaderStream and hands that to Body::from_stream, so large files are streamed chunk by chunk with bounded memory use. It also sets Content-Length from the file metadata, which lets clients show progress and enables range-free but well-behaved downloads.
Each ResolveError variant maps to a specific StatusCode — 403 for traversal attempts, 404 for missing files — via IntoResponse, keeping the handler readable. The router in main.rs mounts the handler under a /files/*path wildcard and injects the canonical root as shared State. Reaching for this pattern is appropriate when a service needs to expose a directory of assets without pulling in a full framework like tower-http's ServeDir, or when custom auth or logging must wrap each request.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
struct Config<'a> {
name: &'a str,
value: &'a str,
}
fn parse_config(line: &str) -> Config {
Lifetime annotations for flexible borrowing in structs
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
Share this code
Here's the card — post it anywhere.