zfin/src/net/http.zig

638 lines
29 KiB
Zig

const std = @import("std");
const builtin = @import("builtin");
const log = std.log.scoped(.http);
/// Public error set for the HTTP client wrapper. Merges the stdlib
/// transport error sets directly so that callers see meaningful Zig
/// error names (`NoAddressReturned`, `ConnectionRefused`,
/// `EndOfStream`, `TlsInitializationFailed`, ...) at the boundary
/// instead of a single opaque `RequestFailed`.
///
/// Why this shape, not `anyerror` and not a hand-rolled flat set:
/// - Callers get exhaustive-switch checking from the compiler when
/// they need to discriminate between, say, DNS failures and
/// connection failures.
/// - The error names propagate through `@errorName(err)` to log
/// lines without per-stage translation tables. An operator
/// reading "DnsLookupFailed" or "ConnectFailed" learns less
/// than one reading "NoAddressReturned" - the stdlib variant
/// is the truth, ours would be a lossy summary.
/// - The set is the same shape as stdlib's own
/// `http.Client.ConnectError = ConnectTcpError || RequestError`
/// pattern. Zig dedupes shared variants automatically.
///
/// HTTP-status classifications (`RateLimited`, `NotFound`, etc.) live
/// alongside the transport variants because callers consume both via
/// the same `try` sites and the same error switches.
pub const HttpError = std.Uri.ParseError ||
std.http.Client.RequestError ||
std.http.Client.Request.ReceiveHeadError ||
std.Io.Writer.Error ||
std.Io.Reader.Error ||
std.mem.Allocator.Error ||
error{
/// Last-resort transport failure. Used only when we cannot
/// recover the underlying stdlib error (e.g., across the
/// retry boundary in `request()` after multiple distinct
/// failures collapsed into a single retry exhaustion).
/// Adding new uses of this variant is a smell - prefer
/// preserving the real error.
RequestFailed,
// ── HTTP status classifications (no stdlib equivalent) ──
RateLimited,
Unauthorized,
NotFound,
/// HTTP 402 Payment Required - used by FMP to mark symbols (mainly ETFs,
/// mutual funds, CUSIPs, and some dual-class shares) that aren't covered
/// by the caller's current plan. Providers should translate this into
/// "no data" rather than a hard failure.
PaymentRequired,
/// HTTP 409 Conflict - the request cannot run because an equivalent one
/// is already in flight server-side. Distinct from `ServerError` because
/// it must NOT be retried by the transport: the correct response is to
/// wait for the in-flight operation, and a retry would only be refused
/// again. `zfin-server`'s `POST /refresh` returns this while a refresh is
/// running, and collapsing it into `InvalidResponse` reported a
/// wait-and-retry condition as a malformed request.
Conflict,
ServerError,
InvalidResponse,
};
pub const Response = struct {
status: std.http.Status,
body: []const u8,
/// Raw `ETag` header value from the server, if present. Owned by the
/// same allocator as `body`. Captured verbatim (including quotes and
/// any `sha256:` scheme prefix) so diagnostic archival can record
/// it as-is.
etag: ?[]const u8,
allocator: std.mem.Allocator,
pub fn deinit(self: *Response) void {
self.allocator.free(self.body);
if (self.etag) |e| self.allocator.free(e);
}
/// Integrity check outcome.
pub const IntegrityResult = union(enum) {
/// No ETag present, or the ETag wasn't a recognized sha256
/// shape. Verification is skipped - the caller should treat
/// this the same as a successful verification.
not_applicable,
/// Server's advertised sha256 matches the body's actual
/// sha256. The body is byte-exact with what the server sent.
ok,
/// Mismatch between server's advertised sha256 and the body's
/// actual sha256. Indicates truncation or corruption in
/// transit. `expected_hex` and `actual_hex` are each 64 chars
/// of lowercase hex; they reference internal buffers of the
/// result and are valid for the lifetime of this struct.
mismatch: struct {
expected_hex: [64]u8,
actual_hex: [64]u8,
},
};
/// Verify the body's sha256 against the server's `ETag` header.
///
/// Recognizes `ETag: "sha256:<64-hex>"` (quoted or unquoted, prefix
/// is case-insensitive). Other ETag shapes - weak etags, md5, etc.
/// - return `.not_applicable` so deployments with non-sha256 etags
/// don't get their requests rejected.
pub fn verifyIntegrity(self: *const Response) IntegrityResult {
const etag = self.etag orelse return .not_applicable;
const expected_hex = parseSha256Etag(etag) orelse return .not_applicable;
var actual: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(self.body, &actual, .{});
var actual_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
// SAFETY: actual_hex is exactly digest_length*2 bytes, which is
// exactly what "{x}" writes for a digest_length-byte input.
_ = std.fmt.bufPrint(&actual_hex, "{x}", .{&actual}) catch &actual_hex;
if (std.ascii.eqlIgnoreCase(&actual_hex, expected_hex)) return .ok;
var result: IntegrityResult = .{
.mismatch = .{
// SAFETY: both fields are populated by the loop and @memcpy below.
.expected_hex = undefined,
// SAFETY: see above.
.actual_hex = undefined,
},
};
// expected_hex may be uppercase depending on server - copy as
// lowercase for stable comparison downstream.
for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c);
@memcpy(&result.mismatch.actual_hex, &actual_hex);
return result;
}
};
/// Extract the hex portion of a `"sha256:<hex>"` ETag. Accepts both
/// quoted and unquoted forms (both are commonly written in the wild),
/// and the `sha256:` prefix is case-insensitive. Returns null for any
/// other shape - callers should then skip the integrity check rather
/// than failing the request.
fn parseSha256Etag(etag: []const u8) ?[]const u8 {
var v = etag;
if (v.len >= 2 and v[0] == '"' and v[v.len - 1] == '"') v = v[1 .. v.len - 1];
const prefix = "sha256:";
if (v.len <= prefix.len) return null;
if (!std.ascii.eqlIgnoreCase(v[0..prefix.len], prefix)) return null;
const hex = v[prefix.len..];
if (hex.len != std.crypto.hash.sha2.Sha256.digest_length * 2) return null;
for (hex) |c| if (!std.ascii.isHex(c)) return null;
return hex;
}
/// Thin HTTP client wrapper with retry and error classification.
pub const Client = struct {
io: std.Io,
allocator: std.mem.Allocator,
http_client: std.http.Client,
max_retries: u8 = 3,
base_backoff_ms: u64 = 500,
pub fn init(io: std.Io, allocator: std.mem.Allocator) Client {
return .{
.io = io,
.allocator = allocator,
.http_client = std.http.Client{ .allocator = allocator, .io = io },
};
}
pub fn deinit(self: *Client) void {
self.http_client.deinit();
}
/// Perform a GET request with automatic retries on transient errors.
pub fn get(self: *Client, url: []const u8) HttpError!Response {
return self.request(.GET, url, null, &.{});
}
/// Perform a POST request with automatic retries on transient errors.
pub fn post(self: *Client, url: []const u8, body: []const u8, extra_headers: []const std.http.Header) HttpError!Response {
return self.request(.POST, url, body, extra_headers);
}
pub fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
// Preserves the last underlying error across retry attempts.
// Without this, the retry loop used to collapse every attempt's
// distinct failure into a single `HttpError.RequestFailed`,
// which is exactly the kind of opaque error that hides root
// causes. A DNS-truncation bug surfaced for hours as bare
// "RequestFailed" because the per-attempt error was discarded
// by `catch {}`. Now the caller's `@errorName(err)` reports
// the real cause (e.g., `NoAddressReturned`).
var attempt: u8 = 0;
var last_err: HttpError = HttpError.RequestFailed;
while (true) : (attempt += 1) {
const response = self.doRequest(method, url, body, extra_headers) catch |err| {
last_err = err;
if (attempt >= self.max_retries) return last_err;
self.backoffSleep(attempt);
continue;
};
return classifyResponse(response) catch |err| {
if (err == HttpError.ServerError and attempt < self.max_retries) {
last_err = err;
self.backoffSleep(attempt);
continue;
}
return err;
};
}
}
fn backoffSleep(self: *Client, attempt: u8) void {
const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt);
std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(@intCast(backoff)), .awake) catch |err| std.log.debug("backoff sleep interrupted: {t}", .{err});
}
fn doRequest(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
// Per-stage timing for diagnosing where transport-level stalls
// occur. Zig 0.16's `std.http.Client` exposes no per-request
// receive timeout (only `connectTcpOptions.timeout`), so a
// request can hang forever in `receiveHead` or `streamRemaining`
// with no upstream visibility. The per-stage logging lets the
// operator pinpoint exactly which network operation wedged
// once they Ctrl-C, even though no `catch` will fire on a
// truly infinite stall.
//
// Each stage that may fail logs the underlying stdlib error
// name verbatim before propagating it through `HttpError`.
// The error returned to the caller IS the underlying stdlib
// error (`NoAddressReturned`, `ConnectionRefused`,
// `EndOfStream`, `TlsInitializationFailed`, ...) - `HttpError`
// is a merged superset of the relevant stdlib error sets, so
// `try` here propagates the original error verbatim. Earlier
// versions of this function caught every error and rethrew as
// a single `HttpError.RequestFailed`, which collapsed six
// distinct failure modes into one and made operator
// diagnosis impossible (this cost hours of debugging at
// least once when an intermittent DNS-truncation bug
// surfaced as bare "RequestFailed").
//
// wall-clock required: per-stage transport timing. Uses
// `.awake` (monotonic) so a system clock jump mid-request
// doesn't produce nonsense elapsed values.
const t_start = std.Io.Timestamp.now(self.io, .awake).nanoseconds;
var t_stage = t_start;
const stageElapsedMs = struct {
fn f(prev: *i96, io: std.Io) i64 {
const now = std.Io.Timestamp.now(io, .awake).nanoseconds;
const delta_ns = now - prev.*;
prev.* = now;
return @intCast(@divTrunc(delta_ns, std.time.ns_per_ms));
}
}.f;
const uri = std.Uri.parse(url) catch |err| {
log.warn("http {s}: stage=uri_parse err={s} url={s}", .{ @tagName(method), @errorName(err), url });
return err;
};
const ms_uri_parse = stageElapsedMs(&t_stage, self.io);
// If the caller supplied a `User-Agent` in extra_headers,
// route it to `headers.user_agent.override` so it REPLACES
// Zig's default "zig/0.x.y (std.http)" UA rather than
// sitting alongside it. Some servers (notably SEC EDGAR)
// reject requests where a default-library UA is present
// even when a descriptive UA is also provided. Same logic
// applies to other "default-then-override" stdlib headers
// (Host, Accept-Encoding, Connection, Content-Type) but
// User-Agent is the only one the EDGAR/Wikidata politeness
// contract cares about today.
var std_headers: std.http.Client.Request.Headers = .{};
var filtered: std.ArrayList(std.http.Header) = .empty;
defer filtered.deinit(self.allocator);
for (extra_headers) |h| {
if (std.ascii.eqlIgnoreCase(h.name, "user-agent"))
std_headers.user_agent = .{ .override = h.value }
else
filtered.append(self.allocator, h) catch return error.OutOfMemory;
}
var req = self.http_client.request(method, uri, .{
.redirect_behavior = @enumFromInt(3),
.headers = std_headers,
.extra_headers = filtered.items,
}) catch |err| {
// The connect stage covers DNS lookup, TCP connect, and
// TLS handshake. Logging at warn level (rather than debug)
// because DNS / connectivity failures are exactly what
// operators need to see immediately.
log.warn("http {s}: stage=connect err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
return err;
};
defer req.deinit();
const ms_connect = stageElapsedMs(&t_stage, self.io);
if (body) |payload| {
var send_buf: [4096]u8 = undefined;
req.transfer_encoding = .{ .content_length = payload.len };
var bw = req.sendBodyUnflushed(&send_buf) catch |err| {
log.warn("http {s}: stage=send_body_open err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
return err;
};
bw.writer.writeAll(payload) catch |err| {
log.warn("http {s}: stage=send_body_write err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
return err;
};
bw.end() catch |err| {
log.warn("http {s}: stage=send_body_end err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
return err;
};
req.connection.?.flush() catch |err| {
log.warn("http {s}: stage=send_body_flush err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
return err;
};
} else {
req.sendBodiless() catch |err| {
log.warn("http {s}: stage=send_bodiless err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
return err;
};
}
const ms_send = stageElapsedMs(&t_stage, self.io);
// Matches the default redirect capacity in std.http.Client.fetch.
var redirect_buffer: [8 * 1024]u8 = undefined;
var response = req.receiveHead(&redirect_buffer) catch |err| {
log.warn("http {s}: stage=receive_head err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
return err;
};
const ms_receive_head = stageElapsedMs(&t_stage, self.io);
// Capture the ETag (if any) from the response head BEFORE
// draining the body. `Response.reader()` invalidates
// `head.bytes`, and `iterateHeaders` reads from that slice, so
// anything we want must be duplicated now.
const etag_owned: ?[]const u8 = blk: {
var it = response.head.iterateHeaders();
while (it.next()) |h| {
if (std.ascii.eqlIgnoreCase(h.name, "etag")) {
const dup = try self.allocator.dupe(u8, h.value);
break :blk dup;
}
}
break :blk null;
};
errdefer if (etag_owned) |e| self.allocator.free(e);
// Drain the body. `readerDecompressing` is adaptive: for
// identity-encoded responses (the zfin server's default) it
// hands back the transfer reader unchanged - zero-cost. For
// gzip/deflate/zstd it wraps the transfer reader with the
// appropriate decompressor. The decompress buffer is only
// touched on the compressed paths; sized at 64 KiB as a
// reasonable default for the unlikely case a provider endpoint
// starts sending compressed SRF/JSON.
var aw: std.Io.Writer.Allocating = .init(self.allocator);
errdefer aw.deinit();
var transfer_buffer: [4096]u8 = undefined;
// SAFETY: `decompress` is initialized by `readerDecompressing`
// before any read. Same pattern as `transfer_buffer`/`decompress_buffer`.
var decompress: std.http.Decompress = undefined;
var decompress_buffer: [64 * 1024]u8 = undefined;
const reader = response.readerDecompressing(&transfer_buffer, &decompress, &decompress_buffer);
_ = reader.streamRemaining(&aw.writer) catch |err| {
log.warn("http {s}: stage=stream_body err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
return err;
};
const ms_body = stageElapsedMs(&t_stage, self.io);
const resp_body = try aw.toOwnedSlice();
const total_ms = @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms);
log.debug(
"http {s}: ok status={d} bytes={d} total_ms={d} (uri_parse={d} connect={d} send={d} receive_head={d} body={d}) url={s}",
.{
@tagName(method),
@intFromEnum(response.head.status),
resp_body.len,
total_ms,
ms_uri_parse,
ms_connect,
ms_send,
ms_receive_head,
ms_body,
url,
},
);
return .{
.status = response.head.status,
.body = resp_body,
.etag = etag_owned,
.allocator = self.allocator,
};
}
fn classifyResponse(response: Response) HttpError!Response {
switch (response.status) {
.ok => return response,
else => {
// Surface the rejection body - many providers
// ship actionable diagnostic text in non-2xx
// bodies (Akamai/SEC's "Request Rate Threshold
// Exceeded" page, Polygon's "free tier exceeded
// 5 calls/min" hints, Wikidata's SPARQL syntax
// errors, etc.). Without this, the caller only
// sees the mapped HttpError variant
// (`Unauthorized`, `RateLimited`, ...) and has no
// path back to the upstream's reason.
//
// 404s are noisy in normal operation: callers
// routinely encounter "no data" 404s for symbols
// EDGAR doesn't track, money-market funds with no
// NPORT-P, ETFs with no shares-outstanding XBRL,
// etc. The body of a 404 is usually a generic
// "NoSuchKey" / "Not Found" XML/HTML page that
// tells the operator nothing actionable. Demote
// those to debug so the warn-level log stream
// stays focused on cases the operator can act on
// (auth, rate, server outages).
//
// Skipped entirely under `zig build test`: the
// error-classification tests intentionally drive
// non-2xx statuses through here, and their warn
// output would otherwise pollute the test stream.
if (!builtin.is_test) {
if (response.status == .not_found) {
log.debug("http rejection body status=404 body={s}", .{response.body});
} else {
log.warn("http rejection body status={d} body={s}", .{ @intFromEnum(response.status), response.body });
}
}
response.allocator.free(response.body);
if (response.etag) |e| response.allocator.free(e);
return switch (response.status) {
.too_many_requests => HttpError.RateLimited,
.unauthorized, .forbidden => HttpError.Unauthorized,
.payment_required => HttpError.PaymentRequired,
.not_found => HttpError.NotFound,
.conflict => HttpError.Conflict,
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
else => HttpError.InvalidResponse,
};
},
}
}
};
/// Build a URL with query parameters. Values are percent-encoded per RFC 3986.
pub fn buildUrl(
allocator: std.mem.Allocator,
base: []const u8,
params: []const [2][]const u8,
) ![]const u8 {
var aw: std.Io.Writer.Allocating = .init(allocator);
errdefer aw.deinit();
try aw.writer.writeAll(base);
for (params, 0..) |param, i| {
try aw.writer.writeByte(if (i == 0) '?' else '&');
try aw.writer.writeAll(param[0]);
try aw.writer.writeByte('=');
try std.Uri.Component.percentEncode(&aw.writer, param[1], isQueryValueChar);
}
return aw.toOwnedSlice();
}
/// RFC 3986 query-safe characters, excluding '&' and '=' which delimit
/// key=value pairs within the query string.
fn isQueryValueChar(c: u8) bool {
return switch (c) {
// Unreserved characters (RFC 3986 section 2.3)
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
// Sub-delimiters safe in query values (excludes '&' and '=')
'!', '$', '\'', '(', ')', '*', '+', ',', ';' => true,
// Additional query/path characters
':', '@', '/', '?' => true,
else => false,
};
}
test "buildUrl" {
const allocator = std.testing.allocator;
const url = try buildUrl(allocator, "https://api.example.com/v1/data", &.{
.{ "symbol", "AAPL" },
.{ "apikey", "test123" },
});
defer allocator.free(url);
try std.testing.expectEqualStrings("https://api.example.com/v1/data?symbol=AAPL&apikey=test123", url);
}
test "buildUrl percent-encodes reserved characters in values" {
const allocator = std.testing.allocator;
// Value contains a space, '&', '=' (all must be encoded) and '/'
// (allowed in query values, so left as-is).
const url = try buildUrl(allocator, "https://api.example.com/q", &.{
.{ "name", "a b&c=d/e" },
});
defer allocator.free(url);
try std.testing.expect(std.mem.indexOf(u8, url, "%20") != null); // space
try std.testing.expect(std.mem.indexOf(u8, url, "%26") != null); // &
try std.testing.expect(std.mem.indexOf(u8, url, "%3D") != null or std.mem.indexOf(u8, url, "%3d") != null); // =
try std.testing.expect(std.mem.indexOf(u8, url, "d/e") != null); // '/' preserved
}
test "classifyResponse maps each HTTP status to its HttpError" {
const allocator = std.testing.allocator;
const Case = struct { status: std.http.Status, expected: HttpError };
const cases = [_]Case{
.{ .status = .too_many_requests, .expected = HttpError.RateLimited },
.{ .status = .unauthorized, .expected = HttpError.Unauthorized },
.{ .status = .forbidden, .expected = HttpError.Unauthorized },
.{ .status = .payment_required, .expected = HttpError.PaymentRequired },
.{ .status = .not_found, .expected = HttpError.NotFound },
.{ .status = .conflict, .expected = HttpError.Conflict },
.{ .status = .internal_server_error, .expected = HttpError.ServerError },
.{ .status = .bad_gateway, .expected = HttpError.ServerError },
.{ .status = .service_unavailable, .expected = HttpError.ServerError },
.{ .status = .gateway_timeout, .expected = HttpError.ServerError },
.{ .status = .bad_request, .expected = HttpError.InvalidResponse },
};
for (cases) |c| {
// On the non-ok path classifyResponse frees body + etag itself,
// so the test must not free them again.
const resp = Response{
.status = c.status,
.body = try allocator.dupe(u8, "rejection body"),
.etag = try allocator.dupe(u8, "\"etag\""),
.allocator = allocator,
};
try std.testing.expectError(c.expected, Client.classifyResponse(resp));
}
}
test "classifyResponse passes 200 OK through unchanged" {
const allocator = std.testing.allocator;
const resp = Response{
.status = .ok,
.body = try allocator.dupe(u8, "payload"),
.etag = null,
.allocator = allocator,
};
// On the ok path the body is not freed - the caller still owns it.
var out = try Client.classifyResponse(resp);
defer out.deinit();
try std.testing.expectEqual(std.http.Status.ok, out.status);
try std.testing.expectEqualStrings("payload", out.body);
}
test "parseSha256Etag: quoted form" {
const hex = parseSha256Etag("\"sha256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\"") orelse unreachable;
try std.testing.expectEqualStrings("0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9", hex);
}
test "parseSha256Etag: unquoted form" {
const hex = parseSha256Etag("sha256:deadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafe1234") orelse unreachable;
try std.testing.expectEqualStrings("deadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafe1234", hex);
}
test "parseSha256Etag: case-insensitive prefix" {
const hex = parseSha256Etag("\"SHA256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\"") orelse unreachable;
try std.testing.expectEqualStrings("0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9", hex);
}
test "parseSha256Etag: wrong scheme returns null" {
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"md5:deadbeef\""));
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("W/\"weak-etag\""));
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag(""));
}
test "parseSha256Etag: wrong hex length returns null" {
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"sha256:deadbeef\""));
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"sha256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9aa\""));
}
test "parseSha256Etag: non-hex character returns null" {
try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"sha256:ZZ02d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\""));
}
test "Response.verifyIntegrity: no etag returns not_applicable" {
var body_buf = [_]u8{ 'h', 'i' };
var response = Response{
.status = .ok,
.body = &body_buf,
.etag = null,
.allocator = std.testing.allocator,
};
const result = response.verifyIntegrity();
try std.testing.expect(result == .not_applicable);
}
test "Response.verifyIntegrity: non-sha256 etag returns not_applicable" {
var body_buf = [_]u8{ 'h', 'i' };
var response = Response{
.status = .ok,
.body = &body_buf,
.etag = "W/\"weak-etag\"",
.allocator = std.testing.allocator,
};
const result = response.verifyIntegrity();
try std.testing.expect(result == .not_applicable);
}
test "Response.verifyIntegrity: matching sha256 returns ok" {
// sha256("hello world") = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
const body = "hello world";
var response = Response{
.status = .ok,
.body = body,
.etag = "\"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9\"",
.allocator = std.testing.allocator,
};
const result = response.verifyIntegrity();
try std.testing.expect(result == .ok);
}
test "Response.verifyIntegrity: mismatched sha256 returns mismatch" {
const body = "hello world";
// Intentionally wrong digest.
var response = Response{
.status = .ok,
.body = body,
.etag = "\"sha256:0000000000000000000000000000000000000000000000000000000000000000\"",
.allocator = std.testing.allocator,
};
const result = response.verifyIntegrity();
switch (result) {
.mismatch => |m| {
try std.testing.expectEqualStrings(
"0000000000000000000000000000000000000000000000000000000000000000",
&m.expected_hex,
);
try std.testing.expectEqualStrings(
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
&m.actual_hex,
);
},
else => try std.testing.expect(false),
}
}