# Build a Static File Server

### Build a Static File Server

A static file server is a program that reads files from a directory and sends them to a browser over HTTP.

For example, suppose you have this directory:

```text
public/
  index.html
  style.css
  app.js
```

A browser can request:

```text
/
```

The server returns:

```text
public/index.html
```

A browser can request:

```text
/style.css
```

The server returns:

```text
public/style.css
```

This project teaches file paths, networking, HTTP basics, error handling, and safe input handling. A file server looks simple, but it has one serious rule: never let a request escape the public directory.

#### The Goal

We will build a small server that supports:

```text
GET /
GET /index.html
GET /style.css
GET /app.js
```

It will return common content types:

```text
.html -> text/html
.css  -> text/css
.js   -> application/javascript
.txt  -> text/plain
.json -> application/json
.png  -> image/png
.jpg  -> image/jpeg
```

It will reject unsafe paths like:

```text
/../secret.txt
```

This first version will stay deliberately small. It will handle one connection at a time. That is enough to understand the structure.

#### Create the Project

Create a new Zig project:

```bash
mkdir static-server
cd static-server
zig init
```

Create a directory for files:

```bash
mkdir public
```

Create `public/index.html`:

```html
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Zig Static Server</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <h1>Hello from Zig</h1>
  <p>This file was served by a small Zig program.</p>
  <script src="/app.js"></script>
</body>
</html>
```

Create `public/style.css`:

```css
body {
  font-family: sans-serif;
  max-width: 720px;
  margin: 40px auto;
  line-height: 1.5;
}
```

Create `public/app.js`:

```javascript
console.log("Hello from Zig static server");
```

#### The Shape of an HTTP Request

When a browser asks for `/style.css`, it sends text like this:

```http
GET /style.css HTTP/1.1
Host: localhost:8080
User-Agent: ...
```

The first line is the most important part for our first server:

```text
GET /style.css HTTP/1.1
```

It has three pieces:

```text
method path version
```

For this project, we only accept `GET`.

#### Start With a TCP Server

Put this in `src/main.zig`:

```zig
const std = @import("std");

pub fn main() !void {
    const address = try std.net.Address.parseIp("127.0.0.1", 8080);

    var server = try address.listen(.{
        .reuse_address = true,
    });
    defer server.deinit();

    std.debug.print("listening on http://127.0.0.1:8080\n", .{});

    while (true) {
        var connection = try server.accept();
        defer connection.stream.close();

        try handleConnection(connection.stream);
    }
}

fn handleConnection(stream: std.net.Stream) !void {
    _ = stream;
    std.debug.print("received connection\n", .{});
}
```

Run it:

```bash
zig build run
```

Open this in your browser:

```text
http://127.0.0.1:8080
```

The browser may show an error because we are not sending an HTTP response yet. But your terminal should print:

```text
received connection
```

That proves the TCP server is accepting browser connections.

#### Reading the Request

Now replace `handleConnection`:

```zig
fn handleConnection(stream: std.net.Stream) !void {
    var buffer: [4096]u8 = undefined;
    const n = try stream.read(&buffer);

    const request = buffer[0..n];

    std.debug.print("request:\n{s}\n", .{request});
}
```

Run the server again and open:

```text
http://127.0.0.1:8080/style.css
```

You should see an HTTP request printed in the terminal.

The request is plain text. HTTP starts simple. It gets complex later, but the first line is easy to parse.

#### Parsing the Request Line

We need a function that extracts the method and path.

Add this struct:

```zig
const Request = struct {
    method: []const u8,
    path: []const u8,
};
```

Add this function:

```zig
fn parseRequest(request: []const u8) !Request {
    const line_end = std.mem.indexOf(u8, request, "\r\n") orelse return error.InvalidRequest;
    const first_line = request[0..line_end];

    var parts = std.mem.splitScalar(u8, first_line, ' ');

    const method = parts.next() orelse return error.InvalidRequest;
    const path = parts.next() orelse return error.InvalidRequest;
    _ = parts.next() orelse return error.InvalidRequest;

    return Request{
        .method = method,
        .path = path,
    };
}
```

