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