This is an automated email from the ASF dual-hosted git repository. royteeuwen pushed a commit to branch feature/osgi-identity-collision-check in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-feature.git
commit 187f88273b0a4d38f4e6e6153c6e900e17857135 Author: Roy Teeuwen <[email protected]> AuthorDate: Fri May 8 21:36:25 2026 +0200 SLING-13197: add OSGi bsn collision detection --- docs/aggregation.md | 40 +++- docs/features.md | 2 +- pom.xml | 2 +- .../sling/feature/builder/BuilderContext.java | 27 +++ .../sling/feature/builder/FeatureBuilder.java | 4 + .../sling/feature/builder/OsgiBsnDeduplicator.java | 191 +++++++++++++++ .../apache/sling/feature/builder/package-info.java | 2 +- .../feature/builder/OsgiBsnDeduplicatorTest.java | 263 +++++++++++++++++++++ 8 files changed, 527 insertions(+), 4 deletions(-) diff --git a/docs/aggregation.md b/docs/aggregation.md index eb7ea98..4a79447 100644 --- a/docs/aggregation.md +++ b/docs/aggregation.md @@ -8,4 +8,42 @@ additional operations based on the extension content. Note that both the aggregate task of the `slingfeature-maven-plugin` as well as the launcher perform merge operations on all the feature models these are provided with. -TBD +## Resolving artifact clashes + +When two features contribute artifacts (typically bundles) with the same Maven +`groupId:artifactId` at different versions, the aggregator requires an explicit +override rule. Rules are added via `BuilderContext.addArtifactsOverride(...)` +and use a fake `ArtifactId` whose version field carries the rule: + +| Rule | Outcome | +|------------|----------------------------------------------------------------------| +| `HIGHEST` | Pick the artifact with the highest version (OSGi version order) | +| `LATEST` | Pick the last contributed artifact | +| `FIRST` | Pick the first contributed artifact | +| `ALL` | Keep all candidates | +| `<x.y.z>` | Pick the artifact whose version equals the literal value | + +The `groupId` and `artifactId` of the override are matched against the clashing +artifacts; `*` matches anything. A typical wildcard rule is `*:*:HIGHEST`. + +## Resolving Bundle-SymbolicName collisions + +The Maven-coordinate clash mechanism above is blind to OSGi identity. Two +bundles can have different `groupId:artifactId` but the same +`Bundle-SymbolicName` — typically when one source synthesises Maven coordinates +from the OSGi manifest because the JAR has no embedded Maven metadata +(`META-INF/maven/.../pom.properties`). The aggregator lets both through; the +OSGi runtime then refuses to install duplicates with `Bundle symbolic name and +version are not unique`. + +Set `BuilderContext.setOsgiBsnCollisionDetection(true)` to detect this. After +the standard GAV merge, every assembled bundle's manifest is read via the +configured `ArtifactProvider`, bundles are grouped by BSN (parameters such as +`;singleton:=true` are stripped), and any group of two or more is resolved +through the same wildcard `*:*:<rule>` overrides described above. If detection +is enabled and a colliding group has no matching override, assembly fails — +symmetric with the existing `Artifact override rule required` error for +unresolved Maven-GAV clashes. + +`ArtifactProvider` must be configured for detection to do anything. Bundles +whose manifest cannot be read are passed through untouched. diff --git a/docs/features.md b/docs/features.md index e9e945e..cb4dd35 100644 --- a/docs/features.md +++ b/docs/features.md @@ -263,7 +263,7 @@ Then the removal instructions of the prototype object are handled next. Finally, the contents from the prototype is then overlayed with the definitions from the feature using the prototype. In detail this means: * Variables from the feature overwrite variables from the prototype. -* A clash of artifacts (such as bundles) between the prototype and the feature is not resolved and both versions end up in the featuree. Artifact clashes are detected based on Maven Coordinates, not on the content of the artifact. So if a prototype defines a bundle with artifact ID `org.sling:somebundle:1.2.0` and the feature itself declares `org.sling:somebundle:1.0.1` in its `bundles` section, version `1.0.1` and version `1.2.0` are used. The removals section can be used to remove the [...] +* A clash of artifacts (such as bundles) between the prototype and the feature is not resolved and both versions end up in the featuree. Artifact clashes are detected based on Maven Coordinates, not on the content of the artifact. So if a prototype defines a bundle with artifact ID `org.sling:somebundle:1.2.0` and the feature itself declares `org.sling:somebundle:1.0.1` in its `bundles` section, version `1.0.1` and version `1.2.0` are used. The removals section can be used to remove the [...] * Configurations will be merged by default, the ones from the feature potentially overwriting configurations from the prototype: * If the same property is declared in more than one feature, the last one wins - in case of an array value, this requires redeclaring all values (if they are meant to be kept). * Framework properties from the feature overwrite framework properties from the prototype. diff --git a/pom.xml b/pom.xml index cefe8f5..1dc6491 100644 --- a/pom.xml +++ b/pom.xml @@ -22,7 +22,7 @@ </parent> <artifactId>org.apache.sling.feature</artifactId> - <version>2.0.5-SNAPSHOT</version> + <version>2.1.0-SNAPSHOT</version> <name>Apache Sling Feature Model</name> <description>A feature describes an OSGi system</description> diff --git a/src/main/java/org/apache/sling/feature/builder/BuilderContext.java b/src/main/java/org/apache/sling/feature/builder/BuilderContext.java index 4c7eb33..55e112c 100644 --- a/src/main/java/org/apache/sling/feature/builder/BuilderContext.java +++ b/src/main/java/org/apache/sling/feature/builder/BuilderContext.java @@ -110,6 +110,7 @@ public class BuilderContext { private final Map<String, String> variables = new HashMap<>(); private final Map<String, String> frameworkProperties = new HashMap<>(); private final Map<String, String> configOverrides = new LinkedHashMap<>(); + private boolean osgiBsnCollisionDetection; /** * Create a new context. @@ -229,6 +230,31 @@ public class BuilderContext { return this; } + /** + * Enable detection of OSGi Bundle-SymbolicName collisions during assembly. + * When bundles share a BSN but have different Maven coordinates, the + * wildcard overrides set with {@link #addArtifactsOverride(ArtifactId)} + * ({@code *:*:HIGHEST/LATEST/FIRST/ALL/<version>}) resolve them; if none + * matches, {@link FeatureBuilder#assemble} throws. + * {@link #setArtifactProvider(ArtifactProvider)} must be configured. + * + * @param enabled {@code true} to enable, {@code false} to disable (default) + * @return The builder context + * @since 2.1.0 + */ + public BuilderContext setOsgiBsnCollisionDetection(final boolean enabled) { + this.osgiBsnCollisionDetection = enabled; + return this; + } + + /** + * @return whether BSN collision detection is enabled. Default {@code false}. + * @since 2.1.0 + */ + public boolean isOsgiBsnCollisionDetectionEnabled() { + return this.osgiBsnCollisionDetection; + } + /** * Obtain the handler configuration. * @@ -297,6 +323,7 @@ public class BuilderContext { ctx.extensionConfiguration.putAll(this.extensionConfiguration); ctx.mergeExtensions.addAll(mergeExtensions); ctx.postProcessExtensions.addAll(postProcessExtensions); + ctx.osgiBsnCollisionDetection = this.osgiBsnCollisionDetection; return ctx; } } diff --git a/src/main/java/org/apache/sling/feature/builder/FeatureBuilder.java b/src/main/java/org/apache/sling/feature/builder/FeatureBuilder.java index bc239b5..11d146c 100644 --- a/src/main/java/org/apache/sling/feature/builder/FeatureBuilder.java +++ b/src/main/java/org/apache/sling/feature/builder/FeatureBuilder.java @@ -216,6 +216,10 @@ public abstract class FeatureBuilder { firstMerge = false; } + // Resolve any OSGi-identity (BSN+Bundle-Version) collisions that the + // Maven-coordinate dedup cannot detect. No-op when no policy is set. + OsgiBsnDeduplicator.apply(target, context); + // check complete flag if (targetIsComplete) { target.setComplete(true); diff --git a/src/main/java/org/apache/sling/feature/builder/OsgiBsnDeduplicator.java b/src/main/java/org/apache/sling/feature/builder/OsgiBsnDeduplicator.java new file mode 100644 index 0000000..37631a0 --- /dev/null +++ b/src/main/java/org/apache/sling/feature/builder/OsgiBsnDeduplicator.java @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.feature.builder; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; + +import org.apache.sling.feature.Artifact; +import org.apache.sling.feature.ArtifactId; +import org.apache.sling.feature.Feature; +import org.osgi.framework.Version; + +/** + * Resolves OSGi Bundle-SymbolicName collisions in an assembled feature. See + * {@link BuilderContext#setOsgiBsnCollisionDetection(boolean)}. + */ +final class OsgiBsnDeduplicator { + + private OsgiBsnDeduplicator() {} + + static void apply(final Feature target, final BuilderContext context) { + if (!context.isOsgiBsnCollisionDetectionEnabled()) { + return; + } + final ArtifactProvider provider = context.getArtifactProvider(); + if (provider == null) { + return; + } + + // Iteration order matches the order features were merged in; LATEST/FIRST rely on it. + final Map<String, List<Artifact>> groups = new LinkedHashMap<>(); + final Map<Artifact, String> bundleVersions = new HashMap<>(); + for (final Artifact a : target.getBundles()) { + final String[] bsnAndVersion = readBsnAndVersion(provider, a.getId()); + if (bsnAndVersion == null) { + continue; + } + groups.computeIfAbsent(bsnAndVersion[0], k -> new ArrayList<>()).add(a); + bundleVersions.put(a, bsnAndVersion[1]); + } + + for (final Map.Entry<String, List<Artifact>> entry : groups.entrySet()) { + final List<Artifact> conflicting = entry.getValue(); + if (conflicting.size() < 2) { + continue; + } + final List<Artifact> winners = + resolve(entry.getKey(), conflicting, bundleVersions, context.getArtifactOverrides()); + for (final Artifact a : conflicting) { + if (!winners.contains(a)) { + target.getBundles().remove(a); + } + } + } + } + + private static List<Artifact> resolve( + final String bsn, + final List<Artifact> conflicting, + final Map<Artifact, String> bundleVersions, + final List<ArtifactId> artifactOverrides) { + final String overrideRule = findWildcardOverrideRule(artifactOverrides); + if (overrideRule == null) { + throw new IllegalStateException("Bundle-SymbolicName collision detected and no override rule available. " + + "Configure a wildcard override (e.g. *:*:HIGHEST) on the BuilderContext to resolve it. " + + buildConflictMessage(bsn, conflicting)); + } + return applyOverrideRule(bsn, conflicting, bundleVersions, overrideRule); + } + + /** @return the rule from a {@code *:*:<rule>} override, or null if none configured. */ + private static String findWildcardOverrideRule(final List<ArtifactId> artifactOverrides) { + for (final ArtifactId override : artifactOverrides) { + if (BuilderContext.COORDINATE_MATCH_ALL.equals(override.getGroupId()) + && BuilderContext.COORDINATE_MATCH_ALL.equals(override.getArtifactId())) { + return override.getVersion(); + } + } + return null; + } + + private static List<Artifact> applyOverrideRule( + final String bsn, + final List<Artifact> conflicting, + final Map<Artifact, String> bundleVersions, + final String rule) { + if (BuilderContext.VERSION_OVERRIDE_ALL.equalsIgnoreCase(rule)) { + return new ArrayList<>(conflicting); + } + if (BuilderContext.VERSION_OVERRIDE_FIRST.equalsIgnoreCase(rule)) { + return Collections.singletonList(conflicting.get(0)); + } + if (BuilderContext.VERSION_OVERRIDE_LATEST.equalsIgnoreCase(rule)) { + return Collections.singletonList(conflicting.get(conflicting.size() - 1)); + } + if (BuilderContext.VERSION_OVERRIDE_HIGHEST.equalsIgnoreCase(rule)) { + Artifact best = conflicting.get(0); + Version bestVersion = parseQuietly(bundleVersions.get(best)); + for (int i = 1; i < conflicting.size(); i++) { + final Artifact candidate = conflicting.get(i); + final Version candidateVersion = parseQuietly(bundleVersions.get(candidate)); + if (candidateVersion.compareTo(bestVersion) > 0) { + best = candidate; + bestVersion = candidateVersion; + } + } + return Collections.singletonList(best); + } + // Literal version: match against each candidate's Bundle-Version. + for (final Artifact a : conflicting) { + if (rule.equals(bundleVersions.get(a))) { + return Collections.singletonList(a); + } + } + throw new IllegalStateException("Wildcard override rule '" + rule + + "' is a literal version that does not match any bundle in the colliding group. " + + buildConflictMessage(bsn, conflicting)); + } + + private static Version parseQuietly(final String version) { + try { + return Version.parseVersion(version); + } catch (final IllegalArgumentException e) { + return Version.emptyVersion; + } + } + + private static String buildConflictMessage(final String bsn, final List<Artifact> conflicting) { + final StringBuilder sb = + new StringBuilder("Bundle-SymbolicName collision: ").append(bsn).append(" provided by "); + for (int i = 0; i < conflicting.size(); i++) { + if (i > 0) { + sb.append(i == conflicting.size() - 1 ? " and " : ", "); + } + sb.append(conflicting.get(i).getId().toMvnId()); + } + return sb.toString(); + } + + /** @return [bsn, version] or null if unavailable. BSN directives stripped. */ + private static String[] readBsnAndVersion(final ArtifactProvider provider, final ArtifactId id) { + final URL url = provider.provide(id); + if (url == null) { + return null; + } + try (final InputStream is = url.openStream(); + final JarInputStream jar = new JarInputStream(is)) { + final Manifest mf = jar.getManifest(); + if (mf == null) { + return null; + } + final Attributes attrs = mf.getMainAttributes(); + final String bsnRaw = attrs.getValue("Bundle-SymbolicName"); + final String version = attrs.getValue("Bundle-Version"); + if (bsnRaw == null || version == null) { + return null; + } + final int sep = bsnRaw.indexOf(';'); + final String bsn = (sep < 0 ? bsnRaw : bsnRaw.substring(0, sep)).trim(); + return new String[] {bsn, version}; + } catch (final IOException e) { + return null; + } + } +} diff --git a/src/main/java/org/apache/sling/feature/builder/package-info.java b/src/main/java/org/apache/sling/feature/builder/package-info.java index 3bb242d..6a8705e 100644 --- a/src/main/java/org/apache/sling/feature/builder/package-info.java +++ b/src/main/java/org/apache/sling/feature/builder/package-info.java @@ -17,5 +17,5 @@ * under the License. */ [email protected]("2.0.0") [email protected]("2.1.0") package org.apache.sling.feature.builder; diff --git a/src/test/java/org/apache/sling/feature/builder/OsgiBsnDeduplicatorTest.java b/src/test/java/org/apache/sling/feature/builder/OsgiBsnDeduplicatorTest.java new file mode 100644 index 0000000..8cbd9df --- /dev/null +++ b/src/test/java/org/apache/sling/feature/builder/OsgiBsnDeduplicatorTest.java @@ -0,0 +1,263 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.feature.builder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.stream.Collectors; + +import org.apache.sling.feature.Artifact; +import org.apache.sling.feature.ArtifactId; +import org.apache.sling.feature.Feature; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class OsgiBsnDeduplicatorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + /** Same BSN+version, different Maven coords — typical cpconverter output. */ + @Test + public void wildcardLatestKeepsTheBundleFromTheLastFeature() throws Exception { + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId platform = ArtifactId.parse("org.ow2.asm:asm-tree:9.9.1"); + final ArtifactId extension = ArtifactId.parse("org.objectweb.asm:tree:9.9.1"); + urls.put(platform, jarWithManifest("org.objectweb.asm.tree", "9.9.1")); + urls.put(extension, jarWithManifest("org.objectweb.asm.tree", "9.9.1")); + + final Feature merged = assembleTwoFeatures(platform, extension, urls, ArtifactId.parse("*:*:LATEST")); + + assertEquals( + "extension feature is last in the arglist, so it wins", + Collections.singletonList(extension), + idsOf(merged)); + } + + @Test + public void wildcardFirstKeepsTheBundleFromTheFirstFeature() throws Exception { + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId platform = ArtifactId.parse("org.ow2.asm:asm-tree:9.9.1"); + final ArtifactId extension = ArtifactId.parse("org.objectweb.asm:tree:9.9.1"); + urls.put(platform, jarWithManifest("org.objectweb.asm.tree", "9.9.1")); + urls.put(extension, jarWithManifest("org.objectweb.asm.tree", "9.9.1")); + + final Feature merged = assembleTwoFeatures(platform, extension, urls, ArtifactId.parse("*:*:FIRST")); + + assertEquals(Collections.singletonList(platform), idsOf(merged)); + } + + @Test + public void wildcardHighestPicksTheHigherBundleVersion() throws Exception { + // HIGHEST consults Bundle-Version, not arglist order. + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId v1 = ArtifactId.parse("g.one:a:1.0.0"); + final ArtifactId v2 = ArtifactId.parse("g.two:b:2.0.0"); + urls.put(v1, jarWithManifest("same.bsn", "1.0.0")); + urls.put(v2, jarWithManifest("same.bsn", "2.0.0")); + + final Feature merged = assembleTwoFeatures(v1, v2, urls, ArtifactId.parse("*:*:HIGHEST")); + + assertEquals(Collections.singletonList(v2), idsOf(merged)); + } + + @Test + public void wildcardAllKeepsBothBundles() throws Exception { + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId v1 = ArtifactId.parse("g.one:a:1.0.0"); + final ArtifactId v2 = ArtifactId.parse("g.two:b:2.0.0"); + urls.put(v1, jarWithManifest("same.bsn", "1.0.0")); + urls.put(v2, jarWithManifest("same.bsn", "2.0.0")); + + final Feature merged = assembleTwoFeatures(v1, v2, urls, ArtifactId.parse("*:*:ALL")); + + assertEquals(Arrays.asList(v1, v2), idsOf(merged)); + } + + /** Symmetric with the existing GAV-clash "Artifact override rule required" failure. */ + @Test + public void noOverrideThrowsOnCollision() throws Exception { + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId platform = ArtifactId.parse("org.ow2.asm:asm-tree:9.9.1"); + final ArtifactId extension = ArtifactId.parse("org.objectweb.asm:tree:9.9.1"); + urls.put(platform, jarWithManifest("org.objectweb.asm.tree", "9.9.1")); + urls.put(extension, jarWithManifest("org.objectweb.asm.tree", "9.9.1")); + + try { + assembleTwoFeatures(platform, extension, urls, null); + fail("Expected IllegalStateException"); + } catch (final IllegalStateException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("org.objectweb.asm.tree")); + } + } + + /** Default off — pre-2.1.0 behaviour preserved. */ + @Test + public void detectionDisabledKeepsBothBundles() throws Exception { + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId platform = ArtifactId.parse("org.ow2.asm:asm-tree:9.9.1"); + final ArtifactId extension = ArtifactId.parse("org.objectweb.asm:tree:9.9.1"); + urls.put(platform, jarWithManifest("org.objectweb.asm.tree", "9.9.1")); + urls.put(extension, jarWithManifest("org.objectweb.asm.tree", "9.9.1")); + + final Feature platformFeature = new Feature(ArtifactId.parse("g.one:platform:1")); + platformFeature.getBundles().add(new Artifact(platform)); + final Feature extensionFeature = new Feature(ArtifactId.parse("g.two:extension:1")); + extensionFeature.getBundles().add(new Artifact(extension)); + + final BuilderContext ctx = new BuilderContext(id -> null).setArtifactProvider(urls::get); + // Note: setOsgiBsnCollisionDetection NOT called → default is disabled + final Feature merged = + FeatureBuilder.assemble(ArtifactId.parse("g.merged:merged:1"), ctx, platformFeature, extensionFeature); + + assertEquals(Arrays.asList(platform, extension), idsOf(merged)); + } + + /** BSN-only collision (different Bundle-Versions); */ + @Test + public void differentBundleVersionsCollideToo() throws Exception { + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId v1 = ArtifactId.parse("g.one:a:1.0.0"); + final ArtifactId v2 = ArtifactId.parse("g.two:b:2.0.0"); + urls.put(v1, jarWithManifest("same.bsn", "1.0.0")); + urls.put(v2, jarWithManifest("same.bsn", "2.0.0")); + + final Feature merged = assembleTwoFeatures(v1, v2, urls, ArtifactId.parse("*:*:LATEST")); + + assertEquals(Collections.singletonList(v2), idsOf(merged)); + } + + @Test + public void singletonDirectiveOnBsnDoesNotSplitGroups() throws Exception { + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId platform = ArtifactId.parse("g.one:a:1.0.0"); + final ArtifactId extension = ArtifactId.parse("g.two:b:1.0.0"); + urls.put(platform, jarWithManifest("same.bsn;singleton:=true", "1.0.0")); + urls.put(extension, jarWithManifest("same.bsn", "1.0.0")); + + final Feature merged = assembleTwoFeatures(platform, extension, urls, ArtifactId.parse("*:*:LATEST")); + + assertEquals(Collections.singletonList(extension), idsOf(merged)); + } + + /** Non-OSGi jars are not grouped, so detection is a no-op for them. */ + @Test + public void plainJarsWithoutBsnArePassedThrough() throws Exception { + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId a = ArtifactId.parse("g.one:a:1"); + final ArtifactId b = ArtifactId.parse("g.two:b:1"); + urls.put(a, jarWithManifest(null, null)); + urls.put(b, jarWithManifest(null, null)); + + final Feature merged = assembleTwoFeatures(a, b, urls, null); + + assertEquals(Arrays.asList(a, b), idsOf(merged)); + } + + @Test + public void noArtifactProviderTreatsDetectionAsNoop() { + final Feature platform = new Feature(ArtifactId.parse("g.one:platform:1")); + platform.getBundles().add(new Artifact(ArtifactId.parse("org.ow2.asm:asm-tree:9.9.1"))); + final Feature extension = new Feature(ArtifactId.parse("g.two:extension:1")); + extension.getBundles().add(new Artifact(ArtifactId.parse("org.objectweb.asm:tree:9.9.1"))); + + // No setArtifactProvider → cannot read manifests → detection gracefully no-ops. + final BuilderContext ctx = new BuilderContext(id -> null).setOsgiBsnCollisionDetection(true); + final Feature merged = FeatureBuilder.assemble(ArtifactId.parse("g.merged:merged:1"), ctx, platform, extension); + + assertEquals(2, merged.getBundles().size()); + } + + @Test + public void specificVersionOverrideSelectsByBundleVersion() throws Exception { + final Map<ArtifactId, URL> urls = new HashMap<>(); + final ArtifactId v1 = ArtifactId.parse("g.one:a:1.0.0"); + final ArtifactId v2 = ArtifactId.parse("g.two:b:2.0.0"); + urls.put(v1, jarWithManifest("same.bsn", "1.0.0")); + urls.put(v2, jarWithManifest("same.bsn", "2.0.0")); + + final Feature merged = assembleTwoFeatures(v1, v2, urls, ArtifactId.parse("*:*:2.0.0")); + + assertEquals(Collections.singletonList(v2), idsOf(merged)); + } + + // --- helpers --- + + private Feature assembleTwoFeatures( + final ArtifactId platformBundle, + final ArtifactId extensionBundle, + final Map<ArtifactId, URL> urls, + final ArtifactId wildcardOverride) { + final Feature platform = new Feature(ArtifactId.parse("g.one:platform:1")); + platform.getBundles().add(new Artifact(platformBundle)); + final Feature extension = new Feature(ArtifactId.parse("g.two:extension:1")); + extension.getBundles().add(new Artifact(extensionBundle)); + + final BuilderContext ctx = + new BuilderContext(id -> null).setArtifactProvider(urls::get).setOsgiBsnCollisionDetection(true); + if (wildcardOverride != null) { + ctx.addArtifactsOverride(wildcardOverride); + } + return FeatureBuilder.assemble(ArtifactId.parse("g.merged:merged:1"), ctx, platform, extension); + } + + private static List<ArtifactId> idsOf(final Feature f) { + return f.getBundles().stream().map(Artifact::getId).collect(Collectors.toList()); + } + + /** + * Build a tiny jar with the given OSGi headers and return its file:// URL. + * Pass null bsn/version to produce a jar with a manifest but no OSGi headers. + */ + private URL jarWithManifest(final String bsn, final String version) throws Exception { + final Manifest manifest = new Manifest(); + final Attributes main = manifest.getMainAttributes(); + main.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + if (bsn != null) { + main.putValue("Bundle-SymbolicName", bsn); + } + if (version != null) { + main.putValue("Bundle-Version", version); + } + final ByteArrayOutputStream buf = new ByteArrayOutputStream(); + try (final JarOutputStream jar = new JarOutputStream(buf, manifest)) { + // empty body — manifest is what we care about + } + final File f = tmp.newFile(); + try (final FileOutputStream out = new FileOutputStream(f)) { + out.write(buf.toByteArray()); + } + return f.toURI().toURL(); + } +}
