diff --git a/biz.aQute.bndlib/src/aQute/bnd/osgi/Jar.java b/biz.aQute.bndlib/src/aQute/bnd/osgi/Jar.java index 10ec3fd0f8..46d2cb49fd 100644 --- a/biz.aQute.bndlib/src/aQute/bnd/osgi/Jar.java +++ b/biz.aQute.bndlib/src/aQute/bnd/osgi/Jar.java @@ -24,16 +24,21 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import java.util.AbstractMap.SimpleEntry; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.EnumSet; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.NavigableMap; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.SortedMap; import java.util.Spliterator; import java.util.Spliterators.AbstractSpliterator; import java.util.TreeMap; @@ -46,6 +51,8 @@ import java.util.jar.Manifest; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import java.util.stream.Stream; import java.util.stream.StreamSupport; import java.util.zip.CRC32; @@ -74,6 +81,7 @@ import aQute.libg.glob.PathSet; public class Jar implements Closeable { + private static final int BUFFER_SIZE = IOConstants.PAGE_SIZE * 16; /** * Note that setting the January 1st 1980 (or even worse, "0", as time) @@ -104,7 +112,7 @@ public enum Compression { private final NavigableMap resources = new TreeMap<>(); private final NavigableMap> directories = new TreeMap<>(); private Optional manifest; - private Optional moduleAttribute; + private Map> moduleAttributes = new HashMap<>(); private boolean manifestFirst; private String manifestName = JarFile.MANIFEST_NAME; private String name; @@ -124,6 +132,14 @@ public enum Compression { private long zipEntryConstantTime = ZIP_ENTRY_CONSTANT_TIME; public static final Pattern METAINF_SIGNING_P = Pattern .compile("META-INF/([^/]+\\.(?:DSA|RSA|EC|SF)|SIG-[^/]+)", Pattern.CASE_INSENSITIVE); + static final String MULTI_RELEASE_HEADER = "Multi-Release"; + static final String SUPPLEMENTAL_MANIFEST_PATH = "OSGI-INF/MANIFEST.MF"; + static final int MULTI_RELEASE_MIN_VERSION = 9; + static final int MULTI_RELEASE_DEFAULT_VERSION = 0; + static final int MULTI_RELEASE_VERSION_GROUP = 1; + static final int MULTI_RELEASE_PATH_GROUP = 2; + static final Pattern MULTI_RELEASE_PATTERN = Pattern + .compile("^META-INF/versions/(\\d+)/(.*)$", Pattern.CASE_INSENSITIVE); public Jar(String name) { this.name = name; @@ -262,6 +278,24 @@ public Jar(String string, File file) throws IOException { this(string, file, DEFAULT_DO_NOT_COPY); } + public boolean isMultiRelease() { + return manifest().map(Manifest::getMainAttributes) + .map(attr -> attr.getValue(MULTI_RELEASE_HEADER)) + .map(Boolean::parseBoolean) + .orElse(Boolean.FALSE); + } + + public void setMultiRelease(boolean multiRelease) { + try { + ensureManifest(); + manifest().get() + .getMainAttributes() + .putValue(MULTI_RELEASE_HEADER, String.valueOf(multiRelease)); + } catch (Exception e) { + Exceptions.duck(e); + } + } + private Jar buildFromDirectory(final Path baseDir, final Pattern doNotCopy) throws IOException { Files.walkFileTree(baseDir, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, new SimpleFileVisitor() { @@ -361,13 +395,22 @@ public boolean putResource(String path, Resource resource) { public boolean putResource(String path, Resource resource, boolean overwrite) { check(); path = ZipUtil.cleanPath(path); + versionedResources = null; if (path.equals(manifestName)) { manifest = null; if (resources.isEmpty()) manifestFirst = true; - } else if (path.equals(Constants.MODULE_INFO_CLASS)) { - moduleAttribute = null; + } else { + if (path.equals(Constants.MODULE_INFO_CLASS)) { + moduleAttributes.remove(MULTI_RELEASE_DEFAULT_VERSION); + } else { + Matcher matcher = MULTI_RELEASE_PATTERN.matcher(path); + if (matcher.matches() && matcher.group(MULTI_RELEASE_PATH_GROUP) + .equals(Constants.MODULE_INFO_CLASS)) { + moduleAttributes.remove(Integer.parseInt(matcher.group(MULTI_RELEASE_VERSION_GROUP))); + } + } } String dir = getParent(path); Map s = directories.get(dir); @@ -397,6 +440,30 @@ public Resource getResource(String path) { return resources.get(path); } + /** + * Returns a resource taking the release version into account as described + * by the {@link JarFile#getJarEntry(String)}. + * + * @param path the path of the resource to read + * @param release the release to use + * @return an optional representing the highest versioned resource for the + * given release or an empty optional if the resource do not exits + */ + public Optional getVersionedResource(String path, int release) { + if (isMultiRelease() && release >= MULTI_RELEASE_MIN_VERSION) { + check(); + path = ZipUtil.cleanPath(path); + NavigableMap map = getAllVersionMap().getOrDefault(path, Collections.emptyNavigableMap()) + .headMap(release, true); + Entry releaseEntry = map.lastEntry(); + if (releaseEntry != null) { + return Optional.of(releaseEntry.getValue()); + } + return Optional.empty(); + } + return Optional.ofNullable(getResource(path)); + } + public Stream getResourceNames(Predicate matches) { return getResources().keySet() .stream() @@ -432,6 +499,74 @@ public Map getResources() { return resources; } + /** + * returns an (unmodifiable) view of resources in this jar according to the + * given release version as described by the + * {@link JarFile#getJarEntry(String)}. + * + * @return a map whose keys are resource names and the value the highest + * available resource for the given release. + */ + public Map getVersionedResources(int release) { + if (isMultiRelease()) { + check(); + Map> versionedResources = getAllVersionMap(); + return versionedResources.entrySet() + .stream() + .map(versions -> { + Entry releaseEntry = versions.getValue() + .headMap(release, true) + .lastEntry(); + if (releaseEntry != null) { + return new SimpleEntry<>(versions.getKey(), releaseEntry.getValue()); + } + return null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + } + return Collections.unmodifiableMap(getResources()); + } + + /** + * provides a stream of all additional releases declared by this jar + * + * @return a stream of additional releases declared by this jar + */ + public IntStream getReleaseVersions() { + if (isMultiRelease()) { + return getAllVersionMap().values() + .stream() + .flatMap(map -> map.keySet() + .stream()) + .mapToInt(i -> i) + .distinct() + .sorted(); + } + return IntStream.empty(); + } + + private Map> getAllVersionMap() { + if (versionedResources == null) { + versionedResources = new HashMap<>(); + for (Entry entry : resources.entrySet()) { + Matcher matcher = Jar.MULTI_RELEASE_PATTERN.matcher(entry.getKey()); + String path; + int version; + if (matcher.matches()) { + path = matcher.group(Jar.MULTI_RELEASE_PATH_GROUP); + version = Integer.parseInt(matcher.group(Jar.MULTI_RELEASE_VERSION_GROUP)); + } else { + path = entry.getKey(); + version = Jar.MULTI_RELEASE_DEFAULT_VERSION; + } + SortedMap map = versionedResources.computeIfAbsent(path, nil -> new TreeMap<>()); + map.put(version, entry.getValue()); + } + } + return versionedResources; + } + public boolean addDirectory(Map directory, boolean overwrite) { check(); boolean duplicates = false; @@ -448,6 +583,48 @@ public Manifest getManifest() throws Exception { return manifest().orElse(null); } + /** + * Creates a copy of the current jars manifest that is enhanced by + * the supplemental manifest data (if any) for the given release. + * + * @param release the release for fetching an enhanced manifest + * @return a copy that is not backed by the original manifest + * but copied and enhanced with the supplemental manifest data (if + * any) for the given release + */ + public Optional getManifest(int release) { + if (isMultiRelease()) { + return manifest().map(original -> { + Manifest copy = new Manifest(original); + if (release >= MULTI_RELEASE_MIN_VERSION) { + Optional releaseEntry = getVersionedResource(SUPPLEMENTAL_MANIFEST_PATH, release); + releaseEntry.map(resource -> { + try (InputStream in = resource.openInputStream()) { + return new Manifest(in); + } catch (Exception e) { + throw Exceptions.duck(e); + } + }) + .ifPresent(supplemental -> { + enhanceManifestAttribute(supplemental, copy, Constants.REQUIRE_CAPABILITY); + enhanceManifestAttribute(supplemental, copy, Constants.IMPORT_PACKAGE); + }); + } + return copy; + }); + } + return manifest().map(Manifest::new); + } + + private static void enhanceManifestAttribute(Manifest supplemental, Manifest target, String key) { + String value = supplemental.getMainAttributes() + .getValue(key); + if (value != null) { + target.getMainAttributes() + .putValue(key, value); + } + } + Optional manifest() { check(); Optional optional = manifest; @@ -468,15 +645,29 @@ Optional manifest() { } Optional moduleAttribute() throws Exception { + return moduleAttribute(MULTI_RELEASE_DEFAULT_VERSION); + } + + Optional moduleAttribute(int release) throws Exception { check(); - Optional optional = moduleAttribute; - if (optional != null) { - return optional; + if (release < MULTI_RELEASE_MIN_VERSION) { + release = MULTI_RELEASE_DEFAULT_VERSION; } - Resource module_info_resource = getResource(Constants.MODULE_INFO_CLASS); - if (module_info_resource == null) { - return moduleAttribute = Optional.empty(); + Optional moduleAttribute = moduleAttributes.get(release); + if (moduleAttribute == null) { + Optional resource = getVersionedResource(Constants.MODULE_INFO_CLASS, release); + if (resource.isPresent()) { + moduleAttribute = readModuleAttribute(resource.get()); + } else { + moduleAttribute = Optional.empty(); + } + moduleAttributes.put(release, moduleAttribute); } + return moduleAttribute; + } + + private static Optional readModuleAttribute(Resource module_info_resource) + throws Exception { ClassFile module_info; ByteBuffer bb = module_info_resource.buffer(); if (bb != null) { @@ -486,25 +677,38 @@ Optional moduleAttribute() throws Exception { module_info = ClassFile.parseClassFile(din); } } - return moduleAttribute = Arrays.stream(module_info.attributes) + return Arrays.stream(module_info.attributes) .filter(ModuleAttribute.class::isInstance) .map(ModuleAttribute.class::cast) .findFirst(); } public String getModuleName() throws Exception { - return moduleAttribute().map(a -> a.module_name) - .orElseGet(this::automaticModuleName); + return getModuleName(MULTI_RELEASE_DEFAULT_VERSION); + } + + public String getModuleName(int release) throws Exception { + return moduleAttribute(release).map(a -> a.module_name) + .orElseGet(() -> automaticModuleName(release)); } String automaticModuleName() { - return manifest().map(m -> m.getMainAttributes() + return automaticModuleName(MULTI_RELEASE_DEFAULT_VERSION); + } + + String automaticModuleName(int release) { + return getManifest(release) + .map(m -> m.getMainAttributes() .getValue(Constants.AUTOMATIC_MODULE_NAME)) .orElse(null); } public String getModuleVersion() throws Exception { - return moduleAttribute().map(a -> a.module_version) + return getModuleVersion(MULTI_RELEASE_DEFAULT_VERSION); + } + + public String getModuleVersion(int release) throws Exception { + return moduleAttribute(release).map(a -> a.module_version) .orElse(null); } @@ -947,6 +1151,8 @@ public void close() { directories.clear(); manifest = null; source = null; + versionedResources = null; + moduleAttributes.clear(); } public long lastModified() { @@ -1303,6 +1509,7 @@ public void removeSubDirs(String dir) { } private static final Predicate pomXmlFilter = new PathSet("META-INF/maven/*/*/pom.xml").matches(); + private Map> versionedResources; public Stream getPomXmlResources() { return getResources(pomXmlFilter); diff --git a/biz.aQute.bndlib/test/aQute/bnd/build/JarTest.java b/biz.aQute.bndlib/test/aQute/bnd/build/JarTest.java new file mode 100644 index 0000000000..58fb9e5308 --- /dev/null +++ b/biz.aQute.bndlib/test/aQute/bnd/build/JarTest.java @@ -0,0 +1,208 @@ +package aQute.bnd.build; + +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import aQute.bnd.classfile.ClassFile; +import aQute.bnd.classfile.builder.ModuleInfoBuilder; +import aQute.bnd.osgi.Constants; +import aQute.bnd.osgi.EmbeddedResource; +import aQute.bnd.osgi.Jar; +import aQute.bnd.osgi.ManifestResource; +import aQute.bnd.osgi.Resource; +import aQute.lib.io.ByteBufferDataOutput; + +public class JarTest { + + private static final String MODULE_INFO_PATH = "META-INF/versions/9/" + Constants.MODULE_INFO_CLASS; + + private static final String MAIN_MANIFEST_PATH = JarFile.MANIFEST_NAME; + + private static final String SUPPLEMENTAL_MANIFEST_PATH = "META-INF/versions/9/OSGI-INF/MANIFEST.MF"; + + private static final String TEST_CLASS_PATH = "a/test/package/Test.class"; + + private static final String VERSIONED_TEST_CLASS_PATH = "META-INF/versions/9/" + TEST_CLASS_PATH; + + @TempDir + File tempDir; + + @Test + public void testMultiReleaseJarResources() throws Exception { + File jarfile = new File(tempDir, "packed.jar"); + try (Jar jar = new Jar("testme")) { + jar.setMultiRelease(true); + Resource java8Class = resource(); + Resource java9Class = resource(); + jar.putResource(TEST_CLASS_PATH, java8Class); + jar.putResource(VERSIONED_TEST_CLASS_PATH, java9Class); + // without release, content must be returned as-is + assertTrue(jar.isMultiRelease()); + assertEquals(java8Class, jar.getResource(TEST_CLASS_PATH)); + assertEquals(java9Class, jar.getResource(VERSIONED_TEST_CLASS_PATH)); + // with release 9 set, we now should see the java 9 content + assertEquals(java9Class, jar.getVersionedResource(TEST_CLASS_PATH, 9) + .orElse(null)); + // with a lower release we should get the java 8 content + assertEquals(java8Class, jar.getVersionedResource(TEST_CLASS_PATH, 8) + .orElse(null)); + assertEquals(java8Class, jar.getVersionedResource(TEST_CLASS_PATH, -1) + .orElse(null)); + assertEquals(java8Class, jar.getVersionedResource(TEST_CLASS_PATH, 4) + .orElse(null)); + // with a higher release we should still get the java 9 content + assertEquals(java9Class, jar.getVersionedResource(TEST_CLASS_PATH, 10) + .orElse(null)); + assertEquals(java9Class, jar.getVersionedResource(TEST_CLASS_PATH, 1000) + .orElse(null)); + // if we write the jar out, all content should be present + jar.writeFolder(tempDir); + File defaultFile = new File(tempDir, TEST_CLASS_PATH); + assertTrue(defaultFile.isFile(), defaultFile.getAbsolutePath() + " is missing"); + File versionedFile = new File(tempDir, VERSIONED_TEST_CLASS_PATH); + assertTrue(versionedFile.isFile(), versionedFile.getAbsolutePath() + " is missing"); + jar.write(jarfile); + } + try (JarFile jar = new JarFile(jarfile)) { + Set collect = Collections.list(jar.entries()) + .stream() + .map(JarEntry::getName) + .collect(Collectors.toSet()); + assertTrue(collect.contains(TEST_CLASS_PATH)); + assertTrue(collect.contains(VERSIONED_TEST_CLASS_PATH)); + assertTrue(Boolean.parseBoolean(jar.getManifest() + .getMainAttributes() + .getValue("Multi-Release"))); + } + } + + @Test + public void testMultiReleaseJaManifest() throws Exception { + try (Jar jar = new Jar("testme")) { + String defaultImport = "main.pkg.import"; + String suplementalImport = "main.pkg.import,additional.package"; + String ignoredValue = "This Must Be Ignored"; + jar.putResource(MAIN_MANIFEST_PATH, + manifest("Multi-Release", "true", Constants.IMPORT_PACKAGE, defaultImport)); + jar.putResource(SUPPLEMENTAL_MANIFEST_PATH, manifest(Constants.IMPORT_PACKAGE, suplementalImport, + Constants.BUNDLE_SYMBOLIC_NAME_ATTRIBUTE, ignoredValue)); + assertTrue(jar.isMultiRelease()); + Manifest mainManifest = Objects.requireNonNull(jar.getManifest()); + Manifest versionedManifest = jar.getManifest(9) + .get(); + assertNotEquals(mainManifest, versionedManifest); + assertEquals(defaultImport, mainManifest.getMainAttributes() + .getValue(Constants.IMPORT_PACKAGE)); + assertEquals(suplementalImport, versionedManifest.getMainAttributes() + .getValue(Constants.IMPORT_PACKAGE)); + assertNull(mainManifest.getMainAttributes() + .getValue(Constants.BUNDLE_SYMBOLIC_NAME_ATTRIBUTE)); + assertNull(versionedManifest.getMainAttributes() + .getValue(Constants.BUNDLE_SYMBOLIC_NAME_ATTRIBUTE)); + + } + } + + @Test + public void testMultiReleaseJaModule() throws Exception { + try (Jar jar = new Jar("testme")) { + jar.setMultiRelease(true); + String moduleName = "my.name"; + String moduleVersion = "1.0.0"; + jar.putResource(MODULE_INFO_PATH, module(moduleName, moduleVersion)); + assertNull(jar.getModuleName()); + assertEquals(moduleName, jar.getModuleName(9)); + assertNull(jar.getModuleVersion()); + assertEquals(moduleVersion, jar.getModuleVersion(9)); + } + } + + /** + * This test assumes the following layout: + * + *
+	 * /Foo.class
+	 * /Bar.class
+	 * /META-INF/versions/9/Foo.class
+	 * /META-INF/versions/11/Bar.class
+	 * /META-INF/versions/17/Foo.class
+	 * 
+ * + * Calling + *
    + *
  • getVersionedResource(Foo.class, 11) - I would expect to get returned + * the resource at /META-INF/versions/9/Foo.class
  • + *
  • getVersionedResource(Bar.class, 9) - I would expect to get returned + * the resource at /Bar.class
  • + *
  • getVersionedResource(Foo.class, 17) - I would expect to get returned + * the resource at /META-INF/versions/17/Foo.class
  • + *
+ * + * @throws Exception + */ + @Test + public void testMultiReleaseJarMultipleResources() throws Exception { + try (Jar jar = new Jar("testme")) { + jar.setMultiRelease(true); + Resource Foo = resource(); + Resource Bar = resource(); + Resource Foo9 = resource(); + Resource Bar11 = resource(); + Resource Foo17 = resource(); + String fooPath = "Foo.class"; + String barPath = "Bar.class"; + jar.putResource(fooPath, Foo); + jar.putResource(barPath, Bar); + jar.putResource("META-INF/versions/9/" + fooPath, Foo9); + jar.putResource("META-INF/versions/11/" + barPath, Bar11); + jar.putResource("META-INF/versions/17/" + fooPath, Foo17); + assertEquals(Foo9, jar.getVersionedResource(fooPath, 11) + .get()); + assertEquals(Bar, jar.getVersionedResource(barPath, 9) + .get()); + assertEquals(Foo17, jar.getVersionedResource(fooPath, 17) + .get()); + } + } + + private static Resource module(String moduleName, String moduleVersion) throws IOException { + ModuleInfoBuilder builder = new ModuleInfoBuilder().module_name(moduleName) + .module_version(moduleVersion) + .module_flags(0); + ClassFile build = builder.build(); + ByteBufferDataOutput bbout = new ByteBufferDataOutput(); + builder.build() + .write(bbout); + EmbeddedResource resource = new EmbeddedResource(bbout.toByteBuffer(), System.currentTimeMillis()); + return resource; + } + + private static Resource resource() { + return new aQute.bnd.osgi.EmbeddedResource(new byte[0], System.currentTimeMillis()); + } + + private static Resource manifest(String k1, String v1, String k2, String v2) { + Manifest manifest = new Manifest(); + manifest.getMainAttributes() + .putValue(k1, v1); + manifest.getMainAttributes() + .putValue(k2, v2); + return new ManifestResource(manifest); + } + +}