Compare commits

...

2 commits

Author SHA1 Message Date
f58fc54eb2
add (local) explain command to provide insights on the resolution chain
All checks were successful
Generic zig build / build (push) Successful in 2m18s
Generic zig build / deploy (push) Successful in 16s
2026-08-13 08:54:55 -07:00
cbabb810ee
pin us/non-us as well for imperial/metric default 2026-08-11 17:43:46 -07:00
10 changed files with 2048 additions and 63 deletions

482
src/cli/explain.zig Normal file
View file

@ -0,0 +1,482 @@
const std = @import("std");
const Config = @import("../Config.zig");
const GeoIp = @import("../location/GeoIp.zig");
const GeoLite2 = @import("../location/GeoLite2.zig");
/// `wttr explain <address>`: reports how an address resolves, stage by stage.
///
/// Deliberately a command rather than an HTTP endpoint. The report reveals the
/// pin table, which records where particular networks are, and `--online` spends
/// the provider's quota and appends to a cache that is never evicted. Gating all
/// of that behind shell access on the server makes it safe by construction, with
/// no credential to configure, no endpoint to rate limit, and no way for a
/// spoofed header to reach it.
pub fn runExplain(
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;
var address: ?[]const u8 = null;
var online = false;
for (args) |arg| {
if (std.mem.eql(u8, arg, "--online")) {
online = true;
} else if (std.mem.startsWith(u8, arg, "-")) {
try ew.print("unknown option {s}\n", .{arg});
try ew.flush();
return 2;
} else if (address == null) {
address = arg;
} else {
try ew.writeAll("usage: wttr explain <address> [--online]\n");
try ew.flush();
return 2;
}
}
const ip = address orelse {
try ew.writeAll("usage: wttr explain <address> [--online]\n");
try ew.flush();
return 2;
};
var geoip = GeoIp.init(allocator, io, cfg.geolite_path, cfg) catch |e| {
// Not downloaded here on purpose: a diagnostic should not pull tens of
// megabytes as a side effect. If the server runs, the file is present.
try ew.print(
\\could not open the GeoLite2 database at {s}: {t}
\\the server downloads this on first start; run it once, or set WTTR_GEOLITE_PATH
\\
, .{ cfg.geolite_path, e });
try ew.flush();
return 1;
};
defer geoip.deinit();
const explanation = try geoip.explain(ip, .{ .online = online });
defer explanation.deinit();
var out_buf: [4096]u8 = undefined;
var out = std.Io.File.stdout().writer(io, &out_buf);
const w = &out.interface;
try report(w, io, cfg, ip, online, explanation);
try w.flush();
// Non-zero when nothing resolved, so this is usable in a script without
// parsing the report.
return if (explanation.result == null) 1 else 0;
}
fn report(
w: *std.Io.Writer,
io: std.Io,
cfg: Config,
ip: []const u8,
online: bool,
e: GeoIp.Explanation,
) !void {
try w.print("address {s}\n", .{ip});
if (!e.parsed) {
// Stated plainly because it is the one case where no stage runs: nothing
// is handed to a provider unless it parses as an address first.
try w.writeAll(" not an IP address; no source was consulted\n");
return;
}
try w.writeAll("\npins ");
try w.print("{s}", .{cfg.pins_file});
try printAge(w, io, cfg.pins_file);
try w.print("\n {d} pin(s) loaded\n", .{e.pins_loaded});
if (e.pin) |p| {
try w.print(" MATCH {s} -> {s}\n", .{ p.cidr, p.location.name });
try w.print(" {d:.4},{d:.4} country {s}\n", .{
p.location.coords.latitude,
p.location.coords.longitude,
isoText(p.location.iso_country),
});
} else {
try w.writeAll(" no pin matches this address\n");
}
try w.writeAll("\ngeolite2 ");
try w.print("{s}", .{cfg.geolite_path});
try printAge(w, io, cfg.geolite_path);
try w.writeAll("\n");
switch (e.database) {
.absent => try w.writeAll(" no entry for this address\n"),
.unusable => try w.writeAll(" entry found, but it has no usable coordinates\n"),
.accepted, .rejected => |hit| {
try w.print(" {s}\n", .{hit.location.name});
try w.print(" {d:.4},{d:.4} country {s}\n", .{
hit.location.coords.latitude,
hit.location.coords.longitude,
isoText(hit.location.iso_country),
});
if (hit.accuracy_radius) |radius| {
// The number that explains a confidently wrong answer. A small
// radius on a wrong city is exactly the case pins exist for,
// because no confidence test and no provider can correct it.
try w.print(" accuracy radius {d} km ({s} threshold {d} km)\n", .{
radius,
if (e.database == .rejected) "REJECTED, over" else "accepted, within",
GeoIp.max_accuracy_radius_km,
});
} else {
try w.writeAll(" no accuracy radius recorded (accepted)\n");
}
},
}
// The provider's answer is shown whether or not it was used. When the
// database answers confidently and wrongly, "what would the other source
// have said" is the question, and only a side-by-side answers it.
try w.print("\nfallback {s}", .{e.fallback_provider});
if (!e.fallbackWasUsed()) try w.writeAll(" (not used, an earlier stage answered)");
try w.writeAll("\n");
switch (e.fallback) {
.not_cached => try w.writeAll(" not cached; re-run with --online to ask the provider\n"),
.failed => try w.writeAll(" queried, no usable answer\n"),
.cached, .fetched => |loc| {
try w.print(" {s}\n", .{loc.name});
try w.print(" {d:.4},{d:.4} country {s}\n", .{
loc.coords.latitude,
loc.coords.longitude,
isoText(loc.iso_country),
});
if (e.fallback == .fetched)
try w.writeAll(" fetched just now, and cached permanently\n");
},
}
// Last, as a footnote: the path matters when diagnosing a wrong cache
// directory, but it is not what the reader came for.
try w.print(" cache {s}", .{e.fallback_cache_path});
try printAge(w, io, e.fallback_cache_path);
try w.writeAll("\n");
try w.writeAll("\nresult ");
if (e.result) |r| {
try w.print("{s}\n", .{r.location.name});
try w.print(" {d:.4},{d:.4}\n", .{
r.location.coords.latitude,
r.location.coords.longitude,
});
try w.print(" from {s}\n", .{r.source.label()});
} else {
try w.writeAll("no source resolved this address\n");
if (!online and e.fallback == .not_cached)
try w.writeAll(" (the provider was not queried; --online would try it)\n");
}
try w.print(" units {s}", .{
if (e.country) |iso| if (std.mem.eql(u8, &iso, "US")) "imperial" else "metric" else "metric",
});
if (e.country) |iso| {
try w.print(" (country {s})\n", .{iso[0..]});
} else {
try w.writeAll(" (no country known for this address)\n");
}
// Said every time rather than only on a mismatch, because there is nothing
// here that can detect one: a running server holds its own mapped database
// and its own pins, and this process cannot see either.
try w.writeAll(
\\
\\note this re-runs the chain against the files above. A running
\\ server may differ if it has not reloaded them (SIGHUP is
\\ sent by `wttr pin`, and the database is remapped on refresh).
\\
);
}
/// Renders the value of an optional country code for display.
fn isoText(iso: ?[2]u8) []const u8 {
if (iso) |*code| return code[0..];
return "unknown";
}
/// Appends "(updated N ago)" for a path, or notes that it is missing.
///
/// The age is what lets an operator judge whether the file this command read is
/// the one a long-running server actually has mapped.
///
/// Stats directly rather than going through `GeoLite2.ageInSeconds`, which logs
/// any failure as a missing *GeoLite2 database*; that would misreport an absent
/// pins file, and an absent pins file is the normal state.
fn printAge(w: *std.Io.Writer, io: std.Io, path: []const u8) !void {
const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch {
try w.writeAll(" (absent)");
return;
};
const age = GeoLite2.ageOf(std.Io.Timestamp.now(io, .real), stat.mtime);
if (age < std.time.s_per_hour) {
try w.print(" (updated {d}m ago)", .{@divFloor(age, std.time.s_per_min)});
} else if (age < std.time.s_per_day) {
try w.print(" (updated {d}h ago)", .{@divFloor(age, std.time.s_per_hour)});
} else {
try w.print(" (updated {d}d ago)", .{@divFloor(age, std.time.s_per_day)});
}
}
test "explain: rejects an argument that is not an address" {
const allocator = std.testing.allocator;
var buf: [4096]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const cfg = try Config.loadForTest(allocator);
defer cfg.deinit(allocator);
// Reported rather than passed on. The provider URLs interpolate the argument,
// so "not an address" has to stop before any stage runs.
const e: GeoIp.Explanation = .{
.allocator = allocator,
.parsed = false,
.pins_loaded = 0,
.pin = null,
.database = .absent,
.fallback = .not_cached,
.fallback_provider = "ipwho.is",
.fallback_cache_path = "/tmp/none",
.result = null,
.country = null,
};
try report(&w, std.testing.io, cfg, "not-an-address", false, e);
const output = buf[0..w.end];
try std.testing.expect(std.mem.indexOf(u8, output, "not an IP address") != null);
// None of the stage headings should appear: there is nothing to say about them.
try std.testing.expect(std.mem.indexOf(u8, output, "geolite2") == null);
}
test "explain: names the accuracy radius and shows the provider it displaced" {
const allocator = std.testing.allocator;
var buf: [4096]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const cfg = try Config.loadForTest(allocator);
defer cfg.deinit(allocator);
// The case this command exists for: a small radius on the wrong city, which
// passes the threshold and so prevents the provider from being asked. Both
// answers are shown, because seeing them side by side is what reveals that
// the database is the thing that is wrong.
const database_answer: @import("../location/resolver.zig").Location = .{
.allocator = allocator,
.name = "Portland, Oregon, United States",
.coords = .{ .latitude = 45.5136, .longitude = -122.5946 },
.iso_country = .{ 'U', 'S' },
};
const e: GeoIp.Explanation = .{
.allocator = allocator,
.parsed = true,
.pins_loaded = 0,
.pin = null,
.database = .{ .accepted = .{
.location = database_answer,
.accuracy_radius = 20,
} },
.fallback = .{ .cached = .{
.allocator = allocator,
.name = "Portland, Washington, United States",
.coords = .{ .latitude = 47.2255, .longitude = -122.4112 },
.iso_country = .{ 'U', 'S' },
} },
.fallback_provider = "ipwho.is",
.fallback_cache_path = "/tmp/none",
.result = .{ .location = database_answer, .source = .database },
.country = .{ 'U', 'S' },
};
try report(&w, std.testing.io, cfg, "70.102.70.100", false, e);
const output = buf[0..w.end];
try std.testing.expect(std.mem.indexOf(u8, output, "accuracy radius 20 km") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "accepted, within threshold 200 km") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "from geolite2") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "units imperial (country US)") != null);
// The provider's answer is present and marked as not the one in force.
try std.testing.expect(std.mem.indexOf(u8, output, "ipwho.is (not used, an earlier stage answered)") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "Portland, Washington, United States") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "47.2255,-122.4112 country US") != null);
}
test "explain: a rejected entry is labelled as over the threshold" {
const allocator = std.testing.allocator;
var buf: [4096]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const cfg = try Config.loadForTest(allocator);
defer cfg.deinit(allocator);
const e: GeoIp.Explanation = .{
.allocator = allocator,
.parsed = true,
.pins_loaded = 0,
.pin = null,
.database = .{ .rejected = .{
.location = .{
.allocator = allocator,
.name = "Somewhere Vague",
.coords = .{ .latitude = 1, .longitude = 2 },
.iso_country = null,
},
.accuracy_radius = 500,
} },
.fallback = .not_cached,
.fallback_provider = "ipwho.is",
.fallback_cache_path = "/tmp/none",
.result = null,
.country = null,
};
try report(&w, std.testing.io, cfg, "203.0.113.7", false, e);
const output = buf[0..w.end];
try std.testing.expect(std.mem.indexOf(u8, output, "REJECTED, over threshold 200 km") != null);
// The operator has to be told the provider is reachable but was not asked,
// otherwise "no source resolved this" looks like a dead end.
try std.testing.expect(std.mem.indexOf(u8, output, "--online") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "no source resolved this address") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "no country known") != null);
}
test "explain: a pin is reported as the match, with the database it overrides" {
const allocator = std.testing.allocator;
var buf: [4096]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const cfg = try Config.loadForTest(allocator);
defer cfg.deinit(allocator);
const pin_location: @import("../location/resolver.zig").Location = .{
.allocator = allocator,
.name = "Seattle, Washington, United States",
.coords = .{ .latitude = 47.6, .longitude = -122.3 },
.iso_country = .{ 'U', 'S' },
};
const e: GeoIp.Explanation = .{
.allocator = allocator,
.parsed = true,
.pins_loaded = 1,
.pin = .{ .cidr = "70.102.70.100/32", .location = pin_location },
// Still reported, because an operator checking a pin needs to see what it
// is overriding.
.database = .{ .accepted = .{
.location = .{
.allocator = allocator,
.name = "Portland, Oregon, United States",
.coords = .{ .latitude = 45.5136, .longitude = -122.5946 },
.iso_country = .{ 'U', 'S' },
},
.accuracy_radius = 20,
} },
.fallback = .not_cached,
.fallback_provider = "ipwho.is",
.fallback_cache_path = "/tmp/none",
.result = .{ .location = pin_location, .source = .pin },
.country = .{ 'U', 'S' },
};
try report(&w, std.testing.io, cfg, "70.102.70.100", false, e);
const output = buf[0..w.end];
try std.testing.expect(std.mem.indexOf(u8, output, "MATCH 70.102.70.100/32") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "1 pin(s) loaded") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "Portland, Oregon") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "from pin") != null);
}
test "explain: a cached provider answer says so, and a fetched one says it was stored" {
const allocator = std.testing.allocator;
const cfg = try Config.loadForTest(allocator);
defer cfg.deinit(allocator);
const loc: @import("../location/resolver.zig").Location = .{
.allocator = allocator,
.name = "Tacoma, Washington, United States",
.coords = .{ .latitude = 47.2255, .longitude = -122.4111 },
.iso_country = .{ 'U', 'S' },
};
const base: GeoIp.Explanation = .{
.allocator = allocator,
.parsed = true,
.pins_loaded = 0,
.pin = null,
.database = .absent,
.fallback = .not_cached,
.fallback_provider = "ipwho.is",
.fallback_cache_path = "/tmp/none",
.result = null,
.country = .{ 'U', 'S' },
};
{
var buf: [4096]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
var cached = base;
cached.fallback = .{ .cached = loc };
cached.result = .{ .location = loc, .source = .fallback_cache };
try report(&w, std.testing.io, cfg, "198.51.100.7", false, cached);
const output = buf[0..w.end];
// Used, so it must NOT be labelled as displaced.
try std.testing.expect(std.mem.indexOf(u8, output, "not used") == null);
try std.testing.expect(std.mem.indexOf(u8, output, "from fallback (cached)") != null);
// Nothing was fetched, so nothing may claim to have been stored.
try std.testing.expect(std.mem.indexOf(u8, output, "cached permanently") == null);
}
{
// The report has to admit the side effect: this cache is append-only and
// never evicted, so a fetch is a permanent change.
var buf: [4096]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
var fetched = base;
fetched.fallback = .{ .fetched = loc };
fetched.result = .{ .location = loc, .source = .fallback_online };
try report(&w, std.testing.io, cfg, "198.51.100.7", true, fetched);
const output = buf[0..w.end];
try std.testing.expect(std.mem.indexOf(u8, output, "cached permanently") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "from fallback (fetched)") != null);
}
}
test "explain: reports the pins file even when there are none" {
const allocator = std.testing.allocator;
var buf: [4096]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const cfg = try Config.loadForTest(allocator);
defer cfg.deinit(allocator);
const e: GeoIp.Explanation = .{
.allocator = allocator,
.parsed = true,
.pins_loaded = 0,
.pin = null,
.database = .absent,
.fallback = .not_cached,
.fallback_provider = "ipwho.is",
.fallback_cache_path = "/tmp/none",
.result = null,
.country = null,
};
try report(&w, std.testing.io, cfg, "203.0.113.7", false, e);
const output = buf[0..w.end];
// Naming the path matters more than the count: the usual mistake is running
// this against a different cache directory than the server uses.
try std.testing.expect(std.mem.indexOf(u8, output, cfg.pins_file) != null);
try std.testing.expect(std.mem.indexOf(u8, output, "0 pin(s) loaded") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "no pin matches") != null);
}

