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

twolf pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git


The following commit(s) were added to refs/heads/master by this push:
     new 5ee81f47e Fix duplicate character echoing in ProcessShell
5ee81f47e is described below

commit 5ee81f47e148649131c91127f29ff2dc26cb61f7
Author: Denys Horman <[email protected]>
AuthorDate: Sun Jan 11 18:29:04 2026 +0200

    Fix duplicate character echoing in ProcessShell
    
    - Remove redundant manual ECHO handling in TtyFilterOutputStream
    - Update ProcessShell to stop passing the echo stream to 
TtyFilterOutputStream
    - Update TtyFilterOutputStream constructors to remove the unused echo 
parameter
    - Update tests to match the new TtyFilterOutputStream API
    
    This fix prevents characters from being duplicated in interactive shell 
sessions
    where the underlying shell process already handles echoing.
---
 .../sshd/server/shell/TtyFilterOutputStream.java   |  19 +---
 .../server/shell/TtyFilterOutputStreamTest.java    |   2 +-
 .../org/apache/sshd/server/shell/ProcessShell.java |   2 +-
 .../apache/sshd/server/shell/ProcessShellTest.java | 116 +++++++++++++++++++++
 4 files changed, 123 insertions(+), 16 deletions(-)

diff --git 
a/sshd-common/src/main/java/org/apache/sshd/server/shell/TtyFilterOutputStream.java
 
