Compare commits

...

9 commits

Author SHA1 Message Date
d2e0f43bf0
remove anonymous import
All checks were successful
Generic zig build / build (push) Successful in 2m21s
Generic zig build / deploy (push) Successful in 18s
2026-08-05 13:33:52 -07:00
16319a9f47
move to srf for all cache file formats 2026-08-05 13:28:39 -07:00
11364b10e7
fix ipv6, @domain, mmdb byte swaps, geocache location 2026-08-05 12:36:14 -07:00
18ee310dd0
introduce concept of pins, a way to override geoip data 2026-08-05 12:18:04 -07:00
3a69b04b22
automatically refresh the geoip db 2026-08-04 17:01:28 -07:00
6bc5b5a209
add .tmp/ to gitignore 2026-08-04 16:55:15 -07:00
a5a9d0d7d7
move to cache dir for downloaded geoip db during tests 2026-08-04 16:25:34 -07:00
95f677eb7f
remove png support 2026-08-04 16:19:37 -07:00
f9919d9521
upgrade to zig 0.16.0/fix latent MMDB C interop issue 2026-08-04 16:06:14 -07:00
37 changed files with 2656 additions and 1192 deletions

3
.gitignore vendored
View file

@ -2,3 +2,6 @@
.zig-cache/
zig-out/
coverage/
.tmp/
# Zig 0.16 stores fetched packages in-tree rather than the global cache
zig-pkg/

View file

@ -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"

View file

@ -29,7 +29,7 @@ repos:
- id: test
name: Run zig build test
entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=80"]
args: ["build", "coverage", "-Ddownload-geoip", "-Dcoverage-threshold=80"]
language: system
types: [file]
pass_filenames: false

View file

