Artifact c52c7ea554dc4471903b1777f149b3ff62df02ce48ace7a37eaad5e308d14ef3:
- File
src/transport.zig
— part of check-in
[5f541eccc7]
at
2026-09-18 11:31:24
on branch trunk
— transport: answer an HTTP Basic challenge
A forge that gates a read answers a clone's first request with 401. The login card cannot answer that round, since its secret needs the project code the first reply teaches, and the status was fatal, so the credential never reached the wire. When a verb holds a plaintext password the transport keeps a Basic credential ready, sends nothing until a 401 names the Basic scheme, then repeats the round with the Authorization header and carries it on every later round, as fossil's --httpauth does. A 401 now says what the peer wanted. Proven by an e2e with an authenticating gate in front of zit serve. (user: dmestas, size: 30310) [annotate] [blame] [check-ins using]
//! CLI-layer HTTP transport for the Fossil sync protocol (sync §4). //! //! This is the sole module in which `std.http` and real network I/O appear. //! `sync.zig` stays sans-IO: it composes request bytes (`Client.buildRequest`) //! and consumes reply bytes (`Client.consumeReply`) without ever holding a //! socket. This module carries those opaque byte strings over an HTTP POST — //! framing the request as the §4.1 Fossil zlib container, POSTing it as //! `application/x-fossil`, decoding the reply container, and honoring the §4 //! redirect and HTTPS-downgrade rules — so the deterministic client can be //! exercised over the wire without any IO leaking into its state machine. //! //! The §4 policy primitives (media-type table, redirect limit, `/xfer` strip, //! downgrade refusal, the zlib container codec) are pure functions already //! defined and clause-tested in `sync.zig`; this module is a thin, IO-bearing //! seam over them. Every value that crosses the wire is `[]const u8` — Fossil //! card streams legally embed NUL, so no sentinel-terminated type is used. const std = @import("std"); const sync = @import("zit").sync; const codec = @import("zit").container; const assert = std.debug.assert; const http = std.http; /// Closed error set at the transport boundary: the CLI can `switch` these /// exhaustively (no inferred `!`, no `anyerror`). Container-codec and /// redirect-policy causes are /// mapped from the pure `sync` error sets into transport-level names; the /// `Connection*`/`Protocol*` causes belong to the live POST seam. pub const TransportError = error{ /// The output buffer could not be grown. OutOfMemory, /// §4.1 — the request body exceeds the container's 4-byte length field. RequestTooLarge, /// §4.1 — a reply container shorter than its 4-byte length prefix. ReplyTruncated, /// §4.1 — the reply's zlib stream is malformed or its Adler-32 fails. ReplyMalformed, /// §4 r9 / §4.1 — the reply body or its declared length exceeds the /// 512 MiB cap (`sync.reply_body_cap`). ReplyTooLarge, /// §4 r10 — a twentieth redirect was reached for a single exchange. RedirectLimit, /// §4 r12 — a redirect would downgrade HTTPS to plaintext HTTP. InsecureRedirect, /// §4 r13 — a redirect appeared on an SSH or local-file transport. RedirectNotAllowed, /// A URL (initial target or redirect Location) did not parse. InvalidUrl, /// A URL named a scheme other than http/https. UnsupportedScheme, /// A 3xx redirect carried no Location header. MissingLocation, /// The server answered with a status that is neither 200 nor a redirect. ProtocolError, /// Any socket/TLS/HTTP-framing failure on the live exchange. ConnectionFailed, }; // --------------------------------------------------------------------------- // §4.1 — request framing / reply deframing (the Fossil zlib container) // --------------------------------------------------------------------------- /// §4.1 — wrap plaintext sync bytes in the Fossil zlib container (4-byte /// big-endian uncompressed length + an RFC1950 zlib stream) at the default /// compression level, ready to POST as `application/x-fossil`. A thin /// error-mapping wrapper over `codec.encode`. pub fn frame(gpa: std.mem.Allocator, plaintext: []const u8) TransportError![]u8 { const container = codec.encode(gpa, plaintext, .default) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.InputTooLarge => return error.RequestTooLarge, }; // §4.1 — every container opens with the 4-byte big-endian length prefix, so // a framed body is never shorter than that header. assert(container.len >= 4); return container; } /// §4.1 (as corrected by the §4.1 compression-length erratum) — decode a /// Fossil zlib container back to plaintext. The 4-byte length field is a /// capacity bound, not an exact length: a stream that decodes within that /// capacity is accepted and the *actual* decoded bytes are returned, which /// may be fewer than declared (the overdeclared-sender case the reference /// receiver tolerates). `sync.reply_body_cap` caps the declared length before /// any decompression, so a lying header cannot drive unbounded allocation. pub fn unframe(gpa: std.mem.Allocator, container: []const u8) TransportError![]u8 { const plaintext = codec.decode(gpa, container, sync.reply_body_cap) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.TruncatedHeader => return error.ReplyTruncated, error.InvalidStream => return error.ReplyMalformed, error.OutputTooLarge => return error.ReplyTooLarge, }; // §4 r9 / §4.1 — the decode is capacity-capped at `reply_body_cap`, so an // accepted reply's plaintext never exceeds the ceiling (`OutputTooLarge` // rejected an over-cap declared length before any decompression). assert(plaintext.len <= sync.reply_body_cap); return plaintext; } // --------------------------------------------------------------------------- // §4 r10/r12/r13 — redirect policy (pure; the tested core of B1) // --------------------------------------------------------------------------- /// The base URL and transport a redirect resolves to, after §4 processing. /// `base_url` is a subslice of the Location passed to `resolveRedirect` (the /// `/xfer`-stripped prefix); it borrows that memory and does not own it. pub const RedirectStep = struct { transport: sync.Transport, base_url: []const u8, }; /// §4 r10/r12/r13 (redirect erratum) — resolve one redirect hop. `from` is the /// transport of the request that received the 3xx; `location` is its Location /// header; `followed` is the number of redirects already consumed by this /// exchange. Order: enforce the redirect limit (r10), reject a redirect on a /// non-HTTP transport (r13), strip any trailing `/xfer` from the Location /// (redirect erratum — the origin URL's path is never consulted), reparse the /// scheme, then refuse an HTTPS->HTTP downgrade (r12). Pure and total over its /// inputs; the returned `base_url` aliases `location`. pub fn resolveRedirect( from: sync.Transport, location: []const u8, followed: usize, ) TransportError!RedirectStep { // §4 r10 — the twentieth redirect (followed == max_redirects) aborts. sync.redirectAllowed(followed) catch return error.RedirectLimit; // §4 r13 — SSH and local-file transports never follow a redirect. if (!sync.redirectAllowedForTransport(from)) return error.RedirectNotAllowed; // Redirect erratum — strip trailing "/xfer" (repeatably) to recover the // base repository URL; no other path transformation. const base = sync.stripRedirectXfer(location); assert(base.len <= location.len); const to = try schemeTransport(base); // §4 r12 — a redirect MUST NOT drop TLS. sync.checkRedirectDowngrade(from, to) catch return error.InsecureRedirect; // Negative space: the resolved transport is always one of the two that // carry redirects; SSH/file were rejected above and no other scheme // survives `schemeTransport`. assert(to == .http or to == .https); return .{ .transport = to, .base_url = base }; } /// Map a URL's scheme to its `sync.Transport`. Only http/https are carried by /// this module; every other scheme (or an unparseable URL) is rejected. fn schemeTransport(url: []const u8) TransportError!sync.Transport { const uri = std.Uri.parse(url) catch return error.InvalidUrl; if (std.ascii.eqlIgnoreCase(uri.scheme, "https")) return .https; if (std.ascii.eqlIgnoreCase(uri.scheme, "http")) return .http; return error.UnsupportedScheme; } /// §4 — the four redirect statuses an HTTP exchange follows. fn isRedirectStatus(status: http.Status) bool { return switch (status) { .moved_permanently, .found, .temporary_redirect, .permanent_redirect => true, else => false, }; } /// §4.1 — whether a reply body carries an outer zlib container. `application/ /// x-fossil` replies do; the `application/x-fossil-uncompressed` clone-reply /// media type does not. An absent or unrecognized Content-Type is treated as /// the compressed default, matching §4.1's "used for all requests and for /// replies to pull, push, and sync operations." fn replyOuterCompressed(content_type: ?[]const u8) bool { const ct = content_type orelse return true; const media = sync.parseMediaType(ct) orelse return true; return sync.media_props.get(media).outer_compressed; } // --------------------------------------------------------------------------- // The live HTTP seam (§4) — the only IO in the module. // --------------------------------------------------------------------------- /// A connection to one sync peer. Concrete: this is the only transport today, /// so it carries no vtable and no speculative abstraction. The open / /// exchange / close shape means a future `union(enum){ http, pipe, local }` /// for SSH and local-file transports is a mechanical wrap touching zero call /// sites: the round-loop driver (plan B2) calls only `exchange`. /// /// `exchange` is the load-bearing boundary — it takes the UNCOMPRESSED /// protocol request bytes `sync.Client.buildRequest` emits and returns the /// UNCOMPRESSED reply bytes `sync.Client.consumeReply` wants, hiding entirely /// inside: the §4.1 zlib container framing, the `application/x-fossil` POST, /// §4 redirect following (with `/xfer` stripping and HTTPS-downgrade refusal), /// the §4 r8/r9 size cap, and cookie carry. Callers never see compression or /// HTTP. pub const Transport = struct { /// Closed error set at the boundary (namespaced alias of `TransportError`). pub const Error = TransportError; client: http.Client, /// The current base URL, owned; a followed redirect rewrites it in place so /// the next round targets the resolved endpoint (§4 redirect semantics). base_url: []u8, /// http or https — the transport scheme of `base_url`, tracked so a /// redirect's HTTPS->HTTP downgrade can be refused (§4 r12). scheme: sync.Transport, /// §6.5 — the preformed `x-f-l-c` cookie-login value to attach as /// `Cookie: x-f-l-c=<value>`, or null. The §6.5 eligibility gate and the /// login-card encoding (which depend on the peer's recorded version and on /// the body the nonce covers) are the sync layer's responsibility; the /// round-loop driver sets this per round when the gate selects the cookie /// transport. The §3.6/§4.3 *state* cookie is a body card threaded by /// `sync.Client` itself, carried opaquely in the exchange bytes — transport /// takes no action for it. login_cookie: ?[]const u8 = null, login_cookie_buf: [sync.max_line_len]u8 = undefined, /// The status that produced the most recent `error.ProtocolError`, so the /// CLI can NAME it. Zig errors carry no payload and the closed error set is /// deliberate, so the code is left here for the caller to read after the /// error rather than widened into a variant per status. /// /// This exists because "the peer answered with an unexpected HTTP status" /// is nearly useless in a bug report: 500 (the server failed to build a /// reply), 413 (the request exceeded the body ceiling), 400 (malformed /// container) and 417 (a rejected Expect: 100-continue) are four different /// investigations, and the message named none of them. Only ever written /// immediately before returning `ProtocolError`; a stale value is /// unreadable because the caller only consults it on that error. last_status: ?http.Status = null, /// An HTTP Basic credential (`Basic BASE64`), owned, offered only after /// the peer challenges for it. A forge that gates a READ answers the /// first, login-less request of a clone with 401 and a Basic challenge; /// the login card cannot answer that round, because its secret needs /// the project code the first reply teaches. fossil answers with /// `--httpauth`; this is the same header, sent when asked. basic_auth: ?[]u8 = null, /// Set once a Basic challenge was answered: from then on every request /// carries the header, as fossil's does. basic_armed: bool = false, /// Set when the peer challenged for Basic at all, so a failing verb can /// say what the peer wanted instead of a bare status. basic_challenged: bool = false, /// Install the credential a Basic challenge is answered with: `user` and /// the plaintext password, base64 in the header form. Nothing is sent /// until a 401 asks for it. pub fn setBasicAuth(self: *Transport, user: []const u8, password: []const u8) error{OutOfMemory}!void { const a = self.client.allocator; if (self.basic_auth) |old| a.free(old); self.basic_auth = null; const raw = std.fmt.allocPrint(a, "{s}:{s}", .{ user, password }) catch return error.OutOfMemory; defer { std.crypto.secureZero(u8, raw); a.free(raw); } const enc = std.base64.standard.Encoder; const value = a.alloc(u8, "Basic ".len + enc.calcSize(raw.len)) catch return error.OutOfMemory; @memcpy(value[0.."Basic ".len], "Basic "); _ = enc.encode(value["Basic ".len..], raw); self.basic_auth = value; } /// Resolve `url` and prepare an HTTP client. The URL scheme MUST be http or /// https; SSH and local-file peers are a later transport variant. pub fn open(io: std.Io, gpa: std.mem.Allocator, url: []const u8) Error!Transport { const scheme = try schemeTransport(url); const base = gpa.dupe(u8, url) catch return error.OutOfMemory; return .{ .client = .{ .allocator = gpa, .io = io }, .base_url = base, .scheme = scheme, }; } /// Release the owned base URL and tear down the HTTP client. pub fn close(self: *Transport) void { self.client.allocator.free(self.base_url); if (self.basic_auth) |b| { std.crypto.secureZero(u8, b); self.client.allocator.free(b); } self.client.deinit(); } /// §4 — carry one sync round: frame `request` (§4.1), POST it as /// `application/x-fossil`, follow §4 redirects (`redirect_behavior = /// .unhandled` so `resolveRedirect` — not std.http's generic handling — /// governs `/xfer` stripping, the 19-redirect limit, and downgrade /// refusal), bound the reply by `sync.reply_body_cap` (§4 r8/r9), and /// deframe it. Returns the decoded plaintext reply (caller owns the slice). /// /// Exercised end-to-end against `zit serve` later (plan B4); the framing /// and redirect logic it composes are unit-tested directly below. /// §6.5 — install (or clear) the x-f-l-c value the next exchange /// sends. Copied, because the caller's buffer is rebuilt per round. pub fn setLoginCookie(self: *Transport, value: ?[]const u8) void { const v = value orelse { self.login_cookie = null; return; }; std.debug.assert(v.len <= self.login_cookie_buf.len); @memcpy(self.login_cookie_buf[0..v.len], v); self.login_cookie = self.login_cookie_buf[0..v.len]; } pub fn exchange(self: *Transport, gpa: std.mem.Allocator, request: []const u8) Error![]u8 { const a = self.client.allocator; const framed = try frame(gpa, request); defer gpa.free(framed); var cookie_value: ?[]u8 = null; defer if (cookie_value) |cv| gpa.free(cv); if (self.login_cookie) |lc| { cookie_value = std.fmt.allocPrint(gpa, "x-f-l-c={s}", .{lc}) catch return error.OutOfMemory; } // §4 r10 — at most max_redirects hops, then one terminal response. The // 20th redirect is rejected by `resolveRedirect`; this loop bound is // the structural backstop. var followed: usize = 0; while (followed <= sync.max_redirects) : (followed += 1) { const uri = std.Uri.parse(self.base_url) catch return error.InvalidUrl; var extra_buf: [2]http.Header = undefined; var n_extra: usize = 0; if (cookie_value) |cv| { extra_buf[n_extra] = .{ .name = "Cookie", .value = cv }; n_extra += 1; } if (self.basic_armed) { extra_buf[n_extra] = .{ .name = "Authorization", .value = self.basic_auth.? }; n_extra += 1; } var req = self.client.request(.POST, uri, .{ .redirect_behavior = .unhandled, .keep_alive = false, .headers = .{ .content_type = .{ .override = sync.media_props.get(.x_fossil).wire }, .accept_encoding = .omit, }, .extra_headers = extra_buf[0..n_extra], }) catch return error.ConnectionFailed; defer req.deinit(); req.transfer_encoding = .{ .content_length = framed.len }; var body = req.sendBodyUnflushed(&.{}) catch return error.ConnectionFailed; body.writer.writeAll(framed) catch return error.ConnectionFailed; body.end() catch return error.ConnectionFailed; req.connection.?.flush() catch return error.ConnectionFailed; var response = req.receiveHead(&.{}) catch return error.ConnectionFailed; const status = response.head.status; if (isRedirectStatus(status)) { const location = response.head.location orelse return error.MissingLocation; const step = try resolveRedirect(self.scheme, location, followed); const next = a.dupe(u8, step.base_url) catch return error.OutOfMemory; a.free(self.base_url); self.base_url = next; self.scheme = step.transport; // §4 r12/r13 — a followed redirect only ever lands on an // HTTP(S) transport; `resolveRedirect` rejected every other // scheme and any downgrade, so the next round targets a // carriable endpoint. assert(self.scheme == .http or self.scheme == .https); continue; } if (status == .unauthorized and basicChallenge(response.head)) { // The peer wants HTTP Basic. With a credential in hand and not // yet offered, offer it and send the same round again; every // later round carries it too. Without one, the failure says // what was asked for. self.basic_challenged = true; if (self.basic_auth != null and !self.basic_armed) { self.basic_armed = true; continue; } } if (status != .ok) { self.last_status = status; return error.ProtocolError; } // §4.1 — decide the reply's outer framing from the Content-Type // BEFORE initializing the body stream. `response.reader` reuses the // connection buffer that backs `head.content_type` (std.http: // "Pointers in this struct are invalidated when the response body // stream is initialized"), so the slice must be read first — an // `application/x-fossil-uncompressed` clone reply (§8.1) would // otherwise be misread as the compressed default and double-decoded. const outer_compressed = replyOuterCompressed(response.head.content_type); var transfer_buf: [4096]u8 = undefined; const reader = response.reader(&transfer_buf); const raw = reader.allocRemaining(gpa, .limited(sync.reply_body_cap)) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.StreamTooLong => return error.ReplyTooLarge, // §4 r9 error.ReadFailed => return error.ConnectionFailed, // socket/framing fault }; // §4 r9 — explicit cap citation; `.limited` already enforced it. sync.replyCapCheck(raw.len) catch { gpa.free(raw); return error.ReplyTooLarge; }; // §4 r9 — both returns below are within the ceiling: the raw clone // body passed `replyCapCheck` just above, and `unframe`'s decode is // itself capacity-capped at `reply_body_cap`. assert(raw.len <= sync.reply_body_cap); if (!outer_compressed) return raw; // §4.1 clone reply (x-fossil-uncompressed) defer gpa.free(raw); return try unframe(gpa, raw); } return error.RedirectLimit; // §4 r10 structural backstop } }; // =========================================================================== // Tests — expected values come from sync §4/§4.1 and the two §4 errata, never // from running fossil; the RFC text is the sole oracle. // =========================================================================== const testing = std.testing; /// True when a response head carries a `WWW-Authenticate` challenge for the /// Basic scheme (name and scheme both case-insensitive, per RFC 7235). pub fn basicChallenge(head: std.http.Client.Response.Head) bool { var it = head.iterateHeaders(); while (it.next()) |h| { if (!std.ascii.eqlIgnoreCase(h.name, "www-authenticate")) continue; const v = std.mem.trimStart(u8, h.value, " \t"); if (v.len >= 5 and std.ascii.eqlIgnoreCase(v[0..5], "basic")) return true; } return false; } test "transport: a 401 carrying a Basic challenge is recognized; Bearer and none are not" { const basic = "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"quarry\"\r\nContent-Length: 0\r\n\r\n"; const lower = "HTTP/1.1 401 Unauthorized\r\nwww-authenticate: basic realm=\"x\"\r\n\r\n"; const bearer = "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Bearer\r\n\r\n"; const none = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"; try std.testing.expect(basicChallenge(try std.http.Client.Response.Head.parse(basic))); try std.testing.expect(basicChallenge(try std.http.Client.Response.Head.parse(lower))); try std.testing.expect(!basicChallenge(try std.http.Client.Response.Head.parse(bearer))); try std.testing.expect(!basicChallenge(try std.http.Client.Response.Head.parse(none))); } test "transport: setBasicAuth builds the header value fossil's --httpauth sends" { const gpa = std.testing.allocator; var tr = try Transport.open(std.testing.io, gpa, "http://h.example/"); defer tr.close(); try tr.setBasicAuth("u", "p"); try std.testing.expectEqualStrings("Basic dTpw", tr.basic_auth.?); try tr.setBasicAuth("alice", "s3cret:with:colons"); try std.testing.expectEqualStrings("Basic YWxpY2U6czNjcmV0OndpdGg6Y29sb25z", tr.basic_auth.?); } test "transport §4.1: frame then unframe round-trips the plaintext" { const gpa = testing.allocator; const cases = [_][]const u8{ "", "hello", "pragma client-version 2 22700 20250727 110500\n", &[_]u8{ 0x00, 0x0a, 0x00, 0xff, 'x', 0x00 }, // NUL/LF-bearing wire data }; for (cases) |plaintext| { const container = try frame(gpa, plaintext); defer gpa.free(container); const decoded = try unframe(gpa, container); defer gpa.free(decoded); try testing.expectEqualSlices(u8, plaintext, decoded); } } test "transport §4.1: a container compressed at a different level still decodes (level-independent)" { // §4.1 L916-918 — "implementations MUST NOT rely on a particular // compression level ... the decompressor is level-independent." `frame` // encodes at the default level; a level-9 container must decode identically. const gpa = testing.allocator; const plaintext = "the quick brown fox jumps over the lazy dog, repeatedly, for entropy."; const level9 = try codec.encode(gpa, plaintext, .best); defer gpa.free(level9); const decoded = try unframe(gpa, level9); defer gpa.free(decoded); try testing.expectEqualSlices(u8, plaintext, decoded); } test "transport §4.1 errata: an overdeclared length field is tolerated (capacity, not exact length)" { // §4.1 compression-length erratum — the 4-byte field is an allocation // capacity, not the exact decoded length; a decode that finishes within // capacity is accepted even when the declared length exceeds it. Build a // conformant container, then overdeclare its length prefix. const gpa = testing.allocator; const plaintext = "capacity-bound decode"; const container = try frame(gpa, plaintext); defer gpa.free(container); std.mem.writeInt(u32, container[0..4], @intCast(plaintext.len + 500), .big); const decoded = try unframe(gpa, container); defer gpa.free(decoded); // The actual decoded bytes are returned — fewer than the declared field. try testing.expectEqualSlices(u8, plaintext, decoded); } test "transport §4.1: a truncated container header is rejected cleanly" { const gpa = testing.allocator; try testing.expectError(error.ReplyTruncated, unframe(gpa, &[_]u8{ 0x00, 0x00 })); } test "transport §4.1: a malformed zlib stream is rejected cleanly" { // §4.1 — "A receiver MUST reject the envelope when the zlib stream is // malformed." A valid 4-byte length prefix over a non-zlib body (all-ones // bytes: BFINAL=1, BTYPE=11 is the reserved/invalid DEFLATE block type). const gpa = testing.allocator; var envelope: [16]u8 = undefined; std.mem.writeInt(u32, envelope[0..4], 8, .big); @memset(envelope[4..], 0xff); try testing.expectError(error.ReplyMalformed, unframe(gpa, &envelope)); } test "transport §4 redirect erratum: a trailing /xfer is stripped to recover the base URL" { // Redirect erratum — strip trailing "/xfer" (repeatably); the origin URL's // path is never consulted. const one = try resolveRedirect(.https, "https://h.example/repo/xfer", 0); try testing.expectEqual(sync.Transport.https, one.transport); try testing.expectEqualStrings("https://h.example/repo", one.base_url); const two = try resolveRedirect(.http, "http://h.example/proj/xfer/xfer", 0); try testing.expectEqualStrings("http://h.example/proj", two.base_url); } test "transport §4 redirect erratum: a Location without /xfer is used verbatim" { // Redirect erratum divergence row — origin path is never consulted; a // Location that does not end in /xfer passes through unchanged. const step = try resolveRedirect(.http, "http://h.example/mirror/proj", 0); try testing.expectEqualStrings("http://h.example/mirror/proj", step.base_url); } test "transport §4 r10: the redirect limit is reached on the twentieth hop" { // §4 r10 — an exchange follows at most 19 redirects; the 20th aborts. Drive // a self-referential redirect and count hops to the fatal limit. const loc = "https://loop.example/repo/xfer"; var followed: usize = 0; while (true) { const step = resolveRedirect(.https, loc, followed) catch |err| { try testing.expectEqual(TransportError.RedirectLimit, err); break; }; try testing.expectEqualStrings("https://loop.example/repo", step.base_url); followed += 1; try testing.expect(followed <= sync.max_redirects); } try testing.expectEqual(sync.max_redirects, followed); } test "transport §4 r12: an https->http redirect is refused" { try testing.expectError(error.InsecureRedirect, resolveRedirect(.https, "http://h.example/repo", 0)); // The safe directions are permitted. _ = try resolveRedirect(.https, "https://h.example/repo", 0); _ = try resolveRedirect(.http, "http://h.example/repo", 0); _ = try resolveRedirect(.http, "https://h.example/repo", 0); // http->https upgrade is fine } test "transport §4 r13: a redirect on an SSH or file transport is rejected" { try testing.expectError(error.RedirectNotAllowed, resolveRedirect(.ssh, "https://h.example/repo", 0)); try testing.expectError(error.RedirectNotAllowed, resolveRedirect(.file, "https://h.example/repo", 0)); } test "transport §4: a redirect to a non-http(s) scheme is rejected" { try testing.expectError(error.UnsupportedScheme, resolveRedirect(.http, "ftp://h.example/repo", 0)); } test "transport §4.1 fuzz: unframe on arbitrary bytes stays bounded and never crashes" { // Deterministic seeded fuzz: decode must terminate with a clean // TransportError or a bounded slice for any input, never UB or a hang. const gpa = testing.allocator; var prng = std.Random.DefaultPrng.init(0x5eed_7a17); const rand = prng.random(); var buf: [512]u8 = undefined; var i: usize = 0; while (i < 2000) : (i += 1) { const len = rand.uintLessThan(usize, buf.len + 1); const slice = buf[0..len]; rand.bytes(slice); if (unframe(gpa, slice)) |decoded| { gpa.free(decoded); // a random stream that happens to decode: bounded output } else |_| { // Any TransportError is acceptable; the point is termination // without UB or an unbounded loop, not a specific rejection cause. } } } test "transport §4.1: frame and unframe survive every allocation-failure point" { // Every public allocating function carries an allocation-failure test. const Ctx = struct { fn run(a: std.mem.Allocator, plaintext: []const u8) !void { const container = try frame(a, plaintext); defer a.free(container); const decoded = try unframe(a, container); defer a.free(decoded); try testing.expectEqualSlices(u8, plaintext, decoded); } }; try testing.checkAllAllocationFailures(testing.allocator, Ctx.run, .{"allocation-failure sweep payload"}); }