All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.helidon.build.linker.Jar Maven / Gradle / Ivy

/*
 * Copyright (c) 2019, 2022 Oracle and/or its affiliates.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package io.helidon.build.linker;

import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.lang.module.ModuleDescriptor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.concurrent.atomic.AtomicReference;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import java.util.zip.ZipEntry;

import io.helidon.build.common.InputStreams;
import io.helidon.build.common.logging.Log;
import io.helidon.build.linker.util.Constants;

import org.jboss.jandex.Index;
import org.jboss.jandex.IndexReader;
import org.jboss.jandex.IndexWriter;
import org.jboss.jandex.Indexer;
import org.jboss.jandex.UnsupportedVersion;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;

import static io.helidon.build.common.FileUtils.fileName;
import static io.helidon.build.common.FileUtils.requireDirectory;
import static io.helidon.build.common.FileUtils.requireFile;
import static java.util.Collections.emptyList;
import static java.util.Objects.requireNonNull;

/**
 * CDI BeansArchive aware jar wrapper. Supports creating an index if missing and adding it during copy.
 */
public final class Jar implements ResourceContainer {
    private static final String JMOD_SUFFIX = ".jmod";
    private static final Set SUPPORTED_SUFFIXES = Set.of(".jar", ".zip", JMOD_SUFFIX);
    private static final String META_INF = "META-INF/";
    private static final String META_INF_VERSIONS = META_INF + "versions/";
    private static final String BEANS_RESOURCE_PATH = META_INF + "beans.xml";
    private static final String JANDEX_INDEX_RESOURCE_PATH = META_INF + "jandex.idx";
    private static final String CLASS_FILE_SUFFIX = ".class";
    private static final String JMOD_CLASSES_PREFIX = "classes/";
    private static final String MODULE_INFO_CLASS = "module-info.class";
    private static final String SIGNATURE_PREFIX = META_INF;
    private static final String SIGNATURE_SUFFIX = ".SF";
    private final Path path;
    private final Runtime.Version version;
    private final boolean isJmod;
    private final JarFile jar;
    private final Manifest manifest;
    private final boolean isMultiRelease;
    private final boolean isBeansArchive;
    private final boolean isSigned;
    private final ModuleDescriptor descriptor;
    private final AtomicReference> resources;
    private Index index;
    private boolean builtIndex;

    /**
     * An entry in a jar file.
     */
    public final class Entry extends JarEntry {

        private Entry(JarEntry entry) {
            super(entry);
        }

        /**
         * Returns entry path.
         *
         * @return The path.
         */
        public String path() {
            return getName();
        }

        /**
         * Returns a stream to access the data for this entry.
         *
         * @return The stream.
         */
        public InputStream data() {
            try {
                return jar.getInputStream(this);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }
    }

    /**
     * Test whether or not the given path should be treated as a jar.
     *
     * @param path The path.
     * @return {@code true} if the path should be treated as a jar.
     */
    public static boolean isJar(Path path) {
        if (Files.isRegularFile(path)) {
            final String name = fileName(path);
            final int lastDot = name.lastIndexOf('.');
            if (lastDot >= 0) {
                return SUPPORTED_SUFFIXES.contains(name.substring(lastDot));
            }
        }
        return false;
    }

    /**
     * Returns the given jar path as a {@link Jar}.
     *
     * @param jarPath The jar path.
     * @return The {@link Jar}.
     * @throws IllegalArgumentException if the path is not treatable as a jar.
     */
    public static Jar open(Path jarPath) {
        return open(jarPath, Runtime.version());
    }

    /**
     * Returns the given jar path as a {@link Jar}.
     *
     * @param jarPath The jar path.
     * @param version The Java version used to find versioned entries if this is
     * a {@link #isMultiRelease() multi-release JAR}.
     * @return The {@link Jar}.
     * @throws IllegalArgumentException if the path is not treatable as a jar.
     */
    public static Jar open(Path jarPath, Runtime.Version version) {
        if (!isJar(jarPath)) {
            throw new IllegalArgumentException("Not a jar: " + jarPath);
        }
        return new Jar(jarPath, version);
    }

