001/*
002 * Copyright (c) FIRST and other WPILib contributors.
003 * Open Source Software; you can modify and/or share it under the terms of
004 * the WPILib BSD license below:
005 *
006 * Redistribution and use in source and binary forms, with or without
007 * modification, are permitted provided that the following conditions
008 * are met:
009 * Redistributions of source code must retain the above copyright notice,
010 * this list of conditions and the following disclaimer.
011 * Redistributions in binary form must reproduce the above copyright notice,
012 * this list of conditions and the following disclaimer in the documentation
013 * and/or other materials provided with the distribution.
014 * Neither the name of FIRST, WPILib, nor the names of other WPILib
015 * contributors may be used to endorse or promote products derived from
016 * this software without specific prior written permission.
017 */
018
019package org.photonvision.jni;
020
021import io.avaje.jsonb.Json;
022import io.avaje.jsonb.JsonType;
023import io.avaje.jsonb.Jsonb;
024import java.io.BufferedInputStream;
025import java.io.File;
026import java.io.FileInputStream;
027import java.io.IOException;
028import java.nio.file.Files;
029import java.nio.file.Paths;
030import java.nio.file.StandardCopyOption;
031import java.security.DigestInputStream;
032import java.security.MessageDigest;
033import java.security.NoSuchAlgorithmException;
034import java.util.ArrayList;
035import java.util.HexFormat;
036import java.util.List;
037import java.util.Map;
038import java.util.Objects;
039import java.util.concurrent.CopyOnWriteArrayList;
040import org.photonvision.common.hardware.Platform;
041
042/** Loads dynamic libraries for all platforms. */
043public final class CombinedRuntimeLoader {
044    private CombinedRuntimeLoader() {}
045
046    private static String extractionDirectory;
047
048    private static final Object extractCompleteLock = new Object();
049    private static boolean extractAndVerifyComplete = false;
050    private static List<String> filesAlreadyExtracted = new CopyOnWriteArrayList<>();
051
052    /**
053     * Returns library extraction directory.
054     *
055     * @return Library extraction directory.
056     */
057    public static synchronized String getExtractionDirectory() {
058        return extractionDirectory;
059    }
060
061    private static synchronized void setExtractionDirectory(String directory) {
062        extractionDirectory = directory;
063    }
064
065    private static String defaultExtractionRoot;
066
067    /**
068     * Gets the default extraction root location (~/.wpilib/nativecache) for use if
069     * setExtractionDirectory is not set.
070     *
071     * @return The default extraction root location.
072     */
073    public static synchronized String getDefaultExtractionRoot() {
074        if (defaultExtractionRoot != null) {
075            return defaultExtractionRoot;
076        }
077        String home = System.getProperty("user.home");
078        defaultExtractionRoot = Paths.get(home, ".wpilib", "nativecache").toString();
079        return defaultExtractionRoot;
080    }
081
082    /**
083     * Returns platform path.
084     *
085     * @return The current platform path.
086     * @throws IllegalStateException Thrown if the operating system is unknown.
087     */
088    public static String getPlatformPath() {
089        String filePath;
090        String arch = System.getProperty("os.arch");
091
092        boolean intel32 = "x86".equals(arch) || "i386".equals(arch);
093        boolean intel64 = "amd64".equals(arch) || "x86_64".equals(arch);
094
095        if (System.getProperty("os.name").startsWith("Windows")) {
096            if (intel32) {
097                filePath = "/windows/x86/";
098            } else {
099                filePath = "/windows/x86-64/";
100            }
101        } else if (System.getProperty("os.name").startsWith("Mac")) {
102            filePath = "/osx/universal/";
103        } else if (System.getProperty("os.name").startsWith("Linux")) {
104            if (intel32) {
105                filePath = "/linux/x86/";
106            } else if (intel64) {
107                filePath = "/linux/x86-64/";
108            } else if (Platform.isAthena()) {
109                filePath = "/linux/athena/";
110            } else if ("arm".equals(arch) || "arm32".equals(arch)) {
111                filePath = "/linux/arm32/";
112            } else if ("aarch64".equals(arch) || "arm64".equals(arch)) {
113                filePath = "/linux/arm64/";
114            } else {
115                filePath = "/linux/nativearm/";
116            }
117        } else {
118            throw new IllegalStateException();
119        }
120
121        return filePath;
122    }
123
124    private static String getLoadErrorMessage(String libraryName, UnsatisfiedLinkError ule) {
125        StringBuilder msg = new StringBuilder(512);
126        msg.append(libraryName)
127                .append(" could not be loaded from path\n" + "\tattempted to load for platform ")
128                .append(getPlatformPath())
129                .append("\nLast Load Error: \n")
130                .append(ule.getMessage())
131                .append('\n');
132        if (System.getProperty("os.name").startsWith("Windows")) {
133            msg.append(
134                    "A common cause of this error is missing the C++ runtime.\n"
135                            + "Download the latest at https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads\n");
136        }
137        return msg.toString();
138    }
139
140    /**
141     * Architecture-specific information containing file hashes for a specific CPU architecture (e.g.,
142     * x86-64, arm64).
143     */
144    @Json
145    public record ArchInfo(Map<String, String> fileHashes) {}
146
147    /**
148     * Platform-specific information containing architectures for a specific OS platform (e.g., linux,
149     * windows).
150     */
151    @Json
152    public record PlatformInfo(Map<String, ArchInfo> architectures) {}
153
154    /** Overall resource information to be serialized */
155    @Json
156    public record ResourceInformation(
157            // Combined MD5 hash of all native resource files
158            String hash,
159            // Platform-specific native libraries organized by platform then architecture
160            Map<String, PlatformInfo> platforms,
161            // List of supported versions for these native resources
162            List<String> versions) {}
163
164    /**
165     * Extract a list of native libraries.
166     *
167     * @param <T> The class where the resources would be located
168     * @param clazz The actual class object
169     * @param resourceName The resource name on the classpath to use for file lookup
170     * @return List of all libraries that were extracted
171     * @throws IOException Thrown if resource not found or file could not be extracted
172     */
173    public static <T> List<String> extractLibraries(Class<T> clazz, String resourceName)
174            throws IOException {
175        JsonType<ResourceInformation> jsonb = Jsonb.instance().type(ResourceInformation.class);
176        ResourceInformation resourceInfo;
177        try (var stream = clazz.getResourceAsStream(resourceName)) {
178            resourceInfo = jsonb.fromJson(stream);
179        }
180
181        var platformPath = Paths.get(getPlatformPath());
182        var platform = platformPath.getName(0).toString();
183        var arch = platformPath.getName(1).toString();
184
185        var platformInfo = resourceInfo.platforms().get(platform);
186        if (platformInfo == null) {
187            throw new IOException("Platform " + platform + " not found in resource information");
188        }
189
190        var archInfo = platformInfo.architectures().get(arch);
191        if (archInfo == null) {
192            throw new IOException("Architecture " + arch + " not found for platform " + platform);
193        }
194
195        // Map of <file to extract> to <hash we loaded from the JSON>
196        Map<String, String> filenameToHash = archInfo.fileHashes();
197
198        var extractionPathString = getExtractionDirectory();
199
200        if (extractionPathString == null) {
201            // Folder to extract to derived from overall hash
202            String combinedHash = resourceInfo.hash();
203
204            var defaultExtractionRoot = getDefaultExtractionRoot();
205            var extractionPath = Paths.get(defaultExtractionRoot, platform, arch, combinedHash);
206            extractionPathString = extractionPath.toString();
207
208            setExtractionDirectory(extractionPathString);
209        }
210
211        List<String> extractedFiles = new ArrayList<>();
212
213        for (String file : filenameToHash.keySet()) {
214            try (var stream = clazz.getResourceAsStream(file)) {
215                Objects.requireNonNull(stream);
216
217                var outputFile = Paths.get(extractionPathString, new File(file).getName());
218
219                String fileHash = filenameToHash.get(file);
220
221                extractedFiles.add(outputFile.toString());
222                if (outputFile.toFile().exists()) {
223                    if (hashEm(outputFile.toFile()).equals(fileHash)) {
224                        continue;
225                    } else {
226                        // Hashes don't match, delete and re-extract
227                        System.err.println(
228                                outputFile.toAbsolutePath().toString()
229                                        + " failed validation - deleting and re-extracting");
230                        outputFile.toFile().delete();
231                    }
232                }
233                var parent = outputFile.getParent();
234                if (parent == null) {
235                    throw new IOException("Output file has no parent");
236                }
237                parent.toFile().mkdirs();
238
239                try (var os = Files.newOutputStream(outputFile)) {
240                    Files.copy(stream, outputFile, StandardCopyOption.REPLACE_EXISTING);
241                }
242
243                if (!hashEm(outputFile.toFile()).equals(fileHash)) {
244                    throw new IOException("Hash of extracted file does not match expected hash");
245                }
246            }
247        }
248
249        return extractedFiles;
250    }
251
252    private static String hashEm(File f) throws IOException {
253        try {
254            MessageDigest fileHash = MessageDigest.getInstance("MD5");
255            try (var dis =
256                    new DigestInputStream(new BufferedInputStream(new FileInputStream(f)), fileHash)) {
257                dis.readAllBytes();
258            }
259            var ret = HexFormat.of().formatHex(fileHash.digest());
260            return ret;
261        } catch (NoSuchAlgorithmException e) {
262            throw new IOException("Unable to verify extracted native files", e);
263        }
264    }
265
266    /**
267     * Load a single library from a list of extracted files.
268     *
269     * @param libraryName The library name to load
270     * @param extractedFiles The extracted files to search
271     * @throws IOException If library was not found
272     */
273    public static void loadLibrary(String libraryName, List<String> extractedFiles)
274            throws IOException {
275        String currentPath = null;
276        try {
277            for (var extractedFile : extractedFiles) {
278                if (extractedFile.contains(libraryName)) {
279                    // Load it
280                    currentPath = extractedFile;
281                    System.load(extractedFile);
282                    return;
283                }
284            }
285            throw new IOException("Could not find library " + libraryName);
286        } catch (UnsatisfiedLinkError ule) {
287            throw new IOException(getLoadErrorMessage(currentPath, ule));
288        }
289    }
290
291    /**
292     * Load a list of native libraries out of a single directory.
293     *
294     * @param <T> The class where the resources would be located
295     * @param clazz The actual class object
296     * @param librariesToLoad List of libraries to load
297     * @throws IOException Throws an IOException if not found
298     */
299    public static <T> void loadLibraries(Class<T> clazz, String... librariesToLoad)
300            throws IOException {
301        synchronized (extractCompleteLock) {
302            if (extractAndVerifyComplete == false) {
303                // Extract everything
304                filesAlreadyExtracted = extractLibraries(clazz, "/ResourceInformation.json");
305                extractAndVerifyComplete = true;
306            }
307
308            for (var library : librariesToLoad) {
309                loadLibrary(library, filesAlreadyExtracted);
310            }
311        }
312    }
313}