A fork of https://github.com/crosspoint-reader/crosspoint-reader
1#include "ZipFile.h"
2
3#include <HalStorage.h>
4#include <InflateReader.h>
5#include <Logging.h>
6
7#include <algorithm>
8
9struct ZipInflateCtx {
10 InflateReader reader; // Must be first — callback casts uzlib_uncomp* to ZipInflateCtx*
11 FsFile* file = nullptr;
12 size_t fileRemaining = 0;
13 uint8_t* readBuf = nullptr;
14 size_t readBufSize = 0;
15};
16
17namespace {
18constexpr uint16_t ZIP_METHOD_STORED = 0;
19constexpr uint16_t ZIP_METHOD_DEFLATED = 8;
20
21// RAII zip: opens the zip if not already open, closes on destruction only if
22// it performed the open. Removes the wasOpen/close boilerplate from every method.
23class ScopedOpenClose final {
24 public:
25 [[nodiscard]] explicit ScopedOpenClose(ZipFile& zf) : zf(zf), needsClose(!zf.isOpen()) {
26 if (needsClose) ok = zf.open();
27 }
28 ~ScopedOpenClose() {
29 if (needsClose && ok) zf.close();
30 }
31 ScopedOpenClose(const ScopedOpenClose&) = delete;
32 ScopedOpenClose& operator=(const ScopedOpenClose&) = delete;
33 ScopedOpenClose(ScopedOpenClose&&) = delete;
34 ScopedOpenClose& operator=(ScopedOpenClose&&) = delete;
35 explicit operator bool() const { return ok || !needsClose; }
36
37 private:
38 ZipFile& zf;
39 bool needsClose = false;
40 bool ok = true; // true when zip was already open (no open() call needed)
41};
42
43int zipReadCallback(uzlib_uncomp* uncomp) {
44 auto* ctx = reinterpret_cast<ZipInflateCtx*>(uncomp);
45 if (ctx->fileRemaining == 0) return -1;
46
47 const size_t toRead = ctx->fileRemaining < ctx->readBufSize ? ctx->fileRemaining : ctx->readBufSize;
48 const size_t bytesRead = ctx->file->read(ctx->readBuf, toRead);
49 ctx->fileRemaining -= bytesRead;
50
51 if (bytesRead == 0) return -1;
52
53 uncomp->source = ctx->readBuf + 1;
54 uncomp->source_limit = ctx->readBuf + bytesRead;
55 return ctx->readBuf[0];
56}
57} // namespace
58
59bool ZipFile::loadAllFileStatSlims() {
60 const ScopedOpenClose zip{*this};
61 if (!zip) return false;
62
63 if (!loadZipDetails()) return false;
64
65 file.seek(zipDetails.centralDirOffset);
66
67 uint32_t sig;
68 char itemName[256];
69 fileStatSlimCache.clear();
70 fileStatSlimCache.reserve(zipDetails.totalEntries);
71
72 while (file.available()) {
73 file.read(&sig, 4);
74 if (sig != 0x02014b50) break; // End of list
75
76 FileStatSlim fileStat = {};
77
78 file.seekCur(6);
79 file.read(&fileStat.method, 2);
80 file.seekCur(8);
81 file.read(&fileStat.compressedSize, 4);
82 file.read(&fileStat.uncompressedSize, 4);
83 uint16_t nameLen, m, k;
84 file.read(&nameLen, 2);
85 file.read(&m, 2);
86 file.read(&k, 2);
87 file.seekCur(8);
88 file.read(&fileStat.localHeaderOffset, 4);
89
90 if (nameLen < sizeof(itemName)) {
91 file.read(itemName, nameLen);
92 itemName[nameLen] = '\0';
93 fileStatSlimCache.emplace(itemName, fileStat);
94 } else {
95 // Skip over oversized entry names to avoid writing past fixed buffer.
96 file.seekCur(nameLen);
97 }
98
99 // Skip the rest of this entry (extra field + comment)
100 file.seekCur(m + k);
101 }
102
103 // Set cursor to start of central directory for sequential access
104 lastCentralDirPos = zipDetails.centralDirOffset;
105 lastCentralDirPosValid = true;
106
107 return true;
108}
109
110bool ZipFile::loadFileStatSlim(const char* filename, FileStatSlim* fileStat) {
111 if (!fileStatSlimCache.empty()) {
112 const auto it = fileStatSlimCache.find(filename);
113 if (it != fileStatSlimCache.end()) {
114 *fileStat = it->second;
115 return true;
116 }
117 return false;
118 }
119
120 const ScopedOpenClose zip{*this};
121 if (!zip) return false;
122
123 if (!loadZipDetails()) return false;
124
125 // Phase 1: Try scanning from cursor position first
126 uint32_t startPos = lastCentralDirPosValid ? lastCentralDirPos : zipDetails.centralDirOffset;
127 bool wrapped = false;
128 bool found = false;
129
130 file.seek(startPos);
131
132 uint32_t sig;
133 char itemName[256];
134
135 while (true) {
136 uint32_t entryStart = file.position();
137
138 if (file.read(&sig, 4) != 4 || sig != 0x02014b50) {
139 // End of central directory
140 if (!wrapped && lastCentralDirPosValid && startPos != zipDetails.centralDirOffset) {
141 // Wrap around to beginning
142 file.seek(zipDetails.centralDirOffset);
143 wrapped = true;
144 continue;
145 }
146 break;
147 }
148
149 // If we've wrapped and reached our start position, stop
150 if (wrapped && entryStart >= startPos) {
151 break;
152 }
153
154 file.seekCur(6);
155 file.read(&fileStat->method, 2);
156 file.seekCur(8);
157 file.read(&fileStat->compressedSize, 4);
158 file.read(&fileStat->uncompressedSize, 4);
159 uint16_t nameLen, m, k;
160 file.read(&nameLen, 2);
161 file.read(&m, 2);
162 file.read(&k, 2);
163 file.seekCur(8);
164 file.read(&fileStat->localHeaderOffset, 4);
165
166 if (nameLen < 256) {
167 file.read(itemName, nameLen);
168 itemName[nameLen] = '\0';
169
170 if (strcmp(itemName, filename) == 0) {
171 // Found it! Update cursor to next entry
172 file.seekCur(m + k);
173 lastCentralDirPos = file.position();
174 lastCentralDirPosValid = true;
175 found = true;
176 break;
177 }
178 } else {
179 // Name too long, skip it
180 file.seekCur(nameLen);
181 }
182
183 // Skip extra field + comment
184 file.seekCur(m + k);
185 }
186
187 return found;
188}
189
190long ZipFile::getDataOffset(const FileStatSlim& fileStat) {
191 const ScopedOpenClose zip{*this};
192 if (!zip) return -1;
193
194 constexpr auto localHeaderSize = 30;
195
196 uint8_t pLocalHeader[localHeaderSize];
197 const uint64_t fileOffset = fileStat.localHeaderOffset;
198
199 file.seek(fileOffset);
200 const size_t read = file.read(pLocalHeader, localHeaderSize);
201
202 if (read != localHeaderSize) {
203 LOG_ERR("ZIP", "Something went wrong reading the local header");
204 return -1;
205 }
206
207 if (pLocalHeader[0] + (pLocalHeader[1] << 8) + (pLocalHeader[2] << 16) + (pLocalHeader[3] << 24) !=
208 0x04034b50 /* ZIP local file header signature */) {
209 LOG_ERR("ZIP", "Not a valid zip file header");
210 return -1;
211 }
212
213 const uint16_t filenameLength = pLocalHeader[26] + (pLocalHeader[27] << 8);
214 const uint16_t extraOffset = pLocalHeader[28] + (pLocalHeader[29] << 8);
215 return fileOffset + localHeaderSize + filenameLength + extraOffset;
216}
217
218bool ZipFile::loadZipDetails() {
219 if (zipDetails.isSet) {
220 return true;
221 }
222
223 const ScopedOpenClose zip{*this};
224 if (!zip) return false;
225
226 const size_t fileSize = file.size();
227 if (fileSize < 22) {
228 LOG_ERR("ZIP", "File too small to be a valid zip");
229 return false; // Minimum EOCD size is 22 bytes
230 }
231
232 // We scan the last 1KB (or the whole file if smaller) for the EOCD signature
233 // 0x06054b50 is stored as 0x50, 0x4b, 0x05, 0x06 in little-endian
234 const int scanRange = fileSize > 1024 ? 1024 : fileSize;
235 const auto buffer = static_cast<uint8_t*>(malloc(scanRange));
236 if (!buffer) {
237 LOG_ERR("ZIP", "Failed to allocate memory for EOCD scan buffer");
238 return false;
239 }
240
241 file.seek(fileSize - scanRange);
242 file.read(buffer, scanRange);
243
244 // Scan backwards for the signature
245 int foundOffset = -1;
246 for (int i = scanRange - 22; i >= 0; i--) {
247 constexpr uint32_t signature = 0x06054b50;
248 if (*reinterpret_cast<uint32_t*>(&buffer[i]) == signature) {
249 foundOffset = i;
250 break;
251 }
252 }
253
254 if (foundOffset == -1) {
255 LOG_ERR("ZIP", "EOCD signature not found in zip file");
256 free(buffer);
257 return false;
258 }
259
260 // Now extract the values we need from the EOCD record
261 // Relative positions within EOCD:
262 // Offset 10: Total number of entries (2 bytes)
263 // Offset 16: Offset of start of central directory with respect to the starting disk number (4 bytes)
264 zipDetails.totalEntries = *reinterpret_cast<uint16_t*>(&buffer[foundOffset + 10]);
265 zipDetails.centralDirOffset = *reinterpret_cast<uint32_t*>(&buffer[foundOffset + 16]);
266 zipDetails.isSet = true;
267
268 free(buffer);
269 return true;
270}
271
272bool ZipFile::open() {
273 if (!Storage.openFileForRead("ZIP", filePath, file)) {
274 return false;
275 }
276 return true;
277}
278
279bool ZipFile::close() {
280 if (file) {
281 // Explicit close() required: member variable persists beyond function scope
282 file.close();
283 }
284 lastCentralDirPos = 0;
285 lastCentralDirPosValid = false;
286 return true;
287}
288
289bool ZipFile::getInflatedFileSize(const char* filename, size_t* size) {
290 FileStatSlim fileStat = {};
291 if (!loadFileStatSlim(filename, &fileStat)) {
292 return false;
293 }
294
295 *size = static_cast<size_t>(fileStat.uncompressedSize);
296 return true;
297}
298
299int ZipFile::fillUncompressedSizes(std::deque<SizeTarget>& targets, std::deque<uint32_t>& sizes) {
300 if (targets.empty()) {
301 return 0;
302 }
303
304 const ScopedOpenClose zip{*this};
305 if (!zip) return 0;
306
307 if (!loadZipDetails()) return 0;
308
309 file.seek(zipDetails.centralDirOffset);
310
311 int matched = 0;
312 const int targetCount = static_cast<int>(targets.size());
313 uint32_t sig;
314 char itemName[256];
315
316 while (file.available()) {
317 file.read(&sig, 4);
318 if (sig != 0x02014b50) break;
319
320 file.seekCur(6);
321 uint16_t method;
322 file.read(&method, 2);
323 file.seekCur(8);
324 uint32_t compressedSize, uncompressedSize;
325 file.read(&compressedSize, 4);
326 file.read(&uncompressedSize, 4);
327 uint16_t nameLen, m, k;
328 file.read(&nameLen, 2);
329 file.read(&m, 2);
330 file.read(&k, 2);
331 file.seekCur(8);
332 uint32_t localHeaderOffset;
333 file.read(&localHeaderOffset, 4);
334
335 if (nameLen < 256) {
336 file.read(itemName, nameLen);
337 itemName[nameLen] = '\0';
338
339 uint64_t hash = fnvHash64(itemName, nameLen);
340 SizeTarget key = {hash, nameLen, 0};
341
342 auto it = std::lower_bound(targets.begin(), targets.end(), key, [](const SizeTarget& a, const SizeTarget& b) {
343 return a.hash < b.hash || (a.hash == b.hash && a.len < b.len);
344 });
345
346 while (it != targets.end() && it->hash == hash && it->len == nameLen) {
347 if (it->index < sizes.size()) {
348 sizes[it->index] = uncompressedSize;
349 matched++;
350 }
351 ++it;
352 }
353
354 if (matched >= targetCount) {
355 break;
356 }
357 } else {
358 file.seekCur(nameLen);
359 }
360
361 file.seekCur(m + k);
362 }
363
364 return matched;
365}
366
367uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const bool trailingNullByte) {
368 const ScopedOpenClose zip{*this};
369 if (!zip) return nullptr;
370
371 FileStatSlim fileStat = {};
372 if (!loadFileStatSlim(filename, &fileStat)) return nullptr;
373
374 const long fileOffset = getDataOffset(fileStat);
375 if (fileOffset < 0) return nullptr;
376
377 file.seek(fileOffset);
378
379 const auto deflatedDataSize = fileStat.compressedSize;
380 const auto inflatedDataSize = fileStat.uncompressedSize;
381 const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize;
382 const auto data = static_cast<uint8_t*>(malloc(dataSize));
383 if (data == nullptr) {
384 LOG_ERR("ZIP", "Failed to allocate memory for output buffer (%zu bytes)", dataSize);
385 return nullptr;
386 }
387
388 if (fileStat.method == ZIP_METHOD_STORED) {
389 // no deflation, just read content
390 const size_t dataRead = file.read(data, inflatedDataSize);
391
392 if (dataRead != inflatedDataSize) {
393 LOG_ERR("ZIP", "Failed to read data");
394 free(data);
395 return nullptr;
396 }
397
398 // Continue out of block with data set
399 } else if (fileStat.method == ZIP_METHOD_DEFLATED) {
400 // Read out deflated content from file
401 const auto deflatedData = static_cast<uint8_t*>(malloc(deflatedDataSize));
402 if (deflatedData == nullptr) {
403 LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer");
404 free(data);
405 return nullptr;
406 }
407
408 const size_t dataRead = file.read(deflatedData, deflatedDataSize);
409
410 if (dataRead != deflatedDataSize) {
411 LOG_ERR("ZIP", "Failed to read data, expected %d got %d", deflatedDataSize, dataRead);
412 free(deflatedData);
413 free(data);
414 return nullptr;
415 }
416
417 bool success = false;
418 {
419 InflateReader r;
420 r.init(false);
421 r.setSource(deflatedData, deflatedDataSize);
422 success = r.read(data, inflatedDataSize);
423 }
424 free(deflatedData);
425
426 if (!success) {
427 LOG_ERR("ZIP", "Failed to inflate file");
428 free(data);
429 return nullptr;
430 }
431
432 // Continue out of block with data set
433 } else {
434 LOG_ERR("ZIP", "Unsupported compression method");
435 free(data);
436 return nullptr;
437 }
438
439 if (trailingNullByte) data[inflatedDataSize] = '\0';
440 if (size) *size = inflatedDataSize;
441 return data;
442}
443
444bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize) {
445 const ScopedOpenClose zip{*this};
446 if (!zip) return false;
447
448 FileStatSlim fileStat = {};
449 if (!loadFileStatSlim(filename, &fileStat)) return false;
450
451 const long fileOffset = getDataOffset(fileStat);
452 if (fileOffset < 0) return false;
453
454 file.seek(fileOffset);
455 const auto deflatedDataSize = fileStat.compressedSize;
456 const auto inflatedDataSize = fileStat.uncompressedSize;
457
458 if (fileStat.method == ZIP_METHOD_STORED) {
459 // no deflation, just read content
460 const auto buffer = static_cast<uint8_t*>(malloc(chunkSize));
461 if (!buffer) {
462 LOG_ERR("ZIP", "Failed to allocate memory for buffer");
463 return false;
464 }
465
466 size_t remaining = inflatedDataSize;
467 while (remaining > 0) {
468 const size_t dataRead = file.read(buffer, remaining < chunkSize ? remaining : chunkSize);
469 if (dataRead == 0) {
470 LOG_ERR("ZIP", "Could not read more bytes");
471 free(buffer);
472 return false;
473 }
474
475 if (out.write(buffer, dataRead) != dataRead) {
476 LOG_ERR("ZIP", "Failed to write all output bytes to stream");
477 free(buffer);
478 return false;
479 }
480 remaining -= dataRead;
481 }
482
483 free(buffer);
484 return true;
485 }
486
487 if (fileStat.method == ZIP_METHOD_DEFLATED) {
488 auto* fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize));
489 if (!fileReadBuffer) {
490 LOG_ERR("ZIP", "Failed to allocate memory for zip file read buffer");
491 return false;
492 }
493
494 auto* outputBuffer = static_cast<uint8_t*>(malloc(chunkSize));
495 if (!outputBuffer) {
496 LOG_ERR("ZIP", "Failed to allocate memory for output buffer");
497 free(fileReadBuffer);
498 return false;
499 }
500
501 ZipInflateCtx ctx;
502 ctx.file = &file;
503 ctx.fileRemaining = deflatedDataSize;
504 ctx.readBuf = fileReadBuffer;
505 ctx.readBufSize = chunkSize;
506
507 if (!ctx.reader.init(true)) {
508 LOG_ERR("ZIP", "Failed to init inflate reader");
509 free(outputBuffer);
510 free(fileReadBuffer);
511 return false;
512 }
513 ctx.reader.setReadCallback(zipReadCallback);
514
515 bool success = false;
516 size_t totalProduced = 0;
517
518 while (true) {
519 size_t produced;
520 const InflateStatus status = ctx.reader.readAtMost(outputBuffer, chunkSize, &produced);
521
522 totalProduced += produced;
523 if (totalProduced > static_cast<size_t>(inflatedDataSize)) {
524 LOG_ERR("ZIP", "Decompressed size exceeds expected (%zu > %zu)", totalProduced,
525 static_cast<size_t>(inflatedDataSize));
526 break;
527 }
528
529 if (produced > 0) {
530 if (out.write(outputBuffer, produced) != produced) {
531 LOG_ERR("ZIP", "Failed to write all output bytes to stream");
532 break;
533 }
534 }
535
536 if (status == InflateStatus::Done) {
537 if (totalProduced != static_cast<size_t>(inflatedDataSize)) {
538 LOG_ERR("ZIP", "Decompressed size mismatch (expected %zu, got %zu)", static_cast<size_t>(inflatedDataSize),
539 totalProduced);
540 break;
541 }
542 LOG_DBG("ZIP", "Decompressed %d bytes into %d bytes", deflatedDataSize, inflatedDataSize);
543 success = true;
544 break;
545 }
546
547 if (status == InflateStatus::Error) {
548 LOG_ERR("ZIP", "Decompression failed");
549 break;
550 }
551 // InflateStatus::Ok: output buffer full, continue
552 }
553
554 free(outputBuffer);
555 free(fileReadBuffer);
556 return success; // ctx.reader destructor frees the ring buffer
557 }
558
559 LOG_ERR("ZIP", "Unsupported compression method");
560 return false;
561}