aboutsummaryrefslogtreecommitdiffstats
path: root/src/zip.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/zip.zig')
-rw-r--r--src/zip.zig192
1 files changed, 192 insertions, 0 deletions
diff --git a/src/zip.zig b/src/zip.zig
new file mode 100644
index 0000000..9268106
--- /dev/null
+++ b/src/zip.zig
@@ -0,0 +1,192 @@
+//! 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);
+}