diff --git a/src/main/java/com/jcabi/ssh/Execution.java b/src/main/java/com/jcabi/ssh/Execution.java new file mode 100644 index 0000000..717e566 --- /dev/null +++ b/src/main/java/com/jcabi/ssh/Execution.java @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2014-2015, jcabi.com + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: 1) Redistributions of source code must retain the above + * copyright notice, this list of conditions and the following + * disclaimer. 2) Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. 3) Neither the name of the jcabi.com nor + * the names of its contributors may be used to endorse or promote + * products derived from this software without specific prior written + * permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT + * NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jcabi.ssh; + +import com.jcabi.log.Logger; +import com.jcraft.jsch.ChannelExec; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.Session; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.TimeUnit; + +/** + * Execution of a single command. + * @author Georgy Vlasov (wlasowegor@gmail.com) + * @version $Id$ + * @since 1.4 + */ +interface Execution { + /** + * Executes some command. + * @return Return code of the command. + * @throws IOException If fails + */ + int exec() throws IOException; + + /** + * Execution of a command in an SSH session. + * @author Georgy Vlasov (wlasowegor@gmail.com) + * @version $Id$ + * @since 1.4 + */ + final class Default implements Execution { + /** + * Command. + */ + private final transient String command; + + /** + * Stdin. + */ + private final transient InputStream stdin; + + /** + * Stdout. + */ + private final transient OutputStream stdout; + + /** + * Stderr. + */ + private final transient OutputStream stderr; + + /** + * Session. + */ + private final transient Session session; + + /** + * Uses an SSH session to execute a single command and disconnect + * immediately. + * @param cmd Command + * @param input Stdin (will be closed) + * @param out Stdout (will be closed) + * @param err Stderr (will be closed) + * @param sess SSH session (will be disconnected) + * @checkstyle ParameterNumberCheck (6 lines) + */ + Default(final String cmd, final InputStream input, + final OutputStream out, final OutputStream err, + final Session sess) { + this.command = cmd; + this.stdin = input; + this.stdout = out; + this.stderr = err; + this.session = sess; + } + + /** + * Execute {@link #command} in {@link #session}. + * @return Exit code + * @throws IOException If fails + */ + @Override + public int exec() throws IOException { + try { + final ChannelExec channel = ChannelExec.class.cast( + this.session.openChannel("exec") + ); + channel.setErrStream(this.stderr, false); + channel.setOutputStream(this.stdout, false); + channel.setInputStream(this.stdin, false); + channel.setCommand(this.command); + channel.connect(); + Logger.info(this, "$ %s", this.command); + return this.exec(channel); + } catch (final JSchException ex) { + throw new IOException(ex); + } finally { + this.session.disconnect(); + } + } + + /** + * Exec this channel and return its exit code. + * @param channel The channel to exec + * @return Exit code (zero in case of success) + * @throws IOException If fails + */ + private int exec(final ChannelExec channel) throws IOException { + try { + return this.code(channel); + } finally { + channel.disconnect(); + } + } + + /** + * Wait until it's done and return its code. + * @param exec The channel + * @return The exit code + * @throws IOException If some IO problem inside + */ + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private int code(final ChannelExec exec) throws IOException { + while (!exec.isClosed()) { + try { + this.session.sendKeepAliveMsg(); + // @checkstyle IllegalCatch (1 line) + } catch (final Exception ex) { + throw new IOException(ex); + } + try { + TimeUnit.SECONDS.sleep(1L); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException(ex); + } + } + return exec.getExitStatus(); + } + } +} diff --git a/src/main/java/com/jcabi/ssh/SSH.java b/src/main/java/com/jcabi/ssh/SSH.java index b883939..bf0f61d 100644 --- a/src/main/java/com/jcabi/ssh/SSH.java +++ b/src/main/java/com/jcabi/ssh/SSH.java @@ -32,7 +32,6 @@ import com.jcabi.aspects.RetryOnFailure; import com.jcabi.aspects.Tv; import com.jcabi.log.Logger; -import com.jcraft.jsch.ChannelExec; import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSchException; import com.jcraft.jsch.Session; @@ -68,6 +67,9 @@ *

It is highly recommended to use classes from {@link Shell} interface, * they will simplify operations.

* + * @todo #21:30min This class has common data, implementation of exec() and + * constructor validations with SSHByPassword class. Common functionality + * should be extracted into a separate module. * @author Yegor Bugayenko (yegor@teamed.io) * @version $Id$ * @since 1.0 @@ -214,25 +216,9 @@ public SSH(final String adr, final int prt, public int exec(final String command, final InputStream stdin, final OutputStream stdout, final OutputStream stderr) throws IOException { - try { - final Session session = this.session(); - try { - final ChannelExec channel = ChannelExec.class.cast( - session.openChannel("exec") - ); - channel.setErrStream(stderr, false); - channel.setOutputStream(stdout, false); - channel.setInputStream(stdin, false); - channel.setCommand(command); - channel.connect(); - Logger.info(this, "$ %s", command); - return this.exec(channel, session); - } finally { - session.disconnect(); - } - } catch (final JSchException ex) { - throw new IOException(ex); - } + return new Execution.Default( + command, stdin, stdout, stderr, this.session() + ).exec(); } /** @@ -244,49 +230,6 @@ public static String escape(final String arg) { return String.format("'%s'", arg.replace("'", "'\\''")); } - /** - * Exec this channel and return its exit code. - * @param channel The channel to exec - * @param session The session - * @return Exit code (zero in case of success) - * @throws IOException If fails - */ - private int exec(final ChannelExec channel, final Session session) - throws IOException { - try { - return this.code(channel, session); - } finally { - channel.disconnect(); - } - } - - /** - * Wait until it's done and return its code. - * @param exec The channel - * @param session The session - * @return The exit code - * @throws IOException If some IO problem inside - */ - @SuppressWarnings("PMD.AvoidCatchingGenericException") - private int code(final ChannelExec exec, final Session session) - throws IOException { - while (!exec.isClosed()) { - try { - session.sendKeepAliveMsg(); - // @checkstyle IllegalCatch (1 line) - } catch (final Exception ex) { - throw new IOException(ex); - } - try { - TimeUnit.SECONDS.sleep(1L); - } catch (final InterruptedException ex) { - Thread.currentThread().interrupt(); - throw new IOException(ex); - } - } - return exec.getExitStatus(); - } - /** * Create and return a session, connected. * @return JSch session diff --git a/src/main/java/com/jcabi/ssh/SSHByPassword.java b/src/main/java/com/jcabi/ssh/SSHByPassword.java new file mode 100644 index 0000000..ade7b14 --- /dev/null +++ b/src/main/java/com/jcabi/ssh/SSHByPassword.java @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2014-2015, jcabi.com + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: 1) Redistributions of source code must retain the above + * copyright notice, this list of conditions and the following + * disclaimer. 2) Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. 3) Neither the name of the jcabi.com nor + * the names of its contributors may be used to endorse or promote + * products derived from this software without specific prior written + * permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT + * NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jcabi.ssh; + +import com.jcabi.aspects.RetryOnFailure; +import com.jcabi.aspects.Tv; +import com.jcabi.log.Logger; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.Session; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.UnknownHostException; +import java.util.concurrent.TimeUnit; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.apache.commons.lang3.Validate; + +/** + * SSH channel with authentication by password. + * @author Georgy Vlasov (wlasowegor@gmail.com) + * @version $Id$ + * @since 1.4 + * @see SSH For SSH channel with authenticaton using private key. + */ +@ToString +@EqualsAndHashCode(of = { "addr", "port", "login", "password" }) +public final class SSHByPassword implements Shell { + + /** + * IP address of the server. + */ + private final transient String addr; + + /** + * Port to use. + */ + private final transient int port; + + /** + * User name. + */ + private final transient String login; + + /** + * User password. + */ + private final transient String password; + + /** + * Constructor. + * @param adr IP address + * @param prt Port of server + * @param user Login + * @param passwd Password + * @throws UnknownHostException If fails + * @checkstyle ParameterNumberCheck (6 lines) + */ + public SSHByPassword(final String adr, final int prt, + final String user, final String passwd) + throws UnknownHostException { + this.addr = adr; + Validate.matchesPattern( + this.addr, + "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}", + "Invalid IP address of the server `%s`", + this.addr + ); + this.port = prt; + this.login = user; + Validate.notEmpty(this.login, "user name can't be empty"); + this.password = passwd; + } + + // @checkstyle ParameterNumberCheck (6 lines) + @Override + public int exec(final String command, final InputStream stdin, + final OutputStream stdout, final OutputStream stderr) + throws IOException { + return new Execution.Default( + command, stdin, stdout, stderr, this.session() + ).exec(); + } + + /** + * Create and return a session, connected. + * @return JSch session + * @throws IOException If some IO problem inside + */ + @RetryOnFailure( + attempts = Tv.SEVEN, + delay = 1, + unit = TimeUnit.MINUTES, + verbose = false, + randomize = true, + types = IOException.class + ) + private Session session() throws IOException { + try { + JSch.setConfig("StrictHostKeyChecking", "no"); + JSch.setLogger(new JschLogger()); + final JSch jsch = new JSch(); + Logger.debug( + this, + "Opening SSH session to %s@%s:%s (auth with password)...", + this.login, this.addr, this.port + ); + final Session session = jsch.getSession( + this.login, this.addr, this.port + ); + session.setPassword(this.password); + session.setServerAliveInterval( + (int) TimeUnit.SECONDS.toMillis((long) Tv.TEN) + ); + session.setServerAliveCountMax(Tv.MILLION); + session.connect(); + return session; + } catch (final JSchException ex) { + throw new IOException(ex); + } + } +} diff --git a/src/test/java/com/jcabi/ssh/ExecutionTest.java b/src/test/java/com/jcabi/ssh/ExecutionTest.java new file mode 100644 index 0000000..c822318 --- /dev/null +++ b/src/test/java/com/jcabi/ssh/ExecutionTest.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2014-2015, jcabi.com + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: 1) Redistributions of source code must retain the above + * copyright notice, this list of conditions and the following + * disclaimer. 2) Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. 3) Neither the name of the jcabi.com nor + * the names of its contributors may be used to endorse or promote + * products derived from this software without specific prior written + * permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT + * NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jcabi.ssh; + +import com.jcraft.jsch.ChannelExec; +import com.jcraft.jsch.Session; +import java.io.ByteArrayOutputStream; +import org.apache.commons.io.input.NullInputStream; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +/** + * Unit tests for {@link Execution}. + * @author Georgy Vlasov (wlasowegor@gmail.com) + * @version $Id$ + * @since 1.4 + */ +public final class ExecutionTest { + + /** + * Exit code expected from a command. + */ + public static final int EXIT_CODE = 127; + + /** + * Tests that {@link Execution} executes a command and returns a correct + * exit code. + * @throws Exception If fails + */ + @Test + public void executesCommand() throws Exception { + final Session session = Mockito.mock(Session.class); + final ChannelExec channel = Mockito.mock(ChannelExec.class); + Mockito.when(session.openChannel(Mockito.anyString())) + .thenReturn(channel); + Mockito.when(channel.isClosed()).thenReturn(Boolean.TRUE); + Mockito.when(channel.getExitStatus()).thenReturn(EXIT_CODE); + Assert.assertEquals( + EXIT_CODE, + new Execution.Default( + "hello", + new NullInputStream(0L), + new ByteArrayOutputStream(), + new ByteArrayOutputStream(), + session + ).exec() + ); + } +} diff --git a/src/test/java/com/jcabi/ssh/SSHByPasswordTest.java b/src/test/java/com/jcabi/ssh/SSHByPasswordTest.java new file mode 100644 index 0000000..57a8310 --- /dev/null +++ b/src/test/java/com/jcabi/ssh/SSHByPasswordTest.java @@ -0,0 +1,216 @@ +/** + * Copyright (c) 2014-2015, jcabi.com + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: 1) Redistributions of source code must retain the above + * copyright notice, this list of conditions and the following + * disclaimer. 2) Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. 3) Neither the name of the jcabi.com nor + * the names of its contributors may be used to endorse or promote + * products derived from this software without specific prior written + * permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT + * NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jcabi.ssh; + +import com.google.common.io.Files; +import com.jcabi.log.Logger; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.util.Collections; +import java.util.logging.Level; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.NullInputStream; +import org.apache.sshd.SshServer; +import org.apache.sshd.common.NamedFactory; +import org.apache.sshd.server.Command; +import org.apache.sshd.server.CommandFactory; +import org.apache.sshd.server.Environment; +import org.apache.sshd.server.ExitCallback; +import org.apache.sshd.server.PasswordAuthenticator; +import org.apache.sshd.server.UserAuth; +import org.apache.sshd.server.auth.UserAuthPassword; +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; +import org.apache.sshd.server.session.ServerSession; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Test; +import org.mockito.Mockito; + +/** + * Unit tests for {@link SSHByPassword}. + * @todo #21:30min SSH server setup in this class is nearly identical to the + * one in {@link SSHTest}. It should be extracted in a separate reusable + * module. + * @author Georgy Vlasov (wlasowegor@gmail.com) + * @version $Id$ + * @since 1.4 + * @checkstyle ClassDataAbstractionCoupling (500 lines) + */ +public final class SSHByPasswordTest { + /** + * SSH login. + */ + private static final String LOGIN = "test"; + + /** + * SSH password. + */ + private static final String PASSWORD = "password"; + + /** + * Checks if {@link SSHByPassword} can execute a command on an SSH server. + * @throws Exception If fails + */ + @Test + public void executesCommand() throws Exception { + final int port = SSHByPasswordTest.port(); + final SshServer sshd = SSHByPasswordTest.server(port); + sshd.setCommandFactory(new SSHByPasswordTest.EchoCommandCreator()); + sshd.start(); + final String cmd = "some test command"; + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final Shell shell = new Shell.Verbose( + new SSHByPassword( + InetAddress.getLocalHost().getHostAddress(), + port, + LOGIN, + PASSWORD + ) + ); + final int exit = shell.exec( + cmd, + new NullInputStream(0L), + output, + Logger.stream(Level.WARNING, true) + ); + sshd.stop(); + MatcherAssert.assertThat(exit, Matchers.equalTo(0)); + MatcherAssert.assertThat(output.toString(), Matchers.equalTo(cmd)); + } + + /** + * Setup SSH server. + * @param port Port to listen on. + * @return SSH server. + */ + private static SshServer server(final int port) { + final SshServer sshd = SshServer.setUpDefaultServer(); + sshd.setPort(port); + final PasswordAuthenticator auth = + Mockito.mock(PasswordAuthenticator.class); + Mockito.when( + auth.authenticate( + Mockito.eq(LOGIN), + Mockito.eq(PASSWORD), + Mockito.any(ServerSession.class) + ) + ).thenReturn(true); + sshd.setPasswordAuthenticator(auth); + sshd.setUserAuthFactories( + Collections.>singletonList( + new UserAuthPassword.Factory() + ) + ); + sshd.setKeyPairProvider( + new SimpleGeneratorHostKeyProvider( + new File(Files.createTempDir(), "hostkey.ser").getAbsolutePath() + ) + ); + return sshd; + } + + /** + * Allocate free port. + * @return Found port. + * @throws IOException In case of error. + */ + private static int port() throws IOException { + final ServerSocket socket = new ServerSocket(0); + final int port = socket.getLocalPort(); + socket.close(); + return port; + } + + /** + * Factory for echo command. + */ + private static final class EchoCommandCreator implements CommandFactory { + @Override + public Command createCommand(final String command) { + return new SSHByPasswordTest.EchoCommand(command); + } + } + + /** + * Command that displays its name. + */ + private static final class EchoCommand implements Command { + /** + * Command being executed. + */ + private final transient String command; + /** + * Exit callback. + */ + private transient ExitCallback callback; + /** + * Output stream for use by command. + */ + private transient OutputStream output; + /** + * Constructor. + * @param cmd Command to echo. + */ + EchoCommand(final String cmd) { + this.command = cmd; + } + @Override + public void setInputStream(final InputStream input) { + // do nothing + } + @Override + public void setOutputStream(final OutputStream stream) { + this.output = stream; + } + @Override + public void setErrorStream(final OutputStream err) { + // do nothing + } + @Override + public void setExitCallback(final ExitCallback cllbck) { + this.callback = cllbck; + } + @Override + public void start(final Environment env) throws IOException { + IOUtils.write(this.command, this.output); + this.output.flush(); + this.callback.onExit(0); + } + @Override + public void destroy() { + // do nothing + } + } +} diff --git a/src/test/java/com/jcabi/ssh/SSHTest.java b/src/test/java/com/jcabi/ssh/SSHTest.java index f018a1a..1967dfd 100644 --- a/src/test/java/com/jcabi/ssh/SSHTest.java +++ b/src/test/java/com/jcabi/ssh/SSHTest.java @@ -161,6 +161,10 @@ private static int port() throws IOException { /** * Factory for echo command. + * @todo #21:30min EchoCommand and EchoCommandCreator are duplicated in + * SSHTest and SSHByPasswordTest, these duplicates should be extracted + * into package com.jcabi.ssh.mock under src/main/java, renamed to + * MkCommandCreator/MkCommand and unit-tested. */ private static final class EchoCommandCreator implements CommandFactory { @Override