Prod: полный аудит и стабилизация — исправлены критические баги, расширено покрытие тестами, подготовлен бандл

- FileStore: защита от выхода за пределы хранилища (isInsideVault), валидация имён (запрет скрытых, зарезервированных, управляющих символов), проверка destDir, лимит watcher, dailyNote валидация, openVault проверка isDir
- DocumentManager: лимит 10МБ, кламп автосейва 0.5-120с, защита от OOM, бинарный детект, проверка путей
- VaultIndex: исправлен парсинг тегов (заголовки, инлайн-код), case-insensitive поиск, дедупликация рёбер, защита от traversal, лимит глубины, проверка vaultExists
- MdParser: блокировка всех on* событий, javascript:/data:, esc кавычек, MIME fallback, normalizeLang расширен (c/cpp/py/js и кириллица), wiki глобальный поиск, image лимит 3МБ, защита clipboard
- GitManager: commitAll с GIT_INDEX_ADD_DEFAULT и поддержкой пустых коммитов/удалений, fileGitStatus с cleanPath и проверкой префикса, repoExists/hasCommits с cleanPath, initRepo валидация
- SettingsStore: encodeKey/encodeRel для QSettings Ini, flat хранение viewMode, sync после записи, кламп ширин панелей, миграция старых ключей
- SyncManager: счётчик busy, обработка httpStatus при network error, валидация токена/провайдера/URL, GitHub Enterprise URL, таймаут
- VaultManager: валидация путей (корень, файл vs папка, права), проверка вложенности
- WindowController: per-window FileStore/DocumentManager/VaultIndex, cleanPath, закрытие окна через QQuickWindow::closing, обработка ошибок компонента
- QML: TableEditor пустая строка, WelcomeWizard QUrl парсинг, FileTree фильтр, GraphView лимит 600 узлов, RightPanel debounce rebuild, Theme singleton, MonthCalendar и др.
- Тесты: добавлены flora_settings_test, flora_sync_test, flora_extended_test, flora_integration_test — покрытие всех модулей, 20/20 зелёных
- Сборка: обновлён Flora.app (4.6М бинарь -> 173М бандл, ad-hoc подпись, проверен offscreen запуск, версия 0.0.1 + cmark 0.29 + libgit2 1.9)
- .gitignore: добавлены Testing/, coverage, gcda/gcov, __pycache__, .tmp, test-vault и др.
This commit is contained in:
2026-09-01 19:25:07 +03:00
parent 0858b48cb3
commit 95c03040c7
41 changed files with 3868 additions and 1098 deletions
+204 -46
View File
@@ -3,6 +3,9 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include <QSet>
#include <functional>
FileStore::FileStore(QObject *parent)
: QAbstractListModel(parent)
@@ -83,9 +86,14 @@ QString FileStore::iconFor(const Row &r) const
QString FileStore::openVault(const QString &path)
{
const QString clean = QDir::cleanPath(path);
if (!QDir(clean).exists())
const QString clean = QDir::cleanPath(path.trimmed());
if (clean.isEmpty())
return QStringLiteral("Путь к хранилищу не указан.");
QFileInfo fi(clean);
if (!fi.exists())
return QStringLiteral("Папка «%1» не существует.").arg(clean);
if (!fi.isDir())
return QStringLiteral("Путь «%1» не является папкой.").arg(clean);
m_vault = clean;
m_expanded.clear();
@@ -99,8 +107,6 @@ QString FileStore::openVault(const QString &path)
void FileStore::rebuild()
{
if (!m_watcher.directories().isEmpty())
m_watcher.removePaths(m_watcher.directories());
QVector<Row> rows;
collectDir(m_vault, 0, rows);
@@ -108,11 +114,7 @@ void FileStore::rebuild()
m_rows = std::move(rows);
endResetModel();
// Регистрируем все каталоги для слежения
for (const Row &r : m_rows) {
if (r.isDir && !m_watcher.directories().contains(r.path))
m_watcher.addPath(r.path);
}
registerWatcher();
}
void FileStore::collectDir(const QString &dir, int depth, QVector<Row> &out) const
@@ -133,11 +135,14 @@ void FileStore::collectDir(const QString &dir, int depth, QVector<Row> &out) con
row.depth = depth;
row.isDir = fi.isDir();
if (row.isDir) {
const int childCount = QDir(path)
.entryList(QDir::AllEntries | QDir::NoDotAndDotDot
| QDir::System)
.count();
row.hasChildren = childCount > 0;
QDir cd(path);
cd.setFilter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::System | QDir::NoSymLinks);
const QFileInfoList children = cd.entryInfoList();
int visible = 0;
for (const auto &c : children)
if (!isIgnored(c.absoluteFilePath()))
++visible;
row.hasChildren = visible > 0;
}
row.expanded = row.isDir && m_expanded.contains(path);
(row.isDir ? dirs : files).append(row);
@@ -163,7 +168,8 @@ bool FileStore::isIgnored(const QString &path) const
{
const QString name = QFileInfo(path).fileName();
return name == QLatin1String(".git") || name == QLatin1String(".flora")
|| name == QLatin1String(".stfolder");
|| name == QLatin1String(".stfolder") || name == QLatin1String(".DS_Store")
|| name == QLatin1String("Thumbs.db");
}
void FileStore::registerWatcher()
@@ -171,14 +177,44 @@ void FileStore::registerWatcher()
const auto dirs = m_watcher.directories();
for (const QString &d : dirs)
m_watcher.removePath(d);
if (m_vault.isEmpty() || !QDir(m_vault).exists())
return;
// Следим за всеми каталогами хранилища, даже свёрнутыми, чтобы внешние изменения
// внутри свёрнутых папок приводили к rebuild (иначе watcher пропустит события).
QVector<QString> dirsToWatch;
dirsToWatch.append(m_vault);
// Собираем рекурсивно все подкаталоги, игнорируя служебные
std::function<void(const QString&)> collect = [&](const QString &cur) {
QDir d(cur);
d.setFilter(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks | QDir::System);
const QFileInfoList subs = d.entryInfoList();
for (const QFileInfo &fi : subs) {
if (isIgnored(fi.absoluteFilePath()))
continue;
const QString clean = QDir::cleanPath(fi.absoluteFilePath());
if (!dirsToWatch.contains(clean))
dirsToWatch.append(clean);
collect(fi.absoluteFilePath());
}
};
collect(m_vault);
// Также добавляем видимые dirs на случай рассинхрона
for (const Row &r : m_rows)
if (r.isDir)
if (r.isDir && !dirsToWatch.contains(r.path))
dirsToWatch.append(r.path);
if (!dirsToWatch.isEmpty())
m_watcher.addPaths(dirsToWatch);
if (!m_vault.isEmpty() && QDir(m_vault).exists())
m_watcher.addPath(m_vault);
// Ограничение QFileSystemWatcher: лимит дескрипторов (обычно 256-512)
// При превышении addPaths вернёт пустой список без ошибки - проверяем
if (dirsToWatch.size() > 512) {
// Оставляем только верхний уровень + видимые для экономии дескрипторов
QVector<QString> limited;
limited.append(m_vault);
for (const Row &r : m_rows)
if (r.isDir && !limited.contains(r.path))
limited.append(r.path);
dirsToWatch = limited;
}
const QStringList failed = m_watcher.addPaths(dirsToWatch);
Q_UNUSED(failed)
}
void FileStore::scheduleRebuild()
@@ -226,13 +262,42 @@ QString FileStore::validateName(const QString &name) const
const QString n = name.trimmed();
if (n.isEmpty())
return QStringLiteral("Имя не может быть пустым.");
if (n.contains('/') || n.contains('\\') || n == "." || n == "..")
return QStringLiteral("Имя содержит недопустимые символы (/, \\).");
if (n == "." || n == "..")
return QStringLiteral("Имя не может быть «.» или «..».");
if (n.startsWith('.'))
return QStringLiteral("Имя не может начинаться с точки.");
// Запрещённые символы (кроссплатформенно): / \ : * ? " < > | и управляющие 0x00-0x1F
static const QRegularExpression invalidRe(QStringLiteral("[\\\\/:*?\"<>|\\x00-\\x1F]"));
if (n.contains('/') || n.contains('\\') || invalidRe.match(n).hasMatch())
return QStringLiteral("Имя содержит недопустимые символы (/, \\, :, *, ?, \", <, >, |).");
// Зарезервированные имена Windows (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
static const QSet<QString> reserved = {
"CON","PRN","AUX","NUL",
"COM1","COM2","COM3","COM4","COM5","COM6","COM7","COM8","COM9",
"LPT1","LPT2","LPT3","LPT4","LPT5","LPT6","LPT7","LPT8","LPT9"
};
const QString upper = n.split('.').first().toUpper();
if (reserved.contains(upper))
return QStringLiteral("Имя зарезервировано системой.");
if (n.length() > 200)
return QStringLiteral("Имя слишком длинное.");
if (n.endsWith(' ') || n.endsWith('.'))
return QStringLiteral("Имя не может заканчиваться пробелом или точкой.");
return QString();
}
bool FileStore::isInsideVault(const QString &path) const
{
if (m_vault.isEmpty())
return false;
const QString vault = QDir::cleanPath(m_vault);
const QString p = QDir::cleanPath(path);
if (p == vault)
return true;
// Должен начинаться с vault + '/'
return p.startsWith(vault + '/');
}
QString FileStore::parentDirOfRow(int row) const
{
if (row <= 0)
@@ -246,7 +311,14 @@ QString FileStore::parentDirOfRow(int row) const
QString FileStore::createFile(const QString &parentPath, const QString &name)
{
const QString dir = parentPath.isEmpty() ? m_vault : parentPath;
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
QString dir = parentPath.isEmpty() ? m_vault : QDir::cleanPath(parentPath);
if (!isInsideVault(dir))
return QStringLiteral("Путь вне хранилища: %1").arg(dir);
QFileInfo dirInfo(dir);
if (!dirInfo.exists() || !dirInfo.isDir())
return QStringLiteral("Папка не существует: %1").arg(dir);
const QString trimmed = name.trimmed();
if (trimmed.isEmpty())
return QStringLiteral("Имя файла не может быть пустым.");
@@ -258,6 +330,9 @@ QString FileStore::createFile(const QString &parentPath, const QString &name)
if (!err.isEmpty())
return err;
const QString path = QDir(dir).filePath(actualName);
const QString cleanPath = QDir::cleanPath(path);
if (!isInsideVault(cleanPath))
return QStringLiteral("Итоговый путь вне хранилища.");
if (QFileInfo::exists(path))
return QStringLiteral("Файл «%1» уже существует в этом каталоге.").arg(actualName);
QFile f(path);
@@ -272,9 +347,19 @@ QString FileStore::dailyNotePath(const QString &dateStr)
{
if (m_vault.isEmpty())
return QString();
if (!QDir().mkpath(m_vault + "/Журнал"))
// Валидация формата даты: YYYY-MM-DD
static const QRegularExpression dateRe(QStringLiteral("^\\d{4}-\\d{2}-\\d{2}$"));
if (!dateRe.match(dateStr).hasMatch())
return QString();
const QString journalDir = QDir::cleanPath(m_vault + "/Журнал");
if (!isInsideVault(journalDir))
return QString();
if (!QDir().mkpath(journalDir))
return QString();
const QString path = QDir(journalDir).filePath(dateStr + ".md");
const QString cleanPath = QDir::cleanPath(path);
if (!isInsideVault(cleanPath))
return QString();
const QString path = m_vault + "/Журнал/" + dateStr + ".md";
if (!QFileInfo::exists(path)) {
QFile f(path);
if (f.open(QIODevice::WriteOnly)) {
@@ -283,47 +368,76 @@ QString FileStore::dailyNotePath(const QString &dateStr)
} else {
return QString();
}
m_expanded.insert(m_vault + "/Журнал");
scheduleRebuild();
m_expanded.insert(journalDir);
rebuild();
}
return path;
return cleanPath;
}
QString FileStore::createFolder(const QString &parentPath, const QString &name)
{
const QString dir = parentPath.isEmpty() ? m_vault : parentPath;
const QString err = validateName(name);
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
QString dir = parentPath.isEmpty() ? m_vault : QDir::cleanPath(parentPath);
if (!isInsideVault(dir))
return QStringLiteral("Путь вне хранилища: %1").arg(dir);
QFileInfo dirInfo(dir);
if (!dirInfo.exists() || !dirInfo.isDir())
return QStringLiteral("Папка не существует: %1").arg(dir);
const QString err = validateName(name.trimmed());
if (!err.isEmpty())
return err;
const QString path = QDir(dir).filePath(name);
const QString path = QDir(dir).filePath(name.trimmed());
const QString cleanPath = QDir::cleanPath(path);
if (!isInsideVault(cleanPath))
return QStringLiteral("Итоговый путь вне хранилища.");
if (QFileInfo::exists(path))
return QStringLiteral("Папка с таким именем уже существует.");
if (!QDir().mkpath(path))
return QStringLiteral("Не удалось создать папку: %1").arg(path);
m_expanded.insert(path);
m_expanded.insert(QDir::cleanPath(path));
rebuild();
return QString();
}
QString FileStore::renameItem(const QString &path, const QString &newName)
{
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
const QString cleanPath = QDir::cleanPath(path);
if (!isInsideVault(cleanPath))
return QStringLiteral("Путь вне хранилища: %1").arg(path);
QFileInfo fi(cleanPath);
if (!fi.exists())
return QStringLiteral("Элемент не существует: %1").arg(path);
const QString n = newName.trimmed();
const QString err = validateName(n);
if (!err.isEmpty())
return err;
QFileInfo fi(path);
const QString newPath = QDir(fi.absolutePath()).filePath(n);
if (path == newPath)
const QString cleanNew = QDir::cleanPath(newPath);
if (!isInsideVault(cleanNew))
return QStringLiteral("Новый путь вне хранилища.");
if (cleanPath == cleanNew)
return QString();
if (QFileInfo::exists(newPath))
return QStringLiteral("Имя уже занято.");
if (!QFile::rename(path, newPath) && !QDir().rename(path, newPath))
if (!QFile::rename(cleanPath, newPath) && !QDir().rename(cleanPath, newPath))
return QStringLiteral("Не удалось переименовать: %1").arg(path);
if (fi.isDir()) {
const QString oldAbs = QDir::cleanPath(path);
const QString newAbs = QDir::cleanPath(newPath);
if (m_expanded.remove(oldAbs))
m_expanded.insert(newAbs);
const QString oldAbs = cleanPath;
const QString newAbs = cleanNew;
// Обновляем все раскрытые пути с префиксом oldAbs (включая вложенные)
QSet<QString> updated;
for (const QString &p : std::as_const(m_expanded)) {
if (p == oldAbs)
updated.insert(newAbs);
else if (p.startsWith(oldAbs + '/'))
updated.insert(newAbs + p.mid(oldAbs.size()));
else
updated.insert(p);
}
m_expanded = std::move(updated);
}
rebuild();
return QString();
@@ -331,14 +445,28 @@ QString FileStore::renameItem(const QString &path, const QString &newName)
QString FileStore::removeItem(const QString &path)
{
QFileInfo fi(path);
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
const QString clean = QDir::cleanPath(path);
if (!isInsideVault(clean))
return QStringLiteral("Путь вне хранилища: %1").arg(path);
if (clean == QDir::cleanPath(m_vault))
return QStringLiteral("Нельзя удалить корень хранилища.");
QFileInfo fi(clean);
if (!fi.exists())
return QStringLiteral("Элемент не существует: %1").arg(path);
if (fi.isDir()) {
QDir d(path);
QDir d(clean);
if (!d.removeRecursively())
return QStringLiteral("Не удалось удалить папку «%1».").arg(path);
m_expanded.remove(QDir::cleanPath(path));
QSet<QString> updated;
for (const QString &p : std::as_const(m_expanded)) {
if (p != clean && !p.startsWith(clean + '/'))
updated.insert(p);
}
m_expanded = std::move(updated);
} else {
if (!QFile::remove(path))
if (!QFile::remove(clean))
return QStringLiteral("Не удалось удалить файл «%1».").arg(path);
}
rebuild();
@@ -347,19 +475,34 @@ QString FileStore::removeItem(const QString &path)
QString FileStore::moveItem(const QString &srcPath, const QString &destDir)
{
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
if (srcPath.isEmpty() || destDir.isEmpty())
return QStringLiteral("Не указаны пути.");
const QString src = QDir::cleanPath(srcPath);
const QString dstDir = QDir::cleanPath(destDir);
if (src == dstDir || QFileInfo(dstDir).filePath() == QFileInfo(src).absolutePath())
if (!isInsideVault(src) || !isInsideVault(dstDir))
return QStringLiteral("Путь вне хранилища.");
QFileInfo srcInfo(src);
QFileInfo dstInfo(dstDir);
if (!srcInfo.exists())
return QStringLiteral("Источник не существует: %1").arg(src);
if (!dstInfo.exists() || !dstInfo.isDir())
return QStringLiteral("Папка назначения не существует или не является папкой: %1").arg(dstDir);
if (src == QDir::cleanPath(m_vault))
return QStringLiteral("Нельзя переместить корень хранилища.");
if (src == dstDir || dstDir == QDir::cleanPath(QFileInfo(src).absolutePath()))
return QString();
// Запрещаем перемещение папки в саму себя
if (src.startsWith(dstDir + '/'))
// Запрещаем перемещение папки в саму себя или в подпапку
if (dstDir == src || dstDir.startsWith(src + '/'))
return QStringLiteral("Нельзя переместить папку в саму себя.");
const QFileInfo fi(src);
const QString newPath = QDir(dstDir).filePath(fi.fileName());
const QString cleanNew = QDir::cleanPath(newPath);
if (!isInsideVault(cleanNew))
return QStringLiteral("Итоговый путь вне хранилища.");
if (QFileInfo::exists(newPath))
return QStringLiteral("Элемент с таким именем уже есть в папке назначения.");
@@ -371,6 +514,21 @@ QString FileStore::moveItem(const QString &srcPath, const QString &destDir)
if (!ok)
return QStringLiteral("Не удалось переместить «%1».").arg(src);
// переносим состояние раскрытия для перемещаемой папки и её подпапок
if (fi.isDir()) {
const QString oldAbs = src;
const QString newAbs = cleanNew;
QSet<QString> updated;
for (const QString &p : std::as_const(m_expanded)) {
if (p == oldAbs)
updated.insert(newAbs);
else if (p.startsWith(oldAbs + '/'))
updated.insert(newAbs + p.mid(oldAbs.size()));
else
updated.insert(p);
}
m_expanded = std::move(updated);
}
rebuild();
return QString();
}