Skip to content
Merged
7 changes: 5 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ 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("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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -88,8 +87,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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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());
Expand All @@ -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<PosixFilePermission> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,37 +21,72 @@
*/
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 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.util.HashMap;
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.LinkedHashMap;
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}
* и разбирает ответ через gson — без клиентских библиотек GitHub (и без Jackson, который тянула
* прежняя github-api), чтобы рантайм-замкнутость оставалась минимальной и OSGi-совместимой.
*
* <p>Из ответа нужны лишь тег релиза, флаг {@code draft} и ассеты (имя + URL). Структурный разбор
* gson берёт их строго из нужных полей, поэтому произвольное содержимое поля {@code body}
* (релиз-ноуты) на результат не влияет.
*
* <p>Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток
* скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito.
*/
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;
// Верхняя граница пагинации: у настоящего 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;

/**
* @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;
}

/**
Expand All @@ -62,37 +97,138 @@ 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);
var release = channel == BslLanguageServerReleaseChannel.PRERELEASE
? latestNonDraftRelease()
: latestStableRelease();

var result = release == null ? null : toRelease(release);
if (result == null) {
throw new IOException(
"Repository " + REPOSITORY + " has no suitable releases for channel " + channel);
}
return result;
}

/**
* Последний стабильный релиз: эндпоинт {@code releases/latest} сам исключает draft
* и pre-release, а при полном отсутствии стабильных релизов отвечает 404.
*/
private @Nullable JsonObject latestStableRelease() throws IOException {
var response = send("/repos/" + REPOSITORY + "/releases/latest");
if (response.statusCode() == 404) {
return null;
}
GitHub github = builder.build();

GHRepository repository = github.getRepository(REPOSITORY);

GHRelease release;
if (channel == BslLanguageServerReleaseChannel.PRERELEASE) {
// GitHub отдаёт релизы newest-first — берём первый не-draft, не подгружая все страницы.
release = null;
for (GHRelease candidate : repository.listReleases()) {
if (!candidate.isDraft()) {
release = candidate;
break;
return parse(body(response)) instanceof JsonObject release ? release : null;
}

/**
* Последний релиз с учётом pre-release: список {@code releases} отдаётся newest-first,
* берём первый не-draft. Драфты видны только пользователям с push-доступом, но при вызове
* с таким токеном их нужно пропустить, дочитывая следующие страницы при необходимости.
*/
private @Nullable JsonObject 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 (!(parse(get(path)) instanceof JsonArray releases) || releases.isEmpty()) {
return null;
}
for (JsonElement candidate : releases) {
if (candidate instanceof JsonObject release && !isDraft(release)) {
return release;
}
}
} else {
release = repository.getLatestRelease();
}
return null;
}

if (release == null) {
throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel);
private static boolean isDraft(JsonObject release) {
return asBoolean(release.get("draft"));
}

/**
* Извлекает из объекта релиза версию (тег) и карту «имя ассета → URL». Возвращает {@code null},
* если у релиза нет тега.
*/
private static @Nullable Release toRelease(JsonObject release) {
var version = asString(release.get("tag_name"));
if (version == null) {
return null;
}
var assetUrls = new LinkedHashMap<String, String>();
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));
}

private HttpResponse<String> 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);
}

var assetUrls = new HashMap<String, String>();
for (GHAsset asset : release.listAssets().toList()) {
assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl());
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<String> response) throws IOException {
if (response.statusCode() != 200) {
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 new Release(release.getTagName(), Map.copyOf(assetUrls));
return ": " + trimmed;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,20 @@ void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir
assertThat(downloader.installedVersion()).contains("1.2.3");
}

@Test
void downloadIfNeededReturnsExecutableLauncher(@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);
Expand Down
Loading
Loading