b/sshd-common/src/main/java/org/apache/sshd/server/shell/TtyFilterOutputStream.java
index 1c127dc97..133d73a23 100644
--- 
a/sshd-common/src/main/java/org/apache/sshd/server/shell/TtyFilterOutputStream.java
+++ 
b/sshd-common/src/main/java/org/apache/sshd/server/shell/TtyFilterOutputStream.java
@@ -25,33 +25,30 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Set;
 
 import org.apache.sshd.common.channel.PtyMode;
 import org.apache.sshd.common.util.GenericUtils;
 
 /**
- * Handles the output stream while taking care of the {@link PtyMode} for CR / 
LF and ECHO settings
+ * Handles the output stream while taking care of the {@link PtyMode} for CR / 
LF settings
  *
  * @author <a href="mailto:[email protected]";>Apache MINA SSHD Project</a>
  */
 public class TtyFilterOutputStream extends FilterOutputStream {
     public static final Set<PtyMode> OUTPUT_OPTIONS
-            = Collections.unmodifiableSet(EnumSet.of(PtyMode.ECHO, 
PtyMode.INLCR, PtyMode.ICRNL, PtyMode.IGNCR));
+            = Collections.unmodifiableSet(EnumSet.of(PtyMode.INLCR, 
PtyMode.ICRNL, PtyMode.IGNCR));
 
     private final Set<PtyMode> ttyOptions;
-    private final TtyFilterInputStream echo;
 
-    public TtyFilterOutputStream(OutputStream out, TtyFilterInputStream echo, 
Map<PtyMode, ?> modes) {
-        this(out, echo, PtyMode.resolveEnabledOptions(modes, OUTPUT_OPTIONS));
+    public TtyFilterOutputStream(OutputStream out, Map<PtyMode, ?> modes) {
+        this(out, PtyMode.resolveEnabledOptions(modes, OUTPUT_OPTIONS));
     }
 
-    public TtyFilterOutputStream(OutputStream out, TtyFilterInputStream echo, 
Collection<PtyMode> ttyOptions) {
+    public TtyFilterOutputStream(OutputStream out, Collection<PtyMode> 
ttyOptions) {
         super(out);
         // we create a copy of the options so as to avoid concurrent 
modifications
         this.ttyOptions = GenericUtils.of(ttyOptions); // TODO validate 
non-conflicting options
-        this.echo = this.ttyOptions.contains(PtyMode.ECHO) ? 
Objects.requireNonNull(echo, "No echo stream") : echo;
     }
 
     @Override
@@ -86,9 +83,6 @@ public class TtyFilterOutputStream extends FilterOutputStream 
{
 
     protected void writeRawOutput(int c) throws IOException {
         this.out.write(c);
-        if (ttyOptions.contains(PtyMode.ECHO)) {
-            echo.write(c);
-        }
     }
 
     @Override
@@ -119,8 +113,5 @@ public class TtyFilterOutputStream extends 
FilterOutputStream {
 
     protected void writeRawOutput(byte[] b, int off, int len) throws 
IOException {
         this.out.write(b, off, len);
-        if (ttyOptions.contains(PtyMode.ECHO)) {
-            echo.write(b, off, len);
-        }
     }
 }
diff --git 
a/sshd-common/src/test/java/org/apache/sshd/server/shell/TtyFilterOutputStreamTest.java
 
b/sshd-common/src/test/java/org/apache/sshd/server/shell/TtyFilterOutputStreamTest.java
index f57f6bcb1..12e49e7c1 100644
--- 
a/sshd-common/src/test/java/org/apache/sshd/server/shell/TtyFilterOutputStreamTest.java
+++ 
b/sshd-common/src/test/java/org/apache/sshd/server/shell/TtyFilterOutputStreamTest.java
@@ -84,7 +84,7 @@ public class TtyFilterOutputStreamTest extends 
JUnitTestSupport {
             }
         };
              TtyFilterOutputStream ttyOut = new TtyFilterOutputStream(
-                     output, null, PtyMode.ECHO.equals(mode) ? 
Collections.emptySet() : EnumSet.of(mode));
+                     output, PtyMode.ECHO.equals(mode) ? 
Collections.emptySet() : EnumSet.of(mode));
              Writer writer = new OutputStreamWriter(ttyOut, 
StandardCharsets.UTF_8)) {
 
             for (String l : lines) {
diff --git 
a/sshd-core/src/main/java/org/apache/sshd/server/shell/ProcessShell.java 
b/sshd-core/src/main/java/org/apache/sshd/server/shell/ProcessShell.java
index 496ab0a9f..df57b76fc 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/shell/ProcessShell.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/shell/ProcessShell.java
@@ -121,7 +121,7 @@ public class ProcessShell extends AbstractLoggingBean 
implements InvertedShell {
         Map<PtyMode, ?> modes = resolveShellTtyOptions(env.getPtyModes());
         out = new TtyFilterInputStream(process.getInputStream(), modes);
         err = new TtyFilterInputStream(process.getErrorStream(), modes);
-        in = new TtyFilterOutputStream(process.getOutputStream(), err, modes);
+        in = new TtyFilterOutputStream(process.getOutputStream(), modes);
     }
 
     protected Map<String, String> resolveShellEnvironment(Map<String, String> 
env) {
diff --git 
a/sshd-core/src/test/java/org/apache/sshd/server/shell/ProcessShellTest.java 
b/sshd-core/src/test/java/org/apache/sshd/server/shell/ProcessShellTest.java
new file mode 100644
index 000000000..75b9b8b38
--- /dev/null
+++ b/sshd-core/src/test/java/org/apache/sshd/server/shell/ProcessShellTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.sshd.server.shell;
+
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.channel.ChannelShell;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.util.OsUtils;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.util.test.BaseTestSupport;
+import org.apache.sshd.util.test.CoreTestSupportUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class ProcessShellTest extends BaseTestSupport {
+    private SshClient client;
+
+    public ProcessShellTest() {
+        super();
+    }
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        client = CoreTestSupportUtils.setupTestClient(getClass());
+        client.start();
+    }
+
+    @AfterEach
+    public void tearDown() throws Exception {
+        if (client != null) {
+            client.stop();
+        }
+    }
+
+    /**
+     * Verifies that characters sent to the shell are returned exactly as they 
were sent, without any modifications or
+     * redundant echoes from the server.
+     * <p>
+     * This test ensures that the SSH server does not inject extra characters 
(like manual echoes) into the data stream.
+     * It verifies that characters sent to the shell are returned without any 
modifications. If the server were to
+     * return modified characters or extra echoes, it would indicate a failure 
in the I/O bridging logic.
+     * </p>
+     * <p>
+     * The test relies on {@code cat} as a shell because it is a simple, 
standard Linux utility that acts as a
+     * transparent bridge, reading from STDIN and writing to STDOUT without 
any internal processing, echoing, or
+     * modifications to the character stream. This allows the test to isolate 
and verify the server's behavior without
+     * interference from the shell process itself.
+     * </p>
+     * <p>
+     * Note: This test is skipped on Windows because {@code cat} is not 
natively available. Since the core logic being
+     * tested is platform-independent Java, verifying it on Unix-like systems 
is sufficient.
+     * </p>
+     *
+     * @throws Exception If failed
+     */
+    @Test
+    void testNoRedundantEchoOnStderr() throws Exception {
+        if (OsUtils.isWin32()) {
+            return;
+        }
+
+        try (SshServer localSshd = 
CoreTestSupportUtils.setupTestServer(getClass())) {
+            localSshd.setShellFactory(new ProcessShellFactory("cat", "cat"));
+            localSshd.start();
+
+            try (ClientSession session = client.connect(getCurrentTestName(), 
"localhost", localSshd.getPort())
+                    .verify(10_000).getSession()) {
+                session.addPasswordIdentity(getCurrentTestName());
+                session.auth().verify(10_000);
+
+                try (ChannelShell channel = session.createShellChannel();
+                     ByteArrayOutputStream out = new ByteArrayOutputStream();
+                     ByteArrayOutputStream err = new ByteArrayOutputStream()) {
+                    channel.setOut(out);
+                    channel.setErr(err);
+                    channel.open().verify(10_000);
+
+                    OutputStream pipe = channel.getInvertedIn();
+                    pipe.write("hello\n".getBytes(StandardCharsets.UTF_8));
+                    pipe.flush();
+
+                    Thread.sleep(1000);
+
+                    String stderr = 
err.toString(StandardCharsets.UTF_8.name());
+                    assertFalse(stderr.contains("hello"), "Redundant echo 
detected on stderr: " + stderr);
+
+                    String stdout = 
out.toString(StandardCharsets.UTF_8.name());
+                    assertTrue(stdout.contains("hello"), "Output should 
contain 'hello': " + stdout);
+                }
+            } finally {
+                localSshd.stop(true);
+            }
+        }
+    }
+}

Reply via email to