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

quantranhong1999 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 01d33d1bc535636c79b636493fcf7d5cd7f04047
Author: Quan Tran <[email protected]>
AuthorDate: Fri Jul 17 11:50:33 2026 +0700

    JAMES-4210 Add POP3 SASL protocol bridge
    
    Translate POP3 Base64 challenges and responses to shared SASL exchanges, 
including cancellation, final server data, and cleanup.
---
 .../james/protocols/pop3/sasl/Pop3SaslBridge.java  | 107 +++++++++++++
 .../protocols/pop3/sasl/Pop3SaslBridgeTest.java    | 166 +++++++++++++++++++++
 2 files changed, 273 insertions(+)

diff --git 
a/protocols/pop3/src/main/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridge.java
 
b/protocols/pop3/src/main/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridge.java
new file mode 100644
index 0000000000..ad9d1fffcf
--- /dev/null
+++ 
b/protocols/pop3/src/main/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridge.java
@@ -0,0 +1,107 @@
+/****************************************************************
+ * 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.james.protocols.pop3.sasl;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+import org.apache.james.protocols.api.Response;
+import org.apache.james.protocols.api.sasl.SaslExchange;
+import org.apache.james.protocols.api.sasl.SaslInitialRequest;
+import org.apache.james.protocols.api.sasl.SaslStep;
+import org.apache.james.protocols.pop3.POP3Response;
+
+public class Pop3SaslBridge {
+    private static final Pattern CANONICAL_BASE64 = 
Pattern.compile("(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?");
+
+    /** Converts a POP3 AUTH command into a protocol-neutral SASL initial 
request. */
+    public SaslInitialRequest initialRequest(String mechanismName, 
Optional<String> initialClientResponse) {
+        return new SaslInitialRequest(mechanismName, 
initialClientResponse.map(this::decodeInitialClientResponse));
+    }
+
+    /** Encodes a SASL challenge as a POP3 continuation response. */
+    public Response challenge(SaslStep.Challenge challenge) {
+        return continuation(challenge.payload());
+    }
+
+    /** Encodes final SASL server data as a POP3 continuation response. */
+    public Response successData(SaslStep.Success success) {
+        return continuation(success.serverData());
+    }
+
+    /** Decodes a POP3 client continuation and forwards it to the SASL 
exchange. */
+    public SaslStep onClientResponse(SaslExchange exchange, byte[] line) {
+        return exchange.onResponse(decodeBase64(stripTrailingCrlf(line)));
+    }
+
+    /** Detects the RFC 5034 client cancellation marker. */
+    public boolean isAbort(byte[] line) {
+        return "*".equals(stripTrailingCrlf(line));
+    }
+
+    /** Detects the empty acknowledgement required after final SASL server 
data. */
+    public boolean isEmptyClientResponse(byte[] line) {
+        return stripTrailingCrlf(line).isEmpty();
+    }
+
+    /** Aborts and releases an active SASL exchange. */
+    public void abort(SaslExchange exchange) {
+        exchange.abort();
+    }
+
+    /** Releases an active SASL exchange. */
+    public void close(SaslExchange exchange) {
+        exchange.close();
+    }
+
+    private Response continuation(Optional<byte[]> payload) {
+        String encodedPayload = payload
+            .map(Base64.getEncoder()::encodeToString)
+            .orElse("");
+        return new POP3Response("+", encodedPayload).immutable();
+    }
+
+    private byte[] decodeInitialClientResponse(String value) {
+        if (value.equals("=")) {
+            return new byte[0];
+        }
+        return decodeBase64(value);
+    }
+
+    private byte[] decodeBase64(String value) {
+        if (!CANONICAL_BASE64.matcher(value).matches()) {
+            throw new IllegalArgumentException("Invalid Base64 value");
+        }
+        return Base64.getDecoder().decode(value);
+    }
+
+    private String stripTrailingCrlf(byte[] line) {
+        String value = new String(line, StandardCharsets.US_ASCII);
+        if (value.endsWith("\r\n")) {
+            return value.substring(0, value.length() - 2);
+        }
+        if (value.endsWith("\n")) {
+            return value.substring(0, value.length() - 1);
+        }
+        return value;
+    }
+}
diff --git 
a/protocols/pop3/src/test/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridgeTest.java
 
