diff --git a/jme3-desktop/src/main/java/com/jme3/system/NativeLibraryExtraction.java b/jme3-desktop/src/main/java/com/jme3/system/NativeLibraryExtraction.java new file mode 100644 index 0000000000..6e48a8eace --- /dev/null +++ b/jme3-desktop/src/main/java/com/jme3/system/NativeLibraryExtraction.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2009-2023 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.system; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.AclEntry; +import java.nio.file.attribute.AclEntryFlag; +import java.nio.file.attribute.AclEntryPermission; +import java.nio.file.attribute.AclEntryType; +import java.nio.file.attribute.AclFileAttributeView; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.nio.file.attribute.UserPrincipal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; + +/** Creates isolated extraction directories without trusting predictable cache files. */ +final class NativeLibraryExtraction { + private NativeLibraryExtraction() {} + + static Path createDirectory(Path root, String prefix) throws IOException { + Path existing = root.toAbsolutePath(); + while (!Files.exists(existing)) { + existing = existing.getParent(); + if (existing == null) throw new IOException("No existing ancestor for " + root); + } + FileAttribute permissions = privatePermissions(existing); + Files.createDirectories(root, permissions); + // Resolve symlinks such as macOS /var -> /private/var before using the path. + root = root.toRealPath(); + if (Files.getFileStore(root).supportsFileAttributeView("posix")) { + checkPosixParents(root); + } + Path directory = Files.createTempDirectory(root, prefix, permissions); + boolean usable = false; + try { + directory.toFile().deleteOnExit(); + if (permissions.name().equals("acl:acl")) { + // Remove any inherited grants before extracting native code. The initial + // ACL also requests owner-only access at creation time. + AclFileAttributeView view = Files.getFileAttributeView(directory, AclFileAttributeView.class); + @SuppressWarnings("unchecked") + List acl = (List) permissions.value(); + view.setAcl(acl); + for (AclEntry entry : view.getAcl()) { + if (entry.type() == AclEntryType.ALLOW && !entry.principal().equals(acl.get(0).principal())) { + throw new IOException("Cannot restrict native directory ACL: " + directory); + } + } + } + checkExecutable(directory); + usable = true; + return directory; + } finally { + if (!usable) { + Files.deleteIfExists(directory); + } + } + } + + private static void checkPosixParents(Path root) throws IOException { + UserPrincipal user = root.getFileSystem().getUserPrincipalLookupService() + .lookupPrincipalByName(System.getProperty("user.name")); + List parents = new ArrayList<>(); + for (Path parent = root; parent != null; parent = parent.getParent()) parents.add(parent); + Collections.reverse(parents); + boolean privateAncestor = false; + for (Path parent : parents) { + PosixFileAttributes attributes = Files.readAttributes(parent, PosixFileAttributes.class); + // A private child is not safe if another user can rename/replace its parent. + boolean systemOwned = attributes.owner().getName().equals("root"); + if (!attributes.owner().equals(user) && !systemOwned) { + throw new IOException("Native extraction ancestor belongs to another user: " + parent); + } + if (!privateAncestor && (attributes.permissions().contains(PosixFilePermission.GROUP_WRITE) + || attributes.permissions().contains(PosixFilePermission.OTHERS_WRITE))) { + // Shared system temp directories are safe only with the sticky bit. + int mode = ((Number) Files.getAttribute(parent, "unix:mode")).intValue(); + if ((mode & 01000) == 0) { + throw new IOException("Native extraction ancestor is writable by other users: " + parent); + } + } + // Permissions below an owner-only directory cannot grant outsiders access. + if (attributes.owner().equals(user) + && !attributes.permissions().contains(PosixFilePermission.GROUP_EXECUTE) + && !attributes.permissions().contains(PosixFilePermission.OTHERS_EXECUTE)) { + privateAncestor = true; + } + } + } + + private static FileAttribute privatePermissions(Path root) throws IOException { + if (Files.getFileStore(root).supportsFileAttributeView("posix")) { + return PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")); + } + if (Files.getFileStore(root).supportsFileAttributeView("acl")) { + UserPrincipal owner = root.getFileSystem().getUserPrincipalLookupService() + .lookupPrincipalByName(System.getProperty("user.name")); + final List acl = Collections.singletonList(AclEntry.newBuilder() + .setType(AclEntryType.ALLOW) + .setPrincipal(owner) + .setPermissions(EnumSet.allOf(AclEntryPermission.class)) + .setFlags(AclEntryFlag.FILE_INHERIT, AclEntryFlag.DIRECTORY_INHERIT) + .build()); + return new FileAttribute>() { + @Override + public String name() { return "acl:acl"; } + @Override + public List value() { return acl; } + }; + } + throw new IOException("Filesystem cannot create private native directories: " + root); + } + + private static void checkExecutable(Path directory) throws IOException { + if (!Files.getFileStore(directory).supportsFileAttributeView("posix")) { + // Windows DLL loading is checked by the native loader, not Unix execute bits. + return; + } + Path probe = Files.createTempFile(directory, "exec-probe-", null); + try { + Files.setPosixFilePermissions(probe, PosixFilePermissions.fromString("rwx------")); + // On Linux, access(X_OK) on a regular file detects noexec. Checking the + // directory itself only checks traversal. No subprocess is executed. + if (!Files.isExecutable(probe)) { + throw new IOException("Native execution is not permitted (possibly noexec): " + directory); + } + } finally { + Files.deleteIfExists(probe); + } + } +} diff --git a/jme3-desktop/src/main/java/com/jme3/system/NativeLibraryLoader.java b/jme3-desktop/src/main/java/com/jme3/system/NativeLibraryLoader.java index 16d75fddb7..a3999ee34d 100644 --- a/jme3-desktop/src/main/java/com/jme3/system/NativeLibraryLoader.java +++ b/jme3-desktop/src/main/java/com/jme3/system/NativeLibraryLoader.java @@ -32,12 +32,13 @@ package com.jme3.system; import java.io.*; -import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -79,14 +80,21 @@ public final class NativeLibraryLoader { /** * System property controlling whether native libraries should be extracted - * from the classpath before loading. Defaults to {@code true}. + * from the classpath before loading. Defaults to {@code true}. When false, + * {@link #CUSTOM_EXTRACTION_FOLDER_PROPERTY} must specify existing natives. */ public static final String EXTRACT_NATIVE_LIBRARIES_PROPERTY = "com.jme3.ExtractNativeLibraries"; + /** Base directory for the automatic native cache. */ + public static final String CACHE_FOLDER_PROPERTY = "com.jme3.CacheFolder"; + private static final Logger logger = Logger.getLogger(NativeLibraryLoader.class.getName()); private static File extractionFolderOverride = null; private static File extractionFolder = null; + private static final int EXTRACTION_ROOT_COUNT = 4; // configured, temp, cache, home + private static int extractionRootIndex = 0; // extraction root to try next private static Boolean extractNativeLibrariesOverride = null; + private static final Map loadedLibraries = new HashMap<>(); private static final HashMap nativeLibraryMap = new HashMap<>(); @@ -100,7 +108,7 @@ public final class NativeLibraryLoader { * This simply registers a known library, the actual extraction and loading * is performed by calling {@link #loadNativeLibrary(java.lang.String, boolean) }. */ - public static void registerNativeLibrary(NativeLibrary library) { + public static synchronized void registerNativeLibrary(NativeLibrary library) { nativeLibraryMap.put(library.getKey(), library); } @@ -110,7 +118,7 @@ public static void registerNativeLibrary(NativeLibrary library) { * This simply registers a known library, the actual extraction and loading is performed by calling * {@link #loadNativeLibrary(java.lang.String, boolean) }. */ - public static void registerNativeLibrary(LibraryInfo library) { + public static synchronized void registerNativeLibrary(LibraryInfo library) { library.getNativeVariants().forEach(NativeLibraryLoader::registerNativeLibrary); } @@ -129,7 +137,7 @@ public static void registerNativeLibrary(LibraryInfo library) { * @param extractAsName The filename that the library should be extracted as, * if null, use the same name as in the path. */ - public static void registerNativeLibrary(String name, Platform platform, + public static synchronized void registerNativeLibrary(String name, Platform platform, String path, String extractAsName) { nativeLibraryMap.put(new NativeLibrary.Key(name, platform), new NativeLibrary(name, platform, path, extractAsName)); @@ -151,7 +159,7 @@ public static void registerNativeLibrary(String name, Platform platform, * corresponding to this library. Must be compatible with the platform * argument. */ - public static void registerNativeLibrary(String name, Platform platform, + public static synchronized void registerNativeLibrary(String name, Platform platform, String path) { registerNativeLibrary(name, platform, path, null); } @@ -178,16 +186,16 @@ public static boolean isUsingNativeBullet() { } /** - * Specify a custom location where native libraries should - * be extracted to. Ensure this is a unique path not used - * by other applications to extract their libraries. + * Specify a custom root for private native extraction directories. * Set to null to restore default * functionality. * - * @param path Path where to extract native libraries. + * @param path Root where private native directories are created. */ - public static void setCustomExtractionFolder(String path) { + public static synchronized void setCustomExtractionFolder(String path) { extractionFolderOverride = path == null ? null : new File(path).getAbsoluteFile(); + extractionFolder = null; + extractionRootIndex = 0; } /** @@ -196,7 +204,7 @@ public static void setCustomExtractionFolder(String path) { * @return the programmatic override if set, otherwise the * {@link #CUSTOM_EXTRACTION_FOLDER_PROPERTY} system property */ - public static File getCustomExtractionFolder() { + public static synchronized File getCustomExtractionFolder() { if (extractionFolderOverride != null) { return extractionFolderOverride; } @@ -211,19 +219,21 @@ public static File getCustomExtractionFolder() { /** * Specify whether native libraries should be extracted from the classpath - * before loading. Set to {@code true} to preserve the default behavior. + * before loading. When false, configure a folder containing the native + * files with {@link #setCustomExtractionFolder(String)} or + * {@link #CUSTOM_EXTRACTION_FOLDER_PROPERTY}. * * @param extractNativeLibraries true to extract classpath natives, false to * load existing files from the extraction folder */ - public static void setExtractNativeLibraries(boolean extractNativeLibraries) { + public static synchronized void setExtractNativeLibraries(boolean extractNativeLibraries) { extractNativeLibrariesOverride = extractNativeLibraries; } /** * Clears the programmatic extraction flag override. */ - public static void clearExtractNativeLibrariesOverride() { + public static synchronized void clearExtractNativeLibrariesOverride() { extractNativeLibrariesOverride = null; } @@ -234,7 +244,7 @@ public static void clearExtractNativeLibrariesOverride() { * {@link #EXTRACT_NATIVE_LIBRARIES_PROPERTY} system property, * defaulting to true */ - public static boolean isExtractNativeLibraries() { + public static synchronized boolean isExtractNativeLibraries() { if (extractNativeLibrariesOverride != null) { return extractNativeLibrariesOverride; } @@ -246,150 +256,151 @@ public static boolean isExtractNativeLibraries() { } /** - * Returns the folder where native libraries will be extracted. - * This is automatically determined at run-time based on the - * following criteria:
- *
    - *
  • If a {@link #setCustomExtractionFolder(java.lang.String) custom - * extraction folder} has been specified, it is returned.
  • - *
  • If the {@link #CUSTOM_EXTRACTION_FOLDER_PROPERTY} system property - * has been specified, it is returned.
  • - *
  • If the user can write to "java.io.tmpdir" folder, then it - * is used.
  • - *
  • Otherwise, the {@link JmeSystem#getStorageFolder() storage folder} - * is used, to prevent collisions, a special subfolder is used - * called natives_<hash> where <hash> - * is computed automatically as the XOR of the classpath hash code - * and the last modified date of this class. - *
- * - * @return Path where natives will be extracted to. + * Returns the folder where native libraries will be extracted or loaded from. + * Extraction uses a fresh private directory scheduled for deletion on JVM + * exit; an abrupt termination may leave it behind. Without extraction, a + * custom folder containing the native files must be configured. + * @return the folder used to extract or load native libraries + * @throws IllegalStateException if extraction is disabled without a custom folder */ - public static File getExtractionFolder() { - File customExtractionFolder = getCustomExtractionFolder(); - if (customExtractionFolder != null) { - return customExtractionFolder; + public static synchronized File getExtractionFolder() { + if (!isExtractNativeLibraries()) { + File custom = getCustomExtractionFolder(); + if (custom != null) return custom; + throw new IllegalStateException("Native library extraction is disabled; configure " + + CUSTOM_EXTRACTION_FOLDER_PROPERTY + " with a directory containing the native files."); } - if (extractionFolder == null) { - File userTempDir = new File(System.getProperty("java.io.tmpdir")); - if (!userTempDir.canWrite()) { - setExtractionFolderToUserCache(); - } else { - try { - File jmeTempDir = new File(userTempDir, "jme3"); - if (!jmeTempDir.exists()) { - jmeTempDir.mkdir(); + if (extractionFolder != null) return extractionFolder; + + UnsatisfiedLinkError error = new UnsatisfiedLinkError( + "Cannot find a suitable extraction folder for native libraries."); + exit: + while (true) { + Path root = null; + try { + switch (extractionRootIndex) { + case 0: { // configured directory + File custom = getCustomExtractionFolder(); + if (custom == null) { + extractionRootIndex++; + continue; + } + root = custom.toPath(); + break; } - if(!jmeTempDir.canWrite()) { - setExtractionFolderToUserCache(); - } else { - extractionFolder = new File(jmeTempDir, "natives_" + Integer.toHexString(computeNativesHash())); + case 1: { // temp directory + String tmp = System.getProperty("java.io.tmpdir", "").trim(); + if (tmp.isEmpty()) throw new IllegalArgumentException("java.io.tmpdir is not set"); + root = Paths.get(tmp); + if (!root.isAbsolute() || !Files.isDirectory(root)) throw new IllegalArgumentException("java.io.tmpdir is not a valid extraction root"); + break; + } + case 2: { // platform cache + root = getJmeUserCacheFolder(); + if (root == null) throw new IOException("No usable cache directory"); + root = root.resolve(".jme3"); + break; + } + case 3: { // user home directory + String home = System.getProperty("user.home", "").trim(); + if (home.isEmpty()) throw new IllegalArgumentException("user.home is not set"); + root = Paths.get(home); + if (!root.isAbsolute() || (root.getParent() != null + && !Files.isDirectory(root.getParent()))) throw new IllegalArgumentException("user.home is not a valid extraction root"); + root = root.resolve(".jme3"); + break; + } + default: { + break exit; + } + } - if (!extractionFolder.exists()) { - extractionFolder.mkdir(); - } - } - } catch (Exception e) { - setExtractionFolderToUserCache(); - } - } - } - return extractionFolder; - } - - /** - * Determine jME3's cache folder for the user account based on the OS. - * - * If the OS cache folder is missing, the assumption is that this - * particular version of the OS does not have a dedicated cache folder, - * hence, we use the user's home folder instead as the root. - * - * The folder returned is as follows:
- *
    - *
  • Windows: ~\AppData\Local\jme3
  • - *
  • Mac OS X: ~/Library/Caches/jme3
  • - *
  • Linux: ~/.cache/jme3
  • - *
- * - * @return the user cache folder. - */ - private static File getJmeUserCacheFolder() { - File userHomeFolder = new File(System.getProperty("user.home")); - File userCacheFolder = null; - - switch (JmeSystem.getPlatform().getOs()) { - case Linux: - userCacheFolder = new File(userHomeFolder, ".cache"); - break; - case MacOS: - userCacheFolder = new File(new File(userHomeFolder, "Library"), "Caches"); - break; - case Windows: - userCacheFolder = new File(new File(userHomeFolder, "AppData"), "Local"); - break; + extractionFolder = NativeLibraryExtraction.createDirectory(root, "jme3-natives-").toFile(); + return extractionFolder; + } catch (IOException | SecurityException | IllegalArgumentException + | UnsupportedOperationException failure) { + error.addSuppressed(new IOException("Cannot use native extraction root: " + root, failure)); + } + extractionRootIndex++; // next call tries the next root + } + extractionRootIndex = 0; // let a later call try every root again + throw error; + } + + /** + * Returns the platform cache folder + */ + private static Path getJmeUserCacheFolder() { + Path base = null; + String cacheFolder = System.getProperty(CACHE_FOLDER_PROPERTY); + if (cacheFolder != null && !cacheFolder.trim().isEmpty()) { + base = Paths.get(cacheFolder); + if (!base.isAbsolute() || !Files.isDirectory(base)) { + base = null; + logger.warning(CACHE_FOLDER_PROPERTY + + " must be an absolute path and must exist. Falling back to default cache location."); + } + } + + if (base == null) { + String loc = null; + Platform.Os os = JmeSystem.getPlatform().getOs(); + if (os == Platform.Os.Windows) { + loc = System.getenv("LOCALAPPDATA"); + } else if (os == Platform.Os.Linux) { + loc = System.getenv("XDG_CACHE_HOME"); + } + + if (loc != null && !loc.trim().isEmpty()) { + base = Paths.get(loc); + if (!base.isAbsolute() || !Files.isDirectory(base)) base = null; + } } - - if (userCacheFolder == null || !userCacheFolder.exists()) { - // Fallback to home directory if cache folder is missing - return new File(userHomeFolder, ".jme3"); + + if (base == null) { + Platform.Os os = JmeSystem.getPlatform().getOs(); + Path home = Paths.get(System.getProperty("user.home")); + try{ + switch (os) { + case Windows: + base = home.resolve("AppData").resolve("Local"); + break; + case MacOS: + base = home.resolve("Library").resolve("Caches"); + break; + default: + base = home.resolve(".cache"); + break; + } + } catch (Exception e) { + logger.warning("Failed to determine default cache location: " + e.getMessage()); + } + + if (base != null && (!base.isAbsolute() || !Files.isDirectory(base))) { + base = null; + } } - - return new File(userCacheFolder, "jme3"); + + return base; } - private static void setExtractionFolderToUserCache() { - File extractFolderInHome = getJmeUserCacheFolder(); - - if (!extractFolderInHome.exists()) { - extractFolderInHome.mkdir(); + /** + * Checks that the value is a single name which cannot escape a directory. + */ + private static boolean isSimpleName(String value) { + if (value == null || value.isEmpty() || value.equals(".") || value.equals("..") + || value.indexOf('/') >= 0 || value.indexOf('\\') >= 0 || value.indexOf(':') >= 0) { + return false; } - - extractionFolder = new File(extractFolderInHome, "natives_" + Integer.toHexString(computeNativesHash())); - - if (!extractionFolder.exists()) { - extractionFolder.mkdir(); + try { + return !Paths.get(value).isAbsolute(); + } catch (IllegalArgumentException malformed) { + return false; } - - logger.log(Level.WARNING, "Temp directory is not writable. " - + "Natives will be extracted to:\n{0}", - extractionFolder); } - private static int computeNativesHash() { - URLConnection conn = null; - String classpath = System.getProperty("java.class.path"); - URL url = Resources.getResource("com/jme3/system/NativeLibraryLoader.class"); - try { - StringBuilder sb = new StringBuilder(url.toString()); - if (sb.indexOf("jar:") == 0) { - sb.delete(0, 4); - sb.delete(sb.indexOf("!"), sb.length()); - sb.delete(sb.lastIndexOf("/") + 1, sb.length()); - } - try { - url = new URL(sb.toString()); - } catch (MalformedURLException ex) { - throw new UnsupportedOperationException(ex); - } - - conn = url.openConnection(); - int hash = classpath.hashCode() ^ (int) conn.getLastModified(); - return hash; - } catch (IOException ex) { - throw new UncheckedIOException("Failed to open file: '" + url - + "'. Error: " + ex, ex); - } finally { - if (conn != null) { - try { - conn.getInputStream().close(); - conn.getOutputStream().close(); - } catch (IOException ex) { } - } - } - } - - public static File[] getJarsWithNatives() { + public static synchronized File[] getJarsWithNatives() { HashSet jarFiles = new HashSet<>(); for (Map.Entry lib : nativeLibraryMap.entrySet()) { File jarFile = getJarForNativeLibrary(lib.getValue().getPlatform(), lib.getValue().getName()); @@ -400,7 +411,7 @@ public static File[] getJarsWithNatives() { return jarFiles.toArray(new File[0]); } - public static void extractNativeLibraries(Platform platform, File targetDir) throws IOException { + public static synchronized void extractNativeLibraries(Platform platform, File targetDir) throws IOException { for (Map.Entry lib : nativeLibraryMap.entrySet()) { if (lib.getValue().getPlatform() == platform) { if (!targetDir.exists()) { @@ -436,8 +447,8 @@ private static String unmapLibraryName(String filename) { } return sb.toString(); } - - public static File getJarForNativeLibrary(Platform platform, String name) { + + public static synchronized File getJarForNativeLibrary(Platform platform, String name) { NativeLibrary library = nativeLibraryMap.get(new NativeLibrary.Key(name, platform)); if (library == null) { return null; @@ -474,7 +485,7 @@ public static File getJarForNativeLibrary(Platform platform, String name) { } } - public static void extractNativeLibrary(Platform platform, String name, File targetDir) throws IOException { + public static synchronized void extractNativeLibrary(Platform platform, String name, File targetDir) throws IOException { NativeLibrary library = nativeLibraryMap.get(new NativeLibrary.Key(name, platform)); if (library == null) { return; @@ -490,13 +501,8 @@ public static void extractNativeLibrary(Platform platform, String name, File tar return; } - String loadedAsFileName; - if (library.getExtractedAsName() != null) { - loadedAsFileName = library.getExtractedAsName(); - } else { - loadedAsFileName = Paths.get(pathInJar).getFileName().toString(); - } - + String loadedAsFileName = getLoadedAsFileName(library, pathInJar); + URLConnection conn = url.openConnection(); File targetFile = new File(targetDir, loadedAsFileName); @@ -518,7 +524,7 @@ public static void extractNativeLibrary(Platform platform, String name, File tar * * @return The absolute path of the loaded library. */ - public static String loadNativeLibrary(String name, boolean isRequired) { + public static synchronized String loadNativeLibrary(String name, boolean isRequired) { if (JmeSystem.isLowPermissions()) { throw new UnsupportedOperationException("JVM is running under " + "reduced permissions. Cannot load native libraries."); @@ -551,8 +557,6 @@ public static String loadNativeLibrary(String name, boolean isRequired) { } String loadedAsFileName = getLoadedAsFileName(library, pathInJar); - File extractionDirectory = getExtractionFolder(); - File targetFile = new File(extractionDirectory, loadedAsFileName); if (isExtractNativeLibraries()) { URL url = Resources.getResource(pathInJar); @@ -570,45 +574,69 @@ public static String loadNativeLibrary(String name, boolean isRequired) { return null; } - // The library has been found and is ready to be extracted. - URLConnection conn; - - try { - conn = url.openConnection(); - } catch (IOException ex) { - // Maybe put more detail here? Not sure. - throw new UncheckedIOException("Failed to open file: '" + url + - "'. Error: " + ex, ex); + String loaded = loadedLibraries.get(library); + if (loaded != null) { + return loaded; } + UnsatisfiedLinkError error = new UnsatisfiedLinkError( + "Cannot extract/load native libraries from the configured directory, temp, user cache, or ~/.jme3."); + while (extractionRootIndex < EXTRACTION_ROOT_COUNT) { + Path target = null; + boolean created = false; + try { + File directory = getExtractionFolder(); + target = directory.toPath().resolve(loadedAsFileName); + try (InputStream in = url.openStream(); + OutputStream out = Files.newOutputStream(target, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + created = true; + target.toFile().deleteOnExit(); + byte[] buffer = new byte[8192]; + for (int read; (read = in.read(buffer)) != -1;) { + out.write(buffer, 0, read); + } + } + loaded = target.toAbsolutePath().toString(); + library.getLoadFunction().accept(loaded); + loadedLibraries.put(library, loaded); + return loaded; - try (InputStream in = conn.getInputStream()) { - if (isExtractingRequired(conn, targetFile)) { - Files.copy(in, targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING); - - // NOTE: On OSes that support "Date Created" property, - // this will cause the last modified date to be lower than - // date created which makes no sense - targetFile.setLastModified(conn.getLastModified()); - - if (logger.isLoggable(Level.FINE)) { - logger.log(Level.FINE, "Extracted native library from ''{0}'' into ''{1}''. ", - new Object[]{url, targetFile}); + } catch (IOException | UnsatisfiedLinkError | SecurityException failure) { + error.addSuppressed(new IOException("Failed to extract/load native library: " + target, failure)); + if (target == null) { + break; // getExtractionFolder() has exhausted every root. } - } else { - if (logger.isLoggable(Level.FINE)) { - logger.log(Level.FINE, "Not copying library {0}. Latest already extracted.", - loadedAsFileName); + if (created) { + try { + Files.deleteIfExists(target); + } catch (IOException | SecurityException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } } + // Keep successful libraries in the previous directory intact. + extractionFolder = null; + extractionRootIndex++; } - } catch (IOException ex) { - /*if (ex.getMessage().contains("used by another process")) { - return; - }*/ - - throw new UncheckedIOException("Failed to extract native library to: " - + targetFile, ex); } - } else if (!targetFile.isFile()) { + extractionRootIndex = 0; + if (isRequired) { + throw error; + } + logger.log(Level.FINE, "Optional native library could not be loaded: " + library.getName(), error); + return null; + } + + File directory; + try { + directory = getExtractionFolder(); + } catch (IllegalStateException missingFolder) { + if (isRequired) throw new UnsatisfiedLinkError(missingFolder.getMessage()); + logger.log(Level.FINE, missingFolder.getMessage()); + return null; + } + File targetFile = new File(directory, loadedAsFileName); + if (!targetFile.isFile()) { if (isRequired) { throw new UnsatisfiedLinkError( "The required native library '" + library.getName() + "'" @@ -632,39 +660,12 @@ public static String loadNativeLibrary(String name, boolean isRequired) { } private static String getLoadedAsFileName(NativeLibrary library, String pathInJar) { - if (library.getExtractedAsName() != null) { - return library.getExtractedAsName(); + String filename = library.getExtractedAsName() != null + ? library.getExtractedAsName() : Paths.get(pathInJar).getFileName().toString(); + if (!isSimpleName(filename)) { + throw new IllegalArgumentException("Native library extraction requires a file name: " + filename); } - - // Just use the original filename as it is in the JAR. - return Paths.get(pathInJar).getFileName().toString(); + return filename; } - /** - * Checks if library extraction is required by comparing source and target - * last modified date. Returns true if target file does not exist. - * - * @param conn the source file - * @param targetFile the target file - * @return false if target file exist and the difference in last modified date is - * less than 1 second, true otherwise - */ - private static boolean isExtractingRequired(URLConnection conn, File targetFile) { - if (!targetFile.exists()) { - // Extract anyway if the file doesn't exist - return true; - } - - // OK, if the file exists then compare last modified date - // of this file to file in jar - long targetLastModified = targetFile.lastModified(); - long sourceLastModified = conn.getLastModified(); - - // Allow ~1 second range for OSes that only support low precision - return Math.abs(sourceLastModified - targetLastModified) >= 1000; - - // Note extraction should also work fine if user who was using - // a newer version of library, downgraded to an older version - // which will make above check invalid and extract it again. - } } diff --git a/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryExtractionTest.java b/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryExtractionTest.java new file mode 100644 index 0000000000..6de6094bf2 --- /dev/null +++ b/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryExtractionTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2009-2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.system; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.net.URI; +import java.nio.file.DirectoryStream; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.AclEntryType; +import java.nio.file.attribute.AclFileAttributeView; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class NativeLibraryExtractionTest { + @TempDir Path root; + + @Test + void createsUniquePrivateDirectories() throws Exception { + Path first = NativeLibraryExtraction.createDirectory(root, "native-"); + Path second = NativeLibraryExtraction.createDirectory(root, "native-"); + assertNotEquals(first, second); + if (Files.getFileStore(first).supportsFileAttributeView("posix")) { + assertEquals(PosixFilePermissions.fromString("rwx------"), Files.getPosixFilePermissions(first)); + } else { + AclFileAttributeView view = Files.getFileAttributeView(first, AclFileAttributeView.class); + assertNotNull(view); + assertTrue(view.getAcl().stream().anyMatch(entry -> entry.type() == AclEntryType.ALLOW + && entry.principal().equals(viewOwner(first)))); + assertTrue(view.getAcl().stream().filter(entry -> entry.type() == AclEntryType.ALLOW) + .allMatch(entry -> entry.principal().equals(viewOwner(first)))); + } + } + + @Test + void refusesSharedDirectoryWithoutStickyBit() throws Exception { + Path publicTemp = Paths.get("/tmp"); + assumeTrue(Files.isDirectory(publicTemp) && Files.getFileStore(publicTemp).supportsFileAttributeView("posix")); + Path shared = Files.createTempDirectory(publicTemp, "native-shared-test-"); + try { + Files.setPosixFilePermissions(shared, PosixFilePermissions.fromString("rwxrwxrwx")); + IOException failure = assertThrows(IOException.class, + () -> NativeLibraryExtraction.createDirectory(shared, "native-")); + assertEquals("Native extraction ancestor is writable by other users: " + shared.toRealPath(), + failure.getMessage()); + } finally { + try (DirectoryStream children = Files.newDirectoryStream(shared)) { + for (Path child : children) Files.deleteIfExists(child); + } + Files.deleteIfExists(shared); + } + } + + @Test + void refusesFilesystemsWithoutPrivatePermissions() throws Exception { + URI zip = URI.create("jar:" + root.resolve("unsupported.zip").toUri()); + try (FileSystem fs = FileSystems.newFileSystem(zip, Collections.singletonMap("create", "true"))) { + assertThrows(IOException.class, () -> NativeLibraryExtraction.createDirectory(fs.getPath("/"), "native-")); + } + } + + private static java.nio.file.attribute.UserPrincipal viewOwner(Path directory) { + try { + return directory.getFileSystem().getUserPrincipalLookupService() + .lookupPrincipalByName(System.getProperty("user.name")); + } catch (IOException failure) { + throw new AssertionError(failure); + } + } +} diff --git a/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryLoaderExtractionTest.java b/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryLoaderExtractionTest.java new file mode 100644 index 0000000000..2cbd64e9f8 --- /dev/null +++ b/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryLoaderExtractionTest.java @@ -0,0 +1,396 @@ +/* + * Copyright (c) 2009-2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.system; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.jme3.util.res.Resources; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class NativeLibraryLoaderExtractionTest { + private static final String RESOURCE = "com/jme3/system/NativeLibraryLoaderExtractionTest.class"; + @TempDir Path directory; + private final Map properties = new HashMap<>(); + private final Map fields = new HashMap<>(); + private Path tmp; + private Path home; + + @BeforeEach + void isolateLoaderState() throws Exception { + for (String key : new String[]{"java.io.tmpdir", "user.home", + NativeLibraryLoader.CUSTOM_EXTRACTION_FOLDER_PROPERTY, + NativeLibraryLoader.EXTRACT_NATIVE_LIBRARIES_PROPERTY, + NativeLibraryLoader.CACHE_FOLDER_PROPERTY}) { + properties.put(key, System.getProperty(key)); + System.clearProperty(key); + } + for (String key : new String[]{"extractionFolder", "extractionFolderOverride", + "extractNativeLibrariesOverride", "extractionRootIndex"}) { + Field field = field(key); + fields.put(key, field.get(null)); + field.set(null, key.equals("extractionRootIndex") ? 0 : null); + } + tmp = Files.createDirectory(directory.resolve("tmp")); + home = directory.resolve("home"); + System.setProperty("java.io.tmpdir", tmp.toString()); + System.setProperty("user.home", home.toString()); + System.setProperty(NativeLibraryLoader.CACHE_FOLDER_PROPERTY, home.resolve(".cache").toString()); + } + + @AfterEach + void restoreLoaderState() throws Exception { + for (Map.Entry entry : fields.entrySet()) field(entry.getKey()).set(null, entry.getValue()); + for (Map.Entry entry : properties.entrySet()) { + if (entry.getValue() == null) System.clearProperty(entry.getKey()); + else System.setProperty(entry.getKey(), entry.getValue()); + } + } + + @Test + void tempIsPrivateAndDoesNotCreateHome() throws Exception { + Path selected = NativeLibraryLoader.getExtractionFolder().toPath(); + assertEquals(tmp.toRealPath(), selected.getParent()); + assertFalse(Files.exists(home)); + if (Files.getFileStore(selected).supportsFileAttributeView("posix")) { + assertEquals(PosixFilePermissions.fromString("rwx------"), Files.getPosixFilePermissions(selected)); + } else if (Files.getFileStore(selected).supportsFileAttributeView("acl")) { + assertTrue(Files.isWritable(selected)); + } + } + + @Test + void invalidHomeDoesNotBreakTemp() { + System.setProperty("user.home", "invalid" + (char) 0 + "home"); + assertTrue(NativeLibraryLoader.getExtractionFolder().isDirectory()); + } + + @Test + void invalidTempFallsBackToCache() throws Exception { + Files.createDirectories(home.resolve(".cache")); + System.setProperty("java.io.tmpdir", "invalid" + (char) 0 + "temp"); + Path selected = NativeLibraryLoader.getExtractionFolder().toPath(); + assertEquals(cacheRoot().toRealPath(), selected.getParent()); + } + + @Test + void tempFileFallsBackToCacheDuringExtraction() throws Exception { + Files.createDirectories(home.resolve(".cache")); + Path blockedTemp = Files.createFile(directory.resolve("temp-file")); + System.setProperty("java.io.tmpdir", blockedTemp.toString()); + + String loaded = NativeLibraryLoader.loadNativeLibrary(register("fixture.so", path -> {}), true); + assertTrue(Paths.get(loaded).startsWith(cacheRoot().toRealPath())); + assertTrue(Files.isRegularFile(Paths.get(loaded))); + } + + @Test + void unusableTempAndCacheFallBackToHome() throws Exception { + System.setProperty("java.io.tmpdir", Files.createFile(directory.resolve("tmp-file")).toString()); + Path cache = cacheRoot(); + Files.createDirectories(cache.getParent()); + Files.createFile(cache); + String loaded = NativeLibraryLoader.loadNativeLibrary(register("fixture.so", path -> {}), true); + assertTrue(Paths.get(loaded).startsWith(home.resolve(".jme3").toRealPath())); + assertTrue(Files.isRegularFile(Paths.get(loaded))); + } + + @Test + void ignoresLegacyPredictableTempPath() throws Exception { + Path legacy = Files.createDirectory(tmp.resolve("jme3")); + Path poison = Files.write(legacy.resolve("poison"), new byte[]{1, 2, 3}); + Path selected = NativeLibraryLoader.getExtractionFolder().toPath(); + assertEquals(tmp.toRealPath(), selected.getParent()); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(poison)); + } + + @Test + void rejectsExistingLibraryInsteadOfTrustingTimestamp() throws Exception { + Path selected = NativeLibraryLoader.getExtractionFolder().toPath(); + Path poison = Files.write(selected.resolve("fixture.so"), new byte[]{1, 2, 3}); + String name = register("fixture.so", path -> {}); + String loaded = NativeLibraryLoader.loadNativeLibrary(name, true); + assertFalse(Paths.get(loaded).startsWith(selected)); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(poison)); + } + + @Test + void rejectsSymlinkWithoutChangingTarget() throws Exception { + assumeTrue(Files.getFileStore(tmp).supportsFileAttributeView("posix")); + Path victim = Files.write(directory.resolve("victim"), new byte[]{7, 8, 9}); + Path selected = NativeLibraryLoader.getExtractionFolder().toPath(); + Files.createSymbolicLink(selected.resolve("fixture.so"), victim); + String loaded = NativeLibraryLoader.loadNativeLibrary(register("fixture.so", path -> {}), true); + assertFalse(Paths.get(loaded).startsWith(selected)); + assertArrayEquals(new byte[]{7, 8, 9}, Files.readAllBytes(victim)); + } + + @Test + void failedLoadFallsBackAndRemovesFailedFile() throws Exception { + Files.createDirectories(home.resolve(".cache")); + AtomicInteger calls = new AtomicInteger(); + List attempted = new ArrayList<>(); + String name = register("fixture.so", path -> { + attempted.add(Paths.get(path)); + if (calls.getAndIncrement() == 0) throw new UnsatisfiedLinkError("simulated mapping failure"); + }); + String loaded = NativeLibraryLoader.loadNativeLibrary(name, true); + assertEquals(2, calls.get()); + assertFalse(Files.exists(attempted.get(0))); + assertTrue(Paths.get(loaded).startsWith(cacheRoot().toRealPath())); + assertEquals(loaded, NativeLibraryLoader.loadNativeLibrary(name, true)); + assertEquals(2, calls.get(), "Successful loads must not be rewritten or reloaded"); + } + + @Test + void errorsIncludeAllLoadAttemptsAndOptionalFailureReturnsNull() throws Exception { + Files.createDirectories(home.resolve(".cache")); + String name = register("fixture.so", path -> { throw new UnsatisfiedLinkError("mapping denied"); }); + UnsatisfiedLinkError error = assertThrows(UnsatisfiedLinkError.class, + () -> NativeLibraryLoader.loadNativeLibrary(name, true)); + assertEquals(3, error.getSuppressed().length); + assertNull(NativeLibraryLoader.loadNativeLibrary(name, false)); + } + + @Test + void allUnusableRootsFailWithoutRetryingForever() throws Exception { + Path notDirectory = Files.createFile(directory.resolve("not-a-directory")); + Path cache = Files.createDirectory(directory.resolve("cache")); + Files.createFile(cache.resolve(".jme3")); + NativeLibraryLoader.setCustomExtractionFolder(notDirectory.toString()); + System.setProperty("java.io.tmpdir", notDirectory.toString()); + System.setProperty(NativeLibraryLoader.CACHE_FOLDER_PROPERTY, cache.toString()); + System.setProperty("user.home", directory.resolve("missing/home").toString()); + + String name = register("fixture.so", path -> fail("Must not load")); + UnsatisfiedLinkError error = assertThrows(UnsatisfiedLinkError.class, + () -> NativeLibraryLoader.loadNativeLibrary(name, true)); + assertEquals(1, error.getSuppressed().length); + assertEquals(4, error.getSuppressed()[0].getCause().getSuppressed().length); + assertNull(NativeLibraryLoader.loadNativeLibrary(name, false)); + } + + @Test + void fatalVmErrorIsNotSwallowed() { + OutOfMemoryError fatal = new OutOfMemoryError("test sentinel"); + String name = register("fixture.so", path -> { throw fatal; }); + assertSame(fatal, assertThrows(OutOfMemoryError.class, () -> NativeLibraryLoader.loadNativeLibrary(name, true))); + } + + @Test + void concurrentCallsShareOneDirectoryAndLoadOnce() throws Exception { + AtomicInteger loads = new AtomicInteger(); + String name = register("fixture.so", path -> loads.incrementAndGet()); + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < 32; i++) { + futures.add(executor.submit(() -> NativeLibraryLoader.loadNativeLibrary(name, true))); + } + String first = futures.get(0).get(10, TimeUnit.SECONDS); + for (Future future : futures) assertEquals(first, future.get(10, TimeUnit.SECONDS)); + assertEquals(1, loads.get()); + try (Stream children = Files.list(tmp)) { + assertEquals(1, children.count()); + } + } finally { + executor.shutdownNow(); + } + } + + @Test + void extractionNameCannotEscapePrivateDirectory() { + for (String filename : new String[]{"../outside.so", "..\\outside.dll", "C:outside.dll", "library.dll:stream"}) { + String name = register(filename, path -> fail("Must not load")); + assertThrows(IllegalArgumentException.class, () -> NativeLibraryLoader.loadNativeLibrary(name, true)); + } + } + + @Test + void customLoadFailureFallsBackToTemp() throws Exception { + Path custom = Files.createDirectory(directory.resolve("custom")); + NativeLibraryLoader.setCustomExtractionFolder(custom.toString()); + AtomicInteger attempts = new AtomicInteger(); + List attempted = new ArrayList<>(); + String name = register("fixture.so", path -> { + attempted.add(Paths.get(path)); + if (attempts.getAndIncrement() == 0) throw new UnsatisfiedLinkError("custom failure"); + }); + String loaded = NativeLibraryLoader.loadNativeLibrary(name, true); + assertEquals(2, attempts.get()); + assertTrue(Paths.get(loaded).startsWith(tmp.toRealPath())); + assertTrue(attempted.get(0).startsWith(custom.toRealPath())); + assertFalse(Files.exists(attempted.get(0))); + } + + @Test + void unusableCustomDirectoryFallsBackToTemp() throws Exception { + Path custom = Files.createFile(directory.resolve("not-a-directory")); + NativeLibraryLoader.setCustomExtractionFolder(custom.toString()); + String loaded = NativeLibraryLoader.loadNativeLibrary(register("fixture.so", path -> {}), true); + assertTrue(Paths.get(loaded).startsWith(tmp.toRealPath())); + } + + @Test + void customDirectoryIsCreatedWhenExtracting() throws Exception { + Path custom = directory.resolve("new-custom"); + NativeLibraryLoader.setCustomExtractionFolder(custom.toString()); + Path selected = NativeLibraryLoader.getExtractionFolder().toPath(); + assertEquals(custom.toRealPath(), selected.getParent()); + assertTrue(Files.isDirectory(selected)); + + String loaded = NativeLibraryLoader.loadNativeLibrary(register("fixture.so", path -> {}), true); + assertEquals(selected.resolve("fixture.so").toString(), loaded); + assertTrue(Files.isDirectory(custom)); + try (InputStream source = Resources.getResource(RESOURCE).openStream()) { + ByteArrayOutputStream expected = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + for (int read; (read = source.read(buffer)) != -1;) { + expected.write(buffer, 0, read); + } + assertArrayEquals(expected.toByteArray(), Files.readAllBytes(Paths.get(loaded))); + } + } + + @Test + void settingCustomDirectoryAfterAutomaticSelectionTakesEffect() throws Exception { + AtomicInteger loads = new AtomicInteger(); + String name = register("fixture.so", path -> loads.incrementAndGet()); + String alreadyLoaded = NativeLibraryLoader.loadNativeLibrary(name, true); + Path custom = directory.resolve("late-custom"); + NativeLibraryLoader.setCustomExtractionFolder(custom.toString()); + Path selected = NativeLibraryLoader.getExtractionFolder().toPath(); + assertEquals(custom.toRealPath(), selected.getParent()); + assertEquals(alreadyLoaded, NativeLibraryLoader.loadNativeLibrary(name, true)); + assertEquals(1, loads.get()); + assertEquals(selected.resolve("other.so").toString(), + NativeLibraryLoader.loadNativeLibrary(register("other.so", path -> {}), true)); + } + + @Test + void customDirectoryDoesNotTrustExistingFile() throws Exception { + Path custom = Files.createDirectory(directory.resolve("existing-custom")); + NativeLibraryLoader.setCustomExtractionFolder(custom.toString()); + Path selected = NativeLibraryLoader.getExtractionFolder().toPath(); + Path target = Files.write(selected.resolve("fixture.so"), new byte[]{1, 2, 3}); + + String loaded = NativeLibraryLoader.loadNativeLibrary(register("fixture.so", path -> {}), true); + assertTrue(Paths.get(loaded).startsWith(tmp.toRealPath())); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(target)); + } + + @Test + void noexecTempIsSkippedEvenForDelegatedLoaders() throws Exception { + Files.createDirectories(home.resolve(".cache")); + Path noexec = createNoexecDirectory(); + try { + System.setProperty("java.io.tmpdir", noexec.toString()); + String loaded = NativeLibraryLoader.loadNativeLibrary(register("fixture.so", path -> {}), true); + assertTrue(Paths.get(loaded).startsWith(cacheRoot().toRealPath())); + try (Stream children = Files.list(noexec)) { + assertEquals(0, children.count()); + } + } finally { + Files.delete(noexec); + } + } + + private String register(String filename, Consumer loader) { + String name = "test-" + System.nanoTime(); + NativeLibraryLoader.registerNativeLibrary(new NativeLibrary(name, JmeSystem.getPlatform(), RESOURCE, filename, loader)); + return name; + } + + private Path cacheRoot() { + return home.resolve(".cache/.jme3"); + } + + private static Path createNoexecDirectory() throws Exception { + assumeTrue(JmeSystem.getPlatform().getOs() == Platform.Os.Linux); + Path executable = Paths.get("/bin/true"); + assumeTrue(Files.isRegularFile(executable)); + for (String name : new String[]{"/dev/shm", "/run/lock"}) { + Path root = Paths.get(name); + if (!Files.isDirectory(root) || !Files.isWritable(root)) continue; + Path probe = null; + try { + probe = Files.createTempFile(root, "jme-noexec-", null); + Files.copy(executable, probe, StandardCopyOption.REPLACE_EXISTING); + Files.setPosixFilePermissions(probe, PosixFilePermissions.fromString("rwx------")); + try { + // Exercise actual execution instead of repeating the loader's isExecutable check. + Process process = new ProcessBuilder(probe.toString()).start(); + if (!process.waitFor(5, TimeUnit.SECONDS)) process.destroyForcibly(); + } catch (IOException cannotExecute) { + return Files.createTempDirectory(root, "jme-test-"); + } + } catch (IOException unavailable) { + // Try another writable candidate. + } finally { + if (probe != null) Files.deleteIfExists(probe); + } + } + assumeTrue(false, "No writable noexec filesystem available"); + return null; + } + + private static Field field(String name) throws Exception { + Field result = NativeLibraryLoader.class.getDeclaredField(name); + result.setAccessible(true); + return result; + } + +} diff --git a/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryLoaderTest.java b/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryLoaderTest.java index 8a5d0a39c4..a3a116c04c 100644 --- a/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryLoaderTest.java +++ b/jme3-desktop/src/test/java/com/jme3/system/NativeLibraryLoaderTest.java @@ -41,6 +41,9 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class NativeLibraryLoaderTest { @@ -117,4 +120,20 @@ void loadNativeLibraryUsesExtractionFolderSystemProperties() throws Exception { assertEquals(nativeLibraryFile.toAbsolutePath().toString(), result); assertEquals(result, loadedPath.get()); } + + @Test + void disabledExtractionRequiresCustomFolder() { + NativeLibraryLoader.setExtractNativeLibraries(false); + String libraryName = "missingCustomFolderTest" + System.nanoTime(); + NativeLibraryLoader.registerNativeLibrary(new NativeLibrary( + libraryName, JmeSystem.getPlatform(), "native/missing/libtest.so", "libtest.so", path -> {})); + + IllegalStateException folderError = assertThrows(IllegalStateException.class, + NativeLibraryLoader::getExtractionFolder); + assertTrue(folderError.getMessage().contains(NativeLibraryLoader.CUSTOM_EXTRACTION_FOLDER_PROPERTY)); + UnsatisfiedLinkError loadError = assertThrows(UnsatisfiedLinkError.class, + () -> NativeLibraryLoader.loadNativeLibrary(libraryName, true)); + assertTrue(loadError.getMessage().contains(NativeLibraryLoader.CUSTOM_EXTRACTION_FOLDER_PROPERTY)); + assertNull(NativeLibraryLoader.loadNativeLibrary(libraryName, false)); + } }