View file

@ -33,21 +33,29 @@ pub fn printUsage(io: std.Io, exe: []const u8) !u8 {
\\ pin <cidr> <location> Override the location for an IP or range \\ pin <cidr> <location> Override the location for an IP or range
\\ unpin <cidr> Remove an override \\ unpin <cidr> Remove an override
\\ pins List overrides \\ pins List overrides
\\ explain <address> Show how an address resolves, stage by stage
\\ help Show this message \\ help Show this message
\\ \\
\\Pins take precedence over the GeoLite2 database, which is the point: \\Pins take precedence over the GeoLite2 database, which is the point:
\\they exist for addresses the database resolves confidently and wrongly. \\they exist for addresses the database resolves confidently and wrongly.
\\`explain` is how you tell that case apart from the rest; it names the
\\source that answered and the accuracy radius the database claimed.
\\ \\
\\Examples: \\Examples:
\\ {s} pin 12.94.132.0/24 San Francisco \\ {s} pin 12.94.132.0/24 San Francisco
\\ {s} pin 12.94.132.170 Portland, Oregon \\ {s} pin 12.94.132.170 Portland, Oregon
\\ {s} pin 2001:db8::/32 London \\ {s} pin 2001:db8::/32 London
\\ {s} unpin 12.94.132.0/24 \\ {s} unpin 12.94.132.0/24
\\ {s} explain 12.94.132.170
\\ \\
\\A bare address is treated as a single host (/32 or /128). When ranges \\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. \\overlap, the longest prefix wins, so a host pin beats a range pin.
\\ \\
, .{ base, base, base, base, base }); \\`explain` does not query the online provider unless given --online,
\\because that spends the provider's quota and writes to a cache that is
\\never evicted.
\\
, .{ base, base, base, base, base, base });
try w.flush(); try w.flush();
return 0; return 0;
} }
@ -109,7 +117,7 @@ pub fn runPin(
var pins = try Pins.load(allocator, io, cfg.pins_file); var pins = try Pins.load(allocator, io, cfg.pins_file);
defer pins.deinit(); defer pins.deinit();
try pins.put(cidr, location.name, location.coords); try pins.put(cidr, location.name, location.coords, location.iso_country);
try pins.save(io, cfg.pins_file); try pins.save(io, cfg.pins_file);
var out_buf: [512]u8 = undefined; var out_buf: [512]u8 = undefined;
@ -121,6 +129,13 @@ pub fn runPin(
location.coords.latitude, location.coords.latitude,
location.coords.longitude, location.coords.longitude,
}); });
// Surfaced because it decides units: without a country the pinned client
// falls back to whatever the database says about its address.
if (location.iso_country) |iso| {
try w.print("country {s} (units follow this)\n", .{iso[0..]});
} else {
try w.writeAll("country unknown (units will follow the GeoLite2 entry for the address)\n");
}
try w.print("wrote {s}\n", .{cfg.pins_file}); try w.print("wrote {s}\n", .{cfg.pins_file});
try reportSignal(w, notifyServers(io)); try reportSignal(w, notifyServers(io));
try w.flush(); try w.flush();
@ -188,8 +203,14 @@ pub fn runList(allocator: std.mem.Allocator, io: std.Io, cfg: Config) !u8 {
.{ .family = e.family, .network = e.network, .prefix_len = e.prefix_len }, .{ .family = e.family, .network = e.network, .prefix_len = e.prefix_len },
&cidr_buf, &cidr_buf,
); );
try w.print("{s: <20} {s} ({d:.4}, {d:.4})\n", .{ // The country is shown because it is what decides units, and a pin
text, e.name, e.coords.latitude, e.coords.longitude, // written before it was recorded shows "--" rather than looking complete.
try w.print("{s: <20} {s: <4} {s} ({d:.4}, {d:.4})\n", .{
text,
if (e.iso_country) |*iso| iso[0..] else "--",
e.name,
e.coords.latitude,
e.coords.longitude,
}); });
} }
try w.flush(); try w.flush();

View file

@ -147,7 +147,11 @@ fn handleWeatherInternal(
var render_options = params.render_options; var render_options = params.render_options;
// Determine if imperial units should be used // Determine if imperial units should be used
// Priority: explicit ?u or ?m > lang=us > US IP > default metric // Priority: explicit ?u or ?m > lang=us > US client address > default metric
//
// The address is resolved through the full pin -> database -> fallback-cache
// chain, so an operator's pin decides units as well as location. It used to
// read the database directly, which rendered a pinned US client in metric.
if (params.use_imperial == null) { if (params.use_imperial == null) {
// User did not ask for anything explicitly // User did not ask for anything explicitly
@ -528,3 +532,95 @@ test "handler: format line 3" {
try std.testing.expect(std.mem.indexOf(u8, pr.body, "California, United States:") != null); try std.testing.expect(std.mem.indexOf(u8, pr.body, "California, United States:") != null);
try std.testing.expect(std.mem.indexOf(u8, pr.body, "°C") != null); try std.testing.expect(std.mem.indexOf(u8, pr.body, "°C") != null);
} }
test "handler: a pin's country selects units, not the database" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
const Pins = @import("../location/Pins.zig");
const Location = @import("../location/resolver.zig").Location;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
// GeoLite2 places this AT&T address in Texas, so the database says US. The
// operator says London. Units have to follow the operator.
try harness.geoip.pins.put(
try Pins.parseCidr("12.94.132.0/24"),
"London, United Kingdom",
.{ .latitude = 51.5074, .longitude = -0.1278 },
Location.isoFrom("GB"),
);
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/12.94.132.170?format=1");
ht.param("location", "12.94.132.170");
try handleWeather(&harness.opts, ht.req, ht.res, "12.94.132.170");
try ht.expectStatus(200);
// Metric. Before unit selection consulted pins this rendered in Fahrenheit,
// because it read the database directly and the database still says US.
try ht.expectBody("☀️ +20°C\n");
}
test "handler: a US pin selects imperial where the database has no entry" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
const Pins = @import("../location/Pins.zig");
const Location = @import("../location/resolver.zig").Location;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
// TEST-NET-3 is absent from GeoLite2, so without the pin there is no country
// and the client would fall back to metric despite being pinned to the US.
try harness.geoip.pins.put(
try Pins.parseCidr("203.0.113.0/24"),
"Seattle, Washington, United States",
.{ .latitude = 47.6, .longitude = -122.3 },
Location.isoFrom("US"),
);
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/203.0.113.7?format=1");
ht.param("location", "203.0.113.7");
try handleWeather(&harness.opts, ht.req, ht.res, "203.0.113.7");
try ht.expectStatus(200);
try ht.expectBody("☀️ +68°F\n");
}
test "handler: an explicit unit request still overrides the pin's country" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
const Pins = @import("../location/Pins.zig");
const Location = @import("../location/resolver.zig").Location;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
try harness.geoip.pins.put(
try Pins.parseCidr("203.0.113.0/24"),
"Seattle, Washington, United States",
.{ .latitude = 47.6, .longitude = -122.3 },
Location.isoFrom("US"),
);
var ht = httpz.testing.init(.{});
defer ht.deinit();
// `?m` has to keep winning: the country is only consulted when the caller
// expressed no preference.
ht.url("/203.0.113.7?format=1&m");
ht.param("location", "203.0.113.7");
try handleWeather(&harness.opts, ht.req, ht.res, "203.0.113.7");
try ht.expectStatus(200);
try ht.expectBody("☀️ +20°C\n");
}

