NativeLibraryLoader.java

/*
 * junixsocket
 *
 * Copyright 2009-2024 Christian Kohlschütter
 *
 * 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.
 */
package org.newsclub.net.unix;

import java.io.Closeable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;

import com.kohlschutter.annotations.compiletime.SuppressFBWarnings;

@SuppressFBWarnings("RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE")
final class NativeLibraryLoader implements Closeable {
  private static final String PROP_LIBRARY_DISABLE = "org.newsclub.net.unix.library.disable";
  private static final String PROP_LIBRARY_OVERRIDE = "org.newsclub.net.unix.library.override";
  private static final String PROP_LIBRARY_OVERRIDE_FORCE =
      "org.newsclub.net.unix.library.override.force";
  private static final String PROP_LIBRARY_TMPDIR = "org.newsclub.net.unix.library.tmpdir";

  private static final File TEMP_DIR;
  private static final String OS_NAME_SIMPLIFIED = lookupArchProperty("os.name", "UnknownOS");

  private static final List<String> ARCHITECTURE_AND_OS = architectureAndOS();
  private static final String LIBRARY_NAME = "junixsocket-native";

  private static final AtomicBoolean LOADED = new AtomicBoolean(false);
  private static final boolean IS_ANDROID = checkAndroid();

  static {
    String dir = System.getProperty(PROP_LIBRARY_TMPDIR, System.getProperty("java.io.tmpdir",
        null));
    TEMP_DIR = (dir == null) ? null : new File(dir);
  }

  NativeLibraryLoader() {
  }

  /**
   * Returns the temporary directory where the native library is extracted to; debugging only.
   *
   * @return The temporary directory.
   */
  static File tempDir() {
    return TEMP_DIR;
  }

  private List<LibraryCandidate> tryProviderClass(String providerClassname, String artifactName)
      throws IOException, ClassNotFoundException {
    Class<?> providerClass = Class.forName(providerClassname);

    String version = getArtifactVersion(providerClass, artifactName);
    String libraryNameAndVersion = LIBRARY_NAME + "-" + version;

    return findLibraryCandidates(artifactName, libraryNameAndVersion, providerClass);
  }

  public static String getJunixsocketVersion() throws IOException {
    // NOTE: This can't easily be tested from within the junixsocket-common Maven build

    String v = BuildProperties.getBuildProperties().get("git.build.version");
    if (v != null && !v.startsWith("$")) {
      return v;
    }

    return getArtifactVersion(AFSocket.class, "junixsocket-common");
  }

  private static String getArtifactVersion(Class<?> providerClass, String... artifactNames)
      throws IOException {
    for (String artifactName : artifactNames) {
      Properties p = new Properties();
      String resource = "/META-INF/maven/com.kohlschutter.junixsocket/" + artifactName
          + "/pom.properties";
      try (InputStream in = providerClass.getResourceAsStream(resource)) {
        if (in == null) {
          throw new FileNotFoundException("Could not find resource " + resource + " relative to "
              + providerClass);
        }
        p.load(in);
        String version = p.getProperty("version");

        Objects.requireNonNull(version, "Could not read version from pom.properties");
        return version;
      }
    }
    throw new IllegalStateException("No artifact names specified");
  }

  private abstract static class LibraryCandidate implements Closeable {
    protected final String libraryNameAndVersion;

    protected LibraryCandidate(String libraryNameAndVersion) {
      this.libraryNameAndVersion = libraryNameAndVersion;
    }

    @SuppressFBWarnings("THROWS_METHOD_THROWS_CLAUSE_BASIC_EXCEPTION")
    abstract String load() throws Exception;

    @Override
    public abstract void close();

    @Override
    public String toString() {
      return super.toString() + "[" + libraryNameAndVersion + "]";
    }
  }

  private static final class StandardLibraryCandidate extends LibraryCandidate {
    StandardLibraryCandidate(String version) {
      super(version == null ? LIBRARY_NAME : LIBRARY_NAME + "-" + version);
    }

    @Override
    @SuppressFBWarnings("THROWS_METHOD_THROWS_CLAUSE_BASIC_EXCEPTION")
    String load() throws Exception, LinkageError {
      if (libraryNameAndVersion != null) {
        System.loadLibrary(libraryNameAndVersion);
        return libraryNameAndVersion;
      }
      return null;
    }

    @Override
    public void close() {
    }

    @Override
    public String toString() {
      return super.toString() + "(standard library path)";
    }
  }

  private static final class ClasspathLibraryCandidate extends LibraryCandidate {
    private final String artifactName;
    private final URL library;
    private final String path;

    ClasspathLibraryCandidate(String artifactName, String libraryNameAndVersion, String path,
        URL library) {
      super(libraryNameAndVersion);
      this.artifactName = artifactName;
      this.path = path;
      this.library = library;
    }

