//! zit — CLI-layer HTTP server for the Fossil sync protocol (sync §4).
//!
//! This module, with `transport.zig`, is one of only two places `std.http` and
//! real network I/O appear; `sync.zig` stays sans-IO. It puts an HTTP face on
//! the stateless responder `sync.Server.respond` (§3.2/§3.8): a POST to `/xfer`
//! carrying an `application/x-fossil` body is decoded from its §4.1 zlib
//! container to plaintext request cards, answered by `respond` over the
//! repository's `ArtifactStore`, and the reply cards are re-framed into a
//! container and returned as `application/x-fossil`.
//!
//! Because `respond` is stateless — it takes `self: *const Server` and mutates
//! only the caller's store (§3.8) — the server holds no cross-request sync
//! state. Each request is answered independently over a freshly read view of
//! the store (`Responder`, supplied by the caller), so a killed server leaves
//! nothing partial.
//!
//! The §4.1 container codec is `sync.zlibContainerEncode`/`zlibContainerDecode`
//! — the single framer `transport.zig` also wraps; no second framer is written
//! here. Every value that crosses the wire is `[]const u8` (Fossil cards embed
//! NUL, so no sentinel-terminated type is used).
const std = @import("std");
const zit = @import("zit");
const sync = zit.sync;
const repostate = zit.repostate;
const assert = std.debug.assert;
const http = std.http;
const net = std.Io.net;
/// §4 L1136-1140 — the sync endpoint path a modern Fossil client POSTs to.
pub const xfer_path: []const u8 = "/xfer";
/// §4 L1144-1147 / r15 — the production request/response media type. The body
/// under it is always the §4.1 zlib container (`outer_compressed = true`).
pub const xfer_media_type: []const u8 = "application/x-fossil";
/// §4 L1148-1149 — the message-body ceiling. Reused symmetrically as the bound
/// on an accepted request container (outer wire size) and on its decoded
/// plaintext, so a lying §4.1 length prefix cannot drive unbounded allocation
/// (`zlibContainerDecode` checks the declared length against this before
/// decompressing).
pub const max_request_body: usize = sync.reply_body_cap;
/// §10.1 — the release integer this server emits as `pragma server-version`.
/// A fixed build identity; carries a datetime so the full server-version form
/// is always available when a dated client-version asks for it (§10.1 r?/A.4).
pub const server_version: sync.VersionInfo = .{
.version = 22800,
.datetime = .{ .date = 20260715, .time = 0 },
};
/// §8.2/§9.1 — the SERVERCODE and PROJECTCODE this server emits in a clone
/// reply's `push` card. Fixed 16-hex placeholders: a fresh clone reads the
/// project code from here (§8.3/§6.3), and the peer treats servercode as
/// informational (§9.1). Real per-repo identity is future work; a well-formed,
/// whitespace-free pair is all the content-only clone the milestone needs.
pub const server_servercode = "0000000000000000";
pub const server_projectcode = "0000000000000000";
/// The closed error set of the per-request seam `handleXfer`. A request that is
/// too large, malformed, or would produce an over-cap reply is a client-visible
/// HTTP status, never a crash. Authentication failure is NOT here — `respond`
/// answers it in-band as an `error` card (§5.2/§6.4).
pub const XferError = error{
/// The request container, or its declared plaintext length, exceeds
/// `max_request_body` (§4 L1148-1149).
RequestTooLarge,
/// The request body is not a decodable §4.1 zlib container.
RequestMalformed,
/// `respond` produced a reply exceeding the §4 body ceiling.
ResponseTooLarge,
OutOfMemory,
};
/// The error set a `Responder` may surface: the seam errors plus a server-side
/// storage read failure (the repository could not be read to answer). Kept
/// distinct from `XferError` so the store-read failure maps to a 5xx, not a
/// client 4xx.
pub const AnswerError = XferError || error{ServerStorage};
/// A capability that answers one framed sync request over the repository. The
/// HTTP layer holds no store knowledge: the caller (the CLI verb) supplies the
/// store-reading side here, so the server mechanism stays a deep module over a
/// one-method interface (§3.8 statelessness lives on the caller's side).
pub const Responder = struct {
ctx: *anyopaque,
answerFn: *const fn (ctx: *anyopaque, gpa: std.mem.Allocator, request_container: []const u8) AnswerError![]u8,
/// Answer `request_container` (a §4.1 container), returning a §4.1 reply
/// container the caller owns and frees.
pub fn answer(self: Responder, gpa: std.mem.Allocator, request_container: []const u8) AnswerError![]u8 {
return self.answerFn(self.ctx, gpa, request_container);
}
};
// ---------------------------------------------------------------------------
// The pure per-request seam — the deterministic, socket-free core.
// ---------------------------------------------------------------------------
/// Answer one framed sync request over `store`: decode the §4.1 container to
/// plaintext request cards, call the stateless `server.respond` over the store,
/// and re-frame the reply cards into a container. Sans-IO and deterministic:
/// framed bytes in, framed bytes out. The returned slice is caller-owned.
pub fn handleXfer(
gpa: std.mem.Allocator,
server: *const sync.Server,
store: repostate.ArtifactStore,
request_container: []const u8,
) XferError![]u8 {
// §4 L1148-1149 — bound the outer wire size before touching it (positive
// space); the declared plaintext length is bounded independently below.
if (request_container.len > max_request_body) return error.RequestTooLarge;
const request = sync.zlibContainerDecode(gpa, request_container, max_request_body) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.TruncatedHeader, error.InvalidStream => return error.RequestMalformed,
error.OutputTooLarge => return error.RequestTooLarge,
};
defer gpa.free(request);
assert(request.len <= max_request_body); // postcondition of the capped decode
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
const reply = server.respond(gpa, store, request, &out) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.ResponseTooLarge => return error.ResponseTooLarge,
};
const container = sync.zlibContainerEncode(gpa, reply, .default) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InputTooLarge => return error.ResponseTooLarge,
};
assert(container.len >= 4); // §4.1 — every container carries the 4-byte length prefix
return container;
}
// ---------------------------------------------------------------------------
// HTTP request classification — pure, so the accept/reject decision is tested
// without a socket.
// ---------------------------------------------------------------------------
/// The HTTP disposition of an incoming request against the sync contract: serve
/// it, or reject with the matching status. Ordered path → method → media type
/// so the most specific "wrong endpoint" answer wins.
pub const Disposition = enum {
ok,
not_found, // 404 — not the /xfer path
method_not_allowed, // 405 — /xfer but not POST
unsupported_media_type, // 415 — POST /xfer without application/x-fossil
};
/// The path portion of an HTTP request target, i.e. up to any `?query`.
pub fn requestPath(target: []const u8) []const u8 {
return if (std.mem.indexOfScalar(u8, target, '?')) |q| target[0..q] else target;
}
/// Classify a request line (§4: POST /xfer, Content-Type application/x-fossil).
/// Pure — no allocation, no I/O.
pub fn classifyXfer(method: http.Method, target: []const u8, content_type: ?[]const u8) Disposition {
if (!std.mem.eql(u8, requestPath(target), xfer_path)) return .not_found;
if (method != .POST) return .method_not_allowed;
const ct = content_type orelse return .unsupported_media_type;
// A charset/parameter suffix (`; charset=…`) is tolerated; the base type
// must be application/x-fossil.
const base = if (std.mem.indexOfScalar(u8, ct, ';')) |s| std.mem.trimEnd(u8, ct[0..s], " ") else ct;
if (!std.mem.eql(u8, base, xfer_media_type)) return .unsupported_media_type;
return .ok;
}
fn statusFor(d: Disposition) http.Status {
return switch (d) {
.ok => .ok,
.not_found => .not_found,
.method_not_allowed => .method_not_allowed,
.unsupported_media_type => .unsupported_media_type,
};
}
// ---------------------------------------------------------------------------
// The socket server — the sole IO-bearing part.
// ---------------------------------------------------------------------------
/// The closed error set of the accept loop. Per-connection faults are handled
/// inside `serveConnection` and never reach here; only bring-up (`listen`) and
/// a non-transient `accept` failure abort the server.
pub const ServeError = error{ Listen, Accept };
/// Bounds and knobs for `serve`. `max_requests` caps the accept loop (the test
/// seam and an explicit TigerStyle bound); null runs until the process is
/// signalled — a stateless server has nothing to flush, so SIGINT termination
/// is a clean shutdown.
pub const ServeOptions = struct {
max_requests: ?u64 = null,
};
/// Bind `address` and serve sync requests single-threaded: accept, answer one
/// request per connection via `responder`, close, repeat. `respond` being
/// stateless, no state is shared across connections.
pub fn serve(
io: std.Io,
gpa: std.mem.Allocator,
address: net.IpAddress,
responder: Responder,
opts: ServeOptions,
) ServeError!void {
var listener = address.listen(io, .{ .reuse_address = true }) catch return error.Listen;
defer listener.deinit(io);
var served: u64 = 0;
while (opts.max_requests == null or served < opts.max_requests.?) {
const stream = listener.accept(io) catch |err| switch (err) {
error.Canceled => return, // cooperative shutdown
error.ConnectionAborted, error.WouldBlock => continue, // transient; not a served request
else => return error.Accept,
};
served += 1;
serveConnection(io, gpa, stream, responder);
}
}
/// Handle exactly one HTTP request on `stream`, then close it. A per-connection
/// fault (a truncated client, a write failure) ends this connection only — it
/// is not fatal to the server, so nothing propagates out.
fn serveConnection(io: std.Io, gpa: std.mem.Allocator, stream: net.Stream, responder: Responder) void {
defer stream.close(io);
var recv_buf: [64 * 1024]u8 = undefined; // header buffer bound (§4 headers are small)
var send_buf: [64 * 1024]u8 = undefined;
var reader = stream.reader(io, &recv_buf);
var writer = stream.writer(io, &send_buf);
var server = http.Server.init(&reader.interface, &writer.interface);
var request = server.receiveHead() catch return;
handleHttpRequest(gpa, &request, responder);
}
/// Route one parsed HTTP request: reject a non-`POST /xfer` with the classified
/// status, else read the body (bounded by `max_request_body`), answer it, and
/// write back the framed reply as `application/x-fossil`. All failures collapse
/// to a status write; a dead connection is swallowed (nothing to recover).
fn handleHttpRequest(gpa: std.mem.Allocator, request: *http.Server.Request, responder: Responder) void {
const head = request.head;
const disposition = classifyXfer(head.method, head.target, head.content_type);
if (disposition != .ok) {
request.respond("", .{ .status = statusFor(disposition), .keep_alive = false }) catch {};
return;
}
var body_buf: [16 * 1024]u8 = undefined;
const body_reader = request.readerExpectContinue(&body_buf) catch {
request.respond("", .{ .status = .expectation_failed, .keep_alive = false }) catch {};
return;
};
const container = body_reader.allocRemaining(gpa, .limited(max_request_body)) catch |err| switch (err) {
error.OutOfMemory => return respondStatus(request, .internal_server_error),
error.StreamTooLong => return respondStatus(request, .payload_too_large), // §4 L1148-1149
error.ReadFailed => return, // client vanished mid-body
};
defer gpa.free(container);
const reply = responder.answer(gpa, container) catch |err| switch (err) {
error.RequestTooLarge => return respondStatus(request, .payload_too_large),
error.RequestMalformed => return respondStatus(request, .bad_request),
error.ResponseTooLarge, error.OutOfMemory, error.ServerStorage => return respondStatus(request, .internal_server_error),
};
defer gpa.free(reply);
request.respond(reply, .{
.status = .ok,
.keep_alive = false,
.extra_headers = &.{.{ .name = "content-type", .value = xfer_media_type }},
}) catch {};
}
fn respondStatus(request: *http.Server.Request, status: http.Status) void {
request.respond("", .{ .status = status, .keep_alive = false }) catch {};
}
// ===========================================================================
// Tests — expected values come from sync §4/§4.1/§3.x, never from running
// fossil; the RFC text and its Appendix A worked examples are the oracle.
// ===========================================================================
const testing = std.testing;
/// A 64-hex artifact name (SHA-256 width) and its content, seeded into a
/// server store so a `gimme` for it is satisfiable (§3.3).
const held_hash = "deadbeef" ** 8;
const held_content = "hello from zit\n";
fn framed(gpa: std.mem.Allocator, plaintext: []const u8) ![]u8 {
return sync.zlibContainerEncode(gpa, plaintext, .default);
}
fn unframed(gpa: std.mem.Allocator, container: []const u8) ![]u8 {
return sync.zlibContainerDecode(gpa, container, max_request_body);
}
test "serve §3.3/§4.1: a framed gimme for held content returns a framed file card" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(held_hash, held_content);
const server = sync.Server{ .version = server_version };
// §3.2 — a bare (undated) client-version emits no server-version, so the
// reply is exactly the file card for the requested, held hash.
const request = try framed(gpa, "pragma client-version 20000\ngimme " ++ held_hash ++ "\n");
defer gpa.free(request);
const reply_container = try handleXfer(gpa, &server, ms.store(), request);
defer gpa.free(reply_container);
const reply = try unframed(gpa, reply_container);
defer gpa.free(reply);
// §3.3 L848-852 — `file HASH LEN\n` + content, in request order.
const expected = "file " ++ held_hash ++ " 15\n" ++ held_content;
try testing.expectEqualStrings(expected, reply);
}
test "serve §3.8: the same request over the same store yields byte-identical replies (stateless)" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(held_hash, held_content);
const server = sync.Server{ .version = server_version };
const request = try framed(gpa, "pragma client-version 20000\ngimme " ++ held_hash ++ "\n");
defer gpa.free(request);
const first = try handleXfer(gpa, &server, ms.store(), request);
defer gpa.free(first);
const second = try handleXfer(gpa, &server, ms.store(), request);
defer gpa.free(second);
try testing.expectEqualSlices(u8, first, second);
// §3.8 — a gimme-only request ingests nothing, so the store is untouched.
try testing.expectEqual(@as(usize, 1), ms.store().count());
}
test "serve §4.1: a truncated container (shorter than its length prefix) is rejected" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = sync.Server{ .version = server_version };
try testing.expectError(error.RequestMalformed, handleXfer(gpa, &server, ms.store(), &.{ 0x00, 0x00 }));
}
test "serve §4.1: a valid length prefix over a non-zlib stream is rejected as malformed" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = sync.Server{ .version = server_version };
// Declared length 5, then five bytes that are not an RFC1950 stream.
const bad = [_]u8{ 0x00, 0x00, 0x00, 0x05 } ++ "xxxxx".*;
try testing.expectError(error.RequestMalformed, handleXfer(gpa, &server, ms.store(), &bad));
}
test "serve §4 L1148-1149: a container whose declared length exceeds the cap is rejected before allocating" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = sync.Server{ .version = server_version };
// A 4-GiB declared length over the 512-MiB cap: rejected on the length
// field, never decompressed.
const lying = [_]u8{ 0xFF, 0xFF, 0xFF, 0xFF } ++ "zzzz".*;
try testing.expectError(error.RequestTooLarge, handleXfer(gpa, &server, ms.store(), &lying));
}
test "serve §4: request classification maps path/method/media type to the right status" {
// §4 — only POST /xfer with application/x-fossil is served.
try testing.expectEqual(Disposition.ok, classifyXfer(.POST, "/xfer", "application/x-fossil"));
try testing.expectEqual(Disposition.ok, classifyXfer(.POST, "/xfer?ic=1", "application/x-fossil; charset=binary"));
try testing.expectEqual(Disposition.not_found, classifyXfer(.POST, "/", "application/x-fossil"));
try testing.expectEqual(Disposition.not_found, classifyXfer(.GET, "/index", "text/html"));
try testing.expectEqual(Disposition.method_not_allowed, classifyXfer(.GET, "/xfer", "application/x-fossil"));
try testing.expectEqual(Disposition.method_not_allowed, classifyXfer(.PUT, "/xfer", "application/x-fossil"));
try testing.expectEqual(Disposition.unsupported_media_type, classifyXfer(.POST, "/xfer", null));
try testing.expectEqual(Disposition.unsupported_media_type, classifyXfer(.POST, "/xfer", "text/plain"));
}
test "serve §4.1 fuzz: handleXfer on arbitrary request bytes stays bounded and never crashes" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(held_hash, held_content);
const server = sync.Server{ .version = server_version };
var prng = std.Random.DefaultPrng.init(0x5e2fe);
const rand = prng.random();
var buf: [256]u8 = undefined;
var i: usize = 0;
while (i < 512) : (i += 1) {
const n = rand.intRangeAtMost(usize, 0, buf.len);
rand.bytes(buf[0..n]);
if (handleXfer(gpa, &server, ms.store(), buf[0..n])) |reply| {
gpa.free(reply); // a run of bytes that happened to decode is fine
} else |err| switch (err) {
error.RequestMalformed, error.RequestTooLarge, error.ResponseTooLarge, error.OutOfMemory => {},
}
}
}
// --- socket-level smoke: drive the real std.http.Server over an in-memory
// stream pair (deterministic, no loopback port to flake in CI) ---------------
const TestResponder = struct {
server: *const sync.Server,
store: repostate.ArtifactStore,
fn answer(ctx: *anyopaque, gpa: std.mem.Allocator, request_container: []const u8) AnswerError![]u8 {
const self: *const TestResponder = @ptrCast(@alignCast(ctx));
return handleXfer(gpa, self.server, self.store, request_container);
}
};
/// Drive one HTTP request through the real `std.http.Server` request path over
/// in-memory reader/writer buffers, returning the raw HTTP response bytes.
fn driveHttp(gpa: std.mem.Allocator, raw_request: []const u8, responder: Responder) ![]u8 {
var in = std.Io.Reader.fixed(raw_request);
var out = std.Io.Writer.Allocating.init(gpa);
defer out.deinit();
var server = http.Server.init(&in, &out.writer);
var request = try server.receiveHead();
handleHttpRequest(gpa, &request, responder);
return gpa.dupe(u8, out.written());
}
fn httpBody(response: []const u8) []const u8 {
const sep = std.mem.indexOf(u8, response, "\r\n\r\n") orelse return &.{};
return response[sep + 4 ..];
}
test "serve §4 smoke: a POST /xfer application/x-fossil round-trips a framed reply over HTTP" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
_ = try ms.store().put(held_hash, held_content);
const server = sync.Server{ .version = server_version };
var ctx = TestResponder{ .server = &server, .store = ms.store() };
const responder = Responder{ .ctx = &ctx, .answerFn = TestResponder.answer };
const body = try framed(gpa, "pragma client-version 20000\ngimme " ++ held_hash ++ "\n");
defer gpa.free(body);
const req = try std.fmt.allocPrint(
gpa,
"POST /xfer HTTP/1.1\r\nHost: z\r\nContent-Type: application/x-fossil\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n",
.{body.len},
);
defer gpa.free(req);
const whole = try std.mem.concat(gpa, u8, &.{ req, body });
defer gpa.free(whole);
const response = try driveHttp(gpa, whole, responder);
defer gpa.free(response);
try testing.expect(std.mem.startsWith(u8, response, "HTTP/1.1 200"));
try testing.expect(std.mem.indexOf(u8, response, "application/x-fossil") != null);
const reply = try unframed(gpa, httpBody(response));
defer gpa.free(reply);
try testing.expectEqualStrings("file " ++ held_hash ++ " 15\n" ++ held_content, reply);
}
test "serve §4 smoke: a GET on /xfer is rejected 405 over HTTP" {
const gpa = testing.allocator;
var ms = repostate.MemStore.init(gpa);
defer ms.deinit();
const server = sync.Server{ .version = server_version };
var ctx = TestResponder{ .server = &server, .store = ms.store() };
const responder = Responder{ .ctx = &ctx, .answerFn = TestResponder.answer };
const response = try driveHttp(gpa, "GET /xfer HTTP/1.1\r\nHost: z\r\nConnection: close\r\n\r\n", responder);
defer gpa.free(response);
try testing.expect(std.mem.startsWith(u8, response, "HTTP/1.1 405"));
}