From 6a6cae0e3085725830662153577d39671f3a35ce Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Mon, 14 Jun 2021 13:11:40 -0700 Subject: [PATCH 1/3] GUACAMOLE-1364: Add "extension-priority" property for overriding extension load order. --- .../apache/guacamole/extension/Extension.java | 18 ++ .../guacamole/extension/ExtensionModule.java | 213 ++++++++++++------ .../extension/ExtensionOrderProperty.java | 163 ++++++++++++++ 3 files changed, 328 insertions(+), 66 deletions(-) create mode 100644 guacamole/src/main/java/org/apache/guacamole/extension/ExtensionOrderProperty.java diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java b/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java index f46a72da1..b78f40781 100644 --- a/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java +++ b/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java @@ -55,6 +55,11 @@ public class Extension { */ private static final String MANIFEST_NAME = "guac-manifest.json"; + /** + * The extension .jar file. + */ + private final File file; + /** * The parsed manifest file of this extension, describing the location of * resources within the extension. @@ -357,6 +362,9 @@ public class Extension { */ public Extension(final ClassLoader parent, final File file) throws GuacamoleException { + // Associate extension abstraction with original file + this.file = file; + try { // Open extension @@ -427,6 +435,16 @@ public class Extension { largeIcon = null; } + /** + * Returns the .jar file containing this Guacamole extension. + * + * @return + * The extension .jar file. + */ + public File getFile() { + return file; + } + /** * Returns the version of the Guacamole web application for which this * extension was built. diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java index f6c4f94f7..d66d5f455 100644 --- a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java +++ b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Set; @@ -40,7 +41,6 @@ import org.apache.guacamole.properties.StringSetProperty; import org.apache.guacamole.resource.Resource; import org.apache.guacamole.resource.ResourceServlet; import org.apache.guacamole.resource.SequenceResource; -import org.apache.guacamole.resource.WebApplicationResource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -105,6 +105,22 @@ public class ExtensionModule extends ServletModule { }; + /** + * A comma-separated list of the namespaces of all extensions that should + * be loaded in a specific order. The special value "*" can be used in + * lieu of a namespace to represent all extensions that are not listed. All + * extensions explicitly listed will be sorted in the order given, while + * all extensions not explicitly listed will be sorted by their filenames. + */ + public static final ExtensionOrderProperty EXTENSION_PRIORITY = new ExtensionOrderProperty() { + + @Override + public String getName() { + return "extension-priority"; + } + + }; + /** * The Guacamole server environment. */ @@ -394,9 +410,101 @@ public class ExtensionModule extends ServletModule { } + /** + * Returns a comparator that sorts extensions by their desired load order, + * as dictated by the "extension-priority" property and their filenames. + * + * @return + * A comparator that sorts extensions by their desired load order. + */ + private Comparator getExtensionLoadOrder() { + + // Parse desired sort order of extensions + try { + return environment.getProperty(EXTENSION_PRIORITY, ExtensionOrderProperty.DEFAULT_COMPARATOR); + } + + // Sort by filename if the desired order cannot be read + catch (GuacamoleException e) { + logger.warn("The list of extensions specified via the \"{}\" property could not be parsed: {}", EXTENSION_PRIORITY.getName(), e.getMessage()); + logger.debug("Unable to parse \"{}\" property.", EXTENSION_PRIORITY.getName(), e); + return ExtensionOrderProperty.DEFAULT_COMPARATOR; + } + + } + + /** + * Returns a list of all installed extensions in the order they should be + * loaded. Extension load order is dictated by the "extension-priority" + * property and by extension filename. Each extension within + * GUACAMOLE_HOME/extensions is read and validated, but not fully loaded. + * It is the responsibility of the caller to continue the load process with + * the extensions in the returned list. + * + * @return + * A list of all installed extensions, ordered by load priority. + */ + private List getExtensions() { + + // Retrieve and validate extensions directory + File extensionsDir = new File(environment.getGuacamoleHome(), EXTENSIONS_DIRECTORY); + if (!extensionsDir.isDirectory()) + return Collections.emptyList(); + + // Retrieve list of all extension files within extensions directory + File[] extensionFiles = extensionsDir.listFiles(new FileFilter() { + + @Override + public boolean accept(File file) { + return file.isFile() && file.getName().endsWith(EXTENSION_SUFFIX); + } + + }); + + // Verify contents are accessible + if (extensionFiles == null) { + logger.warn("Although GUACAMOLE_HOME/" + EXTENSIONS_DIRECTORY + " exists, its contents cannot be read."); + return Collections.emptyList(); + } + + // Read (but do not fully load) each extension within the extension + // directory + List extensions = new ArrayList<>(extensionFiles.length); + for (File extensionFile : extensionFiles) { + + logger.debug("Reading extension: \"{}\"", extensionFile.getName()); + + try { + + // Load extension from file + Extension extension = new Extension(getParentClassLoader(), extensionFile); + + // Validate Guacamole version of extension + if (!isCompatible(extension.getGuacamoleVersion())) { + logger.debug("Declared Guacamole version \"{}\" of extension \"{}\" is not compatible with this version of Guacamole.", + extension.getGuacamoleVersion(), extensionFile.getName()); + throw new GuacamoleServerException("Extension \"" + extension.getName() + "\" is not " + + "compatible with this version of Guacamole."); + } + + extensions.add(extension); + + } + catch (GuacamoleException e) { + logger.error("Extension \"{}\" could not be loaded: {}", extensionFile.getName(), e.getMessage()); + logger.debug("Unable to load extension.", e); + } + + } + + extensions.sort(getExtensionLoadOrder()); + return extensions; + + } + /** * Loads all extensions within the GUACAMOLE_HOME/extensions directory, if - * any, adding their static resource to the given resoure collections. + * any, adding their static resource to the given resource collections. * * @param javaScriptResources * A modifiable collection of static JavaScript resources which may @@ -420,84 +528,57 @@ public class ExtensionModule extends ServletModule { Collection cssResources, Set toleratedAuthProviders) { - // Retrieve and validate extensions directory - File extensionsDir = new File(environment.getGuacamoleHome(), EXTENSIONS_DIRECTORY); - if (!extensionsDir.isDirectory()) - return; + // Advise of current extension load order and how the order may be + // changed + List extensions = getExtensions(); + if (extensions.size() > 1) { + logger.info("Multiple extensions are installed and will be " + + "loaded in order of decreasing priority:"); - // Retrieve list of all extension files within extensions directory - File[] extensionFiles = extensionsDir.listFiles(new FileFilter() { - - @Override - public boolean accept(File file) { - return file.isFile() && file.getName().endsWith(EXTENSION_SUFFIX); + for (Extension extension : extensions) { + logger.info(" - [{}] \"{}\" ({})", extension.getNamespace(), + extension.getName(), extension.getFile()); } - }); - - // Verify contents are accessible - if (extensionFiles == null) { - logger.warn("Although GUACAMOLE_HOME/" + EXTENSIONS_DIRECTORY + " exists, its contents cannot be read."); - return; + logger.info("To change this order, set the \"{}\" property or " + + "rename the extension files. The default priority of " + + "extensions is dictated by the sort order of their " + + "filenames.", EXTENSION_PRIORITY.getName()); } - // Sort files lexicographically - Arrays.sort(extensionFiles); + // Load all extensions + for (Extension extension : extensions) { - // Load each extension within the extension directory - for (File extensionFile : extensionFiles) { + // Add any JavaScript / CSS resources + javaScriptResources.addAll(extension.getJavaScriptResources().values()); + cssResources.addAll(extension.getCSSResources().values()); - logger.debug("Loading extension: \"{}\"", extensionFile.getName()); + // Attempt to load all authentication providers + bindAuthenticationProviders(extension.getAuthenticationProviderClasses(), toleratedAuthProviders); - try { + // Attempt to load all listeners + bindListeners(extension.getListenerClasses()); - // Load extension from file - Extension extension = new Extension(getParentClassLoader(), extensionFile); + // Add any translation resources + serveLanguageResources(extension.getTranslationResources()); - // Validate Guacamole version of extension - if (!isCompatible(extension.getGuacamoleVersion())) { - logger.debug("Declared Guacamole version \"{}\" of extension \"{}\" is not compatible with this version of Guacamole.", - extension.getGuacamoleVersion(), extensionFile.getName()); - throw new GuacamoleServerException("Extension \"" + extension.getName() + "\" is not " - + "compatible with this version of Guacamole."); - } + // Add all HTML patch resources + patchResourceService.addPatchResources(extension.getHTMLResources().values()); - // Add any JavaScript / CSS resources - javaScriptResources.addAll(extension.getJavaScriptResources().values()); - cssResources.addAll(extension.getCSSResources().values()); + // Add all static resources under namespace-derived prefix + String staticResourcePrefix = "/app/ext/" + extension.getNamespace() + "/"; + serveStaticResources(staticResourcePrefix, extension.getStaticResources()); - // Attempt to load all authentication providers - bindAuthenticationProviders(extension.getAuthenticationProviderClasses(), toleratedAuthProviders); + // Serve up the small favicon if provided + if(extension.getSmallIcon() != null) + serve("/images/logo-64.png").with(new ResourceServlet(extension.getSmallIcon())); - // Attempt to load all listeners - bindListeners(extension.getListenerClasses()); + // Serve up the large favicon if provided + if(extension.getLargeIcon()!= null) + serve("/images/logo-144.png").with(new ResourceServlet(extension.getLargeIcon())); - // Add any translation resources - serveLanguageResources(extension.getTranslationResources()); - - // Add all HTML patch resources - patchResourceService.addPatchResources(extension.getHTMLResources().values()); - - // Add all static resources under namespace-derived prefix - String staticResourcePrefix = "/app/ext/" + extension.getNamespace() + "/"; - serveStaticResources(staticResourcePrefix, extension.getStaticResources()); - - // Serve up the small favicon if provided - if(extension.getSmallIcon() != null) - serve("/images/logo-64.png").with(new ResourceServlet(extension.getSmallIcon())); - - // Serve up the large favicon if provided - if(extension.getLargeIcon()!= null) - serve("/images/logo-144.png").with(new ResourceServlet(extension.getLargeIcon())); - - // Log successful loading of extension by name - logger.info("Extension \"{}\" loaded.", extension.getName()); - - } - catch (GuacamoleException e) { - logger.error("Extension \"{}\" could not be loaded: {}", extensionFile.getName(), e.getMessage()); - logger.debug("Unable to load extension.", e); - } + // Log successful loading of extension by name + logger.info("Extension \"{}\" ({}) loaded.", extension.getName(), extension.getNamespace()); } diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionOrderProperty.java b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionOrderProperty.java new file mode 100644 index 000000000..4f520dc00 --- /dev/null +++ b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionOrderProperty.java @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.guacamole.extension; + +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.properties.GuacamoleProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A GuacamoleProperty that defines the order of Guacamole extensions. The + * property value is a comma-separated list of extension namespaces, with "*" + * used to represent all extensions that aren't listed. For example, a value + * like "saml, *, ldap" would order SAML support first and LDAP support last, + * with all other extensions loaded between the two in filename order. For + * values without "*", all other extensions are implicitly after all extensions + * that are explicitly listed. + */ +public abstract class ExtensionOrderProperty implements GuacamoleProperty> { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(ExtensionOrderProperty.class); + + /** + * A pattern which matches against the delimiters between values. This is + * currently simply a comma and any following whitespace. Parts of the + * input string which match this pattern will not be included in the parsed + * result. + */ + private static final Pattern DELIMITER_PATTERN = Pattern.compile(",\\s*"); + + /** + * Static comparator instance that sorts extensions by their filenames + * alone. + */ + public static final Comparator DEFAULT_COMPARATOR = new ExtensionComparator(); + + /** + * Comparator that sorts extensions in order of priority, as dictated by a + * list of the extensions that should be ordered first or last. All + * extensions not explicitly listed will instead be sorted by filename. + */ + private static class ExtensionComparator implements Comparator { + + /** + * The string value representing the set of all extensions not + * explicitly listed. + */ + private final String OTHER_EXTENSIONS = "*"; + + /** + * The relative priorities of all extensions. Any extension not listed + * within this map should be sorted with the priority value stored in + * {@link #defaultPriority}. + */ + private final Map extensionPriority; + + /** + * The relative priority that should be used for all extensions not + * explicitly listed within {@link #extensionPriority}. + */ + private final int defaultPriority; + + /** + * Creates a new ExtensionComparator that sorts all extensions by their + * filenames only. + */ + public ExtensionComparator() { + defaultPriority = 0; + extensionPriority = Collections.emptyMap(); + } + + /** + * Creates a new ExtensionComparator that ensures each of the given + * extensions are sorted in the relative order listed, with any + * extensions not explicitly listed sorted by filename. + * + * @param name + * The name of the property defining the provided list of + * extensions. + * + * @param extensions + * The namespaces of the extensions in the order they should be + * sorted, with the special value "*" functioning as a + * placeholder for all extensions that are not explicitly listed. + */ + public ExtensionComparator(String name, String... extensions) { + + extensionPriority = new HashMap<>(extensions.length); + + for (int priority = 0; priority < extensions.length; priority++) { + String extension = extensions[priority]; + if (extensionPriority.putIfAbsent(extension, priority) != null) + logger.warn("The value \"{}\" was specified multiple " + + "times for property \"{}\". Only the first " + + "occurrence of this value will have any effect.", + extension, name); + } + + Integer otherExtensionPriority = extensionPriority.remove(OTHER_EXTENSIONS); + if (otherExtensionPriority != null) + defaultPriority = otherExtensionPriority; + else + defaultPriority = extensions.length; + + } + + @Override + public int compare(Extension extA, Extension extB) { + + int priorityA = extensionPriority.getOrDefault(extA.getNamespace(), defaultPriority); + int priorityB = extensionPriority.getOrDefault(extB.getNamespace(), defaultPriority); + + // Sort by explicit priority first + if (priorityA != priorityB) + return priorityA - priorityB; + + // Sort all extensions without explicit priorities by their + // filenames (no extensions will have the same priority except + // those that aren't explicitly listed) + return extA.getFile().compareTo(extB.getFile()); + + } + + } + + @Override + public Comparator parseValue(String value) throws GuacamoleException { + + // If no property provided, return null. + if (value == null) + return null; + + // Split string into a set of individual values + return new ExtensionComparator(getName(), DELIMITER_PATTERN.split(value)); + + } + +} From 45c2cbf6b8f8f9d1d20e7a095787798e80c09905 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Mon, 14 Jun 2021 13:12:16 -0700 Subject: [PATCH 2/3] GUACAMOLE-1364: Remove inconsistent "guac-" prefix from standard extension namespaces. --- .../guacamole-auth-cas/src/main/resources/guac-manifest.json | 2 +- .../guacamole-auth-header/src/main/resources/guac-manifest.json | 2 +- .../src/main/resources/guac-manifest.json | 2 +- .../src/main/resources/guac-manifest.json | 2 +- .../src/main/resources/guac-manifest.json | 2 +- .../guacamole-auth-json/src/main/resources/guac-manifest.json | 2 +- .../guacamole-auth-ldap/src/main/resources/guac-manifest.json | 2 +- .../guacamole-auth-openid/src/main/resources/guac-manifest.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/guacamole-auth-cas/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-cas/src/main/resources/guac-manifest.json index afc55d955..a2aaa9433 100644 --- a/extensions/guacamole-auth-cas/src/main/resources/guac-manifest.json +++ b/extensions/guacamole-auth-cas/src/main/resources/guac-manifest.json @@ -3,7 +3,7 @@ "guacamoleVersion" : "1.3.0", "name" : "CAS Authentication Extension", - "namespace" : "guac-cas", + "namespace" : "cas", "authProviders" : [ "org.apache.guacamole.auth.cas.CASAuthenticationProvider" diff --git a/extensions/guacamole-auth-header/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-header/src/main/resources/guac-manifest.json index 21d43dcf2..e443828af 100644 --- a/extensions/guacamole-auth-header/src/main/resources/guac-manifest.json +++ b/extensions/guacamole-auth-header/src/main/resources/guac-manifest.json @@ -3,7 +3,7 @@ "guacamoleVersion" : "1.2.0", "name" : "HTTP Header Authentication Extension", - "namespace" : "guac-header", + "namespace" : "header", "authProviders" : [ "org.apache.guacamole.auth.header.HTTPHeaderAuthenticationProvider" diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json index 074886f74..58bab7e87 100644 --- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json +++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json @@ -3,7 +3,7 @@ "guacamoleVersion" : "1.3.0", "name" : "MySQL Authentication", - "namespace" : "guac-mysql", + "namespace" : "mysql", "authProviders" : [ "org.apache.guacamole.auth.mysql.MySQLAuthenticationProvider", diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json index 1dd2870cb..e882f7670 100644 --- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json +++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json @@ -3,7 +3,7 @@ "guacamoleVersion" : "1.3.0", "name" : "PostgreSQL Authentication", - "namespace" : "guac-postgresql", + "namespace" : "postgresql", "authProviders" : [ "org.apache.guacamole.auth.postgresql.PostgreSQLAuthenticationProvider", diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json index 149905331..1bcb08374 100644 --- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json +++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json @@ -3,7 +3,7 @@ "guacamoleVersion" : "1.3.0", "name" : "SQLServer Authentication", - "namespace" : "guac-sqlserver", + "namespace" : "sqlserver", "authProviders" : [ "org.apache.guacamole.auth.sqlserver.SQLServerAuthenticationProvider", diff --git a/extensions/guacamole-auth-json/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-json/src/main/resources/guac-manifest.json index a9ff464c5..295c3941e 100644 --- a/extensions/guacamole-auth-json/src/main/resources/guac-manifest.json +++ b/extensions/guacamole-auth-json/src/main/resources/guac-manifest.json @@ -3,7 +3,7 @@ "guacamoleVersion" : "1.3.0", "name" : "Encrypted JSON Authentication", - "namespace" : "guac-json", + "namespace" : "json", "authProviders" : [ "org.apache.guacamole.auth.json.JSONAuthenticationProvider" diff --git a/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json index c4734320a..82a9dca92 100644 --- a/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json +++ b/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json @@ -3,7 +3,7 @@ "guacamoleVersion" : "1.3.0", "name" : "LDAP Authentication", - "namespace" : "guac-ldap", + "namespace" : "ldap", "authProviders" : [ "org.apache.guacamole.auth.ldap.LDAPAuthenticationProvider" diff --git a/extensions/guacamole-auth-openid/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-openid/src/main/resources/guac-manifest.json index 3a9aca941..b48bd2d31 100644 --- a/extensions/guacamole-auth-openid/src/main/resources/guac-manifest.json +++ b/extensions/guacamole-auth-openid/src/main/resources/guac-manifest.json @@ -3,7 +3,7 @@ "guacamoleVersion" : "1.3.0", "name" : "OpenID Authentication Extension", - "namespace" : "guac-openid", + "namespace" : "openid", "authProviders" : [ "org.apache.guacamole.auth.openid.OpenIDAuthenticationProvider" From 2aa6a5b6286db8a86469b2c197a61b0b536a5309 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Wed, 7 Apr 2021 16:11:25 -0700 Subject: [PATCH 3/3] GUACAMOLE-1364: Ensure extension resources are included in defined order. --- .../main/java/org/apache/guacamole/extension/Extension.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java b/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java index b78f40781..5c74c897d 100644 --- a/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java +++ b/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java @@ -26,7 +26,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipException; @@ -149,7 +149,7 @@ public class Extension { return Collections.emptyMap(); // Add classpath resource for each path provided - Map resources = new HashMap(paths.size()); + Map resources = new LinkedHashMap<>(paths.size()); for (String path : paths) resources.put(path, new ClassPathResource(classLoader, mimetype, path)); @@ -178,7 +178,7 @@ public class Extension { return Collections.emptyMap(); // Add classpath resource for each path/mimetype pair provided - Map resources = new HashMap(resourceTypes.size()); + Map resources = new LinkedHashMap<>(resourceTypes.size()); for (Map.Entry resource : resourceTypes.entrySet()) { // Get path and mimetype from entry