From f9919d9521afcb2ee7d190dc348d9adb6437f0b6 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 4 Aug 2026 16:06:14 -0700 Subject: [PATCH] upgrade to zig 0.16.0/fix latent MMDB C interop issue --- .gitignore | 2 + .mise.toml | 4 +- build.zig | 43 +++++++++++------ build.zig.zon | 15 +++--- build/Coverage.zig | 8 ++-- build/GitVersion.zig | 53 +++++++++++---------- build/download_kcov.zig | 27 ++++++----- src/Astronomical.zig | 5 +- src/Config.zig | 22 +++++++-- src/cache/Cache.zig | 39 ++++++++------- src/cache/Lru.zig | 15 +++--- src/http/RateLimiter.zig | 29 +++++++----- src/http/Server.zig | 31 +++++++----- src/http/handler.zig | 16 +++++-- src/location/GeoCache.zig | 32 ++++++------- src/location/GeoIp.zig | 40 +++++++++++----- src/location/GeoLite2.zig | 25 +++++----- src/location/Ip2location.zig | 92 ++++++++++++++++++++++++------------ src/location/IpWhoIs.zig | 22 ++++----- src/location/resolver.zig | 53 ++++++++++++--------- src/main.zig | 27 ++++++----- src/render/Custom.zig | 43 +++++++++-------- src/render/Formatted.zig | 32 +++++++------ src/render/Prometheus.zig | 15 ++++-- src/weather/MetNo.zig | 72 ++++++++++++++++------------ src/weather/Provider.zig | 10 ++-- 26 files changed, 464 insertions(+), 308 deletions(-) diff --git a/.gitignore b/.gitignore index ca7c4b5..c0d38a7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .zig-cache/ zig-out/ coverage/ +# Zig 0.16 stores fetched packages in-tree rather than the global cache +zig-pkg/ diff --git a/.mise.toml b/.mise.toml index 9927c37..affb4cf 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,5 +1,5 @@ [tools] prek = "0.3.1" "ubi:DonIsaac/zlint" = "0.7.9" -zig = "0.15.2" -zls = "0.15.1" +zig = "0.16.0" +zls = "0.16.0" diff --git a/build.zig b/build.zig index 7e29547..9f91a9c 100644 --- a/build.zig +++ b/build.zig @@ -55,16 +55,15 @@ pub fn build(b: *std.Build) void { }), }); - sunriset.addIncludePath(b.path("libs/sunriset")); - sunriset.addCSourceFiles(.{ + sunriset.root_module.addIncludePath(b.path("libs/sunriset")); + sunriset.root_module.addCSourceFiles(.{ .root = b.path("libs/sunriset"), .files = &.{ "sunriset.c", }, .flags = &.{ "-D_DEFAULT_SOURCE", "-DSUNRISET_NO_MAIN" }, }); - sunriset.linkLibC(); - sunriset.linkSystemLibrary("m"); + sunriset.root_module.linkSystemLibrary("m", .{}); // Build phoon as a static library const phoon = b.addLibrary(.{ @@ -77,8 +76,8 @@ pub fn build(b: *std.Build) void { }), }); - phoon.addIncludePath(b.path("libs/phoon_14Aug2014")); - phoon.addCSourceFiles(.{ + phoon.root_module.addIncludePath(b.path("libs/phoon_14Aug2014")); + phoon.root_module.addCSourceFiles(.{ .root = b.path("libs/phoon_14Aug2014"), .files = &.{ "astro.c", @@ -86,8 +85,7 @@ pub fn build(b: *std.Build) void { }, .flags = &.{ "-std=c99", "-D_DEFAULT_SOURCE" }, }); - phoon.linkLibC(); - phoon.linkSystemLibrary("m"); + phoon.root_module.linkSystemLibrary("m", .{}); // Build libmaxminddb as a static library const maxminddb = b.addLibrary(.{ @@ -106,14 +104,26 @@ pub fn build(b: *std.Build) void { .include_path = "maxminddb_config.h", }, .{ .PACKAGE_VERSION = "1.11.0", - .MMDB_UINT128_USING_MODE = 1, + // Represent mmdb_uint128_t as a 16-byte array rather than + // `unsigned int __attribute__((__mode__(TI)))`. + // + // translate-c cannot represent the `mode(TI)` attribute, so with + // MMDB_UINT128_USING_MODE Zig computed sizeof(MMDB_entry_data_s) == 32 + // / alignof == 8 while clang used 48 / 16. Every MMDB_get_value call + // then wrote `entry_data->offset` (at C offset 32) past the end of the + // 32-byte variable Zig had reserved on the stack, corrupting whatever + // happened to be adjacent. + // + // With a byte array both sides agree (40 / 8). Nothing here reads + // uint128-typed database fields, so this only affects layout. + .MMDB_UINT128_IS_BYTE_ARRAY = 1, }); - maxminddb.addConfigHeader(maxminddb_config); - maxminddb.addIncludePath(maxminddb_upstream.path("include")); - maxminddb.addIncludePath(maxminddb_upstream.path("src")); + maxminddb.root_module.addConfigHeader(maxminddb_config); + maxminddb.root_module.addIncludePath(maxminddb_upstream.path("include")); + maxminddb.root_module.addIncludePath(maxminddb_upstream.path("src")); - maxminddb.addCSourceFiles(.{ + maxminddb.root_module.addCSourceFiles(.{ .root = maxminddb_upstream.path(""), .files = &.{ "src/data-pool.c", @@ -128,6 +138,8 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, + // Zig 0.16 moved libc linkage onto the module; `Compile.linkLibC` is gone. + .link_libc = true, }); root_module.addImport("httpz", httpz.module("httpz")); root_module.addImport("zeit", zeit.module("zeit")); @@ -192,6 +204,7 @@ pub fn build(b: *std.Build) void { } fn configureCompilationUnit(compile: *std.Build.Step.Compile, libs: []const *std.Build.Step.Compile) void { - for (libs) |lib| compile.linkLibrary(lib); - compile.linkLibC(); + // Zig 0.16: linkLibrary moved to Module, and libc linkage is a module + // option set where `root_module` is created. + for (libs) |lib| compile.root_module.linkLibrary(lib); } diff --git a/build.zig.zon b/build.zig.zon index 1b22391..bec3e11 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,8 +3,8 @@ .version = "0.1.0", .dependencies = .{ .httpz = .{ - .url = "git+https://github.com/karlseguin/http.zig/#1c0ec3751c53e276d5e0c42b6d5481e72d9d1971", - .hash = "httpz-0.0.0-PNVzrEktBwCzPoiua-S8LAYo2tILqczm3tSpneEzLQ9L", + .url = "git+https://github.com/karlseguin/http.zig?ref=master#c22672f820280ccd17c77d3894e431c49b46061d", + .hash = "httpz-0.0.0-PNVzrMPnCAAWJqhMEZR8gDZeg1CQObCEMTEf8zDh-u-j", }, .maxminddb = .{ .url = "https://github.com/maxmind/libmaxminddb/archive/refs/tags/1.11.0.tar.gz", @@ -15,14 +15,14 @@ .hash = "N-V-__8AAL8tFgMfL4Y2FQTRciAyueOiE5K5PPV3gI3eanes", }, .zeit = .{ - .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#7ac64d72dbfb1a4ad549102e7d4e232a687d32d8", - .hash = "zeit-0.6.0-5I6bk36tAgATpSl9wjFmRPMqYN2Mn0JQHgIcRNcqDpJA", + .url = "git+https://github.com/rockorager/zeit?ref=v0.9.0#b1c1c2fcbc71fd7799a316bbcf0ff88d06d80ccc", + .hash = "zeit-0.9.0-5I6bk2m9AgBSMH8-L6rYJkwuQAyhXplnfxnvTSGzVHUR", }, .phoon = .{ .path = "libs/phoon_14Aug2014" }, .sunriset = .{ .path = "libs/sunriset" }, .ghostty = .{ - .url = "git+https://github.com/ghostty-org/ghostty#ec2912dbafe50cc32b786d2327dcd0213c83ecc6", - .hash = "ghostty-1.3.0-dev-5UdBC_y2RASwYWn5fjn71WsP-arlg8wSICLc0rYiozdf", + .url = "git+https://github.com/ghostty-org/ghostty#ccb08f35f683d6087786dda8e793e911ef1a2f8a", + .hash = "ghostty-1.3.2-dev-5UdBC9KHOwUAvF5QKNA6pbo-dV0WE-F9l5NtaT0mhpmu", }, .zigimg = .{ .url = "git+https://github.com/zigimg/zigimg#9714df09f76891323c7fdbbbf23a17b79024fffb", @@ -35,10 +35,11 @@ .nerd_fonts_symbols_only = .{ .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", + .lazy = true, }, }, .fingerprint = 0x710c2b57e81aa678, - .minimum_zig_version = "0.15.2", + .minimum_zig_version = "0.16.0", .paths = .{ "build.zig", "build.zig.zon", diff --git a/build/Coverage.zig b/build/Coverage.zig index a0c97e9..cc29326 100644 --- a/build/Coverage.zig +++ b/build/Coverage.zig @@ -148,11 +148,9 @@ fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void { _ = options; const check: *Coverage = @fieldParentPtr("step", step); const allocator = step.owner.allocator; + const io = step.owner.graph.io; - const file = try std.fs.cwd().openFile(check.json_path, .{}); - defer file.close(); - - const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); + const content = try std.Io.Dir.cwd().readFileAlloc(io, check.json_path, allocator, .limited(10 * 1024 * 1024)); defer allocator.free(content); const json = try std.json.parseFromSlice(CoverageReport, allocator, content, .{}); @@ -160,7 +158,7 @@ fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void { const coverage = json.value; var stdout_buffer: [1024]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer); const stdout = &stdout_writer.interface; if (step.owner.verbose or check.verbose) { const files = coverage.files; diff --git a/build/GitVersion.zig b/build/GitVersion.zig index df43430..e5dbf59 100644 --- a/build/GitVersion.zig +++ b/build/GitVersion.zig @@ -11,16 +11,19 @@ pub const Options = struct { /// Get git version information by reading .git files directly pub fn getVersion(b: *Build, options: Options) []const u8 { const allocator = b.allocator; + // Zig 0.16 moved the filesystem behind an explicit `Io`; build scripts get + // theirs from the build graph. + const io = b.graph.io; // Find build root by looking for build.zig - const build_root = findBuildRoot(allocator) catch return "unknown"; + const build_root = findBuildRoot(allocator, io) catch return "unknown"; defer allocator.free(build_root); // Read .git/HEAD relative to build root const head_path = std.fmt.allocPrint(allocator, "{s}/.git/HEAD", .{build_root}) catch return "unknown"; defer allocator.free(head_path); - const head_data = std.fs.cwd().readFileAlloc(allocator, head_path, 1024) catch { + const head_data = std.Io.Dir.cwd().readFileAlloc(io, head_path, allocator, .limited(1024)) catch { return "not under version control"; }; defer allocator.free(head_data); @@ -29,16 +32,13 @@ pub fn getVersion(b: *Build, options: Options) []const u8 { // Parse HEAD - either "ref: refs/heads/branch" or direct hash const hash_owned = if (std.mem.startsWith(u8, head_trimmed, "ref: ")) blk: { - const ref_path_rel = std.mem.trimLeft(u8, head_trimmed[5..], &std.ascii.whitespace); + const ref_path_rel = std.mem.trimStart(u8, head_trimmed[5..], &std.ascii.whitespace); const ref_file = std.fmt.allocPrint(allocator, "{s}/.git/{s}", .{ build_root, ref_path_rel }) catch return "unknown"; defer allocator.free(ref_file); - const ref_fd = std.fs.openFileAbsolute(ref_file, .{}) catch return "unknown"; - defer ref_fd.close(); - - var ref_buf: [1024]u8 = undefined; - const bytes_read = ref_fd.readAll(&ref_buf) catch return "unknown"; - const ref_data = ref_buf[0..bytes_read]; + const ref_data = std.Io.Dir.cwd().readFileAlloc(io, ref_file, allocator, .limited(1024)) catch + return "unknown"; + defer allocator.free(ref_data); const ref_trimmed = std.mem.trim(u8, ref_data, &std.ascii.whitespace); break :blk allocator.dupe(u8, ref_trimmed) catch return "unknown"; @@ -53,7 +53,7 @@ pub fn getVersion(b: *Build, options: Options) []const u8 { // Check if dirty using simple heuristic: // If any .zig files are newer than .git/index, mark as dirty - const is_dirty = isDirty(allocator, build_root) catch return "unknown"; + const is_dirty = isDirty(allocator, io, build_root) catch return "unknown"; if (is_dirty) { return std.fmt.allocPrint(allocator, "{s}{s}", .{ short_hash, options.dirty_flag }) catch return "unknown"; @@ -62,17 +62,18 @@ pub fn getVersion(b: *Build, options: Options) []const u8 { return allocator.dupe(u8, short_hash) catch return "unknown"; } -fn findBuildRoot(allocator: std.mem.Allocator) ![]const u8 { +fn findBuildRoot(allocator: std.mem.Allocator, io: std.Io) ![]const u8 { var buf: [std.fs.max_path_bytes]u8 = undefined; - const start_cwd = try std.fs.cwd().realpath(".", &buf); - var cwd: []const u8 = start_cwd; + // `realPath` now returns the length written rather than a slice. + const start_len = try std.Io.Dir.cwd().realPath(io, &buf); + var cwd: []const u8 = buf[0..start_len]; while (true) { // Check if build.zig exists in current directory - var dir = std.fs.openDirAbsolute(cwd, .{}) catch break; - defer dir.close(); + var dir = std.Io.Dir.openDirAbsolute(io, cwd, .{}) catch break; + defer dir.close(io); - dir.access("build.zig", .{}) catch { + dir.access(io, "build.zig", .{}) catch { // build.zig not found, try parent const parent = std.fs.path.dirname(cwd) orelse break; if (std.mem.eql(u8, parent, cwd)) break; // Reached root @@ -86,29 +87,31 @@ fn findBuildRoot(allocator: std.mem.Allocator) ![]const u8 { return error.BuildRootNotFound; } -fn isDirty(allocator: std.mem.Allocator, build_root: []const u8) !bool { +fn isDirty(allocator: std.mem.Allocator, io: std.Io, build_root: []const u8) !bool { // Get .git/index mtime const index_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".git", "index" }); defer allocator.free(index_path); - const index_stat = std.fs.cwd().statFile(index_path) catch return error.CannotDetermineDirty; - const index_mtime = index_stat.mtime; + const index_stat = std.Io.Dir.cwd().statFile(io, index_path, .{}) catch return error.CannotDetermineDirty; + // Zig 0.16 models mtime as `Io.Timestamp`; compare the raw nanoseconds so + // we keep the precision the old integer mtime had. + const index_mtime = index_stat.mtime.nanoseconds; // Read .gitignore const ignore_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".gitignore" }); defer allocator.free(ignore_path); - const ignore_data = std.fs.cwd().readFileAlloc(allocator, ignore_path, 1024 * 1024) catch + const ignore_data = std.Io.Dir.cwd().readFileAlloc(io, ignore_path, allocator, .limited(1024 * 1024)) catch try allocator.dupe(u8, ""); defer allocator.free(ignore_data); // Walk source files in build root and check if any are newer - var dir = std.fs.openDirAbsolute(build_root, .{ .iterate = true }) catch return error.CannotDetermineDirty; - defer dir.close(); + var dir = std.Io.Dir.openDirAbsolute(io, build_root, .{ .iterate = true }) catch return error.CannotDetermineDirty; + defer dir.close(io); var walker = dir.walk(allocator) catch return error.CannotDetermineDirty; defer walker.deinit(); - while (walker.next() catch return error.CannotDetermineDirty) |entry| { + while (walker.next(io) catch return error.CannotDetermineDirty) |entry| { if (entry.kind != .file) continue; // Always ignore .git/ @@ -129,8 +132,8 @@ fn isDirty(allocator: std.mem.Allocator, build_root: []const u8) !bool { } if (ignored) continue; - const stat = entry.dir.statFile(entry.basename) catch continue; - if (stat.mtime > index_mtime) { + const stat = entry.dir.statFile(io, entry.basename, .{}) catch continue; + if (stat.mtime.nanoseconds > index_mtime) { return true; } } diff --git a/build/download_kcov.zig b/build/download_kcov.zig index f04c85a..3819c46 100644 --- a/build/download_kcov.zig +++ b/build/download_kcov.zig @@ -1,11 +1,11 @@ const std = @import("std"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - const allocator = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; - const args = try std.process.argsAlloc(allocator); - defer std.process.argsFree(allocator, args); + const args = try init.minimal.args.toSlice(allocator); + defer allocator.free(args); if (args.len != 3) return error.InvalidArgs; @@ -13,20 +13,20 @@ pub fn main() !void { const arch_name = args[2]; // Check to see if file exists. If it does, we have nothing more to do - const stat = std.fs.cwd().statFile(kcov_path) catch |err| blk: { + const stat = std.Io.Dir.cwd().statFile(io, kcov_path, .{}) catch |err| blk: { if (err == error.FileNotFound) break :blk null else return err; }; // This might be better checking whether it's executable and >= 7MB, but // for now, we'll do a simple exists check if (stat != null) return; var stdout_buffer: [1024]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer); const stdout = &stdout_writer.interface; try stdout.writeAll("Determining latest kcov version\n"); try stdout.flush(); - var client = std.http.Client{ .allocator = allocator }; + var client = std.http.Client{ .allocator = allocator, .io = io }; defer client.deinit(); // Get redirect to find latest version @@ -58,17 +58,20 @@ pub fn main() !void { defer allocator.free(binary_url); const cache_dir = std.fs.path.dirname(kcov_path) orelse return error.InvalidPath; - std.fs.cwd().makeDir(cache_dir) catch |e| switch (e) { + std.Io.Dir.cwd().createDir(io, cache_dir, .default_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; const uri = try std.Uri.parse(binary_url); - const file = try std.fs.cwd().createFile(kcov_path, .{ .mode = 0o755 }); - defer file.close(); + const file = try std.Io.Dir.cwd().createFile(io, kcov_path, .{}); + defer file.close(io); + // Zig 0.16 dropped `CreateFileOptions.mode`; permissions are set separately. + // kcov is executed by the coverage step, so it needs the executable bit. + try file.setPermissions(io, .executable_file); var buffer: [8192]u8 = undefined; - var writer = file.writer(&buffer); + var writer = file.writer(io, &buffer); const result = try client.fetch(.{ .location = .{ .uri = uri }, .response_writer = &writer.interface, diff --git a/src/Astronomical.zig b/src/Astronomical.zig index 10ac68c..50b1c9a 100644 --- a/src/Astronomical.zig +++ b/src/Astronomical.zig @@ -133,8 +133,9 @@ pub const Time = struct { /// /// Note: year,month,date = calendar date, 1801-2099 only. pub fn init(latitude: f64, longitude: f64, timestamp: i64) Astronomical { - const instant = zeit.instant(.{ .source = .{ .unix_timestamp = timestamp } }) catch - @panic("This can't happen"); + // zeit 0.9 takes the source directly plus an explicit timezone, and no + // longer returns an error for a fixed timestamp. + const instant = zeit.instant(.{ .unix_timestamp = timestamp }, &zeit.utc); const time = instant.time(); const year: c_int = @intCast(time.year); diff --git a/src/Config.zig b/src/Config.zig index a7bdfa3..1916f77 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -34,10 +34,11 @@ ip2location_cache_file: []const u8, /// Cache file for ipwho.is lookups ipwhois_cache_file: []const u8, -pub fn load(allocator: std.mem.Allocator) !Config { - var env = try std.process.getEnvMap(allocator); - defer env.deinit(); - +/// Loads configuration from the process environment. +/// +/// Zig 0.16 removed `std.process.getEnvMap`; the environment map is now +/// supplied by the runtime to `main` and threaded in by the caller. +pub fn load(allocator: std.mem.Allocator, env: *const std.process.Environ.Map) !Config { // Get XDG_CACHE_HOME or default to ~/.cache const home = env.get("HOME") orelse "/tmp"; const xdg_cache = env.get("XDG_CACHE_HOME") orelse @@ -89,6 +90,17 @@ pub fn load(allocator: std.mem.Allocator) !Config { }; } +/// Loads configuration from an empty environment, for tests. +/// +/// Zig 0.16 removed any way to reach the process environment outside of `main`, +/// and tests are better off not depending on ambient variables anyway: this +/// keeps them reproducible regardless of the developer's shell. +pub fn loadForTest(allocator: std.mem.Allocator) !Config { + var env: std.process.Environ.Map = .init(allocator); + defer env.deinit(); + return load(allocator, &env); +} + pub fn deinit(self: Config, allocator: std.mem.Allocator) void { allocator.free(self.listen_host); allocator.free(self.cache_dir); @@ -101,7 +113,7 @@ pub fn deinit(self: Config, allocator: std.mem.Allocator) void { test "config loads defaults" { const allocator = std.testing.allocator; - const cfg = try Config.load(allocator); + const cfg = try Config.loadForTest(allocator); defer cfg.deinit(allocator); try std.testing.expectEqualStrings("0.0.0.0", cfg.listen_host); diff --git a/src/cache/Cache.zig b/src/cache/Cache.zig index 4269aae..70f66b7 100644 --- a/src/cache/Cache.zig +++ b/src/cache/Cache.zig @@ -6,6 +6,8 @@ const Cache = @This(); const log = std.log.scoped(.cache); allocator: std.mem.Allocator, +/// Zig 0.16 requires an explicit `Io` for filesystem access. +io: std.Io, lru: Lru, /// Cache directory for L2 persistent cache cache_dir: ?[]const u8, @@ -15,9 +17,9 @@ pub const Config = struct { cache_dir: ?[]const u8, }; -pub fn init(allocator: std.mem.Allocator, config: Config) !*Cache { +pub fn init(allocator: std.mem.Allocator, io: std.Io, config: Config) !*Cache { if (config.cache_dir) |d| - std.fs.makeDirAbsolute(d) catch |err| { + std.Io.Dir.cwd().createDirPath(io, d) catch |err| { if (err != error.PathAlreadyExists) return err; }; @@ -26,7 +28,8 @@ pub fn init(allocator: std.mem.Allocator, config: Config) !*Cache { cache.* = Cache{ .allocator = allocator, - .lru = try Lru.init(allocator, config.max_entries), + .io = io, + .lru = try Lru.init(allocator, io, config.max_entries), .cache_dir = if (config.cache_dir) |d| try allocator.dupe(u8, d) else null, }; @@ -62,7 +65,7 @@ pub fn get(self: *Cache, key: []const u8) ?[]const u8 { } pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !void { - const now = std.time.milliTimestamp(); + const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds(); const expires = now + @as(i64, @intCast(ttl_seconds * 1000)); // Write to L2 (disk) first if cache_dir is set @@ -117,18 +120,18 @@ fn loadFromFile(self: *Cache, key: []const u8) !CacheEntry { /// if the file access fails OR if the data has expired. /// If the data has expired, the file will be deleted fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry { - const file = try std.fs.cwd().openFile(file_path, .{}); - defer file.close(); + const file = try std.Io.Dir.cwd().openFile(self.io, file_path, .{}); + defer file.close(self.io); var buffer: [1 * 1024 * 1024]u8 = undefined; - var file_reader = file.reader(&buffer); + var file_reader = file.reader(self.io, &buffer); const reader = &file_reader.interface; const cached = try deserialize(self.allocator, reader); errdefer cached.deinit(self.allocator); // Check if expired - const now = std.time.milliTimestamp(); + const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds(); if (cached.expires <= now) { // We're expired, delete expired file self.deleteFile(cached.key); @@ -171,11 +174,11 @@ fn saveToFile(self: *Cache, key: []const u8, value: []const u8, expires: i64) !v const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename }); defer self.allocator.free(file_path); - const file = try std.fs.cwd().createFile(file_path, .{}); - defer file.close(); + const file = try std.Io.Dir.cwd().createFile(self.io, file_path, .{}); + defer file.close(self.io); var buffer: [4096]u8 = undefined; - var file_writer = file.writer(&buffer); + var file_writer = file.writer(self.io, &buffer); const writer = &file_writer.interface; try serialize(writer, key, value, expires); try writer.flush(); @@ -184,11 +187,11 @@ fn saveToFile(self: *Cache, key: []const u8, value: []const u8, expires: i64) !v fn loadFromDir(self: *Cache) !void { if (self.cache_dir == null) return error.NoCacheDir; - var dir = try std.fs.cwd().openDir(self.cache_dir.?, .{ .iterate = true }); - defer dir.close(); + var dir = try std.Io.Dir.cwd().openDir(self.io, self.cache_dir.?, .{ .iterate = true }); + defer dir.close(self.io); var it = dir.iterate(); - while (try it.next()) |entry| { + while (try it.next(self.io)) |entry| { if (entry.kind != .file) continue; const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, entry.name }); @@ -211,7 +214,7 @@ fn deleteFile(self: *Cache, key: []const u8) void { const file_path = std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename }) catch @panic("OOM"); defer self.allocator.free(file_path); - std.fs.cwd().deleteFile(file_path) catch |e| { + std.Io.Dir.cwd().deleteFile(self.io, file_path) catch |e| { log.warn("Error deleting expired cache file {s}: {}", .{ file_path, e }); }; } @@ -264,9 +267,11 @@ test "L1/L2 cache flow" { defer tmp_dir.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const cache_dir = try tmp_dir.dir.realpath(".", &path_buf); + // 0.16: `realPath` resolves the dir itself and returns the length written. + const cache_dir_len = try tmp_dir.dir.realPath(std.testing.io, &path_buf); + const cache_dir = path_buf[0..cache_dir_len]; - const cache = try Cache.init(allocator, .{ .max_entries = 10, .cache_dir = cache_dir }); + const cache = try Cache.init(allocator, std.testing.io, .{ .max_entries = 10, .cache_dir = cache_dir }); defer cache.deinit(); // Put item in cache diff --git a/src/cache/Lru.zig b/src/cache/Lru.zig index 53d7d9e..473d57f 100644 --- a/src/cache/Lru.zig +++ b/src/cache/Lru.zig @@ -3,6 +3,8 @@ const std = @import("std"); const Lru = @This(); allocator: std.mem.Allocator, +/// Zig 0.16 reads the clock through `Io` rather than `std.time`. +io: std.Io, map: std.StringHashMap(Entry), max_entries: usize, evict_fn: ?*const fn (ctx: *anyopaque, key: []const u8) void = null, @@ -14,9 +16,10 @@ const Entry = struct { access_count: u64, }; -pub fn init(allocator: std.mem.Allocator, max_entries: usize) !Lru { +pub fn init(allocator: std.mem.Allocator, io: std.Io, max_entries: usize) !Lru { return .{ .allocator = allocator, + .io = io, .map = std.StringHashMap(Entry).init(allocator), .max_entries = max_entries, }; @@ -30,7 +33,7 @@ pub fn setEvictionCallback(self: *Lru, ctx: *anyopaque, callback: *const fn (ctx pub fn get(self: *Lru, key: []const u8) ?[]const u8 { var entry = self.map.getPtr(key) orelse return null; - const now = std.time.milliTimestamp(); + const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds(); if (now > entry.expires) { self.remove(key); return null; @@ -120,7 +123,7 @@ pub fn iterator(self: *Lru) Iterator { } test "LRU basic operations" { - var lru = try Lru.init(std.testing.allocator, 3); + var lru = try Lru.init(std.testing.allocator, std.testing.io, 3); defer lru.deinit(); try lru.put("key1", "value1", 9999999999999); @@ -128,7 +131,7 @@ test "LRU basic operations" { } test "LRU eviction" { - var lru = try Lru.init(std.testing.allocator, 2); + var lru = try Lru.init(std.testing.allocator, std.testing.io, 2); defer lru.deinit(); try lru.put("key1", "value1", 9999999999999); @@ -139,11 +142,11 @@ test "LRU eviction" { } test "LRU expired entry returns null" { - var lru = try Lru.init(std.testing.allocator, 10); + var lru = try Lru.init(std.testing.allocator, std.testing.io, 10); defer lru.deinit(); // Put item with past expiration time - const now = std.time.milliTimestamp(); + const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds(); const past_expires = now - 1000; try lru.put("key1", "value1", past_expires); diff --git a/src/http/RateLimiter.zig b/src/http/RateLimiter.zig index 4b4ab73..1a9d710 100644 --- a/src/http/RateLimiter.zig +++ b/src/http/RateLimiter.zig @@ -5,7 +5,11 @@ const RateLimiter = @This(); allocator: std.mem.Allocator, buckets: std.StringHashMap(TokenBucket), config: Config, -mutex: std.Thread.Mutex, +/// Zig 0.16 replaced `std.Thread.Mutex` with `std.Io.Mutex`, whose lock is +/// cancelable and therefore needs the `Io`. Stored so the public API keeps its +/// non-erroring signature. +io: std.Io, +mutex: std.Io.Mutex, pub const Config = struct { capacity: u32 = 300, @@ -39,12 +43,13 @@ const TokenBucket = struct { } }; -pub fn init(allocator: std.mem.Allocator, config: Config) !RateLimiter { +pub fn init(allocator: std.mem.Allocator, io: std.Io, config: Config) !RateLimiter { return RateLimiter{ .allocator = allocator, .buckets = std.StringHashMap(TokenBucket).init(allocator), .config = config, - .mutex = .{}, + .io = io, + .mutex = .init, }; } @@ -52,10 +57,12 @@ pub fn init(allocator: std.mem.Allocator, config: Config) !RateLimiter { /// Note: Calling this function consumes a token from the bucket, even if it returns false. /// Returns true if the request should be accepted, false if rate limited. pub fn shouldAcceptRequest(self: *RateLimiter, ip: []const u8) bool { - self.mutex.lock(); - defer self.mutex.unlock(); + // A canceled lock acquisition is treated as "reject": failing closed is the + // safe direction for a rate limiter. + self.mutex.lock(self.io) catch return false; + defer self.mutex.unlock(self.io); - const now = std.time.milliTimestamp(); + const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds(); const result = self.buckets.getOrPut(ip) catch return false; if (!result.found_existing) { @@ -83,7 +90,7 @@ pub fn deinit(self: *RateLimiter) void { } test "rate limiter allows requests within capacity" { - var limiter = try RateLimiter.init(std.testing.allocator, .{ + var limiter = try RateLimiter.init(std.testing.allocator, std.testing.io, .{ .capacity = 10, .refill_rate = 1, .refill_interval_ms = 1000, @@ -97,7 +104,7 @@ test "rate limiter allows requests within capacity" { } test "rate limiter blocks after capacity exhausted" { - var limiter = try RateLimiter.init(std.testing.allocator, .{ + var limiter = try RateLimiter.init(std.testing.allocator, std.testing.io, .{ .capacity = 5, .refill_rate = 1, .refill_interval_ms = 1000, @@ -113,7 +120,7 @@ test "rate limiter blocks after capacity exhausted" { } test "rate limiter refills tokens over time" { - var limiter = try RateLimiter.init(std.testing.allocator, .{ + var limiter = try RateLimiter.init(std.testing.allocator, std.testing.io, .{ .capacity = 10, .refill_rate = 5, .refill_interval_ms = 100, @@ -127,13 +134,13 @@ test "rate limiter refills tokens over time" { try std.testing.expect(!limiter.shouldAcceptRequest("1.2.3.4")); - std.Thread.sleep(250 * std.time.ns_per_ms); + try std.Io.sleep(std.testing.io, .fromMilliseconds(250), .real); try std.testing.expect(limiter.shouldAcceptRequest("1.2.3.4")); } test "rate limiter tracks different IPs separately" { - var limiter = try RateLimiter.init(std.testing.allocator, .{ + var limiter = try RateLimiter.init(std.testing.allocator, std.testing.io, .{ .capacity = 2, .refill_rate = 1, .refill_interval_ms = 1000, diff --git a/src/http/Server.zig b/src/http/Server.zig index e8264d2..4bb7165 100644 --- a/src/http/Server.zig +++ b/src/http/Server.zig @@ -39,6 +39,7 @@ pub const Context = struct { pub fn init( allocator: std.mem.Allocator, + io: std.Io, host: []const u8, port: u16, options: handler.HandleWeatherOptions, @@ -51,9 +52,10 @@ pub fn init( .rate_limiter = rate_limiter, }; - var httpz_server = try httpz.Server(*Context).init(allocator, .{ - .address = host, - .port = port, + // httpz takes the `Io` first under Zig 0.16, and its listen address is now + // a parsed `Io.net.IpAddress` union rather than a host string plus port. + var httpz_server = try httpz.Server(*Context).init(io, allocator, .{ + .address = .{ .ip = try std.Io.net.IpAddress.parse(host, port) }, }, ctx); // We won't use actual middleware for rate limiting here because we only have @@ -107,7 +109,13 @@ fn rateLimitMiddleware(limiter: *RateLimiter, client_ip: []const u8, res: *httpz } pub fn listen(self: *Server) !void { - log.info("wttr listening on port {d}", .{self.httpz_server.config.port.?}); + // httpz's listen address is a union under Zig 0.16. `IpAddress` formats as + // "host:port" for both v4 and v6, so this reports address and port; the + // unix arm keeps this from illegally reading an inactive union field. + switch (self.httpz_server.config.address) { + .ip => |ip| log.info("wttr listening on {f}", .{ip}), + .unix => |path| log.info("wttr listening on unix socket {s}", .{path}), + } try self.httpz_server.listen(); } @@ -133,28 +141,28 @@ pub const MockHarness = struct { const Cache = @import("../cache/Cache.zig"); pub fn init(allocator: std.mem.Allocator) !MockHarness { - const config = try Config.load(allocator); + const config = try Config.loadForTest(allocator); errdefer config.deinit(allocator); if (build_options.download_geoip) { const GeoLite2 = @import("../location/GeoLite2.zig"); - try GeoLite2.ensureDatabase(allocator, config.geolite_path); + try GeoLite2.ensureDatabase(allocator, std.testing.io, config.geolite_path); } const geoip = try allocator.create(GeoIp); errdefer allocator.destroy(geoip); - geoip.* = GeoIp.init(allocator, config.geolite_path, config) catch + geoip.* = GeoIp.init(allocator, std.testing.io, config.geolite_path, config) catch return error.SkipZigTest; errdefer geoip.deinit(); var geocache = try allocator.create(GeoCache); errdefer allocator.destroy(geocache); - geocache.* = try GeoCache.init(allocator, null); + geocache.* = try GeoCache.init(allocator, std.testing.io, null); errdefer geocache.deinit(); const resolver = try allocator.create(Resolver); errdefer allocator.destroy(resolver); - resolver.* = Resolver.init(allocator, geoip, geocache, null); + resolver.* = Resolver.init(allocator, std.testing.io, geoip, geocache, null); const mock = try allocator.create(Mock); errdefer allocator.destroy(mock); @@ -190,7 +198,7 @@ pub const MockHarness = struct { // Add wildcard response for tests try mock.responses.put(try allocator.dupe(u8, "*"), try allocator.dupe(u8, "{}")); - var cache = try Cache.init(allocator, .{ + var cache = try Cache.init(allocator, std.testing.io, .{ .max_entries = 100, .cache_dir = config.cache_dir, }); @@ -208,6 +216,7 @@ pub const MockHarness = struct { .provider = mock.provider(cache), .resolver = resolver, .geoip = geoip, + .io = std.testing.io, }, }; } @@ -280,7 +289,7 @@ test "handleWeather: client IP only" { defer ht.deinit(); // Set connection address to a valid IP that will be in GeoIP database - ht.req.address = try std.net.Address.parseIp("73.158.64.1", 0); + ht.req.address = try std.Io.net.IpAddress.parse("73.158.64.1", 0); ht.url("/"); diff --git a/src/http/handler.zig b/src/http/handler.zig index 6263d99..a2996c3 100644 --- a/src/http/handler.zig +++ b/src/http/handler.zig @@ -20,6 +20,8 @@ pub const HandleWeatherOptions = struct { provider: WeatherProvider, resolver: *Resolver, geoip: *@import("../location/GeoIp.zig"), + /// Zig 0.16 reads the clock (and everything else) through `Io`. + io: std.Io, }; /// Only used for shutdown route (/stop) in debug mode @@ -97,6 +99,9 @@ fn handleWeatherInternal( client_ip: []const u8, ) !void { const req_alloc = req.arena; + // Read the clock once per request and pass it down, so the render layer + // stays free of I/O. + const now_unix_s = std.Io.Timestamp.now(opts.io, .real).toSeconds(); // Check for PNG request const is_png = if (comptime build_options.enable_png) @@ -183,7 +188,7 @@ fn handleWeatherInternal( const png_writer = &png_writer_impl; render_options.format = .ansi; // Force ANSI for PNG - try renderWeatherData(png_writer, weather, params, render_options); + try renderWeatherData(png_writer, weather, params, render_options, now_unix_s); const text_output = png_buffer[0..png_writer_impl.end]; try png_renderer.buffer.appendSlice(req_alloc, text_output); @@ -208,7 +213,7 @@ fn handleWeatherInternal( ); res.content_type = if (render_options.format == .html) .HTML else .TEXT; } - try renderWeatherData(res.writer(), weather, params, render_options); + try renderWeatherData(res.writer(), weather, params, render_options, now_unix_s); } fn renderWeatherData( @@ -216,6 +221,9 @@ fn renderWeatherData( weather: types.WeatherData, params: QueryParams, render_options: Formatted.RenderOptions, + /// Current unix time, read once by the caller. Keeps the render path free of + /// I/O now that Zig 0.16 requires an `Io` to read the clock. + now_unix_s: i64, ) !void { if (params.format) |fmt| { if (std.mem.eql(u8, fmt, "1")) { @@ -229,11 +237,11 @@ fn renderWeatherData( } else if (std.mem.eql(u8, fmt, "j1")) { try Json.render(writer, weather); } else if (std.mem.eql(u8, fmt, "p1")) { - try Prometheus.render(writer, weather); + try Prometheus.render(writer, weather, now_unix_s); } else if (std.mem.eql(u8, fmt, "v2")) { try V2.render(writer, weather, render_options.use_imperial); } else { - try Custom.render(writer, weather, fmt, render_options.use_imperial); + try Custom.render(writer, weather, fmt, render_options.use_imperial, now_unix_s); } } else { try Formatted.render(writer, weather, render_options); diff --git a/src/location/GeoCache.zig b/src/location/GeoCache.zig index 7180182..c53e6af 100644 --- a/src/location/GeoCache.zig +++ b/src/location/GeoCache.zig @@ -6,6 +6,8 @@ const GeoCache = @This(); const log = std.log.scoped(.geocache); allocator: std.mem.Allocator, +/// Zig 0.16 requires an explicit `Io` for filesystem access. +io: std.Io, cache: std.StringHashMap(CachedLocation), cache_file: ?[]const u8, dirty: bool, @@ -16,22 +18,23 @@ pub const CachedLocation = struct { coords: Coordinates, }; -pub fn init(allocator: std.mem.Allocator, cache_file: ?[]const u8) !GeoCache { +pub fn init(allocator: std.mem.Allocator, io: std.Io, cache_file: ?[]const u8) !GeoCache { var cache = std.StringHashMap(CachedLocation).init(allocator); // Load from file if specified if (cache_file) |file_path| { - loadFromFile(allocator, &cache, file_path) catch |err| { + loadFromFile(allocator, io, &cache, file_path) catch |err| { log.warn("Failed to load geocoding cache from {s}: {}", .{ file_path, err }); }; } return GeoCache{ .allocator = allocator, + .io = io, .cache = cache, .cache_file = if (cache_file) |f| try allocator.dupe(u8, f) else null, .dirty = false, - .last_save = std.time.milliTimestamp(), + .last_save = std.Io.Timestamp.now(io, .real).toMilliseconds(), }; } @@ -73,7 +76,7 @@ pub fn saveIfNeeded(self: *GeoCache) void { const cache_file = self.cache_file orelse return; - const now = std.time.milliTimestamp(); + const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds(); const elapsed_ms = now - self.last_save; const fifteen_minutes_ms = 15 * std.time.ms_per_min; @@ -116,11 +119,8 @@ fn load(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), } } -fn loadFromFile(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), file_path: []const u8) !void { - const file = try std.fs.cwd().openFile(file_path, .{}); - defer file.close(); - - const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); // 10MB max +fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, cache: *std.StringHashMap(CachedLocation), file_path: []const u8) !void { + const content = try std.Io.Dir.cwd().readFileAlloc(io, file_path, allocator, .limited(10 * 1024 * 1024)); // 10MB max defer allocator.free(content); try load(allocator, cache, content); @@ -149,11 +149,11 @@ fn save(self: *GeoCache, writer: *std.Io.Writer) !void { } fn saveToFile(self: *GeoCache, file_path: []const u8) !void { - const file = try std.fs.cwd().createFile(file_path, .{}); - defer file.close(); + const file = try std.Io.Dir.cwd().createFile(self.io, file_path, .{}); + defer file.close(self.io); var buffer: [4096]u8 = undefined; - var file_writer = file.writer(&buffer); + var file_writer = file.writer(self.io, &buffer); const writer = &file_writer.interface; try self.save(writer); @@ -162,7 +162,7 @@ fn saveToFile(self: *GeoCache, file_path: []const u8) !void { test "GeoCache basic operations" { const allocator = std.testing.allocator; - var cache = try GeoCache.init(allocator, null); + var cache = try GeoCache.init(allocator, std.testing.io, null); defer cache.deinit(); // Test put and get @@ -182,7 +182,7 @@ test "GeoCache basic operations" { test "GeoCache miss returns null" { const allocator = std.testing.allocator; - var cache = try GeoCache.init(allocator, null); + var cache = try GeoCache.init(allocator, std.testing.io, null); defer cache.deinit(); const result = cache.get("NonExistent"); @@ -191,7 +191,7 @@ test "GeoCache miss returns null" { test "save produces valid JSON" { const allocator = std.testing.allocator; - var cache = try GeoCache.init(allocator, null); + var cache = try GeoCache.init(allocator, std.testing.io, null); defer cache.deinit(); try cache.put("London", .{ @@ -245,7 +245,7 @@ test "load parses valid JSON" { test "save and load round-trip" { const allocator = std.testing.allocator; - var cache1 = try GeoCache.init(allocator, null); + var cache1 = try GeoCache.init(allocator, std.testing.io, null); defer cache1.deinit(); try cache1.put("Berlin", .{ diff --git a/src/location/GeoIp.zig b/src/location/GeoIp.zig index d1bef6a..348b106 100644 --- a/src/location/GeoIp.zig +++ b/src/location/GeoIp.zig @@ -11,6 +11,24 @@ const c = @cImport({ const GeoIP = @This(); const log = std.log.scoped(.geoip); +// libmaxminddb writes into `MMDB_entry_data_s` values that we declare on the +// Zig stack, so Zig's view of that struct must match the C compiler's exactly. +// +// It did not: `MMDB_UINT128_USING_MODE` types `mmdb_uint128_t` as +// `unsigned int __attribute__((__mode__(TI)))`, which translate-c cannot +// represent. Zig saw 32 bytes / 8-byte alignment where clang saw 48 / 16, so +// every `MMDB_get_value` call wrote `entry_data->offset` (C offset 32) past the +// end of the variable Zig had reserved -- silent stack corruption whose blast +// radius depended on the adjacent stack slot. +// +// `build.zig` now selects `MMDB_UINT128_IS_BYTE_ARRAY`, under which both +// compilers agree. This assertion fails the build if that ever regresses, +// rather than letting the corruption return unnoticed. +comptime { + std.debug.assert(@sizeOf(c.MMDB_entry_data_s) == 40); + std.debug.assert(@alignOf(c.MMDB_entry_data_s) == 8); +} + const FallbackClient = union(enum) { ip2location: *Ip2location, ipwhois: *IpWhoIs, @@ -40,7 +58,7 @@ mmdb: *c.MMDB_s, fallback_client: FallbackClient, allocator: std.mem.Allocator, -pub fn init(allocator: std.mem.Allocator, db_path: []const u8, config: Config) !GeoIP { +pub fn init(allocator: std.mem.Allocator, io: std.Io, db_path: []const u8, config: Config) !GeoIP { const path_z = try std.heap.c_allocator.dupeZ(u8, db_path); defer std.heap.c_allocator.free(path_z); @@ -55,7 +73,7 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, config: Config) ! .ip2location => blk: { const client = try allocator.create(Ip2location); errdefer allocator.destroy(client); - client.* = try Ip2location.init(allocator, config.ip2location_api_key, config.ip2location_cache_file); + client.* = try Ip2location.init(allocator, io, config.ip2location_api_key, config.ip2location_cache_file); std.log.info( "GeoIP fallback: IP2Location ({s}, cache: {s})", .{ if (config.ip2location_api_key) |_| "key provided, 50k/mo limit" else "no key, 1k/day limit", config.ip2location_cache_file }, @@ -65,7 +83,7 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, config: Config) ! .ipwhois => blk: { const client = try allocator.create(IpWhoIs); errdefer allocator.destroy(client); - client.* = try IpWhoIs.init(allocator, config.ipwhois_cache_file); + client.* = try IpWhoIs.init(allocator, io, config.ipwhois_cache_file); std.log.info("GeoIP fallback: ipwho.is (cache: {s})", .{config.ipwhois_cache_file}); break :blk .{ .ipwhois = client }; }, @@ -229,25 +247,25 @@ test "MMDB functions are callable" { } test "GeoIP init with invalid path fails" { - const config = try Config.load(std.testing.allocator); + const config = try Config.loadForTest(std.testing.allocator); defer config.deinit(std.testing.allocator); - const result = GeoIP.init(std.testing.allocator, "/nonexistent/path.mmdb", config); + const result = GeoIP.init(std.testing.allocator, std.testing.io, "/nonexistent/path.mmdb", config); try std.testing.expectError(error.CannotOpenDatabase, result); } test "isUSIp detects US IPs" { const allocator = std.testing.allocator; - const config = try Config.load(allocator); + const config = try Config.loadForTest(allocator); defer config.deinit(allocator); const build_options = @import("build_options"); const db_path = config.geolite_path; if (build_options.download_geoip) { const GeoLite2 = @import("GeoLite2.zig"); - try GeoLite2.ensureDatabase(std.testing.allocator, db_path); + try GeoLite2.ensureDatabase(std.testing.allocator, std.testing.io, db_path); } - var geoip = GeoIP.init(std.testing.allocator, db_path, config) catch + var geoip = GeoIP.init(std.testing.allocator, std.testing.io, db_path, config) catch return error.SkipZigTest; defer geoip.deinit(); @@ -260,17 +278,17 @@ test "isUSIp detects US IPs" { } test "lookup works" { const allocator = std.testing.allocator; - const config = try Config.load(allocator); + const config = try Config.loadForTest(allocator); defer config.deinit(allocator); const build_options = @import("build_options"); const db_path = config.geolite_path; if (build_options.download_geoip) { const GeoLite2 = @import("GeoLite2.zig"); - try GeoLite2.ensureDatabase(std.testing.allocator, db_path); + try GeoLite2.ensureDatabase(std.testing.allocator, std.testing.io, db_path); } - var geoip = GeoIP.init(std.testing.allocator, db_path, config) catch + var geoip = GeoIP.init(std.testing.allocator, std.testing.io, db_path, config) catch return error.SkipZigTest; defer geoip.deinit(); diff --git a/src/location/GeoLite2.zig b/src/location/GeoLite2.zig index 69d9587..79876f1 100644 --- a/src/location/GeoLite2.zig +++ b/src/location/GeoLite2.zig @@ -1,20 +1,20 @@ const std = @import("std"); const log = std.log.scoped(.geolite2); -pub fn ensureDatabase(allocator: std.mem.Allocator, path: []const u8) !void { - std.fs.cwd().access(path, .{}) catch { +pub fn ensureDatabase(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !void { + std.Io.Dir.cwd().access(io, path, .{}) catch { log.info("GeoLite2 database not found at {s}, will download", .{path}); - try downloadDatabase(allocator, path); + try downloadDatabase(allocator, io, path); log.info("GeoLite2 database downloaded successfully", .{}); return; }; } -fn downloadDatabase(allocator: std.mem.Allocator, path: []const u8) !void { - const latest_url = try getLatestReleaseUrl(allocator); +fn downloadDatabase(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !void { + const latest_url = try getLatestReleaseUrl(allocator, io); defer allocator.free(latest_url); - var client: std.http.Client = .{ .allocator = allocator }; + var client: std.http.Client = .{ .allocator = allocator, .io = io }; defer client.deinit(); const uri = try std.Uri.parse(latest_url); @@ -32,16 +32,17 @@ fn downloadDatabase(allocator: std.mem.Allocator, path: []const u8) !void { // Ensure directory exists if (std.fs.path.dirname(path)) |dir| { - try std.fs.cwd().makePath(dir); + try std.Io.Dir.cwd().createDirPath(io, dir); } - const file = try std.fs.cwd().createFile(path, .{}); - defer file.close(); - try file.writeAll(response_buf[0..writer.end]); + try std.Io.Dir.cwd().writeFile(io, .{ + .sub_path = path, + .data = response_buf[0..writer.end], + }); } -fn getLatestReleaseUrl(allocator: std.mem.Allocator) ![]const u8 { - var client: std.http.Client = .{ .allocator = allocator }; +fn getLatestReleaseUrl(allocator: std.mem.Allocator, io: std.Io) ![]const u8 { + var client: std.http.Client = .{ .allocator = allocator, .io = io }; defer client.deinit(); const api_url = "https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases/latest"; diff --git a/src/location/Ip2location.zig b/src/location/Ip2location.zig index 541959c..2c312b1 100644 --- a/src/location/Ip2location.zig +++ b/src/location/Ip2location.zig @@ -7,18 +7,23 @@ const Self = @This(); const log = std.log.scoped(.ip2location); allocator: Allocator, +/// Zig 0.16 requires an explicit `Io` for both filesystem and HTTP work. It is +/// stored alongside the allocator so only `init` signatures change rather than +/// every method that touches a file or the network. +io: std.Io, api_key: ?[]const u8, http_client: std.http.Client, cache: *Cache, -pub fn init(allocator: Allocator, api_key: ?[]const u8, cache_path: []const u8) !Self { +pub fn init(allocator: Allocator, io: std.Io, api_key: ?[]const u8, cache_path: []const u8) !Self { const cache = try allocator.create(Cache); errdefer allocator.destroy(cache); - cache.* = try .init(allocator, cache_path); + cache.* = try .init(allocator, io, cache_path); return .{ .allocator = allocator, + .io = io, .api_key = if (api_key) |k| try allocator.dupe(u8, k) else null, - .http_client = std.http.Client{ .allocator = allocator }, + .http_client = std.http.Client{ .allocator = allocator, .io = io }, .cache = cache, }; } @@ -31,15 +36,32 @@ pub fn deinit(self: *Self) void { self.allocator.free(k); } +/// An IP address packed into a `u128` cache key, with the address family it +/// came from (4 or 6). +pub const PackedIp = struct { + key: u128, + family: u8, +}; + +/// Packs a textual IP address into a `u128` cache key. +/// +/// Zig 0.16 replaced `std.net.Address` with `std.Io.net.IpAddress`, which +/// exposes the address bytes directly instead of requiring casts through +/// `sockaddr`. The union is exhaustive, so there is no longer an unreachable +/// "unknown family" case to handle. +pub fn packIp(ip_str: []const u8) ?PackedIp { + const addr = std.Io.net.IpAddress.parse(ip_str, 0) catch return null; + return switch (addr) { + .ip4 => |a| .{ .key = std.mem.readInt(u32, &a.bytes, .big), .family = 4 }, + .ip6 => |a| .{ .key = std.mem.readInt(u128, &a.bytes, .big), .family = 6 }, + }; +} + pub fn lookup(self: *Self, ip_str: []const u8) ?Location { // Parse IP to u128 for cache lookup - const addr = std.net.Address.parseIp(ip_str, 0) catch return null; - const ip_u128: u128 = switch (addr.any.family) { - std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))), - std.posix.AF.INET6 => std.mem.readInt(u128, @ptrCast(&addr.in6.sa.addr), .big), - else => return null, - }; - const family: u8 = if (addr.any.family == std.posix.AF.INET) 4 else 6; + const parsed = packIp(ip_str) orelse return null; + const ip_u128 = parsed.key; + const family = parsed.family; // Check cache first if (self.cache.get(ip_u128)) |result| @@ -72,7 +94,7 @@ fn fetch(self: *Self, ip_str: []const u8) !Location { try w.print("&key={s}", .{key}); var response_buf: [4096]u8 = undefined; - var writer = std.io.Writer.fixed(&response_buf); + var writer = std.Io.Writer.fixed(&response_buf); const result = try self.http_client.fetch(.{ .location = .{ .url = w.buffered() }, .method = .GET, @@ -142,13 +164,15 @@ inline fn getString(obj: std.json.ObjectMap, key: []const u8) []const u8 { pub const Cache = struct { allocator: Allocator, + io: std.Io, path: []const u8, entries: std.AutoHashMap(u128, Location), - file: ?std.fs.File, + file: ?std.Io.File, - pub fn init(allocator: Allocator, path: []const u8) !Cache { + pub fn init(allocator: Allocator, io: std.Io, path: []const u8) !Cache { var cache = Cache{ .allocator = allocator, + .io = io, .path = try allocator.dupe(u8, path), .entries = std.AutoHashMap(u128, Location).init(allocator), .file = null, @@ -156,17 +180,17 @@ pub const Cache = struct { errdefer allocator.free(cache.path); // Try to open existing cache file - if (std.fs.openFileAbsolute(path, .{ .mode = .read_write })) |file| { + if (std.Io.Dir.openFileAbsolute(io, path, .{ .mode = .read_write })) |file| { cache.file = file; try cache.load(); } else |err| switch (err) { error.FileNotFound => { // Create new cache file const dir = std.fs.path.dirname(path) orelse return error.InvalidPath; - try std.fs.cwd().makePath(dir); - cache.file = try std.fs.createFileAbsolute(path, .{ .read = true }); + try std.Io.Dir.cwd().createDirPath(io, dir); + cache.file = try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true }); // Write header - try cache.file.?.writeAll("#Ip2location:v2\n"); + try cache.file.?.writePositionalAll(io, "#Ip2location:v2\n", 0); }, else => return err, } @@ -175,7 +199,7 @@ pub const Cache = struct { } pub fn deinit(self: *Cache) void { - if (self.file) |f| f.close(); + if (self.file) |f| f.close(self.io); var it = self.entries.valueIterator(); while (it.next()) |loc| { self.allocator.free(loc.name); @@ -186,10 +210,18 @@ pub const Cache = struct { fn load(self: *Cache) !void { const file = self.file orelse return; - const file_size = try file.getEndPos(); + const file_size = try file.length(self.io); if (file_size == 0) return; - const content = try file.readToEndAlloc(self.allocator, file_size); + const content = try std.Io.Dir.cwd().readFileAlloc( + self.io, + self.path, + self.allocator, + // `Io.Limit` fails when the limit is *reached*, not merely exceeded, + // so a limit of exactly `file_size` would reject a file of that + // size. Allow one extra byte. + .limited64(file_size + 1), + ); defer self.allocator.free(content); var lines = std.mem.splitScalar(u8, content, '\n'); @@ -198,9 +230,9 @@ pub const Cache = struct { if (lines.next()) |first_line| { if (!std.mem.eql(u8, first_line, "#Ip2location:v2")) { log.warn("Cache file missing or invalid header, discarding", .{}); - file.close(); + file.close(self.io); self.file = null; - std.fs.deleteFileAbsolute(self.path) catch |e| { + std.Io.Dir.deleteFileAbsolute(self.io, self.path) catch |e| { log.err("error deleting {s}: {}", .{ self.path, e }); }; return; @@ -233,13 +265,10 @@ pub const Cache = struct { const lon = try std.fmt.parseFloat(f64, lon_str); // Try parsing as IP address first, fall back to u128 - const ip_u128 = if (std.net.Address.parseIp(ip_str, 0)) |addr| blk: { - break :blk switch (addr.any.family) { - std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))), - std.posix.AF.INET6 => std.mem.readInt(u128, @ptrCast(&addr.in6.sa.addr), .big), - else => return error.InvalidIpFamily, - }; - } else |_| try std.fmt.parseInt(u128, ip_str, 10); + const ip_u128 = if (packIp(ip_str)) |parsed| + parsed.key + else + try std.fmt.parseInt(u128, ip_str, 10); const name_copy = try allocator.dupe(u8, name); return .{ @@ -271,7 +300,8 @@ pub const Cache = struct { // Append to file: ip,lat,lon,name if (self.file) |file| { - try file.seekFromEnd(0); + // 0.16 has no seek+write; append at the current end offset. + const end = try file.length(self.io); // Format IP as string for file var buf: [64]u8 = undefined; const ip_str = if (family == 4) @@ -299,7 +329,7 @@ pub const Cache = struct { loc.name, }); defer self.allocator.free(line); - try file.writeAll(line); + try file.writePositionalAll(self.io, line, end); } } }; diff --git a/src/location/IpWhoIs.zig b/src/location/IpWhoIs.zig index 8c51c6a..d408256 100644 --- a/src/location/IpWhoIs.zig +++ b/src/location/IpWhoIs.zig @@ -1,23 +1,27 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const Location = @import("resolver.zig").Location; -const Cache = @import("Ip2location.zig").Cache; +const Ip2location = @import("Ip2location.zig"); +const Cache = Ip2location.Cache; const Self = @This(); const log = std.log.scoped(.ipwhois); allocator: Allocator, +/// Zig 0.16 requires an explicit `Io` for filesystem and HTTP work. +io: std.Io, http_client: std.http.Client, cache: *Cache, -pub fn init(allocator: Allocator, cache_path: []const u8) !Self { +pub fn init(allocator: Allocator, io: std.Io, cache_path: []const u8) !Self { const cache = try allocator.create(Cache); errdefer allocator.destroy(cache); - cache.* = try .init(allocator, cache_path); + cache.* = try .init(allocator, io, cache_path); return .{ .allocator = allocator, - .http_client = std.http.Client{ .allocator = allocator }, + .io = io, + .http_client = std.http.Client{ .allocator = allocator, .io = io }, .cache = cache, }; } @@ -30,13 +34,9 @@ pub fn deinit(self: *Self) void { pub fn lookup(self: *Self, ip_str: []const u8) ?Location { // Parse IP to u128 for cache lookup - const addr = std.net.Address.parseIp(ip_str, 0) catch return null; - const ip_u128: u128 = switch (addr.any.family) { - std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))), - std.posix.AF.INET6 => std.mem.readInt(u128, @ptrCast(&addr.in6.sa.addr), .big), - else => return null, - }; - const family: u8 = if (addr.any.family == std.posix.AF.INET) 4 else 6; + const parsed = Ip2location.packIp(ip_str) orelse return null; + const ip_u128 = parsed.key; + const family = parsed.family; // Check cache first if (self.cache.get(ip_u128)) |result| diff --git a/src/location/resolver.zig b/src/location/resolver.zig index df35468..d499cca 100644 --- a/src/location/resolver.zig +++ b/src/location/resolver.zig @@ -80,13 +80,16 @@ pub const LocationType = enum { /// has a permanent cache pub const Resolver = struct { allocator: std.mem.Allocator, + /// Zig 0.16 requires an explicit `Io` for DNS lookups and HTTP requests. + io: std.Io, geoip: ?*GeoIp, geocache: *GeoCache, airports: ?*Airports, - pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIp, geocache: *GeoCache, airports: ?*Airports) Resolver { + pub fn init(allocator: std.mem.Allocator, io: std.Io, geoip: ?*GeoIp, geocache: *GeoCache, airports: ?*Airports) Resolver { return .{ .allocator = allocator, + .io = io, .geoip = geoip, .geocache = geocache, .airports = airports, @@ -124,22 +127,28 @@ pub const Resolver = struct { } fn resolveDomain(self: *Resolver, domain: []const u8) !Location { - // Use std.net to resolve domain to IP - const addr_list = std.net.getAddressList(self.allocator, domain, 0) catch { - return error.LocationNotFound; - }; - defer addr_list.deinit(); + // Zig 0.16 replaced `std.net.getAddressList` with a queue-based lookup + // on `Io.net.HostName`. A capacity of 16 is documented as sufficient to + // avoid blocking, and `lookup` closes the queue when it finishes. + const host_name = std.Io.net.HostName.init(domain) catch return error.LocationNotFound; - if (addr_list.addrs.len == 0) { - return error.LocationNotFound; - } + var results: [16]std.Io.net.HostName.LookupResult = undefined; + var queue: std.Io.Queue(std.Io.net.HostName.LookupResult) = .init(&results); + host_name.lookup(self.io, &queue, .{ .port = 0 }) catch return error.LocationNotFound; - // Format IP address using std.net.Address.format - const addr = addr_list.addrs[0]; - var buf: [64]u8 = undefined; - const ip_str = try std.fmt.bufPrint(&buf, "{f}", .{addr}); + // Take the first address; `canonical_name` entries are not addresses. + while (queue.getOne(self.io)) |result| { + switch (result) { + .address => |addr| { + var buf: [64]u8 = undefined; + const ip_str = try std.fmt.bufPrint(&buf, "{f}", .{addr}); + return self.resolveIP(ip_str); + }, + .canonical_name => continue, + } + } else |_| {} - return self.resolveIP(ip_str); + return error.LocationNotFound; } fn resolveGeocoded(self: *Resolver, name: []const u8) !Location { @@ -164,7 +173,7 @@ pub const Resolver = struct { ); defer self.allocator.free(url); - var client = std.http.Client{ .allocator = self.allocator }; + var client = std.http.Client{ .allocator = self.allocator, .io = self.io }; defer client.deinit(); const uri = try std.Uri.parse(url); @@ -285,9 +294,9 @@ test "detect location type" { test "resolver init" { const allocator = std.testing.allocator; - var geocache = try GeoCache.init(allocator, null); + var geocache = try GeoCache.init(allocator, std.testing.io, null); defer geocache.deinit(); - const resolver = Resolver.init(allocator, null, &geocache, null); + const resolver = Resolver.init(allocator, std.testing.io, null, &geocache, null); try std.testing.expect(resolver.geoip == null); try std.testing.expect(resolver.airports == null); } @@ -295,23 +304,23 @@ test "resolver init" { test "resolve IP address with GeoIP" { const allocator = std.testing.allocator; const Config = @import("../Config.zig"); - const config = try Config.load(allocator); + const config = try Config.loadForTest(allocator); defer config.deinit(allocator); const build_options = @import("build_options"); if (build_options.download_geoip) { const GeoLite2 = @import("GeoLite2.zig"); - try GeoLite2.ensureDatabase(allocator, config.geolite_path); + try GeoLite2.ensureDatabase(allocator, std.testing.io, config.geolite_path); } - var geoip = GeoIp.init(allocator, config.geolite_path, config) catch + var geoip = GeoIp.init(allocator, std.testing.io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); - var geocache = try GeoCache.init(allocator, null); + var geocache = try GeoCache.init(allocator, std.testing.io, null); defer geocache.deinit(); - var resolver = Resolver.init(allocator, &geoip, &geocache, null); + var resolver = Resolver.init(allocator, std.testing.io, &geoip, &geocache, null); // Use IP that's known to have coordinates in GeoLite2 database const test_ip = "73.158.64.1"; diff --git a/src/main.zig b/src/main.zig index 9bb9217..186699b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,12 +11,13 @@ const Resolver = @import("location/resolver.zig").Resolver; const GeoLite2 = @import("location/GeoLite2.zig"); const version = @import("build_options").version; -pub fn main() !u8 { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); +/// Zig 0.16 entry point: the runtime supplies allocators, the `Io` +/// implementation, and the environment map rather than us constructing them. +pub fn main(init: std.process.Init) !u8 { + const allocator = init.gpa; + const io = init.io; - const cfg = try Config.load(allocator); + const cfg = try Config.load(allocator, init.environ_map); defer cfg.deinit(allocator); std.log.info("wttr version {s} starting on {s}:{d}", .{ version, cfg.listen_host, cfg.listen_port }); @@ -29,18 +30,19 @@ pub fn main() !u8 { std.log.info("Geocache: in-memory only", .{}); } - var metno = MetNo.init(allocator, null) catch |err| { + var metno = MetNo.init(allocator, io, init.environ_map, null) catch |err| { if (err == MetNo.MissingIdentificationError) return 1; return err; }; defer metno.deinit(); // Ensure GeoLite2 database exists - try GeoLite2.ensureDatabase(allocator, cfg.geolite_path); + try GeoLite2.ensureDatabase(allocator, io, cfg.geolite_path); // Initialize GeoIP database with configured fallback var geoip = GeoIp.init( allocator, + io, cfg.geolite_path, cfg, ) catch |err| { @@ -50,7 +52,7 @@ pub fn main() !u8 { defer geoip.deinit(); // Initialize geocoding cache - var geocache = try GeoCache.init(allocator, cfg.geocache_file); + var geocache = try GeoCache.init(allocator, io, cfg.geocache_file); defer geocache.deinit(); // Initialize airports database @@ -58,25 +60,26 @@ pub fn main() !u8 { defer airports_db.deinit(); // Initialize location resolver - var resolver = Resolver.init(allocator, &geoip, &geocache, &airports_db); + var resolver = Resolver.init(allocator, io, &geoip, &geocache, &airports_db); - const cache = try Cache.init(allocator, .{ + const cache = try Cache.init(allocator, io, .{ .max_entries = cfg.cache_size, .cache_dir = cfg.cache_dir, }); defer cache.deinit(); - var rate_limiter = try RateLimiter.init(allocator, .{ + var rate_limiter = try RateLimiter.init(allocator, io, .{ .capacity = 300, .refill_rate = 5, .refill_interval_ms = 200, }); defer rate_limiter.deinit(); - var server = try Server.init(allocator, cfg.listen_host, cfg.listen_port, .{ + var server = try Server.init(allocator, io, cfg.listen_host, cfg.listen_port, .{ .provider = metno.provider(cache), .resolver = &resolver, .geoip = &geoip, + .io = io, }, &rate_limiter); // Only set up the server instance in debug mode diff --git a/src/render/Custom.zig b/src/render/Custom.zig index 0f9a57b..1eccbb8 100644 --- a/src/render/Custom.zig +++ b/src/render/Custom.zig @@ -8,7 +8,9 @@ const Astronomical = @import("../Astronomical.zig"); const TimeZoneOffsets = @import("../location/timezone_offsets.zig"); const Coordinates = @import("../Coordinates.zig"); -pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []const u8, use_imperial: bool) !void { +/// `now_unix_s` is supplied by the caller so this renderer performs no I/O; +/// Zig 0.16 requires an `Io` to read the clock. +pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []const u8, use_imperial: bool, now_unix_s: i64) !void { var i: usize = 0; while (i < format.len) { if (format[i] == '%' and i + 1 < format.len) { @@ -54,12 +56,12 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons try writer.print("{d:.2} {s}", .{ pressure, unit }); }, 'm' => { - const now = try nowAt(weather.coords); + const now = try nowAt(weather.coords, now_unix_s); const moon = Moon.getPhase(now); try writer.writeAll(moon.emoji()); }, 'M' => { - const now = try nowAt(weather.coords); + const now = try nowAt(weather.coords, now_unix_s); const moon = Moon.getPhase(now); try writer.print("{d}", .{moon.day()}); }, @@ -69,7 +71,7 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons // to make sure the day is correct for this. Even a day off // should actually be ok. Unix timestamp is always UTC, // so we convert to local - const now = try nowAt(weather.coords); + const now = try nowAt(weather.coords, now_unix_s); const astro = Astronomical.init( weather.coords.latitude, weather.coords.longitude, @@ -99,11 +101,11 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons } } -fn nowAt(coords: Coordinates) !i64 { +fn nowAt(coords: Coordinates, now_unix_s: i64) !i64 { const now = if (@import("builtin").is_test) (try zeit.Time.fromISO8601("2026-01-09")).instant() else - try zeit.instant(.{}); + zeit.instant(.{ .unix_timestamp = now_unix_s }, &zeit.utc); const offset = TimeZoneOffsets.getTimezoneOffset(coords); const new = if (offset >= 0) try now.add(.{ .minutes = @abs(offset) }) @@ -133,6 +135,9 @@ const test_weather = types.WeatherData{ .forecast = &.{}, }; +/// Fixed timestamp (2026-01-09T00:00:00Z) so rendered output is deterministic. +const test_now_unix_s: i64 = 1767916800; + test "render custom format with location and temp" { const allocator = std.testing.allocator; @@ -158,7 +163,7 @@ test "render custom format with location and temp" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, weather, "%l: %c %t", false); + try render(&writer, weather, "%l: %c %t", false, test_now_unix_s); const output = output_buf[0..writer.end]; @@ -191,7 +196,7 @@ test "render custom format with newline" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, weather, "%l%n%C", false); + try render(&writer, weather, "%l%n%C", false, test_now_unix_s); const output = output_buf[0..writer.end]; @@ -223,7 +228,7 @@ test "render custom format with humidity and pressure" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, weather, "Humidity: %h, Pressure: %P", false); + try render(&writer, weather, "Humidity: %h, Pressure: %P", false, test_now_unix_s); const output = output_buf[0..writer.end]; @@ -256,7 +261,7 @@ test "render custom format with imperial units" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, weather, "%t %w %p", true); + try render(&writer, weather, "%t %w %p", true, test_now_unix_s); const output = output_buf[0..writer.end]; @@ -269,7 +274,7 @@ test "render custom format with feels like temp" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, test_weather, "%f", false); + try render(&writer, test_weather, "%f", false, test_now_unix_s); const output = output_buf[0..writer.end]; try std.testing.expectEqualStrings("+10.0°C", output); @@ -279,7 +284,7 @@ test "render custom format with moon phase" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, test_weather, "%m", false); + try render(&writer, test_weather, "%m", false, test_now_unix_s); const output = output_buf[0..writer.end]; try std.testing.expectEqualStrings("🌗", output); @@ -289,7 +294,7 @@ test "render custom format with moon day" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, test_weather, "%M", false); + try render(&writer, test_weather, "%M", false, test_now_unix_s); const output = output_buf[0..writer.end]; try std.testing.expectEqualStrings("21", output); @@ -302,7 +307,7 @@ test "render custom format with astronomical dawn" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, test_weather_astro, "%D", false); + try render(&writer, test_weather_astro, "%D", false, test_now_unix_s); const output = output_buf[0..writer.end]; try std.testing.expectEqualStrings("07:12", output); @@ -315,7 +320,7 @@ test "render custom format with astronomical sunrise" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, test_weather_astro, "%S", false); + try render(&writer, test_weather_astro, "%S", false, test_now_unix_s); const output = output_buf[0..writer.end]; try std.testing.expectEqualStrings("07:45", output); @@ -328,7 +333,7 @@ test "render custom format with astronomical zenith" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, test_weather_astro, "%z", false); + try render(&writer, test_weather_astro, "%z", false, test_now_unix_s); const output = output_buf[0..writer.end]; try std.testing.expectEqualStrings("12:14", output); @@ -341,7 +346,7 @@ test "render custom format with astronomical sunset" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, test_weather_astro, "%s", false); + try render(&writer, test_weather_astro, "%s", false, test_now_unix_s); const output = output_buf[0..writer.end]; try std.testing.expectEqualStrings("16:44", output); @@ -354,7 +359,7 @@ test "render custom format with astronomical dusk" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, test_weather_astro, "%d", false); + try render(&writer, test_weather_astro, "%d", false, test_now_unix_s); const output = output_buf[0..writer.end]; try std.testing.expectEqualStrings("17:17", output); @@ -364,7 +369,7 @@ test "render custom format with percent sign" { var output_buf: [1024]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, test_weather, "%%", false); + try render(&writer, test_weather, "%%", false, test_now_unix_s); const output = output_buf[0..writer.end]; try std.testing.expectEqualStrings("%", output); diff --git a/src/render/Formatted.zig b/src/render/Formatted.zig index 3ef0182..5f820d0 100644 --- a/src/render/Formatted.zig +++ b/src/render/Formatted.zig @@ -163,7 +163,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re const vis_unit = if (options.use_imperial) "mi" else "km"; try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit }); } else { - try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); + try w.print("{s}\n", .{std.mem.trimEnd(u8, art[3], " ")}); } try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit }); }, @@ -180,7 +180,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re const vis_unit = if (options.use_imperial) "mi" else "km"; try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit }); } else { - try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); + try w.print("{s}\n", .{std.mem.trimEnd(u8, art[3], " ")}); } try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit }); }, @@ -196,7 +196,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re const vis_unit = if (options.use_imperial) "mi" else "km"; try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit }); } else { - try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); + try w.print("{s}\n", .{std.mem.trimEnd(u8, art[3], " ")}); } try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit }); }, @@ -217,24 +217,28 @@ fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderO // Format date using gofmt: "Mon 2 Jan" (compressed) const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day }; - var date_stream = std.io.fixedBufferStream(&date_str); - try date_time.gofmt(date_stream.writer(), "Mon 2 Jan"); - const date_len = date_stream.pos; + // 0.16 removed fixedBufferStream; `Io.Writer.fixed` is the replacement + // and tracks the written length in `end`. + var date_stream = std.Io.Writer.fixed(&date_str); + try date_time.gofmt(&date_stream, "Mon 2 Jan"); + const date_len = date_stream.end; try w.print("\n{s}\n", .{date_str[0..date_len]}); try w.print("{s} {s}\n", .{ art[0], day.condition }); try w.print("{s} {d:.0}{s} / {d:.0}{s}\n", .{ art[1], max_temp, temp_unit, min_temp, temp_unit }); - try w.print("{s}\n", .{std.mem.trimRight(u8, art[2], " ")}); - try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); - try w.print("{s}\n", .{std.mem.trimRight(u8, art[4], " ")}); + try w.print("{s}\n", .{std.mem.trimEnd(u8, art[2], " ")}); + try w.print("{s}\n", .{std.mem.trimEnd(u8, art[3], " ")}); + try w.print("{s}\n", .{std.mem.trimEnd(u8, art[4], " ")}); return; } // Format date using gofmt: "Mon _2 Jan" (justified with space padding) const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day }; - var date_stream = std.io.fixedBufferStream(&date_str); - try date_time.gofmt(date_stream.writer(), "Mon _2 Jan"); - const date_len = date_stream.pos; + // 0.16 removed fixedBufferStream; `Io.Writer.fixed` is the replacement + // and tracks the written length in `end`. + var date_stream = std.Io.Writer.fixed(&date_str); + try date_time.gofmt(&date_stream, "Mon _2 Jan"); + const date_len = date_stream.end; if (!options.narrow) { try w.writeAll(" ┌─────────────┐\n"); @@ -759,7 +763,7 @@ fn testArt(data: types.WeatherData) !void { format, ); for (target, 1..) |line, i| { - const trimmed = std.mem.trimRight(u8, line, " "); + const trimmed = std.mem.trimEnd(u8, line, " "); std.testing.expect(std.mem.indexOf(u8, output, trimmed) != null) catch |e| { std.log.err( "Test failure, weather code {}, format {}, line {d}. Line '{s}', Output:\n{s}\n", @@ -964,7 +968,7 @@ test "temperature matches between ansi and custom format" { var custom_buf: [1024]u8 = undefined; var custom_writer = std.Io.Writer.fixed(&custom_buf); - try custom.render(&custom_writer, data, "%t", true); + try custom.render(&custom_writer, data, "%t", true, 1767916800); const output = custom_buf[0..custom_writer.end]; diff --git a/src/render/Prometheus.zig b/src/render/Prometheus.zig index 19bbde1..03f4bc8 100644 --- a/src/render/Prometheus.zig +++ b/src/render/Prometheus.zig @@ -3,7 +3,10 @@ const types = @import("../weather/types.zig"); const Moon = @import("../Moon.zig"); const utils = @import("utils.zig"); -pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void { +/// `now_unix_s` is supplied by the caller rather than read from the clock here: +/// Zig 0.16 requires an `Io` to read time, and keeping renderers free of I/O +/// leaves them pure and directly testable. +pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, now_unix_s: i64) !void { // Current conditions try writer.print("# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius\n", .{}); @@ -86,8 +89,7 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void { try writer.print("snowfall_cm{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not in our data // Moon phase - use current time for simplicity - const timestamp = std.time.timestamp(); - const moon = Moon.getPhase(timestamp); + const moon = Moon.getPhase(now_unix_s); try writer.print("# HELP astronomy_moon_illumination Percentage of the moon illuminated\n", .{}); try writer.print("astronomy_moon_illumination{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, moon.illuminated * 100 }); @@ -109,6 +111,9 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void { } } +/// Fixed timestamp (2026-01-09T00:00:00Z) so rendered output is deterministic. +const test_now_unix_s: i64 = 1767916800; + test "prometheus format includes required metrics" { const allocator = std.testing.allocator; @@ -145,7 +150,7 @@ test "prometheus format includes required metrics" { var output_buf: [8192]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, weather); + try render(&writer, weather, test_now_unix_s); const output = output_buf[0..writer.end]; @@ -182,7 +187,7 @@ test "prometheus format has proper help comments" { var output_buf: [4096]u8 = undefined; var writer = std.Io.Writer.fixed(&output_buf); - try render(&writer, weather); + try render(&writer, weather, test_now_unix_s); const output = output_buf[0..writer.end]; diff --git a/src/weather/MetNo.zig b/src/weather/MetNo.zig index f711375..59fc59f 100644 --- a/src/weather/MetNo.zig +++ b/src/weather/MetNo.zig @@ -60,29 +60,41 @@ const weather_code_entries = [_]MetNoOpenWeatherEntry{ .{ "snowshowers", .snow_shower }, .{ "snowshowersandthunder", .thunderstorm }, // zig fmt: on - }; +}; const WeatherCodeMap = std.StaticStringMap(types.WeatherCode); const weather_code_map = WeatherCodeMap.initComptime(weather_code_entries); allocator: std.mem.Allocator, +/// Zig 0.16 requires an explicit `Io` for HTTP work. +io: std.Io, identifying_email: []const u8, -pub fn init(allocator: std.mem.Allocator, identifying_email: ?[]const u8) !MetNo { - const email = identifying_email orelse blk: { - const env_email = std.process.getEnvVarOwned(allocator, "METNO_TOS_IDENTIFYING_EMAIL") catch |err| { - if (err == error.EnvironmentVariableNotFound) { - std.log.err("Met.no Terms of Service require identification. Set METNO_TOS_IDENTIFYING_EMAIL environment variable", .{}); - std.log.err("See \x1b]8;;https://api.met.no/doc/TermsOfService\x1b\\https://api.met.no/doc/TermsOfService\x1b]8;;\x1b\\ for more information", .{}); - return MissingIdentificationError; - } - return err; +/// Zig 0.16 removed `std.process.getEnvVarOwned`; the environment map is now +/// threaded in from `main`. `Map.get` returns a borrowed slice, so the address +/// is duplicated unconditionally to keep `identifying_email` uniformly owned by +/// this struct (previously an explicitly-passed address would have been freed +/// out from under the caller by `deinit`). +pub fn init( + allocator: std.mem.Allocator, + io: std.Io, + env: *const std.process.Environ.Map, + identifying_email: ?[]const u8, +) !MetNo { + const email = if (identifying_email) |provided| + try allocator.dupe(u8, provided) + else blk: { + const env_email = env.get("METNO_TOS_IDENTIFYING_EMAIL") orelse { + std.log.err("Met.no Terms of Service require identification. Set METNO_TOS_IDENTIFYING_EMAIL environment variable", .{}); + std.log.err("See \x1b]8;;https://api.met.no/doc/TermsOfService\x1b\\https://api.met.no/doc/TermsOfService\x1b]8;;\x1b\\ for more information", .{}); + return MissingIdentificationError; }; - break :blk env_email; + break :blk try allocator.dupe(u8, env_email); }; return MetNo{ .allocator = allocator, + .io = io, .identifying_email = email, }; } @@ -110,7 +122,7 @@ fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) defer self.allocator.free(url); // Fetch weather data from met.no API - var client = std.http.Client{ .allocator = self.allocator }; + var client = std.http.Client{ .allocator = self.allocator, .io = self.io }; defer client.deinit(); const uri = try std.Uri.parse(url); @@ -297,28 +309,28 @@ fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value, // Save previous day if exists if (current_date) |prev_date| { if (day_temps.items.len > 0) { - var max_temp: f32 = day_temps.items[0]; - var min_temp: f32 = day_temps.items[0]; - for (day_temps.items) |t| { - if (t > max_temp) max_temp = t; - if (t < min_temp) min_temp = t; - } + var max_temp: f32 = day_temps.items[0]; + var min_temp: f32 = day_temps.items[0]; + for (day_temps.items) |t| { + if (t > max_temp) max_temp = t; + if (t < min_temp) min_temp = t; + } - const symbol = day_symbol orelse "clearsky_day"; + const symbol = day_symbol orelse "clearsky_day"; - // Return all hourly forecasts - let the renderer decide which to display - const hourly_slice = try day_all_hours.toOwnedSlice(allocator); + // Return all hourly forecasts - let the renderer decide which to display + const hourly_slice = try day_all_hours.toOwnedSlice(allocator); - try days.append(allocator, .{ - .date = prev_date, - .max_temp_c = max_temp, - .min_temp_c = min_temp, - .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)), - .weather_code = symbolCodeToWeatherCode(symbol), - .hourly = hourly_slice, - }); + try days.append(allocator, .{ + .date = prev_date, + .max_temp_c = max_temp, + .min_temp_c = min_temp, + .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)), + .weather_code = symbolCodeToWeatherCode(symbol), + .hourly = hourly_slice, + }); - if (days.items.len >= 3) break; + if (days.items.len >= 3) break; } } diff --git a/src/weather/Provider.zig b/src/weather/Provider.zig index 9890b66..744b6f8 100644 --- a/src/weather/Provider.zig +++ b/src/weather/Provider.zig @@ -34,8 +34,12 @@ pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, coords: Coordi const raw = try self.vtable.fetchRaw(self.ptr, allocator, coords); defer allocator.free(raw); - // TTL: 1000-2000 seconds (16-33 minutes) to avoid thundering herd - const ttl = 1000 + std.crypto.random.intRangeAtMost(u64, 0, 1000); + // TTL: 1000-2000 seconds (16-33 minutes) to avoid thundering herd. + // Zig 0.16 sources randomness from `Io`; `std.crypto.random` is gone. The + // modulo bias here is irrelevant for TTL jitter. + var jitter_bytes: [8]u8 = undefined; + std.Io.random(self.cache.io, &jitter_bytes); + const ttl = 1000 + std.mem.readInt(u64, &jitter_bytes, .little) % 1001; try self.cache.put(cache_key, raw, ttl); // Parse and return @@ -50,7 +54,7 @@ test "provider fetch" { const Mock = @import("Mock.zig"); const MetNo = @import("MetNo.zig"); - const cache = try Cache.init(std.testing.allocator, .{ .max_entries = 10, .cache_dir = null }); + const cache = try Cache.init(std.testing.allocator, std.testing.io, .{ .max_entries = 10, .cache_dir = null }); defer cache.deinit(); var mock = try Mock.init(std.testing.allocator);