This function reads only the first line.

For this input:

```text
GET /style.css HTTP/1.1
```

It returns:

```text
method = GET
path   = /style.css
```

#### Sending a Basic HTTP Response

An HTTP response also starts with text:

```http
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 12

Hello World
```

There is an empty line between headers and body.

Add this helper:

```zig
fn sendText(stream: std.net.Stream, status: []const u8, body: []const u8) !void {
    var writer_buffer: [4096]u8 = undefined;
    var writer = stream.writer(&writer_buffer);
    const out = &writer.interface;

    try out.print(
        "HTTP/1.1 {s}\r\n" ++
            "Content-Type: text/plain\r\n" ++
            "Content-Length: {d}\r\n" ++
            "Connection: close\r\n" ++
            "\r\n" ++
            "{s}",
        .{ status, body.len, body },
    );

    try out.flush();
}
```

Now update `handleConnection`:

```zig
fn handleConnection(stream: std.net.Stream) !void {
    var buffer: [4096]u8 = undefined;
    const n = try stream.read(&buffer);
    const request_text = buffer[0..n];

    const request = parseRequest(request_text) catch {
        try sendText(stream, "400 Bad Request", "bad request\n");
        return;
    };

    if (!std.mem.eql(u8, request.method, "GET")) {
        try sendText(stream, "405 Method Not Allowed", "method not allowed\n");
        return;
    }

    try sendText(stream, "200 OK", "hello from zig\n");
}
```

Open:

```text
http://127.0.0.1:8080/
```

You should see:

```text
hello from zig
```

Now we have a real HTTP response.

#### Mapping URLs to Files

The browser asks for paths like:

```text
/
```

or:

```text
/style.css
```

We need to map them to files under `public`.

Rules:

```text
/           -> public/index.html
/style.css  -> public/style.css
/app.js     -> public/app.js
```

We also need to reject path traversal:

```text
/../secret.txt
```

A path traversal attack tries to escape the public directory. Do not ignore this. File servers must handle it.

Add this function:

```zig
fn isSafePath(path: []const u8) bool {
    if (path.len == 0) return false;
    if (path[0] != '/') return false;

    if (std.mem.indexOf(u8, path, "..") != null) {
        return false;
    }

    if (std.mem.indexOf(u8, path, "\\") != null) {
        return false;
    }

    return true;
}
```

This is a simple safety check. It is not a full URL normalizer, but it is enough for this beginner server.

Now add:

```zig
fn pathToFile(path: []const u8) []const u8 {
    if (std.mem.eql(u8, path, "/")) {
        return "public/index.html";
    }

    return path[1..];
}
```

This function is not finished yet, because for `/style.css` it returns:

```text
style.css
```

We need:

```text
public/style.css
```

Since that requires building a new string, we need an allocator.

Use this version instead:

```zig
fn pathToFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    if (std.mem.eql(u8, path, "/")) {
        return try allocator.dupe(u8, "public/index.html");
    }

    return try std.fmt.allocPrint(allocator, "public{s}", .{path});
}
```

Now the result is allocated memory, so the caller must free it.

#### Reading and Serving Files

Add a function to detect the content type:

```zig
fn contentType(path: []const u8) []const u8 {
    if (std.mem.endsWith(u8, path, ".html")) return "text/html; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".css")) return "text/css; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".js")) return "application/javascript; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".json")) return "application/json; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".txt")) return "text/plain; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".png")) return "image/png";
    if (std.mem.endsWith(u8, path, ".jpg")) return "image/jpeg";
    if (std.mem.endsWith(u8, path, ".jpeg")) return "image/jpeg";

    return "application/octet-stream";
}
```

Now add a function that sends bytes:

