1 /*
  2  * Copyright (c) 2024, Red Hat, Inc.
  3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
  4  *
  5  * This code is free software; you can redistribute it and/or modify it
  6  * under the terms of the GNU General Public License version 2 only, as
  7  * published by the Free Software Foundation.  Oracle designates this
  8  * particular file as subject to the "Classpath" exception as provided
  9  * by Oracle in the LICENSE file that accompanied this code.
 10  *
 11  * This code is distributed in the hope that it will be useful, but WITHOUT
 12  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 13  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 14  * version 2 for more details (a copy is included in the LICENSE file that
 15  * accompanied this code).
 16  *
 17  * You should have received a copy of the GNU General Public License version
 18  * 2 along with this work; if not, write to the Free Software Foundation,
 19  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 20  *
 21  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 22  * or visit www.oracle.com if you need additional information or have any
 23  * questions.
 24  */
 25 
 26 package jdk.tools.jlink.internal;
 27 
 28 import static jdk.tools.jlink.internal.LinkableRuntimeImage.RESPATH_PATTERN;
 29 
 30 import java.io.ByteArrayInputStream;
 31 import java.io.IOException;
 32 import java.io.InputStream;
 33 import java.io.UncheckedIOException;
 34 import java.lang.module.ModuleFinder;
 35 import java.lang.module.ModuleReference;
 36 import java.nio.charset.StandardCharsets;
 37 import java.nio.file.Files;
 38 import java.nio.file.Path;
 39 import java.nio.file.Paths;
 40 import java.security.MessageDigest;
 41 import java.util.ArrayList;
 42 import java.util.Arrays;
 43 import java.util.Collections;
 44 import java.util.HexFormat;
 45 import java.util.List;
 46 import java.util.Map;
 47 import java.util.Objects;
 48 import java.util.Set;
 49 import java.util.function.Function;
 50 import java.util.function.Predicate;
 51 import java.util.stream.Collectors;
 52 import java.util.stream.Stream;
 53 
 54 import jdk.internal.util.OperatingSystem;
 55 import jdk.tools.jlink.internal.Archive.Entry.EntryType;
 56 import jdk.tools.jlink.internal.runtimelink.ResourceDiff;
 57 import jdk.tools.jlink.plugin.ResourcePoolEntry;
 58 import jdk.tools.jlink.plugin.ResourcePoolEntry.Type;
 59 
 60 /**
 61  * An archive implementation based on the JDK's run-time image. That is, classes
 62  * and resources from the modules image (lib/modules, or jimage) and other
 63  * associated files from the filesystem of the JDK installation.
 64  */
 65 public class JRTArchive implements Archive {
 66 
 67     private final String module;
 68     private final Path path;
 69     private final ModuleReference ref;
 70     // The collection of files of this module
 71     private final List<JRTFile> files = new ArrayList<>();
 72     // Files not part of the lib/modules image of the JDK install.
 73     // Thus, native libraries, binaries, legal files, etc.
 74     private final List<String> otherRes;
 75     // Maps a module resource path to the corresponding diff to packaged
 76     // modules for that resource (if any)
 77     private final Map<String, ResourceDiff> resDiff;
 78     private final boolean errorOnModifiedFile;
 79     private final TaskHelper taskHelper;
 80     private final Set<String> upgradeableFiles;
 81 
 82     /**
 83      * JRTArchive constructor
 84      *
 85      * @param module The module name this archive refers to
 86      * @param path The JRT filesystem path.
 87      * @param errorOnModifiedFile Whether or not modified files of the JDK
 88      *        install aborts the link.
 89      * @param perModDiff The lib/modules (a.k.a jimage) diff for this module,
 90      *                   possibly an empty list if there are no differences.
 91      * @param taskHelper The task helper instance.
 92      * @param upgradeableFiles The set of files that are allowed for upgrades.
 93      */
 94     JRTArchive(String module,
 95                Path path,
 96                boolean errorOnModifiedFile,
 97                List<ResourceDiff> perModDiff,
 98                TaskHelper taskHelper,
 99                Set<String> upgradeableFiles) {
100         this.module = module;
101         this.path = path;
102         this.ref = ModuleFinder.ofSystem()
103                                .find(module)
104                                .orElseThrow(() ->
105                                     new IllegalArgumentException(
106                                             "Module " + module +
107                                             " not part of the JDK install"));
108         this.errorOnModifiedFile = errorOnModifiedFile;
109         this.otherRes = readModuleResourceFile(module);
110         this.resDiff = Objects.requireNonNull(perModDiff).stream()
111                             .collect(Collectors.toMap(ResourceDiff::getName, Function.identity()));
112         this.taskHelper = taskHelper;
113         this.upgradeableFiles = upgradeableFiles;
114     }
115 
116     @Override
117     public String moduleName() {
118         return module;
119     }
120 
121     @Override
122     public Path getPath() {
123         return path;
124     }
125 
126     @Override
127     public Stream<Entry> entries() {
128         try {
129             collectFiles();
130         } catch (IOException e) {
131             throw new UncheckedIOException(e);
132         }
133         return files.stream().map(JRTFile::toEntry);
134     }
135 
136     @Override
137     public void open() throws IOException {
138         if (files.isEmpty()) {
139             collectFiles();
140         }
141     }
142 
143     @Override
144     public void close() throws IOException {
145         if (!files.isEmpty()) {
146             files.clear();
147         }
148     }
149 
150     @Override
151     public int hashCode() {
152         return Objects.hash(module, path);
153     }
154 
155     @Override
156     public boolean equals(Object obj) {
157         return (obj instanceof JRTArchive other &&
158                    Objects.equals(module, other.module) &&
159                    Objects.equals(path, other.path));
160     }
161 
162     private void collectFiles() throws IOException {
163         if (files.isEmpty()) {
164             addNonClassResources();
165             // Add classes/resources from the run-time image,
166             // patched with the run-time image diff
167             files.addAll(ref.open().list()
168                                    .filter(i -> {
169                                            String lookupKey = String.format("/%s/%s", module, i);
170                                            ResourceDiff rd = resDiff.get(lookupKey);
171                                            // Filter all resources with a resource diff
172                                            // that are of kind MODIFIED.
173                                            // Note that REMOVED won't happen since in
174                                            // that case the module listing won't have
175                                            // the resource anyway.
176                                            // Note as well that filter removes files
177                                            // of kind ADDED. Those files are not in
178                                            // the packaged modules, so ought not to
179                                            // get returned from the pipeline.
180                                            return (rd == null ||
181                                                    rd.getKind() == ResourceDiff.Kind.MODIFIED);
182                                    })
183                                    .map(s -> {
184                                            String lookupKey = String.format("/%s/%s", module, s);
185                                            return new JRTArchiveFile(JRTArchive.this, s,
186                                                            EntryType.CLASS_OR_RESOURCE,
187                                                            null /* hashOrTarget */,
188                                                            false /* symlink */,
189                                                            resDiff.get(lookupKey));
190                                    })
191                                    .toList());
192             // Finally add all files only present in the resource diff
193             // That is, removed items in the run-time image.
194             files.addAll(resDiff.values().stream()
195                                          .filter(rd -> rd.getKind() == ResourceDiff.Kind.REMOVED)
196                                          .map(s -> {
197                                                  int secondSlash = s.getName().indexOf("/", 1);
198                                                  assert secondSlash != -1;
199                                                  String pathWithoutModule = s.getName().substring(secondSlash + 1);
200                                                  return new JRTArchiveFile(JRTArchive.this,
201                                                          pathWithoutModule,
202                                                          EntryType.CLASS_OR_RESOURCE,
203                                                          null  /* hashOrTarget */,
204                                                          false /* symlink */,
205                                                          s);
206                                          })
207                                          .toList());
208         }
209     }
210 
211     /*
212      * no need to keep track of the warning produced since this is eagerly
213      * checked once.
214      */
215     private void addNonClassResources() {
216         // Not all modules will have other resources like bin, lib, legal etc.
217         // files. In that case the list will be empty.
218         if (!otherRes.isEmpty()) {
219             files.addAll(otherRes.stream()
220                  .filter(Predicate.not(String::isEmpty))
221                  .map(s -> {
222                         ResourceFileEntry m = ResourceFileEntry.decodeFromString(s);
223 
224                         // Read from the base JDK image.
225                         Path path = BASE.resolve(m.resPath);
226                         if (!isUpgradeableFile(m.resPath) &&
227                                 shaSumMismatch(path, m.hashOrTarget, m.symlink)) {
228                             if (errorOnModifiedFile) {
229                                 String msg = taskHelper.getMessage("err.runtime.link.modified.file", path.toString());
230                                 IOException cause = new IOException(msg);
231                                 throw new UncheckedIOException(cause);
232                             } else {
233                                 taskHelper.warning("err.runtime.link.modified.file", path.toString());
234                             }
235                         }
236 
237                         return new JRTArchiveFile(JRTArchive.this,
238                                                   m.resPath,
239                                                   toEntryType(m.resType),
240                                                   m.hashOrTarget,
241                                                   m.symlink,
242                                                   /* diff only for resources */
243                                                   null);
244                  })
245                  .toList());
246         }
247     }
248 
249     /**
250      * Certain files in a module are considered upgradeable. That is,
251      * their hash sums aren't checked.
252      *
253      * @param resPath The resource path of the file to check for upgradeability.
254      * @return {@code true} if the file is upgradeable. {@code false} otherwise.
255      */
256     private boolean isUpgradeableFile(String resPath) {
257         return upgradeableFiles.contains(resPath);
258     }
259 
260     static boolean shaSumMismatch(Path res, String expectedSha, boolean isSymlink) {
261         if (isSymlink) {
262             return false;
263         }
264         // handle non-symlink resources
265         try {
266             HexFormat format = HexFormat.of();
267             byte[] expected = format.parseHex(expectedSha);
268             MessageDigest digest = MessageDigest.getInstance("SHA-512");
269             try (InputStream is = Files.newInputStream(res)) {
270                 byte[] buf = new byte[1024];
271                 int readBytes = -1;
272                 while ((readBytes = is.read(buf)) != -1) {
273                     digest.update(buf, 0, readBytes);
274                 }
275             }
276             byte[] actual = digest.digest();
277             return !MessageDigest.isEqual(expected, actual);
278         } catch (Exception e) {
279             throw new AssertionError("SHA-512 sum check failed!", e);
280         }
281     }
282 
283     private static EntryType toEntryType(Type input) {
284         return switch(input) {
285             case CLASS_OR_RESOURCE -> EntryType.CLASS_OR_RESOURCE;
286             case CONFIG -> EntryType.CONFIG;
287             case HEADER_FILE -> EntryType.HEADER_FILE;
288             case LEGAL_NOTICE -> EntryType.LEGAL_NOTICE;
289             case MAN_PAGE -> EntryType.MAN_PAGE;
290             case NATIVE_CMD -> EntryType.NATIVE_CMD;
291             case NATIVE_LIB -> EntryType.NATIVE_LIB;
292             case TOP -> throw new IllegalArgumentException(
293                            "TOP files should be handled by ReleaseInfoPlugin!");
294             default -> throw new IllegalArgumentException("Unknown type: " + input);
295         };
296     }
297 
298     public record ResourceFileEntry(Type resType,
299                                     boolean symlink,
300                                     String hashOrTarget,
301                                     String resPath) {
302         // Type file format:
303         // '<type>|{0,1}|<sha-sum>|<file-path>'
304         //   (1)    (2)      (3)      (4)
305         //
306         // Where fields are:
307         //
308         // (1) The resource type as specified by ResourcePoolEntry.type()
309         // (2) Symlink designator. 0 => regular resource, 1 => symlinked resource
310         // (3) The SHA-512 sum of the resources' content. The link to the target
311         //     for symlinked resources.
312         // (4) The relative file path of the resource
313         private static final String TYPE_FILE_FORMAT = "%d|%d|%s|%s";
314 
315         private static final Map<Integer, Type> typeMap = Arrays.stream(Type.values())
316                 .collect(Collectors.toMap(Type::ordinal, Function.identity()));
317 
318         public String encodeToString() {
319             return String.format(TYPE_FILE_FORMAT,
320                                  resType.ordinal(),
321                                  symlink ? 1 : 0,
322                                  hashOrTarget,
323                                  resPath);
324         }
325 
326         /**
327          *  line: <int>|<int>|<hashOrTarget>|<path>
328          *
329          *  Take the integer before '|' convert it to a Type. The second
330          *  token is an integer representing symlinks (or not). The third token is
331          *  a hash sum (sha512) of the file denoted by the fourth token (path).
332          */
333         static ResourceFileEntry decodeFromString(String line) {
334             assert !line.isEmpty();
335 
336             String[] tokens = line.split("\\|", 4);
337             Type type = null;
338             int symlinkNum = -1;
339             try {
340                 Integer typeInt = Integer.valueOf(tokens[0]);
341                 type = typeMap.get(typeInt);
342                 if (type == null) {
343                     throw new AssertionError("Illegal type ordinal: " + typeInt);
344                 }
345                 symlinkNum = Integer.valueOf(tokens[1]);
346             } catch (NumberFormatException e) {
347                 throw new AssertionError(e); // must not happen
348             }
349             if (symlinkNum < 0 || symlinkNum > 1) {
350                 throw new AssertionError(
351                         "Symlink designator out of range [0,1] got: " +
352                         symlinkNum);
353             }
354             return new ResourceFileEntry(type,
355                                          symlinkNum == 1,
356                                          tokens[2] /* hash or target */,
357                                          tokens[3] /* resource path */);
358         }
359 
360         public static ResourceFileEntry toResourceFileEntry(ResourcePoolEntry entry,
361                                                             Platform platform) {
362             String resPathWithoutMod = dropModuleFromPath(entry, platform);
363             // Symlinks don't have a hash sum, but a link to the target instead
364             String hashOrTarget = entry.linkedTarget() == null
365                                         ? computeSha512(entry)
366                                         : dropModuleFromPath(entry.linkedTarget(),
367                                                              platform);
368             return new ResourceFileEntry(entry.type(),
369                                          entry.linkedTarget() != null,
370                                          hashOrTarget,
371                                          resPathWithoutMod);
372         }
373 
374         private static String computeSha512(ResourcePoolEntry entry) {
375             try {
376                 assert entry.linkedTarget() == null;
377                 MessageDigest digest = MessageDigest.getInstance("SHA-512");
378                 try (InputStream is = entry.content()) {
379                     byte[] buf = new byte[1024];
380                     int bytesRead = -1;
381                     while ((bytesRead = is.read(buf)) != -1) {
382                         digest.update(buf, 0, bytesRead);
383                     }
384                 }
385                 byte[] db = digest.digest();
386                 HexFormat format = HexFormat.of();
387                 return format.formatHex(db);
388             } catch (Exception e) {
389                 throw new AssertionError("Failed to generate hash sum for " +
390                                          entry.path());
391             }
392         }
393 
394         private static String dropModuleFromPath(ResourcePoolEntry entry,
395                                                  Platform platform) {
396             String resPath = entry.path()
397                                   .substring(
398                                       // + 2 => prefixed and suffixed '/'
399                                       // For example: '/java.base/'
400                                       entry.moduleName().length() + 2);
401             if (!isWindows(platform)) {
402                 return resPath;
403             }
404             // For Windows the libraries live in the 'bin' folder rather than
405             // the 'lib' folder in the final image. Note that going by the
406             // NATIVE_LIB type only is insufficient since only files with suffix
407             // .dll/diz/map/pdb are transplanted to 'bin'.
408             // See: DefaultImageBuilder.nativeDir()
409             return nativeDir(entry, resPath);
410         }
411 
412         private static boolean isWindows(Platform platform) {
413             return platform.os() == OperatingSystem.WINDOWS;
414         }
415 
416         private static String nativeDir(ResourcePoolEntry entry, String resPath) {
417             if (entry.type() != ResourcePoolEntry.Type.NATIVE_LIB) {
418                 return resPath;
419             }
420             // precondition: Native lib, windows platform
421             if (resPath.endsWith(".dll") || resPath.endsWith(".diz")
422                     || resPath.endsWith(".pdb") || resPath.endsWith(".map")) {
423                 if (resPath.startsWith(LIB_DIRNAME + "/")) {
424                     return BIN_DIRNAME + "/" +
425                                resPath.substring((LIB_DIRNAME + "/").length());
426                 }
427             }
428             return resPath;
429         }
430         private static final String BIN_DIRNAME = "bin";
431         private static final String LIB_DIRNAME = "lib";
432     }
433 
434     private static final Path BASE = Paths.get(System.getProperty("java.home"));
435 
436     interface JRTFile {
437         Entry toEntry();
438     }
439 
440     record JRTArchiveFile(Archive archive,
441                           String resPath,
442                           EntryType resType,
443                           String sha,
444                           boolean symlink,
445                           ResourceDiff diff) implements JRTFile {
446         public Entry toEntry() {
447             return new Entry(archive,
448                              String.format("/%s/%s",
449                                            archive.moduleName(),
450                                            resPath),
451                              resPath,
452                              resType) {
453                 @Override
454                 public long size() {
455                     try {
456                         if (resType != EntryType.CLASS_OR_RESOURCE) {
457                             // Read from the base JDK image, special casing
458                             // symlinks, which have the link target in the
459                             // hashOrTarget field
460                             if (symlink) {
461                                 return Files.size(BASE.resolve(sha));
462                             }
463                             return Files.size(BASE.resolve(resPath));
464                         } else {
465                             if (diff != null) {
466                                 // If the resource has a diff to the
467                                 // packaged modules, use the diff. Diffs of kind
468                                 // ADDED have been filtered out in collectFiles();
469                                 assert diff.getKind() != ResourceDiff.Kind.ADDED;
470                                 assert diff.getName().equals(String.format("/%s/%s",
471                                                                            archive.moduleName(),
472                                                                            resPath));
473                                 return diff.getResourceBytes().length;
474                             }
475                             // Read from the module image. This works, because
476                             // the underlying base path is a JrtPath with the
477                             // JrtFileSystem underneath which is able to handle
478                             // this size query.
479                             return Files.size(archive.getPath().resolve(resPath));
480                         }
481                     } catch (IOException e) {
482                         throw new UncheckedIOException(e);
483                     }
484                 }
485 
486                 @Override
487                 public InputStream stream() throws IOException {
488                     if (resType != EntryType.CLASS_OR_RESOURCE) {
489                         // Read from the base JDK image.
490                         Path path = symlink ? BASE.resolve(sha) : BASE.resolve(resPath);
491                         return Files.newInputStream(path);
492                     } else {
493                         // Read from the module image. Use the diff to the
494                         // packaged modules if we have one. Diffs of kind
495                         // ADDED have been filtered out in collectFiles();
496                         if (diff != null) {
497                             assert diff.getKind() != ResourceDiff.Kind.ADDED;
498                             assert diff.getName().equals(String.format("/%s/%s",
499                                                                        archive.moduleName(),
500                                                                        resPath));
501                             return new ByteArrayInputStream(diff.getResourceBytes());
502                         }
503                         String module = archive.moduleName();
504                         ModuleReference mRef = ModuleFinder.ofSystem()
505                                                     .find(module).orElseThrow();
506                         return mRef.open().open(resPath).orElseThrow();
507                     }
508                 }
509 
510             };
511         }
512     }
513 
514     private static List<String> readModuleResourceFile(String modName) {
515         String resName = String.format(RESPATH_PATTERN, modName);
516         try {
517             try (InputStream inStream = JRTArchive.class.getModule()
518                                                   .getResourceAsStream(resName)) {
519                 String input = new String(inStream.readAllBytes(), StandardCharsets.UTF_8);
520                 if (input.isEmpty()) {
521                     // Not all modules have non-class resources
522                     return Collections.emptyList();
523                 } else {
524                     return Arrays.asList(input.split("\n"));
525                 }
526             }
527         } catch (IOException e) {
528             throw new UncheckedIOException("Failed to process resources from the " +
529                                            "run-time image for module " + modName, e);
530         }
531     }
532 }