/* * SonarQube * Copyright (C) 2009-2023 SonarSource SA * mailto:info AT sonarsource DOT com * * This program 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 of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package org.sonar.scanner.bootstrap; import com.google.common.annotations.VisibleForTesting; import java.io.BufferedInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.nio.file.Files; import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.sonar.api.config.Configuration; import org.sonar.scanner.bootstrap.ScannerPluginInstaller.InstalledPlugin; import org.sonarqube.ws.client.GetRequest; import org.sonarqube.ws.client.HttpException; import org.sonarqube.ws.client.WsResponse; import static java.lang.String.format; public class PluginFiles { private static final Logger LOGGER = LoggerFactory.getLogger(PluginFiles.class); private static final String MD5_HEADER = "Sonar-MD5"; @VisibleForTesting static final String PLUGINS_DOWNLOAD_TIMEOUT_PROPERTY = "sonar.plugins.download.timeout"; private static final int PLUGINS_DOWNLOAD_TIMEOUT_DEFAULT = 300; private final DefaultScannerWsClient wsClient; private final Configuration configuration; private final File cacheDir; private final File tempDir; public PluginFiles(DefaultScannerWsClient wsClient, Configuration configuration) { this.wsClient = wsClient; this.configuration = configuration; File home = locateHomeDir(configuration); this.cacheDir = mkdir(new File(home, "cache"), "user cache"); this.tempDir = mkdir(new File(home, "_tmp"), "temp dir"); LOGGER.info("User cache: {}", cacheDir.getAbsolutePath()); } public File createTempDir() { try { return Files.createTempDirectory(tempDir.toPath(), "plugins").toFile(); } catch (IOException e) { throw new IllegalStateException("Fail to create temp directory in " + tempDir, e); } } /** * Get the JAR file of specified plugin. If not present in user local cache, * then it's downloaded from server and added to cache. * * @return the file, or {@link Optional#empty()} if plugin not found (404 HTTP code) * @throws IllegalStateException if the plugin can't be downloaded (not 404 nor 2xx HTTP codes) * or can't be cached locally. */ public Optional get(InstalledPlugin plugin) { // Does not fail if another process tries to create the directory at the same time. File jarInCache = jarInCache(plugin.key, plugin.hash); if (jarInCache.exists() && jarInCache.isFile()) { return Optional.of(jarInCache); } return download(plugin); } private Optional download(InstalledPlugin plugin) { GetRequest request = new GetRequest("api/plugins/download") .setParam("plugin", plugin.key) .setTimeOutInMs(configuration.getInt(PLUGINS_DOWNLOAD_TIMEOUT_PROPERTY).orElse(PLUGINS_DOWNLOAD_TIMEOUT_DEFAULT) * 1000); File downloadedFile = newTempFile(); LOGGER.debug("Download plugin '{}' to '{}'", plugin.key, downloadedFile); try (WsResponse response = wsClient.call(request)) { Optional expectedMd5 = response.header(MD5_HEADER); if (!expectedMd5.isPresent()) { throw new IllegalStateException(format( "Fail to download plugin [%s]. Request to %s did not return header %s", plugin.key, response.requestUrl(), MD5_HEADER)); } downloadBinaryTo(plugin, downloadedFile, response); // verify integrity String effectiveTempMd5 = computeMd5(downloadedFile); if (!expectedMd5.get().equals(effectiveTempMd5)) { throw new IllegalStateException(format( "Fail to download plugin [%s]. File %s was expected to have checksum %s but had %s", plugin.key, downloadedFile, expectedMd5.get(), effectiveTempMd5)); } // un-compress if needed String cacheMd5; File tempJar; tempJar = downloadedFile; cacheMd5 = expectedMd5.get(); // put in cache File jarInCache = jarInCache(plugin.key, cacheMd5); mkdir(jarInCache.getParentFile()); moveFile(tempJar, jarInCache); return Optional.of(jarInCache); } catch (HttpException e) { if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) { // Plugin was listed but not longer available. It has probably been // uninstalled. return Optional.empty(); } // not 2xx nor 404 throw new IllegalStateException(format("Fail to download plugin [%s]. Request to %s returned code %d.", plugin.key, e.url(), e.code())); } } private static void downloadBinaryTo(InstalledPlugin plugin, File downloadedFile, WsResponse response) { try (InputStream stream = response.contentStream()) { FileUtils.copyInputStreamToFile(stream, downloadedFile); } catch (IOException e) { throw new IllegalStateException(format("Fail to download plugin [%s] into %s", plugin.key, downloadedFile), e); } } private File jarInCache(String pluginKey, String hash) { File hashDir = new File(cacheDir, hash); File file = new File(hashDir, format("sonar-%s-plugin.jar", pluginKey)); if (!file.getParentFile().toPath().equals(hashDir.toPath())) { // vulnerability - attempt to create a file outside the cache directory throw new IllegalStateException(format("Fail to download plugin [%s]. Key is not valid.", pluginKey)); } return file; } private File newTempFile() { try { return File.createTempFile("fileCache", null, tempDir); } catch (IOException e) { throw new IllegalStateException("Fail to create temp file in " + tempDir, e); } } private static String computeMd5(File file) { try (InputStream fis = new BufferedInputStream(FileUtils.openInputStream(file))) { return DigestUtils.md5Hex(fis); } catch (IOException e) { throw new IllegalStateException("Fail to compute md5 of " + file, e); } } private static void moveFile(File sourceFile, File targetFile) { boolean rename = sourceFile.renameTo(targetFile); // Check if the file was cached by another process during download if (!rename && !targetFile.exists()) { LOGGER.warn("Unable to rename {} to {}", sourceFile.getAbsolutePath(), targetFile.getAbsolutePath()); LOGGER.warn("A copy/delete will be tempted but with no guarantee of atomicity"); try { Files.move(sourceFile.toPath(), targetFile.toPath()); } catch (IOException e) { throw new IllegalStateException("Fail to move " + sourceFile.getAbsolutePath() + " to " + targetFile, e); } } } private static void mkdir(File dir) { try { Files.createDirectories(dir.toPath()); } catch (IOException e) { throw new IllegalStateException("Fail to create cache directory: " + dir, e); } } private static File mkdir(File dir, String debugTitle) { if (!dir.isDirectory() || !dir.exists()) { LOGGER.debug("Create : {}", dir.getAbsolutePath()); try { Files.createDirectories(dir.toPath()); } catch (IOException e) { throw new IllegalStateException("Unable to create " + debugTitle + dir.getAbsolutePath(), e); } } return dir; } private static File locateHomeDir(Configuration configuration) { return Stream.of( configuration.get("sonar.userHome").orElse(null), System.getenv("SONAR_USER_HOME"), System.getProperty("user.home") + File.separator + ".sonar") .filter(Objects::nonNull) .findFirst() .map(File::new) .get(); } }