milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
//! Static asset serving (milestone-8 ruling 24).
|
||||
//!
|
||||
//! Production serves from `web_assets`, the module the build generates from
|
||||
//! `-Dweb-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 --web-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("web_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_web_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.
|
||||
pub 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.
|
||||
pub 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.
|
||||
pub 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 `--web-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("web-dev directory '{s}' is unreadable: {t}", .{ root, err });
|
||||
return http_util.respondError(request, .internal_server_error, "web-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("web-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_web_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 placeholder dist is embedded with its gzip siblings" {
|
||||
const index = find(embedded, index_path).?;
|
||||
try testing.expectEqualStrings("text/html; charset=utf-8", index.content_type);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, index.bytes, 1, "nxdns"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, index.bytes, 1, "/api/health"));
|
||||
|
||||
const favicon = find(embedded, "/favicon.svg").?;
|
||||
try testing.expectEqualStrings("image/svg+xml", favicon.content_type);
|
||||
|
||||
const gz = select(embedded, index_path, "gzip").?;
|
||||
try testing.expect(gz.gzip);
|
||||
try testing.expect(gz.file.bytes.len < index.bytes.len);
|
||||
// The gzip member header: build-time compression, not an accident.
|
||||
try testing.expectEqual(@as(u8, 0x1f), gz.file.bytes[0]);
|
||||
try testing.expectEqual(@as(u8, 0x8b), gz.file.bytes[1]);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user