//! Minimal ZIP writer: stored (uncompressed) entries, central directory, EOCD. //! //! Entries are stored uncompressed (method 0). Zig 0.15.2's std deflate //! *compressor* is unfinished (`std.compress.flate.Compress` panics / loops), //! so we avoid it entirely; stored entries are fully ZIP/EPUB compliant. The //! cost is larger files, acceptable for docs committed to the repo. //! //! Output is byte-for-byte reproducible: a fixed DOS timestamp is used for every //! entry (no wall clock), so identical inputs produce identical archives and git //! only sees a diff when the underlying docs actually change. const std = @import("std"); const assert = std.debug.assert; const Crc32 = std.hash.crc.Crc32; /// Upper bound on entries in a single archive (NASA Power-of-10: bounded loops). pub const max_entries = 64; /// Fixed DOS date/time for reproducibility (2021-01-01 00:00:00). const dos_time: u16 = 0; const dos_date: u16 = (41 << 9) | (1 << 5) | 1; // (year-1980)<<9 | month<<5 | day const sig_local = std.zip.local_file_header_sig; const sig_central = std.zip.central_file_header_sig; const sig_end = std.zip.end_record_sig; const method_store: u16 = @intFromEnum(std.zip.CompressionMethod.store); const Entry = struct { name: []const u8, crc: u32, comp_size: u32, uncomp_size: u32, method: u16, offset: u32, }; pub const Writer = struct { allocator: std.mem.Allocator, bytes: std.ArrayList(u8), entries: std.ArrayList(Entry), pub fn init(allocator: std.mem.Allocator) Writer { return .{ .allocator = allocator, .bytes = .empty, .entries = .empty, }; } pub fn deinit(w: *Writer) void { w.bytes.deinit(w.allocator); w.entries.deinit(w.allocator); } /// Add an entry stored uncompressed (method 0). pub fn addStored(w: *Writer, name: []const u8, data: []const u8) !void { assert(name.len > 0); assert(w.entries.items.len < max_entries); const crc = Crc32.hash(data); try w.writeLocal(name, crc, @intCast(data.len), @intCast(data.len), method_store); try w.bytes.appendSlice(w.allocator, data); } /// Append central directory + end-of-central-directory; return archive bytes. /// Caller owns the returned slice. pub fn finish(w: *Writer) ![]u8 { assert(w.entries.items.len > 0); const cd_offset: u32 = @intCast(w.bytes.items.len); for (w.entries.items) |e| { try w.appendBytes(&sig_central); try w.appendInt(u16, 20); // version made by try w.appendInt(u16, 20); // version needed try w.appendInt(u16, 0); // flags try w.appendInt(u16, e.method); try w.appendInt(u16, dos_time); try w.appendInt(u16, dos_date); try w.appendInt(u32, e.crc); try w.appendInt(u32, e.comp_size); try w.appendInt(u32, e.uncomp_size); try w.appendInt(u16, @intCast(e.name.len)); try w.appendInt(u16, 0); // extra len try w.appendInt(u16, 0); // comment len try w.appendInt(u16, 0); // disk number start try w.appendInt(u16, 0); // internal attrs try w.appendInt(u32, 0); // external attrs try w.appendInt(u32, e.offset); try w.appendBytes(e.name); } const cd_size: u32 = @as(u32, @intCast(w.bytes.items.len)) - cd_offset; const count: u16 = @intCast(w.entries.items.len); try w.appendBytes(&sig_end); try w.appendInt(u16, 0); // disk number try w.appendInt(u16, 0); // cd start disk try w.appendInt(u16, count); // records this disk try w.appendInt(u16, count); // total records try w.appendInt(u32, cd_size); try w.appendInt(u32, cd_offset); try w.appendInt(u16, 0); // comment len return w.bytes.toOwnedSlice(w.allocator); } fn writeLocal(w: *Writer, name: []const u8, crc: u32, comp_size: u32, uncomp_size: u32, method: u16) !void { const offset: u32 = @intCast(w.bytes.items.len); assert(w.entries.items.len == 0 or offset > w.entries.items[w.entries.items.len - 1].offset); try w.appendBytes(&sig_local); try w.appendInt(u16, 20); // version needed try w.appendInt(u16, 0); // flags try w.appendInt(u16, method); try w.appendInt(u16, dos_time); try w.appendInt(u16, dos_date); try w.appendInt(u32, crc); try w.appendInt(u32, comp_size); try w.appendInt(u32, uncomp_size); try w.appendInt(u16, @intCast(name.len)); try w.appendInt(u16, 0); // extra len try w.appendBytes(name); try w.entries.append(w.allocator, .{ .name = name, .crc = crc, .comp_size = comp_size, .uncomp_size = uncomp_size, .method = method, .offset = offset, }); } fn appendBytes(w: *Writer, data: []const u8) !void { try w.bytes.appendSlice(w.allocator, data); } fn appendInt(w: *Writer, comptime T: type, value: T) !void { var buf: [@sizeOf(T)]u8 = undefined; std.mem.writeInt(T, &buf, value, .little); try w.bytes.appendSlice(w.allocator, &buf); } }; /// Write `archive` to a temp dir, extract it with the std.zip reader, and return /// the extracted contents of `name`. Exercises a full ZIP round-trip. fn extractOne(gpa: std.mem.Allocator, dir: std.fs.Dir, archive: []const u8, name: []const u8) ![]u8 { try dir.writeFile(.{ .sub_path = "out.zip", .data = archive }); var file = try dir.openFile("out.zip", .{}); defer file.close(); var buf: [4096]u8 = undefined; var fr = file.reader(&buf); try std.zip.extract(dir, &fr, .{}); return dir.readFileAlloc(gpa, name, 1 << 20); } test "stored entry round-trips via std.zip reader" { const gpa = std.testing.allocator; var w = Writer.init(gpa); defer w.deinit(); const payload = "hello, epub"; try w.addStored("a.txt", payload); const archive = try w.finish(); defer gpa.free(archive); try std.testing.expect(std.mem.startsWith(u8, archive, "PK")); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const got = try extractOne(gpa, tmp.dir, archive, "a.txt"); defer gpa.free(got); try std.testing.expectEqualStrings(payload, got); } test "multiple entries round-trip in nested paths" { const gpa = std.testing.allocator; var w = Writer.init(gpa); defer w.deinit(); try w.addStored("first.txt", "one"); try w.addStored("dir/second.txt", "A" ** 1000); const archive = try w.finish(); defer gpa.free(archive); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const got = try extractOne(gpa, tmp.dir, archive, "dir/second.txt"); defer gpa.free(got); try std.testing.expectEqualStrings("A" ** 1000, got); }