Something went wrong. Try again.
A Zig client for the Paperless-ngx REST API, with streaming uploads, task waiting and a command-line tool.
Something went wrong. Try again.
9.6 kB · 240 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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"));}