| 1 | //! The .ZIP File Format Specification is found here: |
| 2 | //! https://pkwaredownloads.blob.core.windows.net/pem/APPNOTE.txt |
| 3 | //! |
| 4 | //! Note that this file uses the abbreviation "cd" for "central directory" |
| 5 | |
| 6 | const builtin = @import("builtin"); |
| 7 | const is_le = builtin.target.cpu.arch.endian() == .little; |
| 8 | |
| 9 | const std = @import("std"); |
| 10 | const Io = std.Io; |
| 11 | const File = std.Io.File; |
| 12 | const Writer = std.Io.Writer; |
| 13 | const Reader = std.Io.Reader; |
| 14 | const flate = std.compress.flate; |
| 15 | |
| 16 | pub const CompressionMethod = enum(u16) { |
| 17 | store = 0, |
| 18 | deflate = 8, |
| 19 | _, |
| 20 | }; |
| 21 | |
| 22 | pub const central_file_header_sig = [4]u8{ 'P', 'K', 1, 2 }; |
| 23 | pub const local_file_header_sig = [4]u8{ 'P', 'K', 3, 4 }; |
| 24 | pub const end_record_sig = [4]u8{ 'P', 'K', 5, 6 }; |
| 25 | pub const end_record64_sig = [4]u8{ 'P', 'K', 6, 6 }; |
| 26 | pub const end_locator64_sig = [4]u8{ 'P', 'K', 6, 7 }; |
| 27 | pub const ExtraHeader = enum(u16) { |
| 28 | zip64_info = 0x1, |
| 29 | _, |
| 30 | }; |
| 31 | |
| 32 | const GeneralPurposeFlags = packed struct(u16) { |
| 33 | encrypted: bool, |
| 34 | _: u15, |
| 35 | }; |
| 36 | |
| 37 | pub const LocalFileHeader = extern struct { |
| 38 | signature: [4]u8 align(1), |
| 39 | version_needed_to_extract: u16 align(1), |
| 40 | flags: GeneralPurposeFlags align(1), |
| 41 | compression_method: CompressionMethod align(1), |
| 42 | last_modification_time: u16 align(1), |
| 43 | last_modification_date: u16 align(1), |
| 44 | crc32: u32 align(1), |
| 45 | compressed_size: u32 align(1), |
| 46 | uncompressed_size: u32 align(1), |
| 47 | filename_len: u16 align(1), |
| 48 | extra_len: u16 align(1), |
| 49 | }; |
| 50 | |
| 51 | pub const CentralDirectoryFileHeader = extern struct { |
| 52 | signature: [4]u8 align(1), |
| 53 | version_made_by: u16 align(1), |
| 54 | version_needed_to_extract: u16 align(1), |
| 55 | flags: GeneralPurposeFlags align(1), |
| 56 | compression_method: CompressionMethod align(1), |
| 57 | last_modification_time: u16 align(1), |
| 58 | last_modification_date: u16 align(1), |
| 59 | crc32: u32 align(1), |
| 60 | compressed_size: u32 align(1), |
| 61 | uncompressed_size: u32 align(1), |
| 62 | filename_len: u16 align(1), |
| 63 | extra_len: u16 align(1), |
| 64 | comment_len: u16 align(1), |
| 65 | disk_number: u16 align(1), |
| 66 | internal_file_attributes: u16 align(1), |
| 67 | external_file_attributes: u32 align(1), |
| 68 | local_file_header_offset: u32 align(1), |
| 69 | }; |
| 70 | |
| 71 | pub const EndRecord64 = extern struct { |
| 72 | signature: [4]u8 align(1), |
| 73 | end_record_size: u64 align(1), |
| 74 | version_made_by: u16 align(1), |
| 75 | version_needed_to_extract: u16 align(1), |
| 76 | disk_number: u32 align(1), |
| 77 | central_directory_disk_number: u32 align(1), |
| 78 | record_count_disk: u64 align(1), |
| 79 | record_count_total: u64 align(1), |
| 80 | central_directory_size: u64 align(1), |
| 81 | central_directory_offset: u64 align(1), |
| 82 | }; |
| 83 | |
| 84 | pub const EndLocator64 = extern struct { |
| 85 | signature: [4]u8 align(1), |
| 86 | zip64_disk_count: u32 align(1), |
| 87 | record_file_offset: u64 align(1), |
| 88 | total_disk_count: u32 align(1), |
| 89 | }; |
| 90 | |
| 91 | pub const EndRecord = extern struct { |
| 92 | signature: [4]u8 align(1), |
| 93 | disk_number: u16 align(1), |
| 94 | central_directory_disk_number: u16 align(1), |
| 95 | record_count_disk: u16 align(1), |
| 96 | record_count_total: u16 align(1), |
| 97 | central_directory_size: u32 align(1), |
| 98 | central_directory_offset: u32 align(1), |
| 99 | comment_len: u16 align(1), |
| 100 | |
| 101 | pub fn need_zip64(self: EndRecord) bool { |
| 102 | return isMaxInt(self.record_count_disk) or |
| 103 | isMaxInt(self.record_count_total) or |
| 104 | isMaxInt(self.central_directory_size) or |
| 105 | isMaxInt(self.central_directory_offset); |
| 106 | } |
| 107 | |
| 108 | pub const FindBufferError = error{ ZipNoEndRecord, ZipTruncated }; |
| 109 | |
| 110 | /// TODO audit this logic |
| 111 | pub fn findBuffer(buffer: []const u8) FindBufferError!EndRecord { |
| 112 | const pos = std.mem.findLast(u8, buffer, &end_record_sig) orelse return error.ZipNoEndRecord; |
| 113 | if (pos + @sizeOf(EndRecord) > buffer.len) return error.EndOfStream; |
| 114 | const record_ptr: *EndRecord = @ptrCast(buffer[pos..][0..@sizeOf(EndRecord)]); |
| 115 | var record = record_ptr.*; |
| 116 | if (!is_le) std.mem.byteSwapAllFields(EndRecord, &record); |
| 117 | return record; |
| 118 | } |
| 119 | |
| 120 | pub const FindFileError = File.Reader.SizeError || File.SeekError || File.Reader.Error || error{ |
| 121 | ZipNoEndRecord, |
| 122 | EndOfStream, |
| 123 | ReadFailed, |
| 124 | }; |
| 125 | |
| 126 | pub fn findFile(fr: *File.Reader) FindFileError!EndRecord { |
| 127 | const end_pos = try fr.getSize(); |
| 128 | |
| 129 | var buf: [@sizeOf(EndRecord) + std.math.maxInt(u16)]u8 = undefined; |
| 130 | const record_len_max = @min(end_pos, buf.len); |
| 131 | var loaded_len: u32 = 0; |
| 132 | var comment_len: u16 = 0; |
| 133 | while (true) { |
| 134 | const record_len: u32 = @as(u32, comment_len) + @sizeOf(EndRecord); |
| 135 | if (record_len > record_len_max) |
| 136 | return error.ZipNoEndRecord; |
| 137 | |
| 138 | if (record_len > loaded_len) { |
| 139 | const new_loaded_len = @min(loaded_len + 300, record_len_max); |
| 140 | const read_len = new_loaded_len - loaded_len; |
| 141 | |
| 142 | try fr.seekTo(end_pos - @as(u64, new_loaded_len)); |
| 143 | const read_buf: []u8 = buf[buf.len - new_loaded_len ..][0..read_len]; |
| 144 | fr.interface.readSliceAll(read_buf) catch |err| switch (err) { |
| 145 | error.ReadFailed => return fr.err.?, |
| 146 | error.EndOfStream => |e| return e, |
| 147 | }; |
| 148 | loaded_len = new_loaded_len; |
| 149 | } |
| 150 | |
| 151 | const record_bytes = buf[buf.len - record_len ..][0..@sizeOf(EndRecord)]; |
| 152 | if (std.mem.eql(u8, record_bytes[0..4], &end_record_sig) and |
| 153 | std.mem.readInt(u16, record_bytes[20..22], .little) == comment_len) |
| 154 | { |
| 155 | const record: *align(1) EndRecord = @ptrCast(record_bytes.ptr); |
| 156 | if (!is_le) std.mem.byteSwapAllFields(EndRecord, record); |
| 157 | return record.*; |
| 158 | } |
| 159 | |
| 160 | if (comment_len == std.math.maxInt(u16)) |
| 161 | return error.ZipNoEndRecord; |
| 162 | comment_len += 1; |
| 163 | } |
| 164 | } |
| 165 | }; |
| 166 | |
| 167 | fn isBadFilename(filename: []const u8) bool { |
| 168 | if (filename.len == 0 or filename[0] == '/') |
| 169 | return true; |
| 170 | |
| 171 | var it = std.mem.splitScalar(u8, filename, '/'); |
| 172 | while (it.next()) |part| { |
| 173 | if (std.mem.eql(u8, part, "..")) |
| 174 | return true; |
| 175 | } |
| 176 | |
| 177 | return false; |
| 178 | } |
| 179 | |
| 180 | fn isMaxInt(uint: anytype) bool { |
| 181 | return uint == std.math.maxInt(@TypeOf(uint)); |
| 182 | } |
| 183 | |
| 184 | const FileExtents = struct { |
| 185 | uncompressed_size: u64, |
| 186 | compressed_size: u64, |
| 187 | local_file_header_offset: u64, |
| 188 | }; |
| 189 | |
| 190 | fn readZip64FileExtents(comptime T: type, header: T, extents: *FileExtents, data: []u8) !void { |
| 191 | var data_offset: usize = 0; |
| 192 | if (isMaxInt(header.uncompressed_size)) { |
| 193 | if (data_offset + 8 > data.len) |
| 194 | return error.ZipBadCd64Size; |
| 195 | extents.uncompressed_size = std.mem.readInt(u64, data[data_offset..][0..8], .little); |
| 196 | data_offset += 8; |
| 197 | } |
| 198 | if (isMaxInt(header.compressed_size)) { |
| 199 | if (data_offset + 8 > data.len) |
| 200 | return error.ZipBadCd64Size; |
| 201 | extents.compressed_size = std.mem.readInt(u64, data[data_offset..][0..8], .little); |
| 202 | data_offset += 8; |
| 203 | } |
| 204 | |
| 205 | switch (T) { |
| 206 | CentralDirectoryFileHeader => { |
| 207 | if (isMaxInt(header.local_file_header_offset)) { |
| 208 | if (data_offset + 8 > data.len) |
| 209 | return error.ZipBadCd64Size; |
| 210 | extents.local_file_header_offset = std.mem.readInt(u64, data[data_offset..][0..8], .little); |
| 211 | data_offset += 8; |
| 212 | } |
| 213 | if (isMaxInt(header.disk_number)) { |
| 214 | if (data_offset + 4 > data.len) |
| 215 | return error.ZipInvalid; |
| 216 | const disk_number = std.mem.readInt(u32, data[data_offset..][0..4], .little); |
| 217 | if (disk_number != 0) |
| 218 | return error.ZipMultiDiskUnsupported; |
| 219 | data_offset += 4; |
| 220 | } |
| 221 | if (data_offset > data.len) |
| 222 | return error.ZipBadCd64Size; |
| 223 | }, |
| 224 | else => {}, |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | pub const Iterator = struct { |
| 229 | input: *File.Reader, |
| 230 | |
| 231 | cd_record_count: u64, |
| 232 | cd_zip_offset: u64, |
| 233 | cd_size: u64, |
| 234 | |
| 235 | cd_record_index: u64 = 0, |
| 236 | cd_record_offset: u64 = 0, |
| 237 | |
| 238 | pub fn init(input: *File.Reader) !Iterator { |
| 239 | const end_record = try EndRecord.findFile(input); |
| 240 | |
| 241 | if (!isMaxInt(end_record.record_count_disk) and end_record.record_count_disk > end_record.record_count_total) |
| 242 | return error.ZipDiskRecordCountTooLarge; |
| 243 | |
| 244 | if (end_record.disk_number != 0 or end_record.central_directory_disk_number != 0) |
| 245 | return error.ZipMultiDiskUnsupported; |
| 246 | |
| 247 | { |
| 248 | const counts_valid = !isMaxInt(end_record.record_count_disk) and !isMaxInt(end_record.record_count_total); |
| 249 | if (counts_valid and end_record.record_count_disk != end_record.record_count_total) |
| 250 | return error.ZipMultiDiskUnsupported; |
| 251 | } |
| 252 | |
| 253 | var result: Iterator = .{ |
| 254 | .input = input, |
| 255 | .cd_record_count = end_record.record_count_total, |
| 256 | .cd_zip_offset = end_record.central_directory_offset, |
| 257 | .cd_size = end_record.central_directory_size, |
| 258 | }; |
| 259 | if (!end_record.need_zip64()) return result; |
| 260 | |
| 261 | const locator_end_offset: u64 = @as(u64, end_record.comment_len) + @sizeOf(EndRecord) + @sizeOf(EndLocator64); |
| 262 | const stream_len = try input.getSize(); |
| 263 | |
| 264 | if (locator_end_offset > stream_len) |
| 265 | return error.ZipTruncated; |
| 266 | try input.seekTo(stream_len - locator_end_offset); |
| 267 | const locator = input.interface.takeStruct(EndLocator64, .little) catch |err| switch (err) { |
| 268 | error.ReadFailed => return input.err.?, |
| 269 | error.EndOfStream => |e| return e, |
| 270 | }; |
| 271 | if (!std.mem.eql(u8, &locator.signature, &end_locator64_sig)) |
| 272 | return error.ZipBadLocatorSig; |
| 273 | if (locator.zip64_disk_count != 0) |
| 274 | return error.ZipUnsupportedZip64DiskCount; |
| 275 | if (locator.total_disk_count != 1) |
| 276 | return error.ZipMultiDiskUnsupported; |
| 277 | |
| 278 | try input.seekTo(locator.record_file_offset); |
| 279 | |
| 280 | const record64 = input.interface.takeStruct(EndRecord64, .little) catch |err| switch (err) { |
| 281 | error.ReadFailed => return input.err.?, |
| 282 | error.EndOfStream => |e| return e, |
| 283 | }; |
| 284 | |
| 285 | if (!std.mem.eql(u8, &record64.signature, &end_record64_sig)) |
| 286 | return error.ZipBadEndRecord64Sig; |
| 287 | |
| 288 | if (record64.end_record_size < @sizeOf(EndRecord64) - 12) |
| 289 | return error.ZipEndRecord64SizeTooSmall; |
| 290 | if (record64.end_record_size > @sizeOf(EndRecord64) - 12) |
| 291 | return error.ZipEndRecord64UnhandledExtraData; |
| 292 | |
| 293 | if (record64.version_needed_to_extract > 45) |
| 294 | return error.ZipUnsupportedVersion; |
| 295 | |
| 296 | { |
| 297 | const is_multidisk = record64.disk_number != 0 or |
| 298 | record64.central_directory_disk_number != 0 or |
| 299 | record64.record_count_disk != record64.record_count_total; |
| 300 | if (is_multidisk) |
| 301 | return error.ZipMultiDiskUnsupported; |
| 302 | } |
| 303 | |
| 304 | if (isMaxInt(end_record.record_count_total)) { |
| 305 | result.cd_record_count = record64.record_count_total; |
| 306 | } else if (end_record.record_count_total != record64.record_count_total) |
| 307 | return error.Zip64RecordCountTotalMismatch; |
| 308 | |
| 309 | if (isMaxInt(end_record.central_directory_offset)) { |
| 310 | result.cd_zip_offset = record64.central_directory_offset; |
| 311 | } else if (end_record.central_directory_offset != record64.central_directory_offset) |
| 312 | return error.Zip64CentralDirectoryOffsetMismatch; |
| 313 | |
| 314 | if (isMaxInt(end_record.central_directory_size)) { |
| 315 | result.cd_size = record64.central_directory_size; |
| 316 | } else if (end_record.central_directory_size != record64.central_directory_size) |
| 317 | return error.Zip64CentralDirectorySizeMismatch; |
| 318 | |
| 319 | return result; |
| 320 | } |
| 321 | |
| 322 | pub fn next(self: *Iterator) !?Entry { |
| 323 | if (self.cd_record_index == self.cd_record_count) { |
| 324 | if (self.cd_record_offset != self.cd_size) |
| 325 | return if (self.cd_size > self.cd_record_offset) |
| 326 | error.ZipCdOversized |
| 327 | else |
| 328 | error.ZipCdUndersized; |
| 329 | |
| 330 | return null; |
| 331 | } |
| 332 | |
| 333 | const header_zip_offset = self.cd_zip_offset + self.cd_record_offset; |
| 334 | const input = self.input; |
| 335 | try input.seekTo(header_zip_offset); |
| 336 | const header = input.interface.takeStruct(CentralDirectoryFileHeader, .little) catch |err| switch (err) { |
| 337 | error.ReadFailed => return input.err.?, |
| 338 | error.EndOfStream => |e| return e, |
| 339 | }; |
| 340 | if (!std.mem.eql(u8, &header.signature, &central_file_header_sig)) |
| 341 | return error.ZipBadCdOffset; |
| 342 | |
| 343 | self.cd_record_index += 1; |
| 344 | self.cd_record_offset += @sizeOf(CentralDirectoryFileHeader) + header.filename_len + header.extra_len + header.comment_len; |
| 345 | |
| 346 | // Note: checking the version_needed_to_extract doesn't seem to be helpful, i.e. the zip file |
| 347 | // at https://github.com/ninja-build/ninja/releases/download/v1.12.0/ninja-linux.zip |
| 348 | // has an undocumented version 788 but extracts just fine. |
| 349 | |
| 350 | if (header.flags.encrypted) |
| 351 | return error.ZipEncryptionUnsupported; |
| 352 | // TODO: check/verify more flags |
| 353 | if (header.disk_number != 0) |
| 354 | return error.ZipMultiDiskUnsupported; |
| 355 | |
| 356 | var extents: FileExtents = .{ |
| 357 | .uncompressed_size = header.uncompressed_size, |
| 358 | .compressed_size = header.compressed_size, |
| 359 | .local_file_header_offset = header.local_file_header_offset, |
| 360 | }; |
| 361 | |
| 362 | if (header.extra_len > 0) { |
| 363 | var extra_buf: [std.math.maxInt(u16)]u8 = undefined; |
| 364 | const extra = extra_buf[0..header.extra_len]; |
| 365 | |
| 366 | try input.seekTo(header_zip_offset + @sizeOf(CentralDirectoryFileHeader) + header.filename_len); |
| 367 | input.interface.readSliceAll(extra) catch |err| switch (err) { |
| 368 | error.ReadFailed => return input.err.?, |
| 369 | error.EndOfStream => |e| return e, |
| 370 | }; |
| 371 | |
| 372 | var extra_offset: usize = 0; |
| 373 | while (extra_offset + 4 <= extra.len) { |
| 374 | const header_id = std.mem.readInt(u16, extra[extra_offset..][0..2], .little); |
| 375 | const data_size = std.mem.readInt(u16, extra[extra_offset..][2..4], .little); |
| 376 | const end = extra_offset + 4 + data_size; |
| 377 | if (end > extra.len) |
| 378 | return error.ZipBadExtraFieldSize; |
| 379 | const data = extra[extra_offset + 4 .. end]; |
| 380 | switch (@as(ExtraHeader, @fromBackingInt(@intCast(header_id)))) { |
| 381 | .zip64_info => try readZip64FileExtents(CentralDirectoryFileHeader, header, &extents, data), |
| 382 | else => {}, // ignore |
| 383 | } |
| 384 | extra_offset = end; |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | return .{ |
| 389 | .version_needed_to_extract = header.version_needed_to_extract, |
| 390 | .flags = header.flags, |
| 391 | .compression_method = header.compression_method, |
| 392 | .last_modification_time = header.last_modification_time, |
| 393 | .last_modification_date = header.last_modification_date, |
| 394 | .header_zip_offset = header_zip_offset, |
| 395 | .crc32 = header.crc32, |
| 396 | .filename_len = header.filename_len, |
| 397 | .compressed_size = extents.compressed_size, |
| 398 | .uncompressed_size = extents.uncompressed_size, |
| 399 | .file_offset = extents.local_file_header_offset, |
| 400 | }; |
| 401 | } |
| 402 | |
| 403 | pub const Entry = struct { |
| 404 | version_needed_to_extract: u16, |
| 405 | flags: GeneralPurposeFlags, |
| 406 | compression_method: CompressionMethod, |
| 407 | last_modification_time: u16, |
| 408 | last_modification_date: u16, |
| 409 | header_zip_offset: u64, |
| 410 | crc32: u32, |
| 411 | filename_len: u32, |
| 412 | compressed_size: u64, |
| 413 | uncompressed_size: u64, |
| 414 | file_offset: u64, |
| 415 | |
| 416 | pub fn getFilename(self: Entry, stream: *File.Reader, filename_buf: []u8, options: ExtractOptions) ![]u8 { |
| 417 | if (filename_buf.len < self.filename_len) |
| 418 | return error.ZipInsufficientBuffer; |
| 419 | switch (self.compression_method) { |
| 420 | .store, .deflate => {}, |
| 421 | else => return error.UnsupportedCompressionMethod, |
| 422 | } |
| 423 | const filename = filename_buf[0..self.filename_len]; |
| 424 | { |
| 425 | try stream.seekTo(self.header_zip_offset + @sizeOf(CentralDirectoryFileHeader)); |
| 426 | try stream.interface.readSliceAll(filename); |
| 427 | } |
| 428 | |
| 429 | if (options.allow_backslashes) { |
| 430 | std.mem.replaceScalar(u8, filename, '\\', '/'); |
| 431 | } else { |
| 432 | if (std.mem.findScalar(u8, filename, '\\')) |_| |
| 433 | return error.ZipFilenameHasBackslash; |
| 434 | } |
| 435 | |
| 436 | if (isBadFilename(filename)) |
| 437 | return error.ZipBadFilename; |
| 438 | |
| 439 | return filename; |
| 440 | } |
| 441 | |
| 442 | pub fn extractTo(self: Entry, stream: *File.Reader, w: *Writer) !void { |
| 443 | switch (self.compression_method) { |
| 444 | .store, .deflate => {}, |
| 445 | else => return error.UnsupportedCompressionMethod, |
| 446 | } |
| 447 | |
| 448 | const local_data_header_offset: u64 = local_data_header_offset: { |
| 449 | const local_header = blk: { |
| 450 | try stream.seekTo(self.file_offset); |
| 451 | break :blk try stream.interface.takeStruct(LocalFileHeader, .little); |
| 452 | }; |
| 453 | if (!std.mem.eql(u8, &local_header.signature, &local_file_header_sig)) |
| 454 | return error.ZipBadFileOffset; |
| 455 | if (local_header.version_needed_to_extract != self.version_needed_to_extract) |
| 456 | return error.ZipMismatchVersionNeeded; |
| 457 | if (local_header.last_modification_time != self.last_modification_time) |
| 458 | return error.ZipMismatchModTime; |
| 459 | if (local_header.last_modification_date != self.last_modification_date) |
| 460 | return error.ZipMismatchModDate; |
| 461 | |
| 462 | if (@as(u16, @bitCast(local_header.flags)) != @as(u16, @bitCast(self.flags))) |
| 463 | return error.ZipMismatchFlags; |
| 464 | if (local_header.crc32 != 0 and local_header.crc32 != self.crc32) |
| 465 | return error.ZipMismatchCrc32; |
| 466 | var extents: FileExtents = .{ |
| 467 | .uncompressed_size = local_header.uncompressed_size, |
| 468 | .compressed_size = local_header.compressed_size, |
| 469 | .local_file_header_offset = 0, |
| 470 | }; |
| 471 | if (local_header.extra_len > 0) { |
| 472 | var extra_buf: [std.math.maxInt(u16)]u8 = undefined; |
| 473 | const extra = extra_buf[0..local_header.extra_len]; |
| 474 | |
| 475 | { |
| 476 | try stream.seekTo(self.file_offset + @sizeOf(LocalFileHeader) + local_header.filename_len); |
| 477 | try stream.interface.readSliceAll(extra); |
| 478 | } |
| 479 | |
| 480 | var extra_offset: usize = 0; |
| 481 | while (extra_offset + 4 <= local_header.extra_len) { |
| 482 | const header_id = std.mem.readInt(u16, extra[extra_offset..][0..2], .little); |
| 483 | const data_size = std.mem.readInt(u16, extra[extra_offset..][2..4], .little); |
| 484 | const end = extra_offset + 4 + data_size; |
| 485 | if (end > local_header.extra_len) |
| 486 | return error.ZipBadExtraFieldSize; |
| 487 | const data = extra[extra_offset + 4 .. end]; |
| 488 | switch (@as(ExtraHeader, @fromBackingInt(@intCast(header_id)))) { |
| 489 | .zip64_info => try readZip64FileExtents(LocalFileHeader, local_header, &extents, data), |
| 490 | else => {}, // ignore |
| 491 | } |
| 492 | extra_offset = end; |
| 493 | } |
| 494 | } |
| 495 | |
| 496 | if (extents.compressed_size != 0 and |
| 497 | extents.compressed_size != self.compressed_size) |
| 498 | return error.ZipMismatchCompLen; |
| 499 | if (extents.uncompressed_size != 0 and |
| 500 | extents.uncompressed_size != self.uncompressed_size) |
| 501 | return error.ZipMismatchUncompLen; |
| 502 | |
| 503 | if (local_header.filename_len != self.filename_len) |
| 504 | return error.ZipMismatchFilenameLen; |
| 505 | |
| 506 | break :local_data_header_offset @as(u64, local_header.filename_len) + |
| 507 | @as(u64, local_header.extra_len); |
| 508 | }; |
| 509 | |
| 510 | const local_data_file_offset: u64 = |
| 511 | @as(u64, self.file_offset) + |
| 512 | @as(u64, @sizeOf(LocalFileHeader)) + |
| 513 | local_data_header_offset; |
| 514 | try stream.seekTo(local_data_file_offset); |
| 515 | |
| 516 | // TODO limit based on self.compressed_size |
| 517 | |
| 518 | switch (self.compression_method) { |
| 519 | .store => { |
| 520 | stream.interface.streamExact64(w, self.uncompressed_size) catch |err| switch (err) { |
| 521 | error.ReadFailed => |e| return stream.err orelse e, |
| 522 | error.WriteFailed => |e| return e, |
| 523 | error.EndOfStream => return error.ZipDecompressTruncated, |
| 524 | }; |
| 525 | }, |
| 526 | .deflate => { |
| 527 | var flate_buffer: [flate.max_window_len]u8 = undefined; |
| 528 | var decompress: flate.Decompress = .init(&stream.interface, .raw, &flate_buffer); |
| 529 | decompress.reader.streamExact64(w, self.uncompressed_size) catch |err| switch (err) { |
| 530 | error.ReadFailed => |e| return decompress.err orelse (stream.err orelse e), |
| 531 | error.WriteFailed => |e| return e, |
| 532 | error.EndOfStream => return error.ZipDecompressTruncated, |
| 533 | }; |
| 534 | }, |
| 535 | else => return error.UnsupportedCompressionMethod, |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | pub fn extract( |
| 540 | self: Entry, |
| 541 | stream: *File.Reader, |
| 542 | options: ExtractOptions, |
| 543 | filename_buf: []u8, |
| 544 | dest: Io.Dir, |
| 545 | ) !void { |
| 546 | const io = stream.io; |
| 547 | |
| 548 | const filename = try self.getFilename(stream, filename_buf, options); |
| 549 | |
| 550 | // All entries that end in '/' are directories |
| 551 | if (filename[filename.len - 1] == '/') { |
| 552 | if (self.uncompressed_size != 0) |
| 553 | return error.ZipBadDirectorySize; |
| 554 | try dest.createDirPath(io, filename[0 .. filename.len - 1]); |
| 555 | return; |
| 556 | } |
| 557 | |
| 558 | const out_file = blk: { |
| 559 | if (std.fs.path.dirname(filename)) |dirname| { |
| 560 | var parent_dir = try dest.createDirPathOpen(io, dirname, .{}); |
| 561 | defer parent_dir.close(io); |
| 562 | |
| 563 | const basename = std.fs.path.basename(filename); |
| 564 | break :blk try parent_dir.createFile(io, basename, .{ .exclusive = true }); |
| 565 | } |
| 566 | break :blk try dest.createFile(io, filename, .{ .exclusive = true }); |
| 567 | }; |
| 568 | defer out_file.close(io); |
| 569 | var out_file_buffer: [1024]u8 = undefined; |
| 570 | var file_writer = out_file.writer(io, &out_file_buffer); |
| 571 | self.extractTo(stream, &file_writer.interface) catch |err| switch (err) { |
| 572 | error.WriteFailed => |e| return file_writer.err orelse e, |
| 573 | else => return err, |
| 574 | }; |
| 575 | try file_writer.end(); |
| 576 | } |
| 577 | }; |
| 578 | }; |
| 579 | |
| 580 | // returns true if `filename` starts with `root` followed by a forward slash |
| 581 | fn filenameInRoot(filename: []const u8, root: []const u8) bool { |
| 582 | return (filename.len >= root.len + 1) and |
| 583 | (filename[root.len] == '/') and |
| 584 | std.mem.eql(u8, filename[0..root.len], root); |
| 585 | } |
| 586 | |
| 587 | pub const Diagnostics = struct { |
| 588 | allocator: std.mem.Allocator, |
| 589 | |
| 590 | /// The common root directory for all extracted files if there is one. |
| 591 | root_dir: []const u8 = "", |
| 592 | |
| 593 | saw_first_file: bool = false, |
| 594 | |
| 595 | pub fn deinit(self: *Diagnostics) void { |
| 596 | self.allocator.free(self.root_dir); |
| 597 | self.* = undefined; |
| 598 | } |
| 599 | |
| 600 | // This function assumes name is a filename from a zip file which has already been verified to |
| 601 | // not start with a slash, backslashes have been normalized to forward slashes, and directories |
| 602 | // always end in a slash. |
| 603 | pub fn nextFilename(self: *Diagnostics, name: []const u8) error{OutOfMemory}!void { |
| 604 | if (!self.saw_first_file) { |
| 605 | self.saw_first_file = true; |
| 606 | std.debug.assert(self.root_dir.len == 0); |
| 607 | const root_len = std.mem.findScalar(u8, name, '/') orelse return; |
| 608 | std.debug.assert(root_len > 0); |
| 609 | self.root_dir = try self.allocator.dupe(u8, name[0..root_len]); |
| 610 | } else if (self.root_dir.len > 0) { |
| 611 | if (!filenameInRoot(name, self.root_dir)) { |
| 612 | self.allocator.free(self.root_dir); |
| 613 | self.root_dir = ""; |
| 614 | } |
| 615 | } |
| 616 | } |
| 617 | }; |
| 618 | |
| 619 | pub const ExtractOptions = struct { |
| 620 | /// Allow filenames within the zip to use backslashes. Back slashes are normalized |
| 621 | /// to forward slashes before forwarding them to platform APIs. |
| 622 | allow_backslashes: bool = false, |
| 623 | diagnostics: ?*Diagnostics = null, |
| 624 | verify_checksums: bool = false, |
| 625 | }; |
| 626 | |
| 627 | /// Extract the zipped files to the given `dest` directory. |
| 628 | pub fn extract(dest: Io.Dir, fr: *File.Reader, options: ExtractOptions) !void { |
| 629 | if (options.verify_checksums) @panic("TODO unimplemented"); |
| 630 | |
| 631 | var iter = try Iterator.init(fr); |
| 632 | |
| 633 | var filename_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 634 | while (try iter.next()) |entry| { |
| 635 | try entry.extract(fr, options, &filename_buf, dest); |
| 636 | if (options.diagnostics) |d| { |
| 637 | try d.nextFilename(filename_buf[0..entry.filename_len]); |
| 638 | } |
| 639 | } |
| 640 | } |
| 641 | |
| 642 | const testing = std.testing; |
| 643 | |
| 644 | test "extractTo" { |
| 645 | const io = testing.io; |
| 646 | var tmp = testing.tmpDir(.{}); |
| 647 | defer tmp.cleanup(); |
| 648 | |
| 649 | try tmp.dir.writeFile(io, .{ |
| 650 | .sub_path = "test.zip", |
| 651 | .data = @embedFile("zip/testdata/test.zip"), |
| 652 | }); |
| 653 | |
| 654 | var file = try tmp.dir.openFile(io, "test.zip", .{}); |
| 655 | defer file.close(io); |
| 656 | var read_buf: [512]u8 = undefined; |
| 657 | var reader = file.reader(io, &read_buf); |
| 658 | |
| 659 | const Expected = struct { |
| 660 | contents: []const u8, |
| 661 | compression: CompressionMethod, |
| 662 | }; |
| 663 | const expected_map = std.StaticStringMap(Expected).initComptime(.{ |
| 664 | .{ "deflate.txt", Expected{ .contents = "aaaaaaaaaaaaaaaaaaaaaaaa\n", .compression = .deflate } }, |
| 665 | .{ "store.txt", Expected{ .contents = "hello world\n", .compression = .store } }, |
| 666 | .{ "dir/", Expected{ .contents = "", .compression = .store } }, |
| 667 | }); |
| 668 | |
| 669 | var iter = try Iterator.init(&reader); |
| 670 | var num_entries: usize = 0; |
| 671 | while (try iter.next()) |entry| { |
| 672 | var filename_buf: [256]u8 = undefined; |
| 673 | const filename = try entry.getFilename(&reader, &filename_buf, .{}); |
| 674 | const expected = expected_map.get(filename) orelse { |
| 675 | std.debug.print("found unexpected filename: {f}\n", .{std.ascii.hexEscape(filename, .lower)}); |
| 676 | return error.UnexpectedFilename; |
| 677 | }; |
| 678 | var buf: [256]u8 = undefined; |
| 679 | var w: Writer = .fixed(&buf); |
| 680 | try entry.extractTo(&reader, &w); |
| 681 | try testing.expectEqualStrings(expected.contents, w.buffered()); |
| 682 | try testing.expectEqual(expected.compression, entry.compression_method); |
| 683 | num_entries += 1; |
| 684 | } |
| 685 | try testing.expectEqual(expected_map.kvs.len, num_entries); |
| 686 | } |
| 687 | |
| 688 | test "output buffers too small" { |
| 689 | const io = testing.io; |
| 690 | var tmp = testing.tmpDir(.{}); |
| 691 | defer tmp.cleanup(); |
| 692 | |
| 693 | try tmp.dir.writeFile(io, .{ |
| 694 | .sub_path = "test.zip", |
| 695 | .data = @embedFile("zip/testdata/test.zip"), |
| 696 | }); |
| 697 | |
| 698 | var file = try tmp.dir.openFile(io, "test.zip", .{}); |
| 699 | defer file.close(io); |
| 700 | var read_buf: [512]u8 = undefined; |
| 701 | var reader = file.reader(io, &read_buf); |
| 702 | |
| 703 | var iter = try Iterator.init(&reader); |
| 704 | var num_entries: usize = 0; |
| 705 | while (try iter.next()) |entry| { |
| 706 | try testing.expectError( |
| 707 | error.ZipInsufficientBuffer, |
| 708 | entry.getFilename(&reader, &.{}, .{}), |
| 709 | ); |
| 710 | |
| 711 | if (entry.uncompressed_size <= 1) continue; |
| 712 | |
| 713 | var buf: [1]u8 = undefined; |
| 714 | var w: Writer = .fixed(&buf); |
| 715 | try testing.expectError( |
| 716 | error.WriteFailed, |
| 717 | entry.extractTo(&reader, &w), |
| 718 | ); |
| 719 | num_entries += 1; |
| 720 | } |
| 721 | try std.testing.expect(num_entries > 0); |
| 722 | } |