Bug Fix: SSE Events lines MUST NOT contain \r (#5868)

diff --git a/media/sse/src/main/java/org/glassfish/jersey/media/sse/OutboundEventWriter.java b/media/sse/src/main/java/org/glassfish/jersey/media/sse/OutboundEventWriter.java
index d2e7b85..12620b2 100644
--- a/media/sse/src/main/java/org/glassfish/jersey/media/sse/OutboundEventWriter.java
+++ b/media/sse/src/main/java/org/glassfish/jersey/media/sse/OutboundEventWriter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2012, 2023 Oracle and/or its affiliates. All rights reserved.
+ * Copyright (c) 2012, 2025 Oracle and/or its affiliates. All rights reserved.
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Public License v. 2.0, which is available at
@@ -16,11 +16,15 @@
 
 package org.glassfish.jersey.media.sse;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import java.io.IOException;
 import java.io.OutputStream;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Type;
 import java.nio.charset.Charset;
+import java.util.Objects;
+import java.util.regex.Pattern;
 
 import jakarta.ws.rs.WebApplicationException;
 import jakarta.ws.rs.core.Context;
@@ -43,15 +47,14 @@
  */
 class OutboundEventWriter implements MessageBodyWriter<OutboundSseEvent> {
 
-    private static final Charset UTF8 = Charset.forName("UTF-8");
-
     // encoding does not matter (lower ASCII characters)
-    private static final byte[] COMMENT_LEAD = ": ".getBytes(UTF8);
-    private static final byte[] NAME_LEAD = "event: ".getBytes(UTF8);
-    private static final byte[] ID_LEAD = "id: ".getBytes(UTF8);
-    private static final byte[] RETRY_LEAD = "retry: ".getBytes(UTF8);
-    private static final byte[] DATA_LEAD = "data: ".getBytes(UTF8);
+    private static final byte[] COMMENT_LEAD = ": ".getBytes(UTF_8);
+    private static final byte[] NAME_LEAD = "event: ".getBytes(UTF_8);
+    private static final byte[] ID_LEAD = "id: ".getBytes(UTF_8);
+    private static final byte[] RETRY_LEAD = "retry: ".getBytes(UTF_8);
+    private static final byte[] DATA_LEAD = "data: ".getBytes(UTF_8);
     private static final byte[] EOL = {'\n'};
+    private static final Pattern EOL_PATTERN = Pattern.compile("\r\n|\r|\n");
 
     private final Provider<MessageBodyWorkers> workersProvider;
 
@@ -87,7 +90,7 @@
 
         final Charset charset = MessageUtils.getCharset(mediaType);
         if (outboundEvent.getComment() != null) {
-            for (final String comment : outboundEvent.getComment().split("\n")) {
+            for (final String comment : EOL_PATTERN.split(outboundEvent.getComment())) {
                 entityStream.write(COMMENT_LEAD);
                 entityStream.write(comment.getBytes(charset));
                 entityStream.write(EOL);
@@ -97,12 +100,12 @@
         if (outboundEvent.getType() != null) {
             if (outboundEvent.getName() != null) {
                 entityStream.write(NAME_LEAD);
-                entityStream.write(outboundEvent.getName().getBytes(charset));
+                entityStream.write(outboundEvent.getName().replace("\r", "").replace("\n", "").getBytes(charset));
                 entityStream.write(EOL);
             }
             if (outboundEvent.getId() != null) {
                 entityStream.write(ID_LEAD);
-                entityStream.write(outboundEvent.getId().getBytes(charset));
+                entityStream.write(outboundEvent.getId().replace("\r", "").replace("\n", "").getBytes(charset));
                 entityStream.write(EOL);
             }
             if (outboundEvent.getReconnectDelay() > SseFeature.RECONNECT_NOT_SET) {
@@ -115,6 +118,7 @@
                     outboundEvent.getMediaType() == null ? MediaType.TEXT_PLAIN_TYPE : outboundEvent.getMediaType();
             final MessageBodyWriter messageBodyWriter = workersProvider.get().getMessageBodyWriter(outboundEvent.getType(),
                     outboundEvent.getGenericType(), annotations, eventMediaType);
+            final var dataLeadStream = new DataLeadStream(entityStream);
             messageBodyWriter.writeTo(
                     outboundEvent.getData(),
                     outboundEvent.getType(),
@@ -122,23 +126,74 @@
                     annotations,
                     eventMediaType,
                     httpHeaders,
-                    new OutputStream() {
-
-                        private boolean start = true;
-
-                        @Override
-                        public void write(final int i) throws IOException {
-                            if (start) {
-                                entityStream.write(DATA_LEAD);
-                                start = false;
-                            }
-                            entityStream.write(i);
-                            if (i == '\n') {
-                                entityStream.write(DATA_LEAD);
-                            }
-                        }
-                    });
+                    dataLeadStream);
+            dataLeadStream.finish();
             entityStream.write(EOL);
         }
     }
+
+    static final class DataLeadStream extends OutputStream {
+        private final OutputStream entityStream;
+
+        private int lastChar = -1;
+
+        DataLeadStream(final OutputStream entityStream) {
+            this.entityStream = entityStream;
+        }
+
+        @Override
+        public void write(final int i) throws IOException {
+            if (lastChar == -1) {
+                entityStream.write(DATA_LEAD);
+            } else if (lastChar != '\n' && lastChar != '\r') {
+                entityStream.write(lastChar);
+            } else if (lastChar == '\n' || lastChar == '\r' && i != '\n') {
+                entityStream.write(EOL);
+                entityStream.write(DATA_LEAD);
+            }
+
+            lastChar = i;
+        }
+
+        private static int indexOfEol(final byte[] b, final int fromIndex, final int toIndex) {
+            for (var i = fromIndex; i < toIndex; i++) {
+                if (b[i] == '\n' || b[i] == '\r') {
+                    return i;
+                }
+            }
+            return -1;
+        }
+
+        @Override
+        public void write(final byte[] b, final int off, final int len) throws IOException {
+            Objects.checkFromIndexSize(off, len, b.length);
+            if (len == 0) {
+                return;
+            }
+            write(b[off]);
+            if (len > 1) {
+                final var end = off + len - 1;
+                var i = off;
+                for (var j = indexOfEol(b, i, end); j != -1; j = indexOfEol(b, i, end)) {
+                    entityStream.write(b, i, j - i);
+                    entityStream.write(EOL);
+                    entityStream.write(DATA_LEAD);
+                    if (b[j] == '\r' && b[j + 1] == '\n') {
+                        j++;
+                    }
+                    i = ++j;
+                }
+                if (i < end) {
+                    entityStream.write(b, i, end - i);
+                }
+                lastChar = b[end];
+            }
+        }
+
+        void finish() throws IOException {
+            if (lastChar != -1) {
+                write(-1);
+            }
+        }
+    }
 }
diff --git a/media/sse/src/test/java/org/glassfish/jersey/media/sse/DataLeadStreamTest.java b/media/sse/src/test/java/org/glassfish/jersey/media/sse/DataLeadStreamTest.java
new file mode 100644
index 0000000..b27402c
--- /dev/null
+++ b/media/sse/src/test/java/org/glassfish/jersey/media/sse/DataLeadStreamTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2025 Markus KARG
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+
+package org.glassfish.jersey.media.sse;
+
+import java.io.ByteArrayOutputStream;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Basic set of unit tests for {@link DataLeadStream}.
+ *
+ * @author Markus KARG (markus@headcrashing.eu)
+ */
+public class DataLeadStreamTest {
+
+    @Test
+    public void shouldDetectEolOnWrite() throws Exception {
+        // given
+        final var outputStream = new ByteArrayOutputStream();
+        final var dataLeadStream = new OutboundEventWriter.DataLeadStream(outputStream);
+
+        // when
+        dataLeadStream.write('A');
+        dataLeadStream.write('\r');
+        dataLeadStream.write('B');
+        dataLeadStream.write('\n');
+        dataLeadStream.write('C');
+        dataLeadStream.write('\r');
+        dataLeadStream.write('\n');
+        dataLeadStream.write('D');
+        dataLeadStream.write('\r');
+        dataLeadStream.write('\r');
+        dataLeadStream.write('E');
+        dataLeadStream.write('\n');
+        dataLeadStream.write('\n');
+        dataLeadStream.write('F');
+        dataLeadStream.write('\r');
+        dataLeadStream.write('\n');
+        dataLeadStream.write('\r');
+        dataLeadStream.write('\n');
+        dataLeadStream.write('G');
+        dataLeadStream.write("H".getBytes(UTF_8));
+        dataLeadStream.write("IJ".getBytes(UTF_8));
+        dataLeadStream.write("KLM".getBytes(UTF_8));
+        dataLeadStream.write("N\rO\nP\r\nQ\n\nR\r\rS\r\n\r\nT".getBytes(UTF_8));
+        dataLeadStream.write('\r');
+        dataLeadStream.write("U".getBytes(UTF_8));
+        dataLeadStream.write('\r');
+        dataLeadStream.write("\nV".getBytes(UTF_8));
+        dataLeadStream.write('\r');
+        dataLeadStream.write("\rW".getBytes(UTF_8));
+        dataLeadStream.write('\n');
+        dataLeadStream.write("X".getBytes(UTF_8));
+        dataLeadStream.write('\n');
+        dataLeadStream.write("\nY".getBytes(UTF_8));
+        dataLeadStream.write('\n');
+        dataLeadStream.write("\rZ".getBytes(UTF_8));
+        dataLeadStream.write("a\r".getBytes(UTF_8));
+        dataLeadStream.write('b');
+        dataLeadStream.write("c\n".getBytes(UTF_8));
+        dataLeadStream.write('d');
+        dataLeadStream.write("e\r".getBytes(UTF_8));
+        dataLeadStream.write('\r');
+        dataLeadStream.write("f\n".getBytes(UTF_8));
+        dataLeadStream.write('\n');
+        dataLeadStream.write("g\r".getBytes(UTF_8));
+        dataLeadStream.write('\n');
+        dataLeadStream.write("h\n".getBytes(UTF_8));
+        dataLeadStream.write('\r');
+        dataLeadStream.finish();
+
+        // then
+        assertEquals(
+                "data: A\ndata: B\ndata: C\ndata: D\ndata: \ndata: E\ndata: \ndata: F\ndata: \ndata: G"
+              + "H"
+              + "IJ"
+              + "KLM"
+              + "N\ndata: O\ndata: P\ndata: Q\ndata: \ndata: R\ndata: \ndata: S\ndata: \ndata: T"
+              + "\ndata: U"
+              + "\ndata: V"
+              + "\ndata: \ndata: W"
+              + "\ndata: X"
+              + "\ndata: \ndata: Y"
+              + "\ndata: \ndata: Z"
+              + "a\ndata: b"
+              + "c\ndata: d"
+              + "e\ndata: \ndata: "
+              + "f\ndata: \ndata: "
+              + "g\ndata: "
+              + "h\ndata: \ndata: ",
+                outputStream.toString(UTF_8));
+    }
+}