// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! `multipart/form-data` (RFC 7578), which is how a file reaches Paperless. //! //! The body is three pieces: every form field and the head of the file's //! part, then the file, then the closing delimiter. The first and last are //! small and are held; the file is not. `Form.finish` hands back a reader //! that reads the three in order and a length that is their sum, which is //! what zig-http's `payload_reader` wants -- so a scan of several hundred //! megabytes goes from disk to socket through the connection's buffers and //! nothing else. //! //! Field names and filenames are written the way browsers write them, which //! is the way RFC 7578 §4.2 ends up recommending: UTF-8 as it is inside the //! quotes, with `"`, CR and LF percent-encoded. RFC 2231's `filename*` is //! what a mail client would send, and RFC 7578 says not to send it here. const std = @import("std"); const Io = std.Io; const Allocator = std.mem.Allocator; const testing = std.testing; const mime = @import("mime"); pub const Form = struct { gpa: Allocator, /// 32 hexadecimal digits from 16 random octets. RFC 2046 §5.1.1 asks only /// that it not occur in the content; with 128 bits of randomness the /// chance that a file contains it is not worth checking for. boundary: [boundary_prefix.len + 32]u8, head: Io.Writer.Allocating, tail: [boundary_prefix.len + 32 + 8]u8 = undefined, /// Set by `file`, which has to come last. has_file: bool = false, const boundary_prefix = "zig-paperless-"; pub fn init(gpa: Allocator, random: [16]u8) Form { var form: Form = .{ .gpa = gpa, .boundary = undefined, .head = .init(gpa), }; _ = std.fmt.bufPrint(&form.boundary, boundary_prefix ++ "{x}", .{&random}) catch unreachable; _ = std.fmt.bufPrint(&form.tail, "\r\n--{s}--\r\n", .{&form.boundary}) catch unreachable; return form; } pub fn deinit(form: *Form) void { form.head.deinit(); form.* = undefined; } /// The `Content-Type` the request has to carry. Valid as long as the /// form is. pub fn contentType(form: *const Form, buffer: []u8) []const u8 { return std.fmt.bufPrint(buffer, "multipart/form-data; boundary={s}", .{&form.boundary}) catch unreachable; } /// Adds a text field. A name may be repeated, which is how a list is /// sent: `tags=1`, `tags=2`. pub fn field(form: *Form, name: []const u8, value: []const u8) Allocator.Error!void { std.debug.assert(!form.has_file); form.writeField(name, value) catch return error.OutOfMemory; } /// Adds a text field holding a number. pub fn fieldInt(form: *Form, name: []const u8, value: anytype) Allocator.Error!void { var buffer: [24]u8 = undefined; return form.field(name, std.fmt.bufPrint(&buffer, "{d}", .{value}) catch unreachable); } fn writeField(form: *Form, name: []const u8, value: []const u8) Io.Writer.Error!void { const w = &form.head.writer; try w.print("--{s}\r\nContent-Disposition: form-data; name=\"", .{&form.boundary}); try writeQuoted(w, name); try w.writeAll("\"\r\n\r\n"); try w.writeAll(value); try w.writeAll("\r\n"); } /// Begins the file's part. Nothing may be added after it, since the file /// itself is not held and so has to be the last thing before the closing /// delimiter. /// /// `content_type` null guesses from the filename's extension, with /// zig-mime's table, and falls back to `application/octet-stream`. /// Paperless decides what a file is by looking at its first octets /// rather than at this, so the guess is a courtesy and not a /// requirement. pub fn file(form: *Form, name: []const u8, filename: []const u8, content_type: ?[]const u8) Allocator.Error!void { std.debug.assert(!form.has_file); form.writeFileHead(name, filename, content_type orelse mime.media_types.forFilename(filename)) catch return error.OutOfMemory; form.has_file = true; } fn writeFileHead(form: *Form, name: []const u8, filename: []const u8, content_type: []const u8) Io.Writer.Error!void { const w = &form.head.writer; try w.print("--{s}\r\nContent-Disposition: form-data; name=\"", .{&form.boundary}); try writeQuoted(w, name); try w.writeAll("\"; filename=\""); try writeQuoted(w, filename); try w.print("\"\r\nContent-Type: {s}\r\n\r\n", .{content_type}); } /// The finished body: what `file` began, `length` octets from `content`, /// and the closing delimiter. The form must outlive the reader. pub fn finish(form: *Form, content: *Io.Reader, length: u64) Body { std.debug.assert(form.has_file); return .{ .parts = .{ .{ .reader = .fixed(form.head.written()), .remaining = form.head.written().len }, .{ .reader = undefined, .remaining = length }, .{ .reader = .fixed(&form.tail), .remaining = form.tail.len }, }, .content = content, .total = form.head.written().len + length + form.tail.len, }; } }; /// The body of a form as one reader. Must not be moved once `reader` has /// been called, since the reader is a field of it. pub const Body = struct { parts: [3]Part, /// The file, which stands in for `parts[1].reader`. content: *Io.Reader, /// What `Content-Length` says. total: u64, index: usize = 0, interface: Io.Reader = .{ .vtable = &.{ .stream = stream }, .buffer = &.{}, .seek = 0, .end = 0, }, buffer: [4096]u8 = undefined, const Part = struct { reader: Io.Reader, remaining: u64, }; pub fn length(body: *const Body) u64 { return body.total; } pub fn reader(body: *Body) *Io.Reader { body.interface.buffer = &body.buffer; return &body.interface; } fn partReader(body: *Body, i: usize) *Io.Reader { return if (i == 1) body.content else &body.parts[i].reader; } fn stream(r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { const body: *Body = @alignCast(@fieldParentPtr("interface", r)); while (body.index < body.parts.len) { const part = &body.parts[body.index]; if (part.remaining == 0) { body.index += 1; continue; } // Never past what was declared, so that a file longer than the // length it was sent with is cut at that length rather than // pushing the closing delimiter off the end of the body. const n = try body.partReader(body.index).stream(w, limit.min(.limited64(part.remaining))); part.remaining -= n; return n; } return error.EndOfStream; } }; /// Writes a name or filename for the inside of a quoted-string, as browsers /// do and RFC 7578 §4.2 describes. fn writeQuoted(w: *Io.Writer, s: []const u8) Io.Writer.Error!void { for (s) |c| switch (c) { '"' => try w.writeAll("%22"), '\r' => try w.writeAll("%0D"), '\n' => try w.writeAll("%0A"), else => try w.writeByte(c), }; } test Form { var form: Form = .init(testing.allocator, @splat(0xab)); defer form.deinit(); try form.field("title", "A \"quoted\" title"); try form.fieldInt("tags", 3); try form.fieldInt("tags", 14); try form.file("document", "café\n.pdf", null); var content: Io.Reader = .fixed("%PDF-1.7 and the rest"); var body = form.finish(&content, 8); var out: Io.Writer.Allocating = .init(testing.allocator); defer out.deinit(); const sent = try body.reader().streamRemaining(&out.writer); const b = "zig-paperless-abababababababababababababababab"; try testing.expectEqualStrings( "--" ++ b ++ "\r\n" ++ "Content-Disposition: form-data; name=\"title\"\r\n\r\n" ++ "A \"quoted\" title\r\n" ++ "--" ++ b ++ "\r\n" ++ "Content-Disposition: form-data; name=\"tags\"\r\n\r\n3\r\n" ++ "--" ++ b ++ "\r\n" ++ "Content-Disposition: form-data; name=\"tags\"\r\n\r\n14\r\n" ++ "--" ++ b ++ "\r\n" ++ "Content-Disposition: form-data; name=\"document\"; filename=\"café%0A.pdf\"\r\n" ++ "Content-Type: application/pdf\r\n\r\n" ++ "%PDF-1.7" ++ "\r\n--" ++ b ++ "--\r\n", out.written(), ); try testing.expectEqual(body.length(), sent); var buffer: [128]u8 = undefined; try testing.expectEqualStrings("multipart/form-data; boundary=" ++ b, form.contentType(&buffer)); } test "a file shorter than its length ends the body early" { var form: Form = .init(testing.allocator, @splat(0)); defer form.deinit(); try form.file("document", "x.txt", "text/plain"); var content: Io.Reader = .fixed("abc"); var body = form.finish(&content, 10); var out: Io.Writer.Allocating = .init(testing.allocator); defer out.deinit(); // The total is the declared length; three octets short of the file and // the delimiter never reached is what zig-http reports as a short body. const sent = try body.reader().streamRemaining(&out.writer); try testing.expect(sent < body.length()); try testing.expect(std.mem.endsWith(u8, out.written(), "abc")); }