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:"` 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), } }