```zig
fn sendBytes(
    stream: std.net.Stream,
    status: []const u8,
    mime: []const u8,
    body: []const u8,
) !void {
    var writer_buffer: [4096]u8 = undefined;
    var writer = stream.writer(&writer_buffer);
    const out = &writer.interface;

    try out.print(
        "HTTP/1.1 {s}\r\n" ++
            "Content-Type: {s}\r\n" ++
            "Content-Length: {d}\r\n" ++
            "Connection: close\r\n" ++
            "\r\n",
        .{ status, mime, body.len },
    );

    try out.writeAll(body);
    try out.flush();
}
```

Now we can read a file and send it:

```zig
fn serveFile(allocator: std.mem.Allocator, stream: std.net.Stream, path: []const u8) !void {
    if (!isSafePath(path)) {
        try sendText(stream, "400 Bad Request", "unsafe path\n");
        return;
    }

    const file_path = try pathToFile(allocator, path);
    defer allocator.free(file_path);

    const file = std.fs.cwd().openFile(file_path, .{}) catch |err| {
        switch (err) {
            error.FileNotFound => {
                try sendText(stream, "404 Not Found", "not found\n");
                return;
            },
            else => return err,
        }
    };
    defer file.close();

    const body = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
    defer allocator.free(body);

    try sendBytes(stream, "200 OK", contentType(file_path), body);
}
```

This version limits each file to 10 MiB:

```zig
file.readToEndAlloc(allocator, 10 * 1024 * 1024)
```

That prevents a huge file from consuming unlimited memory.

#### Complete Program

Replace `src/main.zig` with:

```zig
const std = @import("std");

const Request = struct {
    method: []const u8,
    path: []const u8,
};

pub fn main() !void {
    const address = try std.net.Address.parseIp("127.0.0.1", 8080);

    var server = try address.listen(.{
        .reuse_address = true,
    });
    defer server.deinit();

    std.debug.print("listening on http://127.0.0.1:8080\n", .{});

    while (true) {
        var connection = try server.accept();
        defer connection.stream.close();

        try handleConnection(connection.stream);
    }
}

fn handleConnection(stream: std.net.Stream) !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();

    const allocator = arena.allocator();

    var buffer: [4096]u8 = undefined;
    const n = try stream.read(&buffer);
    const request_text = buffer[0..n];

    const request = parseRequest(request_text) catch {
        try sendText(stream, "400 Bad Request", "bad request\n");
        return;
    };

    if (!std.mem.eql(u8, request.method, "GET")) {
        try sendText(stream, "405 Method Not Allowed", "method not allowed\n");
        return;
    }

    try serveFile(allocator, stream, request.path);
}

fn parseRequest(request: []const u8) !Request {
    const line_end = std.mem.indexOf(u8, request, "\r\n") orelse return error.InvalidRequest;
    const first_line = request[0..line_end];

    var parts = std.mem.splitScalar(u8, first_line, ' ');

    const method = parts.next() orelse return error.InvalidRequest;
    const path = parts.next() orelse return error.InvalidRequest;
    _ = parts.next() orelse return error.InvalidRequest;

    return Request{
        .method = method,
        .path = path,
    };
}

fn isSafePath(path: []const u8) bool {
    if (path.len == 0) return false;
    if (path[0] != '/') return false;

    if (std.mem.indexOf(u8, path, "..") != null) {
        return false;
    }

    if (std.mem.indexOf(u8, path, "\\") != null) {
        return false;
    }

    return true;
}

fn pathToFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    if (std.mem.eql(u8, path, "/")) {
        return try allocator.dupe(u8, "public/index.html");
    }

    return try std.fmt.allocPrint(allocator, "public{s}", .{path});
}

fn contentType(path: []const u8) []const u8 {
    if (std.mem.endsWith(u8, path, ".html")) return "text/html; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".css")) return "text/css; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".js")) return "application/javascript; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".json")) return "application/json; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".txt")) return "text/plain; charset=utf-8";
    if (std.mem.endsWith(u8, path, ".png")) return "image/png";
    if (std.mem.endsWith(u8, path, ".jpg")) return "image/jpeg";
    if (std.mem.endsWith(u8, path, ".jpeg")) return "image/jpeg";

    return "application/octet-stream";
}

fn sendText(stream: std.net.Stream, status: []const u8, body: []const u8) !void {
    try sendBytes(stream, status, "text/plain; charset=utf-8", body);
}

fn sendBytes(
    stream: std.net.Stream,
    status: []const u8,
    mime: []const u8,
    body: []const u8,
) !void {
    var writer_buffer: [4096]u8 = undefined;
    var writer = stream.writer(&writer_buffer);
    const out = &writer.interface;

    try out.print(
        "HTTP/1.1 {s}\r\n" ++
            "Content-Type: {s}\r\n" ++
            "Content-Length: {d}\r\n" ++
            "Connection: close\r\n" ++
            "\r\n",
        .{ status, mime, body.len },
    );

    try out.writeAll(body);
    try out.flush();
}

fn serveFile(allocator: std.mem.Allocator, stream: std.net.Stream, path: []const u8) !void {
    if (!isSafePath(path)) {
        try sendText(stream, "400 Bad Request", "unsafe path\n");
        return;
    }

    const file_path = try pathToFile(allocator, path);

    const file = std.fs.cwd().openFile(file_path, .{}) catch |err| {
        switch (err) {
            error.FileNotFound => {
                try sendText(stream, "404 Not Found", "not found\n");
                return;
            },
            else => return err,
        }
    };
    defer file.close();

    const body = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);

    try sendBytes(stream, "200 OK", contentType(file_path), body);
}
```

