From 7be192ce0dd677743f7b00a54a973123ec976863 Mon Sep 17 00:00:00 2001 From: Cavanshirpro Date: Sun, 11 Oct 2026 23:33:56 +0400 Subject: [PATCH 1/7] Recover failed SDK checks and generate usable local integrations --- CHANGELOG.md | 7 ++++ MANIFEST.sha256 | 6 +-- editor/src/PythonSdkPanel.cpp | 33 +++++++++++---- editor/tests/tst_editor_core.cpp | 69 ++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4f673..cef5804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Editor changelog +## Unreleased maintenance + +- Show Python interpreter startup errors immediately and allow subsequent SDK checks instead of remaining stuck in the running state. +- Generate the explicit loopback HTTP opt-in for local Python SDK integrations and health checks while retaining HTTPS requirements for remote endpoints. +- Correct the install command to use the independent `json-api-forge-client` package, including YoungLion/DDM extras. +- Keep these changes separate from the frozen v0.5.2 publication candidate. + ## [0.5.2] — 2026-10-04 - Added an interruptible sidebar animation that resizes the document workspace along with navigation. diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 5a9d0e1..ec2951e 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -13,7 +13,7 @@ af795a365fc479fbc7d4aedd6cf90fc0990da0181d15cd3854da34c2b861b765 .gitignore 09094d533067dddbaf48b9c021ec594292ddc875e8f93735e44dcff9ab62bf55 AI_USAGE_POLICY.md 759da1adabb4d60f41e3b678452af42192ee3a2c9cc40b0d30522153df374708 AUTHORS.md 08a52d0ba592af11dfd45e217bd0f11e9a925043ee1f43c144e85d48f6559660 BRAND.md -cf776572f2de00492197f3ecb13d39a85cc4955ff281a7c4e6684decf1da1eb9 CHANGELOG.md +84ad48c274111283e06db031c8e41f01dc30cd2463b30c5f00f1338e0ef8856d CHANGELOG.md c1e12824e5f0cb70bcd405b005625b07e4a7e1a8f110e5242768725711dea676 CITATION.cff d5c61448d613124dd312e4a78cee516c60f29dc3034eeb8ac55bd10fcf64e005 CMakeLists.txt 3687086aeb50202ab381acb9334a48fe3f72ba2f294ebf1f52bb34c9b84f54f5 CMakePresets.json @@ -99,7 +99,7 @@ d88d5018d225bc55fcfa8d5e7115ad15ef65640ab87b651e64fc7e3001c06191 editor/src/Nod 8783c3a3141fe5dbae4ff39b8765e0ecc9741d63f92e0bb2dda7a515707a06b0 editor/src/PluginCatalogClient.hpp d07cf05b6c850e4cfeede18f32b8b6744bfc1af6fa965d54bffee0c84ca80241 editor/src/PluginManager.cpp 5c00f7ae3a0ee421d57137a145205615726a71576e2e23ca4b831884986d516b editor/src/PluginManager.hpp -d68cff49e8c4816430db1844be1d388159cacb3741dfc6a6f3764ea9c91748b6 editor/src/PythonSdkPanel.cpp +e1409ab312d2bb9e15451d15d2da3d9f9088883d197f9fb3a9c6a8731fdd47d9 editor/src/PythonSdkPanel.cpp 1fdcebef6bccf297074a6fe23d5d3da4e51f33148409793fe33459d78e746ef6 editor/src/PythonSdkPanel.hpp 33ea00abbda668bc8dfc58b4530aa7b5dec5b3c3dbf430ab7e552b2e8c4f7d7f editor/src/SafeZipReader.cpp 9199472d7380299b49848d002e600f20bdcdd021c68da5fea0005261013f2930 editor/src/SafeZipReader.hpp @@ -116,7 +116,7 @@ affa4bcf9e111a6268b38f6cce443a36d8226bba89d50f7076d13b1fbd46f5b7 editor/tests/r 4f2a2a940e8ae21033bc273c99073522013b17b33558278e00231f1eda2a8c2f editor/tests/test_packaging.py 4f043b560c3e49e9e8cbfbcf07242c020768bd203dab26d476e7a96cbdec05bf editor/tests/test_release_assets.py e7804622971a88473783057160bb7e328783bef6645a467d1155e3a95aa9d64f editor/tests/test_windows_installer.py -76840f657930474417db9dbf6c41cef0359a977ac2c416ee35cb7223071c5187 editor/tests/tst_editor_core.cpp +3e784c2299e5cfe8ff7b9a8b204d690af7db07f27e2f27ec9a70e8dbdb321b1e editor/tests/tst_editor_core.cpp 045e2bb13085806d1afa4e02d810261a099531b27467fa54d158adc191f94cb7 editor/tests/tst_editor_ui.cpp 18d8673b83d59483a1abb47d685f263a52b1f0dbdc9fa1ed6d56e80de2822e34 editor/third_party/puff/README.md 5b9d75aeb5baf3575415bc6ade3f2a02e50b6b971b3f8b4fda2b03543bc6e52f editor/third_party/puff/puff.c diff --git a/editor/src/PythonSdkPanel.cpp b/editor/src/PythonSdkPanel.cpp index a30f4a7..0aaaf7b 100644 --- a/editor/src/PythonSdkPanel.cpp +++ b/editor/src/PythonSdkPanel.cpp @@ -44,6 +44,13 @@ bool safeEndpoint(const QString &value, QString *errorMessage) QUrl normalized; return ApiClient::normalizeServerUrl(QUrl(value), true, &normalized, errorMessage); } + +QString pythonEndpoint(const QString &value) +{ + // safeEndpoint has already restricted plain HTTP to an explicit loopback URL. + return pythonLiteral(value) + (QUrl(value).scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) == 0 + ? QStringLiteral(", allow_insecure_http=True") : QString()); +} } // namespace PythonSdkPanel::PythonSdkPanel(QWidget *parent) @@ -145,7 +152,7 @@ QString PythonSdkPanel::generatedSnippet(const PythonSdkSettings &settings, QStr fail(errorMessage, QStringLiteral("Project, route, operation or environment variable contains unsafe characters.")); return {}; } - const auto endpoint = pythonLiteral(settings.endpoint); + const auto endpoint = pythonEndpoint(settings.endpoint); const auto project = pythonLiteral(settings.project); const auto resource = pythonLiteral(settings.resource); const auto operation = pythonLiteral(settings.operation); @@ -191,7 +198,7 @@ QString PythonSdkPanel::generatedSnippet(const PythonSdkSettings &settings, QStr } endpointDefinitions.append( QStringLiteral(" ForgeEndpoint(%1, %2, api_key=os.environ[%3]),") - .arg(pythonLiteral(QStringLiteral("region-%1").arg(++index)), pythonLiteral(value), environment)); + .arg(pythonLiteral(QStringLiteral("region-%1").arg(++index)), pythonEndpoint(value), environment)); } return QStringLiteral( "import os\n\n" @@ -238,8 +245,8 @@ void PythonSdkPanel::refreshSnippet() const auto extra = m_mode->currentData().toString() == QStringLiteral("younglion") ? QStringLiteral("younglion") : (m_mode->currentData().toString() == QStringLiteral("ddm") ? QStringLiteral("ddm") : QString()); - m_install->setText(extra.isEmpty() ? QStringLiteral("Install: pip install json-api-forge") - : QStringLiteral("Install: pip install \"json-api-forge[%1]\"").arg(extra)); + m_install->setText(extra.isEmpty() ? QStringLiteral("Install: pip install json-api-forge-client") + : QStringLiteral("Install: pip install \"json-api-forge-client[%1]\"").arg(extra)); if (snippet.isEmpty()) { m_snippet->setPlainText(QStringLiteral("# %1").arg(error)); return; @@ -280,7 +287,7 @@ void PythonSdkPanel::runHealthCheck() "with ForgeClient(%2, api_key=key, timeout=5.0) as client:\n" " response = client.health()\n" " print('HTTP', response.status_code, response.data)\n") - .arg(pythonLiteral(value.apiKeyEnvironment), pythonLiteral(value.endpoint)); + .arg(pythonLiteral(value.apiKeyEnvironment), pythonEndpoint(value.endpoint)); startPython(script, QStringLiteral("Forge SDK health check")); } @@ -298,11 +305,21 @@ bool PythonSdkPanel::startPython(const QString &script, const QString &operation m_result->setText(QStringLiteral("No Python interpreter was found on PATH.")); return false; } - m_process = new QProcess(this); + auto *process = new QProcess(this); + m_process = process; m_processOperation = operation; m_process->setProcessChannelMode(QProcess::MergedChannels); - connect(m_process, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) { - const auto output = QString::fromUtf8(m_process->readAll()).trimmed().left(4096); + connect(process, &QProcess::errorOccurred, this, [this, process](QProcess::ProcessError error) { + if (error != QProcess::FailedToStart || m_process != process) { return; } + const auto message = QStringLiteral("%1 could not start: %2").arg(m_processOperation, process->errorString()); + m_result->setText(message); + m_process = nullptr; + process->deleteLater(); + emit statusMessage(message); + }); + connect(process, &QProcess::finished, this, [this, process](int exitCode, QProcess::ExitStatus status) { + if (m_process != process) { return; } + const auto output = QString::fromUtf8(process->readAll()).trimmed().left(4096); const bool success = status == QProcess::NormalExit && exitCode == 0; m_result->setText(QStringLiteral("%1: %2\n%3") .arg(m_processOperation, success ? QStringLiteral("passed") : QStringLiteral("failed"), diff --git a/editor/tests/tst_editor_core.cpp b/editor/tests/tst_editor_core.cpp index 5d48558..4eabf81 100644 --- a/editor/tests/tst_editor_core.cpp +++ b/editor/tests/tst_editor_core.cpp @@ -10,6 +10,7 @@ #include "TemplateManager.hpp" #include +#include #include #include #include @@ -24,6 +25,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -70,6 +75,8 @@ private slots: void graphCycleRollback(); void graphCompilerRejectsDesignOnlyNodes(); void pythonSdkSnippetPolicy(); + void pythonSdkFailedStartAllowsRetry(); + void pythonSdkInstallPackageIdentity(); void forgePluginCatalogPolicy(); void embeddedProjectTemplates(); }; @@ -1122,6 +1129,55 @@ void EditorCoreTests::graphCompilerRejectsDesignOnlyNodes() QVERIFY(error.contains(QStringLiteral("not silently omitted"))); } +void EditorCoreTests::pythonSdkInstallPackageIdentity() +{ + PythonSdkPanel panel; + auto *mode = panel.findChild(); + auto *install = panel.findChild(QStringLiteral("sdkInstallCard")); + QVERIFY(mode != nullptr && install != nullptr); + for (int index = 0; index < mode->count(); ++index) { + mode->setCurrentIndex(index); + const auto extra = mode->currentData().toString(); + if (extra == QStringLiteral("younglion") || extra == QStringLiteral("ddm")) { + QCOMPARE(install->text(), QStringLiteral("Install: pip install \"json-api-forge-client[%1]\"").arg(extra)); + } else { + QCOMPARE(install->text(), QStringLiteral("Install: pip install json-api-forge-client")); + } + } +} + +void EditorCoreTests::pythonSdkFailedStartAllowsRetry() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); +#ifdef Q_OS_WIN + const auto name = QStringLiteral("python3.exe"); +#else + const auto name = QStringLiteral("python3"); +#endif + QFile invalidInterpreter(directory.filePath(name)); + QVERIFY(invalidInterpreter.open(QIODevice::WriteOnly)); + invalidInterpreter.write("This is not an executable interpreter.\n"); + invalidInterpreter.close(); + QVERIFY(invalidInterpreter.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + const auto previousPath = qgetenv("PATH"); + const auto restorePath = qScopeGuard([previousPath] { qputenv("PATH", previousPath); }); + qputenv("PATH", directory.path().toLocal8Bit()); + PythonSdkPanel panel; + QPushButton *check = nullptr; + for (auto *button : panel.findChildren()) { + if (button->text() == QStringLiteral("Check installed SDK")) { check = button; } + } + QVERIFY(check != nullptr); + QSignalSpy messages(&panel, &PythonSdkPanel::statusMessage); + check->click(); + QVERIFY(waitUntil([&] { return messages.count() == 1; })); + QVERIFY(messages.at(0).at(0).toString().contains(QStringLiteral("could not start"))); + check->click(); + QVERIFY(waitUntil([&] { return messages.count() == 2; })); + QVERIFY(messages.at(1).at(0).toString().contains(QStringLiteral("could not start"))); +} + void EditorCoreTests::pythonSdkSnippetPolicy() { const PythonSdkSettings sync{QStringLiteral("sync"), QStringLiteral("https://forge.example.com"), QString(), @@ -1140,6 +1196,19 @@ void EditorCoreTests::pythonSdkSnippetPolicy() cluster.clusterEndpoints = QStringLiteral("https://eu.example.com,https://us.example.com"); const auto clusterCode = PythonSdkPanel::generatedSnippet(cluster, &error); QVERIFY2(clusterCode.contains(QStringLiteral("RoutingStrategy.RENDEZVOUS")), qPrintable(error)); + for (const auto &mode : {QStringLiteral("sync"), QStringLiteral("async"), QStringLiteral("ddm"), QStringLiteral("younglion")}) { + auto local = sync; + local.mode = mode; + local.endpoint = QStringLiteral("http://127.0.0.1:8000"); + const auto localCode = PythonSdkPanel::generatedSnippet(local, &error); + QVERIFY2(localCode.contains(QStringLiteral("allow_insecure_http=True")), qPrintable(error)); + local.endpoint = QStringLiteral("http://remote.example.com"); + QVERIFY(PythonSdkPanel::generatedSnippet(local, &error).isEmpty()); + } + cluster.clusterEndpoints = QStringLiteral("http://localhost:8000,https://remote.example.com"); + const auto mixedCode = PythonSdkPanel::generatedSnippet(cluster, &error); + QVERIFY2(!mixedCode.isEmpty(), qPrintable(error)); + QCOMPARE(mixedCode.count(QStringLiteral("allow_insecure_http=True")), 1); } void EditorCoreTests::forgePluginCatalogPolicy() From cb323149ecdd1f899aa035da471458b139e00611 Mon Sep 17 00:00:00 2001 From: Cavanshirpro Date: Mon, 12 Oct 2026 22:16:50 +0400 Subject: [PATCH 2/7] Preserve visual JSON precision and mutation graph safety --- CHANGELOG.md | 4 ++ CONTRIBUTING.md | 7 +++ MANIFEST.sha256 | 14 +++--- editor/src/ChatView.cpp | 2 +- editor/src/GraphModel.cpp | 31 +++++++++++++ editor/src/VisualDesigner.cpp | 13 ++++-- editor/tests/tst_editor_core.cpp | 74 ++++++++++++++++++++++++++++++++ editor/tests/tst_editor_ui.cpp | 56 ++++++++++++++++++++++++ 8 files changed, 189 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cef5804..c044386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ - Show Python interpreter startup errors immediately and allow subsequent SDK checks instead of remaining stuck in the running state. - Generate the explicit loopback HTTP opt-in for local Python SDK integrations and health checks while retaining HTTPS requirements for remote endpoints. - Correct the install command to use the independent `json-api-forge-client` package, including YoungLion/DDM extras. +- Preserve exact signed 64-bit JSON integers in Visual properties and reject extra values or fields in a single property input. +- Keep selected chat text when an incoming message refreshes a conversation at the bottom of the view. +- Preserve SQL row-count guards and operation cache invalidation targets when compiling mutation graphs; reject malformed guard and invalidation values. +- Add a native cross-component contract check that compares generated exampleApps transition graphs with their complete RPC operation definitions. - Keep these changes separate from the frozen v0.5.2 publication candidate. ## [0.5.2] — 2026-10-04 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a199bb5..1872b14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,13 @@ passwords, founder setup secrets, bearer sessions or call tickets. Native plugin changes must preserve path containment, no-symlink, digest, API-version, declared-permission and explicit-enable checks. +For cross-component graph checks, set `FORGE_EDITOR_EXAMPLES_APP_ROOT` to the +`app` directory of a separately checked-out exampleApps branch, then run +`forge_editor_tests graphExamplesCompilerContract` from the native build. +The check compiles every domain-transition graph and compares the entire +resulting operation with its target RPC definition. It skips when the variable +is absent; the Editor source distribution does not include the example apps. + Pull requests should explain behavior, compatibility, security impact, tests and packaging impact. Contribution forks are permitted only within the narrow collaboration exception described by the project license. diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index ec2951e..a903d08 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -13,12 +13,12 @@ af795a365fc479fbc7d4aedd6cf90fc0990da0181d15cd3854da34c2b861b765 .gitignore 09094d533067dddbaf48b9c021ec594292ddc875e8f93735e44dcff9ab62bf55 AI_USAGE_POLICY.md 759da1adabb4d60f41e3b678452af42192ee3a2c9cc40b0d30522153df374708 AUTHORS.md 08a52d0ba592af11dfd45e217bd0f11e9a925043ee1f43c144e85d48f6559660 BRAND.md -84ad48c274111283e06db031c8e41f01dc30cd2463b30c5f00f1338e0ef8856d CHANGELOG.md +4c99795cacc998d4554f8d56e9529ef25bb3f0670b495a4de1a3e65efedd67b8 CHANGELOG.md c1e12824e5f0cb70bcd405b005625b07e4a7e1a8f110e5242768725711dea676 CITATION.cff d5c61448d613124dd312e4a78cee516c60f29dc3034eeb8ac55bd10fcf64e005 CMakeLists.txt 3687086aeb50202ab381acb9334a48fe3f72ba2f294ebf1f52bb34c9b84f54f5 CMakePresets.json ea87ab99ee7120f85aba29aa86365691dbeb12a1c15ace2b07bd4cbf90942af2 CODE_OF_CONDUCT.md -5ae93d46cd55cef66898eef018c7996ad732e38b74afb498bf295d78ab074c14 CONTRIBUTING.md +0595cd55bb59291490cca59e96bbd193f403cc88a0ffc03e8c6901c89feab194 CONTRIBUTING.md 8ddc6cc3010510fdc12fe7e7203a49f1f6a772c37c9e6d867d2f76e527d710c5 CONTRIBUTOR_LICENSE_AGREEMENT.md af80eadad7eab7c270043428ef05a0d01c66f58f64d9a3687d59cb958d0b26bf EDITOR-FIX-2026-10-03.md 7df4943aeac91fdbce54c774af3d4802e5068e345e6062c2622566a357456c7d EDITOR-FIX-v0.5.1.md @@ -77,7 +77,7 @@ c2fcb9d3d16c6bc879863ac5124615383c2ccea261c2522fdfdd20e4404c88c9 editor/resourc d4f81573e7196e0f02956a2892eafe85c4185f7c2fea6f8f62fab38c0b445751 editor/resources/windows.rc 4ada539eac8b3790b51ef32c0972122056db2f7126eadc51fc510aa07b8575a4 editor/src/ApiClient.cpp 7ace57059c2d4477f38606795156667b5498359da68bcb660aa3d909899ecc9d editor/src/ApiClient.hpp -b76a8ea9286cecab41f304ffb69aca5555cef8a79c8fe31aa68e294ab6a12b2e editor/src/ChatView.cpp +097417cb676f46a79b7ac07978dd9715c95705a9bf2354fc099010e792133291 editor/src/ChatView.cpp fb6cdb4d0031b11aa3afc5e60ddb415a373d924bc7824d30f8eecf65591e3f5f editor/src/ChatView.hpp 4de9f9c93e7bc1adec1406f263087f05811188fd2a81acdc529d2ef6b1c19ce1 editor/src/CodeEditor.cpp 80394ea4964e8a998352da50c8bfbef13ed501e7570171eaf306beed05721633 editor/src/CodeEditor.hpp @@ -87,7 +87,7 @@ fb6cdb4d0031b11aa3afc5e60ddb415a373d924bc7824d30f8eecf65591e3f5f editor/src/Cha 1b1e95f90299859e7b87a7f0bc6175dec52b22a3299df712aab68ede1a0c3892 editor/src/DocumentCodec.hpp 450e22bdd8403d0ea286d695a26e684d8798ad60b75d8b2261b569c90d2f9b5d editor/src/EditorSettings.cpp 3d9622521038c72e2ba9fb511d0233e7487dc8b682e87aa73f596cab4eab225a editor/src/EditorSettings.hpp -42165378095dc89a6ef06a86605b7593c54f35ba913d9d589da8bb2744e3ed1c editor/src/GraphModel.cpp +e7e74e7850a3186fff7fcd00ff4efd4e0875c4e170f0312d6624fb557c619280 editor/src/GraphModel.cpp 278cb28cb3652b378386e090cbccf06d22275ce2abdd2fae8acfb5c41e792de1 editor/src/GraphModel.hpp 3016f23550982ea5c6204e66ae82f89b415a6313d96eddf10b3dc980025bad9b editor/src/JsonHighlighter.cpp e10c36a5ff946825cd3ee736a5e93255fb1b05f0ad8ecc329b3c5f45fdcb53b0 editor/src/JsonHighlighter.hpp @@ -108,7 +108,7 @@ fe1857e3fff6f1b8394d0069924a461c333e745be37d659cec52e8fa4b3d2f9c editor/src/Tea 036228cf2017139c3c8040092368bfa319eb8ad1019221303c92408ea20d2787 editor/src/TemplateManager.cpp a4d95987722b71f6f5e0573e92efee307402872ae83341d559d502f56948ed3a editor/src/TemplateManager.hpp ebe5aa708c199c5171b5456f2b8e387444296f472f61725648a7e2fa20e5b0b7 editor/src/UiSizing.hpp -4cef5d201f9de38ece0fcd45bcb4278fdea3d04881f8fb8b8d93dfbabc8b2ad1 editor/src/VisualDesigner.cpp +ac0f0dba8d1363b08ee6a43d38f01faab3287ebae1faa72da2d2993ac810d831 editor/src/VisualDesigner.cpp 2915fae2b3abae41015ed6c1b53a47eb20c5801422be511a2bd2a5545e34583d editor/src/VisualDesigner.hpp f481e5cbe4f1d28e724e448863ccee8307e98a159791cd7b348ea31f2b123b48 editor/src/main.cpp e2aa41034ce77b1e91a63de94a46516f6197eee8d6afd643595295d49a1d779d editor/tests/ZipFixtureWriter.hpp @@ -116,8 +116,8 @@ affa4bcf9e111a6268b38f6cce443a36d8226bba89d50f7076d13b1fbd46f5b7 editor/tests/r 4f2a2a940e8ae21033bc273c99073522013b17b33558278e00231f1eda2a8c2f editor/tests/test_packaging.py 4f043b560c3e49e9e8cbfbcf07242c020768bd203dab26d476e7a96cbdec05bf editor/tests/test_release_assets.py e7804622971a88473783057160bb7e328783bef6645a467d1155e3a95aa9d64f editor/tests/test_windows_installer.py -3e784c2299e5cfe8ff7b9a8b204d690af7db07f27e2f27ec9a70e8dbdb321b1e editor/tests/tst_editor_core.cpp -045e2bb13085806d1afa4e02d810261a099531b27467fa54d158adc191f94cb7 editor/tests/tst_editor_ui.cpp +aa09642aa2df3dac3ab9b7296c37743b3f3db66c0ff5ac3fe7a373724eae8491 editor/tests/tst_editor_core.cpp +902d9cf36689e3d6a9bdc8e1136a8554d72a9155147eea50259a3ef3cb9bbe0a editor/tests/tst_editor_ui.cpp 18d8673b83d59483a1abb47d685f263a52b1f0dbdc9fa1ed6d56e80de2822e34 editor/third_party/puff/README.md 5b9d75aeb5baf3575415bc6ade3f2a02e50b6b971b3f8b4fda2b03543bc6e52f editor/third_party/puff/puff.c 969b7be2a930db0cdcb19b0e5b29ae6741f5a8f663b6dba6d647e12ec60cfa8e editor/third_party/puff/puff.h diff --git a/editor/src/ChatView.cpp b/editor/src/ChatView.cpp index e1964ae..7dcd239 100644 --- a/editor/src/ChatView.cpp +++ b/editor/src/ChatView.cpp @@ -91,7 +91,7 @@ bool ChatView::setConversation(const QJsonArray &messages, const QJsonArray &att QString::number(file.value(QStringLiteral("size")).toDouble() / 1024.0, 'f', 1)); } setHtml(content); - if (!bottom && anchor != selection) { + if (anchor != selection) { QTextCursor restored(document()); const int maximum = qMax(0, document()->characterCount() - 1); restored.setPosition(qBound(0, anchor, maximum)); diff --git a/editor/src/GraphModel.cpp b/editor/src/GraphModel.cpp index 6c1b5f0..4f81587 100644 --- a/editor/src/GraphModel.cpp +++ b/editor/src/GraphModel.cpp @@ -438,6 +438,22 @@ QJsonObject GraphModel::compiledFragment(QString *errorMessage) const if (properties.value(QStringLiteral("max_rows")).isDouble()) { statement.insert(QStringLiteral("max_rows"), properties.value(QStringLiteral("max_rows"))); } + for (const auto &key : {QStringLiteral("require_rowcount_min"), QStringLiteral("require_rowcount_max")}) { + if (!properties.contains(key)) { continue; } + const auto bound = properties.value(key); + if (!bound.isNull() && (!bound.isDouble() || bound.toInteger(-1) < 0 || QJsonValue(bound.toInteger(-1)) != bound)) { + fail(errorMessage, QStringLiteral("SQL node %1 must be a nonnegative integer or null.").arg(key)); + return {}; + } + statement.insert(key, bound); + } + if (!statement.value(QStringLiteral("require_rowcount_min")).isNull() + && !statement.value(QStringLiteral("require_rowcount_max")).isNull() + && statement.contains(QStringLiteral("require_rowcount_min")) && statement.contains(QStringLiteral("require_rowcount_max")) + && statement.value(QStringLiteral("require_rowcount_min")).toInteger() > statement.value(QStringLiteral("require_rowcount_max")).toInteger()) { + fail(errorMessage, QStringLiteral("SQL node require_rowcount_min cannot exceed require_rowcount_max.")); + return {}; + } statements.append(statement); hasExecute = hasExecute || mode == QStringLiteral("execute"); } else if (type == QStringLiteral("python.call")) { @@ -502,6 +518,21 @@ QJsonObject GraphModel::compiledFragment(QString *errorMessage) const if (!operationProperties.value(QStringLiteral("summary")).toString().isEmpty()) { operation.insert(QStringLiteral("summary"), operationProperties.value(QStringLiteral("summary"))); } + for (const auto &key : {QStringLiteral("invalidate_resources"), QStringLiteral("invalidate_operations")}) { + if (!operationProperties.contains(key)) { continue; } + const auto targets = operationProperties.value(key); + if (!targets.isArray()) { + fail(errorMessage, QStringLiteral("Operation %1 must be an array of strings.").arg(key)); + return {}; + } + for (const auto &target : targets.toArray()) { + if (!target.isString()) { + fail(errorMessage, QStringLiteral("Operation %1 must be an array of strings.").arg(key)); + return {}; + } + } + operation.insert(key, targets); + } if (!hooks.isEmpty()) { operation.insert(QStringLiteral("background_hooks"), hooks); } diff --git a/editor/src/VisualDesigner.cpp b/editor/src/VisualDesigner.cpp index 19989c0..d675be1 100644 --- a/editor/src/VisualDesigner.cpp +++ b/editor/src/VisualDesigner.cpp @@ -90,7 +90,12 @@ QString valueText(const QJsonValue &value) { if (value.isString()) { return value.toString(); } if (value.isBool()) { return value.toBool() ? QStringLiteral("true") : QStringLiteral("false"); } - if (value.isDouble()) { return QString::number(value.toDouble(), 'g', 17); } + if (value.isDouble()) { + // Qt keeps signed 64-bit JSON integers exactly; converting to double + // here would round identifiers above 2^53 before the user edits them. + const auto json = QJsonDocument(QJsonArray{value}).toJson(QJsonDocument::Compact); + return QString::fromUtf8(json.mid(1, json.size() - 2)); + } if (value.isObject()) { return QString::fromUtf8(QJsonDocument(value.toObject()).toJson(QJsonDocument::Compact)); } if (value.isArray()) { return QString::fromUtf8(QJsonDocument(value.toArray()).toJson(QJsonDocument::Compact)); } return QStringLiteral("null"); @@ -108,9 +113,9 @@ bool parseValue(const QString &text, const QJsonValue &original, QJsonValue *val if (original.isString()) { *value = text; return true; } if (text.size() > 8 * 1024 * 1024) { return false; } QJsonParseError error; - const auto parsed = QJsonDocument::fromJson(QByteArray("{\"value\":") + text.trimmed().toUtf8() + QByteArray("}"), &error); - if (error.error != QJsonParseError::NoError || !parsed.isObject()) { return false; } - const auto candidate = parsed.object().value(QStringLiteral("value")); + const auto parsed = QJsonDocument::fromJson(QByteArray("[") + text.trimmed().toUtf8() + QByteArray("]"), &error); + if (error.error != QJsonParseError::NoError || !parsed.isArray() || parsed.array().size() != 1) { return false; } + const auto candidate = parsed.array().first(); if (candidate.type() != original.type()) { return false; } *value = candidate; return true; diff --git a/editor/tests/tst_editor_core.cpp b/editor/tests/tst_editor_core.cpp index 4eabf81..d3d2270 100644 --- a/editor/tests/tst_editor_core.cpp +++ b/editor/tests/tst_editor_core.cpp @@ -72,6 +72,8 @@ private slots: void boundedZipDecoderPolicy(); void cancellationStopsQueuedRetries(); void graphModelPolicyAndCompiler(); + void graphCompilerPreservesMutationGuards(); + void graphExamplesCompilerContract(); void graphCycleRollback(); void graphCompilerRejectsDesignOnlyNodes(); void pythonSdkSnippetPolicy(); @@ -1101,6 +1103,78 @@ void EditorCoreTests::graphModelPolicyAndCompiler() QCOMPARE(compiledOperation.value(QStringLiteral("statements")).toArray().size(), 1); } +void EditorCoreTests::graphCompilerPreservesMutationGuards() +{ + GraphModel graph; + const QJsonObject properties{{QStringLiteral("sql"), QStringLiteral("UPDATE orders SET state=:state WHERE id=:id AND deleted_at IS NULL")}, + {QStringLiteral("params"), QJsonObject{{QStringLiteral("state"), QStringLiteral("$body.state")}, + {QStringLiteral("id"), QStringLiteral("$body.id")}}}, + {QStringLiteral("require_rowcount_min"), 1}, {QStringLiteral("require_rowcount_max"), 1}}; + const auto mutation = graph.addNode(QStringLiteral("data.mutate"), QStringLiteral("Mutation"), QPointF(0, 0), properties); + const QJsonArray resources{QStringLiteral("orders")}; + const QJsonArray operations{QStringLiteral("orders.report")}; + const auto operation = graph.addNode(QStringLiteral("operation.call"), QStringLiteral("Operation"), QPointF(280, 0), + QJsonObject{{QStringLiteral("name"), QStringLiteral("orders.transition")}, + {QStringLiteral("invalidate_resources"), resources}, {QStringLiteral("invalidate_operations"), operations}}); + QString error; + QVERIFY(graph.connectNodes(mutation, QStringLiteral("exec"), operation, QStringLiteral("exec"), &error)); + auto compiled = graph.compiledFragment(&error).value(QStringLiteral("operations")).toArray().first().toObject(); + const auto statement = compiled.value(QStringLiteral("statements")).toArray().first().toObject(); + QCOMPARE(statement.value(QStringLiteral("require_rowcount_min")).toInt(-1), 1); + QCOMPARE(statement.value(QStringLiteral("require_rowcount_max")).toInt(-1), 1); + QCOMPARE(compiled.value(QStringLiteral("invalidate_resources")).toArray(), resources); + QCOMPARE(compiled.value(QStringLiteral("invalidate_operations")).toArray(), operations); + QVERIFY(compiled.value(QStringLiteral("transaction")).toBool()); + for (const auto &bad : QJsonArray{-1, 1.5, QStringLiteral("1"), true}) { + auto invalid = properties; + invalid.insert(QStringLiteral("require_rowcount_min"), bad); + QVERIFY(graph.updateNode(mutation, QStringLiteral("Mutation"), invalid, &error)); + QVERIFY(graph.compiledFragment(&error).isEmpty()); + QVERIFY(error.contains(QStringLiteral("require_rowcount_min"))); + } + QVERIFY(graph.updateNode(mutation, QStringLiteral("Mutation"), properties, &error)); + for (const auto &bad : QJsonArray{QStringLiteral("orders"), QJsonArray{42}}) { + auto invalid = graph.node(operation).value(QStringLiteral("properties")).toObject(); + invalid.insert(QStringLiteral("invalidate_resources"), bad); + QVERIFY(graph.updateNode(operation, QStringLiteral("Operation"), invalid, &error)); + QVERIFY(graph.compiledFragment(&error).isEmpty()); + QVERIFY(error.contains(QStringLiteral("invalidate_resources"))); + } +} + +void EditorCoreTests::graphExamplesCompilerContract() +{ + const auto root = qEnvironmentVariable("FORGE_EDITOR_EXAMPLES_APP_ROOT"); + if (root.isEmpty()) { QSKIP("Set FORGE_EDITOR_EXAMPLES_APP_ROOT to run the exampleApps graph/RPC contract."); } + QDir app(root); + QVERIFY(app.exists()); + int checked = 0; + for (const auto &directory : app.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + const QDir project(app.filePath(directory)); + QFile graphFile(project.filePath(QStringLiteral("graphs/domain-transition.forgegraph.json"))); + if (!graphFile.exists()) { continue; } + QVERIFY(graphFile.open(QIODevice::ReadOnly)); + GraphModel graph; + QString error; + QVERIFY2(graph.setDocument(QJsonDocument::fromJson(graphFile.readAll()).object(), &error), qPrintable(error)); + const auto fragment = graph.compiledFragment(&error); + QVERIFY2(!fragment.isEmpty(), qPrintable(directory + QStringLiteral(": ") + error)); + const auto compiled = fragment.value(QStringLiteral("operations")).toArray().first().toObject(); + QFile operationFile(project.filePath(graph.document().value(QStringLiteral("target_document")).toString())); + QVERIFY(operationFile.open(QIODevice::ReadOnly)); + const auto operations = QJsonDocument::fromJson(operationFile.readAll()).object().value(QStringLiteral("operations")).toArray(); + QJsonObject expected; + for (const auto &operation : operations) { + if (operation.toObject().value(QStringLiteral("name")) == compiled.value(QStringLiteral("name"))) { expected = operation.toObject(); break; } + } + QVERIFY2(!expected.isEmpty(), qPrintable(directory)); + QCOMPARE(compiled, expected); + ++checked; + } + QVERIFY2(checked > 0, "No domain-transition graph fixtures were found."); + qInfo("Verified %d generated graph/RPC contracts", checked); +} + void EditorCoreTests::graphCycleRollback() { GraphModel graph; diff --git a/editor/tests/tst_editor_ui.cpp b/editor/tests/tst_editor_ui.cpp index 5b61d18..25ad65d 100644 --- a/editor/tests/tst_editor_ui.cpp +++ b/editor/tests/tst_editor_ui.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -150,6 +151,8 @@ private slots: void repeatedGraphModePreservesEdits(); void openingDocumentDoesNotSerializePreviousMode(); void visualPropertyTypesAndPalette(); + void visualPreservesLargeIntegers(); + void visualRejectsMultiplePropertyValues(); void visualNestedEditsUndoAndReorder(); void visualStructuredAndNewPropertyDialogs(); void spacesExplainAvailabilityAndKeepComposerVisible(); @@ -164,6 +167,7 @@ private slots: void founderWildcardPermissions(); void restrictedNotesCarryReaderRank(); void conversationKeepsSelectionAndEscapesContent(); + void conversationRefreshKeepsSelectedMessageAtBottom(); void composerSupportsMultilineAndFilePaste(); void composerWrapsAndRejectsEmptySend(); void oldCallClientExplainsRequiredUpdate(); @@ -337,6 +341,41 @@ void EditorUiTests::visualPropertyTypesAndPalette() QCOMPARE(designer.document().value(QStringLiteral("resources")).toArray().size(), 1); } +void EditorUiTests::visualPreservesLargeIntegers() +{ + VisualDesigner designer; + const auto original = QJsonDocument::fromJson(QByteArray("{\"id\":9223372036854775807,\"negative\":-9223372036854775808,\"ratio\":0.125}")).object(); + designer.setDocument(original); + QCOMPARE(propertyValue(&designer, QStringLiteral("id"))->text(), QStringLiteral("9223372036854775807")); + QCOMPARE(propertyValue(&designer, QStringLiteral("negative"))->text(), QStringLiteral("-9223372036854775808")); + QCOMPARE(propertyValue(&designer, QStringLiteral("ratio"))->text(), QStringLiteral("0.125")); + propertyValue(&designer, QStringLiteral("id"))->setText(QStringLiteral("9007199254740993")); + QCOMPARE(designer.document().value(QStringLiteral("id")).toInteger(), qint64(9007199254740993LL)); + designer.undo(); + QCOMPARE(designer.document(), original); + designer.redo(); + QCOMPARE(propertyValue(&designer, QStringLiteral("id"))->text(), QStringLiteral("9007199254740993")); +} + +void EditorUiTests::visualRejectsMultiplePropertyValues() +{ + VisualDesigner designer; + const QJsonObject original{{QStringLiteral("enabled"), true}, {QStringLiteral("count"), 5}}; + designer.setDocument(original); + QSignalSpy errors(&designer, &VisualDesigner::statusMessage); + propertyValue(&designer, QStringLiteral("enabled"))->setText(QStringLiteral("false,\"extra\":42")); + QCOMPARE(designer.document(), original); + QCOMPARE(errors.size(), 1); + propertyValue(&designer, QStringLiteral("count"))->setText(QStringLiteral("3,\"value\":7")); + QCOMPARE(designer.document(), original); + QCOMPARE(errors.size(), 2); + propertyValue(&designer, QStringLiteral("count"))->setText(QStringLiteral("3,7")); + QCOMPARE(designer.document(), original); + QCOMPARE(errors.size(), 3); + propertyValue(&designer, QStringLiteral("enabled"))->setText(QStringLiteral(" false ")); + QVERIFY(!designer.document().value(QStringLiteral("enabled")).toBool()); +} + void EditorUiTests::visualNestedEditsUndoAndReorder() { VisualDesigner designer; @@ -765,6 +804,23 @@ void EditorUiTests::conversationKeepsSelectionAndEscapesContent() QVERIFY(view.setConversation({}, {})); } +void EditorUiTests::conversationRefreshKeepsSelectedMessageAtBottom() +{ + ChatView view; + view.resize(700, 500); + view.show(); + QJsonArray records{QJsonObject{{QStringLiteral("body"), QStringLiteral("Copy this message")}}}; + QVERIFY(view.setConversation(records, {})); + QVERIFY(waitUntil([&] { return view.verticalScrollBar()->value() == view.verticalScrollBar()->maximum(); })); + auto cursor = view.document()->find(QStringLiteral("Copy this message")); + QVERIFY(!cursor.isNull()); + view.setTextCursor(cursor); + records.append(QJsonObject{{QStringLiteral("body"), QStringLiteral("New incoming message")}}); + QVERIFY(view.setConversation(records, {})); + QCOMPARE(view.textCursor().selectedText(), QStringLiteral("Copy this message")); + QVERIFY(waitUntil([&] { return view.verticalScrollBar()->value() == view.verticalScrollBar()->maximum(); })); +} + void EditorUiTests::composerSupportsMultilineAndFilePaste() { ChatComposer composer; From 6e10948ed510151237575380dc4a8038ab2b3ade Mon Sep 17 00:00:00 2001 From: Cavanshirpro Date: Mon, 12 Oct 2026 22:26:33 +0400 Subject: [PATCH 3/7] Ignore stale call failures across workspace changes --- CHANGELOG.md | 1 + MANIFEST.sha256 | 12 +++---- editor/src/ApiClient.cpp | 8 ++--- editor/src/ApiClient.hpp | 4 +-- editor/src/TeamWorkspace.cpp | 64 ++++++++++++++++++++++++---------- editor/src/TeamWorkspace.hpp | 6 ++++ editor/tests/tst_editor_ui.cpp | 61 ++++++++++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c044386..9440d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Keep selected chat text when an incoming message refreshes a conversation at the bottom of the view. - Preserve SQL row-count guards and operation cache invalidation targets when compiling mutation graphs; reject malformed guard and invalidation values. - Add a native cross-component contract check that compares generated exampleApps transition graphs with their complete RPC operation definitions. +- Correlate each call-start and ticket request independently so delayed failures from a previous space or earlier attempt cannot cancel a new call, including returning to the same space. - Keep these changes separate from the frozen v0.5.2 publication candidate. ## [0.5.2] — 2026-10-04 diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index a903d08..84b10b5 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -13,7 +13,7 @@ af795a365fc479fbc7d4aedd6cf90fc0990da0181d15cd3854da34c2b861b765 .gitignore 09094d533067dddbaf48b9c021ec594292ddc875e8f93735e44dcff9ab62bf55 AI_USAGE_POLICY.md 759da1adabb4d60f41e3b678452af42192ee3a2c9cc40b0d30522153df374708 AUTHORS.md 08a52d0ba592af11dfd45e217bd0f11e9a925043ee1f43c144e85d48f6559660 BRAND.md -4c99795cacc998d4554f8d56e9529ef25bb3f0670b495a4de1a3e65efedd67b8 CHANGELOG.md +93153d9f66c52fc30a8cbdb71b88f9f8153faa2b374d72c3043a457eb0b924dc CHANGELOG.md c1e12824e5f0cb70bcd405b005625b07e4a7e1a8f110e5242768725711dea676 CITATION.cff d5c61448d613124dd312e4a78cee516c60f29dc3034eeb8ac55bd10fcf64e005 CMakeLists.txt 3687086aeb50202ab381acb9334a48fe3f72ba2f294ebf1f52bb34c9b84f54f5 CMakePresets.json @@ -75,8 +75,8 @@ c2fcb9d3d16c6bc879863ac5124615383c2ccea261c2522fdfdd20e4404c88c9 editor/resourc 1218abc85dbce25d414b6a936b238019edecf4c440729285738718de11d158ce editor/resources/styles/dark.qss 13bc7533e16a1c69a2d4962eb81c48784f11357420bb0581523b9f3ce44d4aff editor/resources/templates/catalog.json d4f81573e7196e0f02956a2892eafe85c4185f7c2fea6f8f62fab38c0b445751 editor/resources/windows.rc -4ada539eac8b3790b51ef32c0972122056db2f7126eadc51fc510aa07b8575a4 editor/src/ApiClient.cpp -7ace57059c2d4477f38606795156667b5498359da68bcb660aa3d909899ecc9d editor/src/ApiClient.hpp +62abc8faec84658871a57a2358a94a936ba6aede6e56e1f32f257fbce21ad02d editor/src/ApiClient.cpp +0e93ef8e89fdaf22e8285862f5c7fcdcab23ce6e5ed9c5536648cefdc8e4532f editor/src/ApiClient.hpp 097417cb676f46a79b7ac07978dd9715c95705a9bf2354fc099010e792133291 editor/src/ChatView.cpp fb6cdb4d0031b11aa3afc5e60ddb415a373d924bc7824d30f8eecf65591e3f5f editor/src/ChatView.hpp 4de9f9c93e7bc1adec1406f263087f05811188fd2a81acdc529d2ef6b1c19ce1 editor/src/CodeEditor.cpp @@ -103,8 +103,8 @@ e1409ab312d2bb9e15451d15d2da3d9f9088883d197f9fb3a9c6a8731fdd47d9 editor/src/Pyt 1fdcebef6bccf297074a6fe23d5d3da4e51f33148409793fe33459d78e746ef6 editor/src/PythonSdkPanel.hpp 33ea00abbda668bc8dfc58b4530aa7b5dec5b3c3dbf430ab7e552b2e8c4f7d7f editor/src/SafeZipReader.cpp 9199472d7380299b49848d002e600f20bdcdd021c68da5fea0005261013f2930 editor/src/SafeZipReader.hpp -dbc5f65e1e7f9fd35789b2b640229c861b16b08dc348cccaf197c1be91cad284 editor/src/TeamWorkspace.cpp -fe1857e3fff6f1b8394d0069924a461c333e745be37d659cec52e8fa4b3d2f9c editor/src/TeamWorkspace.hpp +89d5ccc6773162ba745ecd5e2bb4818620c88775d814f7b65155e77f5c9b5123 editor/src/TeamWorkspace.cpp +0bb51e2fa77f453480de12028da601e9db4d96ca4f25afad423329a049397280 editor/src/TeamWorkspace.hpp 036228cf2017139c3c8040092368bfa319eb8ad1019221303c92408ea20d2787 editor/src/TemplateManager.cpp a4d95987722b71f6f5e0573e92efee307402872ae83341d559d502f56948ed3a editor/src/TemplateManager.hpp ebe5aa708c199c5171b5456f2b8e387444296f472f61725648a7e2fa20e5b0b7 editor/src/UiSizing.hpp @@ -117,7 +117,7 @@ affa4bcf9e111a6268b38f6cce443a36d8226bba89d50f7076d13b1fbd46f5b7 editor/tests/r 4f043b560c3e49e9e8cbfbcf07242c020768bd203dab26d476e7a96cbdec05bf editor/tests/test_release_assets.py e7804622971a88473783057160bb7e328783bef6645a467d1155e3a95aa9d64f editor/tests/test_windows_installer.py aa09642aa2df3dac3ab9b7296c37743b3f3db66c0ff5ac3fe7a373724eae8491 editor/tests/tst_editor_core.cpp -902d9cf36689e3d6a9bdc8e1136a8554d72a9155147eea50259a3ef3cb9bbe0a editor/tests/tst_editor_ui.cpp +a31a4cc6fab511be3961bb211e8472ce2a86719cd16771f313b4f267a3711664 editor/tests/tst_editor_ui.cpp 18d8673b83d59483a1abb47d685f263a52b1f0dbdc9fa1ed6d56e80de2822e34 editor/third_party/puff/README.md 5b9d75aeb5baf3575415bc6ade3f2a02e50b6b971b3f8b4fda2b03543bc6e52f editor/third_party/puff/puff.c 969b7be2a930db0cdcb19b0e5b29ae6741f5a8f663b6dba6d647e12ec60cfa8e editor/third_party/puff/puff.h diff --git a/editor/src/ApiClient.cpp b/editor/src/ApiClient.cpp index 2e22912..f67d883 100644 --- a/editor/src/ApiClient.cpp +++ b/editor/src/ApiClient.cpp @@ -1183,16 +1183,16 @@ void ApiClient::fetchDatabaseRows(const QString &project, const QString &alias, {}, query); } -void ApiClient::startCall(const QString &areaId, const QString &mode) +void ApiClient::startCall(const QString &areaId, const QString &mode, const QString &operation) { - send(QStringLiteral("team-call:%1").arg(areaId), QNetworkAccessManager::PostOperation, + send(operation.isEmpty() ? QStringLiteral("team-call:%1").arg(areaId) : operation, QNetworkAccessManager::PostOperation, {QStringLiteral("calls")}, QJsonObject{{QStringLiteral("area_id"), areaId}, {QStringLiteral("mode"), mode}}); } -void ApiClient::createCallTicket(const QString &callId) +void ApiClient::createCallTicket(const QString &callId, const QString &operation) { - send(QStringLiteral("team-call-ticket:%1").arg(callId), QNetworkAccessManager::PostOperation, + send(operation.isEmpty() ? QStringLiteral("team-call-ticket:%1").arg(callId) : operation, QNetworkAccessManager::PostOperation, {QStringLiteral("calls"), callId, QStringLiteral("ticket")}); } diff --git a/editor/src/ApiClient.hpp b/editor/src/ApiClient.hpp index 02c73eb..4965560 100644 --- a/editor/src/ApiClient.hpp +++ b/editor/src/ApiClient.hpp @@ -76,8 +76,8 @@ class ApiClient final : public QObject { void fetchDatabases(const QString &project); void fetchDatabaseRows(const QString &project, const QString &alias, const QString &table, int limit = 100, int offset = 0); - void startCall(const QString &areaId, const QString &mode); - void createCallTicket(const QString &callId); + void startCall(const QString &areaId, const QString &mode, const QString &operation = {}); + void createCallTicket(const QString &callId, const QString &operation = {}); void fetchCalls(const QString &areaId); void cancelFileTransfers(); void fetchAudit(const QString &project = {}); diff --git a/editor/src/TeamWorkspace.cpp b/editor/src/TeamWorkspace.cpp index f3b8fec..9323fd1 100644 --- a/editor/src/TeamWorkspace.cpp +++ b/editor/src/TeamWorkspace.cpp @@ -187,6 +187,7 @@ TeamWorkspace::TeamWorkspace(ApiClient *api, QWidget *parent) [this](const QString &operation, int status, const QString &message, const QString &category, const QString &, bool outcomeUncertain) { if (category == QStringLiteral("canceled") && !operation.startsWith(QStringLiteral("team-attachment-"))) { return; } + if (!acceptsCallOperation(operation)) { return; } handleError(operation, status, message); if (operation.startsWith(QStringLiteral("team-")) && outcomeUncertain) { m_feedback->setText(message + QStringLiteral(" Check whether the change reached the server before retrying.")); @@ -703,8 +704,7 @@ void TeamWorkspace::setProject(const QString &project) m_attachmentRecords = {}; m_activeCalls->clear(); m_callRecords = {}; m_callsLoading.clear(); - m_pendingCallArea.clear(); - m_pendingCallId.clear(); + clearPendingCall(); m_extrasLoading = false; m_attachments->clear(); m_databaseTree->clear(); @@ -760,8 +760,7 @@ void TeamWorkspace::reset() m_callsEnabled = false; m_callDiscovery = false; m_callClientReady = false; - m_pendingCallArea.clear(); - m_pendingCallId.clear(); + clearPendingCall(); m_pendingUploadArea.clear(); m_transferBusy = false; m_extrasLoading = false; @@ -827,8 +826,7 @@ void TeamWorkspace::selectArea() m_messages->clearConversation(); m_activeCalls->clear(); m_callRecords = {}; m_callsLoading.clear(); - m_pendingCallArea.clear(); - m_pendingCallId.clear(); + clearPendingCall(); m_extrasLoading = false; m_spaceTitle->setText(m_areas->currentItem() != nullptr ? QStringLiteral("# ") + m_areas->currentItem()->text().section(u'\n', 0, 0) : QStringLiteral("# Choose a space")); @@ -1255,9 +1253,10 @@ void TeamWorkspace::startCall(const QString &mode) } m_feedback->hide(); m_pendingCallArea = currentAreaId(); + m_pendingCallOperation = QStringLiteral("team-call:%1:request:%2").arg(m_pendingCallArea).arg(++m_callRequestSerial); m_spaceTitle->setText(QStringLiteral("Creating %1 call…").arg(mode)); updateActions(); - m_api->startCall(m_pendingCallArea, mode); + m_api->startCall(m_pendingCallArea, mode, m_pendingCallOperation); } void TeamWorkspace::joinCall() @@ -1268,7 +1267,23 @@ void TeamWorkspace::joinCall() m_feedback->hide(); m_spaceTitle->setText(QStringLiteral("Preparing your call access…")); updateActions(); - m_api->createCallTicket(m_pendingCallId); + requestCallTicket(); +} + +void TeamWorkspace::requestCallTicket() +{ + m_pendingCallOperation.clear(); + m_pendingCallTicketOperation = QStringLiteral("team-call-ticket:%1:request:%2").arg(m_pendingCallId).arg(++m_callRequestSerial); + m_api->createCallTicket(m_pendingCallId, m_pendingCallTicketOperation); +} + +void TeamWorkspace::clearPendingCall() +{ + m_pendingCallArea.clear(); + m_pendingCallId.clear(); + m_pendingCallOperation.clear(); + m_pendingCallTicketOperation.clear(); + m_browserCallNext = false; } void TeamWorkspace::handleJson(const QString &operation, const QJsonObject &payload) @@ -1541,26 +1556,25 @@ void TeamWorkspace::handleJson(const QString &operation, const QJsonObject &payl updateActions(); return; } - if (operation == QStringLiteral("team-call:%1").arg(m_pendingCallArea) && m_pendingCallArea == currentAreaId()) { + if (!m_pendingCallOperation.isEmpty() && operation == m_pendingCallOperation && m_pendingCallArea == currentAreaId()) { m_pendingCallId = payload.value(QStringLiteral("id")).toString(); if (m_pendingCallId.isEmpty()) { handleError(operation, 0, QStringLiteral("The server returned no call ID.")); return; } - m_api->createCallTicket(m_pendingCallId); + requestCallTicket(); return; } if (operation.startsWith(QStringLiteral("team-call-ticket:"))) { - if (m_pendingCallId.isEmpty() || operation != QStringLiteral("team-call-ticket:%1").arg(m_pendingCallId) + if (m_pendingCallId.isEmpty() || operation != m_pendingCallTicketOperation || m_pendingCallArea != currentAreaId()) { return; } - m_pendingCallArea.clear(); - m_pendingCallId.clear(); + const bool browserNext = m_browserCallNext; + clearPendingCall(); m_spaceTitle->setText(QStringLiteral("# ") + m_areas->currentItem()->text().section(u'\n', 0, 0)); updateActions(); const auto url = m_api->callClientUrl(payload.value(QStringLiteral("call_client_path")).toString(), payload.value(QStringLiteral("ticket")).toString()); if (!url.isValid()) { emit statusMessage(QStringLiteral("The server returned an invalid call URL.")); - } else if (m_browserCallNext) { - m_browserCallNext = false; - if (!QDesktopServices::openUrl(url)) { handleError(operation, 0, QStringLiteral("Could not open the call in your browser.")); } + } else if (browserNext) { + if (!QDesktopServices::openUrl(url)) { handleError(QStringLiteral("team-call-open"), 0, QStringLiteral("Could not open the call in your browser.")); } } else { openCall(url); } @@ -1581,8 +1595,22 @@ void TeamWorkspace::handleJson(const QString &operation, const QJsonObject &payl } } +bool TeamWorkspace::acceptsCallOperation(const QString &operation) const +{ + if (operation.startsWith(QStringLiteral("team-call:"))) { + return !m_pendingCallArea.isEmpty() && m_pendingCallArea == currentAreaId() + && operation == m_pendingCallOperation; + } + if (operation.startsWith(QStringLiteral("team-call-ticket:"))) { + return !m_pendingCallId.isEmpty() && m_pendingCallArea == currentAreaId() + && operation == m_pendingCallTicketOperation; + } + return true; +} + void TeamWorkspace::handleError(const QString &operation, int statusCode, const QString &message) { + if (!acceptsCallOperation(operation)) { return; } if (operation.startsWith(QStringLiteral("team-attachment-"))) { m_transferBusy = false; m_pendingUploadArea.clear(); @@ -1594,7 +1622,7 @@ void TeamWorkspace::handleError(const QString &operation, int statusCode, const if (operation == QStringLiteral("team-attachments:%1").arg(currentAreaId())) { m_extrasLoading = false; } if (operation == QStringLiteral("team-calls:%1").arg(m_callsLoading)) { m_callsLoading.clear(); } if (operation.startsWith(QStringLiteral("team-call:")) || operation.startsWith(QStringLiteral("team-call-ticket:"))) { - m_pendingCallArea.clear(); m_pendingCallId.clear(); m_browserCallNext = false; updateActions(); + clearPendingCall(); updateActions(); m_spaceTitle->setText(m_areas->currentItem() != nullptr ? QStringLiteral("# ") + m_areas->currentItem()->text().section(u'\n', 0, 0) : QStringLiteral("# Choose a space")); } @@ -1641,7 +1669,7 @@ void TeamWorkspace::openCall(const QUrl &url) dialog->close(); updateActions(); // Tickets are single-use: the embedded page has already consumed its link. - m_api->createCallTicket(m_pendingCallId); + requestCallTicket(); }); auto *profile = new QWebEngineProfile(dialog); profile->setHttpCacheType(QWebEngineProfile::MemoryHttpCache); diff --git a/editor/src/TeamWorkspace.hpp b/editor/src/TeamWorkspace.hpp index bedab70..d1cd1f1 100644 --- a/editor/src/TeamWorkspace.hpp +++ b/editor/src/TeamWorkspace.hpp @@ -68,6 +68,8 @@ private slots: void downloadFile(const QString &id); void joinCall(); void startCall(const QString &mode); + void requestCallTicket(); + void clearPendingCall(); void refreshSpaceExtras(); [[nodiscard]] QString currentAreaId() const; [[nodiscard]] QString projectScope() const; @@ -75,6 +77,7 @@ private slots: void updateActions(); void refreshProject(); void requestMessages(); + [[nodiscard]] bool acceptsCallOperation(const QString &operation) const; ApiClient *m_api = nullptr; QLabel *m_profile = nullptr; @@ -99,6 +102,9 @@ private slots: QPushButton *m_joinCall = nullptr; QString m_pendingCallArea; QString m_pendingCallId; + QString m_pendingCallOperation; + QString m_pendingCallTicketOperation; + quint64 m_callRequestSerial = 0; QString m_pendingUploadArea; bool m_transferBusy = false; bool m_callDiscovery = false; diff --git a/editor/tests/tst_editor_ui.cpp b/editor/tests/tst_editor_ui.cpp index 25ad65d..6e49342 100644 --- a/editor/tests/tst_editor_ui.cpp +++ b/editor/tests/tst_editor_ui.cpp @@ -171,6 +171,8 @@ private slots: void composerSupportsMultilineAndFilePaste(); void composerWrapsAndRejectsEmptySend(); void oldCallClientExplainsRequiredUpdate(); + void staleCallFailureDoesNotCancelNewAreaCall_data(); + void staleCallFailureDoesNotCancelNewAreaCall(); void backgroundRefreshAndDownloadIntegrity(); void liveServerContract(); @@ -893,6 +895,65 @@ void EditorUiTests::oldCallClientExplainsRequiredUpdate() QVERIFY(availability->text().isEmpty()); } +void EditorUiTests::staleCallFailureDoesNotCancelNewAreaCall_data() +{ + QTest::addColumn("ticketFailure"); + QTest::addColumn("returnToArea"); + QTest::newRow("old-start-new-area") << false << false; + QTest::newRow("old-ticket-new-area") << true << false; + QTest::newRow("old-start-returned-area") << false << true; + QTest::newRow("old-ticket-returned-area") << true << true; +} + +void EditorUiTests::staleCallFailureDoesNotCancelNewAreaCall() +{ + QFETCH(bool, ticketFailure); + QFETCH(bool, returnToArea); + WorkspaceServer server; + QVERIFY(server.listen(QHostAddress::LocalHost)); + int starts = 0; + int tickets = 0; + int oldTickets = 0; + server.respond = [&](const QByteArray &request) { + if (request.startsWith("POST /__forge/editor/v1/calls ")) { + ++starts; + if (starts == 1) { + return ticketFailure + ? WorkspaceServer::Response{QByteArray("{\"id\":\"alpha-call\"}"), QByteArray("201 Created"), 0} + : WorkspaceServer::Response{QByteArray("{\"detail\":\"Old area unavailable\"}"), QByteArray("503 Service Unavailable"), 150}; + } + return WorkspaceServer::Response{QByteArray("{\"id\":\"beta-call\"}"), QByteArray("201 Created"), 350}; + } + if (request.startsWith("POST /__forge/editor/v1/calls/alpha-call/ticket ")) { + ++oldTickets; + return WorkspaceServer::Response{QByteArray("{\"detail\":\"Old ticket unavailable\"}"), QByteArray("503 Service Unavailable"), 150}; + } + if (request.startsWith("POST /__forge/editor/v1/calls/beta-call/ticket ")) { ++tickets; } + return WorkspaceServer::Response{}; + }; + ApiClient api; + QString error; + QVERIFY(api.configure(server.url(), TestSession, true, &error)); + TeamWorkspace team(&api); + team.setCapabilities(capabilities({QStringLiteral("calls.start")})); + deliver(api, QStringLiteral("team-areas:*"), spaces()); + auto *areas = team.findChild(QStringLiteral("areaList")); + QSignalSpy failed(&api, &ApiClient::requestFailed); + QSignalSpy status(&team, &TeamWorkspace::statusMessage); + QVERIFY(QMetaObject::invokeMethod(&team, "startAudioCall")); + QVERIFY(waitUntil([&] { return starts == 1; })); + if (ticketFailure) { QVERIFY(waitUntil([&] { return oldTickets == 1; })); } + areas->setCurrentRow(1); + if (returnToArea) { areas->setCurrentRow(0); } + QVERIFY(QMetaObject::invokeMethod(&team, "startAudioCall")); + QVERIFY(waitUntil([&] { return starts == 2; })); + QVERIFY(waitUntil([&] { return !failed.isEmpty(); })); + QVERIFY(failed.first().at(0).toString().startsWith(ticketFailure ? QStringLiteral("team-call-ticket:alpha-call:") + : QStringLiteral("team-call:alpha:"))); + QVERIFY(status.isEmpty()); + QVERIFY(waitUntil([&] { return tickets == 1; })); +} + void EditorUiTests::backgroundRefreshAndDownloadIntegrity() { WorkspaceServer server; From 1a3e84ab45470908f3ca101c6f615cc29d5673c0 Mon Sep 17 00:00:00 2001 From: Cavanshirpro Date: Mon, 12 Oct 2026 22:34:54 +0400 Subject: [PATCH 4/7] Reject unsafe manifest paths and Windows reparse boundaries --- .github/workflows/editor-build.yml | 3 + CHANGELOG.md | 1 + MANIFEST.sha256 | 7 +- editor/tests/test_manifest.py | 161 +++++++++++++++++++++++++++++ scripts/check_manifest.py | 73 +++++++++++-- 5 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 editor/tests/test_manifest.py diff --git a/.github/workflows/editor-build.yml b/.github/workflows/editor-build.yml index 813199a..cc05aba 100644 --- a/.github/workflows/editor-build.yml +++ b/.github/workflows/editor-build.yml @@ -231,6 +231,9 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.13" + - name: Check Windows manifest reparse boundaries + if: runner.os == 'Windows' + run: python -m unittest discover -s editor/tests -p "test_manifest.py" -v - name: Install Qt on Windows x64 if: matrix.artifact == 'windows-x64' uses: jurplel/install-qt-action/action@48d3ad6db93f3627c8ee7a0454bc6f3744f7e730 # v4.3.1, direct Node 24 action diff --git a/CHANGELOG.md b/CHANGELOG.md index 9440d4a..6c66411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Preserve SQL row-count guards and operation cache invalidation targets when compiling mutation graphs; reject malformed guard and invalidation values. - Add a native cross-component contract check that compares generated exampleApps transition graphs with their complete RPC operation definitions. - Correlate each call-start and ticket request independently so delayed failures from a previous space or earlier attempt cannot cancel a new call, including returning to the same space. +- Reject unsafe manifest paths and malformed SHA-256 records before scanning or hashing; block symbolic links and Windows reparse/junction boundaries without entering them during source fallback scans. - Keep these changes separate from the frozen v0.5.2 publication candidate. ## [0.5.2] — 2026-10-04 diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 84b10b5..4d88c28 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -7,13 +7,13 @@ 033f593849cbcfc2b4077a62e9145f0819be2280b5ca9e8f519209bbf1699fbc .github/PULL_REQUEST_TEMPLATE.md f53bda57f03c1c41428b26bbdd775cf4f4620a88436b13ef5b295c864647e3d1 .github/dependabot.yml d0a53d7732fa29299125f15146bdde0446514287c1a4932ff238c094ffbb7c47 .github/release.yml -dd91ab7369ff945ad9fba70c4a62460cb4e14c5d6aed9a05fcb7c85d4a39d7b2 .github/workflows/editor-build.yml +27d18adae60406b2adb00ec80e4c4eddc39a11e7c29066e1a990a52479ab514a .github/workflows/editor-build.yml 51339bf046de296de42332ec06054b426c1541342a3b76ae46ee446c6f1ba5e9 .github/workflows/editor-codeql.yml af795a365fc479fbc7d4aedd6cf90fc0990da0181d15cd3854da34c2b861b765 .gitignore 09094d533067dddbaf48b9c021ec594292ddc875e8f93735e44dcff9ab62bf55 AI_USAGE_POLICY.md 759da1adabb4d60f41e3b678452af42192ee3a2c9cc40b0d30522153df374708 AUTHORS.md 08a52d0ba592af11dfd45e217bd0f11e9a925043ee1f43c144e85d48f6559660 BRAND.md -93153d9f66c52fc30a8cbdb71b88f9f8153faa2b374d72c3043a457eb0b924dc CHANGELOG.md +a657af84f20f8fffdac898c78e5bc79d408fbbaaed63de037e3bbea48d3a6dc3 CHANGELOG.md c1e12824e5f0cb70bcd405b005625b07e4a7e1a8f110e5242768725711dea676 CITATION.cff d5c61448d613124dd312e4a78cee516c60f29dc3034eeb8ac55bd10fcf64e005 CMakeLists.txt 3687086aeb50202ab381acb9334a48fe3f72ba2f294ebf1f52bb34c9b84f54f5 CMakePresets.json @@ -113,6 +113,7 @@ ac0f0dba8d1363b08ee6a43d38f01faab3287ebae1faa72da2d2993ac810d831 editor/src/Vis f481e5cbe4f1d28e724e448863ccee8307e98a159791cd7b348ea31f2b123b48 editor/src/main.cpp e2aa41034ce77b1e91a63de94a46516f6197eee8d6afd643595295d49a1d779d editor/tests/ZipFixtureWriter.hpp affa4bcf9e111a6268b38f6cce443a36d8226bba89d50f7076d13b1fbd46f5b7 editor/tests/run_server_contract.py +11a0a51208e840879f37e2389be0437135d2af77b3714af582c2ced962011d54 editor/tests/test_manifest.py 4f2a2a940e8ae21033bc273c99073522013b17b33558278e00231f1eda2a8c2f editor/tests/test_packaging.py 4f043b560c3e49e9e8cbfbcf07242c020768bd203dab26d476e7a96cbdec05bf editor/tests/test_release_assets.py e7804622971a88473783057160bb7e328783bef6645a467d1155e3a95aa9d64f editor/tests/test_windows_installer.py @@ -121,4 +122,4 @@ a31a4cc6fab511be3961bb211e8472ce2a86719cd16771f313b4f267a3711664 editor/tests/t 18d8673b83d59483a1abb47d685f263a52b1f0dbdc9fa1ed6d56e80de2822e34 editor/third_party/puff/README.md 5b9d75aeb5baf3575415bc6ade3f2a02e50b6b971b3f8b4fda2b03543bc6e52f editor/third_party/puff/puff.c 969b7be2a930db0cdcb19b0e5b29ae6741f5a8f663b6dba6d647e12ec60cfa8e editor/third_party/puff/puff.h -c5dfebccb4d838941b4ccfe30823e7c0109131bdcdb6c8752ef8eb8be3329e09 scripts/check_manifest.py +2089316476893395e59a81bdff32dbec1df8fc3e0f01d5163d78a4c2780fd97d scripts/check_manifest.py diff --git a/editor/tests/test_manifest.py b/editor/tests/test_manifest.py new file mode 100644 index 0000000..276d859 --- /dev/null +++ b/editor/tests/test_manifest.py @@ -0,0 +1,161 @@ +"""Manifest validation must reject unsafe records and filesystem boundaries before reading.""" + +from __future__ import annotations + +import importlib.util +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SOURCE = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "editor_manifest", SOURCE / "scripts/check_manifest.py" +) +manifest = importlib.util.module_from_spec(spec) +spec.loader.exec_module(manifest) + + +class ManifestSafetyTests(unittest.TestCase): + def setUp(self): + base = SOURCE / "build/review/manifest-tests" + base.mkdir(parents=True, exist_ok=True) + self.temporary = tempfile.TemporaryDirectory(dir=base, prefix="case-") + self.folder = Path(self.temporary.name).resolve() + assert self.folder.is_relative_to(base.resolve()) + self.addCleanup(self.temporary.cleanup) + self.root = self.folder / "source" + self.root.mkdir() + self.path = self.root / "MANIFEST.sha256" + (self.folder / "outside.txt").write_bytes(b"private fixture") + context = patch.multiple(manifest, ROOT=self.root, MANIFEST=self.path) + context.start() + self.addCleanup(context.stop) + + def test_invalid_records_are_rejected_before_scan_or_hash(self): + cases = [ + (relative, "a" * 64) + for relative in ( + "../outside.txt", + "/absolute/file", + "scripts\\tool.py", + "C:/outside/private.txt", + "README.md:alternate-stream", + "nested/C:drive.txt", + ".env", + "secrets/.env.production", + "build/private.txt", + "a//b", + "a/./b", + ) + ] + [ + ("README.md", digest) + for digest in ("g" * 64, "a" * 63, "A" * 64, "not-a-sha256") + ] + for relative, digest in cases: + with self.subTest(path=relative, digest=digest): + self.path.write_text(f"{digest} {relative}\n", encoding="utf-8") + with ( + patch.object(manifest, "digest") as hash_file, + patch.object(manifest, "release_files", return_value=set()) as scan, + ): + with self.assertRaises(SystemExit): + manifest.main([]) + scan.assert_not_called() + hash_file.assert_not_called() + + def test_normal_fallback_manifest_and_ignored_build_work(self): + (self.root / "README.md").write_bytes(b"release fixture") + (self.root / "build").mkdir() + (self.root / "build/private.txt").write_bytes(b"ignored") + (self.root / ".env").write_bytes(b"ignored secret") + with patch.object(manifest.subprocess, "check_output", side_effect=OSError): + manifest.main(["--write"]) + self.assertEqual(manifest.main([]), 0) + self.assertEqual(len(self.path.read_text(encoding="utf-8").splitlines()), 1) + + def test_symbolic_parent_is_rejected_before_hash(self): + outside = self.folder / "external" + outside.mkdir() + (outside / "private.txt").write_bytes(b"outside fixture") + link = self.root / "linked-source" + try: + link.symlink_to(outside, target_is_directory=True) + except OSError: + self.skipTest("Directory symlink creation unavailable") + self.addCleanup(link.unlink) + self.path.write_text( + "a" * 64 + " linked-source/private.txt\n", encoding="utf-8" + ) + with ( + patch.object( + manifest.subprocess, + "check_output", + side_effect=[str(self.root), b"linked-source/private.txt\0"], + ), + patch.object(manifest, "digest") as hash_file, + ): + with self.assertRaisesRegex(ValueError, "link|junction"): + manifest.main([]) + hash_file.assert_not_called() + + def junction(self, name): + outside = self.folder / "external" + outside.mkdir(exist_ok=True) + (outside / "private.txt").write_bytes(b"outside fixture") + link = self.root / name + result = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(outside)], capture_output=True + ) + if result.returncode: + self.skipTest("Junction creation unavailable") + self.addCleanup(link.rmdir) + self.assertFalse(link.is_symlink()) + return link + + @unittest.skipUnless(os.name == "nt", "Windows junction boundary") + def test_fallback_rejects_junction_without_descending(self): + link = self.junction("linked-source") + real_scan = os.scandir + + def checked_scan(path): + self.assertNotEqual(Path(path), link, "The fallback entered a junction") + return real_scan(path) + + with ( + patch.object(manifest.subprocess, "check_output", side_effect=OSError), + patch("os.scandir", side_effect=checked_scan), + ): + with self.assertRaisesRegex(ValueError, "link|junction"): + manifest.release_files() + + @unittest.skipUnless(os.name == "nt", "Windows junction boundary") + def test_git_candidate_checks_parent_before_hash(self): + self.junction("linked-source") + self.path.write_text( + "a" * 64 + " linked-source/private.txt\n", encoding="utf-8" + ) + with ( + patch.object( + manifest.subprocess, + "check_output", + side_effect=[str(self.root), b"linked-source/private.txt\0"], + ), + patch.object(manifest, "digest") as hash_file, + ): + with self.assertRaisesRegex(ValueError, "link|junction"): + manifest.main([]) + hash_file.assert_not_called() + + @unittest.skipUnless(os.name == "nt", "Windows junction boundary") + def test_ignored_build_junction_does_not_block_release(self): + self.junction("build") + (self.root / "README.md").write_bytes(b"release fixture") + with patch.object(manifest.subprocess, "check_output", side_effect=OSError): + self.assertEqual(manifest.release_files(), {"README.md"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/check_manifest.py b/scripts/check_manifest.py index 3fd9ccf..3f93869 100755 --- a/scripts/check_manifest.py +++ b/scripts/check_manifest.py @@ -3,6 +3,9 @@ import argparse import hashlib +import os +import re +import stat import subprocess from pathlib import Path @@ -11,6 +14,8 @@ def digest(path: Path) -> str: + if not _regular_release_file(path.relative_to(ROOT).as_posix()): + raise ValueError(f"Not a regular release file: {path}") h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): @@ -24,6 +29,7 @@ def _release_path(relative: str) -> bool: not relative or any(part in {"", ".", ".."} for part in parts) or "\\" in relative + or ":" in relative ): raise ValueError(f"Unsafe release path: {relative!r}") if any(ord(char) < 32 or ord(char) == 127 for char in relative): @@ -57,6 +63,53 @@ def _release_path(relative: str) -> bool: ) +def _linked(metadata: os.stat_result) -> bool: + return stat.S_ISLNK(metadata.st_mode) or bool( + getattr(metadata, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + + +def _regular_release_file(relative: str) -> bool: + if not _release_path(relative): + return False + current = ROOT + for part in relative.split("/"): + current = current / part + try: + metadata = current.lstat() + except (FileNotFoundError, NotADirectoryError): + return False + if _linked(metadata): + raise ValueError( + f"Release path may not contain a link or junction: {relative}" + ) + return stat.S_ISREG(metadata.st_mode) + + +def _fallback_candidates() -> set[str]: + candidates = set() + pending = [ROOT] + while pending: + directory = pending.pop() + with os.scandir(directory) as entries: + for entry in entries: + path = Path(entry.path) + relative = path.relative_to(ROOT).as_posix() + if not _release_path(relative): + continue + metadata = entry.stat(follow_symlinks=False) + if _linked(metadata): + raise ValueError( + f"Release path may not contain a link or junction: {relative}" + ) + if stat.S_ISDIR(metadata.st_mode): + pending.append(path) + elif stat.S_ISREG(metadata.st_mode): + candidates.add(relative) + return candidates + + def release_files() -> set[str]: try: repository = subprocess.check_output( @@ -74,20 +127,20 @@ def release_files() -> set[str]: ).decode() candidates = {value for value in raw.split(chr(0)) if value} except (OSError, subprocess.SubprocessError, UnicodeDecodeError, ValueError): - candidates = {path.relative_to(ROOT).as_posix() for path in ROOT.rglob("*")} + candidates = _fallback_candidates() result = set() for relative in candidates: if not _release_path(relative): continue - path = ROOT / relative - if path.is_symlink(): - raise ValueError(f"Release files may not be symbolic links: {relative}") - if path.is_file(): + if _regular_release_file(relative): result.add(relative) return result def write_manifest() -> None: + if MANIFEST.exists() or MANIFEST.is_symlink(): + if _linked(MANIFEST.lstat()): + raise ValueError("Manifest may not be a link or junction") entries = [ f"{digest(ROOT / relative)} {relative}" for relative in sorted(release_files()) ] @@ -110,6 +163,8 @@ def main(argv: list[str] | None = None) -> int: return 0 if not MANIFEST.exists(): raise SystemExit("MANIFEST.sha256 is missing") + if _linked(MANIFEST.lstat()): + raise SystemExit("Manifest may not be a link or junction") expected = {} for n, line in enumerate(MANIFEST.read_text(encoding="utf-8").splitlines(), 1): if not line.strip(): @@ -118,6 +173,12 @@ def main(argv: list[str] | None = None) -> int: d, rel = line.split(" ", 1) except ValueError as exc: raise SystemExit(f"Malformed manifest line {n}") from exc + try: + allowed = _release_path(rel) + except ValueError as exc: + raise SystemExit(f"Unsafe manifest line {n}: {exc}") from exc + if not allowed or not re.fullmatch(r"[0-9a-f]{64}", d): + raise SystemExit(f"Invalid release path or digest on manifest line {n}") if rel in expected: raise SystemExit(f"Duplicate manifest entry: {rel}") expected[rel] = d @@ -125,7 +186,7 @@ def main(argv: list[str] | None = None) -> int: errors = [] for rel, d in expected.items(): p = ROOT / rel - if not p.is_file(): + if not _regular_release_file(rel): errors.append(f"missing: {rel}") elif digest(p) != d: errors.append(f"hash mismatch: {rel}") From 82afde1f4b60e502670e1f1fddccc39320a74fae Mon Sep 17 00:00:00 2001 From: Cavanshirpro Date: Thu, 15 Oct 2026 23:01:39 +0400 Subject: [PATCH 5/7] Isolate stale Editor sessions and message snapshots --- CHANGELOG.md | 2 + MANIFEST.sha256 | 12 ++-- editor/src/ApiClient.cpp | 56 +++++++++++++---- editor/src/ApiClient.hpp | 3 +- editor/src/TeamWorkspace.cpp | 24 ++++--- editor/src/TeamWorkspace.hpp | 4 +- editor/tests/tst_editor_ui.cpp | 111 ++++++++++++++++++++++++++++++++- 7 files changed, 182 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c66411..0ddceb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - Add a native cross-component contract check that compares generated exampleApps transition graphs with their complete RPC operation definitions. - Correlate each call-start and ticket request independently so delayed failures from a previous space or earlier attempt cannot cancel a new call, including returning to the same space. - Reject unsafe manifest paths and malformed SHA-256 records before scanning or hashing; block symbolic links and Windows reparse/junction boundaries without entering them during source fallback scans. +- Discard responses, retries and pending attachment work from an earlier account session so late login/profile/download results cannot replace credentials, sign out a new account or save private files after the session changes. +- Correlate message refresh requests individually so returning to a space cannot let an older snapshot or error overwrite its current conversation. - Keep these changes separate from the frozen v0.5.2 publication candidate. ## [0.5.2] — 2026-10-04 diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 4d88c28..aeda50b 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -13,7 +13,7 @@ af795a365fc479fbc7d4aedd6cf90fc0990da0181d15cd3854da34c2b861b765 .gitignore 09094d533067dddbaf48b9c021ec594292ddc875e8f93735e44dcff9ab62bf55 AI_USAGE_POLICY.md 759da1adabb4d60f41e3b678452af42192ee3a2c9cc40b0d30522153df374708 AUTHORS.md 08a52d0ba592af11dfd45e217bd0f11e9a925043ee1f43c144e85d48f6559660 BRAND.md -a657af84f20f8fffdac898c78e5bc79d408fbbaaed63de037e3bbea48d3a6dc3 CHANGELOG.md +58f4455e1f346d56787c3ac757806aabbae3f9b63bc9857955ff115a7b27a4a7 CHANGELOG.md c1e12824e5f0cb70bcd405b005625b07e4a7e1a8f110e5242768725711dea676 CITATION.cff d5c61448d613124dd312e4a78cee516c60f29dc3034eeb8ac55bd10fcf64e005 CMakeLists.txt 3687086aeb50202ab381acb9334a48fe3f72ba2f294ebf1f52bb34c9b84f54f5 CMakePresets.json @@ -75,8 +75,8 @@ c2fcb9d3d16c6bc879863ac5124615383c2ccea261c2522fdfdd20e4404c88c9 editor/resourc 1218abc85dbce25d414b6a936b238019edecf4c440729285738718de11d158ce editor/resources/styles/dark.qss 13bc7533e16a1c69a2d4962eb81c48784f11357420bb0581523b9f3ce44d4aff editor/resources/templates/catalog.json d4f81573e7196e0f02956a2892eafe85c4185f7c2fea6f8f62fab38c0b445751 editor/resources/windows.rc -62abc8faec84658871a57a2358a94a936ba6aede6e56e1f32f257fbce21ad02d editor/src/ApiClient.cpp -0e93ef8e89fdaf22e8285862f5c7fcdcab23ce6e5ed9c5536648cefdc8e4532f editor/src/ApiClient.hpp +4ce28b4d7e2bcd8d035efc4494d17f6a4415214beace40b11b605fd35aa7d30c editor/src/ApiClient.cpp +54c5f4e8a8c0c03083bc3b2c3ad8361973eac58128bd37656521919b73f0878e editor/src/ApiClient.hpp 097417cb676f46a79b7ac07978dd9715c95705a9bf2354fc099010e792133291 editor/src/ChatView.cpp fb6cdb4d0031b11aa3afc5e60ddb415a373d924bc7824d30f8eecf65591e3f5f editor/src/ChatView.hpp 4de9f9c93e7bc1adec1406f263087f05811188fd2a81acdc529d2ef6b1c19ce1 editor/src/CodeEditor.cpp @@ -103,8 +103,8 @@ e1409ab312d2bb9e15451d15d2da3d9f9088883d197f9fb3a9c6a8731fdd47d9 editor/src/Pyt 1fdcebef6bccf297074a6fe23d5d3da4e51f33148409793fe33459d78e746ef6 editor/src/PythonSdkPanel.hpp 33ea00abbda668bc8dfc58b4530aa7b5dec5b3c3dbf430ab7e552b2e8c4f7d7f editor/src/SafeZipReader.cpp 9199472d7380299b49848d002e600f20bdcdd021c68da5fea0005261013f2930 editor/src/SafeZipReader.hpp -89d5ccc6773162ba745ecd5e2bb4818620c88775d814f7b65155e77f5c9b5123 editor/src/TeamWorkspace.cpp -0bb51e2fa77f453480de12028da601e9db4d96ca4f25afad423329a049397280 editor/src/TeamWorkspace.hpp +0eb4c4550053ab14cb0c04bda920d44784582dacba7e075f76b673b568e69deb editor/src/TeamWorkspace.cpp +57de1299409dd07329f084097484a8cfd27747ae0ed882deef73370479f14f53 editor/src/TeamWorkspace.hpp 036228cf2017139c3c8040092368bfa319eb8ad1019221303c92408ea20d2787 editor/src/TemplateManager.cpp a4d95987722b71f6f5e0573e92efee307402872ae83341d559d502f56948ed3a editor/src/TemplateManager.hpp ebe5aa708c199c5171b5456f2b8e387444296f472f61725648a7e2fa20e5b0b7 editor/src/UiSizing.hpp @@ -118,7 +118,7 @@ affa4bcf9e111a6268b38f6cce443a36d8226bba89d50f7076d13b1fbd46f5b7 editor/tests/r 4f043b560c3e49e9e8cbfbcf07242c020768bd203dab26d476e7a96cbdec05bf editor/tests/test_release_assets.py e7804622971a88473783057160bb7e328783bef6645a467d1155e3a95aa9d64f editor/tests/test_windows_installer.py aa09642aa2df3dac3ab9b7296c37743b3f3db66c0ff5ac3fe7a373724eae8491 editor/tests/tst_editor_core.cpp -a31a4cc6fab511be3961bb211e8472ce2a86719cd16771f313b4f267a3711664 editor/tests/tst_editor_ui.cpp +480effc0f0aea34920f448f4d9c568633863a2521fb343cad2356581fe0f430b editor/tests/tst_editor_ui.cpp 18d8673b83d59483a1abb47d685f263a52b1f0dbdc9fa1ed6d56e80de2822e34 editor/third_party/puff/README.md 5b9d75aeb5baf3575415bc6ade3f2a02e50b6b971b3f8b4fda2b03543bc6e52f editor/third_party/puff/puff.c 969b7be2a930db0cdcb19b0e5b29ae6741f5a8f663b6dba6d647e12ec60cfa8e editor/third_party/puff/puff.h diff --git a/editor/src/ApiClient.cpp b/editor/src/ApiClient.cpp index f67d883..665f505 100644 --- a/editor/src/ApiClient.cpp +++ b/editor/src/ApiClient.cpp @@ -365,6 +365,7 @@ bool ApiClient::setSessionToken(const QByteArray &sessionToken, QString *errorMe } return false; } + invalidatePendingResponses(); m_sessionToken.fill('\0'); m_sessionToken = sessionToken; return true; @@ -379,14 +380,20 @@ void ApiClient::clearCredentials() void ApiClient::clearSession() { + invalidatePendingResponses(); m_sessionToken.fill('\0'); m_sessionToken.clear(); } -void ApiClient::cancelActiveRequests() +void ApiClient::invalidatePendingResponses() { ++m_requestGeneration; for (const auto &job : m_fileJobs) { job->store(true); } +} + +void ApiClient::cancelActiveRequests() +{ + invalidatePendingResponses(); const auto replies = m_network.findChildren(QString(), Qt::FindDirectChildrenOnly); for (auto *reply : replies) { @@ -564,6 +571,7 @@ void ApiClient::trackJsonReply(QNetworkReply *reply, const QString &operation, bool authenticationRequired, bool mutation, int retryAttempt, const std::function &retryRequest) { + const auto generation = m_requestGeneration; reply->setReadBufferSize(m_maxResponseBytes + 1); ++m_activeRequests; const bool foreground = !reply->property("forgeBackground").toBool(); @@ -579,7 +587,8 @@ void ApiClient::trackJsonReply(QNetworkReply *reply, const QString &operation, reply->abort(); } }); - connect(reply, &QNetworkReply::sslErrors, this, [this, reply](const QList &errors) { + connect(reply, &QNetworkReply::sslErrors, this, [this, reply, generation](const QList &errors) { + if (generation != m_requestGeneration) { return; } QStringList descriptions; descriptions.reserve(errors.size()); for (const auto &error : errors) { @@ -591,7 +600,7 @@ void ApiClient::trackJsonReply(QNetworkReply *reply, const QString &operation, }); connect(reply, &QNetworkReply::finished, this, [this, reply, operation, buffer, tooLarge, authenticationRequired, mutation, - retryAttempt, retryRequest, foreground]() { + retryAttempt, retryRequest, foreground, generation]() { if (reply->isOpen()) { buffer->append(reply->read(qMax(0, m_maxResponseBytes - buffer->size()) + 1)); } @@ -603,6 +612,14 @@ void ApiClient::trackJsonReply(QNetworkReply *reply, const QString &operation, m_foregroundRequests = qMax(0, m_foregroundRequests - 1); emit connectionActivityChanged(m_foregroundRequests > 0); } + if (generation != m_requestGeneration) { + if (reply->property("forgeUserCanceled").toBool()) { + fail(operation, 0, QStringLiteral("The request was canceled by the user before completion."), + QStringLiteral("canceled"), {}, mutation); + } + reply->deleteLater(); + return; + } const auto statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const auto redirect = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); const bool timedOut = reply->property("forgeTimedOut").toBool(); @@ -777,9 +794,12 @@ void ApiClient::fetchSetupStatus(const QString &operation) void ApiClient::logout() { cancelActiveRequests(); - send(QStringLiteral("auth-logout"), QNetworkAccessManager::PostOperation, - {QStringLiteral("auth"), QStringLiteral("logout")}); + const auto token = m_sessionToken; clearSession(); + // Invalidate old requests immediately while revoking the captured server session. + send(QStringLiteral("auth-logout"), QNetworkAccessManager::PostOperation, + {QStringLiteral("auth"), QStringLiteral("logout")}, {}, {}, false, + {{QByteArray("Authorization"), QByteArray("Bearer ") + token}}); } void ApiClient::fetchCapabilities(const QString &project) @@ -926,11 +946,11 @@ void ApiClient::createArea(const QString &project, const QString &name, const QS {QStringLiteral("allowed_role_ids"), QJsonArray{}}}); } -void ApiClient::fetchMessages(const QString &areaId) +void ApiClient::fetchMessages(const QString &areaId, const QString &operation) { QUrlQuery query; query.addQueryItem(QStringLiteral("limit"), QStringLiteral("200")); - send(QStringLiteral("team-messages:%1").arg(areaId), QNetworkAccessManager::GetOperation, + send(operation.isEmpty() ? QStringLiteral("team-messages:%1").arg(areaId) : operation, QNetworkAccessManager::GetOperation, {QStringLiteral("areas"), areaId, QStringLiteral("messages")}, {}, query, true, {}, RequestProfile::Background); } @@ -1004,7 +1024,9 @@ void ApiClient::postAttachment(const QString &areaId, const QString &name, const auto *reply = m_network.post(request, multipart); reply->setProperty("forgeFileTransfer", true); emit fileTransferProgress(QStringLiteral("team-attachment-upload:%1").arg(areaId), 0, QStringLiteral("Uploading %1…").arg(name)); - connect(reply, &QNetworkReply::uploadProgress, this, [this, areaId, name](qint64 sent, qint64 total) { + const auto generation = m_requestGeneration; + connect(reply, &QNetworkReply::uploadProgress, this, [this, areaId, name, generation](qint64 sent, qint64 total) { + if (generation != m_requestGeneration) { return; } const int percent = total > 0 ? static_cast(qBound(0LL, sent * 100 / total, 100LL)) : 0; emit fileTransferProgress(QStringLiteral("team-attachment-upload:%1").arg(areaId), percent, percent == 100 ? QStringLiteral("Server is saving %1…").arg(name) : QStringLiteral("Uploading %1 · %2%").arg(name).arg(percent)); @@ -1025,6 +1047,7 @@ void ApiClient::downloadAttachment(const QString &attachmentId, const QString &t qsizetype maxBytes, const QString &sha256) { const auto operation = QStringLiteral("team-attachment-download"); + const auto generation = m_requestGeneration; if (!isConfigured() || targetPath.isEmpty() || maxBytes < 1 || maxBytes > MaximumAttachmentSnapshot) { fail(operation, 0, QStringLiteral("A signed-in server and safe target path are required."), QStringLiteral("validation")); @@ -1037,7 +1060,8 @@ void ApiClient::downloadAttachment(const QString &attachmentId, const QString &t request.setTransferTimeout(m_downloadTimeoutMs); auto *reply = m_network.get(request); reply->setProperty("forgeFileTransfer", true); - connect(reply, &QNetworkReply::downloadProgress, this, [this, operation](qint64 received, qint64 total) { + connect(reply, &QNetworkReply::downloadProgress, this, [this, operation, generation](qint64 received, qint64 total) { + if (generation != m_requestGeneration) { return; } const int percent = total > 0 ? static_cast(qBound(0LL, received * 100 / total, 100LL)) : 0; emit fileTransferProgress(operation, percent, QStringLiteral("Downloading · %1 KiB").arg(received / 1024)); }); @@ -1064,7 +1088,8 @@ void ApiClient::downloadAttachment(const QString &attachmentId, const QString &t reply->abort(); } }); - connect(reply, &QNetworkReply::sslErrors, this, [this, reply](const QList &errors) { + connect(reply, &QNetworkReply::sslErrors, this, [this, reply, generation](const QList &errors) { + if (generation != m_requestGeneration) { return; } QStringList descriptions; for (const auto &error : errors) { descriptions.append(error.errorString()); @@ -1074,7 +1099,7 @@ void ApiClient::downloadAttachment(const QString &attachmentId, const QString &t emit tlsRejected(descriptions.join(QStringLiteral("; "))); }); connect(reply, &QNetworkReply::finished, this, - [this, reply, operation, targetPath, buffer, tooLarge, maxBytes, sha256]() { + [this, reply, operation, targetPath, buffer, tooLarge, maxBytes, sha256, generation]() { if (reply->isOpen()) { buffer->append(reply->read(qMax(0, maxBytes - buffer->size()) + 1)); } @@ -1084,6 +1109,14 @@ void ApiClient::downloadAttachment(const QString &attachmentId, const QString &t m_activeRequests = qMax(0, m_activeRequests - 1); m_foregroundRequests = qMax(0, m_foregroundRequests - 1); emit connectionActivityChanged(m_foregroundRequests > 0); + if (generation != m_requestGeneration) { + if (reply->property("forgeUserCanceled").toBool()) { + fail(operation, 0, QStringLiteral("The request was canceled by the user before completion."), + QStringLiteral("canceled"), {}); + } + reply->deleteLater(); + return; + } const auto statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const auto redirect = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); const bool timedOut = reply->property("forgeTimedOut").toBool(); @@ -1114,7 +1147,6 @@ void ApiClient::downloadAttachment(const QString &attachmentId, const QString &t QStringLiteral("Network error %1; HTTP status %2.") .arg(static_cast(reply->error())).arg(statusCode)); } else { - const auto generation = m_requestGeneration; const auto canceled = QSharedPointer::create(false); m_fileJobs.append(canceled); emit fileTransferProgress(operation, 100, QStringLiteral("Verifying and saving file…")); diff --git a/editor/src/ApiClient.hpp b/editor/src/ApiClient.hpp index 4965560..8130840 100644 --- a/editor/src/ApiClient.hpp +++ b/editor/src/ApiClient.hpp @@ -65,7 +65,7 @@ class ApiClient final : public QObject { void fetchAreas(const QString &project); void createArea(const QString &project, const QString &name, const QString &description, const QString &visibility, int minimumRank); - void fetchMessages(const QString &areaId); + void fetchMessages(const QString &areaId, const QString &operation = {}); void postMessage(const QString &areaId, const QString &body, bool announcement = false); void fetchAttachments(const QString &areaId); void uploadAttachment(const QString &areaId, const QString &filePath, qsizetype maxBytes); @@ -101,6 +101,7 @@ class ApiClient final : public QObject { private: enum class RequestProfile { Normal, Authentication, Background }; + void invalidatePendingResponses(); void postAttachment(const QString &areaId, const QString &name, const QByteArray &snapshot); void send(const QString &operation, QNetworkAccessManager::Operation method, const QStringList &pathSegments, diff --git a/editor/src/TeamWorkspace.cpp b/editor/src/TeamWorkspace.cpp index 9323fd1..2e46a51 100644 --- a/editor/src/TeamWorkspace.cpp +++ b/editor/src/TeamWorkspace.cpp @@ -187,7 +187,7 @@ TeamWorkspace::TeamWorkspace(ApiClient *api, QWidget *parent) [this](const QString &operation, int status, const QString &message, const QString &category, const QString &, bool outcomeUncertain) { if (category == QStringLiteral("canceled") && !operation.startsWith(QStringLiteral("team-attachment-"))) { return; } - if (!acceptsCallOperation(operation)) { return; } + if (!acceptsWorkspaceOperation(operation)) { return; } handleError(operation, status, message); if (operation.startsWith(QStringLiteral("team-")) && outcomeUncertain) { m_feedback->setText(message + QStringLiteral(" Check whether the change reached the server before retrying.")); @@ -734,7 +734,8 @@ void TeamWorkspace::requestMessages() const auto area = currentAreaId(); if (!area.isEmpty() && m_messagesLoading != area && permits(QStringLiteral("messages.read"))) { m_messagesLoading = area; - m_api->fetchMessages(area); + m_messagesOperation = QStringLiteral("team-messages:%1:request:%2").arg(area).arg(++m_messageRequestSerial); + m_api->fetchMessages(area, m_messagesOperation); } } @@ -781,6 +782,7 @@ void TeamWorkspace::reset() m_pendingMessageArea.clear(); m_pendingMessageBody.clear(); m_messagesLoading.clear(); + m_messagesOperation.clear(); m_messagesRefreshNeeded.clear(); m_pendingNoteProject.clear(); m_pendingNoteTitle.clear(); @@ -832,6 +834,7 @@ void TeamWorkspace::selectArea() ? QStringLiteral("# ") + m_areas->currentItem()->text().section(u'\n', 0, 0) : QStringLiteral("# Choose a space")); m_attachments->clear(); m_messagesLoading.clear(); + m_messagesOperation.clear(); m_messagesRefreshNeeded.clear(); updateActions(); if (!area.isEmpty()) { @@ -1398,9 +1401,10 @@ void TeamWorkspace::handleJson(const QString &operation, const QJsonObject &payl return; } if (operation.startsWith(QStringLiteral("team-messages:"))) { - const auto area = operation.mid(QStringLiteral("team-messages:").size()); - if (m_messagesLoading == area) { m_messagesLoading.clear(); } - if (area != currentAreaId()) { return; } + if (!acceptsWorkspaceOperation(operation)) { return; } + const auto area = m_messagesLoading; + m_messagesLoading.clear(); + m_messagesOperation.clear(); const auto records = payload.value(QStringLiteral("messages")).toArray(); const bool changed = records != m_messageRecords; m_messageRecords = records; @@ -1595,8 +1599,12 @@ void TeamWorkspace::handleJson(const QString &operation, const QJsonObject &payl } } -bool TeamWorkspace::acceptsCallOperation(const QString &operation) const +bool TeamWorkspace::acceptsWorkspaceOperation(const QString &operation) const { + if (operation.startsWith(QStringLiteral("team-messages:"))) { + return !m_messagesOperation.isEmpty() && operation == m_messagesOperation + && m_messagesLoading == currentAreaId(); + } if (operation.startsWith(QStringLiteral("team-call:"))) { return !m_pendingCallArea.isEmpty() && m_pendingCallArea == currentAreaId() && operation == m_pendingCallOperation; @@ -1610,7 +1618,7 @@ bool TeamWorkspace::acceptsCallOperation(const QString &operation) const void TeamWorkspace::handleError(const QString &operation, int statusCode, const QString &message) { - if (!acceptsCallOperation(operation)) { return; } + if (!acceptsWorkspaceOperation(operation)) { return; } if (operation.startsWith(QStringLiteral("team-attachment-"))) { m_transferBusy = false; m_pendingUploadArea.clear(); @@ -1635,7 +1643,7 @@ void TeamWorkspace::handleError(const QString &operation, int statusCode, const m_pendingNoteProject.clear(); updateActions(); } - if (operation == QStringLiteral("team-messages:%1").arg(m_messagesLoading)) { m_messagesLoading.clear(); } + if (operation == m_messagesOperation) { m_messagesLoading.clear(); m_messagesOperation.clear(); } if (!operation.startsWith(QStringLiteral("team-"))) { return; } diff --git a/editor/src/TeamWorkspace.hpp b/editor/src/TeamWorkspace.hpp index d1cd1f1..701be51 100644 --- a/editor/src/TeamWorkspace.hpp +++ b/editor/src/TeamWorkspace.hpp @@ -77,7 +77,7 @@ private slots: void updateActions(); void refreshProject(); void requestMessages(); - [[nodiscard]] bool acceptsCallOperation(const QString &operation) const; + [[nodiscard]] bool acceptsWorkspaceOperation(const QString &operation) const; ApiClient *m_api = nullptr; QLabel *m_profile = nullptr; @@ -134,6 +134,8 @@ private slots: QString m_pendingMessageArea; QString m_pendingMessageBody; QString m_messagesLoading; + QString m_messagesOperation; + quint64 m_messageRequestSerial = 0; QString m_messagesRefreshNeeded; QString m_pendingNoteProject; QString m_pendingNoteTitle; diff --git a/editor/tests/tst_editor_ui.cpp b/editor/tests/tst_editor_ui.cpp index 6e49342..3cd61f6 100644 --- a/editor/tests/tst_editor_ui.cpp +++ b/editor/tests/tst_editor_ui.cpp @@ -162,6 +162,8 @@ private slots: void workerRefreshRespectsPermissionsAndGlobalScope(); void failedMessagePreservesDraftAndPreventsDuplicatePosts(); void workspaceIgnoresStaleResponsesAndKeepsAreaDrafts(); + void messageSnapshotCannotOverwriteReturnedSpace_data(); + void messageSnapshotCannotOverwriteReturnedSpace(); void noteAcknowledgementPreservesNewerEdits(); void resetClearsSessionData(); void founderWildcardPermissions(); @@ -173,6 +175,8 @@ private slots: void oldCallClientExplainsRequiredUpdate(); void staleCallFailureDoesNotCancelNewAreaCall_data(); void staleCallFailureDoesNotCancelNewAreaCall(); + void staleSessionResponseIsDiscarded_data(); + void staleSessionResponseIsDiscarded(); void backgroundRefreshAndDownloadIntegrity(); void liveServerContract(); @@ -662,11 +666,18 @@ void EditorUiTests::workspaceIgnoresStaleResponsesAndKeepsAreaDrafts() { WorkspaceServer server; QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + server.respond = [](const QByteArray &request) { + if (request.contains("/areas?")) { + return WorkspaceServer::Response{QJsonDocument(spaces()).toJson(QJsonDocument::Compact)}; + } + return WorkspaceServer::Response{request.contains("/areas/beta/messages?") + ? QJsonDocument(messages(QStringLiteral("correct area"))).toJson(QJsonDocument::Compact) : QByteArray("{}")}; + }; ApiClient api; QString error; QVERIFY(api.configure(server.url(), TestSession, true, &error)); TeamWorkspace team(&api); - team.setCapabilities(capabilities({QStringLiteral("areas.read"), QStringLiteral("messages.write")})); + team.setCapabilities(capabilities({QStringLiteral("areas.read"), QStringLiteral("messages.read"), QStringLiteral("messages.write")})); team.setProject(QStringLiteral("Alpha")); deliver(api, QStringLiteral("team-areas:Alpha"), spaces()); auto *areas = team.findChild(QStringLiteral("areaList")); @@ -677,7 +688,7 @@ void EditorUiTests::workspaceIgnoresStaleResponsesAndKeepsAreaDrafts() composer->setPlainText(QStringLiteral("Beta draft")); deliver(api, QStringLiteral("team-messages:alpha"), messages(QStringLiteral("wrong area"))); QCOMPARE(messageList->messageCount(), 0); - deliver(api, QStringLiteral("team-messages:beta"), messages(QStringLiteral("correct area"))); + QVERIFY(waitUntil([&] { return messageList->messageCount() == 1; })); QCOMPARE(messageList->messageAt(0).value(QStringLiteral("body")).toString(), QStringLiteral("correct area")); areas->setCurrentRow(0); QCOMPARE(composer->toPlainText(), QStringLiteral("Alpha draft")); @@ -692,6 +703,49 @@ void EditorUiTests::workspaceIgnoresStaleResponsesAndKeepsAreaDrafts() QCOMPARE(team.findChild(QStringLiteral("attachmentList"))->topLevelItemCount(), 0); } +void EditorUiTests::messageSnapshotCannotOverwriteReturnedSpace_data() +{ + QTest::addColumn("oldFailure"); + QTest::newRow("old-snapshot") << false; + QTest::newRow("old-error") << true; +} + +void EditorUiTests::messageSnapshotCannotOverwriteReturnedSpace() +{ + QFETCH(bool, oldFailure); + WorkspaceServer server; + QVERIFY(server.listen(QHostAddress::LocalHost)); + int alphaReads = 0; + server.respond = [&](const QByteArray &request) { + if (request.startsWith("GET /__forge/editor/v1/areas/alpha/messages?")) { + ++alphaReads; + const auto body = QJsonDocument(messages(alphaReads == 1 ? QStringLiteral("Old snapshot") : QStringLiteral("Current snapshot"))).toJson(QJsonDocument::Compact); + return WorkspaceServer::Response{body, oldFailure && alphaReads == 1 ? QByteArray("503 Service Unavailable") : QByteArray("200 OK"), alphaReads == 1 ? 250 : 10}; + } + return WorkspaceServer::Response{}; + }; + ApiClient api; + QString error; + QVERIFY(api.configure(server.url(), TestSession, true, &error)); + TeamWorkspace team(&api); + team.setCapabilities(capabilities({QStringLiteral("areas.read"), QStringLiteral("messages.read")})); + deliver(api, QStringLiteral("team-areas:*"), spaces()); + auto *areas = team.findChild(QStringLiteral("areaList")); + auto *view = team.findChild(QStringLiteral("messageList")); + QSignalSpy status(&team, &TeamWorkspace::statusMessage); + EditorPreferences preferences; + preferences.safeGetRetries = 0; + api.applyPreferences(preferences); + QVERIFY(waitUntil([&] { return alphaReads == 1; })); + areas->setCurrentRow(1); + areas->setCurrentRow(0); + QVERIFY(waitUntil([&] { return alphaReads == 2 && view->messageCount() == 1; })); + QCOMPARE(view->messageAt(0).value(QStringLiteral("body")).toString(), QStringLiteral("Current snapshot")); + QTest::qWait(300); + QCOMPARE(view->messageAt(0).value(QStringLiteral("body")).toString(), QStringLiteral("Current snapshot")); + QVERIFY(status.isEmpty()); +} + void EditorUiTests::noteAcknowledgementPreservesNewerEdits() { WorkspaceServer server; @@ -954,6 +1008,59 @@ void EditorUiTests::staleCallFailureDoesNotCancelNewAreaCall() QVERIFY(waitUntil([&] { return tickets == 1; })); } +void EditorUiTests::staleSessionResponseIsDiscarded_data() +{ + QTest::addColumn("responseKind"); + QTest::newRow("expired-profile") << QStringLiteral("unauthorized"); + QTest::newRow("old-profile") << QStringLiteral("profile"); + QTest::newRow("old-login") << QStringLiteral("login"); + QTest::newRow("old-download") << QStringLiteral("download"); + QTest::newRow("expired-download") << QStringLiteral("download-unauthorized"); +} + +void EditorUiTests::staleSessionResponseIsDiscarded() +{ + QFETCH(QString, responseKind); + WorkspaceServer server; + QVERIFY(server.listen(QHostAddress::LocalHost)); + server.respond = [&](const QByteArray &request) { + if (request.contains("/auth/login ")) { + return WorkspaceServer::Response{QByteArray("{\"access_token\":\"") + TestSession + QByteArray("\"}"), QByteArray("200 OK"), 120}; + } + if (responseKind.endsWith(QStringLiteral("unauthorized"))) { + return WorkspaceServer::Response{QByteArray("{\"detail\":\"Old session expired\"}"), QByteArray("401 Unauthorized"), 120}; + } + return WorkspaceServer::Response{request.contains("/attachments/") ? QByteArray("old private file") : QByteArray("{\"display_name\":\"Old account\"}"), QByteArray("200 OK"), 120}; + }; + ApiClient api; + QString error; + QVERIFY(api.configure(server.url(), TestSession, true, &error)); + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const auto target = directory.filePath(QStringLiteral("private.txt")); + QSignalSpy received(&api, &ApiClient::jsonReceived); + QSignalSpy failed(&api, &ApiClient::requestFailed); + QSignalSpy downloaded(&api, &ApiClient::fileDownloaded); + QSignalSpy activity(&api, &ApiClient::connectionActivityChanged); + if (responseKind.startsWith(QStringLiteral("download"))) { api.downloadAttachment(QStringLiteral("old-file"), target, 1024); } + else if (responseKind == QStringLiteral("login")) { api.login(QStringLiteral("old-account"), QStringLiteral("password")); } + else { api.fetchProfile(); } + QVERIFY(waitUntil([&] { return server.requests.size() == 1; })); + auto replacement = TestSession; + replacement[replacement.size() - 1] = 'z'; + QVERIFY(api.setSessionToken(replacement, &error)); + QVERIFY(waitUntil([&] { return !activity.isEmpty() && !activity.last().at(0).toBool(); })); + QTest::qWait(100); + QVERIFY(api.isConfigured()); + QVERIFY(received.isEmpty()); + QVERIFY(failed.isEmpty()); + QVERIFY(downloaded.isEmpty()); + QVERIFY(!QFile::exists(target)); + api.fetchProfile(); + QVERIFY(waitUntil([&] { return server.requests.size() == 2; })); + QVERIFY(server.requests.last().contains(QByteArray("Authorization: Bearer ") + replacement)); +} + void EditorUiTests::backgroundRefreshAndDownloadIntegrity() { WorkspaceServer server; From b08c8ba22eb5eeb78f0a67fd320ad906f1569f91 Mon Sep 17 00:00:00 2001 From: Cavanshirpro Date: Thu, 15 Oct 2026 23:09:25 +0400 Subject: [PATCH 6/7] Compare credential headers portably without changing token case --- MANIFEST.sha256 | 2 +- editor/tests/tst_editor_ui.cpp | 43 +++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index aeda50b..7f68f22 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -118,7 +118,7 @@ affa4bcf9e111a6268b38f6cce443a36d8226bba89d50f7076d13b1fbd46f5b7 editor/tests/r 4f043b560c3e49e9e8cbfbcf07242c020768bd203dab26d476e7a96cbdec05bf editor/tests/test_release_assets.py e7804622971a88473783057160bb7e328783bef6645a467d1155e3a95aa9d64f editor/tests/test_windows_installer.py aa09642aa2df3dac3ab9b7296c37743b3f3db66c0ff5ac3fe7a373724eae8491 editor/tests/tst_editor_core.cpp -480effc0f0aea34920f448f4d9c568633863a2521fb343cad2356581fe0f430b editor/tests/tst_editor_ui.cpp +cfdc899071bc925e8c4f63db9b1f344ffa7fa38b051ec2431a2ec17f8f9676df editor/tests/tst_editor_ui.cpp 18d8673b83d59483a1abb47d685f263a52b1f0dbdc9fa1ed6d56e80de2822e34 editor/third_party/puff/README.md 5b9d75aeb5baf3575415bc6ade3f2a02e50b6b971b3f8b4fda2b03543bc6e52f editor/third_party/puff/puff.c 969b7be2a930db0cdcb19b0e5b29ae6741f5a8f663b6dba6d647e12ec60cfa8e editor/third_party/puff/puff.h diff --git a/editor/tests/tst_editor_ui.cpp b/editor/tests/tst_editor_ui.cpp index 3cd61f6..2d056e9 100644 --- a/editor/tests/tst_editor_ui.cpp +++ b/editor/tests/tst_editor_ui.cpp @@ -102,6 +102,19 @@ class WorkspaceServer final : public QTcpServer { QUrl url() const { return QUrl(QStringLiteral("http://127.0.0.1:%1").arg(serverPort())); } }; +QByteArray requestHeader(const QByteArray &request, const QByteArray &name) +{ + const auto headerEnd = request.indexOf("\r\n\r\n"); + if (headerEnd < 0) { return {}; } + for (const auto &line : request.left(headerEnd).split('\n')) { + const auto colon = line.indexOf(':'); + if (colon > 0 && line.left(colon).compare(name, Qt::CaseInsensitive) == 0) { + return line.mid(colon + 1).trimmed(); + } + } + return {}; +} + QJsonObject capabilities(const QStringList &permissions) { QJsonArray values; @@ -146,6 +159,8 @@ private slots: void sidebarReclaimsSpace(); void sidebarResponsivePreference(); void compactTeamKeepsNavigation(); + void credentialHeaderParsingPreservesTokenCase_data(); + void credentialHeaderParsingPreservesTokenCase(); void disconnectRevokesSession(); void repeatedVisualModePreservesEdits(); void repeatedGraphModePreservesEdits(); @@ -256,6 +271,26 @@ void EditorUiTests::compactTeamKeepsNavigation() QVERIFY(window.m_sidebarButton->isVisible()); } +void EditorUiTests::credentialHeaderParsingPreservesTokenCase_data() +{ + QTest::addColumn("headerName"); + QTest::newRow("title-case") << QByteArray("Authorization"); + QTest::newRow("lower-case") << QByteArray("authorization"); + QTest::newRow("mixed-case") << QByteArray("aUtHoRiZaTiOn"); +} + +void EditorUiTests::credentialHeaderParsingPreservesTokenCase() +{ + QFETCH(QByteArray, headerName); + const auto expected = QByteArray("Bearer ") + TestSession; + const auto request = QByteArray("GET / HTTP/1.1\r\n") + headerName + QByteArray(": ") + + expected + QByteArray("\r\n\r\nAuthorization: body-must-not-be-a-header"); + QCOMPARE(requestHeader(request, QByteArray("AUTHORIZATION")), expected); + QVERIFY(requestHeader(request, QByteArray("authorization")) != QByteArray("Bearer ") + TestSession.toLower()); + const auto bodyOnly = QByteArray("GET / HTTP/1.1\r\nHost: localhost\r\n\r\nAuthorization: ") + expected; + QVERIFY(requestHeader(bodyOnly, QByteArray("authorization")).isEmpty()); +} + void EditorUiTests::disconnectRevokesSession() { WorkspaceServer server; @@ -273,11 +308,7 @@ void EditorUiTests::disconnectRevokesSession() QVERIFY(waitUntil([&] { return (server.requests.size()) == (1); })); QCOMPARE(server.requests.size(), 1); QVERIFY(server.requests.first().startsWith("POST /__forge/editor/v1/auth/logout ")); - QByteArray authorization; - for (const auto &line : server.requests.first().split('\n')) { - if (line.left(14).toLower() == QByteArray("authorization:")) { authorization = line.mid(14).trimmed(); } - } - QCOMPARE(authorization, QByteArray("Bearer ") + TestSession); + QCOMPARE(requestHeader(server.requests.first(), QByteArray("Authorization")), QByteArray("Bearer ") + TestSession); QVERIFY(!window.m_api->isConfigured()); QVERIFY(window.m_disconnectButton->isHidden()); } @@ -1058,7 +1089,7 @@ void EditorUiTests::staleSessionResponseIsDiscarded() QVERIFY(!QFile::exists(target)); api.fetchProfile(); QVERIFY(waitUntil([&] { return server.requests.size() == 2; })); - QVERIFY(server.requests.last().contains(QByteArray("Authorization: Bearer ") + replacement)); + QCOMPARE(requestHeader(server.requests.last(), QByteArray("Authorization")), QByteArray("Bearer ") + replacement); } void EditorUiTests::backgroundRefreshAndDownloadIntegrity() From 2625fc8b883ed9cf304c76a2bf4892e1e06f2b32 Mon Sep 17 00:00:00 2001 From: Cavanshirpro Date: Sat, 17 Oct 2026 20:09:05 +0400 Subject: [PATCH 7/7] Isolate stale shared file and note list responses --- CHANGELOG.md | 1 + MANIFEST.sha256 | 12 +++---- editor/src/ApiClient.cpp | 8 ++--- editor/src/ApiClient.hpp | 4 +-- editor/src/TeamWorkspace.cpp | 47 +++++++++++++++++++++----- editor/src/TeamWorkspace.hpp | 5 +++ editor/tests/tst_editor_ui.cpp | 62 ++++++++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ddceb1..ad804fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Reject unsafe manifest paths and malformed SHA-256 records before scanning or hashing; block symbolic links and Windows reparse/junction boundaries without entering them during source fallback scans. - Discard responses, retries and pending attachment work from an earlier account session so late login/profile/download results cannot replace credentials, sign out a new account or save private files after the session changes. - Correlate message refresh requests individually so returning to a space cannot let an older snapshot or error overwrite its current conversation. +- Correlate shared-file and note list refreshes individually so returning to a space/project, or refreshing notes twice, cannot let an older response replace the current list or display an obsolete error. - Keep these changes separate from the frozen v0.5.2 publication candidate. ## [0.5.2] — 2026-10-04 diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 7f68f22..59255e1 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -13,7 +13,7 @@ af795a365fc479fbc7d4aedd6cf90fc0990da0181d15cd3854da34c2b861b765 .gitignore 09094d533067dddbaf48b9c021ec594292ddc875e8f93735e44dcff9ab62bf55 AI_USAGE_POLICY.md 759da1adabb4d60f41e3b678452af42192ee3a2c9cc40b0d30522153df374708 AUTHORS.md 08a52d0ba592af11dfd45e217bd0f11e9a925043ee1f43c144e85d48f6559660 BRAND.md -58f4455e1f346d56787c3ac757806aabbae3f9b63bc9857955ff115a7b27a4a7 CHANGELOG.md +008a84aa3ea17a647cc05e519bec2d8f38742722815aa8a07df308757759348f CHANGELOG.md c1e12824e5f0cb70bcd405b005625b07e4a7e1a8f110e5242768725711dea676 CITATION.cff d5c61448d613124dd312e4a78cee516c60f29dc3034eeb8ac55bd10fcf64e005 CMakeLists.txt 3687086aeb50202ab381acb9334a48fe3f72ba2f294ebf1f52bb34c9b84f54f5 CMakePresets.json @@ -75,8 +75,8 @@ c2fcb9d3d16c6bc879863ac5124615383c2ccea261c2522fdfdd20e4404c88c9 editor/resourc 1218abc85dbce25d414b6a936b238019edecf4c440729285738718de11d158ce editor/resources/styles/dark.qss 13bc7533e16a1c69a2d4962eb81c48784f11357420bb0581523b9f3ce44d4aff editor/resources/templates/catalog.json d4f81573e7196e0f02956a2892eafe85c4185f7c2fea6f8f62fab38c0b445751 editor/resources/windows.rc -4ce28b4d7e2bcd8d035efc4494d17f6a4415214beace40b11b605fd35aa7d30c editor/src/ApiClient.cpp -54c5f4e8a8c0c03083bc3b2c3ad8361973eac58128bd37656521919b73f0878e editor/src/ApiClient.hpp +eca4ce9a5a36f2d54d0784b50348eb2a5a9f1d60a8eabb76ebaade9b5d365cdb editor/src/ApiClient.cpp +3a19a353eb6bca3d0242e77838e6079e280620838e1a05eb5cbc0f994c78fc0c editor/src/ApiClient.hpp 097417cb676f46a79b7ac07978dd9715c95705a9bf2354fc099010e792133291 editor/src/ChatView.cpp fb6cdb4d0031b11aa3afc5e60ddb415a373d924bc7824d30f8eecf65591e3f5f editor/src/ChatView.hpp 4de9f9c93e7bc1adec1406f263087f05811188fd2a81acdc529d2ef6b1c19ce1 editor/src/CodeEditor.cpp @@ -103,8 +103,8 @@ e1409ab312d2bb9e15451d15d2da3d9f9088883d197f9fb3a9c6a8731fdd47d9 editor/src/Pyt 1fdcebef6bccf297074a6fe23d5d3da4e51f33148409793fe33459d78e746ef6 editor/src/PythonSdkPanel.hpp 33ea00abbda668bc8dfc58b4530aa7b5dec5b3c3dbf430ab7e552b2e8c4f7d7f editor/src/SafeZipReader.cpp 9199472d7380299b49848d002e600f20bdcdd021c68da5fea0005261013f2930 editor/src/SafeZipReader.hpp -0eb4c4550053ab14cb0c04bda920d44784582dacba7e075f76b673b568e69deb editor/src/TeamWorkspace.cpp -57de1299409dd07329f084097484a8cfd27747ae0ed882deef73370479f14f53 editor/src/TeamWorkspace.hpp +63549a78ad9e590a6b2fc8a3a33871190a416a5a3bb8083d55c9fefa7459f41a editor/src/TeamWorkspace.cpp +5809a561faa3910df83f64a01fe9c6d2fd71687859cb705343b2273fc2c4c704 editor/src/TeamWorkspace.hpp 036228cf2017139c3c8040092368bfa319eb8ad1019221303c92408ea20d2787 editor/src/TemplateManager.cpp a4d95987722b71f6f5e0573e92efee307402872ae83341d559d502f56948ed3a editor/src/TemplateManager.hpp ebe5aa708c199c5171b5456f2b8e387444296f472f61725648a7e2fa20e5b0b7 editor/src/UiSizing.hpp @@ -118,7 +118,7 @@ affa4bcf9e111a6268b38f6cce443a36d8226bba89d50f7076d13b1fbd46f5b7 editor/tests/r 4f043b560c3e49e9e8cbfbcf07242c020768bd203dab26d476e7a96cbdec05bf editor/tests/test_release_assets.py e7804622971a88473783057160bb7e328783bef6645a467d1155e3a95aa9d64f editor/tests/test_windows_installer.py aa09642aa2df3dac3ab9b7296c37743b3f3db66c0ff5ac3fe7a373724eae8491 editor/tests/tst_editor_core.cpp -cfdc899071bc925e8c4f63db9b1f344ffa7fa38b051ec2431a2ec17f8f9676df editor/tests/tst_editor_ui.cpp +2a8d993ebd5f302eb347f76ffa06dded910c2ed7ead2ffe363c18a9e8ac068c0 editor/tests/tst_editor_ui.cpp 18d8673b83d59483a1abb47d685f263a52b1f0dbdc9fa1ed6d56e80de2822e34 editor/third_party/puff/README.md 5b9d75aeb5baf3575415bc6ade3f2a02e50b6b971b3f8b4fda2b03543bc6e52f editor/third_party/puff/puff.c 969b7be2a930db0cdcb19b0e5b29ae6741f5a8f663b6dba6d647e12ec60cfa8e editor/third_party/puff/puff.h diff --git a/editor/src/ApiClient.cpp b/editor/src/ApiClient.cpp index 665f505..2d39390 100644 --- a/editor/src/ApiClient.cpp +++ b/editor/src/ApiClient.cpp @@ -963,9 +963,9 @@ void ApiClient::postMessage(const QString &areaId, const QString &body, bool ann : QStringLiteral("message")}}); } -void ApiClient::fetchAttachments(const QString &areaId) +void ApiClient::fetchAttachments(const QString &areaId, const QString &operation) { - send(QStringLiteral("team-attachments:%1").arg(areaId), QNetworkAccessManager::GetOperation, + send(operation.isEmpty() ? QStringLiteral("team-attachments:%1").arg(areaId) : operation, QNetworkAccessManager::GetOperation, {QStringLiteral("areas"), areaId, QStringLiteral("attachments")}, {}, {}, true, {}, RequestProfile::Background); } @@ -1173,11 +1173,11 @@ void ApiClient::downloadAttachment(const QString &attachmentId, const QString &t }); } -void ApiClient::fetchNotes(const QString &project) +void ApiClient::fetchNotes(const QString &project, const QString &operation) { QUrlQuery query; query.addQueryItem(QStringLiteral("project"), project); - send(QStringLiteral("team-notes:%1").arg(project), QNetworkAccessManager::GetOperation, {QStringLiteral("notes")}, {}, + send(operation.isEmpty() ? QStringLiteral("team-notes:%1").arg(project) : operation, QNetworkAccessManager::GetOperation, {QStringLiteral("notes")}, {}, query); } diff --git a/editor/src/ApiClient.hpp b/editor/src/ApiClient.hpp index 8130840..60a7280 100644 --- a/editor/src/ApiClient.hpp +++ b/editor/src/ApiClient.hpp @@ -67,10 +67,10 @@ class ApiClient final : public QObject { const QString &visibility, int minimumRank); void fetchMessages(const QString &areaId, const QString &operation = {}); void postMessage(const QString &areaId, const QString &body, bool announcement = false); - void fetchAttachments(const QString &areaId); + void fetchAttachments(const QString &areaId, const QString &operation = {}); void uploadAttachment(const QString &areaId, const QString &filePath, qsizetype maxBytes); void downloadAttachment(const QString &attachmentId, const QString &targetPath, qsizetype maxBytes, const QString &sha256 = {}); - void fetchNotes(const QString &project); + void fetchNotes(const QString &project, const QString &operation = {}); void createNote(const QString &project, const QString &areaId, const QString &title, const QString &body, const QString &visibility, int minimumRank = 0); void fetchDatabases(const QString &project); diff --git a/editor/src/TeamWorkspace.cpp b/editor/src/TeamWorkspace.cpp index 2e46a51..379ae68 100644 --- a/editor/src/TeamWorkspace.cpp +++ b/editor/src/TeamWorkspace.cpp @@ -425,7 +425,7 @@ void TeamWorkspace::buildSpacesTab(QWidget *tab) connect(download, &QPushButton::clicked, this, &TeamWorkspace::downloadAttachment); connect(refreshFiles, &QPushButton::clicked, this, [this] { if (!currentAreaId().isEmpty()) { - m_api->fetchAttachments(currentAreaId()); + requestAttachments(); } }); download->hide(); refreshFiles->hide(); @@ -516,7 +516,7 @@ void TeamWorkspace::buildNotesTab(QWidget *tab) connect(save, &QPushButton::clicked, this, &TeamWorkspace::saveNote); connect(refresh, &QPushButton::clicked, this, [this] { if (m_collaborationEnabled && permits(QStringLiteral("notes.read"))) { - m_api->fetchNotes(projectScope()); + requestNotes(); } }); connect(m_notes, &QTreeWidget::itemSelectionChanged, this, [this] { @@ -686,7 +686,7 @@ void TeamWorkspace::refreshProject() if (!m_api->isConfigured()) { m_poll->stop(); return; } if (m_collaborationEnabled) { if (permits(QStringLiteral("areas.read"))) { m_api->fetchAreas(projectScope()); } - if (permits(QStringLiteral("notes.read"))) { m_api->fetchNotes(projectScope()); } + if (permits(QStringLiteral("notes.read"))) { requestNotes(); } if (permits(QStringLiteral("messages.read")) || permits(QStringLiteral("attachments.read")) || (m_callsEnabled && permits(QStringLiteral("calls.join")))) { m_poll->start(); } } else { m_poll->stop(); } @@ -706,6 +706,8 @@ void TeamWorkspace::setProject(const QString &project) m_callRecords = {}; m_callsLoading.clear(); clearPendingCall(); m_extrasLoading = false; + m_attachmentsOperation.clear(); + m_notesOperation.clear(); m_attachments->clear(); m_databaseTree->clear(); m_rows->setRowCount(0); @@ -739,11 +741,26 @@ void TeamWorkspace::requestMessages() } } +void TeamWorkspace::requestAttachments() +{ + const auto area = currentAreaId(); + if (area.isEmpty()) { return; } + m_extrasLoading = true; + m_attachmentsOperation = QStringLiteral("team-attachments:%1:request:%2").arg(area).arg(++m_listRequestSerial); + m_api->fetchAttachments(area, m_attachmentsOperation); +} + +void TeamWorkspace::requestNotes() +{ + m_notesOperation = QStringLiteral("team-notes:%1:request:%2").arg(projectScope()).arg(++m_listRequestSerial); + m_api->fetchNotes(projectScope(), m_notesOperation); +} + void TeamWorkspace::refreshSpaceExtras() { const auto area = currentAreaId(); if (area.isEmpty()) { return; } - if (!m_extrasLoading && permits(QStringLiteral("attachments.read"))) { m_extrasLoading = true; m_api->fetchAttachments(area); } + if (!m_extrasLoading && permits(QStringLiteral("attachments.read"))) { requestAttachments(); } if (m_callsLoading != area && m_callsEnabled && m_callDiscovery && permits(QStringLiteral("calls.join"))) { m_callsLoading = area; m_api->fetchCalls(area); } @@ -765,6 +782,8 @@ void TeamWorkspace::reset() m_pendingUploadArea.clear(); m_transferBusy = false; m_extrasLoading = false; + m_attachmentsOperation.clear(); + m_notesOperation.clear(); m_transferProgress->hide(); m_cancelTransfer->hide(); m_transferStatus->setText(QStringLiteral("Drop a file into the conversation to share it.")); @@ -830,6 +849,7 @@ void TeamWorkspace::selectArea() m_callRecords = {}; m_callsLoading.clear(); clearPendingCall(); m_extrasLoading = false; + m_attachmentsOperation.clear(); m_spaceTitle->setText(m_areas->currentItem() != nullptr ? QStringLiteral("# ") + m_areas->currentItem()->text().section(u'\n', 0, 0) : QStringLiteral("# Choose a space")); m_attachments->clear(); @@ -1433,7 +1453,8 @@ void TeamWorkspace::handleJson(const QString &operation, const QJsonObject &payl return; } if (operation.startsWith(QStringLiteral("team-attachments:"))) { - if (operation != QStringLiteral("team-attachments:%1").arg(currentAreaId())) { return; } + if (!acceptsWorkspaceOperation(operation)) { return; } + m_attachmentsOperation.clear(); m_extrasLoading = false; const auto records = payload.value(QStringLiteral("attachments")).toArray(); if (records == m_attachmentRecords) { return; } @@ -1462,7 +1483,7 @@ void TeamWorkspace::handleJson(const QString &operation, const QJsonObject &payl m_transferStatus->setText(QStringLiteral("File shared successfully.")); updateActions(); if (operation != QStringLiteral("team-attachment-upload:%1").arg(currentAreaId())) { return; } - m_api->fetchAttachments(currentAreaId()); + requestAttachments(); emit statusMessage(QStringLiteral("File uploaded to the selected policy-filtered project area.")); return; } @@ -1477,10 +1498,11 @@ void TeamWorkspace::handleJson(const QString &operation, const QJsonObject &payl } m_pendingNoteProject.clear(); updateActions(); - if (operation == QStringLiteral("team-note-create:%1").arg(projectScope()) && permits(QStringLiteral("notes.read"))) { m_api->fetchNotes(projectScope()); } + if (operation == QStringLiteral("team-note-create:%1").arg(projectScope()) && permits(QStringLiteral("notes.read"))) { requestNotes(); } return; } - if (operation != QStringLiteral("team-notes:%1").arg(projectScope())) { return; } + if (!acceptsWorkspaceOperation(operation)) { return; } + m_notesOperation.clear(); QSignalBlocker blocker(m_notes); m_notes->clear(); for (const auto &value : payload.value(QStringLiteral("notes")).toArray()) { @@ -1601,6 +1623,12 @@ void TeamWorkspace::handleJson(const QString &operation, const QJsonObject &payl bool TeamWorkspace::acceptsWorkspaceOperation(const QString &operation) const { + if (operation.startsWith(QStringLiteral("team-attachments:"))) { + return !m_attachmentsOperation.isEmpty() && operation == m_attachmentsOperation; + } + if (operation.startsWith(QStringLiteral("team-notes:"))) { + return !m_notesOperation.isEmpty() && operation == m_notesOperation; + } if (operation.startsWith(QStringLiteral("team-messages:"))) { return !m_messagesOperation.isEmpty() && operation == m_messagesOperation && m_messagesLoading == currentAreaId(); @@ -1627,7 +1655,8 @@ void TeamWorkspace::handleError(const QString &operation, int statusCode, const m_transferStatus->setText(message); updateActions(); } - if (operation == QStringLiteral("team-attachments:%1").arg(currentAreaId())) { m_extrasLoading = false; } + if (operation == m_attachmentsOperation) { m_extrasLoading = false; m_attachmentsOperation.clear(); } + if (operation == m_notesOperation) { m_notesOperation.clear(); } if (operation == QStringLiteral("team-calls:%1").arg(m_callsLoading)) { m_callsLoading.clear(); } if (operation.startsWith(QStringLiteral("team-call:")) || operation.startsWith(QStringLiteral("team-call-ticket:"))) { clearPendingCall(); updateActions(); diff --git a/editor/src/TeamWorkspace.hpp b/editor/src/TeamWorkspace.hpp index 701be51..6c0e734 100644 --- a/editor/src/TeamWorkspace.hpp +++ b/editor/src/TeamWorkspace.hpp @@ -77,6 +77,8 @@ private slots: void updateActions(); void refreshProject(); void requestMessages(); + void requestAttachments(); + void requestNotes(); [[nodiscard]] bool acceptsWorkspaceOperation(const QString &operation) const; ApiClient *m_api = nullptr; @@ -110,6 +112,9 @@ private slots: bool m_callDiscovery = false; bool m_callClientReady = false; bool m_extrasLoading = false; + QString m_attachmentsOperation; + QString m_notesOperation; + quint64 m_listRequestSerial = 0; QTreeWidget *m_attachments = nullptr; QTreeWidget *m_databaseTree = nullptr; QTableWidget *m_rows = nullptr; diff --git a/editor/tests/tst_editor_ui.cpp b/editor/tests/tst_editor_ui.cpp index 2d056e9..7c1406d 100644 --- a/editor/tests/tst_editor_ui.cpp +++ b/editor/tests/tst_editor_ui.cpp @@ -179,6 +179,8 @@ private slots: void workspaceIgnoresStaleResponsesAndKeepsAreaDrafts(); void messageSnapshotCannotOverwriteReturnedSpace_data(); void messageSnapshotCannotOverwriteReturnedSpace(); + void workspaceListSnapshotCannotOverwriteCurrent_data(); + void workspaceListSnapshotCannotOverwriteCurrent(); void noteAcknowledgementPreservesNewerEdits(); void resetClearsSessionData(); void founderWildcardPermissions(); @@ -777,6 +779,66 @@ void EditorUiTests::messageSnapshotCannotOverwriteReturnedSpace() QVERIFY(status.isEmpty()); } +void EditorUiTests::workspaceListSnapshotCannotOverwriteCurrent_data() +{ + QTest::addColumn("listKind"); + QTest::addColumn("oldFailure"); + for (const auto &kind : {QStringLiteral("attachments"), QStringLiteral("notes"), QStringLiteral("notes-refresh")}) { + QTest::newRow(qPrintable(kind + QStringLiteral("-snapshot"))) << kind << false; + QTest::newRow(qPrintable(kind + QStringLiteral("-error"))) << kind << true; + } +} + +void EditorUiTests::workspaceListSnapshotCannotOverwriteCurrent() +{ + QFETCH(QString, listKind); + QFETCH(bool, oldFailure); + const bool attachments = listKind == QStringLiteral("attachments"); + WorkspaceServer server; + QVERIFY(server.listen(QHostAddress::LocalHost)); + int reads = 0; + server.respond = [&](const QByteArray &request) { + const bool target = attachments ? request.startsWith("GET /__forge/editor/v1/areas/alpha/attachments ") + : request.startsWith("GET /__forge/editor/v1/notes?project=Alpha "); + if (!target) { return WorkspaceServer::Response{}; } + ++reads; + const auto label = reads == 1 ? QStringLiteral("Old snapshot") : QStringLiteral("Current snapshot"); + const auto key = attachments ? QStringLiteral("attachments") : QStringLiteral("notes"); + const QJsonObject item{{attachments ? QStringLiteral("original_name") : QStringLiteral("title"), label}}; + return WorkspaceServer::Response{QJsonDocument(QJsonObject{{key, QJsonArray{item}}}).toJson(QJsonDocument::Compact), + oldFailure && reads == 1 ? QByteArray("503 Service Unavailable") : QByteArray("200 OK"), reads == 1 ? 300 : 10}; + }; + ApiClient api; + QString error; + QVERIFY(api.configure(server.url(), TestSession, true, &error)); + EditorPreferences preferences; + preferences.safeGetRetries = 0; + api.applyPreferences(preferences); + TeamWorkspace team(&api); + team.setProject(QStringLiteral("Alpha")); + team.setCapabilities(capabilities({QStringLiteral("areas.read"), attachments ? QStringLiteral("attachments.read") : QStringLiteral("notes.read")})); + if (attachments) { deliver(api, QStringLiteral("team-areas:Alpha"), spaces()); } + else { team.refreshAll(); } + QVERIFY(waitUntil([&] { return reads == 1; })); + QSignalSpy status(&team, &TeamWorkspace::statusMessage); + if (attachments) { + auto *areas = team.findChild(QStringLiteral("areaList")); + areas->setCurrentRow(1); + areas->setCurrentRow(0); + } else if (listKind == QStringLiteral("notes-refresh")) { + team.refreshAll(); + } else { + team.setProject(QStringLiteral("Beta")); + team.setProject(QStringLiteral("Alpha")); + } + auto *tree = team.findChild(attachments ? QStringLiteral("attachmentList") : QStringLiteral("noteList")); + QVERIFY(waitUntil([&] { return reads == 2 && tree->topLevelItemCount() == 1; })); + QCOMPARE(tree->topLevelItem(0)->text(0), QStringLiteral("Current snapshot")); + QTest::qWait(350); + QCOMPARE(tree->topLevelItem(0)->text(0), QStringLiteral("Current snapshot")); + QVERIFY(status.isEmpty()); +} + void EditorUiTests::noteAcknowledgementPreservesNewerEdits() { WorkspaceServer server;