Files
easyrpg-libretro/src/filesystem_zip.cpp
2025-03-02 19:38:55 +01:00

493 lines
17 KiB
C++

/*
* This file is part of EasyRPG Player.
*
* EasyRPG Player is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EasyRPG Player is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EasyRPG Player. If not, see <http://www.gnu.org/licenses/>.
*/
#include "filesystem_zip.h"
#include "filefinder.h"
#include "output.h"
#include "utils.h"
#include <zlib.h>
#include <lcf/encoder.h>
#include <lcf/reader_util.h>
#include <lcf/scope_guard.h>
#include <iostream>
#include <sstream>
#include <cassert>
#include <algorithm>
#include <fmt/format.h>
constexpr char end_of_central_directory[] = "\x50\x4b\x05\x06";
constexpr int32_t end_of_central_directory_size = 18;
constexpr uint32_t central_directory_entry = 0x02014b50;
constexpr uint32_t local_header = 0x04034b50;
constexpr uint32_t local_header_size = 30;
static std::string normalize_path(std::string_view path) {
if (path == "." || path == "/" || path.empty()) {
return "";
};
std::string inner_path = FileFinder::MakeCanonical(path, 1);
std::replace(inner_path.begin(), inner_path.end(), '\\', '/');
if (inner_path.front() == '.') {
inner_path = inner_path.substr(1, inner_path.size() - 1);
}
if (inner_path.front() == '/') {
inner_path = inner_path.substr(1, inner_path.size() - 1);
}
return inner_path;
}
ZipFilesystem::ZipFilesystem(std::string base_path, FilesystemView parent_fs, std::string_view enc) :
Filesystem(base_path, parent_fs) {
zip_is = parent_fs.OpenInputStream(GetPath());
if (!zip_is) {
return;
}
uint16_t central_directory_entries = 0;
uint32_t central_directory_size = 0;
uint32_t central_directory_offset = 0;
ZipEntry entry = {};
entry.is_directory = false;
std::string filepath;
std::string filepath_cp437;
bool is_utf8;
encoding = ToString(enc);
if (!FindCentralDirectory(zip_is, central_directory_offset, central_directory_size, central_directory_entries)) {
Output::Debug("ZipFS: {} is not a valid archive", GetPath());
return;
}
if (encoding.empty()) {
zip_is.seekg(central_directory_offset);
std::stringstream filename_guess;
// Guess the encoding first
int items = 0;
while (ReadCentralDirectoryEntry(zip_is, filepath, entry, is_utf8)) {
// Only consider Non-ASCII & Non-UTF8 for encoding detection
// Directories are skipped as most of them are usually ASCII and do not help with the detection
if (is_utf8 || filepath.back() == '/' || Utils::StringIsAscii(std::get<1>(FileFinder::GetPathAndFilename(filepath)))) {
continue;
}
// Codepath will be only entered by Windows "compressed folder" ZIPs (uses local encoding) and
// 7zip (uses CP932 for Western European filenames)
auto pos = filepath.find_last_of('/');
if (pos == std::string::npos) {
filename_guess << filepath;
} else {
filename_guess << filepath.substr(pos + 1);
}
++items;
if (items == 10) {
break;
}
}
if (items == 0) {
// Only ASCII or UTF-8 flags set
encoding = "UTF-8";
} else {
std::vector<std::string> encodings = lcf::ReaderUtil::DetectEncodings(filename_guess.str());
for (const auto &enc_ : encodings) {
lcf::Encoder lcf_encoder(enc_);
if (!lcf_encoder.IsOk()) {
// Bad encoding
Output::Debug("Bad encoding: {}. Trying next.", enc_);
continue;
}
encoding = enc_;
break;
}
}
Output::Debug("Detected ZIP encoding: {}", encoding);
}
bool enc_is_utf8 = encoding == "UTF-8";
zip_is.clear();
zip_is.seekg(central_directory_offset);
lcf::Encoder detected_encoder(encoding);
lcf::Encoder cp437_encoder("437");
std::vector<std::string> paths;
while (ReadCentralDirectoryEntry(zip_is, filepath, entry, is_utf8)) {
if (is_utf8 || enc_is_utf8 || Utils::StringIsAscii(filepath)) {
// No reencoding necessary
filepath_cp437.clear();
} else {
// also store CP437 to ensure files inside 7zip zip archives are found
filepath_cp437 = filepath;
cp437_encoder.Encode(filepath_cp437);
detected_encoder.Encode(filepath);
}
// Workaround ZIP archives containing invalid "\" paths created by .net or Powershell
std::replace(filepath_cp437.begin(), filepath_cp437.end(), '\\', '/');
std::replace(filepath.begin(), filepath.end(), '\\', '/');
// check if the entry is an directory or not (indicated by trailing /)
// this will fail when the (game) directory has cp437, but the users can rename it before
if (filepath.back() == '/') {
filepath = filepath.substr(0, filepath.size() - 1);
// Determine intermediate directories
while (!filepath.empty()) {
paths.push_back(filepath);
filepath = std::get<0>(FileFinder::GetPathAndFilename(filepath));
}
} else {
zip_entries.emplace_back(filepath, entry);
if (!filepath_cp437.empty()) {
zip_entries_cp437.emplace_back(filepath_cp437, entry);
}
// Determine intermediate directories
for (;;) {
filepath = std::get<0>(FileFinder::GetPathAndFilename(filepath));
if (filepath.empty()) {
break;
}
paths.push_back(filepath);
}
}
}
// Build directories
entry = {};
entry.is_directory = true;
// add root path
paths.emplace_back("");
std::sort(paths.begin(), paths.end());
auto del = std::unique(paths.begin(), paths.end());
paths.erase(del, paths.end());
for (const auto& e : paths) {
zip_entries.emplace_back(e, entry);
}
// entries can be duplicated in the archive, e.g. when appending to the archive.
// Use a stable sort to preserve this order.
std::stable_sort(zip_entries.begin(), zip_entries.end(), [](auto& a, auto& b) {
return a.first < b.first;
});
std::stable_sort(zip_entries_cp437.begin(), zip_entries_cp437.end(), [](auto& a, auto& b) {
return a.first < b.first;
});
// Then remove all duplicates but keep the last
// The archive of the game "Steamed Hams" has a file with the same name as a folder. This filtering also helps against this issue.
auto entries_del_it = std::unique(zip_entries.rbegin(), zip_entries.rend(), [](auto& a, auto& b) {
return a.first == b.first;
});
zip_entries.erase(zip_entries.begin(), entries_del_it.base());
entries_del_it = std::unique(zip_entries_cp437.rbegin(), zip_entries_cp437.rend(), [](auto& a, auto& b) {
return a.first == b.first;
});
zip_entries_cp437.erase(zip_entries_cp437.begin(), entries_del_it.base());
}
bool ZipFilesystem::FindCentralDirectory(std::istream& zipfile, uint32_t& offset, uint32_t& size, uint16_t& num_entries) const {
uint32_t magic = 0;
bool found = false;
// seek to the first position where the end_of_central_directory Signature may occur
zipfile.seekg(-end_of_central_directory_size - UINT16_MAX, std::ios_base::end);
zipfile.clear();
// The only variable length field in the end of central directory is the comment which
// has a maximum length of UINT16_MAX - so if we seek longer, this is no zip file
std::vector<char> items(UINT16_MAX);
zipfile.read(items.data(), items.size());
zipfile.clear();
items.resize(zipfile.gcount());
if (items.size() < sizeof(magic)) {
return false;
}
int i = static_cast<int>(items.size()) - sizeof(magic);
// The data is read once and then scanned backwards in memory (faster)
for (; i >= 0 && !found; --i) {
if (!memcmp(items.data() + i, end_of_central_directory, 4)) {
found = true;
break;
}
}
if (found) {
zipfile.seekg(-(static_cast<int>(items.size()) - i - 4), std::ios_base::cur); // Move right after the magic
zipfile.seekg(6, std::ios_base::cur); // Jump over multiarchive related fields
zipfile.read(reinterpret_cast<char*>(&num_entries), sizeof(uint16_t));
Utils::SwapByteOrder(num_entries);
zipfile.read(reinterpret_cast<char*>(&size), sizeof(uint32_t));
Utils::SwapByteOrder(size);
zipfile.read(reinterpret_cast<char*>(&offset), sizeof(uint32_t));
Utils::SwapByteOrder(offset);
return true;
}
else {
return false;
}
}
bool ZipFilesystem::ReadCentralDirectoryEntry(std::istream& zipfile, std::string& filename, ZipEntry& entry, bool& is_utf8) const {
uint32_t magic = 0;
uint16_t flags;
uint16_t filepath_length;
uint16_t extra_field_length;
uint16_t comment_length;
zipfile.read(reinterpret_cast<char*>(&magic), sizeof(magic));
Utils::SwapByteOrder(magic); // Take care of big endian systems
if (magic != central_directory_entry) return false;
zipfile.seekg(4, std::ios_base::cur); // Jump over currently not needed entries
zipfile.read(reinterpret_cast<char*>(&flags), sizeof(uint16_t));
Utils::SwapByteOrder(flags);
is_utf8 = (flags & 0x800) == 0x800;
zipfile.seekg(10, std::ios_base::cur); // Jump over currently not needed entries
zipfile.read(reinterpret_cast<char*>(&entry.compressed_size), sizeof(uint32_t));
Utils::SwapByteOrder(entry.compressed_size);
zipfile.read(reinterpret_cast<char*>(&entry.uncompressed_size), sizeof(uint32_t));
Utils::SwapByteOrder(entry.uncompressed_size);
zipfile.read(reinterpret_cast<char*>(&filepath_length), sizeof(uint16_t));
Utils::SwapByteOrder(filepath_length);
zipfile.read(reinterpret_cast<char*>(&extra_field_length), sizeof(uint16_t));
Utils::SwapByteOrder(extra_field_length);
zipfile.read(reinterpret_cast<char*>(&comment_length), sizeof(uint16_t));
Utils::SwapByteOrder(comment_length);
zipfile.seekg(8, std::ios_base::cur); // Jump over currently not needed entries
zipfile.read(reinterpret_cast<char*>(&entry.fileoffset), sizeof(uint32_t));
Utils::SwapByteOrder(entry.fileoffset);
if (filename_buffer.capacity() < filepath_length + 1u) {
filename_buffer.resize(filepath_length + 1u);
}
zipfile.read(reinterpret_cast<char*>(filename_buffer.data()), filepath_length);
filename = std::string(filename_buffer.data(), filepath_length);
// Jump over currently not needed entries
zipfile.seekg(comment_length + extra_field_length, std::ios_base::cur);
return true;
}
bool ZipFilesystem::ReadLocalHeader(std::istream& zipfile, StorageMethod& method, ZipEntry& entry) const {
uint32_t magic = 0;
uint16_t filepath_length;
uint16_t extra_field_length;
uint16_t flags;
uint16_t compression;
zipfile.read(reinterpret_cast<char*>(&magic), sizeof(magic));
Utils::SwapByteOrder(magic); // Take care of big endian systems
if (magic != local_header) return false;
zipfile.seekg(2, std::ios_base::cur); // Jump over currently not needed entries
zipfile.read(reinterpret_cast<char*>(&flags), sizeof(uint16_t));
Utils::SwapByteOrder(flags);
zipfile.read(reinterpret_cast<char*>(&compression), sizeof(uint16_t));
Utils::SwapByteOrder(compression);
zipfile.seekg(8, std::ios_base::cur); // Jump over currently not needed entries
zipfile.read(reinterpret_cast<char*>(&entry.compressed_size), sizeof(uint32_t));
Utils::SwapByteOrder(entry.compressed_size);
zipfile.read(reinterpret_cast<char*>(&entry.uncompressed_size), sizeof(uint32_t));
Utils::SwapByteOrder(entry.uncompressed_size);
zipfile.read(reinterpret_cast<char*>(&filepath_length), sizeof(uint16_t));
Utils::SwapByteOrder(filepath_length);
zipfile.read(reinterpret_cast<char*>(&extra_field_length), sizeof(uint16_t));
Utils::SwapByteOrder(extra_field_length);
switch (compression) {
case 0:
method = StorageMethod::Plain;
break;
case 8:
method = StorageMethod::Deflate;
break;
default:
method = StorageMethod::Unknown;
break;
}
entry.fileoffset = local_header_size + filepath_length + extra_field_length;
return true;
}
bool ZipFilesystem::IsFile(std::string_view path) const {
std::string path_normalized = normalize_path(path);
auto entry = Find(path);
if (entry) {
return !entry->is_directory;
}
return false;
}
bool ZipFilesystem::IsDirectory(std::string_view path, bool) const {
std::string path_normalized = normalize_path(path);
auto entry = Find(path);
if (entry) {
return entry->is_directory;
}
return false;
}
bool ZipFilesystem::Exists(std::string_view path) const {
std::string path_normalized = normalize_path(path);
auto entry = Find(path);
return entry != nullptr;
}
int64_t ZipFilesystem::GetFilesize(std::string_view path) const {
std::string path_normalized = normalize_path(path);
auto entry = Find(path);
if (entry) {
return entry->uncompressed_size;
}
return 0;
}
std::streambuf* ZipFilesystem::CreateInputStreambuffer(std::string_view path, std::ios_base::openmode) const {
std::string path_normalized = normalize_path(path);
auto central_entry = Find(path);
if (central_entry && !central_entry->is_directory) {
zip_is.clear();
zip_is.seekg(central_entry->fileoffset);
StorageMethod method;
ZipEntry local_entry = {};
if (ReadLocalHeader(zip_is, method, local_entry)) {
if (central_entry->compressed_size != local_entry.compressed_size) {
if (local_entry.compressed_size == 0) {
local_entry.compressed_size = central_entry->compressed_size;
} else {
Output::Warning("ZipFS: Compressed size mismatch {}: {} != {}", path_normalized, central_entry->compressed_size, local_entry.compressed_size);
return nullptr;
}
}
if (central_entry->uncompressed_size != local_entry.uncompressed_size) {
if (local_entry.uncompressed_size == 0) {
local_entry.uncompressed_size = central_entry->uncompressed_size;
} else {
Output::Warning("ZipFS: Uncompressed size mismatch {}: {} != {}", path_normalized, central_entry->uncompressed_size, local_entry.uncompressed_size);
return nullptr;
}
}
if (local_entry.compressed_size == 0xffffffff || local_entry.uncompressed_size == 0xffffffff) {
Output::Warning("ZipFS: Zip64 is not supported {}", path_normalized);
return nullptr;
}
zip_is.seekg(central_entry->fileoffset + local_entry.fileoffset);
if (method == StorageMethod::Plain) {
auto data = std::vector<uint8_t>(local_entry.uncompressed_size);
zip_is.read(reinterpret_cast<char*>(data.data()), data.size());
return new Filesystem_Stream::InputMemoryStreamBuf(std::move(data));
} else if (method == StorageMethod::Deflate) {
std::vector<uint8_t> comp_buf;
comp_buf.resize(local_entry.compressed_size);
zip_is.read(reinterpret_cast<char*>(comp_buf.data()), comp_buf.size());
auto dec_buf = std::vector<uint8_t>(local_entry.uncompressed_size);
z_stream zlib_stream = {};
zlib_stream.next_in = reinterpret_cast<Bytef*>(comp_buf.data());
zlib_stream.avail_in = static_cast<uInt>(comp_buf.size());
zlib_stream.next_out = reinterpret_cast<Bytef*>(dec_buf.data());
zlib_stream.avail_out = static_cast<uInt>(dec_buf.size());
inflateInit2(&zlib_stream, -MAX_WBITS);
auto inflate_sg = lcf::makeScopeGuard([&]() {
inflateEnd(&zlib_stream);
});
int zlib_error = inflate(&zlib_stream, Z_NO_FLUSH);
if (zlib_error == Z_OK) {
Output::Warning("ZipFS: zlib failed for {}: More data available (Archive corrupted?)", path_normalized);
return nullptr;
}
else if (zlib_error != Z_STREAM_END) {
Output::Warning("ZipFS: zlib failed for {}: {} ({})", path_normalized, zlib_error, zlib_stream.msg ? zlib_stream.msg : "No error message");
return nullptr;
}
return new Filesystem_Stream::InputMemoryStreamBuf(std::move(dec_buf));
} else {
Output::Warning("ZipFS: {} has unsupported compression format. Only Deflate is supported", path_normalized);
return nullptr;
}
}
}
return nullptr;
}
bool ZipFilesystem::GetDirectoryContent(std::string_view path, std::vector<DirectoryTree::Entry>& entries) const {
if (!IsDirectory(path, false)) {
return false;
}
std::string path_normalized = normalize_path(path);
if (!path_normalized.empty() && path_normalized.back() != '/') {
path_normalized += "/";
}
auto check = [&](auto& it) {
if (StartsWith(it.first, path_normalized) &&
it.first.substr(path_normalized.size(), it.first.size() - path_normalized.size()).find_last_of('/') == std::string::npos) {
// Everything that starts with the path but isn't the path and does contain no slash
auto filename = it.first.substr(path_normalized.size(), it.first.size() - path_normalized.size());
if (filename.empty()) {
return;
}
entries.emplace_back(
it.first.substr(path_normalized.size(), it.first.size() - path_normalized.size()),
it.second.is_directory ? DirectoryTree::FileType::Directory : DirectoryTree::FileType::Regular);
}
};
for (const auto& it : zip_entries) {
check(it);
}
for (const auto& it : zip_entries_cp437) {
check(it);
}
return true;
}
const ZipFilesystem::ZipEntry* ZipFilesystem::Find(std::string_view what) const {
auto it = std::lower_bound(zip_entries.begin(), zip_entries.end(), what, [](const auto& e, const auto& w) {
return e.first < w;
});
if (it != zip_entries.end() && it->first == what) {
return &it->second;
}
it = std::lower_bound(zip_entries_cp437.begin(), zip_entries_cp437.end(), what, [](const auto& e, const auto& w) {
return e.first < w;
});
if (it != zip_entries_cp437.end() && it->first == what) {
return &it->second;
}
return nullptr;
}
std::string ZipFilesystem::Describe() const {
return fmt::format("[Zip] {} ({})", GetPath(), encoding);
}