You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by el...@apache.org on 2026/09/29 10:40:35 UTC

(maven-script-interpreter) branch master updated: Make FileLogger platform-independent: UTF-8 encoding and LF line separators (#208)

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

elharo pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-script-interpreter.git


The following commit(s) were added to refs/heads/master by this push:
     new 07c5a13  Make FileLogger platform-independent: UTF-8 encoding and LF line separators (#208)
07c5a13 is described below

commit 07c5a13e0a6dfe7b6d027bc7683682f628acbfa1
Author: Elliotte Rusty Harold <el...@users.noreply.github.com>
AuthorDate: Tue Sep 29 10:40:29 2026 +0000

    Make FileLogger platform-independent: UTF-8 encoding and LF line separators (#208)
    
    * Use UTF-8 for mirror stream byte decoding instead of platform default charset
    
    * Encode and mirror as UTF-8 with LF line separators for platform independence
    
    * Simplify javadoc and make FileLoggerTest line separators platform-independent
    
    * Normalize line separators to LF for platform-independent log output
    
    * make test match
    
    * Update comment for clarity on trailing line end handling
    
    Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
    
    * Implement close method in FileLogger
    
    Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
    
    * Fix FileLoggerTest portability: use temp-relative path and read as UTF-8
    
    ---------
    
    Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
---
 .../maven/shared/scriptinterpreter/FileLogger.java | 138 +++++++++++++++++----
 .../shared/scriptinterpreter/FileLoggerTest.java   |  72 +++++++++--
 .../shared/scriptinterpreter/ScriptRunnerTest.java |  18 +--
 .../scriptinterpreter/TestMirrorHandler.java       |   2 +-
 4 files changed, 188 insertions(+), 42 deletions(-)

diff --git a/src/main/java/org/apache/maven/shared/scriptinterpreter/FileLogger.java b/src/main/java/org/apache/maven/shared/scriptinterpreter/FileLogger.java
index e41b4c9..29237d2 100644
--- a/src/main/java/org/apache/maven/shared/scriptinterpreter/FileLogger.java
+++ b/src/main/java/org/apache/maven/shared/scriptinterpreter/FileLogger.java
@@ -18,10 +18,12 @@
  */
 package org.apache.maven.shared.scriptinterpreter;
 
+import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.OpenOption;
 import java.nio.file.Path;
@@ -75,9 +77,10 @@ public class FileLogger implements ExecutionLogger, AutoCloseable {
         }
 
         if (mirrorHandler != null) {
-            stream = new PrintStream(new MirrorStreamWrapper(outputStream, mirrorHandler));
+            this.stream = new PrintStream(
+                    new MirrorStreamWrapper(outputStream, mirrorHandler), false, StandardCharsets.UTF_8.name());
         } else {
-            stream = new PrintStream(outputStream);
+            this.stream = new PrintStream(new LineFeedNormalizer(outputStream), false, StandardCharsets.UTF_8.name());
         }
     }
 
@@ -111,14 +114,17 @@ public class FileLogger implements ExecutionLogger, AutoCloseable {
     }
 
     /**
-     * Writes the specified line to the log file
-     * and invoke {@link FileLoggerMirrorHandler#consumeOutput(String)} if is given.
+     * Writes the specified line to the log file and invokes
+     * {@link FileLoggerMirrorHandler#consumeOutput(String)} if a mirror handler is configured.
+     * A Unix line terminator (LF) is always used, regardless of the platform, so log files are
+     * identical across operating systems.
      *
      * @param line The message to log.
      */
     @Override
     public void consumeLine(String line) {
-        stream.println(line);
+        stream.print(line);
+        stream.print('\n');
         stream.flush();
     }
 
@@ -133,61 +139,147 @@ public class FileLogger implements ExecutionLogger, AutoCloseable {
         }
     }
 
+    /**
+     * A byte stream that normalizes line terminators to {@code \n}, forwards every normalized byte
+     * to the underlying output stream, and mirrors each flushed chunk to the
+     * {@link FileLoggerMirrorHandler} as a UTF-8 decoded string. Because this wrapper normalizes
+     * line terminators before forwarding, both the underlying file and the mirror always use the
+     * same {@code \n} separators, independent of the platform.
+     */
     private static class MirrorStreamWrapper extends OutputStream {
-        private OutputStream out;
+        private final OutputStream out;
 
         private final FileLoggerMirrorHandler mirrorHandler;
 
-        private StringBuilder lineBuffer;
+        private final ByteArrayOutputStream lineBuffer = new ByteArrayOutputStream();
+
+        private boolean lastWasCR;
 
-        MirrorStreamWrapper(OutputStream outputStream, FileLoggerMirrorHandler mirrorHandler) {
-            this.out = outputStream;
+        MirrorStreamWrapper(OutputStream out, FileLoggerMirrorHandler mirrorHandler) {
+            this.out = out;
             this.mirrorHandler = mirrorHandler;
-            this.lineBuffer = new StringBuilder();
         }
 
         @Override
         public void write(int b) throws IOException {
-            out.write(b);
-            lineBuffer.append((char) (b));
+            writeByte(b);
         }
 
         @Override
         public void write(byte[] b, int off, int len) throws IOException {
-            out.write(b, off, len);
-            lineBuffer.append(new String(b, off, len));
+            for (int i = off; i < off + len; i++) {
+                writeByte(b[i] & 0xFF);
+            }
+        }
+
+        private void writeByte(int b) throws IOException {
+            if (b == '\r') {
+                lastWasCR = true;
+                return;
+            }
+            if (lastWasCR) {
+                lastWasCR = false;
+                emit('\n');
+                if (b == '\n') {
+                    return;
+                }
+            }
+            emit(b);
+        }
+
+        private void emit(int b) throws IOException {
+            out.write(b);
+            lineBuffer.write(b);
         }
 
         @Override
         public void flush() throws IOException {
+            if (lastWasCR) {
+                lastWasCR = false;
+                emit('\n');
+            }
             out.flush();
 
-            int len = lineBuffer.length();
+            byte[] bytes = lineBuffer.toByteArray();
+            int len = bytes.length;
             if (len == 0) {
                 // nothing to log
                 return;
             }
 
-            // remove line end for log
-            while (len > 0 && (lineBuffer.charAt(len - 1) == '\n' || lineBuffer.charAt(len - 1) == '\r')) {
+            // remove one trailing LF before mirroring (a flushed chunk may still contain embedded newlines)
+            if (bytes[len - 1] == '\n') {
                 len--;
             }
-            lineBuffer.setLength(len);
 
-            mirrorHandler.consumeOutput(lineBuffer.toString());
+            mirrorHandler.consumeOutput(new String(bytes, 0, len, StandardCharsets.UTF_8));
 
             // clear buffer
-            lineBuffer = new StringBuilder();
+            lineBuffer.reset();
         }
 
         @Override
         public void close() throws IOException {
             flush();
-            if (out != null) {
-                out.close();
-                out = null;
+            out.close();
+        }
+    }
+
+    /**
+     * An output stream that normalizes line terminators to the Unix LF ({@code \n}) character,
+     * so the written content is independent of the platform on which it runs. A CRLF sequence
+     * ({@code \r\n}) or a lone carriage return ({@code \r}) is converted to a single LF.
+     */
+    private static class LineFeedNormalizer extends OutputStream {
+        private final OutputStream out;
+
+        private boolean lastWasCR;
+
+        LineFeedNormalizer(OutputStream out) {
+            this.out = out;
+        }
+
+        @Override
+        public void write(int b) throws IOException {
+            writeByte(b);
+        }
+
+        @Override
+        public void write(byte[] b, int off, int len) throws IOException {
+            for (int i = off; i < off + len; i++) {
+                writeByte(b[i] & 0xFF);
             }
         }
+
+        private void writeByte(int b) throws IOException {
+            if (b == '\r') {
+                lastWasCR = true;
+                return;
+            }
+            if (lastWasCR) {
+                lastWasCR = false;
+                out.write('\n');
+                if (b == '\n') {
+                    return;
+                }
+            }
+            out.write(b);
+        }
+
+        @Override
+        public void flush() throws IOException {
+            if (lastWasCR) {
+                lastWasCR = false;
+                out.write('\n');
+            }
+            out.flush();
+        }
+
+        @Override
+        public void close() throws IOException {
+            flush();
+            out.close();
+        }
     }
 
     private static class NullOutputStream extends OutputStream {
diff --git a/src/test/java/org/apache/maven/shared/scriptinterpreter/FileLoggerTest.java b/src/test/java/org/apache/maven/shared/scriptinterpreter/FileLoggerTest.java
index 6dd1387..588759f 100644
--- a/src/test/java/org/apache/maven/shared/scriptinterpreter/FileLoggerTest.java
+++ b/src/test/java/org/apache/maven/shared/scriptinterpreter/FileLoggerTest.java
@@ -19,11 +19,13 @@
 package org.apache.maven.shared.scriptinterpreter;
 
 import java.io.File;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -33,13 +35,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
  */
 public class FileLoggerTest {
 
-    public static final String EXPECTED_LOG = "Test1" + System.lineSeparator() + "Test2" + System.lineSeparator();
+    public static final String EXPECTED_LOG = "Test1\nTest2\n";
 
     @Test
     void nullOutputFileNoMirror() throws Exception {
         try (FileLogger fileLogger = new FileLogger(null)) {
             fileLogger.consumeLine("Test1");
-            fileLogger.getPrintStream().println("Test2");
+            fileLogger.getPrintStream().print("Test2");
+            fileLogger.getPrintStream().print('\n');
             fileLogger.getPrintStream().flush();
 
             assertNull(fileLogger.getOutputFile());
@@ -52,7 +55,8 @@ public class FileLoggerTest {
 
         try (FileLogger fileLogger = new FileLogger(null, mirrorHandler)) {
             fileLogger.consumeLine("Test1");
-            fileLogger.getPrintStream().println("Test2");
+            fileLogger.getPrintStream().print("Test2");
+            fileLogger.getPrintStream().print('\n');
             fileLogger.getPrintStream().flush();
 
             assertNull(fileLogger.getOutputFile());
@@ -72,23 +76,24 @@ public class FileLoggerTest {
             assertNull(fileLogger.getOutputFile());
         }
 
-        assertEquals("A" + System.lineSeparator(), mirrorHandler.getLoggedMessage());
+        assertEquals("A\n", mirrorHandler.getLoggedMessage());
     }
 
     @Test
     void outputFileNoMirror(@TempDir File tempDir) throws Exception {
-        File outputFile = new File(tempDir, "/target/test.log");
+        File outputFile = new File(tempDir, "target/test.log");
 
         try (FileLogger fileLogger = new FileLogger(outputFile)) {
             fileLogger.consumeLine("Test1");
-            fileLogger.getPrintStream().println("Test2");
+            fileLogger.getPrintStream().print("Test2");
+            fileLogger.getPrintStream().print('\n');
             fileLogger.getPrintStream().flush();
 
             assertEquals(outputFile, fileLogger.getOutputFile());
         }
 
         assertTrue(outputFile.exists());
-        assertEquals(EXPECTED_LOG, new String(Files.readAllBytes(outputFile.toPath())));
+        assertEquals(EXPECTED_LOG, new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8));
     }
 
     @Test
@@ -98,7 +103,8 @@ public class FileLoggerTest {
 
         try (FileLogger fileLogger = new FileLogger(outputFile, mirrorHandler)) {
             fileLogger.consumeLine("Test1");
-            fileLogger.getPrintStream().println("Test2");
+            fileLogger.getPrintStream().print("Test2");
+            fileLogger.getPrintStream().print('\n');
             fileLogger.getPrintStream().flush();
 
             assertEquals(outputFile, fileLogger.getOutputFile());
@@ -107,7 +113,53 @@ public class FileLoggerTest {
         assertEquals(EXPECTED_LOG, mirrorHandler.getLoggedMessage());
 
         assertTrue(outputFile.exists());
-        assertEquals(EXPECTED_LOG, new String(Files.readAllBytes(outputFile.toPath())));
+        assertEquals(EXPECTED_LOG, new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8));
+    }
+
+    /**
+     * Verifies that non-ASCII content written through the logger is encoded and decoded as UTF-8
+     * on both the mirror handler and the underlying file, independent of the platform default
+     * charset and line separator.
+     * "café" in UTF-8: {0x63, 0x61, 0x66, 0xC3, 0xA9, 0x0A}.
+     * A platform that decodes these bytes as ISO-8859-1 would produce "café" instead of "café".
+     */
+    @Test
+    void mirrorAndFileShouldUseUtf8(@TempDir File tempDir) throws Exception {
+        File outputFile = new File(tempDir, "utf8.log");
+        TestMirrorHandler mirrorHandler = new TestMirrorHandler();
+
+        try (FileLogger fileLogger = new FileLogger(outputFile, mirrorHandler)) {
+            fileLogger.getPrintStream().write("café".getBytes(StandardCharsets.UTF_8));
+            fileLogger.getPrintStream().write('\n');
+            fileLogger.getPrintStream().flush();
+        }
+
+        assertEquals("café\n", mirrorHandler.getLoggedMessage());
+        assertTrue(outputFile.exists());
+        assertArrayEquals("café\n".getBytes(StandardCharsets.UTF_8), Files.readAllBytes(outputFile.toPath()));
+    }
+
+    /**
+     * Scripts running through the logger write with the platform's native line separator, which is
+     * {@code \r\n} on Windows. This verifies that both CRLF and a lone CR are normalized to a
+     * Unix LF ({@code \n}) in the file and the mirror, so the log content is identical on every
+     * platform and the mirror always matches the file.
+     */
+    @Test
+    void crLfAndCarriageReturnAreNormalizedToLf(@TempDir File tempDir) throws Exception {
+        File outputFile = new File(tempDir, "crlf.log");
+        TestMirrorHandler mirrorHandler = new TestMirrorHandler();
+
+        try (FileLogger fileLogger = new FileLogger(outputFile, mirrorHandler)) {
+            fileLogger.getPrintStream().print("line1\r\n");
+            fileLogger.getPrintStream().print("line2\r");
+            fileLogger.getPrintStream().print("\n");
+            fileLogger.consumeLine("line3");
+        }
+
+        assertEquals("line1\nline2\nline3\n", mirrorHandler.getLoggedMessage());
+        assertArrayEquals(
+                "line1\nline2\nline3\n".getBytes(StandardCharsets.UTF_8), Files.readAllBytes(outputFile.toPath()));
     }
 
     @Test
@@ -124,7 +176,7 @@ public class FileLoggerTest {
             }
 
             assertTrue(outputFile.exists());
-            assertEquals(EXPECTED_LOG, new String(Files.readAllBytes(outputFile.toPath())));
+            assertEquals(EXPECTED_LOG, new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8));
         } finally {
             Files.deleteIfExists(outputFile.toPath());
         }
diff --git a/src/test/java/org/apache/maven/shared/scriptinterpreter/ScriptRunnerTest.java b/src/test/java/org/apache/maven/shared/scriptinterpreter/ScriptRunnerTest.java
index 25f23d3..decc21c 100644
--- a/src/test/java/org/apache/maven/shared/scriptinterpreter/ScriptRunnerTest.java
+++ b/src/test/java/org/apache/maven/shared/scriptinterpreter/ScriptRunnerTest.java
@@ -19,6 +19,7 @@
 package org.apache.maven.shared.scriptinterpreter;
 
 import java.io.File;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.util.Collections;
 import java.util.HashMap;
@@ -54,7 +55,7 @@ class ScriptRunnerTest {
             scriptRunner.run("test", new File("src/test/resources/bsh-test"), "verify", buildContext(), fileLogger);
         }
 
-        String logContent = new String(Files.readAllBytes(logFile.toPath()));
+        String logContent = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8);
         assertTrue(logContent.contains(new File("src/test/resources/bsh-test/verify.bsh").getPath()));
         assertTrue(logContent.contains("foo=bar"));
         assertTrue(logContent.contains("globalVar=Yeah baby it's rocks"));
@@ -73,7 +74,7 @@ class ScriptRunnerTest {
             scriptRunner.run("test", new File("src/test/resources/bsh-test"), "return-null", null, fileLogger);
         }
 
-        String logContent = new String(Files.readAllBytes(logFile.toPath()));
+        String logContent = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8);
         assertTrue(logContent.contains(new File("src/test/resources/bsh-test/return-null.bsh").getPath()));
         assertTrue(logContent.contains("ok with null result"));
         assertEquals(logContent, mirrorHandler.getLoggedMessage());
@@ -125,7 +126,7 @@ class ScriptRunnerTest {
             scriptRunner.run("test", new File("src/test/resources/bsh-test/verify.bsh"), buildContext(), fileLogger);
         }
 
-        String logContent = new String(Files.readAllBytes(logFile.toPath()));
+        String logContent = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8);
         assertTrue(logContent.contains(new File("src/test/resources/bsh-test/verify.bsh").getPath()));
         assertTrue(logContent.contains("foo=bar"));
 
@@ -144,7 +145,7 @@ class ScriptRunnerTest {
             scriptRunner.run("test", new File("src/test/resources/groovy-test"), "verify", buildContext(), fileLogger);
         }
 
-        String logContent = new String(Files.readAllBytes(logFile.toPath()));
+        String logContent = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8);
         assertTrue(logContent.contains(new File("src/test/resources/groovy-test/verify.groovy").getPath()));
         assertTrue(logContent.contains("foo=bar"));
         assertTrue(logContent.contains("globalVar=Yeah baby it's rocks"));
@@ -164,7 +165,7 @@ class ScriptRunnerTest {
             scriptRunner.run("test", new File("src/test/resources/groovy-test"), "return-null", null, fileLogger);
         }
 
-        String logContent = new String(Files.readAllBytes(logFile.toPath()));
+        String logContent = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8);
         assertTrue(logContent.contains(new File("src/test/resources/groovy-test/return-null.groovy").getPath()));
         assertTrue(logContent.contains("ok with null result"));
         assertEquals(logContent, mirrorHandler.getLoggedMessage());
@@ -194,6 +195,7 @@ class ScriptRunnerTest {
                 ScriptRunner scriptRunner = new ScriptRunner()) {
             scriptRunner.run(
                     "test", new File("src/test/resources/groovy-test"), "return-false", buildContext(), fileLogger);
+            fail();
         } catch (ScriptReturnException expected) {
             assertEquals(false, expected.getResult());
             assertEquals("The test returned false.", expected.getMessage());
@@ -215,7 +217,7 @@ class ScriptRunnerTest {
                     "test", new File("src/test/resources/groovy-test/verify.groovy"), buildContext(), fileLogger);
         }
 
-        String logContent = new String(Files.readAllBytes(logFile.toPath()));
+        String logContent = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8);
         assertTrue(logContent.contains(new File("src/test/resources/groovy-test/verify.groovy").getPath()));
         assertTrue(logContent.contains("foo=bar"));
 
@@ -240,7 +242,7 @@ class ScriptRunnerTest {
             scriptRunner.run("test classpath 2", basedir, "class-path2", context, logger);
         }
 
-        String logContent = new String(Files.readAllBytes(logFile.toPath()));
+        String logContent = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8);
         assertTrue(logContent.contains("wireMockServer started with port="));
         assertTrue(logContent.contains("wireMockServer stopped"));
     }
@@ -309,7 +311,7 @@ class ScriptRunnerTest {
             scriptRunner.run("utf8-test", new File("src/test/resources/groovy-test/utf8-test.groovy"), context, logger);
         }
 
-        String logContent = new String(Files.readAllBytes(logFile.toPath()));
+        String logContent = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8);
         assertTrue(logContent.contains("expected=café"));
         assertTrue(logContent.contains("actual=café"));
     }
diff --git a/src/test/java/org/apache/maven/shared/scriptinterpreter/TestMirrorHandler.java b/src/test/java/org/apache/maven/shared/scriptinterpreter/TestMirrorHandler.java
index 9e45584..a89d922 100644
--- a/src/test/java/org/apache/maven/shared/scriptinterpreter/TestMirrorHandler.java
+++ b/src/test/java/org/apache/maven/shared/scriptinterpreter/TestMirrorHandler.java
@@ -36,6 +36,6 @@ class TestMirrorHandler implements FileLoggerMirrorHandler {
     @Override
     public void consumeOutput(String message) {
         loggedMessage.append(message);
-        loggedMessage.append(System.lineSeparator());
+        loggedMessage.append('\n');
     }
 }