View file

@ -1,5 +1,6 @@
const std = @import("std"); const std = @import("std");
const Coordinates = @import("../Coordinates.zig"); const Coordinates = @import("../Coordinates.zig");
const Location = @import("resolver.zig").Location;
const srf = @import("srf"); const srf = @import("srf");
const GeoCache = @This(); const GeoCache = @This();
@ -17,6 +18,10 @@ last_save: i64,
pub const CachedLocation = struct { pub const CachedLocation = struct {
name: []const u8, name: []const u8,
coords: Coordinates, coords: Coordinates,
/// ISO 3166-1 alpha-2 country code, upper case, when the geocoder reported
/// one. Cached so that `wttr pin <cidr> <place>` records the country even
/// when the place name is answered from here rather than from Nominatim.
iso_country: ?[2]u8 = null,
}; };
pub fn init(allocator: std.mem.Allocator, io: std.Io, cache_file: ?[]const u8) !GeoCache { pub fn init(allocator: std.mem.Allocator, io: std.Io, cache_file: ?[]const u8) !GeoCache {
@ -66,6 +71,7 @@ pub fn put(self: *GeoCache, query: []const u8, location: CachedLocation) !void {
const value = CachedLocation{ const value = CachedLocation{
.name = try self.allocator.dupe(u8, location.name), .name = try self.allocator.dupe(u8, location.name),
.coords = location.coords, .coords = location.coords,
.iso_country = location.iso_country,
}; };
try self.cache.put(key, value); try self.cache.put(key, value);
self.dirty = true; self.dirty = true;
@ -96,11 +102,17 @@ pub fn saveIfNeeded(self: *GeoCache) void {
/// ///
/// The query is stored alongside the result rather than used as an object key, /// 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. /// because SRF records are flat field lists rather than a nested map.
///
/// `iso` defaults to null so files written before it existed still load: SRF
/// fills a missing field from the Zig default and only errors when there is
/// none. It is also omitted on write when null, so unknown countries add
/// nothing to the file.
const Record = struct { const Record = struct {
query: []const u8, query: []const u8,
name: []const u8, name: []const u8,
lat: f64, lat: f64,
lon: f64, lon: f64,
iso: ?[]const u8 = null,
}; };
fn load(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), content: []const u8) !void { fn load(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), content: []const u8) !void {
@ -129,6 +141,7 @@ fn load(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation),
const existing = try cache.fetchPut(cache_key, .{ const existing = try cache.fetchPut(cache_key, .{
.name = name_copy, .name = name_copy,
.coords = .{ .latitude = record.lat, .longitude = record.lon }, .coords = .{ .latitude = record.lat, .longitude = record.lon },
.iso_country = if (record.iso) |iso| Location.isoFrom(iso) else null,
}); });
if (existing) |old| { if (existing) |old| {
allocator.free(old.key); allocator.free(old.key);
@ -159,6 +172,10 @@ fn save(self: *GeoCache, writer: *std.Io.Writer) !void {
.name = entry.value_ptr.name, .name = entry.value_ptr.name,
.lat = entry.value_ptr.coords.latitude, .lat = entry.value_ptr.coords.latitude,
.lon = entry.value_ptr.coords.longitude, .lon = entry.value_ptr.coords.longitude,
// Borrowed from the map rather than copied: the map is not touched
// between here and the `print` below, and a by-value capture would
// leave the slice pointing at a dead loop temporary.
.iso = if (entry.value_ptr.iso_country) |*iso| iso[0..] else null,
}; };
} }
@ -327,3 +344,76 @@ test "save and load round-trip" {
try std.testing.expectApproxEqAbs(@as(f64, 52.5200), berlin.?.coords.latitude, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 52.5200), berlin.?.coords.latitude, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 13.4050), berlin.?.coords.longitude, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 13.4050), berlin.?.coords.longitude, 0.0001);
} }
test "save and load round-trip the country" {
const allocator = std.testing.allocator;
var cache1 = try GeoCache.init(allocator, std.testing.io, null);
defer cache1.deinit();
try cache1.put("Seattle,+Washington", .{
.name = "Seattle, King County, Washington, United States",
.coords = .{ .latitude = 47.6038321, .longitude = -122.330062 },
.iso_country = Location.isoFrom("us"),
});
// Kept as unknown rather than acquiring a country on the round trip.
try cache1.put("Nowhere", .{
.name = "Nowhere",
.coords = .{ .latitude = 1, .longitude = 2 },
});
var buffer: [2048]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
try cache1.save(&writer);
var cache2 = std.StringHashMap(CachedLocation).init(allocator);
defer {
var it = cache2.iterator();
while (it.next()) |entry| {
allocator.free(entry.key_ptr.*);
allocator.free(entry.value_ptr.name);
}
cache2.deinit();
}
try load(allocator, &cache2, buffer[0..writer.end]);
const seattle = cache2.get("Seattle,+Washington") orelse return error.TestUnexpectedResult;
// Normalized on the way in, so it reads back upper case even though
// Nominatim reports "us".
try std.testing.expectEqualStrings("US", &(seattle.iso_country.?));
const nowhere = cache2.get("Nowhere") orelse return error.TestUnexpectedResult;
try std.testing.expect(nowhere.iso_country == null);
}
test "load: a geocache written before the country field still loads" {
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 four-field shape. These entries are re-geocodable, but discarding them
// would mean a burst of Nominatim traffic on the first restart after upgrade.
const content =
\\#!srfv1
\\#!long
\\query::London
\\name::London, UK
\\lat:num:51.5074
\\lon:num:-0.1278
\\
;
try load(allocator, &cache_map, content);
try std.testing.expectEqual(@as(usize, 1), cache_map.count());
const london = cache_map.get("London") orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("London, UK", london.name);
try std.testing.expect(london.iso_country == null);
}

View file

