// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Build-time downloader: GET a URL over TLS, check its SHA-256, write it out. //! //! usage: fetch //! //! 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 \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; }