Files
nxdns/src/web/static.zig
T
mokhtar cc23c97218
Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 2m3s
Gates / test-aarch64 (push) Failing after 3h13m33s
Gates / package (push) Successful in 5m20s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h30m45s
milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling
2026-08-22 23:31:37 +02:00

472 lines
20 KiB
Zig

//! Static asset serving (milestone-8 ruling 24).
//!
//! Production serves from `admin_assets`, the module the build generates from
//! `-Dadmin-dist`: bytes, content type and a strong ETag per file, plus a
//! `<name>.gz` sibling entry where compressing at build time paid off. Serving
//! is a linear scan over a handful of immutable entries — no allocation, no
//! clock, no disk.
//!
//! `ETag`/`If-None-Match` is the whole caching story. There is no
//! `Last-Modified` and no `Date`: std has no RFC 1123 formatter, and a strong
//! content hash validates an embedded immutable asset strictly better than a
//! timestamp would.
//!
//! An unknown path outside `/api` answers with index.html, 200 — the SPA owns
//! client-side routes, and its router needs the shell to load on a deep link.
//! `.gz` entries are reachable only through content negotiation, never as
//! paths of their own; each is a representation of its base file, with its own
//! ETag so a `304` is always judged against the representation that would be
//! served.
//!
//! Dev mode (`nxdns run --admin-dev <dir>`, wired by the CLI) serves from disk
//! with no cache headers, so a UI developer sees an edit on reload.
const std = @import("std");
const assets = @import("admin_assets");
const http_util = @import("http_util.zig");
const server = @import("server.zig");
const log = std.log.scoped(.web_static);
pub const File = assets.File;
/// What the build embedded. Entries are sorted by path and immutable.
pub const embedded: []const File = assets.files;
pub const index_path = "/index.html";
/// A disk asset a dev-mode request may read. Matches the embed limit in
/// tools/gen_admin_assets.zig.
pub const max_disk_asset_bytes = 64 * 1024 * 1024;
pub const Selection = struct {
file: *const File,
/// True when `file` is the gzip sibling and the response must carry
/// `content-encoding: gzip`.
gzip: bool,
};
/// Resolves a raw request path against `files`: exact match, `/` → index,
/// gzip sibling when the client accepts it. Null means no asset claims the
/// path and the caller decides between the SPA fallback and a 404.
pub fn select(files: []const File, raw_path: []const u8, accept_encoding: []const u8) ?Selection {
const path = if (raw_path.len == 0 or std.mem.eql(u8, raw_path, "/")) index_path else raw_path;
// A `.gz` entry is a representation, not an address.
if (std.mem.endsWith(u8, path, ".gz")) return null;
const file = find(files, path) orelse return null;
if (acceptsGzip(accept_encoding)) {
var buf: [http_util.max_target_len + 3]u8 = undefined;
const sibling = std.fmt.bufPrint(&buf, "{s}.gz", .{path}) catch return .{ .file = file, .gzip = false };
if (find(files, sibling)) |gz| return .{ .file = gz, .gzip = true };
}
return .{ .file = file, .gzip = false };
}
fn find(files: []const File, path: []const u8) ?*const File {
for (files) |*file| {
if (std.mem.eql(u8, file.path, path)) return file;
}
return null;
}
/// Whether `accept-encoding` admits gzip. Every comma-separated entry is
/// scanned; a `gzip` entry decides over `*`; `q=0` refuses; an entry whose
/// parameters fall outside the grammar is unusable and refuses. An empty
/// header (or one the connection budget dropped) reads as identity-only,
/// which degrades to the uncompressed entry.
fn acceptsGzip(header: []const u8) bool {
var gzip_entry: ?bool = null;
var wildcard_entry: ?bool = null;
var tokens = std.mem.splitScalar(u8, header, ',');
while (tokens.next()) |token| {
var parts = std.mem.splitScalar(u8, token, ';');
const name = std.mem.trim(u8, parts.next().?, " \t");
const is_gzip = std.ascii.eqlIgnoreCase(name, "gzip");
if (!is_gzip and !std.mem.eql(u8, name, "*")) continue;
// The grammar admits one parameter and it is the weight.
var acceptable = true;
var saw_weight = false;
while (parts.next()) |param| {
const trimmed = std.mem.trim(u8, param, " \t");
if (saw_weight or !std.ascii.startsWithIgnoreCase(trimmed, "q=")) {
acceptable = false;
break;
}
saw_weight = true;
acceptable = qualityAccepts(trimmed[2..]);
}
if (is_gzip) gzip_entry = acceptable else wildcard_entry = acceptable;
}
return gzip_entry orelse wildcard_entry orelse false;
}
/// A well-formed nonzero qvalue: `0` or `1`, optionally `.` and up to three
/// digits, never exceeding 1. Malformed reads as not acceptable.
fn qualityAccepts(value: []const u8) bool {
if (value.len == 0 or value.len > 5) return false;
if (value[0] != '0' and value[0] != '1') return false;
if (value.len > 1 and value[1] != '.') return false;
var nonzero = value[0] == '1';
if (value.len > 2) for (value[2..]) |c| {
if (!std.ascii.isDigit(c)) return false;
if (value[0] == '1' and c != '0') return false;
if (c != '0') nonzero = true;
};
return nonzero;
}
/// Whether an `if-none-match` header names `etag` (which carries its quotes).
/// Weak validators compare by content: a `W/` prefix on the wire still matches,
/// because the bytes behind a content hash are the content.
fn etagMatches(header: []const u8, etag: []const u8) bool {
var tokens = std.mem.splitScalar(u8, header, ',');
while (tokens.next()) |token| {
var candidate = std.mem.trim(u8, token, " \t");
if (std.mem.eql(u8, candidate, "*")) return true;
if (std.mem.startsWith(u8, candidate, "W/")) candidate = candidate[2..];
if (std.mem.eql(u8, candidate, etag)) return true;
}
return false;
}
/// The SPA fallback handler (ruling 24): every non-`/api` path no route
/// claimed. W9 wires it as `WebState.fallback`.
pub fn fallback(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
_ = state;
_ = io;
if (request.method != .GET and request.method != .HEAD)
return http_util.respondError(request, .not_found, "not found");
const selection = select(embedded, request.raw_path, request.accept_encoding) orelse
select(embedded, index_path, request.accept_encoding) orelse
return http_util.respondError(request, .not_found, "not found");
return respondAsset(request, selection);
}
fn respondAsset(request: *http_util.Request, selection: Selection) http_util.HandlerError!void {
const file = selection.file;
if (etagMatches(request.if_none_match, file.etag)) {
return request.http.respond("", .{
.status = .not_modified,
.extra_headers = &.{
.{ .name = "etag", .value = file.etag },
.{ .name = "vary", .value = "accept-encoding" },
},
});
}
var headers_buf: [3]std.http.Header = .{
.{ .name = "etag", .value = file.etag },
.{ .name = "vary", .value = "accept-encoding" },
.{ .name = "content-encoding", .value = "gzip" },
};
const headers: []const std.http.Header = headers_buf[0..if (selection.gzip) 3 else 2];
return http_util.respondBytes(request, .ok, file.bytes, file.content_type, headers);
}
/// Joins decoded path segments back into a relative disk path, or null when
/// any segment could escape the root. Segments were split before percent
/// decoding, so a decoded segment may contain `/` — that and `..` are the two
/// traversal shapes, and both are refused rather than normalized.
fn diskRelativePath(buf: []u8, segments: []const []const u8) ?[]const u8 {
if (segments.len == 0) return index_path[1..];
var writer: std.Io.Writer = .fixed(buf);
for (segments, 0..) |segment, index| {
if (std.mem.eql(u8, segment, "..") or std.mem.eql(u8, segment, ".")) return null;
if (std.mem.findScalar(u8, segment, '/') != null) return null;
if (std.mem.findScalar(u8, segment, '\\') != null) return null;
if (std.mem.findScalar(u8, segment, 0) != null) return null;
if (index != 0) writer.writeAll("/") catch return null;
writer.writeAll(segment) catch return null;
}
return writer.buffered();
}
/// Dev-mode disk serving for `--admin-dev` (ruling 24). No cache headers: the
/// point of the flag is that an edit shows up on the next reload. The CLI
/// wiring (W9) closes over the directory and passes it here.
pub fn serveFromDisk(
root: []const u8,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
if (request.method != .GET and request.method != .HEAD)
return http_util.respondError(request, .not_found, "not found");
var path_buf: [http_util.max_target_len]u8 = undefined;
const relative = diskRelativePath(&path_buf, request.path.segments()) orelse
return http_util.respondError(request, .not_found, "not found");
var dir = std.Io.Dir.cwd().openDir(io, root, .{}) catch |err| {
log.warn("admin-dev directory '{s}' is unreadable: {t}", .{ root, err });
return http_util.respondError(request, .internal_server_error, "admin-dev directory unavailable");
};
defer dir.close(io);
if (readDiskFile(dir, io, request, relative)) |bytes|
return http_util.respondBytes(request, .ok, bytes, contentType(relative), &.{});
// SPA fallback, same rule as the embedded path.
const index = readDiskFile(dir, io, request, index_path[1..]) orelse
return http_util.respondError(request, .not_found, "not found");
return http_util.respondBytes(request, .ok, index, contentType(index_path), &.{});
}
fn readDiskFile(
dir: std.Io.Dir,
io: std.Io,
request: *http_util.Request,
sub_path: []const u8,
) ?[]const u8 {
if (!resolvesUnderRoot(dir, io, sub_path)) return null;
return dir.readFileAlloc(io, sub_path, request.arena, .limited(max_disk_asset_bytes)) catch |err| {
switch (err) {
error.FileNotFound, error.IsDir => {},
else => log.warn("admin-dev read of '{s}' failed: {t}", .{ sub_path, err }),
}
return null;
};
}
/// The lexical checks in `diskRelativePath` cannot see a symlink inside the
/// tree pointing out of it, so the target's canonical path must sit under the
/// root's. Racy against a concurrent rename, which loopback operator tooling
/// tolerates; any failure to resolve reads as a 404.
fn resolvesUnderRoot(dir: std.Io.Dir, io: std.Io, sub_path: []const u8) bool {
var root_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;
var target_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;
const root_len = dir.realPath(io, &root_buf) catch return false;
const target_len = dir.realPathFile(io, sub_path, &target_buf) catch return false;
const root = root_buf[0..root_len];
const target = target_buf[0..target_len];
return target.len > root.len + 1 and
std.mem.startsWith(u8, target, root) and target[root.len] == '/';
}
/// Extension → MIME type for dev-mode disk serving. The embedded entries carry
/// the same mapping, stamped by tools/gen_admin_assets.zig; a test below keeps
/// the two from drifting.
pub fn contentType(path: []const u8) []const u8 {
const map = [_]struct { ext: []const u8, mime: []const u8 }{
.{ .ext = ".html", .mime = "text/html; charset=utf-8" },
.{ .ext = ".js", .mime = "text/javascript" },
.{ .ext = ".mjs", .mime = "text/javascript" },
.{ .ext = ".css", .mime = "text/css" },
.{ .ext = ".svg", .mime = "image/svg+xml" },
.{ .ext = ".png", .mime = "image/png" },
.{ .ext = ".ico", .mime = "image/x-icon" },
.{ .ext = ".json", .mime = "application/json" },
.{ .ext = ".map", .mime = "application/json" },
.{ .ext = ".webmanifest", .mime = "application/manifest+json" },
.{ .ext = ".txt", .mime = "text/plain; charset=utf-8" },
.{ .ext = ".woff2", .mime = "font/woff2" },
.{ .ext = ".woff", .mime = "font/woff" },
.{ .ext = ".wasm", .mime = "application/wasm" },
};
for (map) |entry| {
if (std.mem.endsWith(u8, path, entry.ext)) return entry.mime;
}
return "application/octet-stream";
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const test_files = [_]File{
.{ .path = "/index.html", .bytes = "<html>", .content_type = "text/html; charset=utf-8", .etag = "\"aaaa\"" },
.{ .path = "/index.html.gz", .bytes = "gz!", .content_type = "text/html; charset=utf-8", .etag = "\"bbbb\"" },
.{ .path = "/app.css", .bytes = "body{}", .content_type = "text/css", .etag = "\"cccc\"" },
};
test "an exact path selects its file and the root selects the index" {
const css = select(&test_files, "/app.css", "").?;
try testing.expectEqualStrings("/app.css", css.file.path);
try testing.expect(!css.gzip);
try testing.expectEqualStrings("/index.html", select(&test_files, "/", "").?.file.path);
try testing.expectEqualStrings("/index.html", select(&test_files, "", "").?.file.path);
try testing.expect(select(&test_files, "/missing.js", "gzip") == null);
}
test "a gzip sibling is chosen only when the client accepts gzip" {
const plain = select(&test_files, "/index.html", "").?;
try testing.expect(!plain.gzip);
try testing.expectEqualStrings("\"aaaa\"", plain.file.etag);
const gz = select(&test_files, "/index.html", "gzip, br").?;
try testing.expect(gz.gzip);
try testing.expectEqualStrings("\"bbbb\"", gz.file.etag);
try testing.expectEqualStrings("text/html; charset=utf-8", gz.file.content_type);
// No sibling: the css stays identity even for a gzip client.
try testing.expect(!select(&test_files, "/app.css", "gzip").?.gzip);
}
test "a .gz path is not addressable directly" {
try testing.expect(select(&test_files, "/index.html.gz", "gzip") == null);
}
test "accept-encoding parsing scans every entry per the grammar" {
const cases = [_]struct { header: []const u8, accepts: bool }{
.{ .header = "gzip", .accepts = true },
.{ .header = "GZIP", .accepts = true },
.{ .header = "br, gzip;q=0.5", .accepts = true },
.{ .header = " deflate , gzip ", .accepts = true },
.{ .header = "*", .accepts = true },
.{ .header = "*;q=0.5", .accepts = true },
.{ .header = "gzip;q=0.001", .accepts = true },
.{ .header = "gzip;q=1", .accepts = true },
.{ .header = "gzip;q=1.000", .accepts = true },
.{ .header = "gzip;Q=0.5", .accepts = true },
.{ .header = "", .accepts = false },
.{ .header = "br, deflate", .accepts = false },
.{ .header = "gzip;q=0", .accepts = false },
.{ .header = "gzip;q=0.000", .accepts = false },
// A specific gzip entry decides over the wildcard, in either order.
.{ .header = "*;q=0, gzip", .accepts = true },
.{ .header = "gzip, *;q=0", .accepts = true },
.{ .header = "gzip;q=0, *", .accepts = false },
.{ .header = "*, gzip;q=0", .accepts = false },
.{ .header = "*;q=0", .accepts = false },
// Malformed entries are unusable, never acceptable.
.{ .header = "gzip;q=invalid", .accepts = false },
.{ .header = "gzip;q=", .accepts = false },
.{ .header = "gzip;q=1.5", .accepts = false },
.{ .header = "gzip;q=0.5000", .accepts = false },
.{ .header = "gzip;q=0..5", .accepts = false },
.{ .header = "gzip;level=9", .accepts = false },
.{ .header = "gzip;q=0.5;q=1", .accepts = false },
// A malformed gzip entry still decides over a usable wildcard.
.{ .header = "*, gzip;q=invalid", .accepts = false },
};
for (cases) |case| {
testing.expectEqual(case.accepts, acceptsGzip(case.header)) catch |err| {
std.debug.print("header: '{s}'\n", .{case.header});
return err;
};
}
}
test "if-none-match matches exact, listed, weak and wildcard validators" {
try testing.expect(etagMatches("\"aaaa\"", "\"aaaa\""));
try testing.expect(etagMatches("\"xxxx\", \"aaaa\"", "\"aaaa\""));
try testing.expect(etagMatches("W/\"aaaa\"", "\"aaaa\""));
try testing.expect(etagMatches("*", "\"aaaa\""));
try testing.expect(!etagMatches("\"xxxx\"", "\"aaaa\""));
try testing.expect(!etagMatches("", "\"aaaa\""));
try testing.expect(!etagMatches("aaaa", "\"aaaa\""));
}
test "disk paths join segments and refuse every traversal shape" {
var buf: [256]u8 = undefined;
const nested = diskRelativePath(&buf, &.{ "assets", "app.js" }).?;
try testing.expectEqualStrings("assets/app.js", nested);
try testing.expectEqualStrings("index.html", diskRelativePath(&buf, &.{}).?);
try testing.expect(diskRelativePath(&buf, &.{ "..", "secret" }) == null);
try testing.expect(diskRelativePath(&buf, &.{"."}) == null);
// `%2F` decodes inside a segment; a joined `/` must not appear.
try testing.expect(diskRelativePath(&buf, &.{"../etc"}) == null);
try testing.expect(diskRelativePath(&buf, &.{"a\\b"}) == null);
var tiny: [4]u8 = undefined;
try testing.expect(diskRelativePath(&tiny, &.{"toolong.html"}) == null);
}
test "dev-mode disk reads refuse a symlink that escapes the root" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var root = try tmp.dir.createDirPathOpen(io, "root", .{});
defer root.close(io);
try root.writeFile(io, .{ .sub_path = "inside.txt", .data = "ok" });
try tmp.dir.writeFile(io, .{ .sub_path = "outside.txt", .data = "secret" });
try root.symLink(io, "../outside.txt", "escape.txt", .{});
try root.symLink(io, "..", "updir", .{ .is_directory = true });
try testing.expect(resolvesUnderRoot(root, io, "inside.txt"));
try testing.expect(!resolvesUnderRoot(root, io, "escape.txt"));
// A symlinked directory escapes through an intermediate component, which
// no-follow on the final open would miss.
try testing.expect(!resolvesUnderRoot(root, io, "updir/outside.txt"));
try testing.expect(!resolvesUnderRoot(root, io, "missing.txt"));
}
test "the embedded dist has an index and consistent gzip siblings" {
try testing.expect(embedded.len > 0);
const index = find(embedded, index_path).?;
try testing.expectEqualStrings("text/html; charset=utf-8", index.content_type);
try testing.expect(index.bytes.len > 0);
for (embedded) |file| {
try testing.expect(file.etag.len >= 3);
try testing.expectEqual(@as(u8, '"'), file.etag[0]);
try testing.expectEqual(@as(u8, '"'), file.etag[file.etag.len - 1]);
var occurrences: usize = 0;
for (embedded) |other| {
if (std.mem.eql(u8, other.path, file.path)) occurrences += 1;
}
try testing.expectEqual(@as(usize, 1), occurrences);
if (std.mem.endsWith(u8, file.path, ".gz")) {
const base = find(embedded, file.path[0 .. file.path.len - 3]).?;
try testing.expect(file.bytes.len < base.bytes.len);
// The gzip member header: build-time compression, not an accident.
try testing.expectEqual(@as(u8, 0x1f), file.bytes[0]);
try testing.expectEqual(@as(u8, 0x8b), file.bytes[1]);
// A sibling is served under its base file's identity, so equal
// content is the only thing that makes the substitution honest.
var input: std.Io.Reader = .fixed(file.bytes);
const window = try testing.allocator.alloc(u8, std.compress.flate.max_window_len);
defer testing.allocator.free(window);
var decompress: std.compress.flate.Decompress = .init(&input, .gzip, window);
const plain = try decompress.reader.allocRemaining(
testing.allocator,
.limited(max_disk_asset_bytes),
);
defer testing.allocator.free(plain);
try testing.expectEqualSlices(u8, base.bytes, plain);
}
}
}
test "embedded entries agree with the dev-mode content type map" {
for (embedded) |file| {
const base = if (std.mem.endsWith(u8, file.path, ".gz"))
file.path[0 .. file.path.len - 3]
else
file.path;
try testing.expectEqualStrings(contentType(base), file.content_type);
}
}
test "every embedded etag is a quoted 32-digit hash" {
for (embedded) |file| {
try testing.expectEqual(@as(usize, 34), file.etag.len);
try testing.expectEqual(@as(u8, '"'), file.etag[0]);
try testing.expectEqual(@as(u8, '"'), file.etag[33]);
for (file.etag[1..33]) |c| try testing.expect(std.ascii.isHex(c));
}
}