@ -41,6 +41,44 @@ const FallbackClient = union(enum) {
}; };
} }
/// Country code for an address already in the provider's cache.
///
/// Cache-only by design: unit selection must not be able to trigger an
/// outbound request, and the cache is permanent, so anything previously
/// resolved is still there.
fn cachedCountry(self: FallbackClient, ip: []const u8) ?[2]u8 {
const packed_ip = Ip2location.packIp(ip) orelse return null;
return switch (self) {
.ip2location => |client| client.cache.getCountry(packed_ip.key),
.ipwhois => |client| client.cache.getCountry(packed_ip.key),
};
}
/// Full cached answer for an address, without contacting the provider.
fn cachedLookup(self: FallbackClient, ip: []const u8) ?Location {
const packed_ip = Ip2location.packIp(ip) orelse return null;
return switch (self) {
.ip2location => |client| client.cache.get(packed_ip.key),
.ipwhois => |client| client.cache.get(packed_ip.key),
};
}
/// Human-readable provider name, for diagnostics and logs.
fn name(self: FallbackClient) []const u8 {
return switch (self) {
.ip2location => "ip2location.io",
.ipwhois => "ipwho.is",
};
}
/// Where this provider's permanent cache lives.
fn cachePath(self: FallbackClient) []const u8 {
return switch (self) {
.ip2location => |client| client.cache.path,
.ipwhois => |client| client.cache.path,
};
}
fn deinit(self: FallbackClient, allocator: std.mem.Allocator) void { fn deinit(self: FallbackClient, allocator: std.mem.Allocator) void {
switch (self) { switch (self) {
.ip2location => |client| { .ip2location => |client| {
@ -192,10 +230,31 @@ pub fn reloadPins(self: *GeoIP) !void {
log.info("reloaded {d} IP pin(s) from {s}", .{ self.pins.entries.items.len, self.pins_path }); log.info("reloaded {d} IP pin(s) from {s}", .{ self.pins.entries.items.len, self.pins_path });
} }
/// Where a resolution came from.
///
/// Reported by `explain` and named in the per-resolution log line, because
/// "which source answered" is the question an operator actually has when a
/// client is placed in the wrong city.
pub const Source = enum {
pin,
database,
fallback_cache,
fallback_online,
pub fn label(self: Source) []const u8 {
return switch (self) {
.pin => "pin",
.database => "geolite2",
.fallback_cache => "fallback (cached)",
.fallback_online => "fallback (fetched)",
};
}
};
pub fn lookup(self: *GeoIP, ip: []const u8) ?Location { pub fn lookup(self: *GeoIP, ip: []const u8) ?Location {
// Try MaxMind first. The shared lock has to cover `extractCoordinates` as // The shared lock has to cover `readEntry` as well as the lookup itself:
// well as the lookup itself: `MMDB_lookup_result_s.entry` holds a pointer // `MMDB_lookup_result_s.entry` holds a pointer back to the `MMDB_s`, which
// back to the `MMDB_s`, which `extractCoordinates` dereferences. // `readEntry` dereferences.
const from_db: ?Location = blk: { const from_db: ?Location = blk: {
self.lock.lockSharedUncancelable(self.io); self.lock.lockSharedUncancelable(self.io);
defer self.lock.unlockShared(self.io); defer self.lock.unlockShared(self.io);
@ -212,7 +271,24 @@ pub fn lookup(self: *GeoIP, ip: []const u8) ?Location {
log.debug("lookup geoip db for ip {s}. Found: {}", .{ ip, result.found_entry }); log.debug("lookup geoip db for ip {s}. Found: {}", .{ ip, result.found_entry });
if (!result.found_entry) break :blk null; if (!result.found_entry) break :blk null;
break :blk self.extractCoordinates(ip, result);
const hit = self.readEntry(ip, result) orelse break :blk null;
// Reject low-confidence entries so the online provider gets a chance.
// `readEntry` reports the radius rather than acting on it, so that
// `explain` can describe this decision without repeating it.
if (hit.accuracy_radius) |radius| {
if (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 },
);
hit.location.deinit();
break :blk null;
}
}
break :blk hit.location;
}; };
if (from_db) |coords| return coords; if (from_db) |coords| return coords;
@ -236,26 +312,283 @@ fn lookupInternal(mmdb: *c.MMDB_s, ip: []const u8) !c.MMDB_lookup_result_s {
return result; return result;
} }
pub fn isUSIp(self: *GeoIP, ip: []const u8) bool { /// A full account of how an address resolves, for `wttr explain`.
// `country_data` borrows from the mapped database via `result.entry`, so the ///
// whole read stays inside the shared lock. /// Reports every stage rather than just the winner, because the interesting
/// failures are the ones where an early stage answered confidently and wrongly
/// and later stages were therefore never consulted.
pub const Explanation = struct {
allocator: std.mem.Allocator,
/// False when the argument was not an address at all. Everything else is
/// skipped in that case: nothing should reach a provider with a value that is
/// not an address.
parsed: bool,
/// How many pins are loaded, so "no pin matched" reads differently from
/// "there are no pins".
pins_loaded: usize,
pin: ?Pinned,
database: Database,
fallback: Fallback,
/// The provider that would be consulted, named even when it is not reached.
fallback_provider: []const u8,
fallback_cache_path: []const u8,
/// The answer `lookup` would return, and where it came from.
result: ?Result,
/// The country `lookupCountry` would return, which decides units.
country: ?[2]u8,
pub const Pinned = struct {
/// Owned text of the matching range.
cidr: []const u8,
location: Location,
};
pub const Result = struct {
location: Location,
source: Source,
};
pub const Database = union(enum) {
/// No entry for this address.
absent,
/// An entry whose coordinates could not be read.
unusable,
/// An entry within the confidence threshold, so `lookup` uses it.
accepted: DbHit,
/// An entry too coarse to trust, so `lookup` falls through to the
/// provider. This is the case the threshold exists for.
rejected: DbHit,
pub fn hit(self: Database) ?DbHit {
return switch (self) {
.accepted, .rejected => |h| h,
.absent, .unusable => null,
};
}
};
pub const Fallback = union(enum) {
/// Answered from the permanent cache.
cached: Location,
/// Fetched over the network during this run.
fetched: Location,
/// Not cached, and no online lookup was asked for.
not_cached,
/// An online lookup was asked for and did not produce an answer.
failed,
pub fn location(self: Fallback) ?Location {
return switch (self) {
.cached, .fetched => |loc| loc,
.not_cached, .failed => null,
};
}
};
/// Whether the provider's answer is the one `lookup` would return.
///
/// Derived rather than stored: the report needs to show what the provider
/// says even when an earlier stage won, so "do we have an answer" and "is it
/// the answer" are separate questions.
pub fn fallbackWasUsed(self: Explanation) bool {
const r = self.result orelse return false;
return r.source == .fallback_cache or r.source == .fallback_online;
}
pub fn deinit(self: Explanation) void {
if (self.pin) |p| {
self.allocator.free(p.cidr);
p.location.deinit();
}
if (self.database.hit()) |h| h.location.deinit();
if (self.fallback.location()) |loc| loc.deinit();
// `result` borrows whichever stage won rather than owning a copy, so it
// is deliberately not freed here.
}
};
pub const ExplainOptions = struct {
/// Whether a cache miss may be resolved over the network.
///
/// Off by default, and that default matters: the fallback cache is permanent
/// and append-only, so a diagnostic that fetched by default would grow the
/// cache and spend the provider's quota every time it ran.
online: bool = false,
};
/// Explains how `ip` resolves, walking every stage.
///
/// This re-runs the chain rather than replaying a past request. The result
/// matches what a running server would answer as long as that server's pins and
/// mapped database are the same as the ones on disk, which is why the caller
/// reports both files.
pub fn explain(self: *GeoIP, ip: []const u8, opts: ExplainOptions) !Explanation {
var out: Explanation = .{
.allocator = self.allocator,
.parsed = Ip2location.packIp(ip) != null,
.pins_loaded = 0,
.pin = null,
.database = .absent,
// Overwritten below for any address that parses. Left as "not cached" so
// an address that never reaches the provider reads as untried rather than
// as a miss the provider reported.
.fallback = .not_cached,
.fallback_provider = self.fallback_client.name(),
.fallback_cache_path = self.fallback_client.cachePath(),
.result = null,
.country = null,
};
// Reads whatever has been filled in so far, so a failure partway through
// does not leak the stages that already succeeded.
errdefer out.deinit();
{
self.lock.lockSharedUncancelable(self.io); self.lock.lockSharedUncancelable(self.io);
defer self.lock.unlockShared(self.io); defer self.lock.unlockShared(self.io);
var result = lookupInternal(self.mmdb, ip) catch return false; out.pins_loaded = self.pins.entries.items.len;
if (!result.found_entry) return false;
// SAFETY: The C API will initialize this on the next line if (out.parsed) {
var country_data: c.MMDB_entry_data_s = undefined; if (self.pins.matchFor(ip)) |entry| {
const status = c.MMDB_get_value(&result.entry, &country_data, "country", "iso_code", @as([*c]const u8, null)); var buf: [64]u8 = undefined;
const text = try Pins.formatCidr(
.{ .family = entry.family, .network = entry.network, .prefix_len = entry.prefix_len },
&buf,
);
// Both copies are owned, because `entry` points into the pin set
// and is only valid while the lock is held.
const cidr = try self.allocator.dupe(u8, text);
errdefer self.allocator.free(cidr);
const name = try self.allocator.dupe(u8, entry.name);
if (status != c.MMDB_SUCCESS or !country_data.has_data) { out.pin = .{
log.info("lookup found result, but no country available in data for ip {s}. MMDB_get_value returned {d}", .{ ip, status }); .cidr = cidr,
return false; .location = .{
.allocator = self.allocator,
.name = name,
.coords = entry.coords,
.iso_country = entry.iso_country,
},
};
} }
const country_code = country_data.unnamed_0.utf8_string[0..country_data.data_size]; // The database is read even when a pin matched. An operator debugging
return std.mem.eql(u8, country_code, "US"); // a pin needs to see what it is overriding.
if (lookupInternal(self.mmdb, ip)) |result| {
if (!result.found_entry) {
out.database = .absent;
} else if (self.readEntry(ip, result)) |hit| {
const too_coarse = if (hit.accuracy_radius) |r| r > max_accuracy_radius_km else false;
out.database = if (too_coarse) .{ .rejected = hit } else .{ .accepted = hit };
} else {
out.database = .unusable;
}
} else |_| {
out.database = .absent;
}
}
}
if (!out.parsed) return out;
// The provider is consulted even when an earlier stage already answered.
// "What would the other source have said" is the question worth asking when
// the database answers confidently and wrongly, and comparing the two is the
// whole reason to run this. It does not affect `result`: precedence below
// still prefers the pin and the database.
//
// Reading the cache is free. Going to the network is not, which is why it
// stays behind `--online` regardless of whether the answer is needed.
if (self.fallback_client.cachedLookup(ip)) |cached| {
out.fallback = .{ .cached = cached };
} else if (opts.online) {
if (self.fallback_client.lookup(ip)) |fetched| {
out.fallback = .{ .fetched = fetched };
} else {
out.fallback = .failed;
}
} else {
out.fallback = .not_cached;
}
// Precedence, restated here against the collected stages so the report and
// `resolve` cannot disagree about who wins. The drift test below asserts
// this matches `lookup` for real addresses.
if (out.pin) |p| {
out.result = .{ .location = p.location, .source = .pin };
} else if (out.database.hit()) |h| {
if (out.database == .accepted) out.result = .{ .location = h.location, .source = .database };
}
if (out.result == null) {
switch (out.fallback) {
.cached => |loc| out.result = .{ .location = loc, .source = .fallback_cache },
.fetched => |loc| out.result = .{ .location = loc, .source = .fallback_online },
else => {},
}
}
out.country = self.lookupCountry(ip);
return out;
}
pub fn isUSIp(self: *GeoIP, ip: []const u8) bool {
const iso = self.lookupCountry(ip) orelse return false;
return std.mem.eql(u8, &iso, "US");
}
/// ISO 3166-1 alpha-2 country code for an address, for unit selection.
///
/// Walks the same precedence as `lookup` -- pins, then the database, then the
/// online provider's cache -- so both an operator's pin and a previously fetched
/// fallback answer influence which units a client sees. This previously read the
/// database directly, which meant a pinned or fallback-resolved client could be
/// rendered in metric while the location it was given was in the US.
///
/// Never performs a live fallback fetch. Units are not worth an outbound request
/// on their own, and for a request with no explicit location the resolution in
/// `handleWeather` has already populated the cache by the time this runs. The
/// residual case -- an explicit location query from an address seen for the first
/// time, whose only answer would come from the network -- gets the default units
/// once and the right ones on every later request.
///
/// A matching pin with no recorded country falls through to the database rather
/// than answering "unknown", so pins written before the country was stored keep
/// behaving exactly as they did.
pub fn lookupCountry(self: *GeoIP, ip: []const u8) ?[2]u8 {
{
// Covers `pins` and `mmdb` only. The cache read below is left outside for
// the same reason `lookup` calls the fallback outside the lock: it is not
// guarded by this lock on the write side either.
self.lock.lockSharedUncancelable(self.io);
defer self.lock.unlockShared(self.io);
if (self.pins.lookupCountry(ip)) |iso| return iso;
if (lookupInternal(self.mmdb, ip)) |result| {
if (result.found_entry) {
if (readIso(result)) |iso| return iso;
}
} else |_| {}
}
return self.fallback_client.cachedCountry(ip);
}
/// Reads `country/iso_code` from a lookup result.
///
/// Shared by `lookupCountry` and `extractCoordinates` so there is one place that
/// knows where the country code lives and how it is validated.
fn readIso(result: c.MMDB_lookup_result_s) ?[2]u8 {
var entry_copy = result.entry;
// SAFETY: iso_data is set by MMDB_get_value
var iso_data: c.MMDB_entry_data_s = undefined;
const status = c.MMDB_get_value(&entry_copy, &iso_data, "country", "iso_code", @as([*c]const u8, null));
if (status != c.MMDB_SUCCESS or !iso_data.has_data) return null;
return Location.isoFrom(iso_data.unnamed_0.utf8_string[0..iso_data.data_size]);
} }
/// Maximum accuracy radius (in km) to trust from GeoLite2. Entries with a /// Maximum accuracy radius (in km) to trust from GeoLite2. Entries with a
@ -265,25 +598,36 @@ pub fn isUSIp(self: *GeoIP, ip: []const u8) bool {
/// ///
/// Note this only catches entries GeoLite2 admits are vague. An entry can be /// Note this only catches entries GeoLite2 admits are vague. An entry can be
/// precise *and* wrong, which is what pins exist for. /// precise *and* wrong, which is what pins exist for.
const max_accuracy_radius_km = 200; pub const max_accuracy_radius_km = 200;
fn extractCoordinates(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result_s) ?Location { /// A database entry, read but not yet judged.
pub const DbHit = struct {
location: Location,
/// MaxMind's own confidence signal, in km. Null when the entry carries none.
accuracy_radius: ?u16,
};
/// Reads an entry into a `Location`, without applying the confidence gate.
///
/// The gate is the caller's business: `resolve` rejects a coarse entry so the
/// online provider gets a chance, while `explain` reports the same decision
/// rather than acting on it. Keeping the reading here means one place knows the
/// database's field layout.
///
/// Must be called with the shared lock held: the returned strings are read
/// through `result.entry`, which points into the mapped database.
fn readEntry(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result_s) ?DbHit {
if (!result.found_entry) return null; if (!result.found_entry) return null;
var entry_copy = result.entry; var entry_copy = result.entry;
// 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 // SAFETY: accuracy_data set by MMDB_get_value
var accuracy_data: c.MMDB_entry_data_s = undefined; 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)); 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 accuracy_radius: ?u16 = if (acc_status == c.MMDB_SUCCESS and accuracy_data.has_data)
const radius = accuracy_data.unnamed_0.uint16; accuracy_data.unnamed_0.uint16
if (radius > max_accuracy_radius_km) { else
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 }); null;
return null;
}
}
entry_copy = result.entry; entry_copy = result.entry;
@ -341,15 +685,24 @@ fn extractCoordinates(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result
else else
""; "";
// The ISO code as well as the display name: unit selection needs the country
// itself, and deriving it by matching the name against "United States" would
// break on any of the other languages the database carries.
const iso_country = readIso(result);
const final_name = Location.buildDisplayName(self.allocator, city, subdivision, country, ip); const final_name = Location.buildDisplayName(self.allocator, city, subdivision, country, ip);
return .{ return .{
.location = .{
.allocator = self.allocator, .allocator = self.allocator,
.name = final_name, .name = final_name,
.coords = .{ .coords = .{
.latitude = latitude, .latitude = latitude,
.longitude = longitude, .longitude = longitude,
}, },
.iso_country = iso_country,
},
.accuracy_radius = accuracy_radius,
}; };
} }
@ -511,6 +864,7 @@ test "a pin overrides a confident but wrong database answer" {
try Pins.parseCidr("12.94.132.0/24"), try Pins.parseCidr("12.94.132.0/24"),
"San Francisco, California, United States", "San Francisco, California, United States",
.{ .latitude = 37.7749, .longitude = -122.4194 }, .{ .latitude = 37.7749, .longitude = -122.4194 },
Location.isoFrom("US"),
); );
try pins.save(io, pins_path); try pins.save(io, pins_path);
} }
@ -553,7 +907,7 @@ test "reloadPins picks up a pin written after startup" {
{ {
var pins: Pins = .init(allocator); var pins: Pins = .init(allocator);
defer pins.deinit(); defer pins.deinit();
try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Somewhere", .{ .latitude = 1, .longitude = 2 }); try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Somewhere", .{ .latitude = 1, .longitude = 2 }, null);
try pins.save(io, pins_path); try pins.save(io, pins_path);
} }
@ -564,3 +918,483 @@ test "reloadPins picks up a pin written after startup" {
defer hit.deinit(); defer hit.deinit();
try std.testing.expectEqualStrings("Somewhere", hit.name); try std.testing.expectEqualStrings("Somewhere", hit.name);
} }
/// Shared setup for the `lookupCountry` tests.
///
/// Both the pins file and the fallback cache are redirected into a temp
/// directory. The cache in particular is permanent and append-only, so a test
/// left pointing at the configured path would write entries into the developer's
/// real cache that nothing ever removes.
fn testConfigIn(allocator: std.mem.Allocator, io: std.Io, dir: []const u8) !Config {
var config = try Config.loadForTest(allocator);
errdefer config.deinit(allocator);
if (@import("build_options").download_geoip) {
const GeoLite2 = @import("GeoLite2.zig");
try GeoLite2.ensureDatabase(allocator, io, config.geolite_path);
}
allocator.free(config.pins_file);
config.pins_file = try std.fmt.allocPrint(allocator, "{s}/pins", .{dir});
allocator.free(config.ipwhois_cache_file);
config.ipwhois_cache_file = try std.fmt.allocPrint(allocator, "{s}/ipwhois", .{dir});
return config;
}
test "lookupCountry: a pin decides units instead of the database" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
// GeoLite2 has this AT&T address in Texas, so the database says US. Unit
// selection used to read the database directly, which meant a client pinned
// to London was still rendered in Fahrenheit.
const ip = "12.94.132.170";
{
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
const unpinned = geoip.lookupCountry(ip) orelse return error.SkipZigTest;
if (!std.mem.eql(u8, &unpinned, "US")) return error.SkipZigTest;
}
{
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try Pins.parseCidr("12.94.132.0/24"), "London, United Kingdom", .{ .latitude = 51.5, .longitude = -0.1 }, Location.isoFrom("GB"));
try pins.save(io, config.pins_file);
}
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
const iso = geoip.lookupCountry(ip) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("GB", &iso);
try std.testing.expect(!geoip.isUSIp(ip));
}
test "lookupCountry: a pin applies where the database has no entry at all" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
// TEST-NET-3, reserved for documentation and absent from GeoLite2. Standing
// in for any address the database does not cover.
const ip = "203.0.113.7";
{
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Seattle, Washington, United States", .{ .latitude = 47.6, .longitude = -122.3 }, Location.isoFrom("US"));
try pins.save(io, config.pins_file);
}
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
const iso = geoip.lookupCountry(ip) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("US", &iso);
try std.testing.expect(geoip.isUSIp(ip));
}
test "lookupCountry: a pin with no country falls through to the database" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
const ip = "12.94.132.170";
// Pins written before the country was recorded exist on disk. They must keep
// behaving as they did rather than forcing every pinned client to metric.
{
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try Pins.parseCidr("12.94.132.0/24"), "Somewhere", .{ .latitude = 1, .longitude = 2 }, null);
try pins.save(io, config.pins_file);
}
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
// The pin still decides the location...
const pinned = geoip.lookup(ip) orelse return error.TestUnexpectedResult;
defer pinned.deinit();
try std.testing.expectEqualStrings("Somewhere", pinned.name);
// ...while the country comes from the database, as it did before pins existed.
const iso = geoip.lookupCountry(ip) orelse return error.SkipZigTest;
try std.testing.expectEqualStrings("US", &iso);
}
test "lookupCountry: a cached fallback answer decides units" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
// Absent from GeoLite2, so the only source of a country is the cache. This is
// the case unit selection could not see before: the fallback runs only when
// the database declines, and units never consulted the fallback at all.
const ip = "198.51.100.7";
const key = Ip2location.packIp(ip).?;
{
var cache = try Ip2location.Cache.init(allocator, io, config.ipwhois_cache_file);
defer cache.deinit();
try cache.put(key.key, key.family, .{
.allocator = allocator,
.name = "Seattle, Washington, United States",
.coords = .{ .latitude = 47.6, .longitude = -122.3 },
.iso_country = Location.isoFrom("US"),
});
}
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
const iso = geoip.lookupCountry(ip) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("US", &iso);
try std.testing.expect(geoip.isUSIp(ip));
}
test "lookupCountry: null when no source knows the address" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
// No pin, nothing in the database, nothing cached. Must not resolve to a
// country, and must not attempt a network request to find one.
try std.testing.expect(geoip.lookupCountry("203.0.113.7") == null);
try std.testing.expect(!geoip.isUSIp("203.0.113.7"));
// A malformed address must be rejected rather than reaching any provider.
try std.testing.expect(geoip.lookupCountry("not-an-address") == null);
try std.testing.expect(!geoip.isUSIp("not-an-address"));
}
test "explain agrees with lookup for every source" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
// `explain` walks the chain separately from `resolve` so it can report the
// stages `resolve` short-circuits past. That means the two can drift, and a
// diagnostic that disagrees with the code it describes is worse than none.
// Cover one address per source.
{
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Pinned Place", .{ .latitude = 1.5, .longitude = 2.5 }, Location.isoFrom("US"));
try pins.save(io, config.pins_file);
}
// Seeded so the fallback-cache source is exercised without a network call.
const cached_ip = "198.51.100.7";
{
var cache = try Ip2location.Cache.init(allocator, io, config.ipwhois_cache_file);
defer cache.deinit();
const key = Ip2location.packIp(cached_ip).?;
try cache.put(key.key, key.family, .{
.allocator = allocator,
.name = "Cached Place",
.coords = .{ .latitude = 3.5, .longitude = 4.5 },
.iso_country = Location.isoFrom("US"),
});
}
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
const cases = [_][]const u8{
"203.0.113.7", // pin
"73.158.64.1", // database
cached_ip, // fallback cache
// Null agreement is covered by an address that parses as nothing, rather
// than by one that is merely absent: for an absent address `lookup` is
// supposed to reach the provider, which a unit test cannot let it do.
// The absent case is asserted against `explain` alone below.
"not-an-address",
};
for (cases) |ip| {
const explanation = try geoip.explain(ip, .{});
defer explanation.deinit();
const direct = geoip.lookup(ip);
defer if (direct) |d| d.deinit();
if (direct) |expected| {
const got = explanation.result orelse {
std.debug.print("explain found nothing for {s}, lookup returned {s}\n", .{ ip, expected.name });
return error.TestUnexpectedResult;
};
try std.testing.expectEqualStrings(expected.name, got.location.name);
try std.testing.expectEqual(expected.coords.latitude, got.location.coords.latitude);
try std.testing.expectEqual(expected.coords.longitude, got.location.coords.longitude);
try std.testing.expectEqual(expected.iso_country, got.location.iso_country);
} else {
if (explanation.result) |got| {
std.debug.print("explain found {s} for {s}, lookup returned nothing\n", .{ got.location.name, ip });
return error.TestUnexpectedResult;
}
}
// And the units it reports have to be the ones the handler would apply.
try std.testing.expectEqual(geoip.lookupCountry(ip), explanation.country);
}
}
test "explain: reports the source each address resolved through" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
{
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Pinned Place", .{ .latitude = 1.5, .longitude = 2.5 }, Location.isoFrom("US"));
try pins.save(io, config.pins_file);
}
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
{
const e = try geoip.explain("203.0.113.7", .{});
defer e.deinit();
try std.testing.expectEqual(Source.pin, e.result.?.source);
try std.testing.expectEqualStrings("203.0.113.0/24", e.pin.?.cidr);
try std.testing.expectEqual(@as(usize, 1), e.pins_loaded);
// A pin does not stop the database from being reported: an operator
// checking a pin needs to see what it overrides. Here there is nothing.
try std.testing.expectEqual(Explanation.Database.absent, std.meta.activeTag(e.database));
}
{
const e = try geoip.explain("73.158.64.1", .{});
defer e.deinit();
try std.testing.expectEqual(Source.database, e.result.?.source);
try std.testing.expect(e.pin == null);
// The radius is the whole point of the report, so it must be populated.
const hit = e.database.hit() orelse return error.TestUnexpectedResult;
try std.testing.expect(hit.accuracy_radius != null);
// The provider is still reported for comparison, but must not be the
// answer, and must not have been fetched without --online.
try std.testing.expect(!e.fallbackWasUsed());
try std.testing.expect(std.meta.activeTag(e.fallback) != .fetched);
}
{
// Absent everywhere. Must report the miss rather than reaching the network.
const e = try geoip.explain("192.0.2.99", .{});
defer e.deinit();
try std.testing.expect(e.result == null);
try std.testing.expectEqual(Explanation.Database.absent, std.meta.activeTag(e.database));
try std.testing.expectEqual(Explanation.Fallback.not_cached, std.meta.activeTag(e.fallback));
}
{
// Not an address: no stage may run, because the argument would otherwise
// be interpolated into a provider URL.
const e = try geoip.explain("not-an-address", .{});
defer e.deinit();
try std.testing.expect(!e.parsed);
try std.testing.expect(e.result == null);
try std.testing.expect(e.pin == null);
// Not even the cache is consulted: nothing that is not an address should
// reach a provider or its cache key.
try std.testing.expectEqual(Explanation.Fallback.not_cached, std.meta.activeTag(e.fallback));
}
}
test "explain: a pin wins over an accepted database entry" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
// The original case: GeoLite2 answers this confidently, so the report has to
// show both the pin that won and the entry it displaced.
{
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try Pins.parseCidr("12.94.132.0/24"), "Seattle, Washington, United States", .{ .latitude = 47.6, .longitude = -122.3 }, Location.isoFrom("US"));
try pins.save(io, config.pins_file);
}
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
const e = try geoip.explain("12.94.132.170", .{});
defer e.deinit();
try std.testing.expectEqual(Source.pin, e.result.?.source);
try std.testing.expectEqualStrings("Seattle, Washington, United States", e.result.?.location.name);
const hit = e.database.hit() orelse return error.SkipZigTest;
// Different answer, still reported, and reported as one the database was
// confident about. That combination is what tells an operator the pin is
// load-bearing rather than redundant.
try std.testing.expect(!std.mem.eql(u8, hit.location.name, "Seattle, Washington, United States"));
try std.testing.expectEqual(Explanation.Database.accepted, std.meta.activeTag(e.database));
}
test "explain: reports the provider's answer alongside the database that displaced it" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
// An address the database answers, with a *different* provider answer seeded
// in the cache. `resolve` never reaches the provider for this address, but the
// report has to show what it would have said: when the database is confidently
// wrong, a side-by-side is what makes that visible.
const ip = "73.158.64.1";
{
var cache = try Ip2location.Cache.init(allocator, io, config.ipwhois_cache_file);
defer cache.deinit();
const key = Ip2location.packIp(ip).?;
try cache.put(key.key, key.family, .{
.allocator = allocator,
.name = "Provider Answer, Elsewhere",
.coords = .{ .latitude = 11.25, .longitude = 22.5 },
.iso_country = Location.isoFrom("GB"),
});
}
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
const e = try geoip.explain(ip, .{});
defer e.deinit();
// The database still wins, and is still what the server would serve.
const db = e.database.hit() orelse return error.SkipZigTest;
try std.testing.expectEqual(Source.database, e.result.?.source);
try std.testing.expectEqualStrings(db.location.name, e.result.?.location.name);
// And the provider's answer is reported, flagged as not the one in force.
try std.testing.expect(!e.fallbackWasUsed());
const provider = e.fallback.location() orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("Provider Answer, Elsewhere", provider.name);
try std.testing.expectEqual(@as(f64, 11.25), provider.coords.latitude);
try std.testing.expectEqual(Explanation.Fallback.cached, std.meta.activeTag(e.fallback));
// Reporting it must not have changed which units the handler applies: the
// provider cache is only consulted for units when nothing earlier answered.
const iso = e.country orelse return error.SkipZigTest;
try std.testing.expectEqualStrings("US", &iso);
}
test "explain: does not reach the network for an unneeded provider answer" {
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 dir = path_buf[0..dir_len];
var config = try testConfigIn(allocator, io, dir);
defer config.deinit(allocator);
var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch
return error.SkipZigTest;
defer geoip.deinit();
// Now that the provider is reported even when unused, the default must still
// never fetch. Otherwise every run of this command would spend quota and
// append to a cache that is never evicted.
const e = try geoip.explain("73.158.64.1", .{});
defer e.deinit();
try std.testing.expectEqual(Explanation.Fallback.not_cached, std.meta.activeTag(e.fallback));
// A fetch would also have been recorded in the cache file.
var cache = try Ip2location.Cache.init(allocator, io, config.ipwhois_cache_file);
defer cache.deinit();
try std.testing.expectEqual(@as(usize, 0), cache.entries.count());
}

