move to srf for all cache file formats
This commit is contained in:
parent
11364b10e7
commit
16319a9f47
4 changed files with 382 additions and 208 deletions
|
|
@ -110,7 +110,7 @@ pub fn load(allocator: std.mem.Allocator, env: *const std.process.Environ.Map) !
|
||||||
if (env.get("WTTR_GEOCACHE_FILE")) |v| {
|
if (env.get("WTTR_GEOCACHE_FILE")) |v| {
|
||||||
break :blk try allocator.dupe(u8, v);
|
break :blk try allocator.dupe(u8, v);
|
||||||
}
|
}
|
||||||
break :blk try std.fmt.allocPrint(allocator, "{s}/geocache.json", .{
|
break :blk try std.fmt.allocPrint(allocator, "{s}/geocache.srf", .{
|
||||||
env.get("WTTR_CACHE_DIR") orelse default_cache_dir,
|
env.get("WTTR_CACHE_DIR") orelse default_cache_dir,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -125,7 +125,7 @@ pub fn load(allocator: std.mem.Allocator, env: *const std.process.Environ.Map) !
|
||||||
if (env.get("IP2LOCATION_CACHE_FILE")) |v| {
|
if (env.get("IP2LOCATION_CACHE_FILE")) |v| {
|
||||||
break :blk try allocator.dupe(u8, v);
|
break :blk try allocator.dupe(u8, v);
|
||||||
}
|
}
|
||||||
break :blk try std.fmt.allocPrint(allocator, "{s}/ip2location.cache", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
|
break :blk try std.fmt.allocPrint(allocator, "{s}/ip2location.srf", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
|
||||||
},
|
},
|
||||||
.pins_file = blk: {
|
.pins_file = blk: {
|
||||||
if (env.get("WTTR_PINS_FILE")) |v| {
|
if (env.get("WTTR_PINS_FILE")) |v| {
|
||||||
|
|
@ -137,7 +137,7 @@ pub fn load(allocator: std.mem.Allocator, env: *const std.process.Environ.Map) !
|
||||||
if (env.get("IPWHOIS_CACHE_FILE")) |v| {
|
if (env.get("IPWHOIS_CACHE_FILE")) |v| {
|
||||||
break :blk try allocator.dupe(u8, v);
|
break :blk try allocator.dupe(u8, v);
|
||||||
}
|
}
|
||||||
break :blk try std.fmt.allocPrint(allocator, "{s}/ipwhois.cache", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
|
break :blk try std.fmt.allocPrint(allocator, "{s}/ipwhois.srf", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
116
src/cache/Cache.zig
vendored
116
src/cache/Cache.zig
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Lru = @import("Lru.zig");
|
const Lru = @import("Lru.zig");
|
||||||
|
const srf = @import("srf");
|
||||||
|
|
||||||
const Cache = @This();
|
const Cache = @This();
|
||||||
|
|
||||||
|
|
@ -83,11 +84,15 @@ pub fn deinit(self: *Cache) void {
|
||||||
self.allocator.destroy(self);
|
self.allocator.destroy(self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extension for L2 entries. Named so `loadFromDir` can tell weather entries
|
||||||
|
/// apart from the other state living in the same directory.
|
||||||
|
const cache_file_extension = ".srf";
|
||||||
|
|
||||||
fn getCacheFilename(self: *Cache, key: []const u8) ![]const u8 {
|
fn getCacheFilename(self: *Cache, key: []const u8) ![]const u8 {
|
||||||
var hasher = std.hash.Wyhash.init(0);
|
var hasher = std.hash.Wyhash.init(0);
|
||||||
hasher.update(key);
|
hasher.update(key);
|
||||||
const hash = hasher.final();
|
const hash = hasher.final();
|
||||||
return std.fmt.allocPrint(self.allocator, "{x}.json", .{hash});
|
return std.fmt.allocPrint(self.allocator, "{x}" ++ cache_file_extension, .{hash});
|
||||||
}
|
}
|
||||||
|
|
||||||
const CacheEntry = struct {
|
const CacheEntry = struct {
|
||||||
|
|
@ -143,25 +148,23 @@ fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry {
|
||||||
|
|
||||||
fn serialize(writer: *std.Io.Writer, key: []const u8, value: []const u8, expires: i64) !void {
|
fn serialize(writer: *std.Io.Writer, key: []const u8, value: []const u8, expires: i64) !void {
|
||||||
const entry = CacheEntry{ .key = key, .value = value, .expires = expires };
|
const entry = CacheEntry{ .key = key, .value = value, .expires = expires };
|
||||||
try writer.print("{f}", .{std.json.fmt(entry, .{})});
|
// Long format: the cached payload is a multi-kilobyte provider response, so
|
||||||
|
// one field per line keeps the file skimmable. SRF length-prefixes the value
|
||||||
|
// because it contains newlines, so the payload needs no escaping.
|
||||||
|
try writer.print("{f}", .{srf.fmt(CacheEntry, &.{entry}, .{ .long_format = true })});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deserialize(allocator: std.mem.Allocator, reader: *std.Io.Reader) !CacheEntry {
|
fn deserialize(allocator: std.mem.Allocator, reader: *std.Io.Reader) !CacheEntry {
|
||||||
var json_reader = std.json.Reader.init(allocator, reader);
|
var records = try srf.iterator(reader, allocator, .{});
|
||||||
defer json_reader.deinit();
|
defer records.deinit();
|
||||||
|
|
||||||
const parsed = try std.json.parseFromTokenSource(
|
const fields = try records.next() orelse return error.EmptyCacheEntry;
|
||||||
CacheEntry,
|
const entry = try fields.to(CacheEntry, .{});
|
||||||
allocator,
|
|
||||||
&json_reader,
|
|
||||||
.{},
|
|
||||||
);
|
|
||||||
defer parsed.deinit();
|
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
.key = try allocator.dupe(u8, parsed.value.key),
|
.key = try allocator.dupe(u8, entry.key),
|
||||||
.value = try allocator.dupe(u8, parsed.value.value),
|
.value = try allocator.dupe(u8, entry.value),
|
||||||
.expires = parsed.value.expires,
|
.expires = entry.expires,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +187,22 @@ fn saveToFile(self: *Cache, key: []const u8, value: []const u8, expires: i64) !v
|
||||||
try writer.flush();
|
try writer.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a directory entry is one of our L2 entries.
|
||||||
|
///
|
||||||
|
/// Entry names are a hash rendered as hex plus the extension. Matching the shape
|
||||||
|
/// rather than just the extension keeps the descriptively-named state files in
|
||||||
|
/// the same directory from being mistaken for cache entries now that they share
|
||||||
|
/// a format.
|
||||||
|
fn isCacheFileName(name: []const u8) bool {
|
||||||
|
if (!std.mem.endsWith(u8, name, cache_file_extension)) return false;
|
||||||
|
const stem = name[0 .. name.len - cache_file_extension.len];
|
||||||
|
if (stem.len == 0) return false;
|
||||||
|
for (stem) |ch| {
|
||||||
|
if (!std.ascii.isHex(ch)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
fn loadFromDir(self: *Cache) !void {
|
fn loadFromDir(self: *Cache) !void {
|
||||||
if (self.cache_dir == null) return error.NoCacheDir;
|
if (self.cache_dir == null) return error.NoCacheDir;
|
||||||
|
|
||||||
|
|
@ -193,6 +212,11 @@ fn loadFromDir(self: *Cache) !void {
|
||||||
var it = dir.iterate();
|
var it = dir.iterate();
|
||||||
while (try it.next(self.io)) |entry| {
|
while (try it.next(self.io)) |entry| {
|
||||||
if (entry.kind != .file) continue;
|
if (entry.kind != .file) continue;
|
||||||
|
// The cache directory also holds the GeoLite2 database and the named
|
||||||
|
// state files (pins, geocache, provider caches). Without this filter every
|
||||||
|
// startup opened and tried to parse all of them, including reading a
|
||||||
|
// megabyte of the 60+ MB database.
|
||||||
|
if (!isCacheFileName(entry.name)) continue;
|
||||||
|
|
||||||
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, entry.name });
|
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, entry.name });
|
||||||
defer self.allocator.free(file_path);
|
defer self.allocator.free(file_path);
|
||||||
|
|
@ -233,7 +257,12 @@ test "serialize and deserialize" {
|
||||||
try fixed_writer.flush();
|
try fixed_writer.flush();
|
||||||
|
|
||||||
const serialized = buffer[0..fixed_writer.end];
|
const serialized = buffer[0..fixed_writer.end];
|
||||||
try std.testing.expectEqualStrings("{\"key\":\"test_key\",\"value\":\"test_value\",\"expires\":1234567890}", serialized);
|
// Assert the shape as well as the round trip, so a change of format is a
|
||||||
|
// deliberate edit here rather than a silent difference on disk.
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, serialized, "#!srfv1") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, serialized, "#!long") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, serialized, "key::test_key") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, serialized, "value::test_value") != null);
|
||||||
|
|
||||||
var fixed_reader = std.Io.Reader.fixed(serialized);
|
var fixed_reader = std.Io.Reader.fixed(serialized);
|
||||||
|
|
||||||
|
|
@ -245,19 +274,68 @@ test "serialize and deserialize" {
|
||||||
try std.testing.expectEqual(expires, cached.expires);
|
try std.testing.expectEqual(expires, cached.expires);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "deserialize handles integer expires" {
|
test "deserialize preserves a millisecond timestamp exactly" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
const json = "{\"key\":\"k\",\"value\":\"v\",\"expires\":9999999999999}";
|
// SRF carries numbers as f64. Millisecond timestamps are around 1.7e12, well
|
||||||
|
// inside the 2^53 range f64 represents exactly, so no precision is lost --
|
||||||
|
// but that is worth pinning down rather than assuming.
|
||||||
|
const expires: i64 = 9999999999999;
|
||||||
|
|
||||||
var fixed_reader = std.Io.Reader.fixed(json);
|
var buffer: [1024]u8 = undefined;
|
||||||
|
var fixed_writer = std.Io.Writer.fixed(&buffer);
|
||||||
|
try serialize(&fixed_writer, "k", "v", expires);
|
||||||
|
try fixed_writer.flush();
|
||||||
|
|
||||||
|
var fixed_reader = std.Io.Reader.fixed(buffer[0..fixed_writer.end]);
|
||||||
const cached = try deserialize(allocator, &fixed_reader);
|
const cached = try deserialize(allocator, &fixed_reader);
|
||||||
defer cached.deinit(allocator);
|
defer cached.deinit(allocator);
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("k", cached.key);
|
try std.testing.expectEqualStrings("k", cached.key);
|
||||||
try std.testing.expectEqualStrings("v", cached.value);
|
try std.testing.expectEqualStrings("v", cached.value);
|
||||||
try std.testing.expectEqual(9999999999999, cached.expires);
|
try std.testing.expectEqual(expires, cached.expires);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "serialize round-trips a payload containing newlines and commas" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
|
// The real payload is a provider JSON response: multi-line, comma-heavy, and
|
||||||
|
// quoted. It survives only because SRF length-prefixes such values.
|
||||||
|
const value =
|
||||||
|
\\{"properties":{"timeseries":[
|
||||||
|
\\ {"time":"2026-08-05T00:00:00Z","data":{"instant":1.5}},
|
||||||
|
\\ {"time":"2026-08-05T01:00:00Z","data":{"instant":2.5}}
|
||||||
|
\\]}}
|
||||||
|
;
|
||||||
|
|
||||||
|
var buffer: [4096]u8 = undefined;
|
||||||
|
var fixed_writer = std.Io.Writer.fixed(&buffer);
|
||||||
|
try serialize(&fixed_writer, "59.9,10.7", value, 9999999999999);
|
||||||
|
try fixed_writer.flush();
|
||||||
|
|
||||||
|
var fixed_reader = std.Io.Reader.fixed(buffer[0..fixed_writer.end]);
|
||||||
|
const cached = try deserialize(allocator, &fixed_reader);
|
||||||
|
defer cached.deinit(allocator);
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings("59.9,10.7", cached.key);
|
||||||
|
try std.testing.expectEqualStrings(value, cached.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "isCacheFileName distinguishes entries from the other state files" {
|
||||||
|
// Entries are a hex hash plus the extension.
|
||||||
|
try std.testing.expect(isCacheFileName("1f44d18884c40d0a.srf"));
|
||||||
|
try std.testing.expect(isCacheFileName("abcdef.srf"));
|
||||||
|
|
||||||
|
// Everything else sharing the directory must be left alone, including the
|
||||||
|
// state files that now use the same format.
|
||||||
|
try std.testing.expect(!isCacheFileName("pins.srf"));
|
||||||
|
try std.testing.expect(!isCacheFileName("geocache.srf"));
|
||||||
|
try std.testing.expect(!isCacheFileName("ipwhois.srf"));
|
||||||
|
try std.testing.expect(!isCacheFileName("ip2location.srf"));
|
||||||
|
try std.testing.expect(!isCacheFileName("GeoLite2-City.mmdb"));
|
||||||
|
try std.testing.expect(!isCacheFileName("GeoLite2-City.mmdb.download"));
|
||||||
|
try std.testing.expect(!isCacheFileName(".srf"));
|
||||||
|
try std.testing.expect(!isCacheFileName("1f44d18884c40d0a.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "L1/L2 cache flow" {
|
test "L1/L2 cache flow" {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Coordinates = @import("../Coordinates.zig");
|
const Coordinates = @import("../Coordinates.zig");
|
||||||
|
const srf = @import("srf");
|
||||||
|
|
||||||
const GeoCache = @This();
|
const GeoCache = @This();
|
||||||
|
|
||||||
|
|
@ -91,31 +92,48 @@ pub fn saveIfNeeded(self: *GeoCache) void {
|
||||||
self.last_save = now;
|
self.last_save = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// On-disk shape of one geocoded place.
|
||||||
|
///
|
||||||
|
/// The query is stored alongside the result rather than used as an object key,
|
||||||
|
/// because SRF records are flat field lists rather than a nested map.
|
||||||
|
const Record = struct {
|
||||||
|
query: []const u8,
|
||||||
|
name: []const u8,
|
||||||
|
lat: f64,
|
||||||
|
lon: f64,
|
||||||
|
};
|
||||||
|
|
||||||
fn load(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), content: []const u8) !void {
|
fn load(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), content: []const u8) !void {
|
||||||
const CacheData = struct {
|
if (std.mem.trim(u8, content, " \r\n\t").len == 0) return;
|
||||||
name: []const u8,
|
|
||||||
latitude: f64,
|
|
||||||
longitude: f64,
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsed = try std.json.parseFromSlice(
|
var reader = std.Io.Reader.fixed(content);
|
||||||
std.json.ArrayHashMap(CacheData),
|
var records = try srf.iterator(&reader, allocator, .{});
|
||||||
allocator,
|
defer records.deinit();
|
||||||
content,
|
|
||||||
.{},
|
|
||||||
);
|
|
||||||
defer parsed.deinit();
|
|
||||||
|
|
||||||
for (parsed.value.map.keys(), parsed.value.map.values()) |key, value| {
|
var index: usize = 0;
|
||||||
const cache_key = try allocator.dupe(u8, key);
|
while (records.next() catch |err| {
|
||||||
const cache_value = CachedLocation{
|
log.warn("stopped reading geocache after {d} entr(ies): {t}", .{ index, err });
|
||||||
.name = try allocator.dupe(u8, value.name),
|
return;
|
||||||
.coords = .{
|
}) |fields| {
|
||||||
.latitude = value.latitude,
|
index += 1;
|
||||||
.longitude = value.longitude,
|
const record = fields.to(Record, .{}) catch continue;
|
||||||
},
|
if (record.query.len == 0) continue;
|
||||||
};
|
|
||||||
try cache.put(cache_key, cache_value);
|
const cache_key = try allocator.dupe(u8, record.query);
|
||||||
|
errdefer allocator.free(cache_key);
|
||||||
|
const name_copy = try allocator.dupe(u8, record.name);
|
||||||
|
errdefer allocator.free(name_copy);
|
||||||
|
|
||||||
|
// A duplicate query in the file would otherwise leak the key and name
|
||||||
|
// already stored under it.
|
||||||
|
const existing = try cache.fetchPut(cache_key, .{
|
||||||
|
.name = name_copy,
|
||||||
|
.coords = .{ .latitude = record.lat, .longitude = record.lon },
|
||||||
|
});
|
||||||
|
if (existing) |old| {
|
||||||
|
allocator.free(old.key);
|
||||||
|
allocator.free(old.value.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,25 +145,24 @@ fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, cache: *std.StringHash
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save(self: *GeoCache, writer: *std.Io.Writer) !void {
|
fn save(self: *GeoCache, writer: *std.Io.Writer) !void {
|
||||||
try writer.writeAll("{\n");
|
// Rewritten in full rather than appended to, because entries are replaced in
|
||||||
|
// place; SRF long format keeps the result legible for a file an operator may
|
||||||
|
// want to inspect or prune.
|
||||||
|
var records = try self.allocator.alloc(Record, self.cache.count());
|
||||||
|
defer self.allocator.free(records);
|
||||||
|
|
||||||
var it = self.cache.iterator();
|
var it = self.cache.iterator();
|
||||||
var first = true;
|
var i: usize = 0;
|
||||||
while (it.next()) |entry| {
|
while (it.next()) |entry| : (i += 1) {
|
||||||
if (!first) try writer.writeAll(",\n");
|
records[i] = .{
|
||||||
first = false;
|
.query = entry.key_ptr.*,
|
||||||
|
.name = entry.value_ptr.name,
|
||||||
try writer.print(" {f}: {f}", .{
|
.lat = entry.value_ptr.coords.latitude,
|
||||||
std.json.fmt(entry.key_ptr.*, .{}),
|
.lon = entry.value_ptr.coords.longitude,
|
||||||
std.json.fmt(.{
|
};
|
||||||
.name = entry.value_ptr.name,
|
|
||||||
.latitude = entry.value_ptr.coords.latitude,
|
|
||||||
.longitude = entry.value_ptr.coords.longitude,
|
|
||||||
}, .{}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try writer.writeAll("\n}\n");
|
try writer.print("{f}", .{srf.fmt(Record, records, .{ .long_format = true })});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn saveToFile(self: *GeoCache, file_path: []const u8) !void {
|
fn saveToFile(self: *GeoCache, file_path: []const u8) !void {
|
||||||
|
|
@ -189,7 +206,7 @@ test "GeoCache miss returns null" {
|
||||||
try std.testing.expect(result == null);
|
try std.testing.expect(result == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "save produces valid JSON" {
|
test "save produces SRF an operator can read" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
var cache = try GeoCache.init(allocator, std.testing.io, null);
|
var cache = try GeoCache.init(allocator, std.testing.io, null);
|
||||||
defer cache.deinit();
|
defer cache.deinit();
|
||||||
|
|
@ -208,12 +225,16 @@ test "save produces valid JSON" {
|
||||||
try cache.save(&writer);
|
try cache.save(&writer);
|
||||||
|
|
||||||
const output = buffer[0..writer.end];
|
const output = buffer[0..writer.end];
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "London") != null);
|
// Assert the shape, not just that the values appear: a silent switch to
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "Paris") != null);
|
// compact output would still round-trip while making the file harder to read.
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, output, "#!srfv1") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, output, "#!long") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, output, "query::London") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, output, "query::Paris") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "51.5074") != null);
|
try std.testing.expect(std.mem.indexOf(u8, output, "51.5074") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "load parses valid JSON" {
|
test "load parses SRF records" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
var cache_map = std.StringHashMap(CachedLocation).init(allocator);
|
var cache_map = std.StringHashMap(CachedLocation).init(allocator);
|
||||||
defer {
|
defer {
|
||||||
|
|
@ -225,22 +246,53 @@ test "load parses valid JSON" {
|
||||||
cache_map.deinit();
|
cache_map.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
const json =
|
const content =
|
||||||
|
\\#!srfv1
|
||||||
|
\\#!long
|
||||||
|
\\query::London
|
||||||
|
\\name::London, UK
|
||||||
|
\\lat:num:51.5074
|
||||||
|
\\lon:num:-0.1278
|
||||||
|
\\
|
||||||
|
\\query::Paris
|
||||||
|
\\name::Paris, France
|
||||||
|
\\lat:num:48.8566
|
||||||
|
\\lon:num:2.3522
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
|
||||||
|
try load(allocator, &cache_map, content);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), cache_map.count());
|
||||||
|
const london = cache_map.get("London") orelse return error.TestUnexpectedResult;
|
||||||
|
try std.testing.expectEqualStrings("London, UK", london.name);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 51.5074), london.coords.latitude, 0.0001);
|
||||||
|
const paris = cache_map.get("Paris") orelse return error.TestUnexpectedResult;
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 2.3522), paris.coords.longitude, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "load ignores a legacy JSON file rather than failing" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var cache_map = std.StringHashMap(CachedLocation).init(allocator);
|
||||||
|
defer {
|
||||||
|
var it = cache_map.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
allocator.free(entry.key_ptr.*);
|
||||||
|
allocator.free(entry.value_ptr.name);
|
||||||
|
}
|
||||||
|
cache_map.deinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The previous format. Entries are re-geocodable, so the file is dropped
|
||||||
|
// rather than migrated; what matters is that this is not fatal.
|
||||||
|
const legacy =
|
||||||
\\{
|
\\{
|
||||||
\\ "London": {"name": "London, UK", "latitude": 51.5074, "longitude": -0.1278},
|
\\ "London": {"name": "London, UK", "latitude": 51.5074, "longitude": -0.1278}
|
||||||
\\ "Paris": {"name": "Paris, France", "latitude": 48.8566, "longitude": 2.3522}
|
|
||||||
\\}
|
\\}
|
||||||
;
|
;
|
||||||
|
|
||||||
try load(allocator, &cache_map, json);
|
load(allocator, &cache_map, legacy) catch {};
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), cache_map.count());
|
||||||
const london = cache_map.get("London");
|
|
||||||
try std.testing.expect(london != null);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 51.5074), london.?.coords.latitude, 0.0001);
|
|
||||||
|
|
||||||
const paris = cache_map.get("Paris");
|
|
||||||
try std.testing.expect(paris != null);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 48.8566), paris.?.coords.latitude, 0.0001);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "save and load round-trip" {
|
test "save and load round-trip" {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const Location = @import("resolver.zig").Location;
|
const Location = @import("resolver.zig").Location;
|
||||||
|
const srf = @import("srf");
|
||||||
|
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
|
|
||||||
|
|
@ -174,6 +175,15 @@ inline fn getString(obj: std.json.ObjectMap, key: []const u8) []const u8 {
|
||||||
return maybe_val.?.string;
|
return maybe_val.?.string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Permanent IP-to-location cache, shared by the online providers.
|
||||||
|
///
|
||||||
|
/// Append-only: an online lookup for an address is answered once and the result
|
||||||
|
/// is kept forever, because an address's city does not meaningfully change and
|
||||||
|
/// every avoided request is one that does not count against a rate limit.
|
||||||
|
///
|
||||||
|
/// Stored as SRF in compact form, one record per line. Compact is safe for place
|
||||||
|
/// names containing commas because SRF length-prefixes any string containing the
|
||||||
|
/// field delimiter, so no escaping rules are needed here.
|
||||||
pub const Cache = struct {
|
pub const Cache = struct {
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
|
|
@ -181,6 +191,17 @@ pub const Cache = struct {
|
||||||
entries: std.AutoHashMap(u128, Location),
|
entries: std.AutoHashMap(u128, Location),
|
||||||
file: ?std.Io.File,
|
file: ?std.Io.File,
|
||||||
|
|
||||||
|
/// On-disk shape of one entry.
|
||||||
|
///
|
||||||
|
/// The address is stored as text rather than the `u128` used for the in-memory
|
||||||
|
/// key, so the file stays legible to whoever has to look at it.
|
||||||
|
const Record = struct {
|
||||||
|
ip: []const u8,
|
||||||
|
lat: f64,
|
||||||
|
lon: f64,
|
||||||
|
name: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn init(allocator: Allocator, io: std.Io, path: []const u8) !Cache {
|
pub fn init(allocator: Allocator, io: std.Io, path: []const u8) !Cache {
|
||||||
var cache = Cache{
|
var cache = Cache{
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
|
|
@ -190,19 +211,22 @@ pub const Cache = struct {
|
||||||
.file = null,
|
.file = null,
|
||||||
};
|
};
|
||||||
errdefer allocator.free(cache.path);
|
errdefer allocator.free(cache.path);
|
||||||
|
errdefer cache.entries.deinit();
|
||||||
|
|
||||||
// Try to open existing cache file
|
// Try to open existing cache file
|
||||||
if (std.Io.Dir.openFileAbsolute(io, path, .{ .mode = .read_write })) |file| {
|
if (std.Io.Dir.openFileAbsolute(io, path, .{ .mode = .read_write })) |file| {
|
||||||
cache.file = file;
|
cache.file = file;
|
||||||
try cache.load();
|
cache.load() catch |err| {
|
||||||
|
// A cache that cannot be read is worth strictly less than the
|
||||||
|
// service staying up: start empty and let it refill.
|
||||||
|
log.warn("could not read cache {s} ({t}); starting empty", .{ path, err });
|
||||||
|
};
|
||||||
} else |err| switch (err) {
|
} else |err| switch (err) {
|
||||||
error.FileNotFound => {
|
error.FileNotFound => {
|
||||||
// Create new cache file
|
|
||||||
const dir = std.fs.path.dirname(path) orelse return error.InvalidPath;
|
const dir = std.fs.path.dirname(path) orelse return error.InvalidPath;
|
||||||
try std.Io.Dir.cwd().createDirPath(io, dir);
|
try std.Io.Dir.cwd().createDirPath(io, dir);
|
||||||
cache.file = try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true });
|
cache.file = try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true });
|
||||||
// Write header
|
try cache.writeDirectives();
|
||||||
try cache.file.?.writePositionalAll(io, "#Ip2location:v2\n", 0);
|
|
||||||
},
|
},
|
||||||
else => return err,
|
else => return err,
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +234,17 @@ pub const Cache = struct {
|
||||||
return cache;
|
return cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes the SRF front matter that opens a new cache file.
|
||||||
|
///
|
||||||
|
/// Emitted once at creation; `put` appends bare records afterwards.
|
||||||
|
fn writeDirectives(self: *Cache) !void {
|
||||||
|
const file = self.file orelse return;
|
||||||
|
var buf: [64]u8 = undefined;
|
||||||
|
var fw = file.writer(self.io, &buf);
|
||||||
|
try fw.interface.print("{f}", .{srf.fmt(Record, &.{}, .{})});
|
||||||
|
try fw.interface.flush();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *Cache) void {
|
pub fn deinit(self: *Cache) void {
|
||||||
if (self.file) |f| f.close(self.io);
|
if (self.file) |f| f.close(self.io);
|
||||||
var it = self.entries.valueIterator();
|
var it = self.entries.valueIterator();
|
||||||
|
|
@ -236,61 +271,30 @@ pub const Cache = struct {
|
||||||
);
|
);
|
||||||
defer self.allocator.free(content);
|
defer self.allocator.free(content);
|
||||||
|
|
||||||
var lines = std.mem.splitScalar(u8, content, '\n');
|
var reader = std.Io.Reader.fixed(content);
|
||||||
|
var records = try srf.iterator(&reader, self.allocator, .{});
|
||||||
|
defer records.deinit();
|
||||||
|
|
||||||
// Check for header magic string
|
var index: usize = 0;
|
||||||
if (lines.next()) |first_line| {
|
while (records.next() catch |err| {
|
||||||
if (!std.mem.eql(u8, first_line, "#Ip2location:v2")) {
|
log.warn("stopped reading {s} after {d} entr(ies): {t}", .{ self.path, index, err });
|
||||||
log.warn("Cache file missing or invalid header, discarding", .{});
|
return;
|
||||||
file.close(self.io);
|
}) |fields| {
|
||||||
self.file = null;
|
index += 1;
|
||||||
std.Io.Dir.deleteFileAbsolute(self.io, self.path) catch |e| {
|
const record = fields.to(Record, .{}) catch continue;
|
||||||
log.err("error deleting {s}: {}", .{ self.path, e });
|
const parsed = packIp(record.ip) orelse continue;
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return; // Empty file
|
|
||||||
}
|
|
||||||
|
|
||||||
while (lines.next()) |line| {
|
const name_copy = try self.allocator.dupe(u8, record.name);
|
||||||
if (line.len == 0) continue;
|
errdefer self.allocator.free(name_copy);
|
||||||
const entry = parseCacheLine(self.allocator, line) catch continue;
|
|
||||||
try self.entries.put(entry.ip, entry.location);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CacheEntry = struct {
|
// Replacing an existing key would leak the name it already owns.
|
||||||
ip: u128,
|
const existing = try self.entries.fetchPut(parsed.key, .{
|
||||||
location: Location,
|
.allocator = self.allocator,
|
||||||
};
|
|
||||||
|
|
||||||
fn parseCacheLine(allocator: Allocator, line: []const u8) !CacheEntry {
|
|
||||||
// Parse: ip,lat,lon,name
|
|
||||||
var parts = std.mem.splitScalar(u8, line, ',');
|
|
||||||
const ip_str = parts.next() orelse return error.InvalidFormat;
|
|
||||||
const lat_str = parts.next() orelse return error.InvalidFormat;
|
|
||||||
const lon_str = parts.next() orelse return error.InvalidFormat;
|
|
||||||
const name = parts.rest();
|
|
||||||
|
|
||||||
const lat = try std.fmt.parseFloat(f64, lat_str);
|
|
||||||
const lon = try std.fmt.parseFloat(f64, lon_str);
|
|
||||||
|
|
||||||
// Try parsing as IP address first, fall back to u128
|
|
||||||
const ip_u128 = if (packIp(ip_str)) |parsed|
|
|
||||||
parsed.key
|
|
||||||
else
|
|
||||||
try std.fmt.parseInt(u128, ip_str, 10);
|
|
||||||
|
|
||||||
const name_copy = try allocator.dupe(u8, name);
|
|
||||||
return .{
|
|
||||||
.ip = ip_u128,
|
|
||||||
.location = .{
|
|
||||||
.allocator = allocator,
|
|
||||||
.name = name_copy,
|
.name = name_copy,
|
||||||
.coords = .{ .latitude = lat, .longitude = lon },
|
.coords = .{ .latitude = record.lat, .longitude = record.lon },
|
||||||
},
|
});
|
||||||
};
|
if (existing) |old| self.allocator.free(old.value.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(self: *Cache, ip: u128) ?Location {
|
pub fn get(self: *Cache, ip: u128) ?Location {
|
||||||
|
|
@ -304,101 +308,141 @@ pub const Cache = struct {
|
||||||
|
|
||||||
pub fn put(self: *Cache, ip: u128, family: u8, loc: Location) !void {
|
pub fn put(self: *Cache, ip: u128, family: u8, loc: Location) !void {
|
||||||
const name_copy = try self.allocator.dupe(u8, loc.name);
|
const name_copy = try self.allocator.dupe(u8, loc.name);
|
||||||
try self.entries.put(ip, .{
|
errdefer self.allocator.free(name_copy);
|
||||||
|
|
||||||
|
const existing = try self.entries.fetchPut(ip, .{
|
||||||
.allocator = self.allocator,
|
.allocator = self.allocator,
|
||||||
.name = name_copy,
|
.name = name_copy,
|
||||||
.coords = loc.coords,
|
.coords = loc.coords,
|
||||||
});
|
});
|
||||||
|
if (existing) |old| self.allocator.free(old.value.name);
|
||||||
|
|
||||||
// Append to file: ip,lat,lon,name
|
const file = self.file orelse return;
|
||||||
if (self.file) |file| {
|
|
||||||
// 0.16 has no seek+write; append at the current end offset.
|
var ip_buf: [64]u8 = undefined;
|
||||||
const end = try file.length(self.io);
|
const ip_str = try formatKey(ip, family, &ip_buf);
|
||||||
// Format IP as string for file
|
|
||||||
var buf: [64]u8 = undefined;
|
// Append one record with no front matter; the directives were written
|
||||||
const ip_str = if (family == 4)
|
// when the file was created.
|
||||||
try std.fmt.bufPrint(&buf, "{}.{}.{}.{}", .{
|
var line_buf: [512]u8 = undefined;
|
||||||
@as(u8, @truncate(ip >> 24)),
|
const line = try std.fmt.bufPrint(&line_buf, "{f}", .{srf.fmt(Record, &.{.{
|
||||||
@as(u8, @truncate(ip >> 16)),
|
.ip = ip_str,
|
||||||
@as(u8, @truncate(ip >> 8)),
|
.lat = loc.coords.latitude,
|
||||||
@as(u8, @truncate(ip)),
|
.lon = loc.coords.longitude,
|
||||||
})
|
.name = loc.name,
|
||||||
else
|
}}, .{ .emit_directives = false })});
|
||||||
try std.fmt.bufPrint(&buf, "{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}:{x:0>4}", .{
|
|
||||||
@as(u16, @truncate(ip >> 112)),
|
// 0.16 has no seek+write; append at the current end offset.
|
||||||
@as(u16, @truncate(ip >> 96)),
|
const end = try file.length(self.io);
|
||||||
@as(u16, @truncate(ip >> 80)),
|
try file.writePositionalAll(self.io, line, end);
|
||||||
@as(u16, @truncate(ip >> 64)),
|
}
|
||||||
@as(u16, @truncate(ip >> 48)),
|
|
||||||
@as(u16, @truncate(ip >> 32)),
|
/// Renders a packed key back to text for storage.
|
||||||
@as(u16, @truncate(ip >> 16)),
|
fn formatKey(ip: u128, family: u8, buf: []u8) ![]const u8 {
|
||||||
@as(u16, @truncate(ip)),
|
if (family == 4) {
|
||||||
});
|
return std.fmt.bufPrint(buf, "{d}.{d}.{d}.{d}", .{
|
||||||
const line = try std.fmt.allocPrint(self.allocator, "{s},{d},{d},{s}\n", .{
|
@as(u8, @truncate(ip >> 24)),
|
||||||
ip_str,
|
@as(u8, @truncate(ip >> 16)),
|
||||||
loc.coords.latitude,
|
@as(u8, @truncate(ip >> 8)),
|
||||||
loc.coords.longitude,
|
@as(u8, @truncate(ip)),
|
||||||
loc.name,
|
|
||||||
});
|
});
|
||||||
defer self.allocator.free(line);
|
|
||||||
try file.writePositionalAll(self.io, line, end);
|
|
||||||
}
|
}
|
||||||
|
var bytes: [16]u8 = undefined;
|
||||||
|
std.mem.writeInt(u128, &bytes, ip, .big);
|
||||||
|
// `Ip6Address.format` would append ":port" and bracket the address;
|
||||||
|
// `Unresolved` is the bare-address formatter.
|
||||||
|
const bare: std.Io.net.Ip6Address.Unresolved = .{ .bytes = bytes, .interface_name = null };
|
||||||
|
return std.fmt.bufPrint(buf, "{f}", .{&bare});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
test "parseCacheLine: valid IPv4 line" {
|
test "Cache: round-trips IPv4 and IPv6 entries through the file" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const line = "192.168.1.1,37.5,-122.5,San Francisco, California, United States";
|
const io = std.testing.io;
|
||||||
const entry = try Cache.parseCacheLine(allocator, line);
|
|
||||||
defer allocator.free(entry.location.name);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(u128, 3232235777), entry.ip); // 192.168.1.1 as u128
|
var tmp = std.testing.tmpDir(.{});
|
||||||
try std.testing.expectEqual(@as(f64, 37.5), entry.location.coords.latitude);
|
defer tmp.cleanup();
|
||||||
try std.testing.expectEqual(@as(f64, -122.5), entry.location.coords.longitude);
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
try std.testing.expectEqualStrings("San Francisco, California, United States", entry.location.name);
|
const dir_len = try tmp.dir.realPath(io, &path_buf);
|
||||||
|
const path = try std.fmt.allocPrint(allocator, "{s}/ip.cache", .{path_buf[0..dir_len]});
|
||||||
|
defer allocator.free(path);
|
||||||
|
|
||||||
|
const v4 = packIp("192.168.1.1").?;
|
||||||
|
const v6 = packIp("2001:db8::1").?;
|
||||||
|
|
||||||
|
{
|
||||||
|
var cache = try Cache.init(allocator, io, path);
|
||||||
|
defer cache.deinit();
|
||||||
|
|
||||||
|
// A name with commas is the interesting case: compact SRF delimits on
|
||||||
|
// commas, so this only survives via length prefixing.
|
||||||
|
try cache.put(v4.key, v4.family, .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.name = "San Francisco, California, United States",
|
||||||
|
.coords = .{ .latitude = 37.5, .longitude = -122.5 },
|
||||||
|
});
|
||||||
|
try cache.put(v6.key, v6.family, .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.name = "London, United Kingdom",
|
||||||
|
.coords = .{ .latitude = 51.5, .longitude = -0.1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var reopened = try Cache.init(allocator, io, path);
|
||||||
|
defer reopened.deinit();
|
||||||
|
|
||||||
|
const got4 = reopened.get(v4.key) orelse return error.TestUnexpectedResult;
|
||||||
|
defer got4.deinit();
|
||||||
|
try std.testing.expectEqualStrings("San Francisco, California, United States", got4.name);
|
||||||
|
try std.testing.expectEqual(@as(f64, 37.5), got4.coords.latitude);
|
||||||
|
try std.testing.expectEqual(@as(f64, -122.5), got4.coords.longitude);
|
||||||
|
|
||||||
|
const got6 = reopened.get(v6.key) orelse return error.TestUnexpectedResult;
|
||||||
|
defer got6.deinit();
|
||||||
|
try std.testing.expectEqualStrings("London, United Kingdom", got6.name);
|
||||||
|
try std.testing.expectEqual(@as(f64, 51.5), got6.coords.latitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseCacheLine: valid IPv6 line" {
|
test "Cache: a legacy or corrupt file starts empty instead of failing" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const line = "2001:db8::1,51.5,-0.1,London, United Kingdom";
|
const io = std.testing.io;
|
||||||
const entry = try Cache.parseCacheLine(allocator, line);
|
|
||||||
defer allocator.free(entry.location.name);
|
|
||||||
|
|
||||||
try std.testing.expect(entry.ip > 0);
|
var tmp = std.testing.tmpDir(.{});
|
||||||
try std.testing.expectEqual(@as(f64, 51.5), entry.location.coords.latitude);
|
defer tmp.cleanup();
|
||||||
try std.testing.expectEqual(@as(f64, -0.1), entry.location.coords.longitude);
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
try std.testing.expectEqualStrings("London, United Kingdom", entry.location.name);
|
const dir_len = try tmp.dir.realPath(io, &path_buf);
|
||||||
|
const path = try std.fmt.allocPrint(allocator, "{s}/ip.cache", .{path_buf[0..dir_len]});
|
||||||
|
defer allocator.free(path);
|
||||||
|
|
||||||
|
// The previous hand-rolled format. Deliberately not migrated: the entries are
|
||||||
|
// re-fetchable and losing them costs a handful of API calls.
|
||||||
|
try std.Io.Dir.cwd().writeFile(io, .{
|
||||||
|
.sub_path = path,
|
||||||
|
.data = "#Ip2location:v2\n192.168.1.1,37.5,-122.5,San Francisco\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
var cache = try Cache.init(allocator, io, path);
|
||||||
|
defer cache.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), cache.entries.count());
|
||||||
|
|
||||||
|
// Still usable afterwards, which is the point of not treating it as fatal.
|
||||||
|
const v4 = packIp("10.0.0.1").?;
|
||||||
|
try cache.put(v4.key, v4.family, .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.name = "Somewhere",
|
||||||
|
.coords = .{ .latitude = 1, .longitude = 2 },
|
||||||
|
});
|
||||||
|
const got = cache.get(v4.key) orelse return error.TestUnexpectedResult;
|
||||||
|
defer got.deinit();
|
||||||
|
try std.testing.expectEqualStrings("Somewhere", got.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseCacheLine: empty name" {
|
test "Cache: formatKey renders both families without a port" {
|
||||||
const allocator = std.testing.allocator;
|
var buf: [64]u8 = undefined;
|
||||||
const line = "10.0.0.1,0.0,0.0,";
|
const v4 = packIp("12.94.132.170").?;
|
||||||
const entry = try Cache.parseCacheLine(allocator, line);
|
try std.testing.expectEqualStrings("12.94.132.170", try Cache.formatKey(v4.key, v4.family, &buf));
|
||||||
defer allocator.free(entry.location.name);
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("", entry.location.name);
|
const v6 = packIp("2001:db8::1").?;
|
||||||
}
|
try std.testing.expectEqualStrings("2001:db8::1", try Cache.formatKey(v6.key, v6.family, &buf));
|
||||||
|
|
||||||
test "parseCacheLine: missing fields" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const line = "192.168.1.1,37.5";
|
|
||||||
try std.testing.expectError(error.InvalidFormat, Cache.parseCacheLine(allocator, line));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseCacheLine: invalid IP" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const line = "not.an.ip,37.5,-122.5,Test";
|
|
||||||
try std.testing.expectError(error.InvalidCharacter, Cache.parseCacheLine(allocator, line));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseCacheLine: invalid latitude" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const line = "192.168.1.1,invalid,-122.5,Test";
|
|
||||||
try std.testing.expectError(error.InvalidCharacter, Cache.parseCacheLine(allocator, line));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseCacheLine: invalid longitude" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const line = "192.168.1.1,37.5,invalid,Test";
|
|
||||||
try std.testing.expectError(error.InvalidCharacter, Cache.parseCacheLine(allocator, line));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue