Preserve desktop configuration on failed writes

This commit is contained in:
evgeny committed 2026-10-27 02:57:07 +01:00
1 parent 61ed80e18c
commit 13918d1cea
5 files changed
+317 -242

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@ static inline int config_ini_read(FILE *file, const char *filename, char **text,
*text = NULL;
*length = 0;
while ((count = fread(chunk, 1, sizeof(chunk), file)) != 0) {
const char *nul = memchr(chunk, '\0', count);
const char *nul = (const char *)memchr(chunk, '\0', count);
if (nul) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: embedded NUL at byte %zu", filename, output.len + (size_t)(nul - chunk));
errno = EINVAL;
+1 -1
View File
@@ -594,7 +594,7 @@ add_executable(test_config_updater
transport/config_updater.cpp
)
target_include_directories(test_config_updater PRIVATE ${CMAKE_SOURCE_DIR})
target_link_libraries(test_config_updater PRIVATE ${QT_CORE} pthread)
target_link_libraries(test_config_updater PRIVATE ${QT_CORE} ${QT_TEST_LIB} utun pthread)
add_test(NAME test_config_updater COMMAND test_config_updater)
add_executable(test_node_config tests/test_node_config.cpp transport/node_config.cpp transport/config_updater.cpp)
+156 -103
View File
@@ -1,117 +1,170 @@
// tests/test_config_updater.cpp — Verify universal config updater
/* Обновление настоящих INI: дубли, UTF-8, независимые секции и отказ записи. */
#include "../transport/config_updater.h"
#include <QtTest>
#include <QTemporaryDir>
#include <QFile>
#include <QDebug>
#define VERIFY(cond, msg) do { if (!(cond)) { qCritical("FAIL: %s", msg); abort(); } } while(0)
static void test_new_file() {
const char* path = "/tmp/test_cfg_new.ini";
QFile::remove(path);
VERIFY(config_update_option(path, "global", "my_name", "testnode"), "new file write");
QFile f(path);
VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "new file open for read");
QString content = f.readAll();
f.close();
VERIFY(content.contains("[global]"), "new file has [global]");
VERIFY(content.contains("my_name=testnode"), "new file has my_name");
QFile::remove(path);
extern "C" {
#include "debug_config.h"
}
#ifdef Q_OS_UNIX
#include <sys/resource.h>
#include <csignal>
#endif
static void test_replace_existing() {
const char* path = "/tmp/test_cfg_replace.ini";
{
QFile f(path);
VERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text), "replace: create file");
f.write("[global]\nmy_name=oldname\nmy_id=abc\n\n[control]\naddr=1.2.3.4\n");
f.close();
class ConfigUpdaterTest : public QObject {
Q_OBJECT
QTemporaryDir directory;
QString path() const { return directory.filePath("node.cfg"); }
bool put(const QByteArray& data) {
QFile file(path());
return file.open(QIODevice::WriteOnly) && file.write(data) == data.size() && file.flush();
}
QByteArray contents() const {
QFile file(path());
if (!file.open(QIODevice::ReadOnly)) return {};
return file.readAll();
}
private slots:
void initTestCase() {
debug_config_init();
debug_enable_console(1);
debug_set_category_level(DEBUG_CATEGORY_CONFIG, DEBUG_LEVEL_DEBUG);
}
void init() { QVERIFY(directory.isValid()); QFile::remove(path()); }
void newFileAndSection() {
QVERIFY(config_update_option(path(), "global", "my_node_name", QString::fromUtf8("Тест")));
QVERIFY(config_update_option(path(), "gui", "language", "ru"));
QCOMPARE(contents(), QByteArray("[global]\nmy_node_name=") + QString::fromUtf8("Тест").toUtf8() +
"\n\n[gui]\nlanguage=ru\n");
}
VERIFY(config_update_option(path, "global", "my_name", "newname"), "replace: update");
QFile f(path);
VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "replace: open for read");
QString content = f.readAll();
f.close();
VERIFY(!content.contains("my_name=oldname"), "replace: old value gone");
VERIFY(content.contains("my_name=newname"), "replace: new value present");
VERIFY(content.contains("my_id=abc"), "replace: my_id preserved");
VERIFY(content.contains("[control]"), "replace: [control] preserved");
VERIFY(content.contains("addr=1.2.3.4"), "replace: addr preserved");
QFile::remove(path);
}
static void test_replace_new_param_in_section() {
const char* path = "/tmp/test_cfg_newparam.ini";
{
QFile f(path);
VERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text), "newparam: create file");
f.write("[global]\nmy_name=test\n\n[control]\naddr=1.2.3.4\n");
f.close();
void replacementUpdatesAllDuplicates() {
QVERIFY(put("# keep\n[global]\nmy_node_name=old\nmy_node_name=last\n"
"[control]\nport=9\n[GLOBAL]\nmy_node_name=override\n"));
QVERIFY(config_update_option(path(), "global", "my_node_name", "new"));
QCOMPARE(contents().count("my_node_name=new"), 3);
QVERIFY(!contents().contains("old"));
QVERIFY(!contents().contains("override"));
QVERIFY(contents().contains("# keep\n"));
QVERIFY(contents().contains("[control]\nport=9\n"));
}
VERIFY(config_update_option(path, "global", "new_param", "newval"), "newparam: insert");
QFile f(path);
VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "newparam: open for read");
QString content = f.readAll();
f.close();
VERIFY(content.contains("my_name=test"), "newparam: my_name preserved");
VERIFY(content.contains("new_param=newval"), "newparam: new_param inserted");
VERIFY(content.contains("[control]"), "newparam: [control] preserved");
QFile::remove(path);
}
static void test_append_mode() {
const char* path = "/tmp/test_cfg_append.ini";
{
QFile f(path);
VERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text), "append: create file");
f.write("[client:peer1]\nlink=srv:addr1:port1\nlink=srv:addr2:port2\n\n[global]\nname=x\n");
f.close();
void chatAliasUsesSameSection() {
QVERIFY(put("[ chat ]\nradio_auto_enabled=0\n[chatserver]\nradio_auto_enabled=0\n"));
QVERIFY(config_update_option(path(), "chatserver", "radio_auto_enabled", "1"));
QCOMPARE(contents().count("radio_auto_enabled=1"), 2);
}
VERIFY(config_update_option(path, "client:peer1", "link", "srv:addr3:port3", CONFIG_UPDATE_APPEND), "append: add link");
QFile f(path);
VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "append: open for read");
QString content = f.readAll();
f.close();
VERIFY(content.contains("link=srv:addr1:port1"), "append: link1 preserved");
VERIFY(content.contains("link=srv:addr2:port2"), "append: link2 preserved");
VERIFY(content.contains("link=srv:addr3:port3"), "append: link3 added");
QFile::remove(path);
}
static void test_new_section() {
const char* path = "/tmp/test_cfg_newsect.ini";
{
QFile f(path);
VERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text), "newsect: create file");
f.write("[global]\nname=x\n");
f.close();
void appendUsesLastMatchingSection() {
QStringList lines{"[client:peer]", "link=one", "[client:other]", "link=other",
"[client:peer]", "link=two", "[global]", "my_node_name=keep"};
QVERIFY(config_update_in_lines(lines, "client:peer", "link", "three", false));
QCOMPARE(lines.indexOf("link=three"), lines.indexOf("link=two") + 1);
QCOMPARE(lines.count("link=one"), 1);
QCOMPARE(lines.count("link=other"), 1);
}
VERIFY(config_update_option(path, "gui", "theme", "dark"), "newsect: add [gui]");
void insertsMissingKey() {
QStringList lines{"[global]", "my_node_name=keep", "[gui]", "language=en"};
QVERIFY(config_update_in_lines(lines, "global", "auto_sockets", "yes", true));
QCOMPARE(lines, (QStringList{"[global]", "auto_sockets=yes", "my_node_name=keep", "[gui]", "language=en"}));
}
QFile f(path);
VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "newsect: open for read");
QString content = f.readAll();
f.close();
VERIFY(content.contains("[global]"), "newsect: [global] preserved");
VERIFY(content.contains("name=x"), "newsect: name preserved");
VERIFY(content.contains("[gui]"), "newsect: [gui] added");
VERIFY(content.contains("theme=dark"), "newsect: theme added");
QFile::remove(path);
}
void invalidArgumentsDoNotMutate() {
const QStringList original{"[global]", "my_node_name=keep"};
for (const auto& values : {QStringList{"", "name", "value"}, QStringList{"global", "x=y", "value"},
QStringList{"global", "name", "one\ntwo"}, QStringList{"global]", "name", "value"}}) {
auto lines = original;
QVERIFY(!config_update_in_lines(lines, values[0], values[1], values[2], true));
QCOMPARE(lines, original);
}
}
int main() {
test_new_file();
test_replace_existing();
test_replace_new_param_in_section();
test_append_mode();
test_new_section();
return 0;
}
void rewritesOnlyServerSections() {
QVERIFY(put("[global]\nmy_node_name=keep\n\n[ SERVER : old ]\naddr=127.0.0.1:1\n"
"[client:peer]\nlink=old:127.0.0.1:2\n[server:other]\naddr=[::1]:3\n[gui]\nlanguage=ru\n"));
QVERIFY(config_rewrite_servers(path(), {{"lan", "127.0.0.1:10", "", "", ""},
{"lan6", "[::1]:10", "tcp", "local", "eth0"}}));
const auto text = contents();
QVERIFY(!text.contains("[ SERVER : old ]"));
QVERIFY(!text.contains("[server:other]"));
QVERIFY(text.contains("[server:lan]\naddr=127.0.0.1:10\ntransport=udp\ntype=public\n"));
QVERIFY(text.contains("[server:lan6]\naddr=[::1]:10\ntransport=tcp\ntype=local\nnetif=eth0\n"));
QVERIFY(text.indexOf("[server:lan]") < text.indexOf("[client:peer]"));
QVERIFY(text.contains("[client:peer]\nlink=old:127.0.0.1:2\n[gui]\nlanguage=ru\n"));
QVERIFY(config_rewrite_servers(path(), {}));
QVERIFY(!contents().contains("[server:"));
}
void readAndOpenFailures() {
QVERIFY(!config_update_option(directory.path(), "gui", "language", "en"));
QVERIFY(!config_rewrite_servers(directory.path(), {}));
QVERIFY(!config_update_option(directory.filePath("missing/node.cfg"), "gui", "language", "en"));
QVERIFY(!QFile::exists(directory.filePath("missing/node.cfg")));
}
void invalidTextIsNotRewritten() {
for (const QByteArray& original : {QByteArray("[global]\nname=bad\0tail\n", 23),
QByteArray("[global]\nname=\xff\n"), QByteArray("[global]\nname=") + QByteArray(1023, 'x') + '\n'}) {
QVERIFY(put(original));
QVERIFY(!config_update_option(path(), "global", "my_node_name", "new"));
QCOMPARE(contents(), original);
QVERIFY(!config_rewrite_servers(path(), {}));
QCOMPARE(contents(), original);
}
}
void invalidServerDoesNotReplaceFile() {
const QByteArray original("[server:keep]\naddr=127.0.0.1:1\n");
QVERIFY(put(original));
QVERIFY(!config_rewrite_servers(path(), {{"bad]\n[global", "127.0.0.1:2", "udp", "public", ""}}));
QCOMPARE(contents(), original);
}
void normalizesCrLf() {
QVERIFY(put("[global]\r\nmy_node_name=old\r\n# keep\r\n"));
QVERIFY(config_update_option(path(), "global", "my_node_name", "new"));
QCOMPARE(contents(), QByteArray("[global]\nmy_node_name=new\n# keep\n"));
}
void failedWritePreservesOriginal_data() {
QTest::addColumn<bool>("servers");
QTest::newRow("option") << false;
QTest::newRow("servers") << true;
}
void failedWritePreservesOriginal() {
#ifdef Q_OS_UNIX
QFETCH(bool, servers);
const QByteArray original("[global]\nmy_node_name=keep\n");
QVERIFY(put(original));
bool saved;
{
struct LimitGuard {
struct rlimit previous{};
using Handler = void (*)(int);
Handler handler = SIG_DFL;
bool active = false;
~LimitGuard() { if (active) { setrlimit(RLIMIT_FSIZE, &previous); std::signal(SIGXFSZ, handler); } }
} guard;
QVERIFY(getrlimit(RLIMIT_FSIZE, &guard.previous) == 0);
struct rlimit limited = guard.previous;
limited.rlim_cur = 64;
guard.handler = std::signal(SIGXFSZ, SIG_IGN);
guard.active = true;
QVERIFY(setrlimit(RLIMIT_FSIZE, &limited) == 0);
saved = servers ? config_rewrite_servers(path(), {{"lan", "127.0.0.1:10", "udp", "public", QString(200, 'x')}}) :
config_update_option(path(), "global", "my_node_name", QString(200, 'x'));
}
QVERIFY(!saved);
QCOMPARE(contents(), original);
QCOMPARE(QDir(directory.path()).entryList(QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot), QStringList{"node.cfg"});
#else
QSKIP("RLIMIT_FSIZE is available on Unix");
#endif
}
};
QTEST_GUILESS_MAIN(ConfigUpdaterTest)
#include "test_config_updater.moc"
+136 -130
View File
@@ -1,159 +1,165 @@
// config_updater.cpp — Universal INI config file updater (like insert_or_replace_option in utun)
/* Текстовые INI-обновления без перезаписи исходного файла до успешного commit. */
#include "config_updater.h"
#include <QFile>
#include <QTextStream>
#include <QFileInfo>
#include <QSaveFile>
static std::pair<int,int> find_section_bounds(const QStringList& lines, const QString& section) {
QString tag = "[" + section + "]";
for (int i = 0; i < lines.size(); i++) {
if (lines[i].trimmed() == tag) {
int se = lines.size();
for (int j = i + 1; j < lines.size(); j++) {
QString t = lines[j].trimmed();
if (t.startsWith('[') && t.endsWith(']')) { se = j; break; }
}
return {i, se};
}
}
return {-1, -1};
extern "C" {
#include "../../../src/config_ini.h"
}
/* Тип секции регистронезависим, имя экземпляра сохраняет регистр, как в ядре. */
static QString normalized_section(const QString& section) {
const int colon = section.indexOf(':');
if (colon >= 0) return section.left(colon).trimmed().toLower() + ':' + section.mid(colon + 1).trimmed();
const QString name = section.trimmed().toLower();
return name == "chat" ? QString("chatserver") : name;
}
static QString section_name(const QString& line) {
const QString text = line.trimmed();
return text.startsWith('[') && text.endsWith(']') ? normalized_section(text.mid(1, text.size() - 2)) : QString();
}
static bool single_line(const QString& value) {
return !value.contains('\n') && !value.contains('\r') && !value.contains(QChar(0));
}
/* Отказ до изменения lines: аргументы не могут создавать дополнительные строки/секции. */
bool config_update_in_lines(QStringList& lines, const QString& section,
const QString& key, const QString& value, bool replace) {
auto bounds = find_section_bounds(lines, section);
if (bounds.first < 0) {
if (!lines.isEmpty() && !lines.last().trimmed().isEmpty())
lines.append("");
lines.append("[" + section + "]");
lines.append(key + "=" + value);
return true;
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");
return false;
}
int si = bounds.first, se = bounds.second;
int ki = -1;
for (int i = si + 1; i < se; i++) {
int eq = lines[i].indexOf('=');
if (eq > 0 && lines[i].left(eq).trimmed() == key) { ki = i; break; }
const QString wanted = normalized_section(section);
const QString replacement = key + '=' + value;
int lastSection = -1, lastKey = -1, replaced = 0;
bool matching = false;
for (int i = 0; i < lines.size(); ++i) {
const QString name = section_name(lines[i]);
if (!name.isEmpty()) {
matching = name == wanted;
if (matching) lastSection = i;
continue;
}
if (!matching) continue;
const int equal = lines[i].indexOf('=');
if (equal <= 0 || lines[i].left(equal).trimmed() != key) continue;
lastKey = i;
if (replace) { lines[i] = replacement; ++replaced; }
}
QString newLine = key + "=" + value;
if (replace) {
if (ki >= 0)
lines[ki] = newLine;
else
lines.insert(si + 1, newLine);
} else {
if (ki >= 0) {
int lastKi = ki;
for (int i = ki + 1; i < se; i++) {
int eq = lines[i].indexOf('=');
if (eq > 0 && lines[i].left(eq).trimmed() == key) lastKi = i;
}
lines.insert(lastKi + 1, newLine);
if (!replaced) {
if (lastSection < 0) {
if (!lines.isEmpty() && !lines.last().trimmed().isEmpty()) lines.append("");
lines.append('[' + section + ']');
lines.append(replacement);
} else {
lines.insert(si + 1, newLine);
lines.insert(lastKey >= 0 ? lastKey + 1 : lastSection + 1, replacement);
}
}
DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "GUI config prepared section=%s key=%s mode=%s replaced=%d",
qPrintable(section), qPrintable(key), replace ? "replace" : "append", replaced);
return true;
}
/* ENOENT допускает создание; ошибки чтения и непредставимый UTF-8 запрещают перезапись. */
static bool read_config_lines(const QString& path, QStringList& lines) {
QFile input(path);
const QFileInfo info(path);
if (!info.exists() && !info.isSymLink()) 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;
}
const QByteArray data = input.readAll();
if (input.error() != QFileDevice::NoError) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config read failed path=%s: %s", qPrintable(path), qPrintable(input.errorString()));
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));
return false;
}
if (!text.isEmpty()) {
lines = text.split('\n');
if (lines.last().isEmpty()) lines.removeLast();
for (QString& line : lines) if (line.endsWith('\r')) line.chop(1);
}
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) {
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()));
return false;
}
DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "GUI config saved path=%s bytes=%lld", qPrintable(path), (long long)data.size());
return true;
}
bool config_update_option(const QString& filePath, const QString& section,
const QString& param, const QString& value, int flags) {
QFile f(filePath);
QStringList lines;
if (f.exists()) {
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return false;
QTextStream in(&f);
while (!in.atEnd()) {
QString line = in.readLine(0);
if (line.endsWith('\r')) line.chop(1);
lines.append(line);
}
f.close();
}
bool replace = (flags != CONFIG_UPDATE_APPEND);
if (!config_update_in_lines(lines, section, param, value, replace))
if (flags != CONFIG_UPDATE_REPLACE && flags != CONFIG_UPDATE_APPEND) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config update rejected flags=%d path=%s", flags, qPrintable(filePath));
return false;
if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) return false;
QTextStream out(&f);
for (int i = 0; i < lines.size(); i++)
out << lines[i] << '\n';
f.close();
return true;
}
static bool is_section_header(const QString& line) {
QString t = line.trimmed();
return t.startsWith('[') && t.endsWith(']');
}
QStringList lines;
if (!read_config_lines(filePath, lines) ||
!config_update_in_lines(lines, section, param, value, flags == CONFIG_UPDATE_REPLACE)) return false;
return write_config_lines(filePath, lines);
}
bool config_rewrite_servers(const QString& filePath, const QList<ConfigServer>& servers) {
QFile f(filePath);
QStringList lines;
if (!read_config_lines(filePath, lines)) return false;
if (f.exists()) {
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return false;
QTextStream in(&f);
while (!in.atEnd()) {
QString line = in.readLine(0);
if (line.endsWith('\r')) line.chop(1);
lines.append(line);
}
f.close();
}
/* 1. Удалить все [server:*] секции (заголовок + ключи + хвостовую пустую строку-разделитель) */
QStringList filtered;
filtered.reserve(lines.size());
for (int i = 0; i < lines.size(); i++) {
QString t = lines[i].trimmed();
if (t.startsWith("[server:") && t.endsWith(']')) {
int j = i + 1;
while (j < lines.size() && !is_section_header(lines[j])) j++;
i = j - 1; /* for-loop сделает i++ → j: заголовок+ключи+пустая строка пропущены */
continue;
}
filtered << lines[i];
}
lines = filtered;
/* 2. Собрать блок секций [server:*] */
QStringList block;
for (const ConfigServer& s : servers) {
block << ("[server:" + s.name + "]");
block << ("addr=" + s.addr);
block << ("transport=" + (s.transport.isEmpty() ? QString("udp") : s.transport));
block << ("type=" + (s.type.isEmpty() ? QString("public") : s.type));
if (!s.netif.isEmpty()) block << ("netif=" + s.netif);
block << ""; /* разделитель: одна пустая строка между секциями */
}
/* 3. Точка вставки: после секции [global] (перед следующей секцией) */
int insertPos = lines.size();
for (int i = 0; i < lines.size(); i++) {
if (lines[i].trimmed() == "[global]") {
int j = i + 1;
while (j < lines.size() && !is_section_header(lines[j])) j++;
insertPos = j;
break;
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));
return false;
}
block << ("[server:" + server.name + ']') << ("addr=" + server.addr);
block << ("transport=" + (server.transport.isEmpty() ? QString("udp") : server.transport));
block << ("type=" + (server.type.isEmpty() ? QString("public") : server.type));
if (!server.netif.isEmpty()) block << ("netif=" + server.netif);
block << "";
}
/* 4. Вставить блок (если непустой) */
if (!block.isEmpty()) {
for (int i = block.size() - 1; i >= 0; i--)
lines.insert(insertPos, block[i]);
// Удаляем только server-секции целиком, сохраняя прочие строки в прежнем порядке.
QStringList filtered;
bool serverSection = false;
for (const QString& line : lines) {
const QString section = section_name(line);
if (!section.isEmpty()) serverSection = section.startsWith("server:");
if (!serverSection) filtered << line;
}
/* 5. Записать */
if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) return false;
QTextStream out(&f);
for (const QString& l : lines)
out << l << '\n';
f.close();
return true;
int insertPos = filtered.size();
bool globalSection = false;
for (int i = 0; i < filtered.size(); ++i) {
const QString section = section_name(filtered[i]);
if (section.isEmpty()) continue;
if (globalSection) { insertPos = i; break; }
globalSection = section == "global";
}
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);
}
+23 -7
View File
@@ -1,4 +1,9 @@
// config_updater.h — Universal INI config file updater (like insert_or_replace_option in utun)
/* Текстовые правки UTF-8 INI для desktop. Вызовы синхронные; вызывающий исключает
* параллельные изменения одного файла. Это не проверка схемы/значений ядра.
* Файловые операции проверяют чтение, UTF-8, NUL и предел строки 1022 байта;
* запись через соседний temp QSaveFile без direct-write fallback, с проверкой commit.
* До успешной замены исходный файл не усекается; ошибки логируются в CONFIG.
* Неправленные строки сохраняются, окончания строк нормализуются в LF. */
#ifndef CONFIG_UPDATER_H
#define CONFIG_UPDATER_H
@@ -7,8 +12,8 @@
#include <QList>
enum ConfigUpdateFlag {
CONFIG_UPDATE_REPLACE = 0, // replace existing param (single value)
CONFIG_UPDATE_APPEND = 1 // add new param even if exists (multi-value)
CONFIG_UPDATE_REPLACE = 0, // заменить все вхождения ключа в подходящих секциях
CONFIG_UPDATE_APPEND = 1 // добавить ещё одно значение, сохранив прежние
};
/* Один listen-сокет = секция [server:name]. */
@@ -20,17 +25,28 @@ struct ConfigServer {
QString netif; // "" = не привязывать к интерфейсу
};
// Batch update — for callers that read/write the file once (e.g. NodeConfig::save)
/* Изменить принадлежащие вызывающему lines, без файлового I/O; true — подготовлено.
* section/key непустые, без внешних пробелов; аргументы не содержат CR/LF/NUL.
* Значение — сырой INI-текст без экранирования. Ошибочные аргументы дают false без правок.
* Тип секции без учёта регистра, суффикс :name с учётом; chat == chatserver.
* replace меняет все дубли; иначе вставка после последнего ключа. При отсутствии ключа
* используется последняя подходящая секция, при отсутствии секции она создаётся в конце.
* Физические ограничения файла проверяются при файловой записи, не этой функцией. */
bool config_update_in_lines(QStringList& lines, const QString& section,
const QString& key, const QString& value, bool replace);
// File-level wrapper — reads, updates, writes
/* Прочитать filePath (отсутствующий файл допустим), применить правку и сохранить.
* flags — ConfigUpdateFlag; true только после успешного commit, false с логом ошибки.
* Как и read/modify/write в целом, не обеспечивает атомарность нескольких отдельных вызовов. */
bool config_update_option(const QString& filePath, const QString& section,
const QString& param, const QString& value,
int flags = CONFIG_UPDATE_REPLACE);
// Полностью заменяет секции [server:*]: удаляет старые, вставляет новые после [global].
// Внутри секции без пустых строк, между секциями ровно одна пустая строка.
/* Полностью заменить server-секции; вставить блок после первого global, иначе в конец.
* Пустой servers удаляет все server-секции. Остальные секции не изменяются.
* В новом блоке одна пустая строка после каждой секции, transport/type по умолчанию udp/public.
* Имена/поля не могут добавлять заголовки или строки; адреса и enum-значения проверяет вызывающий/ядро.
* Возврат и файловые гарантии — как у config_update_option. */
bool config_rewrite_servers(const QString& filePath, const QList<ConfigServer>& servers);
#endif // CONFIG_UPDATER_H