Compare commits
2 commits
d2e0f43bf0
...
f58fc54eb2
| Author | SHA1 | Date | |
|---|---|---|---|
| f58fc54eb2 | |||
| cbabb810ee |
10 changed files with 2048 additions and 63 deletions
482
src/cli/explain.zig
Normal file
482
src/cli/explain.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
\\ unpin <cidr> Remove an override
|
||||
\\ pins List overrides
|
||||
\\ explain <address> Show how an address resolves, stage by stage
|
||||
\\ help Show this message
|
||||
\\
|
||||
\\Pins take precedence over the GeoLite2 database, which is the point:
|
||||
\\they exist for addresses the database resolves confidently and wrongly.
|
||||
\\`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:
|
||||
\\ {s} pin 12.94.132.0/24 San Francisco
|
||||
\\ {s} pin 12.94.132.170 Portland, Oregon
|
||||
\\ {s} pin 2001:db8::/32 London
|
||||
\\ {s} unpin 12.94.132.0/24
|
||||
\\ {s} explain 12.94.132.170
|
||||
\\
|
||||
\\A bare address is treated as a single host (/32 or /128). When ranges
|
||||
\\overlap, the longest prefix wins, so a host pin beats a range pin.
|
||||
\\
|
||||
, .{ base, base, base, base, base });
|
||||
\\`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();
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -109,7 +117,7 @@ pub fn runPin(
|
|||
var pins = try Pins.load(allocator, io, cfg.pins_file);
|
||||
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);
|
||||
|
||||
var out_buf: [512]u8 = undefined;
|
||||
|
|
@ -121,6 +129,13 @@ pub fn runPin(
|
|||
location.coords.latitude,
|
||||
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 reportSignal(w, notifyServers(io));
|
||||
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 },
|
||||
&cidr_buf,
|
||||
);
|
||||
try w.print("{s: <20} {s} ({d:.4}, {d:.4})\n", .{
|
||||
text, e.name, e.coords.latitude, e.coords.longitude,
|
||||
// The country is shown because it is what decides units, and a pin
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -147,7 +147,11 @@ fn handleWeatherInternal(
|
|||
|
||||
var render_options = params.render_options;
|
||||
// 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) {
|
||||
// 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, "°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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const std = @import("std");
|
||||
const Coordinates = @import("../Coordinates.zig");
|
||||
const Location = @import("resolver.zig").Location;
|
||||
const srf = @import("srf");
|
||||
|
||||
const GeoCache = @This();
|
||||
|
|
@ -17,6 +18,10 @@ last_save: i64,
|
|||
pub const CachedLocation = struct {
|
||||
name: []const u8,
|
||||
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 {
|
||||
|
|
@ -66,6 +71,7 @@ pub fn put(self: *GeoCache, query: []const u8, location: CachedLocation) !void {
|
|||
const value = CachedLocation{
|
||||
.name = try self.allocator.dupe(u8, location.name),
|
||||
.coords = location.coords,
|
||||
.iso_country = location.iso_country,
|
||||
};
|
||||
try self.cache.put(key, value);
|
||||
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,
|
||||
/// 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 {
|
||||
query: []const u8,
|
||||
name: []const u8,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
iso: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
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, .{
|
||||
.name = name_copy,
|
||||
.coords = .{ .latitude = record.lat, .longitude = record.lon },
|
||||
.iso_country = if (record.iso) |iso| Location.isoFrom(iso) else null,
|
||||
});
|
||||
if (existing) |old| {
|
||||
allocator.free(old.key);
|
||||
|
|
@ -159,6 +172,10 @@ fn save(self: *GeoCache, writer: *std.Io.Writer) !void {
|
|||
.name = entry.value_ptr.name,
|
||||
.lat = entry.value_ptr.coords.latitude,
|
||||
.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, 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
switch (self) {
|
||||
.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 });
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// Try MaxMind first. The shared lock has to cover `extractCoordinates` as
|
||||
// well as the lookup itself: `MMDB_lookup_result_s.entry` holds a pointer
|
||||
// back to the `MMDB_s`, which `extractCoordinates` dereferences.
|
||||
// The shared lock has to cover `readEntry` as well as the lookup itself:
|
||||
// `MMDB_lookup_result_s.entry` holds a pointer back to the `MMDB_s`, which
|
||||
// `readEntry` dereferences.
|
||||
const from_db: ?Location = blk: {
|
||||
self.lock.lockSharedUncancelable(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 });
|
||||
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;
|
||||
|
||||
|
|
@ -236,26 +312,283 @@ fn lookupInternal(mmdb: *c.MMDB_s, ip: []const u8) !c.MMDB_lookup_result_s {
|
|||
return result;
|
||||
}
|
||||
|
||||
pub fn isUSIp(self: *GeoIP, ip: []const u8) bool {
|
||||
// `country_data` borrows from the mapped database via `result.entry`, so the
|
||||
// whole read stays inside the shared lock.
|
||||
self.lock.lockSharedUncancelable(self.io);
|
||||
defer self.lock.unlockShared(self.io);
|
||||
/// A full account of how an address resolves, for `wttr explain`.
|
||||
///
|
||||
/// 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,
|
||||
|
||||
var result = lookupInternal(self.mmdb, ip) catch return false;
|
||||
if (!result.found_entry) return false;
|
||||
/// 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,
|
||||
|
||||
// SAFETY: The C API will initialize this on the next line
|
||||
var country_data: c.MMDB_entry_data_s = undefined;
|
||||
const status = c.MMDB_get_value(&result.entry, &country_data, "country", "iso_code", @as([*c]const u8, null));
|
||||
/// The answer `lookup` would return, and where it came from.
|
||||
result: ?Result,
|
||||
/// The country `lookupCountry` would return, which decides units.
|
||||
country: ?[2]u8,
|
||||
|
||||
if (status != c.MMDB_SUCCESS or !country_data.has_data) {
|
||||
log.info("lookup found result, but no country available in data for ip {s}. MMDB_get_value returned {d}", .{ ip, status });
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
|
||||
const country_code = country_data.unnamed_0.utf8_string[0..country_data.data_size];
|
||||
return std.mem.eql(u8, country_code, "US");
|
||||
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);
|
||||
defer self.lock.unlockShared(self.io);
|
||||
|
||||
out.pins_loaded = self.pins.entries.items.len;
|
||||
|
||||
if (out.parsed) {
|
||||
if (self.pins.matchFor(ip)) |entry| {
|
||||
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);
|
||||
|
||||
out.pin = .{
|
||||
.cidr = cidr,
|
||||
.location = .{
|
||||
.allocator = self.allocator,
|
||||
.name = name,
|
||||
.coords = entry.coords,
|
||||
.iso_country = entry.iso_country,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The database is read even when a pin matched. An operator debugging
|
||||
// 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
|
||||
|
|
@ -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
|
||||
/// 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;
|
||||
|
||||
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
|
||||
var accuracy_data: c.MMDB_entry_data_s = undefined;
|
||||
const acc_status = c.MMDB_get_value(&entry_copy, &accuracy_data, "location", "accuracy_radius", @as([*c]const u8, null));
|
||||
if (acc_status == c.MMDB_SUCCESS and accuracy_data.has_data) {
|
||||
const radius = accuracy_data.unnamed_0.uint16;
|
||||
if (radius > max_accuracy_radius_km) {
|
||||
log.info("GeoLite2 accuracy_radius for ip {s} is {d} km (>{d} km threshold), falling back to the online provider", .{ ip, radius, max_accuracy_radius_km });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const accuracy_radius: ?u16 = if (acc_status == c.MMDB_SUCCESS and accuracy_data.has_data)
|
||||
accuracy_data.unnamed_0.uint16
|
||||
else
|
||||
null;
|
||||
|
||||
entry_copy = result.entry;
|
||||
|
||||
|
|
@ -341,15 +685,24 @@ fn extractCoordinates(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result
|
|||
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);
|
||||
|
||||
return .{
|
||||
.allocator = self.allocator,
|
||||
.name = final_name,
|
||||
.coords = .{
|
||||
.latitude = latitude,
|
||||
.longitude = longitude,
|
||||
.location = .{
|
||||
.allocator = self.allocator,
|
||||
.name = final_name,
|
||||
.coords = .{
|
||||
.latitude = latitude,
|
||||
.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"),
|
||||
"San Francisco, California, United States",
|
||||
.{ .latitude = 37.7749, .longitude = -122.4194 },
|
||||
Location.isoFrom("US"),
|
||||
);
|
||||
try pins.save(io, pins_path);
|
||||
}
|
||||
|
|
@ -553,7 +907,7 @@ test "reloadPins picks up a pin written after startup" {
|
|||
{
|
||||
var pins: Pins = .init(allocator);
|
||||
defer pins.deinit();
|
||||
try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Somewhere", .{ .latitude = 1, .longitude = 2 });
|
||||
try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Somewhere", .{ .latitude = 1, .longitude = 2 }, null);
|
||||
try pins.save(io, pins_path);
|
||||
}
|
||||
|
||||
|
|
@ -564,3 +918,483 @@ test "reloadPins picks up a pin written after startup" {
|
|||
defer hit.deinit();
|
||||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ fn fetch(self: *Self, ip_str: []const u8) !Location {
|
|||
.latitude = @floatCast(lat.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
|
||||
/// 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 {
|
||||
ip: []const u8,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
name: []const u8,
|
||||
iso: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub fn init(allocator: Allocator, io: std.Io, path: []const u8) !Cache {
|
||||
|
|
@ -292,6 +298,7 @@ pub const Cache = struct {
|
|||
.allocator = self.allocator,
|
||||
.name = name_copy,
|
||||
.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);
|
||||
}
|
||||
|
|
@ -303,9 +310,19 @@ pub const Cache = struct {
|
|||
.allocator = self.allocator,
|
||||
.name = self.allocator.dupe(u8, entry.name) catch return null,
|
||||
.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 {
|
||||
const name_copy = try self.allocator.dupe(u8, loc.name);
|
||||
errdefer self.allocator.free(name_copy);
|
||||
|
|
@ -314,6 +331,7 @@ pub const Cache = struct {
|
|||
.allocator = self.allocator,
|
||||
.name = name_copy,
|
||||
.coords = loc.coords,
|
||||
.iso_country = loc.iso_country,
|
||||
});
|
||||
if (existing) |old| self.allocator.free(old.value.name);
|
||||
|
||||
|
|
@ -330,6 +348,7 @@ pub const Cache = struct {
|
|||
.lat = loc.coords.latitude,
|
||||
.lon = loc.coords.longitude,
|
||||
.name = loc.name,
|
||||
.iso = if (loc.iso_country) |*iso| iso[0..] else null,
|
||||
}}, .{ .emit_directives = false })});
|
||||
|
||||
// 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").?;
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,8 +65,9 @@ fn fetch(self: *Self, ip_str: []const u8) !Location {
|
|||
var w = std.Io.Writer.fixed(&buf);
|
||||
try w.writeAll("https://ipwho.is/");
|
||||
try w.writeAll(ip_str);
|
||||
// Request only the fields we need
|
||||
try w.writeAll("?fields=city,region,country,latitude,longitude&output=json");
|
||||
// Request only the fields we need. `country_code` drives unit selection,
|
||||
// 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 writer = std.Io.Writer.fixed(&response_buf);
|
||||
|
|
@ -139,6 +140,7 @@ fn fetch(self: *Self, ip_str: []const u8) !Location {
|
|||
.latitude = lat_val,
|
||||
.longitude = lon_val,
|
||||
},
|
||||
.iso_country = Location.isoFrom(getString(obj, "country_code")),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ pub const Entry = struct {
|
|||
prefix_len: u8,
|
||||
name: []const u8,
|
||||
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 {
|
||||
|
|
@ -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
|
||||
/// 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;
|
||||
|
||||
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 (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 .{
|
||||
.allocator = allocator,
|
||||
.name = allocator.dupe(u8, entry.name) catch return null,
|
||||
.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.
|
||||
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);
|
||||
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);
|
||||
e.name = name_copy;
|
||||
e.coords = coords;
|
||||
e.iso_country = iso_country;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -158,6 +188,7 @@ pub fn put(self: *Pins, cidr: Cidr, name: []const u8, coords: Coordinates) !void
|
|||
.prefix_len = cidr.prefix_len,
|
||||
.name = name_copy,
|
||||
.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,
|
||||
/// California, United States"), which a comma-delimited format has to special
|
||||
/// 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 {
|
||||
cidr: []const u8,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
name: []const u8,
|
||||
iso: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// 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
|
||||
// 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;
|
||||
|
|
@ -255,7 +295,7 @@ pub fn save(self: *const Pins, io: std.Io, path: []const u8) !void {
|
|||
self.allocator.free(cidr_texts);
|
||||
}
|
||||
|
||||
for (self.entries.items, 0..) |e, i| {
|
||||
for (self.entries.items, 0..) |*e, i| {
|
||||
var buf: [64]u8 = undefined;
|
||||
const text = try formatCidr(
|
||||
.{ .family = e.family, .network = e.network, .prefix_len = e.prefix_len },
|
||||
|
|
@ -268,6 +308,10 @@ pub fn save(self: *const Pins, io: std.Io, path: []const u8) !void {
|
|||
.lat = e.coords.latitude,
|
||||
.lon = e.coords.longitude,
|
||||
.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);
|
||||
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;
|
||||
defer hit.deinit();
|
||||
|
|
@ -350,7 +394,7 @@ test "lookup: ignores an address outside the range" {
|
|||
var pins: Pins = .init(allocator);
|
||||
defer pins.deinit();
|
||||
|
||||
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco", .{ .latitude = 37.7749, .longitude = -122.4194 });
|
||||
try 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);
|
||||
}
|
||||
|
|
@ -361,8 +405,8 @@ test "lookup: longest prefix wins over a broader range" {
|
|||
defer pins.deinit();
|
||||
|
||||
// Deliberately inserted broad-first so the result cannot come from ordering.
|
||||
try pins.put(try parseCidr("12.0.0.0/8"), "Broad", .{ .latitude = 1, .longitude = 1 });
|
||||
try pins.put(try parseCidr("12.94.132.0/24"), "Specific", .{ .latitude = 2, .longitude = 2 });
|
||||
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 }, null);
|
||||
|
||||
const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult;
|
||||
defer hit.deinit();
|
||||
|
|
@ -374,8 +418,8 @@ test "lookup: an exact host pin beats a range containing it" {
|
|||
var pins: Pins = .init(allocator);
|
||||
defer pins.deinit();
|
||||
|
||||
try pins.put(try parseCidr("12.94.132.170"), "Host", .{ .latitude = 2, .longitude = 2 });
|
||||
try pins.put(try parseCidr("12.94.132.0/24"), "Range", .{ .latitude = 1, .longitude = 1 });
|
||||
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 }, null);
|
||||
|
||||
const hit = pins.lookup(allocator, "12.94.132.170") orelse return error.TestUnexpectedResult;
|
||||
defer hit.deinit();
|
||||
|
|
@ -387,7 +431,7 @@ test "lookup: families do not cross-match" {
|
|||
var pins: Pins = .init(allocator);
|
||||
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.
|
||||
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();
|
||||
|
||||
const cidr = try parseCidr("12.94.132.0/24");
|
||||
try pins.put(cidr, "Old", .{ .latitude = 1, .longitude = 1 });
|
||||
try pins.put(cidr, "New", .{ .latitude = 2, .longitude = 2 });
|
||||
try pins.put(cidr, "Old", .{ .latitude = 1, .longitude = 1 }, null);
|
||||
try pins.put(cidr, "New", .{ .latitude = 2, .longitude = 2 }, null);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), pins.entries.items.len);
|
||||
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();
|
||||
|
||||
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));
|
||||
|
|
@ -449,8 +493,8 @@ test "save then load round-trips, including commas in the name" {
|
|||
{
|
||||
var pins: Pins = .init(allocator);
|
||||
defer pins.deinit();
|
||||
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California, United States", .{ .latitude = 37.7749, .longitude = -122.4194 });
|
||||
try pins.put(try parseCidr("2001:db8::/32"), "Test, Place", .{ .latitude = -1.5, .longitude = 2.25 });
|
||||
try pins.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 }, null);
|
||||
try pins.save(io, path);
|
||||
}
|
||||
|
||||
|
|
@ -549,7 +593,7 @@ test "save writes SRF that an operator can read" {
|
|||
|
||||
var pins: Pins = .init(allocator);
|
||||
defer pins.deinit();
|
||||
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California", .{ .latitude = 37.7749, .longitude = -122.4194 });
|
||||
try pins.put(try parseCidr("12.94.132.0/24"), "San Francisco, California", .{ .latitude = 37.7749, .longitude = -122.4194 }, null);
|
||||
try pins.save(io, path);
|
||||
|
||||
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, "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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,38 @@ pub const Location = struct {
|
|||
name: []const u8,
|
||||
coords: Coordinates,
|
||||
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 {
|
||||
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
|
||||
/// 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 {
|
||||
|
|
@ -161,15 +188,18 @@ pub const Resolver = struct {
|
|||
.allocator = self.allocator,
|
||||
.name = try self.allocator.dupe(u8, cached.name),
|
||||
.coords = cached.coords,
|
||||
.iso_country = cached.iso_country,
|
||||
};
|
||||
}
|
||||
|
||||
log.info("Calling nominatim (OpenStreetMap) to resolve place name {s} to coordinates", .{name});
|
||||
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(
|
||||
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},
|
||||
);
|
||||
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);
|
||||
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
|
||||
try self.geocache.put(name, .{
|
||||
.name = display_name,
|
||||
|
|
@ -225,6 +266,7 @@ pub const Resolver = struct {
|
|||
.latitude = lat,
|
||||
.longitude = lon,
|
||||
},
|
||||
.iso_country = iso_country,
|
||||
});
|
||||
|
||||
return .{
|
||||
|
|
@ -234,6 +276,7 @@ pub const Resolver = struct {
|
|||
.latitude = lat,
|
||||
.longitude = lon,
|
||||
},
|
||||
.iso_country = iso_country,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -441,3 +484,50 @@ test "buildDisplayName: city only (no state or country)" {
|
|||
defer allocator.free(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const GeoLite2 = @import("location/GeoLite2.zig");
|
|||
const Refresher = @import("location/Refresher.zig");
|
||||
const Signals = @import("Signals.zig");
|
||||
const pin_cmd = @import("cli/pin.zig");
|
||||
const explain_cmd = @import("cli/explain.zig");
|
||||
const version = @import("build_options").version;
|
||||
|
||||
/// 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, "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, "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"))
|
||||
return pin_cmd.printUsage(io, args[0]);
|
||||
|
||||
|
|
@ -193,4 +195,5 @@ test {
|
|||
_ = @import("location/Pins.zig");
|
||||
_ = @import("Signals.zig");
|
||||
_ = @import("cli/pin.zig");
|
||||
_ = @import("cli/explain.zig");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue