Share checked desktop config batches and diagnose write failures
This commit is contained in:
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"},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user