record) throws ProcessingException {
final BomUploadedEvent event = record.value();
- final BomUploadStorageProvider storageProvider;
- try (final var qm = new QueryManager()) {
- final ConfigProperty storageProviderProperty = qm.getConfigProperty(
- BOM_UPLOAD_STORAGE_PROVIDER.getGroupName(),
- BOM_UPLOAD_STORAGE_PROVIDER.getPropertyName()
- );
- final String storageProviderClassName = storageProviderProperty != null
- ? storageProviderProperty.getPropertyValue()
- : BOM_UPLOAD_STORAGE_PROVIDER.getDefaultPropertyValue();
- storageProvider = BomUploadStorageProvider.getForClassName(storageProviderClassName);
- }
+ final var storageProviderFactory = PluginManager.getInstance().getFactory(BomUploadStorageProvider.class);
final var ctx = new Context(UUID.fromString(event.getToken()), event.getProject());
try (var ignoredMdcProjectUuid = MDC.putCloseable(MDC_PROJECT_UUID, ctx.project.getUuid().toString());
var ignoredMdcProjectName = MDC.putCloseable(MDC_PROJECT_NAME, ctx.project.getName());
var ignoredMdcProjectVersion = MDC.putCloseable(MDC_PROJECT_VERSION, ctx.project.getVersion());
- var ignoredMdcBomUploadToken = MDC.putCloseable(MDC_BOM_UPLOAD_TOKEN, ctx.token.toString())) {
+ var ignoredMdcBomUploadToken = MDC.putCloseable(MDC_BOM_UPLOAD_TOKEN, ctx.token.toString());
+ final BomUploadStorageProvider storageProvider = storageProviderFactory.create()) {
processEvent(ctx, storageProvider);
- } finally {
+
try {
storageProvider.deleteBomByToken(ctx.token);
} catch (IOException | RuntimeException e) {
diff --git a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java
index 2934f2428..29b9d9864 100644
--- a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java
+++ b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java
@@ -20,13 +20,10 @@
import alpine.model.IConfigProperty;
import alpine.model.IConfigProperty.PropertyType;
-import com.github.luben.zstd.Zstd;
import org.apache.commons.lang3.SystemUtils;
-import org.dependencytrack.storage.DatabaseBomUploadStorageProvider;
import java.util.Arrays;
import java.util.UUID;
-import java.util.concurrent.TimeUnit;
public enum ConfigPropertyConstants {
@@ -71,9 +68,6 @@ public enum ConfigPropertyConstants {
VULNERABILITY_SOURCE_EPSS_FEEDS_URL("vuln-source", "epss.feeds.url", "https://epss.cyentia.com", PropertyType.URL, "A base URL pointing to the hostname and path of the EPSS feeds", ConfigPropertyAccessMode.READ_WRITE),
ACCEPT_ARTIFACT_CYCLONEDX("artifact", "cyclonedx.enabled", "true", PropertyType.BOOLEAN, "Flag to enable/disable the systems ability to accept CycloneDX uploads", ConfigPropertyAccessMode.READ_WRITE),
BOM_VALIDATION_ENABLED("artifact", "bom.validation.enabled", "true", PropertyType.BOOLEAN, "Flag to control bom validation", ConfigPropertyAccessMode.READ_WRITE),
- BOM_UPLOAD_STORAGE_PROVIDER("artifact", "bom.upload.storage.provider", DatabaseBomUploadStorageProvider.class.getName(), PropertyType.STRING, "Class of the BOM upload storage provider", ConfigPropertyAccessMode.READ_WRITE),
- BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL("artifact", "bom.upload.storage.compression.level", String.valueOf(Zstd.defaultCompressionLevel()), PropertyType.INTEGER, "Compression level to use for storage of uploaded BOMs", ConfigPropertyAccessMode.READ_WRITE),
- BOM_UPLOAD_STORAGE_RETENTION_MS("artifact", "bom.upload.storage.retention.ms", String.valueOf(TimeUnit.HOURS.toMillis(1)), PropertyType.INTEGER, "Maximum storage retention duration for uploaded BOMs in milliseconds", ConfigPropertyAccessMode.READ_WRITE),
FORTIFY_SSC_ENABLED("integrations", "fortify.ssc.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable Fortify SSC integration", ConfigPropertyAccessMode.READ_WRITE),
FORTIFY_SSC_SYNC_CADENCE("integrations", "fortify.ssc.sync.cadence", "60", PropertyType.INTEGER, "The cadence (in minutes) to upload to Fortify SSC", ConfigPropertyAccessMode.READ_WRITE),
FORTIFY_SSC_URL("integrations", "fortify.ssc.url", null, PropertyType.URL, "Base URL to Fortify SSC", ConfigPropertyAccessMode.READ_WRITE),
diff --git a/src/main/java/org/dependencytrack/persistence/jdbi/BomDao.java b/src/main/java/org/dependencytrack/persistence/jdbi/BomDao.java
index 684437f45..94f3781e5 100644
--- a/src/main/java/org/dependencytrack/persistence/jdbi/BomDao.java
+++ b/src/main/java/org/dependencytrack/persistence/jdbi/BomDao.java
@@ -34,6 +34,10 @@ public interface BomDao {
@SqlUpdate("""
INSERT INTO "BOM_UPLOAD" ("TOKEN", "UPLOADED_AT", "BOM")
VALUES (:token, NOW(), :bomBytes)
+ ON CONFLICT ("TOKEN")
+ DO UPDATE
+ SET "UPLOADED_AT" = NOW()
+ , "BOM" = :bomBytes
""")
void createUpload(@Bind UUID token, @Bind byte[] bomBytes);
diff --git a/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java b/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java
new file mode 100644
index 000000000..a18eccdbe
--- /dev/null
+++ b/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java
@@ -0,0 +1,106 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.plugin;
+
+import alpine.Config;
+
+import java.util.Optional;
+
+import static java.util.Objects.requireNonNull;
+import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle;
+
+/**
+ * A read-only registry for accessing application configuration.
+ *
+ * The registry enforces namespacing of property names,
+ * to prevent {@link Provider}s from accessing values
+ * belonging to the core application, or other plugins.
+ *
+ * Namespacing is based on the plugin's, and the provider's name.
+ * Provider {@code foo} of plugin {@code bar} can access:
+ *
+ * - Runtime properties with {@code groupName} of {@code plugin} and {@code propertyName} starting with {@code bar.provider.foo}
+ * - Deployment properties prefix {@code bar.provider.foo}
+ *
+ *
+ * Runtime properties are sourced from the {@code CONFIGPROPERTY} database table.
+ * Deployment properties are sourced from environment variables, and the {@code application.properties} file.
+ *
+ * @since 5.6.0
+ */
+public class ConfigRegistry {
+
+ private final String pluginName;
+ private final String providerName;
+
+ public ConfigRegistry(final String pluginName, final String providerName) {
+ this.pluginName = requireNonNull(pluginName);
+ this.providerName = requireNonNull(providerName);
+ }
+
+ /**
+ * @param propertyName Name of the runtime property.
+ * @return An {@link Optional} holding the property value, or {@link Optional#empty()}.
+ */
+ public Optional getRuntimeProperty(final String propertyName) {
+ final String namespacedPropertyName = "%s.provider.%s.%s".formatted(pluginName, providerName, propertyName);
+
+ return withJdbiHandle(handle -> handle.createQuery("""
+ SELECT "PROPERTYVALUE"
+ FROM "CONFIGPROPERTY"
+ WHERE "GROUPNAME" = 'plugin'
+ AND "PROPERTYNAME" = :propertyName
+ """)
+ .bind("propertyName", namespacedPropertyName)
+ .mapTo(String.class)
+ .findOne());
+ }
+
+ /**
+ * @param propertyName Name of the deployment property.
+ * @return An {@link Optional} holding the property value, or {@link Optional#empty()}.
+ */
+ public Optional getDeploymentProperty(final String propertyName) {
+ final var key = new DeploymentConfigKey(pluginName, providerName, propertyName);
+ return Optional.ofNullable(Config.getInstance().getProperty(key));
+ }
+
+ record DeploymentConfigKey(String pluginName, String providerName, String name) implements Config.Key {
+
+ DeploymentConfigKey(final String pluginName, final String name) {
+ this(pluginName, null, name);
+ }
+
+ @Override
+ public String getPropertyName() {
+ if (providerName == null) {
+ return "%s.%s".formatted(pluginName, name);
+ }
+
+ return "%s.provider.%s.%s".formatted(pluginName, providerName, name);
+ }
+
+ @Override
+ public Object getDefaultValue() {
+ return null;
+ }
+
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/plugin/Plugin.java b/src/main/java/org/dependencytrack/plugin/Plugin.java
new file mode 100644
index 000000000..311e6600b
--- /dev/null
+++ b/src/main/java/org/dependencytrack/plugin/Plugin.java
@@ -0,0 +1,46 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.plugin;
+
+/**
+ * @since 5.6.0
+ */
+public interface Plugin {
+
+ /**
+ * @return The name of the plugin. Can contain lowercase letters, numbers, and periods.
+ */
+ String name();
+
+ /**
+ * @return Whether this plugin is required. Required plugins must have at least one active {@link Provider}.
+ */
+ boolean required();
+
+ /**
+ * @return Class of the {@link ProviderFactory}
+ */
+ Class extends ProviderFactory extends Provider>> providerFactoryClass();
+
+ /**
+ * @return Class of the {@link Provider}
+ */
+ Class extends Provider> providerClass();
+
+}
diff --git a/src/main/java/org/dependencytrack/plugin/PluginInitializer.java b/src/main/java/org/dependencytrack/plugin/PluginInitializer.java
new file mode 100644
index 000000000..e9ca7f08b
--- /dev/null
+++ b/src/main/java/org/dependencytrack/plugin/PluginInitializer.java
@@ -0,0 +1,47 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.plugin;
+
+import alpine.common.logging.Logger;
+
+import javax.servlet.ServletContextEvent;
+import javax.servlet.ServletContextListener;
+
+/**
+ * @since 5.6.0
+ */
+public class PluginInitializer implements ServletContextListener {
+
+ private static final Logger LOGGER = Logger.getLogger(PluginInitializer.class);
+
+ private final PluginManager pluginManager = PluginManager.getInstance();
+
+ @Override
+ public void contextInitialized(final ServletContextEvent event) {
+ LOGGER.info("Loading plugins");
+ pluginManager.loadPlugins();
+ }
+
+ @Override
+ public void contextDestroyed(final ServletContextEvent event) {
+ LOGGER.info("Unloading plugins");
+ pluginManager.unloadPlugins();
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/plugin/PluginManager.java b/src/main/java/org/dependencytrack/plugin/PluginManager.java
new file mode 100644
index 000000000..6e3df3684
--- /dev/null
+++ b/src/main/java/org/dependencytrack/plugin/PluginManager.java
@@ -0,0 +1,286 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.plugin;
+
+import alpine.Config;
+import alpine.common.logging.Logger;
+import org.dependencytrack.plugin.ConfigRegistry.DeploymentConfigKey;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.regex.Pattern;
+
+import static org.dependencytrack.plugin.ProviderFactory.PRIORITY_HIGHEST;
+import static org.dependencytrack.plugin.ProviderFactory.PRIORITY_LOWEST;
+
+/**
+ * @since 5.6.0
+ */
+public class PluginManager {
+
+ private record ProviderIdentity(Class extends Provider> clazz, String name) {
+ }
+
+ private static final Logger LOGGER = Logger.getLogger(PluginManager.class);
+ private static final Pattern PLUGIN_NAME_PATTERN = Pattern.compile("^[a-z0-9.]+$");
+ private static final Pattern PROVIDER_NAME_PATTERN = PLUGIN_NAME_PATTERN;
+ private static final String PROPERTY_PROVIDER_ENABLED = "enabled";
+ private static final String PROPERTY_DEFAULT_PROVIDER = "default.provider";
+ private static final PluginManager INSTANCE = new PluginManager();
+
+ private final List loadedPlugins;
+ private final Map, Plugin> pluginByProviderClass;
+ private final Map, Set> providerNamesByProviderClass;
+ private final Map> factoryByProviderKey;
+ private final Map, ProviderFactory>> defaultFactoryByProviderClass;
+ private final Comparator> providerFactoryComparator;
+ private final ReentrantLock lock;
+
+ private PluginManager() {
+ this.loadedPlugins = new ArrayList<>();
+ this.pluginByProviderClass = new HashMap<>();
+ this.providerNamesByProviderClass = new HashMap<>();
+ this.factoryByProviderKey = new HashMap<>();
+ this.defaultFactoryByProviderClass = new HashMap<>();
+ this.providerFactoryComparator = Comparator
+ .>comparingInt(ProviderFactory::priority)
+ .thenComparing(ProviderFactory::providerName);
+ this.lock = new ReentrantLock();
+ }
+
+ public static PluginManager getInstance() {
+ return INSTANCE;
+ }
+
+ public List getLoadedPlugins() {
+ return List.copyOf(loadedPlugins);
+ }
+
+ @SuppressWarnings("unchecked")
+ public > U getFactory(final Class providerClass) {
+ final ProviderFactory> factory = defaultFactoryByProviderClass.get(providerClass);
+ if (factory == null) {
+ return null;
+ }
+
+ return (U) factory;
+ }
+
+ @SuppressWarnings("unchecked")
+ public > SortedSet getFactories(final Class providerClass) {
+ final Set providerNames = providerNamesByProviderClass.get(providerClass);
+ if (providerNames == null) {
+ return Collections.emptySortedSet();
+ }
+
+ final var factories = new TreeSet(providerFactoryComparator);
+ for (final String providerName : providerNames) {
+ final var providerKey = new ProviderIdentity(providerClass, providerName);
+ final ProviderFactory> factory = factoryByProviderKey.get(providerKey);
+ if (factory != null) {
+ factories.add((U) factory);
+ }
+ }
+
+ return factories;
+ }
+
+ void loadPlugins() {
+ lock.lock();
+ try {
+ if (!loadedPlugins.isEmpty()) {
+ // NB: This is primarily to prevent erroneous redundant calls to loadPlugins.
+ // Under normal circumstances, this method will be called once on application
+ // startup, making this very unlikely to happen.
+ throw new IllegalStateException("Plugins were already loaded; Unload them first");
+ }
+
+ loadPluginsLocked();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void loadPluginsLocked() {
+ assert lock.isHeldByCurrentThread() : "Lock is not held by current thread";
+
+ LOGGER.debug("Discovering plugins");
+ final var pluginServiceLoader = ServiceLoader.load(Plugin.class);
+ for (final Plugin plugin : pluginServiceLoader) {
+ if (!PLUGIN_NAME_PATTERN.matcher(plugin.name()).matches()) {
+ throw new IllegalStateException("%s is not a valid plugin name".formatted(plugin.name()));
+ }
+
+ loadProvidersForPlugin(plugin);
+
+ LOGGER.debug("Loaded plugin %s".formatted(plugin.name()));
+ loadedPlugins.add(plugin);
+ }
+
+ determineDefaultProviders();
+
+ assertRequiredPlugins();
+ }
+
+ private void loadProvidersForPlugin(final Plugin plugin) {
+ LOGGER.debug("Discovering providers for plugin %s".formatted(plugin.name()));
+ final ServiceLoader extends ProviderFactory extends Provider>> providerFactoryServiceLoader = ServiceLoader.load(plugin.providerFactoryClass());
+ for (final ProviderFactory extends Provider> providerFactory : providerFactoryServiceLoader) {
+ if (!PROVIDER_NAME_PATTERN.matcher(providerFactory.providerName()).matches()) {
+ throw new IllegalStateException("%s is not a valid provider name".formatted(providerFactory.providerName()));
+ }
+
+ LOGGER.debug("Discovered provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name()));
+ final var configRegistry = new ConfigRegistry(plugin.name(), providerFactory.providerName());
+ final boolean isEnabled = configRegistry.getDeploymentProperty(PROPERTY_PROVIDER_ENABLED).map(Boolean::parseBoolean).orElse(true);
+ if (!isEnabled) {
+ LOGGER.debug("Provider %s for plugin %s is disabled; Skipping".formatted(providerFactory.providerName(), plugin.name()));
+ continue;
+ }
+
+ if (providerFactory.priority() < PRIORITY_HIGHEST) {
+ throw new IllegalStateException("""
+ Provider %s has an invalid priority of %d; \
+ Allowed range is [%d..%d] (highest to lowest priority)\
+ """.formatted(providerFactory.providerName(), providerFactory.priority(), PRIORITY_HIGHEST, PRIORITY_LOWEST)
+ );
+ }
+
+ LOGGER.debug("Initializing provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name()));
+ try {
+ providerFactory.init(configRegistry);
+ } catch (RuntimeException e) {
+ LOGGER.warn("Failed to initialize provider %s for plugin %s; Skipping".formatted(providerFactory.providerName(), plugin.name()), e);
+ continue;
+ }
+
+ pluginByProviderClass.put(plugin.providerClass(), plugin);
+
+ providerNamesByProviderClass.compute(plugin.providerClass(), (ignored, providerNames) -> {
+ if (providerNames == null) {
+ return new HashSet<>(Set.of(providerFactory.providerName()));
+ }
+
+ providerNames.add(providerFactory.providerName());
+ return providerNames;
+ });
+
+ final var providerIdentity = new ProviderIdentity(plugin.providerClass(), providerFactory.providerName());
+ factoryByProviderKey.put(providerIdentity, providerFactory);
+ }
+ }
+
+ private void determineDefaultProviders() {
+ for (final Class extends Provider> providerClass : providerNamesByProviderClass.keySet()) {
+ final SortedSet extends ProviderFactory>> factories = getFactories(providerClass);
+ if (factories == null || factories.isEmpty()) {
+ LOGGER.debug("No factories available for provider class %s; Skipping".formatted(providerClass.getName()));
+ continue;
+ }
+
+ final Plugin plugin = pluginByProviderClass.get(providerClass);
+ if (plugin == null) {
+ throw new IllegalStateException("""
+ No plugin exists for provider class %s; \
+ This is likely a logic error in the plugin loading procedure\
+ """.formatted(providerClass));
+ }
+
+ final ProviderFactory> providerFactory;
+ final var defaultProviderConfigKey = new DeploymentConfigKey(plugin.name(), PROPERTY_DEFAULT_PROVIDER);
+ final String providerName = Config.getInstance().getProperty(defaultProviderConfigKey);
+ if (providerName == null) {
+ LOGGER.debug("""
+ No default provider configured for plugin %s; \
+ Choosing based on priority""".formatted(plugin.name()));
+ providerFactory = factories.first();
+ LOGGER.debug("Chose provider %s with priority %d for plugin %s"
+ .formatted(providerFactory.providerName(), providerFactory.priority(), plugin.name()));
+ } else {
+ LOGGER.debug("Using configured default provider %s for plugin %s".formatted(providerName, plugin.name()));
+ providerFactory = factories.stream()
+ .filter(factory -> factory.providerName().equals(providerName))
+ .findFirst()
+ .orElseThrow(() -> new NoSuchElementException("""
+ No provider named %s exists for plugin %s\
+ """.formatted(providerName, plugin.name())));
+ }
+
+ defaultFactoryByProviderClass.put(providerClass, providerFactory);
+ }
+ }
+
+ private void assertRequiredPlugins() {
+ for (final Plugin plugin : loadedPlugins) {
+ if (!plugin.required()) {
+ continue;
+ }
+
+ if (getFactory(plugin.providerClass()) == null) {
+ throw new IllegalStateException("Plugin %s is required, but no provider is active".formatted(plugin.name()));
+ }
+ }
+ }
+
+ void unloadPlugins() {
+ lock.lock();
+ try {
+ unloadPluginsLocked();
+ defaultFactoryByProviderClass.clear();
+ factoryByProviderKey.clear();
+ providerNamesByProviderClass.clear();
+ pluginByProviderClass.clear();
+ loadedPlugins.clear();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void unloadPluginsLocked() {
+ assert lock.isHeldByCurrentThread() : "Lock is not held by current thread";
+
+ for (final Plugin plugin : loadedPlugins) {
+ LOGGER.debug("Closing providers for plugin %s".formatted(plugin.name()));
+
+ for (ProviderFactory> providerFactory : getFactories(plugin.providerClass())) {
+ LOGGER.debug("Closing provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name()));
+
+ try {
+ providerFactory.close();
+ } catch (RuntimeException e) {
+ LOGGER.warn("Failed to close provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name()), e);
+ }
+ }
+
+ LOGGER.debug("Unloaded plugin %s".formatted(plugin.name()));
+ }
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/plugin/Provider.java b/src/main/java/org/dependencytrack/plugin/Provider.java
new file mode 100644
index 000000000..c7f144f16
--- /dev/null
+++ b/src/main/java/org/dependencytrack/plugin/Provider.java
@@ -0,0 +1,34 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.plugin;
+
+/**
+ * @since 5.6.0
+ */
+public interface Provider extends AutoCloseable {
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ default void close() {
+ // Default no-op to remove checked exception from method signature.
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/plugin/ProviderFactory.java b/src/main/java/org/dependencytrack/plugin/ProviderFactory.java
new file mode 100644
index 000000000..74eee2d07
--- /dev/null
+++ b/src/main/java/org/dependencytrack/plugin/ProviderFactory.java
@@ -0,0 +1,60 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.plugin;
+
+/**
+ * @since 5.6.0
+ */
+public interface ProviderFactory extends AutoCloseable {
+
+ int PRIORITY_HIGHEST = 0;
+ int PRIORITY_LOWEST = Integer.MAX_VALUE;
+
+ /**
+ * @return Name of the provider. Can contain lowercase letters, numbers, and periods.
+ */
+ String providerName();
+
+ /**
+ * @return Priority of the provider. Must be a value between {@value #PRIORITY_HIGHEST}
+ * (highest priority) and {@value #PRIORITY_LOWEST} (lowest priority).
+ */
+ int priority();
+
+ /**
+ * Initialize the factory. This method is called once during application startup.
+ *
+ * @param configRegistry A {@link ConfigRegistry} to read configuration from.
+ */
+ void init(final ConfigRegistry configRegistry);
+
+ /**
+ * @return An instance of {@link T}.
+ */
+ T create();
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ default void close() {
+ // Default no-op to remove checked exception from method signature.
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/resources/v1/AbstractConfigPropertyResource.java b/src/main/java/org/dependencytrack/resources/v1/AbstractConfigPropertyResource.java
index 3fb0810fd..45a7c7027 100644
--- a/src/main/java/org/dependencytrack/resources/v1/AbstractConfigPropertyResource.java
+++ b/src/main/java/org/dependencytrack/resources/v1/AbstractConfigPropertyResource.java
@@ -24,11 +24,9 @@
import alpine.model.IConfigProperty;
import alpine.security.crypto.DataEncryption;
import alpine.server.resources.AlpineResource;
-import com.github.luben.zstd.Zstd;
import org.dependencytrack.model.ConfigPropertyAccessMode;
import org.dependencytrack.model.ConfigPropertyConstants;
import org.dependencytrack.persistence.QueryManager;
-import org.dependencytrack.storage.BomUploadStorageProvider;
import org.owasp.security.logging.SecurityMarkers;
import javax.ws.rs.core.Response;
@@ -67,24 +65,6 @@ private Response updatePropertyValueInternal(IConfigProperty json, IConfigProper
.build();
}
- if (wellKnownProperty == ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER
- && !BomUploadStorageProvider.exists(json.getPropertyValue())) {
- return Response
- .status(Response.Status.BAD_REQUEST)
- .entity("%s is not a known storage provider".formatted(json.getPropertyValue()))
- .build();
- } else if (wellKnownProperty == ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL
- && json.getPropertyValue() != null) {
- final int compressionLevel = Integer.parseInt(json.getPropertyValue());
- if (compressionLevel < 1 || compressionLevel > Zstd.maxCompressionLevel()) {
- return Response
- .status(Response.Status.BAD_REQUEST)
- .entity("Compression level %d is out of the valid [1..%d] range"
- .formatted(compressionLevel, Zstd.maxCompressionLevel()))
- .build();
- }
- }
-
if (property.getPropertyType() == IConfigProperty.PropertyType.BOOLEAN) {
boolean propertyValue = BooleanUtil.valueOf(json.getPropertyValue());
if (ConfigPropertyConstants.CUSTOM_RISK_SCORE_HISTORY_ENABLED.getPropertyName().equals(json.getPropertyName())){
diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java
index 44ff105a5..87484ec2c 100644
--- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java
+++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java
@@ -19,7 +19,6 @@
package org.dependencytrack.resources.v1;
import alpine.common.logging.Logger;
-import alpine.model.ConfigProperty;
import alpine.server.auth.PermissionRequired;
import alpine.server.resources.AlpineResource;
import io.swagger.annotations.Api;
@@ -45,6 +44,7 @@
import org.dependencytrack.parser.cyclonedx.CycloneDxValidator;
import org.dependencytrack.parser.cyclonedx.InvalidBomException;
import org.dependencytrack.persistence.QueryManager;
+import org.dependencytrack.plugin.PluginManager;
import org.dependencytrack.resources.v1.problems.InvalidBomProblemDetails;
import org.dependencytrack.resources.v1.problems.ProblemDetails;
import org.dependencytrack.resources.v1.vo.BomSubmitRequest;
@@ -77,8 +77,6 @@
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
-import static org.dependencytrack.model.ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL;
-import static org.dependencytrack.model.ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER;
import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_ENABLED;
/**
@@ -435,7 +433,7 @@ private Response process(QueryManager qm, Project project, String encodedBomData
final var decodedInputStream = Base64.getDecoder().wrap(encodedInputStream);
final var byteOrderMarkInputStream = new BOMInputStream(decodedInputStream)) {
final byte[] bomBytes = IOUtils.toByteArray(byteOrderMarkInputStream);
- validateAndStoreBom(qm, bomUploadEvent.getChainIdentifier(), bomBytes);
+ validateAndStoreBom(bomUploadEvent.getChainIdentifier(), bomBytes);
} catch (IOException e) {
LOGGER.error("An unexpected error occurred while validating or storing a BOM uploaded to project: " + project.getUuid(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
@@ -467,7 +465,7 @@ private Response process(QueryManager qm, Project project, List loadedPlugins = providerManager.getLoadedPlugins().stream()
+ .map(plugin -> {
+ final SortedSet extends ProviderFactory extends Provider>> factories =
+ providerManager.getFactories(plugin.providerClass());
+ final List providerNames = factories.stream()
+ .map(ProviderFactory::providerName)
+ .toList();
+
+ final ProviderFactory> defaultFactory = providerManager.getFactory(plugin.providerClass());
+ final String defaultProviderName = defaultFactory != null ? defaultFactory.providerName() : null;
+
+ return new LoadedPluginListResponseItem(plugin.name(), providerNames, defaultProviderName);
+ })
+ .toList();
+
+ return Response.ok(loadedPlugins).build();
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java
new file mode 100644
index 000000000..1288a2f8e
--- /dev/null
+++ b/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java
@@ -0,0 +1,35 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v1.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModelProperty;
+
+import java.util.List;
+
+/**
+ * @since 5.6.0
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record LoadedPluginListResponseItem(
+ @ApiModelProperty(value = "Name of the plugin", required = true) String name,
+ @ApiModelProperty(value = "Names of all loaded providers for the plugin") List providers,
+ @ApiModelProperty(value = "Name of the default provider for the plugin") String defaultProvider
+) {
+}
diff --git a/src/main/java/org/dependencytrack/storage/BomUploadStoragePlugin.java b/src/main/java/org/dependencytrack/storage/BomUploadStoragePlugin.java
new file mode 100644
index 000000000..7856714ab
--- /dev/null
+++ b/src/main/java/org/dependencytrack/storage/BomUploadStoragePlugin.java
@@ -0,0 +1,52 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.storage;
+
+import org.dependencytrack.plugin.Plugin;
+import org.dependencytrack.plugin.Provider;
+import org.dependencytrack.plugin.ProviderFactory;
+
+/**
+ * @since 5.6.0
+ */
+public class BomUploadStoragePlugin implements Plugin {
+
+ static final String PLUGIN_NAME = "bom.upload.storage";
+
+ @Override
+ public String name() {
+ return PLUGIN_NAME;
+ }
+
+ @Override
+ public boolean required() {
+ return true;
+ }
+
+ @Override
+ public Class extends ProviderFactory extends Provider>> providerFactoryClass() {
+ return BomUploadStorageProviderFactory.class;
+ }
+
+ @Override
+ public Class extends Provider> providerClass() {
+ return BomUploadStorageProvider.class;
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/storage/BomUploadStorageProvider.java b/src/main/java/org/dependencytrack/storage/BomUploadStorageProvider.java
index c6da4d2ba..cf2803446 100644
--- a/src/main/java/org/dependencytrack/storage/BomUploadStorageProvider.java
+++ b/src/main/java/org/dependencytrack/storage/BomUploadStorageProvider.java
@@ -19,31 +19,57 @@
package org.dependencytrack.storage;
import com.github.luben.zstd.Zstd;
+import org.dependencytrack.plugin.Provider;
import java.io.IOException;
import java.time.Duration;
-import java.util.NoSuchElementException;
-import java.util.ServiceLoader;
import java.util.UUID;
/**
* @since 5.6.0
*/
-public interface BomUploadStorageProvider {
+public interface BomUploadStorageProvider extends Provider {
+ /**
+ * @param token The token to store the BOM for.
+ * @param bom The BOM to store.
+ * @throws IOException When storing the BOM failed.
+ */
void storeBom(final UUID token, final byte[] bom) throws IOException;
+ /**
+ * @param token The token to get the BOM for.
+ * @return The BOM, or {@code null} when no BOM was found.
+ * @throws IOException When getting the BOM failed.
+ */
byte[] getBomByToken(final UUID token) throws IOException;
+ /**
+ * @param token The token to delete the BOM for.
+ * @return {@code true} when the BOM was deleted, otherwise {@code false}.
+ * @throws IOException When deleting the BOM failed.
+ */
boolean deleteBomByToken(final UUID token) throws IOException;
int deleteBomsForRetentionDuration(final Duration duration) throws IOException;
+ /**
+ * @param token The token to store the BOM for.
+ * @param bom The BOM to store.
+ * @param compressionLevel The compression level to use.
+ * @throws IOException When storing the BOM failed.
+ * @see #storeBom(UUID, byte[])
+ */
default void storeBomCompressed(final UUID token, final byte[] bom, final int compressionLevel) throws IOException {
final byte[] compressedBom = Zstd.compress(bom, compressionLevel);
storeBom(token, compressedBom);
}
+ /**
+ * @param token The token to get the BOM for.
+ * @return The BOM, or {@code null} when no BOM was found.
+ * @throws IOException When getting the BOM failed.
+ */
default byte[] getDecompressedBomByToken(final UUID token) throws IOException {
final byte[] compressedBom = getBomByToken(token);
if (compressedBom == null) {
@@ -58,18 +84,4 @@ default byte[] getDecompressedBomByToken(final UUID token) throws IOException {
return Zstd.decompress(compressedBom, (int) decompressedSize);
}
- static BomUploadStorageProvider getForClassName(final String providerClassName) {
- final var serviceLoader = ServiceLoader.load(BomUploadStorageProvider.class);
- return serviceLoader.stream()
- .filter(provider -> provider.type().getName().equals(providerClassName))
- .findFirst()
- .map(ServiceLoader.Provider::get)
- .orElseThrow(() -> new NoSuchElementException("%s is not a known storage provider".formatted(providerClassName)));
- }
-
- static boolean exists(final String providerClassName) {
- final var serviceLoader = ServiceLoader.load(BomUploadStorageProvider.class);
- return serviceLoader.stream().anyMatch(provider -> provider.type().getName().equals(providerClassName));
- }
-
}
diff --git a/src/main/java/org/dependencytrack/storage/BomUploadStorageProviderFactory.java b/src/main/java/org/dependencytrack/storage/BomUploadStorageProviderFactory.java
new file mode 100644
index 000000000..be0141bd2
--- /dev/null
+++ b/src/main/java/org/dependencytrack/storage/BomUploadStorageProviderFactory.java
@@ -0,0 +1,27 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.storage;
+
+import org.dependencytrack.plugin.ProviderFactory;
+
+/**
+ * @since 5.6.0
+ */
+public interface BomUploadStorageProviderFactory extends ProviderFactory {
+}
diff --git a/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProvider.java b/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProvider.java
index c40a3ccfc..670cbdd3a 100644
--- a/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProvider.java
+++ b/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProvider.java
@@ -28,9 +28,13 @@
import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle;
/**
+ * A {@link BomUploadStorageProvider} that stores uploaded BOMs in the {@code BOM_UPLOAD} database table.
+ *
* @since 5.6.0
*/
-public class DatabaseBomUploadStorageProvider implements BomUploadStorageProvider {
+class DatabaseBomUploadStorageProvider implements BomUploadStorageProvider {
+
+ static final String NAME = "database";
@Override
public void storeBom(final UUID token, final byte[] bom) {
diff --git a/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderFactory.java b/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderFactory.java
new file mode 100644
index 000000000..24d2bce41
--- /dev/null
+++ b/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderFactory.java
@@ -0,0 +1,48 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.storage;
+
+import org.dependencytrack.plugin.ConfigRegistry;
+
+/**
+ * @since 5.6.0
+ */
+public class DatabaseBomUploadStorageProviderFactory implements BomUploadStorageProviderFactory {
+
+ @Override
+ public String providerName() {
+ return DatabaseBomUploadStorageProvider.NAME;
+ }
+
+ @Override
+ public int priority() {
+ return 100;
+ }
+
+ @Override
+ public void init(final ConfigRegistry configRegistry) {
+ // Nothing to configure.
+ }
+
+ @Override
+ public BomUploadStorageProvider create() {
+ return new DatabaseBomUploadStorageProvider();
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProvider.java b/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProvider.java
index 3c4754b39..5fbdc522c 100644
--- a/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProvider.java
+++ b/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProvider.java
@@ -18,14 +18,13 @@
*/
package org.dependencytrack.storage;
-import alpine.Config;
import alpine.common.logging.Logger;
-import org.dependencytrack.common.ClusterInfo;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Duration;
@@ -33,19 +32,17 @@
import java.util.UUID;
/**
+ * A {@link BomUploadStorageProvider} that stores uploaded BOMs on the local filesystem.
+ *
* @since 5.6.0
*/
-public class LocalBomUploadStorageProvider implements BomUploadStorageProvider {
+class LocalBomUploadStorageProvider implements BomUploadStorageProvider {
private static final Logger LOGGER = Logger.getLogger(LocalBomUploadStorageProvider.class);
+ static final String NAME = "local";
private final Path baseDirPath;
- @SuppressWarnings("unused") // Used by ServiceLoader.
- public LocalBomUploadStorageProvider() {
- this(defaultBaseDirPath());
- }
-
LocalBomUploadStorageProvider(final Path baseDirPath) {
this.baseDirPath = baseDirPath;
}
@@ -53,7 +50,7 @@ public LocalBomUploadStorageProvider() {
@Override
public void storeBom(final UUID token, final byte[] bom) throws IOException {
final Path outputFilePath = baseDirPath.resolve(token.toString());
- LOGGER.info("Storing BOM at %s".formatted(outputFilePath));
+ LOGGER.debug("Storing BOM at %s".formatted(outputFilePath));
try (final var fileOutputStream = Files.newOutputStream(outputFilePath);
final var bufferedOutputStream = new BufferedOutputStream(fileOutputStream)) {
@@ -64,15 +61,19 @@ public void storeBom(final UUID token, final byte[] bom) throws IOException {
@Override
public byte[] getBomByToken(final UUID token) throws IOException {
final Path inputFilePath = baseDirPath.resolve(token.toString());
- LOGGER.info("Retrieving BOM from %s".formatted(inputFilePath));
+ LOGGER.debug("Retrieving BOM from %s".formatted(inputFilePath));
- return Files.readAllBytes(inputFilePath);
+ try {
+ return Files.readAllBytes(inputFilePath);
+ } catch (NoSuchFileException e) {
+ return null;
+ }
}
@Override
public boolean deleteBomByToken(final UUID token) throws IOException {
final Path bomFilePath = baseDirPath.resolve(token.toString());
- LOGGER.info("Deleting BOM from %s".formatted(token));
+ LOGGER.debug("Deleting BOM from %s".formatted(token));
return Files.deleteIfExists(bomFilePath);
}
@@ -94,7 +95,7 @@ public int deleteBomsForRetentionDuration(final Duration duration) throws IOExce
// TODO: Is this problematic for network volumes in other timezones?
final var attributes = Files.readAttributes(filePath, BasicFileAttributes.class);
if (retentionCutoff.isAfter(attributes.lastModifiedTime().toInstant())) {
- LOGGER.info("Deleting BOM from %s".formatted(filePath));
+ LOGGER.debug("Deleting BOM from %s".formatted(filePath));
Files.delete(filePath);
bomFilesDeleted++;
}
@@ -103,18 +104,4 @@ public int deleteBomsForRetentionDuration(final Duration duration) throws IOExce
return bomFilesDeleted;
}
- private static Path defaultBaseDirPath() {
- // TODO: Use separate, operator-controllable directory.
- final Path dataDirPath = Config.getInstance().getDataDirectorty().toPath();
- final Path bomUploadsDirPath = dataDirPath.resolve("bom-uploads").resolve(ClusterInfo.getClusterId());
-
- try {
- return Files.createDirectories(bomUploadsDirPath);
- } catch (IOException e) {
- throw new IllegalStateException("""
- Failed to create directory for BOM upload storage at %s\
- """.formatted(bomUploadsDirPath), e);
- }
- }
-
}
diff --git a/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProviderFactory.java b/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProviderFactory.java
new file mode 100644
index 000000000..57f02b027
--- /dev/null
+++ b/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProviderFactory.java
@@ -0,0 +1,79 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.storage;
+
+import alpine.Config;
+import alpine.common.logging.Logger;
+import org.dependencytrack.plugin.ConfigRegistry;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * @since 5.6.0
+ */
+public class LocalBomUploadStorageProviderFactory implements BomUploadStorageProviderFactory {
+
+ private static final Logger LOGGER = Logger.getLogger(LocalBomUploadStorageProviderFactory.class);
+
+ private Path directoryPath;
+
+ @Override
+ public String providerName() {
+ return LocalBomUploadStorageProvider.NAME;
+ }
+
+ @Override
+ public int priority() {
+ return 110;
+ }
+
+ @Override
+ public void init(final ConfigRegistry configRegistry) {
+ directoryPath = configRegistry.getDeploymentProperty("directory")
+ .map(Paths::get)
+ .orElseGet(() -> {
+ final Path path = Config.getInstance().getDataDirectorty().toPath().resolve("bom-uploads");
+ try {
+ return Files.createDirectories(path);
+ } catch (IOException e) {
+ throw new IllegalStateException("""
+ Failed to create directory for BOM upload storage at %s\
+ """.formatted(path), e);
+ }
+ });
+
+ final boolean canRead = directoryPath.toFile().canRead();
+ final boolean canWrite = directoryPath.toFile().canWrite();
+ if (!canRead || !canWrite) {
+ throw new IllegalStateException("Insufficient permissions for directory %s (canRead=%s, canWrite=%s)"
+ .formatted(directoryPath, canRead, canWrite));
+ }
+
+ LOGGER.info("BOM uploads will be stored in %s".formatted(directoryPath));
+ }
+
+ @Override
+ public BomUploadStorageProvider create() {
+ return new LocalBomUploadStorageProvider(directoryPath);
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProvider.java b/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProvider.java
new file mode 100644
index 000000000..cf292a65e
--- /dev/null
+++ b/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProvider.java
@@ -0,0 +1,106 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.storage;
+
+import io.minio.GetObjectArgs;
+import io.minio.GetObjectResponse;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.RemoveObjectArgs;
+import io.minio.errors.ErrorResponseException;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.UUID;
+
+/**
+ * A {@link BomUploadStorageProvider} that stores uploaded BOMs in an S3 bucket.
+ *
+ * @since 5.6.0
+ */
+class S3BomUploadStorageProvider implements BomUploadStorageProvider {
+
+ static final String NAME = "s3";
+
+ private final MinioClient s3Client;
+ private final String bucketName;
+
+ S3BomUploadStorageProvider(final MinioClient s3Client, final String bucketName) {
+ this.s3Client = s3Client;
+ this.bucketName = bucketName;
+ }
+
+ @Override
+ public void storeBom(final UUID token, final byte[] bom) throws IOException {
+ try {
+ s3Client.putObject(PutObjectArgs.builder()
+ .bucket(bucketName)
+ .object(token.toString())
+ .stream(new ByteArrayInputStream(bom), bom.length, -1)
+ .build());
+ } catch (Exception e) {
+ throw new IOException("Failed to store BOM for token %s".formatted(token), e);
+ }
+ }
+
+ @Override
+ public byte[] getBomByToken(final UUID token) throws IOException {
+ try {
+ try (final GetObjectResponse response = s3Client.getObject(GetObjectArgs.builder()
+ .bucket(bucketName)
+ .object(token.toString())
+ .build())) {
+ return response.readAllBytes();
+ }
+ } catch (ErrorResponseException e) {
+ // https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html#ErrorCodeList
+ if ("NoSuchKey".equalsIgnoreCase(e.errorResponse().code())) {
+ return null;
+ }
+
+ throw new IOException("Failed to get BOM for token %s".formatted(token), e);
+ } catch (Exception e) {
+ throw new IOException("Failed to get BOM for token %s".formatted(token), e);
+ }
+ }
+
+ @Override
+ public boolean deleteBomByToken(final UUID token) throws IOException {
+ try {
+ s3Client.removeObject(RemoveObjectArgs.builder()
+ .bucket(bucketName)
+ .object(token.toString())
+ .build());
+ } catch (Exception e) {
+ throw new IOException("Failed to delete BOM for token %s".formatted(token), e);
+ }
+
+ // NB: S3 doesn't return any indication or error if the object
+ // to be deleted did not exist. We have to assume that if the
+ // request succeeded, it has successfully deleted the object.
+ return true;
+ }
+
+ @Override
+ public int deleteBomsForRetentionDuration(final Duration duration) throws IOException {
+ return 0;
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProviderFactory.java b/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProviderFactory.java
new file mode 100644
index 000000000..8320242d2
--- /dev/null
+++ b/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProviderFactory.java
@@ -0,0 +1,99 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.storage;
+
+import alpine.common.logging.Logger;
+import io.minio.BucketExistsArgs;
+import io.minio.MinioClient;
+import org.dependencytrack.plugin.ConfigRegistry;
+
+import java.util.Optional;
+
+import static java.util.function.Predicate.not;
+
+/**
+ * @since 5.6.0
+ */
+public class S3BomUploadStorageProviderFactory implements BomUploadStorageProviderFactory {
+
+ private static final Logger LOGGER = Logger.getLogger(S3BomUploadStorageProviderFactory.class);
+
+ private MinioClient s3Client;
+ private String bucketName;
+
+ @Override
+ public String providerName() {
+ return S3BomUploadStorageProvider.NAME;
+ }
+
+ @Override
+ public int priority() {
+ return 120;
+ }
+
+ @Override
+ public void init(final ConfigRegistry configRegistry) {
+ final String endpoint = configRegistry.getDeploymentProperty("endpoint")
+ .map(String::trim)
+ .filter(not(String::isEmpty))
+ .orElseThrow(() -> new IllegalStateException("No endpoint configured"));
+ bucketName = configRegistry.getDeploymentProperty("bucket")
+ .map(String::trim)
+ .filter(not(String::isEmpty))
+ .orElseThrow(() -> new IllegalStateException("No bucket name configured"));
+ final Optional optionalAccessKey = configRegistry.getDeploymentProperty("access.key");
+ final Optional optionalSecretKey = configRegistry.getDeploymentProperty("secret.key");
+ final Optional optionalRegion = configRegistry.getDeploymentProperty("region");
+
+ final var clientBuilder = MinioClient.builder().endpoint(endpoint);
+ if (optionalAccessKey.isPresent() && optionalSecretKey.isPresent()) {
+ clientBuilder.credentials(optionalAccessKey.get(), optionalSecretKey.get());
+ }
+ optionalRegion.ifPresent(clientBuilder::region);
+ s3Client = clientBuilder.build();
+
+ final boolean doesBucketExist;
+ try {
+ doesBucketExist = s3Client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to determine if bucket %s exists".formatted(bucketName), e);
+ }
+
+ if (!doesBucketExist) {
+ throw new IllegalStateException("Bucket %s does not exist".formatted(bucketName));
+ }
+ }
+
+ @Override
+ public BomUploadStorageProvider create() {
+ return new S3BomUploadStorageProvider(s3Client, bucketName);
+ }
+
+ @Override
+ public void close() {
+ if (s3Client != null) {
+ try {
+ s3Client.close();
+ } catch (Exception e) {
+ LOGGER.warn("Failed to close S3 client", e);
+ }
+ }
+ }
+
+}
diff --git a/src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin b/src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin
new file mode 100644
index 000000000..d9742db51
--- /dev/null
+++ b/src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin
@@ -0,0 +1 @@
+org.dependencytrack.storage.BomUploadStoragePlugin
\ No newline at end of file
diff --git a/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProvider b/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProvider
deleted file mode 100644
index 79dc18e13..000000000
--- a/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProvider
+++ /dev/null
@@ -1,2 +0,0 @@
-org.dependencytrack.storage.DatabaseBomUploadStorageProvider
-org.dependencytrack.storage.LocalBomUploadStorageProvider
\ No newline at end of file
diff --git a/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProviderFactory b/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProviderFactory
new file mode 100644
index 000000000..82c4a2712
--- /dev/null
+++ b/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProviderFactory
@@ -0,0 +1,3 @@
+org.dependencytrack.storage.DatabaseBomUploadStorageProviderFactory
+org.dependencytrack.storage.LocalBomUploadStorageProviderFactory
+org.dependencytrack.storage.S3BomUploadStorageProviderFactory
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 20159b261..e1f4a994d 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1343,3 +1343,96 @@ dev.services.image.kafka=docker.redpanda.com/vectorized/redpanda:v24.1.11
# @category: Development
# @type: string
dev.services.image.postgres=postgres:16
+
+# Defines the BOM upload storage provider to use.
+# When null, an enabled provider will be chosen based on its priority.
+# It is recommended to explicitly configure a provider for predictable behavior.
+#
+#
+# -
+#
database
is recommended for small to medium deployments.
+# It does not require additional infrastructure, and works with multiple
+# API server replicas.
+#
+# -
+#
local
is suitable for all deployment sizes. It can help with
+# reducing database load, and does not require additional infrastructure for
+# single-replica deployments. To support multiple API server replicas, usage
+# of a shared volume is required.
+#
+# -
+#
s3
is recommended for medium to large deployments. It can help
+# with reducing database load, but does require additional infrastructure.
+# It works with multiple API server replicas and is cheap to operate.
+#
+#
+# Note that the selected provider must also be enabled,
+# for example via bom.upload.storage.provider.s3.enabled.
+#
+# @category: Storage
+# @type: enum
+# @valid-values: [database, local, s3]
+# bom.upload.storage.default.provider=
+
+# Whether the database BOM upload storage provider shall be enabled.
+#
+# @category: Storage
+# @type: boolean
+bom.upload.storage.provider.database.enabled=true
+
+# Whether the local BOM upload storage provider shall be enabled.
+#
+# @category: Storage
+# @type: boolean
+bom.upload.storage.provider.local.enabled=false
+
+# Defines the local directory where uploaded BOMs shall be stored.
+# Has no effect unless bom.upload.storage.provider.local.enabled is `true`.
+#
+# @category: Storage
+# @default: ${java.io.tmpdir}/bom-uploads
+# @type: string
+# bom.upload.storage.provider.local.directory=
+
+# Whether the S3 BOM upload storage provider shall be enabled.
+#
+# @category: Storage
+# @type: boolean
+bom.upload.storage.provider.s3.enabled=false
+
+# Defines the S3 endpoint URL for storing uploaded BOMs.
+# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`.
+#
+# @category: Storage
+# @type: string
+# bom.upload.storage.provider.s3.endpoint=
+
+# Defines the S3 bucket to use for storing uploaded BOMs.
+# The existence of the bucket will be verified during startup,
+# even when S3 is not configured as default provider.
+# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`.
+#
+# @category: Storage
+# @type: string
+# bom.upload.storage.provider.s3.bucket=
+
+# Defines the S3 access key / username.
+# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`.
+#
+# @category: Storage
+# @type: string
+# bom.upload.storage.provider.s3.access.key=
+
+# Defines the S3 secret key / password.
+# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`.
+#
+# @category: Storage
+# @type: string
+# bom.upload.storage.provider.s3.secret.key=
+
+# Defines the region of the S3 bucket.
+# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`.
+#
+# @category: Storage
+# @type: string
+# bom.upload.storage.provider.s3.region=
\ No newline at end of file
diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml
index 392ed5cb7..257e2df37 100644
--- a/src/main/webapp/WEB-INF/web.xml
+++ b/src/main/webapp/WEB-INF/web.xml
@@ -37,6 +37,9 @@
alpine.server.persistence.PersistenceManagerFactory
+
+ org.dependencytrack.plugin.PluginInitializer
+
org.dependencytrack.health.HealthCheckInitializer
diff --git a/src/test/java/org/dependencytrack/PersistenceCapableTest.java b/src/test/java/org/dependencytrack/PersistenceCapableTest.java
index 648649c94..2a6938546 100644
--- a/src/test/java/org/dependencytrack/PersistenceCapableTest.java
+++ b/src/test/java/org/dependencytrack/PersistenceCapableTest.java
@@ -38,6 +38,9 @@
import java.sql.Statement;
import java.util.Properties;
+import static org.dependencytrack.plugin.PluginManagerTestUtil.initPluginManager;
+import static org.dependencytrack.plugin.PluginManagerTestUtil.tearDownPluginManager;
+
public abstract class PersistenceCapableTest {
@Rule
@@ -53,6 +56,8 @@ public static void init() {
postgresContainer = new PostgresTestContainer();
postgresContainer.start();
+
+ initPluginManager();
}
@Before
@@ -83,6 +88,8 @@ public void after() {
@AfterClass
public static void tearDownClass() {
+ tearDownPluginManager();
+
if (postgresContainer != null) {
postgresContainer.stopWhenNotReusing();
}
diff --git a/src/test/java/org/dependencytrack/ResourceTest.java b/src/test/java/org/dependencytrack/ResourceTest.java
index 2920907b7..c2cffbe74 100644
--- a/src/test/java/org/dependencytrack/ResourceTest.java
+++ b/src/test/java/org/dependencytrack/ResourceTest.java
@@ -43,6 +43,8 @@
import static org.dependencytrack.PersistenceCapableTest.configurePmf;
import static org.dependencytrack.PersistenceCapableTest.truncateTables;
+import static org.dependencytrack.plugin.PluginManagerTestUtil.initPluginManager;
+import static org.dependencytrack.plugin.PluginManagerTestUtil.tearDownPluginManager;
public abstract class ResourceTest {
@@ -63,6 +65,7 @@ public abstract class ResourceTest {
protected final String V1_NOTIFICATION_RULE = "/v1/notification/rule";
protected final String V1_OIDC = "/v1/oidc";
protected final String V1_PERMISSION = "/v1/permission";
+ protected final String V1_PLUGIN = "/v1/plugin";
protected final String V1_OSV_ECOSYSTEM = "/v1/integration/osv/ecosystem";
protected final String V1_POLICY = "/v1/policy";
protected final String V1_POLICY_VIOLATION = "/v1/violation";
@@ -105,6 +108,8 @@ public static void init() {
postgresContainer = new PostgresTestContainer();
postgresContainer.start();
+
+ initPluginManager();
}
@Before
@@ -136,6 +141,8 @@ public void after() {
@AfterClass
public static void tearDownClass() {
+ tearDownPluginManager();
+
if (postgresContainer != null) {
postgresContainer.stopWhenNotReusing();
}
diff --git a/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java b/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java
new file mode 100644
index 000000000..37d3d86a4
--- /dev/null
+++ b/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java
@@ -0,0 +1,73 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.plugin;
+
+import alpine.model.IConfigProperty.PropertyType;
+import org.dependencytrack.PersistenceCapableTest;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.contrib.java.lang.system.EnvironmentVariables;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ConfigRegistryTest extends PersistenceCapableTest {
+
+ @Rule
+ public EnvironmentVariables environmentVariables = new EnvironmentVariables();
+
+ @Test
+ public void testGetRuntimeProperty() {
+ qm.createConfigProperty(
+ /* groupName */ "plugin",
+ /* propertyName */ "foo.provider.bar.baz",
+ /* propertyValue */ "qux",
+ PropertyType.STRING,
+ /* description */ null
+ );
+
+ final var configRegistry = new ConfigRegistry("foo", "bar");
+ final Optional optionalProperty = configRegistry.getRuntimeProperty("baz");
+ assertThat(optionalProperty).contains("qux");
+ }
+
+ @Test
+ public void testGetRuntimePropertyThatDoesNotExist() {
+ final var configRegistry = new ConfigRegistry("foo", "bar");
+ final Optional optionalProperty = configRegistry.getRuntimeProperty("baz");
+ assertThat(optionalProperty).isNotPresent();
+ }
+
+ @Test
+ public void testDeploymentProperty() {
+ environmentVariables.set("FOO_PROVIDER_BAR_BAZ", "qux");
+ final var configRegistry = new ConfigRegistry("foo", "bar");
+ final Optional optionalProperty = configRegistry.getDeploymentProperty("baz");
+ assertThat(optionalProperty).contains("qux");
+ }
+
+ @Test
+ public void testDeploymentPropertyThatDoesNotExist() {
+ final var configRegistry = new ConfigRegistry("foo", "bar");
+ final Optional optionalProperty = configRegistry.getDeploymentProperty("baz");
+ assertThat(optionalProperty).isNotPresent();
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java
new file mode 100644
index 000000000..913f60fd9
--- /dev/null
+++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java
@@ -0,0 +1,78 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.plugin;
+
+import org.dependencytrack.PersistenceCapableTest;
+import org.dependencytrack.storage.BomUploadStorageProvider;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.SortedSet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+public class PluginManagerTest extends PersistenceCapableTest {
+
+ interface InvalidProvider extends Provider {
+ }
+
+ @Test
+ public void testGetLoadedPlugins() {
+ final List loadedPlugins = PluginManager.getInstance().getLoadedPlugins();
+ assertThat(loadedPlugins).hasSize(1);
+ assertThat(loadedPlugins).isUnmodifiable();
+ }
+
+ @Test
+ public void testGetFactory() {
+ final ProviderFactory factory =
+ PluginManager.getInstance().getFactory(BomUploadStorageProvider.class);
+ assertThat(factory).isNotNull();
+ }
+
+ @Test
+ public void testGetFactoryForInvalidProvider() {
+ final ProviderFactory factory =
+ PluginManager.getInstance().getFactory(InvalidProvider.class);
+ assertThat(factory).isNull();
+ }
+
+ @Test
+ public void testGetFactories() {
+ final SortedSet> factories =
+ PluginManager.getInstance().getFactories(BomUploadStorageProvider.class);
+ assertThat(factories).hasSize(1);
+ }
+
+ @Test
+ public void testGetFactoriesForInvalidProvider() {
+ final SortedSet> factories =
+ PluginManager.getInstance().getFactories(InvalidProvider.class);
+ assertThat(factories).isEmpty();
+ }
+
+ @Test
+ public void testLoadPluginsRepeatedly() {
+ assertThatExceptionOfType(IllegalStateException.class)
+ .isThrownBy(() -> PluginManager.getInstance().loadPlugins())
+ .withMessage("Plugins were already loaded; Unload them first");
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java b/src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java
new file mode 100644
index 000000000..f5690e767
--- /dev/null
+++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java
@@ -0,0 +1,31 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.plugin;
+
+public class PluginManagerTestUtil {
+
+ public static void initPluginManager() {
+ PluginManager.getInstance().loadPlugins();
+ }
+
+ public static void tearDownPluginManager() {
+ PluginManager.getInstance().unloadPlugins();
+ }
+
+}
diff --git a/src/test/java/org/dependencytrack/resources/v1/ConfigPropertyResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ConfigPropertyResourceTest.java
index 62fd6e827..22e0a12bb 100644
--- a/src/test/java/org/dependencytrack/resources/v1/ConfigPropertyResourceTest.java
+++ b/src/test/java/org/dependencytrack/resources/v1/ConfigPropertyResourceTest.java
@@ -37,7 +37,6 @@
import javax.ws.rs.core.Response;
import java.util.Arrays;
-import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
import static org.dependencytrack.model.ConfigPropertyConstants.CUSTOM_RISK_SCORE_CRITICAL;
import static org.dependencytrack.model.ConfigPropertyConstants.CUSTOM_RISK_SCORE_HIGH;
@@ -240,96 +239,6 @@ public void updateConfigPropertyReadOnlyTest() {
assertThat(getPlainTextBody(response)).isEqualTo("The property internal.cluster.id can not be modified");
}
- @Test
- public void updateConfigPropertyBomStorageProviderTest() {
- qm.createConfigProperty(
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getGroupName(),
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getPropertyName(),
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getDefaultPropertyValue(),
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getPropertyType(),
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getDescription()
- );
-
- Response response = jersey.target(V1_CONFIG_PROPERTY).request()
- .header(X_API_KEY, apiKey)
- .post(Entity.entity("""
- {
- "groupName": "artifact",
- "propertyName": "bom.upload.storage.provider",
- "propertyValue": "foobar"
- }
- """, MediaType.APPLICATION_JSON));
-
- assertThat(response.getStatus()).isEqualTo(400);
- assertThat(getPlainTextBody(response)).isEqualTo("foobar is not a known storage provider");
-
- response = jersey.target(V1_CONFIG_PROPERTY).request()
- .header(X_API_KEY, apiKey)
- .post(Entity.entity("""
- {
- "groupName": "artifact",
- "propertyName": "bom.upload.storage.provider",
- "propertyValue": "org.dependencytrack.storage.LocalBomUploadStorageProvider"
- }
- """, MediaType.APPLICATION_JSON));
-
- assertThat(response.getStatus()).isEqualTo(200);
- assertThatJson(getPlainTextBody(response)).isEqualTo("""
- {
- "groupName": "artifact",
- "propertyName": "bom.upload.storage.provider",
- "propertyValue": "org.dependencytrack.storage.LocalBomUploadStorageProvider",
- "propertyType": "STRING",
- "description": ""
- }
- """);
- }
-
- @Test
- public void updateConfigPropertyBomStorageCompressionLevelTest() {
- qm.createConfigProperty(
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getGroupName(),
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getPropertyName(),
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getDefaultPropertyValue(),
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getPropertyType(),
- ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getDescription()
- );
-
- Response response = jersey.target(V1_CONFIG_PROPERTY).request()
- .header(X_API_KEY, apiKey)
- .post(Entity.entity("""
- {
- "groupName": "artifact",
- "propertyName": "bom.upload.storage.compression.level",
- "propertyValue": "999"
- }
- """, MediaType.APPLICATION_JSON));
-
- assertThat(response.getStatus()).isEqualTo(400);
- assertThat(getPlainTextBody(response)).isEqualTo("Compression level 999 is out of the valid [1..22] range");
-
- response = jersey.target(V1_CONFIG_PROPERTY).request()
- .header(X_API_KEY, apiKey)
- .post(Entity.entity("""
- {
- "groupName": "artifact",
- "propertyName": "bom.upload.storage.compression.level",
- "propertyValue": "11"
- }
- """, MediaType.APPLICATION_JSON));
-
- assertThat(response.getStatus()).isEqualTo(200);
- assertThatJson(getPlainTextBody(response)).isEqualTo("""
- {
- "groupName": "artifact",
- "propertyName": "bom.upload.storage.compression.level",
- "propertyValue": "11",
- "propertyType": "INTEGER",
- "description": ""
- }
- """);
- }
-
@Test
public void testRiskScoreInvalid(){
qm.createConfigProperty(
diff --git a/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java
new file mode 100644
index 000000000..31a9a1cce
--- /dev/null
+++ b/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java
@@ -0,0 +1,61 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v1;
+
+import alpine.server.filters.ApiFilter;
+import alpine.server.filters.AuthenticationFilter;
+import org.dependencytrack.JerseyTestRule;
+import org.dependencytrack.ResourceTest;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import javax.ws.rs.core.Response;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class PluginResourceTest extends ResourceTest {
+
+ @ClassRule
+ public static JerseyTestRule jersey = new JerseyTestRule(
+ new ResourceConfig(PluginResource.class)
+ .register(ApiFilter.class)
+ .register(AuthenticationFilter.class));
+
+ @Test
+ public void getAllLoadedPluginsTest() {
+ final Response response = jersey.target(V1_PLUGIN).request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThatJson(getPlainTextBody(response)).isEqualTo("""
+ [
+ {
+ "defaultProvider": "database",
+ "name": "bom.upload.storage",
+ "providers": [
+ "database"
+ ]
+ }
+ ]
+ """);
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderTest.java b/src/test/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderTest.java
index 17e490706..fe46874cf 100644
--- a/src/test/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderTest.java
+++ b/src/test/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderTest.java
@@ -19,68 +19,65 @@
package org.dependencytrack.storage;
import org.dependencytrack.PersistenceCapableTest;
-import org.jdbi.v3.core.statement.UnableToExecuteStatementException;
+import org.dependencytrack.plugin.ConfigRegistry;
import org.junit.Test;
-import org.postgresql.util.PSQLException;
-import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle;
+import static org.assertj.core.api.Assertions.assertThatNoException;
public class DatabaseBomUploadStorageProviderTest extends PersistenceCapableTest {
- private final DatabaseBomUploadStorageProvider storageProvider = new DatabaseBomUploadStorageProvider();
-
@Test
- public void testStoreBom() {
- final var token = UUID.randomUUID();
- storageProvider.storeBom(token, "foo".getBytes());
-
- final Optional result = withJdbiHandle(handle -> handle
- .createQuery("SELECT 1 FROM \"BOM_UPLOAD\" WHERE \"TOKEN\" = :token")
- .bind("token", token)
- .mapTo(Integer.class)
- .findOne());
- assertThat(result).isPresent();
+ public void testFactoryName() {
+ try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) {
+ assertThat(providerFactory.providerName()).isEqualTo("database");
+ }
}
@Test
- public void testStoreBomDuplicate() {
- final var token = UUID.randomUUID();
- storageProvider.storeBom(token, "foo".getBytes());
-
- assertThatExceptionOfType(UnableToExecuteStatementException.class)
- .isThrownBy(() -> storageProvider.storeBom(token, "bar".getBytes()))
- .withRootCauseInstanceOf(PSQLException.class);
+ public void testFactoryPriority() {
+ try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) {
+ assertThat(providerFactory.priority()).isEqualTo(100);
+ }
}
@Test
- public void testGetBom() {
- final var token = UUID.randomUUID();
- storageProvider.storeBom(token, "foo".getBytes());
- assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo");
+ public void testStoreAndGetAndDeleteBom() throws Exception {
+ try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, DatabaseBomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ final var token = UUID.randomUUID();
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes()));
+ assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo");
+ assertThat(storageProvider.deleteBomByToken(token)).isTrue();
+ assertThat(storageProvider.getBomByToken(token)).isNull();
+ }
}
@Test
- public void testDeleteBom() {
- final var token = UUID.randomUUID();
- storageProvider.storeBom(token, "foo".getBytes());
- assertThat(storageProvider.deleteBomByToken(token)).isTrue();
+ public void testStoreBomDuplicate() throws Exception {
+ try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, DatabaseBomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
- final Optional result = withJdbiHandle(handle -> handle
- .createQuery("SELECT 1 FROM \"BOM_UPLOAD\" WHERE \"TOKEN\" = :token")
- .bind("token", token)
- .mapTo(Integer.class)
- .findOne());
- assertThat(result).isNotPresent();
+ final var token = UUID.randomUUID();
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes()));
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "bar".getBytes()));
+ assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("bar");
+ }
}
@Test
- public void testDeleteNonExistentBom() {
- assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isFalse();
+ public void testDeleteNonExistentBom() throws Exception {
+ try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isFalse();
+ }
}
}
\ No newline at end of file
diff --git a/src/test/java/org/dependencytrack/storage/LocalBomUploadStorageProviderTest.java b/src/test/java/org/dependencytrack/storage/LocalBomUploadStorageProviderTest.java
index 460de9f0c..0d60cb9fa 100644
--- a/src/test/java/org/dependencytrack/storage/LocalBomUploadStorageProviderTest.java
+++ b/src/test/java/org/dependencytrack/storage/LocalBomUploadStorageProviderTest.java
@@ -18,53 +18,101 @@
*/
package org.dependencytrack.storage;
+import org.dependencytrack.plugin.ConfigRegistry;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
+import org.junit.contrib.java.lang.system.EnvironmentVariables;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
public class LocalBomUploadStorageProviderTest {
+ @Rule
+ public EnvironmentVariables environmentVariables = new EnvironmentVariables();
+
private Path tempDirPath;
- private LocalBomUploadStorageProvider storageProvider;
@Before
- public void setUp() throws Exception {
+ public void before() throws Exception {
tempDirPath = Files.createTempDirectory(null);
tempDirPath.toFile().deleteOnExit();
+ }
- storageProvider = new LocalBomUploadStorageProvider(tempDirPath);
+ @Test
+ public void testFactoryName() {
+ try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) {
+ assertThat(providerFactory.providerName()).isEqualTo("local");
+ }
}
@Test
- public void testStoreBom() throws Exception {
- final var token = UUID.randomUUID();
- storageProvider.storeBom(token, "foo".getBytes());
- assertThat(tempDirPath.resolve(token.toString())).exists();
+ public void testFactoryPriority() {
+ try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) {
+ assertThat(providerFactory.priority()).isEqualTo(110);
+ }
}
@Test
- public void testGetBom() throws Exception {
- final var token = UUID.randomUUID();
- storageProvider.storeBom(token, "foo".getBytes());
- assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo");
+ public void testStoreAndGetAndDeleteBom() throws Exception {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_LOCAL_DIRECTORY", tempDirPath.toString());
+
+ try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, LocalBomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ final var token = UUID.randomUUID();
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes()));
+ assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo");
+ assertThat(storageProvider.deleteBomByToken(token)).isTrue();
+ assertThat(storageProvider.getBomByToken(token)).isNull();
+ }
}
@Test
- public void testDeleteBom() throws Exception {
- final var token = UUID.randomUUID();
- storageProvider.storeBom(token, "foo".getBytes());
- assertThat(storageProvider.deleteBomByToken(token)).isTrue();
- assertThat(tempDirPath.resolve(token.toString())).doesNotExist();
+ public void testStoreAndGetAndDeleteBomWithDefaultDirectory() throws Exception {
+ try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, LocalBomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ final var token = UUID.randomUUID();
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes()));
+ assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo");
+ assertThat(storageProvider.deleteBomByToken(token)).isTrue();
+ assertThat(storageProvider.getBomByToken(token)).isNull();
+ }
+ }
+
+ @Test
+ public void testStoreBomDuplicate() throws Exception {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_LOCAL_DIRECTORY", tempDirPath.toString());
+
+ try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, LocalBomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ final var token = UUID.randomUUID();
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes()));
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "bar".getBytes()));
+ assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("bar");
+ }
}
@Test
public void testDeleteNonExistentBom() throws Exception {
- assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isFalse();
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_LOCAL_DIRECTORY", tempDirPath.toString());
+
+ try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, LocalBomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isFalse();
+ }
}
}
\ No newline at end of file
diff --git a/src/test/java/org/dependencytrack/storage/S3BomUploadStorageProviderTest.java b/src/test/java/org/dependencytrack/storage/S3BomUploadStorageProviderTest.java
new file mode 100644
index 000000000..437992156
--- /dev/null
+++ b/src/test/java/org/dependencytrack/storage/S3BomUploadStorageProviderTest.java
@@ -0,0 +1,231 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.storage;
+
+import io.minio.MakeBucketArgs;
+import io.minio.MinioClient;
+import org.dependencytrack.plugin.ConfigRegistry;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.contrib.java.lang.system.EnvironmentVariables;
+import org.testcontainers.containers.MinIOContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import java.io.IOException;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+public class S3BomUploadStorageProviderTest {
+
+ @Rule
+ public EnvironmentVariables environmentVariables = new EnvironmentVariables();
+
+ private MinIOContainer minioContainer;
+ private MinioClient s3Client;
+
+ @Before
+ public void before() throws Exception {
+ minioContainer = new MinIOContainer(DockerImageName.parse("minio/minio:RELEASE.2023-12-14T18-51-57Z"));
+ minioContainer.start();
+
+ s3Client = MinioClient.builder()
+ .endpoint(minioContainer.getS3URL())
+ .credentials(minioContainer.getUserName(), minioContainer.getPassword())
+ .build();
+
+ s3Client.makeBucket(MakeBucketArgs.builder()
+ .bucket("test")
+ .build());
+ }
+
+ @After
+ public void after() throws Exception {
+ if (s3Client != null) {
+ s3Client.close();
+ }
+ if (minioContainer != null) {
+ minioContainer.stop();
+ }
+ }
+
+ @Test
+ public void testFactoryName() {
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ assertThat(providerFactory.providerName()).isEqualTo("s3");
+ }
+ }
+
+ @Test
+ public void testFactoryPriority() {
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ assertThat(providerFactory.priority()).isEqualTo(120);
+ }
+ }
+
+ @Test
+ public void testFactoryInitWhenBucketDoesNotExist() {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "not-not-exist");
+
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ final var configRegistry = new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME);
+ assertThatExceptionOfType(IllegalStateException.class)
+ .isThrownBy(() -> providerFactory.init(configRegistry))
+ .withMessage("Bucket not-not-exist does not exist");
+ }
+ }
+
+ @Test
+ public void testFactoryInitWithErrorOnBucketExistsCheck() {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "not-not-exist");
+
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ final var configRegistry = new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME);
+
+ minioContainer.stop();
+
+ assertThatExceptionOfType(IllegalStateException.class)
+ .isThrownBy(() -> providerFactory.init(configRegistry))
+ .withMessage("Failed to determine if bucket not-not-exist exists");
+ }
+ }
+
+ @Test
+ public void testStoreAndGetAndDeleteBom() throws Exception {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test");
+
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ final var token = UUID.randomUUID();
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes()));
+ assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo");
+ assertThat(storageProvider.deleteBomByToken(token)).isTrue();
+ assertThat(storageProvider.getBomByToken(token)).isNull();
+ }
+ }
+
+ @Test
+ public void testStoreBomDuplicate() throws Exception {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test");
+
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ final var token = UUID.randomUUID();
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes()));
+ assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "bar".getBytes()));
+ assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("bar");
+ }
+ }
+
+ @Test
+ public void testStoreBomError() {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test");
+
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ minioContainer.stop();
+
+ final var token = UUID.randomUUID();
+ assertThatExceptionOfType(IOException.class)
+ .isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes()))
+ .withMessage("Failed to store BOM for token %s".formatted(token));
+ }
+ }
+
+ @Test
+ public void testGetBomError() {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test");
+
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ minioContainer.stop();
+
+ final var token = UUID.randomUUID();
+ assertThatExceptionOfType(IOException.class)
+ .isThrownBy(() -> storageProvider.getBomByToken(token))
+ .withMessage("Failed to get BOM for token %s".formatted(token));
+ }
+ }
+
+ @Test
+ public void testDeleteNonExistentBom() throws Exception {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test");
+
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isTrue();
+ }
+ }
+
+ @Test
+ public void testDeleteBomError() {
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword());
+ environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test");
+
+ try (final var providerFactory = new S3BomUploadStorageProviderFactory()) {
+ providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME));
+ final BomUploadStorageProvider storageProvider = providerFactory.create();
+
+ minioContainer.stop();
+
+ final var token = UUID.randomUUID();
+ assertThatExceptionOfType(IOException.class)
+ .isThrownBy(() -> storageProvider.deleteBomByToken(token))
+ .withMessage("Failed to delete BOM for token %s".formatted(token));
+ }
+ }
+
+}
\ No newline at end of file