Something went wrong. Try again.
A Zig library and CLI for reading Mobipocket (.mobi, .prc, .azw) e-books.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Build-time downloader: GET a URL over TLS, check its SHA-256, write it out.//!//! usage: fetch <url> <sha256-hex> <output-path>//!//! Zig's own package manager cannot do this job: it only accepts archive//! formats, not a bare file, and its HTTP client fails to handshake with some//! hosts this needs to reach. So this is a small HTTP/1.1 client over//! `tls.zig`, which is the TLS implementation this project uses everywhere.
const std = @import("std");const Io = std.Io;const tls = @import("tls");
const max_redirects = 5;const max_body = 64 * 1024 * 1024;
pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const io = init.io; const arena = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena); if (args.len != 4) { std.debug.print("usage: fetch <url> <sha256-hex> <output-path>\n", .{}); std.process.exit(2); } const url = args[1]; const want_hex = args[2]; const out_path = args[3];
const body = get(gpa, io, arena, url) catch |err| { std.debug.print("fetch: {s}: {t}\n", .{ url, err }); std.process.exit(1); }; defer gpa.free(body);
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(body, &digest, .{}); const got_hex = std.fmt.bytesToHex(digest, .lower);
if (!std.ascii.eqlIgnoreCase(&got_hex, want_hex)) { std.debug.print( \\fetch: hash mismatch \\ url: {s} \\ expected: {s} \\ actual: {s} \\ \\The file at that URL has changed. Check the new one, then update \\the expected hash in build.zig. \\ , .{ url, want_hex, &got_hex }); std.process.exit(1); }
try Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = body });}
/// Fetch `url`, following redirects. Caller owns the returned body.fn get(gpa: std.mem.Allocator, io: Io, arena: std.mem.Allocator, start_url: []const u8) ![]u8 { var url = start_url; for (0..max_redirects + 1) |_| { const response = try request(gpa, io, url); switch (response.status) { 200 => return response.body, 301, 302, 303, 307, 308 => { defer gpa.free(response.body); const location = response.location orelse return error.RedirectWithoutLocation; url = try resolve(arena, url, location); }, else => { gpa.free(response.body); return error.HttpRequestFailed; }, } } return error.TooManyRedirects;}
const Response = struct { status: u16, body: []u8, location: ?[]const u8,};
fn request(gpa: std.mem.Allocator, io: Io, url: []const u8) !Response { const uri = try std.Uri.parse(url); if (!std.mem.eql(u8, uri.scheme, "https")) return error.UnsupportedScheme; const host = (uri.host orelse return error.MissingHost).percent_encoded; const port = uri.port orelse 443;
const host_name = try Io.net.HostName.init(host); var tcp = try host_name.connect(io, port, .{ .mode = .stream }); defer tcp.close(io);
var root_ca = try tls.config.cert.fromSystem(gpa, io); defer root_ca.deinit(gpa);
// Randomness for the handshake, taken from the OS through `Io`. var rng_source: std.Random.IoSource = .{ .io = io };
var conn = try tls.clientFromStream(io, tcp, .{ .host = host, .root_ca = root_ca, .rng = rng_source.interface(), .now = Io.Clock.real.now(io), });
// No ALPN is advertised, so the server answers in HTTP/1.1. var head_buf: [2048]u8 = undefined; const head = try std.fmt.bufPrint( &head_buf, "GET {s} HTTP/1.1\r\n" ++ "Host: {s}\r\n" ++ "User-Agent: zig-mobi-fetch/1.0\r\n" ++ "Accept: */*\r\n" ++ "Connection: close\r\n\r\n", .{ pathOf(uri), host }, ); try conn.writeAll(head);
var raw: std.ArrayList(u8) = .empty; defer raw.deinit(gpa); var buf: [64 * 1024]u8 = undefined; while (true) { // `read` reports end of stream as zero bytes, including when the peer // closes without a TLS close_notify. A truncated body is still caught // by the Content-Length check below. const n = try conn.read(&buf); if (n == 0) break; if (raw.items.len + n > max_body) return error.ResponseTooLarge; try raw.appendSlice(gpa, buf[0..n]); }
return parseResponse(gpa, raw.items);}
fn pathOf(uri: std.Uri) []const u8 { const path = uri.path.percent_encoded; return if (path.len == 0) "/" else path;}
fn parseResponse(gpa: std.mem.Allocator, raw: []const u8) !Response { const split = std.mem.find(u8, raw, "\r\n\r\n") orelse return error.MalformedResponse; const head = raw[0..split]; const body = raw[split + 4 ..];
var lines = std.mem.splitSequence(u8, head, "\r\n"); const status_line = lines.next() orelse return error.MalformedResponse; var status_parts = std.mem.splitScalar(u8, status_line, ' '); _ = status_parts.next() orelse return error.MalformedResponse; // HTTP/1.1 const status = try std.fmt.parseInt(u16, status_parts.next() orelse return error.MalformedResponse, 10);
var content_length: ?usize = null; var chunked = false; var location: ?[]const u8 = null; while (lines.next()) |line| { const colon = std.mem.findScalar(u8, line, ':') orelse continue; const name = line[0..colon]; const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); if (std.ascii.eqlIgnoreCase(name, "content-length")) { content_length = try std.fmt.parseInt(usize, value, 10); } else if (std.ascii.eqlIgnoreCase(name, "transfer-encoding")) { chunked = std.ascii.findIgnoreCase(value, "chunked") != null; } else if (std.ascii.eqlIgnoreCase(name, "location")) { location = value; } }
const decoded = if (chunked) try dechunk(gpa, body) else blk: { if (content_length) |len| { // Anything short means the connection dropped mid-transfer. if (body.len < len) return error.IncompleteResponse; break :blk try gpa.dupe(u8, body[0..len]); } break :blk try gpa.dupe(u8, body); }; errdefer gpa.free(decoded);
return .{ .status = status, .body = decoded, .location = if (location) |l| try gpa.dupe(u8, l) else null, };}
fn dechunk(gpa: std.mem.Allocator, body: []const u8) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa);
var rest = body; while (true) { const eol = std.mem.find(u8, rest, "\r\n") orelse return error.MalformedChunk; // A chunk size line may carry extensions after a semicolon. const size_text = std.mem.sliceTo(rest[0..eol], ';'); const size = try std.fmt.parseInt(usize, std.mem.trim(u8, size_text, " \t"), 16); rest = rest[eol + 2 ..]; if (size == 0) break; if (rest.len < size + 2) return error.IncompleteResponse; try out.appendSlice(gpa, rest[0..size]); rest = rest[size + 2 ..]; } return out.toOwnedSlice(gpa);}
/// Resolve a `Location` value, which may be absolute or host-relative.fn resolve(arena: std.mem.Allocator, base: []const u8, location: []const u8) ![]const u8 { if (std.mem.startsWith(u8, location, "http://") or std.mem.startsWith(u8, location, "https://")) { return arena.dupe(u8, location); } const uri = try std.Uri.parse(base); const host = (uri.host orelse return error.MissingHost).percent_encoded; if (std.mem.startsWith(u8, location, "/")) { return std.fmt.allocPrint(arena, "{s}://{s}{s}", .{ uri.scheme, host, location }); } return error.UnsupportedRedirect;}