diff --git a/.github/actions/qtmesh/action.yml b/.github/actions/qtmesh/action.yml index 9094347fa..7c890037b 100644 --- a/.github/actions/qtmesh/action.yml +++ b/.github/actions/qtmesh/action.yml @@ -18,6 +18,20 @@ inputs: description: 'Docker image tag to use' required: false default: 'latest' + qtmesh-token: + description: 'Optional. Sets QTMESH_TOKEN in the container (same as CLI --token). Use qtmesh-no-upload / qtmesh-strict-upload or options for other scan flags.' + required: false + qtmesh-api-base: + description: 'Optional. Sets QTMESH_API_BASE in the container.' + required: false + qtmesh-no-upload: + description: 'When true and command=scan, pass --no-upload.' + required: false + default: 'false' + qtmesh-strict-upload: + description: 'When true and command=scan, pass --strict-upload.' + required: false + default: 'false' outputs: result: @@ -34,6 +48,10 @@ runs: INPUT_FILE: ${{ inputs.input-file }} INPUT_OUTPUT_FILE: ${{ inputs.output-file }} INPUT_OPTIONS: ${{ inputs.options }} + INPUT_QTMESH_TOKEN: ${{ inputs.qtmesh-token }} + INPUT_QTMESH_API_BASE: ${{ inputs.qtmesh-api-base }} + INPUT_QTMESH_NO_UPLOAD: ${{ inputs.qtmesh-no-upload }} + INPUT_QTMESH_STRICT_UPLOAD: ${{ inputs.qtmesh-strict-upload }} run: | # Build command args safely via arrays to prevent injection cmd=("$INPUT_COMMAND" "/workspace/$INPUT_FILE") @@ -44,10 +62,27 @@ runs: read -r -a opts <<< "$INPUT_OPTIONS" cmd+=("${opts[@]}") fi + if [ "$INPUT_COMMAND" = "scan" ]; then + if [ "${INPUT_QTMESH_NO_UPLOAD:-false}" = "true" ]; then + cmd+=("--no-upload") + fi + if [ "${INPUT_QTMESH_STRICT_UPLOAD:-false}" = "true" ]; then + cmd+=("--strict-upload") + fi + fi + + docker_env=() + if [ -n "${INPUT_QTMESH_TOKEN:-}" ]; then + docker_env+=(--env "QTMESH_TOKEN=${INPUT_QTMESH_TOKEN}") + fi + if [ -n "${INPUT_QTMESH_API_BASE:-}" ]; then + docker_env+=(--env "QTMESH_API_BASE=${INPUT_QTMESH_API_BASE}") + fi OUTPUT=$(docker run --rm \ --user "$(id -u):$(id -g)" \ -v "${{ github.workspace }}:/workspace" \ + "${docker_env[@]}" \ "ghcr.io/fernandotonon/qtmesh:${{ inputs.image-tag }}" \ "${cmd[@]}") diff --git a/CMakeLists.txt b/CMakeLists.txt index 8072d9f29..e05cf80cf 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 2.25.1 LANGUAGES C CXX) +project(QtMeshEditor VERSION 2.26.0 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/action.yml b/action.yml index f1b79a7c1..60dd37147 100644 --- a/action.yml +++ b/action.yml @@ -38,6 +38,20 @@ inputs: badge-base-url: description: 'Public base URL hosting badge JSON files (used to expose ready-to-use badge URLs in outputs).' required: false + qtmesh-token: + description: 'Optional. Sets QTMESH_TOKEN in the container for QtMesh Cloud (same effect as CLI --token). Combine with qtmesh-no-upload / qtmesh-strict-upload or pass extra flags via options.' + required: false + qtmesh-api-base: + description: 'Optional. Sets QTMESH_API_BASE (override API host, e.g. for self-hosted).' + required: false + qtmesh-no-upload: + description: 'When true and command=scan, append --no-upload (skip cloud POST even if QTMESH_TOKEN is set).' + required: false + default: 'false' + qtmesh-strict-upload: + description: 'When true and command=scan, append --strict-upload (fail the job if cloud upload fails).' + required: false + default: 'false' outputs: result: @@ -85,6 +99,10 @@ runs: INPUT_BADGE_OUTPUT_DIR: ${{ inputs.badge-output-dir }} INPUT_BADGE_LABEL_PREFIX: ${{ inputs.badge-label-prefix }} INPUT_BADGE_BASE_URL: ${{ inputs.badge-base-url }} + INPUT_QTMESH_TOKEN: ${{ inputs.qtmesh-token }} + INPUT_QTMESH_API_BASE: ${{ inputs.qtmesh-api-base }} + INPUT_QTMESH_NO_UPLOAD: ${{ inputs.qtmesh-no-upload }} + INPUT_QTMESH_STRICT_UPLOAD: ${{ inputs.qtmesh-strict-upload }} run: | set -o pipefail @@ -122,15 +140,32 @@ runs: read -r -a opts <<< "$INPUT_OPTIONS" cmd+=("${opts[@]}") fi + if [ "$INPUT_COMMAND" = "scan" ]; then + if [ "${INPUT_QTMESH_NO_UPLOAD:-false}" = "true" ]; then + cmd+=("--no-upload") + fi + if [ "${INPUT_QTMESH_STRICT_UPLOAD:-false}" = "true" ]; then + cmd+=("--strict-upload") + fi + fi tmp_stdout=$(mktemp) tmp_stderr=$(mktemp) trap 'rm -f "$tmp_stdout" "$tmp_stderr"' EXIT + docker_env=() + if [ -n "${INPUT_QTMESH_TOKEN:-}" ]; then + docker_env+=(--env "QTMESH_TOKEN=${INPUT_QTMESH_TOKEN}") + fi + if [ -n "${INPUT_QTMESH_API_BASE:-}" ]; then + docker_env+=(--env "QTMESH_API_BASE=${INPUT_QTMESH_API_BASE}") + fi + set +e docker run --rm \ --user "$(id -u):$(id -g)" \ -v "${{ github.workspace }}:/workspace" \ + "${docker_env[@]}" \ "ghcr.io/fernandotonon/qtmesh:${{ inputs.image-tag }}" \ "${cmd[@]}" >"$tmp_stdout" 2>"$tmp_stderr" EXIT_CODE=$? diff --git a/qtmesh.example.yml b/qtmesh.example.yml index 9b7e7d768..0f0468bd0 100644 --- a/qtmesh.example.yml +++ b/qtmesh.example.yml @@ -5,6 +5,10 @@ # The scan command picks up qtmesh.yml (or qtmesh.yaml / qtmesh.json) # automatically from the current directory. Override with --config . # +# QtMesh Cloud: with QTMESH_TOKEN, QTMESH_CLOUD_TOKEN, or --token , and no local +# qtmesh.yml|yaml|json, `qtmesh scan` loads remote rules from the API and uploads the scan +# JSON after each run unless --no-upload is set (see CLI --help). +# # Run: # qtmesh scan ./assets # qtmesh scan ./assets --json diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 1af1491c2..4a3c677eb 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -8,6 +8,7 @@ #include "SentryReporter.h" #include "ScanConfig.h" #include "ScanEngine.h" +#include "QtMeshCloudClient.h" #include #include #include @@ -156,6 +157,10 @@ static QString formatScanSummary(const ScanResult& result, bool colorize) if (result.skipped > 0) s << " " << skippedIcon << " Skipped: " << result.skipped << "\n"; s << " " << timeIcon << " Time: " << QString::number(result.elapsedMs / 1000.0, 'f', 1) << "s\n"; + QString utcStart, utcEnd; + ScanEngine::scanReportUtcTimes(result, &utcStart, &utcEnd); + s << " UTC start: " << utcStart << "\n"; + s << " UTC end: " << utcEnd << "\n"; return out; } @@ -165,6 +170,21 @@ static QTextStream& err() return s; } +/// Ingest token: `--token` overrides `QTMESH_TOKEN`, then `QTMESH_CLOUD_TOKEN`. +static QString resolveIngestToken(const QString& flagToken) +{ + const QString trimmed = flagToken.trimmed(); + if (!trimmed.isEmpty()) + return trimmed; + const QByteArray a = qgetenv("QTMESH_TOKEN"); + if (!a.isEmpty()) + return QString::fromUtf8(a); + const QByteArray b = qgetenv("QTMESH_CLOUD_TOKEN"); + if (!b.isEmpty()) + return QString::fromUtf8(b); + return {}; +} + static bool s_verbose = false; static bool s_noTelemetry = false; @@ -262,6 +282,14 @@ void CLIPipeline::printUsage() " --require-animation-names Required animation names/patterns CSV\n" " --require-bone-names Required bone names/patterns CSV\n" " --fail-on Exit 1 threshold: info, warning, error, never\n" + " --token Ingest token (overrides QTMESH_TOKEN / QTMESH_CLOUD_TOKEN)\n" + " --no-upload Skip POSTing scan JSON to QtMesh Cloud when a token is set\n" + " --strict-upload Exit 1 if cloud upload fails (default: warn only)\n" + "\n" + " Cloud rules: if no --config and no local qtmesh.yml|yaml|json, QTMESH_TOKEN loads\n" + " remote rules from the API; otherwise built-in defaults apply if the API is unreachable.\n" + " --config or a local file skips fetching remote rules; scan JSON still uploads when a\n" + " token is set (unless --no-upload). Override API base with QTMESH_API_BASE.\n" "\n" "Fix flags:\n" " --remove-degenerates Remove degenerate triangles\n" @@ -1750,6 +1778,9 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) // Parse: scan [path] [options] QString scanRoot; QString configPath; + QString tokenArg; + bool strictUpload = false; + bool noUpload = false; bool jsonOutput = false; QString reportPath; QString sarifPath; @@ -1844,6 +1875,8 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) if (arg == "--json") { jsonOutput = true; continue; } if (arg == "--fix") { fix = true; continue; } if (arg == "--dry-run") { dryRun = true; continue; } + if (arg == "--strict-upload") { strictUpload = true; continue; } + if (arg == "--no-upload") { noUpload = true; continue; } QString value; ParseValueResult parseResult = parseValueArg(arg, "--config", i, value); if (parseResult == ParseValueResult::Error) return 2; @@ -1863,6 +1896,9 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) parseResult = parseValueArg(arg, "--fail-on", i, value); if (parseResult == ParseValueResult::Error) return 2; if (parseResult == ParseValueResult::Matched) { failOn = value; continue; } + parseResult = parseValueArg(arg, "--token", i, value); + if (parseResult == ParseValueResult::Error) return 2; + if (parseResult == ParseValueResult::Matched) { tokenArg = value; continue; } parseResult = parseValueArg(arg, "--allowed-formats", i, value); if (parseResult == ParseValueResult::Error) return 2; if (parseResult == ParseValueResult::Matched) { @@ -1986,7 +2022,11 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) if (!arg.startsWith("-") && scanRoot.isEmpty()) { scanRoot = arg; continue; } } - // Load config: explicit file → auto-detect → defaults + // Load config (precedence): + // 1) --config path (never fetch remote rules) + // 2) Else local qtmesh.yml | yaml | json in cwd (never fetch remote rules) + // 3) Else if ingest token set → GET /v1/ingest/rules, or defaults if API fails + // 4) Else built-in defaults ScanConfig config; if (!configPath.isEmpty()) { if (!QFileInfo::exists(configPath)) { @@ -1994,14 +2034,47 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) return 2; } config = ScanConfig::loadFromFile(configPath); - } else if (QFileInfo::exists("qtmesh.yml")) { - config = ScanConfig::loadFromFile("qtmesh.yml"); - } else if (QFileInfo::exists("qtmesh.yaml")) { - config = ScanConfig::loadFromFile("qtmesh.yaml"); - } else if (QFileInfo::exists("qtmesh.json")) { - config = ScanConfig::loadFromFile("qtmesh.json"); + if (!resolveIngestToken(tokenArg).isEmpty()) { + err() << "Note: Using --config file; remote cloud rules were not fetched." + << " Scan JSON is still uploaded when an ingest token is set (unless --no-upload)." + << Qt::endl; + } } else { - config = ScanConfig::defaults(); + QString localAutoPath; + if (QFileInfo::exists(QStringLiteral("qtmesh.yml"))) + localAutoPath = QStringLiteral("qtmesh.yml"); + else if (QFileInfo::exists(QStringLiteral("qtmesh.yaml"))) + localAutoPath = QStringLiteral("qtmesh.yaml"); + else if (QFileInfo::exists(QStringLiteral("qtmesh.json"))) + localAutoPath = QStringLiteral("qtmesh.json"); + + if (!localAutoPath.isEmpty()) { + config = ScanConfig::loadFromFile(localAutoPath); + err() << "Note: Using local " << localAutoPath + << " — QtMesh Cloud remote rules are not used for validation." << Qt::endl; + } else { + const QString ingestForRules = resolveIngestToken(tokenArg); + if (!ingestForRules.isEmpty()) { + SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"), + QStringLiteral("QtMesh Cloud fetchRules: requested")); + const auto rules = QtMeshCloudClient::fetchRules(ingestForRules); + if (rules.ok) { + config = ScanConfig::fromJson(rules.config); + err() << "Note: Using QtMesh Cloud rules (source: " << rules.source << ")." << Qt::endl; + SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"), + QStringLiteral("QtMesh Cloud fetchRules: ok source=%1").arg(rules.source)); + } else { + err() << "Warning: Could not load QtMesh Cloud rules (" << rules.errorString + << "). Using built-in defaults." << Qt::endl; + config = ScanConfig::defaults(); + SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"), + QStringLiteral("QtMesh Cloud fetchRules: failed %1").arg(rules.errorString), + QStringLiteral("warning")); + } + } else { + config = ScanConfig::defaults(); + } + } } // CLI overrides @@ -2143,9 +2216,18 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) }) : ScanEngine::AssetProcessedCallback()); + if (jsonOutput) { + for (const auto& f : result.findings) { + if (f.rule == QLatin1String("load_error")) + err() << "Load error (" << f.file << "): " << f.message << Qt::endl; + } + } + + const QJsonObject reportJson = ScanEngine::scanReportToJsonObject(result); + // Output to terminal if (jsonOutput) { - cliWrite(ScanEngine::formatJson(result) + "\n"); + cliWrite(QString::fromUtf8(QJsonDocument(reportJson).toJson(QJsonDocument::Indented)) + "\n"); } else { cliWrite(formatScanSummary(result, colorizeTextOutput)); } @@ -2155,7 +2237,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) QFile f(reportPath); QDir().mkpath(QFileInfo(reportPath).path()); if (f.open(QIODevice::WriteOnly | QIODevice::Text)) - f.write(ScanEngine::formatJson(result).toUtf8()); + f.write(QJsonDocument(reportJson).toJson(QJsonDocument::Indented)); else err() << "Warning: Could not write report to " << reportPath << Qt::endl; } @@ -2177,7 +2259,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) if (config.reportFormat == "text") f.write(ScanEngine::formatText(result, config, false).toUtf8()); else - f.write(ScanEngine::formatJson(result).toUtf8()); + f.write(QJsonDocument(reportJson).toJson(QJsonDocument::Indented)); } } if (sarifPath.isEmpty() && !config.sarifOutput.isEmpty()) { @@ -2187,11 +2269,44 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) f.write(ScanEngine::formatSarif(result).toUtf8()); } - // Exit code based on fail_on threshold - if (config.failOn == "never") return 0; - if (config.failOn == "error" && result.errors > 0) return 1; - if (config.failOn == "warning" && (result.errors > 0 || result.warnings > 0)) return 1; - if (config.failOn == "info" && (result.errors > 0 || result.warnings > 0 || result.infos > 0)) return 1; + bool uploadOk = true; + const QString ingestToken = resolveIngestToken(tokenArg); + if (!ingestToken.isEmpty() && !noUpload) { + SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"), + QStringLiteral("QtMesh Cloud uploadScan: posting")); + const auto up = QtMeshCloudClient::uploadScanReport(ingestToken, reportJson); + uploadOk = up.ok; + if (up.ok) { + SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"), + QStringLiteral("QtMesh Cloud uploadScan: ok HTTP %1").arg(up.httpStatus)); + } else { + SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"), + QStringLiteral("QtMesh Cloud uploadScan: failed HTTP %1").arg(up.httpStatus), + QStringLiteral("warning")); + const QString prefix = strictUpload ? QStringLiteral("Error: ") + : QStringLiteral("Warning: "); + err() << prefix << "QtMesh Cloud scan upload failed (HTTP " << up.httpStatus << "): " + << up.errorString; + if (!up.responseBodySnippet.isEmpty() + && !up.errorString.contains(up.responseBodySnippet)) { + err() << " — " << up.responseBodySnippet; + } + err() << Qt::endl; + } + } - return 0; + // Exit code from fail_on threshold (scan/lint outcome) + int scanExit = 0; + if (config.failOn != "never") { + if (config.failOn == "error" && result.errors > 0) + scanExit = 1; + else if (config.failOn == "warning" && (result.errors > 0 || result.warnings > 0)) + scanExit = 1; + else if (config.failOn == "info" + && (result.errors > 0 || result.warnings > 0 || result.infos > 0)) + scanExit = 1; + } + if (strictUpload && !uploadOk) + return 1; + return scanExit; } diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index cb8f2a1e7..cf1b03a27 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -646,6 +647,30 @@ class ScopedCurrentDir { QString m_old; }; +class ScopedEnvVar { +public: + ScopedEnvVar(const char* name, const QByteArray& value) + : m_name(name) + , m_had(qEnvironmentVariableIsSet(name)) + , m_old(qgetenv(name)) + { + qputenv(name, value); + } + + ~ScopedEnvVar() + { + if (m_had) + qputenv(m_name, m_old); + else + qunsetenv(m_name); + } + +private: + const char* m_name = nullptr; + bool m_had = false; + QByteArray m_old; +}; + } // anonymous namespace // --- initOgreHeadless tests --- @@ -1959,12 +1984,16 @@ TEST(CLIPipelineCmdScan, WritesJsonAndSarifReports) const QString reportContent = QString::fromUtf8(reportFile.readAll()); EXPECT_TRUE(reportContent.contains("\"summary\"")); EXPECT_TRUE(reportContent.contains("\"assets\"")); + EXPECT_TRUE(reportContent.contains("\"scanStartedUtc\"")); + EXPECT_TRUE(reportContent.contains("\"scanCompletedUtc\"")); QFile sarifFile(sarifPath); ASSERT_TRUE(sarifFile.open(QIODevice::ReadOnly | QIODevice::Text)); const QString sarifContent = QString::fromUtf8(sarifFile.readAll()); EXPECT_TRUE(sarifContent.contains("\"runs\"")); EXPECT_TRUE(sarifContent.contains("qtmesh scan")); + EXPECT_TRUE(sarifContent.contains("\"startTimeUtc\"")); + EXPECT_TRUE(sarifContent.contains("\"endTimeUtc\"")); } TEST(CLIPipelineCmdScan, ReportAndSarifAreWrittenWithFailOnNever) @@ -2285,3 +2314,53 @@ TEST(CLIPipelineCmdScan, EmptyCliOverridesCanClearScopedRules) "--require-animation-names=", "--require-bone-names="}); EXPECT_EQ(CLIPipeline::cmdScan(clearArgs.argc(), clearArgs.argv()), 0); } + +TEST(CLIPipelineCmdScanCloud, StrictUploadFailsWhenApiUnreachable) +{ + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + ScopedCurrentDir scoped(tmpDir.path()); + ScopedEnvVar api("QTMESH_API_BASE", "http://127.0.0.1:1"); + ScopedEnvVar tok("QTMESH_TOKEN", "test-token"); + TestArgv args({"qtmesh", "scan", "--strict-upload"}); + EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdScanCloud, UploadFailureDoesNotChangeExitCodeWithoutStrict) +{ + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + ScopedCurrentDir scoped(tmpDir.path()); + ScopedEnvVar api("QTMESH_API_BASE", "http://127.0.0.1:1"); + ScopedEnvVar tok("QTMESH_TOKEN", "test-token"); + TestArgv args({"qtmesh", "scan"}); + EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 0); +} + +TEST(CLIPipelineCmdScanCloud, LocalYmlOverridesRemoteTokenForRules) +{ + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + const QString rootPath = QDir(tmpDir.path()).filePath("assets"); + ASSERT_TRUE(QDir().mkpath(rootPath)); + ASSERT_FALSE(writeMinimalObj(rootPath, "scan_mesh.obj").isEmpty()); + + const QString ymlPath = QDir(tmpDir.path()).filePath("qtmesh.yml"); + QFile yml(ymlPath); + ASSERT_TRUE(yml.open(QIODevice::WriteOnly | QIODevice::Text)); + yml.write( + "scan:\n" + " include:\n" + " - \"**/*.obj\"\n" + "rules:\n" + " max_vertex_count: 2\n"); + yml.close(); + + ScopedCurrentDir scoped(tmpDir.path()); + ScopedEnvVar api("QTMESH_API_BASE", "http://127.0.0.1:1"); + ScopedEnvVar tok("QTMESH_TOKEN", "test-token"); + + QByteArray rootBa = rootPath.toUtf8(); + TestArgv args({"qtmesh", "scan", rootBa.constData()}); + EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 1); +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fcc7849c2..fd80a7288 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -64,6 +64,7 @@ WelcomeScreenController.cpp WelcomeDialog.cpp ScanConfig.cpp ScanEngine.cpp +QtMeshCloudClient.cpp AssetBrowserController.cpp MaterialPreviewRenderer.cpp EditableMesh.cpp @@ -135,6 +136,7 @@ WelcomeScreenController.h WelcomeDialog.h ScanConfig.h ScanEngine.h +QtMeshCloudClient.h AssetBrowserController.h MaterialPreviewRenderer.h EditableMesh.h diff --git a/src/QtMeshCloudClient.cpp b/src/QtMeshCloudClient.cpp new file mode 100644 index 000000000..3f614deeb --- /dev/null +++ b/src/QtMeshCloudClient.cpp @@ -0,0 +1,242 @@ +#include "QtMeshCloudClient.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QString trimSnippet(const QByteArray& body, int maxLen = 512) +{ + QString s = QString::fromUtf8(body); + s.replace(QLatin1Char('\n'), QLatin1Char(' ')); + s.replace(QLatin1Char('\r'), QLatin1Char(' ')); + if (s.size() > maxLen) + s = s.left(maxLen) + QStringLiteral("…"); + return s; +} + +bool httpStatusRetryable(int code) +{ + return code == 408 || code == 429 || code == 500 || code == 502 || code == 503 || code == 504; +} + +} // namespace + +QString QtMeshCloudClient::apiBaseUrl() +{ + const QByteArray env = qgetenv("QTMESH_API_BASE"); + if (!env.isEmpty()) { + QString u = QString::fromUtf8(env).trimmed(); + while (u.endsWith(QLatin1Char('/'))) + u.chop(1); + return u; + } + return QStringLiteral("https://api.qtmesh.dev"); +} + +bool QtMeshCloudClient::validateCloudConfigJson(const QJsonObject& root) +{ + const QJsonValue ver = root.value(QStringLiteral("version")); + if (ver.isUndefined() || ver.isNull()) + return false; + // JSON numbers only (reject strings and bools — server must send numeric version) + if (ver.isString() || ver.isBool() || !ver.isDouble()) + return false; + + const QJsonValue scan = root.value(QStringLiteral("scan")); + if (!scan.isObject()) + return false; + + const QJsonValue rules = root.value(QStringLiteral("rules")); + if (!rules.isObject()) + return false; + + return true; +} + +QtMeshCloudClient::RulesResult QtMeshCloudClient::fetchRules(const QString& bearerToken, int timeoutMs) +{ + RulesResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + + const QUrl url(apiBaseUrl() + QStringLiteral("/v1/ingest/rules")); + if (!url.isValid()) { + out.errorString = QStringLiteral("invalid API base URL"); + return out; + } + + QNetworkAccessManager nam; + + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("QtMesh Cloud fetchRules: start %1").arg(url.toString())); + + for (int attempt = 0; attempt < 3; ++attempt) { + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qtmesh-cli")); + req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + bearerToken.toUtf8()); + req.setTransferTimeout(timeoutMs); + + QNetworkReply* reply = nam.get(req); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray body = reply->readAll(); + const auto err = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + if (err == QNetworkReply::NoError && httpStatus == 200) { + QJsonParseError perr{}; + const QJsonDocument doc = QJsonDocument::fromJson(body, &perr); + if (perr.error != QJsonParseError::NoError || !doc.isObject()) { + out.errorString = QStringLiteral("invalid JSON: %1").arg(perr.errorString()); + return out; + } + const QJsonObject obj = doc.object(); + const QJsonValue cfgVal = obj.value(QStringLiteral("config")); + if (!cfgVal.isObject()) { + out.errorString = QStringLiteral("response missing \"config\" object"); + return out; + } + out.config = cfgVal.toObject(); + out.source = obj.value(QStringLiteral("source")).toString(); + if (out.source.isEmpty()) + out.source = QStringLiteral("default"); + if (!validateCloudConfigJson(out.config)) { + out.errorString = QStringLiteral("remote config failed validation (need version, scan, rules)"); + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("QtMesh Cloud fetchRules: validation failed")); + out.config = {}; + return out; + } + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("QtMesh Cloud fetchRules: ok source=%1").arg(out.source)); + out.ok = true; + return out; + } + + QString errMsg; + if (err != QNetworkReply::NoError) + errMsg = transportErr; + else + errMsg = QStringLiteral("HTTP %1").arg(httpStatus); + + const bool retry = attempt < 2 + && (err == QNetworkReply::TimeoutError + || err == QNetworkReply::TemporaryNetworkFailureError + || err == QNetworkReply::NetworkSessionFailedError + || err == QNetworkReply::ConnectionRefusedError + || httpStatusRetryable(httpStatus)); + + if (retry) { + QThread::msleep(150 * (attempt + 1)); + continue; + } + + out.errorString = errMsg; + if (!body.isEmpty()) + out.errorString += QStringLiteral(": ") + trimSnippet(body); + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("QtMesh Cloud fetchRules: failure %1").arg(out.errorString), + QStringLiteral("warning")); + return out; + } + + out.errorString = QStringLiteral("exhausted retries"); + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("QtMesh Cloud fetchRules: exhausted retries"), QStringLiteral("warning")); + return out; +} + +QtMeshCloudClient::UploadResult QtMeshCloudClient::uploadScanReport(const QString& bearerToken, + const QJsonObject& reportJson, + int timeoutMs) +{ + UploadResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + + const QUrl url(apiBaseUrl() + QStringLiteral("/v1/ingest/scan")); + if (!url.isValid()) { + out.errorString = QStringLiteral("invalid API base URL"); + return out; + } + + QNetworkAccessManager nam; + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qtmesh-cli")); + req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + bearerToken.toUtf8()); + req.setTransferTimeout(timeoutMs); + + const QByteArray payload = QJsonDocument(reportJson).toJson(QJsonDocument::Compact); + + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("QtMesh Cloud uploadScan: start %1").arg(url.toString())); + + for (int attempt = 0; attempt < 3; ++attempt) { + QNetworkReply* reply = nam.post(req, payload); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray body = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + if (nerr == QNetworkReply::NoError && out.httpStatus >= 200 && out.httpStatus < 300) { + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("QtMesh Cloud uploadScan: ok HTTP %1").arg(out.httpStatus)); + out.ok = true; + return out; + } + + QString errMsg; + if (nerr != QNetworkReply::NoError) + errMsg = transportErr; + else + errMsg = QStringLiteral("HTTP %1").arg(out.httpStatus); + + out.responseBodySnippet = trimSnippet(body); + + const bool retry = attempt < 2 + && (nerr == QNetworkReply::TimeoutError + || nerr == QNetworkReply::TemporaryNetworkFailureError + || httpStatusRetryable(out.httpStatus)); + + if (retry) { + QThread::msleep(200 * (attempt + 1)); + continue; + } + + out.errorString = errMsg; + if (!out.responseBodySnippet.isEmpty()) + out.errorString += QStringLiteral(" — ") + out.responseBodySnippet; + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("QtMesh Cloud uploadScan: failure HTTP %1 %2") + .arg(out.httpStatus) + .arg(out.responseBodySnippet.isEmpty() ? errMsg : out.responseBodySnippet), + QStringLiteral("warning")); + return out; + } + + out.errorString = QStringLiteral("exhausted retries"); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("QtMesh Cloud uploadScan: exhausted retries"), QStringLiteral("warning")); + return out; +} diff --git a/src/QtMeshCloudClient.h b/src/QtMeshCloudClient.h new file mode 100644 index 000000000..4f167f495 --- /dev/null +++ b/src/QtMeshCloudClient.h @@ -0,0 +1,40 @@ +#ifndef QTMESH_CLOUD_CLIENT_H +#define QTMESH_CLOUD_CLIENT_H + +#include +#include + +/// HTTP client for QtMesh Cloud ingest API (remote rules + scan upload). +class QtMeshCloudClient { +public: + QtMeshCloudClient() = delete; + + /// Base URL without trailing slash. Override with env `QTMESH_API_BASE` (e.g. for tests). + static QString apiBaseUrl(); + + /// Minimal validation: JSON numeric `version` (not a string), object `scan`, object `rules`. + static bool validateCloudConfigJson(const QJsonObject& root); + + struct RulesResult { + bool ok = false; + QString errorString; + QJsonObject config; + QString source; + }; + + /// GET /v1/ingest/rules — retries transient failures; returns parsed `config` object. + static RulesResult fetchRules(const QString& bearerToken, int timeoutMs = 20000); + + struct UploadResult { + bool ok = false; + int httpStatus = 0; + QString errorString; + QString responseBodySnippet; + }; + + /// POST /v1/ingest/scan with compact JSON body (same schema as `qtmesh scan --json`). + static UploadResult uploadScanReport(const QString& bearerToken, const QJsonObject& reportJson, + int timeoutMs = 120000); +}; + +#endif diff --git a/src/QtMeshCloudClient_test.cpp b/src/QtMeshCloudClient_test.cpp new file mode 100644 index 000000000..a0e2584e0 --- /dev/null +++ b/src/QtMeshCloudClient_test.cpp @@ -0,0 +1,46 @@ +#include +#include +#include "QtMeshCloudClient.h" + +TEST(QtMeshCloudClientValidate, AcceptsMinimalValid) +{ + QJsonObject o; + o["version"] = 1; + o["scan"] = QJsonObject{}; + o["rules"] = QJsonObject{}; + EXPECT_TRUE(QtMeshCloudClient::validateCloudConfigJson(o)); +} + +TEST(QtMeshCloudClientValidate, RejectsMissingRules) +{ + QJsonObject o; + o["version"] = 1; + o["scan"] = QJsonObject{}; + EXPECT_FALSE(QtMeshCloudClient::validateCloudConfigJson(o)); +} + +TEST(QtMeshCloudClientValidate, RejectsMissingVersion) +{ + QJsonObject o; + o["scan"] = QJsonObject{}; + o["rules"] = QJsonObject{}; + EXPECT_FALSE(QtMeshCloudClient::validateCloudConfigJson(o)); +} + +TEST(QtMeshCloudClientValidate, RejectsStringVersion) +{ + QJsonObject o; + o["version"] = QStringLiteral("foo"); + o["scan"] = QJsonObject{}; + o["rules"] = QJsonObject{}; + EXPECT_FALSE(QtMeshCloudClient::validateCloudConfigJson(o)); +} + +TEST(QtMeshCloudClientValidate, RejectsBoolVersion) +{ + QJsonObject o; + o["version"] = true; + o["scan"] = QJsonObject{}; + o["rules"] = QJsonObject{}; + EXPECT_FALSE(QtMeshCloudClient::validateCloudConfigJson(o)); +} diff --git a/src/ScanEngine.cpp b/src/ScanEngine.cpp index 1be061f3d..e839040ba 100644 --- a/src/ScanEngine.cpp +++ b/src/ScanEngine.cpp @@ -1,5 +1,6 @@ #include "ScanEngine.h" +#include #include #include #include @@ -506,6 +507,8 @@ ScanResult ScanEngine::run(const ScanConfig& config, const QString& rootOverride const AssetProcessedCallback& onAssetProcessed) { ScanResult result; + const QString utcFmt = QStringLiteral("yyyy-MM-dd'T'HH:mm:ss.zzz'Z'"); + result.scanStartedUtc = QDateTime::currentDateTimeUtc().toString(utcFmt); QElapsedTimer timer; timer.start(); @@ -562,6 +565,7 @@ ScanResult ScanEngine::run(const ScanConfig& config, const QString& rootOverride } result.elapsedMs = timer.elapsed(); + result.scanCompletedUtc = QDateTime::currentDateTimeUtc().toString(utcFmt); return result; } @@ -637,6 +641,10 @@ QString ScanEngine::formatText(const ScanResult& result, const ScanConfig& confi if (result.skipped > 0) s << " ⏭ Skipped: " << result.skipped << "\n"; s << " ⏱ Time: " << QString::number(result.elapsedMs / 1000.0, 'f', 1) << "s\n"; + QString utcStart, utcEnd; + scanReportUtcTimes(result, &utcStart, &utcEnd); + s << " UTC start: " << utcStart << "\n"; + s << " UTC end: " << utcEnd << "\n"; return out; } @@ -655,10 +663,36 @@ static QString severityStr(Severity s) return "info"; } -QString ScanEngine::formatJson(const ScanResult& result) +/// Exported JSON (and cloud upload) must not embed Assimp paths or other local details. +static QString findingMessageForExport(const Finding& f) +{ + if (f.rule == QLatin1String("load_error")) + return QStringLiteral("Failed to load asset (details redacted from exported JSON)"); + return f.message; +} + +void ScanEngine::scanReportUtcTimes(const ScanResult& result, QString* scanStartedUtc, + QString* scanCompletedUtc) +{ + const QString fmt = QStringLiteral("yyyy-MM-dd'T'HH:mm:ss.zzz'Z'"); + QString end = result.scanCompletedUtc; + QString start = result.scanStartedUtc; + if (end.isEmpty()) + end = QDateTime::currentDateTimeUtc().toString(fmt); + if (start.isEmpty()) + start = end; + *scanStartedUtc = start; + *scanCompletedUtc = end; +} + +QJsonObject ScanEngine::scanReportToJsonObject(const ScanResult& result) { QJsonObject root; root["version"] = QTMESHEDITOR_VERSION; + QString utcStart, utcEnd; + scanReportUtcTimes(result, &utcStart, &utcEnd); + root["scanStartedUtc"] = utcStart; + root["scanCompletedUtc"] = utcEnd; // Summary QJsonObject summary; @@ -707,10 +741,8 @@ QString ScanEngine::formatJson(const ScanResult& result) ao["bones"] = bones; } - if (asset.loadError) { + if (asset.loadError) ao["loadError"] = true; - ao["errorMessage"] = asset.errorMessage; - } // Inline findings for this asset QJsonArray findingsArr; @@ -719,7 +751,7 @@ QString ScanEngine::formatJson(const ScanResult& result) QJsonObject fo; fo["rule"] = f.rule; fo["severity"] = severityStr(f.severity); - fo["message"] = f.message; + fo["message"] = findingMessageForExport(f); if (f.fixable) fo["fixable"] = true; if (f.fixed) fo["fixed"] = true; findingsArr.append(fo); @@ -730,7 +762,12 @@ QString ScanEngine::formatJson(const ScanResult& result) } root["assets"] = assetsArr; - return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)); + return root; +} + +QString ScanEngine::formatJson(const ScanResult& result) +{ + return QString::fromUtf8(QJsonDocument(scanReportToJsonObject(result)).toJson(QJsonDocument::Indented)); } // --------------------------------------------------------------------------- @@ -830,6 +867,13 @@ QString ScanEngine::formatSarif(const ScanResult& result) QJsonObject run; run["tool"] = tool; run["results"] = resultsArr; + QString sarifStart, sarifEnd; + scanReportUtcTimes(result, &sarifStart, &sarifEnd); + QJsonObject invocation; + invocation["startTimeUtc"] = sarifStart; + invocation["endTimeUtc"] = sarifEnd; + invocation["executionSuccessful"] = true; + run["invocations"] = QJsonArray{invocation}; QJsonObject sarif; sarif["$schema"] = "https://json.schemastore.org/sarif-2.1.0.json"; diff --git a/src/ScanEngine.h b/src/ScanEngine.h index 566b129f6..9ec11440a 100644 --- a/src/ScanEngine.h +++ b/src/ScanEngine.h @@ -2,6 +2,7 @@ #define SCANENGINE_H #include "ScanConfig.h" +#include #include #include #include @@ -59,6 +60,10 @@ struct ScanResult { int fixed = 0; int skipped = 0; double elapsedMs = 0; + + /// Wall-clock bounds for reports, always UTC (`yyyy-MM-dd'T'HH:mm:ss.zzzZ`). Set by `ScanEngine::run`. + QString scanStartedUtc; + QString scanCompletedUtc; }; class ScanEngine { @@ -89,6 +94,11 @@ class ScanEngine { // --- Formatters --- static QString formatText(const ScanResult& result, const ScanConfig& config, bool colorize = false); + /// Canonical JSON object for `--json`, report files, and QtMesh Cloud upload (identical schema). + static QJsonObject scanReportToJsonObject(const ScanResult& result); + + /// UTC ISO-8601 timestamps for reports (`scanStartedUtc` / `scanCompletedUtc`); missing values use `scanCompletedUtc` or current UTC. + static void scanReportUtcTimes(const ScanResult& result, QString* scanStartedUtc, QString* scanCompletedUtc); static QString formatJson(const ScanResult& result); static QString formatSarif(const ScanResult& result); diff --git a/src/ScanEngine_test.cpp b/src/ScanEngine_test.cpp index 50233c204..53a6060c2 100644 --- a/src/ScanEngine_test.cpp +++ b/src/ScanEngine_test.cpp @@ -3,8 +3,10 @@ #include "ScanEngine.h" #include +#include #include #include +#include #include namespace { @@ -590,10 +592,17 @@ TEST(ScanEngineTest, FormatJson_Structure) f.message = "200000 vertices exceeds limit of 100000"; result.findings.append(f); + result.scanStartedUtc = QStringLiteral("2024-06-15T12:00:00.000Z"); + result.scanCompletedUtc = QStringLiteral("2024-06-15T12:00:01.000Z"); + QString json = ScanEngine::formatJson(result); EXPECT_TRUE(json.contains("\"scanned\": 2")); EXPECT_TRUE(json.contains("\"max_vertex_count\"")); EXPECT_TRUE(json.contains("bad.fbx")); + + const QJsonObject reportObj = ScanEngine::scanReportToJsonObject(result); + const QString fromObj = QString::fromUtf8(QJsonDocument(reportObj).toJson(QJsonDocument::Indented)); + EXPECT_EQ(fromObj, json); } TEST(ScanEngineTest, FormatJson_IncludesAnimationsBonesAndLoadError) @@ -624,11 +633,33 @@ TEST(ScanEngineTest, FormatJson_IncludesAnimationsBonesAndLoadError) EXPECT_TRUE(json.contains("\"animations\"")); EXPECT_TRUE(json.contains("\"bones\"")); EXPECT_TRUE(json.contains("\"loadError\": true")); - EXPECT_TRUE(json.contains("\"errorMessage\": \"mock error\"")); + EXPECT_FALSE(json.contains("\"errorMessage\"")); EXPECT_TRUE(json.contains("\"fixable\": true")); EXPECT_TRUE(json.contains("\"fixed\": true")); } +TEST(ScanEngineTest, FormatJson_RedactsLoadErrorFindingMessage) +{ + ScanResult result; + AssetInfo asset; + asset.relativePath = "bad.fbx"; + asset.format = "fbx"; + asset.loadError = true; + asset.errorMessage = "/secret/path/model.fbx: cannot open"; + result.assets.append(asset); + + Finding loadErr; + loadErr.file = "bad.fbx"; + loadErr.rule = "load_error"; + loadErr.severity = Severity::Error; + loadErr.message = QStringLiteral("Failed to load: /secret/path/model.fbx: cannot open"); + result.findings.append(loadErr); + + const QString json = ScanEngine::formatJson(result); + EXPECT_FALSE(json.contains("secret")); + EXPECT_TRUE(json.contains("Failed to load asset (details redacted from exported JSON)")); +} + TEST(ScanEngineTest, FormatText_ContainsSummary) { ScanResult result; @@ -701,6 +732,8 @@ TEST(ScanEngineTest, FormatText_SummaryUsesIcons) EXPECT_TRUE(text.contains("🔧 Fixed:")); EXPECT_TRUE(text.contains("⏭ Skipped:")); EXPECT_TRUE(text.contains("⏱ Time:")); + EXPECT_TRUE(text.contains("UTC start:")); + EXPECT_TRUE(text.contains("UTC end:")); } TEST(ScanEngineTest, FormatText_ColorizesStatusWhenEnabled) @@ -745,6 +778,8 @@ TEST(ScanEngineTest, FormatSarif_ValidStructure) EXPECT_TRUE(sarif.contains("\"version\": \"2.1.0\"")); EXPECT_TRUE(sarif.contains("qtmesh scan")); EXPECT_TRUE(sarif.contains("max_vertex_count")); + EXPECT_TRUE(sarif.contains("\"startTimeUtc\"")); + EXPECT_TRUE(sarif.contains("\"endTimeUtc\"")); } TEST(ScanEngineTest, FormatSarif_FixableProperties) @@ -1283,6 +1318,15 @@ TEST(ScanEngineTest, Run_AggregatesPassedAndSkippedCounts) EXPECT_EQ(result.skipped, 1); EXPECT_GE(result.errors, 1); EXPECT_EQ(result.assets.size(), 2); + EXPECT_FALSE(result.scanStartedUtc.isEmpty()); + EXPECT_FALSE(result.scanCompletedUtc.isEmpty()); + EXPECT_TRUE(result.scanStartedUtc.endsWith(QLatin1Char('Z'))); + EXPECT_TRUE(result.scanCompletedUtc.endsWith(QLatin1Char('Z'))); + const QDateTime tStart = QDateTime::fromString(result.scanStartedUtc, Qt::ISODateWithMs); + const QDateTime tEnd = QDateTime::fromString(result.scanCompletedUtc, Qt::ISODateWithMs); + EXPECT_TRUE(tStart.isValid()); + EXPECT_TRUE(tEnd.isValid()); + EXPECT_LE(tStart, tEnd); } TEST(ScanEngineTest, Run_UsesConfiguredRootsWhenNoOverrideProvided) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0b3dde2b9..246012aba 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -55,6 +55,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPSettingsDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshInfoOverlay.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/CLIPipeline.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/QtMeshCloudClient.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/RTShaderHelper.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/QMLMaterialHighlighter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ViewCube/ViewCubeController.cpp @@ -127,6 +128,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPSettingsDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshInfoOverlay.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/CLIPipeline.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/QtMeshCloudClient.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/RTShaderHelper.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ViewCube/ViewCubeController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/UndoManager.h