This is an automated email from the ASF dual-hosted git repository.

coheigea pushed a commit to branch 2_4_x-fixes
in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git


The following commit(s) were added to refs/heads/2_4_x-fixes by this push:
     new 770f19727 WSS4J messages now render correctly even if Santuario is 
never initialised at all. (#731)
770f19727 is described below

commit 770f19727765be0b071015232e7aa67b6604c16d
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Wed Sep 23 08:08:13 2026 +0100

    WSS4J messages now render correctly even if Santuario is never initialised 
at all. (#731)
---
 .../wss4j/common/ext/WSSecurityException.java      |  68 +++++++++++
 .../common/crypto/MerlinAKIKeyIdentifierTest.java  |  14 ++-
 .../common/ext/WSSecurityExceptionMessageTest.java | 132 +++++++++++++++++++++
 3 files changed, 212 insertions(+), 2 deletions(-)

diff --git 
a/ws-security-common/src/main/java/org/apache/wss4j/common/ext/WSSecurityException.java
 
b/ws-security-common/src/main/java/org/apache/wss4j/common/ext/WSSecurityException.java
index a837dbc48..d2f6d3f94 100644
--- 
a/ws-security-common/src/main/java/org/apache/wss4j/common/ext/WSSecurityException.java
+++ 
b/ws-security-common/src/main/java/org/apache/wss4j/common/ext/WSSecurityException.java
@@ -23,6 +23,10 @@ import 
org.apache.xml.security.exceptions.XMLSecurityException;
 
 import javax.xml.namespace.QName;
 
+import java.text.MessageFormat;
+import java.util.MissingResourceException;
+import java.util.ResourceBundle;
+
 /**
  * Exception class for WS-Security.
  */
@@ -126,8 +130,59 @@ public class WSSecurityException extends 
XMLSecurityException {
         }
     }
 
+    /**
+     * The WSS4J message bundle, resolved directly rather than through 
Santuario's I18n.
+     * <p/>
+     * I18n holds a single static bundle for the whole JVM and both of its 
init methods return
+     * early once it has been set, so whichever library initialises it first 
owns it for the
+     * lifetime of the JVM. WSProviderConfig installs {@code 
WSS4JResourceBundle}, which knows
+     * both WSS4J's message ids and Santuario's, but it only gets the chance 
if nothing has
+     * initialised Santuario before it. When it loses that race - an 
application that uses XML
+     * Signature or OpenSAML before it uses WSS4J, for instance - none of the 
ids in
+     * wss4j_errors.properties resolve any more, and every WSS4J error 
degrades to
+     * 'No message with ID "..." found in resource bundle "..."'.
+     * <p/>
+     * Resolving our own ids ourselves makes the text independent of who won 
that race. Ids we
+     * do not own are left to I18n, so Santuario's messages keep working as 
before.
+     */
+    private static final ResourceBundle WSS4J_MESSAGES = loadMessages();
+
+    private static ResourceBundle loadMessages() {
+        try {
+            return ResourceBundle.getBundle("messages.wss4j_errors");
+        } catch (Exception ex) { //NOPMD
+            // Should not happen, the bundle ships with this jar. Deliberately 
broad: this runs in
+            // a static initializer of the exception class every WSS4J failure 
path goes through,
+            // so anything thrown here would surface as an 
ExceptionInInitializerError in place of
+            // the real error. Falling back to I18n is always safe.
+            return null;
+        }
+    }
+
+    /**
+     * Format the message for a WSS4J message id, or null if the id is not one 
of ours, in which
+     * case the message that XMLSecurityException already derived via I18n is 
used instead.
+     */
+    private static String formatMessage(String msgId, Object... arguments) {
+        if (WSS4J_MESSAGES == null || msgId == null) {
+            return null;
+        }
+        try {
+            return MessageFormat.format(WSS4J_MESSAGES.getString(msgId), 
arguments);
+        } catch (MissingResourceException ex) { //NOPMD
+            // Not a WSS4J message id - it is one of Santuario's, so leave it 
to I18n
+            return null;
+        }
+    }
+
     private ErrorCode errorCode;
 
+    /**
+     * The message resolved from WSS4J's own bundle, or null when the message 
id is not one of
+     * WSS4J's. Never read directly, see {@link #getMessage()}.
+     */
+    private final String wss4jMessage;
+
     public WSSecurityException(ErrorCode errorCode) {
         this(errorCode, errorCode.name());
     }
@@ -135,26 +190,39 @@ public class WSSecurityException extends 
XMLSecurityException {
     public WSSecurityException(ErrorCode errorCode, String msgId) {
         super(msgId, new Object[]{});
         this.errorCode = errorCode;
+        this.wss4jMessage = formatMessage(msgId);
     }
 
     public WSSecurityException(ErrorCode errorCode, Exception exception) {
         super(exception);
         this.errorCode = errorCode;
+        // No message id involved, the message is the wrapped exception's own
+        this.wss4jMessage = null;
     }
 
     public WSSecurityException(ErrorCode errorCode, Exception exception, 
String msgId) {
         super(exception, msgId);
         this.errorCode = errorCode;
+        // Mirrors I18n.getExceptionMessage(String, Exception), which formats 
the wrapped
+        // exception's message as the single argument
+        this.wss4jMessage = formatMessage(msgId, exception.getMessage());
     }
 
     public WSSecurityException(ErrorCode errorCode, Exception exception, 
String msgId, Object[] arguments) {
         super(exception, msgId, arguments);
         this.errorCode = errorCode;
+        this.wss4jMessage = formatMessage(msgId, arguments);
     }
 
     public WSSecurityException(ErrorCode errorCode, String msgId, Object[] 
arguments) {
         super(msgId, arguments);
         this.errorCode = errorCode;
+        this.wss4jMessage = formatMessage(msgId, arguments);
+    }
+
+    @Override
+    public String getMessage() {
+        return wss4jMessage != null ? wss4jMessage : super.getMessage();
     }
 
     /**
diff --git 
a/ws-security-common/src/test/java/org/apache/wss4j/common/crypto/MerlinAKIKeyIdentifierTest.java
 
b/ws-security-common/src/test/java/org/apache/wss4j/common/crypto/MerlinAKIKeyIdentifierTest.java
index 36d6ad6da..5d5c6b68f 100644
--- 
a/ws-security-common/src/test/java/org/apache/wss4j/common/crypto/MerlinAKIKeyIdentifierTest.java
+++ 
b/ws-security-common/src/test/java/org/apache/wss4j/common/crypto/MerlinAKIKeyIdentifierTest.java
@@ -28,10 +28,10 @@ import org.apache.wss4j.common.util.Loader;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  * MerlinAKI finds the issuing certificate by matching the received 
certificate's
@@ -77,7 +77,17 @@ public class MerlinAKIKeyIdentifierTest {
         // path validation - which would arrive here wrapping a 
CertPathValidatorException.
         assertNull(ex.getCause(), "Expected no issuer to be selected, but one 
was and it failed "
                                   + "path validation: " + ex.getMessage());
-        assertTrue(ex.getMessage().contains("No trusted certs found"), 
ex.getMessage());
+        assertEquals(WSSecurityException.ErrorCode.FAILURE, ex.getErrorCode());
+
+        // Deliberately asserted on the message ID rather than on the 
formatted message. The text
+        // is rendered through Santuario's global I18n bundle, which is 
whichever bundle is
+        // installed first in the JVM and never replaced afterwards. If 
anything initialises
+        // Santuario before WSProviderConfig.init() installs 
WSS4JResourceBundle, "certpath" stops
+        // resolving and every WSS4J message degrades to "No message with ID 
... found in resource
+        // bundle ...". That is a property of the JVM the test happens to 
share, not of the code
+        // under test, and it made this assertion fail under a full module 
test run while passing
+        // when the class was run on its own. The message ID is stable either 
way.
+        assertEquals("certpath", ex.getMsgID());
     }
 
     private static X509Certificate getCertificate(String keyStoreFile, String 
password, String alias)
diff --git 
a/ws-security-common/src/test/java/org/apache/wss4j/common/ext/WSSecurityExceptionMessageTest.java
 
b/ws-security-common/src/test/java/org/apache/wss4j/common/ext/WSSecurityExceptionMessageTest.java
new file mode 100644
index 000000000..3e7182c33
--- /dev/null
+++ 
b/ws-security-common/src/test/java/org/apache/wss4j/common/ext/WSSecurityExceptionMessageTest.java
@@ -0,0 +1,132 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.wss4j.common.ext;
+
+import java.lang.reflect.Field;
+import java.util.Locale;
+import java.util.ResourceBundle;
+
+import org.apache.wss4j.common.crypto.WSProviderConfig;
+import org.apache.xml.security.utils.I18n;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Santuario's I18n holds one static resource bundle for the whole JVM and 
ignores every attempt
+ * to set it after the first, so the bundle WSS4J error messages are rendered 
from depends on
+ * whether WSProviderConfig ran before anything else initialised Santuario. 
WSS4J message ids must
+ * resolve either way.
+ */
+public class WSSecurityExceptionMessageTest {
+
+    @BeforeAll
+    public static void setup() throws Exception {
+        WSProviderConfig.init();
+    }
+
+    /**
+     * The case that used to degrade: Santuario was initialised first, so I18n 
holds the
+     * xmlsecurity bundle and none of WSS4J's own message ids are in it. The 
message used to come
+     * out as 'No message with ID "certpath" found in resource bundle "..."'.
+     */
+    @Test
+    public void testWSS4JMessageIdResolvesWhenSantuarioOwnsTheBundle() throws 
Exception {
+        ResourceBundle previous = installXMLSecBundle();
+        try {
+            WSSecurityException ex = new WSSecurityException(
+                WSSecurityException.ErrorCode.FAILURE, "certpath", new 
Object[] {"No trusted certs found"});
+
+            assertEquals("Error during certificate path validation: No trusted 
certs found", ex.getMessage());
+        } finally {
+            restoreBundle(previous);
+        }
+    }
+
+    /**
+     * A message id with no arguments, taken from the WSS4J bundle rather than 
formatted from it.
+     */
+    @Test
+    public void 
testArgumentLessWSS4JMessageIdResolvesWhenSantuarioOwnsTheBundle() throws 
Exception {
+        ResourceBundle previous = installXMLSecBundle();
+        try {
+            WSSecurityException ex = new 
WSSecurityException(WSSecurityException.ErrorCode.FAILURE);
+
+            assertEquals("General security error", ex.getMessage());
+        } finally {
+            restoreBundle(previous);
+        }
+    }
+
+    /**
+     * WSS4J also throws with message ids that belong to Santuario, such as 
"empty". Those are not
+     * in wss4j_errors.properties and must keep being resolved by I18n.
+     */
+    @Test
+    public void testSantuarioMessageIdIsLeftToI18n() throws Exception {
+        WSSecurityException ex = new WSSecurityException(
+            WSSecurityException.ErrorCode.FAILURE, "empty", new Object[] 
{"Attachment not found: cid:foo"});
+
+        assertEquals("Attachment not found: cid:foo", ex.getMessage());
+    }
+
+    /**
+     * The wrapped exception's message is the single argument, as I18n does it.
+     */
+    @Test
+    public void testWrappedExceptionMessageIsUsedAsTheArgument() throws 
Exception {
+        ResourceBundle previous = installXMLSecBundle();
+        try {
+            WSSecurityException ex = new WSSecurityException(
+                WSSecurityException.ErrorCode.FAILURE, new 
IllegalStateException("no path"), "certpath");
+
+            assertEquals("Error during certificate path validation: no path", 
ex.getMessage());
+        } finally {
+            restoreBundle(previous);
+        }
+    }
+
+    private static ResourceBundle installXMLSecBundle() throws Exception {
+        ResourceBundle xmlSecBundle =
+            
ResourceBundle.getBundle("org/apache/xml/security/resource/xmlsecurity", 
Locale.US);
+        return swapBundle(xmlSecBundle);
+    }
+
+    private static void restoreBundle(ResourceBundle previous) throws 
Exception {
+        swapBundle(previous);
+    }
+
+    private static ResourceBundle swapBundle(ResourceBundle bundle) throws 
Exception {
+        Field field;
+        try {
+            field = I18n.class.getDeclaredField("resourceBundle");
+            field.setAccessible(true);  //NOPMD
+        } catch (NoSuchFieldException | RuntimeException ex) {
+            // I18n is not ours, so do not fail the build if it is reshaped or 
locked down
+            Assumptions.abort("Cannot reach Santuario's I18n resource bundle: 
" + ex.getMessage());
+            return null;
+        }
+        ResourceBundle previous = (ResourceBundle)field.get(null);
+        field.set(null, bundle);
+        return previous;
+    }
+}

Reply via email to