From 32b5d311a0fa321b7a3ccbb1dc915709e2550dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JB=20Onofr=C3=A9?= Date: Tue, 7 Jul 2026 16:49:30 +0200 Subject: [PATCH 1/2] Fix flaky SSH security itests on Windows by waiting for command completion The SSH command security integration tests (SshCommandTestBase) wrote a command to the SSH channel and immediately sent "logout" to tear down the session. On slower runners - in particular the Windows CI - the session could be closed while the command output was still in flight, producing truncated output (e.g. a partial command echo "she" instead of "shell:nano") and spurious assertion failures such as "Should contain 'Command not found'". Introduce a writeCommandAndWait() helper that appends a sentinel "echo " command and blocks until the unique marker appears in the captured output before closing the channel. Because the remote shell reads and executes its input line by line, the marker cannot appear before the command under test has been fully executed and flushed back to the client, providing a deterministic completion signal that also works for the OK case (which has no positive marker of its own). "echo" is a gogo built-in that is not restricted by any command ACL, so it is safe for every test user. The helper is also used by addUsers()/addViewer() so the JAAS users are fully created before the test logs in as them. --- .../karaf/itests/ssh/SshCommandTestBase.java | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/itests/test/src/test/java/org/apache/karaf/itests/ssh/SshCommandTestBase.java b/itests/test/src/test/java/org/apache/karaf/itests/ssh/SshCommandTestBase.java index e40fb73e87e..87e10ffe7cb 100644 --- a/itests/test/src/test/java/org/apache/karaf/itests/ssh/SshCommandTestBase.java +++ b/itests/test/src/test/java/org/apache/karaf/itests/ssh/SshCommandTestBase.java @@ -22,6 +22,7 @@ import java.util.EnumSet; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeUnit; import org.apache.karaf.itests.BaseTest; import org.apache.sshd.client.SshClient; @@ -33,6 +34,7 @@ import org.apache.sshd.client.session.ClientSession.ClientSessionEvent; import org.apache.sshd.common.channel.PtyMode; import org.awaitility.Awaitility; +import org.awaitility.core.ConditionTimeoutException; import org.junit.Assert; import org.junit.runner.RunWith; import org.ops4j.pax.exam.junit.PaxExam; @@ -52,7 +54,7 @@ enum Result { OK, NOT_FOUND, NO_CREDENTIALS } void addUsers(String manageruser, String vieweruser) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); OutputStream pipe = openSshChannel("karaf", "karaf", out); - pipe.write(("jaas:realm-manage --realm=karaf" + writeCommandAndWait(pipe, out, "jaas:realm-manage --realm=karaf" + ";jaas:user-add " + manageruser + " " + manageruser + ";jaas:role-add " + manageruser + " manager" + ";jaas:role-add " + manageruser + " viewer" @@ -60,8 +62,7 @@ void addUsers(String manageruser, String vieweruser) throws Exception { + ";jaas:user-add " + vieweruser + " " + vieweruser + ";jaas:role-add " + vieweruser + " viewer" + ";jaas:role-add " + vieweruser + " ssh" - + ";jaas:update;jaas:realm-manage --realm=karaf;jaas:user-list\n").getBytes()); - pipe.flush(); + + ";jaas:update;jaas:realm-manage --realm=karaf;jaas:user-list"); closeSshChannel(pipe); System.out.println(new String(out.toByteArray())); } @@ -69,24 +70,51 @@ void addUsers(String manageruser, String vieweruser) throws Exception { void addViewer(String vieweruser) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); OutputStream pipe = openSshChannel("karaf", "karaf", out); - pipe.write(("jaas:realm-manage --realm=karaf" + writeCommandAndWait(pipe, out, "jaas:realm-manage --realm=karaf" + ";jaas:user-add " + vieweruser + " " + vieweruser + ";jaas:role-add " + vieweruser + " viewer" + ";jaas:role-add " + vieweruser + " ssh" - + ";jaas:update;jaas:realm-manage --realm=karaf;jaas:user-list\n").getBytes()); - pipe.flush(); + + ";jaas:update;jaas:realm-manage --realm=karaf;jaas:user-list"); closeSshChannel(pipe); System.out.println(new String(out.toByteArray())); } - String assertCommand(String user, String command, Result result) throws Exception { + /** + * Writes the given command(s) to the SSH channel and blocks until they have been fully + * processed by the remote shell. + * + *

A sentinel {@code echo } command is appended after the supplied command and + * this method waits until the unique marker shows up in the captured output. Because the + * remote shell reads and executes its input line by line, the marker cannot appear before + * the supplied command has been fully executed and its output flushed back to the client. + * Without this synchronization the SSH session could be torn down (see + * {@link #closeSshChannel(OutputStream)}) while the command output is still in flight, + * producing truncated output and spurious assertion failures - regularly observed on slower + * Windows CI runners. {@code echo} is a gogo built-in that is not restricted by any command + * ACL, so it is safe to use for every test user.

+ */ + private void writeCommandAndWait(OutputStream pipe, ByteArrayOutputStream out, String command) throws IOException { if (!command.endsWith("\n")) command += "\n"; + pipe.write(command.getBytes()); + String marker = "KARAF_ITEST_MARKER_" + System.nanoTime(); + pipe.write(("echo " + marker + "\n").getBytes()); + pipe.flush(); + try { + Awaitility.await().atMost(60, TimeUnit.SECONDS) + .pollInterval(200, TimeUnit.MILLISECONDS) + .until(() -> out.toString().contains(marker)); + } catch (ConditionTimeoutException e) { + // Fall through: proceed with whatever output was captured so far. For assertions + // this yields a more useful failure message than the timeout itself. + } + } + + String assertCommand(String user, String command, Result result) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); OutputStream pipe = openSshChannel(user, user, out, out); - pipe.write(command.getBytes()); - pipe.flush(); + writeCommandAndWait(pipe, out, command); closeSshChannel(pipe); String output = new String(out.toByteArray()); From c394ab11c617ca336a55f083e5e5ca685735f1a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JB=20Onofr=C3=A9?= Date: Wed, 8 Jul 2026 08:27:04 +0200 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/org/apache/karaf/itests/ssh/SshCommandTestBase.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/itests/test/src/test/java/org/apache/karaf/itests/ssh/SshCommandTestBase.java b/itests/test/src/test/java/org/apache/karaf/itests/ssh/SshCommandTestBase.java index 87e10ffe7cb..99194f75f8e 100644 --- a/itests/test/src/test/java/org/apache/karaf/itests/ssh/SshCommandTestBase.java +++ b/itests/test/src/test/java/org/apache/karaf/itests/ssh/SshCommandTestBase.java @@ -106,8 +106,9 @@ private void writeCommandAndWait(OutputStream pipe, ByteArrayOutputStream out, S .pollInterval(200, TimeUnit.MILLISECONDS) .until(() -> out.toString().contains(marker)); } catch (ConditionTimeoutException e) { - // Fall through: proceed with whatever output was captured so far. For assertions - // this yields a more useful failure message than the timeout itself. + throw new AssertionError( + "Timed out waiting for SSH command completion marker. Output so far: " + out, + e); } }