View file

@ -165,6 +165,7 @@ fn fetch(self: *Self, ip_str: []const u8) !Location {
.latitude = @floatCast(lat.float), .latitude = @floatCast(lat.float),
.longitude = @floatCast(lon.float), .longitude = @floatCast(lon.float),
}, },
.iso_country = Location.isoFrom(getString(obj, "country_code")),
}; };
} }
@ -195,11 +196,16 @@ pub const Cache = struct {
/// ///
/// The address is stored as text rather than the `u128` used for the in-memory /// 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. /// key, so the file stays legible to whoever has to look at it.
///
/// `iso` defaults to null so cache files written before it existed still
/// load: SRF fills a missing field from the Zig default. Entries cached
/// without a country resolve their units from the database instead.
const Record = struct { const Record = struct {
ip: []const u8, ip: []const u8,
lat: f64, lat: f64,
lon: f64, lon: f64,
name: []const u8, name: []const u8,
iso: ?[]const u8 = null,
}; };
pub fn init(allocator: Allocator, io: std.Io, path: []const u8) !Cache { pub fn init(allocator: Allocator, io: std.Io, path: []const u8) !Cache {
@ -292,6 +298,7 @@ pub const Cache = struct {
.allocator = self.allocator, .allocator = self.allocator,
.name = name_copy, .name = name_copy,
.coords = .{ .latitude = record.lat, .longitude = record.lon }, .coords = .{ .latitude = record.lat, .longitude = record.lon },
.iso_country = if (record.iso) |iso| Location.isoFrom(iso) else null,
}); });
if (existing) |old| self.allocator.free(old.value.name); if (existing) |old| self.allocator.free(old.value.name);
} }
@ -303,9 +310,19 @@ pub const Cache = struct {
.allocator = self.allocator, .allocator = self.allocator,
.name = self.allocator.dupe(u8, entry.name) catch return null, .name = self.allocator.dupe(u8, entry.name) catch return null,
.coords = entry.coords, .coords = entry.coords,
.iso_country = entry.iso_country,
}; };
} }
/// Country code for a cached address, without allocating or fetching.
///
/// Unit selection only needs the country, and doing it this way keeps that
/// path free of both an allocation and any chance of a network request.
pub fn getCountry(self: *Cache, ip: u128) ?[2]u8 {
const entry = self.entries.getPtr(ip) orelse return null;
return entry.iso_country;
}
pub fn put(self: *Cache, ip: u128, family: u8, loc: Location) !void { pub fn put(self: *Cache, ip: u128, family: u8, loc: Location) !void {
const name_copy = try self.allocator.dupe(u8, loc.name); const name_copy = try self.allocator.dupe(u8, loc.name);
errdefer self.allocator.free(name_copy); errdefer self.allocator.free(name_copy);
@ -314,6 +331,7 @@ pub const Cache = struct {
.allocator = self.allocator, .allocator = self.allocator,
.name = name_copy, .name = name_copy,
.coords = loc.coords, .coords = loc.coords,
.iso_country = loc.iso_country,
}); });
if (existing) |old| self.allocator.free(old.value.name); if (existing) |old| self.allocator.free(old.value.name);
@ -330,6 +348,7 @@ pub const Cache = struct {
.lat = loc.coords.latitude, .lat = loc.coords.latitude,
.lon = loc.coords.longitude, .lon = loc.coords.longitude,
.name = loc.name, .name = loc.name,
.iso = if (loc.iso_country) |*iso| iso[0..] else null,
}}, .{ .emit_directives = false })}); }}, .{ .emit_directives = false })});
// 0.16 has no seek+write; append at the current end offset. // 0.16 has no seek+write; append at the current end offset.
@ -446,3 +465,115 @@ test "Cache: formatKey renders both families without a port" {
const v6 = packIp("2001:db8::1").?; const v6 = packIp("2001:db8::1").?;
try std.testing.expectEqualStrings("2001:db8::1", try Cache.formatKey(v6.key, v6.family, &buf)); try std.testing.expectEqualStrings("2001:db8::1", try Cache.formatKey(v6.key, v6.family, &buf));
} }
test "Cache: round-trips the country through the file" {
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}/ip.cache", .{path_buf[0..dir_len]});
defer allocator.free(path);
const us = packIp("198.51.100.1").?;
const unknown = packIp("198.51.100.2").?;
{
var cache = try Cache.init(allocator, io, path);
defer cache.deinit();
try cache.put(us.key, us.family, .{
.allocator = allocator,
.name = "Seattle, Washington, United States",
.coords = .{ .latitude = 47.6, .longitude = -122.3 },
.iso_country = Location.isoFrom("US"),
});
// An entry cached with no country has to stay that way rather than
// acquiring one, so units fall back to the database for it.
try cache.put(unknown.key, unknown.family, .{
.allocator = allocator,
.name = "Nowhere",
.coords = .{ .latitude = 1, .longitude = 2 },
});
}
var reopened = try Cache.init(allocator, io, path);
defer reopened.deinit();
const got = reopened.get(us.key) orelse return error.TestUnexpectedResult;
defer got.deinit();
try std.testing.expectEqualStrings("US", &(got.iso_country.?));
const iso = reopened.getCountry(us.key) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("US", &iso);
try std.testing.expect(reopened.getCountry(unknown.key) == null);
}
test "Cache: getCountry is null for an address that was never cached" {
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}/ip.cache", .{path_buf[0..dir_len]});
defer allocator.free(path);
var cache = try Cache.init(allocator, io, path);
defer cache.deinit();
// Must not be mistaken for "cached with no country": both read as null here,
// and in both cases the caller falls through, so this pins the shape rather
// than a distinction the caller makes.
try std.testing.expect(cache.getCountry(packIp("203.0.113.9").?.key) == null);
}
test "Cache: a file written before the country field still loads" {
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}/ip.cache", .{path_buf[0..dir_len]});
defer allocator.free(path);
// Produced by writing the four-field record, then reopened against the
// five-field one. This cache is permanent and never evicted, so dropping it
// on a schema change would mean re-fetching every address ever resolved.
{
var cache = try Cache.init(allocator, io, path);
defer cache.deinit();
const OldRecord = struct {
ip: []const u8,
lat: f64,
lon: f64,
name: []const u8,
};
var line_buf: [512]u8 = undefined;
const line = try std.fmt.bufPrint(&line_buf, "{f}", .{srf.fmt(OldRecord, &.{.{
.ip = "198.51.100.7",
.lat = 47.6,
.lon = -122.3,
.name = "Seattle, Washington, United States",
}}, .{ .emit_directives = false })});
const file = cache.file.?;
const end = try file.length(io);
try file.writePositionalAll(io, line, end);
}
var reopened = try Cache.init(allocator, io, path);
defer reopened.deinit();
const key = packIp("198.51.100.7").?.key;
const got = reopened.get(key) orelse return error.TestUnexpectedResult;
defer got.deinit();
try std.testing.expectEqualStrings("Seattle, Washington, United States", got.name);
try std.testing.expectEqual(@as(f64, 47.6), got.coords.latitude);
try std.testing.expect(got.iso_country == null);
}

