/* SPDX-FileCopyrightText: 2026 Project Tick
* SPDX-FileContributor: Project Tick
* SPDX-License-Identifier: GPL-3.0-or-later
*
* MeshMC - A Custom Launcher for Minecraft
* Copyright (C) 2026 Project Tick
*
* This program 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.
*
* This program 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 this program. If not, see .
*
* This file incorporates work covered by the following copyright and
* permission notice:
*
* Copyright 2013-2021 MultiMC Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "MMCZip.h"
#include "FileSystem.h"
#include
#include
#include
#include
#include
#include
#include
// RAII helpers for libarchive
struct ArchiveReadDeleter {
void operator()(struct archive* a) const
{
archive_read_free(a);
} // NOLINT(readability-identifier-naming)
};
struct ArchiveWriteDeleter {
void operator()(struct archive* a) const
{
archive_write_free(a);
} // NOLINT(readability-identifier-naming)
};
using ArchiveReadPtr = std::unique_ptr<
struct archive,
ArchiveReadDeleter>; // NOLINT(readability-identifier-naming)
using ArchiveWritePtr = std::unique_ptr<
struct archive,
ArchiveWriteDeleter>; // NOLINT(readability-identifier-naming)
static ArchiveReadPtr openZipForReading(const QString& path)
{
ArchiveReadPtr a(archive_read_new());
archive_read_support_format_zip(a.get());
if (archive_read_open_filename(a.get(), path.toUtf8().constData(), 10240) !=
ARCHIVE_OK) {
qWarning() << "Could not open archive:" << path
<< archive_error_string(a.get());
return nullptr;
}
return a;
}
static ArchiveWritePtr createZipForWriting(const QString& path)
{
ArchiveWritePtr a(archive_write_new());
archive_write_set_format_zip(a.get());
if (archive_write_open_filename(a.get(), path.toUtf8().constData()) !=
ARCHIVE_OK) {
qWarning() << "Could not create archive:" << path
<< archive_error_string(a.get());
return nullptr;
}
return a;
}
static bool
copyArchiveData(struct archive* ar,
struct archive* aw) // NOLINT(readability-identifier-naming)
{
const void* buff;
size_t size;
la_int64_t offset;
int r;
while ((r = archive_read_data_block(ar, &buff, &size, &offset)) ==
ARCHIVE_OK) {
if (archive_write_data_block(aw, buff, size, offset) != ARCHIVE_OK)
return false;
}
return r == ARCHIVE_EOF;
}
static bool writeFileToArchive(
struct archive* aw,
const QString& entryName, // NOLINT(readability-identifier-naming)
const QByteArray& data)
{
struct archive_entry* entry = archive_entry_new();
archive_entry_set_pathname(entry, entryName.toUtf8().constData());
archive_entry_set_size(entry, data.size());
archive_entry_set_filetype(entry, AE_IFREG);
archive_entry_set_perm(entry, 0644);
if (archive_write_header(aw, entry) != ARCHIVE_OK) {
archive_entry_free(entry);
return false;
}
if (data.size() > 0) {
archive_write_data(aw, data.constData(), data.size());
}
archive_entry_free(entry);
return true;
}
static bool writeDiskEntry(struct archive* ar, const QString& absFilePath)
{
// Ensure parent directory exists
QFileInfo fi(absFilePath);
QDir().mkpath(fi.absolutePath());
if (absFilePath.endsWith('/')) {
// Directory entry
QDir().mkpath(absFilePath);
return true;
}
QFile outFile(absFilePath);
if (!outFile.open(QIODevice::WriteOnly)) {
qWarning() << "Failed to open for writing:" << absFilePath;
return false;
}
const void* buff;
size_t size;
la_int64_t offset;
while (archive_read_data_block(ar, &buff, &size, &offset) == ARCHIVE_OK) {
outFile.write(static_cast(buff), size);
}
outFile.close();
return true;
}
bool MMCZip::compressDir(QString zipFile, QString dir,
FilterFunction excludeFilter)
{
auto aw = createZipForWriting(zipFile);
if (!aw)
return false;
QDir directory(dir);
if (!directory.exists())
return false;
QDirIterator it(dir, QDir::Files | QDir::Hidden,
QDirIterator::Subdirectories);
bool success = true;
while (it.hasNext()) {
it.next();
QString relPath = directory.relativeFilePath(it.filePath());
if (excludeFilter && excludeFilter(relPath))
continue;
QFile file(it.filePath());
if (!file.open(QIODevice::ReadOnly)) {
success = false;
break;
}
QByteArray data = file.readAll();
file.close();
if (!writeFileToArchive(aw.get(), relPath, data)) {
success = false;
break;
}
}
archive_write_close(aw.get());
return success;
}
bool MMCZip::mergeZipFiles(const QString& intoPath, QFileInfo from,
QSet& contained,
const FilterFunction filter)
{
// Read all entries from source zip
auto ar = openZipForReading(from.filePath());
if (!ar)
return false;
// Read existing entries from target if it exists, then append new ones
// We build a list of entries to write
struct EntryData {
QString name;
QByteArray data;
};
QList newEntries;
struct archive_entry* entry;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
QString filename = QString::fromUtf8(archive_entry_pathname(entry));
if (filter && !filter(filename)) {
qDebug() << "Skipping file " << filename << " from "
<< from.fileName() << " - filtered";
archive_read_data_skip(ar.get());
continue;
}
if (contained.contains(filename)) {
qDebug() << "Skipping already contained file " << filename
<< " from " << from.fileName();
archive_read_data_skip(ar.get());
continue;
}
contained.insert(filename);
// Read data
la_int64_t entrySize = archive_entry_size(entry);
QByteArray data;
if (entrySize > 0) {
data.resize(entrySize);
la_ssize_t bytesRead =
archive_read_data(ar.get(), data.data(), entrySize);
if (bytesRead < 0) {
qCritical() << "Failed to read " << filename << " from "
<< from.fileName();
return false;
}
data.resize(bytesRead);
}
newEntries.append({filename, data});
}
// Now append to existing zip (or create new one)
// libarchive doesn't support append, so we need to read existing + write
// all
QList existingEntries;
if (QFile::exists(intoPath)) {
auto existingAr = openZipForReading(intoPath);
if (existingAr) {
while (archive_read_next_header(existingAr.get(), &entry) ==
ARCHIVE_OK) {
QString name = QString::fromUtf8(archive_entry_pathname(entry));
la_int64_t sz = archive_entry_size(entry);
QByteArray data;
if (sz > 0) {
data.resize(sz);
archive_read_data(existingAr.get(), data.data(), sz);
}
existingEntries.append({name, data});
}
}
}
auto aw = createZipForWriting(intoPath);
if (!aw)
return false;
// Write existing entries
for (const auto& e : existingEntries) {
if (!writeFileToArchive(aw.get(), e.name, e.data)) {
qCritical() << "Failed to write existing entry " << e.name;
return false;
}
}
// Write new entries
for (const auto& e : newEntries) {
if (!writeFileToArchive(aw.get(), e.name, e.data)) {
qCritical() << "Failed to write " << e.name << " into the jar";
return false;
}
}
archive_write_close(aw.get());
return true;
}
bool MMCZip::createModdedJar(QString sourceJarPath, QString targetJarPath,
const QList& mods)
{
// Files already added to the jar. These files will be skipped.
QSet addedFiles;
// We collect all entries first, then write them all at once.
struct EntryData {
QString name;
QByteArray data;
};
QList allEntries;
// Modify the jar - process mods in reverse order
QListIterator i(mods);
i.toBack();
while (i.hasPrevious()) {
const Mod& mod = i.previous();
if (!mod.enabled())
continue;
if (mod.type() == Mod::MOD_ZIPFILE) {
auto ar = openZipForReading(mod.filename().absoluteFilePath());
if (!ar) {
QFile::remove(targetJarPath);
qCritical() << "Failed to add" << mod.filename().fileName()
<< "to the jar.";
return false;
}
struct archive_entry* entry;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
QString filename =
QString::fromUtf8(archive_entry_pathname(entry));
if (addedFiles.contains(filename)) {
archive_read_data_skip(ar.get());
continue;
}
addedFiles.insert(filename);
la_int64_t sz = archive_entry_size(entry);
QByteArray data;
if (sz > 0) {
data.resize(sz);
archive_read_data(ar.get(), data.data(), sz);
}
allEntries.append({filename, data});
}
} else if (mod.type() == Mod::MOD_SINGLEFILE) {
auto filename = mod.filename();
QFile file(filename.absoluteFilePath());
if (!file.open(QIODevice::ReadOnly)) {
QFile::remove(targetJarPath);
qCritical()
<< "Failed to add" << filename.fileName() << "to the jar.";
return false;
}
allEntries.append({filename.fileName(), file.readAll()});
addedFiles.insert(filename.fileName());
} else if (mod.type() == Mod::MOD_FOLDER) {
auto filename = mod.filename();
QString what_to_zip = filename.absoluteFilePath();
QDir dir(what_to_zip);
dir.cdUp();
QString parent_dir = dir.absolutePath();
QDirIterator it(what_to_zip, QDir::Files | QDir::Hidden,
QDirIterator::Subdirectories);
while (it.hasNext()) {
it.next();
QString relPath =
QDir(parent_dir).relativeFilePath(it.filePath());
QFile f(it.filePath());
if (f.open(QIODevice::ReadOnly)) {
allEntries.append({relPath, f.readAll()});
}
}
qDebug() << "Adding folder " << filename.fileName() << " from "
<< filename.absoluteFilePath();
} else {
QFile::remove(targetJarPath);
qCritical() << "Failed to add unknown mod type"
<< mod.filename().fileName() << "to the jar.";
return false;
}
}
// Add source jar contents (skip META-INF and already added files)
auto ar = openZipForReading(sourceJarPath);
if (!ar) {
QFile::remove(targetJarPath);
qCritical() << "Failed to insert minecraft.jar contents.";
return false;
}
struct archive_entry* entry;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
QString filename = QString::fromUtf8(archive_entry_pathname(entry));
if (filename.contains("META-INF")) {
archive_read_data_skip(ar.get());
continue;
}
if (addedFiles.contains(filename)) {
archive_read_data_skip(ar.get());
continue;
}
la_int64_t sz = archive_entry_size(entry);
QByteArray data;
if (sz > 0) {
data.resize(sz);
archive_read_data(ar.get(), data.data(), sz);
}
allEntries.append({filename, data});
}
// Write the final jar
auto aw = createZipForWriting(targetJarPath);
if (!aw) {
QFile::remove(targetJarPath);
qCritical() << "Failed to open the minecraft.jar for modding";
return false;
}
for (const auto& e : allEntries) {
if (!writeFileToArchive(aw.get(), e.name, e.data)) {
archive_write_close(aw.get());
(void)QFile::remove(targetJarPath);
qCritical() << "Failed to finalize minecraft.jar!";
return false;
}
}
archive_write_close(aw.get());
return true;
}
QString MMCZip::findFolderOfFileInZip(const QString& zipPath,
const QString& what, const QString& root)
{
auto entries = listEntries(zipPath, root, QDir::Files);
for (const auto& fileName : entries) {
if (fileName == what)
return root;
}
auto dirs = listEntries(zipPath, root, QDir::Dirs);
for (const auto& dirName : dirs) {
QString result = findFolderOfFileInZip(zipPath, what, root + dirName);
if (!result.isEmpty())
return result;
}
return QString();
}
bool MMCZip::findFilesInZip(const QString& zipPath, const QString& what,
QStringList& result, const QString& root)
{
auto entries = listEntries(zipPath, root, QDir::Files);
for (const auto& fileName : entries) {
if (fileName == what) {
result.append(root);
return true;
}
}
auto dirs = listEntries(zipPath, root, QDir::Dirs);
for (const auto& dirName : dirs) {
(void)findFilesInZip(zipPath, what, result, root + dirName);
}
return !result.isEmpty();
}
nonstd::optional MMCZip::extractSubDir(const QString& zipPath,
const QString& subdir,
const QString& target)
{
QDir directory(target);
QStringList extracted;
qDebug() << "Extracting subdir" << subdir << "from" << zipPath << "to"
<< target;
auto ar = openZipForReading(zipPath);
if (!ar) {
// check if this is a minimum size empty zip file...
QFileInfo fileInfo(zipPath);
if (fileInfo.size() == 22) {
return extracted;
}
qWarning() << "Failed to open archive:" << zipPath;
return nonstd::nullopt;
}
struct archive_entry* entry;
bool hasEntries = false;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
hasEntries = true;
QString name = QString::fromUtf8(archive_entry_pathname(entry));
if (!name.startsWith(subdir)) {
archive_read_data_skip(ar.get());
continue;
}
QString relName = name.mid(subdir.size());
QString absFilePath = directory.absoluteFilePath(relName);
if (relName.isEmpty()) {
absFilePath += "/";
}
if (!writeDiskEntry(ar.get(), absFilePath)) {
qWarning() << "Failed to extract file" << name << "to"
<< absFilePath;
// Clean up extracted files
for (const auto& f : extracted)
(void)QFile::remove(f);
return nonstd::nullopt;
}
extracted.append(absFilePath);
qDebug() << "Extracted file" << relName;
}
if (!hasEntries) {
qDebug() << "Extracting empty archives seems odd...";
}
return extracted;
}
bool MMCZip::extractRelFile(const QString& zipPath, const QString& file,
const QString& target)
{
auto ar = openZipForReading(zipPath);
if (!ar)
return false;
struct archive_entry* entry;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
QString name = QString::fromUtf8(archive_entry_pathname(entry));
if (name == file) {
return writeDiskEntry(ar.get(), target);
}
archive_read_data_skip(ar.get());
}
return false;
}
nonstd::optional MMCZip::extractDir(QString fileCompressed,
QString dir)
{
return MMCZip::extractSubDir(fileCompressed, "", dir);
}
nonstd::optional MMCZip::extractDir(QString fileCompressed,
QString subdir, QString dir)
{
return MMCZip::extractSubDir(fileCompressed, subdir, dir);
}
bool MMCZip::extractFile(QString fileCompressed, QString file, QString target)
{
// check if this is a minimum size empty zip file...
QFileInfo fileInfo(fileCompressed);
if (fileInfo.size() == 22) {
return true;
}
return MMCZip::extractRelFile(fileCompressed, file, target);
}
QByteArray MMCZip::readFileFromZip(const QString& zipPath,
const QString& entryName)
{
auto ar = openZipForReading(zipPath);
if (!ar)
return {};
struct archive_entry* entry;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
QString name = QString::fromUtf8(archive_entry_pathname(entry));
if (name == entryName) {
la_int64_t sz = archive_entry_size(entry);
if (sz <= 0) {
// Size may be unknown; read in chunks
QByteArray result;
char buf[8192];
la_ssize_t r;
while ((r = archive_read_data(ar.get(), buf, sizeof(buf))) > 0)
result.append(buf, r);
return result;
}
QByteArray data(sz, Qt::Uninitialized);
archive_read_data(ar.get(), data.data(), sz);
return data;
}
archive_read_data_skip(ar.get());
}
return {};
}
bool MMCZip::entryExists(const QString& zipPath, const QString& entryName)
{
auto ar = openZipForReading(zipPath);
if (!ar)
return false;
struct archive_entry* entry;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
QString name = QString::fromUtf8(archive_entry_pathname(entry));
if (name == entryName || name == entryName + "/")
return true;
archive_read_data_skip(ar.get());
}
return false;
}
QStringList MMCZip::listEntries(const QString& zipPath)
{
QStringList result;
auto ar = openZipForReading(zipPath);
if (!ar)
return result;
struct archive_entry* entry;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
result.append(QString::fromUtf8(archive_entry_pathname(entry)));
archive_read_data_skip(ar.get());
}
return result;
}
QStringList MMCZip::listEntries(const QString& zipPath, const QString& dirPath,
QDir::Filters type)
{
QStringList result;
auto ar = openZipForReading(zipPath);
if (!ar)
return result;
QSet seen;
struct archive_entry* entry;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
QString name = QString::fromUtf8(archive_entry_pathname(entry));
// Must start with dirPath
if (!name.startsWith(dirPath)) {
archive_read_data_skip(ar.get());
continue;
}
QString relative = name.mid(dirPath.size());
// Remove leading slashes
while (relative.startsWith('/'))
relative = relative.mid(1);
if (relative.isEmpty()) {
archive_read_data_skip(ar.get());
continue;
}
int slashIdx = relative.indexOf('/');
if (slashIdx < 0) {
// It's a file directly in dirPath
if (type & QDir::Files) {
if (!seen.contains(relative)) {
seen.insert(relative);
result.append(relative);
}
}
} else {
// It's inside a subdirectory
if (type & QDir::Dirs) {
QString dirName =
relative.left(slashIdx + 1); // include trailing /
if (!seen.contains(dirName)) {
seen.insert(dirName);
result.append(dirName);
}
}
}
archive_read_data_skip(ar.get());
}
return result;
}
QDateTime MMCZip::getEntryModTime(const QString& zipPath,
const QString& entryName)
{
auto ar = openZipForReading(zipPath);
if (!ar)
return {};
struct archive_entry* entry;
while (archive_read_next_header(ar.get(), &entry) == ARCHIVE_OK) {
QString name = QString::fromUtf8(archive_entry_pathname(entry));
if (name == entryName) {
time_t mtime = archive_entry_mtime(entry);
return QDateTime::fromSecsSinceEpoch(mtime);
}
archive_read_data_skip(ar.get());
}
return {};
}