diff --git a/platform/statistics/src/com/intellij/internal/statistic/eventLog/validator/storage/FusComponentProvider.kt b/platform/statistics/src/com/intellij/internal/statistic/eventLog/validator/storage/FusComponentProvider.kt index dda4b0f8b003..78e6a6352263 100644 --- a/platform/statistics/src/com/intellij/internal/statistic/eventLog/validator/storage/FusComponentProvider.kt +++ b/platform/statistics/src/com/intellij/internal/statistic/eventLog/validator/storage/FusComponentProvider.kt @@ -1,4 +1,7 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// +// Modified by Nikita Iarychenko at 2026 as part of the OpenIDE project (https://openide.ru). +// Any modifications are available on the same license terms as the original source code. package com.intellij.internal.statistic.eventLog.validator.storage import com.fasterxml.jackson.annotation.JsonInclude @@ -57,6 +60,7 @@ import com.jetbrains.fus.reporting.model.serialization.SerializationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import org.jetbrains.annotations.ApiStatus +import ru.openide.statistics.OpenIdeFusHttpClient import tools.jackson.core.JsonGenerator import tools.jackson.core.StreamReadFeature import tools.jackson.core.util.DefaultIndenter @@ -232,7 +236,8 @@ object FusComponentProvider { val jsonSerializer = FusJacksonSerializer() - val httpClient = applicationInfo.connectionSettings.createJvmHttpClient() + // OpenIDE: the SDK hardcodes the JetBrains configuration URL, see OpenIdeFusHttpClient. + val httpClient = OpenIdeFusHttpClient(applicationInfo.connectionSettings.createJvmHttpClient()) val remoteConfig = DefaultRemoteConfig( config, diff --git a/platform/statistics/src/ru/openide/statistics/OpenIdeFusHttpClient.kt b/platform/statistics/src/ru/openide/statistics/OpenIdeFusHttpClient.kt new file mode 100644 index 000000000000..5e2e089a7a7b --- /dev/null +++ b/platform/statistics/src/ru/openide/statistics/OpenIdeFusHttpClient.kt @@ -0,0 +1,110 @@ +// OpenIDE Project +// Copyright (C) 2026 “Open Development Platform” Ltd. (https://openide.ru) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 or later as published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +package ru.openide.statistics + +import com.intellij.openapi.diagnostic.logger +import com.jetbrains.fus.reporting.FusHttpClient +import com.jetbrains.fus.reporting.HttpResponse +import org.jetbrains.annotations.ApiStatus + +/** + * OpenIDE customization: keeps the bundled `com.jetbrains.fus.reporting` SDK away from the JetBrains + * statistics services. + * + * The SDK composes its recorder-configuration URL itself, from the template hardcoded in + * `com.jetbrains.fus.reporting.configuration.RegionCode` + * (`https://resources.jetbrains.com/storage/fus/config/v4//.json`), so the rebranded + * [com.intellij.internal.statistic.eventLog.connection.ConfigurationClientFactory] — which only the legacy + * upload path goes through — does not apply to it. Without this wrapper the SDK path + * ([com.intellij.internal.statistic.eventLog.validator.storage.FusComponentProvider]) fetched the + * configuration, the metadata and the dictionaries from `resources.jetbrains.com` for every recorder + * (FUS, MP, ML), disclosing the product name, the build number and the client address to JetBrains on + * every start. + * + * No event ever left through that path: the configuration served by JetBrains points `send` at + * `analytics.services.jetbrains.com`, but nothing reads `RemoteConfig.getSendUrl()` — the uploader takes + * its endpoint from the already rebranded legacy path instead. The refusal branch below is therefore a + * guard against that changing, not a plug for an observed leak of collected data. + * + * `ru.openide.io.BlackListUrls` does not cover this: it is consulted only from + * `com.intellij.util.io.HttpRequests`, while the SDK talks over `java.net.http.HttpClient`. + * + * Every request the SDK makes goes through [FusHttpClient], which is why this is the one place worth + * wrapping. Hosts in [REWRITTEN_HOSTS] are replaced with [OPENIDE_STATISTICS_HOST], which mirrors the + * JetBrains URL layout for all recorders, so only the host changes and the path is kept as-is. Any other + * JetBrains host is refused rather than rewritten: the paths there do not match ours, and a request that + * reaches this branch means a code path is still trying to report upstream, which should fail loudly in + * the log instead of being silently repointed at a URL that does not exist. + */ +@ApiStatus.Internal +class OpenIdeFusHttpClient(private val delegate: FusHttpClient) : FusHttpClient { + override fun post(url: String, data: String): HttpResponse { + val target = toOpenIdeUrl(url) ?: return REFUSED + return delegate.post(target, data) + } + + override fun get(url: String): HttpResponse { + val target = toOpenIdeUrl(url) ?: return REFUSED + return delegate.get(target) + } + + override fun lastModified(url: String): Long { + val target = toOpenIdeUrl(url) ?: return 0 + return delegate.lastModified(target) + } + + /** + * Returns the URL to request, or `null` if it must not be requested at all. + */ + private fun toOpenIdeUrl(url: String): String? { + val host = hostOf(url) ?: return url + for (rewritten in REWRITTEN_HOSTS) { + if (host.equals(rewritten, ignoreCase = true)) { + return url.replaceFirst(host, OPENIDE_STATISTICS_HOST) + } + } + if (host.equals(JETBRAINS_DOMAIN, ignoreCase = true) || host.endsWith(".$JETBRAINS_DOMAIN", ignoreCase = true)) { + LOG.warn("Refused a statistics request to a JetBrains host: $url") + return null + } + return url + } + + private fun hostOf(url: String): String? { + val schemeEnd = url.indexOf("://") + if (schemeEnd < 0) return null + val hostStart = schemeEnd + "://".length + val delimiter = url.indexOfAny(HOST_DELIMITERS, hostStart) + val hostEnd = if (delimiter < 0) url.length else delimiter + return url.substring(hostStart, hostEnd).ifEmpty { null } + } + + private companion object { + private val LOG = logger() + + private const val OPENIDE_STATISTICS_HOST = "stats.openide.ru" + private const val JETBRAINS_DOMAIN = "jetbrains.com" + + private val HOST_DELIMITERS = charArrayOf('/', ':', '?', '#') + + /** Hosts whose URL layout `stats.openide.ru` mirrors, so that replacing the host is enough. */ + private val REWRITTEN_HOSTS = listOf( + "resources.jetbrains.com", + "resources.jetbrains.com.cn", + ) + + /** What a refused request looks like to the SDK: a failed response it already knows how to handle. */ + private val REFUSED = HttpResponse(statusCode = 403, body = null) + } +} diff --git a/platform/statistics/test/ru/openide/statistics/OpenIdeFusHttpClientTest.kt b/platform/statistics/test/ru/openide/statistics/OpenIdeFusHttpClientTest.kt new file mode 100644 index 000000000000..6d8106efb12b --- /dev/null +++ b/platform/statistics/test/ru/openide/statistics/OpenIdeFusHttpClientTest.kt @@ -0,0 +1,101 @@ +// OpenIDE Project +// Copyright (C) 2026 “Open Development Platform” Ltd. (https://openide.ru) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 or later as published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +package ru.openide.statistics + +import com.jetbrains.fus.reporting.FusHttpClient +import com.jetbrains.fus.reporting.HttpResponse +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class OpenIdeFusHttpClientTest { + /** Records the URL the wrapper actually asked for, so that a refused request is visible as `null`. */ + private class RecordingClient : FusHttpClient { + var requestedUrl: String? = null + + override fun post(url: String, data: String): HttpResponse { + requestedUrl = url + return HttpResponse(200, "") + } + + override fun get(url: String): HttpResponse { + requestedUrl = url + return HttpResponse(200, "") + } + + override fun lastModified(url: String): Long { + requestedUrl = url + return 1 + } + } + + private fun get(url: String): Pair { + val delegate = RecordingClient() + val response = OpenIdeFusHttpClient(delegate).get(url) + return delegate.requestedUrl to response + } + + @Test + fun `configuration url is taken from the OpenIDE host`() { + val (requested, response) = get("https://resources.jetbrains.com/storage/fus/config/v4/FUS/IC.json") + assertThat(requested).isEqualTo("https://stats.openide.ru/storage/fus/config/v4/FUS/IC.json") + assertThat(response.statusCode).isEqualTo(200) + } + + @Test + fun `china configuration host is rewritten as well`() { + val (requested, _) = get("https://resources.jetbrains.com.cn/storage/fus/config/v4/ML/IC.json") + assertThat(requested).isEqualTo("https://stats.openide.ru/storage/fus/config/v4/ML/IC.json") + } + + @Test + fun `metadata and dictionary paths are kept as-is`() { + val (requested, _) = get("https://resources.jetbrains.com/storage/ap/fus/metadata/dictionaries/FUS/dictionaries.json") + assertThat(requested).isEqualTo("https://stats.openide.ru/storage/ap/fus/metadata/dictionaries/FUS/dictionaries.json") + } + + @Test + fun `reporting to the JetBrains analytics service is refused`() { + val (requested, response) = get("https://analytics.services.jetbrains.com/fus/v5/send/") + assertThat(requested).isNull() + assertThat(response.statusCode).isEqualTo(403) + } + + @Test + fun `any other JetBrains host is refused`() { + val (requested, response) = get("https://plugins.jetbrains.com/api/search") + assertThat(requested).isNull() + assertThat(response.statusCode).isEqualTo(403) + } + + @Test + fun `refused requests report an unknown last modified timestamp`() { + val delegate = RecordingClient() + val lastModified = OpenIdeFusHttpClient(delegate).lastModified("https://uploads.jetbrains.com/x.json") + assertThat(delegate.requestedUrl).isNull() + assertThat(lastModified).isEqualTo(0) + } + + @Test + fun `a host that only looks like a JetBrains one is not treated as JetBrains`() { + val url = "https://resources.jetbrains.com.example.org/storage/fus/config/v4/FUS/IC.json" + val (requested, _) = get(url) + assertThat(requested).isEqualTo(url) + } + + @Test + fun `OpenIDE hosts are passed through untouched`() { + val (requested, _) = get("https://stats.openide.ru/statistics/fus/v5/send/") + assertThat(requested).isEqualTo("https://stats.openide.ru/statistics/fus/v5/send/") + } +} diff --git a/plugins/stats-collector/src/com/intellij/stats/completion/sender/SenderPreloadingActivity.kt b/plugins/stats-collector/src/com/intellij/stats/completion/sender/SenderPreloadingActivity.kt index 749f77a3339c..f93f2ae6802c 100644 --- a/plugins/stats-collector/src/com/intellij/stats/completion/sender/SenderPreloadingActivity.kt +++ b/plugins/stats-collector/src/com/intellij/stats/completion/sender/SenderPreloadingActivity.kt @@ -1,9 +1,11 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// +// Modified by Nikita Iarychenko at 2026 as part of the OpenIDE project (https://openide.ru). +// Any modifications are available on the same license terms as the original source code. package com.intellij.stats.completion.sender import com.intellij.ide.ApplicationActivity import com.intellij.internal.statistic.utils.StatisticsUploadAssistant -import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service import com.intellij.openapi.components.serviceAsync @@ -35,9 +37,14 @@ internal class SenderPreloadingActivity : ApplicationActivity { serviceAsync() if (!Registry.`is`("completion.stats.analytics.platform.send", false)) return val urlValue = Registry.get("completion.stats.analytics.platform.url") - val statusUrl = - if (urlValue.isChangedFromDefault()) urlValue.asString() - else "https://resources.jetbrains.com/storage/ap/mlcc/config/v1/${ApplicationInfo.getInstance().build.productCode}.json" + // OpenIDE: upstream falls back to https://resources.jetbrains.com/storage/ap/mlcc/config/v1/.json here. + // OpenIDE has no such endpoint and completion logs must not be reported to JetBrains, so the sender stays off + // unless an endpoint is configured explicitly through the registry key. + if (!urlValue.isChangedFromDefault()) { + LOG.info("Completion log sending is disabled: 'completion.stats.analytics.platform.url' is not configured.") + return + } + val statusUrl = urlValue.asString() // do not check right after the start - avoid getting UsageStatisticsPersistenceComponent too early delay(5.minutes)