View file

@ -65,8 +65,9 @@ fn fetch(self: *Self, ip_str: []const u8) !Location {
var w = std.Io.Writer.fixed(&buf); var w = std.Io.Writer.fixed(&buf);
try w.writeAll("https://ipwho.is/"); try w.writeAll("https://ipwho.is/");
try w.writeAll(ip_str); try w.writeAll(ip_str);
// Request only the fields we need // Request only the fields we need. `country_code` drives unit selection,
try w.writeAll("?fields=city,region,country,latitude,longitude&output=json"); // which cannot be derived from `country` without matching display text.
try w.writeAll("?fields=city,region,country,country_code,latitude,longitude&output=json");
var response_buf: [4096]u8 = undefined; var response_buf: [4096]u8 = undefined;
var writer = std.Io.Writer.fixed(&response_buf); var writer = std.Io.Writer.fixed(&response_buf);
@ -139,6 +140,7 @@ fn fetch(self: *Self, ip_str: []const u8) !Location {
.latitude = lat_val, .latitude = lat_val,
.longitude = lon_val, .longitude = lon_val,
}, },
.iso_country = Location.isoFrom(getString(obj, "country_code")),
}; };
} }

View file

@ -30,6 +30,11 @@ pub const Entry = struct {
prefix_len: u8, prefix_len: u8,
name: []const u8, name: []const u8,
coords: Coordinates, coords: Coordinates,
/// ISO 3166-1 alpha-2 country code, upper case, when it was known at the
/// time the pin was written. Null for pins created before this was recorded,
/// which `GeoIp.lookupCountry` treats as "no opinion" and resolves from the
/// database instead of forcing a wrong answer.
iso_country: ?[2]u8 = null,
}; };
pub fn init(allocator: std.mem.Allocator) Pins { pub fn init(allocator: std.mem.Allocator) Pins {
@ -120,7 +125,7 @@ pub fn formatCidr(cidr: Cidr, buf: []u8) ![]const u8 {
/// ///
/// Longest-prefix ordering means an exact host pin naturally wins over a range /// Longest-prefix ordering means an exact host pin naturally wins over a range
/// covering it, without needing a separate precedence rule. /// covering it, without needing a separate precedence rule.
pub fn lookup(self: *const Pins, allocator: std.mem.Allocator, ip_str: []const u8) ?Location { fn bestMatch(self: *const Pins, ip_str: []const u8) ?*const Entry {
const packed_ip = packIp(ip_str) orelse return null; const packed_ip = packIp(ip_str) orelse return null;
var best: ?*const Entry = null; var best: ?*const Entry = null;
@ -129,17 +134,41 @@ pub fn lookup(self: *const Pins, allocator: std.mem.Allocator, ip_str: []const u
if (maskToPrefix(packed_ip.key, e.family, e.prefix_len) != e.network) 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; if (best == null or e.prefix_len > best.?.prefix_len) best = e;
} }
return best;
}
const entry = best orelse return null; /// The matching entry itself, for callers that need more than a `Location`.
///
/// `explain` reports the range that matched, which the `Location` does not carry.
/// Borrowed rather than copied, so the caller must hold whatever lock guards the
/// pin set for as long as it uses the result.
pub fn matchFor(self: *const Pins, ip_str: []const u8) ?*const Entry {
return self.bestMatch(ip_str);
}
/// Longest-prefix match for `ip_str`, or null when no pin applies.
pub fn lookup(self: *const Pins, allocator: std.mem.Allocator, ip_str: []const u8) ?Location {
const entry = self.bestMatch(ip_str) orelse return null;
return .{ return .{
.allocator = allocator, .allocator = allocator,
.name = allocator.dupe(u8, entry.name) catch return null, .name = allocator.dupe(u8, entry.name) catch return null,
.coords = entry.coords, .coords = entry.coords,
.iso_country = entry.iso_country,
}; };
} }
/// Country code of the matching pin, without allocating.
///
/// Null both when no pin matches and when the matching pin has no recorded
/// country, because the caller treats those the same way: fall through to the
/// database rather than claim the country is unknown.
pub fn lookupCountry(self: *const Pins, ip_str: []const u8) ?[2]u8 {
const entry = self.bestMatch(ip_str) orelse return null;
return entry.iso_country;
}
/// Inserts a pin, replacing any existing pin for the same network. /// Inserts a pin, replacing any existing pin for the same network.
pub fn put(self: *Pins, cidr: Cidr, name: []const u8, coords: Coordinates) !void { pub fn put(self: *Pins, cidr: Cidr, name: []const u8, coords: Coordinates, iso_country: ?[2]u8) !void {
const name_copy = try self.allocator.dupe(u8, name); const name_copy = try self.allocator.dupe(u8, name);
errdefer self.allocator.free(name_copy); errdefer self.allocator.free(name_copy);
@ -148,6 +177,7 @@ pub fn put(self: *Pins, cidr: Cidr, name: []const u8, coords: Coordinates) !void
self.allocator.free(e.name); self.allocator.free(e.name);
e.name = name_copy; e.name = name_copy;
e.coords = coords; e.coords = coords;
e.iso_country = iso_country;
return; return;
} }
} }
@ -158,6 +188,7 @@ pub fn put(self: *Pins, cidr: Cidr, name: []const u8, coords: Coordinates) !void
.prefix_len = cidr.prefix_len, .prefix_len = cidr.prefix_len,
.name = name_copy, .name = name_copy,
.coords = coords, .coords = coords,
.iso_country = iso_country,
}); });
} }
@ -181,11 +212,15 @@ pub fn remove(self: *Pins, cidr: Cidr) bool {
/// part for free: place names routinely contain commas ("San Francisco, /// part for free: place names routinely contain commas ("San Francisco,
/// California, United States"), which a comma-delimited format has to special /// California, United States"), which a comma-delimited format has to special
/// case. /// case.
///
/// `iso` defaults to null so pins written before it existed still load: SRF
/// fills a missing field from the Zig default rather than rejecting the record.
const Record = struct { const Record = struct {
cidr: []const u8, cidr: []const u8,
lat: f64, lat: f64,
lon: f64, lon: f64,
name: []const u8, name: []const u8,
iso: ?[]const u8 = null,
}; };
/// Loads pins from `path`. A missing file yields an empty set: having no /// Loads pins from `path`. A missing file yields an empty set: having no
@ -228,7 +263,12 @@ pub fn load(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !Pins {
// `put` copies the name, so it does not matter that the parsed strings // `put` copies the name, so it does not matter that the parsed strings
// belong to the iterator's arena. // belong to the iterator's arena.
try pins.put(cidr, record.name, .{ .latitude = record.lat, .longitude = record.lon }); try pins.put(
cidr,
record.name,
.{ .latitude = record.lat, .longitude = record.lon },
if (record.iso) |iso| Location.isoFrom(iso) else null,
);
} }
return pins; return pins;
@ -255,7 +295,7 @@ pub fn save(self: *const Pins, io: std.Io, path: []const u8) !void {
self.allocator.free(cidr_texts); self.allocator.free(cidr_texts);
} }
for (self.entries.items, 0..) |e, i| { for (self.entries.items, 0..) |*e, i| {
var buf: [64]u8 = undefined; var buf: [64]u8 = undefined;
const text = try formatCidr( const text = try formatCidr(
.{ .family = e.family, .network = e.network, .prefix_len = e.prefix_len }, .{ .family = e.family, .network = e.network, .prefix_len = e.prefix_len },
@ -268,6 +308,10 @@ pub fn save(self: *const Pins, io: std.Io, path: []const u8) !void {
.lat = e.coords.latitude, .lat = e.coords.latitude,
.lon = e.coords.longitude, .lon = e.coords.longitude,
.name = e.name, .name = e.name,
// Borrowed from the entry rather than copied: the list is not
// mutated before the `print` below, and a by-value capture would
// leave this pointing at a dead loop temporary.
.iso = if (e.iso_country) |*iso| iso[0..] else null,
}; };
} }
@ -337,7 +381,7 @@ test "lookup: matches an address inside the range" {
var pins: Pins = .init(allocator); var pins: Pins = .init(allocator);
defer pins.deinit(); defer pins.deinit();
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California", .{ .latitude = 37.7749, .longitude = -122.4194 }); try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California", .{ .latitude = 37.7749, .longitude = -122.4194 }, null);
const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult; const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult;
defer hit.deinit(); defer hit.deinit();
@ -350,7 +394,7 @@ test "lookup: ignores an address outside the range" {
var pins: Pins = .init(allocator); var pins: Pins = .init(allocator);
defer pins.deinit(); defer pins.deinit();
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco", .{ .latitude = 37.7749, .longitude = -122.4194 }); try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco", .{ .latitude = 37.7749, .longitude = -122.4194 }, null);
try std.testing.expect(pins.lookup(allocator, "12.94.133.1") == null); try std.testing.expect(pins.lookup(allocator, "12.94.133.1") == null);
} }
@ -361,8 +405,8 @@ test "lookup: longest prefix wins over a broader range" {
defer pins.deinit(); defer pins.deinit();
// Deliberately inserted broad-first so the result cannot come from ordering. // 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.0.0.0/8"), "Broad", .{ .latitude = 1, .longitude = 1 }, null);
try pins.put(try parseCidr("12.94.132.0/24"), "Specific", .{ .latitude = 2, .longitude = 2 }); try pins.put(try parseCidr("12.94.132.0/24"), "Specific", .{ .latitude = 2, .longitude = 2 }, null);
const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult; const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult;
defer hit.deinit(); defer hit.deinit();
@ -374,8 +418,8 @@ test "lookup: an exact host pin beats a range containing it" {
var pins: Pins = .init(allocator); var pins: Pins = .init(allocator);
defer pins.deinit(); 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.170"), "Host", .{ .latitude = 2, .longitude = 2 }, null);
try pins.put(try parseCidr("12.94.132.0/24"), "Range", .{ .latitude = 1, .longitude = 1 }); try pins.put(try parseCidr("12.94.132.0/24"), "Range", .{ .latitude = 1, .longitude = 1 }, null);
const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult; const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult;
defer hit.deinit(); defer hit.deinit();
@ -387,7 +431,7 @@ test "lookup: families do not cross-match" {
var pins: Pins = .init(allocator); var pins: Pins = .init(allocator);
defer pins.deinit(); defer pins.deinit();
try pins.put(try parseCidr("0.0.0.0/0"), "All IPv4", .{ .latitude = 1, .longitude = 1 }); try pins.put(try parseCidr("0.0.0.0/0"), "All IPv4", .{ .latitude = 1, .longitude = 1 }, null);
// An IPv4 /0 must not swallow IPv6 clients. // An IPv4 /0 must not swallow IPv6 clients.
try std.testing.expect(pins.lookup(allocator, "2001:db8::1") == null); try std.testing.expect(pins.lookup(allocator, "2001:db8::1") == null);
@ -401,8 +445,8 @@ test "put: replaces an existing pin for the same network" {
defer pins.deinit(); defer pins.deinit();
const cidr = try parseCidr("12.94.132.0/24"); const cidr = try parseCidr("12.94.132.0/24");
try pins.put(cidr, "Old", .{ .latitude = 1, .longitude = 1 }); try pins.put(cidr, "Old", .{ .latitude = 1, .longitude = 1 }, null);
try pins.put(cidr, "New", .{ .latitude = 2, .longitude = 2 }); try pins.put(cidr, "New", .{ .latitude = 2, .longitude = 2 }, null);
try std.testing.expectEqual(@as(usize, 1), pins.entries.items.len); try std.testing.expectEqual(@as(usize, 1), pins.entries.items.len);
const hit = pins.lookup(allocator, "12.94.132.5") orelse return error.TestUnexpectedResult; const hit = pins.lookup(allocator, "12.94.132.5") orelse return error.TestUnexpectedResult;
@ -416,7 +460,7 @@ test "remove: reports whether a pin was present" {
defer pins.deinit(); defer pins.deinit();
const cidr = try parseCidr("12.94.132.0/24"); const cidr = try parseCidr("12.94.132.0/24");
try pins.put(cidr, "SF", .{ .latitude = 1, .longitude = 1 }); try pins.put(cidr, "SF", .{ .latitude = 1, .longitude = 1 }, null);
try std.testing.expect(pins.remove(cidr)); try std.testing.expect(pins.remove(cidr));
try std.testing.expect(!pins.remove(cidr)); try std.testing.expect(!pins.remove(cidr));
@ -449,8 +493,8 @@ test "save then load round-trips, including commas in the name" {
{ {
var pins: Pins = .init(allocator); var pins: Pins = .init(allocator);
defer pins.deinit(); 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("12.94.132.0/24"), "San Francisco, California, United States", .{ .latitude = 37.7749, .longitude = -122.4194 }, null);
try pins.put(try parseCidr("2001:db8::/32"), "Test, Place", .{ .latitude = -1.5, .longitude = 2.25 }); try pins.put(try parseCidr("2001:db8::/32"), "Test, Place", .{ .latitude = -1.5, .longitude = 2.25 }, null);
try pins.save(io, path); try pins.save(io, path);
} }
@ -549,7 +593,7 @@ test "save writes SRF that an operator can read" {
var pins: Pins = .init(allocator); var pins: Pins = .init(allocator);
defer pins.deinit(); defer pins.deinit();
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California", .{ .latitude = 37.7749, .longitude = -122.4194 }); try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California", .{ .latitude = 37.7749, .longitude = -122.4194 }, null);
try pins.save(io, path); try pins.save(io, path);
const content = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024)); const content = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024));
@ -563,3 +607,195 @@ test "save writes SRF that an operator can read" {
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, "cidr::12.94.132.0/24") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "San Francisco, California") != null); try std.testing.expect(std.mem.indexOf(u8, content, "San Francisco, California") != null);
} }
test "lookupCountry: returns the country of the matching pin" {
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, United States",
.{ .latitude = 37.7749, .longitude = -122.4194 },
Location.isoFrom("US"),
);
const iso = pins.lookupCountry("12.94.132.170") orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("US", &iso);
}
test "lookupCountry: null for a pin with no recorded country" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
// Pins written before the country was stored must read as "no opinion" so
// the caller can fall through to the database rather than assume a country.
try pins.put(try parseCidr("12.94.132.0/24"), "Somewhere", .{ .latitude = 1, .longitude = 2 }, null);
try std.testing.expect(pins.lookupCountry("12.94.132.170") == null);
}
test "lookupCountry: null when no pin matches" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try parseCidr("12.94.132.0/24"), "SF", .{ .latitude = 1, .longitude = 2 }, Location.isoFrom("US"));
try std.testing.expect(pins.lookupCountry("8.8.8.8") == null);
}
test "lookupCountry: longest prefix decides the country too" {
const allocator = std.testing.allocator;
var pins: Pins = .init(allocator);
defer pins.deinit();
try pins.put(try parseCidr("12.0.0.0/8"), "Broad", .{ .latitude = 1, .longitude = 1 }, Location.isoFrom("GB"));
try pins.put(try parseCidr("12.94.132.0/24"), "Specific", .{ .latitude = 2, .longitude = 2 }, Location.isoFrom("US"));
const iso = pins.lookupCountry("12.94.132.170") orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("US", &iso);
}
test "put: replacing a pin also replaces its country" {
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, "London", .{ .latitude = 1, .longitude = 1 }, Location.isoFrom("GB"));
try pins.put(cidr, "Seattle", .{ .latitude = 2, .longitude = 2 }, Location.isoFrom("US"));
try std.testing.expectEqual(@as(usize, 1), pins.entries.items.len);
const iso = pins.lookupCountry("12.94.132.5") orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("US", &iso);
}
test "save then load round-trips the country" {
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"), "Seattle", .{ .latitude = 47.6, .longitude = -122.3 }, Location.isoFrom("US"));
// A pin with no country has to survive the round trip as "no country"
// rather than becoming a bogus one.
try pins.put(try parseCidr("10.0.0.0/8"), "Unknown", .{ .latitude = 1, .longitude = 2 }, null);
try pins.save(io, path);
}
var loaded = try load(allocator, io, path);
defer loaded.deinit();
const us = loaded.lookupCountry("12.94.132.1") orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("US", &us);
try std.testing.expect(loaded.lookupCountry("10.1.2.3") == null);
}
test "save omits the country field entirely when it is unknown" {
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("10.0.0.0/8"), "Unknown", .{ .latitude = 1, .longitude = 2 }, null);
try pins.save(io, path);
const content = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024));
defer allocator.free(content);
// An unknown country should add nothing to a file an operator reads, rather
// than an empty or "null" field they have to interpret.
try std.testing.expect(std.mem.indexOf(u8, content, "iso") == null);
}
test "load: a pins file written before the country field still loads" {
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);
// Exactly the shape `save` produced before `iso` existed. Operators have
// these on disk, so failing to read them would silently drop every override.
try std.Io.Dir.cwd().writeFile(io, .{
.sub_path = path,
.data =
\\#!srfv1
\\#!long
\\cidr::70.102.70.100/32
\\lat:num:47.6038321
\\lon:num:-122.330062
\\name::Seattle, King County, Washington, United States
\\
,
});
var pins = try load(allocator, io, path);
defer pins.deinit();
try std.testing.expectEqual(@as(usize, 1), pins.entries.items.len);
const hit = pins.lookup(allocator, "70.102.70.100") orelse return error.TestUnexpectedResult;
defer hit.deinit();
try std.testing.expectEqualStrings("Seattle, King County, Washington, United States", hit.name);
// No country recorded, so the caller must fall through rather than guess.
try std.testing.expect(hit.iso_country == null);
try std.testing.expect(pins.lookupCountry("70.102.70.100") == null);
}
test "load: an unparseable country is dropped, keeping the rest of the pin" {
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);
// Hand-edited files are expected here, so a bad country must not cost the
// operator the location they actually cared about.
try std.Io.Dir.cwd().writeFile(io, .{
.sub_path = path,
.data =
\\#!srfv1
\\#!long
\\cidr::10.0.0.0/8
\\lat:num:1
\\lon:num:2
\\name::Somewhere
\\iso::United States
\\
,
});
var pins = try load(allocator, io, path);
defer pins.deinit();
try std.testing.expectEqual(@as(usize, 1), pins.entries.items.len);
try std.testing.expect(pins.lookupCountry("10.1.2.3") == null);
const hit = pins.lookup(allocator, "10.1.2.3") orelse return error.TestUnexpectedResult;
defer hit.deinit();
try std.testing.expectEqualStrings("Somewhere", hit.name);
}