    /**
     * Even though we ask the JVM to delete the library file upon VM exit, this may not be honored
     * in all cases (crash, Windows, etc.)
     *
     * Therefore, we attempt to delete these files whenever another JVM using junixsocket starts up.
     * This is simplified by keeping empty marker files next to the temporary shared library file.
     *
     * @param libDir The directory to check.
     */
    private void deleteLibTmpDelFiles(File libDir) {
      if (libDir == null) {
        try {
          File tempFile = File.createTempFile("libtmp", ".del");
          libDir = tempFile.getParentFile();
          tryDelete(tempFile);
        } catch (IOException e) {
          return;
        }
      }
      File[] filesToDelete = libDir.listFiles((File f) -> {
        if (!f.isFile()) {
          return false;
        }
        String name = f.getName();
        return name.startsWith("libtmp") && name.endsWith(".del");
      });
      if (filesToDelete == null || filesToDelete.length == 0) {
        return;
      }

      for (File f : filesToDelete) {
        tryDelete(f);
        String n = f.getName();
        n = n.substring(0, n.length() - ".del".length());
        File libFile = new File(f.getParentFile(), n);
        tryDelete(libFile);
      }
    }

    @Override
    @SuppressWarnings("PMD.CognitiveComplexity")
    synchronized String load() throws IOException, LinkageError {
      if (libraryNameAndVersion == null) {
        return null;
      }

      File libDir = TEMP_DIR;
      File userHomeDir = new File(System.getProperty("user.home", "."));
      File userDirOrNull = new File(System.getProperty("user.dir", "."));
      if (userHomeDir.equals(userDirOrNull)) {
        userDirOrNull = null;
      }

      deleteLibTmpDelFiles(libDir);
      deleteLibTmpDelFiles(userHomeDir);
      if (userDirOrNull != null) {
        deleteLibTmpDelFiles(userDirOrNull);
      }

      for (int attempt = 0; attempt < 3; attempt++) {
        File libFile;
        try {
          libFile = File.createTempFile("libtmp", System.mapLibraryName(libraryNameAndVersion),
              libDir);
          try (InputStream libraryIn = library.openStream();
              OutputStream out = new FileOutputStream(libFile)) { // NOPMD UseTryWithResources
            byte[] buf = new byte[4096];
            int read;
            while ((read = libraryIn.read(buf)) >= 0) {
              out.write(buf, 0, read);
            }
          }
        } catch (IOException e) {
          throw e;
        }

        try {
          System.load(libFile.getAbsolutePath());
        } catch (UnsatisfiedLinkError e) {
          // Operation not permitted; permission denied; EPERM...
          // -> tmp directory may be mounted with "noexec", try loading from user.home, user.dir

          switch (attempt) {
            case 0:
              libDir = userHomeDir;
              break;
            case 1:
              if (userDirOrNull != null) {
                libDir = userDirOrNull;
                break;
              }
              // fall-through
            default:
              throw e;
          }

          continue;
        } finally {
          if (!libFile.delete() && libFile.exists()) {
            libFile.deleteOnExit();

            File markerFile = new File(libFile.getParentFile(), libFile.getName() + ".del");
            try {
              Files.createFile(markerFile.toPath());
              Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                if (!libFile.exists() || libFile.delete()) {
                  tryDelete(markerFile);
                }
              }));
            } catch (IOException | UnsupportedOperationException e) {
              // ignore
            }
          }
        }

