From a37d3cd41de5f9de1164c59b29c754a352c2f7ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 06:47:22 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=D0=9E=D0=B1=D1=80=D0=B0=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=20GitHub=20API=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA=D0=B8=20?= =?UTF-8?q?github-api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переводит GitHubReleaseClient на java.net.http.HttpClient и встроенный минимальный JSON-парсер (RFC 8259) вместо org.kohsuke:github-api. Из рантайм-замкнутости уходят github-api, jackson-databind/core/annotations (~4 МБ) — остаются только slf4j-api, commons-compress, semver4j и jspecify, что упрощает встраивание в OSGi (EDT) и убирает конфликт версий Jackson. Поведение сохранено: стабильный канал берётся из releases/latest (404 = нет подходящих релизов), pre-release — первый не-draft из списка releases с дочитыванием страниц. Ассеты читаются из того же ответа, так что вместо двух обращений к API выполняется одно. Добавлен конструктор с внешним HttpClient (настройка прокси, тесты). Fixes #81 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WwLcL3YofsPmKBJpcDoVor --- build.gradle.kts | 1 - .../BslLanguageServerDownloader.java | 4 +- .../utils/downloader/GitHubReleaseClient.java | 135 ++++++++-- .../_1c_syntax/utils/downloader/Json.java | 247 ++++++++++++++++++ .../downloader/GitHubReleaseClientTest.java | 234 +++++++++++++++++ .../_1c_syntax/utils/downloader/JsonTest.java | 126 +++++++++ 6 files changed, 715 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/github/_1c_syntax/utils/downloader/Json.java create mode 100644 src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java create mode 100644 src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java diff --git a/build.gradle.kts b/build.gradle.kts index dc8d93d..d5e240d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,7 +49,6 @@ dependencies { // Загрузчик BSL Language Server implementation("org.slf4j:slf4j-api:2.0.18") - implementation("org.kohsuke:github-api:1.330") implementation("org.apache.commons:commons-compress:1.28.0") implementation("org.semver4j:semver4j:6.0.0") diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 82bca80..dfd674e 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -88,8 +88,8 @@ public class BslLanguageServerDownloader { * @param installDir каталог установки сервера; в нём создаются подпапки с версиями * и файл {@code SERVER-INFO} * @param releaseClient источник сведений о последнем релизе - * @param httpClient клиент только для скачивания ассета (github-api эту загрузку не умеет); - * должен следовать редиректам — ассеты GitHub отдаются редиректом на CDN + * @param httpClient клиент для скачивания ассета; должен следовать редиректам — + * ассеты GitHub отдаются редиректом на CDN */ public BslLanguageServerDownloader(Path installDir, GitHubReleaseClient releaseClient, HttpClient httpClient) { diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java index 87eeee9..0c73238 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -22,21 +22,22 @@ package com.github._1c_syntax.utils.downloader; import org.jspecify.annotations.Nullable; -import org.kohsuke.github.GHAsset; -import org.kohsuke.github.GHRelease; -import org.kohsuke.github.GHRepository; -import org.kohsuke.github.GitHub; -import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.extras.HttpClientGitHubConnector; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; import java.util.HashMap; +import java.util.List; import java.util.Map; /** * Клиент GitHub-релизов BSL Language Server: находит последний релиз канала в репозитории - * {@value #REPOSITORY}. HTTP-доступ обеспечивает сама github-api (свой {@link HttpClientGitHubConnector} - * со встроенным клиентом) — отдельный HTTP-клиент здесь не нужен. + * {@value #REPOSITORY} через GitHub REST API. Работает на {@link java.net.http.HttpClient} + * и встроенном JSON-парсере — без клиентских библиотек GitHub и внешних JSON-библиотек, + * чтобы рантайм-замкнутость оставалась минимальной (важно для встраивания в OSGi). * *

Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток * скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito. @@ -44,14 +45,33 @@ public class GitHubReleaseClient { private static final String REPOSITORY = "1c-syntax/bsl-language-server"; + private static final String API_ROOT = "https://api.github.com"; + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + // Релизы отдаются newest-first: не-draft почти всегда на первой странице, поэтому страницы + // небольшие; пагинация ниже дочитает хвост в вырожденном случае «страница целиком из драфтов». + private static final int RELEASES_PER_PAGE = 30; private final @Nullable String token; + private final HttpClient httpClient; /** * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} */ public GitHubReleaseClient(@Nullable String token) { + this(token, HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL) + .build()); + } + + /** + * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} + * @param httpClient клиент для запросов к GitHub API — например, с настроенным прокси + */ + public GitHubReleaseClient(@Nullable String token, HttpClient httpClient) { this.token = token; + this.httpClient = httpClient; } /** @@ -62,37 +82,94 @@ public GitHubReleaseClient(@Nullable String token) { * @throws IOException если релизы недоступны или подходящего релиза нет */ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector()); - if (token != null && !token.isBlank()) { - builder.withOAuthToken(token); + Map release; + if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { + release = latestNonDraftRelease(); + } else { + release = latestStableRelease(); } - GitHub github = builder.build(); - GHRepository repository = github.getRepository(REPOSITORY); + if (release == null || !(release.get("tag_name") instanceof String tagName)) { + throw new IOException( + "Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + } + return new Release(tagName, assetDownloadUrls(release)); + } - GHRelease release; - if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { - // GitHub отдаёт релизы newest-first — берём первый не-draft, не подгружая все страницы. - release = null; - for (GHRelease candidate : repository.listReleases()) { - if (!candidate.isDraft()) { - release = candidate; - break; + /** + * Последний стабильный релиз: эндпоинт {@code releases/latest} сам исключает draft + * и pre-release, а при полном отсутствии стабильных релизов отвечает 404. + */ + private @Nullable Map latestStableRelease() throws IOException { + var response = send("/repos/" + REPOSITORY + "/releases/latest"); + if (response.statusCode() == 404) { + return null; + } + return Json.parse(body(response)) instanceof Map release ? release : null; + } + + /** + * Последний релиз с учётом pre-release: список {@code releases} отдаётся newest-first, + * берём первый не-draft. Драфты видны только пользователям с push-доступом, но при вызове + * с таким токеном их нужно пропустить, дочитывая следующие страницы при необходимости. + */ + private @Nullable Map latestNonDraftRelease() throws IOException { + for (var page = 1; ; page++) { + var path = "/repos/" + REPOSITORY + "/releases?per_page=" + RELEASES_PER_PAGE + "&page=" + page; + if (!(Json.parse(get(path)) instanceof List releases) || releases.isEmpty()) { + return null; + } + for (Object candidate : releases) { + if (candidate instanceof Map release && !Boolean.TRUE.equals(release.get("draft"))) { + return release; } } - } else { - release = repository.getLatestRelease(); } + } - if (release == null) { - throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + private static Map assetDownloadUrls(Map release) { + var assetUrls = new HashMap(); + if (release.get("assets") instanceof List assets) { + for (Object candidate : assets) { + if (candidate instanceof Map asset + && asset.get("name") instanceof String name + && asset.get("browser_download_url") instanceof String url) { + assetUrls.putIfAbsent(name, url); + } + } } + return Map.copyOf(assetUrls); + } - var assetUrls = new HashMap(); - for (GHAsset asset : release.listAssets().toList()) { - assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl()); + private String get(String path) throws IOException { + return body(send(path)); + } + + private HttpResponse send(String path) throws IOException { + var builder = HttpRequest.newBuilder(URI.create(API_ROOT + path)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "1c-syntax-utils") + .timeout(REQUEST_TIMEOUT) + .GET(); + if (token != null && !token.isBlank()) { + builder.header("Authorization", "Bearer " + token); + } + + try { + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("GitHub API request " + path + " was interrupted", e); + } + } + + private static String body(HttpResponse response) throws IOException { + if (response.statusCode() != 200) { + throw new IOException( + "GitHub API request " + response.request().uri() + " failed: HTTP " + response.statusCode()); } - return new Release(release.getTagName(), Map.copyOf(assetUrls)); + return response.body(); } /** diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java b/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java new file mode 100644 index 0000000..993e211 --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java @@ -0,0 +1,247 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Минимальный JSON-парсер для ответов GitHub REST API — чтобы не тянуть Jackson/gson + * в рантайм-замкнутость библиотеки (важно для встраивания в OSGi, см. issue #81). + * + *

Поддерживает весь синтаксис RFC 8259. Значения отображаются в {@link Map} + * (объект, порядок ключей сохраняется), {@link List} (массив), {@link String}, + * {@link Long}/{@link Double} (число), {@link Boolean} и {@code null}. + * + *

Некорректный JSON приводит к {@link IOException}: парсер применяется только к сетевым + * ответам, где повреждённые данные — та же ошибка обмена, что и обрыв соединения. + */ +final class Json { + + private final String text; + private int pos; + + private Json(String text) { + this.text = text; + } + + /** + * Разбирает строку с одним JSON-значением. + * + * @param text текст JSON + * @return значение: {@code Map}, {@code List}, {@link String}, + * {@link Long}, {@link Double}, {@link Boolean} или {@code null} + * @throws IOException если текст не является корректным JSON + */ + static @Nullable Object parse(String text) throws IOException { + var parser = new Json(text); + parser.skipWhitespace(); + var value = parser.readValue(); + parser.skipWhitespace(); + if (parser.pos < text.length()) { + throw parser.error("Unexpected trailing characters"); + } + return value; + } + + private @Nullable Object readValue() throws IOException { + return switch (peek()) { + case '{' -> readObject(); + case '[' -> readArray(); + case '"' -> readString(); + case 't' -> readLiteral("true", Boolean.TRUE); + case 'f' -> readLiteral("false", Boolean.FALSE); + case 'n' -> readLiteral("null", null); + default -> readNumber(); + }; + } + + private Map readObject() throws IOException { + expect('{'); + var object = new LinkedHashMap(); + skipWhitespace(); + if (peek() == '}') { + pos++; + return object; + } + while (true) { + skipWhitespace(); + var key = readString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + object.put(key, readValue()); + skipWhitespace(); + char next = peek(); + pos++; + if (next == '}') { + return object; + } + if (next != ',') { + throw error("Expected ',' or '}' in object"); + } + } + } + + private List<@Nullable Object> readArray() throws IOException { + expect('['); + var array = new ArrayList<@Nullable Object>(); + skipWhitespace(); + if (peek() == ']') { + pos++; + return array; + } + while (true) { + skipWhitespace(); + array.add(readValue()); + skipWhitespace(); + char next = peek(); + pos++; + if (next == ']') { + return array; + } + if (next != ',') { + throw error("Expected ',' or ']' in array"); + } + } + } + + private String readString() throws IOException { + expect('"'); + var builder = new StringBuilder(); + while (true) { + char c = next(); + if (c == '"') { + return builder.toString(); + } + if (c == '\\') { + builder.append(readEscape()); + } else if (c < 0x20) { + throw error("Unescaped control character in string"); + } else { + builder.append(c); + } + } + } + + private char readEscape() throws IOException { + char c = next(); + return switch (c) { + case '"', '\\', '/' -> c; + case 'b' -> '\b'; + case 'f' -> '\f'; + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case 'u' -> readUnicodeEscape(); + default -> throw error("Invalid escape sequence '\\" + c + "'"); + }; + } + + private char readUnicodeEscape() throws IOException { + if (pos + 4 > text.length()) { + throw error("Unexpected end of unicode escape"); + } + var hex = text.substring(pos, pos + 4); + try { + var code = Integer.parseInt(hex, 16); + pos += 4; + return (char) code; + } catch (NumberFormatException e) { + throw error("Invalid unicode escape '\\u" + hex + "'"); + } + } + + private Number readNumber() throws IOException { + var start = pos; + if (peek() == '-') { + pos++; + } + while (pos < text.length() && isNumberChar(text.charAt(pos))) { + pos++; + } + var literal = text.substring(start, pos); + try { + if (literal.indexOf('.') < 0 && literal.indexOf('e') < 0 && literal.indexOf('E') < 0) { + try { + return Long.parseLong(literal); + } catch (NumberFormatException outOfLongRange) { + return Double.parseDouble(literal); + } + } + return Double.parseDouble(literal); + } catch (NumberFormatException e) { + throw error("Invalid number '" + literal + "'"); + } + } + + private static boolean isNumberChar(char c) { + return (c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-'; + } + + private @Nullable Object readLiteral(String literal, @Nullable Object value) throws IOException { + if (!text.startsWith(literal, pos)) { + throw error("Invalid literal"); + } + pos += literal.length(); + return value; + } + + private void skipWhitespace() { + while (pos < text.length()) { + char c = text.charAt(pos); + if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { + return; + } + pos++; + } + } + + private char peek() throws IOException { + if (pos >= text.length()) { + throw error("Unexpected end of JSON"); + } + return text.charAt(pos); + } + + private char next() throws IOException { + char c = peek(); + pos++; + return c; + } + + private void expect(char expected) throws IOException { + if (next() != expected) { + pos--; + throw error("Expected '" + expected + "'"); + } + } + + private IOException error(String message) { + return new IOException("Malformed JSON at position " + pos + ": " + message); + } +} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java new file mode 100644 index 0000000..22f0d19 --- /dev/null +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java @@ -0,0 +1,234 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitHubReleaseClientTest { + + private static final String LATEST_RELEASE_JSON = """ + { + "tag_name": "v1.2.3", + "draft": false, + "prerelease": false, + "assets": [ + {"name": "bsl-language-server_nix.zip", + "browser_download_url": "https://example.invalid/nix.zip"}, + {"name": "bsl-language-server_win.zip", + "browser_download_url": "https://example.invalid/win.zip"} + ] + } + """; + + private final List requests = new ArrayList<>(); + + @Test + void stableChannelUsesLatestReleaseEndpoint() throws IOException { + var client = new GitHubReleaseClient("token", httpClient(200, LATEST_RELEASE_JSON)); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(release.version()).isEqualTo("v1.2.3"); + assertThat(release.assetDownloadUrls()).containsOnly( + entry("bsl-language-server_nix.zip", "https://example.invalid/nix.zip"), + entry("bsl-language-server_win.zip", "https://example.invalid/win.zip")); + + assertThat(requests).hasSize(1); + var request = requests.get(0); + assertThat(request.uri().toString()) + .isEqualTo("https://api.github.com/repos/1c-syntax/bsl-language-server/releases/latest"); + assertThat(request.headers().firstValue("Authorization")).contains("Bearer token"); + assertThat(request.headers().firstValue("Accept")).contains("application/vnd.github+json"); + assertThat(request.headers().firstValue("User-Agent")).contains("1c-syntax-utils"); + } + + @Test + void anonymousClientSendsNoAuthorizationHeader() throws IOException { + var client = new GitHubReleaseClient(null, httpClient(200, LATEST_RELEASE_JSON)); + + client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(requests.get(0).headers().firstValue("Authorization")).isEmpty(); + } + + @Test + void blankTokenSendsNoAuthorizationHeader() throws IOException { + var client = new GitHubReleaseClient(" ", httpClient(200, LATEST_RELEASE_JSON)); + + client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(requests.get(0).headers().firstValue("Authorization")).isEmpty(); + } + + @Test + void prereleaseChannelPicksFirstNonDraftFromReleasesList() throws IOException { + var releasesJson = """ + [ + {"tag_name": "v9.9.9", "draft": true, "prerelease": true, "assets": []}, + {"tag_name": "v1.3.0-rc.1", "draft": false, "prerelease": true, + "assets": [{"name": "bsl-language-server_nix.zip", + "browser_download_url": "https://example.invalid/rc.zip"}]}, + {"tag_name": "v1.2.3", "draft": false, "prerelease": false, "assets": []} + ] + """; + var client = new GitHubReleaseClient(null, httpClient(200, releasesJson)); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); + + assertThat(release.version()).isEqualTo("v1.3.0-rc.1"); + assertThat(release.assetDownloadUrls()) + .containsOnly(entry("bsl-language-server_nix.zip", "https://example.invalid/rc.zip")); + assertThat(requests).hasSize(1); + assertThat(requests.get(0).uri().toString()) + .startsWith("https://api.github.com/repos/1c-syntax/bsl-language-server/releases?"); + } + + @Test + void prereleaseChannelReadsNextPageWhenFirstPageContainsOnlyDrafts() throws IOException { + var draftsOnlyPage = """ + [{"tag_name": "v9.9.9", "draft": true, "prerelease": true, "assets": []}] + """; + var secondPage = """ + [{"tag_name": "v1.3.0-rc.1", "draft": false, "prerelease": true, "assets": []}] + """; + var client = new GitHubReleaseClient(null, httpClient(request -> + response(request, 200, request.uri().toString().endsWith("page=1") ? draftsOnlyPage : secondPage))); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); + + assertThat(release.version()).isEqualTo("v1.3.0-rc.1"); + assertThat(requests).hasSize(2); + assertThat(requests.get(0).uri().toString()).endsWith("page=1"); + assertThat(requests.get(1).uri().toString()).endsWith("page=2"); + } + + @Test + void prereleaseChannelFailsWhenThereAreNoReleases() { + var client = new GitHubReleaseClient(null, httpClient(200, "[]")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + } + + @Test + void stableChannelFailsWhenThereAreNoStableReleases() { + var client = new GitHubReleaseClient(null, httpClient(404, "{\"message\": \"Not Found\"}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + } + + @Test + void failsOnHttpError() { + var client = new GitHubReleaseClient(null, httpClient(403, "{\"message\": \"rate limit\"}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("HTTP 403"); + } + + @Test + void failsOnMalformedResponse() { + var client = new GitHubReleaseClient(null, httpClient(200, "not a json")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("Malformed JSON"); + } + + @Test + void failsWhenReleaseHasNoTagName() { + var client = new GitHubReleaseClient(null, httpClient(200, "{\"assets\": []}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + } + + @Test + void interruptedRequestRestoresInterruptFlagAndFails() throws Exception { + var httpClient = mock(HttpClient.class); + doAnswer(invocation -> { + throw new InterruptedException("interrupted"); + }).when(httpClient).send(any(), any()); + var client = new GitHubReleaseClient(null, httpClient); + + try { + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("interrupted"); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } finally { + Thread.interrupted(); // сбрасываем флаг, чтобы не влиять на другие тесты + } + } + + private HttpClient httpClient(int status, String body) { + return httpClient(request -> response(request, status, body)); + } + + /** + * Мок {@link HttpClient}, отвечающий на каждый {@code send} через {@code responses} + * и записывающий запросы в {@link #requests}. + */ + private HttpClient httpClient(Function> responses) { + var client = mock(HttpClient.class); + try { + when(client.send(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + requests.add(request); + return responses.apply(request); + }); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException(e); // не бывает: это настройка мока, а не реальный вызов + } + return client; + } + + @SuppressWarnings("unchecked") + private static HttpResponse response(HttpRequest request, int status, String body) { + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(status); + when(response.body()).thenReturn(body); + when(response.request()).thenReturn(request); + return response; + } +} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java new file mode 100644 index 0000000..6628ca1 --- /dev/null +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java @@ -0,0 +1,126 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JsonTest { + + @Test + void parsesScalars() throws IOException { + assertThat(Json.parse("\"text\"")).isEqualTo("text"); + assertThat(Json.parse("42")).isEqualTo(42L); + assertThat(Json.parse("-7")).isEqualTo(-7L); + assertThat(Json.parse("3.5")).isEqualTo(3.5d); + assertThat(Json.parse("1e3")).isEqualTo(1000d); + assertThat(Json.parse("true")).isEqualTo(Boolean.TRUE); + assertThat(Json.parse("false")).isEqualTo(Boolean.FALSE); + assertThat(Json.parse("null")).isNull(); + } + + @Test + void parsesNumberOutOfLongRangeAsDouble() throws IOException { + assertThat(Json.parse("123456789012345678901234567890")) + .isEqualTo(123456789012345678901234567890d); + } + + @Test + void parsesStringEscapes() throws IOException { + assertThat(Json.parse("\"a\\\"b\\\\c\\/d\\b\\f\\n\\r\\t\"")).isEqualTo("a\"b\\c/d\b\f\n\r\t"); + assertThat(Json.parse("\"\\u0416\\u045E\"")).isEqualTo("Жў"); + // суррогатная пара + assertThat(Json.parse("\"\\uD83D\\uDE00\"")).isEqualTo("😀"); + } + + @Test + void parsesObjectsAndArrays() throws IOException { + var value = Json.parse(""" + { + "tag_name": "v1.2.3", + "draft": false, + "assets": [ + {"name": "a.zip", "size": 100}, + {"name": "b.zip", "size": 200.5} + ], + "empty_object": {}, + "empty_array": [] + } + """); + + assertThat(value).isEqualTo(Map.of( + "tag_name", "v1.2.3", + "draft", false, + "assets", List.of( + Map.of("name", "a.zip", "size", 100L), + Map.of("name", "b.zip", "size", 200.5d)), + "empty_object", Map.of(), + "empty_array", List.of())); + } + + @Test + void parsesNestedArrays() throws IOException { + assertThat(Json.parse("[[1, 2], [], [null, true]]")) + .isEqualTo(List.of(List.of(1L, 2L), List.of(), java.util.Arrays.asList(null, true))); + } + + @Test + void rejectsMalformedJson() { + var samples = List.of( + "", + " ", + "{", + "[1, 2", + "{\"a\" 1}", + "{\"a\": 1,}", + "{\"a\": 1 \"b\": 2}", + "[1 2]", + "\"unterminated", + "\"bad escape \\x\"", + "\"bad unicode \\u12GX\"", + "\"truncated unicode \\u12", + "tru", + "nul", + "01a", + "--1", + "1.2.3", + "{} extra", + "42 43"); + + for (var malformed : samples) { + assertThatThrownBy(() -> Json.parse(malformed)) + .as("Json.parse(%s)", malformed) + .isInstanceOf(IOException.class); + } + } + + @Test + void rejectsUnescapedControlCharacterInString() { + assertThatThrownBy(() -> Json.parse("\"line\nbreak\"")).isInstanceOf(IOException.class); + } +} From 817bc99d74748a93e884aabee70096024b271341 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 10:20:46 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D0=BF=D0=B0=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20java.util?= =?UTF-8?q?.zip=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20commons-compress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убирает зависимость org.apache.commons:commons-compress и притянутые ею commons-codec/commons-io/commons-lang3 (~2.6 МБ). Распаковка архива сервера переведена на java.util.zip.ZipFile. java.util.zip не переносит unix-права из архива, поэтому бит исполнения на POSIX-системах выставляется явно на файле, который и будет запущен (binaryPath) — для native-image раскладки это единственный исполняемый файл бандла. Добавлен тест на это (пропускается на Windows). Итоговая рантайм-замкнутость downloader'а: slf4j-api, semver4j, jspecify. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WwLcL3YofsPmKBJpcDoVor --- build.gradle.kts | 1 - .../BslLanguageServerDownloader.java | 47 +++++++++---------- .../BslLanguageServerDownloaderTest.java | 15 ++++++ 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d5e240d..5d61118 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,7 +49,6 @@ dependencies { // Загрузчик BSL Language Server implementation("org.slf4j:slf4j-api:2.0.18") - implementation("org.apache.commons:commons-compress:1.28.0") implementation("org.semver4j:semver4j:6.0.0") testImplementation("org.assertj:assertj-core:3.27.7") diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index dfd674e..eaa7843 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -22,8 +22,6 @@ package com.github._1c_syntax.utils.downloader; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; -import org.apache.commons.compress.archivers.zip.ZipFile; import org.jspecify.annotations.Nullable; import org.semver4j.Semver; @@ -34,20 +32,21 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; import java.time.Duration; import java.util.Comparator; -import java.util.EnumSet; import java.util.Locale; import java.util.Optional; import java.util.Properties; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; @@ -224,6 +223,7 @@ private void downloadAndExtract(GitHubReleaseClient.Release release, String vers download(downloadUrl, archive, progressListener); deleteRecursively(versionDir); extract(archive, versionDir); + makeLauncherExecutable(version); } finally { Files.deleteIfExists(archive); } @@ -293,10 +293,10 @@ private static void closeQuietly(InputStream stream) { private static void extract(Path archive, Path targetDir) throws IOException { Files.createDirectories(targetDir); - try (var zip = ZipFile.builder().setPath(archive).get()) { - var entries = zip.getEntries(); + try (var zip = new ZipFile(archive.toFile(), StandardCharsets.UTF_8)) { + var entries = zip.entries(); while (entries.hasMoreElements()) { - ZipArchiveEntry entry = entries.nextElement(); + ZipEntry entry = entries.nextElement(); var target = targetDir.resolve(entry.getName()).normalize(); if (!target.startsWith(targetDir)) { throw new IOException("Illegal archive entry (zip slip): " + entry.getName()); @@ -309,30 +309,29 @@ private static void extract(Path archive, Path targetDir) throws IOException { try (InputStream input = zip.getInputStream(entry)) { Files.copy(input, target, REPLACE_EXISTING); } - applyUnixMode(target, entry.getUnixMode()); } } } - private static void applyUnixMode(Path path, int unixMode) throws IOException { - if (!POSIX || unixMode == 0) { - return; - } - Files.setPosixFilePermissions(path, permissionsFromMode(unixMode)); - } - /** - * Преобразует unix-режим из zip в набор прав, ограниченный владельцем: групповые и «прочие» - * права намеренно не выдаются, чтобы не создавать слишком свободный доступ. Владельцу всегда - * доступны чтение и запись, бит исполнения выставляется, если он был установлен в архиве - * (нужно для launcher'а и бинарей внутри native-image). + * Помечает лаунчер сервера исполняемым. {@link java.util.zip} не переносит unix-права из архива + * (в отличие от прежней распаковки через commons-compress), поэтому на POSIX-системах бит + * исполнения выставляется вручную — на том единственном файле, который и будет запущен. Для + * native-image раскладки это и есть исполняемый файл бандла. На Windows, а также если ожидаемого + * файла в архиве не оказалось, — no-op. */ - private static Set permissionsFromMode(int mode) { - var permissions = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); - if ((mode & 0111) != 0) { - permissions.add(PosixFilePermission.OWNER_EXECUTE); + private void makeLauncherExecutable(String version) throws IOException { + if (!POSIX) { + return; + } + var binary = binaryPath(version); + if (!Files.exists(binary)) { + return; + } + var permissions = Files.getPosixFilePermissions(binary); + if (permissions.add(PosixFilePermission.OWNER_EXECUTE)) { + Files.setPosixFilePermissions(binary, permissions); } - return permissions; } private void cleanupOtherVersions(String keepVersion) { diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 38336ed..9d8dfa7 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -190,6 +190,21 @@ void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir assertThat(downloader.installedVersion()).contains("1.2.3"); } + @Test + @DisabledOnOs(OS.WINDOWS) + void downloadIfNeededMarksLauncherExecutable(@TempDir Path installDir) throws IOException { + var archive = zipWithLaunchers(300 * 1024); + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())) + .thenReturn(new GitHubReleaseClient.Release("1.2.3", allOsAssets())); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, httpClientReturning(archive)); + + var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); + + assertThat(binary).exists(); + assertThat(Files.isExecutable(binary)).isTrue(); + } + @Test void downloadIfNeededReportsProgressForTheAsset(@TempDir Path installDir) throws IOException { var archive = zipWithLaunchers(300 * 1024); From 0b5f4cb6d7cd96280cbbef45b8be65a9aec05ca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 10:31:30 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E:=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=B8=D0=B9=20\u-escape,=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Json.readUnicodeEscape: разбор ровно четырёх hex-цифр через Character.digit вместо Integer.parseInt(hex, 16), который принимал знак (+/-) — escape со знаком теперь корректно отвергается как некорректный JSON. - Json.readNumber: вложенный try вынесен в parseIntegral (замечание Sonar); javadoc смягчён — парсер чуть мягче RFC 8259 к числовым литералам. - GitHubReleaseClient: пагинация pre-release ограничена MAX_RELEASES_PAGES, чтобы зеркало/прокси, бесконечно отдающее draft-страницу, не гоняло опрос до rate limit; текст ошибки HTTP дополнен усечённым телом ответа GitHub. - Тесты на границу пагинации, тело в ошибке и escape со знаком. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WwLcL3YofsPmKBJpcDoVor --- .../utils/downloader/GitHubReleaseClient.java | 26 ++++++++++-- .../_1c_syntax/utils/downloader/Json.java | 40 +++++++++++++------ .../downloader/GitHubReleaseClientTest.java | 17 +++++++- .../_1c_syntax/utils/downloader/JsonTest.java | 5 ++- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java index 0c73238..a368a94 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -51,6 +51,13 @@ public class GitHubReleaseClient { // Релизы отдаются newest-first: не-draft почти всегда на первой странице, поэтому страницы // небольшие; пагинация ниже дочитает хвост в вырожденном случае «страница целиком из драфтов». private static final int RELEASES_PER_PAGE = 30; + // Верхняя граница пагинации: у настоящего GitHub цикл завершает пустая страница за последней, + // но зеркало/прокси/кэш может бесконечно отдавать одну и ту же непустую страницу драфтов — + // ограничение защищает от бесконечного опроса до упора в rate limit. + private static final int MAX_RELEASES_PAGES = 10; + // Сколько символов тела ответа включать в текст ошибки для диагностики (GitHub кладёт причину + // в поле message; токен в теле не возвращается, так что утечки секрета нет). + private static final int ERROR_BODY_LIMIT = 500; private final @Nullable String token; private final HttpClient httpClient; @@ -114,7 +121,7 @@ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOE * с таким токеном их нужно пропустить, дочитывая следующие страницы при необходимости. */ private @Nullable Map latestNonDraftRelease() throws IOException { - for (var page = 1; ; page++) { + for (var page = 1; page <= MAX_RELEASES_PAGES; page++) { var path = "/repos/" + REPOSITORY + "/releases?per_page=" + RELEASES_PER_PAGE + "&page=" + page; if (!(Json.parse(get(path)) instanceof List releases) || releases.isEmpty()) { return null; @@ -125,6 +132,7 @@ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOE } } } + return null; } private static Map assetDownloadUrls(Map release) { @@ -166,12 +174,24 @@ private HttpResponse send(String path) throws IOException { private static String body(HttpResponse response) throws IOException { if (response.statusCode() != 200) { - throw new IOException( - "GitHub API request " + response.request().uri() + " failed: HTTP " + response.statusCode()); + var details = errorDetails(response.body()); + throw new IOException("GitHub API request " + response.request().uri() + + " failed: HTTP " + response.statusCode() + details); } return response.body(); } + private static String errorDetails(@Nullable String body) { + if (body == null || body.isBlank()) { + return ""; + } + var trimmed = body.strip(); + if (trimmed.length() > ERROR_BODY_LIMIT) { + trimmed = trimmed.substring(0, ERROR_BODY_LIMIT) + "…"; + } + return ": " + trimmed; + } + /** * Сведения о релизе, нужные загрузчику. * diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java b/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java index 993e211..852a6e0 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java @@ -33,8 +33,9 @@ * Минимальный JSON-парсер для ответов GitHub REST API — чтобы не тянуть Jackson/gson * в рантайм-замкнутость библиотеки (важно для встраивания в OSGi, см. issue #81). * - *

Поддерживает весь синтаксис RFC 8259. Значения отображаются в {@link Map} - * (объект, порядок ключей сохраняется), {@link List} (массив), {@link String}, + *

Принимает любой валидный по RFC 8259 документ; к числовым литералам чуть мягче стандарта + * (например, допускает ведущие нули) — для ответов GitHub это несущественно. Значения отображаются + * в {@link Map} (объект, порядок ключей сохраняется), {@link List} (массив), {@link String}, * {@link Long}/{@link Double} (число), {@link Boolean} и {@code null}. * *

Некорректный JSON приводит к {@link IOException}: парсер применяется только к сетевым @@ -167,13 +168,18 @@ private char readUnicodeEscape() throws IOException { throw error("Unexpected end of unicode escape"); } var hex = text.substring(pos, pos + 4); - try { - var code = Integer.parseInt(hex, 16); - pos += 4; - return (char) code; - } catch (NumberFormatException e) { - throw error("Invalid unicode escape '\\u" + hex + "'"); + // Не через Integer.parseInt(hex, 16): он допускает знак (+/-), а RFC 8259 требует + // ровно четыре hex-цифры — иначе escape со знаком дал бы отрицательный код и мусорный символ. + var code = 0; + for (var i = 0; i < 4; i++) { + var digit = Character.digit(hex.charAt(i), 16); + if (digit < 0) { + throw error("Invalid unicode escape '\\u" + hex + "'"); + } + code = code * 16 + digit; } + pos += 4; + return (char) code; } private Number readNumber() throws IOException { @@ -187,11 +193,7 @@ private Number readNumber() throws IOException { var literal = text.substring(start, pos); try { if (literal.indexOf('.') < 0 && literal.indexOf('e') < 0 && literal.indexOf('E') < 0) { - try { - return Long.parseLong(literal); - } catch (NumberFormatException outOfLongRange) { - return Double.parseDouble(literal); - } + return parseIntegral(literal); } return Double.parseDouble(literal); } catch (NumberFormatException e) { @@ -199,6 +201,18 @@ private Number readNumber() throws IOException { } } + /** + * Разбирает целочисленный литерал как {@link Long}, а при выходе за диапазон {@code long} — + * как {@link Double}. + */ + private static Number parseIntegral(String literal) { + try { + return Long.parseLong(literal); + } catch (NumberFormatException outOfLongRange) { + return Double.parseDouble(literal); + } + } + private static boolean isNumberChar(char c) { return (c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-'; } diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java index 22f0d19..0c66c72 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java @@ -138,6 +138,20 @@ void prereleaseChannelReadsNextPageWhenFirstPageContainsOnlyDrafts() throws IOEx assertThat(requests.get(1).uri().toString()).endsWith("page=2"); } + @Test + void prereleaseChannelStopsPagingAtBoundWhenEveryPageIsDrafts() { + var draftsOnlyPage = """ + [{"tag_name": "v9.9.9", "draft": true, "prerelease": true, "assets": []}] + """; + var client = new GitHubReleaseClient(null, httpClient(request -> response(request, 200, draftsOnlyPage))); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + // Ограничение пагинации: без него мок отдавал бы непустую draft-страницу бесконечно. + assertThat(requests).hasSize(10); + } + @Test void prereleaseChannelFailsWhenThereAreNoReleases() { var client = new GitHubReleaseClient(null, httpClient(200, "[]")); @@ -162,7 +176,8 @@ void failsOnHttpError() { assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) .isInstanceOf(IOException.class) - .hasMessageContaining("HTTP 403"); + .hasMessageContaining("HTTP 403") + .hasMessageContaining("rate limit"); } @Test diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java index 6628ca1..f8d5d1c 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import java.io.IOException; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -86,7 +87,7 @@ void parsesObjectsAndArrays() throws IOException { @Test void parsesNestedArrays() throws IOException { assertThat(Json.parse("[[1, 2], [], [null, true]]")) - .isEqualTo(List.of(List.of(1L, 2L), List.of(), java.util.Arrays.asList(null, true))); + .isEqualTo(List.of(List.of(1L, 2L), List.of(), Arrays.asList(null, true))); } @Test @@ -103,6 +104,8 @@ void rejectsMalformedJson() { "\"unterminated", "\"bad escape \\x\"", "\"bad unicode \\u12GX\"", + "\"signed unicode \\u+041\"", + "\"signed unicode \\u-041\"", "\"truncated unicode \\u12", "tru", "nul", From 84e2ab53d6407ddcaffbbb006c01f564a8e648e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 13:37:29 +0000 Subject: [PATCH 4/7] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20JSON?= =?UTF-8?q?-=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80:=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D0=B0=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D0=BC=20=D0=BF?= =?UTF-8?q?=D0=BE=20download-=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для такого узкого клиента (один репозиторий, валидный ответ GitHub) полноценный JSON-парсер избыточен. Оба канала запрашиваются так, чтобы в ответе был ровно один релиз: - stable — releases/latest (как раньше); - pre-release — releases?per_page=1&page=N: каждая страница = один релиз, draft'ы пропускаются переходом к следующей странице (граница MAX_RELEASES_PAGES). Версия и ссылки на ассеты извлекаются регэкспом по самим download-ссылкам https://github.com//releases/download//: версия берётся из тега в пути, имя ассета — из последнего сегмента. Поэтому произвольное содержимое поля body релиза (markdown со скобками/кавычками) на результат не влияет — раньше именно ради корректного скоупинга ассетов и был нужен структурный парсер. Удалены Json.java и JsonTest.java. Добавлен тест на «отравленные» tag_name/body. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WwLcL3YofsPmKBJpcDoVor --- .../utils/downloader/GitHubReleaseClient.java | 101 ++++--- .../_1c_syntax/utils/downloader/Json.java | 261 ------------------ .../downloader/GitHubReleaseClientTest.java | 131 +++++---- .../_1c_syntax/utils/downloader/JsonTest.java | 129 --------- 4 files changed, 133 insertions(+), 489 deletions(-) delete mode 100644 src/main/java/com/github/_1c_syntax/utils/downloader/Json.java delete mode 100644 src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java index a368a94..cddf836 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -29,15 +29,22 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; -import java.util.HashMap; -import java.util.List; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.regex.Pattern; /** * Клиент GitHub-релизов BSL Language Server: находит последний релиз канала в репозитории * {@value #REPOSITORY} через GitHub REST API. Работает на {@link java.net.http.HttpClient} - * и встроенном JSON-парсере — без клиентских библиотек GitHub и внешних JSON-библиотек, - * чтобы рантайм-замкнутость оставалась минимальной (важно для встраивания в OSGi). + * и разбирает ответ регулярными выражениями по ссылкам на ассеты — без клиентских библиотек + * GitHub и внешних JSON-библиотек, чтобы рантайм-замкнутость оставалась минимальной (важно + * для встраивания в OSGi). + * + *

Полноценный JSON-разбор не нужен: и стабильный, и pre-release-канал запрашиваются так, чтобы + * в ответе был ровно один релиз, а из него нужны лишь версия и ссылки на ассеты. И то, и другое + * берётся из самих download-ссылок вида + * {@code https://github.com//releases/download//} — поэтому произвольное + * содержимое поля {@code body} (релиз-ноуты) не влияет на результат. * *

Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток * скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito. @@ -48,17 +55,27 @@ public class GitHubReleaseClient { private static final String API_ROOT = "https://api.github.com"; private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); - // Релизы отдаются newest-first: не-draft почти всегда на первой странице, поэтому страницы - // небольшие; пагинация ниже дочитает хвост в вырожденном случае «страница целиком из драфтов». - private static final int RELEASES_PER_PAGE = 30; - // Верхняя граница пагинации: у настоящего GitHub цикл завершает пустая страница за последней, - // но зеркало/прокси/кэш может бесконечно отдавать одну и ту же непустую страницу драфтов — - // ограничение защищает от бесконечного опроса до упора в rate limit. + // Верхняя граница пагинации pre-release: каждая страница — один релиз (per_page=1), цикл + // дочитывает хвост, только пока встречает draft'ы; ограничение защищает от бесконечного + // опроса, если апстрим (зеркало/прокси/кэш) отдаёт draft-релиз бесконечно. private static final int MAX_RELEASES_PAGES = 10; // Сколько символов тела ответа включать в текст ошибки для диагностики (GitHub кладёт причину // в поле message; токен в теле не возвращается, так что утечки секрета нет). private static final int ERROR_BODY_LIMIT = 500; + // Ссылка на ассет релиза: https://github.com//releases/download//. + // Группа 1 — тег (версия), группа 2 — имя ассета, всё совпадение — URL для скачивания. Только + // browser_download_url имеет такой путь (у html_url — /releases/tag/, у API-ссылок другой хост), + // поэтому лишнего не захватываем. + private static final Pattern ASSET_URL = Pattern.compile( + "https://github\\.com/" + Pattern.quote(REPOSITORY) + "/releases/download/([^/\"]+)/([^/\"]+)"); + // Флаг draft у релиза. В pre-release-канале страница содержит ровно один релиз, поэтому + // сопоставлять флаг конкретному объекту в списке не нужно. + private static final Pattern DRAFT = Pattern.compile("\"draft\"\\s*:\\s*true"); + // Есть ли в ответе вообще объект релиза — чтобы отличить его от пустого списка [] за последней + // страницей пагинации. + private static final Pattern HAS_RELEASE = Pattern.compile("\"tag_name\"\\s*:"); + private final @Nullable String token; private final HttpClient httpClient; @@ -89,64 +106,68 @@ public GitHubReleaseClient(@Nullable String token, HttpClient httpClient) { * @throws IOException если релизы недоступны или подходящего релиза нет */ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - Map release; - if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { - release = latestNonDraftRelease(); - } else { - release = latestStableRelease(); - } + var release = channel == BslLanguageServerReleaseChannel.PRERELEASE + ? latestNonDraftRelease() + : latestStableRelease(); - if (release == null || !(release.get("tag_name") instanceof String tagName)) { + if (release == null) { throw new IOException( "Repository " + REPOSITORY + " has no suitable releases for channel " + channel); } - return new Release(tagName, assetDownloadUrls(release)); + return release; } /** * Последний стабильный релиз: эндпоинт {@code releases/latest} сам исключает draft * и pre-release, а при полном отсутствии стабильных релизов отвечает 404. */ - private @Nullable Map latestStableRelease() throws IOException { + private @Nullable Release latestStableRelease() throws IOException { var response = send("/repos/" + REPOSITORY + "/releases/latest"); if (response.statusCode() == 404) { return null; } - return Json.parse(body(response)) instanceof Map release ? release : null; + return parseRelease(body(response)); } /** - * Последний релиз с учётом pre-release: список {@code releases} отдаётся newest-first, - * берём первый не-draft. Драфты видны только пользователям с push-доступом, но при вызове - * с таким токеном их нужно пропустить, дочитывая следующие страницы при необходимости. + * Последний релиз с учётом pre-release. Запрашиваем по одному релизу на страницу + * ({@code per_page=1}, newest-first): так в ответе всегда ровно один релиз и не нужно + * сопоставлять ассеты нескольким релизам в списке. Draft'ы (видны только push-токену) + * пропускаем, переходя к следующей странице; анонимно GitHub их вообще не отдаёт. */ - private @Nullable Map latestNonDraftRelease() throws IOException { + private @Nullable Release latestNonDraftRelease() throws IOException { for (var page = 1; page <= MAX_RELEASES_PAGES; page++) { - var path = "/repos/" + REPOSITORY + "/releases?per_page=" + RELEASES_PER_PAGE + "&page=" + page; - if (!(Json.parse(get(path)) instanceof List releases) || releases.isEmpty()) { + var body = get("/repos/" + REPOSITORY + "/releases?per_page=1&page=" + page); + if (!HAS_RELEASE.matcher(body).find()) { return null; } - for (Object candidate : releases) { - if (candidate instanceof Map release && !Boolean.TRUE.equals(release.get("draft"))) { - return release; - } + if (DRAFT.matcher(body).find()) { + continue; } + return parseRelease(body); } return null; } - private static Map assetDownloadUrls(Map release) { - var assetUrls = new HashMap(); - if (release.get("assets") instanceof List assets) { - for (Object candidate : assets) { - if (candidate instanceof Map asset - && asset.get("name") instanceof String name - && asset.get("browser_download_url") instanceof String url) { - assetUrls.putIfAbsent(name, url); - } + /** + * Извлекает версию и ссылки на ассеты из ответа с одним релизом. Версия — тег из пути + * download-ссылки (у всех ассетов релиза он одинаковый), карта — «имя ассета → URL». Если + * ассетов нет, релиз бесполезен загрузчику — возвращается {@code null}. + */ + private static @Nullable Release parseRelease(String body) { + var assetUrls = new LinkedHashMap(); + String version = null; + var matcher = ASSET_URL.matcher(body); + while (matcher.find()) { + if (version == null) { + version = matcher.group(1); } + assetUrls.putIfAbsent(matcher.group(2), matcher.group()); + } + if (version == null) { + return null; } - return Map.copyOf(assetUrls); + return new Release(version, Map.copyOf(assetUrls)); } private String get(String path) throws IOException { diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java b/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java deleted file mode 100644 index 852a6e0..0000000 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java +++ /dev/null @@ -1,261 +0,0 @@ -/* - * This file is a part of 1c-syntax utils. - * - * Copyright (c) 2018-2026 - * Alexey Sosnoviy , Nikita Fedkin and contributors - * - * SPDX-License-Identifier: LGPL-3.0-or-later - * - * 1c-syntax utils is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3.0 of the License, or (at your option) any later version. - * - * 1c-syntax utils 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 - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with 1c-syntax utils. - */ -package com.github._1c_syntax.utils.downloader; - -import org.jspecify.annotations.Nullable; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Минимальный JSON-парсер для ответов GitHub REST API — чтобы не тянуть Jackson/gson - * в рантайм-замкнутость библиотеки (важно для встраивания в OSGi, см. issue #81). - * - *

Принимает любой валидный по RFC 8259 документ; к числовым литералам чуть мягче стандарта - * (например, допускает ведущие нули) — для ответов GitHub это несущественно. Значения отображаются - * в {@link Map} (объект, порядок ключей сохраняется), {@link List} (массив), {@link String}, - * {@link Long}/{@link Double} (число), {@link Boolean} и {@code null}. - * - *

Некорректный JSON приводит к {@link IOException}: парсер применяется только к сетевым - * ответам, где повреждённые данные — та же ошибка обмена, что и обрыв соединения. - */ -final class Json { - - private final String text; - private int pos; - - private Json(String text) { - this.text = text; - } - - /** - * Разбирает строку с одним JSON-значением. - * - * @param text текст JSON - * @return значение: {@code Map}, {@code List}, {@link String}, - * {@link Long}, {@link Double}, {@link Boolean} или {@code null} - * @throws IOException если текст не является корректным JSON - */ - static @Nullable Object parse(String text) throws IOException { - var parser = new Json(text); - parser.skipWhitespace(); - var value = parser.readValue(); - parser.skipWhitespace(); - if (parser.pos < text.length()) { - throw parser.error("Unexpected trailing characters"); - } - return value; - } - - private @Nullable Object readValue() throws IOException { - return switch (peek()) { - case '{' -> readObject(); - case '[' -> readArray(); - case '"' -> readString(); - case 't' -> readLiteral("true", Boolean.TRUE); - case 'f' -> readLiteral("false", Boolean.FALSE); - case 'n' -> readLiteral("null", null); - default -> readNumber(); - }; - } - - private Map readObject() throws IOException { - expect('{'); - var object = new LinkedHashMap(); - skipWhitespace(); - if (peek() == '}') { - pos++; - return object; - } - while (true) { - skipWhitespace(); - var key = readString(); - skipWhitespace(); - expect(':'); - skipWhitespace(); - object.put(key, readValue()); - skipWhitespace(); - char next = peek(); - pos++; - if (next == '}') { - return object; - } - if (next != ',') { - throw error("Expected ',' or '}' in object"); - } - } - } - - private List<@Nullable Object> readArray() throws IOException { - expect('['); - var array = new ArrayList<@Nullable Object>(); - skipWhitespace(); - if (peek() == ']') { - pos++; - return array; - } - while (true) { - skipWhitespace(); - array.add(readValue()); - skipWhitespace(); - char next = peek(); - pos++; - if (next == ']') { - return array; - } - if (next != ',') { - throw error("Expected ',' or ']' in array"); - } - } - } - - private String readString() throws IOException { - expect('"'); - var builder = new StringBuilder(); - while (true) { - char c = next(); - if (c == '"') { - return builder.toString(); - } - if (c == '\\') { - builder.append(readEscape()); - } else if (c < 0x20) { - throw error("Unescaped control character in string"); - } else { - builder.append(c); - } - } - } - - private char readEscape() throws IOException { - char c = next(); - return switch (c) { - case '"', '\\', '/' -> c; - case 'b' -> '\b'; - case 'f' -> '\f'; - case 'n' -> '\n'; - case 'r' -> '\r'; - case 't' -> '\t'; - case 'u' -> readUnicodeEscape(); - default -> throw error("Invalid escape sequence '\\" + c + "'"); - }; - } - - private char readUnicodeEscape() throws IOException { - if (pos + 4 > text.length()) { - throw error("Unexpected end of unicode escape"); - } - var hex = text.substring(pos, pos + 4); - // Не через Integer.parseInt(hex, 16): он допускает знак (+/-), а RFC 8259 требует - // ровно четыре hex-цифры — иначе escape со знаком дал бы отрицательный код и мусорный символ. - var code = 0; - for (var i = 0; i < 4; i++) { - var digit = Character.digit(hex.charAt(i), 16); - if (digit < 0) { - throw error("Invalid unicode escape '\\u" + hex + "'"); - } - code = code * 16 + digit; - } - pos += 4; - return (char) code; - } - - private Number readNumber() throws IOException { - var start = pos; - if (peek() == '-') { - pos++; - } - while (pos < text.length() && isNumberChar(text.charAt(pos))) { - pos++; - } - var literal = text.substring(start, pos); - try { - if (literal.indexOf('.') < 0 && literal.indexOf('e') < 0 && literal.indexOf('E') < 0) { - return parseIntegral(literal); - } - return Double.parseDouble(literal); - } catch (NumberFormatException e) { - throw error("Invalid number '" + literal + "'"); - } - } - - /** - * Разбирает целочисленный литерал как {@link Long}, а при выходе за диапазон {@code long} — - * как {@link Double}. - */ - private static Number parseIntegral(String literal) { - try { - return Long.parseLong(literal); - } catch (NumberFormatException outOfLongRange) { - return Double.parseDouble(literal); - } - } - - private static boolean isNumberChar(char c) { - return (c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-'; - } - - private @Nullable Object readLiteral(String literal, @Nullable Object value) throws IOException { - if (!text.startsWith(literal, pos)) { - throw error("Invalid literal"); - } - pos += literal.length(); - return value; - } - - private void skipWhitespace() { - while (pos < text.length()) { - char c = text.charAt(pos); - if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { - return; - } - pos++; - } - } - - private char peek() throws IOException { - if (pos >= text.length()) { - throw error("Unexpected end of JSON"); - } - return text.charAt(pos); - } - - private char next() throws IOException { - char c = peek(); - pos++; - return c; - } - - private void expect(char expected) throws IOException { - if (next() != expected) { - pos--; - throw error("Expected '" + expected + "'"); - } - } - - private IOException error(String message) { - return new IOException("Malformed JSON at position " + pos + ": " + message); - } -} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java index 0c66c72..f46a58a 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java @@ -28,9 +28,10 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -42,32 +43,18 @@ class GitHubReleaseClientTest { - private static final String LATEST_RELEASE_JSON = """ - { - "tag_name": "v1.2.3", - "draft": false, - "prerelease": false, - "assets": [ - {"name": "bsl-language-server_nix.zip", - "browser_download_url": "https://example.invalid/nix.zip"}, - {"name": "bsl-language-server_win.zip", - "browser_download_url": "https://example.invalid/win.zip"} - ] - } - """; - private final List requests = new ArrayList<>(); @Test void stableChannelUsesLatestReleaseEndpoint() throws IOException { - var client = new GitHubReleaseClient("token", httpClient(200, LATEST_RELEASE_JSON)); + var client = new GitHubReleaseClient("token", httpClient(200, releaseObject("v1.2.3", false, "nix", "win"))); var release = client.latestRelease(BslLanguageServerReleaseChannel.STABLE); assertThat(release.version()).isEqualTo("v1.2.3"); assertThat(release.assetDownloadUrls()).containsOnly( - entry("bsl-language-server_nix.zip", "https://example.invalid/nix.zip"), - entry("bsl-language-server_win.zip", "https://example.invalid/win.zip")); + entry("bsl-language-server_nix.zip", downloadUrl("v1.2.3", "nix")), + entry("bsl-language-server_win.zip", downloadUrl("v1.2.3", "win"))); assertThat(requests).hasSize(1); var request = requests.get(0); @@ -78,9 +65,26 @@ void stableChannelUsesLatestReleaseEndpoint() throws IOException { assertThat(request.headers().firstValue("User-Agent")).contains("1c-syntax-utils"); } + @Test + void versionAndAssetsComeFromDownloadUrlNotFromReleaseBody() throws IOException { + // tag_name и body намеренно «отравлены»: фейковый тег и чужая download-ссылка в релиз-ноутах. + // Результат должен опираться только на настоящие ссылки на ассеты этого репозитория. + var body = "{\"tag_name\":\"v0.0.0-fake\",\"draft\":false," + + "\"body\":\"см. https://github.com/evil/repo/releases/download/v9.9.9/bsl-language-server_nix.zip " + + "и {\\\"assets\\\":[]}\"," + + "\"assets\":[" + asset("nix", "v1.2.3") + "]}"; + var client = new GitHubReleaseClient(null, httpClient(200, body)); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(release.version()).isEqualTo("v1.2.3"); + assertThat(release.assetDownloadUrls()) + .containsOnly(entry("bsl-language-server_nix.zip", downloadUrl("v1.2.3", "nix"))); + } + @Test void anonymousClientSendsNoAuthorizationHeader() throws IOException { - var client = new GitHubReleaseClient(null, httpClient(200, LATEST_RELEASE_JSON)); + var client = new GitHubReleaseClient(null, httpClient(200, releaseObject("v1.2.3", false, "nix"))); client.latestRelease(BslLanguageServerReleaseChannel.STABLE); @@ -89,7 +93,7 @@ void anonymousClientSendsNoAuthorizationHeader() throws IOException { @Test void blankTokenSendsNoAuthorizationHeader() throws IOException { - var client = new GitHubReleaseClient(" ", httpClient(200, LATEST_RELEASE_JSON)); + var client = new GitHubReleaseClient(" ", httpClient(200, releaseObject("v1.2.3", false, "nix"))); client.latestRelease(BslLanguageServerReleaseChannel.STABLE); @@ -97,38 +101,29 @@ void blankTokenSendsNoAuthorizationHeader() throws IOException { } @Test - void prereleaseChannelPicksFirstNonDraftFromReleasesList() throws IOException { - var releasesJson = """ - [ - {"tag_name": "v9.9.9", "draft": true, "prerelease": true, "assets": []}, - {"tag_name": "v1.3.0-rc.1", "draft": false, "prerelease": true, - "assets": [{"name": "bsl-language-server_nix.zip", - "browser_download_url": "https://example.invalid/rc.zip"}]}, - {"tag_name": "v1.2.3", "draft": false, "prerelease": false, "assets": []} - ] - """; - var client = new GitHubReleaseClient(null, httpClient(200, releasesJson)); + void prereleaseChannelRequestsSinglereleasePageAndPicksIt() throws IOException { + var client = new GitHubReleaseClient(null, + httpClient(200, releaseArray(releaseObject("v1.3.0-rc.1", false, "nix")))); var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); assertThat(release.version()).isEqualTo("v1.3.0-rc.1"); assertThat(release.assetDownloadUrls()) - .containsOnly(entry("bsl-language-server_nix.zip", "https://example.invalid/rc.zip")); + .containsOnly(entry("bsl-language-server_nix.zip", downloadUrl("v1.3.0-rc.1", "nix"))); assertThat(requests).hasSize(1); assertThat(requests.get(0).uri().toString()) - .startsWith("https://api.github.com/repos/1c-syntax/bsl-language-server/releases?"); + .contains("/releases?per_page=1&page=1"); } @Test - void prereleaseChannelReadsNextPageWhenFirstPageContainsOnlyDrafts() throws IOException { - var draftsOnlyPage = """ - [{"tag_name": "v9.9.9", "draft": true, "prerelease": true, "assets": []}] - """; - var secondPage = """ - [{"tag_name": "v1.3.0-rc.1", "draft": false, "prerelease": true, "assets": []}] - """; - var client = new GitHubReleaseClient(null, httpClient(request -> - response(request, 200, request.uri().toString().endsWith("page=1") ? draftsOnlyPage : secondPage))); + void prereleaseChannelSkipsDraftPagesUntilNonDraft() throws IOException { + var client = new GitHubReleaseClient(null, httpClient(request -> { + var draftPage = request.uri().toString().endsWith("page=1"); + return response(request, 200, releaseArray( + draftPage + ? releaseObject("v9.9.9", true, "nix") + : releaseObject("v1.3.0-rc.1", false, "nix"))); + })); var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); @@ -139,11 +134,9 @@ void prereleaseChannelReadsNextPageWhenFirstPageContainsOnlyDrafts() throws IOEx } @Test - void prereleaseChannelStopsPagingAtBoundWhenEveryPageIsDrafts() { - var draftsOnlyPage = """ - [{"tag_name": "v9.9.9", "draft": true, "prerelease": true, "assets": []}] - """; - var client = new GitHubReleaseClient(null, httpClient(request -> response(request, 200, draftsOnlyPage))); + void prereleaseChannelStopsPagingAtBoundWhenEveryPageIsDraft() { + var client = new GitHubReleaseClient(null, httpClient(request -> + response(request, 200, releaseArray(releaseObject("v9.9.9", true, "nix"))))); assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE)) .isInstanceOf(IOException.class) @@ -153,12 +146,13 @@ void prereleaseChannelStopsPagingAtBoundWhenEveryPageIsDrafts() { } @Test - void prereleaseChannelFailsWhenThereAreNoReleases() { + void prereleaseChannelStopsAtEmptyPage() { var client = new GitHubReleaseClient(null, httpClient(200, "[]")); assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE)) .isInstanceOf(IOException.class) .hasMessageContaining("no suitable releases"); + assertThat(requests).hasSize(1); } @Test @@ -181,17 +175,10 @@ void failsOnHttpError() { } @Test - void failsOnMalformedResponse() { - var client = new GitHubReleaseClient(null, httpClient(200, "not a json")); - - assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) - .isInstanceOf(IOException.class) - .hasMessageContaining("Malformed JSON"); - } - - @Test - void failsWhenReleaseHasNoTagName() { - var client = new GitHubReleaseClient(null, httpClient(200, "{\"assets\": []}")); + void releaseWithoutMatchingAssetsIsTreatedAsNoSuitable() { + // Ответ 200, но ссылок на ассеты этого репозитория нет — версию вывести не из чего. + var client = new GitHubReleaseClient(null, + httpClient(200, "{\"tag_name\":\"v1.0.0\",\"draft\":false,\"assets\":[]}")); assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) .isInstanceOf(IOException.class) @@ -216,6 +203,32 @@ void interruptedRequestRestoresInterruptFlagAndFails() throws Exception { } } + private static String downloadUrl(String tag, String os) { + return "https://github.com/1c-syntax/bsl-language-server/releases/download/" + + tag + "/bsl-language-server_" + os + ".zip"; + } + + private static String asset(String os, String tag) { + return "{\"name\":\"bsl-language-server_" + os + ".zip\"," + + "\"browser_download_url\":\"" + downloadUrl(tag, os) + "\"}"; + } + + /** + * JSON одного релиза с ассетами под указанные ОС (как отдаёт {@code releases/latest}). + */ + private static String releaseObject(String tag, boolean draft, String... oses) { + var assets = Arrays.stream(oses).map(os -> asset(os, tag)).collect(Collectors.joining(",")); + return "{\"tag_name\":\"" + tag + "\",\"draft\":" + draft + + ",\"body\":\"notes\",\"assets\":[" + assets + "]}"; + } + + /** + * Обёртка релиза в список из одного элемента (как отдаёт {@code releases?per_page=1}). + */ + private static String releaseArray(String releaseObject) { + return "[" + releaseObject + "]"; + } + private HttpClient httpClient(int status, String body) { return httpClient(request -> response(request, status, body)); } diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java deleted file mode 100644 index f8d5d1c..0000000 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * This file is a part of 1c-syntax utils. - * - * Copyright (c) 2018-2026 - * Alexey Sosnoviy , Nikita Fedkin and contributors - * - * SPDX-License-Identifier: LGPL-3.0-or-later - * - * 1c-syntax utils is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3.0 of the License, or (at your option) any later version. - * - * 1c-syntax utils 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 - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with 1c-syntax utils. - */ -package com.github._1c_syntax.utils.downloader; - -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class JsonTest { - - @Test - void parsesScalars() throws IOException { - assertThat(Json.parse("\"text\"")).isEqualTo("text"); - assertThat(Json.parse("42")).isEqualTo(42L); - assertThat(Json.parse("-7")).isEqualTo(-7L); - assertThat(Json.parse("3.5")).isEqualTo(3.5d); - assertThat(Json.parse("1e3")).isEqualTo(1000d); - assertThat(Json.parse("true")).isEqualTo(Boolean.TRUE); - assertThat(Json.parse("false")).isEqualTo(Boolean.FALSE); - assertThat(Json.parse("null")).isNull(); - } - - @Test - void parsesNumberOutOfLongRangeAsDouble() throws IOException { - assertThat(Json.parse("123456789012345678901234567890")) - .isEqualTo(123456789012345678901234567890d); - } - - @Test - void parsesStringEscapes() throws IOException { - assertThat(Json.parse("\"a\\\"b\\\\c\\/d\\b\\f\\n\\r\\t\"")).isEqualTo("a\"b\\c/d\b\f\n\r\t"); - assertThat(Json.parse("\"\\u0416\\u045E\"")).isEqualTo("Жў"); - // суррогатная пара - assertThat(Json.parse("\"\\uD83D\\uDE00\"")).isEqualTo("😀"); - } - - @Test - void parsesObjectsAndArrays() throws IOException { - var value = Json.parse(""" - { - "tag_name": "v1.2.3", - "draft": false, - "assets": [ - {"name": "a.zip", "size": 100}, - {"name": "b.zip", "size": 200.5} - ], - "empty_object": {}, - "empty_array": [] - } - """); - - assertThat(value).isEqualTo(Map.of( - "tag_name", "v1.2.3", - "draft", false, - "assets", List.of( - Map.of("name", "a.zip", "size", 100L), - Map.of("name", "b.zip", "size", 200.5d)), - "empty_object", Map.of(), - "empty_array", List.of())); - } - - @Test - void parsesNestedArrays() throws IOException { - assertThat(Json.parse("[[1, 2], [], [null, true]]")) - .isEqualTo(List.of(List.of(1L, 2L), List.of(), Arrays.asList(null, true))); - } - - @Test - void rejectsMalformedJson() { - var samples = List.of( - "", - " ", - "{", - "[1, 2", - "{\"a\" 1}", - "{\"a\": 1,}", - "{\"a\": 1 \"b\": 2}", - "[1 2]", - "\"unterminated", - "\"bad escape \\x\"", - "\"bad unicode \\u12GX\"", - "\"signed unicode \\u+041\"", - "\"signed unicode \\u-041\"", - "\"truncated unicode \\u12", - "tru", - "nul", - "01a", - "--1", - "1.2.3", - "{} extra", - "42 43"); - - for (var malformed : samples) { - assertThatThrownBy(() -> Json.parse(malformed)) - .as("Json.parse(%s)", malformed) - .isInstanceOf(IOException.class); - } - } - - @Test - void rejectsUnescapedControlCharacterInString() { - assertThatThrownBy(() -> Json.parse("\"line\nbreak\"")).isInstanceOf(IOException.class); - } -} From d795dff5b359dda930712b3fb0d29479dc6fbc6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 13:44:28 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=80=D0=B5=D0=B3=D1=8D=D0=BA=D1=81=D0=BF=20?= =?UTF-8?q?=D0=B0=D1=81=D1=81=D0=B5=D1=82=D0=B0=20=D0=BA=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D1=83=20browser=5Fdownload=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью справедливо отметило: голая ссылка на старый ассет того же репозитория, упомянутая в релиз-ноутах (body), попадала бы в ASSET_URL и задавала неверную версию. Теперь совпадение привязано к ключу "browser_download_url", который в корректном ответе GitHub есть только у объектов assets[*], — текст body уже не совпадает. Регрессионный тест дополнен голой ссылкой на старый ассет в body. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WwLcL3YofsPmKBJpcDoVor --- .../utils/downloader/GitHubReleaseClient.java | 16 +++++++++------- .../downloader/GitHubReleaseClientTest.java | 9 +++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java index cddf836..8838a8c 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -63,12 +63,14 @@ public class GitHubReleaseClient { // в поле message; токен в теле не возвращается, так что утечки секрета нет). private static final int ERROR_BODY_LIMIT = 500; - // Ссылка на ассет релиза: https://github.com//releases/download//. - // Группа 1 — тег (версия), группа 2 — имя ассета, всё совпадение — URL для скачивания. Только - // browser_download_url имеет такой путь (у html_url — /releases/tag/, у API-ссылок другой хост), - // поэтому лишнего не захватываем. + // Ассет релиза: значение поля browser_download_url вида + // https://github.com//releases/download//. Группа 1 — сам URL, группа 2 — тег + // (версия), группа 3 — имя ассета. Совпадение привязано к ключу browser_download_url, а не к + // «любому URL в теле», — иначе такая же ссылка на старый ассет, упомянутая в релиз-ноутах (body), + // задала бы неверную версию. В корректном ответе GitHub этот ключ есть только у объектов assets[*]. private static final Pattern ASSET_URL = Pattern.compile( - "https://github\\.com/" + Pattern.quote(REPOSITORY) + "/releases/download/([^/\"]+)/([^/\"]+)"); + "\"browser_download_url\"\\s*:\\s*\"(https://github\\.com/" + + Pattern.quote(REPOSITORY) + "/releases/download/([^/\"]+)/([^/\"]+))\""); // Флаг draft у релиза. В pre-release-канале страница содержит ровно один релиз, поэтому // сопоставлять флаг конкретному объекту в списке не нужно. private static final Pattern DRAFT = Pattern.compile("\"draft\"\\s*:\\s*true"); @@ -160,9 +162,9 @@ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOE var matcher = ASSET_URL.matcher(body); while (matcher.find()) { if (version == null) { - version = matcher.group(1); + version = matcher.group(2); } - assetUrls.putIfAbsent(matcher.group(2), matcher.group()); + assetUrls.putIfAbsent(matcher.group(3), matcher.group(1)); } if (version == null) { return null; diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java index f46a58a..da15150 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java @@ -67,11 +67,12 @@ void stableChannelUsesLatestReleaseEndpoint() throws IOException { @Test void versionAndAssetsComeFromDownloadUrlNotFromReleaseBody() throws IOException { - // tag_name и body намеренно «отравлены»: фейковый тег и чужая download-ссылка в релиз-ноутах. - // Результат должен опираться только на настоящие ссылки на ассеты этого репозитория. + // body намеренно «отравлен»: голая ссылка на СТАРЫЙ ассет того же репозитория (до настоящих + // ассетов), чужая download-ссылка и фейковый tag_name. Результат должен опираться только на + // значения browser_download_url настоящих ассетов, а не на текст релиз-ноутов. var body = "{\"tag_name\":\"v0.0.0-fake\",\"draft\":false," - + "\"body\":\"см. https://github.com/evil/repo/releases/download/v9.9.9/bsl-language-server_nix.zip " - + "и {\\\"assets\\\":[]}\"," + + "\"body\":\"со времён " + downloadUrl("v0.20.0", "nix") + " раскладка изменилась; " + + "см. также https://github.com/evil/repo/releases/download/v9.9.9/bsl-language-server_nix.zip\"," + "\"assets\":[" + asset("nix", "v1.2.3") + "]}"; var client = new GitHubReleaseClient(null, httpClient(200, body)); From eb39e0b20e686e40cdf499ff229277210ee73d13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 13:53:02 +0000 Subject: [PATCH 6/7] =?UTF-8?q?=D0=A1=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20gson=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B3=D1=8D=D0=BA=D1=81=D0=BF=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По просьбе — переход на gson: лёгкий (~0.28 МБ, без транзитивов), OSGi-совместимый JSON-парсер вместо самодельного разбора регэкспом. Возвращён естественный поток: - pre-release снова берёт первый не-draft из списка releases (per_page=30) со строгим скоупингом ассетов к выбранному релизу, а не постранично по одному; - версия берётся из tag_name, флаг draft и ассеты — из своих полей структурно, поэтому содержимое body на результат не влияет (нулевой остаточный риск); - некорректный JSON — IOException, как ошибка обмена. Транзитивный error_prone_annotations (только compile-time) исключён, чтобы не попадал в рантайм-замкнутость: jspecify + slf4j-api + gson + semver4j. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WwLcL3YofsPmKBJpcDoVor --- build.gradle.kts | 5 + .../utils/downloader/GitHubReleaseClient.java | 124 ++++++++++-------- .../downloader/GitHubReleaseClientTest.java | 69 ++++++---- 3 files changed, 115 insertions(+), 83 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 5d61118..251b365 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,11 @@ dependencies { // Загрузчик BSL Language Server implementation("org.slf4j:slf4j-api:2.0.18") + implementation("com.google.code.gson:gson:2.13.1") { + // Чисто compile-time аннотации (@CanIgnoreReturnValue и пр.) — в рантайме не нужны, + // не тащим их в замкнутость. + exclude(group = "com.google.errorprone", module = "error_prone_annotations") + } implementation("org.semver4j:semver4j:6.0.0") testImplementation("org.assertj:assertj-core:3.27.7") diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java index 8838a8c..58bce06 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -21,6 +21,11 @@ */ package com.github._1c_syntax.utils.downloader; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; import org.jspecify.annotations.Nullable; import java.io.IOException; @@ -31,20 +36,16 @@ import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; -import java.util.regex.Pattern; /** * Клиент GitHub-релизов BSL Language Server: находит последний релиз канала в репозитории * {@value #REPOSITORY} через GitHub REST API. Работает на {@link java.net.http.HttpClient} - * и разбирает ответ регулярными выражениями по ссылкам на ассеты — без клиентских библиотек - * GitHub и внешних JSON-библиотек, чтобы рантайм-замкнутость оставалась минимальной (важно - * для встраивания в OSGi). + * и разбирает ответ через gson — без клиентских библиотек GitHub (и без Jackson, который тянула + * прежняя github-api), чтобы рантайм-замкнутость оставалась минимальной и OSGi-совместимой. * - *

Полноценный JSON-разбор не нужен: и стабильный, и pre-release-канал запрашиваются так, чтобы - * в ответе был ровно один релиз, а из него нужны лишь версия и ссылки на ассеты. И то, и другое - * берётся из самих download-ссылок вида - * {@code https://github.com//releases/download//} — поэтому произвольное - * содержимое поля {@code body} (релиз-ноуты) не влияет на результат. + *

Из ответа нужны лишь тег релиза, флаг {@code draft} и ассеты (имя + URL). Структурный разбор + * gson берёт их строго из нужных полей, поэтому произвольное содержимое поля {@code body} + * (релиз-ноуты) на результат не влияет. * *

Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток * скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito. @@ -55,29 +56,17 @@ public class GitHubReleaseClient { private static final String API_ROOT = "https://api.github.com"; private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); - // Верхняя граница пагинации pre-release: каждая страница — один релиз (per_page=1), цикл - // дочитывает хвост, только пока встречает draft'ы; ограничение защищает от бесконечного - // опроса, если апстрим (зеркало/прокси/кэш) отдаёт draft-релиз бесконечно. + // Релизы отдаются newest-first: не-draft почти всегда на первой странице, поэтому страницы + // небольшие; пагинация ниже дочитает хвост в вырожденном случае «страница целиком из драфтов». + private static final int RELEASES_PER_PAGE = 30; + // Верхняя граница пагинации: у настоящего GitHub цикл завершает пустая страница за последней, + // но зеркало/прокси/кэш может бесконечно отдавать одну и ту же непустую страницу драфтов — + // ограничение защищает от бесконечного опроса до упора в rate limit. private static final int MAX_RELEASES_PAGES = 10; // Сколько символов тела ответа включать в текст ошибки для диагностики (GitHub кладёт причину // в поле message; токен в теле не возвращается, так что утечки секрета нет). private static final int ERROR_BODY_LIMIT = 500; - // Ассет релиза: значение поля browser_download_url вида - // https://github.com//releases/download//. Группа 1 — сам URL, группа 2 — тег - // (версия), группа 3 — имя ассета. Совпадение привязано к ключу browser_download_url, а не к - // «любому URL в теле», — иначе такая же ссылка на старый ассет, упомянутая в релиз-ноутах (body), - // задала бы неверную версию. В корректном ответе GitHub этот ключ есть только у объектов assets[*]. - private static final Pattern ASSET_URL = Pattern.compile( - "\"browser_download_url\"\\s*:\\s*\"(https://github\\.com/" - + Pattern.quote(REPOSITORY) + "/releases/download/([^/\"]+)/([^/\"]+))\""); - // Флаг draft у релиза. В pre-release-канале страница содержит ровно один релиз, поэтому - // сопоставлять флаг конкретному объекту в списке не нужно. - private static final Pattern DRAFT = Pattern.compile("\"draft\"\\s*:\\s*true"); - // Есть ли в ответе вообще объект релиза — чтобы отличить его от пустого списка [] за последней - // страницей пагинации. - private static final Pattern HAS_RELEASE = Pattern.compile("\"tag_name\"\\s*:"); - private final @Nullable String token; private final HttpClient httpClient; @@ -112,66 +101,93 @@ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOE ? latestNonDraftRelease() : latestStableRelease(); - if (release == null) { + var result = release == null ? null : toRelease(release); + if (result == null) { throw new IOException( "Repository " + REPOSITORY + " has no suitable releases for channel " + channel); } - return release; + return result; } /** * Последний стабильный релиз: эндпоинт {@code releases/latest} сам исключает draft * и pre-release, а при полном отсутствии стабильных релизов отвечает 404. */ - private @Nullable Release latestStableRelease() throws IOException { + private @Nullable JsonObject latestStableRelease() throws IOException { var response = send("/repos/" + REPOSITORY + "/releases/latest"); if (response.statusCode() == 404) { return null; } - return parseRelease(body(response)); + return parse(body(response)) instanceof JsonObject release ? release : null; } /** - * Последний релиз с учётом pre-release. Запрашиваем по одному релизу на страницу - * ({@code per_page=1}, newest-first): так в ответе всегда ровно один релиз и не нужно - * сопоставлять ассеты нескольким релизам в списке. Draft'ы (видны только push-токену) - * пропускаем, переходя к следующей странице; анонимно GitHub их вообще не отдаёт. + * Последний релиз с учётом pre-release: список {@code releases} отдаётся newest-first, + * берём первый не-draft. Драфты видны только пользователям с push-доступом, но при вызове + * с таким токеном их нужно пропустить, дочитывая следующие страницы при необходимости. */ - private @Nullable Release latestNonDraftRelease() throws IOException { + private @Nullable JsonObject latestNonDraftRelease() throws IOException { for (var page = 1; page <= MAX_RELEASES_PAGES; page++) { - var body = get("/repos/" + REPOSITORY + "/releases?per_page=1&page=" + page); - if (!HAS_RELEASE.matcher(body).find()) { + var path = "/repos/" + REPOSITORY + "/releases?per_page=" + RELEASES_PER_PAGE + "&page=" + page; + if (!(parse(get(path)) instanceof JsonArray releases) || releases.isEmpty()) { return null; } - if (DRAFT.matcher(body).find()) { - continue; + for (JsonElement candidate : releases) { + if (candidate instanceof JsonObject release && !isDraft(release)) { + return release; + } } - return parseRelease(body); } return null; } + private static boolean isDraft(JsonObject release) { + return asBoolean(release.get("draft")); + } + /** - * Извлекает версию и ссылки на ассеты из ответа с одним релизом. Версия — тег из пути - * download-ссылки (у всех ассетов релиза он одинаковый), карта — «имя ассета → URL». Если - * ассетов нет, релиз бесполезен загрузчику — возвращается {@code null}. + * Извлекает из объекта релиза версию (тег) и карту «имя ассета → URL». Возвращает {@code null}, + * если у релиза нет тега. */ - private static @Nullable Release parseRelease(String body) { - var assetUrls = new LinkedHashMap(); - String version = null; - var matcher = ASSET_URL.matcher(body); - while (matcher.find()) { - if (version == null) { - version = matcher.group(2); - } - assetUrls.putIfAbsent(matcher.group(3), matcher.group(1)); - } + private static @Nullable Release toRelease(JsonObject release) { + var version = asString(release.get("tag_name")); if (version == null) { return null; } + var assetUrls = new LinkedHashMap(); + if (release.get("assets") instanceof JsonArray assets) { + for (JsonElement candidate : assets) { + if (candidate instanceof JsonObject asset) { + var name = asString(asset.get("name")); + var url = asString(asset.get("browser_download_url")); + if (name != null && url != null) { + assetUrls.putIfAbsent(name, url); + } + } + } + } return new Release(version, Map.copyOf(assetUrls)); } + private static @Nullable String asString(@Nullable JsonElement element) { + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString() + ? element.getAsString() + : null; + } + + private static boolean asBoolean(@Nullable JsonElement element) { + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isBoolean() + && element.getAsBoolean(); + } + + private static JsonElement parse(String body) throws IOException { + try { + return JsonParser.parseString(body); + } catch (JsonSyntaxException e) { + throw new IOException("Malformed GitHub API response", e); + } + } + private String get(String path) throws IOException { return body(send(path)); } diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java index da15150..4e7aadf 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java @@ -66,11 +66,10 @@ void stableChannelUsesLatestReleaseEndpoint() throws IOException { } @Test - void versionAndAssetsComeFromDownloadUrlNotFromReleaseBody() throws IOException { - // body намеренно «отравлен»: голая ссылка на СТАРЫЙ ассет того же репозитория (до настоящих - // ассетов), чужая download-ссылка и фейковый tag_name. Результат должен опираться только на - // значения browser_download_url настоящих ассетов, а не на текст релиз-ноутов. - var body = "{\"tag_name\":\"v0.0.0-fake\",\"draft\":false," + void releaseBodyDoesNotAffectStructuralParsing() throws IOException { + // body содержит и старую download-ссылку того же репозитория, и чужую — структурный разбор + // берёт версию из tag_name, а ассеты только из assets[], поэтому ссылки в body игнорируются. + var body = "{\"tag_name\":\"v1.2.3\",\"draft\":false," + "\"body\":\"со времён " + downloadUrl("v0.20.0", "nix") + " раскладка изменилась; " + "см. также https://github.com/evil/repo/releases/download/v9.9.9/bsl-language-server_nix.zip\"," + "\"assets\":[" + asset("nix", "v1.2.3") + "]}"; @@ -102,28 +101,29 @@ void blankTokenSendsNoAuthorizationHeader() throws IOException { } @Test - void prereleaseChannelRequestsSinglereleasePageAndPicksIt() throws IOException { - var client = new GitHubReleaseClient(null, - httpClient(200, releaseArray(releaseObject("v1.3.0-rc.1", false, "nix")))); + void prereleaseChannelPicksFirstNonDraftAndScopesAssetsToIt() throws IOException { + var json = "[" + releaseObject("v9.9.9", true, "nix") + "," + + releaseObject("v1.3.0-rc.1", false, "nix") + "," + + releaseObject("v1.2.3", false, "win") + "]"; + var client = new GitHubReleaseClient(null, httpClient(200, json)); var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); assertThat(release.version()).isEqualTo("v1.3.0-rc.1"); + // Ассеты именно выбранного релиза, а не соседних в списке. assertThat(release.assetDownloadUrls()) .containsOnly(entry("bsl-language-server_nix.zip", downloadUrl("v1.3.0-rc.1", "nix"))); assertThat(requests).hasSize(1); assertThat(requests.get(0).uri().toString()) - .contains("/releases?per_page=1&page=1"); + .startsWith("https://api.github.com/repos/1c-syntax/bsl-language-server/releases?"); } @Test - void prereleaseChannelSkipsDraftPagesUntilNonDraft() throws IOException { + void prereleaseChannelReadsNextPageWhenFirstPageContainsOnlyDrafts() throws IOException { var client = new GitHubReleaseClient(null, httpClient(request -> { - var draftPage = request.uri().toString().endsWith("page=1"); - return response(request, 200, releaseArray( - draftPage - ? releaseObject("v9.9.9", true, "nix") - : releaseObject("v1.3.0-rc.1", false, "nix"))); + var firstPage = request.uri().toString().endsWith("page=1"); + return response(request, 200, "[" + releaseObject( + firstPage ? "v9.9.9" : "v1.3.0-rc.1", firstPage, "nix") + "]"); })); var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); @@ -137,7 +137,7 @@ void prereleaseChannelSkipsDraftPagesUntilNonDraft() throws IOException { @Test void prereleaseChannelStopsPagingAtBoundWhenEveryPageIsDraft() { var client = new GitHubReleaseClient(null, httpClient(request -> - response(request, 200, releaseArray(releaseObject("v9.9.9", true, "nix"))))); + response(request, 200, "[" + releaseObject("v9.9.9", true, "nix") + "]"))); assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE)) .isInstanceOf(IOException.class) @@ -165,6 +165,26 @@ void stableChannelFailsWhenThereAreNoStableReleases() { .hasMessageContaining("no suitable releases"); } + @Test + void failsWhenReleaseHasNoTagName() { + var client = new GitHubReleaseClient(null, httpClient(200, "{\"draft\": false, \"assets\": []}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + } + + @Test + void releaseWithoutAssetsYieldsVersionAndEmptyMap() throws IOException { + var client = new GitHubReleaseClient(null, + httpClient(200, "{\"tag_name\": \"v1.0.0\", \"draft\": false, \"assets\": []}")); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(release.version()).isEqualTo("v1.0.0"); + assertThat(release.assetDownloadUrls()).isEmpty(); + } + @Test void failsOnHttpError() { var client = new GitHubReleaseClient(null, httpClient(403, "{\"message\": \"rate limit\"}")); @@ -176,14 +196,12 @@ void failsOnHttpError() { } @Test - void releaseWithoutMatchingAssetsIsTreatedAsNoSuitable() { - // Ответ 200, но ссылок на ассеты этого репозитория нет — версию вывести не из чего. - var client = new GitHubReleaseClient(null, - httpClient(200, "{\"tag_name\":\"v1.0.0\",\"draft\":false,\"assets\":[]}")); + void failsOnMalformedResponse() { + var client = new GitHubReleaseClient(null, httpClient(200, "{ this is not json")); assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) .isInstanceOf(IOException.class) - .hasMessageContaining("no suitable releases"); + .hasMessageContaining("Malformed"); } @Test @@ -215,7 +233,7 @@ private static String asset(String os, String tag) { } /** - * JSON одного релиза с ассетами под указанные ОС (как отдаёт {@code releases/latest}). + * JSON одного релиза с ассетами под указанные ОС. */ private static String releaseObject(String tag, boolean draft, String... oses) { var assets = Arrays.stream(oses).map(os -> asset(os, tag)).collect(Collectors.joining(",")); @@ -223,13 +241,6 @@ private static String releaseObject(String tag, boolean draft, String... oses) { + ",\"body\":\"notes\",\"assets\":[" + assets + "]}"; } - /** - * Обёртка релиза в список из одного элемента (как отдаёт {@code releases?per_page=1}). - */ - private static String releaseArray(String releaseObject) { - return "[" + releaseObject + "]"; - } - private HttpClient httpClient(int status, String body) { return httpClient(request -> response(request, status, body)); } From 4db02e2a2f517f283013446476244b8e624812a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 16:15:48 +0000 Subject: [PATCH 7/7] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5?= =?UTF-8?q?=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20=D0=BB=D0=B0=D1=83=D0=BD=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=D0=BD=D0=B0=20=D0=B2=D1=81=D0=B5=D1=85=20?= =?UTF-8?q?=D0=9E=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Инвариант «возвращённый бинарь исполняемый» держится и на Windows (.exe исполняется по расширению), поэтому нет причин отключать тест на Windows. Убран @DisabledOnOs(WINDOWS), тест переименован в OS-нейтральный. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WwLcL3YofsPmKBJpcDoVor --- .../utils/downloader/BslLanguageServerDownloaderTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 9d8dfa7..0247c8c 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -191,8 +191,7 @@ void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir } @Test - @DisabledOnOs(OS.WINDOWS) - void downloadIfNeededMarksLauncherExecutable(@TempDir Path installDir) throws IOException { + void downloadIfNeededReturnsExecutableLauncher(@TempDir Path installDir) throws IOException { var archive = zipWithLaunchers(300 * 1024); var releaseClient = mock(GitHubReleaseClient.class); when(releaseClient.latestRelease(any()))