View file

@ -11,11 +11,38 @@ pub const Location = struct {
name: []const u8, name: []const u8,
coords: Coordinates, coords: Coordinates,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// ISO 3166-1 alpha-2 country code, upper case, when the producing source
/// knew it. Drives unit selection, which needs the country rather than the
/// display name.
///
/// Deliberately a fixed-size array rather than a slice: a country code is
/// always two characters, so storing it inline keeps `deinit` a single free
/// and leaves every existing construction site valid via the default.
iso_country: ?[2]u8 = null,
pub fn deinit(self: Location) void { pub fn deinit(self: Location) void {
self.allocator.free(self.name); self.allocator.free(self.name);
} }
/// Normalizes a country code from a provider into the stored form.
///
/// Providers disagree on case -- GeoLite2 and ipwho.is return "US" while
/// Nominatim returns "us" -- so everything is upper-cased on the way in and
/// comparisons downstream can be plain equality. Anything that is not two
/// ASCII letters is rejected rather than stored, so a provider returning ""
/// or "N/A" reads back as "unknown" instead of as a country.
pub fn isoFrom(text: []const u8) ?[2]u8 {
if (text.len != 2) return null;
if (!std.ascii.isAlphabetic(text[0]) or !std.ascii.isAlphabetic(text[1])) return null;
return .{ std.ascii.toUpper(text[0]), std.ascii.toUpper(text[1]) };
}
/// Whether this location is in the United States, for unit selection.
pub fn isUS(self: Location) bool {
const iso = self.iso_country orelse return false;
return std.mem.eql(u8, &iso, "US");
}
/// Build a display name from city, subdivision (state/province), and country /// Build a display name from city, subdivision (state/province), and country
/// Returns allocated string that must be freed by caller /// Returns allocated string that must be freed by caller
pub fn buildDisplayName(allocator: std.mem.Allocator, city: []const u8, subdivision: []const u8, country: []const u8, fallback: []const u8) []const u8 { pub fn buildDisplayName(allocator: std.mem.Allocator, city: []const u8, subdivision: []const u8, country: []const u8, fallback: []const u8) []const u8 {
@ -161,15 +188,18 @@ pub const Resolver = struct {
.allocator = self.allocator, .allocator = self.allocator,
.name = try self.allocator.dupe(u8, cached.name), .name = try self.allocator.dupe(u8, cached.name),
.coords = cached.coords, .coords = cached.coords,
.iso_country = cached.iso_country,
}; };
} }
log.info("Calling nominatim (OpenStreetMap) to resolve place name {s} to coordinates", .{name}); log.info("Calling nominatim (OpenStreetMap) to resolve place name {s} to coordinates", .{name});
if (@import("builtin").is_test) return error.GeocodingUnavailableInUnitTest; if (@import("builtin").is_test) return error.GeocodingUnavailableInUnitTest;
// Call Nominatim API // Call Nominatim API. `addressdetails=1` is requested for
// `address.country_code`, which is what lets a pin created from a place
// name record its country for unit selection.
const url = try std.fmt.allocPrint( const url = try std.fmt.allocPrint(
self.allocator, self.allocator,
"https://nominatim.openstreetmap.org/search?q={s}&format=json&limit=1", "https://nominatim.openstreetmap.org/search?q={s}&format=json&limit=1&addressdetails=1",
.{name}, .{name},
); );
defer self.allocator.free(url); defer self.allocator.free(url);
@ -218,6 +248,17 @@ pub const Resolver = struct {
const lon = try std.fmt.parseFloat(f64, first.object.get("lon").?.string); const lon = try std.fmt.parseFloat(f64, first.object.get("lon").?.string);
log.info("nominatim resolved place name {s} to {}, {}", .{ name, lat, lon }); log.info("nominatim resolved place name {s} to {}, {}", .{ name, lat, lon });
// Nominatim nests the country code under `address` and returns it lower
// case; `isoFrom` normalizes. Absent for results that carry no address
// block, which is why this is optional rather than required.
const iso_country: ?[2]u8 = blk: {
const address = first.object.get("address") orelse break :blk null;
if (address != .object) break :blk null;
const code = address.object.get("country_code") orelse break :blk null;
if (code != .string) break :blk null;
break :blk Location.isoFrom(code.string);
};
// Cache the result // Cache the result
try self.geocache.put(name, .{ try self.geocache.put(name, .{
.name = display_name, .name = display_name,
@ -225,6 +266,7 @@ pub const Resolver = struct {
.latitude = lat, .latitude = lat,
.longitude = lon, .longitude = lon,
}, },
.iso_country = iso_country,
}); });
return .{ return .{
@ -234,6 +276,7 @@ pub const Resolver = struct {
.latitude = lat, .latitude = lat,
.longitude = lon, .longitude = lon,
}, },
.iso_country = iso_country,
}; };
} }
@ -441,3 +484,50 @@ test "buildDisplayName: city only (no state or country)" {
defer allocator.free(name); defer allocator.free(name);
try std.testing.expectEqualStrings("Paris", name); try std.testing.expectEqualStrings("Paris", name);
} }
test "isoFrom: upper-cases so providers of either convention agree" {
// Nominatim returns "us", GeoLite2 and ipwho.is return "US". Without
// normalization the same country would compare unequal depending on source.
try std.testing.expectEqualStrings("US", &(Location.isoFrom("us").?));
try std.testing.expectEqualStrings("US", &(Location.isoFrom("US").?));
try std.testing.expectEqualStrings("GB", &(Location.isoFrom("gB").?));
}
test "isoFrom: rejects anything that is not two letters" {
// Providers return "" for unknown and occasionally a placeholder. Storing
// those would make an unknown country read back as a real one.
try std.testing.expect(Location.isoFrom("") == null);
try std.testing.expect(Location.isoFrom("U") == null);
try std.testing.expect(Location.isoFrom("USA") == null);
try std.testing.expect(Location.isoFrom("12") == null);
try std.testing.expect(Location.isoFrom("N/A") == null);
try std.testing.expect(Location.isoFrom("-") == null);
}
test "isUS: only true for a known US country code" {
const allocator = std.testing.allocator;
const base: Location = .{ .allocator = allocator, .name = "", .coords = .{ .latitude = 0, .longitude = 0 } };
// An unknown country must not read as US: that would flip every client with
// no country data to imperial.
try std.testing.expect(!base.isUS());
var us = base;
us.iso_country = Location.isoFrom("us");
try std.testing.expect(us.isUS());
var gb = base;
gb.iso_country = Location.isoFrom("GB");
try std.testing.expect(!gb.isUS());
}
test "Location: iso_country defaults to unknown" {
// The default is what keeps every existing construction site valid, so it is
// worth pinning down rather than leaving implied.
const loc: Location = .{
.allocator = std.testing.allocator,
.name = "",
.coords = .{ .latitude = 0, .longitude = 0 },
};
try std.testing.expect(loc.iso_country == null);
}

