='\
+ org.w3c.dom.css,\
+ org.w3c.dom.html,\
+ org.w3c.dom.stylesheets,\
+ org.w3c.dom.xpath'
+
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Attribute.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Attribute.java
index 352d9dcc34..b312c3b68f 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Attribute.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Attribute.java
@@ -59,8 +59,6 @@
*
* This annotation is not retained at runtime. It is for use by tools to
* generate bundle manifests.
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Capabilities.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Capabilities.java
index 3bf2bfb0e6..940daba8b3 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Capabilities.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Capabilities.java
@@ -24,8 +24,6 @@
/**
* Container annotation for repeated {@link Capability} annotations.
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Capability.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Capability.java
index c0ad5288fc..f2f29aa548 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Capability.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Capability.java
@@ -37,8 +37,6 @@
* generate bundle manifests or otherwise process the type or package.
*
* This annotation can be used to annotate an annotation
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Directive.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Directive.java
index fa79d0e019..de733fc840 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Directive.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Directive.java
@@ -59,8 +59,6 @@
*
* This annotation is not retained at runtime. It is for use by tools to
* generate bundle manifests.
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Export.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Export.java
index df2d71ffec..0afdf5d183 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Export.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Export.java
@@ -32,8 +32,6 @@
*
* This annotation is not retained at runtime. It is for use by tools to
* generate bundle manifests or otherwise process the package.
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Header.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Header.java
index 92dc357753..457304ca9a 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Header.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Header.java
@@ -34,8 +34,6 @@
*
* This annotation is not retained at runtime. It is for use by tools to
* generate bundle manifests.
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Headers.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Headers.java
index 9f4c58a6f3..f8769e82ec 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Headers.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Headers.java
@@ -24,8 +24,6 @@
/**
* Container annotation for repeated {@link Header} annotations.
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Referenced.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Referenced.java
index 709e8ab000..0343ee3f49 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Referenced.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Referenced.java
@@ -30,8 +30,6 @@
*
* This annotation is not retained at runtime. It is for use by tools to
* generate bundle manifests.
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Requirement.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Requirement.java
index 4142eeaab7..fd08d2d064 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Requirement.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Requirement.java
@@ -37,8 +37,6 @@
* generate bundle manifests or otherwise process the a package.
*
* This annotation can be used to annotate an annotation.
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Requirements.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Requirements.java
index 471e939985..e7e3799f65 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Requirements.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/Requirements.java
@@ -24,8 +24,6 @@
/**
* Container annotation for repeated {@link Requirement} annotations.
- *
- * @author $Id$
*/
@Documented
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/package-info.java b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/package-info.java
index af1788ce5c..5962e7f934 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/package-info.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/bundle/annotations/package-info.java
@@ -18,8 +18,6 @@
* OSGi Bundle Annotations Package Version 1.0.
*
* This package is not used at runtime.
- *
- * @author $Id$
*/
@Version("1.0")
package aQute.bnd.bundle.annotations;
diff --git a/biz.aQute.bndlib/src/aQute/bnd/compatibility/Signatures.java b/biz.aQute.bndlib/src/aQute/bnd/compatibility/Signatures.java
index d9bcbd3da7..ba923c8085 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/compatibility/Signatures.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/compatibility/Signatures.java
@@ -17,8 +17,6 @@
* signature and it can normalize a signature. Both are methods. Normalized
* signatures can be string compared and match even if the type variable names
* differ.
- *
- * @version $Id$
*/
public class Signatures {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/ComponentConstants.java b/biz.aQute.bndlib/src/aQute/bnd/component/ComponentConstants.java
index 8259e09ee4..f49fa952e3 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/ComponentConstants.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/ComponentConstants.java
@@ -22,8 +22,6 @@
/**
* Defines standard names for Service Component constants.
- *
- * @author $Id$
*/
@ProviderType
public interface ComponentConstants {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/DSAnnotationReader.java b/biz.aQute.bndlib/src/aQute/bnd/component/DSAnnotationReader.java
index 2d566cdeb6..38e1f683be 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/DSAnnotationReader.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/DSAnnotationReader.java
@@ -117,6 +117,10 @@ public class DSAnnotationReader extends ClassDataCollector {
MethodSignature constructorSig;
int parameter;
int constructorArg;
+
+ private TypeRef implementationClass;
+ private boolean inImplementationClass;
+ private boolean hasPublicNoArgConstructor;
TypeRef className;
Analyzer analyzer;
MultiMap methods = new MultiMap<>();
@@ -172,6 +176,17 @@ private ComponentDef getDef() throws Exception {
if (component.implementation == null)
return null;
+ // Validate constructors for the component implementation class:
+ // - either it has a public no-arg constructor
+ // - or it uses constructor injection (public @Activate constructor)
+ if ((component.init == null || component.init.intValue() == 0) && !hasPublicNoArgConstructor) {
+ DeclarativeServicesAnnotationError details = new DeclarativeServicesAnnotationError(
+ implementationClass.getFQN(), null, null, ErrorType.INVALID_COMPONENT_TYPE);
+ details.addError(analyzer,
+ "[%s] The DS component class %s must be publicly accessible and have either a public no-arg constructor or a public constructor annotated with @Activate. Non-public classes, including public inner classes enclosed in non-public classes, are not supported.",
+ details.location(), implementationClass.getFQN());
+ }
+
if (options.contains(Options.inherit)) {
baseclass = false;
while (extendsClass != null) {
@@ -419,6 +434,10 @@ private void doActivate(Annotation annotation) {
break;
}
case CONSTRUCTOR : {
+ if (!((MethodDef)member).getContainingClass().getFQN().equals(clazz.getFQN())) {
+ // Ignore constructors from super classes as cannot be called directly
+ break;
+ }
DeclarativeServicesAnnotationError details = new DeclarativeServicesAnnotationError(className.getFQN(),
member.getName(), memberDescriptor, ErrorType.CONSTRUCTOR_SIGNATURE_ERROR);
if (component.init != null) {
@@ -1504,6 +1523,14 @@ private void doComponent(Component comp, Annotation annotation) throws Exception
@Override
public void classBegin(int access, TypeRef name) {
className = name;
+
+ // The first class we visit during parsing is the component implementation class.
+ // When scanning superclasses (inherit option), we do not want to change the
+ // constructor validation target.
+ if (implementationClass == null) {
+ implementationClass = name;
+ }
+ inImplementationClass = (implementationClass == name);
}
@Override
@@ -1579,6 +1606,15 @@ public void method(MethodDef method) {
methods.add(method.getName(), method);
methodSig = getMethodSignature(method);
+
+ // Track whether the *implementation* class has a public no-arg ctor.
+ if (inImplementationClass && method.isConstructor() && method.isPublic()) {
+ // methodSig is available and based on signature/descriptor
+
+ if ((methodSig != null) && (methodSig.parameterTypes.length == 0)) {
+ hasPublicNoArgConstructor = true;
+ }
+ }
}
private MethodSignature getMethodSignature(MethodDef method) {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Activate.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Activate.java
index 44eb374708..66e988a00e 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Activate.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Activate.java
@@ -45,7 +45,6 @@
* bundle.
*
* @see "The init, activate, and activation-fields attributes of the component element of a Component Description."
- * @author $Id$
* @since 1.1
*/
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/CollectionType.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/CollectionType.java
index 06405ec000..55d490916d 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/CollectionType.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/CollectionType.java
@@ -22,7 +22,6 @@
* Collection types for the {@link Reference} annotation.
*
* @since 1.4
- * @author $Id$
*/
public enum CollectionType {
/**
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Component.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Component.java
index 1b02250b50..b53df102e8 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Component.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Component.java
@@ -35,7 +35,6 @@
* bundle.
*
* @see "The component element of a Component Description."
- * @author $Id$
*/
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ComponentPropertyType.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ComponentPropertyType.java
index fe85822c60..1287775863 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ComponentPropertyType.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ComponentPropertyType.java
@@ -42,7 +42,6 @@
* bundle.
*
* @see "Component Property Types."
- * @author $Id$
* @since 1.4
*/
@Documented
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ConfigurationPolicy.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ConfigurationPolicy.java
index 10603759ed..724efa65bf 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ConfigurationPolicy.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ConfigurationPolicy.java
@@ -27,7 +27,6 @@
* Admin service. A corresponding configuration is a Configuration object where
* the PID is the name of the component.
*
- * @author $Id$
* @since 1.1
*/
public enum ConfigurationPolicy {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Deactivate.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Deactivate.java
index 1585a8712b..70c143e677 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Deactivate.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Deactivate.java
@@ -36,7 +36,6 @@
* bundle.
*
* @see "The deactivate attribute of the component element of a Component Description."
- * @author $Id$
* @since 1.1
*/
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/FieldOption.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/FieldOption.java
index 00181a75ae..d13bbabdc1 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/FieldOption.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/FieldOption.java
@@ -22,7 +22,6 @@
* Field options for the {@link Reference} annotation.
*
* @since 1.3
- * @author $Id$
*/
public enum FieldOption {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Modified.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Modified.java
index 22aa8d6444..3c948d8e9d 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Modified.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Modified.java
@@ -36,7 +36,6 @@
* bundle.
*
* @see "The modified attribute of the component element of a Component Description."
- * @author $Id$
* @since 1.1
*/
@Retention(RetentionPolicy.CLASS)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Reference.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Reference.java
index 62d279dc87..ee85284c4e 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Reference.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/Reference.java
@@ -45,7 +45,6 @@
* ) of the reference {@link #name() name}s.
*
* @see "The reference element of a Component Description."
- * @author $Id$
*/
@Retention(RetentionPolicy.CLASS)
@Target({
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferenceCardinality.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferenceCardinality.java
index d216a2b36c..745a818d88 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferenceCardinality.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferenceCardinality.java
@@ -24,8 +24,6 @@
*
* Specifies if the reference is optional and if the component implementation
* support a single bound service or multiple bound services.
- *
- * @author $Id$
*/
public enum ReferenceCardinality {
/**
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferencePolicy.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferencePolicy.java
index 000f48b235..280213be43 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferencePolicy.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferencePolicy.java
@@ -20,8 +20,6 @@
/**
* Policy for the {@link Reference} annotation.
- *
- * @author $Id$
*/
public enum ReferencePolicy {
/**
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferencePolicyOption.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferencePolicyOption.java
index 3adf91a189..4ad0284ecc 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferencePolicyOption.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferencePolicyOption.java
@@ -21,7 +21,6 @@
/**
* Policy option for the {@link Reference} annotation.
*
- * @author $Id$
* @since 1.2
*/
public enum ReferencePolicyOption {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferenceScope.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferenceScope.java
index 34d56af400..4714d4c2c6 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferenceScope.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ReferenceScope.java
@@ -21,7 +21,6 @@
/**
* Reference scope for the {@link Reference} annotation.
*
- * @author $Id$
* @since 1.3
*/
public enum ReferenceScope {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/RequireServiceComponentRuntime.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/RequireServiceComponentRuntime.java
index c199443e89..1dc82101fe 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/RequireServiceComponentRuntime.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/RequireServiceComponentRuntime.java
@@ -34,7 +34,6 @@
* process Declarative Services components. It can be used directly, or as a
* meta-annotation.
*
- * @author $Id$
* @since 1.4
*/
@Documented
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ServiceScope.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ServiceScope.java
index 6176ea7b42..1e4e10e2fb 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ServiceScope.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/ServiceScope.java
@@ -21,7 +21,6 @@
/**
* Service scope for the {@link Component} annotation.
*
- * @author $Id$
* @since 1.3
*/
public enum ServiceScope {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/package-info.java b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/package-info.java
index 32f4103831..47fc5f2e93 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/component/annotations/package-info.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/component/annotations/package-info.java
@@ -21,8 +21,6 @@
*
* This package is not used at runtime. Annotated classes are processed by tools
* to generate Component Descriptions which are used at runtime.
- *
- * @author $Id$
*/
@Version(COMPONENT_SPECIFICATION_VERSION)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/differ/Baseline.java b/biz.aQute.bndlib/src/aQute/bnd/differ/Baseline.java
index f9e23bd261..ece12e1f52 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/differ/Baseline.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/differ/Baseline.java
@@ -77,10 +77,20 @@ public static class BundleInfo {
Version olderVersion;
Version suggestedVersion;
String releaseRepository;
+ final boolean includeZeroMajor;
public Baseline(Reporter bnd, Differ differ) throws IOException {
this.differ = differ;
this.bnd = bnd;
+
+ // Read includeZeroMajor from global property
+ // The Reporter interface doesn't have getProperty, but the actual
+ // instance is always a Processor
+ if (bnd instanceof Processor proc) {
+ includeZeroMajor = proc.is(Constants.BASELINEINCLUDEZEROMAJOR);
+ } else {
+ includeZeroMajor = false;
+ }
}
/**
@@ -319,10 +329,19 @@ private Delta getThreshold(Instructions packageFilters, Instruction matcher) {
/**
* "Major version zero (0.y.z) is for initial development. Anything may
* change at any time. The public API should not be considered stable."
+ *
+ * This method returns {@code true} if baselining should report mismatches
+ * for the given versions. By default, it returns {@code false} for versions
+ * with major version 0 (unless {@code includeZeroMajor} is enabled).
*
* @see SemVer
*/
private boolean mismatch(Version older, Version newer) {
+ if (includeZeroMajor) {
+ // When includeZeroMajor is enabled, only exclude 0.0.x versions
+ return !(newer.getMajor() == 0 && newer.getMinor() == 0);
+ }
+ // Default behavior: exclude all versions with major version 0
return older.getMajor() > 0 && newer.getMajor() > 0;
}
diff --git a/biz.aQute.bndlib/src/aQute/bnd/maven/PomResource.java b/biz.aQute.bndlib/src/aQute/bnd/maven/PomResource.java
index bf9041cb9d..e29fd23739 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/maven/PomResource.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/maven/PomResource.java
@@ -14,6 +14,7 @@
import aQute.bnd.header.Attrs;
import aQute.bnd.header.OSGiHeader;
import aQute.bnd.header.Parameters;
+import aQute.bnd.osgi.Builder;
import aQute.bnd.osgi.Constants;
import aQute.bnd.osgi.Domain;
import aQute.bnd.osgi.Processor;
@@ -143,6 +144,12 @@ public PomResource(Processor scoped, Manifest manifest, String groupId, String a
version = "0";
}
+ // Apply -snapshot instruction transformation to the version
+ String snapshot = processor.getProperty(Constants.SNAPSHOT);
+ if ((snapshot != null) && (version.contains("SNAPSHOT"))) {
+ version = Builder.doSnapshot(version, snapshot);
+ }
+
this.groupId = groupId;
this.artifactId = artifactId;
this.version = version;
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/AttributeDefinition.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/AttributeDefinition.java
index aee7bd42d2..9ed6fe4381 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/AttributeDefinition.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/AttributeDefinition.java
@@ -73,7 +73,6 @@
* and used to contribute to a Meta Type Resource document for the bundle.
*
* @see "The AD element of a Meta Type Resource."
- * @author $Id$
*/
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/AttributeType.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/AttributeType.java
index 1eeff02e1d..e3d996f921 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/AttributeType.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/AttributeType.java
@@ -20,7 +20,6 @@
* Attribute types for the {@link AttributeDefinition} annotation.
*
* @see AttributeDefinition#type()
- * @author $Id$
*/
public enum AttributeType {
/**
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Designate.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Designate.java
index fa7f3ed492..8060037022 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Designate.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Designate.java
@@ -35,7 +35,6 @@
* and used to contribute to a Meta Type Resource document for the bundle.
*
* @see "The Designate element of a Meta Type Resource."
- * @author $Id$
*/
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Icon.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Icon.java
index 1b655a33c9..3e0ae3477f 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Icon.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Icon.java
@@ -24,7 +24,6 @@
* {@code Icon} information for an {@link ObjectClassDefinition}.
*
* @see ObjectClassDefinition#icon()
- * @author $Id$
*/
@Retention(RetentionPolicy.CLASS)
@Target({})
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/MetaTypeConstants.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/MetaTypeConstants.java
index 87bd702072..832f3201fe 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/MetaTypeConstants.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/MetaTypeConstants.java
@@ -30,7 +30,6 @@
* {@code MetaTypeProvider} objects.
*
* @ThreadSafe
- * @author $Id$
* @since 1.1
*/
class MetaTypeConstants {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/ObjectClassDefinition.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/ObjectClassDefinition.java
index b9adf57cb5..980ffbbcf8 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/ObjectClassDefinition.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/ObjectClassDefinition.java
@@ -42,7 +42,6 @@
* and used to generate a Meta Type Resource document for the bundle.
*
* @see "The OCD element of a Meta Type Resource."
- * @author $Id$
*/
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Option.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Option.java
index c374630b8e..fb62e6b5aa 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Option.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/Option.java
@@ -24,7 +24,6 @@
* {@code Option} information for an {@link AttributeDefinition}.
*
* @see AttributeDefinition#options()
- * @author $Id$
*/
@Retention(RetentionPolicy.CLASS)
@Target({})
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/RequireMetaTypeExtender.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/RequireMetaTypeExtender.java
index c3c3735a26..1c6e2a26e5 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/RequireMetaTypeExtender.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/RequireMetaTypeExtender.java
@@ -29,7 +29,6 @@
* This annotation can be used to require the Meta Type extender to process
* metatype resources. It can be used directly, or as a meta-annotation.
*
- * @author $Id$
* @since 1.4
*/
@Documented
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/RequireMetaTypeImplementation.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/RequireMetaTypeImplementation.java
index ed729a09b6..ed4fe0a5ef 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/RequireMetaTypeImplementation.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/RequireMetaTypeImplementation.java
@@ -29,7 +29,6 @@
* This annotation can be used to require the Meta Type implementation. It can
* be used directly, or as a meta-annotation.
*
- * @author $Id$
* @since 1.4
*/
@Documented
diff --git a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/package-info.java b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/package-info.java
index b6b51f1465..f6564f7f13 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/package-info.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/metatype/annotations/package-info.java
@@ -19,8 +19,6 @@
*
* This package is not used at runtime. Annotated classes are processed by tools
* to generate Meta Type Resources which are used at runtime.
- *
- * @author $Id$
*/
@Version(METATYPE_SPECIFICATION_VERSION)
diff --git a/biz.aQute.bndlib/src/aQute/bnd/osgi/About.java b/biz.aQute.bndlib/src/aQute/bnd/osgi/About.java
index 789e179ce8..8b475d5473 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/osgi/About.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/osgi/About.java
@@ -47,6 +47,8 @@
* The namespace is maintained by {@link Descriptors}, which here is owned by
* {@link Analyzer}. A special class, {@link Packages} maintains the attributes
* that are found in the code. @version $Revision: 1.2 $
+ *
+ * Ensure you only add Major and minor versions (the 7.2.1 below was a mistake)
*/
public class About {
private final static Logger logger = LoggerFactory.getLogger(About.class);
@@ -74,12 +76,16 @@ public class About {
public static final Version _7_0 = new Version(7, 0, 0);
public static final Version _7_1 = new Version(7, 1, 0);
public static final Version _7_2 = new Version(7, 2, 0);
+ public static final Version _7_2_1 = new Version(7, 2, 1);
public static final Version _7_3 = new Version(7, 3, 0);
public static final Version CURRENT = _7_3;
public static final String[] CHANGES_7_3 = {
"See https://github.com/bndtools/bnd/wiki/Changes-in-7.3.0 for a list of changes."
};
+ public static final String[] CHANGES_7_2_1 = {
+ "See https://github.com/bndtools/bnd/wiki/Changes-in-7.2.1 for a list of changes."
+ };
public static final String[] CHANGES_7_2 = {
"See https://github.com/bndtools/bnd/wiki/Changes-in-7.2.0 for a list of changes."
};
diff --git a/biz.aQute.bndlib/src/aQute/bnd/osgi/Analyzer.java b/biz.aQute.bndlib/src/aQute/bnd/osgi/Analyzer.java
index 56cf785fd0..fbc1e521bf 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/osgi/Analyzer.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/osgi/Analyzer.java
@@ -2108,6 +2108,10 @@ void augmentImports(Packages imports, Packages exports) throws Exception {
if (Strings.nonNullOrTrimmedEmpty(importRange)) {
importAttributes.put(VERSION_ATTRIBUTE, importRange);
}
+
+ // Notify analysis plugins about the version decision
+ String reason = buildVersionReason(provider, importAttributes, exportAttributes);
+ reportImportVersion(packageRef, importRange, reason);
}
//
@@ -2218,6 +2222,30 @@ String applyVersionPolicy(String exportVersion, String importRange, boolean prov
return importRange;
}
+ /**
+ * Build a human-readable reason for why a version range was chosen.
+ */
+ private String buildVersionReason(boolean provider, Attrs importAttributes, Attrs exportAttributes) {
+ if (importAttributes.containsKey(PROVIDE_DIRECTIVE)) {
+ return "explicit provide directive: " + importAttributes.get(PROVIDE_DIRECTIVE);
+ } else if (exportAttributes.containsKey(PROVIDE_DIRECTIVE)) {
+ return "export provide directive: " + exportAttributes.get(PROVIDE_DIRECTIVE);
+ } else if (provider) {
+ return "provider type detected";
+ } else {
+ return "consumer type (default)";
+ }
+ }
+
+ /**
+ * Report import version decision to analysis plugins.
+ */
+ private void reportImportVersion(PackageRef packageRef, String version, String reason) {
+ doPlugins(aQute.bnd.service.AnalysisPlugin.class, (plugin) -> {
+ plugin.reportImportVersion(this, packageRef, version, reason);
+ });
+ }
+
/**
* Find the packages we depend on, where we implement an interface that is a
* Provider Type. These packages, when we import them, must use the provider
diff --git a/biz.aQute.bndlib/src/aQute/bnd/osgi/Builder.java b/biz.aQute.bndlib/src/aQute/bnd/osgi/Builder.java
index ed70ff6e90..af82e2e1c8 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/osgi/Builder.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/osgi/Builder.java
@@ -470,38 +470,12 @@ public void analyze() throws Exception {
String version = getProperty(BUNDLE_VERSION);
if (version != null) {
version = cleanupVersion(version);
- version = doSnapshot(version);
+ version = doSnapshot(version, getProperty(Constants.SNAPSHOT));
setProperty(BUNDLE_VERSION, version);
}
}
- private String doSnapshot(String version) {
- String snapshot = getProperty(SNAPSHOT);
- if (snapshot == null) {
- return version;
- }
- if (snapshot.isEmpty()) {
- snapshot = null;
- }
- Version v = Version.parseVersion(version);
- String q = v.getQualifier();
- if (q == null) {
- return version;
- }
- if (q.equals("SNAPSHOT")) {
- q = snapshot;
- } else if (q.endsWith("-SNAPSHOT")) {
- int end = q.length() - "SNAPSHOT".length();
- if (snapshot == null) {
- q = q.substring(0, end - 1);
- } else {
- q = q.substring(0, end) + snapshot;
- }
- } else {
- return version;
- }
- return new Version(v.getMajor(), v.getMinor(), v.getMicro(), q).toString();
- }
+
public void cleanupVersion(Packages packages, String defaultVersion) {
cleanupVersion(packages, defaultVersion, "external");
@@ -2267,6 +2241,33 @@ private static boolean isIdentical(Resource a, Resource b) {
}
}
+ public static String doSnapshot(String version, String snapshot) {
+
+ if (snapshot == null) {
+ return version;
+ }
+ if (snapshot.isEmpty()) {
+ snapshot = null;
+ }
+ Version v = Version.parseVersion(version);
+ String q = v.getQualifier();
+ if (q == null) {
+ return version;
+ }
+ if (q.equals("SNAPSHOT")) {
+ q = snapshot;
+ } else if (q.endsWith("-SNAPSHOT")) {
+ int end = q.length() - "SNAPSHOT".length();
+ if (snapshot == null) {
+ q = q.substring(0, end - 1);
+ } else {
+ q = q.substring(0, end) + snapshot;
+ }
+ } else {
+ return version;
+ }
+ return new Version(v.getMajor(), v.getMinor(), v.getMicro(), q).toString();
+ }
}
diff --git a/biz.aQute.bndlib/src/aQute/bnd/osgi/Constants.java b/biz.aQute.bndlib/src/aQute/bnd/osgi/Constants.java
index 6e701cb8ed..ce50a3ffaf 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/osgi/Constants.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/osgi/Constants.java
@@ -93,6 +93,7 @@ public interface Constants {
REQUIRE_CAPABILITY, SERVICE_COMPONENT, PRIVATE_PACKAGE, IGNORE_PACKAGE, TESTCASES);
String BASELINE = "-baseline";
+ String BASELINEINCLUDEZEROMAJOR = "-baselineincludezeromajor";
String BASELINEREPO = "-baselinerepo";
String BNDDRIVER = "-bnd-driver";
@@ -301,6 +302,10 @@ public interface Constants {
String TESTPACKAGES = "-testpackages";
String TESTPATH = "-testpath";
String TESTCONTINUOUS = "-testcontinuous";
+ /**
+ * @deprecated forRemoval since = "8.0.0"
+ */
+ @Deprecated
String TESTTERMINATE = "-testterminate";
String TESTSOURCES = "-testsources";
String TESTUNRESOLVED = "-testunresolved";
@@ -332,7 +337,7 @@ public interface Constants {
Set options = Sets.of(BASELINE, BUILDPATH, BUMPPOLICY, CONDUIT,
CLASSPATH, COMPRESSION, CONSUMER_POLICY, DEPENDSON, DONOTCOPY, EXPORT_CONTENTS, FAIL_OK, INCLUDE,
- INCLUDERESOURCE, MAKE, MANIFEST, NOEXTRAHEADERS, NOUSES, NOBUNDLES, PEDANTIC, PLUGIN, POM, PROVIDER_POLICY,
+ BASELINEINCLUDEZEROMAJOR, INCLUDERESOURCE, MAKE, MANIFEST, NOEXTRAHEADERS, NOUSES, NOBUNDLES, PEDANTIC, PLUGIN, POM, PROVIDER_POLICY,
REMOVEHEADERS, RESOURCEONLY, SOURCES, SOURCEPATH, SUB, RUNBUNDLES, RUNPATH, RUNSYSTEMPACKAGES,
RUNSYSTEMCAPABILITIES, RUNPROPERTIES, REPORTNEWER, UNDERTEST, TESTPATH, TESTPACKAGES, NOMANIFEST, DEPLOYREPO,
RELEASEREPO, SAVEMANIFEST, RUNVM, RUNPROGRAMARGS, WAB, WABLIB, RUNFRAMEWORK, RUNFW, RUNKEEP, RUNTRACE,
diff --git a/biz.aQute.bndlib/src/aQute/bnd/osgi/ExecutorGroup.java b/biz.aQute.bndlib/src/aQute/bnd/osgi/ExecutorGroup.java
index b6c014f003..1ea65db391 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/osgi/ExecutorGroup.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/osgi/ExecutorGroup.java
@@ -10,8 +10,6 @@
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Function;
import org.osgi.util.promise.PromiseFactory;
@@ -77,51 +75,55 @@ public ExecutorGroup(int corePoolSize, int maximumPoolSize) {
executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, 60L, TimeUnit.SECONDS,
new SynchronousQueue<>(), new ExecutorThreadFactory(defaultThreadFactory, "Bnd-Executor,"),
rejectedExecutionHandler);
+
scheduledExecutor = new ScheduledThreadPoolExecutor(corePoolSize,
new ExecutorThreadFactory(defaultThreadFactory, "Bnd-ScheduledExecutor,"), rejectedExecutionHandler);
+ // Configure scheduled executor to prevent unbounded thread growth
+ scheduledExecutor.setMaximumPoolSize(Math.max(corePoolSize, 4));
+ scheduledExecutor.setKeepAliveTime(60L, TimeUnit.SECONDS);
+ scheduledExecutor.allowCoreThreadTimeOut(false);
+
// Use dedicated ScheduledThreadPoolExecutor for the promise factory
promiseScheduledExecutor = new ScheduledThreadPoolExecutor(corePoolSize,
new ExecutorThreadFactory(defaultThreadFactory, "Bnd-PromiseScheduledExecutor,"), rejectedExecutionHandler);
+ // Configure promise scheduled executor to prevent unbounded thread growth
+ promiseScheduledExecutor.setMaximumPoolSize(Math.max(corePoolSize, 4));
+ promiseScheduledExecutor.setKeepAliveTime(60L, TimeUnit.SECONDS);
+ promiseScheduledExecutor.allowCoreThreadTimeOut(false);
+
promiseFactory = new PromiseFactory(executor, promiseScheduledExecutor);
List executors = Lists.of(scheduledExecutor, promiseScheduledExecutor, executor);
- // Handle shutting down executors via shutdown hook
- AtomicBoolean shutdownHookInstalled = new AtomicBoolean();
- Function shutdownHookInstaller = threadPoolExecutor -> {
- ThreadFactory threadFactory = threadPoolExecutor.getThreadFactory();
- return (Runnable r) -> {
- threadPoolExecutor.setThreadFactory(threadFactory);
- if (shutdownHookInstalled.compareAndSet(false, true)) {
- Thread shutdownThread = new Thread(() -> {
- executors.forEach(executor -> {
- executor.shutdown();
- try {
- executor.awaitTermination(20, TimeUnit.SECONDS);
- } catch (InterruptedException e) {
- Thread.currentThread()
- .interrupt();
- }
- });
- }, "Bnd-ExecutorShutdownHook");
- try {
- Runtime.getRuntime()
- .addShutdownHook(shutdownThread);
- } catch (IllegalStateException e) {
- // VM is already shutting down...
- executors.forEach(ThreadPoolExecutor::shutdown);
- }
- }
- return threadFactory.newThread(r);
- };
- };
- executors.forEach(executor -> {
- executor.setThreadFactory(shutdownHookInstaller.apply(executor));
- if (executor instanceof ScheduledThreadPoolExecutor scheduledExecutor) {
- scheduledExecutor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
- scheduledExecutor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
+ // Configure shutdown policies for scheduled executors
+ executors.forEach(exec -> {
+ if (exec instanceof ScheduledThreadPoolExecutor scheduledExec) {
+ scheduledExec.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+ scheduledExec.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
+ scheduledExec.setRemoveOnCancelPolicy(true);
}
});
+
+ // Install shutdown hook eagerly to avoid thread factory complications
+ Thread shutdownThread = new Thread(() -> {
+ executors.forEach(exec -> {
+ exec.shutdown();
+ try {
+ exec.awaitTermination(20, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread()
+ .interrupt();
+ }
+ });
+ }, "Bnd-ExecutorShutdownHook");
+ shutdownThread.setDaemon(false);
+ try {
+ Runtime.getRuntime()
+ .addShutdownHook(shutdownThread);
+ } catch (IllegalStateException e) {
+ // VM is already shutting down...
+ executors.forEach(ThreadPoolExecutor::shutdown);
+ }
}
public Executor getExecutor() {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/osgi/Jar.java b/biz.aQute.bndlib/src/aQute/bnd/osgi/Jar.java
index 72870235e9..33886ac304 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/osgi/Jar.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/osgi/Jar.java
@@ -786,7 +786,8 @@ private static Manifest clean(Manifest org) {
.entrySet()) {
String nice = clean((String) entry.getValue());
Object key = entry.getKey();
- if (Constants.OSGI_SYNTAX_HEADERS.contains(key.toString())) {
+ if (Constants.OSGI_SYNTAX_HEADERS.contains(key.toString())
+ && !Constants.BUNDLE_NATIVECODE.equals(key.toString())) {
nice = reorderClause(nice, collator);
}
mainAttributes.put(key, nice);
diff --git a/biz.aQute.bndlib/src/aQute/bnd/osgi/resource/CapReqBuilder.java b/biz.aQute.bndlib/src/aQute/bnd/osgi/resource/CapReqBuilder.java
index a0fb415010..7c8bbfdd5b 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/osgi/resource/CapReqBuilder.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/osgi/resource/CapReqBuilder.java
@@ -383,15 +383,47 @@ public static Requirement unalias(Requirement requirement) {
.get(REQ_ALIAS_IDENTITY_NAME_ATTRIB), null);
String versionRange = Objects.toString(requirement.getAttributes()
.get(REQ_ALIAS_IDENTITY_VERSION_ATTRIB), null);
- if (name == null) {
- throw new IllegalArgumentException(
- String.format("Requirement alias '%s' is missing mandatory attribute '%s' of type String",
- REQ_ALIAS_IDENTITY, REQ_ALIAS_IDENTITY_NAME_ATTRIB));
+ String type = Objects.toString(requirement.getAttributes()
+ .get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE), null);
+ if (name == null) {
+ throw new IllegalArgumentException(
+ String.format("Requirement alias '%s' is missing mandatory attribute '%s' of type String",
+ REQ_ALIAS_IDENTITY, REQ_ALIAS_IDENTITY_NAME_ATTRIB));
+ }
+ CapReqBuilder builder = new CapReqBuilder(IdentityNamespace.IDENTITY_NAMESPACE).from(requirement)
+ .removeAttribute(REQ_ALIAS_IDENTITY_NAME_ATTRIB)
+ .removeAttribute(REQ_ALIAS_IDENTITY_VERSION_ATTRIB)
+ .removeAttribute(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE);
+
+ // Build filter with identity, optional type, and optional version
+ StringBuilder filter = new StringBuilder();
+ boolean needsAnd = (type != null || versionRange != null);
+ if (needsAnd) {
+ filter.append("(&");
+ }
+ filter.append('(').append(IdentityNamespace.IDENTITY_NAMESPACE).append('=').append(name).append(')');
+
+ // Add type filter if present
+ if (type != null) {
+ filter.append('(').append(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE).append('=').append(type).append(')');
+ }
+
+ // Add version range filter if present
+ if (versionRange != null && VersionRange.isOSGiVersionRange(versionRange)) {
+ String versionFilter = VersionRange.parseOSGiVersionRange(versionRange).toFilter(Constants.VERSION_ATTRIBUTE);
+ // versionFilter is already wrapped in (&...) for ranges, so extract inner parts
+ if (versionFilter.length() > 1 && versionFilter.charAt(0) == '(' && versionFilter.charAt(1) == '&') {
+ filter.append(versionFilter, 2, versionFilter.length() - 1);
+ } else {
+ filter.append(versionFilter);
}
- CapReqBuilder builder = new CapReqBuilder(IdentityNamespace.IDENTITY_NAMESPACE).from(requirement)
- .removeAttribute(REQ_ALIAS_IDENTITY_NAME_ATTRIB)
- .removeAttribute(REQ_ALIAS_IDENTITY_VERSION_ATTRIB);
- builder.addFilter(IdentityNamespace.IDENTITY_NAMESPACE, name, versionRange, null);
+ }
+
+ if (needsAnd) {
+ filter.append(')');
+ }
+
+ builder.addDirective(Namespace.REQUIREMENT_FILTER_DIRECTIVE, filter.toString());
return builder.buildSyntheticRequirement();
}
default : {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/osgi/resource/ResourceBuilder.java b/biz.aQute.bndlib/src/aQute/bnd/osgi/resource/ResourceBuilder.java
index faf99345f1..e6a6773e86 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/osgi/resource/ResourceBuilder.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/osgi/resource/ResourceBuilder.java
@@ -23,6 +23,7 @@
import java.util.function.Supplier;
import java.util.jar.Manifest;
import java.util.stream.Stream;
+import java.util.zip.ZipException;
import org.osgi.annotation.versioning.ProviderType;
import org.osgi.framework.Constants;
@@ -1034,7 +1035,21 @@ public static SupportingResource parse(File file, URI uri) {
file.length(), mime);
return rb.build();
- } catch (Exception rt) {
+ } catch (ZipException rt) {
+ // can happen if the file is not a JAR file (e.g. a dynamic libray,
+ // .so, .dylib)
+ ResourceBuilder rb = new ResourceBuilder();
+ // placeholder for "any file"
+ String mime = "application/octet-stream";
+ rb.addContentCapability(uri,
+ new DeferredComparableValue(String.class,
+ SupplierWithException.asSupplier(() -> SHA256.digest(file)
+ .asHex()),
+ file.hashCode()),
+ file.length(), mime);
+ return rb.build();
+ }
+ catch (Exception rt) {
throw new IllegalArgumentException("illegal format " + file.getAbsolutePath(), rt);
}
}
diff --git a/biz.aQute.bndlib/src/aQute/bnd/service/AnalysisPlugin.java b/biz.aQute.bndlib/src/aQute/bnd/service/AnalysisPlugin.java
new file mode 100644
index 0000000000..88ae6a4591
--- /dev/null
+++ b/biz.aQute.bndlib/src/aQute/bnd/service/AnalysisPlugin.java
@@ -0,0 +1,46 @@
+package aQute.bnd.service;
+
+import aQute.bnd.osgi.Analyzer;
+import aQute.bnd.osgi.Descriptors.PackageRef;
+
+/**
+ * A plugin that is called during the analysis phase to collect information
+ * about analysis decisions. This allows implementations to track why certain
+ * decisions were made, such as why a particular version range was chosen for an
+ * import.
+ *
+ * This plugin is called during the {@link Analyzer#analyze()} phase, before the
+ * manifest is generated. It provides callbacks for various analysis events,
+ * allowing implementations to build a detailed log of analysis decisions.
+ */
+public interface AnalysisPlugin extends OrderedPlugin {
+
+ /**
+ * Called when the analyzer determines a version range for an import package.
+ * This provides insight into why a particular version range was chosen.
+ *
+ * @param analyzer the analyzer performing the analysis
+ * @param packageRef the package being analyzed
+ * @param version the version or version range determined
+ * @param reason a human-readable explanation for why this version was chosen
+ * (e.g., "provider type", "consumer type", "explicit version
+ * policy")
+ * @throws Exception if an error occurs during processing
+ */
+ void reportImportVersion(Analyzer analyzer, PackageRef packageRef, String version, String reason)
+ throws Exception;
+
+ /**
+ * Called when the analyzer makes other analysis decisions that may be of
+ * interest.
+ *
+ * @param analyzer the analyzer performing the analysis
+ * @param category the category of the analysis decision (e.g., "uses",
+ * "export", "capability")
+ * @param details detailed information about the decision
+ * @throws Exception if an error occurs during processing
+ */
+ default void reportAnalysis(Analyzer analyzer, String category, String details) throws Exception {
+ // Default implementation does nothing
+ }
+}
diff --git a/biz.aQute.bndlib/src/aQute/bnd/service/package-info.java b/biz.aQute.bndlib/src/aQute/bnd/service/package-info.java
index 6a9f0a0594..a7d1e38580 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/service/package-info.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/service/package-info.java
@@ -1,4 +1,4 @@
-@Version("4.9.0")
+@Version("4.10.0")
package aQute.bnd.service;
import org.osgi.annotation.versioning.Version;
diff --git a/biz.aQute.bndlib/src/aQute/bnd/util/dto/DTO.java b/biz.aQute.bndlib/src/aQute/bnd/util/dto/DTO.java
index 505218c264..7338c16c4d 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/util/dto/DTO.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/util/dto/DTO.java
@@ -35,7 +35,6 @@
* The object graph from a Data Transfer Object must be a tree to simplify
* serialization and deserialization.
*
- * @author $Id$
* @NotThreadSafe
*/
public abstract class DTO {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/version/VersionRange.java b/biz.aQute.bndlib/src/aQute/bnd/version/VersionRange.java
index 05f42c1cd6..2d8ed4fa85 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/version/VersionRange.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/version/VersionRange.java
@@ -118,7 +118,7 @@ static Version unique(Version v) {
}
public boolean isRange() {
- return getHigh() != getLow();
+ return !getHigh().equals(getLow());
}
public boolean includeLow() {
diff --git a/biz.aQute.bndlib/src/aQute/bnd/wstemplates/FragmentTemplateEngine.java b/biz.aQute.bndlib/src/aQute/bnd/wstemplates/FragmentTemplateEngine.java
index 158b005151..eb9cdfafe4 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/wstemplates/FragmentTemplateEngine.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/wstemplates/FragmentTemplateEngine.java
@@ -450,25 +450,25 @@ public TemplateUpdater updater(File folder, List templates) {
* Resolve all required templates recursively. This method processes the
* 'require' field of each template and includes all transitively required
* templates.
- *
+ *
* @param templates the initial list of templates
* @return a list containing the original templates plus all required
* templates, with duplicates removed
*/
- List resolveRequirements(List templates) {
+ public List resolveRequirements(List templates) {
Set seen = new HashSet<>();
List result = new ArrayList<>();
-
+
for (TemplateInfo template : templates) {
resolveRequirements(template, seen, result);
}
-
+
return result;
}
/**
* Recursively resolve requirements for a single template.
- *
+ *
* @param template the template to resolve
* @param seen set of already processed template IDs to prevent circular
* dependencies
@@ -479,20 +479,20 @@ private void resolveRequirements(TemplateInfo template, Set seen, Li
if (seen.contains(template.id())) {
return;
}
-
+
seen.add(template.id());
-
+
// First, recursively resolve all required templates
if (template.require() != null && template.require().length > 0) {
for (String requiredId : template.require()) {
TemplateID reqId = TemplateID.from(requiredId.trim());
-
+
// Find the required template in our available templates
TemplateInfo requiredTemplate = templates.stream()
.filter(t -> t.id().equals(reqId))
.findFirst()
.orElse(null);
-
+
if (requiredTemplate != null) {
// Recursively resolve this required template
resolveRequirements(requiredTemplate, seen, result);
@@ -502,7 +502,7 @@ private void resolveRequirements(TemplateInfo template, Set seen, Li
}
}
}
-
+
// Add the current template after its dependencies
result.add(template);
}
diff --git a/biz.aQute.bndlib/src/aQute/bnd/wstemplates/package-info.java b/biz.aQute.bndlib/src/aQute/bnd/wstemplates/package-info.java
index 7b149e87f0..7be6e3d0fd 100644
--- a/biz.aQute.bndlib/src/aQute/bnd/wstemplates/package-info.java
+++ b/biz.aQute.bndlib/src/aQute/bnd/wstemplates/package-info.java
@@ -1,4 +1,4 @@
-@Version("1.1.0")
+@Version("1.2.0")
package aQute.bnd.wstemplates;
import org.osgi.annotation.versioning.Version;
diff --git a/biz.aQute.junit/README b/biz.aQute.junit/README
index 516751e083..884edba856 100644
--- a/biz.aQute.junit/README
+++ b/biz.aQute.junit/README
@@ -1,3 +1,6 @@
+Deprecated: This bundle is marked as deprecated in bnd 7.2.0 for remove in 8.0.0
+Please migrate to biz.aQute.tester.junit-platform
+
The junit project is a plugin that extends the ProjectTester.java class to run OSGi tests
inside a framework.
diff --git a/biz.aQute.junit/bnd.bnd b/biz.aQute.junit/bnd.bnd
index 9233ed276e..64b5061cb3 100644
--- a/biz.aQute.junit/bnd.bnd
+++ b/biz.aQute.junit/bnd.bnd
@@ -1,6 +1,11 @@
# Set javac settings from JDT prefs
-include: ${workspace}/cnf/includes/jdt.bnd
+# Deprecated for removal in 8.0.0
+Bundle-Description: Deprecated for removal in 8.0.0: \
+ The junit project is a plugin that extends the ProjectTester.java class \
+ to run OSGi tests inside a framework.
+
-maven-scope: provided
-buildpath: \
diff --git a/biz.aQute.launcher/bnd.bnd b/biz.aQute.launcher/bnd.bnd
index c9d8acb6ad..7171bac68a 100644
--- a/biz.aQute.launcher/bnd.bnd
+++ b/biz.aQute.launcher/bnd.bnd
@@ -28,6 +28,11 @@
aQute.launcher.plugin
-includeresource: \
${p}.pre.jar=pre.jar
+
+Import-Package: \
+ !aQute.launcher.agent,\
+ !aQute.launcher.pre,\
+ *;version="${range;[==,+)}"
-builderignore: testresources
diff --git a/biz.aQute.repository/.gitignore b/biz.aQute.repository/.gitignore
index 90dde36e4a..382a9cf5c4 100644
--- a/biz.aQute.repository/.gitignore
+++ b/biz.aQute.repository/.gitignore
@@ -1,3 +1,4 @@
/bin/
/bin_test/
/generated/
+/.m2/
\ No newline at end of file
diff --git a/biz.aQute.repository/bnd.bnd b/biz.aQute.repository/bnd.bnd
index 19aac16618..e181481fd9 100644
--- a/biz.aQute.repository/bnd.bnd
+++ b/biz.aQute.repository/bnd.bnd
@@ -20,17 +20,22 @@
org.tukaani.xz;version=latest;maven-scope=provided
-testpath: \
- biz.aQute.bnd.test;version=project,\
- ${junit},\
- ${mockito},\
- biz.aQute.http.testservers;version=latest,\
- slf4j.simple;version=latest
+ biz.aQute.bnd.test;version=project,\
+ ${junit},\
+ ${mockito},\
+ biz.aQute.http.testservers;version=latest,\
+ slf4j.simple;version=latest
+
+-runpath: \
+ slf4j.api;version='[1.7.25,2)',\
+ slf4j.simple;version=latest
Export-Package: \
aQute.bnd.deployer.http;bnd-plugins=true,\
aQute.p2.api,\
aQute.p2.export;bnd-plugins=true,\
- aQute.bnd.deployer.obr;bnd-plugins=true,\
+ aQute.p2.provider,\
+ aQute.bnd.deployer.obr;bnd-plugins=true,\
aQute.bnd.deployer.repository;bnd-plugins=true,\
aQute.bnd.deployer.repository.api,\
aQute.bnd.deployer.repository.providers;bnd-plugins=true,\
@@ -38,9 +43,9 @@ Export-Package: \
aQute.bnd.repository.maven.provider;bnd-plugins=true,\
aQute.bnd.repository.p2.provider;bnd-plugins=true,\
aQute.bnd.repository.maven.pom.provider;bnd-plugins=true,\
- aQute.bnd.repository.osgi;bnd-plugins=true, \
- aQute.bnd.repository.fileset;bnd-plugins=true, \
- aQute.maven.*, \
+ aQute.bnd.repository.osgi;bnd-plugins=true,\
+ aQute.bnd.repository.fileset;bnd-plugins=true,\
+ aQute.maven.*
-includepackage: \
aQute.p2.provider,\
diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/IndexFile.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/IndexFile.java
index cc7b18bfc0..a98496746d 100644
--- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/IndexFile.java
+++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/IndexFile.java
@@ -7,9 +7,14 @@
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
+import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Collection;
import java.util.Collections;
+import java.util.Comparator;
import java.util.Formatter;
import java.util.HashMap;
import java.util.HashSet;
@@ -74,6 +79,7 @@ class IndexFile {
final IMavenRepo repo;
private final Processor domain;
private final Macro replacer;
+ final boolean isPom;
final Reporter reporter;
final PromiseFactory promiseFactory;
@@ -101,6 +107,7 @@ class IndexFile {
this.repo = repo;
this.promiseFactory = promiseFactory;
this.multi = multi;
+ this.isPom = looksLikePomXml(indexFile);
this.updateSerializer = promiseFactory.resolved(Boolean.TRUE);
this.bridge = Memoize.supplier(BridgeRepository::new);
}
@@ -412,9 +419,25 @@ private Set read(File file) throws IOException {
this.status = "Not an index file: " + file;
return Collections.emptySet();
}
+ if (isPom) {
+ try {
+ return POMIndexFile.readFromPom(file, msg -> {
+ if (this.status == null) {
+ this.status = msg;
+ }
+ });
+ } catch (Exception e) {
+ this.status = "Failed to read pom.xml index: " + e.getMessage();
+ logger.debug("Failed to read pom.xml index {}", file, e);
+ return Collections.emptySet();
+ }
+ }
return read(IO.collect(file), true);
}
+
+
+
private Set read(String source, boolean macro) {
Set archives = Strings.splitLinesAsStream(source)
.map(s -> toArchive(s, macro))
@@ -434,6 +457,15 @@ private boolean isBsn(String bsn, Resource resource) {
}
private void save(Set add, Set remove) throws Exception {
+ if (isPom) {
+ POMIndexFile.savePom(indexFile, sort(archives.keySet()));
+ } else {
+ saveText(add, remove);
+ }
+ lastModified = indexFile.lastModified();
+ }
+
+ private void saveText(Set add, Set remove) throws Exception {
try (Formatter f = new Formatter()) {
if (indexFile.isFile()) {
String content = IO.collect(indexFile);
@@ -454,8 +486,60 @@ private void save(Set add, Set remove) throws Exception {
IO.mkdirs(indexFile.getParentFile());
IO.store(f.toString(), indexFile);
}
+ }
+
+
+ /**
+ * Replace an archive in the index with an updated version (e.g. after a
+ * version upgrade). The replacement is persisted to the index file in its
+ * native format (text or pom.xml). This method is thread-safe: it goes
+ * through the same {@link #serialize(SupplierWithException) serializer}
+ * used by {@link #add(Archive)} and {@link #remove(Archive)}, ensuring
+ * that concurrent index modifications are ordered correctly.
+ *
+ * @param oldArchive the archive currently in the index
+ * @param updatedArchive the archive with the new version to replace it
+ */
+ void replaceArchive(Archive oldArchive, Archive updatedArchive) throws Exception {
+ Promise serializer = serialize(() -> {
+ removeWithDerived(oldArchive);
+ Set toAdd = Collections.singleton(updatedArchive);
+ Set toRemove = Collections.singleton(oldArchive);
+ return update(toAdd).thenAccept(b -> save(toAdd, toRemove));
+ });
+ sync(serializer);
+ }
+
+ void convertTextXml() {
+ Promise serializer = serialize(() -> {
+
+ if (isPom) {
+ try (Formatter f = new Formatter()) {
+ sort(archives.keySet()).forEach(archive -> f.format("%s\n", archive));
+ IO.store(f.toString(), indexFile);
+ }
+
+ } else {
+ POMIndexFile.savePom(indexFile, sort(archives.keySet()));
+ }
+ lastModified = indexFile.lastModified();
+ return null;
+ });
+ sync(serializer);
- lastModified = indexFile.lastModified();
+ }
+
+ private static List sort(Set archives) {
+
+ // Compute archives from current state (update() already applied
+ // changes)
+ return archives.stream()
+ .sorted(Comparator.comparing((Archive a) -> a.revision.program.group)
+ .thenComparing(a -> a.revision.program.artifact)
+ .thenComparing(Comparator.comparing(a -> a.extension))
+ .thenComparing(Comparator.comparing(a -> a.classifier))
+ .thenComparing(a -> a.revision.version.toString()))
+ .collect(toList());
}
private Archive toArchive(String s, boolean macro) {
@@ -625,4 +709,40 @@ public String getStatus() {
return null;
}
+ /**
+ * A simple check if we are dealing with an XML file. If yes, then we assume
+ * a pom.xml
+ *
+ * @param file
+ * @return
+ * @throws IOException
+ */
+ private static boolean looksLikePomXml(File file) throws IOException {
+ Path path = file.toPath();
+ if (!Files.isRegularFile(path)) {
+ return false;
+ }
+
+ byte[] buffer = new byte[4096];
+ int n;
+
+ try (InputStream in = Files.newInputStream(path)) {
+ n = in.read(buffer);
+ }
+
+ if (n <= 0) {
+ return false;
+ }
+
+ String content = new String(buffer, 0, n, StandardCharsets.UTF_8);
+
+ // remove UTF-8 BOM if present
+ if (!content.isEmpty() && content.charAt(0) == '\uFEFF') {
+ content = content.substring(1);
+ }
+
+ content = content.trim();
+
+ return content.startsWith(" release = new ArrayList();
MavenBackingRepository staging = null;
+ List snapshot = new ArrayList();
String releaseUrl = configuration.releaseUrl();
SonatypeMode sonatypeMode = configuration.sonatypeMode(SonatypeMode.NONE.name());
String stagingUrl = configuration.stagingUrl();
+ String snapshotUrl = configuration.snapshotUrl();
- String sonatypeUrl = null;
+ String sonatypeReleaseUrl = null;
+ String sonatypeSnapshotUrl = null;
switch (sonatypeMode) {
case MANUAL, AUTOPUBLISH -> {
logger.info("deployment via Sonatype Central Portal configured in {} mode", sonatypeMode);
File releaseDir = registry.getPlugin(Workspace.class)
.getFile(SONATYPE_RELEASE_DIR);
+ File snapshotDir = registry.getPlugin(Workspace.class)
+ .getFile(SONATYPE_SNAPSHOT_DIR);
if (stagingUrl == null) {
logger.debug("deployment via relase url to Sonatype Portal configured");
List releaseLocal = MavenBackingRepository.create(releaseDir.toURI()
.toString(), reporter, localRepo, client);
release.addAll(releaseLocal);
- sonatypeUrl = releaseUrl;
+ sonatypeReleaseUrl = releaseUrl;
} else {
logger.debug("deployment via staging url to Sonatype Portal configured");
release = MavenBackingRepository.create(releaseUrl, reporter, localRepo, client);
staging = MavenBackingRepository.getBackingRepository(releaseDir.toURI()
.toString(), reporter, localRepo, client);
- sonatypeUrl = stagingUrl;
+ sonatypeReleaseUrl = stagingUrl;
+ }
+ if (snapshotUrl != null) {
+ logger.debug("deployment via snapshot url to Sonatype Portal configured");
+ List snapshotLocal = MavenBackingRepository.create(snapshotDir.toURI()
+ .toString(), reporter, localRepo, client);
+ snapshot.addAll(snapshotLocal);
+ sonatypeSnapshotUrl = snapshotUrl;
}
}
case NONE -> {
@@ -686,14 +699,14 @@ synchronized boolean init() {
release = MavenBackingRepository.create(releaseUrl, reporter, localRepo, client);
} else {
release = MavenBackingRepository.create(releaseUrl, reporter, localRepo, client);
- staging = MavenBackingRepository.getBackingRepository(configuration.stagingUrl(), reporter,
- localRepo, client);
+ staging = MavenBackingRepository.getBackingRepository(stagingUrl, reporter, localRepo, client);
+ }
+ if (snapshotUrl != null) {
+ snapshot = MavenBackingRepository.create(snapshotUrl, reporter, localRepo, client);
}
}
}
- List snapshot = MavenBackingRepository.create(configuration.snapshotUrl(), reporter,
- localRepo, client);
for (MavenBackingRepository mbr : release) {
if (mbr.isRemote()) {
@@ -713,8 +726,9 @@ synchronized boolean init() {
.executor(), reporter);
MavenRepository storageMvn = (MavenRepository) storage;
storageMvn.setSonatypeMode(sonatypeMode);
- if (sonatypeUrl != null) {
- storageMvn.setSonatypePublisherUrl(sonatypeUrl);
+ if (sonatypeReleaseUrl != null) {
+ storageMvn.setSonatypePublisherUrl(sonatypeReleaseUrl);
+ storageMvn.setSonatypePublishSnapshotUrl(sonatypeSnapshotUrl);
}
File indexFile = getIndexFile();
@@ -902,6 +916,7 @@ public String tooltip(Object... target) throws Exception {
.size());
f.format("Storage : %s\n", localRepo);
f.format("Index : %s\n", index.indexFile);
+ f.format("Format : %s\n", index.isPom ? "pom.xml" : "text");
f.format("Release repos : \n %s\n", storage.getReleaseRepositories()
.stream()
.filter(Objects::nonNull)
diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MbrUpdater.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MbrUpdater.java
index 2088088f91..048fe282fe 100644
--- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MbrUpdater.java
+++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MbrUpdater.java
@@ -3,7 +3,6 @@
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
-import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -14,7 +13,6 @@
import aQute.bnd.version.MavenVersion;
import aQute.bnd.version.Version;
import aQute.lib.collections.MultiMap;
-import aQute.lib.io.IO;
import aQute.lib.justif.Justif;
import aQute.maven.api.Archive;
@@ -121,65 +119,27 @@ String preview(Scope scope, Collection archives) throws Exception {
* @throws IOException
*/
public boolean update(Map map) throws IOException {
- StringBuilder sb = new StringBuilder();
- boolean changes = buildGAVString(sb, map);
- if (!changes)
- return false;
-
- repo.getIndexFile()
- .getParentFile()
- .mkdirs();
- IO.store(sb.toString(), repo.getIndexFile());
- return changes;
- }
-
- /**
- * @param sb will be written with a list of Maven GAVs like in a central.mvn
- * file
- * @param repo
- * @param translations
- * @return true when there were changes / updates, otherwise
- * false
- * @throws IOException
- */
- private boolean buildGAVString(StringBuilder sb, Map translations) throws IOException {
boolean changes = false;
- Iterator lc;
- if (repo.getIndexFile()
- .isFile()) {
- lc = IO.reader(repo.getIndexFile())
- .lines()
- .iterator();
- } else {
- lc = Collections.emptyIterator();
- }
-
- for (Iterator i = lc; i.hasNext();) {
- String line = i.next()
- .trim();
- if (!line.startsWith("#") && !line.isEmpty()) {
-
- Archive archive = Archive.valueOf(line);
- if (archive != null) {
- MavenVersion version = translations.get(archive)
- .mavenVersion();
- if (version != null) {
- if (!archive.revision.version.equals(version)) {
- Archive updated = archive.update(version);
- sb.append(updated)
- .append("\n");
- changes = true;
- continue;
- }
- }
+ for (Map.Entry e : map.entrySet()) {
+ Archive old = e.getKey();
+ MavenVersionResult result = e.getValue();
+ if (result.mavenVersionAvailable() && !old.revision.version.equals(result.mavenVersion())) {
+ Archive updated = old.update(result.mavenVersion());
+ try {
+ repo.index.replaceArchive(old, updated);
+ } catch (Exception ex) {
+ throw new IOException("Failed to update " + old + " to " + result.mavenVersion(), ex);
}
+ changes = true;
}
- sb.append(line)
- .append("\n");
}
return changes;
}
+ void convertTextXml() throws Exception {
+ repo.index.convertTextXml();
+ }
+
private String format(MultiMap overlap) {
Justif j = new Justif(140, 50, 60, 70, 80, 90, 100, 110);
j.formatter()
diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/POMIndexFile.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/POMIndexFile.java
new file mode 100644
index 0000000000..3f58070c34
--- /dev/null
+++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/POMIndexFile.java
@@ -0,0 +1,217 @@
+package aQute.bnd.repository.maven.provider;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamWriter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import aQute.lib.io.IO;
+import aQute.lib.strings.Strings;
+import aQute.lib.xml.XML;
+import aQute.maven.api.Archive;
+
+/**
+ * Helper for reading and writing pom.xml with GAV coordinates with
+ * {@link IndexFile}
+ */
+abstract class POMIndexFile {
+
+ private final static Logger logger = LoggerFactory.getLogger(POMIndexFile.class);
+
+ private POMIndexFile() {}
+
+ static Set readFromPom(File pomFile, Consumer statusSetter) throws Exception {
+ Document doc = XML.newDocumentBuilderFactory()
+ .newDocumentBuilder()
+ .parse(pomFile);
+ Set result = new HashSet<>();
+ NodeList deps = doc.getElementsByTagName("dependency");
+ for (int i = 0; i < deps.getLength(); i++) {
+ Node dep = deps.item(i);
+ if (dep.getNodeType() != Node.ELEMENT_NODE)
+ continue;
+ // Only process direct project/dependencies/dependency (skip
+ // dependencyManagement)
+ Node parent = dep.getParentNode();
+ if (parent == null || !"dependencies".equals(parent.getNodeName()))
+ continue;
+ Node grandparent = parent.getParentNode();
+ if (grandparent == null || !"project".equals(grandparent.getNodeName()))
+ continue;
+
+ Element depEl = (Element) dep;
+ String groupId = pomChildText(depEl, "groupId");
+ String artifactId = pomChildText(depEl, "artifactId");
+ String version = pomChildText(depEl, "version");
+ if (groupId == null || artifactId == null || version == null)
+ continue;
+
+ String type = pomChildText(depEl, "type");
+ if (type == null)
+ type = Archive.JAR_EXTENSION;
+ String classifier = pomChildText(depEl, "classifier");
+
+ Archive archive = Archive.valueOf(groupId, artifactId, version, type, classifier);
+ if (archive != null) {
+ result.add(archive);
+ } else {
+ statusSetter.accept("Invalid GAV in pom.xml index: " + groupId + ":" + artifactId + ":" + version);
+ }
+ }
+ logger.debug("loaded pom.xml index {}", result);
+ return result;
+ }
+
+ private static String pomChildText(Element element, String tagName) {
+ NodeList nl = element.getElementsByTagName(tagName);
+ if (nl.getLength() == 0)
+ return null;
+ String text = nl.item(0)
+ .getTextContent();
+ return text != null ? text.trim() : null;
+ }
+
+ static void savePom(File indexFile, List sortedArchives)
+ throws Exception {
+ // Read existing project-level metadata to preserve user values
+ String projectGroupId = "bnd.index";
+ String[] parts = Strings.extension(indexFile.getName());
+ String projectArtifactId = (parts != null) ? parts[0] : indexFile.getName();
+ String projectVersion = "1.0-SNAPSHOT";
+ String projectName = null;
+ String projectDescription = null;
+
+ if (indexFile.isFile() && indexFile.length() > 0) {
+ try {
+ Document doc = XML.newDocumentBuilderFactory()
+ .newDocumentBuilder()
+ .parse(indexFile);
+ Element root = doc.getDocumentElement();
+ String g = pomDirectChildText(root, "groupId");
+ String a = pomDirectChildText(root, "artifactId");
+ String v = pomDirectChildText(root, "version");
+ if (g != null)
+ projectGroupId = g;
+ if (a != null)
+ projectArtifactId = a;
+ if (v != null)
+ projectVersion = v;
+ projectName = pomDirectChildText(root, "name");
+ projectDescription = pomDirectChildText(root, "description");
+ } catch (Exception e) {
+ logger.debug("Could not read existing pom.xml metadata, using defaults", e);
+ }
+ }
+
+ // Ensure parent directory exists
+ File parent = indexFile.getParentFile();
+ if (parent != null && !parent.isDirectory()) {
+ IO.mkdirs(indexFile.getParentFile());
+ }
+
+ XMLOutputFactory xof = XMLOutputFactory.newFactory();
+ xof.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, Boolean.TRUE);
+
+ final String POM_NS = "http://maven.apache.org/POM/4.0.0";
+ final String XSI_NS = "http://www.w3.org/2001/XMLSchema-instance";
+
+ try (FileOutputStream fos = new FileOutputStream(indexFile)) {
+ XMLStreamWriter xsw = xof.createXMLStreamWriter(fos, "UTF-8");
+ try {
+ xsw.writeStartDocument("UTF-8", "1.0");
+ xsw.writeCharacters("\n");
+
+ xsw.setDefaultNamespace(POM_NS);
+ xsw.setPrefix("xsi", XSI_NS);
+ xsw.writeStartElement(POM_NS, "project");
+ xsw.writeDefaultNamespace(POM_NS);
+ xsw.writeNamespace("xsi", XSI_NS);
+ xsw.writeAttribute(XSI_NS, "schemaLocation", POM_NS + " http://maven.apache.org/xsd/maven-4.0.0.xsd");
+
+ writePomElement(xsw, POM_NS, "modelVersion", "4.0.0", 1);
+ writePomElement(xsw, POM_NS, "groupId", projectGroupId, 1);
+ writePomElement(xsw, POM_NS, "artifactId", projectArtifactId, 1);
+ writePomElement(xsw, POM_NS, "version", projectVersion, 1);
+ writePomElement(xsw, POM_NS, "packaging", "pom", 1);
+ if (projectName != null)
+ writePomElement(xsw, POM_NS, "name", projectName, 1);
+ if (projectDescription != null)
+ writePomElement(xsw, POM_NS, "description", projectDescription, 1);
+
+ if (!sortedArchives.isEmpty()) {
+ writePomIndent(xsw, 1);
+ xsw.writeStartElement(POM_NS, "dependencies");
+ for (Archive arch : sortedArchives) {
+ writePomIndent(xsw, 2);
+ xsw.writeStartElement(POM_NS, "dependency");
+ writePomElement(xsw, POM_NS, "groupId", arch.revision.program.group, 3);
+ writePomElement(xsw, POM_NS, "artifactId", arch.revision.program.artifact, 3);
+ writePomElement(xsw, POM_NS, "version", arch.revision.version.toString(), 3);
+ if (!Archive.JAR_EXTENSION.equals(arch.extension))
+ writePomElement(xsw, POM_NS, "type", arch.extension, 3);
+ if (!arch.classifier.isEmpty())
+ writePomElement(xsw, POM_NS, "classifier", arch.classifier, 3);
+ writePomIndent(xsw, 2);
+ xsw.writeEndElement(); // dependency
+ }
+ writePomIndent(xsw, 1);
+ xsw.writeEndElement(); // dependencies
+ }
+
+ xsw.writeCharacters("\n");
+ xsw.writeEndElement(); // project
+ xsw.writeCharacters("\n");
+ xsw.writeEndDocument();
+ } finally {
+ xsw.flush();
+ xsw.close();
+ }
+ }
+ }
+
+ private static void writePomElement(XMLStreamWriter xsw, String ns, String localName, String text, int depth)
+ throws XMLStreamException {
+ writePomIndent(xsw, depth);
+ xsw.writeStartElement(ns, localName);
+ xsw.writeCharacters(text);
+ xsw.writeEndElement();
+ }
+
+ private static void writePomIndent(XMLStreamWriter xsw, int depth) throws XMLStreamException {
+ xsw.writeCharacters("\n" + " ".repeat(depth));
+ }
+
+ /**
+ * Get the text content of the first direct child element with the given tag
+ * name. Unlike {@link #pomChildText} which uses
+ * {@code getElementsByTagName} (recursive), this method only searches
+ * immediate children, making it suitable for reading project-level metadata
+ * without accidentally picking up nested element values.
+ */
+ private static String pomDirectChildText(Element parent, String tagName) {
+ NodeList children = parent.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ Node child = children.item(i);
+ if (child.getNodeType() == Node.ELEMENT_NODE && tagName.equals(child.getNodeName())) {
+ String text = child.getTextContent();
+ return text != null ? text.trim() : null;
+ }
+ }
+ return null;
+ }
+
+
+}
diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/RepoActions.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/RepoActions.java
index 31ca07af53..aeec7caffd 100644
--- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/RepoActions.java
+++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/RepoActions.java
@@ -53,6 +53,9 @@ Map getRepoActions(final Clipboard clipboard) throws Exception
map.put("Update Revisions :: To higher MAJOR revision", () -> {
upgradeRevisions(major);
});
+ map.put("Convert :: Text <--> pom.xml", () -> {
+ convertTextXmlRevisions();
+ });
map.put("Update Revisions :: Dry run to clipboard - Update to higher MICRO revision", () -> {
clipboard.copy(preview(micro));
@@ -266,4 +269,13 @@ private void upgradeRevisions(Scope scope) {
}
}
+ private void convertTextXmlRevisions() {
+ try {
+ mbr.convertTextXml();
+ repo.refresh();
+ } catch (Exception e) {
+ throw Exceptions.duck(e);
+ }
+ }
+
}
diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/SonatypeMode.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/SonatypeMode.java
index 785c74fe41..6dfda1a402 100644
--- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/SonatypeMode.java
+++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/SonatypeMode.java
@@ -1,5 +1,6 @@
package aQute.bnd.repository.maven.provider;
+@Deprecated(forRemoval = true, since = "7.3.0")
public enum SonatypeMode {
/*
@@ -23,6 +24,7 @@ public enum SonatypeMode {
this.value = value;
}
+ @Deprecated
@Override
public String toString() {
return value;
diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/package-info.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/package-info.java
index 310d12c19c..6914d20e28 100644
--- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/package-info.java
+++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/package-info.java
@@ -1,4 +1,4 @@
-@Version("2.2.0")
+@Version("2.3.0")
package aQute.bnd.repository.maven.provider;
import org.osgi.annotation.versioning.Version;
diff --git a/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/P2Indexer.java b/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/P2Indexer.java
index 9fa90d1eb0..db62ffc3ab 100644
--- a/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/P2Indexer.java
+++ b/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/P2Indexer.java
@@ -6,7 +6,9 @@
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
@@ -16,6 +18,7 @@
import java.util.SortedSet;
import java.util.concurrent.TimeUnit;
+import org.osgi.framework.namespace.IdentityNamespace;
import org.osgi.resource.Capability;
import org.osgi.resource.Requirement;
import org.osgi.resource.Resource;
@@ -43,6 +46,7 @@
import aQute.p2.api.Artifact;
import aQute.p2.api.ArtifactProvider;
import aQute.p2.packed.Unpack200;
+import aQute.p2.provider.Feature;
import aQute.p2.provider.P2Impl;
import aQute.p2.provider.TargetImpl;
import aQute.service.reporter.Reporter;
@@ -94,7 +98,14 @@ private void validate() {
File get(String bsn, Version version, Map properties, DownloadListener... listeners)
throws Exception {
- Resource resource = getBridge().get(bsn, version);
+ String requestedType = properties != null
+ ? properties.get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE)
+ : null;
+
+ Resource resource = findResource(bsn, version, requestedType);
+ if (resource == null) {
+ resource = getBridge().get(bsn, version);
+ }
if (resource == null)
return null;
@@ -123,6 +134,34 @@ File get(String bsn, Version version, Map properties, DownloadLi
return link;
}
+ private Resource findResource(String bsn, Version version, String requestedType) {
+ if (!(getBridge().getRepository() instanceof ResourcesRepository resourcesRepo)) {
+ return null;
+ }
+
+ org.osgi.framework.Version osgiVersion = org.osgi.framework.Version.parseVersion(version.toString());
+ for (Resource resource : resourcesRepo.getResources()) {
+ for (Capability identity : resource.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE)) {
+ Object id = identity.getAttributes()
+ .get(IdentityNamespace.IDENTITY_NAMESPACE);
+ Object capVersion = identity.getAttributes()
+ .get(IdentityNamespace.CAPABILITY_VERSION_ATTRIBUTE);
+ Object capType = identity.getAttributes()
+ .get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE);
+
+ if (!bsn.equals(id) || !osgiVersion.equals(capVersion)) {
+ continue;
+ }
+ if (requestedType != null && !requestedType.equals(capType)) {
+ continue;
+ }
+ return resource;
+ }
+ }
+
+ return null;
+ }
+
List list(String pattern) throws Exception {
return getBridge().list(pattern);
}
@@ -145,13 +184,12 @@ private ResourcesRepository readRepository(File index) throws Exception {
private ResourcesRepository readRepository() throws Exception {
ArtifactProvider p2;
- if (this.url.getPath()
- .endsWith(".target"))
+ if (isTargetPlatform(this.url))
p2 = new TargetImpl(processor, client, this.url, promiseFactory);
else
p2 = new P2Impl(processor, client, this.url, promiseFactory);
- List artifacts = p2.getBundles();
+ List artifacts = p2.getAllArtifacts();
Set visitedArtifacts = new HashSet<>(artifacts.size());
Set visitedURIs = new HashSet<>(artifacts.size());
@@ -166,11 +204,7 @@ private ResourcesRepository readRepository() throws Exception {
}
Promise fetched = fetch(a, 2, 1000L)
.map(tag -> processor.unpackAndLinkIfNeeded(tag, null))
- .map(file -> {
- ResourceBuilder rb = new ResourceBuilder();
- rb.addFile(file, a.uri);
- return rb.build();
- })
+ .map(file -> processArtifact(a, file))
.recover(failed -> {
logger.info("{}: Failed to create resource for {}", name, a.uri, failed.getFailure());
return RECOVERY;
@@ -187,6 +221,25 @@ private ResourcesRepository readRepository() throws Exception {
.getValue();
}
+ static boolean isTargetPlatform(URI repositoryUri) {
+ String path = repositoryUri.getPath();
+ if (path == null) {
+ path = repositoryUri.getSchemeSpecificPart();
+ }
+ if (path == null) {
+ return false;
+ }
+ int queryIndex = path.indexOf('?');
+ if (queryIndex >= 0) {
+ path = path.substring(0, queryIndex);
+ }
+ int fragmentIndex = path.indexOf('#');
+ if (fragmentIndex >= 0) {
+ path = path.substring(0, fragmentIndex);
+ }
+ return path.endsWith(".target");
+ }
+
private Promise fetch(Artifact a, int retries, long delay) {
return client.build()
.useCache(MAX_STALE)
@@ -241,6 +294,49 @@ private void checkDownload(Artifact a, TaggedData tag) throws Exception {
}
}
+ /**
+ * Process an artifact (bundle or feature) and convert it to an OSGi
+ * Resource
+ */
+ private SupportingResource processArtifact(Artifact artifact, File file) throws Exception {
+ ResourceBuilder rb = new ResourceBuilder();
+
+ if (artifact.classifier == aQute.p2.api.Classifier.FEATURE) {
+ // Process feature: parse it and add capabilities/requirements
+ try (java.io.InputStream in = IO.stream(file)) {
+ aQute.p2.provider.Feature feature = new aQute.p2.provider.Feature(in);
+ feature.parse();
+ Resource featureResource = feature.toResource();
+
+ // Copy all capabilities and requirements from the feature resource
+ for (Capability cap : featureResource.getCapabilities(null)) {
+ aQute.bnd.osgi.resource.CapReqBuilder cb = new aQute.bnd.osgi.resource.CapReqBuilder(
+ cap.getNamespace());
+ cap.getAttributes()
+ .forEach(cb::addAttribute);
+ cap.getDirectives()
+ .forEach(cb::addDirective);
+ rb.addCapability(cb);
+ }
+
+ for (Requirement req : featureResource.getRequirements(null)) {
+ aQute.bnd.osgi.resource.CapReqBuilder crb = new aQute.bnd.osgi.resource.CapReqBuilder(
+ req.getNamespace());
+ req.getAttributes()
+ .forEach(crb::addAttribute);
+ req.getDirectives()
+ .forEach(crb::addDirective);
+ rb.addRequirement(crb);
+ }
+ }
+ }
+
+ // Add content capability for the artifact (bundle or feature JAR)
+ rb.addFile(file, artifact.uri);
+
+ return rb.build();
+ }
+
private ResourcesRepository save(ResourcesRepository repository) throws IOException, Exception {
XMLResourceGenerator xrg = new XMLResourceGenerator();
xrg.repository(repository)
@@ -269,4 +365,248 @@ void reread() throws Exception {
indexFile.delete();
init();
}
+
+ /**
+ * Get all features available in this P2 repository.
+ *
+ * @return a list of features, or empty list if none available
+ * @throws Exception if an error occurs while fetching features
+ */
+ public List getFeatures() throws Exception {
+ List features = new ArrayList<>();
+
+ // IMPORTANT: We must get resources directly from the repository's raw list.
+ // BridgeRepository uses a Map which drops duplicate entries.
+ // P2 repositories can have BOTH a bundle AND a feature with the same ID/version.
+ aQute.bnd.osgi.repository.ResourcesRepository resourcesRepo =
+ (aQute.bnd.osgi.repository.ResourcesRepository) getBridge().getRepository();
+ List allResources = resourcesRepo.getResources();
+
+ // Filter resources with type=org.eclipse.update.feature in identity capability
+ for (Resource resource : allResources) {
+ List identities = resource.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE);
+ for (Capability identity : identities) {
+ Object type = identity.getAttributes().get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE);
+ if ("org.eclipse.update.feature".equals(type)) {
+ String featureId = (String) identity.getAttributes().get(IdentityNamespace.IDENTITY_NAMESPACE);
+ logger.debug("Processing feature resource: {}", featureId);
+
+ // Try to extract the full feature from the JAR
+ Feature feature = extractFeatureFromResource(resource);
+ if (feature == null) {
+ // If extraction fails, create a minimal feature from resource metadata
+ logger.debug("Full extraction failed for {}, creating minimal feature", featureId);
+ feature = createMinimalFeatureFromResource(resource);
+ if (feature == null) {
+ logger.debug("Minimal feature creation also failed for {}", featureId);
+ }
+ }
+ if (feature != null) {
+ features.add(feature);
+ logger.debug("Added feature: {} version {}", feature.getId(), feature.getVersion());
+ }
+ break;
+ }
+ }
+ }
+
+ return features;
+ }
+
+ /**
+ * Get a specific feature by ID and version.
+ *
+ * @param id the feature ID
+ * @param version the feature version
+ * @return the feature, or null if not found
+ * @throws Exception if an error occurs while fetching the feature
+ */
+ public Feature getFeature(String id, String version) throws Exception {
+ // Get all resources from the repository
+ org.osgi.service.repository.Repository repository = getBridge().getRepository();
+
+ // Create a wildcard requirement to find all identity capabilities
+ aQute.bnd.osgi.resource.RequirementBuilder rb = new aQute.bnd.osgi.resource.RequirementBuilder(
+ IdentityNamespace.IDENTITY_NAMESPACE);
+ Requirement req = rb.buildSyntheticRequirement();
+
+ // Find all providers
+ Map> providers = repository.findProviders(
+ java.util.Collections.singleton(req));
+ Collection allCaps = providers.get(req);
+
+ if (allCaps == null || allCaps.isEmpty()) {
+ return null;
+ }
+
+ // Get unique resources
+ Set allResources = aQute.bnd.osgi.resource.ResourceUtils.getResources(allCaps);
+
+ org.osgi.framework.Version requestedVersion = org.osgi.framework.Version.parseVersion(version);
+
+ // Find the matching feature resource
+ for (Resource resource : allResources) {
+ List identities = resource.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE);
+ for (Capability identity : identities) {
+ Object type = identity.getAttributes().get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE);
+ Object idAttr = identity.getAttributes().get(IdentityNamespace.IDENTITY_NAMESPACE);
+ Object versionAttr = identity.getAttributes().get(IdentityNamespace.CAPABILITY_VERSION_ATTRIBUTE);
+
+ if ("org.eclipse.update.feature".equals(type) &&
+ id.equals(idAttr) &&
+ requestedVersion.equals(versionAttr)) {
+ return extractFeatureFromResource(resource);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract a Feature object from a Resource by downloading and parsing the
+ * feature JAR.
+ *
+ * @param resource the resource representing the feature
+ * @return the parsed Feature, or null if extraction fails
+ */
+ private Feature extractFeatureFromResource(Resource resource) {
+ try {
+ // Get the content capability to find the JAR location
+ ContentCapability contentCapability = ResourceUtils.getContentCapability(resource);
+ if (contentCapability == null) {
+ logger.debug("Feature resource has no content capability, skipping");
+ return null;
+ }
+
+ URI uri = contentCapability.url();
+ logger.debug("Extracting feature from URI: {}", uri);
+
+ // Download and get TaggedData
+ TaggedData tag = client.build()
+ .useCache(MAX_STALE)
+ .get()
+ .asTag()
+ .go(uri);
+
+ logger.debug("Downloaded feature JAR, state: {}, file: {}", tag.getState(), tag.getFile());
+
+ // Try to unpack if it's a pack.gz or similar format
+ File featureFile = processor.unpackAndLinkIfNeeded(tag, null);
+ logger.debug("Unpacked feature file: {}", featureFile);
+
+ // Parse the feature.xml from the JAR
+ Feature feature = parseFeatureFromJar(featureFile);
+ if (feature != null) {
+ logger.debug("Successfully extracted feature: {} version {}", feature.getId(), feature.getVersion());
+ } else {
+ logger.debug("Failed to parse feature from file: {}", featureFile);
+ }
+ return feature;
+
+ } catch (Exception e) {
+ logger.debug("Failed to extract feature from resource: {}", e.getMessage(), e);
+ return null;
+ }
+ }
+
+ /**
+ * Parse a Feature from a feature JAR file.
+ *
+ * @param jarFile the feature JAR file (may be .jar, .zip, or .content from cache)
+ * @return the parsed Feature, or null if parsing fails
+ */
+ private Feature parseFeatureFromJar(File jarFile) {
+ // Check if file exists and is readable
+ if (!jarFile.exists() || !jarFile.canRead()) {
+ logger.debug("Feature file not accessible: {}", jarFile);
+ return null;
+ }
+
+ try (InputStream in = IO.stream(jarFile)) {
+ Feature feature = new Feature(in);
+ feature.parse();
+ return feature;
+ } catch (Exception e) {
+ logger.debug("Failed to parse feature from {}: {}", jarFile, e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Create a minimal Feature object from resource metadata when full extraction fails.
+ * This ensures features are visible in the repository even if their JARs can't be downloaded/parsed.
+ *
+ * @param resource the resource representing the feature
+ * @return a minimal Feature with id, version, label, and provider, or null if metadata is insufficient
+ */
+ private Feature createMinimalFeatureFromResource(Resource resource) {
+ try {
+ // Extract metadata from identity capability
+ String id = null;
+ String version = null;
+ String label = null;
+ String providerName = null;
+
+ List identities = resource.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE);
+ for (Capability identity : identities) {
+ Map attrs = identity.getAttributes();
+ Object typeAttr = attrs.get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE);
+ if ("org.eclipse.update.feature".equals(typeAttr)) {
+ id = (String) attrs.get(IdentityNamespace.IDENTITY_NAMESPACE);
+ Object versionObj = attrs.get(IdentityNamespace.CAPABILITY_VERSION_ATTRIBUTE);
+ if (versionObj instanceof org.osgi.framework.Version) {
+ version = versionObj.toString();
+ } else if (versionObj != null) {
+ version = versionObj.toString();
+ }
+ label = (String) attrs.get("label");
+ providerName = (String) attrs.get("provider-name");
+ break;
+ }
+ }
+
+ if (id == null || version == null) {
+ logger.debug("Cannot create minimal feature: missing id or version");
+ return null;
+ }
+
+ // Create a minimal XML document for the Feature
+ String xml = String.format(
+ "\n" +
+ "\n" +
+ "",
+ escapeXml(id),
+ escapeXml(version),
+ label != null ? " label=\"" + escapeXml(label) + "\"" : "",
+ providerName != null ? " provider-name=\"" + escapeXml(providerName) + "\"" : ""
+ );
+
+ javax.xml.parsers.DocumentBuilderFactory dbf = aQute.lib.xml.XML.newDocumentBuilderFactory();
+ javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder();
+ org.w3c.dom.Document doc = db.parse(new java.io.ByteArrayInputStream(xml.getBytes("UTF-8")));
+
+ Feature feature = new Feature(doc);
+ feature.parse();
+
+ logger.debug("Created minimal feature from metadata: {} version {}", id, version);
+ return feature;
+
+ } catch (Exception e) {
+ logger.debug("Failed to create minimal feature from resource metadata: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Escape XML special characters
+ */
+ private String escapeXml(String text) {
+ if (text == null) return "";
+ return text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
}
diff --git a/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/P2Repository.java b/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/P2Repository.java
index 44bc2056a9..fd724790e7 100644
--- a/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/P2Repository.java
+++ b/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/P2Repository.java
@@ -31,6 +31,7 @@
import aQute.lib.converter.Converter;
import aQute.lib.io.IO;
import aQute.p2.packed.Unpack200;
+import aQute.p2.provider.Feature;
import aQute.service.reporter.Reporter;
/**
@@ -196,5 +197,26 @@ public String title(Object... target) throws Exception {
return null;
}
+ /**
+ * Get all features available in this P2 repository.
+ *
+ * @return a list of features, or empty list if none available
+ * @throws Exception if an error occurs while fetching features
+ */
+ public List getFeatures() throws Exception {
+ return getP2Index().getFeatures();
+ }
+
+ /**
+ * Get a specific feature by ID and version.
+ *
+ * @param id the feature ID
+ * @param version the feature version
+ * @return the feature, or null if not found
+ * @throws Exception if an error occurs while fetching the feature
+ */
+ public Feature getFeature(String id, String version) throws Exception {
+ return getP2Index().getFeature(id, version);
+ }
}
diff --git a/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/package-info.java b/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/package-info.java
index 864b955a3d..3612981f2d 100644
--- a/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/package-info.java
+++ b/biz.aQute.repository/src/aQute/bnd/repository/p2/provider/package-info.java
@@ -1,4 +1,4 @@
-@Version("1.5.0")
+@Version("1.6.0")
package aQute.bnd.repository.p2.provider;
import org.osgi.annotation.versioning.Version;
diff --git a/biz.aQute.repository/src/aQute/maven/provider/MavenFileRepository.java b/biz.aQute.repository/src/aQute/maven/provider/MavenFileRepository.java
index 2681c398b3..5f3bbda530 100644
--- a/biz.aQute.repository/src/aQute/maven/provider/MavenFileRepository.java
+++ b/biz.aQute.repository/src/aQute/maven/provider/MavenFileRepository.java
@@ -7,6 +7,10 @@
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -19,7 +23,7 @@
public class MavenFileRepository extends MavenBackingRepository {
- private final File remote;
+ private final File remote;
private HttpClient client = null;
public MavenFileRepository(File localRepo, File remote, Reporter reporter) throws Exception {
@@ -89,62 +93,160 @@ public boolean isRemote() {
return false;
}
- /**
- * Creates a ZIP archive containing all files from the remote repository directory.
- *
- * @param outputFile the destination file for the ZIP archive
- * @return the created ZIP file
- * @throws IOException if an error occurs during ZIP creation
- */
- public File createZipArchive(File outputFile) throws IOException {
- if (!remote.exists() || !remote.isDirectory()) {
- throw new IllegalStateException("Remote directory does not exist or is not a directory: " + remote);
- }
-
- IO.mkdirs(outputFile.getParentFile());
-
- try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(outputFile))) {
- Path remotePath = remote.toPath();
-
- Files.walk(remotePath)
- .filter(path -> !Files.isDirectory(path))
- .forEach(path -> {
- try {
- Path relativePath = remotePath.relativize(path);
- ZipEntry zipEntry = new ZipEntry(relativePath.toString().replace("\\", "/"));
-
- zos.putNextEntry(zipEntry);
-
- try (FileInputStream fis = new FileInputStream(path.toFile())) {
- byte[] buffer = new byte[4096];
- int len;
- while ((len = fis.read(buffer)) > 0) {
- zos.write(buffer, 0, len);
- }
- }
-
- zos.closeEntry();
- } catch (IOException e) {
- reporter.error("Error adding file %s to ZIP archive: %s", path, e.getMessage());
- }
- });
- }
-
- return outputFile;
- }
-
- /**
- * Creates a ZIP archive containing all files from the remote repository directory.
- * The ZIP file will be created in the system temp directory with a generated name.
- *
- * @return the created ZIP file
- * @throws IOException if an error occurs during ZIP creation
- */
- public File createZipArchive() throws IOException {
- File tempFile = File.createTempFile("sonatype-bundle-", ".zip");
- tempFile.deleteOnExit();
- return createZipArchive(tempFile);
- }
+ /**
+ * Discovers all groupIds in the Maven repository by analyzing the directory
+ * structure.
+ *
+ * @return map of groupId to its directory path
+ * @throws IOException if an error occurs reading the directory
+ */
+ private Map discoverGroupIds() throws IOException {
+ Map groupIds = new LinkedHashMap<>();
+ if (!remote.exists() || !remote.isDirectory()) {
+ return groupIds;
+ }
+
+ // Walk the directory tree to find all groupIds
+ Files.walk(remote.toPath())
+ .filter(Files::isDirectory)
+ .forEach(dir -> {
+ File dirFile = dir.toFile();
+ File[] children = dirFile.listFiles();
+ if (children != null && children.length > 0) {
+ // Look for artifact directories (contain version
+ // subdirectories with artifacts)
+ for (File child : children) {
+ if (child.isDirectory()) {
+ File[] versionDirs = child.listFiles();
+ if (versionDirs != null) {
+ for (File versionDir : versionDirs) {
+ if (versionDir.isDirectory() && containsArtifacts(versionDir)) {
+ // Found a groupId directory
+ Path relativePath = remote.toPath()
+ .relativize(dir);
+ String groupId = relativePath.toString()
+ .replace(File.separatorChar, '.');
+ if (!groupId.isEmpty() && !groupIds.containsKey(groupId)) {
+ groupIds.put(groupId, dirFile);
+ }
+ return;
+ }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ return groupIds;
+ }
+
+ /**
+ * Checks if a directory contains Maven artifacts (JAR, POM, WAR, AAR
+ * files).
+ */
+ private boolean containsArtifacts(File dir) {
+ File[] files = dir.listFiles();
+ if (files == null) {
+ return false;
+ }
+ for (File file : files) {
+ if (file.isFile()) {
+ String name = file.getName();
+ if (name.endsWith(".jar") || name.endsWith(".pom") || name.endsWith(".war") || name.endsWith(".aar")) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Creates ZIP archives, one for each Maven namespace/groupId in the
+ * repository. Each archive contains all files for that specific groupId.
+ *
+ * @return list of created ZIP files with groupId information
+ * @throws IOException if an error occurs during ZIP creation
+ */
+ public List createZipArchive() throws IOException {
+ if (!remote.exists() || !remote.isDirectory()) {
+ throw new IllegalStateException("Remote directory does not exist or is not a directory: " + remote);
+ }
+
+ List archives = new ArrayList<>();
+ Map groupIds = discoverGroupIds();
+
+ if (groupIds.isEmpty()) {
+ reporter.warning("No groupIds found in repository: %s", remote);
+ return archives;
+ }
+
+ reporter.trace("Found %d groupId(s) in repository: %s", groupIds.size(), groupIds.keySet());
+
+ // Create a separate ZIP archive for each groupId
+ for (Map.Entry entry : groupIds.entrySet()) {
+ String groupId = entry.getKey();
+ File groupDir = entry.getValue();
+
+ String sanitizedGroupId = groupId.replace('.', '_');
+ File tempFile = File.createTempFile("sonatype-bundle-" + sanitizedGroupId + "-", ".zip");
+ tempFile.deleteOnExit();
+
+ try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(tempFile))) {
+ Path groupDirPath = groupDir.toPath();
+ Path remotePath = remote.toPath();
+
+ Files.walk(groupDirPath)
+ .filter(path -> !Files.isDirectory(path))
+ .forEach(path -> {
+ try {
+ // Use path relative to remote root (includes
+ // groupId path)
+ Path relativePath = remotePath.relativize(path);
+ ZipEntry zipEntry = new ZipEntry(relativePath.toString()
+ .replace("\\", "/"));
+
+ zos.putNextEntry(zipEntry);
+
+ try (FileInputStream fis = new FileInputStream(path.toFile())) {
+ byte[] buffer = new byte[4096];
+ int len;
+ while ((len = fis.read(buffer)) > 0) {
+ zos.write(buffer, 0, len);
+ }
+ }
+
+ zos.closeEntry();
+ } catch (IOException e) {
+ reporter.error("Error adding file %s to ZIP archive for groupId %s: %s", path, groupId,
+ e.getMessage());
+ }
+ });
+ }
+
+ archives.add(new GroupIdArchive(groupId, tempFile));
+ reporter.trace("Created ZIP archive for groupId %s: %s", groupId, tempFile);
+ }
+
+ return archives;
+ }
+
+ /**
+ * Container class for a groupId and its corresponding archive file.
+ */
+ public static class GroupIdArchive {
+ public final String groupId;
+ public final File archiveFile;
+
+ public GroupIdArchive(String groupId, File archiveFile) {
+ this.groupId = groupId;
+ this.archiveFile = archiveFile;
+ }
+
+ public String getSanitizedGroupId() {
+ return groupId.replace('.', '_');
+ }
+ }
protected HttpClient getClient() {
return client;
diff --git a/biz.aQute.repository/src/aQute/maven/provider/MavenRepository.java b/biz.aQute.repository/src/aQute/maven/provider/MavenRepository.java
index ad29983392..226afa16fa 100644
--- a/biz.aQute.repository/src/aQute/maven/provider/MavenRepository.java
+++ b/biz.aQute.repository/src/aQute/maven/provider/MavenRepository.java
@@ -48,7 +48,8 @@ public class MavenRepository implements IMavenRepo, Closeable {
private final Map> poms = new WeakHashMap<>();
private final Reporter reporter;
private SonatypeMode sonatypeMode = SonatypeMode.NONE;
- private String sonatypeUrl = null;
+ private String sonatypeReleaseUrl = null;
+ private String sonatypeSnapshotUrl = null;
public MavenRepository(File base, String id, List release,
List snapshot, Executor executor, Reporter reporter) throws Exception {
@@ -418,11 +419,20 @@ public SonatypeMode getSonatypeMode() {
return sonatypeMode;
}
- public void setSonatypePublisherUrl(String sonatypeUrl) {
- this.sonatypeUrl = sonatypeUrl;
+ public void setSonatypePublisherUrl(String sonatypeReleaseUrl) {
+ this.sonatypeReleaseUrl = sonatypeReleaseUrl;
}
public String getSonatypePublisherUrl() {
- return sonatypeUrl;
+ return sonatypeReleaseUrl;
}
+
+ public void setSonatypePublishSnapshotUrl(String sonatypeSnapshotUrl) {
+ this.sonatypeSnapshotUrl = sonatypeSnapshotUrl;
+ }
+
+ public String getSonatypePublishSnapshotUrl() {
+ return sonatypeSnapshotUrl;
+ }
+
}
diff --git a/biz.aQute.repository/src/aQute/maven/provider/Releaser.java b/biz.aQute.repository/src/aQute/maven/provider/Releaser.java
index 867abe087e..050fd34baf 100644
--- a/biz.aQute.repository/src/aQute/maven/provider/Releaser.java
+++ b/biz.aQute.repository/src/aQute/maven/provider/Releaser.java
@@ -92,7 +92,7 @@ public void close() throws IOException {
if (Boolean.parseBoolean(isStartSonatypePublish)) {
switch (home.getSonatypeMode()) {
case NONE -> logger.info("Sonatype mode is 'none', nothing to do");
- case MANUAL, AUTOPUBLISH -> prepareSonatypeUpload();
+ case MANUAL, AUTOPUBLISH -> prepareSonatypeUpload(localMetadata.version.isSnapshot());
}
}
home.clear(revision);
@@ -104,27 +104,64 @@ public void close() throws IOException {
}
}
- private void prepareSonatypeUpload() throws IOException, Exception {
- MavenBackingRepository mbr = home.getStagingRepository();
- publisherUrl = home.getSonatypePublisherUrl();
- if (mbr == null) {
- List releaseRepositories = home.getReleaseRepositories();
- if (!releaseRepositories.isEmpty()) {
- mbr = releaseRepositories.get(0);
+ private void prepareSonatypeUpload(boolean isSnapshot) throws IOException, Exception {
+ MavenBackingRepository mbr = null;
+
+ List releaseRepositories = new ArrayList();
+ if (isSnapshot) {
+ publisherUrl = normalize(home.getSonatypePublishSnapshotUrl());
+ releaseRepositories = home.getSnapshotRepositories();
+ } else {
+ publisherUrl = normalize(home.getSonatypePublisherUrl());
+ mbr = home.getStagingRepository();
+ if (mbr != null) {
+ releaseRepositories.add(mbr);
} else {
- throw new IllegalStateException("No release repository configured for Sonatype upload");
+ releaseRepositories = home.getReleaseRepositories();
}
}
- logger.info("Creating and uploading deployment bundle for Sonatype Central Portal");
+ if (releaseRepositories.isEmpty()) {
+ throw new IllegalStateException("No release/snapshot repository configured for Sonatype upload");
+ } else {
+ mbr = releaseRepositories.get(0);
+ }
+
+ logger.info("Creating and uploading deployment bundles for Sonatype Central Portal");
MavenFileRepository mfr = (MavenFileRepository) mbr;
client = mfr.getClient();
- File deploymentBundle = mfr.createZipArchive();
- uploadToPortal(deploymentBundle);
- File deploymentIdFile = Files.createTempFile("deploymentid", ".txt")
- .toFile();
- Files.writeString(deploymentIdFile.toPath(), deploymentId, StandardOpenOption.CREATE);
- deploymentIdFile.deleteOnExit();
- mbr.store(deploymentIdFile, MavenBndRepository.SONATYPE_DEPLOYMENTID_FILE);
+
+ // Create archives for each groupId
+ List archives = mfr.createZipArchive();
+ if (archives.isEmpty()) {
+ throw new IllegalStateException("No groupIds found in staging repository");
+ }
+
+ logger.info("Found {} groupId(s) to upload", archives.size());
+
+ // Upload each archive separately
+ for (MavenFileRepository.GroupIdArchive archive : archives) {
+ logger.info("Processing groupId: {}", archive.groupId);
+ uploadToPortal(archive.archiveFile);
+
+ // Store deployment ID file with groupId in filename
+ String sanitizedGroupId = archive.getSanitizedGroupId();
+ File deploymentIdFile = Files.createTempFile(sanitizedGroupId + "_deploymentid", ".txt")
+ .toFile();
+ Files.writeString(deploymentIdFile.toPath(), deploymentId, StandardOpenOption.CREATE);
+ deploymentIdFile.deleteOnExit();
+
+ String deploymentIdPath = sanitizedGroupId + "_" + MavenBndRepository.SONATYPE_DEPLOYMENTID_FILE;
+ mbr.store(deploymentIdFile, deploymentIdPath);
+ logger.info("Completed upload for groupId: {} with deployment ID: {}", archive.groupId, deploymentId);
+ }
+ }
+
+ private String normalize(String sonatypePublisherUrl) {
+ String uploadUrl = sonatypePublisherUrl;
+ if (uploadUrl.endsWith("/")) {
+ uploadUrl = uploadUrl.substring(0, uploadUrl.length() - 1);
+ }
+ return uploadUrl;
}
protected RevisionMetadata localMetadata() {
@@ -309,13 +346,12 @@ public void setPassphrase(String passphrase) {
private void uploadToPortal(File deploymentBundle) throws Exception {
logger.info("Uploading deployment bundle to Sonatype Central Portal...");
- String uploadUrl = publisherUrl + UPLOAD_ENDPOINT;
try {
String boundary = "----WebKitFormBoundary" + System.currentTimeMillis();
File multipartForm = createMultipartForm(deploymentBundle, boundary);
- logger.debug("Upload details: URL={}, Bundle size={} bytes, Multipart size={} bytes", uploadUrl,
+ logger.debug("Upload details: URL={}, Bundle size={} bytes, Multipart size={} bytes", publisherUrl,
deploymentBundle.length(), multipartForm.length());
StringJoiner urlQueryParamJoiner = new StringJoiner("&", "?", "");
@@ -323,7 +359,8 @@ private void uploadToPortal(File deploymentBundle) throws Exception {
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss.SSS");
String msg = String.format("uploaded from bnd on %s", LocalDateTime.now()
.format(dtf));
- String encodedMsg = URLEncoder.encode(msg, StandardCharsets.UTF_8)
+ String sonatypeDesc = System.getProperty("bnd.sonatype.release.description", msg);
+ String encodedMsg = URLEncoder.encode(sonatypeDesc, StandardCharsets.UTF_8)
.replace("+", "%20");
String paramName = !encodedMsg.isEmpty() ? "name=" + encodedMsg : "";
urlQueryParamJoiner.add(paramName);
@@ -339,7 +376,7 @@ private void uploadToPortal(File deploymentBundle) throws Exception {
.upload(multipartForm)
.post()
.asTag()
- .go(new URI(uploadUrl + urlQueryParamJoiner.toString()).toURL());
+ .go(new URI(publisherUrl + urlQueryParamJoiner.toString()).toURL());
if (taggedData.isOk()) {
deploymentId = IO.collect(taggedData.getInputStream());
@@ -396,7 +433,9 @@ private boolean checkDeploymentStatus(String deploymentId) throws Exception {
return false;
}
- String statusUrl = publisherUrl + STATUS_ENDPOINT + "?id=" + deploymentId;
+ String statusEndpointUrl = publisherUrl.substring(0, publisherUrl.length() - UPLOAD_ENDPOINT.length())
+ + STATUS_ENDPOINT;
+ String statusUrl = statusEndpointUrl + "?id=" + deploymentId;
try {
var taggedData = client.build()
diff --git a/biz.aQute.repository/src/aQute/maven/provider/packageinfo b/biz.aQute.repository/src/aQute/maven/provider/packageinfo
index 5d7711a0af..e4836f9d19 100644
--- a/biz.aQute.repository/src/aQute/maven/provider/packageinfo
+++ b/biz.aQute.repository/src/aQute/maven/provider/packageinfo
@@ -1 +1 @@
-version 2.7
+version 2.8
diff --git a/biz.aQute.repository/src/aQute/p2/export/P2Export.java b/biz.aQute.repository/src/aQute/p2/export/P2Export.java
index 555b03386f..0d51a2e5f6 100644
--- a/biz.aQute.repository/src/aQute/p2/export/P2Export.java
+++ b/biz.aQute.repository/src/aQute/p2/export/P2Export.java
@@ -959,6 +959,7 @@ private BundleId getFeatureId(Processor definition) {
}
}
+ version = Builder.doSnapshot(version, definition.get(Constants.SNAPSHOT));
return getBundleId(featureName, version);
}
diff --git a/biz.aQute.repository/src/aQute/p2/provider/Feature.java b/biz.aQute.repository/src/aQute/p2/provider/Feature.java
index a6e58ab57b..8e3f3abefe 100644
--- a/biz.aQute.repository/src/aQute/p2/provider/Feature.java
+++ b/biz.aQute.repository/src/aQute/p2/provider/Feature.java
@@ -2,23 +2,44 @@
import java.io.InputStream;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Properties;
import javax.xml.parsers.DocumentBuilder;
import org.osgi.framework.Version;
+import org.osgi.framework.namespace.IdentityNamespace;
+import org.osgi.resource.Resource;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import aQute.bnd.osgi.Jar;
-import aQute.bnd.osgi.Resource;
+import aQute.bnd.osgi.resource.CapReqBuilder;
+import aQute.bnd.osgi.resource.ResourceBuilder;
+/**
+ * Parser for Eclipse feature.xml files. This class parses Eclipse features and
+ * creates OSGi Resource representations with capabilities and requirements in
+ * the osgi.identity namespace with type=org.eclipse.update.feature.
+ */
public class Feature extends XMLBase {
+ /**
+ * Represents a plugin reference in a feature
+ */
public static class Plugin {
public String id;
- public Version version;
+ public String version;
+ public boolean unpack;
+ public boolean fragment;
+ public String os;
+ public String ws;
+ public String arch;
+ public String download_size;
+ public String install_size;
@Override
public String toString() {
@@ -26,29 +47,166 @@ public String toString() {
}
}
- List plugins = new ArrayList<>();
+ /**
+ * Represents an included feature reference
+ */
+ public static class Includes {
+ public String id;
+ public String version;
+ public boolean optional;
+ public String os;
+ public String ws;
+ public String arch;
+
+ @Override
+ public String toString() {
+ return id + ":" + version + (optional ? " (optional)" : "");
+ }
+ }
+
+ /**
+ * Represents a required feature or plugin
+ */
+ public static class Requires {
+ public String plugin;
+ public String feature;
+ public String version;
+ public String match;
+
+ @Override
+ public String toString() {
+ return (plugin != null ? "plugin:" + plugin : "feature:" + feature) + ":" + version;
+ }
+ }
+
+ // Feature properties from feature.xml root element
+ public String id;
+ public String label;
+ public String version;
+ public String providerName;
+ public String plugin;
+ public String image;
+ public String application;
+ public String os;
+ public String ws;
+ public String arch;
+ public String nl;
+ public String colocationAffinity;
+ public String primary;
+ public String exclusive;
+ public String licenseFeature;
+ public String licenseFeatureVersion;
+
+ // Child elements
+ private List plugins = new ArrayList<>();
+ private List includes = new ArrayList<>();
+ private List requires = new ArrayList<>();
+
+ // Feature properties for resolving %key references
+ private Properties properties = new Properties();
public Feature(Document document) {
super(document);
}
+ public Feature(Document document, Properties properties) {
+ super(document);
+ this.properties = properties != null ? properties : new Properties();
+ }
+
public Feature(InputStream in) throws Exception {
- this(toDoc(in));
+ this(loadFromJar(in));
+ }
+
+ private Feature(DocumentAndProperties docAndProps) {
+ super(docAndProps.document);
+ this.properties = docAndProps.properties;
+ }
+
+ private static class DocumentAndProperties {
+ Document document;
+ Properties properties;
+
+ DocumentAndProperties(Document document, Properties properties) {
+ this.document = document;
+ this.properties = properties != null ? properties : new Properties();
+ }
}
- private static Document toDoc(InputStream in) throws Exception {
+ private static DocumentAndProperties loadFromJar(InputStream in) throws Exception {
try (Jar jar = new Jar("feature", in)) {
- Resource resource = jar.getResource("feature.xml");
- if (resource == null) {
+ // Load feature.xml
+ aQute.bnd.osgi.Resource featureXml = jar.getResource("feature.xml");
+ if (featureXml == null) {
throw new IllegalArgumentException("JAR does not contain proper 'feature.xml");
}
DocumentBuilder db = XMLBase.dbf.newDocumentBuilder();
- Document doc = db.parse(resource.openInputStream());
- return doc;
+ Document doc = db.parse(featureXml.openInputStream());
+
+ // Load feature.properties if present
+ Properties props = new Properties();
+ aQute.bnd.osgi.Resource featureProps = jar.getResource("feature.properties");
+ if (featureProps != null) {
+ try (InputStream propsIn = featureProps.openInputStream()) {
+ props.load(propsIn);
+ }
+ }
+
+ return new DocumentAndProperties(doc, props);
+ }
+ }
+
+ /**
+ * Resolve property references in the form %key by looking up in feature.properties
+ */
+ private String resolveProperty(String value) {
+ if (value != null && value.startsWith("%")) {
+ String key = value.substring(1);
+ String resolved = properties.getProperty(key);
+ return resolved != null ? resolved : value;
}
+ return value;
+ }
+
+ /**
+ * Parse the feature.xml document and populate all properties
+ */
+ public void parse() throws Exception {
+ // Parse root feature element attributes
+ Node featureNode = getNodes("/feature").item(0);
+ if (featureNode != null) {
+ id = getAttribute(featureNode, "id");
+ label = resolveProperty(getAttribute(featureNode, "label"));
+ version = getAttribute(featureNode, "version");
+ providerName = resolveProperty(getAttribute(featureNode, "provider-name"));
+ plugin = getAttribute(featureNode, "plugin");
+ image = getAttribute(featureNode, "image");
+ application = getAttribute(featureNode, "application");
+ os = getAttribute(featureNode, "os");
+ ws = getAttribute(featureNode, "ws");
+ arch = getAttribute(featureNode, "arch");
+ nl = getAttribute(featureNode, "nl");
+ colocationAffinity = getAttribute(featureNode, "colocation-affinity");
+ primary = getAttribute(featureNode, "primary");
+ exclusive = getAttribute(featureNode, "exclusive");
+ licenseFeature = getAttribute(featureNode, "license-feature");
+ licenseFeatureVersion = getAttribute(featureNode, "license-feature-version");
+ }
+
+ // Parse plugins
+ plugins = getPlugins();
+
+ // Parse includes
+ includes = getIncludes();
+
+ // Parse requires
+ requires = getRequires();
}
- List getPlugins() throws Exception {
+ /**
+ * Get all plugin references in this feature
+ */
+ public List getPlugins() throws Exception {
NodeList nodes = getNodes("/feature/plugin");
List result = new ArrayList<>();
for (int i = 0; i < nodes.getLength(); i++) {
@@ -56,12 +214,295 @@ List getPlugins() throws Exception {
Plugin plugin = getFromType(item, Plugin.class);
result.add(plugin);
}
+ return result;
+ }
+
+ /**
+ * Get all included features
+ */
+ public List getIncludes() throws Exception {
+ NodeList nodes = getNodes("/feature/includes");
+ List result = new ArrayList<>();
+ for (int i = 0; i < nodes.getLength(); i++) {
+ Node item = nodes.item(i);
+ Includes include = getFromType(item, Includes.class);
+ result.add(include);
+ }
+ return result;
+ }
+ /**
+ * Get all required features and plugins
+ */
+ public List getRequires() throws Exception {
+ NodeList nodes = getNodes("/feature/requires/import");
+ List result = new ArrayList<>();
+ for (int i = 0; i < nodes.getLength(); i++) {
+ Node item = nodes.item(i);
+ Requires req = getFromType(item, Requires.class);
+ result.add(req);
+ }
return result;
}
+ /**
+ * Create an OSGi Resource representation of this feature with capabilities
+ * and requirements
+ */
+ public Resource toResource() throws Exception {
+ ResourceBuilder rb = new ResourceBuilder();
+
+ // Create identity capability with type=org.eclipse.update.feature
+ CapReqBuilder identity = new CapReqBuilder(IdentityNamespace.IDENTITY_NAMESPACE);
+ identity.addAttribute(IdentityNamespace.IDENTITY_NAMESPACE, id);
+ identity.addAttribute(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE, "org.eclipse.update.feature");
+ if (version != null) {
+ try {
+ Version v = Version.parseVersion(version);
+ identity.addAttribute(IdentityNamespace.CAPABILITY_VERSION_ATTRIBUTE, v);
+ } catch (IllegalArgumentException e) {
+ // If version parsing fails, store as string
+ identity.addAttribute("version.string", version);
+ }
+ }
+
+ // Add feature properties as attributes
+ Map props = new HashMap<>();
+ if (label != null)
+ props.put("label", label);
+ if (providerName != null)
+ props.put("provider-name", providerName);
+ if (plugin != null)
+ props.put("plugin", plugin);
+ if (image != null)
+ props.put("image", image);
+ if (application != null)
+ props.put("application", application);
+ if (os != null)
+ props.put("os", os);
+ if (ws != null)
+ props.put("ws", ws);
+ if (arch != null)
+ props.put("arch", arch);
+ if (nl != null)
+ props.put("nl", nl);
+ if (colocationAffinity != null)
+ props.put("colocation-affinity", colocationAffinity);
+ if (primary != null)
+ props.put("primary", primary);
+ if (exclusive != null)
+ props.put("exclusive", exclusive);
+ if (licenseFeature != null)
+ props.put("license-feature", licenseFeature);
+ if (licenseFeatureVersion != null)
+ props.put("license-feature-version", licenseFeatureVersion);
+
+ for (Map.Entry entry : props.entrySet()) {
+ identity.addAttribute(entry.getKey(), entry.getValue());
+ }
+
+ rb.addCapability(identity);
+
+ // Create requirements for included plugins
+ for (Plugin plugin : plugins) {
+ CapReqBuilder req = new CapReqBuilder("osgi.identity");
+ req.addDirective("filter", String.format("(osgi.identity=%s)", plugin.id));
+ if (plugin.version != null && !plugin.version.equals("0.0.0")) {
+ // Add version constraint
+ req.addDirective("filter",
+ String.format("(&(osgi.identity=%s)(version=%s))", plugin.id, plugin.version));
+ }
+ rb.addRequirement(req);
+ }
+
+ // Create requirements for included features
+ for (Includes include : includes) {
+ CapReqBuilder req = new CapReqBuilder("osgi.identity");
+ String filter = String.format("(&(osgi.identity=%s)(type=org.eclipse.update.feature))", include.id);
+ if (include.version != null && !include.version.equals("0.0.0")) {
+ filter = String.format("(&(osgi.identity=%s)(type=org.eclipse.update.feature)(version=%s))", include.id,
+ include.version);
+ }
+ req.addDirective("filter", filter);
+ if (include.optional) {
+ req.addDirective("resolution", "optional");
+ }
+ rb.addRequirement(req);
+ }
+
+ // Create requirements for required features and plugins
+ for (Requires requirement : requires) {
+ CapReqBuilder req = new CapReqBuilder("osgi.identity");
+ String reqIdentity = requirement.plugin != null ? requirement.plugin : requirement.feature;
+
+ // Build the filter with version constraint if present
+ String filter = buildRequirementFilter(reqIdentity, requirement.version, requirement.match,
+ requirement.feature != null);
+ req.addDirective("filter", filter);
+ rb.addRequirement(req);
+ }
+
+ return rb.build();
+ }
+
+ /**
+ * Build an LDAP filter for a requirement with version constraint.
+ * Handles Eclipse match rules: "perfect", "equivalent", "compatible", "greaterOrEqual"
+ *
+ * @param identity the identity (plugin or feature id)
+ * @param version the version string (may be null)
+ * @param match the match rule (may be null, defaults to "greaterOrEqual")
+ * @param isFeature true if this is a feature (adds type filter)
+ * @return LDAP filter string
+ */
+ public String buildRequirementFilter(String identity, String version, String match, boolean isFeature) {
+ StringBuilder filter = new StringBuilder();
+
+ // Determine if we need version filtering
+ boolean hasVersion = version != null && !version.equals("0.0.0");
+
+ // Start with identity
+ if (isFeature || hasVersion) {
+ filter.append("(&");
+ }
+ filter.append("(osgi.identity=")
+ .append(identity)
+ .append(")");
+
+ // Add type for features
+ if (isFeature) {
+ filter.append("(type=org.eclipse.update.feature)");
+ }
+
+ // Add version constraint if present
+ if (hasVersion) {
+ String versionFilter = buildVersionFilter(version, match);
+ if (versionFilter != null) {
+ filter.append(versionFilter);
+ }
+ }
+
+ if (isFeature || hasVersion) {
+ filter.append(")");
+ }
+
+ return filter.toString();
+ }
+
+ /**
+ * Build a version filter based on Eclipse match rules.
+ * Eclipse match rules are:
+ * - perfect: exact version match
+ * - equivalent: same major.minor, micro/qualifier may differ
+ * - compatible: same major, minor/micro/qualifier may differ
+ * - greaterOrEqual: version >= specified (default if no match specified)
+ *
+ * @param version the version string
+ * @param match the match rule (may be null)
+ * @return version filter fragment (without outer parentheses)
+ */
+ public String buildVersionFilter(String version, String match) {
+ if (version == null || version.isEmpty() || version.equals("0.0.0")) {
+ return null;
+ }
+
+ // Default to greaterOrEqual if no match specified
+ if (match == null || match.isEmpty()) {
+ match = "greaterOrEqual";
+ }
+
+ try {
+ Version v = Version.parseVersion(version);
+
+ switch (match.toLowerCase()) {
+ case "perfect":
+ // Exact version match
+ return String.format("(version=%s)", version);
+
+ case "equivalent":
+ // Same major.minor, micro/qualifier may differ
+ // Range: [major.minor.micro, major.(minor+1).0)
+ Version lower = v;
+ Version upper = new Version(v.getMajor(), v.getMinor() + 1, 0);
+ return String.format("(version>=%s)(!(version>=%s))", lower, upper);
+
+ case "compatible":
+ // Same major, minor/micro/qualifier may differ
+ // Range: [major.minor.micro, (major+1).0.0)
+ Version lowerCompat = v;
+ Version upperCompat = new Version(v.getMajor() + 1, 0, 0);
+ return String.format("(version>=%s)(!(version>=%s))", lowerCompat, upperCompat);
+
+ case "greaterorequal":
+ default:
+ // Unbounded range: version >= specified
+ return String.format("(version>=%s)", v);
+ }
+ } catch (Exception e) {
+ // If version parsing fails, fall back to greaterOrEqual with the raw string
+ return String.format("(version>=%s)", version);
+ }
+ }
+
+ /**
+ * Build a platform filter expression from os, ws, and arch attributes.
+ * Returns null if no platform attributes are specified.
+ *
+ * @param os the operating system (may be null)
+ * @param ws the windowing system (may be null)
+ * @param arch the architecture (may be null)
+ * @return LDAP filter string for platform attributes, or null if none specified
+ */
+ public static String buildPlatformFilter(String os, String ws, String arch) {
+ List filters = new ArrayList<>();
+
+ if (os != null && !os.isEmpty()) {
+ filters.add(String.format("(osgi.os=%s)", os));
+ }
+ if (ws != null && !ws.isEmpty()) {
+ filters.add(String.format("(osgi.ws=%s)", ws));
+ }
+ if (arch != null && !arch.isEmpty()) {
+ filters.add(String.format("(osgi.arch=%s)", arch));
+ }
+
+ if (filters.isEmpty()) {
+ return null;
+ }
+
+ if (filters.size() == 1) {
+ return filters.get(0);
+ }
+
+ // Combine multiple filters with AND
+ StringBuilder result = new StringBuilder("(&");
+ for (String filter : filters) {
+ result.append(filter);
+ }
+ result.append(")");
+
+ return result.toString();
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public String getProviderName() {
+ return providerName;
+ }
+
@Override
public String toString() {
- return "Feature [plugins=" + plugins + "]";
+ return "Feature [id=" + id + ", version=" + version + ", plugins=" + plugins.size() + ", includes="
+ + includes.size() + ", requires=" + requires.size() + "]";
}
}
diff --git a/biz.aQute.repository/src/aQute/p2/provider/P2Impl.java b/biz.aQute.repository/src/aQute/p2/provider/P2Impl.java
index 8bc07ca0d0..64f4aa5317 100644
--- a/biz.aQute.repository/src/aQute/p2/provider/P2Impl.java
+++ b/biz.aQute.repository/src/aQute/p2/provider/P2Impl.java
@@ -50,6 +50,7 @@ public P2Impl(Unpack200 processor, HttpClient c, URI base, PromiseFactory promis
}
private URI normalize(URI base) throws Exception {
+ base = normalizeOpaqueFileUri(base);
String path = base.getPath();
if (path == null || path.endsWith("/"))
return base;
@@ -57,6 +58,26 @@ private URI normalize(URI base) throws Exception {
return new URI(base.toString() + "/");
}
+ static URI normalizeOpaqueFileUri(URI uri) {
+ if (!uri.isOpaque() || !"file".equalsIgnoreCase(uri.getScheme())) {
+ return uri;
+ }
+ String schemeSpecificPart = uri.getSchemeSpecificPart();
+ if (schemeSpecificPart == null || schemeSpecificPart.isEmpty()) {
+ return uri;
+ }
+ return new File(schemeSpecificPart).toURI();
+ }
+
+ private static String path(URI uri) {
+ String path = uri.getPath();
+ if (path != null) {
+ return path;
+ }
+ String schemeSpecificPart = uri.getSchemeSpecificPart();
+ return (schemeSpecificPart != null) ? schemeSpecificPart : "";
+ }
+
@Override
public List getAllArtifacts() throws Exception {
Set cycles = Collections.newSetFromMap(new ConcurrentHashMap());
@@ -73,12 +94,13 @@ public List getArtifacts() throws Exception {
}
private Promise> getArtifacts(Set cycles, URI uri) {
+ uri = normalizeOpaqueFileUri(uri);
if (!cycles.add(uri)) {
return promiseFactory.resolved(Collections.emptyList());
}
try {
- String type = uri.getPath();
+ String type = path(uri);
logger.info("getArtifacts type={}", uri);
if (type.endsWith("/compositeArtifacts.xml")) {
return parseCompositeArtifacts(cycles, hideAndSeek(uri), uri);
@@ -151,7 +173,8 @@ private Promise> getArtifacts(Set cycles, final Collection artifacts) throws URISyntaxException {
return;
for (URI uri : new ArrayList<>(artifacts)) {
- if (uri.getPath()
+ if (path(uri)
.endsWith(".xml"))
artifacts.remove(new URI(uri.toString() + ".xz"));
}
diff --git a/biz.aQute.repository/src/aQute/p2/provider/TargetImpl.java b/biz.aQute.repository/src/aQute/p2/provider/TargetImpl.java
index 244dc849e4..35aa9deea0 100644
--- a/biz.aQute.repository/src/aQute/p2/provider/TargetImpl.java
+++ b/biz.aQute.repository/src/aQute/p2/provider/TargetImpl.java
@@ -169,10 +169,13 @@ private List filterArtifactsAgainstLocationUnits(Location location, Li
File file = processor.unpackAndLinkIfNeeded(tag, null);
try (InputStream in = IO.stream(file)) {
Feature f = new Feature(in);
+ f.parse();
logger.debug("Adding feature {}", f);
for (Plugin plugin : f.getPlugins()) {
- plugins.add(plugin.id, plugin.version);
+ Version version = plugin.version != null ? Version.parseVersion(plugin.version)
+ : Version.emptyVersion;
+ plugins.add(plugin.id, version);
}
}
} catch (Exception e) {
diff --git a/biz.aQute.repository/test/aQute/bnd/repository/maven/provider/MavenBndRepoTest.java b/biz.aQute.repository/test/aQute/bnd/repository/maven/provider/MavenBndRepoTest.java
index a4d066d2e5..0e042e3331 100644
--- a/biz.aQute.repository/test/aQute/bnd/repository/maven/provider/MavenBndRepoTest.java
+++ b/biz.aQute.repository/test/aQute/bnd/repository/maven/provider/MavenBndRepoTest.java
@@ -1280,4 +1280,244 @@ public void testPutReleaseWithStaging() throws Exception {
.isFile());
}
+ @Test
+ public void testPomXmlIndexRead() throws Exception {
+ File pomIndex = IO.getFile(tmp, "index.pom.xml");
+ IO.copy(IO.getFile("testresources/mavenrepo/index.pom.xml"), pomIndex);
+
+ Map map = Map.of("index", pomIndex.getAbsolutePath());
+ config(map);
+
+ assertThat(repo.getStatus()).isNull();
+ List list = repo.list(null);
+ assertThat(list).contains("commons-cli:commons-cli");
+ SortedSet versions = repo.versions("commons-cli:commons-cli");
+ assertThat(versions).contains(new Version("1.0.0"), new Version("1.2.0"));
+
+ // Verify the pom.xml structure via DOM/XPath
+ Document doc = dbf.newDocumentBuilder()
+ .parse(pomIndex);
+ XPath xp = xpf.newXPath();
+
+ // commons-cli 1.0 dependency block must be complete
+ assertThat(xp.evaluate("//dependency[artifactId='commons-cli' and version='1.0']/groupId", doc)
+ .trim()).as("commons-cli 1.0 must have commons-cli")
+ .isEqualTo("commons-cli");
+ assertThat(xp.evaluate("//dependency[artifactId='commons-cli' and version='1.0']/artifactId", doc)
+ .trim()).as("commons-cli 1.0 must have commons-cli")
+ .isEqualTo("commons-cli");
+ assertThat(xp.evaluate("//dependency[artifactId='commons-cli' and version='1.0']/version", doc)
+ .trim()).as("commons-cli 1.0 must have 1.0")
+ .isEqualTo("1.0");
+
+ // commons-cli 1.2 dependency block must be complete
+ assertThat(xp.evaluate("//dependency[artifactId='commons-cli' and version='1.2']/groupId", doc)
+ .trim()).as("commons-cli 1.2 must have commons-cli")
+ .isEqualTo("commons-cli");
+ assertThat(xp.evaluate("//dependency[artifactId='commons-cli' and version='1.2']/version", doc)
+ .trim()).as("commons-cli 1.2 must have 1.2")
+ .isEqualTo("1.2");
+ }
+
+ @Test
+ public void testPomXmlIndexAdd() throws Exception {
+ File pomIndex = IO.getFile(tmp, "index.pom.xml");
+ IO.copy(IO.getFile("testresources/mavenrepo/index.pom.xml"), pomIndex);
+ config(Map.of("index", pomIndex.getAbsolutePath()));
+
+ assertThat(repo.list("org.osgi.service.log")).isEmpty();
+ repo.index.add(Archive.valueOf("org.osgi:org.osgi.service.log:1.3.0"));
+ assertThat(repo.list("org.osgi.service.log")).isNotEmpty();
+
+ // Verify the added dependency block is fully and correctly written
+ Document doc = dbf.newDocumentBuilder()
+ .parse(pomIndex);
+ XPath xp = xpf.newXPath();
+
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.service.log']/groupId", doc)
+ .trim()).as("added dependency must have org.osgi")
+ .isEqualTo("org.osgi");
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.service.log']/artifactId", doc)
+ .trim()).as("added dependency must have org.osgi.service.log")
+ .isEqualTo("org.osgi.service.log");
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.service.log']/version", doc)
+ .trim()).as("added dependency must have 1.3.0")
+ .isEqualTo("1.3.0");
+
+ // check there is exactly one entry
+ assertThat((Double) xp.evaluate(
+ "count(//dependency[groupId='org.osgi' and artifactId='org.osgi.service.log' and version='1.3.0'])",
+ doc, javax.xml.xpath.XPathConstants.NUMBER)).isEqualTo(1.0);
+
+ // plain jar add must NOT have a element
+ double typeCount = (double) xp.evaluate("count(//dependency[artifactId='org.osgi.service.log']/type)", doc,
+ javax.xml.xpath.XPathConstants.NUMBER);
+ assertThat(typeCount).as("plain jar added dependency must NOT have a element")
+ .isEqualTo(0.0);
+ }
+
+ @Test
+ public void testPomXmlIndexRemove() throws Exception {
+ File pomIndex = IO.getFile(tmp, "index.pom.xml");
+ IO.copy(IO.getFile("testresources/mavenrepo/index.pom.xml"), pomIndex);
+ config(Map.of("index", pomIndex.getAbsolutePath()));
+
+ assertThat(repo.getStatus()).isNull();
+ assertNotNull(repo.get("org.osgi.dto", new Version("1.0.0.201505202023"), null));
+
+ // Verify org.osgi.dto is present before removal
+ Document docBefore = dbf.newDocumentBuilder()
+ .parse(pomIndex);
+ XPath xp = xpf.newXPath();
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.dto']/artifactId", docBefore)
+ .trim()).as("org.osgi.dto must be present in the pom.xml before removal")
+ .isEqualTo("org.osgi.dto");
+
+ repo.index.remove(Archive.valueOf("org.osgi:org.osgi.dto:1.0.0"));
+ assertThat(repo.list("org.osgi.dto")).isEmpty();
+
+ // Verify the entire org.osgi.dto dependency block is gone
+ Document docAfter = dbf.newDocumentBuilder()
+ .parse(pomIndex);
+ double dtoCount = (double) xp.evaluate("count(//dependency[artifactId='org.osgi.dto'])", docAfter,
+ javax.xml.xpath.XPathConstants.NUMBER);
+ assertThat(dtoCount).as("org.osgi.dto dependency block must be fully removed from pom.xml")
+ .isEqualTo(0.0);
+ }
+
+ @Test
+ public void testPomXmlIndexReplaceArchive() throws Exception {
+ File pomIndex = IO.getFile(tmp, "index.pom.xml");
+ IO.copy(IO.getFile("testresources/mavenrepo/index.pom.xml"), pomIndex);
+ config(Map.of("index", pomIndex.getAbsolutePath()));
+
+ // trigger init()
+ assertThat(repo.list(null)).isNotNull();
+
+ Archive oldArchive = Archive.valueOf("org.osgi:org.osgi.dto:1.0.0");
+ Archive newArchive = Archive.valueOf("org.osgi:org.osgi.dto:2.0.0");
+ repo.index.replaceArchive(oldArchive, newArchive);
+
+ // Verify the full replaced dependency block via DOM/XPath
+ Document doc = dbf.newDocumentBuilder()
+ .parse(pomIndex);
+ XPath xp = xpf.newXPath();
+
+ // new version block must exist with correct groupId, artifactId,
+ // version
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.dto' and version='2.0.0']/groupId", doc)
+ .trim()).as("replaced dependency must have org.osgi")
+ .isEqualTo("org.osgi");
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.dto' and version='2.0.0']/artifactId", doc)
+ .trim()).as("replaced dependency must have org.osgi.dto")
+ .isEqualTo("org.osgi.dto");
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.dto' and version='2.0.0']/version", doc)
+ .trim()).as("replaced dependency must have 2.0.0")
+ .isEqualTo("2.0.0");
+
+ // old version block must be gone entirely
+ double oldCount = (double) xp.evaluate("count(//dependency[artifactId='org.osgi.dto' and version='1.0.0'])",
+ doc, javax.xml.xpath.XPathConstants.NUMBER);
+ assertThat(oldCount).as("old org.osgi.dto 1.0.0 dependency block must be gone after replace")
+ .isEqualTo(0.0);
+
+ // replaced plain jar must NOT have a element
+ double typeCount = (double) xp.evaluate(
+ "count(//dependency[artifactId='org.osgi.dto' and version='2.0.0']/type)", doc,
+ javax.xml.xpath.XPathConstants.NUMBER);
+ assertThat(typeCount).as("replaced plain jar dependency must NOT have a element")
+ .isEqualTo(0.0);
+
+ // assert if repo reflects the changes
+ assertThat(repo.versions("org.osgi:org.osgi.dto")).contains(new Version("2.0.0"));
+ assertThat(repo.versions("org.osgi:org.osgi.dto")).doesNotContain(new Version("1.0.0"));
+ }
+
+ @Test
+ public void testPomXmlPreservesProjectMetadata() throws Exception {
+ File pomIndex = IO.getFile(tmp, "index.pom.xml");
+ IO.copy(IO.getFile("testresources/mavenrepo/index.pom.xml"), pomIndex);
+ config(Map.of("index", pomIndex.getAbsolutePath()));
+
+ // trigger init()
+ assertThat(repo.list(null)).isNotNull();
+
+ repo.index.add(Archive.valueOf("org.osgi:org.osgi.service.log:1.3.0"));
+
+ // Verify project-level metadata is preserved via DOM/XPath (not just
+ // string contains)
+ Document doc = dbf.newDocumentBuilder()
+ .parse(pomIndex);
+ XPath xp = xpf.newXPath();
+
+ assertThat(xp.evaluate("/project/groupId", doc)
+ .trim()).as("project must be preserved as 'test'")
+ .isEqualTo("test");
+ assertThat(xp.evaluate("/project/artifactId", doc)
+ .trim()).as("project must be preserved as 'index'")
+ .isEqualTo("index");
+ assertThat(xp.evaluate("/project/version", doc)
+ .trim()).as("project must be preserved as '1.0-SNAPSHOT'")
+ .isEqualTo("1.0-SNAPSHOT");
+ }
+
+ @Test
+ public void testPomXmlIndexTypeAndClassifier() throws Exception {
+ File pomIndex = IO.getFile(tmp, "index.pom.xml");
+ IO.copy(IO.getFile("testresources/mavenrepo/index.pom.xml"), pomIndex);
+ config(Map.of("index", pomIndex.getAbsolutePath()));
+
+ // Verify that org.osgi.dto is listed
+ assertThat(repo.list("org.osgi.dto")).isNotEmpty();
+
+ // Add an archive with a non-jar type and a sources classifier
+ Archive withClassifier = Archive.valueOf("org.osgi:org.osgi.dto:jar:sources:1.0.0");
+ Archive withType = Archive.valueOf("org.osgi:org.osgi.dto:zip:1.0.0");
+ repo.index.add(withClassifier);
+ repo.index.add(withType);
+
+ // Parse the saved pom.xml with DOM/XPath for structural assertions
+ Document doc = dbf.newDocumentBuilder().parse(pomIndex);
+ XPath xp = xpf.newXPath();
+
+ // 1. The sources classifier entry MUST have sources
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.dto' and classifier='sources']/classifier", doc)
+ .trim())
+ .as("sources dependency must have sources")
+ .isEqualTo("sources");
+
+ // 2. The sources classifier entry MUST have the correct
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.dto' and classifier='sources']/artifactId", doc)
+ .trim())
+ .as("sources dependency must have org.osgi.dto")
+ .isEqualTo("org.osgi.dto");
+
+ // 3. The zip type entry MUST have zip
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.dto' and type='zip']/type", doc)
+ .trim())
+ .as("zip dependency must have zip")
+ .isEqualTo("zip");
+
+ // 4. The zip type entry MUST have the correct
+ assertThat(xp.evaluate("//dependency[artifactId='org.osgi.dto' and type='zip']/artifactId", doc)
+ .trim())
+ .as("zip dependency must have org.osgi.dto")
+ .isEqualTo("org.osgi.dto");
+
+ // 5. The plain jar entry must NOT have a element
+ double plainJarTypeCount = (double) xp.evaluate(
+ "count(//dependency[artifactId='org.osgi.dto' and not(classifier) and not(type)]/type)",
+ doc, javax.xml.xpath.XPathConstants.NUMBER);
+ assertThat(plainJarTypeCount)
+ .as("plain jar org.osgi.dto dependency must NOT have a element")
+ .isEqualTo(0.0);
+
+ // 6. The plain jar entry must exist (sanity check)
+ assertThat(
+ xp.evaluate("//dependency[artifactId='org.osgi.dto' and not(classifier) and not(type)]/artifactId", doc)
+ .trim())
+ .as("plain jar dependency for org.osgi.dto must exist without or ")
+ .isEqualTo("org.osgi.dto");
+ }
+
}
diff --git a/biz.aQute.repository/test/aQute/bnd/repository/maven/provider/SonatypeReleaseTest.java b/biz.aQute.repository/test/aQute/bnd/repository/maven/provider/SonatypeDeploymentTest.java
similarity index 50%
rename from biz.aQute.repository/test/aQute/bnd/repository/maven/provider/SonatypeReleaseTest.java
rename to biz.aQute.repository/test/aQute/bnd/repository/maven/provider/SonatypeDeploymentTest.java
index de57ec5471..aa45ae93a4 100644
--- a/biz.aQute.repository/test/aQute/bnd/repository/maven/provider/SonatypeReleaseTest.java
+++ b/biz.aQute.repository/test/aQute/bnd/repository/maven/provider/SonatypeDeploymentTest.java
@@ -17,6 +17,7 @@
import java.util.List;
import java.util.Set;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -29,86 +30,132 @@
import aQute.bnd.test.jupiter.InjectTemporaryDirectory;
import aQute.lib.io.IO;
-public class SonatypeReleaseTest {
+public class SonatypeDeploymentTest {
+
+ static final String CENTRAL_SONATYPE_PUBLISHER_URL = "https://central.sonatype.com/api/v1/publisher";
@InjectTemporaryDirectory
static File wsDir;
-
- static final String CENTRAL_SONATYPE_PUBLISHER_URL = "https://central.sonatype.com/api/v1/publisher";
+ static File localMavenRepo = new File(wsDir, ".m2");
File sonatypeRepoFile = new File(wsDir, "cnf/ext/sonatype_release.bnd");
File releasedVersionFile = new File(wsDir, "cnf/ext/gav_30_sonatype.mvn");
- File deploymentIDFile = new File(wsDir,
- MavenBndRepository.SONATYPE_RELEASE_DIR + "/" + MavenBndRepository.SONATYPE_DEPLOYMENTID_FILE);
+ File deploymentIDFile = new File(wsDir, MavenBndRepository.SONATYPE_RELEASE_DIR + "/"
+ + "biz_aQute_eval_" + MavenBndRepository.SONATYPE_DEPLOYMENTID_FILE);
boolean remoteTest = true;
+ @BeforeAll
+ public static void checkGpg() {
+ System.out.println("using wrkdir: " + wsDir);
+ assumeTrue(isGpgAvailable(), "Skipping test: GPG executable not found in system PATH");
+ assumeTrue(hasRequiredEnvironment(),
+ "Skipping test: Required environment variables (SONATYPE_BEARER, GPG_KEY_ID, GPG_PASSPHRASE) are not set");
+ // configure local Maven repository directory
+ localMavenRepo.mkdirs();
+ assertTrue("Local Maven repository should be created", localMavenRepo.exists());
+ System.setProperty("maven.repo.local", localMavenRepo.getAbsolutePath());
+ }
+
@BeforeEach
public void prepareTest() throws IOException {
- // Only run tests if required environment variables are set
- assumeTrue(hasRequiredEnvironmentVariables(),
- "Skipping test: Required environment variables (SONATYPE_BEARER, GPG_KEYNAME, GPG_PASSPHRASE) are not set");
-
+ // clean workspace directory - start fresh for each test
IO.delete(wsDir);
- assertFalse("directory could not be deleted " + wsDir, wsDir.exists());
+ assertFalse("wrkdir directory could not be deleted " + wsDir, wsDir.exists());
IO.copy(IO.getFile("testresources/sonatype"), wsDir);
- System.err.println("--" + wsDir);
+ assertFalse(" Sonatype repo config must not exist before test", sonatypeRepoFile.exists());
+ assertFalse("Released version file must not exist before test", releasedVersionFile.exists());
+ }
- sonatypeRepoFile.delete();
- assertFalse(sonatypeRepoFile.exists());
+ @Test
+ public void testReleaseUrl() throws Exception {
+ try (Workspace ws = Workspace.findWorkspace(wsDir)) {
+ new File(wsDir, "cnf/ext/sonatype_release.bnd_").renameTo(sonatypeRepoFile);
+ assertTrue(sonatypeRepoFile.exists());
+ }
+ testDeployment(false);
+ }
- releasedVersionFile.delete();
- assertFalse(releasedVersionFile.exists());
+ @Test
+ public void testStagingUrl() throws Exception {
+ try (Workspace ws = Workspace.findWorkspace(wsDir)) {
+ new File(wsDir, "cnf/ext/sonatype_staging.bnd_").renameTo(sonatypeRepoFile);
+ assertTrue(sonatypeRepoFile.exists());
+ }
+ testDeployment(false);
}
- private boolean hasRequiredEnvironmentVariables() {
- String sonatypeBearer = System.getenv("SONATYPE_BEARER");
- String gpgKeyname = System.getenv("GPG_KEYNAME");
- String gpgPassphrase = System.getenv("GPG_PASSPHRASE");
+ @Test
+ public void testSonatypeModeNone() throws Exception {
+ try (Workspace ws = Workspace.findWorkspace(wsDir)) {
+ new File(wsDir, "cnf/ext/sonatype_none.bnd_").renameTo(sonatypeRepoFile);
+ assertTrue(sonatypeRepoFile.exists());
+ }
+ remoteTest = false;
+ testDeployment(false);
+ }
- return sonatypeBearer != null && !sonatypeBearer.isEmpty() && gpgKeyname != null && !gpgKeyname.isEmpty()
- && gpgPassphrase != null && !gpgPassphrase.isEmpty();
+ @Test
+ public void testSonatypeModeUnset() throws Exception {
+ try (Workspace ws = Workspace.findWorkspace(wsDir)) {
+ new File(wsDir, "cnf/ext/sonatype_null.bnd_").renameTo(sonatypeRepoFile);
+ assertTrue(sonatypeRepoFile.exists());
+ }
+ remoteTest = false;
+ testDeployment(false);
}
@Test
- public void testReleaseDeployment() throws Exception {
+ public void testReleaseUrlSnapshot() throws Exception {
try (Workspace ws = Workspace.findWorkspace(wsDir)) {
new File(wsDir, "cnf/ext/sonatype_release.bnd_").renameTo(sonatypeRepoFile);
assertTrue(sonatypeRepoFile.exists());
}
- testDeployment();
+ commentSnapshotLine();
+ testDeployment(true);
}
@Test
- public void testStagingDeployment() throws Exception {
+ public void testStagingUrlSnapshot() throws Exception {
try (Workspace ws = Workspace.findWorkspace(wsDir)) {
new File(wsDir, "cnf/ext/sonatype_staging.bnd_").renameTo(sonatypeRepoFile);
assertTrue(sonatypeRepoFile.exists());
}
- testDeployment();
+ commentSnapshotLine();
+ testDeployment(true);
}
@Test
- public void testSonatypeNone() throws Exception {
+ public void testSonatypeModeNoneSnapshot() throws Exception {
try (Workspace ws = Workspace.findWorkspace(wsDir)) {
new File(wsDir, "cnf/ext/sonatype_none.bnd_").renameTo(sonatypeRepoFile);
assertTrue(sonatypeRepoFile.exists());
}
remoteTest = false;
- testDeployment();
+ commentSnapshotLine();
+ testDeployment(true);
}
@Test
- public void testSonatypeNull() throws Exception {
+ public void testSonatypeModeUnsetSnapshot() throws Exception {
try (Workspace ws = Workspace.findWorkspace(wsDir)) {
new File(wsDir, "cnf/ext/sonatype_null.bnd_").renameTo(sonatypeRepoFile);
assertTrue(sonatypeRepoFile.exists());
}
remoteTest = false;
- testDeployment();
+ commentSnapshotLine();
+ testDeployment(true);
+ }
+
+ private void commentSnapshotLine() throws IOException {
+ File buildBnd = new File(wsDir, "cnf/build.bnd");
+ String content = Files.readString(buildBnd.toPath());
+ // Comment out the -snapshot: line
+ content = content.replaceAll("(?m)^-snapshot:", "#-snapshot:");
+ Files.writeString(buildBnd.toPath(), content);
}
- private void testDeployment() throws Exception, IOException {
+ private void testDeployment(boolean isSnapshot) throws Exception, IOException {
LinkedList releasedFiles = new LinkedList();
try (Workspace ws = Workspace.findWorkspace(wsDir)) {
ws.setPedantic(true);
@@ -127,15 +174,15 @@ private void testDeployment() throws Exception, IOException {
releasedFiles.addAll(Arrays.asList(files));
if (iterator.hasNext()) {
- System.out.println("Release project: " + p.getName());
- System.out.println(" of builded BSNs " + p.getBsns());
+ System.out.println("Release project: " + p.getName() + " with bundle(s) BSN(s) " + p.getBsns());
p.release();
} else {
- System.out.println("Release of last project: " + p.getName());
- System.out.println(" of builded BSNs " + p.getBsns());
+ System.out.println(
+ "Release last wrkspc project: " + p.getName() + " with bundle(s) BSN(s) " + p.getBsns());
p.release(new ReleaseParameter(null, false, true));
}
}
+ ws.refresh();
assertTrue(releasedVersionFile.exists());
String content = Files.readString(releasedVersionFile.toPath());
@@ -148,7 +195,7 @@ private void testDeployment() throws Exception, IOException {
assertTrue("Filename without extension '" + nameWithoutExtension + "' not found as word in content",
found);
}
- if (remoteTest) {
+ if (remoteTest && !isSnapshot) {
assertTrue("Deployment ID file not found: " + deploymentIDFile, deploymentIDFile.exists());
String deploymentId = Files.readString(deploymentIDFile.toPath());
@@ -204,12 +251,53 @@ private void testDeploymentStatusCheck(Workspace ws, String deploymentId) throws
e.printStackTrace();
}
- // Drop the deployment after successful status check
+ // Wait for validation before dropping
if (statusOk) {
+ waitForValidation(client, deploymentId);
dropDeployment(client, deploymentId);
}
}
+ private void waitForValidation(HttpClient client, String deploymentId) throws Exception {
+ String statusUrl = CENTRAL_SONATYPE_PUBLISHER_URL + "/status?id=" + deploymentId;
+ System.out.println("Waiting for deployment validation at: " + statusUrl);
+
+ long startTime = System.currentTimeMillis();
+ long maxWaitTime = 30 * 1000; // 30 seconds
+ boolean validated = false;
+
+ while (System.currentTimeMillis() - startTime < maxWaitTime) {
+ try {
+ TaggedData taggedData = client.build()
+ .post()
+ .asTag()
+ .go(new URI(statusUrl).toURL());
+
+ if (taggedData.isOk()) {
+ String responseBody = aQute.lib.io.IO.collect(taggedData.getInputStream());
+ System.out.println("Deployment status response: " + responseBody);
+
+ if (responseBody.contains("\"deploymentState\":\"VALIDATED\"")) {
+ System.out.println("Deployment validated successfully");
+ validated = true;
+ break;
+ }
+ }
+
+ // Wait 1 second before next check
+ Thread.sleep(1000);
+ } catch (Exception e) {
+ System.err.println("Error checking validation status: " + e.getMessage());
+ e.printStackTrace();
+ break;
+ }
+ }
+
+ if (!validated) {
+ System.out.printf("Deployment validation not confirmed within %d seconds%n", maxWaitTime / 1000);
+ }
+ }
+
private void dropDeployment(HttpClient client, String deploymentId) throws Exception {
String dropUrl = CENTRAL_SONATYPE_PUBLISHER_URL + "/deployment/" + deploymentId;
@@ -236,4 +324,61 @@ private void dropDeployment(HttpClient client, String deploymentId) throws Excep
}
}
+ private static boolean isGpgAvailable() {
+ String[] gpgCommands = {
+ "gpg", "gpg2"
+ };
+
+ for (String gpgCommand : gpgCommands) {
+ try {
+ ProcessBuilder pb = new ProcessBuilder(gpgCommand, "--version");
+ Process process = pb.start();
+ int exitCode = process.waitFor();
+
+ if (exitCode == 0) {
+ System.out.println("Found GPG executable: " + gpgCommand);
+ return true;
+ }
+ } catch (IOException e) {
+ // Command not found, try next
+ System.out.println("GPG command '" + gpgCommand + "' not found: " + e.getMessage());
+ } catch (InterruptedException e) {
+ Thread.currentThread()
+ .interrupt();
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ private static boolean hasRequiredEnvironment() {
+ String[] vars = {
+ "SONATYPE_BEARER", "GPG_KEY_ID", "GPG_PASSPHRASE"
+ };
+ boolean existing = true; // initialize to true
+ for (String var : vars) {
+ existing = existing && ensureEnvVar(var);
+ }
+ if ("true".equals(System.getenv("GITHUB_ACTIONS"))) {
+ // Running in GitHub Actions
+ if ("false".equals(System.getenv("CANONICAL"))) {
+ System.out.println("skipping tests cause in GITHUB but NOT in CANONICAL");
+ existing = false;
+ }
+ }
+ return existing;
+ }
+
+ private static boolean ensureEnvVar(String varName) {
+ boolean found = false;
+ String value = System.getenv(varName);
+ if (value == null || value.isEmpty()) {
+ System.err.println("Mandatory environment variable '" + varName + "' is not set or empty.");
+ } else {
+ found = true;
+ }
+ return found;
+ }
+
}
diff --git a/biz.aQute.repository/test/aQute/bnd/repository/p2/provider/Eclipse431FeatureParsingTest.java b/biz.aQute.repository/test/aQute/bnd/repository/p2/provider/Eclipse431FeatureParsingTest.java
new file mode 100644
index 0000000000..5924219862
--- /dev/null
+++ b/biz.aQute.repository/test/aQute/bnd/repository/p2/provider/Eclipse431FeatureParsingTest.java
@@ -0,0 +1,247 @@
+package aQute.bnd.repository.p2.provider;
+
+import java.io.File;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.osgi.resource.Requirement;
+import org.osgi.resource.Resource;
+
+import aQute.bnd.http.HttpClient;
+import aQute.bnd.osgi.Jar;
+import aQute.bnd.osgi.Processor;
+import aQute.bnd.test.jupiter.InjectTemporaryDirectory;
+import aQute.lib.io.IO;
+import aQute.p2.api.Artifact;
+import aQute.p2.packed.Unpack200;
+import aQute.p2.provider.Feature;
+import aQute.p2.provider.P2Impl;
+
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+
+/**
+ * Test that P2 feature parsing correctly translates Eclipse match rules to OSGi
+ * version ranges. Specifically tests the R-4.31-202402290520 repository to
+ * verify that feature requirements with range [2.7.0,3.0.0) are not incorrectly
+ * narrowed to [2.7.0,2.8.0).
+ */
+@ExtendWith(SoftAssertionsExtension.class)
+public class Eclipse431FeatureParsingTest {
+
+ @InjectSoftAssertions
+ SoftAssertions softly;
+
+ @InjectTemporaryDirectory
+ File tmp;
+
+ // This specific Eclipse 4.31 release is referenced in the bug report for this issue.
+ // The org.eclipse.e4.rcp feature in this release requires org.eclipse.emf.common
+ // with range [2.7.0,3.0.0) (compatible match), which was incorrectly parsed as
+ // [2.7.0,2.8.0).
+ private static final String ECLIPSE_431_REPO = "https://download.eclipse.org/eclipse/updates/4.31/R-4.31-202402290520/";
+
+ /**
+ * Test that feature requirements from the Eclipse 4.31 repository use
+ * correct version ranges. The org.eclipse.e4.rcp feature requires
+ * org.eclipse.emf.common with range [2.7.0,3.0.0) (compatible match) which
+ * must not be narrowed to [2.7.0,2.8.0).
+ */
+ @Test
+ public void testEclipse431FeatureVersionRanges() throws Exception {
+ try (HttpClient client = new HttpClient(); Processor proc = new Processor()) {
+ client.setCache(IO.getFile(tmp, "http-cache"));
+
+ Unpack200 processor = new Unpack200(proc);
+ P2Impl p2 = new P2Impl(processor, client, new URI(ECLIPSE_431_REPO),
+ Processor.getPromiseFactory());
+
+ List allArtifacts = p2.getAllArtifacts();
+ softly.assertThat(allArtifacts)
+ .as("Eclipse 4.31 P2 repo should contain artifacts")
+ .isNotEmpty();
+
+ // Find the org.eclipse.e4.rcp feature
+ Artifact e4rcpArtifact = allArtifacts.stream()
+ .filter(a -> a.classifier == aQute.p2.api.Classifier.FEATURE
+ && "org.eclipse.e4.rcp".equals(a.id))
+ .findFirst()
+ .orElse(null);
+
+ softly.assertThat(e4rcpArtifact)
+ .as("org.eclipse.e4.rcp feature must be present in Eclipse 4.31 repository")
+ .isNotNull();
+
+ if (e4rcpArtifact == null) {
+ return;
+ }
+
+ // Download and parse the feature JAR
+ File featureJar = client.build()
+ .useCache()
+ .go(e4rcpArtifact.uri);
+ softly.assertThat(featureJar)
+ .as("Feature JAR should be downloadable")
+ .isNotNull()
+ .exists();
+
+ if (featureJar == null) {
+ return;
+ }
+
+ Feature feature;
+ try (InputStream is = IO.stream(featureJar)) {
+ feature = new Feature(is);
+ feature.parse();
+ }
+
+ softly.assertThat(feature.getId())
+ .as("Feature ID")
+ .isEqualTo("org.eclipse.e4.rcp");
+
+ // Convert to OSGi Resource
+ Resource resource = feature.toResource();
+ List requirements = resource.getRequirements("osgi.identity");
+
+ // Find requirements for org.eclipse.emf.common and org.eclipse.emf.ecore
+ // In feature.xml these use match="compatible" with version="2.7.0",
+ // which must produce range [2.7.0,3.0.0) - NOT [2.7.0,2.8.0)
+ String emfCommonFilter = requirements.stream()
+ .map(req -> req.getDirectives()
+ .get("filter"))
+ .filter(f -> f != null && f.contains("org.eclipse.emf.common"))
+ .findFirst()
+ .orElse(null);
+
+ String emfEcoreFilter = requirements.stream()
+ .map(req -> req.getDirectives()
+ .get("filter"))
+ .filter(f -> f != null && f.contains("org.eclipse.emf.ecore"))
+ .findFirst()
+ .orElse(null);
+
+ System.out.println("org.eclipse.emf.common filter: " + emfCommonFilter);
+ System.out.println("org.eclipse.emf.ecore filter: " + emfEcoreFilter);
+
+ softly.assertThat(emfCommonFilter)
+ .as("org.eclipse.emf.common requirement must have range [2.7.0,3.0.0) - not [2.7.0,2.8.0)")
+ .isNotNull()
+ .contains("(version>=2.7.0)")
+ .contains("(!(version>=3.0.0))")
+ .doesNotContain("(!(version>=2.8.0))");
+
+ softly.assertThat(emfEcoreFilter)
+ .as("org.eclipse.emf.ecore requirement must have range [2.7.0,3.0.0) - not [2.7.0,2.8.0)")
+ .isNotNull()
+ .contains("(version>=2.7.0)")
+ .contains("(!(version>=3.0.0))")
+ .doesNotContain("(!(version>=2.8.0))");
+ }
+ }
+
+ /**
+ * Unit test for buildVersionFilter match rules using a synthetic feature.xml.
+ * Verifies Eclipse match semantics:
+ *
+ * - compatible (same major): [M.m.u, (M+1).0.0)
+ * - equivalent (same major.minor): [M.m.u, M.(m+1).0)
+ * - greaterOrEqual: [M.m.u, inf)
+ * - perfect: [M.m.u.q, M.m.u.q] (exact)
+ *
+ */
+ @Test
+ public void testFeatureMatchRuleVersionRanges() throws Exception {
+ String featureXml = """
+
+
+
+
+
+
+
+
+
+ """;
+
+ File featureJar = new File(tmp, "test.feature.jar");
+ try (Jar jar = new Jar("test.feature")) {
+ File featureFile = new File(tmp, "feature.xml");
+ IO.store(featureXml, featureFile);
+ jar.putResource("feature.xml", new aQute.bnd.osgi.FileResource(featureFile));
+ jar.write(featureJar);
+ }
+
+ Feature feature;
+ try (InputStream is = IO.stream(featureJar)) {
+ feature = new Feature(is);
+ feature.parse();
+ }
+
+ Resource resource = feature.toResource();
+ List requirements = resource.getRequirements("osgi.identity");
+
+ // "compatible" with version 2.7.0: same major (2), so upper = 3.0.0
+ String emfCompatFilter = requirements.stream()
+ .map(req -> req.getDirectives()
+ .get("filter"))
+ .filter(f -> f != null && f.contains("emf.compatible"))
+ .findFirst()
+ .orElse(null);
+
+ softly.assertThat(emfCompatFilter)
+ .as("compatible match on feature emf.compatible version 2.7.0 must give range [2.7.0, 3.0.0)")
+ .isNotNull()
+ .contains("(version>=2.7.0)")
+ .contains("(!(version>=3.0.0))")
+ .doesNotContain("(!(version>=2.8.0))");
+
+ // "equivalent" with version 2.7.0: same major.minor (2.7), so upper = 2.8.0
+ String emfEquivFilter = requirements.stream()
+ .map(req -> req.getDirectives()
+ .get("filter"))
+ .filter(f -> f != null && f.contains("emf.equivalent"))
+ .findFirst()
+ .orElse(null);
+
+ softly.assertThat(emfEquivFilter)
+ .as("equivalent match on feature emf.equivalent version 2.7.0 must give range [2.7.0, 2.8.0)")
+ .isNotNull()
+ .contains("(version>=2.7.0)")
+ .contains("(!(version>=2.8.0))")
+ .doesNotContain("(!(version>=2.7.1))");
+
+ // "compatible" with version 1.2.3: upper = 2.0.0
+ String pluginCompatFilter = requirements.stream()
+ .map(req -> req.getDirectives()
+ .get("filter"))
+ .filter(f -> f != null && f.contains("plugin.compatible"))
+ .findFirst()
+ .orElse(null);
+
+ softly.assertThat(pluginCompatFilter)
+ .as("compatible match on plugin.compatible version 1.2.3 must give range [1.2.3, 2.0.0)")
+ .isNotNull()
+ .contains("(version>=1.2.3)")
+ .contains("(!(version>=2.0.0))")
+ .doesNotContain("(!(version>=1.3.0))");
+
+ // "equivalent" with version 1.2.3: upper = 1.3.0
+ String pluginEquivFilter = requirements.stream()
+ .map(req -> req.getDirectives()
+ .get("filter"))
+ .filter(f -> f != null && f.contains("plugin.equivalent"))
+ .findFirst()
+ .orElse(null);
+
+ softly.assertThat(pluginEquivFilter)
+ .as("equivalent match on plugin.equivalent version 1.2.3 must give range [1.2.3, 1.3.0)")
+ .isNotNull()
+ .contains("(version>=1.2.3)")
+ .contains("(!(version>=1.3.0))")
+ .doesNotContain("(!(version>=1.2.4))");
+ }
+}
diff --git a/biz.aQute.repository/test/aQute/bnd/repository/p2/provider/Eclipse438FeatureTest.java b/biz.aQute.repository/test/aQute/bnd/repository/p2/provider/Eclipse438FeatureTest.java
new file mode 100644
index 0000000000..7ac72c8193
--- /dev/null
+++ b/biz.aQute.repository/test/aQute/bnd/repository/p2/provider/Eclipse438FeatureTest.java
@@ -0,0 +1,420 @@
+package aQute.bnd.repository.p2.provider;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.File;
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.osgi.framework.namespace.IdentityNamespace;
+import org.osgi.resource.Capability;
+import org.osgi.resource.Resource;
+
+import aQute.bnd.http.HttpClient;
+import aQute.bnd.osgi.Processor;
+import aQute.bnd.osgi.resource.RequirementBuilder;
+import aQute.bnd.osgi.resource.ResourceUtils;
+import aQute.bnd.test.jupiter.InjectTemporaryDirectory;
+import aQute.lib.io.IO;
+import aQute.p2.packed.Unpack200;
+import aQute.p2.provider.Feature;
+
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+
+/**
+ * Comprehensive test to verify parsing of Eclipse 4.38 P2 repository features.
+ * This test validates that:
+ * 1. Features are correctly identified in the repository
+ * 2. Feature JARs can be downloaded from the features/ directory
+ * 3. feature.xml files are correctly parsed from feature JARs
+ * 4. Features are indexed with proper capabilities and requirements
+ * 5. Plugin references and included features are correctly extracted
+ */
+//@Disabled("Deactivated for server builds - only for manual local testing due to long runtime")
+@ExtendWith(SoftAssertionsExtension.class)
+public class Eclipse438FeatureTest {
+
+ @InjectSoftAssertions
+ SoftAssertions softly;
+
+ @InjectTemporaryDirectory
+ File tmp;
+
+ // Eclipse 4.38 P2 repository URL
+ private static final String ECLIPSE_438_REPO = "https://download.eclipse.org/eclipse/updates/4.38/R-4.38-202512010920/";
+
+ /**
+ * Test that the Eclipse 4.38 P2 repository is properly indexed with features
+ */
+ @Test
+ public void testEclipse438RepositoryIndexing() throws Exception {
+ try (HttpClient client = new HttpClient(); Processor proc = new Processor()) {
+ client.setCache(IO.getFile(tmp, "http-cache"));
+
+ Unpack200 processor = new Unpack200(proc);
+ File indexLocation = new File(tmp, "eclipse-438-index");
+
+ System.out.println("Creating P2 indexer for Eclipse 4.38 repository...");
+ P2Indexer indexer = new P2Indexer(processor, proc, indexLocation, client, new URI(ECLIPSE_438_REPO),
+ "Eclipse 4.38");
+
+ // Verify index was created
+ softly.assertThat(indexLocation)
+ .as("Index location should exist")
+ .exists()
+ .isDirectory();
+
+ File indexFile = new File(indexLocation, "index.xml.gz");
+ softly.assertThat(indexFile)
+ .as("Index file should be created")
+ .exists();
+
+ System.out.println("Index location: " + indexLocation.getAbsolutePath());
+ System.out.println("Index file: " + indexFile.getAbsolutePath() + " (" + indexFile.length() + " bytes)");
+ }
+ }
+
+ /**
+ * Test feature extraction and parsing from Eclipse 4.38 repository
+ */
+ @Test
+ public void testEclipse438FeatureExtraction() throws Exception {
+ try (HttpClient client = new HttpClient(); Processor proc = new Processor()) {
+ client.setCache(IO.getFile(tmp, "http-cache"));
+
+ Unpack200 processor = new Unpack200(proc);
+ File indexLocation = new File(tmp, "eclipse-438-features");
+
+ System.out.println("\n=== Testing Eclipse 4.38 Feature Extraction ===");
+ P2Indexer indexer = new P2Indexer(processor, proc, indexLocation, client, new URI(ECLIPSE_438_REPO),
+ "Eclipse 4.38");
+
+ // Get all resources from the repository
+ org.osgi.service.repository.Repository repository = indexer.getBridge()
+ .getRepository();
+ RequirementBuilder rb = new RequirementBuilder(IdentityNamespace.IDENTITY_NAMESPACE);
+ org.osgi.resource.Requirement req = rb.buildSyntheticRequirement();
+
+ java.util.Collection allCaps = repository
+ .findProviders(java.util.Collections.singleton(req))
+ .getOrDefault(req, java.util.Collections.emptyList());
+
+ System.out.println("Total capabilities in repository: " + allCaps.size());
+
+ // Get unique resources
+ java.util.Set allResources = ResourceUtils.getResources(allCaps);
+ System.out.println("Total resources in repository: " + allResources.size());
+
+ // Count and categorize resources by type
+ Map typeCount = allResources.stream()
+ .flatMap(r -> r.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE)
+ .stream())
+ .map(cap -> (String) cap.getAttributes()
+ .get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE))
+ .filter(type -> type != null)
+ .collect(Collectors.groupingBy(type -> type, Collectors.counting()));
+
+ System.out.println("\nResource types in repository:");
+ typeCount.forEach((type, count) -> System.out.println(" " + type + ": " + count));
+
+ // Verify features exist
+ long featureCount = typeCount.getOrDefault("org.eclipse.update.feature", 0L);
+ softly.assertThat(featureCount)
+ .as("Eclipse 4.38 repository should contain features")
+ .isGreaterThan(0);
+
+ // Extract features using the getFeatures() method
+ System.out.println("\n=== Extracting features ===");
+ List features = indexer.getFeatures();
+
+ System.out.println("Total features extracted: " + features.size());
+ softly.assertThat(features)
+ .as("Should be able to extract features from repository")
+ .isNotEmpty();
+
+ // Analyze and validate features
+ int validatedCount = 0;
+ int withPlugins = 0;
+ int withIncludes = 0;
+ int withRequires = 0;
+
+ for (Feature feature : features) {
+ // Basic validation
+ softly.assertThat(feature.getId())
+ .as("Feature should have an ID")
+ .isNotNull()
+ .isNotEmpty();
+
+ softly.assertThat(feature.getVersion())
+ .as("Feature " + feature.getId() + " should have a version")
+ .isNotNull()
+ .isNotEmpty();
+
+ // Count features with different elements
+ if (!feature.getPlugins()
+ .isEmpty()) {
+ withPlugins++;
+ }
+ if (!feature.getIncludes()
+ .isEmpty()) {
+ withIncludes++;
+ }
+ if (!feature.getRequires()
+ .isEmpty()) {
+ withRequires++;
+ }
+
+ // Print details for first few features
+ if (validatedCount < 5) {
+ System.out.println("\nFeature #" + (validatedCount + 1) + ": " + feature.getId());
+ System.out.println(" Version: " + feature.getVersion());
+ System.out.println(" Label: " + feature.getLabel());
+ System.out.println(" Provider: " + feature.getProviderName());
+ System.out.println(" Plugins: " + feature.getPlugins()
+ .size());
+ System.out.println(" Includes: " + feature.getIncludes()
+ .size());
+ System.out.println(" Requires: " + feature.getRequires()
+ .size());
+
+ // Print some plugin details
+ if (!feature.getPlugins()
+ .isEmpty()) {
+ System.out.println(" Sample plugins:");
+ feature.getPlugins()
+ .stream()
+ .limit(3)
+ .forEach(
+ p -> System.out.println(" - " + p.id + " version=" + p.version));
+ }
+
+ // Print some included features
+ if (!feature.getIncludes()
+ .isEmpty()) {
+ System.out.println(" Included features:");
+ feature.getIncludes()
+ .stream()
+ .limit(3)
+ .forEach(inc -> System.out
+ .println(" - " + inc.id + " version=" + inc.version
+ + (inc.optional ? " (optional)" : "")));
+ }
+ }
+
+ validatedCount++;
+ }
+
+ System.out.println("\n=== Feature Statistics ===");
+ System.out.println("Total features validated: " + validatedCount);
+ System.out.println("Features with plugins: " + withPlugins);
+ System.out.println("Features with includes: " + withIncludes);
+ System.out.println("Features with requires: " + withRequires);
+
+ // Validate that many features have content
+ softly.assertThat(withPlugins)
+ .as("Many features should contain plugin references")
+ .isGreaterThan(0);
+ }
+ }
+
+ /**
+ * Test retrieval of specific well-known Eclipse platform features
+ */
+ @Test
+ public void testEclipse438SpecificFeatures() throws Exception {
+ try (HttpClient client = new HttpClient(); Processor proc = new Processor()) {
+ client.setCache(IO.getFile(tmp, "http-cache"));
+
+ Unpack200 processor = new Unpack200(proc);
+ File indexLocation = new File(tmp, "eclipse-438-specific");
+
+ System.out.println("\n=== Testing Specific Eclipse 4.38 Features ===");
+ P2Indexer indexer = new P2Indexer(processor, proc, indexLocation, client, new URI(ECLIPSE_438_REPO),
+ "Eclipse 4.38");
+
+ // Get all features to find available ones
+ List allFeatures = indexer.getFeatures();
+ softly.assertThat(allFeatures)
+ .as("Repository should contain features")
+ .isNotEmpty();
+
+ // Print first few features for reference
+ System.out.println("\nAvailable features (first 10):");
+ allFeatures.stream()
+ .limit(10)
+ .forEach(f -> System.out.println(" - " + f.getId() + " version " + f.getVersion()));
+
+ // Try to get a specific feature
+ if (!allFeatures.isEmpty()) {
+ Feature firstFeature = allFeatures.get(0);
+ System.out.println("\nTesting getFeature() with: " + firstFeature.getId() + " v" + firstFeature.getVersion());
+
+ Feature retrieved = indexer.getFeature(firstFeature.getId(), firstFeature.getVersion());
+
+ softly.assertThat(retrieved)
+ .as("Should be able to retrieve feature by ID and version")
+ .isNotNull();
+
+ if (retrieved != null) {
+ softly.assertThat(retrieved.getId())
+ .as("Retrieved feature should have correct ID")
+ .isEqualTo(firstFeature.getId());
+
+ softly.assertThat(retrieved.getVersion())
+ .as("Retrieved feature should have correct version")
+ .isEqualTo(firstFeature.getVersion());
+
+ System.out.println("Successfully retrieved feature: " + retrieved.getId());
+ System.out.println(" Plugins: " + retrieved.getPlugins()
+ .size());
+ System.out.println(" Includes: " + retrieved.getIncludes()
+ .size());
+ }
+ }
+
+ // Look for platform feature (common in Eclipse releases)
+ Feature platformFeature = allFeatures.stream()
+ .filter(f -> f.getId() != null && f.getId()
+ .contains("org.eclipse.platform"))
+ .findFirst()
+ .orElse(null);
+
+ if (platformFeature != null) {
+ System.out.println("\nFound platform feature: " + platformFeature.getId());
+ System.out.println(" Version: " + platformFeature.getVersion());
+ System.out.println(" Label: " + platformFeature.getLabel());
+ System.out.println(" Plugins: " + platformFeature.getPlugins()
+ .size());
+ System.out.println(" Includes: " + platformFeature.getIncludes()
+ .size());
+
+ softly.assertThat(platformFeature.getId())
+ .as("Platform feature should have ID")
+ .isNotNull();
+
+ softly.assertThat(platformFeature.getPlugins())
+ .as("Platform feature should have plugins")
+ .isNotEmpty();
+ }
+ }
+ }
+
+ /**
+ * Test that feature parsing handles feature.properties correctly
+ */
+ @Test
+ public void testEclipse438FeatureProperties() throws Exception {
+ try (HttpClient client = new HttpClient(); Processor proc = new Processor()) {
+ client.setCache(IO.getFile(tmp, "http-cache"));
+
+ Unpack200 processor = new Unpack200(proc);
+ File indexLocation = new File(tmp, "eclipse-438-properties");
+
+ System.out.println("\n=== Testing Feature Property Resolution ===");
+ P2Indexer indexer = new P2Indexer(processor, proc, indexLocation, client, new URI(ECLIPSE_438_REPO),
+ "Eclipse 4.38");
+
+ List features = indexer.getFeatures();
+
+ // Check that properties are resolved (not showing %key references)
+ int resolvedLabels = 0;
+ int resolvedProviders = 0;
+
+ for (Feature feature : features) {
+ if (feature.getLabel() != null && !feature.getLabel()
+ .startsWith("%")) {
+ resolvedLabels++;
+ if (resolvedLabels <= 3) {
+ System.out.println("Feature " + feature.getId() + " has resolved label: " + feature.getLabel());
+ }
+ }
+
+ if (feature.getProviderName() != null && !feature.getProviderName()
+ .startsWith("%")) {
+ resolvedProviders++;
+ }
+ }
+
+ System.out.println("\nFeatures with resolved labels: " + resolvedLabels + "/" + features.size());
+ System.out.println("Features with resolved provider names: " + resolvedProviders + "/" + features.size());
+
+ // Most features should have resolved properties
+ softly.assertThat(resolvedLabels)
+ .as("Many features should have resolved labels")
+ .isGreaterThan(features.size() / 2);
+ }
+ }
+
+ /**
+ * Test that resource capabilities are correctly created for features
+ */
+ @Test
+ public void testEclipse438FeatureCapabilities() throws Exception {
+ try (HttpClient client = new HttpClient(); Processor proc = new Processor()) {
+ client.setCache(IO.getFile(tmp, "http-cache"));
+
+ Unpack200 processor = new Unpack200(proc);
+ File indexLocation = new File(tmp, "eclipse-438-capabilities");
+
+ System.out.println("\n=== Testing Feature Capabilities in Index ===");
+ P2Indexer indexer = new P2Indexer(processor, proc, indexLocation, client, new URI(ECLIPSE_438_REPO),
+ "Eclipse 4.38");
+
+ // Get repository and check feature resources
+ org.osgi.service.repository.Repository repository = indexer.getBridge()
+ .getRepository();
+ RequirementBuilder rb = new RequirementBuilder(IdentityNamespace.IDENTITY_NAMESPACE);
+ org.osgi.resource.Requirement req = rb.buildSyntheticRequirement();
+
+ java.util.Collection allCaps = repository
+ .findProviders(java.util.Collections.singleton(req))
+ .getOrDefault(req, java.util.Collections.emptyList());
+
+ // Find feature capabilities
+ List featureCaps = allCaps.stream()
+ .filter(cap -> "org.eclipse.update.feature"
+ .equals(cap.getAttributes()
+ .get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE)))
+ .collect(Collectors.toList());
+
+ System.out.println("Total feature capabilities: " + featureCaps.size());
+
+ softly.assertThat(featureCaps)
+ .as("Index should contain feature capabilities")
+ .isNotEmpty();
+
+ // Inspect first few feature capabilities
+ int count = 0;
+ for (Capability cap : featureCaps) {
+ if (count++ >= 3)
+ break;
+
+ Map attrs = cap.getAttributes();
+ System.out.println("\nFeature capability #" + count + ":");
+ System.out.println(" Identity: " + attrs.get(IdentityNamespace.IDENTITY_NAMESPACE));
+ System.out.println(" Version: " + attrs.get(IdentityNamespace.CAPABILITY_VERSION_ATTRIBUTE));
+ System.out.println(" Type: " + attrs.get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE));
+
+ // Validate required attributes
+ softly.assertThat(attrs.get(IdentityNamespace.IDENTITY_NAMESPACE))
+ .as("Feature capability should have identity")
+ .isNotNull();
+
+ softly.assertThat(attrs.get(IdentityNamespace.CAPABILITY_VERSION_ATTRIBUTE))
+ .as("Feature capability should have version")
+ .isNotNull();
+
+ softly.assertThat(attrs.get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE))
+ .as("Feature capability should have type")
+ .isEqualTo("org.eclipse.update.feature");
+ }
+ }
+ }
+}
diff --git a/biz.aQute.repository/test/aQute/bnd/repository/p2/provider/Eclipse438PlatformFeatureTest.java b/biz.aQute.repository/test/aQute/bnd/repository/p2/provider/Eclipse438PlatformFeatureTest.java
new file mode 100644
index 0000000000..aa3f4564e9
--- /dev/null
+++ b/biz.aQute.repository/test/aQute/bnd/repository/p2/provider/Eclipse438PlatformFeatureTest.java
@@ -0,0 +1,333 @@
+package aQute.bnd.repository.p2.provider;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.File;
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.osgi.framework.namespace.IdentityNamespace;
+import org.osgi.resource.Capability;
+import org.osgi.resource.Resource;
+
+import aQute.bnd.http.HttpClient;
+import aQute.bnd.osgi.Processor;
+import aQute.bnd.test.jupiter.InjectTemporaryDirectory;
+import aQute.p2.packed.Unpack200;
+import aQute.p2.provider.Feature;
+
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+
+/**
+ * Test Eclipse 4.38 Platform repository to ensure features are
+ * properly extracted and available, even when full JAR extraction fails.
+ * This tests the minimal feature creation fallback mechanism.
+ */
+@ExtendWith(SoftAssertionsExtension.class)
+public class Eclipse438PlatformFeatureTest {
+
+ @InjectSoftAssertions
+ SoftAssertions softly;
+
+ @InjectTemporaryDirectory
+ File tmp;
+
+ // Use the actual Eclipse 4.38 Platform repository
+ private static final String ECLIPSE_438_PLATFORM_REPO = "https://download.eclipse.org/eclipse/updates/4.38/";
+
+ /**
+ * Test that org.eclipse.sdk feature (which we know exists) is present in the Eclipse 4.38
+ * Platform repository and can be retrieved via getFeatures().
+ * This ensures the minimal feature creation works when full extraction fails.
+ */
+ @Test
+ public void testEclipsePlatformFeatureIsAvailable() throws Exception {
+ try (HttpClient client = new HttpClient(); Processor proc = new Processor()) {
+ Unpack200 processor = new Unpack200(proc);
+
+ File indexLocation = new File(tmp, "eclipse-438-platform");
+
+ P2Indexer indexer = new P2Indexer(processor, proc, indexLocation, client, new URI(ECLIPSE_438_PLATFORM_REPO),
+ "Eclipse 4.38 Platform Test");
+
+ // Get features from the P2Indexer
+ List features = indexer.getFeatures();
+
+ softly.assertThat(features)
+ .as("Features list should not be null")
+ .isNotNull();
+
+ softly.assertThat(features)
+ .as("Features list should not be empty")
+ .isNotEmpty();
+
+ // Print all feature IDs to help debug
+ System.out.println("\nAll features in repository:");
+ features.forEach(f -> System.out.println(" - " + f.getId() + " : " + f.getVersion()));
+
+ // Find org.eclipse.sdk feature (which we know exists from the list above)
+ Feature sdkFeature = features.stream()
+ .filter(f -> "org.eclipse.sdk".equals(f.getId()))
+ .findFirst()
+ .orElse(null);
+
+ softly.assertThat(sdkFeature)
+ .as("org.eclipse.sdk feature must be present in features list")
+ .isNotNull();
+
+ if (sdkFeature != null) {
+ softly.assertThat(sdkFeature.getId())
+ .as("Feature ID should be org.eclipse.sdk")
+ .isEqualTo("org.eclipse.sdk");
+
+ softly.assertThat(sdkFeature.getVersion())
+ .as("Feature version should not be null")
+ .isNotNull();
+
+ // Check that version follows Eclipse pattern (should contain 4.38)
+ softly.assertThat(sdkFeature.getVersion())
+ .as("Feature version should contain 4.38")
+ .contains("4.38");
+
+ System.out.println("Found org.eclipse.sdk feature:");
+ System.out.println(" ID: " + sdkFeature.getId());
+ System.out.println(" Version: " + sdkFeature.getVersion());
+ System.out.println(" Label: " + sdkFeature.getLabel());
+ System.out.println(" Provider: " + sdkFeature.getProviderName());
+ }
+
+ // Also check for org.eclipse.platform feature (which exists as BOTH bundle and feature)
+ Feature platformFeature = features.stream()
+ .filter(f -> "org.eclipse.platform".equals(f.getId()))
+ .findFirst()
+ .orElse(null);
+
+ softly.assertThat(platformFeature)
+ .as("org.eclipse.platform feature must be present in features list (repo has both bundle and feature with this ID)")
+ .isNotNull();
+
+ if (platformFeature != null) {
+ System.out.println("\nFound org.eclipse.platform feature:");
+ System.out.println(" ID: " + platformFeature.getId());
+ System.out.println(" Version: " + platformFeature.getVersion());
+ System.out.println(" Label: " + platformFeature.getLabel());
+ System.out.println(" Provider: " + platformFeature.getProviderName());
+ }
+
+ // Count feature vs bundle resources
+ var bridge = indexer.getBridge();
+ var repository = bridge.getRepository();
+
+ aQute.bnd.osgi.resource.RequirementBuilder rb = new aQute.bnd.osgi.resource.RequirementBuilder(
+ IdentityNamespace.IDENTITY_NAMESPACE);
+ org.osgi.resource.Requirement req = rb.buildSyntheticRequirement();
+
+ var providers = repository.findProviders(java.util.Collections.singleton(req));
+ java.util.Collection allCaps = providers.get(req);
+ java.util.Set allResources = aQute.bnd.osgi.resource.ResourceUtils.getResources(allCaps);
+
+ // Look for org.eclipse.platform in raw resources
+ List platformResources = allResources.stream()
+ .filter(r -> {
+ List identityCaps = r.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE);
+ if (identityCaps.isEmpty())
+ return false;
+ Object id = identityCaps.get(0)
+ .getAttributes()
+ .get(IdentityNamespace.IDENTITY_NAMESPACE);
+ return "org.eclipse.platform".equals(id);
+ })
+ .toList();
+
+ System.out.println("\norg.eclipse.platform resources found: " + platformResources.size());
+ platformResources.forEach(r -> {
+ r.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE)
+ .forEach(cap -> {
+ Object type = cap.getAttributes()
+ .get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE);
+ Object label = cap.getAttributes()
+ .get("label");
+ Object providerName = cap.getAttributes()
+ .get("provider-name");
+ System.out.println(" Type: " + type);
+ if (label != null) {
+ System.out.println(" Label: " + label);
+ }
+ if (providerName != null) {
+ System.out.println(" Provider: " + providerName);
+ }
+ });
+ });
+
+ Map resourcesByType = allResources.stream()
+ .flatMap(r -> r.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE)
+ .stream())
+ .map(cap -> (String) cap.getAttributes()
+ .get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE))
+ .filter(type -> type != null)
+ .collect(Collectors.groupingBy(type -> type, Collectors.counting()));
+
+ System.out.println("\nResource type distribution:");
+ resourcesByType.forEach((type, count) -> System.out.println(" " + type + ": " + count));
+
+ softly.assertThat(resourcesByType.get("org.eclipse.update.feature"))
+ .as("Index should contain feature resources")
+ .isGreaterThan(0L);
+ }
+ }
+
+ /**
+ * Test that feature resources in the index have proper identity capabilities
+ * with the correct attributes (type, id, version, label, provider-name)
+ */
+ @Test
+ public void testFeatureResourcesHaveProperIdentityCapabilities() throws Exception {
+ try (HttpClient client = new HttpClient(); Processor proc = new Processor()) {
+ Unpack200 processor = new Unpack200(proc);
+
+ File indexLocation = new File(tmp, "eclipse-438-platform-2");
+
+ P2Indexer indexer = new P2Indexer(processor, proc, indexLocation, client, new URI(ECLIPSE_438_PLATFORM_REPO),
+ "Eclipse 4.38 Platform Test 2");
+
+ var bridge = indexer.getBridge();
+ var repository = bridge.getRepository();
+
+ // Find all feature resources
+ aQute.bnd.osgi.resource.RequirementBuilder rb = new aQute.bnd.osgi.resource.RequirementBuilder(
+ IdentityNamespace.IDENTITY_NAMESPACE);
+ org.osgi.resource.Requirement req = rb.buildSyntheticRequirement();
+
+ var providers = repository.findProviders(java.util.Collections.singleton(req));
+ java.util.Collection allCaps = providers.get(req);
+ java.util.Set allResources = aQute.bnd.osgi.resource.ResourceUtils.getResources(allCaps);
+
+ List featureResources = allResources.stream()
+ .filter(r -> {
+ List identityCaps = r.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE);
+ if (identityCaps.isEmpty())
+ return false;
+ Object type = identityCaps.get(0)
+ .getAttributes()
+ .get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE);
+ return "org.eclipse.update.feature".equals(type);
+ })
+ .toList();
+
+ softly.assertThat(featureResources)
+ .as("Should find feature resources in index")
+ .isNotEmpty();
+
+ // Find org.eclipse.sdk resource specifically (we know it exists)
+ Resource sdkResource = featureResources.stream()
+ .filter(r -> {
+ List identityCaps = r.getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE);
+ if (identityCaps.isEmpty())
+ return false;
+ Object id = identityCaps.get(0)
+ .getAttributes()
+ .get(IdentityNamespace.IDENTITY_NAMESPACE);
+ return "org.eclipse.sdk".equals(id);
+ })
+ .findFirst()
+ .orElse(null);
+
+ softly.assertThat(sdkResource)
+ .as("org.eclipse.sdk resource should be in index")
+ .isNotNull();
+
+ if (sdkResource != null) {
+ List identityCaps = sdkResource
+ .getCapabilities(IdentityNamespace.IDENTITY_NAMESPACE);
+ softly.assertThat(identityCaps)
+ .as("SDK resource should have identity capability")
+ .hasSize(1);
+
+ Capability identity = identityCaps.get(0);
+ Map