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

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new c98c2573e2 GROOVY-12155: decode process output streams using the 
native encoding
c98c2573e2 is described below

commit c98c2573e2a1b402e5900b9350139f8d26e41004
Author: Paul King <[email protected]>
AuthorDate: Mon Jul 13 15:11:01 2026 +1000

    GROOVY-12155: decode process output streams using the native encoding
    
    with thanks to https://github.com/netliomax25-code for identifying the 
problem
---
 .../groovy/runtime/FlushingStreamWriter.java       |  13 ++
 .../groovy/runtime/ProcessGroovyMethods.java       | 226 +++++++++++++++++++--
 src/test/groovy/groovy/ProcessTest.groovy          |  93 ++++++++-
 3 files changed, 303 insertions(+), 29 deletions(-)

diff --git 
a/src/main/java/org/codehaus/groovy/runtime/FlushingStreamWriter.java 
b/src/main/java/org/codehaus/groovy/runtime/FlushingStreamWriter.java
index a1fd825244..bc028c44cc 100644
--- a/src/main/java/org/codehaus/groovy/runtime/FlushingStreamWriter.java
+++ b/src/main/java/org/codehaus/groovy/runtime/FlushingStreamWriter.java
@@ -21,6 +21,7 @@ package org.codehaus.groovy.runtime;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
 
 /**
  * Stream writer which flushes after each write operation.
@@ -37,6 +38,18 @@ public class FlushingStreamWriter extends OutputStreamWriter 
{
         super(out);
     }
 
+    /**
+     * Constructs a FlushingStreamWriter that flushes on every write.
+     *
+     * @param out the underlying output stream to write to
+     * @param charset the charset to encode with
+     * @throws NullPointerException if out or charset is null
+     * @since 6.0.0
+     */
+    public FlushingStreamWriter(OutputStream out, Charset charset) {
+        super(out, charset);
+    }
+
     @Override
     public void write(char[] cbuf, int off, int len) throws IOException {
         super.write(cbuf, off, len);
diff --git 
a/src/main/java/org/codehaus/groovy/runtime/ProcessGroovyMethods.java 
b/src/main/java/org/codehaus/groovy/runtime/ProcessGroovyMethods.java
index fd20400fc2..cca3ba7b77 100644
--- a/src/main/java/org/codehaus/groovy/runtime/ProcessGroovyMethods.java
+++ b/src/main/java/org/codehaus/groovy/runtime/ProcessGroovyMethods.java
@@ -33,6 +33,7 @@ import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.Writer;
+import java.nio.charset.Charset;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -57,6 +58,49 @@ import java.util.concurrent.TimeUnit;
  */
 public class ProcessGroovyMethods extends DefaultGroovyMethodsSupport {
 
+    /**
+     * The system property naming the charset used for process streams when no 
explicit
+     * charset is supplied. It overrides the native encoding and is useful for 
a child
+     * known to emit (or expect) something other than the platform's native 
encoding,
+     * e.g. a UTF-8 child on a Windows host.
+     *
+     * @since 6.0.0
+     */
+    public static final String PROCESS_ENCODING_PROPERTY = 
"groovy.process.encoding";
+
+    /**
+     * The operating system's native encoding, as named by the {@code 
native.encoding}
+     * system property (JDK 17+), falling back to {@link 
Charset#defaultCharset()} when
+     * that property is absent or names an unsupported charset. This cannot 
change during
+     * the life of the JVM, so it is resolved once.
+     */
+    private static final Charset NATIVE_ENCODING =
+            charsetOrDefault(System.getProperty("native.encoding"), 
Charset.defaultCharset());
+
+    /**
+     * The charset used for a process's streams when no explicit charset is 
supplied.
+     * A child process reads and writes text in the operating system's native 
encoding,
+     * whereas {@link Charset#defaultCharset()} has been UTF-8 on every 
platform since
+     * JEP 400, so on a non-UTF-8 host the default charset no longer describes 
what the
+     * child emits or expects. The native encoding is used instead, matching 
the charset
+     * selection of {@link Process#inputReader()} and {@link 
Process#outputWriter()}.
+     * Setting {@value #PROCESS_ENCODING_PROPERTY} overrides it.
+     */
+    private static Charset processEncoding() {
+        return charsetOrDefault(System.getProperty(PROCESS_ENCODING_PROPERTY), 
NATIVE_ENCODING);
+    }
+
+    private static Charset charsetOrDefault(String name, Charset fallback) {
+        if (name != null && !name.isEmpty()) {
+            try {
+                return Charset.forName(name);
+            } catch (IllegalArgumentException ignore) {
+                // unknown or unsupported charset name
+            }
+        }
+        return fallback;
+    }
+
     /**
      * An alias method so that a process appears similar to System.out, 
System.in, System.err;
      * you can use process.in, process.out, process.err in a similar fashion.
@@ -70,7 +114,8 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
     }
 
     /**
-     * Read the text of the output stream of the Process.
+     * Read the text of the output stream of the Process, decoding using the 
operating
+     * system's native encoding (or {@value #PROCESS_ENCODING_PROPERTY} when 
set).
      * Closes all the streams associated with the process after retrieving the 
text.
      *
      * @param self a Process instance
@@ -79,9 +124,26 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * @since 1.0
      */
     public static String getText(Process self) throws IOException {
-        String text = IOGroovyMethods.getText(new BufferedReader(new 
InputStreamReader(self.getInputStream())));
-        closeStreams(self);
-        return text;
+        return getText(self, processEncoding());
+    }
+
+    /**
+     * Read the text of the output stream of the Process using the given 
charset.
+     * Closes all the streams associated with the process after retrieving the 
text.
+     *
+     * @param self a Process instance
+     * @param charset the charset the process writes its output in, or null to 
use the process encoding
+     * @return the text of the output
+     * @throws java.io.IOException if an IOException occurs.
+     * @since 6.0.0
+     */
+    public static String getText(Process self, Charset charset) throws 
IOException {
+        if (charset == null) charset = processEncoding();
+        try {
+            return IOGroovyMethods.getText(new BufferedReader(new 
InputStreamReader(self.getInputStream(), charset)));
+        } finally {
+            closeStreams(self);
+        }
     }
 
     /**
@@ -110,7 +172,9 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
 
     /**
      * Overloads the left shift operator (&lt;&lt;) to provide an append 
mechanism
-     * to pipe data to a Process.
+     * to pipe data to a Process. The value is encoded using the operating 
system's
+     * native encoding (or {@value #PROCESS_ENCODING_PROPERTY} when set), 
which is what
+     * the child process expects to read.
      *
      * @param self  a Process instance
      * @param value a value to append
@@ -119,7 +183,7 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * @since 1.0
      */
     public static Writer leftShift(Process self, Object value) throws 
IOException {
-        return IOGroovyMethods.leftShift(self.getOutputStream(), value);
+        return IOGroovyMethods.leftShift(new 
FlushingStreamWriter(self.getOutputStream(), processEncoding()), value);
     }
 
     /**
@@ -192,8 +256,27 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * @since 1.7.5
      */
     public static void consumeProcessOutput(Process self, Appendable output, 
Appendable error) {
-        consumeProcessOutputStream(self, output);
-        consumeProcessErrorStream(self, error);
+        consumeProcessOutput(self, output, error, processEncoding());
+    }
+
+    /**
+     * Gets the output and error streams from a process and reads them
+     * to keep the process from blocking due to a full output buffer.
+     * The processed stream data is decoded using the given charset and 
appended
+     * to the supplied Appendable.
+     * For this, two Threads are started, so this method will return 
immediately.
+     * The threads will not be join()ed, even if waitFor() is called. To wait
+     * for the output to be fully consumed call waitForProcessOutput().
+     *
+     * @param self a Process
+     * @param output an Appendable to capture the process stdout
+     * @param error an Appendable to capture the process stderr
+     * @param charset the charset the process writes its output in
+     * @since 6.0.0
+     */
+    public static void consumeProcessOutput(Process self, Appendable output, 
Appendable error, Charset charset) {
+        consumeProcessOutputStream(self, output, charset);
+        consumeProcessErrorStream(self, error, charset);
     }
 
     /**
@@ -246,8 +329,27 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * @since 1.7.5
      */
     public static void waitForProcessOutput(Process self, Appendable output, 
Appendable error) {
-        Thread tout = consumeProcessOutputStream(self, output);
-        Thread terr = consumeProcessErrorStream(self, error);
+        waitForProcessOutput(self, output, error, processEncoding());
+    }
+
+    /**
+     * Gets the output and error streams from a process and reads them
+     * to keep the process from blocking due to a full output buffer.
+     * The processed stream data is decoded using the given charset and 
appended
+     * to the supplied Appendable.
+     * For this, two Threads are started, but join()ed, so we wait.
+     * As implied by the waitFor... name, we also wait until we finish
+     * as well. Finally, the input, output and error streams are closed.
+     *
+     * @param self a Process
+     * @param output an Appendable to capture the process stdout
+     * @param error an Appendable to capture the process stderr
+     * @param charset the charset the process writes its output in
+     * @since 6.0.0
+     */
+    public static void waitForProcessOutput(Process self, Appendable output, 
Appendable error, Charset charset) {
+        Thread tout = consumeProcessOutputStream(self, output, charset);
+        Thread terr = consumeProcessErrorStream(self, error, charset);
         boolean interrupted = false;
         try {
             try { tout.join(); } catch (InterruptedException ignore) { 
interrupted = true; }
@@ -303,10 +405,25 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * @since 6.0.0
      */
     public static ProcessResult waitForResult(Process self) throws 
InterruptedException {
+        return waitForResult(self, processEncoding());
+    }
+
+    /**
+     * Executes the process and waits for it to complete, capturing
+     * the standard output and standard error decoded using the given charset,
+     * plus the exit code, into a {@link ProcessResult}.
+     *
+     * @param self a Process
+     * @param charset the charset the process writes its output in
+     * @return a ProcessResult containing stdout, stderr, and exit code
+     * @throws InterruptedException if the current thread is interrupted.
+     * @since 6.0.0
+     */
+    public static ProcessResult waitForResult(Process self, Charset charset) 
throws InterruptedException {
         StringBuilder sout = new StringBuilder();
         StringBuilder serr = new StringBuilder();
-        Thread tout = consumeProcessOutputStream(self, sout);
-        Thread terr = consumeProcessErrorStream(self, serr);
+        Thread tout = consumeProcessOutputStream(self, sout, charset);
+        Thread terr = consumeProcessErrorStream(self, serr, charset);
         boolean interrupted = false;
         try {
             try { tout.join(); } catch (InterruptedException ignore) { 
interrupted = true; }
@@ -337,10 +454,28 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * @since 6.0.0
      */
     public static ProcessResult waitForResult(Process self, long timeout, 
TimeUnit unit) throws InterruptedException {
+        return waitForResult(self, timeout, unit, processEncoding());
+    }
+
+    /**
+     * Executes the process and waits for it to complete within the given 
timeout,
+     * capturing the standard output and standard error decoded using the 
given charset,
+     * plus the exit code, into a {@link ProcessResult}. If the process does 
not complete
+     * within the timeout, it is forcibly destroyed.
+     *
+     * @param self a Process
+     * @param timeout the maximum time to wait
+     * @param unit the time unit of the timeout argument
+     * @param charset the charset the process writes its output in
+     * @return a ProcessResult containing stdout, stderr, and exit code
+     * @throws InterruptedException if the current thread is interrupted.
+     * @since 6.0.0
+     */
+    public static ProcessResult waitForResult(Process self, long timeout, 
TimeUnit unit, Charset charset) throws InterruptedException {
         StringBuilder sout = new StringBuilder();
         StringBuilder serr = new StringBuilder();
-        Thread tout = consumeProcessOutputStream(self, sout);
-        Thread terr = consumeProcessErrorStream(self, serr);
+        Thread tout = consumeProcessOutputStream(self, sout, charset);
+        Thread terr = consumeProcessErrorStream(self, serr, charset);
         boolean interrupted = false;
         try {
             try {
@@ -412,7 +547,24 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * @since 1.7.5
      */
     public static Thread consumeProcessErrorStream(Process self, Appendable 
error) {
-        Thread thread = new Thread(new TextDumper(self.getErrorStream(), 
error));
+        return consumeProcessErrorStream(self, error, processEncoding());
+    }
+
+    /**
+     * Gets the error stream from a process and reads it
+     * to keep the process from blocking due to a full buffer.
+     * The stream data is decoded using the given charset and appended
+     * to the supplied Appendable.
+     * A new Thread is started, so this method will return immediately.
+     *
+     * @param self a Process
+     * @param error an Appendable to capture the process stderr
+     * @param charset the charset the process writes its error output in
+     * @return the Thread
+     * @since 6.0.0
+     */
+    public static Thread consumeProcessErrorStream(Process self, Appendable 
error, Charset charset) {
+        Thread thread = new Thread(new TextDumper(self.getErrorStream(), 
error, charset));
         thread.start();
         return thread;
     }
@@ -429,7 +581,24 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * @since 1.7.5
      */
     public static Thread consumeProcessOutputStream(Process self, Appendable 
output) {
-        Thread thread = new Thread(new TextDumper(self.getInputStream(), 
output));
+        return consumeProcessOutputStream(self, output, processEncoding());
+    }
+
+    /**
+     * Gets the output stream from a process and reads it
+     * to keep the process from blocking due to a full output buffer.
+     * The stream data is decoded using the given charset and appended
+     * to the supplied Appendable.
+     * A new Thread is started, so this method will return immediately.
+     *
+     * @param self a Process
+     * @param output an Appendable to capture the process stdout
+     * @param charset the charset the process writes its output in
+     * @return the Thread
+     * @since 6.0.0
+     */
+    public static Thread consumeProcessOutputStream(Process self, Appendable 
output, Charset charset) {
+        Thread thread = new Thread(new TextDumper(self.getInputStream(), 
output, charset));
         thread.start();
         return thread;
     }
@@ -455,6 +624,8 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * Creates a new BufferedWriter as stdin for this process,
      * passes it to the closure, and ensures the stream is flushed
      * and closed after the closure returns.
+     * Text is encoded using the operating system's native encoding
+     * (or {@value #PROCESS_ENCODING_PROPERTY} when set).
      * A new Thread is started, so this method will return immediately.
      *
      * @param self a Process
@@ -462,9 +633,24 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
      * @since 1.5.2
      */
     public static void withWriter(final Process self, final Closure closure) {
+        withWriter(self, processEncoding(), closure);
+    }
+
+    /**
+     * Creates a new BufferedWriter as stdin for this process using the given 
charset,
+     * passes it to the closure, and ensures the stream is flushed
+     * and closed after the closure returns.
+     * A new Thread is started, so this method will return immediately.
+     *
+     * @param self a Process
+     * @param charset the charset the process expects to read its input in
+     * @param closure a closure
+     * @since 6.0.0
+     */
+    public static void withWriter(final Process self, final Charset charset, 
final Closure closure) {
         new Thread(() -> {
             try {
-                IOGroovyMethods.withWriter(new 
BufferedOutputStream(getOut(self)), closure);
+                IOGroovyMethods.withWriter(new 
BufferedOutputStream(getOut(self)), charset.name(), closure);
             } catch (IOException e) {
                 throw new GroovyRuntimeException("exception while reading 
process stream", e);
             }
@@ -621,15 +807,17 @@ public class ProcessGroovyMethods extends 
DefaultGroovyMethodsSupport {
     private static class TextDumper implements Runnable {
         final InputStream in;
         final Appendable app;
+        final Charset charset;
 
-        public TextDumper(InputStream in, Appendable app) {
+        public TextDumper(InputStream in, Appendable app, Charset charset) {
             this.in = in;
             this.app = app;
+            this.charset = charset;
         }
 
         @Override
         public void run() {
-            InputStreamReader isr = new InputStreamReader(in);
+            InputStreamReader isr = new InputStreamReader(in, charset);
             BufferedReader br = new BufferedReader(isr);
             String next;
             try {
diff --git a/src/test/groovy/groovy/ProcessTest.groovy 
b/src/test/groovy/groovy/ProcessTest.groovy
index e8c1270c18..6fbd0bf489 100644
--- a/src/test/groovy/groovy/ProcessTest.groovy
+++ b/src/test/groovy/groovy/ProcessTest.groovy
@@ -22,14 +22,23 @@ import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
 
+import java.nio.charset.Charset
+
+import static java.nio.charset.StandardCharsets.ISO_8859_1
+import static java.nio.charset.StandardCharsets.UTF_8
+
 /**
  * check that groovy Process methods do their job.
  */
 class ProcessTest {
+    private static final String PROCESS_ENCODING = 'groovy.process.encoding'
+
     def myProcess
+    private String previousProcessEncoding
 
     @BeforeEach
     void setUp() {
+        previousProcessEncoding = System.getProperty(PROCESS_ENCODING)
         myProcess = new MockProcess()
     }
 
@@ -73,6 +82,69 @@ class ProcessTest {
         assert "" == myProcess.text
     }
 
+    @Test
+    void testProcessTextUsesSuppliedCharset() {
+        // a single 0xE9 byte is 'é' in ISO-8859-1 but is not valid UTF-8
+        def latin1 = new MockProcess([0xE9] as byte[])
+        assert "é" == latin1.getText(ISO_8859_1)
+
+        def utf8 = new MockProcess("é".getBytes(UTF_8))
+        assert "é" == utf8.getText(UTF_8)
+    }
+
+    @Test
+    void testProcessTextDefaultsToTheNativeEncoding() {
+        def bytes = [0xE9] as byte[]
+        def expected = new String(bytes, 
Charset.forName(System.getProperty("native.encoding")))
+        assert expected == new MockProcess(bytes).text
+    }
+
+    @Test
+    void testProcessEncodingPropertyOverridesTheNativeEncoding() {
+        System.setProperty(PROCESS_ENCODING, "ISO-8859-1")
+        assert "é" == new MockProcess([0xE9] as byte[]).text
+
+        System.setProperty(PROCESS_ENCODING, "UTF-8")
+        assert "é" == new MockProcess("é".getBytes(UTF_8)).text
+    }
+
+    @Test
+    void testProcessEncodingPropertyIgnoredWhenNotAValidCharset() {
+        System.setProperty(PROCESS_ENCODING, "no-such-charset")
+        def bytes = [0xE9] as byte[]
+        def expected = new String(bytes, 
Charset.forName(System.getProperty("native.encoding")))
+        assert expected == new MockProcess(bytes).text
+    }
+
+    @Test
+    void testConsumeProcessOutputUsesSuppliedCharset() {
+        def out = new StringBuilder()
+        def err = new StringBuilder()
+        def proc = new MockProcess([0xE9] as byte[], [0xE8] as byte[])
+
+        proc.waitForProcessOutput(out, err, ISO_8859_1)
+
+        assert "é\n" == out.toString()
+        assert "è\n" == err.toString()
+    }
+
+    @Test
+    void testWaitForResultUsesSuppliedCharset() {
+        def result = new MockProcess([0xE9] as 
byte[]).waitForResult(ISO_8859_1)
+
+        assert "é\n" == result.out
+        assert result.ok
+    }
+
+    @Test
+    void testProcessAppendEncodesUsingTheProcessEncoding() {
+        System.setProperty(PROCESS_ENCODING, "ISO-8859-1")
+
+        myProcess << "é"
+
+        assert [0xE9] as byte[] == myProcess.outputStream.toByteArray()
+    }
+
     @Test
     void testProcessErrorStream() {
         assert myProcess.err instanceof InputStream
@@ -89,6 +161,11 @@ class ProcessTest {
 
     @AfterEach
     void tearDown() {
+        if (previousProcessEncoding == null) {
+            System.clearProperty(PROCESS_ENCODING)
+        } else {
+            System.setProperty(PROCESS_ENCODING, previousProcessEncoding)
+        }
         myProcess.destroy()
     }
 }
@@ -102,8 +179,12 @@ class MockProcess extends Process {
     private def o
 
     MockProcess() {
-        e = new AnotherMockInputStream()
-        i = new AnotherMockInputStream()
+        this(new byte[0], new byte[0])
+    }
+
+    MockProcess(byte[] stdout, byte[] stderr = new byte[0]) {
+        i = new ByteArrayInputStream(stdout)
+        e = new ByteArrayInputStream(stderr)
         o = new ByteArrayOutputStream()
     }
 
@@ -119,11 +200,3 @@ class MockProcess extends Process {
 
     int waitFor() { return 0 }
 }
-
-/**
- * only needed for workaround in groovy,
- *     new ByteArrayInputStream(myByteArray) doesn't work at mo... 
(28-Sep-2004)
- */
-class AnotherMockInputStream extends InputStream {
-    int read() { return -1 }
-}

Reply via email to