Preserve desktop configuration on failed writes
This commit is contained in:
1 parent
61ed80e18c
commit
13918d1cea
5 files changed
+317
-242
No files matched your search
+1
-1
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user