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
472 lines
20 KiB
Zig
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));
|
|
}
|
|
}
|