b/protocols/pop3/src/test/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridgeTest.java
new file mode 100644
index 0000000000..d56c78ce77
--- /dev/null
+++ 
b/protocols/pop3/src/test/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridgeTest.java
@@ -0,0 +1,166 @@
+/****************************************************************
+ * 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.james.protocols.pop3.sasl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Optional;
+
+import org.apache.james.core.Username;
+import org.apache.james.protocols.api.Response;
+import org.apache.james.protocols.api.sasl.SaslExchange;
+import org.apache.james.protocols.api.sasl.SaslIdentity;
+import org.apache.james.protocols.api.sasl.SaslInitialRequest;
+import org.apache.james.protocols.api.sasl.SaslStep;
+import org.junit.jupiter.api.Test;
+
+class Pop3SaslBridgeTest {
+    private static final Username USER = Username.of("[email protected]");
+    private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER);
+
+    private final Pop3SaslBridge testee = new Pop3SaslBridge();
+
+    private static class RecordingExchange implements SaslExchange {
+        private byte[] lastClientResponse;
+        private boolean closed;
+
+        @Override
+        public SaslStep firstStep() {
+            return new SaslStep.Challenge(Optional.empty());
+        }
+
+        @Override
+        public SaslStep onResponse(byte[] clientResponse) {
+            lastClientResponse = clientResponse.clone();
+            return new SaslStep.Success(IDENTITY, Optional.empty());
+        }
+
+        @Override
+        public void close() {
+            closed = true;
+        }
+    }
+
+    @Test
+    void initialRequestShouldDecodeCanonicalBase64() {
+        SaslInitialRequest request = testee.initialRequest("PLAIN", 
Optional.of(encoded("initial")));
+
+        assertThat(request.initialResponse()).hasValueSatisfying(value -> 
assertThat(value).containsExactly(bytes("initial")));
+    }
+
+    @Test
+    void initialRequestShouldDecodeEqualSignAsEmptyResponse() {
+        SaslInitialRequest request = testee.initialRequest("PLAIN", 
Optional.of("="));
+
+        assertThat(request.initialResponse()).hasValueSatisfying(value -> 
assertThat(value).isEmpty());
+    }
+
+    @Test
+    void initialRequestShouldRejectMissingPadding() {
+        assertThatThrownBy(() -> testee.initialRequest("PLAIN", 
Optional.of("YQ")))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void initialRequestShouldRejectMisplacedPadding() {
+        assertThatThrownBy(() -> testee.initialRequest("PLAIN", 
Optional.of("Y=Q=")))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void challengeShouldEncodePop3Continuation() {
+        Response response = testee.challenge(new 
SaslStep.Challenge(Optional.of(bytes("challenge"))));
+
+        assertThat(response.getLines()).containsExactly("+ " + 
encoded("challenge"));
+    }
+
+    @Test
+    void challengeShouldPreserveSpaceForEmptyPayload() {
+        Response response = testee.challenge(new 
SaslStep.Challenge(Optional.empty()));
+
+        assertThat(response.getLines()).containsExactly("+ ");
+    }
+
+    @Test
+    void successDataShouldEncodePop3Continuation() {
+        Response response = testee.successData(new SaslStep.Success(IDENTITY, 
Optional.of(bytes("server-data"))));
+
+        assertThat(response.getLines()).containsExactly("+ " + 
encoded("server-data"));
+    }
+
+    @Test
+    void onClientResponseShouldDecodeContinuation() {
+        RecordingExchange exchange = new RecordingExchange();
+
+        SaslStep result = testee.onClientResponse(exchange, 
(encoded("response") + "\r\n").getBytes(StandardCharsets.US_ASCII));
+
+        assertThat(result).isInstanceOf(SaslStep.Success.class);
+        
assertThat(exchange.lastClientResponse).containsExactly(bytes("response"));
+    }
+
+    @Test
+    void onClientResponseShouldAcceptEmptyResponse() {
+        RecordingExchange exchange = new RecordingExchange();
+
+        testee.onClientResponse(exchange, 
"\r\n".getBytes(StandardCharsets.US_ASCII));
+
+        assertThat(exchange.lastClientResponse).isEmpty();
+    }
+
+    @Test
+    void onClientResponseShouldRejectMissingPadding() {
+        RecordingExchange exchange = new RecordingExchange();
+
+        assertThatThrownBy(() -> testee.onClientResponse(exchange, 
"YQ\r\n".getBytes(StandardCharsets.US_ASCII)))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void isAbortShouldOnlyAcceptSingleAsterisk() {
+        
assertThat(testee.isAbort("*\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
+        
assertThat(testee.isAbort("**\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse();
+    }
+
+    @Test
+    void isEmptyClientResponseShouldAcceptEmptyLine() {
+        
assertThat(testee.isEmptyClientResponse("\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue();
+        
assertThat(testee.isEmptyClientResponse("=\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse();
+    }
+
+    @Test
+    void abortShouldUseCombinedExchangeLifecycleContract() {
+        RecordingExchange exchange = new RecordingExchange();
+
+        testee.abort(exchange);
+
+        assertThat(exchange.closed).isTrue();
+    }
+
+    private static String encoded(String value) {
+        return Base64.getEncoder().encodeToString(bytes(value));
+    }
+
+    private static byte[] bytes(String value) {
+        return value.getBytes(StandardCharsets.UTF_8);
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to