diff --git a/pnnl.goss.core/build.gradle b/pnnl.goss.core/build.gradle index 2ab604ae..6b47379a 100644 --- a/pnnl.goss.core/build.gradle +++ b/pnnl.goss.core/build.gradle @@ -20,7 +20,15 @@ dependencies { test { useJUnitPlatform() - + + // The activation-order test (GADP-012) asserts the generated Declarative + // Services descriptors, which are the real runtime contract. DS annotations + // are CLASS-retention and invisible to runtime reflection, so the bnd-emitted + // OSGI-INF XML is the authoritative artifact to assert against. Build the + // bundles first and hand the test their output directory. + dependsOn 'jar' + systemProperty 'goss.generated.dir', project.file('generated').absolutePath + testLogging { events "passed", "skipped", "failed" exceptionFormat "full" diff --git a/pnnl.goss.core/src/pnnl/goss/core/security/GossRealm.java b/pnnl.goss.core/src/pnnl/goss/core/security/GossRealm.java index 56ff3458..1e98d742 100644 --- a/pnnl.goss.core/src/pnnl/goss/core/security/GossRealm.java +++ b/pnnl.goss.core/src/pnnl/goss/core/security/GossRealm.java @@ -12,6 +12,31 @@ */ public interface GossRealm extends Realm, PermissionAdapter { + /** + * Service property name used to mark the system-authenticating realm + * (SystemBasedRealm) so ordering-sensitive consumers can select it with a + * target filter rather than accepting any bound GossRealm. See + * {@link #SYSTEM_REALM_TYPE} and GADP-012 / issue #1882. + */ + String REALM_TYPE_PROPERTY = "realm.type"; + + /** + * Service property value published by the system-authenticating realm + * (SystemBasedRealm). Consumers gate activation on this realm with + * {@code @Reference(target = SYSTEM_REALM_TARGET_FILTER)}. + */ + String SYSTEM_REALM_TYPE = "system"; + + /** + * The literal target filter string consumers pass to + * {@code @Reference(target = ...)} to select the system realm. Kept as its own + * constant (rather than assembled from the two constants above at runtime) + * because {@code @Reference(target = ...)} requires a compile-time constant + * expression, and bnd is strict about resolving that expression to a literal + * when it emits the OSGI-INF descriptor. + */ + String SYSTEM_REALM_TARGET_FILTER = "(" + REALM_TYPE_PROPERTY + "=" + SYSTEM_REALM_TYPE + ")"; + Set getPermissions(String identifier); boolean hasIdentifier(String identifier); diff --git a/pnnl.goss.core/src/pnnl/goss/core/security/impl/Activator.java b/pnnl.goss.core/src/pnnl/goss/core/security/impl/Activator.java index 284cb1f7..73f0c293 100644 --- a/pnnl.goss.core/src/pnnl/goss/core/security/impl/Activator.java +++ b/pnnl.goss.core/src/pnnl/goss/core/security/impl/Activator.java @@ -29,13 +29,17 @@ * for authentication and authorization. * * This component collects all GossRealm services and registers them with the - * SecurityManager before exposing the SecurityManager service. This ensures - * that authentication can work immediately when the broker starts. + * SecurityManager before exposing the SecurityManager service. It is the SINGLE + * writer of the SecurityManager's realm set (SecurityManagerRealmHandler no + * longer writes it), so the realm set cannot be clobbered mid-startup. * - * IMPORTANT: This component requires at least one GossRealm to be available - * (cardinality = AT_LEAST_ONE) before the SecurityManager service is - * registered. This prevents the race condition where GridOpticsServer tries to - * authenticate before any realms are configured. + * IMPORTANT: The SecurityManager service is not published until BOTH the + * AT_LEAST_ONE realmAdded binder has a realm AND the mandatory, target-filtered + * systemRealm reference is bound. The AT_LEAST_ONE guard alone was insufficient + * (GADP-012 / issue #1882): it guaranteed some realm, not the + * system-authenticating one, so GridOpticsServer could connect as + * system/manager against a realm set that returned null for "system". The + * systemRealm reference closes that gap. */ @Component(service = SecurityManager.class, immediate = true) public class Activator extends DefaultActiveMqSecurityManager { @@ -44,6 +48,34 @@ public class Activator extends DefaultActiveMqSecurityManager { private final Map, GossRealm> realmMap = new ConcurrentHashMap<>(); + /** + * Mandatory, target-filtered dependency on the system-authenticating realm + * (SystemBasedRealm, marked with the realm.type=system service property). + * + * This reference is the ordering guarantee for GADP-012 / issue #1882. + * Declarative Services will not invoke this component's @Activate, and thus + * will not publish the SecurityManager service, until the realm that + * authenticates the system/manager principal with the "*" permission is bound. + * Because GridOpticsServer holds a mandatory reference on the SecurityManager + * service, gating the service publication here transitively gates + * GridOpticsServer's broker-connect (createConnection("system", "manager")) on + * the system realm being present. + * + * Mandatory cardinality (the DS default 1..1) is what forces the ordering: DS + * holds activation until the reference is bound. Binding does not deadlock, + * because SystemBasedRealm depends only on SecurityConfig and + * GossPermissionResolver, neither of which depends back on the SecurityManager. + * The same realm instance is also collected by the dynamic realmAdded binder + * below, so it is included in the realm set. + * + * Never read directly in Java: the field's sole purpose is to give DS a + * mandatory reference to gate @Activate on, so it is unused from this class's + * own code and the compiler's unused-field warning is suppressed. + */ + @Reference(target = GossRealm.SYSTEM_REALM_TARGET_FILTER) + @SuppressWarnings("unused") + private volatile GossRealm systemRealm; + @Activate public void activate() { log.info("Activating SecurityManager service"); diff --git a/pnnl.goss.core/src/pnnl/goss/core/security/impl/SecurityManagerRealmHandler.java b/pnnl.goss.core/src/pnnl/goss/core/security/impl/SecurityManagerRealmHandler.java index c12c272a..340eafd5 100644 --- a/pnnl.goss.core/src/pnnl/goss/core/security/impl/SecurityManagerRealmHandler.java +++ b/pnnl.goss.core/src/pnnl/goss/core/security/impl/SecurityManagerRealmHandler.java @@ -10,9 +10,6 @@ import org.osgi.service.component.annotations.Reference; import org.osgi.service.component.annotations.ReferenceCardinality; import org.osgi.service.component.annotations.ReferencePolicy; -import org.apache.shiro.mgt.DefaultSecurityManager; -import org.apache.shiro.mgt.SecurityManager; -import org.apache.shiro.realm.Realm; import org.osgi.framework.ServiceReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,52 +22,36 @@ public class SecurityManagerRealmHandler implements PermissionAdapter { private static final Logger log = LoggerFactory.getLogger(SecurityManagerRealmHandler.class); - @Reference - private volatile SecurityManager securityManager; private final Map, GossRealm> realmMap = new ConcurrentHashMap<>(); @Activate public void activate() { - log.info("SecurityManagerRealmHandler activated with {} pending realms", realmMap.size()); - // Register any realms that were added before the SecurityManager was available - if (!realmMap.isEmpty()) { - registerAllRealms(); - } + log.info("SecurityManagerRealmHandler activated with {} known realms", realmMap.size()); } + /** + * Tracks GossRealm services so this component can answer getPermissions() + * across every realm (its PermissionAdapter role). + * + * This binder deliberately does NOT call setRealms() on the SecurityManager. + * The SecurityManager component (Activator) is the single writer of the + * SecurityManager's realm set; it collects the same GossRealm services and owns + * setRealms(). Having two independently-populated maps both write the shared + * SecurityManager was the double-writer race in GADP-012 / issue #1882: a + * partial write here could transiently drop the system realm from the set + * GridOpticsServer authenticates against. Realm wiring is now owned solely by + * the Activator; this component only reads realms for permissions. + */ @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC, unbind = "realmRemoved") public void realmAdded(ServiceReference ref, GossRealm handler) { realmMap.put(ref, handler); - log.debug("Realm added: {}", handler.getClass().getName()); - - // Only register if the SecurityManager is available - if (securityManager != null) { - registerAllRealms(); - } - } - - private synchronized void registerAllRealms() { - if (securityManager == null) { - log.warn("Cannot register realms - SecurityManager is null"); - return; - } - - DefaultSecurityManager defaultInstance = (DefaultSecurityManager) securityManager; - Set realms = new HashSet<>(); - for (GossRealm r : realmMap.values()) { - realms.add((Realm) r); - } - defaultInstance.setRealms(realms); - log.info("Registered {} realms with SecurityManager", realms.size()); + log.debug("Realm tracked for permissions: {}", handler.getClass().getName()); } public void realmRemoved(ServiceReference ref) { GossRealm removed = realmMap.remove(ref); - if (removed != null && securityManager != null) { - DefaultSecurityManager defaultInstance = (DefaultSecurityManager) securityManager; - if (defaultInstance.getRealms() != null) { - defaultInstance.getRealms().remove(removed); - } + if (removed != null) { + log.debug("Realm untracked for permissions: {}", removed.getClass().getName()); } } diff --git a/pnnl.goss.core/src/pnnl/goss/core/security/jwt/UnauthTokenBasedRealm.java b/pnnl.goss.core/src/pnnl/goss/core/security/jwt/UnauthTokenBasedRealm.java index e613b450..74184220 100644 --- a/pnnl.goss.core/src/pnnl/goss/core/security/jwt/UnauthTokenBasedRealm.java +++ b/pnnl.goss.core/src/pnnl/goss/core/security/jwt/UnauthTokenBasedRealm.java @@ -113,7 +113,7 @@ protected AuthenticationInfo doGetAuthenticationInfo( // look up permissions based on roles and add them Set permissions = new HashSet(); JWTAuthenticationToken tokenObj = securityConfig.parseToken(username); - log.info("Has token roles count: {}", + log.info("Has token roles count: {}", tokenObj.getRoles() != null ? tokenObj.getRoles().size() : 0); if (roleManager != null) { diff --git a/pnnl.goss.core/src/pnnl/goss/core/security/system/SystemBasedRealm.java b/pnnl.goss.core/src/pnnl/goss/core/security/system/SystemBasedRealm.java index 4b7cf79e..1aa6da87 100644 --- a/pnnl.goss.core/src/pnnl/goss/core/security/system/SystemBasedRealm.java +++ b/pnnl.goss.core/src/pnnl/goss/core/security/system/SystemBasedRealm.java @@ -39,10 +39,19 @@ * * NOTE: This class assumes uniqueness of username in the properties file. * + * The realm.type=system service property is the distinguishing marker that lets + * ordering-sensitive consumers (the SecurityManager Activator and + * GridOpticsServer) select this realm with a target filter, so Declarative + * Services can gate their activation on the system-authenticating realm rather + * than on "some realm". See GADP-012 / issue #1882: without this marker the + * AT_LEAST_ONE realm guard let GridOpticsServer connect as system/manager + * before this realm was wired. + * * @author Craig Allwardt * */ -@Component(service = GossRealm.class, configurationPid = "pnnl.goss.core.security.systemrealm", configurationPolicy = ConfigurationPolicy.REQUIRE) +@Component(service = GossRealm.class, configurationPid = "pnnl.goss.core.security.systemrealm", configurationPolicy = ConfigurationPolicy.REQUIRE, property = { + GossRealm.REALM_TYPE_PROPERTY + "=" + GossRealm.SYSTEM_REALM_TYPE}) public class SystemBasedRealm extends AuthorizingRealm implements GossRealm { private static final Logger log = LoggerFactory.getLogger(SystemBasedRealm.class); diff --git a/pnnl.goss.core/src/pnnl/goss/core/server/impl/GridOpticsServer.java b/pnnl.goss.core/src/pnnl/goss/core/server/impl/GridOpticsServer.java index ed2c2b15..fe1657e2 100644 --- a/pnnl.goss.core/src/pnnl/goss/core/server/impl/GridOpticsServer.java +++ b/pnnl.goss.core/src/pnnl/goss/core/server/impl/GridOpticsServer.java @@ -170,8 +170,27 @@ public class GridOpticsServer implements ServerControl { @Reference private volatile RequestHandlerRegistry handlerRegistry; - @Reference - private volatile GossRealm permissionAdapter; + /** + * Mandatory, target-filtered dependency on the system-authenticating realm + * (SystemBasedRealm, marked with the realm.type=system service property). + * + * This is the explicit activation-order gate for GADP-012 / issue #1882. + * Declarative Services will not invoke this component's @Activate (start(Map), + * which reaches createConnection("system", "manager") below) until the realm + * that authenticates the system principal with the "*" permission is bound. The + * SecurityManager reference above provides the same ordering transitively (its + * own component gates publication on this realm); holding the reference here as + * well makes the contract local, explicit, and independent of the + * SecurityManager component's internals. The field was previously declared as + * an unused permissionAdapter reference; it is retargeted, not added. + * + * Never read directly in Java: the field's sole purpose is to give DS a + * mandatory reference to gate @Activate on, so it is unused from this class's + * own code and the compiler's unused-field warning is suppressed. + */ + @Reference(target = GossRealm.SYSTEM_REALM_TARGET_FILTER) + @SuppressWarnings("unused") + private volatile GossRealm systemRealm; private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); diff --git a/pnnl.goss.core/test/pnnl/goss/core/security/impl/test/SystemRealmActivationOrderTest.java b/pnnl.goss.core/test/pnnl/goss/core/security/impl/test/SystemRealmActivationOrderTest.java new file mode 100644 index 00000000..0de2af90 --- /dev/null +++ b/pnnl.goss.core/test/pnnl/goss/core/security/impl/test/SystemRealmActivationOrderTest.java @@ -0,0 +1,313 @@ +package pnnl.goss.core.security.impl.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.Mockito.mock; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.SimpleAccount; +import org.apache.shiro.authc.UsernamePasswordToken; +import org.apache.shiro.authz.AuthorizationInfo; +import org.apache.shiro.mgt.RealmSecurityManager; +import org.apache.shiro.realm.AuthorizingRealm; +import org.apache.shiro.realm.Realm; +import org.apache.shiro.subject.PrincipalCollection; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.osgi.framework.ServiceReference; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import pnnl.goss.core.security.GossRealm; +import pnnl.goss.core.security.impl.Activator; + +/** + * Verifies the activation-order contract that closes GADP-012 / issue #1882. + * + * The regression: GridOpticsServer.start() authenticated the system/manager + * principal against the Shiro SecurityManager before the system-authenticating + * realm was wired, because the SecurityManager was published as soon as ANY + * realm bound (an AT_LEAST_ONE guard) and two components both wrote the realm + * set. When only a realm that returns null for "system" was present, startup + * died. + * + * The fix has two parts, both asserted here: 1. Ordering gate: the system realm + * carries a realm.type=system service property, and the SecurityManager + * component (Activator) holds a mandatory, target-filtered reference on it, so + * Declarative Services cannot publish the SecurityManager until the system + * realm is bound. 2. Single writer: SecurityManagerRealmHandler no longer + * writes the shared SecurityManager's realm set, so it cannot clobber the + * system realm. + * + * True multi-bundle activation ordering across a running Felix container is an + * integration concern (Pax Exam / a boot on Armando's machine), not a unit + * concern; that coverage gap is stated in the class-level note and in the + * report. These tests assert the wiring and authentication invariants that the + * container ordering depends on. + */ +public class SystemRealmActivationOrderTest { + + private static final String SYSTEM_USER = "system"; + private static final String SYSTEM_PASSWORD = "manager"; + + /** + * A GossRealm that authenticates the system principal with the "*" permission, + * mirroring SystemBasedRealm's production behavior without its ConfigAdmin + * wiring. + */ + private static final class FakeSystemRealm extends AuthorizingRealm implements GossRealm { + private final SimpleAccount account; + + FakeSystemRealm() { + this.account = new SimpleAccount(SYSTEM_USER, SYSTEM_PASSWORD, getName()); + this.account.addStringPermission("*"); + } + + @Override + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { + UsernamePasswordToken upToken = (UsernamePasswordToken) token; + return SYSTEM_USER.equals(upToken.getUsername()) ? account : null; + } + + @Override + protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { + String username = (String) getAvailablePrincipal(principals); + return SYSTEM_USER.equals(username) ? account : null; + } + + @Override + public Set getPermissions(String identifier) { + return SYSTEM_USER.equals(identifier) ? Set.of("*") : new HashSet<>(); + } + + @Override + public boolean hasIdentifier(String identifier) { + return SYSTEM_USER.equals(identifier); + } + } + + /** + * A GossRealm that intentionally returns null for the system principal, + * mirroring UnauthTokenBasedRealm's deliberate refusal to authenticate "system" + * (UnauthTokenBasedRealm.java:143-144). This models the realm that won the race + * in the #1882 failure. + */ + private static final class UnauthLikeRealm extends AuthorizingRealm implements GossRealm { + @Override + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { + UsernamePasswordToken upToken = (UsernamePasswordToken) token; + if (SYSTEM_USER.equals(upToken.getUsername())) { + return null; + } + return new SimpleAccount(upToken.getUsername(), new String(upToken.getPassword()), getName()); + } + + @Override + protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { + return null; + } + + @Override + public Set getPermissions(String identifier) { + return new HashSet<>(); + } + + @Override + public boolean hasIdentifier(String identifier) { + return false; + } + } + + @SuppressWarnings("unchecked") + private static ServiceReference realmRef() { + return mock(ServiceReference.class); + } + + @Test + @DisplayName("SystemBasedRealm publishes the realm.type=system service property the gate filters on") + public void systemRealmPublishesDistinguishingProperty() throws Exception { + Document doc = descriptor("pnnl.goss.core.security-system.jar", + "pnnl.goss.core.security.system.SystemBasedRealm"); + NodeList properties = doc.getElementsByTagName("property"); + boolean hasMarker = false; + for (int i = 0; i < properties.getLength(); i++) { + Element p = (Element) properties.item(i); + if ("realm.type".equals(p.getAttribute("name")) && "system".equals(p.getAttribute("value"))) { + hasMarker = true; + } + } + assertThat(hasMarker) + .as("system realm descriptor must publish realm.type=system so the target filter can select it") + .isTrue(); + } + + @Test + @DisplayName("SecurityManager component gates activation on the system realm via a mandatory target filter") + public void activatorReferencesSystemRealmWithTargetFilter() throws Exception { + Document doc = descriptor("pnnl.goss.core.goss-core-security.jar", + "pnnl.goss.core.security.impl.Activator"); + Element systemRef = referenceByName(doc, "systemRealm"); + assertNotNull(systemRef, + "Activator descriptor must declare a systemRealm reference so DS can gate @Activate on it"); + assertThat(systemRef.getAttribute("target")) + .as("reference must select the system realm specifically, not any realm") + .isEqualTo("(realm.type=system)"); + assertThat(systemRef.getAttribute("interface")) + .isEqualTo("pnnl.goss.core.security.GossRealm"); + // A missing cardinality attribute means the DS default 1..1 (mandatory), + // which is what forces the ordering: DS will not activate until it binds. + String cardinality = systemRef.getAttribute("cardinality"); + assertThat(cardinality.isEmpty() || "1..1".equals(cardinality)) + .as("systemRealm reference must be mandatory (1..1); was '%s'", cardinality) + .isTrue(); + } + + @Test + @DisplayName("SecurityManagerRealmHandler no longer references the SecurityManager (single-writer invariant)") + public void realmHandlerIsNotASecondRealmWriter() throws Exception { + Document doc = descriptor("pnnl.goss.core.goss-core-security.jar", + "pnnl.goss.core.security.impl.SecurityManagerRealmHandler"); + NodeList refs = doc.getElementsByTagName("reference"); + for (int i = 0; i < refs.getLength(); i++) { + Element ref = (Element) refs.item(i); + assertThat(ref.getAttribute("interface")) + .as("handler must not reference the SecurityManager; the Activator is the sole realm writer") + .isNotEqualTo("org.apache.shiro.mgt.SecurityManager"); + } + } + + @Test + @DisplayName("System principal fails to authenticate when only the unauth-style realm is wired (the #1882 failure)") + public void systemAuthFailsWithoutSystemRealm() { + Activator securityManager = new Activator(); + securityManager.realmAdded(realmRef(), new UnauthLikeRealm()); + securityManager.activate(); + + assertThatThrownBy(() -> securityManager.authenticate( + new UsernamePasswordToken(SYSTEM_USER, SYSTEM_PASSWORD))) + .as("without the system realm in the set, system auth must fail; this is the race symptom") + .isInstanceOf(AuthenticationException.class); + } + + @Test + @DisplayName("System principal authenticates once the system realm is in the wired realm set") + public void systemAuthSucceedsWithSystemRealmWired() { + Activator securityManager = new Activator(); + securityManager.realmAdded(realmRef(), new UnauthLikeRealm()); + securityManager.realmAdded(realmRef(), new FakeSystemRealm()); + securityManager.activate(); + + AuthenticationInfo info = securityManager.authenticate( + new UsernamePasswordToken(SYSTEM_USER, SYSTEM_PASSWORD)); + + assertNotNull(info, "system/manager must authenticate against the wired realm set"); + assertThat(info.getPrincipals().getPrimaryPrincipal()).isEqualTo(SYSTEM_USER); + + RealmSecurityManager rsm = securityManager; + assertThat(rsm.getRealms()) + .as("the final realm set the broker authenticates against must contain the system realm") + .anyMatch(r -> r instanceof FakeSystemRealm); + } + + @Test + @DisplayName("System realm survives a subsequent realm binding (no double-writer clobber)") + public void systemRealmSurvivesLaterRealmBinding() { + Activator securityManager = new Activator(); + securityManager.realmAdded(realmRef(), new FakeSystemRealm()); + securityManager.activate(); + // A later realm binding must extend, never replace, the realm set. + securityManager.realmAdded(realmRef(), new UnauthLikeRealm()); + + RealmSecurityManager rsm = securityManager; + assertThat(rsm.getRealms()).anyMatch(r -> r instanceof FakeSystemRealm); + assertThat(securityManager.authenticate( + new UsernamePasswordToken(SYSTEM_USER, SYSTEM_PASSWORD))) + .as("system auth must still succeed after another realm binds") + .isNotNull(); + + Realm systemRealm = rsm.getRealms().stream() + .filter(r -> r instanceof FakeSystemRealm).findFirst().orElseThrow(); + assertThat(((FakeSystemRealm) systemRealm).getPermissions(SYSTEM_USER)).contains("*"); + } + + /** + * Loads the generated Declarative Services descriptor for a component from a + * built bundle jar. DS annotations are CLASS-retention and invisible to runtime + * reflection, so the bnd-emitted OSGI-INF XML is the authoritative runtime + * contract to assert against. The 'goss.generated.dir' system property is set + * by the test task, which depends on the jar task, so under Gradle the bundle + * is guaranteed to exist: a missing bundle there is a real failure, not a skip. + * Only when the property is unset (an IDE run that bypasses the Gradle test + * task and its 'jar' dependency) is the case genuinely indeterminate, and this + * falls back to a skip. + */ + private Document descriptor(String bundleName, String componentName) throws Exception { + String generatedDir = System.getProperty("goss.generated.dir"); + assumeTrue(generatedDir != null, "goss.generated.dir not set; run via the Gradle test task"); + File bundle = new File(generatedDir, bundleName); + if (!bundle.isFile()) { + fail("bundle not built: " + bundle + + " (goss.generated.dir is set, so the 'jar' task ran; a missing bundle here" + + " is a real build/wiring failure, not a skip)"); + } + + try (JarFile jar = new JarFile(bundle)) { + String entryName = "OSGI-INF/" + componentName + ".xml"; + JarEntry entry = jar.getJarEntry(entryName); + if (entry == null) { + // Fall back to scanning OSGI-INF for a descriptor naming the component. + entry = findByComponentName(jar, componentName); + } + assertNotNull(entry, "no DS descriptor for " + componentName + " in " + bundleName); + try (InputStream in = jar.getInputStream(entry)) { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + return factory.newDocumentBuilder().parse(in); + } + } + } + + private JarEntry findByComponentName(JarFile jar, String componentName) throws IOException { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry e = entries.nextElement(); + if (e.getName().startsWith("OSGI-INF/") && e.getName().endsWith(".xml") + && e.getName().contains(componentName)) { + return e; + } + } + return null; + } + + private Element referenceByName(Document doc, String refName) { + NodeList refs = doc.getElementsByTagName("reference"); + for (int i = 0; i < refs.getLength(); i++) { + Node node = refs.item(i); + if (node instanceof Element) { + Element ref = (Element) node; + if (refName.equals(ref.getAttribute("name"))) { + return ref; + } + } + } + return null; + } +}