fix: harden search and release safeguards
This commit is contained in:
@@ -34,6 +34,10 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: cmake --build build --parallel
|
run: cmake --build build --parallel
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
shell: bash
|
||||||
|
run: ctest --test-dir build --output-on-failure
|
||||||
|
|
||||||
- name: Smoke test GUI
|
- name: Smoke test GUI
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
/build/
|
/build/
|
||||||
|
/cmake-build-*/
|
||||||
|
/out/
|
||||||
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
|
/CMakeLists.txt.user
|
||||||
|
/CMakeLists.txt.user.*
|
||||||
|
|||||||
+38
-1
@@ -14,14 +14,51 @@ set(CMAKE_AUTOMOC ON)
|
|||||||
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS Widgets Concurrent DBus)
|
find_package(Qt6 REQUIRED COMPONENTS Widgets Concurrent DBus)
|
||||||
|
|
||||||
|
add_library(folderscope_core STATIC
|
||||||
|
src/search_core.cpp
|
||||||
|
src/search_core.h
|
||||||
|
)
|
||||||
|
target_link_libraries(folderscope_core PUBLIC Qt6::Core)
|
||||||
|
target_compile_options(folderscope_core PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
target_include_directories(folderscope_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||||
|
|
||||||
qt_add_executable(searchmyfiles
|
qt_add_executable(searchmyfiles
|
||||||
|
src/long_long_spin_box.cpp
|
||||||
|
src/long_long_spin_box.h
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(searchmyfiles PRIVATE Qt6::Widgets Qt6::Concurrent Qt6::DBus)
|
target_link_libraries(searchmyfiles PRIVATE folderscope_core Qt6::Widgets Qt6::Concurrent Qt6::DBus)
|
||||||
target_compile_options(searchmyfiles PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(searchmyfiles PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
target_compile_definitions(searchmyfiles PRIVATE FOLDERSCOPE_VERSION="${PROJECT_VERSION}")
|
target_compile_definitions(searchmyfiles PRIVATE FOLDERSCOPE_VERSION="${PROJECT_VERSION}")
|
||||||
|
|
||||||
install(TARGETS searchmyfiles RUNTIME DESTINATION bin)
|
install(TARGETS searchmyfiles RUNTIME DESTINATION bin)
|
||||||
install(FILES packaging/searchmyfiles.desktop
|
install(FILES packaging/searchmyfiles.desktop
|
||||||
DESTINATION share/applications)
|
DESTINATION share/applications)
|
||||||
|
|
||||||
|
include(CTest)
|
||||||
|
if (BUILD_TESTING)
|
||||||
|
find_package(Qt6 REQUIRED COMPONENTS Test)
|
||||||
|
|
||||||
|
qt_add_executable(search_core_tests
|
||||||
|
tests/search_core_test.cpp
|
||||||
|
)
|
||||||
|
target_link_libraries(search_core_tests PRIVATE folderscope_core Qt6::Test)
|
||||||
|
target_compile_options(search_core_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
add_test(NAME search-core COMMAND search_core_tests)
|
||||||
|
|
||||||
|
qt_add_executable(long_long_spin_box_tests
|
||||||
|
src/long_long_spin_box.cpp
|
||||||
|
src/long_long_spin_box.h
|
||||||
|
tests/long_long_spin_box_test.cpp
|
||||||
|
)
|
||||||
|
target_link_libraries(long_long_spin_box_tests PRIVATE Qt6::Widgets Qt6::Test)
|
||||||
|
target_compile_options(long_long_spin_box_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
target_include_directories(long_long_spin_box_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||||
|
add_test(NAME long-long-spin-box COMMAND long_long_spin_box_tests)
|
||||||
|
set_tests_properties(long-long-spin-box PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||||
|
|
||||||
|
add_test(NAME release-rejects-untracked
|
||||||
|
COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/tests/release_dirty_test.sh"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/release.sh")
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ cmake --build build -j
|
|||||||
./build/searchmyfiles
|
./build/searchmyfiles
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run the automated tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ctest --test-dir build --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
Install into the current system:
|
Install into the current system:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ fi
|
|||||||
version="$1"
|
version="$1"
|
||||||
tag="v${version}"
|
tag="v${version}"
|
||||||
|
|
||||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
echo "Refusing to release with uncommitted changes." >&2
|
echo "Refusing to release with uncommitted changes." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#include "long_long_spin_box.h"
|
||||||
|
|
||||||
|
#include <QLineEdit>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
LongLongSpinBox::LongLongSpinBox(QWidget *parent) : QAbstractSpinBox(parent)
|
||||||
|
{
|
||||||
|
setValue(0);
|
||||||
|
connect(this, &QAbstractSpinBox::editingFinished, this, &LongLongSpinBox::commitText);
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 LongLongSpinBox::value() const
|
||||||
|
{
|
||||||
|
qint64 parsed = value_;
|
||||||
|
return valueFromText(lineEdit()->text(), parsed) ? parsed : value_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void LongLongSpinBox::setValue(qint64 value)
|
||||||
|
{
|
||||||
|
value_ = std::clamp(value, minimum_, maximum_);
|
||||||
|
lineEdit()->setText(textFromValue(value_));
|
||||||
|
}
|
||||||
|
|
||||||
|
void LongLongSpinBox::setRange(qint64 minimum, qint64 maximum)
|
||||||
|
{
|
||||||
|
minimum_ = minimum;
|
||||||
|
maximum_ = std::max(minimum, maximum);
|
||||||
|
setValue(value_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LongLongSpinBox::setSingleStep(qint64 step)
|
||||||
|
{
|
||||||
|
singleStep_ = std::max<qint64>(1, step);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LongLongSpinBox::setSuffix(const QString &suffix)
|
||||||
|
{
|
||||||
|
suffix_ = suffix;
|
||||||
|
setValue(value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void LongLongSpinBox::setSpecialValueText(const QString &text)
|
||||||
|
{
|
||||||
|
specialValueText_ = text;
|
||||||
|
setValue(value());
|
||||||
|
}
|
||||||
|
|
||||||
|
QValidator::State LongLongSpinBox::validate(QString &input, int &position) const
|
||||||
|
{
|
||||||
|
Q_UNUSED(position);
|
||||||
|
if (!specialValueText_.isEmpty() && input == specialValueText_)
|
||||||
|
return QValidator::Acceptable;
|
||||||
|
|
||||||
|
QString number = input;
|
||||||
|
if (!suffix_.isEmpty() && number.endsWith(suffix_))
|
||||||
|
number.chop(suffix_.size());
|
||||||
|
number = number.trimmed();
|
||||||
|
if (number.isEmpty() || number == QStringLiteral("-"))
|
||||||
|
return QValidator::Intermediate;
|
||||||
|
|
||||||
|
qint64 parsed = 0;
|
||||||
|
if (!valueFromText(input, parsed))
|
||||||
|
return QValidator::Invalid;
|
||||||
|
return parsed >= minimum_ && parsed <= maximum_
|
||||||
|
? QValidator::Acceptable : QValidator::Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void LongLongSpinBox::fixup(QString &input) const
|
||||||
|
{
|
||||||
|
qint64 parsed = value_;
|
||||||
|
input = valueFromText(input, parsed)
|
||||||
|
? textFromValue(std::clamp(parsed, minimum_, maximum_))
|
||||||
|
: textFromValue(value_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LongLongSpinBox::stepBy(int steps)
|
||||||
|
{
|
||||||
|
commitText();
|
||||||
|
qint64 next = value_;
|
||||||
|
if (steps > 0) {
|
||||||
|
const qint64 count = steps;
|
||||||
|
next = singleStep_ > (maximum_ - value_) / count
|
||||||
|
? maximum_ : value_ + singleStep_ * count;
|
||||||
|
} else if (steps < 0) {
|
||||||
|
const qint64 count = -qint64(steps);
|
||||||
|
next = singleStep_ > (value_ - minimum_) / count
|
||||||
|
? minimum_ : value_ - singleStep_ * count;
|
||||||
|
}
|
||||||
|
setValue(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
QAbstractSpinBox::StepEnabled LongLongSpinBox::stepEnabled() const
|
||||||
|
{
|
||||||
|
StepEnabled enabled = StepNone;
|
||||||
|
const qint64 current = value();
|
||||||
|
if (current > minimum_)
|
||||||
|
enabled |= StepDownEnabled;
|
||||||
|
if (current < maximum_)
|
||||||
|
enabled |= StepUpEnabled;
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString LongLongSpinBox::textFromValue(qint64 value) const
|
||||||
|
{
|
||||||
|
if (value == minimum_ && !specialValueText_.isEmpty())
|
||||||
|
return specialValueText_;
|
||||||
|
return QString::number(value) + suffix_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool LongLongSpinBox::valueFromText(const QString &text, qint64 &value) const
|
||||||
|
{
|
||||||
|
if (!specialValueText_.isEmpty() && text == specialValueText_) {
|
||||||
|
value = minimum_;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
QString number = text;
|
||||||
|
if (!suffix_.isEmpty() && number.endsWith(suffix_))
|
||||||
|
number.chop(suffix_.size());
|
||||||
|
bool ok = false;
|
||||||
|
const qint64 parsed = number.trimmed().toLongLong(&ok);
|
||||||
|
if (ok)
|
||||||
|
value = parsed;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
void LongLongSpinBox::commitText()
|
||||||
|
{
|
||||||
|
setValue(value());
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAbstractSpinBox>
|
||||||
|
#include <QValidator>
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
class LongLongSpinBox final : public QAbstractSpinBox {
|
||||||
|
public:
|
||||||
|
explicit LongLongSpinBox(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
qint64 value() const;
|
||||||
|
void setValue(qint64 value);
|
||||||
|
void setRange(qint64 minimum, qint64 maximum);
|
||||||
|
void setSingleStep(qint64 step);
|
||||||
|
void setSuffix(const QString &suffix);
|
||||||
|
void setSpecialValueText(const QString &text);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
QValidator::State validate(QString &input, int &position) const override;
|
||||||
|
void fixup(QString &input) const override;
|
||||||
|
void stepBy(int steps) override;
|
||||||
|
StepEnabled stepEnabled() const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString textFromValue(qint64 value) const;
|
||||||
|
bool valueFromText(const QString &text, qint64 &value) const;
|
||||||
|
void commitText();
|
||||||
|
|
||||||
|
qint64 minimum_ = 0;
|
||||||
|
qint64 maximum_ = std::numeric_limits<qint64>::max();
|
||||||
|
qint64 value_ = 0;
|
||||||
|
qint64 singleStep_ = 1;
|
||||||
|
QString suffix_;
|
||||||
|
QString specialValueText_;
|
||||||
|
};
|
||||||
+53
-232
@@ -55,13 +55,15 @@
|
|||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QtConcurrent>
|
#include <QtConcurrent>
|
||||||
|
|
||||||
|
#include "long_long_spin_box.h"
|
||||||
|
#include "search_core.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <pwd.h>
|
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
@@ -69,220 +71,6 @@
|
|||||||
|
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
struct SearchOptions {
|
|
||||||
QString roots = QDir::homePath();
|
|
||||||
QString fileWildcards = QStringLiteral("*");
|
|
||||||
QString subfolderWildcards = QStringLiteral("*");
|
|
||||||
QString excludeFiles;
|
|
||||||
QString excludeFolders;
|
|
||||||
QString includeFolders;
|
|
||||||
QString contains;
|
|
||||||
bool binary = false;
|
|
||||||
bool multipleValues = false;
|
|
||||||
bool multipleAnd = false;
|
|
||||||
bool caseSensitive = false;
|
|
||||||
qint64 minSize = 0;
|
|
||||||
qint64 maxSize = 0;
|
|
||||||
qint64 minTime = 0;
|
|
||||||
qint64 maxTime = 0;
|
|
||||||
QString timeField = QStringLiteral("Modified time");
|
|
||||||
int lastMinutes = 0;
|
|
||||||
bool recursive = true;
|
|
||||||
bool findFiles = true;
|
|
||||||
bool findFolders = false;
|
|
||||||
bool followLinks = false;
|
|
||||||
bool retrieveOwner = false;
|
|
||||||
bool accurateProgress = true;
|
|
||||||
bool useCache = true;
|
|
||||||
int maxDepth = 0;
|
|
||||||
int maxResults = 0;
|
|
||||||
QString mode = QStringLiteral("Standard search");
|
|
||||||
QString duplicateNameMode = QStringLiteral("All files and folders");
|
|
||||||
bool duplicateNameWithoutExtension = false;
|
|
||||||
bool showOnlyDuplicateFiles = true;
|
|
||||||
bool includeSubfoldersInSummary = false;
|
|
||||||
QString hidden = QStringLiteral("Any");
|
|
||||||
QString readonly = QStringLiteral("Any");
|
|
||||||
QString executable = QStringLiteral("Any");
|
|
||||||
};
|
|
||||||
|
|
||||||
struct FileRecord {
|
|
||||||
QString path;
|
|
||||||
QString name;
|
|
||||||
QString folder;
|
|
||||||
qint64 size = 0;
|
|
||||||
qint64 sizeOnDisk = 0;
|
|
||||||
qint64 modified = 0;
|
|
||||||
qint64 created = 0;
|
|
||||||
qint64 accessed = 0;
|
|
||||||
qint64 changed = 0;
|
|
||||||
QString type;
|
|
||||||
QString owner;
|
|
||||||
QString attributes;
|
|
||||||
int group = 0;
|
|
||||||
int duplicateCopy = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
Q_DECLARE_METATYPE(SearchOptions)
|
|
||||||
Q_DECLARE_METATYPE(FileRecord)
|
|
||||||
Q_DECLARE_METATYPE(QVector<FileRecord>)
|
|
||||||
|
|
||||||
static QStringList patterns(const QString &text)
|
|
||||||
{
|
|
||||||
QStringList out;
|
|
||||||
QString item;
|
|
||||||
bool quoted = false;
|
|
||||||
for (QChar ch : text) {
|
|
||||||
if (ch == u'"') {
|
|
||||||
quoted = !quoted;
|
|
||||||
} else if (!quoted && (ch == u';' || ch == u',')) {
|
|
||||||
if (!item.trimmed().isEmpty())
|
|
||||||
out.push_back(item.trimmed());
|
|
||||||
item.clear();
|
|
||||||
} else {
|
|
||||||
item += ch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!item.trimmed().isEmpty())
|
|
||||||
out.push_back(item.trimmed());
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
static QStringList exclusionPatterns(const QString &text)
|
|
||||||
{
|
|
||||||
QString normalized = text;
|
|
||||||
normalized.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(";"));
|
|
||||||
QStringList result = patterns(normalized);
|
|
||||||
for (QString &pattern : result) {
|
|
||||||
if (!pattern.contains(u'*') && !pattern.contains(u'?') && !pattern.contains(u'/'))
|
|
||||||
pattern = QStringLiteral("*.") + pattern.remove(QRegularExpression(QStringLiteral("^\\.")));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs)
|
|
||||||
{
|
|
||||||
for (const QString &pattern : items) {
|
|
||||||
const auto regex = QRegularExpression(
|
|
||||||
QRegularExpression::wildcardToRegularExpression(pattern),
|
|
||||||
cs == Qt::CaseInsensitive ? QRegularExpression::CaseInsensitiveOption
|
|
||||||
: QRegularExpression::NoPatternOption);
|
|
||||||
if (regex.match(value).hasMatch())
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static QString humanSize(qint64 bytes)
|
|
||||||
{
|
|
||||||
static const QStringList units{QStringLiteral("B"), QStringLiteral("KB"),
|
|
||||||
QStringLiteral("MB"), QStringLiteral("GB"),
|
|
||||||
QStringLiteral("TB")};
|
|
||||||
double value = static_cast<double>(bytes);
|
|
||||||
int unit = 0;
|
|
||||||
while (value >= 1024.0 && unit < units.size() - 1) {
|
|
||||||
value /= 1024.0;
|
|
||||||
++unit;
|
|
||||||
}
|
|
||||||
return unit == 0 ? QStringLiteral("%1 B").arg(bytes)
|
|
||||||
: QStringLiteral("%1 %2").arg(value, 0, 'f', 2).arg(units[unit]);
|
|
||||||
}
|
|
||||||
|
|
||||||
static QString ownerName(uid_t uid)
|
|
||||||
{
|
|
||||||
struct passwd pwd {};
|
|
||||||
struct passwd *result = nullptr;
|
|
||||||
QByteArray buffer(16384, Qt::Uninitialized);
|
|
||||||
if (getpwuid_r(uid, &pwd, buffer.data(), static_cast<size_t>(buffer.size()), &result) == 0 && result)
|
|
||||||
return QString::fromLocal8Bit(result->pw_name);
|
|
||||||
return QString::number(uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::optional<QByteArray> hexNeedle(QString text)
|
|
||||||
{
|
|
||||||
text.remove(QRegularExpression(QStringLiteral("\\s+")));
|
|
||||||
if (text.size() % 2 != 0 || !QRegularExpression(QStringLiteral("^[0-9a-fA-F]*$")).match(text).hasMatch())
|
|
||||||
return std::nullopt;
|
|
||||||
return QByteArray::fromHex(text.toLatin1());
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool fileContains(const QString &path, const SearchOptions &o)
|
|
||||||
{
|
|
||||||
if (o.contains.isEmpty())
|
|
||||||
return true;
|
|
||||||
|
|
||||||
QStringList terms = o.multipleValues ? patterns(o.contains) : QStringList{o.contains};
|
|
||||||
QList<QByteArray> needles;
|
|
||||||
for (const QString &term : terms) {
|
|
||||||
if (o.binary) {
|
|
||||||
const auto bytes = hexNeedle(term);
|
|
||||||
if (!bytes)
|
|
||||||
return false;
|
|
||||||
needles.push_back(*bytes);
|
|
||||||
} else {
|
|
||||||
needles.push_back(term.toUtf8());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (needles.isEmpty())
|
|
||||||
return true;
|
|
||||||
|
|
||||||
QFile file(path);
|
|
||||||
if (!file.open(QIODevice::ReadOnly))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
QVector<bool> found(needles.size(), false);
|
|
||||||
qsizetype overlap = 1;
|
|
||||||
for (const QByteArray &needle : needles)
|
|
||||||
overlap = std::max(overlap, needle.size());
|
|
||||||
QByteArray tail;
|
|
||||||
while (!file.atEnd()) {
|
|
||||||
QByteArray data = tail + file.read(1024 * 1024);
|
|
||||||
if (!o.caseSensitive && !o.binary)
|
|
||||||
data = data.toLower();
|
|
||||||
for (qsizetype i = 0; i < needles.size(); ++i) {
|
|
||||||
QByteArray needle = needles[i];
|
|
||||||
if (!o.caseSensitive && !o.binary)
|
|
||||||
needle = needle.toLower();
|
|
||||||
if (!found[i] && data.contains(needle))
|
|
||||||
found[i] = true;
|
|
||||||
}
|
|
||||||
const bool matched = o.multipleAnd
|
|
||||||
? std::all_of(found.cbegin(), found.cend(), [](bool x) { return x; })
|
|
||||||
: std::any_of(found.cbegin(), found.cend(), [](bool x) { return x; });
|
|
||||||
if (matched)
|
|
||||||
return true;
|
|
||||||
tail = data.right(overlap - 1);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool filesEqual(const QString &leftPath, const QString &rightPath)
|
|
||||||
{
|
|
||||||
QFile left(leftPath);
|
|
||||||
QFile right(rightPath);
|
|
||||||
if (!left.open(QIODevice::ReadOnly) || !right.open(QIODevice::ReadOnly)
|
|
||||||
|| left.size() != right.size())
|
|
||||||
return false;
|
|
||||||
while (!left.atEnd()) {
|
|
||||||
const QByteArray a = left.read(1024 * 1024);
|
|
||||||
const QByteArray b = right.read(a.size());
|
|
||||||
if (a != b)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return right.atEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
static QByteArray sha256(const QString &path)
|
|
||||||
{
|
|
||||||
QFile file(path);
|
|
||||||
if (!file.open(QIODevice::ReadOnly))
|
|
||||||
return {};
|
|
||||||
QCryptographicHash hash(QCryptographicHash::Sha256);
|
|
||||||
while (!file.atEnd())
|
|
||||||
hash.addData(file.read(1024 * 1024));
|
|
||||||
return hash.result();
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SearchCacheEntry {
|
struct SearchCacheEntry {
|
||||||
qint64 size = -1;
|
qint64 size = -1;
|
||||||
qint64 modified = -1;
|
qint64 modified = -1;
|
||||||
@@ -1080,7 +868,7 @@ public:
|
|||||||
binary_ = new QCheckBox(tr("Binary search (hex bytes, e.g. A2 C5 2F)")); binary_->setChecked(o.binary);
|
binary_ = new QCheckBox(tr("Binary search (hex bytes, e.g. A2 C5 2F)")); binary_->setChecked(o.binary);
|
||||||
multiple_ = new QCheckBox(tr("Search multiple values (comma separated)")); multiple_->setChecked(o.multipleValues);
|
multiple_ = new QCheckBox(tr("Search multiple values (comma separated)")); multiple_->setChecked(o.multipleValues);
|
||||||
multipleMode_ = new QComboBox; multipleMode_->addItems({tr("Or"), tr("And")});
|
multipleMode_ = new QComboBox; multipleMode_->addItems({tr("Or"), tr("And")});
|
||||||
multipleMode_->setCurrentText(o.multipleAnd ? tr("And") : tr("Or"));
|
multipleMode_->setCurrentIndex(o.multipleAnd ? 1 : 0);
|
||||||
caseSensitive_ = new QCheckBox(tr("Case sensitive")); caseSensitive_->setChecked(o.caseSensitive);
|
caseSensitive_ = new QCheckBox(tr("Case sensitive")); caseSensitive_->setChecked(o.caseSensitive);
|
||||||
contentForm->addRow(binary_);
|
contentForm->addRow(binary_);
|
||||||
contentForm->addRow(multiple_);
|
contentForm->addRow(multiple_);
|
||||||
@@ -1119,7 +907,7 @@ public:
|
|||||||
tabs->addTab(filterPage, tr("Size, time & attributes"));
|
tabs->addTab(filterPage, tr("Size, time & attributes"));
|
||||||
|
|
||||||
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||||
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
connect(buttons, &QDialogButtonBox::accepted, this, &OptionsDialog::accept);
|
||||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
connect(browse, &QPushButton::clicked, this, [this] {
|
connect(browse, &QPushButton::clicked, this, [this] {
|
||||||
const QStringList existingFolders = patterns(roots_->text());
|
const QStringList existingFolders = patterns(roots_->text());
|
||||||
@@ -1151,14 +939,14 @@ public:
|
|||||||
o.contains = contains_->toPlainText();
|
o.contains = contains_->toPlainText();
|
||||||
o.binary = binary_->isChecked();
|
o.binary = binary_->isChecked();
|
||||||
o.multipleValues = multiple_->isChecked();
|
o.multipleValues = multiple_->isChecked();
|
||||||
o.multipleAnd = multipleMode_->currentText() == tr("And");
|
o.multipleAnd = multipleMode_->currentIndex() == 1;
|
||||||
o.caseSensitive = caseSensitive_->isChecked();
|
o.caseSensitive = caseSensitive_->isChecked();
|
||||||
o.minSize = minSize_->value();
|
o.minSize = minSize_->value();
|
||||||
o.maxSize = maxSize_->value();
|
o.maxSize = maxSize_->value();
|
||||||
o.timeField = timeField_->currentText();
|
o.timeField = timeField_->currentText();
|
||||||
o.lastMinutes = lastMinutes_->value();
|
o.lastMinutes = lastMinutes_->value();
|
||||||
o.minTime = QDateTime::fromString(minTime_->text(), Qt::ISODate).toSecsSinceEpoch();
|
o.minTime = dateValue(minTime_);
|
||||||
o.maxTime = QDateTime::fromString(maxTime_->text(), Qt::ISODate).toSecsSinceEpoch();
|
o.maxTime = dateValue(maxTime_);
|
||||||
o.recursive = recursive_->isChecked();
|
o.recursive = recursive_->isChecked();
|
||||||
o.findFiles = findFiles_->isChecked();
|
o.findFiles = findFiles_->isChecked();
|
||||||
o.findFolders = findFolders_->isChecked();
|
o.findFolders = findFolders_->isChecked();
|
||||||
@@ -1177,6 +965,15 @@ public:
|
|||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void accept() override
|
||||||
|
{
|
||||||
|
if (!validateDate(minTime_, tr("From time"))
|
||||||
|
|| !validateDate(maxTime_, tr("To time")))
|
||||||
|
return;
|
||||||
|
QDialog::accept();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static QLineEdit *addLine(QFormLayout *layout, const QString &label, const QString &value)
|
static QLineEdit *addLine(QFormLayout *layout, const QString &label, const QString &value)
|
||||||
{
|
{
|
||||||
@@ -1184,14 +981,33 @@ private:
|
|||||||
layout->addRow(label, edit);
|
layout->addRow(label, edit);
|
||||||
return edit;
|
return edit;
|
||||||
}
|
}
|
||||||
static QSpinBox *sizeSpin(qint64 value)
|
static LongLongSpinBox *sizeSpin(qint64 value)
|
||||||
{
|
{
|
||||||
auto *spin = new QSpinBox;
|
auto *spin = new LongLongSpinBox;
|
||||||
spin->setRange(0, 2'000'000'000);
|
spin->setRange(0, std::numeric_limits<qint64>::max());
|
||||||
spin->setSuffix(QObject::tr(" bytes"));
|
spin->setSuffix(QObject::tr(" bytes"));
|
||||||
spin->setValue(static_cast<int>(std::min<qint64>(value, spin->maximum())));
|
spin->setValue(value);
|
||||||
return spin;
|
return spin;
|
||||||
}
|
}
|
||||||
|
static qint64 dateValue(const QLineEdit *edit)
|
||||||
|
{
|
||||||
|
qint64 seconds = 0;
|
||||||
|
parseOptionalIsoDate(edit->text(), seconds);
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
bool validateDate(QLineEdit *edit, const QString &label)
|
||||||
|
{
|
||||||
|
qint64 seconds = 0;
|
||||||
|
if (parseOptionalIsoDate(edit->text(), seconds))
|
||||||
|
return true;
|
||||||
|
QMessageBox::warning(
|
||||||
|
this, tr("Invalid date"),
|
||||||
|
tr("%1 must be a valid ISO date and time, for example:\n2026-07-25T20:30:00")
|
||||||
|
.arg(label));
|
||||||
|
edit->setFocus();
|
||||||
|
edit->selectAll();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
static QComboBox *attrCombo(const QString &value)
|
static QComboBox *attrCombo(const QString &value)
|
||||||
{
|
{
|
||||||
auto *combo = new QComboBox;
|
auto *combo = new QComboBox;
|
||||||
@@ -1214,8 +1030,8 @@ private:
|
|||||||
QCheckBox *multiple_{};
|
QCheckBox *multiple_{};
|
||||||
QComboBox *multipleMode_{};
|
QComboBox *multipleMode_{};
|
||||||
QCheckBox *caseSensitive_{};
|
QCheckBox *caseSensitive_{};
|
||||||
QSpinBox *minSize_{};
|
LongLongSpinBox *minSize_{};
|
||||||
QSpinBox *maxSize_{};
|
LongLongSpinBox *maxSize_{};
|
||||||
QComboBox *timeField_{};
|
QComboBox *timeField_{};
|
||||||
QSpinBox *lastMinutes_{};
|
QSpinBox *lastMinutes_{};
|
||||||
QLineEdit *minTime_{};
|
QLineEdit *minTime_{};
|
||||||
@@ -1925,7 +1741,7 @@ private slots:
|
|||||||
this, &MainWindow::showProperties);
|
this, &MainWindow::showProperties);
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
menu.addAction(tr("Refresh"), QKeySequence(Qt::Key_F5), this, &MainWindow::refreshSearch);
|
menu.addAction(tr("Refresh"), QKeySequence(Qt::Key_F5), this, &MainWindow::refreshSearch);
|
||||||
menu.exec(table_->viewport()->mapToGlobal(point));
|
menu.exec(table_->mapToGlobal(point));
|
||||||
}
|
}
|
||||||
|
|
||||||
void headerContextMenu(const QPoint &point)
|
void headerContextMenu(const QPoint &point)
|
||||||
@@ -2132,11 +1948,16 @@ private:
|
|||||||
QStringList selectedPaths() const
|
QStringList selectedPaths() const
|
||||||
{
|
{
|
||||||
QStringList paths;
|
QStringList paths;
|
||||||
QSet<int> sourceRows;
|
QModelIndexList selected = table_->selectionModel()->selectedRows();
|
||||||
for (const QModelIndex &index : table_->selectionModel()->selectedRows())
|
std::sort(selected.begin(), selected.end(),
|
||||||
sourceRows.insert(proxy_->mapToSource(index).row());
|
[](const QModelIndex &left, const QModelIndex &right) {
|
||||||
for (int row : sourceRows)
|
return left.row() < right.row();
|
||||||
paths.push_back(model_->data(model_->index(row, 0), Qt::UserRole).toString());
|
});
|
||||||
|
for (const QModelIndex &index : selected) {
|
||||||
|
const int sourceRow = proxy_->mapToSource(index).row();
|
||||||
|
paths.push_back(model_->data(
|
||||||
|
model_->index(sourceRow, ResultsModel::Name), Qt::UserRole).toString());
|
||||||
|
}
|
||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#include "search_core.h"
|
||||||
|
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <pwd.h>
|
||||||
|
|
||||||
|
QStringList patterns(const QString &text)
|
||||||
|
{
|
||||||
|
QStringList out;
|
||||||
|
QString item;
|
||||||
|
bool quoted = false;
|
||||||
|
for (QChar ch : text) {
|
||||||
|
if (ch == u'"') {
|
||||||
|
quoted = !quoted;
|
||||||
|
} else if (!quoted && (ch == u';' || ch == u',')) {
|
||||||
|
if (!item.trimmed().isEmpty())
|
||||||
|
out.push_back(item.trimmed());
|
||||||
|
item.clear();
|
||||||
|
} else {
|
||||||
|
item += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!item.trimmed().isEmpty())
|
||||||
|
out.push_back(item.trimmed());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList exclusionPatterns(const QString &text)
|
||||||
|
{
|
||||||
|
QString normalized = text;
|
||||||
|
normalized.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(";"));
|
||||||
|
QStringList result = patterns(normalized);
|
||||||
|
for (QString &pattern : result) {
|
||||||
|
if (!pattern.contains(u'*') && !pattern.contains(u'?') && !pattern.contains(u'/'))
|
||||||
|
pattern = QStringLiteral("*.") + pattern.remove(QRegularExpression(QStringLiteral("^\\.")));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs)
|
||||||
|
{
|
||||||
|
for (const QString &pattern : items) {
|
||||||
|
const auto regex = QRegularExpression(
|
||||||
|
QRegularExpression::wildcardToRegularExpression(pattern),
|
||||||
|
cs == Qt::CaseInsensitive ? QRegularExpression::CaseInsensitiveOption
|
||||||
|
: QRegularExpression::NoPatternOption);
|
||||||
|
if (regex.match(value).hasMatch())
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString humanSize(qint64 bytes)
|
||||||
|
{
|
||||||
|
static const QStringList units{QStringLiteral("B"), QStringLiteral("KB"),
|
||||||
|
QStringLiteral("MB"), QStringLiteral("GB"),
|
||||||
|
QStringLiteral("TB")};
|
||||||
|
double value = static_cast<double>(bytes);
|
||||||
|
int unit = 0;
|
||||||
|
while (value >= 1024.0 && unit < units.size() - 1) {
|
||||||
|
value /= 1024.0;
|
||||||
|
++unit;
|
||||||
|
}
|
||||||
|
return unit == 0 ? QStringLiteral("%1 B").arg(bytes)
|
||||||
|
: QStringLiteral("%1 %2").arg(value, 0, 'f', 2).arg(units[unit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ownerName(uid_t uid)
|
||||||
|
{
|
||||||
|
struct passwd pwd {};
|
||||||
|
struct passwd *result = nullptr;
|
||||||
|
QByteArray buffer(16384, Qt::Uninitialized);
|
||||||
|
if (getpwuid_r(uid, &pwd, buffer.data(), static_cast<size_t>(buffer.size()), &result) == 0 && result)
|
||||||
|
return QString::fromLocal8Bit(result->pw_name);
|
||||||
|
return QString::number(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<QByteArray> hexNeedle(QString text)
|
||||||
|
{
|
||||||
|
text.remove(QRegularExpression(QStringLiteral("\\s+")));
|
||||||
|
if (text.size() % 2 != 0 || !QRegularExpression(QStringLiteral("^[0-9a-fA-F]*$")).match(text).hasMatch())
|
||||||
|
return std::nullopt;
|
||||||
|
return QByteArray::fromHex(text.toLatin1());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool fileContains(const QString &path, const SearchOptions &options)
|
||||||
|
{
|
||||||
|
if (options.contains.isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const QStringList terms = options.multipleValues
|
||||||
|
? patterns(options.contains) : QStringList{options.contains};
|
||||||
|
QList<QByteArray> needles;
|
||||||
|
for (const QString &term : terms) {
|
||||||
|
if (options.binary) {
|
||||||
|
const auto bytes = hexNeedle(term);
|
||||||
|
if (!bytes)
|
||||||
|
return false;
|
||||||
|
needles.push_back(*bytes);
|
||||||
|
} else {
|
||||||
|
needles.push_back(term.toUtf8());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (needles.isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
QVector<bool> found(needles.size(), false);
|
||||||
|
qsizetype overlap = 1;
|
||||||
|
for (const QByteArray &needle : needles)
|
||||||
|
overlap = std::max(overlap, needle.size());
|
||||||
|
QByteArray tail;
|
||||||
|
while (!file.atEnd()) {
|
||||||
|
QByteArray data = tail + file.read(1024 * 1024);
|
||||||
|
if (!options.caseSensitive && !options.binary)
|
||||||
|
data = data.toLower();
|
||||||
|
for (qsizetype i = 0; i < needles.size(); ++i) {
|
||||||
|
QByteArray needle = needles[i];
|
||||||
|
if (!options.caseSensitive && !options.binary)
|
||||||
|
needle = needle.toLower();
|
||||||
|
if (!found[i] && data.contains(needle))
|
||||||
|
found[i] = true;
|
||||||
|
}
|
||||||
|
const bool matched = options.multipleAnd
|
||||||
|
? std::all_of(found.cbegin(), found.cend(), [](bool foundTerm) { return foundTerm; })
|
||||||
|
: std::any_of(found.cbegin(), found.cend(), [](bool foundTerm) { return foundTerm; });
|
||||||
|
if (matched)
|
||||||
|
return true;
|
||||||
|
tail = data.right(overlap - 1);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool filesEqual(const QString &leftPath, const QString &rightPath)
|
||||||
|
{
|
||||||
|
QFile left(leftPath);
|
||||||
|
QFile right(rightPath);
|
||||||
|
if (!left.open(QIODevice::ReadOnly) || !right.open(QIODevice::ReadOnly)
|
||||||
|
|| left.size() != right.size())
|
||||||
|
return false;
|
||||||
|
while (!left.atEnd()) {
|
||||||
|
const QByteArray leftData = left.read(1024 * 1024);
|
||||||
|
const QByteArray rightData = right.read(leftData.size());
|
||||||
|
if (leftData != rightData)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return right.atEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray sha256(const QString &path)
|
||||||
|
{
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return {};
|
||||||
|
QCryptographicHash hash(QCryptographicHash::Sha256);
|
||||||
|
while (!file.atEnd())
|
||||||
|
hash.addData(file.read(1024 * 1024));
|
||||||
|
return hash.result();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseOptionalIsoDate(const QString &text, qint64 &seconds)
|
||||||
|
{
|
||||||
|
const QString trimmed = text.trimmed();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
seconds = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const QDateTime parsed = QDateTime::fromString(trimmed, Qt::ISODate);
|
||||||
|
if (!parsed.isValid())
|
||||||
|
return false;
|
||||||
|
seconds = parsed.toSecsSinceEpoch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QMetaType>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <sys/types.h>
|
||||||
|
|
||||||
|
struct SearchOptions {
|
||||||
|
QString roots = QDir::homePath();
|
||||||
|
QString fileWildcards = QStringLiteral("*");
|
||||||
|
QString subfolderWildcards = QStringLiteral("*");
|
||||||
|
QString excludeFiles;
|
||||||
|
QString excludeFolders;
|
||||||
|
QString includeFolders;
|
||||||
|
QString contains;
|
||||||
|
bool binary = false;
|
||||||
|
bool multipleValues = false;
|
||||||
|
bool multipleAnd = false;
|
||||||
|
bool caseSensitive = false;
|
||||||
|
qint64 minSize = 0;
|
||||||
|
qint64 maxSize = 0;
|
||||||
|
qint64 minTime = 0;
|
||||||
|
qint64 maxTime = 0;
|
||||||
|
QString timeField = QStringLiteral("Modified time");
|
||||||
|
int lastMinutes = 0;
|
||||||
|
bool recursive = true;
|
||||||
|
bool findFiles = true;
|
||||||
|
bool findFolders = false;
|
||||||
|
bool followLinks = false;
|
||||||
|
bool retrieveOwner = false;
|
||||||
|
bool accurateProgress = true;
|
||||||
|
bool useCache = true;
|
||||||
|
int maxDepth = 0;
|
||||||
|
int maxResults = 0;
|
||||||
|
QString mode = QStringLiteral("Standard search");
|
||||||
|
QString duplicateNameMode = QStringLiteral("All files and folders");
|
||||||
|
bool duplicateNameWithoutExtension = false;
|
||||||
|
bool showOnlyDuplicateFiles = true;
|
||||||
|
bool includeSubfoldersInSummary = false;
|
||||||
|
QString hidden = QStringLiteral("Any");
|
||||||
|
QString readonly = QStringLiteral("Any");
|
||||||
|
QString executable = QStringLiteral("Any");
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FileRecord {
|
||||||
|
QString path;
|
||||||
|
QString name;
|
||||||
|
QString folder;
|
||||||
|
qint64 size = 0;
|
||||||
|
qint64 sizeOnDisk = 0;
|
||||||
|
qint64 modified = 0;
|
||||||
|
qint64 created = 0;
|
||||||
|
qint64 accessed = 0;
|
||||||
|
qint64 changed = 0;
|
||||||
|
QString type;
|
||||||
|
QString owner;
|
||||||
|
QString attributes;
|
||||||
|
int group = 0;
|
||||||
|
int duplicateCopy = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
Q_DECLARE_METATYPE(SearchOptions)
|
||||||
|
Q_DECLARE_METATYPE(FileRecord)
|
||||||
|
Q_DECLARE_METATYPE(QVector<FileRecord>)
|
||||||
|
|
||||||
|
QStringList patterns(const QString &text);
|
||||||
|
QStringList exclusionPatterns(const QString &text);
|
||||||
|
bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs);
|
||||||
|
QString humanSize(qint64 bytes);
|
||||||
|
QString ownerName(uid_t uid);
|
||||||
|
std::optional<QByteArray> hexNeedle(QString text);
|
||||||
|
bool fileContains(const QString &path, const SearchOptions &options);
|
||||||
|
bool filesEqual(const QString &leftPath, const QString &rightPath);
|
||||||
|
QByteArray sha256(const QString &path);
|
||||||
|
bool parseOptionalIsoDate(const QString &text, qint64 &seconds);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#include "long_long_spin_box.h"
|
||||||
|
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
class LongLongSpinBoxTest final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void preservesValuesAboveTwoGigabytes()
|
||||||
|
{
|
||||||
|
LongLongSpinBox spin;
|
||||||
|
spin.setRange(0, std::numeric_limits<qint64>::max());
|
||||||
|
spin.setSuffix(QStringLiteral(" bytes"));
|
||||||
|
spin.setValue(5'000'000'000);
|
||||||
|
QCOMPARE(spin.value(), qint64(5'000'000'000));
|
||||||
|
QCOMPARE(spin.findChild<QLineEdit *>()->text(), QStringLiteral("5000000000 bytes"));
|
||||||
|
spin.stepUp();
|
||||||
|
QCOMPARE(spin.value(), qint64(5'000'000'001));
|
||||||
|
|
||||||
|
spin.findChild<QLineEdit *>()->setText(QStringLiteral("6000000000 bytes"));
|
||||||
|
QVERIFY(QMetaObject::invokeMethod(&spin, "editingFinished", Qt::DirectConnection));
|
||||||
|
QCOMPARE(spin.value(), qint64(6'000'000'000));
|
||||||
|
}
|
||||||
|
|
||||||
|
void clampsAtRangeEdges()
|
||||||
|
{
|
||||||
|
LongLongSpinBox spin;
|
||||||
|
spin.setRange(0, 10);
|
||||||
|
spin.setValue(10);
|
||||||
|
spin.stepUp();
|
||||||
|
QCOMPARE(spin.value(), qint64(10));
|
||||||
|
spin.setValue(-50);
|
||||||
|
QCOMPARE(spin.value(), qint64(0));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_MAIN(LongLongSpinBoxTest)
|
||||||
|
|
||||||
|
#include "long_long_spin_box_test.moc"
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
release_script="$(realpath "$1")"
|
||||||
|
test_directory="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$test_directory"' EXIT
|
||||||
|
|
||||||
|
git -C "$test_directory" init -q
|
||||||
|
git -C "$test_directory" config user.name "FolderScope Tests"
|
||||||
|
git -C "$test_directory" config user.email "tests@folderscope.invalid"
|
||||||
|
printf '9.9.9\n' > "$test_directory/VERSION"
|
||||||
|
printf '# Changelog\n\n## [9.9.9]\n' > "$test_directory/CHANGELOG.md"
|
||||||
|
git -C "$test_directory" add VERSION CHANGELOG.md
|
||||||
|
git -C "$test_directory" commit -q -m "test fixture"
|
||||||
|
touch "$test_directory/untracked"
|
||||||
|
|
||||||
|
if (cd "$test_directory" && "$release_script" 9.9.9 >/dev/null 2>&1); then
|
||||||
|
echo "release.sh accepted a repository with untracked files" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#include "search_core.h"
|
||||||
|
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
class SearchCoreTest final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void parsesPatternsAndExclusions()
|
||||||
|
{
|
||||||
|
QCOMPARE(patterns(QStringLiteral("/a; \"/b,with-comma\" , /c")),
|
||||||
|
QStringList({QStringLiteral("/a"), QStringLiteral("/b,with-comma"),
|
||||||
|
QStringLiteral("/c")}));
|
||||||
|
QCOMPARE(exclusionPatterns(QStringLiteral("tmp log *.bak")),
|
||||||
|
QStringList({QStringLiteral("*.tmp"), QStringLiteral("*.log"),
|
||||||
|
QStringLiteral("*.bak")}));
|
||||||
|
QVERIFY(wildcardMatch(QStringLiteral("Report.TXT"), {QStringLiteral("*.txt")},
|
||||||
|
Qt::CaseInsensitive));
|
||||||
|
QVERIFY(!wildcardMatch(QStringLiteral("Report.TXT"), {QStringLiteral("*.txt")},
|
||||||
|
Qt::CaseSensitive));
|
||||||
|
}
|
||||||
|
|
||||||
|
void parsesOptionalIsoDates()
|
||||||
|
{
|
||||||
|
qint64 seconds = -1;
|
||||||
|
QVERIFY(parseOptionalIsoDate(QString{}, seconds));
|
||||||
|
QCOMPARE(seconds, 0);
|
||||||
|
QVERIFY(parseOptionalIsoDate(QStringLiteral("2026-07-25T20:30:00"), seconds));
|
||||||
|
QVERIFY(seconds > 0);
|
||||||
|
QVERIFY(!parseOptionalIsoDate(QStringLiteral("2026-not-a-date"), seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
void searchesContentsAndHashes()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
const QString firstPath = directory.filePath(QStringLiteral("first.bin"));
|
||||||
|
const QString secondPath = directory.filePath(QStringLiteral("second.bin"));
|
||||||
|
const QString differentPath = directory.filePath(QStringLiteral("different.bin"));
|
||||||
|
const QByteArray content = QByteArray(1024 * 1024 - 2, 'x')
|
||||||
|
+ QByteArray("Alpha needle BETA");
|
||||||
|
|
||||||
|
QFile first(firstPath);
|
||||||
|
QVERIFY(first.open(QIODevice::WriteOnly));
|
||||||
|
QCOMPARE(first.write(content), content.size());
|
||||||
|
first.close();
|
||||||
|
|
||||||
|
QFile second(secondPath);
|
||||||
|
QVERIFY(second.open(QIODevice::WriteOnly));
|
||||||
|
QCOMPARE(second.write(content), content.size());
|
||||||
|
second.close();
|
||||||
|
|
||||||
|
QFile different(differentPath);
|
||||||
|
QVERIFY(different.open(QIODevice::WriteOnly));
|
||||||
|
QCOMPARE(different.write("different"), qint64(9));
|
||||||
|
different.close();
|
||||||
|
|
||||||
|
SearchOptions options;
|
||||||
|
options.contains = QStringLiteral("needle");
|
||||||
|
QVERIFY(fileContains(firstPath, options));
|
||||||
|
options.contains = QStringLiteral("alpha,beta");
|
||||||
|
options.multipleValues = true;
|
||||||
|
options.multipleAnd = true;
|
||||||
|
QVERIFY(fileContains(firstPath, options));
|
||||||
|
options.caseSensitive = true;
|
||||||
|
QVERIFY(!fileContains(firstPath, options));
|
||||||
|
options.binary = true;
|
||||||
|
options.multipleValues = false;
|
||||||
|
options.multipleAnd = false;
|
||||||
|
options.contains = QStringLiteral("41 6c 70 68 61");
|
||||||
|
QVERIFY(fileContains(firstPath, options));
|
||||||
|
options.contains = QStringLiteral("ABC");
|
||||||
|
QVERIFY(!fileContains(firstPath, options));
|
||||||
|
|
||||||
|
QVERIFY(filesEqual(firstPath, secondPath));
|
||||||
|
QVERIFY(!filesEqual(firstPath, differentPath));
|
||||||
|
QCOMPARE(sha256(firstPath), QCryptographicHash::hash(content, QCryptographicHash::Sha256));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(SearchCoreTest)
|
||||||
|
|
||||||
|
#include "search_core_test.moc"
|
||||||
Reference in New Issue
Block a user