Run:

```bash
zig build run
```

Open:

```text
http://127.0.0.1:8080/
```

You should see your HTML page.

Open:

```text
http://127.0.0.1:8080/style.css
```

You should see the CSS file.

Open:

```text
http://127.0.0.1:8080/app.js
```

You should see the JavaScript file.

#### Testing With curl

You can also test with `curl`:

```bash
curl -i http://127.0.0.1:8080/
```

The `-i` flag shows response headers.

Example output:

```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 270
Connection: close

<!doctype html>
<html>
...
```

Test a missing file:

```bash
curl -i http://127.0.0.1:8080/missing.txt
```

Output:

```http
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 10
Connection: close

not found
```

Test an unsafe path:

```bash
curl -i http://127.0.0.1:8080/../secret.txt
```

You should get:

```http
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Connection: close

unsafe path
```

#### Why This Server Handles One Connection at a Time

The main loop does this:

```zig
while (true) {
    var connection = try server.accept();
    defer connection.stream.close();

    try handleConnection(connection.stream);
}
```

That means the server accepts one connection, handles it, closes it, then accepts the next one.

This is simple and good for learning. A production server would usually handle multiple connections at once using threads, an event loop, or async I/O.

The goal here is to understand the complete path from browser request to file response.

#### Why We Used an Arena

Each request may allocate temporary memory:

```zig
const file_path = try pathToFile(allocator, path);
const body = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
```

Those allocations are only needed while handling that one request.

So `handleConnection` creates an arena:

```zig
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
```

When the request is done, the whole arena is freed at once.

This keeps cleanup simple.

#### Important Security Notes

This server is for learning.

A production file server needs stricter path handling. It should decode URLs correctly, normalize paths, reject encoded traversal like `%2e%2e`, handle symbolic links carefully, set better headers, handle large files by streaming, and limit slow clients.

The key lesson still applies: never map a user-provided path directly to the filesystem without validation.

This is unsafe:

```zig
const file = try std.fs.cwd().openFile(request.path, .{});
```

The browser should never get direct control over the filesystem path. The server must decide what paths are allowed.

#### What You Learned

You built a working static file server.

You accepted TCP connections.

You read HTTP request text.

You parsed the request line.

You sent HTTP responses.

You mapped URLs to files.

You read files from disk.

You returned content types.

You handled missing files.

You rejected unsafe paths.

This is the core of many web servers. Larger servers add concurrency, routing, streaming, caching, compression, TLS, logging, and better HTTP support. The base idea remains the same: read a request, decide what it means, produce a response.

