You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
filemanager/cio/archivejob.cpp

722 lines
23 KiB
C++

#include "archivejob.h"
#include <zip.h>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFileInfoList>
#include <QUuid>
#include <cstdlib>
#include <cstring>
#ifdef Q_OS_UNIX
#include <sys/stat.h>
#endif
namespace {
QString zipError(zip_t *archive)
{
if (!archive)
return QStringLiteral("Unknown ZIP error");
return QString::fromUtf8(zip_strerror(archive));
}
QString zipOpenError(int errorCode)
{
zip_error_t error;
zip_error_init_with_code(&error, errorCode);
const QString message = QString::fromUtf8(zip_error_strerror(&error));
zip_error_fini(&error);
return message;
}
QString temporaryPath(const QString &parentPath, const QString &prefix)
{
return QDir(parentPath).filePath(QStringLiteral(".%1-%2")
.arg(prefix, QUuid::createUuid().toString(QUuid::WithoutBraces)));
}
bool isDirectoryEntry(const QString &name)
{
return name.endsWith(QLatin1Char('/'));
}
bool hasSymbolicLinkParent(const QString &rootPath, const QString &relativePath)
{
const QStringList components = relativePath.split(QLatin1Char('/'), Qt::SkipEmptyParts);
QString currentPath = rootPath;
for (qsizetype index = 0; index + 1 < components.size(); ++index) {
currentPath = QDir(currentPath).filePath(components.at(index));
const QFileInfo info(currentPath);
if (info.isSymLink())
return true;
if (!info.exists())
break;
}
return false;
}
QString topLevelArchiveName(const QString &archivePath)
{
const qsizetype separator = archivePath.indexOf(QLatin1Char('/'));
return separator < 0 ? archivePath : archivePath.left(separator);
}
} // namespace
ArchiveJob::ArchiveJob(Operation operation,
const QStringList &inputs,
const QString &outputPath,
QObject *parent)
: QThread(parent)
, m_operation(operation)
, m_inputs(inputs)
, m_outputPath(outputPath)
, m_cancelRequested(false)
{
}
ArchiveJob::~ArchiveJob()
{
cancel();
wait();
}
void ArchiveJob::cancel()
{
m_cancelRequested.store(true);
}
bool ArchiveJob::isCanceled() const
{
return m_cancelRequested.load();
}
void ArchiveJob::run()
{
QString error;
const bool success = m_operation == Compress ? compress(&error) : extract(&error);
// Once the final rename has succeeded, the operation is committed. A
// cancellation arriving in the tiny window before this function reads
// the flag must not turn a completed operation into a false cancellation.
const bool canceled = !success && isCanceled();
emit completed(success && !canceled, canceled, error,
success && !canceled ? m_outputPath : QString());
}
void ArchiveJob::emitProgress(quint64 processed, quint64 total)
{
if (total == 0) {
emit progressChanged(processed == 0 ? 0 : 100);
return;
}
const double ratio = static_cast<double>(processed) / static_cast<double>(total);
emit progressChanged(qBound(0, static_cast<int>(ratio * 100.0), 100));
}
bool ArchiveJob::collectCompressionEntries(const QString &sourcePath,
const QString &archivePath,
QList<CompressionEntry> *entries,
QString *error) const
{
if (isCanceled())
return false;
const QFileInfo info(sourcePath);
if (!info.exists() && !info.isSymLink()) {
*error = QStringLiteral("The source no longer exists: %1").arg(sourcePath);
return false;
}
if (info.isSymLink()) {
#ifdef Q_OS_UNIX
const QString linkTarget = info.symLinkTarget();
if (linkTarget.isEmpty()) {
*error = QStringLiteral("Could not read symbolic link: %1").arg(sourcePath);
return false;
}
entries->append({sourcePath, archivePath, false, 0, true, linkTarget});
return true;
#else
*error = QStringLiteral("Symbolic links are not supported: %1").arg(sourcePath);
return false;
#endif
}
if (info.isDir()) {
const QString directoryArchivePath = archivePath.endsWith(QLatin1Char('/'))
? archivePath
: archivePath + QLatin1Char('/');
entries->append({sourcePath, directoryArchivePath, true, 0, false, QString()});
QDir directory(sourcePath);
const QFileInfoList children = directory.entryInfoList(
QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot,
QDir::DirsFirst | QDir::Name);
for (const QFileInfo &child : children) {
const QString childArchivePath = directoryArchivePath + child.fileName();
if (!collectCompressionEntries(child.absoluteFilePath(), childArchivePath, entries, error))
return false;
}
return true;
}
if (!info.isFile()) {
*error = QStringLiteral("The source is not a regular file: %1").arg(sourcePath);
return false;
}
entries->append({sourcePath, archivePath, false, static_cast<quint64>(info.size()), false, QString()});
return true;
}
bool ArchiveJob::compress(QString *error)
{
if (m_inputs.isEmpty()) {
*error = QStringLiteral("No files were selected");
return false;
}
QList<CompressionEntry> entries;
for (const QString &input : m_inputs) {
const QFileInfo info(input);
if (!collectCompressionEntries(input, info.fileName(), &entries, error))
return false;
}
if (isCanceled())
return false;
const QString parentPath = QFileInfo(m_outputPath).absolutePath();
if (!QDir().exists(parentPath)) {
*error = QStringLiteral("The destination folder does not exist: %1").arg(parentPath);
return false;
}
const QString tempPath = temporaryPath(parentPath, QStringLiteral("cutefish-archive"));
int openError = 0;
const QByteArray tempPathBytes = QFile::encodeName(tempPath);
zip_t *archive = zip_open(tempPathBytes.constData(), ZIP_CREATE | ZIP_TRUNCATE, &openError);
if (!archive) {
QFile::remove(tempPath);
*error = zipOpenError(openError);
return false;
}
if (zip_register_progress_callback_with_state(archive, 0.01, &ArchiveJob::zipProgressCallback,
nullptr, this) != 0 ||
zip_register_cancel_callback_with_state(archive, &ArchiveJob::zipCancelCallback,
nullptr, this) != 0) {
*error = zipError(archive);
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
emit progressChanged(0);
for (const CompressionEntry &entry : entries) {
if (isCanceled()) {
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
// Keep the progress UI focused on the selected item. A folder may
// contain many entries, but its internal archive paths are not useful
// to show while the archive is being created.
emit currentFileChanged(topLevelArchiveName(entry.archivePath));
const QByteArray archiveName = entry.archivePath.toUtf8();
if (entry.directory) {
if (zip_dir_add(archive, archiveName.constData(), ZIP_FL_ENC_UTF_8) < 0) {
*error = zipError(archive);
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
continue;
}
zip_source_t *source = nullptr;
if (entry.symbolicLink) {
const QByteArray linkTarget = entry.linkTarget.toUtf8();
const size_t bufferSize = qMax<size_t>(linkTarget.size(), 1);
char *buffer = static_cast<char *>(std::malloc(bufferSize));
if (!buffer) {
*error = QStringLiteral("Could not allocate memory for symbolic link");
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
std::memcpy(buffer, linkTarget.constData(), static_cast<size_t>(linkTarget.size()));
source = zip_source_buffer(archive, buffer, linkTarget.size(), 1);
if (!source)
std::free(buffer);
} else {
const QByteArray sourceName = QFile::encodeName(entry.sourcePath);
source = zip_source_file(archive, sourceName.constData(), 0, -1);
}
if (!source) {
*error = zipError(archive);
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
const zip_int64_t entryIndex = zip_file_add(archive, archiveName.constData(), source,
ZIP_FL_ENC_UTF_8);
if (entryIndex < 0) {
zip_source_free(source);
*error = zipError(archive);
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
// Use a fast, low-level DEFLATE profile. This keeps compression close
// to Finder's quick archive behavior while avoiding an unnecessarily
// expensive maximum-compression pass.
if (zip_set_file_compression(archive,
static_cast<zip_uint64_t>(entryIndex),
ZIP_CM_DEFLATE,
1) != 0) {
*error = zipError(archive);
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
if (entry.symbolicLink &&
zip_file_set_external_attributes(archive,
static_cast<zip_uint64_t>(entryIndex),
0,
ZIP_OPSYS_UNIX,
static_cast<zip_uint32_t>(S_IFLNK | 0777) << 16) != 0) {
*error = zipError(archive);
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
}
if (isCanceled()) {
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
if (zip_close(archive) != 0) {
*error = zipError(archive);
zip_discard(archive);
QFile::remove(tempPath);
return false;
}
if (isCanceled() || !QFile::rename(tempPath, m_outputPath)) {
QFile::remove(tempPath);
if (!isCanceled())
*error = QStringLiteral("Could not create %1").arg(m_outputPath);
return false;
}
emit progressChanged(100);
return true;
}
bool ArchiveJob::archiveEntryPath(const QString &archiveName,
QString *relativePath,
bool *directory) const
{
QString name = archiveName;
name.replace(QLatin1Char('\\'), QLatin1Char('/'));
*directory = isDirectoryEntry(name);
while (name.endsWith(QLatin1Char('/')))
name.chop(1);
if (name.isEmpty()) {
relativePath->clear();
return true;
}
if (name.startsWith(QLatin1Char('/')) ||
(name.size() >= 2 && name.at(1) == QLatin1Char(':')) ||
name.contains(QChar::Null))
return false;
const QStringList components = name.split(QLatin1Char('/'), Qt::SkipEmptyParts);
for (const QString &component : components) {
if (component == QLatin1String(".."))
return false;
}
const QString cleanName = QDir::cleanPath(name);
if (cleanName == QLatin1String(".") || cleanName.startsWith(QLatin1String("../")) ||
cleanName == QLatin1String("..") || cleanName.startsWith(QLatin1Char('/'))) {
return false;
}
*relativePath = cleanName;
return true;
}
bool ArchiveJob::zipEntryIsSymbolicLink(zip_t *archive, zip_uint64_t index)
{
#ifdef Q_OS_UNIX
zip_uint8_t operatingSystem = ZIP_OPSYS_UNIX;
zip_uint32_t externalAttributes = 0;
if (zip_file_get_external_attributes(archive, index, 0,
&operatingSystem, &externalAttributes) != 0) {
return false;
}
if (operatingSystem != ZIP_OPSYS_UNIX && operatingSystem != ZIP_OPSYS_OS_X)
return false;
const mode_t mode = static_cast<mode_t>(externalAttributes >> 16);
return S_ISLNK(mode);
#else
Q_UNUSED(archive)
Q_UNUSED(index)
return false;
#endif
}
bool ArchiveJob::extract(QString *error)
{
if (m_inputs.size() != 1) {
*error = QStringLiteral("Only one archive can be extracted at a time");
return false;
}
const QString archivePath = m_inputs.first();
const QByteArray archivePathBytes = QFile::encodeName(archivePath);
int openError = 0;
zip_t *archive = zip_open(archivePathBytes.constData(), 0, &openError);
if (!archive) {
*error = zipOpenError(openError);
return false;
}
quint64 total = 0;
const zip_int64_t entriesCount = zip_get_num_entries(archive, 0);
if (entriesCount < 0) {
*error = zipError(archive);
zip_discard(archive);
return false;
}
for (zip_uint64_t index = 0; index < static_cast<zip_uint64_t>(entriesCount); ++index) {
if (isCanceled()) {
zip_discard(archive);
return false;
}
zip_stat_t stat;
zip_stat_init(&stat);
if (zip_stat_index(archive, index, 0, &stat) != 0 || !stat.name) {
*error = zipError(archive);
zip_discard(archive);
return false;
}
QString relativePath;
bool directory = false;
if (!archiveEntryPath(QString::fromUtf8(stat.name), &relativePath, &directory)) {
*error = QStringLiteral("Unsafe path in archive: %1").arg(QString::fromUtf8(stat.name));
zip_discard(archive);
return false;
}
if (!directory && !zipEntryIsSymbolicLink(archive, index))
total += stat.size;
}
const QString parentPath = QFileInfo(m_outputPath).absolutePath();
if (!QDir().exists(parentPath)) {
*error = QStringLiteral("The destination folder does not exist: %1").arg(parentPath);
zip_discard(archive);
return false;
}
const QString tempPath = temporaryPath(parentPath, QStringLiteral("cutefish-extract"));
if (!QDir().mkpath(tempPath)) {
*error = QStringLiteral("Could not create a temporary extraction folder");
zip_discard(archive);
return false;
}
quint64 processed = 0;
emit progressChanged(0);
for (zip_uint64_t index = 0; index < static_cast<zip_uint64_t>(entriesCount); ++index) {
if (isCanceled()) {
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
zip_stat_t stat;
zip_stat_init(&stat);
if (zip_stat_index(archive, index, 0, &stat) != 0 || !stat.name) {
*error = zipError(archive);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
QString relativePath;
bool directory = false;
if (!archiveEntryPath(QString::fromUtf8(stat.name), &relativePath, &directory)) {
*error = QStringLiteral("Unsafe path in archive: %1").arg(QString::fromUtf8(stat.name));
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
if (zipEntryIsSymbolicLink(archive, index))
continue;
if (relativePath.isEmpty())
continue;
const QString destinationPath = QDir(tempPath).filePath(relativePath);
emit currentFileChanged(relativePath);
if (directory) {
if (!QDir().mkpath(destinationPath)) {
*error = QStringLiteral("Could not create %1").arg(relativePath);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
continue;
}
if (!QDir().mkpath(QFileInfo(destinationPath).absolutePath())) {
*error = QStringLiteral("Could not create the parent folder for %1").arg(relativePath);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
zip_file_t *file = zip_fopen_index(archive, index, 0);
if (!file) {
*error = zipError(archive);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
QFile output(destinationPath);
if (!output.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
*error = output.errorString();
zip_fclose(file);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
char buffer[64 * 1024];
while (true) {
if (isCanceled()) {
output.close();
zip_fclose(file);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
const zip_int64_t bytesRead = zip_fread(file, buffer, sizeof(buffer));
if (bytesRead < 0) {
*error = zipError(archive);
output.close();
zip_fclose(file);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
if (bytesRead == 0)
break;
if (output.write(buffer, bytesRead) != bytesRead) {
*error = output.errorString();
output.close();
zip_fclose(file);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
processed += static_cast<quint64>(bytesRead);
emitProgress(processed, total);
}
output.close();
if (zip_fclose(file) != 0) {
*error = zipError(archive);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
}
// Create symbolic links only after all regular files have been written.
// A link inside an archive must never redirect extraction of a later file.
for (zip_uint64_t index = 0; index < static_cast<zip_uint64_t>(entriesCount); ++index) {
if (isCanceled()) {
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
if (!zipEntryIsSymbolicLink(archive, index))
continue;
zip_stat_t stat;
zip_stat_init(&stat);
if (zip_stat_index(archive, index, 0, &stat) != 0 || !stat.name) {
*error = zipError(archive);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
QString relativePath;
bool directory = false;
if (!archiveEntryPath(QString::fromUtf8(stat.name), &relativePath, &directory) ||
directory || relativePath.isEmpty()) {
*error = QStringLiteral("Invalid symbolic link path in archive: %1")
.arg(QString::fromUtf8(stat.name));
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
emit currentFileChanged(relativePath);
const QString destinationPath = QDir(tempPath).filePath(relativePath);
if (hasSymbolicLinkParent(tempPath, relativePath)) {
*error = QStringLiteral("Symbolic link parent in archive: %1").arg(relativePath);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
if (!QDir().mkpath(QFileInfo(destinationPath).absolutePath())) {
*error = QStringLiteral("Could not create the parent folder for %1").arg(relativePath);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
const QFileInfo existing(destinationPath);
if (existing.exists() || existing.isSymLink()) {
*error = QStringLiteral("Duplicate path in archive: %1").arg(relativePath);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
zip_file_t *file = zip_fopen_index(archive, index, 0);
if (!file) {
*error = zipError(archive);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
QByteArray target;
char buffer[4096];
bool readError = false;
while (true) {
const zip_int64_t bytesRead = zip_fread(file, buffer, sizeof(buffer));
if (bytesRead < 0) {
readError = true;
break;
}
if (bytesRead == 0)
break;
if (target.size() > 16 * 1024 - bytesRead) {
*error = QStringLiteral("Symbolic link target is too long: %1").arg(relativePath);
readError = true;
break;
}
target.append(buffer, static_cast<qsizetype>(bytesRead));
}
const int closeResult = zip_fclose(file);
if (readError || closeResult != 0 || target.contains('\0')) {
if (readError && error->isEmpty())
*error = zipError(archive);
if (error->isEmpty())
*error = QStringLiteral("Invalid symbolic link target: %1").arg(relativePath);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
#ifdef Q_OS_UNIX
if (!QFile::link(QString::fromUtf8(target), destinationPath)) {
*error = QStringLiteral("Could not restore symbolic link: %1").arg(relativePath);
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
}
#else
*error = QStringLiteral("Symbolic links are not supported on this platform");
zip_discard(archive);
QDir(tempPath).removeRecursively();
return false;
#endif
}
zip_discard(archive);
if (isCanceled() || !QFile::rename(tempPath, m_outputPath)) {
QDir(tempPath).removeRecursively();
if (!isCanceled())
*error = QStringLiteral("Could not create %1").arg(m_outputPath);
return false;
}
emit progressChanged(100);
return true;
}
void ArchiveJob::zipProgressCallback(zip_t *archive, double progress, void *state)
{
Q_UNUSED(archive)
auto *job = static_cast<ArchiveJob *>(state);
if (!job)
return;
job->emitProgress(static_cast<quint64>(progress * 100.0), 100);
}
int ArchiveJob::zipCancelCallback(zip_t *archive, void *state)
{
Q_UNUSED(archive)
auto *job = static_cast<ArchiveJob *>(state);
return job && job->isCanceled() ? 1 : 0;
}