diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c2446c4df..9b96df8010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Set individual page title - Copy on write - A new repository can be initialized with a branch (for git and mercurial) and custom files (README.md on default) +- Plugins are validated directly after download ### Changed - Stop fetching commits when it takes too long diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java index 6f003c1e31..082058d663 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -19,11 +19,13 @@ class PluginInstaller { private final SCMContextProvider context; private final AdvancedHttpClient client; + private final SmpDescriptorExtractor smpDescriptorExtractor; @Inject - public PluginInstaller(SCMContextProvider context, AdvancedHttpClient client) { + public PluginInstaller(SCMContextProvider context, AdvancedHttpClient client, SmpDescriptorExtractor smpDescriptorExtractor) { this.context = context; this.client = client; + this.smpDescriptorExtractor = smpDescriptorExtractor; } @SuppressWarnings("squid:S4790") // hashing should be safe @@ -34,6 +36,7 @@ class PluginInstaller { Files.copy(input, file); verifyChecksum(plugin, input.hash(), file); + verifyConditions(plugin, file); return new PendingPluginInstallation(plugin.install(), file); } catch (IOException ex) { cleanup(file); @@ -64,6 +67,20 @@ class PluginInstaller { } } + private void verifyConditions(AvailablePlugin plugin, Path file) throws IOException { + InstalledPluginDescriptor pluginDescriptor = smpDescriptorExtractor.extractPluginDescriptor(file); + if (!pluginDescriptor.getCondition().isSupported()) { + cleanup(file); + throw new PluginConditionFailedException( + pluginDescriptor.getCondition(), + String.format( + "could not load plugin %s, the plugin condition does not match", + plugin.getDescriptor().getInformation().getName() + ) + ); + } + } + private InputStream download(AvailablePlugin plugin) throws IOException { return client.get(plugin.getDescriptor().getUrl()).request().contentAsStream(); } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java b/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java new file mode 100644 index 0000000000..b8e9cddabd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/SmpDescriptorExtractor.java @@ -0,0 +1,28 @@ +package sonia.scm.plugin; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +class SmpDescriptorExtractor { + + InstalledPluginDescriptor extractPluginDescriptor(Path file) throws IOException { + try (ZipInputStream zipInputStream = new ZipInputStream(Files.newInputStream(file), StandardCharsets.UTF_8)) { + ZipEntry nextEntry; + while ((nextEntry = zipInputStream.getNextEntry()) != null) { + if ("META-INF/scm/plugin.xml".equals(nextEntry.getName())) { + JAXBContext context = JAXBContext.newInstance(ScmModule.class, InstalledPluginDescriptor.class); + return (InstalledPluginDescriptor) context.createUnmarshaller().unmarshal(zipInputStream); + } + } + } catch (JAXBException e) { + throw new IOException("failed to read descriptor file META-INF/scm/plugin.xml from plugin", e); + } + throw new IOException("Missing plugin descriptor META-INF/scm/plugin.xml in download package"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java index 3f918cd4fa..fbc22e1ebd 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -32,18 +32,23 @@ class PluginInstallerTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) private AdvancedHttpClient client; + @Mock + private SmpDescriptorExtractor extractor; + @InjectMocks private PluginInstaller installer; private Path directory; @BeforeEach - void setUpContext(@TempDirectory.TempDir Path directory) { + void setUpContext(@TempDirectory.TempDir Path directory) throws IOException { this.directory = directory; lenient().when(context.resolve(any())).then(ic -> { Path arg = ic.getArgument(0); return directory.resolve(arg); }); + InstalledPluginDescriptor supportedPlugin = createPluginDescriptor(true); + lenient().when(extractor.extractPluginDescriptor(any())).thenReturn(supportedPlugin); } @Test @@ -105,6 +110,15 @@ class PluginInstallerTest { assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); } + @Test + void shouldFailForUnsupportedPlugin() throws IOException { + mockContent("42"); + InstalledPluginDescriptor supportedPlugin = createPluginDescriptor(false); + when(extractor.extractPluginDescriptor(any())).thenReturn(supportedPlugin); + + assertThrows(PluginConditionFailedException.class, () -> installer.install(createGitPlugin())); + assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).doesNotExist(); + } private AvailablePlugin createPlugin(String name, String url, String checksum) { PluginInformation information = new PluginInformation(); @@ -114,4 +128,11 @@ class PluginInstallerTest { ); return new AvailablePlugin(descriptor); } + + private InstalledPluginDescriptor createPluginDescriptor(boolean supported) { + InstalledPluginDescriptor installedPluginDescriptor = mock(InstalledPluginDescriptor.class, RETURNS_DEEP_STUBS); + lenient().when(installedPluginDescriptor.getCondition().isSupported()).thenReturn(supported); + lenient().when(installedPluginDescriptor.getInformation().getId()).thenReturn("scm-git-plugin"); + return installedPluginDescriptor; + } } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java new file mode 100644 index 0000000000..6ca19a3692 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/SmpDescriptorExtractorTest.java @@ -0,0 +1,76 @@ +package sonia.scm.plugin; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ExtendWith(TempDirectory.class) +class SmpDescriptorExtractorTest { + + private static final String PLUGIN_XML = "\n" + + "\n" + + "\n" + + " 2\n" + + "\n" + + " \n" + + " Test\n" + + " Cloudogu GmbH\n" + + " Testing\n" + + " scm-test-plugin\n" + + "2.0.0\n" + + "Collects information for support cases\n" + + "\n" + + "\n" + + " \n" + + " 2.0.0-rc1\n" + + " \n" + + "\n" + + " \n" + + " \n" + + " \n" + + "\n" + + "\n"; + + @Test + void shouldExtractPluginXml(@TempDirectory.TempDir Path tempDir) throws IOException { + Path pluginFile = createZipFile(tempDir, "META-INF/scm/plugin.xml", PLUGIN_XML); + + InstalledPluginDescriptor installedPluginDescriptor = new SmpDescriptorExtractor().extractPluginDescriptor(pluginFile); + + Assertions.assertThat(installedPluginDescriptor.getInformation().getName()).isEqualTo("scm-test-plugin"); + } + + @Test + void shouldFailWithoutPluginXml(@TempDirectory.TempDir Path tempDir) throws IOException { + Path pluginFile = createZipFile(tempDir, "META-INF/wrong/plugin.xml", PLUGIN_XML); + + assertThrows(IOException.class, () -> new SmpDescriptorExtractor().extractPluginDescriptor(pluginFile)); + } + + @Test + void shouldFailWithIllegalPluginXml(@TempDirectory.TempDir Path tempDir) throws IOException { + Path pluginFile = createZipFile(tempDir, "META-INF/scm/plugin.xml", "content"); + + assertThrows(IOException.class, () -> new SmpDescriptorExtractor().extractPluginDescriptor(pluginFile)); + } + + Path createZipFile(Path tempDir, String internalFileName, String content) throws IOException { + Path pluginFile = tempDir.resolve("scm-test-plugin.smp"); + ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(pluginFile), UTF_8); + zipOutputStream.putNextEntry(new ZipEntry(internalFileName)); + zipOutputStream.write(content.getBytes(UTF_8)); + zipOutputStream.closeEntry(); + zipOutputStream.close(); + return pluginFile; + } +}