View file

@ -12,6 +12,7 @@ const GeoLite2 = @import("location/GeoLite2.zig");
const Refresher = @import("location/Refresher.zig"); const Refresher = @import("location/Refresher.zig");
const Signals = @import("Signals.zig"); const Signals = @import("Signals.zig");
const pin_cmd = @import("cli/pin.zig"); const pin_cmd = @import("cli/pin.zig");
const explain_cmd = @import("cli/explain.zig");
const version = @import("build_options").version; const version = @import("build_options").version;
/// Zig 0.16 entry point: the runtime supplies allocators, the `Io` /// Zig 0.16 entry point: the runtime supplies allocators, the `Io`
@ -40,6 +41,7 @@ pub fn main(init: std.process.Init) !u8 {
if (std.mem.eql(u8, cmd, "pin")) return pin_cmd.runPin(allocator, io, cfg, args[2..]); 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, "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, "pins")) return pin_cmd.runList(allocator, io, cfg);
if (std.mem.eql(u8, cmd, "explain")) return explain_cmd.runExplain(allocator, io, cfg, args[2..]);
if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "--help") or std.mem.eql(u8, cmd, "-h")) 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]); return pin_cmd.printUsage(io, args[0]);
@ -193,4 +195,5 @@ test {
_ = @import("location/Pins.zig"); _ = @import("location/Pins.zig");
_ = @import("Signals.zig"); _ = @import("Signals.zig");
_ = @import("cli/pin.zig"); _ = @import("cli/pin.zig");
_ = @import("cli/explain.zig");
} }