diff --git a/java/pom.xml b/java/pom.xml index 5016a34b9d..a1db6ccf6e 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -134,6 +134,28 @@ + + + + src/main/resources + true + + copilot-runtime.properties + + + + src/main/resources + false + + copilot-runtime.properties + + + diff --git a/java/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java b/java/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java new file mode 100644 index 0000000000..6f9cd9b82f --- /dev/null +++ b/java/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.ffi; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +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.Properties; +import java.util.UUID; +import java.util.logging.Logger; + +/** + * Locates, extracts, and caches the {@code runtime.node} native binary. + * + *

+ * Resolution order: + *

    + *
  1. The {@code COPILOT_RUNTIME_PATH} environment variable (if set, treated as + * the resolved {@code runtime.node} path and returned directly).
  2. + *
  3. Classpath resource {@code native//runtime.node} extracted to + * {@code ~/.copilot/runtime-cache///runtime.node}.
  4. + *
  5. A {@code runtime.node} file alongside the bundled CLI binary.
  6. + *
+ * + *

+ * The version is read from the {@code copilot-runtime.properties} resource that + * is written by Maven resource filtering at build time. A missing or blank + * version is a configuration error and causes {@link #resolve()} to throw. + * + *

+ * Extraction is atomic: the binary is written to a unique sibling temp file and + * renamed into place with {@link StandardCopyOption#ATOMIC_MOVE}. If another + * process wins the race, the winner's file is accepted after a + * regular/non-empty sanity check. No file locking is used. The execute + * permission bit is NOT set on the extracted file; JNA's {@code dlopen} does + * not require it. + */ +public final class NativeRuntimeLoader { + + private static final Logger LOG = Logger.getLogger(NativeRuntimeLoader.class.getName()); + private static final String PROPERTIES_RESOURCE = "copilot-runtime.properties"; + private static final String BINARY_NAME = "runtime.node"; + private static final String RUNTIME_PATH_ENV = "COPILOT_RUNTIME_PATH"; + private static final String CLI_PATH_ENV = "COPILOT_CLI_PATH"; + + private NativeRuntimeLoader() { + } + + /** + * Resolves the filesystem path to the {@code runtime.node} native binary, + * extracting and caching it from the classpath if necessary. + * + * @return the absolute path to an existing, non-empty {@code runtime.node} file + * @throws NativeRuntimeLoaderException + * if the binary cannot be resolved, extracted, or cached + */ + public static Path resolve() throws NativeRuntimeLoaderException { + return resolve(System.getenv(RUNTIME_PATH_ENV), System.getenv(CLI_PATH_ENV), + NativeRuntimeLoader.class.getClassLoader(), Paths.get(System.getProperty("user.home")), + PlatformDetector.detectClassifier()); + } + + static Path resolve(String runtimePathOverride, String bundledCliPath, ClassLoader classLoader, Path userHome, + String classifier) throws NativeRuntimeLoaderException { + // 1. Explicit runtime.node override + if (runtimePathOverride != null && !runtimePathOverride.isBlank()) { + return Paths.get(runtimePathOverride); + } + + // 2. Extract from classpath resource + String resourcePath = "native/" + classifier + "/" + BINARY_NAME; + + URL resourceUrl = classLoader.getResource(resourcePath); + if (resourceUrl != null) { + String version = loadVersion(classLoader); + return extractToCache(resourceUrl, version, classifier, userHome); + } + + // 3. Alongside bundled CLI (fall-through when no classpath resource) + if (bundledCliPath != null && !bundledCliPath.isBlank()) { + Path sibling = Paths.get(bundledCliPath).getParent(); + if (sibling != null) { + Path candidate = sibling.resolve(BINARY_NAME); + if (isValidCacheEntry(candidate)) { + return candidate; + } + } + } + + throw new NativeRuntimeLoaderException("Could not locate native/" + classifier + + "/runtime.node on the classpath. " + "Ensure a platform-specific native JAR is on the classpath."); + } + + /** + * Loads the artifact version from the {@code copilot-runtime.properties} + * resource on the classpath. + * + * @return the non-blank version string + * @throws NativeRuntimeLoaderException + * if the resource is missing or the version value is blank + */ + static String loadVersion() throws NativeRuntimeLoaderException { + return loadVersion(NativeRuntimeLoader.class.getClassLoader()); + } + + static String loadVersion(ClassLoader classLoader) throws NativeRuntimeLoaderException { + InputStream in = classLoader.getResourceAsStream(PROPERTIES_RESOURCE); + if (in == null) { + throw new NativeRuntimeLoaderException("Missing classpath resource: " + PROPERTIES_RESOURCE + + ". Ensure the SDK JAR was built with Maven resource filtering enabled."); + } + Properties props = new Properties(); + try (in) { + props.load(in); + } catch (IOException e) { + throw new NativeRuntimeLoaderException("Failed to read " + PROPERTIES_RESOURCE + ": " + e.getMessage(), e); + } + String version = props.getProperty("version"); + if (version == null || version.isBlank() || version.startsWith("${")) { + throw new NativeRuntimeLoaderException( + "Version property in " + PROPERTIES_RESOURCE + " is missing or was not filtered by Maven. " + + "Rebuild the project with Maven to apply resource filtering."); + } + return version.trim(); + } + + static Path extractToCache(URL resourceUrl, String version, String classifier, Path userHome) + throws NativeRuntimeLoaderException { + Path cacheDir = userHome.resolve(Paths.get(".copilot", "runtime-cache", version, classifier)); + Path cached = cacheDir.resolve(BINARY_NAME); + + // 1. Cache hit: regular, non-empty file + if (isValidCacheEntry(cached)) { + LOG.fine("Native binary cache hit: " + cached); + return cached; + } + + // 2. Create cache directory + try { + Files.createDirectories(cacheDir); + } catch (IOException e) { + throw new NativeRuntimeLoaderException("Failed to create native binary cache directory: " + cacheDir, e); + } + + // 3. Create unique temp file in same directory (ATOMIC_MOVE requires same + // filesystem) + Path temp = cacheDir.resolve(BINARY_NAME + ".tmp-" + UUID.randomUUID()); + try { + extractToTemp(resourceUrl, temp); + atomicPublish(temp, cached); + } finally { + // 6. Delete caller's temp file in finally block (no-op if already moved or + // missing) + try { + Files.deleteIfExists(temp); + } catch (IOException ignored) { + // best-effort cleanup + } + } + + return cached; + } + + private static void extractToTemp(URL resourceUrl, Path temp) throws NativeRuntimeLoaderException { + try (InputStream in = resourceUrl.openStream(); + FileChannel fc = FileChannel.open(temp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + // Copy via InputStream → temp path (piping through a buffer) + byte[] buf = new byte[65536]; + long total = 0; + int n; + while ((n = in.read(buf)) >= 0) { + int written = 0; + while (written < n) { + written += fc.write(java.nio.ByteBuffer.wrap(buf, written, n - written)); + } + total += n; + } + if (total == 0) { + throw new NativeRuntimeLoaderException( + "Classpath resource native/…/runtime.node is empty; the native JAR may be corrupt."); + } + // 4. Flush and force to disk before atomic rename + fc.force(true); + } catch (IOException e) { + throw new NativeRuntimeLoaderException("Failed to write native binary to temp file: " + temp, e); + } + } + + private static void atomicPublish(Path temp, Path cached) throws NativeRuntimeLoaderException { + // 5. Atomic rename + try { + Files.move(temp, cached, StandardCopyOption.ATOMIC_MOVE); + LOG.fine("Native binary extracted to cache: " + cached); + } catch (AtomicMoveNotSupportedException e) { + throw new NativeRuntimeLoaderException( + "Filesystem does not support atomic moves; cannot safely publish native binary to " + cached + + ". Use a local filesystem for the home directory.", + e); + } catch (IOException e) { + // Another process may have published first — accept if valid + if (isValidCacheEntry(cached)) { + LOG.fine("Native binary race: another process published first, accepting winner: " + cached); + return; + } + throw new NativeRuntimeLoaderException("Failed to atomically publish native binary to " + cached, e); + } + } + + /** + * Returns {@code true} if {@code path} is a regular, non-empty file. + * + * @param path + * the path to check + * @return {@code true} if the cache entry is valid + */ + static boolean isValidCacheEntry(Path path) { + try { + return Files.isRegularFile(path) && Files.size(path) > 0; + } catch (IOException e) { + return false; + } + } +} diff --git a/java/src/main/java/com/github/copilot/ffi/NativeRuntimeLoaderException.java b/java/src/main/java/com/github/copilot/ffi/NativeRuntimeLoaderException.java new file mode 100644 index 0000000000..9689a4cdf1 --- /dev/null +++ b/java/src/main/java/com/github/copilot/ffi/NativeRuntimeLoaderException.java @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.ffi; + +/** + * Thrown when the {@code runtime.node} native binary cannot be resolved, + * extracted, or cached by {@link NativeRuntimeLoader}. + */ +public final class NativeRuntimeLoaderException extends Exception { + + private static final long serialVersionUID = 1L; + + /** + * Constructs a new exception with the given detail message. + * + * @param message + * the detail message + */ + public NativeRuntimeLoaderException(String message) { + super(message); + } + + /** + * Constructs a new exception with the given detail message and cause. + * + * @param message + * the detail message + * @param cause + * the cause + */ + public NativeRuntimeLoaderException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/java/src/main/java/com/github/copilot/ffi/PlatformDetector.java b/java/src/main/java/com/github/copilot/ffi/PlatformDetector.java new file mode 100644 index 0000000000..1b8d20d8e6 --- /dev/null +++ b/java/src/main/java/com/github/copilot/ffi/PlatformDetector.java @@ -0,0 +1,340 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.ffi; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +/** + * Detects the current platform's OS, architecture, and (on Linux) C library + * variant, and derives the classifier string used to locate the correct + * {@code runtime.node} native binary. + * + *

+ * The 8 supported classifiers are: {@code linux-x64}, {@code linux-arm64}, + * {@code linuxmusl-x64}, {@code linuxmusl-arm64}, {@code darwin-x64}, + * {@code darwin-arm64}, {@code win32-x64}, {@code win32-arm64}. + */ +public final class PlatformDetector { + + private static final int ELF_HEADER_PROBE_BYTES = 2048; + private static final int ELF_MAGIC_0 = 0x7F; + private static final int ELF_MAGIC_1 = 'E'; + private static final int ELF_MAGIC_2 = 'L'; + private static final int ELF_MAGIC_3 = 'F'; + private static final int ELF_CLASS_32 = 1; + private static final int ELF_CLASS_64 = 2; + private static final int ELF_DATA_LITTLE_ENDIAN = 1; + private static final int ELF_DATA_BIG_ENDIAN = 2; + private static final int PT_INTERP = 3; + + /** Linux C library variant detected via ELF PT_INTERP parsing. */ + public enum LinuxLibc { + /** GNU/glibc dynamic linker detected. */ + GLIBC, + /** musl libc dynamic linker detected. */ + MUSL, + /** PT_INTERP found but not recognised as glibc or musl. */ + UNKNOWN, + /** Not running on Linux; ELF parsing is not applicable. */ + NOT_APPLICABLE + } + + private PlatformDetector() { + } + + /** + * Returns the OS identifier for the current platform. + * + * @return {@code "darwin"}, {@code "linux"}, or {@code "win32"} + * @throws IllegalStateException + * if the OS is not recognised + */ + public static String detectOs() { + return detectOs(System.getProperty("os.name", "")); + } + + /** + * Returns the OS identifier for the given {@code os.name} value. + * + * @param osName + * the value of the {@code os.name} system property + * @return {@code "darwin"}, {@code "linux"}, or {@code "win32"} + * @throws IllegalStateException + * if the value is not recognised + */ + static String detectOs(String osName) { + String normalized = osName.toLowerCase(Locale.ROOT); + if (normalized.contains("mac") || normalized.contains("darwin")) { + return "darwin"; + } + if (normalized.contains("win")) { + return "win32"; + } + if (normalized.contains("linux")) { + return "linux"; + } + throw new IllegalStateException("Unsupported os.name: " + osName); + } + + /** + * Returns the architecture identifier for the current platform. + * + * @return {@code "x64"} or {@code "arm64"} + * @throws IllegalStateException + * if the architecture is not recognised + */ + public static String detectArch() { + return detectArch(System.getProperty("os.arch", "")); + } + + /** + * Returns the architecture identifier for the given {@code os.arch} value. + * + * @param osArch + * the value of the {@code os.arch} system property + * @return {@code "x64"} or {@code "arm64"} + * @throws IllegalStateException + * if the value is not recognised + */ + static String detectArch(String osArch) { + String normalized = osArch.toLowerCase(Locale.ROOT).replace('-', '_'); + if (normalized.equals("amd64") || normalized.equals("x86_64") || normalized.equals("x64")) { + return "x64"; + } + if (normalized.equals("aarch64") || normalized.equals("arm64")) { + return "arm64"; + } + throw new IllegalStateException("Unsupported os.arch: " + osArch); + } + + /** + * Detects the Linux C library variant by parsing the ELF {@code PT_INTERP} + * segment of {@code /proc/self/exe}. + * + * @return {@link LinuxLibc#NOT_APPLICABLE} when not running on Linux, + * {@link LinuxLibc#MUSL} when the musl dynamic linker is detected, + * {@link LinuxLibc#GLIBC} when the glibc dynamic linker is detected, or + * {@link LinuxLibc#UNKNOWN} when parsing fails or the interpreter path + * is not recognised + */ + public static LinuxLibc detectLinuxLibc() { + if (!"linux".equals(detectOs())) { + return LinuxLibc.NOT_APPLICABLE; + } + try { + String interpreter = readElfPtInterp(Path.of("/proc/self/exe")); + if (interpreter.contains("/ld-musl-")) { + return LinuxLibc.MUSL; + } + if (interpreter.contains("/ld-linux-")) { + return LinuxLibc.GLIBC; + } + return LinuxLibc.UNKNOWN; + } catch (IOException ex) { + return LinuxLibc.UNKNOWN; + } + } + + /** + * Returns the classifier string for the current platform, combining OS, + * architecture, and (on Linux) C library variant. + * + * @return one of the 8 supported classifier strings, e.g. {@code "linux-x64"} + * @throws IllegalStateException + * if the platform is not recognised or not supported + */ + public static String detectClassifier() { + return detectClassifier(System.getProperty("os.name", ""), System.getProperty("os.arch", ""), + detectLinuxLibc()); + } + + static String detectClassifier(String osName, String osArch, LinuxLibc linuxLibc) { + String os = detectOs(osName); + String arch = detectArch(osArch); + String classifier; + if ("linux".equals(os)) { + classifier = (linuxLibc == LinuxLibc.MUSL ? "linuxmusl-" : "linux-") + arch; + } else { + classifier = os + "-" + arch; + } + validateClassifier(classifier); + return classifier; + } + + private static void validateClassifier(String classifier) { + switch (classifier) { + case "linux-x64" : + case "linux-arm64" : + case "linuxmusl-x64" : + case "linuxmusl-arm64" : + case "darwin-x64" : + case "darwin-arm64" : + case "win32-x64" : + case "win32-arm64" : + return; + default : + throw new IllegalStateException("Unsupported platform classifier: " + classifier); + } + } + + /** + * Reads the ELF {@code PT_INTERP} segment (dynamic linker path) from the given + * executable path. + * + * @param executablePath + * path to the ELF executable to inspect + * @return the null-terminated interpreter string, without the trailing NUL + * @throws IOException + * if the file cannot be read or is not a recognised ELF binary with + * a {@code PT_INTERP} segment within the probe window + */ + static String readElfPtInterp(Path executablePath) throws IOException { + byte[] probe = readPrefix(executablePath, ELF_HEADER_PROBE_BYTES); + int size = probe.length; + if (size < 64) { + throw new IOException("ELF probe too small: " + size + " bytes"); + } + if ((probe[0] & 0xFF) != ELF_MAGIC_0 || (probe[1] & 0xFF) != ELF_MAGIC_1 || (probe[2] & 0xFF) != ELF_MAGIC_2 + || (probe[3] & 0xFF) != ELF_MAGIC_3) { + throw new IOException("Not an ELF executable: " + executablePath); + } + + int elfClass = probe[4] & 0xFF; + int elfData = probe[5] & 0xFF; + if (elfData != ELF_DATA_LITTLE_ENDIAN && elfData != ELF_DATA_BIG_ENDIAN) { + throw new IOException("Unsupported ELF data encoding: " + elfData); + } + boolean littleEndian = elfData == ELF_DATA_LITTLE_ENDIAN; + + long phoff; + int phentsize; + int phnum; + if (elfClass == ELF_CLASS_64) { + phoff = readUInt64(probe, 32, littleEndian); + phentsize = readUInt16(probe, 54, littleEndian); + phnum = readUInt16(probe, 56, littleEndian); + } else if (elfClass == ELF_CLASS_32) { + phoff = readUInt32(probe, 28, littleEndian); + phentsize = readUInt16(probe, 42, littleEndian); + phnum = readUInt16(probe, 44, littleEndian); + } else { + throw new IOException("Unsupported ELF class: " + elfClass); + } + + if (phoff < 0 || phoff >= size) { + throw new IOException("Program header table offset outside probe window: " + phoff); + } + if (phentsize <= 0 || phnum <= 0) { + throw new IOException("Invalid ELF program header metadata: phentsize=" + phentsize + ", phnum=" + phnum); + } + int minProgramHeaderSize = elfClass == ELF_CLASS_64 ? 56 : 32; + if (phentsize < minProgramHeaderSize) { + throw new IOException("Invalid ELF program header entry size: " + phentsize + " (expected >= " + + minProgramHeaderSize + ")"); + } + + for (int i = 0; i < phnum; i++) { + long baseLong = phoff + ((long) i * phentsize); + if (baseLong < 0 || baseLong > Integer.MAX_VALUE) { + break; + } + long entryEnd = baseLong + phentsize; + if (entryEnd > size) { + break; + } + int base = (int) baseLong; + + long pType = readUInt32(probe, base, littleEndian); + if (pType != PT_INTERP) { + continue; + } + + long pOffset; + long pFileSize; + if (elfClass == ELF_CLASS_64) { + pOffset = readUInt64(probe, base + 8, littleEndian); + pFileSize = readUInt64(probe, base + 32, littleEndian); + } else { + pOffset = readUInt32(probe, base + 4, littleEndian); + pFileSize = readUInt32(probe, base + 16, littleEndian); + } + + if (pOffset < 0 || pFileSize <= 0 || pOffset > Integer.MAX_VALUE || pFileSize > Integer.MAX_VALUE) { + throw new IOException("Invalid PT_INTERP bounds"); + } + + long endLong = pOffset + pFileSize; + if (endLong > size || endLong > Integer.MAX_VALUE || endLong <= pOffset) { + throw new IOException("PT_INTERP extends past probe window; increase probe size"); + } + int start = (int) pOffset; + int end = (int) endLong; + + int nulIndex = start; + while (nulIndex < end && probe[nulIndex] != 0) { + nulIndex++; + } + if (nulIndex == start) { + throw new IOException("Empty PT_INTERP segment"); + } + return new String(probe, start, nulIndex - start, StandardCharsets.UTF_8); + } + + throw new IOException("ELF PT_INTERP segment not found"); + } + + private static byte[] readPrefix(Path path, int maxBytes) throws IOException { + byte[] buffer = new byte[maxBytes]; + int total = 0; + try (InputStream in = Files.newInputStream(path)) { + while (total < maxBytes) { + int read = in.read(buffer, total, maxBytes - total); + if (read < 0) { + break; + } + total += read; + } + } + byte[] resized = new byte[total]; + System.arraycopy(buffer, 0, resized, 0, total); + return resized; + } + + private static int readUInt16(byte[] data, int offset, boolean littleEndian) { + int b0 = data[offset] & 0xFF; + int b1 = data[offset + 1] & 0xFF; + return littleEndian ? (b0 | (b1 << 8)) : ((b0 << 8) | b1); + } + + private static long readUInt32(byte[] data, int offset, boolean littleEndian) { + long b0 = data[offset] & 0xFFL; + long b1 = data[offset + 1] & 0xFFL; + long b2 = data[offset + 2] & 0xFFL; + long b3 = data[offset + 3] & 0xFFL; + if (littleEndian) { + return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24); + } + return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3; + } + + private static long readUInt64(byte[] data, int offset, boolean littleEndian) { + long result = 0L; + if (littleEndian) { + for (int i = 7; i >= 0; i--) { + result = (result << 8) | (data[offset + i] & 0xFFL); + } + return result; + } + for (int i = 0; i < 8; i++) { + result = (result << 8) | (data[offset + i] & 0xFFL); + } + return result; + } +} diff --git a/java/src/main/resources/copilot-runtime.properties b/java/src/main/resources/copilot-runtime.properties new file mode 100644 index 0000000000..39a8d014c8 --- /dev/null +++ b/java/src/main/resources/copilot-runtime.properties @@ -0,0 +1,3 @@ +# This file is processed by Maven resource filtering at build time. +# The ${project.version} placeholder is replaced with the artifact version. +version=${project.version} diff --git a/java/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java b/java/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java new file mode 100644 index 0000000000..8eee0a1c53 --- /dev/null +++ b/java/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java @@ -0,0 +1,249 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.ffi; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.net.URLStreamHandler; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Unit tests for {@link NativeRuntimeLoader}. */ +class NativeRuntimeLoaderTest { + + @Test + void isValidCacheEntryNonExistent(@TempDir Path tmp) { + assertFalse(NativeRuntimeLoader.isValidCacheEntry(tmp.resolve("missing"))); + } + + @Test + void isValidCacheEntryEmpty(@TempDir Path tmp) throws Exception { + Path empty = tmp.resolve("empty"); + Files.createFile(empty); + assertFalse(NativeRuntimeLoader.isValidCacheEntry(empty)); + } + + @Test + void isValidCacheEntryNonEmpty(@TempDir Path tmp) throws Exception { + Path f = tmp.resolve("binary"); + Files.write(f, "content".getBytes(StandardCharsets.UTF_8)); + assertTrue(NativeRuntimeLoader.isValidCacheEntry(f)); + } + + @Test + void isValidCacheEntryDirectory(@TempDir Path tmp) { + assertFalse(NativeRuntimeLoader.isValidCacheEntry(tmp)); + } + + @Test + void loadVersionMissingResourceThrows() throws Exception { + try (URLClassLoader loader = new URLClassLoader(new URL[0], null)) { + NativeRuntimeLoaderException ex = assertThrows(NativeRuntimeLoaderException.class, + () -> NativeRuntimeLoader.loadVersion(loader)); + assertTrue(ex.getMessage().contains("Missing classpath resource")); + } + } + + @Test + void loadVersionUnfilteredPlaceholderThrows(@TempDir Path tmp) throws Exception { + URLClassLoader loader = writePropertiesAndCreateLoader(tmp, "version=${project.version}\n"); + try (loader) { + NativeRuntimeLoaderException ex = assertThrows(NativeRuntimeLoaderException.class, + () -> NativeRuntimeLoader.loadVersion(loader)); + assertTrue(ex.getMessage().contains("not filtered")); + } + } + + @Test + void loadVersionBlankValueThrows(@TempDir Path tmp) throws Exception { + URLClassLoader loader = writePropertiesAndCreateLoader(tmp, "version=\n"); + try (loader) { + NativeRuntimeLoaderException ex = assertThrows(NativeRuntimeLoaderException.class, + () -> NativeRuntimeLoader.loadVersion(loader)); + assertTrue(ex.getMessage().contains("missing")); + } + } + + @Test + void loadVersionMissingKeyThrows(@TempDir Path tmp) throws Exception { + URLClassLoader loader = writePropertiesAndCreateLoader(tmp, "other=value\n"); + try (loader) { + NativeRuntimeLoaderException ex = assertThrows(NativeRuntimeLoaderException.class, + () -> NativeRuntimeLoader.loadVersion(loader)); + assertTrue(ex.getMessage().contains("missing")); + } + } + + @Test + void loadVersionValidValueSucceeds(@TempDir Path tmp) throws Exception { + URLClassLoader loader = writePropertiesAndCreateLoader(tmp, "version=1.2.3\n"); + try (loader) { + assertEquals("1.2.3", NativeRuntimeLoader.loadVersion(loader)); + } + } + + @Test + void extractionCreatesFileInCacheDir(@TempDir Path tmpHome) throws Exception { + String version = "1.2.3-test"; + String classifier = "linux-x64"; + byte[] content = "fake-runtime-node-content".getBytes(StandardCharsets.UTF_8); + + Path cached = NativeRuntimeLoader.extractToCache(buildInMemoryUrl(content), version, classifier, tmpHome); + + assertTrue(Files.isRegularFile(cached)); + assertArrayEquals(content, Files.readAllBytes(cached)); + assertEquals(tmpHome.resolve(".copilot/runtime-cache/" + version + "/" + classifier + "/runtime.node"), cached); + } + + @Test + void extractionCacheHitSkipsReExtraction(@TempDir Path tmpHome) throws Exception { + String version = "1.2.3-test"; + String classifier = "linux-x64"; + byte[] content = "fake-runtime-node-content".getBytes(StandardCharsets.UTF_8); + + Path cached = NativeRuntimeLoader.extractToCache(buildInMemoryUrl(content), version, classifier, tmpHome); + long modifiedFirst = Files.getLastModifiedTime(cached).toMillis(); + + Thread.sleep(50); + Path cached2 = NativeRuntimeLoader.extractToCache(buildInMemoryUrl(content), version, classifier, tmpHome); + long modifiedSecond = Files.getLastModifiedTime(cached2).toMillis(); + + assertEquals(cached, cached2); + assertEquals(modifiedFirst, modifiedSecond, "File was re-written on cache hit"); + } + + @Test + void extractionEmptyResourceThrows(@TempDir Path tmpHome) { + NativeRuntimeLoaderException ex = assertThrows(NativeRuntimeLoaderException.class, + () -> NativeRuntimeLoader.extractToCache(buildInMemoryUrl(new byte[0]), "1.0.0", "linux-x64", tmpHome)); + assertTrue(ex.getMessage().contains("empty")); + } + + @Test + void extractionNoTempFileLeftAfterSuccess(@TempDir Path tmpHome) throws Exception { + String version = "1.2.3-test"; + String classifier = "linux-x64"; + byte[] content = "fake-runtime-node-content".getBytes(StandardCharsets.UTF_8); + + NativeRuntimeLoader.extractToCache(buildInMemoryUrl(content), version, classifier, tmpHome); + + Path cacheDir = tmpHome.resolve(".copilot/runtime-cache/" + version + "/" + classifier); + try (var files = Files.list(cacheDir)) { + long tmpCount = files.filter(p -> p.getFileName().toString().contains(".tmp-")).count(); + assertEquals(0, tmpCount, "Temp files left after extraction"); + } + } + + @Test + void concurrentExtractionBothSucceed(@TempDir Path tmpHome) throws Exception { + String version = "1.2.3-concurrent"; + String classifier = "linux-x64"; + byte[] content = "fake-runtime-node-concurrent".getBytes(StandardCharsets.UTF_8); + + int threadCount = 8; + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + futures.add(pool.submit(() -> { + start.await(); + return NativeRuntimeLoader.extractToCache(buildInMemoryUrl(content), version, classifier, tmpHome); + })); + } + + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS)); + + for (Future f : futures) { + Path result = f.get(); + assertNotNull(result); + assertTrue(Files.isRegularFile(result)); + assertArrayEquals(content, Files.readAllBytes(result), "Concurrent extraction produced corrupt content"); + } + } + + @Test + void runtimeOverrideWinsOverClasspathAndCliSibling(@TempDir Path tmpHome) throws Exception { + Path explicitRuntime = tmpHome.resolve("explicit-runtime.node"); + Files.write(explicitRuntime, "runtime".getBytes(StandardCharsets.UTF_8)); + Path bundledCli = tmpHome.resolve("copilot"); + Files.write(bundledCli, "cli".getBytes(StandardCharsets.UTF_8)); + Path siblingRuntime = tmpHome.resolve("runtime.node"); + Files.write(siblingRuntime, "sibling".getBytes(StandardCharsets.UTF_8)); + + try (URLClassLoader emptyLoader = new URLClassLoader(new URL[0], null)) { + Path resolved = NativeRuntimeLoader.resolve(explicitRuntime.toString(), bundledCli.toString(), emptyLoader, + tmpHome, "linux-x64"); + assertEquals(explicitRuntime, resolved); + } + } + + @Test + void resolveFallsBackToRuntimeNodeSibling(@TempDir Path tmpHome) throws Exception { + Path bundledCli = tmpHome.resolve("copilot"); + Files.write(bundledCli, "cli".getBytes(StandardCharsets.UTF_8)); + Path siblingRuntime = tmpHome.resolve("runtime.node"); + Files.write(siblingRuntime, "sibling".getBytes(StandardCharsets.UTF_8)); + + try (URLClassLoader emptyLoader = new URLClassLoader(new URL[0], null)) { + Path resolved = NativeRuntimeLoader.resolve(null, bundledCli.toString(), emptyLoader, tmpHome, "linux-x64"); + assertEquals(siblingRuntime, resolved); + } + } + + @Test + void missingClasspathResourceThrows(@TempDir Path tmpHome) throws Exception { + Path bundledCli = tmpHome.resolve("copilot"); + Files.write(bundledCli, "cli".getBytes(StandardCharsets.UTF_8)); + + try (URLClassLoader emptyLoader = new URLClassLoader(new URL[0], null)) { + NativeRuntimeLoaderException ex = assertThrows(NativeRuntimeLoaderException.class, + () -> NativeRuntimeLoader.resolve(null, bundledCli.toString(), emptyLoader, tmpHome, "win32-x64")); + assertTrue(ex.getMessage().contains("Could not locate native/win32-x64/runtime.node")); + } + } + + private static URLClassLoader writePropertiesAndCreateLoader(Path dir, String content) throws IOException { + Files.writeString(dir.resolve("copilot-runtime.properties"), content, StandardCharsets.UTF_8); + return new URLClassLoader(new URL[]{dir.toUri().toURL()}, null); + } + + /** Builds a {@code URL} that serves {@code data} as its content. */ + private static URL buildInMemoryUrl(byte[] data) throws IOException { + return new URL("mem", "", 0, "/runtime.node", new URLStreamHandler() { + @Override + protected java.net.URLConnection openConnection(URL u) { + return new java.net.URLConnection(u) { + @Override + public void connect() { + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(data); + } + }; + } + }); + } +} diff --git a/java/src/test/java/com/github/copilot/ffi/PlatformDetectorTest.java b/java/src/test/java/com/github/copilot/ffi/PlatformDetectorTest.java new file mode 100644 index 0000000000..8739e23f4d --- /dev/null +++ b/java/src/test/java/com/github/copilot/ffi/PlatformDetectorTest.java @@ -0,0 +1,245 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.ffi; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** Unit tests for {@link PlatformDetector}. */ +class PlatformDetectorTest { + + @Test + void detectOsMacOsX() { + assertEquals("darwin", PlatformDetector.detectOs("Mac OS X")); + } + + @Test + void detectOsDarwin() { + assertEquals("darwin", PlatformDetector.detectOs("Darwin")); + } + + @Test + void detectOsLinux() { + assertEquals("linux", PlatformDetector.detectOs("Linux")); + } + + @Test + void detectOsWindowsLowercase() { + assertEquals("win32", PlatformDetector.detectOs("Windows 10")); + } + + @Test + void detectOsWindowsServer() { + assertEquals("win32", PlatformDetector.detectOs("Windows Server 2022")); + } + + @Test + void detectOsUnknownThrows() { + assertThrows(IllegalStateException.class, () -> PlatformDetector.detectOs("SunOS")); + } + + @Test + void detectOsEmptyThrows() { + assertThrows(IllegalStateException.class, () -> PlatformDetector.detectOs("")); + } + + @Test + void detectArchAmd64() { + assertEquals("x64", PlatformDetector.detectArch("amd64")); + } + + @Test + void detectArchX86_64() { + assertEquals("x64", PlatformDetector.detectArch("x86_64")); + } + + @Test + void detectArchX64() { + assertEquals("x64", PlatformDetector.detectArch("x64")); + } + + @Test + void detectArchAarch64() { + assertEquals("arm64", PlatformDetector.detectArch("aarch64")); + } + + @Test + void detectArchArm64() { + assertEquals("arm64", PlatformDetector.detectArch("arm64")); + } + + @Test + void detectArchUnknownThrows() { + assertThrows(IllegalStateException.class, () -> PlatformDetector.detectArch("i686")); + } + + @Test + void detectArchEmptyThrows() { + assertThrows(IllegalStateException.class, () -> PlatformDetector.detectArch("")); + } + + @Test + void readElfPtInterpGlibc(@TempDir Path tmp) throws Exception { + Path elf = tmp.resolve("glibc.elf"); + Files.write(elf, buildMinimalElf64("/lib64/ld-linux-x86-64.so.2")); + String interp = PlatformDetector.readElfPtInterp(elf); + assertEquals("/lib64/ld-linux-x86-64.so.2", interp); + } + + @Test + void readElfPtInterpMusl(@TempDir Path tmp) throws Exception { + Path elf = tmp.resolve("musl.elf"); + Files.write(elf, buildMinimalElf64("/lib/ld-musl-x86_64.so.1")); + String interp = PlatformDetector.readElfPtInterp(elf); + assertEquals("/lib/ld-musl-x86_64.so.1", interp); + } + + @Test + void readElfPtInterpNotElfThrows(@TempDir Path tmp) throws Exception { + Path f = tmp.resolve("not-elf"); + Files.write(f, new byte[]{0x00, 0x01, 0x02, 0x03, 0x04}); + assertThrows(IOException.class, () -> PlatformDetector.readElfPtInterp(f)); + } + + @Test + void readElfPtInterpTooSmallThrows(@TempDir Path tmp) throws Exception { + Path f = tmp.resolve("tiny"); + Files.write(f, new byte[]{0x7F, 'E', 'L', 'F', 0x02}); + assertThrows(IOException.class, () -> PlatformDetector.readElfPtInterp(f)); + } + + @Test + void readElfPtInterpRejectsTruncatedProgramHeaderEntry(@TempDir Path tmp) throws Exception { + byte[] elf = buildMinimalElf64("/lib64/ld-linux-x86-64.so.2"); + elf[54] = 8; + elf[55] = 0; + Path f = tmp.resolve("truncated-phdr.elf"); + Files.write(f, elf); + IOException ex = assertThrows(IOException.class, () -> PlatformDetector.readElfPtInterp(f)); + assertTrue(ex.getMessage().contains("entry size")); + } + + @ParameterizedTest + @MethodSource("classifierCases") + void detectClassifierFromTuple(String osName, String osArch, PlatformDetector.LinuxLibc libc, String expected) { + assertEquals(expected, PlatformDetector.detectClassifier(osName, osArch, libc)); + } + + @ParameterizedTest + @MethodSource("unsupportedClassifierCases") + void detectClassifierRejectsUnsupportedTuples(String osName, String osArch, PlatformDetector.LinuxLibc libc) { + assertThrows(IllegalStateException.class, () -> PlatformDetector.detectClassifier(osName, osArch, libc)); + } + + @Test + void detectClassifierOnCurrentPlatformReturnsKnownValue() { + String classifier = PlatformDetector.detectClassifier(); + assertNotNull(classifier); + assertTrue(classifier.matches("(linux|linuxmusl|darwin|win32)-(x64|arm64)"), + "Unexpected classifier: " + classifier); + } + + private static Stream classifierCases() { + return Stream.of(Arguments.of("Linux", "amd64", PlatformDetector.LinuxLibc.GLIBC, "linux-x64"), + Arguments.of("Linux", "x86_64", PlatformDetector.LinuxLibc.MUSL, "linuxmusl-x64"), + Arguments.of("Linux", "aarch64", PlatformDetector.LinuxLibc.GLIBC, "linux-arm64"), + Arguments.of("Linux", "arm64", PlatformDetector.LinuxLibc.MUSL, "linuxmusl-arm64"), + Arguments.of("Darwin", "x86_64", PlatformDetector.LinuxLibc.NOT_APPLICABLE, "darwin-x64"), + Arguments.of("Mac OS X", "arm64", PlatformDetector.LinuxLibc.NOT_APPLICABLE, "darwin-arm64"), + Arguments.of("Windows 11", "amd64", PlatformDetector.LinuxLibc.NOT_APPLICABLE, "win32-x64"), + Arguments.of("Windows Server 2022", "aarch64", PlatformDetector.LinuxLibc.NOT_APPLICABLE, + "win32-arm64")); + } + + private static Stream unsupportedClassifierCases() { + return Stream.of(Arguments.of("Linux", "ppc64le", PlatformDetector.LinuxLibc.GLIBC), + Arguments.of("Haiku", "x86_64", PlatformDetector.LinuxLibc.NOT_APPLICABLE)); + } + + /** + * Builds a minimal ELF64 binary with a single PT_INTERP segment containing the + * given interpreter path. The binary is fully self-contained within the 2 KB + * probe window used by {@link PlatformDetector#readElfPtInterp}. + */ + static byte[] buildMinimalElf64(String interpPath) throws IOException { + byte[] interpBytes = interpPath.getBytes(java.nio.charset.StandardCharsets.UTF_8); + int phdrOffset = 64; + int phdrSize = 56; + int interpOffset = phdrOffset + phdrSize; + int interpSize = interpBytes.length + 1; + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + + dos.writeByte(0x7F); + dos.writeByte('E'); + dos.writeByte('L'); + dos.writeByte('F'); + dos.writeByte(2); + dos.writeByte(1); + dos.writeByte(1); + dos.writeByte(0); + dos.write(new byte[8]); + writeUInt16Le(dos, 2); + writeUInt16Le(dos, 62); + writeUInt32Le(dos, 1); + writeUInt64Le(dos, 0); + writeUInt64Le(dos, phdrOffset); + writeUInt64Le(dos, 0); + writeUInt32Le(dos, 0); + writeUInt16Le(dos, 64); + writeUInt16Le(dos, phdrSize); + writeUInt16Le(dos, 1); + writeUInt16Le(dos, 64); + writeUInt16Le(dos, 0); + writeUInt16Le(dos, 0); + + writeUInt32Le(dos, 3); + writeUInt32Le(dos, 4); + writeUInt64Le(dos, interpOffset); + writeUInt64Le(dos, 0); + writeUInt64Le(dos, 0); + writeUInt64Le(dos, interpSize); + writeUInt64Le(dos, interpSize); + writeUInt64Le(dos, 1); + + dos.write(interpBytes); + dos.writeByte(0); + + dos.flush(); + return bos.toByteArray(); + } + + private static void writeUInt16Le(DataOutputStream dos, int v) throws IOException { + dos.writeByte(v & 0xFF); + dos.writeByte((v >> 8) & 0xFF); + } + + private static void writeUInt32Le(DataOutputStream dos, long v) throws IOException { + dos.writeByte((int) (v & 0xFF)); + dos.writeByte((int) ((v >> 8) & 0xFF)); + dos.writeByte((int) ((v >> 16) & 0xFF)); + dos.writeByte((int) ((v >> 24) & 0xFF)); + } + + private static void writeUInt64Le(DataOutputStream dos, long v) throws IOException { + for (int i = 0; i < 8; i++) { + dos.writeByte((int) (v & 0xFF)); + v >>= 8; + } + } +} diff --git a/java/src/test/resources/native/linux-x64/runtime.node b/java/src/test/resources/native/linux-x64/runtime.node new file mode 100644 index 0000000000..42ce53335a --- /dev/null +++ b/java/src/test/resources/native/linux-x64/runtime.node @@ -0,0 +1 @@ +fake-runtime-node-binary-for-testing