@ -2,23 +2,28 @@
Features not yet implemented in the Zig version:
## 1. PNG Generation
- Render weather reports as PNG images
- Support transparency and custom styling
- Requires image rendering library integration
## 2. Language/Localization
## 1. Language/Localization
- Accept-Language header parsing
- lang query parameter support
- Translation of weather conditions and text (54 languages)
## 3. Json output
## 2. Json output
- Does not match wttr.in format
## 4. Moon endpoint
## 3. Moon endpoint
- `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented
- Moon phase calculation is implemented and available in custom format (%m, %M)
## ~~PNG Generation~~
- Render weather reports as PNG images
- Support transparency and custom styling
This was implemented behind a `-Denable-png` build flag and has been removed. It
was slow and memory-intensive (a full font stack plus megabyte-scale buffers per
request), carried its own set of bugs, and saw effectively no use — it did not
justify its maintenance cost or the dependency surface it pulled in (zigimg,
freetype via ghostty, and two embedded font packages).
## ~~Multiple Locations Support~~
- Handle colon-separated locations (e.g., `London:Paris:Berlin`)
- Process and display weather for multiple cities in one request

View file

@ -3,8 +3,8 @@
*wttr* is a console-oriented weather forecast service written in Zig, based on [wttr.in](https://wttr.in).
wttr supports various information representation methods like terminal-oriented
ANSI-sequences for console HTTP clients (curl, httpie, or wget), HTML for web
browsers, or PNG for graphical viewers.
ANSI-sequences for console HTTP clients (curl, httpie, or wget), and HTML for
web browsers.
## Usage

126
build.zig
View file

@ -8,11 +8,10 @@ pub fn build(b: *std.Build) void {
const version = GitVersion.getVersion(b, .{});
const download_geoip = b.option(bool, "download-geoip", "Download GeoIP database for tests") orelse false;
const enable_png = b.option(bool, "enable-png", "Enable PNG image generation (adds zigimg, freetype, and embedded fonts)") orelse false;
const build_options = b.addOptions();
build_options.addOption([]const u8, "version", version);
build_options.addOption(bool, "download_geoip", download_geoip);
build_options.addOption(bool, "enable_png", enable_png);
build_options.addOption([]const u8, "cache_dir", resolveCacheDir(b));
const httpz = b.dependency("httpz", .{
.target = target,
@ -24,21 +23,10 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
});
const zigimg = if (enable_png) b.dependency("zigimg", .{
const srf = b.dependency("srf", .{
.target = target,
.optimize = optimize,
}) else null;
const freetype = if (enable_png) b.dependency("ghostty", .{
.target = target,
.optimize = optimize,
}).builder.dependency("freetype", .{
.target = target,
.optimize = optimize,
}) else null;
const jetbrains_mono = if (enable_png) b.dependency("jetbrains_mono", .{}) else null;
const nerd_fonts = if (enable_png) b.dependency("nerd_fonts_symbols_only", .{}) else null;
});
const openflights = b.dependency("openflights", .{});
@ -55,16 +43,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 +64,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 +73,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 +92,38 @@ 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,
// Tell libmaxminddb the target's byte order.
//
// Database floats and doubles are stored big-endian, and
// `get_ieee754_double` only reverses them when this is set; otherwise it
// memcpy's the raw bytes and returns a nonsense value on a little-endian
// host. Autotools normally sets this, so a hand-written config header
// that omits it silently produces garbage coordinates.
//
// Getting this right in the C layer means callers do not have to
// compensate, which they previously did with a byteswap that was correct
// only by accident on little-endian targets and wrong on big-endian ones.
.MMDB_LITTLE_ENDIAN = @as(u8, if (target.result.cpu.arch.endian() == .little) 1 else 0),
});
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,39 +138,25 @@ 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"));
if (zigimg) |dep| root_module.addImport("zigimg", dep.module("zigimg"));
if (freetype) |dep| root_module.addImport("freetype", dep.module("freetype"));
root_module.addAnonymousImport("airports.dat", .{
root_module.addImport("srf", srf.module("srf"));
// A named module rather than an anonymous import. Anonymous imports can put
// the same file in more than one module, which surfaces later as a confusing
// "file exists in multiple modules" error, and they leave the wiring implicit.
root_module.addImport("airports.dat", b.addModule("airports.dat", .{
.root_source_file = openflights.path("data/airports.dat"),
});
if (jetbrains_mono) |dep| root_module.addAnonymousImport("JetBrainsMono-Regular.ttf", .{
.root_source_file = dep.path("fonts/ttf/JetBrainsMono-Regular.ttf"),
});
if (enable_png) root_module.addAnonymousImport("LexiGulim.ttf", .{
.root_source_file = b.path("libs/2914-LexiGulim090423.ttf"),
});
if (nerd_fonts) |dep| root_module.addAnonymousImport("SymbolsNerdFont-Regular.ttf", .{
.root_source_file = dep.path("SymbolsNerdFont-Regular.ttf"),
});
}));
root_module.addOptions("build_options", build_options);
root_module.addIncludePath(maxminddb_upstream.path("include"));
root_module.addIncludePath(b.path("libs/phoon_14Aug2014"));
root_module.addIncludePath(b.path("libs/sunriset"));
root_module.addConfigHeader(maxminddb_config);
var libs_buf: [4]*std.Build.Step.Compile = undefined;
libs_buf[0] = maxminddb;
libs_buf[1] = phoon;
libs_buf[2] = sunriset;
var libs_len: usize = 3;
if (freetype) |dep| {
libs_buf[libs_len] = dep.artifact("freetype");
libs_len += 1;
}
const libs = libs_buf[0..libs_len];
const libs: []const *std.Build.Step.Compile = &.{ maxminddb, phoon, sunriset };
const exe = b.addExecutable(.{
.name = "wttr",
@ -192,6 +188,30 @@ 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);
}
/// Resolves the runtime cache directory, mirroring `Config.load`'s precedence
/// (`WTTR_CACHE_DIR`, else `${XDG_CACHE_HOME:-$HOME/.cache}/wttr`).
///
/// Tests need this because Zig 0.16 removed process-environment access outside
/// of `main`; build scripts kept it. Without this, `Config.loadForTest` would
/// fall back to `HOME = "/tmp"` and write test artifacts -- including the
/// GeoLite2 database -- into `/tmp`.
fn resolveCacheDir(b: *std.Build) []const u8 {
const env = &b.graph.environ_map;
if (env.get("WTTR_CACHE_DIR")) |dir| return b.dupe(dir);
const xdg_cache = if (env.get("XDG_CACHE_HOME")) |x|
b.dupe(x)
else if (env.get("HOME")) |home|
b.pathJoin(&.{ home, ".cache" })
else
// No home directory to work from; keep artifacts inside the build
// cache rather than falling back to a world-writable location.
b.pathFromRoot(".zig-cache");
return b.pathJoin(&.{ xdg_cache, "wttr" });
}

View file

@ -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,30 +15,18 @@
.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",
},
.zigimg = .{
.url = "git+https://github.com/zigimg/zigimg#9714df09f76891323c7fdbbbf23a17b79024fffb",
.hash = "zigimg-0.1.0-8_eo2j4mFwCU7tWnqvkYtzqe-OPRn_bxEql_IJhW85LT",
},
.jetbrains_mono = .{
.url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz",
.hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x",
},
.nerd_fonts_symbols_only = .{
.url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz",
.hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s",
.srf = .{
.url = "git+https://git.lerch.org/lobo/srf#4a3e5f00f15b0e0ba79d06ffe69dbcfa052baa5b",
.hash = "srf-0.0.0-qZj572nkAQAAz3zEg6fdD8A7PJnQ9je3zCeAOJS5PoZj",
},
},
.fingerprint = 0x710c2b57e81aa678,
.minimum_zig_version = "0.15.2",
.minimum_zig_version = "0.16.0",
.paths = .{
"build.zig",
"build.zig.zon",

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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,

Binary file not shown.

View file

@ -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);

View file

@ -17,6 +17,20 @@ cache_dir: []const u8,
/// fallback provider is used (ipwho.is by default, or IP2Location)
geolite_path: []const u8,
/// How old the GeoLite2 database may get before the background refresher
/// replaces it. Upstream rebuilds it continuously and IP allocations move
/// between cities, so a stale database quietly resolves clients to the wrong
/// place. `WTTR_GEOLITE_MAX_AGE_DAYS=0` refreshes on every check.
geolite_max_age_seconds: u64,
/// How often the refresher wakes up to compare the database's age against
/// `geolite_max_age_seconds`. `WTTR_GEOLITE_CHECK_INTERVAL_HOURS=0` disables
/// background refreshing entirely.
///
/// "Disable" lives on the interval rather than on the max age so that neither
/// value has an ambiguous zero.
geolite_check_interval_seconds: u64,
/// Geocache file stores location lookups
/// (e.g. "Portland -> 45.52345°N, -122.67621° W). When not found in cache,
/// a web service from Nominatum (https://nominatim.org/) is used
@ -34,10 +48,19 @@ 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();
/// Manual IP-range to location overrides, consulted before GeoLite2.
///
/// Not a cache: GeoLite2 can answer confidently and wrongly (a corporate WAN
/// address with `accuracy_radius = 20` pointing 1400 miles away), and the
/// fallback provider only runs when GeoLite2 declines to answer. Overrides
/// therefore have to sit in front of the database.
pins_file: []const u8,
/// 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
@ -66,7 +89,31 @@ pub fn load(allocator: std.mem.Allocator) !Config {
env.get("WTTR_CACHE_DIR") orelse default_cache_dir,
});
},
.geocache_file = if (env.get("WTTR_GEOCACHE_FILE")) |v| try allocator.dupe(u8, v) else try std.fs.path.join(allocator, &[_][]const u8{ default_cache_dir, "geocache.json" }),
.geolite_max_age_seconds = blk: {
const days = if (env.get("WTTR_GEOLITE_MAX_AGE_DAYS")) |v|
try std.fmt.parseInt(u64, v, 10)
else
30;
break :blk days * std.time.s_per_day;
},
.geolite_check_interval_seconds = blk: {
const hours = if (env.get("WTTR_GEOLITE_CHECK_INTERVAL_HOURS")) |v|
try std.fmt.parseInt(u64, v, 10)
else
24;
break :blk hours * std.time.s_per_hour;
},
// Derived from WTTR_CACHE_DIR like every other cache path. It previously
// ignored it and always used the default location, so pointing the server
// at a different cache directory silently left this one file behind.
.geocache_file = blk: {
if (env.get("WTTR_GEOCACHE_FILE")) |v| {
break :blk try allocator.dupe(u8, v);
}
break :blk try std.fmt.allocPrint(allocator, "{s}/geocache.srf", .{
env.get("WTTR_CACHE_DIR") orelse default_cache_dir,
});
},
.geoip_fallback = blk: {
if (env.get("WTTR_GEOIP_FALLBACK")) |v| {
if (std.mem.eql(u8, v, "ip2location")) break :blk .ip2location;
@ -78,17 +125,42 @@ pub fn load(allocator: std.mem.Allocator) !Config {
if (env.get("IP2LOCATION_CACHE_FILE")) |v| {
break :blk try allocator.dupe(u8, v);
}
break :blk try std.fmt.allocPrint(allocator, "{s}/ip2location.cache", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
break :blk try std.fmt.allocPrint(allocator, "{s}/ip2location.srf", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
},
.pins_file = blk: {
if (env.get("WTTR_PINS_FILE")) |v| {
break :blk try allocator.dupe(u8, v);
}
break :blk try std.fmt.allocPrint(allocator, "{s}/pins.srf", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
},
.ipwhois_cache_file = blk: {
if (env.get("IPWHOIS_CACHE_FILE")) |v| {
break :blk try allocator.dupe(u8, v);
}
break :blk try std.fmt.allocPrint(allocator, "{s}/ipwhois.cache", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
break :blk try std.fmt.allocPrint(allocator, "{s}/ipwhois.srf", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
},
};
}
/// Loads configuration for tests.
///
/// Zig 0.16 removed any way to reach the process environment outside of `main`,
/// so tests cannot resolve `HOME`/`XDG_CACHE_HOME` themselves. Left to an empty
/// environment, `load` would fall back to `HOME = "/tmp"` and scatter test
/// artifacts (including the ~63 MB GeoLite2 database that `-Ddownload-geoip`
/// fetches) into `/tmp`.
///
/// Instead `build.zig` resolves the cache directory from its own environment --
/// build scripts still have access -- and passes it through `build_options`, so
/// tests read and write the same location the server uses. Everything else is
/// left unset so tests do not depend on the developer's shell.
pub fn loadForTest(allocator: std.mem.Allocator) !Config {
var env: std.process.Environ.Map = .init(allocator);
defer env.deinit();
try env.put("WTTR_CACHE_DIR", @import("build_options").cache_dir);
return load(allocator, &env);
}
pub fn deinit(self: Config, allocator: std.mem.Allocator) void {
allocator.free(self.listen_host);
allocator.free(self.cache_dir);
@ -97,11 +169,12 @@ pub fn deinit(self: Config, allocator: std.mem.Allocator) void {
if (self.ip2location_api_key) |k| allocator.free(k);
allocator.free(self.ip2location_cache_file);
allocator.free(self.ipwhois_cache_file);
allocator.free(self.pins_file);
}
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);

106
src/Signals.zig Normal file
View file

@ -0,0 +1,106 @@
const std = @import("std");
const builtin = @import("builtin");
const log = std.log.scoped(.signals);
/// SIGHUP-driven reload of on-disk state.
///
/// The handler does nothing but an atomic store. That is deliberate: a signal
/// handler may run on any thread that has not blocked the signal (including an
/// httpz worker mid-request), and almost nothing is safe to call from one. An
/// atomic store is, so the handler records the request and a watcher task does
/// the actual work on a normal thread.
///
/// A `signalfd` or self-pipe would remove the polling, but both add file
/// descriptor handling for no practical gain here: reloads are operator-driven
/// and rare, so noticing within a second is indistinguishable from instant.
///
/// `SA_RESTART` matters more than it looks. Without it, delivering SIGHUP would
/// interrupt whatever blocking syscall the receiving thread was in (an `accept`
/// or `read` in the HTTP server) with `EINTR`, turning an operator reload into
/// spurious request failures.
var reload_requested: std.atomic.Value(bool) = .init(false);
fn onHup(_: std.posix.SIG) callconv(.c) void {
reload_requested.store(true, .release);
}
/// Installs the SIGHUP handler. Safe to call when unsupported: it does nothing.
///
/// Installing a real handler (rather than blocking the signal and draining it
/// elsewhere) also settles a container question. The server runs as PID 1 under
/// Docker, and the kernel discards signals sent to PID 1 when they would take
/// their default action and no handler is installed. With a handler present,
/// delivery is unambiguous.
pub fn install() void {
if (!supported) {
log.debug("SIGHUP reload is not supported on this target", .{});
return;
}
var act: std.posix.Sigaction = .{
.handler = .{ .handler = onHup },
.mask = std.posix.sigemptyset(),
.flags = std.posix.SA.RESTART,
};
std.posix.sigaction(.HUP, &act, null);
}
/// Ignores SIGHUP, for processes that are not the server.
///
/// SIGHUP's default disposition is to terminate. Without this, a short-lived
/// command that happens to be running while another invocation looks for servers
/// to signal would simply be killed: process discovery matches on the program,
/// and a command in flight (geocoding a location, say) looks much like a server.
///
/// Making non-server modes ignore the signal removes that hazard at the source,
/// so correctness no longer depends on discovery being perfectly precise.
pub fn ignore() void {
if (!supported) return;
var act: std.posix.Sigaction = .{
.handler = .{ .handler = std.posix.SIG.IGN },
.mask = std.posix.sigemptyset(),
.flags = 0,
};
std.posix.sigaction(.HUP, &act, null);
}
pub const supported = switch (builtin.os.tag) {
.windows, .wasi => false,
else => true,
};
/// Consumes a pending reload request, returning whether one was set.
pub fn takeReloadRequest() bool {
return reload_requested.swap(false, .acq_rel);
}
/// Poll interval for the watcher. Short enough that an operator running
/// `wttr pin` sees the effect immediately in human terms, long enough to be
/// free.
pub const poll_interval_ms = 1000;
test "takeReloadRequest consumes the flag exactly once" {
// Reset in case another test in this process set it.
_ = takeReloadRequest();
try std.testing.expect(!takeReloadRequest());
onHup(.HUP);
try std.testing.expect(takeReloadRequest());
try std.testing.expect(!takeReloadRequest());
}
test "repeated signals before a poll collapse into one reload" {
_ = takeReloadRequest();
onHup(.HUP);
onHup(.HUP);
onHup(.HUP);
// Coalescing is intended: three rapid reload requests need one reload, not
// three sequential re-reads of the same files.
try std.testing.expect(takeReloadRequest());
try std.testing.expect(!takeReloadRequest());
}

155
src/cache/Cache.zig vendored
View file

@ -1,11 +1,14 @@
const std = @import("std");
const Lru = @import("Lru.zig");
const srf = @import("srf");
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 +18,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 +29,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 +66,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
@ -80,11 +84,15 @@ pub fn deinit(self: *Cache) void {
self.allocator.destroy(self);
}
/// Extension for L2 entries. Named so `loadFromDir` can tell weather entries
/// apart from the other state living in the same directory.
const cache_file_extension = ".srf";
fn getCacheFilename(self: *Cache, key: []const u8) ![]const u8 {
var hasher = std.hash.Wyhash.init(0);
hasher.update(key);
const hash = hasher.final();
return std.fmt.allocPrint(self.allocator, "{x}.json", .{hash});
return std.fmt.allocPrint(self.allocator, "{x}" ++ cache_file_extension, .{hash});
}
const CacheEntry = struct {
@ -117,18 +125,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);
@ -140,25 +148,23 @@ fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry {
fn serialize(writer: *std.Io.Writer, key: []const u8, value: []const u8, expires: i64) !void {
const entry = CacheEntry{ .key = key, .value = value, .expires = expires };
try writer.print("{f}", .{std.json.fmt(entry, .{})});
// Long format: the cached payload is a multi-kilobyte provider response, so
// one field per line keeps the file skimmable. SRF length-prefixes the value
// because it contains newlines, so the payload needs no escaping.
try writer.print("{f}", .{srf.fmt(CacheEntry, &.{entry}, .{ .long_format = true })});
}
fn deserialize(allocator: std.mem.Allocator, reader: *std.Io.Reader) !CacheEntry {
var json_reader = std.json.Reader.init(allocator, reader);
defer json_reader.deinit();
var records = try srf.iterator(reader, allocator, .{});
defer records.deinit();
const parsed = try std.json.parseFromTokenSource(
CacheEntry,
allocator,
&json_reader,
.{},
);
defer parsed.deinit();
const fields = try records.next() orelse return error.EmptyCacheEntry;
const entry = try fields.to(CacheEntry, .{});
return .{
.key = try allocator.dupe(u8, parsed.value.key),
.value = try allocator.dupe(u8, parsed.value.value),
.expires = parsed.value.expires,
.key = try allocator.dupe(u8, entry.key),
.value = try allocator.dupe(u8, entry.value),
.expires = entry.expires,
};
}
@ -171,25 +177,46 @@ 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();
}
/// Whether a directory entry is one of our L2 entries.
///
/// Entry names are a hash rendered as hex plus the extension. Matching the shape
/// rather than just the extension keeps the descriptively-named state files in
/// the same directory from being mistaken for cache entries now that they share
/// a format.
fn isCacheFileName(name: []const u8) bool {
if (!std.mem.endsWith(u8, name, cache_file_extension)) return false;
const stem = name[0 .. name.len - cache_file_extension.len];
if (stem.len == 0) return false;
for (stem) |ch| {
if (!std.ascii.isHex(ch)) return false;
}
return true;
}
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;
// The cache directory also holds the GeoLite2 database and the named
// state files (pins, geocache, provider caches). Without this filter every
// startup opened and tried to parse all of them, including reading a
// megabyte of the 60+ MB database.
if (!isCacheFileName(entry.name)) continue;
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, entry.name });
defer self.allocator.free(file_path);
@ -211,7 +238,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 });
};
}
@ -230,7 +257,12 @@ test "serialize and deserialize" {
try fixed_writer.flush();
const serialized = buffer[0..fixed_writer.end];
try std.testing.expectEqualStrings("{\"key\":\"test_key\",\"value\":\"test_value\",\"expires\":1234567890}", serialized);
// Assert the shape as well as the round trip, so a change of format is a
// deliberate edit here rather than a silent difference on disk.
try std.testing.expect(std.mem.indexOf(u8, serialized, "#!srfv1") != null);
try std.testing.expect(std.mem.indexOf(u8, serialized, "#!long") != null);
try std.testing.expect(std.mem.indexOf(u8, serialized, "key::test_key") != null);
try std.testing.expect(std.mem.indexOf(u8, serialized, "value::test_value") != null);
var fixed_reader = std.Io.Reader.fixed(serialized);
@ -242,19 +274,68 @@ test "serialize and deserialize" {
try std.testing.expectEqual(expires, cached.expires);
}
test "deserialize handles integer expires" {
test "deserialize preserves a millisecond timestamp exactly" {
const allocator = std.testing.allocator;
const json = "{\"key\":\"k\",\"value\":\"v\",\"expires\":9999999999999}";
// SRF carries numbers as f64. Millisecond timestamps are around 1.7e12, well
// inside the 2^53 range f64 represents exactly, so no precision is lost --
// but that is worth pinning down rather than assuming.
const expires: i64 = 9999999999999;
var fixed_reader = std.Io.Reader.fixed(json);
var buffer: [1024]u8 = undefined;
var fixed_writer = std.Io.Writer.fixed(&buffer);
try serialize(&fixed_writer, "k", "v", expires);
try fixed_writer.flush();
var fixed_reader = std.Io.Reader.fixed(buffer[0..fixed_writer.end]);
const cached = try deserialize(allocator, &fixed_reader);
defer cached.deinit(allocator);
try std.testing.expectEqualStrings("k", cached.key);
try std.testing.expectEqualStrings("v", cached.value);
try std.testing.expectEqual(9999999999999, cached.expires);
try std.testing.expectEqual(expires, cached.expires);
}
test "serialize round-trips a payload containing newlines and commas" {
const allocator = std.testing.allocator;
// The real payload is a provider JSON response: multi-line, comma-heavy, and
// quoted. It survives only because SRF length-prefixes such values.
const value =
\\{"properties":{"timeseries":[
\\ {"time":"2026-08-05T00:00:00Z","data":{"instant":1.5}},
\\ {"time":"2026-08-05T01:00:00Z","data":{"instant":2.5}}
\\]}}
;
var buffer: [4096]u8 = undefined;
var fixed_writer = std.Io.Writer.fixed(&buffer);
try serialize(&fixed_writer, "59.9,10.7", value, 9999999999999);
try fixed_writer.flush();
var fixed_reader = std.Io.Reader.fixed(buffer[0..fixed_writer.end]);
const cached = try deserialize(allocator, &fixed_reader);
defer cached.deinit(allocator);
try std.testing.expectEqualStrings("59.9,10.7", cached.key);
try std.testing.expectEqualStrings(value, cached.value);
}
test "isCacheFileName distinguishes entries from the other state files" {
// Entries are a hex hash plus the extension.
try std.testing.expect(isCacheFileName("1f44d18884c40d0a.srf"));
try std.testing.expect(isCacheFileName("abcdef.srf"));
// Everything else sharing the directory must be left alone, including the
// state files that now use the same format.
try std.testing.expect(!isCacheFileName("pins.srf"));
try std.testing.expect(!isCacheFileName("geocache.srf"));
try std.testing.expect(!isCacheFileName("ipwhois.srf"));
try std.testing.expect(!isCacheFileName("ip2location.srf"));
try std.testing.expect(!isCacheFileName("GeoLite2-City.mmdb"));
try std.testing.expect(!isCacheFileName("GeoLite2-City.mmdb.download"));
try std.testing.expect(!isCacheFileName(".srf"));
try std.testing.expect(!isCacheFileName("1f44d18884c40d0a.json"));
}
test "L1/L2 cache flow" {
@ -264,9 +345,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

15
src/cache/Lru.zig vendored
View file

@ -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);

354
src/cli/pin.zig Normal file
View file

@ -0,0 +1,354 @@
const std = @import("std");
const builtin = @import("builtin");
const Config = @import("../Config.zig");
const Pins = @import("../location/Pins.zig");
const GeoCache = @import("../location/GeoCache.zig");
const Resolver = @import("../location/resolver.zig").Resolver;
const log = std.log.scoped(.pin);
/// Operator commands for managing IP pins.
///
/// A pin is an IP range to location override consulted ahead of GeoLite2. It
/// exists because GeoLite2 can be confidently wrong: a corporate WAN address may
/// carry `accuracy_radius = 20` while pointing at a city 1400 miles away, and
/// because the online fallback only runs when GeoLite2 declines to answer,
/// nothing automatic ever corrects it.
///
/// After writing the file these commands signal any running server so the change
/// takes effect without a restart. The database is mapped and the pins are held
/// in memory, so editing files alone would not be noticed.
pub fn printUsage(io: std.Io, exe: []const u8) !u8 {
var buf: [2048]u8 = undefined;
var out = std.Io.File.stdout().writer(io, &buf);
const w = &out.interface;
const base = std.fs.path.basename(exe);
try w.print(
\\usage: {s} [command]
\\
\\With no command, runs the weather server.
\\
\\Commands:
\\ pin <cidr> <location> Override the location for an IP or range
\\ unpin <cidr> Remove an override
\\ pins List overrides
\\ help Show this message
\\
\\Pins take precedence over the GeoLite2 database, which is the point:
\\they exist for addresses the database resolves confidently and wrongly.
\\
\\Examples:
\\ {s} pin 12.94.132.0/24 San Francisco
\\ {s} pin 12.94.132.170 Portland, Oregon
\\ {s} pin 2001:db8::/32 London
\\ {s} unpin 12.94.132.0/24
\\
\\A bare address is treated as a single host (/32 or /128). When ranges
\\overlap, the longest prefix wins, so a host pin beats a range pin.
\\
, .{ base, base, base, base, base });
try w.flush();
return 0;
}
/// Joins the remaining argv into a Nominatim-friendly query.
///
/// Spaces become `+` because the geocoding path builds a URL query directly, so
/// both `pin ... San Francisco` and `pin ... "San Francisco"` work.
fn joinLocation(allocator: std.mem.Allocator, parts: []const []const u8) ![]u8 {
var list: std.ArrayList(u8) = .empty;
errdefer list.deinit(allocator);
for (parts, 0..) |part, i| {
if (i > 0) try list.append(allocator, '+');
for (part) |ch| try list.append(allocator, if (ch == ' ') '+' else ch);
}
return list.toOwnedSlice(allocator);
}
pub fn runPin(
allocator: std.mem.Allocator,
io: std.Io,
cfg: Config,
args: []const []const u8,
) !u8 {
var err_buf: [512]u8 = undefined;
var err_out = std.Io.File.stderr().writer(io, &err_buf);
const ew = &err_out.interface;
if (args.len < 2) {
try ew.writeAll("usage: wttr pin <cidr> <location>\n");
try ew.flush();
return 2;
}
const cidr = Pins.parseCidr(args[0]) catch |e| {
try ew.print("invalid address or range {s}: {t}\n", .{ args[0], e });
try ew.flush();
return 2;
};
const query = try joinLocation(allocator, args[1..]);
defer allocator.free(query);
// Geocode now rather than storing a bare name, for two reasons: it validates
// the location while the operator is present to see the error, and it keeps
// the lookup path free of network calls.
var geocache = try GeoCache.init(allocator, io, cfg.geocache_file);
defer geocache.deinit();
var resolver = Resolver.init(allocator, io, null, &geocache, null);
const location = resolver.resolve(query) catch |e| {
try ew.print("could not resolve location {s}: {t}\n", .{ query, e });
try ew.flush();
return 1;
};
defer location.deinit();
var pins = try Pins.load(allocator, io, cfg.pins_file);
defer pins.deinit();
try pins.put(cidr, location.name, location.coords);
try pins.save(io, cfg.pins_file);
var out_buf: [512]u8 = undefined;
var out = std.Io.File.stdout().writer(io, &out_buf);
const w = &out.interface;
try w.print("pinned {s} -> {s} ({d:.4}, {d:.4})\n", .{
args[0],
location.name,
location.coords.latitude,
location.coords.longitude,
});
try w.print("wrote {s}\n", .{cfg.pins_file});
try reportSignal(w, notifyServers(io));
try w.flush();
return 0;
}
pub fn runUnpin(
allocator: std.mem.Allocator,
io: std.Io,
cfg: Config,
args: []const []const u8,
) !u8 {
var err_buf: [512]u8 = undefined;
var err_out = std.Io.File.stderr().writer(io, &err_buf);
const ew = &err_out.interface;
if (args.len != 1) {
try ew.writeAll("usage: wttr unpin <cidr>\n");
try ew.flush();
return 2;
}
const cidr = Pins.parseCidr(args[0]) catch |e| {
try ew.print("invalid address or range {s}: {t}\n", .{ args[0], e });
try ew.flush();
return 2;
};
var pins = try Pins.load(allocator, io, cfg.pins_file);
defer pins.deinit();
if (!pins.remove(cidr)) {
try ew.print("no pin found for {s}\n", .{args[0]});
try ew.flush();
return 1;
}
try pins.save(io, cfg.pins_file);
var out_buf: [512]u8 = undefined;
var out = std.Io.File.stdout().writer(io, &out_buf);
const w = &out.interface;
try w.print("removed pin {s}\n", .{args[0]});
try reportSignal(w, notifyServers(io));
try w.flush();
return 0;
}
pub fn runList(allocator: std.mem.Allocator, io: std.Io, cfg: Config) !u8 {
var pins = try Pins.load(allocator, io, cfg.pins_file);
defer pins.deinit();
var out_buf: [4096]u8 = undefined;
var out = std.Io.File.stdout().writer(io, &out_buf);
const w = &out.interface;
if (pins.entries.items.len == 0) {
try w.print("no pins configured ({s})\n", .{cfg.pins_file});
try w.flush();
return 0;
}
var cidr_buf: [64]u8 = undefined;
for (pins.entries.items) |e| {
const text = try Pins.formatCidr(
.{ .family = e.family, .network = e.network, .prefix_len = e.prefix_len },
&cidr_buf,
);
try w.print("{s: <20} {s} ({d:.4}, {d:.4})\n", .{
text, e.name, e.coords.latitude, e.coords.longitude,
});
}
try w.flush();
return 0;
}
fn reportSignal(w: *std.Io.Writer, signaled: usize) !void {
switch (signaled) {
// Worth saying plainly: the most likely cause is running this outside the
// container the server lives in, where the pins file being written is not
// the one the server reads.
0 => try w.writeAll(
\\signaled 0 running servers (change applies on next start)
\\ if the server runs in a container, run this inside it, e.g.
\\ docker exec <container> /wttr ...
\\
),
1 => try w.writeAll("signaled 1 running server\n"),
else => try w.print("signaled {d} running servers\n", .{signaled}),
}
}
/// Sends SIGHUP to every running server, returning how many were signalled.
///
/// Scanning `/proc` rather than tracking a pidfile keeps the server free of
/// pidfile lifecycle handling (staleness, crash leftovers, one-per-cache-dir
/// assumptions). It also does the right thing inside a container, where the
/// server is PID 1 and shares the namespace with `docker exec`.
///
/// On a system without `/proc` the directory simply fails to open and the count
/// is zero, which the caller reports as "applies on next start".
fn notifyServers(io: std.Io) usize {
if (!@import("../Signals.zig").supported) return 0;
if (builtin.os.tag != .linux) return 0;
var dir = std.Io.Dir.cwd().openDir(io, "/proc", .{ .iterate = true }) catch return 0;
defer dir.close(io);
// Compare against our own name rather than a hardcoded "wttr" so that running
// under a different filename (a canary build, a symlinked unit) still works.
// `comm` is compared on both sides so the kernel's 15-character truncation
// applies identically and cannot cause a spurious mismatch.
var self_name_buf: [64]u8 = undefined;
const self_name = readComm(io, &dir, "self", &self_name_buf) orelse return 0;
const self_pid = std.os.linux.getpid();
var signaled: usize = 0;
var it = dir.iterate();
while (it.next(io) catch null) |entry| {
if (entry.kind != .directory) continue;
const pid = std.fmt.parseInt(std.posix.pid_t, entry.name, 10) catch continue;
if (pid == self_pid) continue;
var name_buf: [64]u8 = undefined;
const name = readComm(io, &dir, entry.name, &name_buf) orelse continue;
if (!std.mem.eql(u8, name, self_name)) continue;
// Only the server reloads on SIGHUP; another command has nothing to
// reload. Skipping them keeps this from depending on those processes
// ignoring the signal.
if (!isServing(io, &dir, entry.name)) continue;
std.posix.kill(pid, .HUP) catch |err| {
log.debug("could not signal pid {d}: {t}", .{ pid, err });
continue;
};
signaled += 1;
}
return signaled;
}
/// Reads `/proc/<pid>/comm` into `buf`, returning the trimmed name.
fn readComm(io: std.Io, proc_dir: *std.Io.Dir, pid_name: []const u8, buf: []u8) ?[]const u8 {
var path_buf: [64]u8 = undefined;
const path = std.fmt.bufPrint(&path_buf, "{s}/comm", .{pid_name}) catch return null;
const file = proc_dir.openFile(io, path, .{}) catch return null;
defer file.close(io);
// Read streaming rather than via `readFileAlloc`: procfs reports `st_size`
// as 0, so anything that sizes the read from the file's length reads nothing
// and every process looks nameless.
const n = file.readStreaming(io, &.{buf}) catch return null;
if (n == 0) return null;
return std.mem.trim(u8, buf[0..n], " \n");
}
/// Whether a process is running the server rather than a command.
///
/// The server takes no arguments; every command has a subcommand as its first.
fn isServing(io: std.Io, proc_dir: *std.Io.Dir, pid_name: []const u8) bool {
var path_buf: [64]u8 = undefined;
const path = std.fmt.bufPrint(&path_buf, "{s}/cmdline", .{pid_name}) catch return false;
const file = proc_dir.openFile(io, path, .{}) catch return false;
defer file.close(io);
var buf: [4096]u8 = undefined;
const n = file.readStreaming(io, &.{&buf}) catch return false;
return cmdlineIsBare(buf[0..n]);
}
/// Whether a `/proc/<pid>/cmdline` payload holds exactly one argument.
///
/// Split from `isServing` so the parsing is testable without a live process.
/// `cmdline` is NUL-separated with a trailing NUL after the final argument, so a
/// bare invocation is one entry and anything with a subcommand has two or more.
fn cmdlineIsBare(raw: []const u8) bool {
if (raw.len == 0) return false;
const args = std.mem.trimEnd(u8, raw, "\x00");
if (args.len == 0) return false;
return std.mem.indexOfScalar(u8, args, 0) == null;
}
test "joinLocation joins argv with plus and converts spaces" {
const allocator = std.testing.allocator;
const a = try joinLocation(allocator, &.{ "San", "Francisco" });
defer allocator.free(a);
try std.testing.expectEqualStrings("San+Francisco", a);
// A quoted argument arrives as one part containing a space.
const b = try joinLocation(allocator, &.{"San Francisco"});
defer allocator.free(b);
try std.testing.expectEqualStrings("San+Francisco", b);
const c = try joinLocation(allocator, &.{ "Portland,", "Oregon" });
defer allocator.free(c);
try std.testing.expectEqualStrings("Portland,+Oregon", c);
}
test "joinLocation on a single word is unchanged" {
const allocator = std.testing.allocator;
const s = try joinLocation(allocator, &.{"London"});
defer allocator.free(s);
try std.testing.expectEqualStrings("London", s);
}
test "cmdlineIsBare: a bare invocation is the server" {
try std.testing.expect(cmdlineIsBare("/wttr\x00"));
// Some kernels omit the trailing NUL on short reads.
try std.testing.expect(cmdlineIsBare("/wttr"));
try std.testing.expect(cmdlineIsBare("/usr/local/bin/wttr\x00"));
}
test "cmdlineIsBare: anything with a subcommand is not the server" {
try std.testing.expect(!cmdlineIsBare("/wttr\x00pin\x00"));
try std.testing.expect(!cmdlineIsBare("/wttr\x00pins\x00"));
try std.testing.expect(!cmdlineIsBare("/wttr\x00pin\x0012.0.0.0/8\x00London\x00"));
// Trailing NULs must not hide a second argument.
try std.testing.expect(!cmdlineIsBare("/wttr\x00help\x00\x00\x00"));
}
test "cmdlineIsBare: empty or all-NUL input is not a server" {
try std.testing.expect(!cmdlineIsBare(""));
try std.testing.expect(!cmdlineIsBare("\x00"));
try std.testing.expect(!cmdlineIsBare("\x00\x00\x00"));
}

View file

@ -25,9 +25,6 @@ const QueryParams = @This();
format: ?[]const u8 = null,
lang: ?[]const u8 = null,
location: ?[]const u8 = null,
transparency: ?u8 = null,
background: ?[]const u8 = null,
add_frame: bool = false,
/// A: Ignore user agent and force ansi mode
ansi: bool = false,
/// T: Avoid terminal sequences and just output plain text
@ -61,8 +58,6 @@ pub fn parse(allocator: std.mem.Allocator, query_string: []const u8) !QueryParam
'Q' => render_options.super_quiet = true,
'A' => params.ansi = true,
'T' => params.text_only = true,
't' => params.transparency = 150,
'p' => params.add_frame = true,
else => continue,
}
}
@ -76,12 +71,6 @@ pub fn parse(allocator: std.mem.Allocator, query_string: []const u8) !QueryParam
params.use_imperial = true;
} else if (std.mem.eql(u8, key, "use_metric")) {
params.use_imperial = false;
} else if (std.mem.eql(u8, key, "transparency")) {
if (value) |v| {
params.transparency = try std.fmt.parseInt(u8, v, 10);
}
} else if (std.mem.eql(u8, key, "background")) {
params.background = if (value) |v| try allocator.dupe(u8, v) else null;
}
}
@ -145,16 +134,6 @@ test "parse multiple parameters" {
try std.testing.expect(!params.use_imperial.?);
}
test "parse transparency" {
const allocator = std.testing.allocator;
const params_t = try QueryParams.parse(allocator, "t");
try std.testing.expect(params_t.transparency != null);
try std.testing.expectEqual(@as(u8, 150), params_t.transparency.?);
const params_custom = try QueryParams.parse(allocator, "transparency=200");
try std.testing.expectEqual(@as(u8, 200), params_custom.transparency.?);
}
test "imperial units selection logic" {
// This test documents the priority order for unit selection:
// 1. Explicit ?u or ?m parameter (highest priority)

View file

@ -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,

View file

@ -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("/");

View file

@ -9,10 +9,8 @@ const Json = @import("../render/Json.zig");
const V2 = @import("../render/V2.zig");
const Custom = @import("../render/Custom.zig");
const Prometheus = @import("../render/Prometheus.zig");
const Png = if (build_options.enable_png) @import("../render/Png.zig") else struct {};
const help = @import("help.zig");
const types = @import("../weather/types.zig");
const build_options = @import("build_options");
const log = std.log.scoped(.handler);
@ -20,6 +18,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
@ -39,7 +39,6 @@ pub fn handleWeather(
defer {
if (params.format) |f| req.arena.free(f);
if (params.lang) |l| req.arena.free(l);
if (params.background) |b| req.arena.free(b);
}
if (params.location) |loc| {
@ -97,24 +96,20 @@ fn handleWeatherInternal(
client_ip: []const u8,
) !void {
const req_alloc = req.arena;
// Check for PNG request
const is_png = if (comptime build_options.enable_png)
std.mem.endsWith(u8, location_query, ".png")
else
false;
const location_str = if (is_png) location_query[0 .. location_query.len - 4] else location_query;
// 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();
// Resolve location. By the time we get here, we really
// should have a location from the path, query string, or
// client IP lookup. So if we have an empty location parameter, it
// is better to 404 than to fake it with a London response
if (location_str.len == 0) {
if (location_query.len == 0) {
res.status = 404;
res.body = "Location not found\n";
return;
}
const location = opts.resolver.resolve(location_str) catch |err| {
const location = opts.resolver.resolve(location_query) catch |err| {
switch (err) {
error.LocationNotFound => {
log.debug("Location not found for query {s}", .{location_query});
@ -148,7 +143,6 @@ fn handleWeatherInternal(
defer {
if (params.format) |f| req_alloc.free(f);
if (params.lang) |l| req_alloc.free(l);
if (params.background) |b| req_alloc.free(b);
}
var render_options = params.render_options;
@ -172,31 +166,6 @@ fn handleWeatherInternal(
res.headers.add("X-Location-Coordinates", coords_header);
// Render weather data
if (comptime build_options.enable_png) {
if (is_png) {
res.content_type = .PNG;
var png_renderer = Png.init(req_alloc);
defer png_renderer.deinit();
var png_buffer: [1024 * 1024]u8 = undefined;
var png_writer_impl = std.Io.Writer.fixed(&png_buffer);
const png_writer = &png_writer_impl;
render_options.format = .ansi; // Force ANSI for PNG
try renderWeatherData(png_writer, weather, params, render_options);
const text_output = png_buffer[0..png_writer_impl.end];
try png_renderer.buffer.appendSlice(req_alloc, text_output);
const png_options = Png.PngOptions{
.transparency = params.transparency orelse 150,
.background = params.background,
.add_frame = params.add_frame,
};
try png_renderer.render(res.writer(), png_options);
return;
}
}
// Set content type based on format
if (params.format) |fmt| {
res.content_type = if (std.mem.eql(u8, fmt, "j1")) .JSON else .TEXT;
@ -208,7 +177,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 +185,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 +201,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);

View file

@ -75,20 +75,10 @@ pub const help_page =
\\ %s # sunset time
\\ %d # dusk time
\\
\\PNG options:
\\
\\ /paris.png # generate a PNG file
\\ p # add frame around the output
\\ t # transparency 150
\\ transparency=... # transparency from 0 to 255 (255 = not transparent)
\\ background=... # background color in form RRGGBB, e.g. 00aaaa
\\
\\Options can be combined:
\\
\\ /Paris?0pq
\\ /Paris?0pq&lang=fr
\\ /Paris_0pq.png # in PNG the file mode are specified after _
\\ /Rome_0pq_lang=it.png # long options are separated with underscore
\\ /Paris?0q
\\ /Paris?0q&lang=fr
\\
\\* Localization:
\\

View file

@ -1,11 +1,14 @@
const std = @import("std");
const Coordinates = @import("../Coordinates.zig");
const srf = @import("srf");
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 +19,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 +77,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;
@ -88,72 +92,85 @@ pub fn saveIfNeeded(self: *GeoCache) void {
self.last_save = now;
}
/// On-disk shape of one geocoded place.
///
/// The query is stored alongside the result rather than used as an object key,
/// because SRF records are flat field lists rather than a nested map.
const Record = struct {
query: []const u8,
name: []const u8,
lat: f64,
lon: f64,
};
fn load(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), content: []const u8) !void {
const CacheData = struct {
name: []const u8,
latitude: f64,
longitude: f64,
};
if (std.mem.trim(u8, content, " \r\n\t").len == 0) return;
const parsed = try std.json.parseFromSlice(
std.json.ArrayHashMap(CacheData),
allocator,
content,
.{},
);
defer parsed.deinit();
var reader = std.Io.Reader.fixed(content);
var records = try srf.iterator(&reader, allocator, .{});
defer records.deinit();
for (parsed.value.map.keys(), parsed.value.map.values()) |key, value| {
const cache_key = try allocator.dupe(u8, key);
const cache_value = CachedLocation{
.name = try allocator.dupe(u8, value.name),
.coords = .{
.latitude = value.latitude,
.longitude = value.longitude,
},
};
try cache.put(cache_key, cache_value);
var index: usize = 0;
while (records.next() catch |err| {
log.warn("stopped reading geocache after {d} entr(ies): {t}", .{ index, err });
return;
}) |fields| {
index += 1;
const record = fields.to(Record, .{}) catch continue;
if (record.query.len == 0) continue;
const cache_key = try allocator.dupe(u8, record.query);
errdefer allocator.free(cache_key);
const name_copy = try allocator.dupe(u8, record.name);
errdefer allocator.free(name_copy);
// A duplicate query in the file would otherwise leak the key and name
// already stored under it.
const existing = try cache.fetchPut(cache_key, .{
.name = name_copy,
.coords = .{ .latitude = record.lat, .longitude = record.lon },
});
if (existing) |old| {
allocator.free(old.key);
allocator.free(old.value.name);
}
}
}
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);
}
fn save(self: *GeoCache, writer: *std.Io.Writer) !void {
try writer.writeAll("{\n");
// Rewritten in full rather than appended to, because entries are replaced in
// place; SRF long format keeps the result legible for a file an operator may
// want to inspect or prune.
var records = try self.allocator.alloc(Record, self.cache.count());
defer self.allocator.free(records);
var it = self.cache.iterator();
var first = true;
while (it.next()) |entry| {
if (!first) try writer.writeAll(",\n");
first = false;
try writer.print(" {f}: {f}", .{
std.json.fmt(entry.key_ptr.*, .{}),
std.json.fmt(.{
.name = entry.value_ptr.name,
.latitude = entry.value_ptr.coords.latitude,
.longitude = entry.value_ptr.coords.longitude,
}, .{}),
});
var i: usize = 0;
while (it.next()) |entry| : (i += 1) {
records[i] = .{
.query = entry.key_ptr.*,
.name = entry.value_ptr.name,
.lat = entry.value_ptr.coords.latitude,
.lon = entry.value_ptr.coords.longitude,
};
}
try writer.writeAll("\n}\n");
try writer.print("{f}", .{srf.fmt(Record, records, .{ .long_format = true })});
}
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 +179,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,16 +199,16 @@ 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");
try std.testing.expect(result == null);
}
test "save produces valid JSON" {
test "save produces SRF an operator can read" {
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", .{
@ -208,12 +225,16 @@ test "save produces valid JSON" {
try cache.save(&writer);
const output = buffer[0..writer.end];
try std.testing.expect(std.mem.indexOf(u8, output, "London") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "Paris") != null);
// Assert the shape, not just that the values appear: a silent switch to
// compact output would still round-trip while making the file harder to read.
try std.testing.expect(std.mem.indexOf(u8, output, "#!srfv1") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "#!long") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "query::London") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "query::Paris") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "51.5074") != null);
}
test "load parses valid JSON" {
test "load parses SRF records" {
const allocator = std.testing.allocator;
var cache_map = std.StringHashMap(CachedLocation).init(allocator);
defer {
@ -225,27 +246,58 @@ test "load parses valid JSON" {
cache_map.deinit();
}
const json =
const content =
\\#!srfv1
\\#!long
\\query::London
\\name::London, UK
\\lat:num:51.5074
\\lon:num:-0.1278
\\
\\query::Paris
\\name::Paris, France
\\lat:num:48.8566
\\lon:num:2.3522
\\
;
try load(allocator, &cache_map, content);
try std.testing.expectEqual(@as(usize, 2), cache_map.count());
const london = cache_map.get("London") orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("London, UK", london.name);
try std.testing.expectApproxEqAbs(@as(f64, 51.5074), london.coords.latitude, 0.0001);
const paris = cache_map.get("Paris") orelse return error.TestUnexpectedResult;
try std.testing.expectApproxEqAbs(@as(f64, 2.3522), paris.coords.longitude, 0.0001);
}
test "load ignores a legacy JSON file rather than failing" {
const allocator = std.testing.allocator;
var cache_map = std.StringHashMap(CachedLocation).init(allocator);
defer {
var it = cache_map.iterator();
while (it.next()) |entry| {
allocator.free(entry.key_ptr.*);
allocator.free(entry.value_ptr.name);
}
cache_map.deinit();
}
// The previous format. Entries are re-geocodable, so the file is dropped
// rather than migrated; what matters is that this is not fatal.
const legacy =
\\{
\\ "London": {"name": "London, UK", "latitude": 51.5074, "longitude": -0.1278},
\\ "Paris": {"name": "Paris, France", "latitude": 48.8566, "longitude": 2.3522}
\\ "London": {"name": "London, UK", "latitude": 51.5074, "longitude": -0.1278}
\\}
;
try load(allocator, &cache_map, json);
const london = cache_map.get("London");
try std.testing.expect(london != null);
try std.testing.expectApproxEqAbs(@as(f64, 51.5074), london.?.coords.latitude, 0.0001);
const paris = cache_map.get("Paris");
try std.testing.expect(paris != null);
try std.testing.expectApproxEqAbs(@as(f64, 48.8566), paris.?.coords.latitude, 0.0001);
load(allocator, &cache_map, legacy) catch {};
try std.testing.expectEqual(@as(usize, 0), cache_map.count());
}
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", .{

View file

@ -3,6 +3,7 @@ const Ip2location = @import("Ip2location.zig");
const IpWhoIs = @import("IpWhoIs.zig");
const Location = @import("resolver.zig").Location;
const Config = @import("../Config.zig");
const Pins = @import("Pins.zig");
const c = @cImport({
@cInclude("maxminddb.h");
@ -11,6 +12,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,
@ -39,8 +58,78 @@ const FallbackClient = union(enum) {
mmdb: *c.MMDB_s,
fallback_client: FallbackClient,
allocator: std.mem.Allocator,
io: std.Io,
/// Retained so `reload` can reopen the database after it is replaced on disk.
db_path: []const u8,
/// Guards `mmdb` and `pins`. Lookups hold this shared; the reload paths hold it
/// exclusively to swap state. Request threads, the background refresher, and the
/// SIGHUP handler all touch this state, so the swaps cannot be unsynchronized.
lock: std.Io.RwLock,
/// Manual IP-range overrides, consulted before the database. Separate from the
/// fallback cache on purpose: the fallback only runs when GeoLite2 declines to
/// answer, and the addresses that need overriding are exactly the ones GeoLite2
/// answers confidently and wrongly.
pins: Pins,
/// Path pins are loaded from, retained so SIGHUP can re-read them.
pins_path: []const u8,
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 mmdb = try openDatabase(allocator, db_path);
errdefer {
c.MMDB_close(mmdb);
allocator.destroy(mmdb);
}
const db_path_copy = try allocator.dupe(u8, db_path);
errdefer allocator.free(db_path_copy);
const pins_path_copy = try allocator.dupe(u8, config.pins_file);
errdefer allocator.free(pins_path_copy);
// A malformed or unreadable pins file must not stop the server: overrides are
// an enhancement, and losing them is far better than refusing to serve.
var pins = Pins.load(allocator, io, config.pins_file) catch |err| blk: {
std.log.warn("could not load pins from {s} ({t}); continuing without overrides", .{ config.pins_file, err });
break :blk Pins.init(allocator);
};
errdefer pins.deinit();
if (pins.entries.items.len > 0)
std.log.info("loaded {d} IP pin(s) from {s}", .{ pins.entries.items.len, config.pins_file });
const fallback_client: FallbackClient = switch (config.geoip_fallback) {
.ip2location => blk: {
const client = try allocator.create(Ip2location);
errdefer allocator.destroy(client);
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 },
);
break :blk .{ .ip2location = client };
},
.ipwhois => blk: {
const client = try allocator.create(IpWhoIs);
errdefer allocator.destroy(client);
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 };
},
};
return GeoIP{
.mmdb = mmdb,
.fallback_client = fallback_client,
.allocator = allocator,
.io = io,
.db_path = db_path_copy,
.lock = .init,
.pins = pins,
.pins_path = pins_path_copy,
};
}
/// Opens an mmdb file, returning an owned handle.
fn openDatabase(allocator: std.mem.Allocator, db_path: []const u8) !*c.MMDB_s {
const path_z = try std.heap.c_allocator.dupeZ(u8, db_path);
defer std.heap.c_allocator.free(path_z);
@ -51,49 +140,84 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, config: Config) !
if (status != c.MMDB_SUCCESS)
return error.CannotOpenDatabase;
const fallback_client: FallbackClient = switch (config.geoip_fallback) {
.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);
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 },
);
break :blk .{ .ip2location = client };
},
.ipwhois => blk: {
const client = try allocator.create(IpWhoIs);
errdefer allocator.destroy(client);
client.* = try IpWhoIs.init(allocator, config.ipwhois_cache_file);
std.log.info("GeoIP fallback: ipwho.is (cache: {s})", .{config.ipwhois_cache_file});
break :blk .{ .ipwhois = client };
},
};
return mmdb;
}
return GeoIP{
.mmdb = mmdb,
.fallback_client = fallback_client,
.allocator = allocator,
};
/// Reopens the database from `db_path`, replacing the in-use handle.
///
/// The file is opened *before* the exclusive lock is taken so that lookups only
/// block for the pointer swap rather than for the open. The previous handle is
/// closed after the lock is released: lookups never retain the pointer beyond
/// their own critical section, so once the swap is published no thread can still
/// reach the old handle.
///
/// Reopening is required after the file is replaced on disk. The database is
/// mapped with `MMDB_MODE_MMAP`, so an atomic rename leaves this process mapped
/// to the old inode until it opens the new one.
pub fn reload(self: *GeoIP) !void {
const new_mmdb = try openDatabase(self.allocator, self.db_path);
self.lock.lockUncancelable(self.io);
const old_mmdb = self.mmdb;
self.mmdb = new_mmdb;
self.lock.unlock(self.io);
c.MMDB_close(old_mmdb);
self.allocator.destroy(old_mmdb);
log.info("GeoLite2 database reloaded from {s}", .{self.db_path});
}
pub fn deinit(self: *GeoIP) void {
c.MMDB_close(self.mmdb);
self.allocator.destroy(self.mmdb);
self.allocator.free(self.db_path);
self.allocator.free(self.pins_path);
self.pins.deinit();
self.fallback_client.deinit(self.allocator);
}
/// Re-reads the pins file, replacing the in-memory overrides.
///
/// Parsing happens before the exclusive lock is taken so lookups only block for
/// the swap. A parse failure leaves the existing pins installed.
pub fn reloadPins(self: *GeoIP) !void {
const fresh = try Pins.load(self.allocator, self.io, self.pins_path);
self.lock.lockUncancelable(self.io);
var old = self.pins;
self.pins = fresh;
self.lock.unlock(self.io);
old.deinit();
log.info("reloaded {d} IP pin(s) from {s}", .{ self.pins.entries.items.len, self.pins_path });
}
pub fn lookup(self: *GeoIP, ip: []const u8) ?Location {
// Try MaxMind first
const result = lookupInternal(self.mmdb, ip) catch return null;
// Try MaxMind first. The shared lock has to cover `extractCoordinates` as
// well as the lookup itself: `MMDB_lookup_result_s.entry` holds a pointer
// back to the `MMDB_s`, which `extractCoordinates` dereferences.
const from_db: ?Location = blk: {
self.lock.lockSharedUncancelable(self.io);
defer self.lock.unlockShared(self.io);
log.debug("lookup geoip db for ip {s}. Found: {}", .{ ip, result.found_entry });
if (result.found_entry)
if (self.extractCoordinates(ip, result)) |coords|
return coords;
// Manual overrides win outright. They exist precisely for addresses the
// database resolves confidently and incorrectly, so consulting the
// database first would defeat the purpose.
if (self.pins.lookup(self.allocator, ip)) |pinned| {
log.debug("pin matched for ip {s} -> {s}", .{ ip, pinned.name });
break :blk pinned;
}
// Fallback to configured online provider
const result = lookupInternal(self.mmdb, ip) catch break :blk null;
log.debug("lookup geoip db for ip {s}. Found: {}", .{ ip, result.found_entry });
if (!result.found_entry) break :blk null;
break :blk self.extractCoordinates(ip, result);
};
if (from_db) |coords| return coords;
// Fallback to configured online provider. Deliberately outside the lock:
// this performs a network request and must not block a reload.
return self.fallback_client.lookup(ip);
}
@ -113,6 +237,11 @@ fn lookupInternal(mmdb: *c.MMDB_s, ip: []const u8) !c.MMDB_lookup_result_s {
}
pub fn isUSIp(self: *GeoIP, ip: []const u8) bool {
// `country_data` borrows from the mapped database via `result.entry`, so the
// whole read stays inside the shared lock.
self.lock.lockSharedUncancelable(self.io);
defer self.lock.unlockShared(self.io);
var result = lookupInternal(self.mmdb, ip) catch return false;
if (!result.found_entry) return false;
@ -131,7 +260,11 @@ pub fn isUSIp(self: *GeoIP, ip: []const u8) bool {
/// Maximum accuracy radius (in km) to trust from GeoLite2. Entries with a
/// radius above this are too coarse for weather lookups (e.g. backbone/transit
/// IPs that MaxMind maps to the wrong city) and should fall back to IP2Location.
/// IPs that MaxMind maps to the wrong city) and should fall back to the
/// configured online provider.
///
/// Note this only catches entries GeoLite2 admits are vague. An entry can be
/// precise *and* wrong, which is what pins exist for.
const max_accuracy_radius_km = 200;
fn extractCoordinates(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result_s) ?Location {
@ -139,15 +272,15 @@ fn extractCoordinates(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result
var entry_copy = result.entry;
// Check accuracy_radius first -- reject low-confidence entries so we
// fall back to the IP2Location online lookup instead.
// Check accuracy_radius first -- reject low-confidence entries so we fall
// back to the configured online provider instead.
// SAFETY: accuracy_data set by MMDB_get_value
var accuracy_data: c.MMDB_entry_data_s = undefined;
const acc_status = c.MMDB_get_value(&entry_copy, &accuracy_data, "location", "accuracy_radius", @as([*c]const u8, null));
if (acc_status == c.MMDB_SUCCESS and accuracy_data.has_data) {
const radius = accuracy_data.unnamed_0.uint16;
if (radius > max_accuracy_radius_km) {
log.info("GeoLite2 accuracy_radius for ip {s} is {d} km (>{d} km threshold), falling back to IP2Location", .{ ip, radius, max_accuracy_radius_km });
log.info("GeoLite2 accuracy_radius for ip {s} is {d} km (>{d} km threshold), falling back to the online provider", .{ ip, radius, max_accuracy_radius_km });
return null;
}
}
@ -172,16 +305,13 @@ fn extractCoordinates(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result
return null;
}
var coords = [_]f64{ latitude_data.unnamed_0.double_value, longitude_data.unnamed_0.double_value };
// Depending on how this is compiled, the byteswap may or may not be necessary
// original c, compiled with zig, statically linked: byteSwap
// pre=built, dynamically linked, do not byte swap
// I'm not sure precisely what causes this
std.mem.byteSwapAllElements(f64, &coords);
const latitude = coords[0];
const longitude = coords[1];
// No byte swapping here: `build.zig` sets `MMDB_LITTLE_ENDIAN` for the target,
// so libmaxminddb converts the database's big-endian doubles to host order in
// `get_ieee754_double` before we ever see them. If that define were ever
// dropped, these coordinates would silently become nonsense, which the
// "lookup works" test below is positioned to catch.
const latitude = latitude_data.unnamed_0.double_value;
const longitude = longitude_data.unnamed_0.double_value;
// Extract location name parts
// SAFETY: value set by MMDB_get_value
@ -229,25 +359,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 +390,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();
@ -287,3 +417,150 @@ test "lookup works" {
try std.testing.expect(result.coords.longitude < -121.0 and result.coords.longitude > -123.0);
try std.testing.expect(result.name.len > 0);
}
test "reload swaps the database while lookups keep working" {
const allocator = std.testing.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(allocator, std.testing.io, db_path);
}
var geoip = GeoIP.init(allocator, std.testing.io, db_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
// A residential IP that resolves from the database rather than the online
// fallback, so the assertions below exercise the mmap and not the network.
const test_ip = "73.158.64.1";
const before = geoip.lookup(test_ip) orelse return error.SkipZigTest;
defer before.deinit();
const handle_before = geoip.mmdb;
try geoip.reload();
// A fresh handle must be installed, and the old one must not be reused.
try std.testing.expect(geoip.mmdb != handle_before);
// Same query, same answer: the swap installed an equivalent database and
// left the lookup path intact.
const after = geoip.lookup(test_ip) orelse return error.TestUnexpectedResult;
defer after.deinit();
try std.testing.expectEqualStrings(before.name, after.name);
try std.testing.expectEqual(before.coords.latitude, after.coords.latitude);
try std.testing.expectEqual(before.coords.longitude, after.coords.longitude);
// Lookups that go through the shared lock a second time still work, which
// catches a reload that left the lock in a bad state.
try std.testing.expect(geoip.isUSIp(test_ip));
}
test "a pin overrides a confident but wrong database answer" {
const allocator = std.testing.allocator;
const io = std.testing.io;
const build_options = @import("build_options");
var config = try Config.loadForTest(allocator);
defer config.deinit(allocator);
if (build_options.download_geoip) {
const GeoLite2 = @import("GeoLite2.zig");
try GeoLite2.ensureDatabase(allocator, io, config.geolite_path);
}
// This is the bug this feature exists for: GeoLite2 places this AT&T address
// in Fort Worth, Texas with accuracy_radius = 20. The radius is low enough
// that the low-confidence fallback never triggers, so nothing but an explicit
// override can correct it.
const wrong_ip = "12.94.132.170";
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPath(io, &path_buf);
const pins_path = try std.fmt.allocPrint(allocator, "{s}/pins", .{path_buf[0..dir_len]});
defer allocator.free(pins_path);
// Start with no pins and confirm the database's wrong answer is what we get.
allocator.free(config.pins_file);
config.pins_file = try allocator.dupe(u8, pins_path);
{
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
const unpinned = geoip.lookup(wrong_ip) orelse return error.SkipZigTest;
defer unpinned.deinit();
// Guard against a future database that fixes this on its own; if that
// happens this test needs a different address rather than a silent pass.
if (std.mem.indexOf(u8, unpinned.name, "Texas") == null) return error.SkipZigTest;
}
// Write a pin covering the whole /24 the address sits in.
{
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(
try Pins.parseCidr("12.94.132.0/24"),
"San Francisco, California, United States",
.{ .latitude = 37.7749, .longitude = -122.4194 },
);
try pins.save(io, pins_path);
}
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
const pinned = geoip.lookup(wrong_ip) orelse return error.TestUnexpectedResult;
defer pinned.deinit();
try std.testing.expectEqualStrings("San Francisco, California, United States", pinned.name);
try std.testing.expectEqual(@as(f64, 37.7749), pinned.coords.latitude);
try std.testing.expectEqual(@as(f64, -122.4194), pinned.coords.longitude);
}
test "reloadPins picks up a pin written after startup" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var config = try Config.loadForTest(allocator);
defer config.deinit(allocator);
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPath(io, &path_buf);
const pins_path = try std.fmt.allocPrint(allocator, "{s}/pins", .{path_buf[0..dir_len]});
defer allocator.free(pins_path);
allocator.free(config.pins_file);
config.pins_file = try allocator.dupe(u8, pins_path);
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
try std.testing.expectEqual(@as(usize, 0), geoip.pins.entries.items.len);
{
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Somewhere", .{ .latitude = 1, .longitude = 2 });
try pins.save(io, pins_path);
}
try geoip.reloadPins();
try std.testing.expectEqual(@as(usize, 1), geoip.pins.entries.items.len);
const hit = geoip.lookup("203.0.113.7") orelse return error.TestUnexpectedResult;
defer hit.deinit();
try std.testing.expectEqualStrings("Somewhere", hit.name);
}

View file

@ -1,47 +1,96 @@
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 {
log.info("GeoLite2 database not found at {s}, will download", .{path});
try downloadDatabase(allocator, path);
/// Ensures a GeoLite2 database exists at `path`, downloading it if absent.
///
/// A missing database is fatal: `GeoIp.init` cannot open anything, so there is
/// nothing to serve lookups with. Keeping the database *fresh* is a separate
/// concern handled off-request by `Refresher`, because a stale database still
/// answers lookups and a network problem must not stop the service from
/// starting.
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}, downloading", .{path});
try download(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);
/// Age of the database at `path`, in seconds, or null if it cannot be stat'd.
pub fn ageInSeconds(io: std.Io, path: []const u8) ?u64 {
const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch |err| {
log.warn("could not stat GeoLite2 database {s}: {t}", .{ path, err });
return null;
};
return ageOf(std.Io.Timestamp.now(io, .real), stat.mtime);
}
/// Seconds between `mtime` and `now`, clamped at 0.
///
/// Split out from `ageInSeconds` so the arithmetic is testable without a file.
/// The clamp matters: an mtime in the future (clock skew, or a file copied with
/// its timestamps from a machine that is ahead) would otherwise wrap the
/// unsigned result into a huge age and trigger a pointless download.
pub fn ageOf(now: std.Io.Timestamp, mtime: std.Io.Timestamp) u64 {
const age_ns = now.nanoseconds - mtime.nanoseconds;
if (age_ns <= 0) return 0;
return @intCast(@divFloor(age_ns, std.time.ns_per_s));
}
/// Downloads the current database and atomically replaces `path`.
///
/// The download lands on a sibling temp file and is renamed into place, so a
/// download that dies partway cannot leave a truncated database behind, and any
/// process with the old file mapped keeps reading a consistent inode until it
/// reopens.
pub fn download(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);
const response_buf = try allocator.alloc(u8, 64 * 1024 * 1024);
defer allocator.free(response_buf);
var writer = std.Io.Writer.fixed(response_buf);
const result = try client.fetch(.{
.location = .{ .uri = uri },
.method = .GET,
.response_writer = &writer,
});
if (result.status != .ok) return error.DownloadFailed;
// 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]);
const tmp_path = try std.fmt.allocPrint(allocator, "{s}.download", .{path});
defer allocator.free(tmp_path);
{
const file = try std.Io.Dir.cwd().createFile(io, tmp_path, .{});
// A partial download is worse than no download; do not leave it around.
errdefer std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |err|
log.warn("could not remove partial download {s}: {t}", .{ tmp_path, err });
defer file.close(io);
// Stream straight to disk. Buffering the whole database in memory needed
// an allocation sized by guesswork, and the guess (64 MiB) was already
// within about 1 MB of the real database size.
var buf: [64 * 1024]u8 = undefined;
var file_writer = file.writer(io, &buf);
const result = try client.fetch(.{
.location = .{ .uri = uri },
.method = .GET,
.response_writer = &file_writer.interface,
});
if (result.status != .ok) {
log.err("GeoLite2 download returned HTTP {d}", .{@intFromEnum(result.status)});
return error.DownloadFailed;
}
try file_writer.interface.flush();
}
try std.Io.Dir.cwd().rename(tmp_path, .cwd(), path, io);
}
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";

View file

@ -1,24 +1,30 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const Location = @import("resolver.zig").Location;
const srf = @import("srf");
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 +37,44 @@ 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.
///
/// IPv4-mapped IPv6 addresses (`::ffff:1.2.3.4`) are folded down to IPv4.
/// `IpAddress.parse` does not do this itself, and without it one address has two
/// representations: cache entries would be stored twice, and a CIDR pin written
/// for an IPv4 range would not match a client that arrived as a mapped address.
/// That is not hypothetical -- with no `X-Forwarded-For` header the client
/// address comes from the socket, and a dual-stack listener reports IPv4 peers
/// in mapped form.
pub fn packIp(ip_str: []const u8) ?PackedIp {
const parsed = std.Io.net.IpAddress.parse(ip_str, 0) catch return null;
const addr = switch (parsed) {
.ip4 => parsed,
.ip6 => |a| std.Io.net.IpAddress.fromIp6(a),
};
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 +107,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,
@ -140,33 +175,58 @@ inline fn getString(obj: std.json.ObjectMap, key: []const u8) []const u8 {
return maybe_val.?.string;
}
/// Permanent IP-to-location cache, shared by the online providers.
///
/// Append-only: an online lookup for an address is answered once and the result
/// is kept forever, because an address's city does not meaningfully change and
/// every avoided request is one that does not count against a rate limit.
///
/// Stored as SRF in compact form, one record per line. Compact is safe for place
/// names containing commas because SRF length-prefixes any string containing the
/// field delimiter, so no escaping rules are needed here.
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 {
/// On-disk shape of one entry.
///
/// The address is stored as text rather than the `u128` used for the in-memory
/// key, so the file stays legible to whoever has to look at it.
const Record = struct {
ip: []const u8,
lat: f64,
lon: f64,
name: []const u8,
};
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,
};
errdefer allocator.free(cache.path);
errdefer cache.entries.deinit();
// 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();
cache.load() catch |err| {
// A cache that cannot be read is worth strictly less than the
// service staying up: start empty and let it refill.
log.warn("could not read cache {s} ({t}); starting empty", .{ path, err });
};
} 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 });
// Write header
try cache.file.?.writeAll("#Ip2location:v2\n");
try std.Io.Dir.cwd().createDirPath(io, dir);
cache.file = try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true });
try cache.writeDirectives();
},
else => return err,
}
@ -174,8 +234,19 @@ pub const Cache = struct {
return cache;
}
/// Writes the SRF front matter that opens a new cache file.
///
/// Emitted once at creation; `put` appends bare records afterwards.
fn writeDirectives(self: *Cache) !void {
const file = self.file orelse return;
var buf: [64]u8 = undefined;
var fw = file.writer(self.io, &buf);
try fw.interface.print("{f}", .{srf.fmt(Record, &.{}, .{})});
try fw.interface.flush();
}
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,70 +257,44 @@ 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');
var reader = std.Io.Reader.fixed(content);
var records = try srf.iterator(&reader, self.allocator, .{});
defer records.deinit();
// Check for header magic string
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();
self.file = null;
std.fs.deleteFileAbsolute(self.path) catch |e| {
log.err("error deleting {s}: {}", .{ self.path, e });
};
return;
}
} else {
return; // Empty file
}
var index: usize = 0;
while (records.next() catch |err| {
log.warn("stopped reading {s} after {d} entr(ies): {t}", .{ self.path, index, err });
return;
}) |fields| {
index += 1;
const record = fields.to(Record, .{}) catch continue;
const parsed = packIp(record.ip) orelse continue;
while (lines.next()) |line| {
if (line.len == 0) continue;
const entry = parseCacheLine(self.allocator, line) catch continue;
try self.entries.put(entry.ip, entry.location);
}
}
const name_copy = try self.allocator.dupe(u8, record.name);
errdefer self.allocator.free(name_copy);
const CacheEntry = struct {
ip: u128,
location: Location,
};
fn parseCacheLine(allocator: Allocator, line: []const u8) !CacheEntry {
// Parse: ip,lat,lon,name
var parts = std.mem.splitScalar(u8, line, ',');
const ip_str = parts.next() orelse return error.InvalidFormat;
const lat_str = parts.next() orelse return error.InvalidFormat;
const lon_str = parts.next() orelse return error.InvalidFormat;
const name = parts.rest();
const lat = try std.fmt.parseFloat(f64, lat_str);
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 name_copy = try allocator.dupe(u8, name);
return .{
.ip = ip_u128,
.location = .{
.allocator = allocator,
// Replacing an existing key would leak the name it already owns.
const existing = try self.entries.fetchPut(parsed.key, .{
.allocator = self.allocator,
.name = name_copy,
.coords = .{ .latitude = lat, .longitude = lon },
},
};
.coords = .{ .latitude = record.lat, .longitude = record.lon },
});
if (existing) |old| self.allocator.free(old.value.name);
}
}
pub fn get(self: *Cache, ip: u128) ?Location {
@ -263,100 +308,141 @@ pub const Cache = struct {
pub fn put(self: *Cache, ip: u128, family: u8, loc: Location) !void {
const name_copy = try self.allocator.dupe(u8, loc.name);
try self.entries.put(ip, .{
errdefer self.allocator.free(name_copy);
const existing = try self.entries.fetchPut(ip, .{
.allocator = self.allocator,
.name = name_copy,
.coords = loc.coords,
});
if (existing) |old| self.allocator.free(old.value.name);
// Append to file: ip,lat,lon,name
if (self.file) |file| {
try file.seekFromEnd(0);
// Format IP as string for file
var buf: [64]u8 = undefined;
const ip_str = if (family == 4)
try std.fmt.bufPrint(&buf, "{}.{}.{}.{}", .{
@as(u8, @truncate(ip >> 24)),
@as(u8, @truncate(ip >> 16)),
@as(u8, @truncate(ip >> 8)),
@as(u8, @truncate(ip)),
})
else
try std.fmt.bufPrint(&buf, "{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}", .{
@as(u16, @truncate(ip >> 112)),
@as(u16, @truncate(ip >> 96)),
@as(u16, @truncate(ip >> 80)),
@as(u16, @truncate(ip >> 64)),
@as(u16, @truncate(ip >> 48)),
@as(u16, @truncate(ip >> 32)),
@as(u16, @truncate(ip >> 16)),
@as(u16, @truncate(ip)),
});
const line = try std.fmt.allocPrint(self.allocator, "{s},{d},{d},{s}\n", .{
ip_str,
loc.coords.latitude,
loc.coords.longitude,
loc.name,
const file = self.file orelse return;
var ip_buf: [64]u8 = undefined;
const ip_str = try formatKey(ip, family, &ip_buf);
// Append one record with no front matter; the directives were written
// when the file was created.
var line_buf: [512]u8 = undefined;
const line = try std.fmt.bufPrint(&line_buf, "{f}", .{srf.fmt(Record, &.{.{
.ip = ip_str,
.lat = loc.coords.latitude,
.lon = loc.coords.longitude,
.name = loc.name,
}}, .{ .emit_directives = false })});
// 0.16 has no seek+write; append at the current end offset.
const end = try file.length(self.io);
try file.writePositionalAll(self.io, line, end);
}
/// Renders a packed key back to text for storage.
fn formatKey(ip: u128, family: u8, buf: []u8) ![]const u8 {
if (family == 4) {
return std.fmt.bufPrint(buf, "{d}.{d}.{d}.{d}", .{
@as(u8, @truncate(ip >> 24)),
@as(u8, @truncate(ip >> 16)),
@as(u8, @truncate(ip >> 8)),
@as(u8, @truncate(ip)),
});
defer self.allocator.free(line);
try file.writeAll(line);
}
var bytes: [16]u8 = undefined;
std.mem.writeInt(u128, &bytes, ip, .big);
// `Ip6Address.format` would append ":port" and bracket the address;
// `Unresolved` is the bare-address formatter.
const bare: std.Io.net.Ip6Address.Unresolved = .{ .bytes = bytes, .interface_name = null };
return std.fmt.bufPrint(buf, "{f}", .{&bare});
}
};
test "parseCacheLine: valid IPv4 line" {
test "Cache: round-trips IPv4 and IPv6 entries through the file" {
const allocator = std.testing.allocator;
const line = "192.168.1.1,37.5,-122.5,San Francisco, California, United States";
const entry = try Cache.parseCacheLine(allocator, line);
defer allocator.free(entry.location.name);
const io = std.testing.io;
try std.testing.expectEqual(@as(u128, 3232235777), entry.ip); // 192.168.1.1 as u128
try std.testing.expectEqual(@as(f64, 37.5), entry.location.coords.latitude);
try std.testing.expectEqual(@as(f64, -122.5), entry.location.coords.longitude);
try std.testing.expectEqualStrings("San Francisco, California, United States", entry.location.name);
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPath(io, &path_buf);
const path = try std.fmt.allocPrint(allocator, "{s}/ip.cache", .{path_buf[0..dir_len]});
defer allocator.free(path);
const v4 = packIp("192.168.1.1").?;
const v6 = packIp("2001:db8::1").?;
{
var cache = try Cache.init(allocator, io, path);
defer cache.deinit();
// A name with commas is the interesting case: compact SRF delimits on
// commas, so this only survives via length prefixing.
try cache.put(v4.key, v4.family, .{
.allocator = allocator,
.name = "San Francisco, California, United States",
.coords = .{ .latitude = 37.5, .longitude = -122.5 },
});
try cache.put(v6.key, v6.family, .{
.allocator = allocator,
.name = "London, United Kingdom",
.coords = .{ .latitude = 51.5, .longitude = -0.1 },
});
}
var reopened = try Cache.init(allocator, io, path);
defer reopened.deinit();
const got4 = reopened.get(v4.key) orelse return error.TestUnexpectedResult;
defer got4.deinit();
try std.testing.expectEqualStrings("San Francisco, California, United States", got4.name);
try std.testing.expectEqual(@as(f64, 37.5), got4.coords.latitude);
try std.testing.expectEqual(@as(f64, -122.5), got4.coords.longitude);
const got6 = reopened.get(v6.key) orelse return error.TestUnexpectedResult;
defer got6.deinit();
try std.testing.expectEqualStrings("London, United Kingdom", got6.name);
try std.testing.expectEqual(@as(f64, 51.5), got6.coords.latitude);
}
test "parseCacheLine: valid IPv6 line" {
test "Cache: a legacy or corrupt file starts empty instead of failing" {
const allocator = std.testing.allocator;
const line = "2001:db8::1,51.5,-0.1,London, United Kingdom";
const entry = try Cache.parseCacheLine(allocator, line);
defer allocator.free(entry.location.name);
const io = std.testing.io;
try std.testing.expect(entry.ip > 0);
try std.testing.expectEqual(@as(f64, 51.5), entry.location.coords.latitude);
try std.testing.expectEqual(@as(f64, -0.1), entry.location.coords.longitude);
try std.testing.expectEqualStrings("London, United Kingdom", entry.location.name);
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPath(io, &path_buf);
const path = try std.fmt.allocPrint(allocator, "{s}/ip.cache", .{path_buf[0..dir_len]});
defer allocator.free(path);
// The previous hand-rolled format. Deliberately not migrated: the entries are
// re-fetchable and losing them costs a handful of API calls.
try std.Io.Dir.cwd().writeFile(io, .{
.sub_path = path,
.data = "#Ip2location:v2\n192.168.1.1,37.5,-122.5,San Francisco\n",
});
var cache = try Cache.init(allocator, io, path);
defer cache.deinit();
try std.testing.expectEqual(@as(usize, 0), cache.entries.count());
// Still usable afterwards, which is the point of not treating it as fatal.
const v4 = packIp("10.0.0.1").?;
try cache.put(v4.key, v4.family, .{
.allocator = allocator,
.name = "Somewhere",
.coords = .{ .latitude = 1, .longitude = 2 },
});
const got = cache.get(v4.key) orelse return error.TestUnexpectedResult;
defer got.deinit();
try std.testing.expectEqualStrings("Somewhere", got.name);
}
test "parseCacheLine: empty name" {
const allocator = std.testing.allocator;
const line = "10.0.0.1,0.0,0.0,";
const entry = try Cache.parseCacheLine(allocator, line);
defer allocator.free(entry.location.name);
test "Cache: formatKey renders both families without a port" {
var buf: [64]u8 = undefined;
const v4 = packIp("12.94.132.170").?;
try std.testing.expectEqualStrings("12.94.132.170", try Cache.formatKey(v4.key, v4.family, &buf));
try std.testing.expectEqualStrings("", entry.location.name);
}
test "parseCacheLine: missing fields" {
const allocator = std.testing.allocator;
const line = "192.168.1.1,37.5";
try std.testing.expectError(error.InvalidFormat, Cache.parseCacheLine(allocator, line));
}
test "parseCacheLine: invalid IP" {
const allocator = std.testing.allocator;
const line = "not.an.ip,37.5,-122.5,Test";
try std.testing.expectError(error.InvalidCharacter, Cache.parseCacheLine(allocator, line));
}
test "parseCacheLine: invalid latitude" {
const allocator = std.testing.allocator;
const line = "192.168.1.1,invalid,-122.5,Test";
try std.testing.expectError(error.InvalidCharacter, Cache.parseCacheLine(allocator, line));
}
test "parseCacheLine: invalid longitude" {
const allocator = std.testing.allocator;
const line = "192.168.1.1,37.5,invalid,Test";
try std.testing.expectError(error.InvalidCharacter, Cache.parseCacheLine(allocator, line));
const v6 = packIp("2001:db8::1").?;
try std.testing.expectEqualStrings("2001:db8::1", try Cache.formatKey(v6.key, v6.family, &buf));
}

View file

@ -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|

565
src/location/Pins.zig Normal file
View file

@ -0,0 +1,565 @@
const std = @import("std");
const Coordinates = @import("../Coordinates.zig");
const Location = @import("resolver.zig").Location;
const packIp = @import("Ip2location.zig").packIp;
const srf = @import("srf");
const Pins = @This();
const log = std.log.scoped(.pins);
/// Manual IP-range to location overrides, consulted *before* GeoLite2.
///
/// This exists because GeoLite2 can be confidently wrong. A corporate WAN
/// address may carry `accuracy_radius = 20` (a strong confidence signal) while
/// pointing at a city 1400 miles from the client, which means no
/// confidence-based heuristic and no online fallback will ever correct it: the
/// fallback only runs when GeoLite2 declines to answer.
///
/// Pins are therefore a higher-priority store rather than another cache. The
/// existing ipwho.is cache cannot serve this purpose, as it is only reached when
/// GeoLite2 has already given up.
allocator: std.mem.Allocator,
entries: std.ArrayList(Entry),
pub const Entry = struct {
/// 4 or 6. A pin only matches addresses of the same family.
family: u8,
/// Network address with all bits below `prefix_len` cleared.
network: u128,
/// Prefix length in bits (0-32 for IPv4, 0-128 for IPv6).
prefix_len: u8,
name: []const u8,
coords: Coordinates,
};
pub fn init(allocator: std.mem.Allocator) Pins {
return .{ .allocator = allocator, .entries = .empty };
}
pub fn deinit(self: *Pins) void {
for (self.entries.items) |e| self.allocator.free(e.name);
self.entries.deinit(self.allocator);
}
/// Total bits in an address of `family`.
fn familyBits(family: u8) u8 {
return if (family == 4) 32 else 128;
}
/// Masks `addr` down to its first `prefix_len` bits.
///
/// Shifting by the full width is undefined in Zig, so a prefix that covers the
/// whole address is returned unchanged.
fn maskToPrefix(addr: u128, family: u8, prefix_len: u8) u128 {
const bits = familyBits(family);
if (prefix_len >= bits) return addr;
const host_bits: u7 = @intCast(bits - prefix_len);
const mask = ~@as(u128, 0) << host_bits;
// For IPv4 the value only occupies the low 32 bits, so the mask needs
// narrowing to avoid clearing bits that were never part of the address.
const family_mask: u128 = if (bits == 128) ~@as(u128, 0) else (@as(u128, 1) << 32) - 1;
return addr & mask & family_mask;
}
pub const ParseError = error{
InvalidAddress,
InvalidPrefixLength,
};
pub const Cidr = struct {
family: u8,
network: u128,
prefix_len: u8,
};
/// Parses `1.2.3.0/24`, `2001:db8::/32`, or a bare address.
///
/// A bare address is treated as a host route (`/32` or `/128`), so the common
/// case of pinning one address needs no prefix.
pub fn parseCidr(text: []const u8) ParseError!Cidr {
const slash = std.mem.indexOfScalar(u8, text, '/');
const addr_text = if (slash) |i| text[0..i] else text;
const packed_ip = packIp(addr_text) orelse return error.InvalidAddress;
const bits = familyBits(packed_ip.family);
const prefix_len: u8 = if (slash) |i| blk: {
const n = std.fmt.parseInt(u8, text[i + 1 ..], 10) catch return error.InvalidPrefixLength;
if (n > bits) return error.InvalidPrefixLength;
break :blk n;
} else bits;
return .{
.family = packed_ip.family,
.network = maskToPrefix(packed_ip.key, packed_ip.family, prefix_len),
.prefix_len = prefix_len,
};
}
/// Formats a `Cidr` back into `address/prefix` form.
pub fn formatCidr(cidr: Cidr, buf: []u8) ![]const u8 {
if (cidr.family == 4) {
const v: u32 = @intCast(cidr.network);
return std.fmt.bufPrint(buf, "{d}.{d}.{d}.{d}/{d}", .{
@as(u8, @truncate(v >> 24)),
@as(u8, @truncate(v >> 16)),
@as(u8, @truncate(v >> 8)),
@as(u8, @truncate(v)),
cidr.prefix_len,
});
}
var bytes: [16]u8 = undefined;
std.mem.writeInt(u128, &bytes, cidr.network, .big);
// `Ip6Address.format` renders "[addr]:port", which would not parse back.
// `Unresolved` is the bare-address formatter.
const bare: std.Io.net.Ip6Address.Unresolved = .{ .bytes = bytes, .interface_name = null };
return std.fmt.bufPrint(buf, "{f}/{d}", .{ &bare, cidr.prefix_len });
}
/// Longest-prefix match for `ip_str`, or null when no pin applies.
///
/// Longest-prefix ordering means an exact host pin naturally wins over a range
/// covering it, without needing a separate precedence rule.
pub fn lookup(self: *const Pins, allocator: std.mem.Allocator, ip_str: []const u8) ?Location {
const packed_ip = packIp(ip_str) orelse return null;
var best: ?*const Entry = null;
for (self.entries.items) |*e| {
if (e.family != packed_ip.family) continue;
if (maskToPrefix(packed_ip.key, e.family, e.prefix_len) != e.network) continue;
if (best == null or e.prefix_len > best.?.prefix_len) best = e;
}
const entry = best orelse return null;
return .{
.allocator = allocator,
.name = allocator.dupe(u8, entry.name) catch return null,
.coords = entry.coords,
};
}
/// Inserts a pin, replacing any existing pin for the same network.
pub fn put(self: *Pins, cidr: Cidr, name: []const u8, coords: Coordinates) !void {
const name_copy = try self.allocator.dupe(u8, name);
errdefer self.allocator.free(name_copy);
for (self.entries.items) |*e| {
if (e.family == cidr.family and e.prefix_len == cidr.prefix_len and e.network == cidr.network) {
self.allocator.free(e.name);
e.name = name_copy;
e.coords = coords;
return;
}
}
try self.entries.append(self.allocator, .{
.family = cidr.family,
.network = cidr.network,
.prefix_len = cidr.prefix_len,
.name = name_copy,
.coords = coords,
});
}
/// Removes the pin for `cidr`, returning whether one was present.
pub fn remove(self: *Pins, cidr: Cidr) bool {
for (self.entries.items, 0..) |e, i| {
if (e.family == cidr.family and e.prefix_len == cidr.prefix_len and e.network == cidr.network) {
self.allocator.free(e.name);
_ = self.entries.orderedRemove(i);
return true;
}
}
return false;
}
/// On-disk shape of one pin.
///
/// Stored in SRF rather than a hand-rolled line format. This file is new, so
/// there is no legacy data to preserve, and inventing a bespoke format here
/// would only create something to migrate later. SRF also handles the awkward
/// part for free: place names routinely contain commas ("San Francisco,
/// California, United States"), which a comma-delimited format has to special
/// case.
const Record = struct {
cidr: []const u8,
lat: f64,
lon: f64,
name: []const u8,
};
/// Loads pins from `path`. A missing file yields an empty set: having no
/// overrides is the normal state, not an error.
pub fn load(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !Pins {
var pins: Pins = .init(allocator);
errdefer pins.deinit();
const content = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| switch (err) {
error.FileNotFound => return pins,
else => return err,
};
defer allocator.free(content);
if (std.mem.trim(u8, content, " \r\n\t").len == 0) return pins;
var reader = std.Io.Reader.fixed(content);
// Lenient number parsing because this file is meant to be operator-editable:
// a hand-written `lat::37.7` (untyped) should work as well as `lat:num:37.7`.
var records = srf.iterator(&reader, allocator, .{ .strict_number_parsing = false }) catch |err| {
log.warn("could not parse pins file {s} ({t}); continuing with no overrides", .{ path, err });
return pins;
};
defer records.deinit();
var index: usize = 0;
while (records.next() catch |err| {
log.warn("stopped reading {s} after {d} pin(s): {t}", .{ path, index, err });
return pins;
}) |fields| {
index += 1;
const record = fields.to(Record, .{ .strings_to_numbers = true }) catch |err| {
log.warn("skipping malformed pin #{d} in {s}: {t}", .{ index, path, err });
continue;
};
const cidr = parseCidr(record.cidr) catch |err| {
log.warn("skipping pin #{d} in {s}: invalid range {s} ({t})", .{ index, path, record.cidr, err });
continue;
};
// `put` copies the name, so it does not matter that the parsed strings
// belong to the iterator's arena.
try pins.put(cidr, record.name, .{ .latitude = record.lat, .longitude = record.lon });
}
return pins;
}
/// Writes all pins to `path`, replacing it atomically so a crash mid-write
/// cannot leave a half-written override file that fails to parse on next boot.
pub fn save(self: *const Pins, io: std.Io, path: []const u8) !void {
const tmp_path = try std.fmt.allocPrint(self.allocator, "{s}.new", .{path});
defer self.allocator.free(tmp_path);
if (std.fs.path.dirname(path)) |dir| {
try std.Io.Dir.cwd().createDirPath(io, dir);
}
// Build the records up front: the formatter takes a slice, and the CIDR text
// has to outlive it.
var records = try self.allocator.alloc(Record, self.entries.items.len);
defer self.allocator.free(records);
var cidr_texts = try self.allocator.alloc([]u8, self.entries.items.len);
var made: usize = 0;
defer {
for (cidr_texts[0..made]) |t| self.allocator.free(t);
self.allocator.free(cidr_texts);
}
for (self.entries.items, 0..) |e, i| {
var buf: [64]u8 = undefined;
const text = try formatCidr(
.{ .family = e.family, .network = e.network, .prefix_len = e.prefix_len },
&buf,
);
cidr_texts[i] = try self.allocator.dupe(u8, text);
made += 1;
records[i] = .{
.cidr = cidr_texts[i],
.lat = e.coords.latitude,
.lon = e.coords.longitude,
.name = e.name,
};
}
{
const file = try std.Io.Dir.cwd().createFile(io, tmp_path, .{});
errdefer std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |err|
log.warn("could not remove partial pins file {s}: {t}", .{ tmp_path, err });
defer file.close(io);
var buf: [4096]u8 = undefined;
var fw = file.writer(io, &buf);
const w = &fw.interface;
// Long format: one field per line, which is what an operator reading or
// editing this file by hand wants.
try w.print("{f}", .{srf.fmt(Record, records, .{ .long_format = true })});
try w.flush();
}
try std.Io.Dir.cwd().rename(tmp_path, .cwd(), path, io);
}
test "parseCidr: bare IPv4 becomes a host route" {
const c = try parseCidr("12.94.132.170");
try std.testing.expectEqual(@as(u8, 4), c.family);
try std.testing.expectEqual(@as(u8, 32), c.prefix_len);
}
test "parseCidr: masks host bits off the network" {
// .170 must be discarded by the /24.
const c = try parseCidr("12.94.132.170/24");
const expected = try parseCidr("12.94.132.0/24");
try std.testing.expectEqual(expected.network, c.network);
try std.testing.expectEqual(@as(u8, 24), c.prefix_len);
}
test "parseCidr: IPv6 with prefix" {
const c = try parseCidr("2001:db8::/32");
try std.testing.expectEqual(@as(u8, 6), c.family);
try std.testing.expectEqual(@as(u8, 32), c.prefix_len);
}
test "parseCidr: bare IPv6 becomes a /128" {
const c = try parseCidr("2001:db8::1");
try std.testing.expectEqual(@as(u8, 6), c.family);
try std.testing.expectEqual(@as(u8, 128), c.prefix_len);
}
test "parseCidr: rejects an out-of-range prefix" {
try std.testing.expectError(error.InvalidPrefixLength, parseCidr("10.0.0.0/33"));
try std.testing.expectError(error.InvalidPrefixLength, parseCidr("2001:db8::/129"));
}
test "parseCidr: rejects garbage" {
try std.testing.expectError(error.InvalidAddress, parseCidr("not-an-ip/24"));
try std.testing.expectError(error.InvalidPrefixLength, parseCidr("10.0.0.0/abc"));
}
test "parseCidr: /0 matches everything in its family" {
const c = try parseCidr("0.0.0.0/0");
try std.testing.expectEqual(@as(u128, 0), c.network);
try std.testing.expectEqual(@as(u8, 0), c.prefix_len);
}
test "lookup: matches an address inside the range" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California", .{ .latitude = 37.7749, .longitude = -122.4194 });
const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult;
defer hit.deinit();
try std.testing.expectEqualStrings("San Francisco, California", hit.name);
try std.testing.expectEqual(@as(f64, 37.7749), hit.coords.latitude);
}
test "lookup: ignores an address outside the range" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco", .{ .latitude = 37.7749, .longitude = -122.4194 });
try std.testing.expect(pins.lookup(allocator, "12.94.133.1") == null);
}
test "lookup: longest prefix wins over a broader range" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
// Deliberately inserted broad-first so the result cannot come from ordering.
try pins.put(try parseCidr("12.0.0.0/8"), "Broad", .{ .latitude = 1, .longitude = 1 });
try pins.put(try parseCidr("12.94.132.0/24"), "Specific", .{ .latitude = 2, .longitude = 2 });
const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult;
defer hit.deinit();
try std.testing.expectEqualStrings("Specific", hit.name);
}
test "lookup: an exact host pin beats a range containing it" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try parseCidr("12.94.132.170"), "Host", .{ .latitude = 2, .longitude = 2 });
try pins.put(try parseCidr("12.94.132.0/24"), "Range", .{ .latitude = 1, .longitude = 1 });
const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult;
defer hit.deinit();
try std.testing.expectEqualStrings("Host", hit.name);
}
test "lookup: families do not cross-match" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try parseCidr("0.0.0.0/0"), "All IPv4", .{ .latitude = 1, .longitude = 1 });
// An IPv4 /0 must not swallow IPv6 clients.
try std.testing.expect(pins.lookup(allocator, "2001:db8::1") == null);
const v4_hit = pins.lookup(allocator, "8.8.8.8") orelse return error.TestUnexpectedResult;
defer v4_hit.deinit();
}
test "put: replaces an existing pin for the same network" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
const cidr = try parseCidr("12.94.132.0/24");
try pins.put(cidr, "Old", .{ .latitude = 1, .longitude = 1 });
try pins.put(cidr, "New", .{ .latitude = 2, .longitude = 2 });
try std.testing.expectEqual(@as(usize, 1), pins.entries.items.len);
const hit = pins.lookup(allocator, "12.94.132.5") orelse return error.TestUnexpectedResult;
defer hit.deinit();
try std.testing.expectEqualStrings("New", hit.name);
}
test "remove: reports whether a pin was present" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
const cidr = try parseCidr("12.94.132.0/24");
try pins.put(cidr, "SF", .{ .latitude = 1, .longitude = 1 });
try std.testing.expect(pins.remove(cidr));
try std.testing.expect(!pins.remove(cidr));
try std.testing.expectEqual(@as(usize, 0), pins.entries.items.len);
}
test "formatCidr round-trips through parseCidr" {
var buf: [64]u8 = undefined;
for ([_][]const u8{ "12.94.132.0/24", "10.0.0.0/8", "0.0.0.0/0", "2001:db8::/32" }) |text| {
const parsed = try parseCidr(text);
const formatted = try formatCidr(parsed, &buf);
const reparsed = try parseCidr(formatted);
try std.testing.expectEqual(parsed.family, reparsed.family);
try std.testing.expectEqual(parsed.network, reparsed.network);
try std.testing.expectEqual(parsed.prefix_len, reparsed.prefix_len);
}
}
test "save then load round-trips, including commas in the name" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPath(io, &path_buf);
const path = try std.fmt.allocPrint(allocator, "{s}/pins", .{path_buf[0..dir_len]});
defer allocator.free(path);
{
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California, United States", .{ .latitude = 37.7749, .longitude = -122.4194 });
try pins.put(try parseCidr("2001:db8::/32"), "Test, Place", .{ .latitude = -1.5, .longitude = 2.25 });
try pins.save(io, path);
}
var loaded = try load(allocator, io, path);
defer loaded.deinit();
try std.testing.expectEqual(@as(usize, 2), loaded.entries.items.len);
const v4 = loaded.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult;
defer v4.deinit();
try std.testing.expectEqualStrings("San Francisco, California, United States", v4.name);
try std.testing.expectEqual(@as(f64, 37.7749), v4.coords.latitude);
try std.testing.expectEqual(@as(f64, -122.4194), v4.coords.longitude);
const v6 = loaded.lookup(allocator, "2001:db8::1") orelse return error.TestUnexpectedResult;
defer v6.deinit();
try std.testing.expectEqualStrings("Test, Place", v6.name);
}
test "load: a missing file is an empty set, not an error" {
var pins = try load(std.testing.allocator, std.testing.io, "/nonexistent/pins");
defer pins.deinit();
try std.testing.expectEqual(@as(usize, 0), pins.entries.items.len);
}
test "load: malformed records are skipped, valid ones kept" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPath(io, &path_buf);
const path = try std.fmt.allocPrint(allocator, "{s}/pins", .{path_buf[0..dir_len]});
defer allocator.free(path);
try std.Io.Dir.cwd().writeFile(io, .{
.sub_path = path,
.data =
\\#!srfv1
\\#!long
\\# an operator comment
\\
\\cidr::999.1.1.1/24
\\lat:num:1
\\lon:num:2
\\name::Bad Address
\\
\\cidr::12.94.132.0/24
\\lat:num:37.7749
\\lon:num:-122.4194
\\name::Good Entry, With Commas
\\
,
});
var pins = try load(allocator, io, path);
defer pins.deinit();
// The bad address is dropped; a single unparseable record must not discard
// the operator's other overrides.
try std.testing.expectEqual(@as(usize, 1), pins.entries.items.len);
const hit = pins.lookup(allocator, "12.94.132.1") orelse return error.TestUnexpectedResult;
defer hit.deinit();
try std.testing.expectEqualStrings("Good Entry, With Commas", hit.name);
}
test "load: an empty or whitespace-only file yields no pins" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPath(io, &path_buf);
const path = try std.fmt.allocPrint(allocator, "{s}/pins", .{path_buf[0..dir_len]});
defer allocator.free(path);
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = "\n \n" });
var pins = try load(allocator, io, path);
defer pins.deinit();
try std.testing.expectEqual(@as(usize, 0), pins.entries.items.len);
}
test "save writes SRF that an operator can read" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPath(io, &path_buf);
const path = try std.fmt.allocPrint(allocator, "{s}/pins", .{path_buf[0..dir_len]});
defer allocator.free(path);
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California", .{ .latitude = 37.7749, .longitude = -122.4194 });
try pins.save(io, path);
const content = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024));
defer allocator.free(content);
// Assert the shape, not just that it round-trips: this file is meant to be
// human-inspectable, and a silent switch to compact output would undermine
// that without failing any round-trip test.
try std.testing.expect(std.mem.indexOf(u8, content, "#!srfv1") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "#!long") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "cidr::12.94.132.0/24") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "San Francisco, California") != null);
}

136
src/location/Refresher.zig Normal file
View file

@ -0,0 +1,136 @@
const std = @import("std");
const GeoLite2 = @import("GeoLite2.zig");
const GeoIp = @import("GeoIp.zig");
const Refresher = @This();
const log = std.log.scoped(.geolite2_refresh);
allocator: std.mem.Allocator,
io: std.Io,
geoip: *GeoIp,
db_path: []const u8,
max_age_seconds: u64,
check_interval_seconds: u64,
/// Background refresh of the GeoLite2 database.
///
/// Runs off-request on its own unit of concurrency: the download is tens of
/// megabytes, so doing it inside a request handler would stall that request and
/// occupy a worker thread. Doing it at startup would either delay boot or, worse,
/// make a transient network failure fatal.
///
/// Replacing the file is not enough on its own. The database is mapped with
/// `MMDB_MODE_MMAP`, so this process keeps reading the old inode until it
/// reopens; `GeoIp.reload` performs that reopen under its lock.
pub fn run(self: Refresher) void {
// Check once up front so a database that is already stale at boot is
// replaced promptly rather than after a full interval.
while (true) {
self.checkOnce();
std.Io.sleep(
self.io,
.fromNanoseconds(@intCast(self.check_interval_seconds * std.time.ns_per_s)),
.real,
) catch {
// Cancelled, which is how shutdown stops this loop.
log.debug("refresher stopping", .{});
return;
};
}
}
fn checkOnce(self: Refresher) void {
const age = GeoLite2.ageInSeconds(self.io, self.db_path) orelse return;
if (age < self.max_age_seconds) {
log.debug(
"GeoLite2 database is {d} day(s) old, under the {d} day limit",
.{ @divFloor(age, std.time.s_per_day), @divFloor(self.max_age_seconds, std.time.s_per_day) },
);
return;
}
log.info(
"GeoLite2 database is {d} day(s) old (limit {d} day(s)), refreshing",
.{ @divFloor(age, std.time.s_per_day), @divFloor(self.max_age_seconds, std.time.s_per_day) },
);
GeoLite2.download(self.allocator, self.io, self.db_path) catch |err| {
// A stale database still answers lookups. Keep serving from it.
log.warn(
"GeoLite2 download failed ({t}); continuing with the existing database",
.{err},
);
return;
};
self.geoip.reload() catch |err| {
// The new file is on disk but this process could not map it. The old
// handle is still installed and usable, so keep going and retry on the
// next interval.
log.err(
"GeoLite2 downloaded but reload failed ({t}); still serving the previous database",
.{err},
);
return;
};
log.info("GeoLite2 database refreshed", .{});
}
test "ageOf clamps a future mtime to zero" {
const now: std.Io.Timestamp = .fromNanoseconds(1_000 * std.time.ns_per_s);
const future: std.Io.Timestamp = .fromNanoseconds(2_000 * std.time.ns_per_s);
try std.testing.expectEqual(@as(u64, 0), GeoLite2.ageOf(now, future));
}
test "ageOf reports whole seconds elapsed" {
const mtime: std.Io.Timestamp = .fromNanoseconds(1_000 * std.time.ns_per_s);
const now: std.Io.Timestamp = .fromNanoseconds(1_090 * std.time.ns_per_s);
try std.testing.expectEqual(@as(u64, 90), GeoLite2.ageOf(now, mtime));
}
test "ageOf truncates sub-second remainders rather than rounding up" {
const mtime: std.Io.Timestamp = .fromNanoseconds(0);
const now: std.Io.Timestamp = .fromNanoseconds(std.time.ns_per_s - 1);
try std.testing.expectEqual(@as(u64, 0), GeoLite2.ageOf(now, mtime));
}
test "ageOf spans days for the staleness comparison" {
const mtime: std.Io.Timestamp = .fromNanoseconds(0);
const now: std.Io.Timestamp = .fromNanoseconds(31 * std.time.s_per_day * std.time.ns_per_s);
const age = GeoLite2.ageOf(now, mtime);
try std.testing.expectEqual(@as(u64, 31), @divFloor(age, std.time.s_per_day));
try std.testing.expect(age >= 30 * std.time.s_per_day);
}
/// Watches for operator-requested reloads (SIGHUP) and applies them.
///
/// Separate from `run` because the two have unrelated cadences: staleness is
/// checked on the order of a day, while a reload request should be picked up
/// promptly. Folding them together would mean either polling the signal flag
/// once a day or waking the staleness check every second.
pub fn watchReloads(self: Refresher) void {
const Signals = @import("../Signals.zig");
while (true) {
std.Io.sleep(self.io, .fromMilliseconds(Signals.poll_interval_ms), .real) catch {
log.debug("reload watcher stopping", .{});
return;
};
if (!Signals.takeReloadRequest()) continue;
log.info("SIGHUP received, reloading on-disk state", .{});
// Pins are the common case (an operator just ran `wttr pin`), and a
// failure here leaves the previous overrides installed.
self.geoip.reloadPins() catch |err|
log.err("could not reload pins ({t}); keeping the previous overrides", .{err});
// Also reopen the database, so replacing the file by hand does not
// require waiting for the staleness check or restarting the server.
self.geoip.reload() catch |err|
log.err("could not reload the GeoLite2 database ({t}); keeping the previous one", .{err});
}
}

View file

@ -3,6 +3,7 @@ const GeoIp = @import("GeoIp.zig");
const GeoCache = @import("GeoCache.zig");
const Airports = @import("Airports.zig");
const Coordinates = @import("../Coordinates.zig");
const packIp = @import("Ip2location.zig").packIp;
const log = std.log.scoped(.resolver);
@ -80,13 +81,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 +128,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 formatAddressBare(addr, &buf);
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 +174,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);
@ -249,6 +259,27 @@ pub const Resolver = struct {
return self.resolveGeocoded(code);
}
/// Formats an address with no port and no brackets.
///
/// Every formatter in `std.Io.net` appends ":port", and the IPv6 one also
/// brackets the address. Feeding that to `resolveIP` produced strings like
/// "1.2.3.4:0", which are not addresses, so *every* `@domain` lookup failed
/// with LocationNotFound.
fn formatAddressBare(addr: std.Io.net.IpAddress, buf: []u8) ![]const u8 {
return switch (addr) {
.ip4 => |a| std.fmt.bufPrint(buf, "{d}.{d}.{d}.{d}", .{
a.bytes[0], a.bytes[1], a.bytes[2], a.bytes[3],
}),
.ip6 => |a| blk: {
const bare: std.Io.net.Ip6Address.Unresolved = .{
.bytes = a.bytes,
.interface_name = null,
};
break :blk std.fmt.bufPrint(buf, "{f}", .{&bare});
},
};
}
fn isAlpha(s: []const u8) bool {
for (s) |c| {
if (!std.ascii.isAlphabetic(c)) return false;
@ -256,17 +287,17 @@ pub const Resolver = struct {
return true;
}
/// Whether `s` is an address we can look up.
///
/// This counted dots and required exactly three, so it only ever recognized
/// IPv4. An IPv6 client address fell through to `.city_name` and was sent to
/// the geocoder as a place name, e.g. asking Nominatim to find a town called
/// "2001:4860:4860::8888".
///
/// Deferring to `packIp` keeps this in step with what the rest of the
/// pipeline accepts, including IPv4-mapped IPv6 forms.
fn isIPAddress(s: []const u8) bool {
// Simple check for IPv4
var dots: u8 = 0;
for (s) |c| {
if (c == '.') {
dots += 1;
} else if (!std.ascii.isDigit(c)) {
return false;
}
}
return dots == 3;
return packIp(s) != null;
}
};
@ -275,19 +306,61 @@ test "detect IP address" {
try std.testing.expect(!Resolver.isIPAddress("not.an.ip"));
}
test "detect IP address: IPv6 forms" {
// Previously these were not recognized as addresses at all, so a v6 client
// was handed to the geocoder as though it were the name of a place.
try std.testing.expect(Resolver.isIPAddress("2001:4860:4860::8888"));
try std.testing.expect(Resolver.isIPAddress("::1"));
try std.testing.expect(Resolver.isIPAddress("fe80::1"));
try std.testing.expect(Resolver.isIPAddress("::ffff:12.94.132.170"));
}
test "detect IP address: rejects things that only look numeric" {
try std.testing.expect(!Resolver.isIPAddress("1.2.3"));
try std.testing.expect(!Resolver.isIPAddress("1.2.3.4.5"));
try std.testing.expect(!Resolver.isIPAddress("999.1.1.1"));
try std.testing.expect(!Resolver.isIPAddress("London"));
try std.testing.expect(!Resolver.isIPAddress(""));
// A range is not a query; pins parse CIDR, lookups do not.
try std.testing.expect(!Resolver.isIPAddress("12.94.132.0/24"));
}
test "formatAddressBare omits the port and brackets" {
var buf: [64]u8 = undefined;
const v4 = try std.Io.net.IpAddress.parse("12.94.132.170", 0);
try std.testing.expectEqualStrings("12.94.132.170", try Resolver.formatAddressBare(v4, &buf));
const v6 = try std.Io.net.IpAddress.parse("2001:4860:4860::8888", 0);
try std.testing.expectEqualStrings("2001:4860:4860::8888", try Resolver.formatAddressBare(v6, &buf));
}
test "formatAddressBare output is accepted back as an address" {
var buf: [64]u8 = undefined;
// The round trip is the property that matters: `resolveIP` re-parses this
// string, and the old formatting produced "1.2.3.4:0", which it rejected.
for ([_][]const u8{ "8.8.8.8", "2001:4860:4860::8888", "::1" }) |text| {
const addr = try std.Io.net.IpAddress.parse(text, 0);
const formatted = try Resolver.formatAddressBare(addr, &buf);
try std.testing.expect(Resolver.isIPAddress(formatted));
}
}
test "detect location type" {
try std.testing.expectEqual(LocationType.ip_address, Resolver.detectType("8.8.8.8"));
try std.testing.expectEqual(LocationType.domain_name, Resolver.detectType("@github.com"));
try std.testing.expectEqual(LocationType.special_location, Resolver.detectType("~Eiffel+Tower"));
try std.testing.expectEqual(LocationType.airport_code, Resolver.detectType("muc"));
try std.testing.expectEqual(LocationType.city_name, Resolver.detectType("London"));
try std.testing.expectEqual(LocationType.ip_address, Resolver.detectType("2001:4860:4860::8888"));
try std.testing.expectEqual(LocationType.ip_address, Resolver.detectType("::1"));
}
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 +368,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";

View file

@ -9,16 +9,54 @@ const GeoCache = @import("location/GeoCache.zig");
const Airports = @import("location/Airports.zig");
const Resolver = @import("location/resolver.zig").Resolver;
const GeoLite2 = @import("location/GeoLite2.zig");
const Refresher = @import("location/Refresher.zig");
const Signals = @import("Signals.zig");
const pin_cmd = @import("cli/pin.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 args = try init.minimal.args.toSlice(allocator);
defer allocator.free(args);
const cfg = try Config.load(allocator, init.environ_map);
defer cfg.deinit(allocator);
// No subcommand means "run the server", which keeps the deployed entrypoint
// (`ENTRYPOINT ["/wttr"]`) working unchanged.
if (args.len <= 1) return serve(allocator, io, init, cfg);
// Everything below is a short-lived command rather than the server. SIGHUP
// defaults to terminating the process, and a concurrent invocation looking
// for servers to signal cannot tell a command in flight from a server, so
// ignore the signal instead of being killed by it.
Signals.ignore();
const cmd = args[1];
if (std.mem.eql(u8, cmd, "pin")) return pin_cmd.runPin(allocator, io, cfg, args[2..]);
if (std.mem.eql(u8, cmd, "unpin")) return pin_cmd.runUnpin(allocator, io, cfg, args[2..]);
if (std.mem.eql(u8, cmd, "pins")) return pin_cmd.runList(allocator, io, cfg);
if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "--help") or std.mem.eql(u8, cmd, "-h"))
return pin_cmd.printUsage(io, args[0]);
var stderr_buf: [512]u8 = undefined;
var stderr = std.Io.File.stderr().writer(io, &stderr_buf);
try stderr.interface.print("unknown command: {s}\n\n", .{cmd});
try stderr.interface.flush();
_ = try pin_cmd.printUsage(io, args[0]);
return 2;
}
fn serve(
allocator: std.mem.Allocator,
io: std.Io,
init: std.process.Init,
cfg: Config,
) !u8 {
std.log.info("wttr version {s} starting on {s}:{d}", .{ version, cfg.listen_host, cfg.listen_port });
std.log.info("Cache size: {d}", .{cfg.cache_size});
std.log.info("Cache dir: {s}", .{cfg.cache_dir});
@ -29,18 +67,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);
// A missing database is fatal; keeping it fresh is handled below.
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 +89,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,30 +97,75 @@ 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
if (@import("builtin").mode == .Debug) @import("http/handler.zig").server_instance = &server.httpz_server;
const refresher: Refresher = .{
.allocator = allocator,
.io = io,
.geoip = &geoip,
.db_path = cfg.geolite_path,
.max_age_seconds = cfg.geolite_max_age_seconds,
.check_interval_seconds = cfg.geolite_check_interval_seconds,
};
// Watch for operator-driven reloads (SIGHUP, as sent by `wttr pin`). Started
// before the refresher so a reload is possible even if the refresher is
// disabled or could not start.
Signals.install();
var reload_future = io.concurrent(Refresher.watchReloads, .{refresher}) catch |err| blk: {
std.log.warn("could not start the SIGHUP reload watcher ({t}); pins will apply on next start", .{err});
break :blk null;
};
defer if (reload_future) |*f| f.cancel(io);
// Refresh the GeoLite2 database in the background. `concurrent` rather than
// `async` because this must get a real unit of concurrency: `async` is
// allowed to run the function inline, which would block startup on a
// multi-megabyte download.
var refresher_future = if (cfg.geolite_check_interval_seconds == 0) blk: {
std.log.info("GeoLite2 background refresh disabled", .{});
break :blk null;
} else blk: {
std.log.info(
"GeoLite2 refresh: checking every {d}h, refreshing when older than {d}d",
.{
@divFloor(cfg.geolite_check_interval_seconds, std.time.s_per_hour),
@divFloor(cfg.geolite_max_age_seconds, std.time.s_per_day),
},
);
break :blk io.concurrent(Refresher.run, .{refresher}) catch |err| {
// Not fatal: without a refresher the database simply ages, which is
// the behavior that existed before.
std.log.warn("could not start GeoLite2 refresher ({t}); database will not auto-refresh", .{err});
break :blk null;
};
};
// `Refresher.run` returns void, so cancelling yields nothing to discard.
defer if (refresher_future) |*f| f.cancel(io);
try server.listen();
std.debug.print("shutting down\n", .{});
return 0;
@ -105,4 +189,8 @@ test {
_ = @import("location/Airports.zig");
_ = @import("location/resolver.zig");
_ = @import("location/IpWhoIs.zig");
_ = @import("location/Refresher.zig");
_ = @import("location/Pins.zig");
_ = @import("Signals.zig");
_ = @import("cli/pin.zig");
}

View file

@ -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);

View file

@ -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];

View file

@ -1,489 +0,0 @@
const std = @import("std");
const zigimg = @import("zigimg");
const freetype = @import("freetype");
const Png = @This();
allocator: std.mem.Allocator,
buffer: std.ArrayList(u8),
pub const PngOptions = struct {
transparency: u8 = 150,
background: ?[]const u8 = null,
add_frame: bool = false,
};
const Color = struct {
r: u8,
g: u8,
b: u8,
a: u8,
};
const CHAR_WIDTH = 8;
const CHAR_HEIGHT = 14;
const FRAME_PADDING = 10;
const FRAME_BORDER = 2;
pub fn init(allocator: std.mem.Allocator) Png {
return .{
.allocator = allocator,
.buffer = .{},
};
}
pub fn deinit(self: *Png) void {
self.buffer.deinit(self.allocator);
}
pub fn writer(self: *Png) std.ArrayList(u8).Writer {
return self.buffer.writer(self.allocator);
}
pub fn render(self: *Png, output: *std.Io.Writer, options: PngOptions) !void {
// Parse ANSI text to get dimensions and content
var parsed = try parseAnsiText(self.allocator, self.buffer.items);
defer parsed.deinit();
if (parsed.lines.items.len == 0) {
return error.NoTextToRender;
}
// Calculate image dimensions
const content_width: u32 = @intCast(parsed.max_width * CHAR_WIDTH);
const content_height: u32 = @intCast(parsed.lines.items.len * CHAR_HEIGHT);
std.debug.print("PNG: max_width={}, lines={}, content={}x{}\n", .{ parsed.max_width, parsed.lines.items.len, content_width, content_height });
const padding: u32 = if (options.add_frame) FRAME_PADDING else 0;
const border: u32 = if (options.add_frame) FRAME_BORDER else 0;
const total_padding = padding + border;
const img_width = content_width + (total_padding * 2);
const img_height = content_height + (total_padding * 2);
// Parse background color - default to black (matching legacy wttr.in)
const bg_color = if (options.background) |bg|
try parseColor(bg, 255) // Opaque if background color specified
else
Color{ .r = 0, .g = 0, .b = 0, .a = 255 }; // Black background by default
// Initialize FreeType
var ft_lib = try freetype.Library.init();
defer ft_lib.deinit();
// Load fonts
const mono_font_data = @embedFile("LexiGulim.ttf");
const symbol_font_data = @embedFile("SymbolsNerdFont-Regular.ttf");
var mono_face = try ft_lib.initMemoryFace(mono_font_data, 0);
defer mono_face.deinit();
var symbol_face = try ft_lib.initMemoryFace(symbol_font_data, 0);
defer symbol_face.deinit();
try mono_face.setCharSize(0, 13 * 64, 0, 0);
try symbol_face.setCharSize(0, 13 * 64, 0, 0);
// Create image buffer
var image = try zigimg.Image.create(self.allocator, img_width, img_height, .rgba32);
defer image.deinit(self.allocator);
// Fill background
fillBackground(&image, bg_color);
// Draw frame if requested
if (options.add_frame) {
drawFrame(&image, border);
}
// Render text
const x_offset = total_padding;
const y_offset = total_padding;
for (parsed.lines.items, 0..) |line, row| {
for (line.chars.items, 0..) |char_info, col| {
const x = x_offset + @as(u32, @intCast(col)) * CHAR_WIDTH;
const y = y_offset + @as(u32, @intCast(row)) * CHAR_HEIGHT;
const face = if (isSymbol(char_info.codepoint)) &symbol_face else &mono_face;
try renderChar(
&image,
face,
char_info.codepoint,
x,
y,
char_info.fg_color,
char_info.bg_color,
);
}
}
// Encode to PNG
var write_buffer: [1024 * 1024]u8 = undefined;
const png_data = try image.writeToMemory(self.allocator, &write_buffer, .{ .png = .{} });
try output.writeAll(png_data);
}
const ParsedText = struct {
allocator: std.mem.Allocator,
lines: std.ArrayList(Line),
max_width: usize,
const Line = struct {
chars: std.ArrayList(CharInfo),
};
const CharInfo = struct {
codepoint: u21,
fg_color: Color,
bg_color: Color,
};
fn deinit(self: *ParsedText) void {
for (self.lines.items) |*line| {
line.chars.deinit(self.allocator);
}
self.lines.deinit(self.allocator);
}
};
fn parseAnsiText(allocator: std.mem.Allocator, text: []const u8) !ParsedText {
var result = ParsedText{
.allocator = allocator,
.lines = .{},
.max_width = 0,
};
var current_line = ParsedText.Line{
.chars = .{},
};
var fg_color = Color{ .r = 255, .g = 255, .b = 255, .a = 255 }; // white for dark bg
var bg_color = Color{ .r = 0, .g = 0, .b = 0, .a = 255 }; // black background
var i: usize = 0;
while (i < text.len) {
if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') {
// ANSI escape sequence
const seq_end = std.mem.indexOfScalarPos(u8, text, i, 'm') orelse text.len;
const seq = text[i + 2 .. seq_end];
// Parse color codes
var iter = std.mem.splitScalar(u8, seq, ';');
var codes: std.ArrayList(u8) = .{};
defer codes.deinit(allocator);
while (iter.next()) |code_str| {
const code = std.fmt.parseInt(u8, code_str, 10) catch continue;
try codes.append(allocator, code);
}
// Handle 256-color codes: ESC[38;5;Nm or ESC[48;5;Nm
if (codes.items.len >= 3 and codes.items[0] == 38 and codes.items[1] == 5) {
fg_color = ansi256ToRgb(codes.items[2]);
} else if (codes.items.len >= 3 and codes.items[0] == 48 and codes.items[1] == 5) {
bg_color = ansi256ToRgb(codes.items[2]);
} else {
// Basic 16-color codes
for (codes.items) |code| {
fg_color = parseAnsiColor(code, fg_color);
}
}
i = seq_end + 1;
} else if (text[i] == '\n') {
if (current_line.chars.items.len > result.max_width) {
result.max_width = current_line.chars.items.len;
}
try result.lines.append(allocator, current_line);
current_line = ParsedText.Line{
.chars = .{},
};
i += 1;
} else {
// Regular character
const len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const codepoint = std.unicode.utf8Decode(text[i .. i + len]) catch '?';
try current_line.chars.append(allocator, .{
.codepoint = codepoint,
.fg_color = fg_color,
.bg_color = bg_color,
});
i += len;
}
}
// Add last line
if (current_line.chars.items.len > 0) {
if (current_line.chars.items.len > result.max_width) {
result.max_width = current_line.chars.items.len;
}
try result.lines.append(allocator, current_line);
}
return result;
}
fn parseAnsiColor(code: u8, current: Color) Color {
return switch (code) {
0 => Color{ .r = 255, .g = 255, .b = 255, .a = 255 }, // reset to white
30 => Color{ .r = 0, .g = 0, .b = 0, .a = 255 }, // black
31 => Color{ .r = 205, .g = 49, .b = 49, .a = 255 }, // red
32 => Color{ .r = 13, .g = 188, .b = 121, .a = 255 }, // green
33 => Color{ .r = 229, .g = 229, .b = 16, .a = 255 }, // yellow
34 => Color{ .r = 36, .g = 114, .b = 200, .a = 255 }, // blue
35 => Color{ .r = 188, .g = 63, .b = 188, .a = 255 }, // magenta
36 => Color{ .r = 17, .g = 168, .b = 205, .a = 255 }, // cyan
37 => Color{ .r = 229, .g = 229, .b = 229, .a = 255 }, // white
else => current,
};
}
fn ansi256ToRgb(code: u8) Color {
// ANSI 256 color palette
if (code < 16) {
// Basic 16 colors
return switch (code) {
0 => Color{ .r = 0, .g = 0, .b = 0, .a = 255 },
1 => Color{ .r = 205, .g = 49, .b = 49, .a = 255 },
2 => Color{ .r = 13, .g = 188, .b = 121, .a = 255 },
3 => Color{ .r = 229, .g = 229, .b = 16, .a = 255 },
4 => Color{ .r = 36, .g = 114, .b = 200, .a = 255 },
5 => Color{ .r = 188, .g = 63, .b = 188, .a = 255 },
6 => Color{ .r = 17, .g = 168, .b = 205, .a = 255 },
7 => Color{ .r = 229, .g = 229, .b = 229, .a = 255 },
8 => Color{ .r = 102, .g = 102, .b = 102, .a = 255 },
9 => Color{ .r = 241, .g = 76, .b = 76, .a = 255 },
10 => Color{ .r = 35, .g = 209, .b = 139, .a = 255 },
11 => Color{ .r = 245, .g = 245, .b = 67, .a = 255 },
12 => Color{ .r = 59, .g = 142, .b = 234, .a = 255 },
13 => Color{ .r = 214, .g = 112, .b = 214, .a = 255 },
14 => Color{ .r = 41, .g = 184, .b = 219, .a = 255 },
15 => Color{ .r = 255, .g = 255, .b = 255, .a = 255 },
else => Color{ .r = 255, .g = 255, .b = 255, .a = 255 },
};
} else if (code < 232) {
// 216 color cube (6x6x6)
const idx = code - 16;
const r = (idx / 36) % 6;
const g = (idx / 6) % 6;
const b = idx % 6;
return Color{
.r = if (r > 0) @as(u8, @intCast(55 + r * 40)) else 0,
.g = if (g > 0) @as(u8, @intCast(55 + g * 40)) else 0,
.b = if (b > 0) @as(u8, @intCast(55 + b * 40)) else 0,
.a = 255,
};
} else {
// Grayscale (24 shades)
const gray: u8 = @intCast(8 + (code - 232) * 10);
return Color{ .r = gray, .g = gray, .b = gray, .a = 255 };
}
}
fn parseColor(hex: []const u8, alpha: u8) !Color {
if (hex.len != 6) return error.InvalidColor;
const r = try std.fmt.parseInt(u8, hex[0..2], 16);
const g = try std.fmt.parseInt(u8, hex[2..4], 16);
const b = try std.fmt.parseInt(u8, hex[4..6], 16);
return Color{ .r = r, .g = g, .b = b, .a = alpha };
}
fn fillBackground(image: *zigimg.Image, color: Color) void {
const pixels = image.pixels.rgba32;
for (pixels) |*pixel| {
pixel.* = .{ .r = color.r, .g = color.g, .b = color.b, .a = color.a };
}
}
fn drawFrame(image: *zigimg.Image, border_width: u32) void {
const pixels = image.pixels.rgba32;
const width = image.width;
const height = image.height;
const frame_color = zigimg.color.Rgba32{ .r = 255, .g = 255, .b = 255, .a = 255 };
// Draw border
for (0..height) |y| {
for (0..width) |x| {
if (x < border_width or x >= width - border_width or
y < border_width or y >= height - border_width)
{
pixels[y * width + x] = frame_color;
}
}
}
}
fn isSymbol(codepoint: u21) bool {
// Nerd Fonts symbol ranges
return (codepoint >= 0xe000 and codepoint <= 0xf8ff) or // Private Use Area
(codepoint >= 0xf0000 and codepoint <= 0xffffd) or // Supplementary Private Use Area-A
(codepoint >= 0x100000 and codepoint <= 0x10fffd); // Supplementary Private Use Area-B
}
fn renderChar(
image: *zigimg.Image,
face: *freetype.Face,
codepoint: u21,
x: u32,
y: u32,
fg_color: Color,
bg_color: Color,
) !void {
// Draw background for this character cell first
const pixels = image.pixels.rgba32;
const width = image.width;
var dy: u32 = 0;
while (dy < CHAR_HEIGHT) : (dy += 1) {
var dx: u32 = 0;
while (dx < CHAR_WIDTH) : (dx += 1) {
const px = x + dx;
const py = y + dy;
if (px < width and py < image.height) {
const idx = py * width + px;
pixels[idx] = .{ .r = bg_color.r, .g = bg_color.g, .b = bg_color.b, .a = bg_color.a };
}
}
}
const glyph_index = face.getCharIndex(codepoint) orelse return;
try face.loadGlyph(glyph_index, .{ .render = true });
const glyph_slot = face.handle.*.glyph;
const bitmap = glyph_slot.*.bitmap;
if (bitmap.width == 0 or bitmap.rows == 0) return;
const buffer = bitmap.buffer orelse return;
// Match original wttr.in: y + runeHeight - 3
const base_x: i32 = @as(i32, @intCast(x)) + glyph_slot.*.bitmap_left;
const base_y: i32 = @as(i32, @intCast(y + CHAR_HEIGHT - 3)) - glyph_slot.*.bitmap_top;
for (0..bitmap.rows) |row| {
for (0..bitmap.width) |col| {
const alpha = buffer[row * @as(usize, @intCast(bitmap.pitch)) + col];
if (alpha == 0) continue;
const px: i32 = base_x + @as(i32, @intCast(col));
const py: i32 = base_y + @as(i32, @intCast(row));
if (px < 0 or py < 0 or px >= width or py >= image.height) continue;
const idx = @as(u32, @intCast(py)) * width + @as(u32, @intCast(px));
const bg = pixels[idx];
// Proper alpha blending: blend foreground with background
const alpha_f: u16 = alpha;
const alpha_inv = 255 - alpha_f;
pixels[idx] = .{
.r = @intCast((alpha_f * fg_color.r + alpha_inv * bg.r) / 255),
.g = @intCast((alpha_f * fg_color.g + alpha_inv * bg.g) / 255),
.b = @intCast((alpha_f * fg_color.b + alpha_inv * bg.b) / 255),
.a = 255,
};
}
}
}
test "parseColor valid hex" {
const color = try parseColor("ff0000", 255);
try std.testing.expectEqual(@as(u8, 255), color.r);
try std.testing.expectEqual(@as(u8, 0), color.g);
try std.testing.expectEqual(@as(u8, 0), color.b);
try std.testing.expectEqual(@as(u8, 255), color.a);
}
test "parseColor with transparency" {
const color = try parseColor("00ff00", 128);
try std.testing.expectEqual(@as(u8, 0), color.r);
try std.testing.expectEqual(@as(u8, 255), color.g);
try std.testing.expectEqual(@as(u8, 0), color.b);
try std.testing.expectEqual(@as(u8, 128), color.a);
}
test "parseColor invalid length" {
try std.testing.expectError(error.InvalidColor, parseColor("fff", 255));
try std.testing.expectError(error.InvalidColor, parseColor("fffffff", 255));
}
test "parseAnsiText simple text" {
const allocator = std.testing.allocator;
const text = "Hello";
var parsed = try parseAnsiText(allocator, text);
defer parsed.deinit();
try std.testing.expectEqual(@as(usize, 1), parsed.lines.items.len);
try std.testing.expectEqual(@as(usize, 5), parsed.lines.items[0].chars.items.len);
try std.testing.expectEqual(@as(u21, 'H'), parsed.lines.items[0].chars.items[0].codepoint);
}
test "parseAnsiText with newlines" {
const allocator = std.testing.allocator;
const text = "Line1\nLine2\nLine3";
var parsed = try parseAnsiText(allocator, text);
defer parsed.deinit();
try std.testing.expectEqual(@as(usize, 3), parsed.lines.items.len);
try std.testing.expectEqual(@as(usize, 5), parsed.max_width);
}
test "parseAnsiText with color codes" {
const allocator = std.testing.allocator;
const text = "\x1b[31mRed\x1b[0m";
var parsed = try parseAnsiText(allocator, text);
defer parsed.deinit();
try std.testing.expectEqual(@as(usize, 1), parsed.lines.items.len);
try std.testing.expectEqual(@as(usize, 3), parsed.lines.items[0].chars.items.len);
const first_char = parsed.lines.items[0].chars.items[0];
try std.testing.expectEqual(@as(u8, 205), first_char.fg_color.r);
}
test "parseAnsiColor codes" {
const white = Color{ .r = 255, .g = 255, .b = 255, .a = 255 };
const red = parseAnsiColor(31, white);
try std.testing.expectEqual(@as(u8, 205), red.r);
const green = parseAnsiColor(32, white);
try std.testing.expectEqual(@as(u8, 188), green.g);
const reset = parseAnsiColor(0, red);
try std.testing.expectEqual(@as(u8, 255), reset.r); // Reset to white
}
test "isSymbol detects nerd font ranges" {
try std.testing.expect(isSymbol(0xe000));
try std.testing.expect(isSymbol(0xf8ff));
try std.testing.expect(!isSymbol(0x0041)); // 'A'
try std.testing.expect(!isSymbol(0x263a)); //
}
test "init and deinit" {
const allocator = std.testing.allocator;
var png = init(allocator);
defer png.deinit();
try std.testing.expectEqual(@as(usize, 0), png.buffer.items.len);
}
test "writer captures data" {
const allocator = std.testing.allocator;
var png = init(allocator);
defer png.deinit();
const w = png.writer();
try w.writeAll("test data");
try std.testing.expectEqualStrings("test data", png.buffer.items);
}

View file

@ -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];

View file

@ -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;
}
}

View file

@ -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);