diff options
Diffstat (limited to 'src/zip.zig')
| -rw-r--r-- | src/zip.zig | 192 |
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); +} |