    private Jar(Path path, Runtime.Version version) {
        this.path = requireFile(path); // Absolute and normalized
        this.version = Objects.requireNonNull(version);
        this.isJmod = fileName(path).endsWith(JMOD_SUFFIX);
        try {
            this.jar = new JarFile(path.toFile());
            this.manifest = jar.getManifest();
            this.isMultiRelease = !isJmod && isMultiRelease(manifest);
            this.isSigned = !isJmod && hasSignatureFile();
            this.isBeansArchive = !isJmod && hasEntry(BEANS_RESOURCE_PATH);
            final Entry moduleInfo;
            if (isJmod) {
                moduleInfo = findEntry(JMOD_CLASSES_PREFIX + MODULE_INFO_CLASS);
            } else if (isMultiRelease) {
                moduleInfo = findVersionedEntry(MODULE_INFO_CLASS);
            } else {
                moduleInfo = findEntry(MODULE_INFO_CLASS);
            }
            if (moduleInfo != null) {
                this.descriptor = ModuleDescriptor.read(moduleInfo.data());
            } else {
                this.descriptor = null;
            }
            this.resources = new AtomicReference<>();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    /**
     * Returns the file name of this jar.
     *
     * @return The name.
     */
    public String name() {
        return fileName(path);
    }

    /**
     * Returns the path to this jar.
     *
     * @return The path.
     */
    public Path path() {
        return path;
    }

    /**
     * Returns the Java version used to find versioned entries if this is
     * a {@link #isMultiRelease() multi-release JAR}.
     *
     * @return The version.
     */
    public Runtime.Version version() {
        return version;
    }

    /**
     * Returns whether or not this is a {@code .jmod} file.
     *
     * @return {@code true} if {@code .jmod}.
     */
    public boolean isJmod() {
        return isJmod;
    }

    /**
     * Returns the manifest class-path if present.
     *
     * @return The paths that exist in the file system. May be empty or contain directories.
     */
    public List classPath() {
        if (manifest != null) {
            final Object classPath = manifest.getMainAttributes().get(Attributes.Name.CLASS_PATH);
            if (classPath != null) {
                final Path root = requireNonNull(path().getParent());
                return Arrays.stream(((String) classPath).split(" "))
                             .map(root::resolve)
                             .filter(file -> Files.exists(file))
                             .collect(Collectors.toList());
            }
        }
        return emptyList();
    }

    /**
     * Returns the manifest, if present.
     *
     * @return The manifest or {@code null} if not present.
     */
    public Manifest manifest() {
        return manifest;
    }

    /**
     * Returns the entries in this jar.
     *
     * @return The entries.
     */
    public Stream entries() {
        final Iterator iterator = jar.entries().asIterator();
        return StreamSupport.stream(Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false)
                            .map(Entry::new);
    }

    @Override
    public boolean containsResource(String resourcePath) {
        Set paths = resources.get();
        if (paths == null) {
            synchronized (resources) {
                paths = entries().map(JarEntry::getName).collect(Collectors.toSet());
                resources.set(paths);
            }
        }
        return paths.contains(resourcePath);
    }

    /**
     * Returns whether or not this jar is signed.
     *
     * @return {@code true} if signed.
     */
    public boolean isSigned() {
        return isSigned;
    }

    /**
     * Returns whether or not this jar is multi-release.
     *
     * @return {@code true} if multi-release.
     */
    public boolean isMultiRelease() {
        return isMultiRelease;
    }

    /**
     * Returns whether or not this jar is a CDI beans archive.
     *
     * @return {@code true} if a beans archive.
     */
    public boolean isBeansArchive() {
        return isBeansArchive;
    }

    /**
     * Returns whether or not this jar is a CDI beans archive containing a Jandex index.
     *
     * @return {@code true} if a beans archive containing an index.
     */
    public boolean hasIndex() {
        return isBeansArchive && index != null;
    }

    /**
     * Returns whether or not this jar contains a {@code module-info.class}.
     *
     * @return {@code true} if a {@code module-info.class} is present.
     */
    public boolean hasModuleDescriptor() {
        return descriptor != null;
    }

    /**
     * Returns the descriptor if a {@code module-info.class} is present.
     *
     * @return The descriptor or {@code null} if not present.
     */
    public ModuleDescriptor moduleDescriptor() {
        return descriptor;
    }

    /**
     * Copy this jar into the given directory. Adds a Jandex index if required.
     *
     * @param targetDir The targetDirectory.
     * @param ensureIndex {@code true} if an index should be added if this is a beans archive
     * and their is no Jandex index present.
     * @param stripDebug {@code true} if debug information should be stripped from classes.
     * @return The normalized, absolute path to the new file.
     */
    public Path copyToDirectory(Path targetDir, boolean ensureIndex, boolean stripDebug) {
        final Path fileName = path.getFileName();
        final Path targetFile = requireDirectory(targetDir).resolve(fileName);
        if (ensureIndex) {
            ensureIndex();
        }
        try (BufferedOutputStream out = new BufferedOutputStream(Files.newOutputStream(targetFile))) {

            // Add the index if we built it, and/or strip debug information if required; otherwise just copy the whole jar file

            if (builtIndex) {
                copy(out, true, stripDebug);
            } else if (stripDebug) {
                copy(out, false, true);
            } else {
                InputStreams.transfer(Files.newInputStream(path), out);
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
        if (Constants.OS.isPosix()) {
            try {
                Files.setPosixFilePermissions(targetFile, Set.of(
                        PosixFilePermission.OWNER_READ,
                        PosixFilePermission.OWNER_WRITE,
                        PosixFilePermission.GROUP_READ,
                        PosixFilePermission.OTHERS_READ
                ));
            } catch (IOException e) {
                Log.warn("Unable to set %s read-only: %s", e.getMessage());
            }
        }
        return targetFile;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        final Jar jar = (Jar) o;
        return path.equals(jar.path);
    }

    @Override
    public int hashCode() {
        return Objects.hash(path);
    }

    @Override
    public String toString() {
        return isSigned ? name() + " (signed)" : name();
    }

    private void ensureIndex() {
        if (isBeansArchive) {
            if (hasEntry(JANDEX_INDEX_RESOURCE_PATH)) {
                index = loadIndex();
            }
            if (index == null) {
                if (isSigned) {
                    Log.warn("Cannot add Jandex index to signed jar %s", name());
                } else {
                    index = buildIndex();
                    builtIndex = true;
                }
            }
        }
    }

    private Index loadIndex() {
        Log.info("  checking index in CDI beans archive %s", this);
        try (InputStream in = getEntry(JANDEX_INDEX_RESOURCE_PATH).data()) {
            return new IndexReader(in).read();
        } catch (IllegalArgumentException e) {
            Log.warn("  Jandex index in %s is not valid, will re-create: %s", path, e.getMessage());
        } catch (UnsupportedVersion e) {
            Log.warn("  Jandex index in %s is an unsupported version, will re-create: %s", path, e.getMessage());
        } catch (IOException e) {
            Log.warn("  Jandex index in %s cannot be read, will re-create: %s", path, e.getMessage());
        }
        return null;
    }

    private Index buildIndex() {
        Log.info("  creating missing index for CDI beans archive %s", this);
        final Indexer indexer = new Indexer();
        classEntries().forEach(entry -> {
            try {
                indexer.index(entry.data());
            } catch (IOException e) {
                Log.warn("  could not index class %s in %s: %s", entry.path(), this, e.getMessage());
            }
        });
        return indexer.complete();
    }

    private boolean hasSignatureFile() {
        return entries().anyMatch(e -> {
            final String path = e.path();
            return path.startsWith(SIGNATURE_PREFIX) && path.endsWith(SIGNATURE_SUFFIX);
        });
    }

    private boolean hasEntry(String path) {
        return entries().anyMatch(entry -> entry.path().equals(path));
    }

    private Entry findEntry(String path) {
        return entries().filter(entry -> entry.path().equals(path))
                        .findFirst().orElse(null);
    }

    private Entry findVersionedEntry(String path) {
        if (path.startsWith(META_INF)) {
            return findEntry(path);
        }

        Map featureLookup = new HashMap<>();
        for (int feature = version().feature(); feature > 8; feature--) {
            featureLookup.put(META_INF_VERSIONS + feature + "/" + path, feature);
        }
        int featureFound = -1;
        Entry entryFound = null;
        for (Entry entry : (Iterable) entries()::iterator) {
            String entryPath = entry.path();
            Integer feature = featureLookup.get(entryPath);
            if (feature != null && feature > featureFound) {
                featureFound = feature;
                entryFound = entry;
            } else if (entryFound == null && entryPath.equals(path)) {
                entryFound = entry;
            }
        }
        return entryFound;
    }

    private Entry getEntry(String path) {
        return entries().filter(entry -> entry.path().equals(path))
                        .findFirst()
                        .orElseThrow(() -> new IllegalStateException("Could not get '" + path + "' entry."));
    }

    private Stream classEntries() {
        return entries().filter(Jar::isNormalClassFile);
    }

    private void copy(OutputStream out, boolean addIndex, boolean stripDebug) throws IOException {
        try (JarOutputStream jar = new JarOutputStream(out)) {

            if (addIndex) {
                addIndex(jar);
            }

            // Copy all entries, filtering out any previous index (that could not be read)

            entries().filter(e -> !e.path().equals(JANDEX_INDEX_RESOURCE_PATH))
                     .forEach(entry -> {
                         try {
                             jar.putNextEntry(newJarEntry(entry));
                             if (!entry.isDirectory()) {
                                 InputStreams.transfer(data(entry, stripDebug), jar);
                             }
                             jar.flush();
                             jar.closeEntry();
                         } catch (IOException e) {
                             throw new UncheckedIOException(e);
                         }
                     });
        }
    }

    private InputStream data(Entry entry, boolean stripDebug) throws IOException {
        if (stripDebug && isNormalClassFile(entry) && !isSigned) {
            ClassReader reader = new ClassReader(entry.data());
            ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
            reader.accept(writer, ClassReader.SKIP_DEBUG);
            return new ByteArrayInputStream(writer.toByteArray());
        } else {
            return entry.data();
        }
    }

    private static boolean isNormalClassFile(Entry entry) {
        final String name = entry.path();
        return name.endsWith(CLASS_FILE_SUFFIX) && !name.equals(MODULE_INFO_CLASS);
    }

    private static JarEntry newJarEntry(Entry entry) {
        final JarEntry result = new JarEntry(entry.getName());
        if (result.getCreationTime() != null) {
            result.setCreationTime(entry.getCreationTime());
        }
        if (result.getLastModifiedTime() != null) {
            result.setLastModifiedTime(entry.getLastModifiedTime());
        }
        if (entry.getExtra() != null) {
            result.setExtra(entry.getExtra());
        }
        if (result.getComment() != null) {
            result.setComment(entry.getComment());
        }
        if (!entry.isDirectory()) {
            final int method = entry.getMethod();
            if (method == JarEntry.STORED || method == ZipEntry.DEFLATED) {
                result.setMethod(method);
            }
        }
        return result;
    }

    private void addIndex(JarOutputStream jar) {
        final ByteArrayOutputStream out = new ByteArrayOutputStream();
        final IndexWriter writer = new IndexWriter(out);
        try {
            writer.write(index);
            final ByteArrayInputStream data = new ByteArrayInputStream(out.toByteArray());
            final JarEntry entry = new JarEntry(JANDEX_INDEX_RESOURCE_PATH);
            entry.setLastModifiedTime(FileTime.fromMillis(System.currentTimeMillis()));
            jar.putNextEntry(entry);
            InputStreams.transfer(data, jar);
            jar.flush();
            jar.closeEntry();
        } catch (IOException e) {
            Log.warn("Unable to add index: %s", e);
        }
    }


    private static boolean isMultiRelease(Manifest manifest) {
        return manifest != null && "true".equalsIgnoreCase(mainAttribute(manifest, Attributes.Name.MULTI_RELEASE));
    }

    private static String mainAttribute(Manifest manifest, Attributes.Name name) {
        if (manifest != null) {
            final Object value = manifest.getMainAttributes().get(name);
            if (value != null) {
                return value.toString();
            }
        }
        return null;
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy