Share checked desktop config batches and diagnose write failures

This commit is contained in:
evgeny committed 2026-10-27 03:14:00 +01:00
1 parent 13918d1cea
commit f6f3ca8da7
3 files changed
+82 -30

No files matched your search

@@ -72,6 +72,26 @@ private slots:
QCOMPARE(lines, (QStringList{"[global]", "auto_sockets=yes", "my_node_name=keep", "[gui]", "language=en"}));
}
void controlAliasesAreUpdatedTogether() {
QStringList lines{"[control]", "port=1", "control_port=2", "ip=127.0.0.1", "control_ip=127.0.0.2"};
QVERIFY(config_update_in_lines(lines, "control", "port", "0", true));
QVERIFY(config_update_in_lines(lines, "control", "control_ip", "::1", true));
QCOMPARE(lines.count("port=0"), 2);
QCOMPARE(lines.count("control_ip=::1"), 2);
}
void batchReadKeepsOutputOnError() {
const QStringList original{"preserve caller buffer"};
auto lines = original;
QVERIFY(!config_read_lines(path(), lines));
QCOMPARE(lines, original);
QVERIFY(config_read_lines(path(), lines, true));
QVERIFY(lines.isEmpty());
QVERIFY(put("[gui]\nlanguage=ru\n"));
QVERIFY(config_read_lines(path(), lines));
QCOMPARE(lines, (QStringList{"[gui]", "language=ru"}));
}
void invalidArgumentsDoNotMutate() {
const QStringList original{"[global]", "my_node_name=keep"};
for (const auto& values : {QStringList{"", "name", "value"}, QStringList{"global", "x=y", "value"},
+50 -26
View File
@@ -28,14 +28,19 @@ static bool single_line(const QString& value) {
/* Отказ до изменения lines: аргументы не могут создавать дополнительные строки/секции. */
bool config_update_in_lines(QStringList& lines, const QString& section,
const QString& key, const QString& value, bool replace) {
if (section.trimmed().isEmpty() || section != section.trimmed() || !single_line(section) ||
section.contains('[') || section.contains(']') || key.trimmed().isEmpty() || key != key.trimmed() ||
!single_line(key) || key.contains('=') || key.contains('[') || key.contains(']') || key.startsWith('#') ||
key.startsWith(';') || !single_line(value)) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config update rejected invalid section/key or multiline value");
const bool sectionValid = !section.trimmed().isEmpty() && section == section.trimmed() && single_line(section) &&
!section.contains('[') && !section.contains(']');
const bool keyValid = !key.trimmed().isEmpty() && key == key.trimmed() && single_line(key) && !key.contains('=') &&
!key.contains('[') && !key.contains(']') && !key.startsWith('#') && !key.startsWith(';');
const bool valueValid = single_line(value);
if (!sectionValid || !keyValid || !valueValid) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config update rejected section_valid=%d key_valid=%d value_single_line=%d",
sectionValid, keyValid, valueValid);
return false;
}
const QString wanted = normalized_section(section);
const bool controlAlias = wanted == "control" && (key == "control_ip" || key == "control_port" || key == "control_allow");
const QString option = controlAlias ? key.mid(8) : key;
const QString replacement = key + '=' + value;
int lastSection = -1, lastKey = -1, replaced = 0;
bool matching = false;
@@ -48,7 +53,11 @@ bool config_update_in_lines(QStringList& lines, const QString& section,
}
if (!matching) continue;
const int equal = lines[i].indexOf('=');
if (equal <= 0 || lines[i].left(equal).trimmed() != key) continue;
if (equal <= 0) continue;
QString candidate = lines[i].left(equal).trimmed();
if (wanted == "control" && (candidate == "control_ip" || candidate == "control_port" || candidate == "control_allow"))
candidate = candidate.mid(8);
if (candidate != option) continue;
lastKey = i;
if (replace) { lines[i] = replacement; ++replaced; }
}
@@ -67,10 +76,10 @@ bool config_update_in_lines(QStringList& lines, const QString& section,
}
/* ENOENT допускает создание; ошибки чтения и непредставимый UTF-8 запрещают перезапись. */
static bool read_config_lines(const QString& path, QStringList& lines) {
bool config_read_lines(const QString& path, QStringList& lines, bool allowMissing) {
QFile input(path);
const QFileInfo info(path);
if (!info.exists() && !info.isSymLink()) return true;
if (allowMissing && !info.exists() && !info.isSymLink()) { lines.clear(); return true; }
if (!input.open(QIODevice::ReadOnly)) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config read open failed path=%s: %s", qPrintable(path), qPrintable(input.errorString()));
return false;
@@ -81,30 +90,38 @@ static bool read_config_lines(const QString& path, QStringList& lines) {
return false;
}
const QString text = QString::fromUtf8(data);
if (text.toUtf8() != data || config_ini_validate(data.constData(), size_t(data.size()), qPrintable(path))) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config rejected invalid UTF-8/INI text path=%s", qPrintable(path));
if (text.toUtf8() != data) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config rejected non-UTF-8 text path=%s", qPrintable(path));
return false;
}
if (config_ini_validate(data.constData(), size_t(data.size()), qPrintable(path))) return false;
QStringList result;
if (!text.isEmpty()) {
lines = text.split('\n');
if (lines.last().isEmpty()) lines.removeLast();
for (QString& line : lines) if (line.endsWith('\r')) line.chop(1);
result = text.split('\n');
if (result.last().isEmpty()) result.removeLast();
for (QString& line : result) if (line.endsWith('\r')) line.chop(1);
}
lines = result;
DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "GUI config read path=%s bytes=%lld lines=%lld",
qPrintable(path), (long long)data.size(), (long long)lines.size());
return true;
}
/* QSaveFile владеет соседним temp и удаляет его при ошибке; прямой fallback запрещён. */
static bool write_config_lines(const QString& path, const QStringList& lines) {
bool config_write_lines(const QString& path, const QStringList& lines) {
QByteArray data = lines.join('\n').toUtf8();
if (!data.isEmpty() && !data.endsWith('\n')) data.append('\n');
if (config_ini_validate(data.constData(), size_t(data.size()), qPrintable(path))) return false;
QSaveFile output(path);
output.setDirectWriteFallback(false);
if (!output.open(QIODevice::WriteOnly) || output.write(data) != data.size() || !output.commit()) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config save failed path=%s bytes=%lld: %s",
qPrintable(path), (long long)data.size(), qPrintable(output.errorString()));
const char *phase = "open";
bool saved = output.open(QIODevice::WriteOnly);
if (saved) { phase = "write"; saved = output.write(data) == data.size(); }
if (saved) { phase = "commit"; saved = output.commit(); }
if (!saved) {
const int error = errno;
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config save failed path=%s phase=%s bytes=%lld: %s; errno=%d (%s)",
qPrintable(path), phase, (long long)data.size(), qPrintable(output.errorString()), error, strerror(error));
return false;
}
DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "GUI config saved path=%s bytes=%lld", qPrintable(path), (long long)data.size());
@@ -118,22 +135,28 @@ bool config_update_option(const QString& filePath, const QString& section,
return false;
}
QStringList lines;
if (!read_config_lines(filePath, lines) ||
if (!config_read_lines(filePath, lines, true) ||
!config_update_in_lines(lines, section, param, value, flags == CONFIG_UPDATE_REPLACE)) return false;
return write_config_lines(filePath, lines);
return config_write_lines(filePath, lines);
}
bool config_rewrite_servers(const QString& filePath, const QList<ConfigServer>& servers) {
QStringList lines;
if (!read_config_lines(filePath, lines)) return false;
if (!config_read_lines(filePath, lines, true)) return false;
QStringList block;
int index = 0;
for (const ConfigServer& server : servers) {
if (server.name.trimmed().isEmpty() || server.name != server.name.trimmed() || server.name.contains(':') ||
server.name.contains('[') || server.name.contains(']') || !single_line(server.name) ||
!single_line(server.addr) || !single_line(server.transport) ||
!single_line(server.type) || !single_line(server.netif)) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config rejected malformed server definition path=%s", qPrintable(filePath));
const struct { const char *field; bool valid; } checks[] = {
{"name", !server.name.trimmed().isEmpty() && server.name == server.name.trimmed() && !server.name.contains(':') &&
!server.name.contains('[') && !server.name.contains(']') && single_line(server.name)},
{"addr", single_line(server.addr)}, {"transport", single_line(server.transport)},
{"type", single_line(server.type)}, {"netif", single_line(server.netif)}
};
for (const auto& check : checks) {
if (check.valid) continue;
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config rejected malformed server index=%d field=%s path=%s",
index, check.field, qPrintable(filePath));
return false;
}
block << ("[server:" + server.name + ']') << ("addr=" + server.addr);
@@ -141,6 +164,7 @@ bool config_rewrite_servers(const QString& filePath, const QList<ConfigServer>&
block << ("type=" + (server.type.isEmpty() ? QString("public") : server.type));
if (!server.netif.isEmpty()) block << ("netif=" + server.netif);
block << "";
++index;
}
// Удаляем только server-секции целиком, сохраняя прочие строки в прежнем порядке.
@@ -161,5 +185,5 @@ bool config_rewrite_servers(const QString& filePath, const QList<ConfigServer>&
}
for (int i = block.size() - 1; i >= 0; --i) filtered.insert(insertPos, block[i]);
DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "GUI config prepared servers path=%s count=%lld", qPrintable(filePath), (long long)servers.size());
return write_config_lines(filePath, filtered);
return config_write_lines(filePath, filtered);
}
+12 -4
View File
@@ -4,8 +4,8 @@
* запись через соседний temp QSaveFile без direct-write fallback, с проверкой commit.
* До успешной замены исходный файл не усекается; ошибки логируются в CONFIG.
* Неправленные строки сохраняются, окончания строк нормализуются в LF. */
#ifndef CONFIG_UPDATER_H
#define CONFIG_UPDATER_H
#ifndef GUI_CONFIG_UPDATER_H
#define GUI_CONFIG_UPDATER_H
#include <QString>
#include <QStringList>
@@ -25,10 +25,18 @@ struct ConfigServer {
QString netif; // "" = не привязывать к интерфейсу
};
/* Проверенное чтение/сохранение для пакетных правок, одна запись на весь набор.
* read заменяет lines только при успехе; allowMissing принимает отсутствующий файл как пустой.
* write нормализует LF и добавляет конечный LF непустому тексту, проверяет физический INI-формат.
* true — успех; false — ошибка с CONFIG-диагностикой. Правила владения и записи см. в начале файла. */
bool config_read_lines(const QString& filePath, QStringList& lines, bool allowMissing = false);
bool config_write_lines(const QString& filePath, const QStringList& lines);
/* Изменить принадлежащие вызывающему lines, без файлового I/O; true — подготовлено.
* section/key непустые, без внешних пробелов; аргументы не содержат CR/LF/NUL.
* Значение — сырой INI-текст без экранирования. Ошибочные аргументы дают false без правок.
* Тип секции без учёта регистра, суффикс :name с учётом; chat == chatserver.
* Тип секции без учёта регистра, суффикс :name с учётом; chat == chatserver;
* в control пары ip/control_ip, port/control_port, allow/control_allow равнозначны.
* replace меняет все дубли; иначе вставка после последнего ключа. При отсутствии ключа
* используется последняя подходящая секция, при отсутствии секции она создаётся в конце.
* Физические ограничения файла проверяются при файловой записи, не этой функцией. */
@@ -49,4 +57,4 @@ bool config_update_option(const QString& filePath, const QString& section,
* Возврат и файловые гарантии — как у config_update_option. */
bool config_rewrite_servers(const QString& filePath, const QList<ConfigServer>& servers);
#endif // CONFIG_UPDATER_H
#endif // GUI_CONFIG_UPDATER_H