Skip to content
zit Artifact [edde6c00ac]
Login

Artifact [edde6c00ac]

Artifact edde6c00aca998fd6c26adb8353462da7620ae289f65a342d3b236a0f14fda35:


//! 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,
    /// The peer challenged for HTTP Basic over plaintext `http://` to a host
    /// that is not loopback. Answering would put the password on the wire in
    /// the clear, so the exchange stops instead.
    InsecureCredential,
};

// ---------------------------------------------------------------------------
// §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 one trailing "/xfer" 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;
}

/// The host a URL names, as written (an IPv6 literal keeps its brackets), or
/// null when it does not parse or names none.
fn hostOf(url: []const u8) ?[]const u8 {
    const uri = std.Uri.parse(url) catch return null;
    const host = uri.host orelse return null;
    return switch (host) {
        .raw => |r| r,
        .percent_encoded => |e| e,
    };
}

/// Whether `host` is this machine: `localhost`, anything in 127.0.0.0/8, or
/// `::1`. Plaintext to loopback never leaves the host, which is how a forge
/// talks to its own zit child and how the tests run.
pub fn isLoopbackHost(host: []const u8) bool {
    if (std.ascii.eqlIgnoreCase(host, "localhost")) return true;
    if (std.mem.eql(u8, host, "::1") or std.mem.eql(u8, host, "[::1]")) return true;
    if (!std.mem.startsWith(u8, host, "127.")) return false;
    var octets: usize = 0;
    var it = std.mem.splitScalar(u8, host, '.');
    while (it.next()) |part| : (octets += 1) {
        _ = std.fmt.parseInt(u8, part, 10) catch return false;
    }
    return octets == 4;
}

/// True when `url` is plaintext http to a host that is not loopback: where a
/// credential must not be sent and content crosses readable. A URL whose host
/// cannot be read counts as remote, the safe direction.
pub fn plaintextToRemote(url: []const u8) bool {
    const scheme = schemeTransport(url) catch return false;
    if (scheme != .http) return false;
    const host = hostOf(url) orelse return true;
    return !isLoopbackHost(host);
}

/// §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;
    }

    /// A Basic challenge arrived: arm the credential and report whether to
    /// resend the round with it. False when there is none to offer or it was
    /// already offered (the peer refused it). Over plaintext http to a host
    /// that is not loopback it is never armed: Basic is the password,
    /// base64'd, readable by anyone on the path, and the saved remote would
    /// repeat it on every later sync.
    fn answerBasicChallenge(self: *Transport) error{InsecureCredential}!bool {
        if (self.basic_auth == null or self.basic_armed) return false;
        if (plaintextToRemote(self.base_url)) return error.InsecureCredential;
        self.basic_armed = true;
        return true;
    }

    /// A redirect to `next` is about to be followed. A credential belongs to
    /// the host it was given for, so a redirect to another host drops it for
    /// the rest of this exchange: otherwise a server could hand the next host
    /// the password just by answering 302. curl and every browser do the same.
    fn dropCredentialLeaving(self: *Transport, next: []const u8) void {
        const b = self.basic_auth orelse return;
        const from = hostOf(self.base_url) orelse "";
        const to = hostOf(next) orelse "";
        if (std.ascii.eqlIgnoreCase(from, to)) return;
        std.crypto.secureZero(u8, b);
        self.client.allocator.free(b);
        self.basic_auth = null;
        self.basic_armed = false;
    }

    /// 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);
                // A credential belongs to the host it was given for. A redirect
                // elsewhere drops it for the rest of this exchange, so a server
                // cannot hand the next host the password by answering 302 —
                // which is what curl and every browser do.
                self.dropCredentialLeaving(step.base_url);
                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 (try self.answerBasicChallenge()) 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: a credential crosses plaintext http only to loopback" {
    try testing.expect(plaintextToRemote("http://zeitforge.app/r/x"));
    try testing.expect(plaintextToRemote("http://10.0.0.5:8080/r"));
    try testing.expect(plaintextToRemote("HTTP://example.com/"));
    // A name that merely starts like loopback is not loopback.
    try testing.expect(plaintextToRemote("http://127.evil.example/r"));
    try testing.expect(!plaintextToRemote("https://zeitforge.app/r/x"));
    try testing.expect(!plaintextToRemote("http://127.0.0.1:8080/xfer"));
    try testing.expect(!plaintextToRemote("http://127.8.9.10/xfer"));
    try testing.expect(!plaintextToRemote("http://localhost:9000/xfer"));
    try testing.expect(!plaintextToRemote("http://LocalHost/xfer"));
    try testing.expect(!plaintextToRemote("http://[::1]:9000/xfer"));
}

test "transport: a Basic challenge is answered over https and loopback http, and refused over remote http" {
    const cases = [_]struct { url: []const u8, refused: bool }{
        .{ .url = "http://zeitforge.app/r/x", .refused = true },
        .{ .url = "https://zeitforge.app/r/x", .refused = false },
        .{ .url = "http://127.0.0.1:8080/xfer", .refused = false },
    };
    for (cases) |c| {
        var tr = try Transport.open(testing.io, testing.allocator, c.url);
        defer tr.close();
        // No credential: nothing to answer with, over any scheme.
        try testing.expect(!try tr.answerBasicChallenge());
        try tr.setBasicAuth("u", "p");
        if (c.refused) {
            try testing.expectError(error.InsecureCredential, tr.answerBasicChallenge());
            try testing.expect(!tr.basic_armed);
        } else {
            try testing.expect(try tr.answerBasicChallenge());
            try testing.expect(tr.basic_armed);
            // Offered once; a second challenge is the peer refusing it.
            try testing.expect(!try tr.answerBasicChallenge());
        }
    }
}

test "transport: a redirect to another host drops the credential, one to the same host keeps it" {
    var tr = try Transport.open(testing.io, testing.allocator, "https://a.example/repo");
    defer tr.close();
    try tr.setBasicAuth("u", "p");
    tr.basic_armed = true;
    tr.dropCredentialLeaving("https://A.example/other");
    try testing.expect(tr.basic_auth != null and tr.basic_armed);
    tr.dropCredentialLeaving("https://b.example/repo");
    try testing.expect(tr.basic_auth == null and !tr.basic_armed);
    // Nothing left to answer a challenge from the new host with.
    try testing.expect(!try tr.answerBasicChallenge());
}

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 one trailing "/xfer"; 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);

    // One strip only: the inner /xfer belongs to the repository path.
    const two = try resolveRedirect(.http, "http://h.example/proj/xfer/xfer", 0);
    try testing.expectEqualStrings("http://h.example/proj/xfer", 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"});
}