        // If we reach this, then we were able to load the library
        break; // NOPMD.AvoidBranchingStatementAsLastInLoop
      }
      return artifactName + "/" + libraryNameAndVersion;
    }

    @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_BAD_PRACTICE")
    private static void tryDelete(File f) {
      f.delete(); // NOPMD
    }

    @Override
    public void close() {
    }

    @Override
    public String toString() {
      return super.toString() + "(" + artifactName + ":" + path + ")";
    }
  }

  private synchronized void setLoaded(String library) {
    setLoaded0(library);
  }

  @SuppressFBWarnings("THROWS_METHOD_THROWS_RUNTIMEEXCEPTION")
  private static synchronized void setLoaded0(String library) {
    if (LOADED.compareAndSet(false, true)) {
      NativeUnixSocket.setLoaded(true);
      AFSocket.loadedLibrary = library;
      try {
        NativeUnixSocket.init();
      } catch (RuntimeException e) {
        throw e;
      } catch (Exception e) {
        throw new IllegalStateException(e);
      }
    }
  }

  private Throwable loadLibraryOverride() {
    String libraryOverride = System.getProperty(PROP_LIBRARY_OVERRIDE, "");
    String libraryOverrideForce = System.getProperty(PROP_LIBRARY_OVERRIDE_FORCE, "false");

    boolean overrideIsAbsolute;
    try {
      if (libraryOverrideForce.length() <= 5) { // reasonable simplification
        overrideIsAbsolute = false;
      } else {
        overrideIsAbsolute = new File(libraryOverrideForce).isAbsolute();
      }
    } catch (Exception e) {
      overrideIsAbsolute = false;
      e.printStackTrace(); // NOPMD
    }
    if (libraryOverride.isEmpty() && overrideIsAbsolute) {
      libraryOverride = libraryOverrideForce;
      libraryOverrideForce = "true";
    }

    if (!libraryOverride.isEmpty()) {
      try {
        System.load(libraryOverride);
        setLoaded(libraryOverride);
        return null;
      } catch (Exception | LinkageError e) {
        if (Boolean.parseBoolean(libraryOverrideForce)) {
          throw e;
        }
        return e;
      }
    } else {
      return new Exception("No library specified with -D" + PROP_LIBRARY_OVERRIDE + "=");
    }
  }

  private static Object loadLibrarySyncMonitor() {
    Object monitor = NativeLibraryLoader.class.getClassLoader(); // NOPMD
    if (monitor == null) {
      // bootstrap classloader?
      return NativeLibraryLoader.class;
    } else {
      return monitor;
    }
  }

  @SuppressWarnings("null")
  public synchronized void loadLibrary() {
    synchronized (loadLibrarySyncMonitor()) { // NOPMD We want to lock this class' classloader.
      if (LOADED.get()) {
        // Already loaded
        return;
      }

      NativeUnixSocket.initPre();

      // set -Dorg.newsclub.net.unix.library.override.force=provided to assume that
      // we already have loaded the library via System.load, etc.
      if ("provided".equals(System.getProperty(PROP_LIBRARY_OVERRIDE_FORCE, ""))) {
        setLoaded("provided");
        return;
      }

      boolean provided = false;
      try {
        NativeUnixSocket.noop();
        provided = true;
      } catch (UnsatisfiedLinkError | Exception e) {
        // expected unless we manually loaded the library
      }
      if (provided) {
        setLoaded("provided");
        return;
      }

      if (Boolean.parseBoolean(System.getProperty(PROP_LIBRARY_DISABLE, "false"))) {
        throw initCantLoadLibraryError(Collections.singletonList(new UnsupportedOperationException(
            "junixsocket disabled by System.property " + PROP_LIBRARY_DISABLE)));
      }

      List<Throwable> suppressedThrowables = new ArrayList<>();
      Throwable ex = loadLibraryOverride();
      if (ex == null) {
        return;
      }
      suppressedThrowables.add(ex);

      List<LibraryCandidate> candidates = initLibraryCandidates(suppressedThrowables);

      String loadedLibraryId = null;
      for (LibraryCandidate candidate : candidates) {
        try {
          if ((loadedLibraryId = candidate.load()) != null) {
            break;
          }
        } catch (Exception | LinkageError e) {
          suppressedThrowables.add(e);
        }
      }

      for (LibraryCandidate candidate : candidates) {
        candidate.close();
      }

      if (loadedLibraryId == null) {
        throw initCantLoadLibraryError(suppressedThrowables);
      }

      setLoaded(loadedLibraryId);
    }
  }

  private UnsatisfiedLinkError initCantLoadLibraryError(List<Throwable> suppressedThrowables) {
    String message = "Could not load native library " + LIBRARY_NAME + " for architecture "
        + ARCHITECTURE_AND_OS;

    String cp = System.getProperty("java.class.path", "");
    if (cp.contains("junixsocket-native-custom/target-eclipse") || cp.contains(
        "junixsocket-native-common/target-eclipse")) {
      message += "\n\n*** ECLIPSE USERS ***\nIf you're running from within Eclipse, "
          + "please close the projects \"junixsocket-native-common\" and \"junixsocket-native-custom\"\n";
    }

    UnsatisfiedLinkError e = new UnsatisfiedLinkError(message);
    if (suppressedThrowables != null) {
      for (Throwable suppressed : suppressedThrowables) {
        e.addSuppressed(suppressed);
      }
    }
    throw e;
  }

  private List<LibraryCandidate> initLibraryCandidates(List<Throwable> suppressedThrowables) {
    List<LibraryCandidate> candidates = new ArrayList<>();
    try {
      String version = getArtifactVersion(getClass(), "junixsocket-common", "junixsocket-core");
      if (version != null) {
        candidates.add(new StandardLibraryCandidate(version));
      }
    } catch (Exception e) {
      suppressedThrowables.add(e);
    }

    try {
      candidates.addAll(tryProviderClass("org.newsclub.lib.junixsocket.custom.NarMetadata",
          "junixsocket-native-custom"));
    } catch (Exception e) {
      suppressedThrowables.add(e);
    }
    try {
      candidates.addAll(tryProviderClass("org.newsclub.lib.junixsocket.common.NarMetadata",
          "junixsocket-native-common"));
    } catch (Exception e) {
      suppressedThrowables.add(e);
    }

    candidates.add(new StandardLibraryCandidate(null));

    return candidates;
  }

  private static String lookupArchProperty(String key, String defaultVal) {
    return System.getProperty(key, defaultVal).replaceAll("[ /\\\\'\";:\\$]", "");
  }

  private static List<String> architectureAndOS() {
    String arch = lookupArchProperty("os.arch", "UnknownArch");

    List<String> list = new ArrayList<>();
    if (IS_ANDROID) {
      // Android identifies itself as os.name="Linux"
      // let's probe for an Android-specific library first
      list.add(arch + "-Android");
    }
    list.add(arch + "-" + OS_NAME_SIMPLIFIED);
    if (OS_NAME_SIMPLIFIED.startsWith("Windows") && !"Windows10".equals(OS_NAME_SIMPLIFIED)) {
      list.add(arch + "-" + "Windows10");
    }

    if ("MacOSX".equals(OS_NAME_SIMPLIFIED) && "x86_64".equals(arch)) {
      list.add("aarch64-MacOSX"); // Rosetta 2
    }

    return list;
  }

  private static boolean checkAndroid() {
    String vmName = lookupArchProperty("java.vm.name", "UnknownVM");
    String vmSpecVendor = lookupArchProperty("java.vm.specification.vendor",
        "UnknownSpecificationVendor");

    return ("Dalvik".equals(vmName) || vmSpecVendor.contains("Android"));
  }

  static boolean isAndroid() {
    return IS_ANDROID;
  }

  static List<String> getArchitectureAndOS() {
    return ARCHITECTURE_AND_OS;
  }

  private static URL validateResourceURL(URL url) {
    if (url == null) {
      return null;
    }
    try (InputStream unused = url.openStream()) {
      return url;
    } catch (IOException e) {
      return null;
    }
  }

  private static String mapLibraryName(String libraryNameAndVersion) {
    String mappedName = System.mapLibraryName(libraryNameAndVersion);
    if (mappedName.endsWith(".so")) {
      // https://github.com/eclipse-openj9/openj9/issues/9788
      // Many thanks to Fabrice Bourquin for finding this issue!
      switch (OS_NAME_SIMPLIFIED) {
        case "AIX":
          mappedName = mappedName.substring(0, mappedName.length() - 3) + ".a";
          break;
        case "OS400":
          mappedName = mappedName.substring(0, mappedName.length() - 3) + ".srvpgm";
          break;
        default:
          break;
      }
    }
    return mappedName;
  }

  private List<LibraryCandidate> findLibraryCandidates(String artifactName,
      String libraryNameAndVersion, Class<?> providerClass) {
    String mappedName = mapLibraryName(libraryNameAndVersion);

    String[] prefixes = mappedName.startsWith("lib") ? new String[] {""} : new String[] {"", "lib"};

    List<LibraryCandidate> list = new ArrayList<>();
    for (String archOs : ARCHITECTURE_AND_OS) {
      for (String compiler : new String[] {"clang", "gcc"
          // "gpp", "g++", "linker", "clang", "gcc", "cc", "CC", "icpc", "icc", "xlC", "xlC_r",
          // "msvc",
          // "icl", "ecpc", "ecc"
      }) {
        for (String prefix : prefixes) {
          String path = "/lib/" + archOs + "-" + compiler + "/jni/" + prefix + mappedName;

          URL url;

          url = validateResourceURL(providerClass.getResource(path));
          if (url != null) {
            list.add(new ClasspathLibraryCandidate(artifactName, libraryNameAndVersion, path, url));
          }

          // NOTE: we have to try .nodeps version _after_ trying the properly linked one.
          // While the former may throw an UnsatisfiedLinkError, this one may just terminate the VM
          // with a "symbol lookup error"
          String nodepsPath = nodepsPath(path);
          if (nodepsPath != null) {
            url = validateResourceURL(providerClass.getResource(nodepsPath));
            if (url != null) {
              list.add(new ClasspathLibraryCandidate(artifactName, libraryNameAndVersion,
                  nodepsPath, url));
            }
          }
        }
      }
    }
    return list;
  }

  private String nodepsPath(String path) {
    int lastDot = path.lastIndexOf('.');
    if (lastDot == -1) {
      return null;
    } else {
      return path.substring(0, lastDot) + ".nodeps" + path.substring(lastDot);
    }
  }

  @Override
  public void close() {
  }
}